mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-06 20:10:12 +02:00
support image drop for upload
This commit is contained in:
@@ -41,8 +41,13 @@ interface AlbumArtistDetailHeaderProps {
|
|||||||
albumsQuery: UseSuspenseQueryResult<AlbumListResponse, Error>;
|
albumsQuery: UseSuspenseQueryResult<AlbumListResponse, Error>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ArtistImageUploadOverlay({ data }: { data?: AlbumArtistDetailResponse }) {
|
function ArtistImageUploadOverlay({
|
||||||
const uploadArtistImageMutation = useUploadArtistImage({});
|
data,
|
||||||
|
onUploadFile,
|
||||||
|
}: {
|
||||||
|
data?: AlbumArtistDetailResponse;
|
||||||
|
onUploadFile: (file: File) => Promise<void>;
|
||||||
|
}) {
|
||||||
const deleteArtistImageMutation = useDeleteArtistImage({});
|
const deleteArtistImageMutation = useDeleteArtistImage({});
|
||||||
const server = useCurrentServer();
|
const server = useCurrentServer();
|
||||||
|
|
||||||
@@ -54,16 +59,8 @@ function ArtistImageUploadOverlay({ data }: { data?: AlbumArtistDetailResponse }
|
|||||||
<FileButton
|
<FileButton
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
onChange={async (file) => {
|
onChange={async (file) => {
|
||||||
if (!file || !data?._serverId) return;
|
if (!file) return;
|
||||||
|
await onUploadFile(file);
|
||||||
const buffer = await file.arrayBuffer();
|
|
||||||
uploadArtistImageMutation.mutate({
|
|
||||||
apiClientProps: {
|
|
||||||
serverId: data._serverId,
|
|
||||||
},
|
|
||||||
body: { image: new Uint8Array(buffer) },
|
|
||||||
query: { id: data.id },
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(props) => (
|
{(props) => (
|
||||||
@@ -146,6 +143,7 @@ export const AlbumArtistDetailHeader = forwardRef<HTMLDivElement, AlbumArtistDet
|
|||||||
const playButtonBehavior = usePlayButtonBehavior();
|
const playButtonBehavior = usePlayButtonBehavior();
|
||||||
const setFavorite = useSetFavorite();
|
const setFavorite = useSetFavorite();
|
||||||
const setRating = useSetRating();
|
const setRating = useSetRating();
|
||||||
|
const uploadArtistImageMutation = useUploadArtistImage({});
|
||||||
|
|
||||||
const albumArtistDetailSort = useAppStore((state) => state.albumArtistDetailSort);
|
const albumArtistDetailSort = useAppStore((state) => state.albumArtistDetailSort);
|
||||||
const sortBy = albumArtistDetailSort.sortBy;
|
const sortBy = albumArtistDetailSort.sortBy;
|
||||||
@@ -244,10 +242,35 @@ export const AlbumArtistDetailHeader = forwardRef<HTMLDivElement, AlbumArtistDet
|
|||||||
|
|
||||||
const showRating = showRatings && detailQuery?.data?._serverType === ServerType.NAVIDROME;
|
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 (
|
return (
|
||||||
<LibraryHeader
|
<LibraryHeader
|
||||||
compact
|
imageOverlay={
|
||||||
imageOverlay={<ArtistImageUploadOverlay data={detailQuery.data} />}
|
<ArtistImageUploadOverlay
|
||||||
|
data={detailQuery.data}
|
||||||
|
onUploadFile={handleArtistImageUpload}
|
||||||
|
/>
|
||||||
|
}
|
||||||
imageUrl={headerImageUrl}
|
imageUrl={headerImageUrl}
|
||||||
item={{
|
item={{
|
||||||
imageId: detailQuery.data?.imageId,
|
imageId: detailQuery.data?.imageId,
|
||||||
@@ -255,6 +278,7 @@ export const AlbumArtistDetailHeader = forwardRef<HTMLDivElement, AlbumArtistDet
|
|||||||
route: AppRoute.LIBRARY_ALBUM_ARTISTS,
|
route: AppRoute.LIBRARY_ALBUM_ARTISTS,
|
||||||
type: LibraryItem.ALBUM_ARTIST,
|
type: LibraryItem.ALBUM_ARTIST,
|
||||||
}}
|
}}
|
||||||
|
onImageFileDrop={canUploadArtistImage ? handleArtistImageUpload : undefined}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
title={detailQuery.data?.name || ''}
|
title={detailQuery.data?.name || ''}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useLocation, useParams } from 'react-router';
|
import { useLocation, useParams } from 'react-router';
|
||||||
|
|
||||||
@@ -40,8 +41,13 @@ interface PlaylistDetailSongListHeaderProps {
|
|||||||
onToggleQueryBuilder?: () => void;
|
onToggleQueryBuilder?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ImageUploadOverlay({ data }: { data?: Playlist }) {
|
function ImageUploadOverlay({
|
||||||
const uploadPlaylistImageMutation = useUploadPlaylistImage({});
|
data,
|
||||||
|
onUploadFile,
|
||||||
|
}: {
|
||||||
|
data?: Playlist;
|
||||||
|
onUploadFile: (file: File) => Promise<void>;
|
||||||
|
}) {
|
||||||
const deletePlaylistImageMutation = useDeletePlaylistImage({});
|
const deletePlaylistImageMutation = useDeletePlaylistImage({});
|
||||||
const server = useCurrentServer();
|
const server = useCurrentServer();
|
||||||
|
|
||||||
@@ -53,16 +59,8 @@ function ImageUploadOverlay({ data }: { data?: Playlist }) {
|
|||||||
<FileButton
|
<FileButton
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
onChange={async (file) => {
|
onChange={async (file) => {
|
||||||
if (!file || !data?._serverId) return;
|
if (!file) return;
|
||||||
|
await onUploadFile(file);
|
||||||
const buffer = await file.arrayBuffer();
|
|
||||||
uploadPlaylistImageMutation.mutate({
|
|
||||||
apiClientProps: {
|
|
||||||
serverId: data._serverId,
|
|
||||||
},
|
|
||||||
body: { image: new Uint8Array(buffer) },
|
|
||||||
query: { id: data.id },
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(props) => (
|
{(props) => (
|
||||||
@@ -121,11 +119,32 @@ export const PlaylistDetailSongListHeader = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const player = usePlayer();
|
const player = usePlayer();
|
||||||
|
const uploadPlaylistImageMutation = useUploadPlaylistImage({});
|
||||||
|
|
||||||
const handlePlay = (type?: Play) => {
|
const handlePlay = (type?: Play) => {
|
||||||
player.addToQueueByData(listData as Song[], type || Play.NOW);
|
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({
|
const imageUrl = useItemImageUrl({
|
||||||
id: detailQuery?.data?.imageId || undefined,
|
id: detailQuery?.data?.imageId || undefined,
|
||||||
itemType: LibraryItem.PLAYLIST,
|
itemType: LibraryItem.PLAYLIST,
|
||||||
@@ -163,7 +182,12 @@ export const PlaylistDetailSongListHeader = ({
|
|||||||
) : (
|
) : (
|
||||||
<LibraryHeader
|
<LibraryHeader
|
||||||
compact
|
compact
|
||||||
imageOverlay={<ImageUploadOverlay data={detailQuery?.data} />}
|
imageOverlay={
|
||||||
|
<ImageUploadOverlay
|
||||||
|
data={detailQuery?.data}
|
||||||
|
onUploadFile={handlePlaylistImageUpload}
|
||||||
|
/>
|
||||||
|
}
|
||||||
imageUrl={imageUrl}
|
imageUrl={imageUrl}
|
||||||
item={{
|
item={{
|
||||||
imageId: detailQuery?.data?.imageId,
|
imageId: detailQuery?.data?.imageId,
|
||||||
@@ -171,6 +195,7 @@ export const PlaylistDetailSongListHeader = ({
|
|||||||
route: AppRoute.PLAYLISTS,
|
route: AppRoute.PLAYLISTS,
|
||||||
type: LibraryItem.PLAYLIST,
|
type: LibraryItem.PLAYLIST,
|
||||||
}}
|
}}
|
||||||
|
onImageFileDrop={canUploadPlaylistImage ? handlePlaylistImageUpload : undefined}
|
||||||
title={detailQuery?.data?.name || ''}
|
title={detailQuery?.data?.name || ''}
|
||||||
topRight={<ListSearchInput />}
|
topRight={<ListSearchInput />}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { useCurrentServer, useCurrentServerId, usePermissions } from '/@/rendere
|
|||||||
import { hasFeature } from '/@/shared/api/utils';
|
import { hasFeature } from '/@/shared/api/utils';
|
||||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||||
import { Box } from '/@/shared/components/box/box';
|
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 { FileButton } from '/@/shared/components/file-button/file-button';
|
||||||
import { Flex } from '/@/shared/components/flex/flex';
|
import { Flex } from '/@/shared/components/flex/flex';
|
||||||
import { Group } from '/@/shared/components/group/group';
|
import { Group } from '/@/shared/components/group/group';
|
||||||
@@ -270,16 +271,20 @@ function PlaylistCoverField({
|
|||||||
const iconControls = (
|
const iconControls = (
|
||||||
<>
|
<>
|
||||||
<FileButton accept="image/*" onChange={onFileSelect}>
|
<FileButton accept="image/*" onChange={onFileSelect}>
|
||||||
{(props) => (
|
{(props) => {
|
||||||
|
const { ...triggerRest } = props;
|
||||||
|
return (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
icon="uploadImage"
|
icon="uploadImage"
|
||||||
iconProps={{ size: 'lg' }}
|
iconProps={{ size: 'lg' }}
|
||||||
radius="xl"
|
radius="xl"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="default"
|
variant="default"
|
||||||
{...props}
|
{...triggerRest}
|
||||||
|
style={{ pointerEvents: 'auto' }}
|
||||||
/>
|
/>
|
||||||
)}
|
);
|
||||||
|
}}
|
||||||
</FileButton>
|
</FileButton>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
disabled={secondaryDisabled}
|
disabled={secondaryDisabled}
|
||||||
@@ -288,22 +293,12 @@ function PlaylistCoverField({
|
|||||||
onClick={secondaryAction}
|
onClick={secondaryAction}
|
||||||
radius="xl"
|
radius="xl"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
style={{ pointerEvents: 'auto' }}
|
||||||
variant="default"
|
variant="default"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const coverArt = (
|
|
||||||
<ItemImage
|
|
||||||
enableViewport={false}
|
|
||||||
id={previewId}
|
|
||||||
itemType={LibraryItem.PLAYLIST}
|
|
||||||
serverId={server?.id}
|
|
||||||
src={previewSrc}
|
|
||||||
type="header"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
style={{
|
style={{
|
||||||
@@ -315,21 +310,41 @@ function PlaylistCoverField({
|
|||||||
width: COVER_SIZE,
|
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
|
<Group
|
||||||
gap={4}
|
gap={4}
|
||||||
style={{
|
style={{
|
||||||
background: 'rgba(0, 0, 0, 0.55)',
|
background: 'rgba(0, 0, 0, 0.55)',
|
||||||
borderRadius: 'var(--mantine-radius-md)',
|
|
||||||
bottom: 6,
|
bottom: 6,
|
||||||
padding: 4,
|
padding: 4,
|
||||||
|
pointerEvents: 'none',
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
right: 6,
|
right: 6,
|
||||||
|
zIndex: 2,
|
||||||
}}
|
}}
|
||||||
wrap="nowrap"
|
wrap="nowrap"
|
||||||
>
|
>
|
||||||
{iconControls}
|
{iconControls}
|
||||||
</Group>
|
</Group>
|
||||||
|
</DragDropZone>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { logMsg } from '/@/renderer/utils/logger-message';
|
|||||||
import { hasFeature } from '/@/shared/api/utils';
|
import { hasFeature } from '/@/shared/api/utils';
|
||||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||||
import { Box } from '/@/shared/components/box/box';
|
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 { FileButton } from '/@/shared/components/file-button/file-button';
|
||||||
import { Flex } from '/@/shared/components/flex/flex';
|
import { Flex } from '/@/shared/components/flex/flex';
|
||||||
import { Group } from '/@/shared/components/group/group';
|
import { Group } from '/@/shared/components/group/group';
|
||||||
@@ -241,16 +242,20 @@ function RadioStationCoverField({
|
|||||||
const iconControls = (
|
const iconControls = (
|
||||||
<>
|
<>
|
||||||
<FileButton accept="image/*" onChange={onFileSelect}>
|
<FileButton accept="image/*" onChange={onFileSelect}>
|
||||||
{(props) => (
|
{(props) => {
|
||||||
|
const { ...triggerRest } = props;
|
||||||
|
return (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
icon="uploadImage"
|
icon="uploadImage"
|
||||||
iconProps={{ size: 'lg' }}
|
iconProps={{ size: 'lg' }}
|
||||||
radius="xl"
|
radius="xl"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="default"
|
variant="default"
|
||||||
{...props}
|
{...triggerRest}
|
||||||
|
style={{ pointerEvents: 'auto' }}
|
||||||
/>
|
/>
|
||||||
)}
|
);
|
||||||
|
}}
|
||||||
</FileButton>
|
</FileButton>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
disabled={secondaryDisabled}
|
disabled={secondaryDisabled}
|
||||||
@@ -259,22 +264,12 @@ function RadioStationCoverField({
|
|||||||
onClick={secondaryAction}
|
onClick={secondaryAction}
|
||||||
radius="xl"
|
radius="xl"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
style={{ pointerEvents: 'auto' }}
|
||||||
variant="default"
|
variant="default"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const coverArt = (
|
|
||||||
<ItemImage
|
|
||||||
enableViewport={false}
|
|
||||||
id={previewId}
|
|
||||||
itemType={LibraryItem.RADIO_STATION}
|
|
||||||
serverId={server?.id}
|
|
||||||
src={previewSrc}
|
|
||||||
type="header"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
style={{
|
style={{
|
||||||
@@ -286,21 +281,41 @@ function RadioStationCoverField({
|
|||||||
width: COVER_SIZE,
|
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
|
<Group
|
||||||
gap={4}
|
gap={4}
|
||||||
style={{
|
style={{
|
||||||
background: 'rgba(0, 0, 0, 0.55)',
|
background: 'rgba(0, 0, 0, 0.55)',
|
||||||
borderRadius: 'var(--mantine-radius-md)',
|
|
||||||
bottom: 6,
|
bottom: 6,
|
||||||
padding: 4,
|
padding: 4,
|
||||||
|
pointerEvents: 'none',
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
right: 6,
|
right: 6,
|
||||||
|
zIndex: 2,
|
||||||
}}
|
}}
|
||||||
wrap="nowrap"
|
wrap="nowrap"
|
||||||
>
|
>
|
||||||
{iconControls}
|
{iconControls}
|
||||||
</Group>
|
</Group>
|
||||||
|
</DragDropZone>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { KeyboardEvent } from 'react';
|
||||||
|
|
||||||
import { closeAllModals, openModal } from '@mantine/modals';
|
import { closeAllModals, openModal } from '@mantine/modals';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { forwardRef, ReactNode, Ref, useCallback } from 'react';
|
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 { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||||
import { Button } from '/@/shared/components/button/button';
|
import { Button } from '/@/shared/components/button/button';
|
||||||
import { Center } from '/@/shared/components/center/center';
|
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 { Group } from '/@/shared/components/group/group';
|
||||||
import { Icon } from '/@/shared/components/icon/icon';
|
import { Icon } from '/@/shared/components/icon/icon';
|
||||||
import { BaseImage } from '/@/shared/components/image/image';
|
import { BaseImage } from '/@/shared/components/image/image';
|
||||||
@@ -47,6 +50,7 @@ interface LibraryHeaderProps {
|
|||||||
type?: LibraryItem;
|
type?: LibraryItem;
|
||||||
};
|
};
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
onImageFileDrop?: (file: File) => Promise<void> | void;
|
||||||
title: string;
|
title: string;
|
||||||
topRight?: ReactNode;
|
topRight?: ReactNode;
|
||||||
}
|
}
|
||||||
@@ -60,6 +64,7 @@ export const LibraryHeader = forwardRef(
|
|||||||
imageOverlay,
|
imageOverlay,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
item,
|
item,
|
||||||
|
onImageFileDrop,
|
||||||
title,
|
title,
|
||||||
topRight,
|
topRight,
|
||||||
}: LibraryHeaderProps,
|
}: LibraryHeaderProps,
|
||||||
@@ -136,6 +141,17 @@ export const LibraryHeader = forwardRef(
|
|||||||
});
|
});
|
||||||
}, [blurExplicitImages, item.explicitStatus, item.imageId, item.type]);
|
}, [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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -146,17 +162,13 @@ export const LibraryHeader = forwardRef(
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
{topRight && <div className={styles.topRight}>{topRight}</div>}
|
{topRight && <div className={styles.topRight}>{topRight}</div>}
|
||||||
<div
|
{onImageFileDrop ? (
|
||||||
|
<DragDropZone
|
||||||
|
accept="image/*"
|
||||||
className={styles.imageSection}
|
className={styles.imageSection}
|
||||||
onClick={() => {
|
mode="file"
|
||||||
openImage();
|
onFileSelected={(file) => void onImageFileDrop(file)}
|
||||||
}}
|
{...imageSectionSharedProps}
|
||||||
onKeyDown={(event) =>
|
|
||||||
[' ', 'Enter', 'Spacebar'].includes(event.key) && openImage()
|
|
||||||
}
|
|
||||||
role="button"
|
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
tabIndex={0}
|
|
||||||
>
|
>
|
||||||
<ItemImage
|
<ItemImage
|
||||||
className={styles.image}
|
className={styles.image}
|
||||||
@@ -180,7 +192,33 @@ export const LibraryHeader = forwardRef(
|
|||||||
{imageOverlay}
|
{imageOverlay}
|
||||||
</div>
|
</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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{title && (
|
{title && (
|
||||||
<div className={styles.metadataSection}>
|
<div className={styles.metadataSection}>
|
||||||
{item.children ? (
|
{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 { t } from 'i18next';
|
||||||
import { useCallback, useRef, useState } from 'react';
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import styles from './drag-drop-zone.module.css';
|
||||||
|
|
||||||
import { Flex } from '/@/shared/components/flex/flex';
|
import { Flex } from '/@/shared/components/flex/flex';
|
||||||
import { AppIcon, Icon } from '/@/shared/components/icon/icon';
|
import { AppIcon, Icon } from '/@/shared/components/icon/icon';
|
||||||
import { Text } from '/@/shared/components/text/text';
|
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;
|
icon: keyof typeof AppIcon;
|
||||||
|
mode?: 'text';
|
||||||
onItemSelected: (contents: string) => void;
|
onItemSelected: (contents: string) => void;
|
||||||
validateItem?: (contents: string) => { error?: string; isValid: boolean };
|
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 zoneFileInput = useRef<HTMLInputElement | null>(null);
|
||||||
const [error, setError] = useState<string>('');
|
const [error, setError] = useState<string>('');
|
||||||
|
|
||||||
@@ -32,7 +53,7 @@ export const DragDropZone = ({ icon, onItemSelected, validateItem }: DragDropZon
|
|||||||
);
|
);
|
||||||
|
|
||||||
const onItemDropped = useCallback(
|
const onItemDropped = useCallback(
|
||||||
(event: React.DragEvent<HTMLDivElement>) => {
|
(event: DragEvent<HTMLDivElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const items = event.dataTransfer.items;
|
const items = event.dataTransfer.items;
|
||||||
@@ -62,7 +83,7 @@ export const DragDropZone = ({ icon, onItemSelected, validateItem }: DragDropZon
|
|||||||
[processItem],
|
[processItem],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onDragOver = useCallback((event: React.DragEvent<HTMLDivElement>) => {
|
const onDragOver = useCallback((event: DragEvent<HTMLDivElement>) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -72,7 +93,7 @@ export const DragDropZone = ({ icon, onItemSelected, validateItem }: DragDropZon
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onZoneInputChange = useCallback(
|
const onZoneInputChange = useCallback(
|
||||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
(event: ChangeEvent<HTMLInputElement>) => {
|
||||||
const { files } = event.target;
|
const { files } = event.target;
|
||||||
|
|
||||||
if (!files || files.length > 1) {
|
if (!files || files.length > 1) {
|
||||||
@@ -131,3 +152,83 @@ export const DragDropZone = ({ icon, onItemSelected, validateItem }: DragDropZon
|
|||||||
</Flex>
|
</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} />;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user