diff --git a/src/renderer/features/artists/components/album-artist-detail-header.tsx b/src/renderer/features/artists/components/album-artist-detail-header.tsx index 63f690c5c..924a20a92 100644 --- a/src/renderer/features/artists/components/album-artist-detail-header.tsx +++ b/src/renderer/features/artists/components/album-artist-detail-header.tsx @@ -41,8 +41,13 @@ interface AlbumArtistDetailHeaderProps { albumsQuery: UseSuspenseQueryResult; } -function ArtistImageUploadOverlay({ data }: { data?: AlbumArtistDetailResponse }) { - const uploadArtistImageMutation = useUploadArtistImage({}); +function ArtistImageUploadOverlay({ + data, + onUploadFile, +}: { + data?: AlbumArtistDetailResponse; + onUploadFile: (file: File) => Promise; +}) { const deleteArtistImageMutation = useDeleteArtistImage({}); const server = useCurrentServer(); @@ -54,16 +59,8 @@ function ArtistImageUploadOverlay({ data }: { data?: AlbumArtistDetailResponse } { - 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 state.albumArtistDetailSort); const sortBy = albumArtistDetailSort.sortBy; @@ -244,10 +242,35 @@ export const AlbumArtistDetailHeader = forwardRef { + 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 ( } + imageOverlay={ + + } imageUrl={headerImageUrl} item={{ imageId: detailQuery.data?.imageId, @@ -255,6 +278,7 @@ export const AlbumArtistDetailHeader = forwardRef diff --git a/src/renderer/features/playlists/components/playlist-detail-song-list-header.tsx b/src/renderer/features/playlists/components/playlist-detail-song-list-header.tsx index 61a7a1c2f..84f0117d9 100644 --- a/src/renderer/features/playlists/components/playlist-detail-song-list-header.tsx +++ b/src/renderer/features/playlists/components/playlist-detail-song-list-header.tsx @@ -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; +}) { const deletePlaylistImageMutation = useDeletePlaylistImage({}); const server = useCurrentServer(); @@ -53,16 +59,8 @@ function ImageUploadOverlay({ data }: { data?: Playlist }) { { - 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 = ({ ) : ( } + imageOverlay={ + + } 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={} > diff --git a/src/renderer/features/playlists/components/update-playlist-form.tsx b/src/renderer/features/playlists/components/update-playlist-form.tsx index e412366f4..0f559bbe4 100644 --- a/src/renderer/features/playlists/components/update-playlist-form.tsx +++ b/src/renderer/features/playlists/components/update-playlist-form.tsx @@ -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 = ( <> - {(props) => ( - - )} + {(props) => { + const { ...triggerRest } = props; + return ( + + ); + }} ); - const coverArt = ( - - ); - return ( - {coverArt} - onFileSelect(file)} style={{ - background: 'rgba(0, 0, 0, 0.55)', - borderRadius: 'var(--mantine-radius-md)', - bottom: 6, - padding: 4, - position: 'absolute', - right: 6, + height: '100%', + overflow: 'hidden', + position: 'relative', + width: '100%', }} - wrap="nowrap" > - {iconControls} - + + + {iconControls} + + ); } diff --git a/src/renderer/features/radio/components/edit-radio-station-form.tsx b/src/renderer/features/radio/components/edit-radio-station-form.tsx index 4befa817b..6595a5d78 100644 --- a/src/renderer/features/radio/components/edit-radio-station-form.tsx +++ b/src/renderer/features/radio/components/edit-radio-station-form.tsx @@ -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 = ( <> - {(props) => ( - - )} + {(props) => { + const { ...triggerRest } = props; + return ( + + ); + }} ); - const coverArt = ( - - ); - return ( - {coverArt} - onFileSelect(file)} style={{ - background: 'rgba(0, 0, 0, 0.55)', - borderRadius: 'var(--mantine-radius-md)', - bottom: 6, - padding: 4, - position: 'absolute', - right: 6, + height: '100%', + overflow: 'hidden', + position: 'relative', + width: '100%', }} - wrap="nowrap" > - {iconControls} - + + + {iconControls} + + ); } diff --git a/src/renderer/features/shared/components/library-header.tsx b/src/renderer/features/shared/components/library-header.tsx index 4b3bcd476..5b3962218 100644 --- a/src/renderer/features/shared/components/library-header.tsx +++ b/src/renderer/features/shared/components/library-header.tsx @@ -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; 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 (
{topRight &&
{topRight}
} -
{ - openImage(); - }} - onKeyDown={(event) => - [' ', 'Enter', 'Spacebar'].includes(event.key) && openImage() - } - role="button" - style={{ cursor: 'pointer' }} - tabIndex={0} - > - - {imageOverlay && ( -
e.stopPropagation()} - onKeyDown={(e) => e.stopPropagation()} - role="presentation" - > - {imageOverlay} -
- )} -
+ {onImageFileDrop ? ( + void onImageFileDrop(file)} + {...imageSectionSharedProps} + > + + {imageOverlay && ( +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + role="presentation" + > + {imageOverlay} +
+ )} +
+ ) : ( +
+ + {imageOverlay && ( +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + role="presentation" + > + {imageOverlay} +
+ )} +
+ )} {title && (
{item.children ? ( diff --git a/src/shared/components/drag-drop-zone/drag-drop-zone.module.css b/src/shared/components/drag-drop-zone/drag-drop-zone.module.css new file mode 100644 index 000000000..7efbbd631 --- /dev/null +++ b/src/shared/components/drag-drop-zone/drag-drop-zone.module.css @@ -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); +} diff --git a/src/shared/components/drag-drop-zone/drag-drop-zone.tsx b/src/shared/components/drag-drop-zone/drag-drop-zone.tsx index 629d3c31a..2d14478db 100644 --- a/src/shared/components/drag-drop-zone/drag-drop-zone.tsx +++ b/src/shared/components/drag-drop-zone/drag-drop-zone.tsx @@ -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; +} + +export type DragDropZoneProps = DragDropZoneFileProps | DragDropZoneTextProps; + +type DivProps = Omit< + HTMLAttributes, + '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(null); const [error, setError] = useState(''); @@ -32,7 +53,7 @@ export const DragDropZone = ({ icon, onItemSelected, validateItem }: DragDropZon ); const onItemDropped = useCallback( - (event: React.DragEvent) => { + (event: DragEvent) => { 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) => { + const onDragOver = useCallback((event: DragEvent) => { event.stopPropagation(); event.preventDefault(); }, []); @@ -72,7 +93,7 @@ export const DragDropZone = ({ icon, onItemSelected, validateItem }: DragDropZon }, []); const onZoneInputChange = useCallback( - (event: React.ChangeEvent) => { + (event: ChangeEvent) => { const { files } = event.target; if (!files || files.length > 1) { @@ -131,3 +152,83 @@ export const DragDropZone = ({ icon, onItemSelected, validateItem }: DragDropZon ); }; + +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) => { + if (!isNativeFileDrag(e)) return; + e.preventDefault(); + e.stopPropagation(); + fileDragDepth.current += 1; + setFileDragOver(true); + }, []); + + const handleDragLeave = useCallback((e: DragEvent) => { + 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) => { + if (!isNativeFileDrag(e)) return; + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = 'copy'; + }, []); + + const handleDrop = useCallback( + (e: DragEvent) => { + 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 ( +
+ {children} +
+ ); +}; + +export const DragDropZone = (props: DragDropZoneProps) => { + if (props.mode === 'file') { + return ; + } + + return ; +}; diff --git a/src/shared/utils/image-drop.ts b/src/shared/utils/image-drop.ts new file mode 100644 index 000000000..ac73de5a0 --- /dev/null +++ b/src/shared/utils/image-drop.ts @@ -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; +}