support image drop for upload

This commit is contained in:
jeffvli
2026-04-06 11:32:03 -07:00
parent 918f453066
commit 6fc7b6b271
8 changed files with 385 additions and 133 deletions
@@ -41,8 +41,13 @@ interface AlbumArtistDetailHeaderProps {
albumsQuery: UseSuspenseQueryResult<AlbumListResponse, Error>;
}
function ArtistImageUploadOverlay({ data }: { data?: AlbumArtistDetailResponse }) {
const uploadArtistImageMutation = useUploadArtistImage({});
function ArtistImageUploadOverlay({
data,
onUploadFile,
}: {
data?: AlbumArtistDetailResponse;
onUploadFile: (file: File) => Promise<void>;
}) {
const deleteArtistImageMutation = useDeleteArtistImage({});
const server = useCurrentServer();
@@ -54,16 +59,8 @@ function ArtistImageUploadOverlay({ data }: { data?: AlbumArtistDetailResponse }
<FileButton
accept="image/*"
onChange={async (file) => {
if (!file || !data?._serverId) return;
const buffer = await file.arrayBuffer();
uploadArtistImageMutation.mutate({
apiClientProps: {
serverId: data._serverId,
},
body: { image: new Uint8Array(buffer) },
query: { id: data.id },
});
if (!file) return;
await onUploadFile(file);
}}
>
{(props) => (
@@ -146,6 +143,7 @@ export const AlbumArtistDetailHeader = forwardRef<HTMLDivElement, AlbumArtistDet
const playButtonBehavior = usePlayButtonBehavior();
const setFavorite = useSetFavorite();
const setRating = useSetRating();
const uploadArtistImageMutation = useUploadArtistImage({});
const albumArtistDetailSort = useAppStore((state) => state.albumArtistDetailSort);
const sortBy = albumArtistDetailSort.sortBy;
@@ -244,10 +242,35 @@ export const AlbumArtistDetailHeader = forwardRef<HTMLDivElement, AlbumArtistDet
const showRating = showRatings && detailQuery?.data?._serverType === ServerType.NAVIDROME;
const canUploadArtistImage =
hasFeature(server, ServerFeature.ARTIST_IMAGE_UPLOAD) &&
Boolean(detailQuery.data?._serverId);
const handleArtistImageUpload = useCallback(
async (file: File) => {
const artist = detailQuery.data;
if (!artist?._serverId) return;
const buffer = await file.arrayBuffer();
uploadArtistImageMutation.mutate({
apiClientProps: {
serverId: artist._serverId,
},
body: { image: new Uint8Array(buffer) },
query: { id: artist.id },
});
},
[detailQuery.data, uploadArtistImageMutation],
);
return (
<LibraryHeader
compact
imageOverlay={<ArtistImageUploadOverlay data={detailQuery.data} />}
imageOverlay={
<ArtistImageUploadOverlay
data={detailQuery.data}
onUploadFile={handleArtistImageUpload}
/>
}
imageUrl={headerImageUrl}
item={{
imageId: detailQuery.data?.imageId,
@@ -255,6 +278,7 @@ export const AlbumArtistDetailHeader = forwardRef<HTMLDivElement, AlbumArtistDet
route: AppRoute.LIBRARY_ALBUM_ARTISTS,
type: LibraryItem.ALBUM_ARTIST,
}}
onImageFileDrop={canUploadArtistImage ? handleArtistImageUpload : undefined}
ref={ref}
title={detailQuery.data?.name || ''}
>
@@ -1,4 +1,5 @@
import { useQuery } from '@tanstack/react-query';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation, useParams } from 'react-router';
@@ -40,8 +41,13 @@ interface PlaylistDetailSongListHeaderProps {
onToggleQueryBuilder?: () => void;
}
function ImageUploadOverlay({ data }: { data?: Playlist }) {
const uploadPlaylistImageMutation = useUploadPlaylistImage({});
function ImageUploadOverlay({
data,
onUploadFile,
}: {
data?: Playlist;
onUploadFile: (file: File) => Promise<void>;
}) {
const deletePlaylistImageMutation = useDeletePlaylistImage({});
const server = useCurrentServer();
@@ -53,16 +59,8 @@ function ImageUploadOverlay({ data }: { data?: Playlist }) {
<FileButton
accept="image/*"
onChange={async (file) => {
if (!file || !data?._serverId) return;
const buffer = await file.arrayBuffer();
uploadPlaylistImageMutation.mutate({
apiClientProps: {
serverId: data._serverId,
},
body: { image: new Uint8Array(buffer) },
query: { id: data.id },
});
if (!file) return;
await onUploadFile(file);
}}
>
{(props) => (
@@ -121,11 +119,32 @@ export const PlaylistDetailSongListHeader = ({
});
const player = usePlayer();
const uploadPlaylistImageMutation = useUploadPlaylistImage({});
const handlePlay = (type?: Play) => {
player.addToQueueByData(listData as Song[], type || Play.NOW);
};
const canUploadPlaylistImage =
hasFeature(server, ServerFeature.PLAYLIST_IMAGE_UPLOAD) && Boolean(detailQuery?.data?._serverId);
const handlePlaylistImageUpload = useCallback(
async (file: File) => {
const playlist = detailQuery?.data;
if (!playlist?._serverId) return;
const buffer = await file.arrayBuffer();
uploadPlaylistImageMutation.mutate({
apiClientProps: {
serverId: playlist._serverId,
},
body: { image: new Uint8Array(buffer) },
query: { id: playlist.id },
});
},
[detailQuery?.data, uploadPlaylistImageMutation],
);
const imageUrl = useItemImageUrl({
id: detailQuery?.data?.imageId || undefined,
itemType: LibraryItem.PLAYLIST,
@@ -163,7 +182,12 @@ export const PlaylistDetailSongListHeader = ({
) : (
<LibraryHeader
compact
imageOverlay={<ImageUploadOverlay data={detailQuery?.data} />}
imageOverlay={
<ImageUploadOverlay
data={detailQuery?.data}
onUploadFile={handlePlaylistImageUpload}
/>
}
imageUrl={imageUrl}
item={{
imageId: detailQuery?.data?.imageId,
@@ -171,6 +195,7 @@ export const PlaylistDetailSongListHeader = ({
route: AppRoute.PLAYLISTS,
type: LibraryItem.PLAYLIST,
}}
onImageFileDrop={canUploadPlaylistImage ? handlePlaylistImageUpload : undefined}
title={detailQuery?.data?.name || ''}
topRight={<ListSearchInput />}
>
@@ -13,6 +13,7 @@ import { useCurrentServer, useCurrentServerId, usePermissions } from '/@/rendere
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';
@@ -270,16 +271,20 @@ function PlaylistCoverField({
const iconControls = (
<>
<FileButton accept="image/*" onChange={onFileSelect}>
{(props) => (
{(props) => {
const { ...triggerRest } = props;
return (
<ActionIcon
icon="uploadImage"
iconProps={{ size: 'lg' }}
radius="xl"
size="sm"
variant="default"
{...props}
{...triggerRest}
style={{ pointerEvents: 'auto' }}
/>
)}
);
}}
</FileButton>
<ActionIcon
disabled={secondaryDisabled}
@@ -288,22 +293,12 @@ function PlaylistCoverField({
onClick={secondaryAction}
radius="xl"
size="sm"
style={{ pointerEvents: 'auto' }}
variant="default"
/>
</>
);
const coverArt = (
<ItemImage
enableViewport={false}
id={previewId}
itemType={LibraryItem.PLAYLIST}
serverId={server?.id}
src={previewSrc}
type="header"
/>
);
return (
<Box
style={{
@@ -315,21 +310,41 @@ function PlaylistCoverField({
width: COVER_SIZE,
}}
>
{coverArt}
<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)',
borderRadius: 'var(--mantine-radius-md)',
bottom: 6,
padding: 4,
pointerEvents: 'none',
position: 'absolute',
right: 6,
zIndex: 2,
}}
wrap="nowrap"
>
{iconControls}
</Group>
</DragDropZone>
</Box>
);
}
@@ -12,6 +12,7 @@ import { logMsg } from '/@/renderer/utils/logger-message';
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';
@@ -241,16 +242,20 @@ function RadioStationCoverField({
const iconControls = (
<>
<FileButton accept="image/*" onChange={onFileSelect}>
{(props) => (
{(props) => {
const { ...triggerRest } = props;
return (
<ActionIcon
icon="uploadImage"
iconProps={{ size: 'lg' }}
radius="xl"
size="sm"
variant="default"
{...props}
{...triggerRest}
style={{ pointerEvents: 'auto' }}
/>
)}
);
}}
</FileButton>
<ActionIcon
disabled={secondaryDisabled}
@@ -259,22 +264,12 @@ function RadioStationCoverField({
onClick={secondaryAction}
radius="xl"
size="sm"
style={{ pointerEvents: 'auto' }}
variant="default"
/>
</>
);
const coverArt = (
<ItemImage
enableViewport={false}
id={previewId}
itemType={LibraryItem.RADIO_STATION}
serverId={server?.id}
src={previewSrc}
type="header"
/>
);
return (
<Box
style={{
@@ -286,21 +281,41 @@ function RadioStationCoverField({
width: COVER_SIZE,
}}
>
{coverArt}
<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.RADIO_STATION}
serverId={server?.id}
src={previewSrc}
type="header"
/>
<Group
gap={4}
style={{
background: 'rgba(0, 0, 0, 0.55)',
borderRadius: 'var(--mantine-radius-md)',
bottom: 6,
padding: 4,
pointerEvents: 'none',
position: 'absolute',
right: 6,
zIndex: 2,
}}
wrap="nowrap"
>
{iconControls}
</Group>
</DragDropZone>
</Box>
);
}
@@ -1,3 +1,5 @@
import type { KeyboardEvent } from 'react';
import { closeAllModals, openModal } from '@mantine/modals';
import clsx from 'clsx';
import { forwardRef, ReactNode, Ref, useCallback } from 'react';
@@ -22,6 +24,7 @@ import { useGeneralSettings } from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Button } from '/@/shared/components/button/button';
import { Center } from '/@/shared/components/center/center';
import { DragDropZone } from '/@/shared/components/drag-drop-zone/drag-drop-zone';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
import { BaseImage } from '/@/shared/components/image/image';
@@ -47,6 +50,7 @@ interface LibraryHeaderProps {
type?: LibraryItem;
};
loading?: boolean;
onImageFileDrop?: (file: File) => Promise<void> | void;
title: string;
topRight?: ReactNode;
}
@@ -60,6 +64,7 @@ export const LibraryHeader = forwardRef(
imageOverlay,
imageUrl,
item,
onImageFileDrop,
title,
topRight,
}: LibraryHeaderProps,
@@ -136,6 +141,17 @@ export const LibraryHeader = forwardRef(
});
}, [blurExplicitImages, item.explicitStatus, item.imageId, item.type]);
const imageSectionSharedProps = {
onClick: () => {
openImage();
},
onKeyDown: (event: KeyboardEvent) =>
[' ', 'Enter', 'Spacebar'].includes(event.key) && openImage(),
role: 'button' as const,
style: { cursor: 'pointer' as const },
tabIndex: 0,
};
return (
<div
className={clsx(
@@ -146,17 +162,13 @@ export const LibraryHeader = forwardRef(
ref={ref}
>
{topRight && <div className={styles.topRight}>{topRight}</div>}
<div
{onImageFileDrop ? (
<DragDropZone
accept="image/*"
className={styles.imageSection}
onClick={() => {
openImage();
}}
onKeyDown={(event) =>
[' ', 'Enter', 'Spacebar'].includes(event.key) && openImage()
}
role="button"
style={{ cursor: 'pointer' }}
tabIndex={0}
mode="file"
onFileSelected={(file) => void onImageFileDrop(file)}
{...imageSectionSharedProps}
>
<ItemImage
className={styles.image}
@@ -180,7 +192,33 @@ export const LibraryHeader = forwardRef(
{imageOverlay}
</div>
)}
</DragDropZone>
) : (
<div className={styles.imageSection} {...imageSectionSharedProps}>
<ItemImage
className={styles.image}
containerClassName={styles.image}
enableDebounce={false}
enableViewport={false}
explicitStatus={item.explicitStatus ?? null}
fetchPriority="high"
id={item.imageId}
itemType={item.type as LibraryItem}
src={imageUrl || ''}
type="header"
/>
{imageOverlay && (
<div
className={styles.imageOverlay}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
role="presentation"
>
{imageOverlay}
</div>
)}
</div>
)}
{title && (
<div className={styles.metadataSection}>
{item.children ? (
@@ -0,0 +1,18 @@
/*
* Inset outline on the root is hidden behind a full-bleed ItemImage; a ::after layer paints
* above the image. Keep z-index below overlay controls (e.g. z-index: 2).
* Avoid positive outline-offset so ancestors with overflow:hidden do not clip the indicator.
*/
.file-target-drag-over {
position: relative;
}
.file-target-drag-over::after {
position: absolute;
inset: calc(var(--theme-spacing-sm) * -1);
z-index: 1;
pointer-events: none;
content: '';
border-radius: var(--theme-radius-md);
box-shadow: inset 0 0 0 3px var(--theme-colors-primary);
}
@@ -1,17 +1,38 @@
import type { ChangeEvent, DragEvent, HTMLAttributes, ReactNode } from 'react';
import clsx from 'clsx';
import { t } from 'i18next';
import { useCallback, useRef, useState } from 'react';
import styles from './drag-drop-zone.module.css';
import { Flex } from '/@/shared/components/flex/flex';
import { AppIcon, Icon } from '/@/shared/components/icon/icon';
import { Text } from '/@/shared/components/text/text';
import { isNativeFileDrag, pickFirstImageFile } from '/@/shared/utils/image-drop';
interface DragDropZoneProps {
export interface DragDropZoneFileProps extends DivProps {
accept?: string;
children: ReactNode;
mode: 'file';
onFileSelected: (file: File) => Promise<void> | void;
}
export type DragDropZoneProps = DragDropZoneFileProps | DragDropZoneTextProps;
type DivProps = Omit<
HTMLAttributes<HTMLDivElement>,
'children' | 'onDragEnter' | 'onDragLeave' | 'onDragOver' | 'onDrop'
>;
interface DragDropZoneTextProps {
icon: keyof typeof AppIcon;
mode?: 'text';
onItemSelected: (contents: string) => void;
validateItem?: (contents: string) => { error?: string; isValid: boolean };
}
export const DragDropZone = ({ icon, onItemSelected, validateItem }: DragDropZoneProps) => {
const DragDropZoneText = ({ icon, onItemSelected, validateItem }: DragDropZoneTextProps) => {
const zoneFileInput = useRef<HTMLInputElement | null>(null);
const [error, setError] = useState<string>('');
@@ -32,7 +53,7 @@ export const DragDropZone = ({ icon, onItemSelected, validateItem }: DragDropZon
);
const onItemDropped = useCallback(
(event: React.DragEvent<HTMLDivElement>) => {
(event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
const items = event.dataTransfer.items;
@@ -62,7 +83,7 @@ export const DragDropZone = ({ icon, onItemSelected, validateItem }: DragDropZon
[processItem],
);
const onDragOver = useCallback((event: React.DragEvent<HTMLDivElement>) => {
const onDragOver = useCallback((event: DragEvent<HTMLDivElement>) => {
event.stopPropagation();
event.preventDefault();
}, []);
@@ -72,7 +93,7 @@ export const DragDropZone = ({ icon, onItemSelected, validateItem }: DragDropZon
}, []);
const onZoneInputChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
(event: ChangeEvent<HTMLInputElement>) => {
const { files } = event.target;
if (!files || files.length > 1) {
@@ -131,3 +152,83 @@ export const DragDropZone = ({ icon, onItemSelected, validateItem }: DragDropZon
</Flex>
);
};
const DragDropZoneFile = (props: DragDropZoneFileProps) => {
const { accept = 'image/*', children, className, mode, onFileSelected, ...divProps } = props;
void mode;
const fileDragDepth = useRef(0);
const [fileDragOver, setFileDragOver] = useState(false);
const resolveFile = useCallback(
(dataTransfer: DataTransfer): File | null => {
if (accept === 'image/*') {
return pickFirstImageFile(dataTransfer.files);
}
const first = dataTransfer.files?.item(0);
return first ?? null;
},
[accept],
);
const handleDragEnter = useCallback((e: DragEvent<HTMLDivElement>) => {
if (!isNativeFileDrag(e)) return;
e.preventDefault();
e.stopPropagation();
fileDragDepth.current += 1;
setFileDragOver(true);
}, []);
const handleDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => {
if (!isNativeFileDrag(e)) return;
e.preventDefault();
e.stopPropagation();
fileDragDepth.current -= 1;
if (fileDragDepth.current <= 0) {
fileDragDepth.current = 0;
setFileDragOver(false);
}
}, []);
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
if (!isNativeFileDrag(e)) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'copy';
}, []);
const handleDrop = useCallback(
(e: DragEvent<HTMLDivElement>) => {
if (!isNativeFileDrag(e)) return;
e.preventDefault();
e.stopPropagation();
fileDragDepth.current = 0;
setFileDragOver(false);
const file = resolveFile(e.dataTransfer);
if (file) void onFileSelected(file);
},
[onFileSelected, resolveFile],
);
return (
<div
{...divProps}
className={clsx(className, {
[styles.fileTargetDragOver]: fileDragOver,
})}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{children}
</div>
);
};
export const DragDropZone = (props: DragDropZoneProps) => {
if (props.mode === 'file') {
return <DragDropZoneFile {...props} />;
}
return <DragDropZoneText {...props} />;
};
+16
View File
@@ -0,0 +1,16 @@
import type { DragEvent } from 'react';
// OS / native file drag (vs in-app library drag).
export function isNativeFileDrag(event: DragEvent): boolean {
return event.dataTransfer.types.includes('Files');
}
// First file in the list whose MIME type is an image.
export function pickFirstImageFile(files: FileList | null): File | null {
if (!files?.length) return null;
for (let i = 0; i < files.length; i++) {
const f = files.item(i);
if (f?.type.startsWith('image/')) return f;
}
return null;
}