add playlist image upload to edit playlist modal

This commit is contained in:
jeffvli
2026-04-02 17:41:25 -07:00
parent 92cea5dfda
commit fbf82c1ef0
2 changed files with 286 additions and 72 deletions
@@ -1,21 +1,31 @@
import { closeModal, ContextModalProps } from '@mantine/modals';
import { useQuery } from '@tanstack/react-query';
import { t } from 'i18next';
import { type ReactNode, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ItemImage } from '/@/renderer/components/item-image/item-image';
import { useDeletePlaylistImage } from '/@/renderer/features/playlists/mutations/delete-playlist-image-mutation';
import { useUpdatePlaylist } from '/@/renderer/features/playlists/mutations/update-playlist-mutation';
import { useUploadPlaylistImage } from '/@/renderer/features/playlists/mutations/upload-playlist-image-mutation';
import { sharedQueries } from '/@/renderer/features/shared/api/shared-api';
import { useCurrentServer, useCurrentServerId, usePermissions } from '/@/renderer/store';
import { hasFeature } from '/@/shared/api/utils';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Box } from '/@/shared/components/box/box';
import { FileButton } from '/@/shared/components/file-button/file-button';
import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group';
import { ModalButton } from '/@/shared/components/modal/model-shared';
import { Select } from '/@/shared/components/select/select';
import { Stack } from '/@/shared/components/stack/stack';
import { Switch } from '/@/shared/components/switch/switch';
import { TextInput } from '/@/shared/components/text-input/text-input';
import { Textarea } from '/@/shared/components/textarea/textarea';
import { toast } from '/@/shared/components/toast/toast';
import { useForm } from '/@/shared/hooks/use-form';
import {
LibraryItem,
ServerType,
SortOrder,
UpdatePlaylistBody,
@@ -24,17 +34,41 @@ import {
} from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types';
type PlaylistImageProps = {
imageId: null | string;
imageUrl: null | string;
uploadedImage?: string;
};
export const UpdatePlaylistContextModal = ({
id,
innerProps,
}: ContextModalProps<{
body: Partial<UpdatePlaylistBody>;
playlistImage?: PlaylistImageProps;
query: UpdatePlaylistQuery;
}>) => {
const { t } = useTranslation();
const mutation = useUpdatePlaylist({});
const updateMutation = useUpdatePlaylist({});
const uploadImageMutation = useUploadPlaylistImage({});
const deleteImageMutation = useDeletePlaylistImage({});
const server = useCurrentServer();
const { body, query } = innerProps;
const { body, playlistImage, query } = innerProps;
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [pendingPreviewUrl, setPendingPreviewUrl] = useState<null | string>(null);
const [removeCustomCover, setRemoveCustomCover] = useState(false);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
if (!pendingFile) {
setPendingPreviewUrl(null);
return;
}
const url = URL.createObjectURL(pendingFile);
setPendingPreviewUrl(url);
return () => URL.revokeObjectURL(url);
}, [pendingFile]);
const form = useForm<UpdatePlaylistBody>({
initialValues: {
@@ -47,91 +81,259 @@ export const UpdatePlaylistContextModal = ({
},
});
const handleSubmit = form.onSubmit((values) => {
mutation.mutate(
{
apiClientProps: { serverId: server?.id || '' },
const handleSubmit = form.onSubmit(async (values) => {
if (!server?.id) return;
setIsSaving(true);
try {
await updateMutation.mutateAsync({
apiClientProps: { serverId: server.id },
body: values,
query,
},
{
onError: (err) => {
toast.error({
message: err.message,
title: t('error.genericError', { postProcess: 'sentenceCase' }),
});
},
onSuccess: () => {
toast.success({
message: t('form.editPlaylist.success', { postProcess: 'sentenceCase' }),
});
closeModal(id);
},
},
);
});
if (pendingFile) {
const buffer = await pendingFile.arrayBuffer();
await uploadImageMutation.mutateAsync({
apiClientProps: { serverId: server.id },
body: { image: new Uint8Array(buffer) },
query: { id: query.id },
});
} else if (removeCustomCover && playlistImage?.uploadedImage) {
await deleteImageMutation.mutateAsync({
apiClientProps: { serverId: server.id },
query: { id: query.id },
});
}
toast.success({
message: t('form.editPlaylist.success', { postProcess: 'sentenceCase' }),
});
closeModal(id);
} catch (err: any) {
toast.error({
message: err?.message,
title: t('error.genericError', { postProcess: 'sentenceCase' }),
});
} finally {
setIsSaving(false);
}
});
const isPublicDisplayed = hasFeature(server, ServerFeature.PUBLIC_PLAYLIST);
const isOwnerDisplayed = server?.type === ServerType.NAVIDROME;
const isCommentDisplayed = server?.type === ServerType.NAVIDROME;
const isSubmitDisabled = !form.values.name || mutation.isPending;
const isCoverImageDisplayed = hasFeature(server, ServerFeature.PLAYLIST_IMAGE_UPLOAD);
const isSubmitDisabled = !form.values.name || isSaving;
const hadUploadedCover = !!playlistImage?.uploadedImage;
const fieldNodes: ReactNode[] = [
<TextInput
data-autofocus
key="name"
label={t('form.createPlaylist.input', {
context: 'name',
postProcess: 'titleCase',
})}
required
{...form.getInputProps('name')}
/>,
];
if (isCommentDisplayed) {
fieldNodes.push(
<Textarea
autosize
key="comment"
label={t('form.createPlaylist.input', {
context: 'description',
postProcess: 'titleCase',
})}
minRows={5}
{...form.getInputProps('comment')}
/>,
);
}
if (isOwnerDisplayed) {
fieldNodes.push(<OwnerSelect form={form} key="owner" />);
}
if (isPublicDisplayed) {
if (server?.type === ServerType.JELLYFIN) {
fieldNodes.push(
<div key="jellyfin-public-note">
{t('form.editPlaylist.publicJellyfinNote', {
postProcess: 'sentenceCase',
})}
</div>,
);
}
fieldNodes.push(
<Switch
key="public"
label={t('form.createPlaylist.input', {
context: 'public',
postProcess: 'titleCase',
})}
{...form.getInputProps('public', { type: 'checkbox' })}
/>,
);
}
fieldNodes.push(
<Group justify="flex-end" key="actions">
<ModalButton disabled={isSaving} onClick={() => closeModal(id)}>
{t('common.cancel')}
</ModalButton>
<ModalButton
disabled={isSubmitDisabled}
loading={isSaving}
type="submit"
variant="filled"
>
{t('common.save')}
</ModalButton>
</Group>,
);
return (
<form onSubmit={handleSubmit}>
<Stack>
<TextInput
data-autofocus
label={t('form.createPlaylist.input', {
context: 'name',
postProcess: 'titleCase',
})}
required
{...form.getInputProps('name')}
/>
{isCommentDisplayed && (
<TextInput
label={t('form.createPlaylist.input', {
context: 'description',
postProcess: 'titleCase',
})}
{...form.getInputProps('comment')}
{isCoverImageDisplayed ? (
<Flex align="flex-start" gap="lg" wrap="wrap">
<PlaylistCoverField
hadUploadedCover={hadUploadedCover}
onClearPending={() => setPendingFile(null)}
onFileSelect={(file) => {
if (!file) return;
setRemoveCustomCover(false);
setPendingFile(file);
}}
onToggleRemoveCover={() => setRemoveCustomCover((v) => !v)}
pendingFile={pendingFile}
pendingPreviewUrl={pendingPreviewUrl}
playlistImage={playlistImage}
removeCustomCover={removeCustomCover}
/>
)}
{isOwnerDisplayed && <OwnerSelect form={form} />}
{isPublicDisplayed && (
<>
{server?.type === ServerType.JELLYFIN && (
<div>
{t('form.editPlaylist.publicJellyfinNote', {
postProcess: 'sentenceCase',
})}
</div>
)}
<Switch
label={t('form.createPlaylist.input', {
context: 'public',
postProcess: 'titleCase',
})}
{...form.getInputProps('public', { type: 'checkbox' })}
/>
</>
)}
<Group justify="flex-end">
<ModalButton onClick={() => closeModal(id)}>{t('common.cancel')}</ModalButton>
<ModalButton
disabled={isSubmitDisabled}
loading={mutation.isPending}
type="submit"
variant="filled"
>
{t('common.save')}
</ModalButton>
</Group>
</Stack>
<Stack gap="md" style={{ flex: '1 1 220px', minWidth: 0 }}>
{fieldNodes}
</Stack>
</Flex>
) : (
<Stack gap="md">{fieldNodes}</Stack>
)}
</form>
);
};
const COVER_SIZE = 240;
function PlaylistCoverField({
hadUploadedCover,
onClearPending,
onFileSelect,
onToggleRemoveCover,
pendingFile,
pendingPreviewUrl,
playlistImage,
removeCustomCover,
}: {
hadUploadedCover: boolean;
onClearPending: () => void;
onFileSelect: (file: File | null) => void;
onToggleRemoveCover: () => void;
pendingFile: File | null;
pendingPreviewUrl: null | string;
playlistImage?: PlaylistImageProps;
removeCustomCover: boolean;
}) {
const server = useCurrentServer();
const showServerCover = !pendingPreviewUrl && !removeCustomCover;
const previewId = showServerCover ? playlistImage?.imageId || undefined : undefined;
const previewSrc = pendingPreviewUrl || (showServerCover ? playlistImage?.imageUrl || '' : '');
const secondaryAction = () => {
if (pendingFile) {
onClearPending();
return;
}
if (hadUploadedCover) {
onToggleRemoveCover();
}
};
const secondaryDisabled = !pendingFile && !hadUploadedCover;
const secondaryIcon = pendingFile ? 'x' : removeCustomCover ? 'arrowLeft' : 'delete';
const iconControls = (
<>
<FileButton accept="image/*" onChange={onFileSelect}>
{(props) => (
<ActionIcon
icon="uploadImage"
iconProps={{ size: 'lg' }}
radius="xl"
size="sm"
variant="default"
{...props}
/>
)}
</FileButton>
<ActionIcon
disabled={secondaryDisabled}
icon={secondaryIcon}
iconProps={{ size: 'lg' }}
onClick={secondaryAction}
radius="xl"
size="sm"
variant="default"
/>
</>
);
const coverArt = (
<ItemImage
enableViewport={false}
id={previewId}
itemType={LibraryItem.PLAYLIST}
serverId={server?.id}
src={previewSrc}
type="header"
/>
);
return (
<Box
style={{
borderRadius: 'var(--mantine-radius-md)',
flexShrink: 0,
height: COVER_SIZE,
overflow: 'hidden',
position: 'relative',
width: COVER_SIZE,
}}
>
{coverArt}
<Group
gap={4}
style={{
background: 'rgba(0, 0, 0, 0.55)',
borderRadius: 'var(--mantine-radius-md)',
bottom: 6,
padding: 4,
position: 'absolute',
right: 6,
}}
wrap="nowrap"
>
{iconControls}
</Group>
</Box>
);
}
const OwnerSelect = ({ form }: { form: ReturnType<typeof useForm<UpdatePlaylistBody>> }) => {
const serverId = useCurrentServerId();
const permissions = usePermissions();
@@ -1,11 +1,17 @@
import { openContextModal } from '@mantine/modals';
import i18n from '/@/i18n/i18n';
import { useAuthStore } from '/@/renderer/store';
import { hasFeature } from '/@/shared/api/utils';
import { Playlist } from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types';
export const openUpdatePlaylistModal = async (args: { playlist: Playlist }) => {
const { playlist } = args;
const server = useAuthStore.getState().currentServer;
const hasImageUpload = hasFeature(server, ServerFeature.PLAYLIST_IMAGE_UPLOAD);
openContextModal({
innerProps: {
body: {
@@ -17,9 +23,15 @@ export const openUpdatePlaylistModal = async (args: { playlist: Playlist }) => {
queryBuilderRules: playlist?.rules || undefined,
sync: playlist?.sync || undefined,
},
playlistImage: {
imageId: playlist.imageId,
imageUrl: playlist.imageUrl,
uploadedImage: playlist.uploadedImage,
},
query: { id: playlist?.id },
},
modalKey: 'updatePlaylist',
size: hasImageUpload ? 'lg' : 'md',
title: i18n.t('form.editPlaylist.title', { postProcess: 'titleCase' }) as string,
});
};