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 { closeModal, ContextModalProps } from '@mantine/modals';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { t } from 'i18next'; import { t } from 'i18next';
import { type ReactNode, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; 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 { 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 { sharedQueries } from '/@/renderer/features/shared/api/shared-api';
import { useCurrentServer, useCurrentServerId, usePermissions } from '/@/renderer/store'; import { useCurrentServer, useCurrentServerId, usePermissions } from '/@/renderer/store';
import { hasFeature } from '/@/shared/api/utils'; 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 { Group } from '/@/shared/components/group/group';
import { ModalButton } from '/@/shared/components/modal/model-shared'; import { ModalButton } from '/@/shared/components/modal/model-shared';
import { Select } from '/@/shared/components/select/select'; import { Select } from '/@/shared/components/select/select';
import { Stack } from '/@/shared/components/stack/stack'; import { Stack } from '/@/shared/components/stack/stack';
import { Switch } from '/@/shared/components/switch/switch'; import { Switch } from '/@/shared/components/switch/switch';
import { TextInput } from '/@/shared/components/text-input/text-input'; import { TextInput } from '/@/shared/components/text-input/text-input';
import { Textarea } from '/@/shared/components/textarea/textarea';
import { toast } from '/@/shared/components/toast/toast'; import { toast } from '/@/shared/components/toast/toast';
import { useForm } from '/@/shared/hooks/use-form'; import { useForm } from '/@/shared/hooks/use-form';
import { import {
LibraryItem,
ServerType, ServerType,
SortOrder, SortOrder,
UpdatePlaylistBody, UpdatePlaylistBody,
@@ -24,17 +34,41 @@ import {
} from '/@/shared/types/domain-types'; } from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types'; import { ServerFeature } from '/@/shared/types/features-types';
type PlaylistImageProps = {
imageId: null | string;
imageUrl: null | string;
uploadedImage?: string;
};
export const UpdatePlaylistContextModal = ({ export const UpdatePlaylistContextModal = ({
id, id,
innerProps, innerProps,
}: ContextModalProps<{ }: ContextModalProps<{
body: Partial<UpdatePlaylistBody>; body: Partial<UpdatePlaylistBody>;
playlistImage?: PlaylistImageProps;
query: UpdatePlaylistQuery; query: UpdatePlaylistQuery;
}>) => { }>) => {
const { t } = useTranslation(); const { t } = useTranslation();
const mutation = useUpdatePlaylist({}); const updateMutation = useUpdatePlaylist({});
const uploadImageMutation = useUploadPlaylistImage({});
const deleteImageMutation = useDeletePlaylistImage({});
const server = useCurrentServer(); 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>({ const form = useForm<UpdatePlaylistBody>({
initialValues: { initialValues: {
@@ -47,91 +81,259 @@ export const UpdatePlaylistContextModal = ({
}, },
}); });
const handleSubmit = form.onSubmit((values) => { const handleSubmit = form.onSubmit(async (values) => {
mutation.mutate( if (!server?.id) return;
{
apiClientProps: { serverId: server?.id || '' }, setIsSaving(true);
try {
await updateMutation.mutateAsync({
apiClientProps: { serverId: server.id },
body: values, body: values,
query, query,
},
{
onError: (err) => {
toast.error({
message: err.message,
title: t('error.genericError', { postProcess: 'sentenceCase' }),
}); });
},
onSuccess: () => { 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({ toast.success({
message: t('form.editPlaylist.success', { postProcess: 'sentenceCase' }), message: t('form.editPlaylist.success', { postProcess: 'sentenceCase' }),
}); });
closeModal(id); 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 isPublicDisplayed = hasFeature(server, ServerFeature.PUBLIC_PLAYLIST);
const isOwnerDisplayed = server?.type === ServerType.NAVIDROME; const isOwnerDisplayed = server?.type === ServerType.NAVIDROME;
const isCommentDisplayed = 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;
return ( const fieldNodes: ReactNode[] = [
<form onSubmit={handleSubmit}>
<Stack>
<TextInput <TextInput
data-autofocus data-autofocus
key="name"
label={t('form.createPlaylist.input', { label={t('form.createPlaylist.input', {
context: 'name', context: 'name',
postProcess: 'titleCase', postProcess: 'titleCase',
})} })}
required required
{...form.getInputProps('name')} {...form.getInputProps('name')}
/> />,
{isCommentDisplayed && ( ];
<TextInput
if (isCommentDisplayed) {
fieldNodes.push(
<Textarea
autosize
key="comment"
label={t('form.createPlaylist.input', { label={t('form.createPlaylist.input', {
context: 'description', context: 'description',
postProcess: 'titleCase', postProcess: 'titleCase',
})} })}
minRows={5}
{...form.getInputProps('comment')} {...form.getInputProps('comment')}
/> />,
)} );
{isOwnerDisplayed && <OwnerSelect form={form} />} }
{isPublicDisplayed && (
<> if (isOwnerDisplayed) {
{server?.type === ServerType.JELLYFIN && ( fieldNodes.push(<OwnerSelect form={form} key="owner" />);
<div> }
if (isPublicDisplayed) {
if (server?.type === ServerType.JELLYFIN) {
fieldNodes.push(
<div key="jellyfin-public-note">
{t('form.editPlaylist.publicJellyfinNote', { {t('form.editPlaylist.publicJellyfinNote', {
postProcess: 'sentenceCase', postProcess: 'sentenceCase',
})} })}
</div> </div>,
)} );
}
fieldNodes.push(
<Switch <Switch
key="public"
label={t('form.createPlaylist.input', { label={t('form.createPlaylist.input', {
context: 'public', context: 'public',
postProcess: 'titleCase', postProcess: 'titleCase',
})} })}
{...form.getInputProps('public', { type: 'checkbox' })} {...form.getInputProps('public', { type: 'checkbox' })}
/> />,
</> );
)} }
<Group justify="flex-end">
<ModalButton onClick={() => closeModal(id)}>{t('common.cancel')}</ModalButton> fieldNodes.push(
<Group justify="flex-end" key="actions">
<ModalButton disabled={isSaving} onClick={() => closeModal(id)}>
{t('common.cancel')}
</ModalButton>
<ModalButton <ModalButton
disabled={isSubmitDisabled} disabled={isSubmitDisabled}
loading={mutation.isPending} loading={isSaving}
type="submit" type="submit"
variant="filled" variant="filled"
> >
{t('common.save')} {t('common.save')}
</ModalButton> </ModalButton>
</Group> </Group>,
);
return (
<form onSubmit={handleSubmit}>
{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}
/>
<Stack gap="md" style={{ flex: '1 1 220px', minWidth: 0 }}>
{fieldNodes}
</Stack> </Stack>
</Flex>
) : (
<Stack gap="md">{fieldNodes}</Stack>
)}
</form> </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 OwnerSelect = ({ form }: { form: ReturnType<typeof useForm<UpdatePlaylistBody>> }) => {
const serverId = useCurrentServerId(); const serverId = useCurrentServerId();
const permissions = usePermissions(); const permissions = usePermissions();
@@ -1,11 +1,17 @@
import { openContextModal } from '@mantine/modals'; import { openContextModal } from '@mantine/modals';
import i18n from '/@/i18n/i18n'; import i18n from '/@/i18n/i18n';
import { useAuthStore } from '/@/renderer/store';
import { hasFeature } from '/@/shared/api/utils';
import { Playlist } from '/@/shared/types/domain-types'; import { Playlist } from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types';
export const openUpdatePlaylistModal = async (args: { playlist: Playlist }) => { export const openUpdatePlaylistModal = async (args: { playlist: Playlist }) => {
const { playlist } = args; const { playlist } = args;
const server = useAuthStore.getState().currentServer;
const hasImageUpload = hasFeature(server, ServerFeature.PLAYLIST_IMAGE_UPLOAD);
openContextModal({ openContextModal({
innerProps: { innerProps: {
body: { body: {
@@ -17,9 +23,15 @@ export const openUpdatePlaylistModal = async (args: { playlist: Playlist }) => {
queryBuilderRules: playlist?.rules || undefined, queryBuilderRules: playlist?.rules || undefined,
sync: playlist?.sync || undefined, sync: playlist?.sync || undefined,
}, },
playlistImage: {
imageId: playlist.imageId,
imageUrl: playlist.imageUrl,
uploadedImage: playlist.uploadedImage,
},
query: { id: playlist?.id }, query: { id: playlist?.id },
}, },
modalKey: 'updatePlaylist', modalKey: 'updatePlaylist',
size: hasImageUpload ? 'lg' : 'md',
title: i18n.t('form.editPlaylist.title', { postProcess: 'titleCase' }) as string, title: i18n.t('form.editPlaylist.title', { postProcess: 'titleCase' }) as string,
}); });
}; };