move feature from meta-sort to meta-core
This commit is contained in:
@@ -0,0 +1,378 @@
|
||||
import { SchemaField, FileMetadata } from '../types';
|
||||
import { LanguageCombobox, formatLanguageOption } from './LanguageCombobox';
|
||||
import './DynamicFieldRenderer.css';
|
||||
|
||||
interface DynamicFieldRendererProps {
|
||||
fieldKey: string;
|
||||
schema: SchemaField;
|
||||
metadata: FileMetadata;
|
||||
onChange: (key: string, value: any) => void;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single metadata field based on its schema definition
|
||||
*/
|
||||
export function DynamicFieldRenderer({
|
||||
fieldKey,
|
||||
schema,
|
||||
metadata,
|
||||
onChange,
|
||||
readonly = false,
|
||||
}: DynamicFieldRendererProps) {
|
||||
const isReadonly = readonly || schema.readonly;
|
||||
const fieldType = schema.type || 'string';
|
||||
|
||||
// Get value from metadata using path-style key
|
||||
const getValue = (): any => {
|
||||
// Handle path-style keys like "stream/duration" or "nfo/title"
|
||||
const parts = fieldKey.split('/');
|
||||
let value: any = metadata;
|
||||
for (const part of parts) {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
value = value[part];
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
// Set value in metadata using path-style key
|
||||
const setValue = (newValue: any) => {
|
||||
onChange(fieldKey, newValue);
|
||||
};
|
||||
|
||||
const value = getValue();
|
||||
|
||||
// Render link if configured
|
||||
const renderValueWithLink = (displayValue: string) => {
|
||||
if (schema.link && displayValue) {
|
||||
const url = schema.link.replace('{value}', encodeURIComponent(displayValue));
|
||||
return (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" className="field-link">
|
||||
{displayValue}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return displayValue;
|
||||
};
|
||||
|
||||
// Render based on field type
|
||||
switch (fieldType) {
|
||||
case 'boolean':
|
||||
return (
|
||||
<div className="dynamic-field">
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(value)}
|
||||
onChange={(e) => setValue(e.target.checked)}
|
||||
disabled={isReadonly}
|
||||
/>
|
||||
<span>{schema.label}</span>
|
||||
</label>
|
||||
{schema.hint && <small className="field-hint">{schema.hint}</small>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'number':
|
||||
return (
|
||||
<div className="dynamic-field">
|
||||
<label>{schema.label}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => setValue(e.target.value ? Number(e.target.value) : undefined)}
|
||||
disabled={isReadonly}
|
||||
min={schema.min}
|
||||
max={schema.max}
|
||||
placeholder={schema.placeholder}
|
||||
/>
|
||||
{schema.hint && <small className="field-hint">{schema.hint}</small>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<div className="dynamic-field">
|
||||
<label>{schema.label}</label>
|
||||
<select
|
||||
value={value ?? ''}
|
||||
onChange={(e) => setValue(e.target.value || undefined)}
|
||||
disabled={isReadonly}
|
||||
>
|
||||
<option value="">Select...</option>
|
||||
{schema.options?.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{schema.hint && <small className="field-hint">{schema.hint}</small>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'text':
|
||||
return (
|
||||
<div className="dynamic-field">
|
||||
<label>{schema.label}</label>
|
||||
<textarea
|
||||
value={value ?? ''}
|
||||
onChange={(e) => setValue(e.target.value || undefined)}
|
||||
disabled={isReadonly}
|
||||
placeholder={schema.placeholder}
|
||||
rows={4}
|
||||
/>
|
||||
{schema.hint && <small className="field-hint">{schema.hint}</small>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'array':
|
||||
// Convert RecordSet to comma-separated string for display
|
||||
const arrayValue = (() => {
|
||||
if (!value) return '';
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'object') {
|
||||
return Object.values(value).join(', ');
|
||||
}
|
||||
return '';
|
||||
})();
|
||||
|
||||
// Convert comma-separated string back to RecordSet
|
||||
const handleArrayChange = (str: string) => {
|
||||
const items = str.split(',').map(s => s.trim()).filter(Boolean);
|
||||
if (items.length === 0) {
|
||||
setValue(undefined);
|
||||
return;
|
||||
}
|
||||
const recordSet: Record<string, string> = {};
|
||||
items.forEach((item, idx) => {
|
||||
recordSet[idx.toString()] = item;
|
||||
});
|
||||
setValue(recordSet);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dynamic-field">
|
||||
<label>{schema.label}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={arrayValue}
|
||||
onChange={(e) => handleArrayChange(e.target.value)}
|
||||
disabled={isReadonly}
|
||||
placeholder={schema.placeholder || 'Comma-separated values'}
|
||||
/>
|
||||
{schema.hint && <small className="field-hint">{schema.hint}</small>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'json':
|
||||
// Aggregate matching keys based on pattern
|
||||
const jsonData = (() => {
|
||||
if (!schema.pattern) {
|
||||
return value;
|
||||
}
|
||||
|
||||
// Pattern like "stream/video/{n}/*" - aggregate all matching keys
|
||||
const patternPrefix = schema.pattern.replace(/\{n\}.*$/, '');
|
||||
const result: Record<string, any> = {};
|
||||
|
||||
// Recursively find all matching keys
|
||||
const findMatching = (obj: any, currentPath: string = '') => {
|
||||
if (!obj || typeof obj !== 'object') return;
|
||||
|
||||
for (const [key, val] of Object.entries(obj)) {
|
||||
const newPath = currentPath ? `${currentPath}/${key}` : key;
|
||||
if (newPath.startsWith(patternPrefix.replace(/\/$/, ''))) {
|
||||
// Store in result with relative path
|
||||
const relativePath = newPath.substring(patternPrefix.length - 1);
|
||||
if (relativePath) {
|
||||
setNestedValue(result, relativePath, val);
|
||||
}
|
||||
}
|
||||
if (typeof val === 'object' && val !== null) {
|
||||
findMatching(val, newPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
findMatching(metadata);
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
})();
|
||||
|
||||
if (!jsonData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dynamic-field json-field">
|
||||
<label>{schema.label}</label>
|
||||
<pre className="json-viewer">
|
||||
{JSON.stringify(jsonData, null, 2)}
|
||||
</pre>
|
||||
{schema.hint && <small className="field-hint">{schema.hint}</small>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'cid':
|
||||
// CID field type - displays CID with "Open in Editor" button
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cidValue = String(value);
|
||||
const editorUrl = `/editor/file/${cidValue}`;
|
||||
|
||||
return (
|
||||
<div className="dynamic-field cid-field">
|
||||
<label>{schema.label}</label>
|
||||
<div className="cid-value-container">
|
||||
<code
|
||||
className="cid-value"
|
||||
onClick={() => navigator.clipboard.writeText(cidValue)}
|
||||
title="Click to copy"
|
||||
>
|
||||
{cidValue}
|
||||
</code>
|
||||
<a
|
||||
href={editorUrl}
|
||||
className="cid-open-button"
|
||||
title="Open in Editor"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
↗ Open
|
||||
</a>
|
||||
</div>
|
||||
{schema.hint && <small className="field-hint">{schema.hint}</small>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'languageString':
|
||||
// Language-keyed string object: { "eng": "English Title", "jpn": "日本語タイトル" }
|
||||
const langEntries: Array<[string, string]> = value && typeof value === 'object'
|
||||
? Object.entries(value).map(([k, v]) => [k, String(v)])
|
||||
: [];
|
||||
|
||||
const handleLangCodeChange = (oldLang: string, newLang: string) => {
|
||||
if (oldLang === newLang) return;
|
||||
const newValue = { ...value };
|
||||
const text = newValue[oldLang];
|
||||
delete newValue[oldLang];
|
||||
newValue[newLang] = text;
|
||||
setValue(Object.keys(newValue).length > 0 ? newValue : undefined);
|
||||
};
|
||||
|
||||
const handleLangTextChange = (lang: string, text: string) => {
|
||||
const newValue = { ...value, [lang]: text };
|
||||
setValue(newValue);
|
||||
};
|
||||
|
||||
const handleRemoveLang = (lang: string) => {
|
||||
const newValue = { ...value };
|
||||
delete newValue[lang];
|
||||
setValue(Object.keys(newValue).length > 0 ? newValue : undefined);
|
||||
};
|
||||
|
||||
const handleAddLang = () => {
|
||||
// Find a language code not already used, default to 'eng'
|
||||
const usedLangs = new Set(langEntries.map(([k]) => k));
|
||||
const commonDefaults = ['eng', 'jpn', 'zho', 'kor', 'fra', 'deu', 'spa'];
|
||||
const defaultLang = commonDefaults.find(l => !usedLangs.has(l)) || 'eng';
|
||||
setValue({ ...value, [defaultLang]: '' });
|
||||
};
|
||||
|
||||
if (isReadonly && langEntries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dynamic-field language-string-field">
|
||||
<label>{schema.label}</label>
|
||||
<div className="language-entries">
|
||||
{langEntries.map(([lang, text]) => (
|
||||
<div key={lang} className="language-entry">
|
||||
{isReadonly ? (
|
||||
<>
|
||||
<span className="lang-code" title={formatLanguageOption(lang)}>{lang}</span>
|
||||
<span className="lang-text">{text}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LanguageCombobox
|
||||
value={lang}
|
||||
onChange={(newLang) => handleLangCodeChange(lang, newLang)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="lang-input"
|
||||
value={text}
|
||||
onChange={(e) => handleLangTextChange(lang, e.target.value)}
|
||||
placeholder={schema.placeholder || 'Text...'}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="lang-remove-btn"
|
||||
onClick={() => handleRemoveLang(lang)}
|
||||
title="Remove"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{!isReadonly && (
|
||||
<button
|
||||
type="button"
|
||||
className="lang-add-btn"
|
||||
onClick={handleAddLang}
|
||||
>
|
||||
+ Add Language
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{schema.hint && <small className="field-hint">{schema.hint}</small>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'string':
|
||||
default:
|
||||
// Show nothing if no value and readonly
|
||||
if (isReadonly && !value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dynamic-field">
|
||||
<label>{schema.label}</label>
|
||||
{isReadonly ? (
|
||||
<div className="readonly-value">
|
||||
{renderValueWithLink(String(value ?? ''))}
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => setValue(e.target.value || undefined)}
|
||||
placeholder={schema.placeholder}
|
||||
/>
|
||||
)}
|
||||
{schema.hint && <small className="field-hint">{schema.hint}</small>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to set nested value in object
|
||||
function setNestedValue(obj: any, path: string, value: any) {
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
let current = obj;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
if (!current[parts[i]]) {
|
||||
current[parts[i]] = {};
|
||||
}
|
||||
current = current[parts[i]];
|
||||
}
|
||||
if (parts.length > 0) {
|
||||
current[parts[parts.length - 1]] = value;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user