mirror of
https://github.com/antebudimir/feishin.git
synced 2025-12-31 10:03:33 +00:00
Refactor add to playlist modal (#1236)
* Refactor add to playlist modal * redesign base modal component, add ModalButton component * improve visibility of filled button focus --------- Co-authored-by: jeffvli <jeffvictorli@gmail.com>
This commit is contained in:
parent
829c27a5e9
commit
1a176fd118
18 changed files with 667 additions and 225 deletions
|
|
@ -92,6 +92,8 @@
|
||||||
"playerMustBePaused": "player must be paused",
|
"playerMustBePaused": "player must be paused",
|
||||||
"preview": "preview",
|
"preview": "preview",
|
||||||
"previousSong": "previous $t(entity.track_one)",
|
"previousSong": "previous $t(entity.track_one)",
|
||||||
|
"private": "private",
|
||||||
|
"public": "public",
|
||||||
"quit": "quit",
|
"quit": "quit",
|
||||||
"random": "random",
|
"random": "random",
|
||||||
"rating": "rating",
|
"rating": "rating",
|
||||||
|
|
@ -250,8 +252,10 @@
|
||||||
"title": "add server"
|
"title": "add server"
|
||||||
},
|
},
|
||||||
"addToPlaylist": {
|
"addToPlaylist": {
|
||||||
|
"create": "create $t(entity.playlist_one) {{playlist}}",
|
||||||
"input_playlists": "$t(entity.playlist_other)",
|
"input_playlists": "$t(entity.playlist_other)",
|
||||||
"input_skipDuplicates": "skip duplicates",
|
"input_skipDuplicates": "skip duplicates",
|
||||||
|
"searchOrCreate": "Search $t(entity.playlist_other) or type to create a new one",
|
||||||
"success": "added $t(entity.trackWithCount, {\"count\": {{message}} }) to $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
|
"success": "added $t(entity.trackWithCount, {\"count\": {{message}} }) to $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
|
||||||
"title": "add to $t(entity.playlist_one)"
|
"title": "add to $t(entity.playlist_one)"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -504,7 +504,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||||
songId: songId.length > 0 ? songId : undefined,
|
songId: songId.length > 0 ? songId : undefined,
|
||||||
},
|
},
|
||||||
modal: 'addToPlaylist',
|
modal: 'addToPlaylist',
|
||||||
size: 'md',
|
size: 'lg',
|
||||||
title: t('page.contextMenu.addToPlaylist', { postProcess: 'sentenceCase' }),
|
title: t('page.contextMenu.addToPlaylist', { postProcess: 'sentenceCase' }),
|
||||||
});
|
});
|
||||||
}, [ctx.data, ctx.dataNodes, t]);
|
}, [ctx.data, ctx.dataNodes, t]);
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,14 @@
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
|
&:active,
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
color: var(--theme-btn-default-fg-hover);
|
@mixin dark {
|
||||||
background: var(--theme-btn-default-bg-hover);
|
background-color: lighten(var(--theme-colors-background), 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin light {
|
||||||
|
background-color: darken(var(--theme-colors-background), 5%);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-col {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-container {
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-text {
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import { useForm } from '@mantine/form';
|
import { useForm } from '@mantine/form';
|
||||||
import { closeModal, ContextModalProps } from '@mantine/modals';
|
import { closeModal, ContextModalProps } from '@mantine/modals';
|
||||||
import { useMemo, useState } from 'react';
|
import { memo, useCallback, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import styles from './add-to-playlist-context-modal.module.css';
|
||||||
|
|
||||||
import { api } from '/@/renderer/api';
|
import { api } from '/@/renderer/api';
|
||||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||||
import { getGenreSongsById } from '/@/renderer/features/player';
|
import { getGenreSongsById } from '/@/renderer/features/player';
|
||||||
|
|
@ -10,13 +12,26 @@ import { useAddToPlaylist } from '/@/renderer/features/playlists/mutations/add-t
|
||||||
import { usePlaylistList } from '/@/renderer/features/playlists/queries/playlist-list-query';
|
import { usePlaylistList } from '/@/renderer/features/playlists/queries/playlist-list-query';
|
||||||
import { queryClient } from '/@/renderer/lib/react-query';
|
import { queryClient } from '/@/renderer/lib/react-query';
|
||||||
import { useCurrentServer } from '/@/renderer/store';
|
import { useCurrentServer } from '/@/renderer/store';
|
||||||
|
import { formatDurationString } from '/@/renderer/utils';
|
||||||
|
import { Box } from '/@/shared/components/box/box';
|
||||||
import { Button } from '/@/shared/components/button/button';
|
import { Button } from '/@/shared/components/button/button';
|
||||||
|
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
|
||||||
|
import { Flex } from '/@/shared/components/flex/flex';
|
||||||
|
import { Grid } from '/@/shared/components/grid/grid';
|
||||||
import { Group } from '/@/shared/components/group/group';
|
import { Group } from '/@/shared/components/group/group';
|
||||||
import { MultiSelect } from '/@/shared/components/multi-select/multi-select';
|
import { Icon } from '/@/shared/components/icon/icon';
|
||||||
|
import { Image } from '/@/shared/components/image/image';
|
||||||
|
import { ModalButton } from '/@/shared/components/modal/model-shared';
|
||||||
|
import { Pill } from '/@/shared/components/pill/pill';
|
||||||
|
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
|
||||||
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 { Table } from '/@/shared/components/table/table';
|
||||||
|
import { TextInput } from '/@/shared/components/text-input/text-input';
|
||||||
|
import { Text } from '/@/shared/components/text/text';
|
||||||
import { toast } from '/@/shared/components/toast/toast';
|
import { toast } from '/@/shared/components/toast/toast';
|
||||||
import {
|
import {
|
||||||
|
Playlist,
|
||||||
PlaylistListSort,
|
PlaylistListSort,
|
||||||
SongListQuery,
|
SongListQuery,
|
||||||
SongListSort,
|
SongListSort,
|
||||||
|
|
@ -36,7 +51,18 @@ export const AddToPlaylistContextModal = ({
|
||||||
const { albumId, artistId, genreId, songId } = innerProps;
|
const { albumId, artistId, genreId, songId } = innerProps;
|
||||||
const server = useCurrentServer();
|
const server = useCurrentServer();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isDropdownOpened, setIsDropdownOpened] = useState(true);
|
const [search, setSearch] = useState<string>('');
|
||||||
|
const [focusedRowIndex, setFocusedRowIndex] = useState<null | number>(null);
|
||||||
|
const rowRefs = useRef<(HTMLTableRowElement | null)[]>([]);
|
||||||
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
newPlaylists: [] as string[],
|
||||||
|
selectedPlaylistIds: [] as string[],
|
||||||
|
skipDuplicates: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const addToPlaylistMutation = useAddToPlaylist({});
|
const addToPlaylistMutation = useAddToPlaylist({});
|
||||||
|
|
||||||
|
|
@ -54,188 +80,425 @@ export const AddToPlaylistContextModal = ({
|
||||||
serverId: server?.id,
|
serverId: server?.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const playlistSelect = useMemo(() => {
|
const [playlistSelect, playlistMap] = useMemo(() => {
|
||||||
return (
|
const existingPlaylists = new Array<Playlist & { label: string; value: string }>();
|
||||||
playlistList.data?.items?.map((playlist) => ({
|
const playlistMap = new Map<string, string>();
|
||||||
label: playlist.name,
|
|
||||||
value: playlist.id,
|
for (const playlist of playlistList.data?.items ?? []) {
|
||||||
})) || []
|
existingPlaylists.push({ ...playlist, label: playlist.name, value: playlist.id });
|
||||||
);
|
playlistMap.set(playlist.id, playlist.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [existingPlaylists, playlistMap];
|
||||||
}, [playlistList.data]);
|
}, [playlistList.data]);
|
||||||
|
|
||||||
const form = useForm({
|
const filteredItems = useMemo(() => {
|
||||||
initialValues: {
|
if (search) {
|
||||||
playlistId: [],
|
return playlistSelect.filter((item) =>
|
||||||
skipDuplicates: true,
|
item.label.toLocaleLowerCase().includes(search.toLocaleLowerCase()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return playlistSelect;
|
||||||
|
}, [playlistSelect, search]);
|
||||||
|
|
||||||
|
const getSongsByAlbum = useCallback(
|
||||||
|
async (albumId: string) => {
|
||||||
|
const query: SongListQuery = {
|
||||||
|
albumIds: [albumId],
|
||||||
|
sortBy: SongListSort.ALBUM,
|
||||||
|
sortOrder: SortOrder.ASC,
|
||||||
|
startIndex: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const queryKey = queryKeys.songs.list(server?.id || '', query);
|
||||||
|
|
||||||
|
const songsRes = await queryClient.fetchQuery(queryKey, ({ signal }) => {
|
||||||
|
if (!server) throw new Error('No server');
|
||||||
|
return api.controller.getSongList({ apiClientProps: { server, signal }, query });
|
||||||
|
});
|
||||||
|
|
||||||
|
return songsRes;
|
||||||
},
|
},
|
||||||
});
|
[server],
|
||||||
|
);
|
||||||
|
|
||||||
const getSongsByAlbum = async (albumId: string) => {
|
const getSongsByArtist = useCallback(
|
||||||
const query: SongListQuery = {
|
async (artistId: string) => {
|
||||||
albumIds: [albumId],
|
const query: SongListQuery = {
|
||||||
sortBy: SongListSort.ALBUM,
|
artistIds: [artistId],
|
||||||
sortOrder: SortOrder.ASC,
|
sortBy: SongListSort.ARTIST,
|
||||||
startIndex: 0,
|
sortOrder: SortOrder.ASC,
|
||||||
};
|
startIndex: 0,
|
||||||
|
};
|
||||||
|
|
||||||
const queryKey = queryKeys.songs.list(server?.id || '', query);
|
const queryKey = queryKeys.songs.list(server?.id || '', query);
|
||||||
|
|
||||||
const songsRes = await queryClient.fetchQuery(queryKey, ({ signal }) => {
|
const songsRes = await queryClient.fetchQuery(queryKey, ({ signal }) => {
|
||||||
if (!server) throw new Error('No server');
|
if (!server) throw new Error('No server');
|
||||||
return api.controller.getSongList({ apiClientProps: { server, signal }, query });
|
return api.controller.getSongList({ apiClientProps: { server, signal }, query });
|
||||||
});
|
});
|
||||||
|
|
||||||
return songsRes;
|
return songsRes;
|
||||||
};
|
},
|
||||||
|
[server],
|
||||||
const getSongsByArtist = async (artistId: string) => {
|
);
|
||||||
const query: SongListQuery = {
|
|
||||||
artistIds: [artistId],
|
|
||||||
sortBy: SongListSort.ARTIST,
|
|
||||||
sortOrder: SortOrder.ASC,
|
|
||||||
startIndex: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const queryKey = queryKeys.songs.list(server?.id || '', query);
|
|
||||||
|
|
||||||
const songsRes = await queryClient.fetchQuery(queryKey, ({ signal }) => {
|
|
||||||
if (!server) throw new Error('No server');
|
|
||||||
return api.controller.getSongList({ apiClientProps: { server, signal }, query });
|
|
||||||
});
|
|
||||||
|
|
||||||
return songsRes;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isSubmitDisabled = form.values.playlistId.length === 0 || addToPlaylistMutation.isLoading;
|
|
||||||
|
|
||||||
const handleSubmit = form.onSubmit(async (values) => {
|
const handleSubmit = form.onSubmit(async (values) => {
|
||||||
|
if (isLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const allSongIds: string[] = [];
|
const allSongIds: string[] = [];
|
||||||
let totalUniquesAdded = 0;
|
let totalUniquesAdded = 0;
|
||||||
|
|
||||||
if (albumId && albumId.length > 0) {
|
try {
|
||||||
for (const id of albumId) {
|
if (albumId && albumId.length > 0) {
|
||||||
const songs = await getSongsByAlbum(id);
|
for (const id of albumId) {
|
||||||
allSongIds.push(...(songs?.items?.map((song) => song.id) || []));
|
const songs = await getSongsByAlbum(id);
|
||||||
|
allSongIds.push(...(songs?.items?.map((song) => song.id) || []));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (artistId && artistId.length > 0) {
|
if (artistId && artistId.length > 0) {
|
||||||
for (const id of artistId) {
|
for (const id of artistId) {
|
||||||
const songs = await getSongsByArtist(id);
|
const songs = await getSongsByArtist(id);
|
||||||
allSongIds.push(...(songs?.items?.map((song) => song.id) || []));
|
allSongIds.push(...(songs?.items?.map((song) => song.id) || []));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (genreId && genreId.length > 0) {
|
if (genreId && genreId.length > 0) {
|
||||||
const songs = await getGenreSongsById({
|
const songs = await getGenreSongsById({
|
||||||
id: genreId,
|
id: genreId,
|
||||||
queryClient,
|
queryClient,
|
||||||
server,
|
server,
|
||||||
});
|
|
||||||
|
|
||||||
allSongIds.push(...(songs?.items?.map((song) => song.id) || []));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (songId && songId.length > 0) {
|
|
||||||
allSongIds.push(...songId);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const playlistId of values.playlistId) {
|
|
||||||
const uniqueSongIds: string[] = [];
|
|
||||||
|
|
||||||
if (values.skipDuplicates) {
|
|
||||||
const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId);
|
|
||||||
|
|
||||||
const playlistSongsRes = await queryClient.fetchQuery(queryKey, ({ signal }) => {
|
|
||||||
if (!server)
|
|
||||||
throw new Error(
|
|
||||||
t('error.serverNotSelectedError', { postProcess: 'sentenceCase' }),
|
|
||||||
);
|
|
||||||
return api.controller.getPlaylistSongList({
|
|
||||||
apiClientProps: {
|
|
||||||
server,
|
|
||||||
signal,
|
|
||||||
},
|
|
||||||
query: {
|
|
||||||
id: playlistId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const playlistSongIds = playlistSongsRes?.items?.map((song) => song.id);
|
allSongIds.push(...(songs?.items?.map((song) => song.id) || []));
|
||||||
|
}
|
||||||
|
|
||||||
for (const songId of allSongIds) {
|
if (songId && songId.length > 0) {
|
||||||
if (!playlistSongIds?.includes(songId)) {
|
allSongIds.push(...songId);
|
||||||
uniqueSongIds.push(songId);
|
}
|
||||||
|
|
||||||
|
const playlistIds = [...values.selectedPlaylistIds];
|
||||||
|
|
||||||
|
if (values.newPlaylists) {
|
||||||
|
for (const playlist of values.newPlaylists) {
|
||||||
|
try {
|
||||||
|
const response = await api.controller.createPlaylist({
|
||||||
|
apiClientProps: { server },
|
||||||
|
body: {
|
||||||
|
name: playlist,
|
||||||
|
public: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response?.id) {
|
||||||
|
playlistIds.push(response?.id);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error({
|
||||||
|
message: `[${playlist}] ${error?.message}`,
|
||||||
|
title: t('error.genericError', { postProcess: 'sentenceCase' }),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
totalUniquesAdded += uniqueSongIds.length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (values.skipDuplicates ? uniqueSongIds.length > 0 : allSongIds.length > 0) {
|
for (const playlistId of playlistIds) {
|
||||||
if (!server) return null;
|
const uniqueSongIds: string[] = [];
|
||||||
addToPlaylistMutation.mutate(
|
|
||||||
{
|
if (values.skipDuplicates) {
|
||||||
body: { songId: values.skipDuplicates ? uniqueSongIds : allSongIds },
|
const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId);
|
||||||
query: { id: playlistId },
|
|
||||||
serverId: server?.id,
|
const playlistSongsRes = await queryClient.fetchQuery(
|
||||||
},
|
queryKey,
|
||||||
{
|
({ signal }) => {
|
||||||
onError: (err) => {
|
if (!server)
|
||||||
toast.error({
|
throw new Error(
|
||||||
message: `[${
|
t('error.serverNotSelectedError', {
|
||||||
playlistSelect.find((playlist) => playlist.value === playlistId)
|
postProcess: 'sentenceCase',
|
||||||
?.label
|
}),
|
||||||
}] ${err.message}`,
|
);
|
||||||
title: t('error.genericError', { postProcess: 'sentenceCase' }),
|
return api.controller.getPlaylistSongList({
|
||||||
|
apiClientProps: {
|
||||||
|
server,
|
||||||
|
signal,
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
id: playlistId,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
);
|
||||||
);
|
|
||||||
|
const playlistSongIds = playlistSongsRes?.items?.map((song) => song.id);
|
||||||
|
|
||||||
|
for (const songId of allSongIds) {
|
||||||
|
if (!playlistSongIds?.includes(songId)) {
|
||||||
|
uniqueSongIds.push(songId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
totalUniquesAdded += uniqueSongIds.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.skipDuplicates ? uniqueSongIds.length > 0 : allSongIds.length > 0) {
|
||||||
|
if (!server) {
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
addToPlaylistMutation.mutate(
|
||||||
|
{
|
||||||
|
body: { songId: values.skipDuplicates ? uniqueSongIds : allSongIds },
|
||||||
|
query: { id: playlistId },
|
||||||
|
serverId: server?.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error({
|
||||||
|
message: `[${
|
||||||
|
playlistSelect.find(
|
||||||
|
(playlist) => playlist.value === playlistId,
|
||||||
|
)?.label
|
||||||
|
}] ${err.message}`,
|
||||||
|
title: t('error.genericError', { postProcess: 'sentenceCase' }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const addMessage =
|
||||||
|
values.skipDuplicates &&
|
||||||
|
allSongIds.length * playlistIds.length !== totalUniquesAdded
|
||||||
|
? Math.floor(totalUniquesAdded / playlistIds.length)
|
||||||
|
: allSongIds.length;
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
toast.success({
|
||||||
|
message: t('form.addToPlaylist.success', {
|
||||||
|
message: addMessage,
|
||||||
|
numOfPlaylists: playlistIds.length,
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
closeModal(id);
|
||||||
|
} catch (error: any) {
|
||||||
|
setIsLoading(false);
|
||||||
|
toast.error({
|
||||||
|
message: error?.message || t('error.genericError', { postProcess: 'sentenceCase' }),
|
||||||
|
title: t('error.genericError', { postProcess: 'sentenceCase' }),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const addMessage =
|
|
||||||
values.skipDuplicates &&
|
|
||||||
allSongIds.length * values.playlistId.length !== totalUniquesAdded
|
|
||||||
? Math.floor(totalUniquesAdded / values.playlistId.length)
|
|
||||||
: allSongIds.length;
|
|
||||||
|
|
||||||
setIsLoading(false);
|
|
||||||
toast.success({
|
|
||||||
message: t('form.addToPlaylist.success', {
|
|
||||||
message: addMessage,
|
|
||||||
numOfPlaylists: values.playlistId.length,
|
|
||||||
postProcess: 'sentenceCase',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
closeModal(id);
|
|
||||||
return null;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleSelectItem = useCallback(
|
||||||
|
(item: { value: string }) => {
|
||||||
|
const currentIds = form.values.selectedPlaylistIds;
|
||||||
|
if (currentIds.includes(item.value)) {
|
||||||
|
form.setFieldValue(
|
||||||
|
'selectedPlaylistIds',
|
||||||
|
currentIds.filter((id) => id !== item.value),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
form.setFieldValue('selectedPlaylistIds', [...currentIds, item.value]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[form],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCheckboxChange = useCallback(
|
||||||
|
(itemValue: string, checked: boolean) => {
|
||||||
|
const currentIds = form.values.selectedPlaylistIds;
|
||||||
|
if (checked) {
|
||||||
|
form.setFieldValue('selectedPlaylistIds', [...currentIds, itemValue]);
|
||||||
|
} else {
|
||||||
|
form.setFieldValue(
|
||||||
|
'selectedPlaylistIds',
|
||||||
|
currentIds.filter((id) => id !== itemValue),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[form],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreatePlaylist = useCallback(() => {
|
||||||
|
form.setFieldValue('newPlaylists', [...form.values.newPlaylists, search]);
|
||||||
|
setSearch('');
|
||||||
|
}, [form, search]);
|
||||||
|
|
||||||
|
const handleRemoveSelectedPlaylist = useCallback(
|
||||||
|
(playlistId: string) => {
|
||||||
|
form.setFieldValue(
|
||||||
|
'selectedPlaylistIds',
|
||||||
|
form.values.selectedPlaylistIds.filter((id) => id !== playlistId),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[form],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRemoveNewPlaylist = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
form.setFieldValue(
|
||||||
|
'newPlaylists',
|
||||||
|
form.values.newPlaylists.filter((_, existingIdx) => index !== existingIdx),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[form],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(
|
||||||
|
event: React.KeyboardEvent<HTMLTableRowElement>,
|
||||||
|
index: number,
|
||||||
|
item: { value: string },
|
||||||
|
) => {
|
||||||
|
const totalRows = filteredItems.length;
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
case ' ': {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
handleSelectItem(item);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'ArrowDown': {
|
||||||
|
event.preventDefault();
|
||||||
|
const nextIndex = index < totalRows - 1 ? index + 1 : index;
|
||||||
|
setFocusedRowIndex(nextIndex);
|
||||||
|
rowRefs.current[nextIndex]?.focus();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'ArrowUp': {
|
||||||
|
event.preventDefault();
|
||||||
|
const prevIndex = index > 0 ? index - 1 : 0;
|
||||||
|
setFocusedRowIndex(prevIndex);
|
||||||
|
rowRefs.current[prevIndex]?.focus();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'Enter': {
|
||||||
|
event.preventDefault();
|
||||||
|
if (formRef.current) {
|
||||||
|
formRef.current.requestSubmit();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'Tab': {
|
||||||
|
// Allow Tab to exit the table naturally - don't prevent default
|
||||||
|
setFocusedRowIndex(null);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[filteredItems.length, handleSelectItem],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setRowRef = useCallback(
|
||||||
|
(index: number) => (el: HTMLTableRowElement | null) => {
|
||||||
|
rowRefs.current[index] = el;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '1rem' }}>
|
<Box>
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit} ref={formRef}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<MultiSelect
|
<TextInput
|
||||||
clearable
|
data-autofocus
|
||||||
data={playlistSelect}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
disabled={playlistList.isLoading}
|
placeholder={t('form.addToPlaylist.searchOrCreate', {
|
||||||
dropdownOpened={isDropdownOpened}
|
postProcess: 'sentenceCase',
|
||||||
label={t('form.addToPlaylist.input', {
|
|
||||||
context: 'playlists',
|
|
||||||
postProcess: 'titleCase',
|
|
||||||
})}
|
})}
|
||||||
searchable
|
value={search}
|
||||||
size="md"
|
|
||||||
{...form.getInputProps('playlistId')}
|
|
||||||
onChange={(e) => {
|
|
||||||
setIsDropdownOpened(false);
|
|
||||||
form.getInputProps('playlistId').onChange(e);
|
|
||||||
}}
|
|
||||||
onClick={() => setIsDropdownOpened(true)}
|
|
||||||
/>
|
/>
|
||||||
|
<ScrollArea style={{ maxHeight: '18rem' }}>
|
||||||
|
<Table styles={{ td: { padding: 'var(--theme-spacing-sm)' } }}>
|
||||||
|
<Table.Tbody>
|
||||||
|
{filteredItems.map((item, index) => (
|
||||||
|
<Table.Tr
|
||||||
|
key={item.value}
|
||||||
|
onBlur={() => setFocusedRowIndex(null)}
|
||||||
|
onClick={() => handleSelectItem(item)}
|
||||||
|
onFocus={() => setFocusedRowIndex(index)}
|
||||||
|
onKeyDown={(e) => handleKeyDown(e, index, item)}
|
||||||
|
ref={setRowRef(index)}
|
||||||
|
role="button"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
focusedRowIndex === index
|
||||||
|
? 'var(--theme-colors-surface)'
|
||||||
|
: 'transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
outline: 'none',
|
||||||
|
}}
|
||||||
|
tabIndex={index === 0 ? 0 : -1}
|
||||||
|
>
|
||||||
|
<Table.Td w={10}>
|
||||||
|
<Checkbox
|
||||||
|
checked={form.values.selectedPlaylistIds.includes(
|
||||||
|
item.value,
|
||||||
|
)}
|
||||||
|
onChange={(event) => {
|
||||||
|
handleCheckboxChange(
|
||||||
|
item.value,
|
||||||
|
event.target.checked,
|
||||||
|
);
|
||||||
|
event.preventDefault();
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
tabIndex={-1}
|
||||||
|
/>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td style={{ maxWidth: 0, width: '100%' }}>
|
||||||
|
<PlaylistTableItem item={item} />
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</ScrollArea>
|
||||||
|
{search && (
|
||||||
|
<Button
|
||||||
|
leftSection={<Icon icon="add" size="lg" />}
|
||||||
|
onClick={handleCreatePlaylist}
|
||||||
|
variant="subtle"
|
||||||
|
w="100%"
|
||||||
|
>
|
||||||
|
{t('form.addToPlaylist.create', {
|
||||||
|
playlist: search,
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Pill.Group>
|
||||||
|
{form.values.selectedPlaylistIds.map((item) => (
|
||||||
|
<Pill
|
||||||
|
key={item}
|
||||||
|
onRemove={() => handleRemoveSelectedPlaylist(item)}
|
||||||
|
withRemoveButton
|
||||||
|
>
|
||||||
|
{playlistMap.get(item)}
|
||||||
|
</Pill>
|
||||||
|
))}
|
||||||
|
{form.values.newPlaylists.map((item, idx) => (
|
||||||
|
<Pill
|
||||||
|
key={idx}
|
||||||
|
onRemove={() => handleRemoveNewPlaylist(idx)}
|
||||||
|
withRemoveButton
|
||||||
|
>
|
||||||
|
<Flex align="center" gap="lg" wrap="nowrap">
|
||||||
|
<Icon icon="plus" />
|
||||||
|
{item}
|
||||||
|
</Flex>
|
||||||
|
</Pill>
|
||||||
|
))}
|
||||||
|
</Pill.Group>
|
||||||
<Switch
|
<Switch
|
||||||
label={t('form.addToPlaylist.input', {
|
label={t('form.addToPlaylist.input', {
|
||||||
context: 'skipDuplicates',
|
context: 'skipDuplicates',
|
||||||
|
|
@ -244,26 +507,89 @@ export const AddToPlaylistContextModal = ({
|
||||||
{...form.getInputProps('skipDuplicates', { type: 'checkbox' })}
|
{...form.getInputProps('skipDuplicates', { type: 'checkbox' })}
|
||||||
/>
|
/>
|
||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
<Button
|
<ModalButton
|
||||||
disabled={addToPlaylistMutation.isLoading}
|
disabled={isLoading || addToPlaylistMutation.isLoading}
|
||||||
onClick={() => closeModal(id)}
|
onClick={() => closeModal(id)}
|
||||||
size="md"
|
uppercase
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
>
|
>
|
||||||
{t('common.cancel', { postProcess: 'titleCase' })}
|
{t('common.cancel', { postProcess: 'titleCase' })}
|
||||||
</Button>
|
</ModalButton>
|
||||||
<Button
|
<ModalButton
|
||||||
disabled={isSubmitDisabled}
|
disabled={
|
||||||
|
isLoading ||
|
||||||
|
addToPlaylistMutation.isLoading ||
|
||||||
|
(form.values.selectedPlaylistIds.length === 0 &&
|
||||||
|
form.values.newPlaylists.length === 0)
|
||||||
|
}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
size="md"
|
|
||||||
type="submit"
|
type="submit"
|
||||||
|
uppercase
|
||||||
variant="filled"
|
variant="filled"
|
||||||
>
|
>
|
||||||
{t('common.add', { postProcess: 'titleCase' })}
|
{t('common.add', { postProcess: 'titleCase' })}
|
||||||
</Button>
|
</ModalButton>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PlaylistTableItem = memo(
|
||||||
|
({ item }: { item: Playlist & { label: string; value: string } }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className={styles.container} w="100%">
|
||||||
|
<Grid align="center" gutter="xs" w="100%">
|
||||||
|
<Grid.Col span="content">
|
||||||
|
<Flex align="center" justify="center" px="sm">
|
||||||
|
{item.imageUrl && (
|
||||||
|
<Image
|
||||||
|
imageContainerProps={{
|
||||||
|
className: styles.imageContainer,
|
||||||
|
}}
|
||||||
|
src={item.imageUrl}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col className={styles.gridCol} span="auto">
|
||||||
|
<Stack gap="xs" w="100%">
|
||||||
|
<Text className={styles.labelText} isNoSelect overflow="hidden">
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<Group gap="md" wrap="nowrap">
|
||||||
|
<Group align="center" gap="xs" wrap="nowrap">
|
||||||
|
<Icon color="muted" icon="track" size="sm" />
|
||||||
|
<Text isMuted size="sm">
|
||||||
|
{item.songCount}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Group align="center" gap="xs" wrap="nowrap">
|
||||||
|
<Icon color="muted" icon="duration" size="sm" />
|
||||||
|
<Text isMuted size="sm">
|
||||||
|
{formatDurationString(item.duration ?? 0)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Text className={styles.statusText} isMuted size="sm">
|
||||||
|
{item.public
|
||||||
|
? t('common.public', {
|
||||||
|
postProcess: 'titleCase',
|
||||||
|
})
|
||||||
|
: t('common.private', {
|
||||||
|
postProcess: 'titleCase',
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/crea
|
||||||
import { convertQueryGroupToNDQuery } from '/@/renderer/features/playlists/utils';
|
import { convertQueryGroupToNDQuery } from '/@/renderer/features/playlists/utils';
|
||||||
import { useCurrentServer } from '/@/renderer/store';
|
import { useCurrentServer } from '/@/renderer/store';
|
||||||
import { hasFeature } from '/@/shared/api/utils';
|
import { hasFeature } from '/@/shared/api/utils';
|
||||||
import { Button } from '/@/shared/components/button/button';
|
|
||||||
import { Group } from '/@/shared/components/group/group';
|
import { Group } from '/@/shared/components/group/group';
|
||||||
|
import { ModalButton } from '/@/shared/components/modal/model-shared';
|
||||||
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';
|
||||||
|
|
@ -155,17 +155,17 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
<Button onClick={onCancel} variant="subtle">
|
<ModalButton onClick={onCancel} px="2xl" uppercase variant="subtle">
|
||||||
{t('common.cancel', { postProcess: 'titleCase' })}
|
{t('common.cancel')}
|
||||||
</Button>
|
</ModalButton>
|
||||||
<Button
|
<ModalButton
|
||||||
disabled={isSubmitDisabled}
|
disabled={isSubmitDisabled}
|
||||||
loading={mutation.isLoading}
|
loading={mutation.isLoading}
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
>
|
>
|
||||||
{t('common.create', { postProcess: 'titleCase' })}
|
{t('common.create')}
|
||||||
</Button>
|
</ModalButton>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ import { useTranslation } from 'react-i18next';
|
||||||
import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/create-playlist-mutation';
|
import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/create-playlist-mutation';
|
||||||
import { useCurrentServer } from '/@/renderer/store';
|
import { useCurrentServer } from '/@/renderer/store';
|
||||||
import { hasFeature } from '/@/shared/api/utils';
|
import { hasFeature } from '/@/shared/api/utils';
|
||||||
import { Button } from '/@/shared/components/button/button';
|
|
||||||
import { Group } from '/@/shared/components/group/group';
|
import { Group } from '/@/shared/components/group/group';
|
||||||
|
import { ModalButton } from '/@/shared/components/modal/model-shared';
|
||||||
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';
|
||||||
|
|
@ -103,17 +103,15 @@ export const SaveAsPlaylistForm = ({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
<Button onClick={onCancel} variant="subtle">
|
<ModalButton onClick={onCancel}>{t('common.cancel')}</ModalButton>
|
||||||
{t('common.cancel', { postProcess: 'titleCase' })}
|
<ModalButton
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
disabled={isSubmitDisabled}
|
disabled={isSubmitDisabled}
|
||||||
loading={mutation.isLoading}
|
loading={mutation.isLoading}
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
>
|
>
|
||||||
{t('common.save', { postProcess: 'titleCase' })}
|
{t('common.save')}
|
||||||
</Button>
|
</ModalButton>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@ import { useUpdatePlaylist } from '/@/renderer/features/playlists/mutations/upda
|
||||||
import { queryClient } from '/@/renderer/lib/react-query';
|
import { queryClient } from '/@/renderer/lib/react-query';
|
||||||
import { useCurrentServer } from '/@/renderer/store';
|
import { useCurrentServer } from '/@/renderer/store';
|
||||||
import { hasFeature } from '/@/shared/api/utils';
|
import { hasFeature } from '/@/shared/api/utils';
|
||||||
import { Button } from '/@/shared/components/button/button';
|
|
||||||
import { Group } from '/@/shared/components/group/group';
|
import { Group } from '/@/shared/components/group/group';
|
||||||
|
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';
|
||||||
|
|
@ -140,17 +140,15 @@ export const UpdatePlaylistForm = ({ body, onCancel, query, users }: UpdatePlayl
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
<Button onClick={onCancel} variant="subtle">
|
<ModalButton onClick={onCancel}>{t('common.cancel')}</ModalButton>
|
||||||
{t('common.cancel', { postProcess: 'titleCase' })}
|
<ModalButton
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
disabled={isSubmitDisabled}
|
disabled={isSubmitDisabled}
|
||||||
loading={mutation.isLoading}
|
loading={mutation.isLoading}
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
>
|
>
|
||||||
{t('common.save', { postProcess: 'titleCase' })}
|
{t('common.save')}
|
||||||
</Button>
|
</ModalButton>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,9 @@ import JellyfinIcon from '/@/renderer/features/servers/assets/jellyfin.png';
|
||||||
import NavidromeIcon from '/@/renderer/features/servers/assets/navidrome.png';
|
import NavidromeIcon from '/@/renderer/features/servers/assets/navidrome.png';
|
||||||
import SubsonicIcon from '/@/renderer/features/servers/assets/opensubsonic.png';
|
import SubsonicIcon from '/@/renderer/features/servers/assets/opensubsonic.png';
|
||||||
import { useAuthStoreActions } from '/@/renderer/store';
|
import { useAuthStoreActions } from '/@/renderer/store';
|
||||||
import { Button } from '/@/shared/components/button/button';
|
|
||||||
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
|
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
|
||||||
import { Group } from '/@/shared/components/group/group';
|
import { Group } from '/@/shared/components/group/group';
|
||||||
|
import { ModalButton } from '/@/shared/components/modal/model-shared';
|
||||||
import { Paper } from '/@/shared/components/paper/paper';
|
import { Paper } from '/@/shared/components/paper/paper';
|
||||||
import { PasswordInput } from '/@/shared/components/password-input/password-input';
|
import { PasswordInput } from '/@/shared/components/password-input/password-input';
|
||||||
import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';
|
import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';
|
||||||
|
|
@ -298,20 +298,18 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Group grow justify="flex-end">
|
<Group justify="flex-end">
|
||||||
{onCancel && (
|
{onCancel && (
|
||||||
<Button onClick={onCancel} variant="subtle">
|
<ModalButton onClick={onCancel}>{t('common.cancel')}</ModalButton>
|
||||||
{t('common.cancel', { postProcess: 'titleCase' })}
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
<Button
|
<ModalButton
|
||||||
disabled={isSubmitDisabled}
|
disabled={isSubmitDisabled}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
>
|
>
|
||||||
{t('common.add', { postProcess: 'titleCase' })}
|
{t('common.add')}
|
||||||
</Button>
|
</ModalButton>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,10 @@ import i18n from '/@/i18n/i18n';
|
||||||
import { api } from '/@/renderer/api';
|
import { api } from '/@/renderer/api';
|
||||||
import { queryClient } from '/@/renderer/lib/react-query';
|
import { queryClient } from '/@/renderer/lib/react-query';
|
||||||
import { useAuthStoreActions } from '/@/renderer/store';
|
import { useAuthStoreActions } from '/@/renderer/store';
|
||||||
import { Button } from '/@/shared/components/button/button';
|
|
||||||
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
|
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
|
||||||
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 { ModalButton } from '/@/shared/components/modal/model-shared';
|
||||||
import { PasswordInput } from '/@/shared/components/password-input/password-input';
|
import { PasswordInput } from '/@/shared/components/password-input/password-input';
|
||||||
import { Stack } from '/@/shared/components/stack/stack';
|
import { Stack } from '/@/shared/components/stack/stack';
|
||||||
import { TextInput } from '/@/shared/components/text-input/text-input';
|
import { TextInput } from '/@/shared/components/text-input/text-input';
|
||||||
|
|
@ -216,12 +216,10 @@ export const EditServerForm = ({ isUpdate, onCancel, password, server }: EditSer
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
<Button onClick={onCancel} variant="subtle">
|
<ModalButton onClick={onCancel}>{t('common.cancel')}</ModalButton>
|
||||||
{t('common.cancel', { postProcess: 'titleCase' })}
|
<ModalButton loading={isLoading} type="submit" variant="filled">
|
||||||
</Button>
|
{t('common.save')}
|
||||||
<Button loading={isLoading} type="submit" variant="filled">
|
</ModalButton>
|
||||||
{t('common.save', { postProcess: 'titleCase' })}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,9 @@ import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useShareItem } from '/@/renderer/features/sharing/mutations/share-item-mutation';
|
import { useShareItem } from '/@/renderer/features/sharing/mutations/share-item-mutation';
|
||||||
import { useCurrentServer } from '/@/renderer/store';
|
import { useCurrentServer } from '/@/renderer/store';
|
||||||
import { Button } from '/@/shared/components/button/button';
|
|
||||||
import { DateTimePicker } from '/@/shared/components/date-time-picker/date-time-picker';
|
import { DateTimePicker } from '/@/shared/components/date-time-picker/date-time-picker';
|
||||||
import { Group } from '/@/shared/components/group/group';
|
import { Group } from '/@/shared/components/group/group';
|
||||||
|
import { ModalButton } from '/@/shared/components/modal/model-shared';
|
||||||
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 { Textarea } from '/@/shared/components/textarea/textarea';
|
import { Textarea } from '/@/shared/components/textarea/textarea';
|
||||||
|
|
@ -127,14 +127,10 @@ export const ShareItemContextModal = ({
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
<Group>
|
<ModalButton onClick={() => closeModal(id)}>{t('common.cancel')}</ModalButton>
|
||||||
<Button onClick={() => closeModal(id)} size="md" variant="subtle">
|
<ModalButton type="submit" variant="filled">
|
||||||
{t('common.cancel', { postProcess: 'titleCase' })}
|
{t('common.share')}
|
||||||
</Button>
|
</ModalButton>
|
||||||
<Button size="md" type="submit" variant="filled">
|
|
||||||
{t('common.share', { postProcess: 'titleCase' })}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,11 @@
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
background: darken(var(--theme-colors-primary-filled), 10%);
|
background: darken(var(--theme-colors-primary-filled), 10%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 1px solid var(--theme-colors-primary-filled);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&[data-variant='state-error'] {
|
&[data-variant='state-error'] {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { MotionConfigProps } from 'motion/react';
|
import { ForwardedRef, forwardRef, HTMLAttributes, type ImgHTMLAttributes, ReactNode } from 'react';
|
||||||
import { ForwardedRef, forwardRef, type ImgHTMLAttributes } from 'react';
|
|
||||||
import { Img } from 'react-image';
|
import { Img } from 'react-image';
|
||||||
|
|
||||||
import styles from './image.module.css';
|
import styles from './image.module.css';
|
||||||
|
|
@ -9,9 +8,8 @@ import { Icon } from '/@/shared/components/icon/icon';
|
||||||
import { Skeleton } from '/@/shared/components/skeleton/skeleton';
|
import { Skeleton } from '/@/shared/components/skeleton/skeleton';
|
||||||
import { useInViewport } from '/@/shared/hooks/use-in-viewport';
|
import { useInViewport } from '/@/shared/hooks/use-in-viewport';
|
||||||
|
|
||||||
interface ImageContainerProps extends MotionConfigProps {
|
interface ImageContainerProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
children: React.ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
|
||||||
enableAnimation?: boolean;
|
enableAnimation?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -44,6 +42,7 @@ export function Image({
|
||||||
includeLoader = true,
|
includeLoader = true,
|
||||||
includeUnloader = true,
|
includeUnloader = true,
|
||||||
src,
|
src,
|
||||||
|
...props
|
||||||
}: ImageProps) {
|
}: ImageProps) {
|
||||||
const { inViewport, ref } = useInViewport();
|
const { inViewport, ref } = useInViewport();
|
||||||
|
|
||||||
|
|
@ -78,6 +77,7 @@ export function Image({
|
||||||
</ImageContainer>
|
</ImageContainer>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,26 @@
|
||||||
.title {
|
|
||||||
font-size: var(--theme-font-size-lg);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.body {
|
|
||||||
padding: var(--theme-spacing-sm) var(--theme-spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: none;
|
||||||
background: var(--theme-colors-background);
|
background: var(--theme-colors-background);
|
||||||
border-bottom: none;
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h2 {
|
||||||
|
width: 100%;
|
||||||
|
font-size: var(--theme-font-size-2xl);
|
||||||
|
font-weight: 700;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
|
overflow: hidden;
|
||||||
background: var(--theme-colors-background);
|
background: var(--theme-colors-background);
|
||||||
|
border: 2px solid var(--theme-colors-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--theme-spacing-md);
|
||||||
|
right: var(--theme-spacing-md);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { Button } from '/@/shared/components/button/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';
|
||||||
import { Icon } from '/@/shared/components/icon/icon';
|
import { Icon } from '/@/shared/components/icon/icon';
|
||||||
|
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
|
||||||
import { Stack } from '/@/shared/components/stack/stack';
|
import { Stack } from '/@/shared/components/stack/stack';
|
||||||
|
|
||||||
export interface ModalProps extends Omit<MantineModalProps, 'onClose'> {
|
export interface ModalProps extends Omit<MantineModalProps, 'onClose'> {
|
||||||
|
|
@ -113,15 +114,23 @@ export const ModalsProvider = ({ children, ...rest }: ModalsProviderProps) => {
|
||||||
centered: true,
|
centered: true,
|
||||||
classNames: {
|
classNames: {
|
||||||
body: styles.body,
|
body: styles.body,
|
||||||
|
close: styles.close,
|
||||||
content: styles.content,
|
content: styles.content,
|
||||||
header: styles.header,
|
header: styles.header,
|
||||||
|
inner: styles.inner,
|
||||||
|
overlay: styles.overlay,
|
||||||
root: styles.root,
|
root: styles.root,
|
||||||
title: styles.title,
|
title: styles.title,
|
||||||
},
|
},
|
||||||
closeButtonProps: {
|
closeButtonProps: {
|
||||||
icon: <Icon icon="x" />,
|
icon: <Icon icon="x" size="xl" />,
|
||||||
},
|
},
|
||||||
radius: 'lg',
|
overlayProps: {
|
||||||
|
backgroundOpacity: 0.8,
|
||||||
|
blur: 4,
|
||||||
|
},
|
||||||
|
radius: 'xl',
|
||||||
|
scrollAreaComponent: ScrollArea,
|
||||||
transitionProps: {
|
transitionProps: {
|
||||||
duration: 300,
|
duration: 300,
|
||||||
exitDuration: 300,
|
exitDuration: 300,
|
||||||
|
|
|
||||||
9
src/shared/components/modal/model-shared.tsx
Normal file
9
src/shared/components/modal/model-shared.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Button, ButtonProps } from '/@/shared/components/button/button';
|
||||||
|
|
||||||
|
export const ModalButton = ({ children, ...props }: ButtonProps) => {
|
||||||
|
return (
|
||||||
|
<Button px="2xl" uppercase variant="subtle" {...props}>
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
32
src/shared/components/pill/pill.module.css
Normal file
32
src/shared/components/pill/pill.module.css
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
.label {
|
||||||
|
font-family: var(--theme-content-font-family);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label.sm {
|
||||||
|
font-size: var(--theme-font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label.md {
|
||||||
|
font-size: var(--theme-font-size-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label.lg {
|
||||||
|
font-size: var(--theme-font-size-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label.xl {
|
||||||
|
font-size: var(--theme-font-size-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label.xs {
|
||||||
|
font-size: var(--theme-font-size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.remove {
|
||||||
|
transition: color 0.1s ease-in-out;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--theme-colors-foreground-muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/shared/components/pill/pill.tsx
Normal file
29
src/shared/components/pill/pill.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { Pill as MantinePill, PillProps as MantinePillProps } from '@mantine/core';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
import styles from './pill.module.css';
|
||||||
|
|
||||||
|
export const Pill = ({ children, size = 'md', ...props }: MantinePillProps) => {
|
||||||
|
return (
|
||||||
|
<MantinePill
|
||||||
|
classNames={{
|
||||||
|
label: clsx({
|
||||||
|
[styles.label]: true,
|
||||||
|
[styles.lg]: size === 'lg',
|
||||||
|
[styles.md]: size === 'md',
|
||||||
|
[styles.sm]: size === 'sm',
|
||||||
|
[styles.xl]: size === 'xl',
|
||||||
|
[styles.xs]: size === 'xs',
|
||||||
|
}),
|
||||||
|
remove: styles.remove,
|
||||||
|
root: styles.root,
|
||||||
|
}}
|
||||||
|
size="md"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</MantinePill>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
Pill.Group = MantinePill.Group;
|
||||||
Loading…
Add table
Add a link
Reference in a new issue