mirror of
https://github.com/jeffvli/feishin.git
synced 2026-07-22 10:26:33 +02:00
allow undo changes and deleted rows
This commit is contained in:
@@ -96,12 +96,16 @@ export const SongEditModal = ({ songs }: { songs: Song[] }) => {
|
||||
{editor.sortedFieldEntries.map(([key, value]) => (
|
||||
<TagFieldRow
|
||||
favoriteValues={editor.favoriteValues[key] ?? []}
|
||||
isDirty={key in editor.editedFields}
|
||||
isDirty={
|
||||
key in editor.editedFields ||
|
||||
editor.removedKeys.has(key)
|
||||
}
|
||||
isMixed={editor.mixedKeys.has(key)}
|
||||
isMultiValue={
|
||||
editor.multiValueKeys.has(key) ||
|
||||
Array.isArray(value)
|
||||
}
|
||||
isRemoved={editor.removedKeys.has(key)}
|
||||
key={key}
|
||||
meta={editor.getFieldMeta(key)}
|
||||
mixedPlaceholder={
|
||||
@@ -114,6 +118,8 @@ export const SongEditModal = ({ songs }: { songs: Song[] }) => {
|
||||
}
|
||||
onChange={(v) => editor.handleFieldChange(key, v)}
|
||||
onRemove={() => editor.handleRemoveField(key)}
|
||||
onReset={() => editor.handleResetField(key)}
|
||||
onRevert={() => editor.handleRevertField(key)}
|
||||
tagKey={key}
|
||||
value={value}
|
||||
/>
|
||||
|
||||
@@ -10,3 +10,8 @@
|
||||
.dirty-label {
|
||||
box-shadow: inset 2px 0 0 var(--mantine-primary-color-filled);
|
||||
}
|
||||
|
||||
.removed-row {
|
||||
opacity: 0.55;
|
||||
transition: opacity 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
@@ -2,13 +2,12 @@ import type { TagValue } from '/@/shared/types/tag-editor';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiCloseLine } from 'react-icons/ri';
|
||||
|
||||
import type { KnownTag } from '../utils/known-tags';
|
||||
|
||||
import styles from './tag-field-row.module.css';
|
||||
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
|
||||
import { NumberInput } from '/@/shared/components/number-input/number-input';
|
||||
import { Table } from '/@/shared/components/table/table';
|
||||
@@ -17,6 +16,7 @@ import { TextInput } from '/@/shared/components/text-input/text-input';
|
||||
import { Textarea } from '/@/shared/components/textarea/textarea';
|
||||
|
||||
interface FavoriteTagsInputProps {
|
||||
disabled: boolean;
|
||||
favoriteValues: string[];
|
||||
mixedPlaceholder?: string;
|
||||
onAddFavorite: (value: string) => void;
|
||||
@@ -29,11 +29,14 @@ interface TagFieldRowProps {
|
||||
isDirty?: boolean;
|
||||
isMixed: boolean;
|
||||
isMultiValue: boolean;
|
||||
isRemoved: boolean;
|
||||
meta: KnownTag;
|
||||
mixedPlaceholder?: string;
|
||||
onAddFavorite: (value: string) => void;
|
||||
onChange: (value: TagValue) => void;
|
||||
onRemove: () => void;
|
||||
onReset: () => void;
|
||||
onRevert: () => void;
|
||||
tagKey: string;
|
||||
value: TagValue;
|
||||
}
|
||||
@@ -41,6 +44,7 @@ interface TagFieldRowProps {
|
||||
const ADD_FAVORITE_PREFIX = '__feishin_add_favorite__:';
|
||||
|
||||
const FavoriteTagsInput = ({
|
||||
disabled,
|
||||
favoriteValues,
|
||||
mixedPlaceholder,
|
||||
onAddFavorite,
|
||||
@@ -73,6 +77,7 @@ const FavoriteTagsInput = ({
|
||||
<TagsInput
|
||||
clearable
|
||||
data={data}
|
||||
disabled={disabled}
|
||||
onChange={(values) => {
|
||||
const normalizedValues = values.map((item) =>
|
||||
item.startsWith(ADD_FAVORITE_PREFIX)
|
||||
@@ -107,64 +112,94 @@ export const TagFieldRow = ({
|
||||
isDirty,
|
||||
isMixed,
|
||||
isMultiValue,
|
||||
isRemoved,
|
||||
meta,
|
||||
mixedPlaceholder,
|
||||
onAddFavorite,
|
||||
onChange,
|
||||
onRemove,
|
||||
onReset,
|
||||
onRevert,
|
||||
tagKey,
|
||||
value,
|
||||
}: TagFieldRowProps) => (
|
||||
<Table.Tr data-field-key={tagKey} key={tagKey}>
|
||||
<Table.Th className={isDirty ? styles.dirtyLabel : undefined}>{meta.label}</Table.Th>
|
||||
<Table.Td>
|
||||
{isMultiValue && tagKey !== 'lyrics' ? (
|
||||
<FavoriteTagsInput
|
||||
favoriteValues={favoriteValues}
|
||||
mixedPlaceholder={mixedPlaceholder}
|
||||
onAddFavorite={onAddFavorite}
|
||||
onChange={onChange}
|
||||
value={Array.isArray(value) ? value : value ? [value] : []}
|
||||
/>
|
||||
) : meta.type === 'textarea' ? (
|
||||
<Textarea
|
||||
autosize
|
||||
maxRows={6}
|
||||
minRows={2}
|
||||
onChange={(e) => onChange(e.currentTarget.value)}
|
||||
placeholder={mixedPlaceholder}
|
||||
}: TagFieldRowProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Table.Tr
|
||||
className={isRemoved ? styles.removedRow : undefined}
|
||||
data-field-key={tagKey}
|
||||
key={tagKey}
|
||||
>
|
||||
<Table.Th className={isDirty ? styles.dirtyLabel : undefined}>{meta.label}</Table.Th>
|
||||
<Table.Td>
|
||||
{isMultiValue && tagKey !== 'lyrics' ? (
|
||||
<FavoriteTagsInput
|
||||
disabled={isRemoved}
|
||||
favoriteValues={favoriteValues}
|
||||
mixedPlaceholder={mixedPlaceholder}
|
||||
onAddFavorite={onAddFavorite}
|
||||
onChange={onChange}
|
||||
value={Array.isArray(value) ? value : value ? [value] : []}
|
||||
/>
|
||||
) : meta.type === 'textarea' ? (
|
||||
<Textarea
|
||||
autosize
|
||||
disabled={isRemoved}
|
||||
maxRows={6}
|
||||
minRows={2}
|
||||
onChange={(e) => onChange(e.currentTarget.value)}
|
||||
placeholder={mixedPlaceholder}
|
||||
size="sm"
|
||||
value={Array.isArray(value) ? value.join('\n\n') : value}
|
||||
/>
|
||||
) : meta.type === 'number' ? (
|
||||
<NumberInput
|
||||
disabled={isRemoved}
|
||||
onChange={(v) => onChange(v === undefined ? '' : String(v))}
|
||||
placeholder={mixedPlaceholder}
|
||||
size="sm"
|
||||
value={
|
||||
isMixed || value === '' || Array.isArray(value)
|
||||
? undefined
|
||||
: Number(value)
|
||||
}
|
||||
/>
|
||||
) : meta.type === 'boolean' ? (
|
||||
<Checkbox
|
||||
checked={!isMixed && value === '1'}
|
||||
disabled={isRemoved}
|
||||
indeterminate={isMixed}
|
||||
onChange={(e) => onChange(e.currentTarget.checked ? '1' : '0')}
|
||||
size="sm"
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
disabled={isRemoved}
|
||||
onChange={(e) => onChange(e.currentTarget.value)}
|
||||
placeholder={mixedPlaceholder}
|
||||
size="sm"
|
||||
value={Array.isArray(value) ? value.join('; ') : value}
|
||||
/>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td className={styles.removeCell}>
|
||||
<ActionIcon
|
||||
aria-label={isRemoved || isDirty ? t('common.undo') : t('common.delete')}
|
||||
className={styles.removeButton}
|
||||
icon={isRemoved || isDirty ? 'undo' : 'x'}
|
||||
iconProps={{
|
||||
color: isRemoved ? 'error' : 'default',
|
||||
size: 'md',
|
||||
}}
|
||||
onClick={isRemoved ? onReset : isDirty ? onRevert : onRemove}
|
||||
size="sm"
|
||||
value={Array.isArray(value) ? value.join('\n\n') : value}
|
||||
tooltip={{
|
||||
label: isRemoved || isDirty ? t('common.undo') : t('common.delete'),
|
||||
}}
|
||||
variant="subtle"
|
||||
/>
|
||||
) : meta.type === 'number' ? (
|
||||
<NumberInput
|
||||
onChange={(v) => onChange(v === undefined ? '' : String(v))}
|
||||
placeholder={mixedPlaceholder}
|
||||
size="sm"
|
||||
value={
|
||||
isMixed || value === '' || Array.isArray(value) ? undefined : Number(value)
|
||||
}
|
||||
/>
|
||||
) : meta.type === 'boolean' ? (
|
||||
<Checkbox
|
||||
checked={!isMixed && value === '1'}
|
||||
indeterminate={isMixed}
|
||||
onChange={(e) => onChange(e.currentTarget.checked ? '1' : '0')}
|
||||
size="sm"
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
onChange={(e) => onChange(e.currentTarget.value)}
|
||||
placeholder={mixedPlaceholder}
|
||||
size="sm"
|
||||
value={Array.isArray(value) ? value.join('; ') : value}
|
||||
/>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td className={styles.removeCell}>
|
||||
<Button className={styles.removeButton} onClick={onRemove} size="sm" variant="subtle">
|
||||
<RiCloseLine size={16} />
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -87,7 +87,6 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
|
||||
const allKeys = new Set<string>();
|
||||
for (const k of Object.keys(tagSummary)) allKeys.add(k);
|
||||
for (const k of Object.keys(editedFields)) allKeys.add(k);
|
||||
for (const k of removedKeys) allKeys.delete(k);
|
||||
|
||||
const displayFields: Record<string, TagValue> = {};
|
||||
const mixedKeys = new Set<string>();
|
||||
@@ -118,7 +117,7 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
|
||||
});
|
||||
|
||||
return { displayFields, mixedKeys, sortedFieldEntries };
|
||||
}, [tagSummary, editedFields, multiValueKeys, removedKeys]);
|
||||
}, [tagSummary, editedFields, multiValueKeys]);
|
||||
|
||||
/**
|
||||
* Runs once on mount: reads metadata for all songs in batch and populates
|
||||
@@ -210,14 +209,32 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
|
||||
[favoriteValues, setSettings],
|
||||
);
|
||||
|
||||
/** Removes `key` from both `editedFields` and the display, marking it for deletion on save. */
|
||||
/** Marks `key` for deletion while preserving its displayed value for undo. */
|
||||
const handleRemoveField = useCallback((key: string) => {
|
||||
setRemovedKeys((prev) => new Set(prev).add(key));
|
||||
}, []);
|
||||
|
||||
/** Re-enables a field that was marked for deletion. */
|
||||
const handleResetField = useCallback((key: string) => {
|
||||
setRemovedKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
/** Restores a field to its original on-disk value and removal state. */
|
||||
const handleRevertField = useCallback((key: string) => {
|
||||
setEditedFields((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
setRemovedKeys((prev) => new Set(prev).add(key));
|
||||
setRemovedKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Refs so the functional updater inside handleAddField can see latest values without needing them in the dependency array.
|
||||
@@ -232,6 +249,7 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
|
||||
if (!key) return;
|
||||
setEditedFields((prev) => {
|
||||
const wasRemoved = removedKeysRef.current.has(key);
|
||||
if (wasRemoved) return prev;
|
||||
const alreadyVisible = (key in tagSummaryRef.current || key in prev) && !wasRemoved;
|
||||
if (alreadyVisible) return prev;
|
||||
return { ...prev, [key]: multiValueKeys.has(key) ? [] : '' };
|
||||
@@ -284,7 +302,10 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
|
||||
const handleSave = useCallback(async () => {
|
||||
if (resolvedSongs.length === 0) return;
|
||||
|
||||
const emptyFields = Object.entries(editedFields)
|
||||
const activeEdits = Object.fromEntries(
|
||||
Object.entries(editedFields).filter(([key]) => !removedKeys.has(key)),
|
||||
) as Record<string, TagValue>;
|
||||
const emptyFields = Object.entries(activeEdits)
|
||||
.filter(([key, value]) => {
|
||||
const meta = KNOWN_TAG_MAP.get(key);
|
||||
if (meta?.type === 'boolean') return false;
|
||||
@@ -309,7 +330,7 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
|
||||
const writeResult = await withBatchProgress(utils, setLoadProgress, () =>
|
||||
utils.writeSongTagsBatch(
|
||||
paths,
|
||||
editedFields,
|
||||
activeEdits,
|
||||
[...removedKeys],
|
||||
artworkOp ?? undefined,
|
||||
),
|
||||
@@ -393,6 +414,8 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
|
||||
handleFieldChange,
|
||||
handleRemoveArtwork,
|
||||
handleRemoveField,
|
||||
handleResetField,
|
||||
handleRevertField,
|
||||
handleSave,
|
||||
isLoading,
|
||||
isSaving,
|
||||
@@ -401,6 +424,7 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
|
||||
mixedPlaceholder,
|
||||
multiValueKeys,
|
||||
readWarning,
|
||||
removedKeys,
|
||||
rescan: triggerRescan,
|
||||
setRescan,
|
||||
showRemoveArtworkButton,
|
||||
|
||||
Reference in New Issue
Block a user