mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 04:20:12 +02:00
385 lines
13 KiB
TypeScript
385 lines
13 KiB
TypeScript
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 { DragDropZone } from '/@/shared/components/drag-drop-zone/drag-drop-zone';
|
|
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,
|
|
UpdatePlaylistQuery,
|
|
UserListSort,
|
|
} 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 updateMutation = useUpdatePlaylist({});
|
|
const uploadImageMutation = useUploadPlaylistImage({});
|
|
const deleteImageMutation = useDeletePlaylistImage({});
|
|
const server = useCurrentServer();
|
|
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: {
|
|
comment: body?.comment || '',
|
|
name: body?.name || '',
|
|
ownerId: body.ownerId,
|
|
public: body.public,
|
|
queryBuilderRules: body.queryBuilderRules,
|
|
sync: body.sync,
|
|
},
|
|
});
|
|
|
|
const handleSubmit = form.onSubmit(async (values) => {
|
|
if (!server?.id) return;
|
|
|
|
setIsSaving(true);
|
|
try {
|
|
await updateMutation.mutateAsync({
|
|
apiClientProps: { serverId: server.id },
|
|
body: values,
|
|
query,
|
|
});
|
|
|
|
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 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}>
|
|
{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>
|
|
</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) => {
|
|
const { ...triggerRest } = props;
|
|
return (
|
|
<ActionIcon
|
|
icon="uploadImage"
|
|
iconProps={{ size: 'lg' }}
|
|
radius="xl"
|
|
size="sm"
|
|
variant="default"
|
|
{...triggerRest}
|
|
style={{ pointerEvents: 'auto' }}
|
|
/>
|
|
);
|
|
}}
|
|
</FileButton>
|
|
<ActionIcon
|
|
disabled={secondaryDisabled}
|
|
icon={secondaryIcon}
|
|
iconProps={{ size: 'lg' }}
|
|
onClick={secondaryAction}
|
|
radius="xl"
|
|
size="sm"
|
|
style={{ pointerEvents: 'auto' }}
|
|
variant="default"
|
|
/>
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<Box
|
|
style={{
|
|
borderRadius: 'var(--mantine-radius-md)',
|
|
flexShrink: 0,
|
|
height: COVER_SIZE,
|
|
overflow: 'hidden',
|
|
position: 'relative',
|
|
width: COVER_SIZE,
|
|
}}
|
|
>
|
|
<DragDropZone
|
|
accept="image/*"
|
|
mode="file"
|
|
onFileSelected={(file) => onFileSelect(file)}
|
|
style={{
|
|
height: '100%',
|
|
overflow: 'hidden',
|
|
position: 'relative',
|
|
width: '100%',
|
|
}}
|
|
>
|
|
<ItemImage
|
|
enableViewport={false}
|
|
id={previewId}
|
|
itemType={LibraryItem.PLAYLIST}
|
|
serverId={server?.id}
|
|
src={previewSrc}
|
|
type="header"
|
|
/>
|
|
<Group
|
|
gap={4}
|
|
style={{
|
|
background: 'rgba(0, 0, 0, 0.55)',
|
|
bottom: 6,
|
|
padding: 4,
|
|
pointerEvents: 'none',
|
|
position: 'absolute',
|
|
right: 6,
|
|
zIndex: 2,
|
|
}}
|
|
wrap="nowrap"
|
|
>
|
|
{iconControls}
|
|
</Group>
|
|
</DragDropZone>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
const OwnerSelect = ({ form }: { form: ReturnType<typeof useForm<UpdatePlaylistBody>> }) => {
|
|
const serverId = useCurrentServerId();
|
|
const permissions = usePermissions();
|
|
|
|
const usersQuery = useQuery(
|
|
sharedQueries.users({
|
|
options: { enabled: permissions.playlists.editOwner },
|
|
query: { sortBy: UserListSort.NAME, sortOrder: SortOrder.ASC, startIndex: 0 },
|
|
serverId,
|
|
}),
|
|
);
|
|
|
|
const userList = usersQuery.data?.items?.map((user) => ({
|
|
label: user.name,
|
|
value: user.id,
|
|
}));
|
|
|
|
if (!permissions.playlists.editOwner) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<Select
|
|
data={usersQuery.isLoading ? [] : userList}
|
|
disabled={usersQuery.isLoading}
|
|
{...form.getInputProps('ownerId')}
|
|
label={t('form.createPlaylist.input', {
|
|
context: 'owner',
|
|
postProcess: 'titleCase',
|
|
})}
|
|
/>
|
|
);
|
|
};
|