mirror of
https://github.com/jeffvli/feishin.git
synced 2026-07-22 18:36:34 +02:00
allow undo changes and deleted rows
This commit is contained in:
@@ -161,6 +161,7 @@
|
|||||||
"trackGain": "Track gain",
|
"trackGain": "Track gain",
|
||||||
"trackPeak": "Track peak",
|
"trackPeak": "Track peak",
|
||||||
"translation": "Translation",
|
"translation": "Translation",
|
||||||
|
"undo": "Undo",
|
||||||
"unknown": "Unknown",
|
"unknown": "Unknown",
|
||||||
"version": "Version",
|
"version": "Version",
|
||||||
"year": "Year",
|
"year": "Year",
|
||||||
|
|||||||
@@ -96,12 +96,16 @@ export const SongEditModal = ({ songs }: { songs: Song[] }) => {
|
|||||||
{editor.sortedFieldEntries.map(([key, value]) => (
|
{editor.sortedFieldEntries.map(([key, value]) => (
|
||||||
<TagFieldRow
|
<TagFieldRow
|
||||||
favoriteValues={editor.favoriteValues[key] ?? []}
|
favoriteValues={editor.favoriteValues[key] ?? []}
|
||||||
isDirty={key in editor.editedFields}
|
isDirty={
|
||||||
|
key in editor.editedFields ||
|
||||||
|
editor.removedKeys.has(key)
|
||||||
|
}
|
||||||
isMixed={editor.mixedKeys.has(key)}
|
isMixed={editor.mixedKeys.has(key)}
|
||||||
isMultiValue={
|
isMultiValue={
|
||||||
editor.multiValueKeys.has(key) ||
|
editor.multiValueKeys.has(key) ||
|
||||||
Array.isArray(value)
|
Array.isArray(value)
|
||||||
}
|
}
|
||||||
|
isRemoved={editor.removedKeys.has(key)}
|
||||||
key={key}
|
key={key}
|
||||||
meta={editor.getFieldMeta(key)}
|
meta={editor.getFieldMeta(key)}
|
||||||
mixedPlaceholder={
|
mixedPlaceholder={
|
||||||
@@ -114,6 +118,8 @@ export const SongEditModal = ({ songs }: { songs: Song[] }) => {
|
|||||||
}
|
}
|
||||||
onChange={(v) => editor.handleFieldChange(key, v)}
|
onChange={(v) => editor.handleFieldChange(key, v)}
|
||||||
onRemove={() => editor.handleRemoveField(key)}
|
onRemove={() => editor.handleRemoveField(key)}
|
||||||
|
onReset={() => editor.handleResetField(key)}
|
||||||
|
onRevert={() => editor.handleRevertField(key)}
|
||||||
tagKey={key}
|
tagKey={key}
|
||||||
value={value}
|
value={value}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -10,3 +10,8 @@
|
|||||||
.dirty-label {
|
.dirty-label {
|
||||||
box-shadow: inset 2px 0 0 var(--mantine-primary-color-filled);
|
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 { useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { RiCloseLine } from 'react-icons/ri';
|
|
||||||
|
|
||||||
import type { KnownTag } from '../utils/known-tags';
|
import type { KnownTag } from '../utils/known-tags';
|
||||||
|
|
||||||
import styles from './tag-field-row.module.css';
|
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 { Checkbox } from '/@/shared/components/checkbox/checkbox';
|
||||||
import { NumberInput } from '/@/shared/components/number-input/number-input';
|
import { NumberInput } from '/@/shared/components/number-input/number-input';
|
||||||
import { Table } from '/@/shared/components/table/table';
|
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';
|
import { Textarea } from '/@/shared/components/textarea/textarea';
|
||||||
|
|
||||||
interface FavoriteTagsInputProps {
|
interface FavoriteTagsInputProps {
|
||||||
|
disabled: boolean;
|
||||||
favoriteValues: string[];
|
favoriteValues: string[];
|
||||||
mixedPlaceholder?: string;
|
mixedPlaceholder?: string;
|
||||||
onAddFavorite: (value: string) => void;
|
onAddFavorite: (value: string) => void;
|
||||||
@@ -29,11 +29,14 @@ interface TagFieldRowProps {
|
|||||||
isDirty?: boolean;
|
isDirty?: boolean;
|
||||||
isMixed: boolean;
|
isMixed: boolean;
|
||||||
isMultiValue: boolean;
|
isMultiValue: boolean;
|
||||||
|
isRemoved: boolean;
|
||||||
meta: KnownTag;
|
meta: KnownTag;
|
||||||
mixedPlaceholder?: string;
|
mixedPlaceholder?: string;
|
||||||
onAddFavorite: (value: string) => void;
|
onAddFavorite: (value: string) => void;
|
||||||
onChange: (value: TagValue) => void;
|
onChange: (value: TagValue) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
|
onReset: () => void;
|
||||||
|
onRevert: () => void;
|
||||||
tagKey: string;
|
tagKey: string;
|
||||||
value: TagValue;
|
value: TagValue;
|
||||||
}
|
}
|
||||||
@@ -41,6 +44,7 @@ interface TagFieldRowProps {
|
|||||||
const ADD_FAVORITE_PREFIX = '__feishin_add_favorite__:';
|
const ADD_FAVORITE_PREFIX = '__feishin_add_favorite__:';
|
||||||
|
|
||||||
const FavoriteTagsInput = ({
|
const FavoriteTagsInput = ({
|
||||||
|
disabled,
|
||||||
favoriteValues,
|
favoriteValues,
|
||||||
mixedPlaceholder,
|
mixedPlaceholder,
|
||||||
onAddFavorite,
|
onAddFavorite,
|
||||||
@@ -73,6 +77,7 @@ const FavoriteTagsInput = ({
|
|||||||
<TagsInput
|
<TagsInput
|
||||||
clearable
|
clearable
|
||||||
data={data}
|
data={data}
|
||||||
|
disabled={disabled}
|
||||||
onChange={(values) => {
|
onChange={(values) => {
|
||||||
const normalizedValues = values.map((item) =>
|
const normalizedValues = values.map((item) =>
|
||||||
item.startsWith(ADD_FAVORITE_PREFIX)
|
item.startsWith(ADD_FAVORITE_PREFIX)
|
||||||
@@ -107,19 +112,30 @@ export const TagFieldRow = ({
|
|||||||
isDirty,
|
isDirty,
|
||||||
isMixed,
|
isMixed,
|
||||||
isMultiValue,
|
isMultiValue,
|
||||||
|
isRemoved,
|
||||||
meta,
|
meta,
|
||||||
mixedPlaceholder,
|
mixedPlaceholder,
|
||||||
onAddFavorite,
|
onAddFavorite,
|
||||||
onChange,
|
onChange,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
onReset,
|
||||||
|
onRevert,
|
||||||
tagKey,
|
tagKey,
|
||||||
value,
|
value,
|
||||||
}: TagFieldRowProps) => (
|
}: TagFieldRowProps) => {
|
||||||
<Table.Tr data-field-key={tagKey} key={tagKey}>
|
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.Th className={isDirty ? styles.dirtyLabel : undefined}>{meta.label}</Table.Th>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
{isMultiValue && tagKey !== 'lyrics' ? (
|
{isMultiValue && tagKey !== 'lyrics' ? (
|
||||||
<FavoriteTagsInput
|
<FavoriteTagsInput
|
||||||
|
disabled={isRemoved}
|
||||||
favoriteValues={favoriteValues}
|
favoriteValues={favoriteValues}
|
||||||
mixedPlaceholder={mixedPlaceholder}
|
mixedPlaceholder={mixedPlaceholder}
|
||||||
onAddFavorite={onAddFavorite}
|
onAddFavorite={onAddFavorite}
|
||||||
@@ -129,6 +145,7 @@ export const TagFieldRow = ({
|
|||||||
) : meta.type === 'textarea' ? (
|
) : meta.type === 'textarea' ? (
|
||||||
<Textarea
|
<Textarea
|
||||||
autosize
|
autosize
|
||||||
|
disabled={isRemoved}
|
||||||
maxRows={6}
|
maxRows={6}
|
||||||
minRows={2}
|
minRows={2}
|
||||||
onChange={(e) => onChange(e.currentTarget.value)}
|
onChange={(e) => onChange(e.currentTarget.value)}
|
||||||
@@ -138,22 +155,27 @@ export const TagFieldRow = ({
|
|||||||
/>
|
/>
|
||||||
) : meta.type === 'number' ? (
|
) : meta.type === 'number' ? (
|
||||||
<NumberInput
|
<NumberInput
|
||||||
|
disabled={isRemoved}
|
||||||
onChange={(v) => onChange(v === undefined ? '' : String(v))}
|
onChange={(v) => onChange(v === undefined ? '' : String(v))}
|
||||||
placeholder={mixedPlaceholder}
|
placeholder={mixedPlaceholder}
|
||||||
size="sm"
|
size="sm"
|
||||||
value={
|
value={
|
||||||
isMixed || value === '' || Array.isArray(value) ? undefined : Number(value)
|
isMixed || value === '' || Array.isArray(value)
|
||||||
|
? undefined
|
||||||
|
: Number(value)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : meta.type === 'boolean' ? (
|
) : meta.type === 'boolean' ? (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={!isMixed && value === '1'}
|
checked={!isMixed && value === '1'}
|
||||||
|
disabled={isRemoved}
|
||||||
indeterminate={isMixed}
|
indeterminate={isMixed}
|
||||||
onChange={(e) => onChange(e.currentTarget.checked ? '1' : '0')}
|
onChange={(e) => onChange(e.currentTarget.checked ? '1' : '0')}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<TextInput
|
<TextInput
|
||||||
|
disabled={isRemoved}
|
||||||
onChange={(e) => onChange(e.currentTarget.value)}
|
onChange={(e) => onChange(e.currentTarget.value)}
|
||||||
placeholder={mixedPlaceholder}
|
placeholder={mixedPlaceholder}
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -162,9 +184,22 @@ export const TagFieldRow = ({
|
|||||||
)}
|
)}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td className={styles.removeCell}>
|
<Table.Td className={styles.removeCell}>
|
||||||
<Button className={styles.removeButton} onClick={onRemove} size="sm" variant="subtle">
|
<ActionIcon
|
||||||
<RiCloseLine size={16} />
|
aria-label={isRemoved || isDirty ? t('common.undo') : t('common.delete')}
|
||||||
</Button>
|
className={styles.removeButton}
|
||||||
|
icon={isRemoved || isDirty ? 'undo' : 'x'}
|
||||||
|
iconProps={{
|
||||||
|
color: isRemoved ? 'error' : 'default',
|
||||||
|
size: 'md',
|
||||||
|
}}
|
||||||
|
onClick={isRemoved ? onReset : isDirty ? onRevert : onRemove}
|
||||||
|
size="sm"
|
||||||
|
tooltip={{
|
||||||
|
label: isRemoved || isDirty ? t('common.undo') : t('common.delete'),
|
||||||
|
}}
|
||||||
|
variant="subtle"
|
||||||
|
/>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -87,7 +87,6 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
|
|||||||
const allKeys = new Set<string>();
|
const allKeys = new Set<string>();
|
||||||
for (const k of Object.keys(tagSummary)) allKeys.add(k);
|
for (const k of Object.keys(tagSummary)) allKeys.add(k);
|
||||||
for (const k of Object.keys(editedFields)) 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 displayFields: Record<string, TagValue> = {};
|
||||||
const mixedKeys = new Set<string>();
|
const mixedKeys = new Set<string>();
|
||||||
@@ -118,7 +117,7 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
|
|||||||
});
|
});
|
||||||
|
|
||||||
return { displayFields, mixedKeys, sortedFieldEntries };
|
return { displayFields, mixedKeys, sortedFieldEntries };
|
||||||
}, [tagSummary, editedFields, multiValueKeys, removedKeys]);
|
}, [tagSummary, editedFields, multiValueKeys]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs once on mount: reads metadata for all songs in batch and populates
|
* 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],
|
[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) => {
|
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) => {
|
setEditedFields((prev) => {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
delete next[key];
|
delete next[key];
|
||||||
return next;
|
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.
|
// 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;
|
if (!key) return;
|
||||||
setEditedFields((prev) => {
|
setEditedFields((prev) => {
|
||||||
const wasRemoved = removedKeysRef.current.has(key);
|
const wasRemoved = removedKeysRef.current.has(key);
|
||||||
|
if (wasRemoved) return prev;
|
||||||
const alreadyVisible = (key in tagSummaryRef.current || key in prev) && !wasRemoved;
|
const alreadyVisible = (key in tagSummaryRef.current || key in prev) && !wasRemoved;
|
||||||
if (alreadyVisible) return prev;
|
if (alreadyVisible) return prev;
|
||||||
return { ...prev, [key]: multiValueKeys.has(key) ? [] : '' };
|
return { ...prev, [key]: multiValueKeys.has(key) ? [] : '' };
|
||||||
@@ -284,7 +302,10 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
|
|||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
if (resolvedSongs.length === 0) return;
|
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]) => {
|
.filter(([key, value]) => {
|
||||||
const meta = KNOWN_TAG_MAP.get(key);
|
const meta = KNOWN_TAG_MAP.get(key);
|
||||||
if (meta?.type === 'boolean') return false;
|
if (meta?.type === 'boolean') return false;
|
||||||
@@ -309,7 +330,7 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
|
|||||||
const writeResult = await withBatchProgress(utils, setLoadProgress, () =>
|
const writeResult = await withBatchProgress(utils, setLoadProgress, () =>
|
||||||
utils.writeSongTagsBatch(
|
utils.writeSongTagsBatch(
|
||||||
paths,
|
paths,
|
||||||
editedFields,
|
activeEdits,
|
||||||
[...removedKeys],
|
[...removedKeys],
|
||||||
artworkOp ?? undefined,
|
artworkOp ?? undefined,
|
||||||
),
|
),
|
||||||
@@ -393,6 +414,8 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
|
|||||||
handleFieldChange,
|
handleFieldChange,
|
||||||
handleRemoveArtwork,
|
handleRemoveArtwork,
|
||||||
handleRemoveField,
|
handleRemoveField,
|
||||||
|
handleResetField,
|
||||||
|
handleRevertField,
|
||||||
handleSave,
|
handleSave,
|
||||||
isLoading,
|
isLoading,
|
||||||
isSaving,
|
isSaving,
|
||||||
@@ -401,6 +424,7 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
|
|||||||
mixedPlaceholder,
|
mixedPlaceholder,
|
||||||
multiValueKeys,
|
multiValueKeys,
|
||||||
readWarning,
|
readWarning,
|
||||||
|
removedKeys,
|
||||||
rescan: triggerRescan,
|
rescan: triggerRescan,
|
||||||
setRescan,
|
setRescan,
|
||||||
showRemoveArtworkButton,
|
showRemoveArtworkButton,
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ import {
|
|||||||
LuTimerOff,
|
LuTimerOff,
|
||||||
LuTrash,
|
LuTrash,
|
||||||
LuTriangleAlert,
|
LuTriangleAlert,
|
||||||
|
LuUndo2,
|
||||||
LuUpload,
|
LuUpload,
|
||||||
LuUser,
|
LuUser,
|
||||||
LuUserPen,
|
LuUserPen,
|
||||||
@@ -361,6 +362,7 @@ export const AppIcon = {
|
|||||||
themeDark: LuMoon,
|
themeDark: LuMoon,
|
||||||
themeLight: LuSun,
|
themeLight: LuSun,
|
||||||
track: LuMusic2,
|
track: LuMusic2,
|
||||||
|
undo: LuUndo2,
|
||||||
unfavorite: LuHeartCrack,
|
unfavorite: LuHeartCrack,
|
||||||
unpin: LuPinOff,
|
unpin: LuPinOff,
|
||||||
upload: LuUpload,
|
upload: LuUpload,
|
||||||
|
|||||||
Reference in New Issue
Block a user