mirror of
https://github.com/jeffvli/feishin.git
synced 2026-07-21 01:46:32 +02:00
split add field input to separate component
This commit is contained in:
@@ -0,0 +1,109 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { KNOWN_TAG_MAP, resolveTagKey } from '../utils/known-tags';
|
||||||
|
|
||||||
|
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||||
|
import { Autocomplete } from '/@/shared/components/autocomplete/autocomplete';
|
||||||
|
import { Group } from '/@/shared/components/group/group';
|
||||||
|
|
||||||
|
interface AddFieldInputProps {
|
||||||
|
availableFields: Array<{ label: string; value: string }>;
|
||||||
|
existingFieldKeys: string[];
|
||||||
|
onAddField: (key: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddFieldInput = ({
|
||||||
|
availableFields,
|
||||||
|
existingFieldKeys,
|
||||||
|
onAddField,
|
||||||
|
}: AddFieldInputProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const skipNextEnterRef = useRef(false);
|
||||||
|
const skipNextOnChangeResetRef = useRef(false);
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [duplicateAttempted, setDuplicateAttempted] = useState(false);
|
||||||
|
|
||||||
|
const trimmedInput = input.trim();
|
||||||
|
const customKeyError =
|
||||||
|
trimmedInput && !KNOWN_TAG_MAP.has(trimmedInput)
|
||||||
|
? trimmedInput.includes('=')
|
||||||
|
? "Tag key cannot contain '='"
|
||||||
|
: // eslint-disable-next-line no-control-regex
|
||||||
|
/[^\x00-\x7F]/.test(trimmedInput)
|
||||||
|
? 'Tag key must use ASCII characters only'
|
||||||
|
: null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const resolvedInputKey = trimmedInput ? resolveTagKey(trimmedInput) : '';
|
||||||
|
const duplicateError =
|
||||||
|
trimmedInput && !customKeyError && existingFieldKeys.includes(resolvedInputKey)
|
||||||
|
? 'Field already exists'
|
||||||
|
: null;
|
||||||
|
const fieldError = customKeyError ?? (duplicateAttempted ? duplicateError : null);
|
||||||
|
|
||||||
|
const addField = (key: string): boolean => {
|
||||||
|
const trimmed = key.trim();
|
||||||
|
if (!trimmed) return false;
|
||||||
|
if (
|
||||||
|
!KNOWN_TAG_MAP.has(trimmed) &&
|
||||||
|
(trimmed.includes('=') || // eslint-disable-next-line no-control-regex
|
||||||
|
/[^\x00-\x7F]/.test(trimmed))
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const normalizedKey = resolveTagKey(trimmed);
|
||||||
|
if (existingFieldKeys.includes(normalizedKey)) {
|
||||||
|
setDuplicateAttempted(true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDuplicateAttempted(false);
|
||||||
|
setInput('');
|
||||||
|
onAddField(normalizedKey);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group gap="xs">
|
||||||
|
<Autocomplete
|
||||||
|
data={availableFields}
|
||||||
|
error={fieldError}
|
||||||
|
flex={1}
|
||||||
|
onChange={(value) => {
|
||||||
|
setInput(value);
|
||||||
|
if (skipNextOnChangeResetRef.current) {
|
||||||
|
skipNextOnChangeResetRef.current = false;
|
||||||
|
} else {
|
||||||
|
setDuplicateAttempted(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
if (skipNextEnterRef.current) {
|
||||||
|
skipNextEnterRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
addField(input);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onOptionSubmit={(value) => {
|
||||||
|
skipNextEnterRef.current = true;
|
||||||
|
skipNextOnChangeResetRef.current = true;
|
||||||
|
const added = addField(value);
|
||||||
|
if (added) {
|
||||||
|
queueMicrotask(() => setInput(''));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={t('page.itemDetail.addField', 'Add field…')}
|
||||||
|
value={input}
|
||||||
|
/>
|
||||||
|
<ActionIcon
|
||||||
|
disabled={!!fieldError}
|
||||||
|
icon="add"
|
||||||
|
onClick={() => addField(input)}
|
||||||
|
variant="filled"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,17 +1,14 @@
|
|||||||
import { closeAllModals } from '@mantine/modals';
|
import { closeAllModals } from '@mantine/modals';
|
||||||
import { useRef, useState } from 'react';
|
import { useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { RiAddLine } from 'react-icons/ri';
|
|
||||||
|
|
||||||
import { useMetadataEditor } from '../hooks/use-metadata-editor';
|
import { useMetadataEditor } from '../hooks/use-metadata-editor';
|
||||||
import { KNOWN_TAG_MAP, resolveTagKey } from '../utils/known-tags';
|
import { AddFieldInput } from './add-field-input';
|
||||||
import { ArtworkPanel } from './artwork-panel';
|
import { ArtworkPanel } from './artwork-panel';
|
||||||
import styles from './song-edit-modal.module.css';
|
import styles from './song-edit-modal.module.css';
|
||||||
import { TagEditorSettings } from './tag-editor-settings';
|
import { TagEditorSettings } from './tag-editor-settings';
|
||||||
import { TagFieldRow } from './tag-field-row';
|
import { TagFieldRow } from './tag-field-row';
|
||||||
|
|
||||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
|
||||||
import { Autocomplete } from '/@/shared/components/autocomplete/autocomplete';
|
|
||||||
import { Button } from '/@/shared/components/button/button';
|
import { Button } from '/@/shared/components/button/button';
|
||||||
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
|
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
|
||||||
import { Group } from '/@/shared/components/group/group';
|
import { Group } from '/@/shared/components/group/group';
|
||||||
@@ -25,10 +22,6 @@ import { Song } from '/@/shared/types/domain-types';
|
|||||||
export const SongEditModal = ({ songs }: { songs: Song[] }) => {
|
export const SongEditModal = ({ songs }: { songs: Song[] }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const skipNextEnterRef = useRef(false);
|
|
||||||
const skipNextOnChangeResetRef = useRef(false);
|
|
||||||
const [addFieldInput, setAddFieldInput] = useState('');
|
|
||||||
const [duplicateAttempted, setDuplicateAttempted] = useState(false);
|
|
||||||
|
|
||||||
const editor = useMetadataEditor({
|
const editor = useMetadataEditor({
|
||||||
browser: window.api.browser,
|
browser: window.api.browser,
|
||||||
@@ -36,52 +29,15 @@ export const SongEditModal = ({ songs }: { songs: Song[] }) => {
|
|||||||
utils: window.api.utils,
|
utils: window.api.utils,
|
||||||
});
|
});
|
||||||
|
|
||||||
const trimmedInput = addFieldInput.trim();
|
const handleAddField = (key: string) => {
|
||||||
const customKeyError =
|
editor.handleAddField(key);
|
||||||
trimmedInput && !KNOWN_TAG_MAP.has(trimmedInput)
|
|
||||||
? trimmedInput.includes('=')
|
|
||||||
? "Tag key cannot contain '='"
|
|
||||||
: // eslint-disable-next-line no-control-regex
|
|
||||||
/[^\x00-\x7F]/.test(trimmedInput)
|
|
||||||
? 'Tag key must use ASCII characters only'
|
|
||||||
: null
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const resolvedInputKey = trimmedInput ? resolveTagKey(trimmedInput) : '';
|
|
||||||
const duplicateError =
|
|
||||||
trimmedInput &&
|
|
||||||
!customKeyError &&
|
|
||||||
editor.sortedFieldEntries.some(([k]) => k === resolvedInputKey)
|
|
||||||
? 'Field already exists'
|
|
||||||
: null;
|
|
||||||
const fieldError = customKeyError ?? (duplicateAttempted ? duplicateError : null);
|
|
||||||
|
|
||||||
const handleAddField = (key: string): boolean => {
|
|
||||||
const trimmed = key.trim();
|
|
||||||
if (!trimmed) return false;
|
|
||||||
if (
|
|
||||||
!KNOWN_TAG_MAP.has(trimmed) &&
|
|
||||||
(trimmed.includes('=') || // eslint-disable-next-line no-control-regex
|
|
||||||
/[^\x00-\x7F]/.test(trimmed))
|
|
||||||
)
|
|
||||||
return false;
|
|
||||||
const normalizedKey = resolveTagKey(trimmed);
|
|
||||||
if (editor.sortedFieldEntries.some(([k]) => k === normalizedKey)) {
|
|
||||||
setDuplicateAttempted(true);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
setDuplicateAttempted(false);
|
|
||||||
editor.handleAddField(normalizedKey);
|
|
||||||
setAddFieldInput('');
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const row = tableContainerRef.current?.querySelector<HTMLElement>(
|
const row = tableContainerRef.current?.querySelector<HTMLElement>(
|
||||||
`[data-field-key="${normalizedKey}"]`,
|
`[data-field-key="${key}"]`,
|
||||||
);
|
);
|
||||||
row?.scrollIntoView({ block: 'nearest' });
|
row?.scrollIntoView({ block: 'nearest' });
|
||||||
row?.querySelector<HTMLElement>('input, textarea')?.focus();
|
row?.querySelector<HTMLElement>('input, textarea')?.focus();
|
||||||
});
|
});
|
||||||
return true;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// While loading, shows a spinner
|
// While loading, shows a spinner
|
||||||
@@ -125,46 +81,10 @@ export const SongEditModal = ({ songs }: { songs: Song[] }) => {
|
|||||||
{editor.readWarning}
|
{editor.readWarning}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<Autocomplete
|
<AddFieldInput
|
||||||
data={editor.availableToAdd}
|
availableFields={editor.availableToAdd}
|
||||||
error={fieldError}
|
existingFieldKeys={editor.sortedFieldEntries.map(([key]) => key)}
|
||||||
onChange={(v) => {
|
onAddField={handleAddField}
|
||||||
setAddFieldInput(v);
|
|
||||||
if (skipNextOnChangeResetRef.current) {
|
|
||||||
skipNextOnChangeResetRef.current = false;
|
|
||||||
} else {
|
|
||||||
setDuplicateAttempted(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
if (skipNextEnterRef.current) {
|
|
||||||
skipNextEnterRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
handleAddField(addFieldInput);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onOptionSubmit={(value) => {
|
|
||||||
skipNextEnterRef.current = true;
|
|
||||||
skipNextOnChangeResetRef.current = true;
|
|
||||||
const added = handleAddField(value);
|
|
||||||
if (added) {
|
|
||||||
queueMicrotask(() => setAddFieldInput(''));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder={t('page.itemDetail.addField', 'Add field…')}
|
|
||||||
rightSection={
|
|
||||||
<ActionIcon
|
|
||||||
disabled={!!fieldError}
|
|
||||||
onClick={() => handleAddField(addFieldInput)}
|
|
||||||
variant="filled"
|
|
||||||
>
|
|
||||||
<RiAddLine size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
}
|
|
||||||
rightSectionPointerEvents="all"
|
|
||||||
value={addFieldInput}
|
|
||||||
/>
|
/>
|
||||||
<div className={styles.tableScroller} ref={tableContainerRef}>
|
<div className={styles.tableScroller} ref={tableContainerRef}>
|
||||||
<Table
|
<Table
|
||||||
|
|||||||
Reference in New Issue
Block a user