From 1a176fd118ccd68ca583158c955ee3011b72969e Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Sun, 2 Nov 2025 04:57:12 +0000 Subject: [PATCH] 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 --- src/i18n/locales/en.json | 4 + .../context-menu/context-menu-provider.tsx | 2 +- .../components/lyrics-search-form.module.css | 10 +- .../add-to-playlist-context-modal.module.css | 25 + .../add-to-playlist-context-modal.tsx | 646 +++++++++++++----- .../components/create-playlist-form.tsx | 14 +- .../components/save-as-playlist-form.tsx | 12 +- .../components/update-playlist-form.tsx | 12 +- .../servers/components/add-server-form.tsx | 14 +- .../servers/components/edit-server-form.tsx | 12 +- .../components/share-item-context-modal.tsx | 14 +- .../components/button/button.module.css | 5 + src/shared/components/image/image.tsx | 10 +- src/shared/components/modal/modal.module.css | 29 +- src/shared/components/modal/modal.tsx | 13 +- src/shared/components/modal/model-shared.tsx | 9 + src/shared/components/pill/pill.module.css | 32 + src/shared/components/pill/pill.tsx | 29 + 18 files changed, 667 insertions(+), 225 deletions(-) create mode 100644 src/renderer/features/playlists/components/add-to-playlist-context-modal.module.css create mode 100644 src/shared/components/modal/model-shared.tsx create mode 100644 src/shared/components/pill/pill.module.css create mode 100644 src/shared/components/pill/pill.tsx diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 1fc75fe2..9bead681 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -92,6 +92,8 @@ "playerMustBePaused": "player must be paused", "preview": "preview", "previousSong": "previous $t(entity.track_one)", + "private": "private", + "public": "public", "quit": "quit", "random": "random", "rating": "rating", @@ -250,8 +252,10 @@ "title": "add server" }, "addToPlaylist": { + "create": "create $t(entity.playlist_one) {{playlist}}", "input_playlists": "$t(entity.playlist_other)", "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}} })", "title": "add to $t(entity.playlist_one)" }, diff --git a/src/renderer/features/context-menu/context-menu-provider.tsx b/src/renderer/features/context-menu/context-menu-provider.tsx index 46fb9edd..9052369c 100644 --- a/src/renderer/features/context-menu/context-menu-provider.tsx +++ b/src/renderer/features/context-menu/context-menu-provider.tsx @@ -504,7 +504,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => { songId: songId.length > 0 ? songId : undefined, }, modal: 'addToPlaylist', - size: 'md', + size: 'lg', title: t('page.contextMenu.addToPlaylist', { postProcess: 'sentenceCase' }), }); }, [ctx.data, ctx.dataNodes, t]); diff --git a/src/renderer/features/lyrics/components/lyrics-search-form.module.css b/src/renderer/features/lyrics/components/lyrics-search-form.module.css index fe60fc8f..8e8081fc 100644 --- a/src/renderer/features/lyrics/components/lyrics-search-form.module.css +++ b/src/renderer/features/lyrics/components/lyrics-search-form.module.css @@ -6,8 +6,14 @@ border-radius: 5px; &:hover, + &:active, &:focus-visible { - color: var(--theme-btn-default-fg-hover); - background: var(--theme-btn-default-bg-hover); + @mixin dark { + background-color: lighten(var(--theme-colors-background), 10%); + } + + @mixin light { + background-color: darken(var(--theme-colors-background), 5%); + } } } diff --git a/src/renderer/features/playlists/components/add-to-playlist-context-modal.module.css b/src/renderer/features/playlists/components/add-to-playlist-context-modal.module.css new file mode 100644 index 00000000..b3584717 --- /dev/null +++ b/src/renderer/features/playlists/components/add-to-playlist-context-modal.module.css @@ -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; +} diff --git a/src/renderer/features/playlists/components/add-to-playlist-context-modal.tsx b/src/renderer/features/playlists/components/add-to-playlist-context-modal.tsx index 9bae51c0..63b07be0 100644 --- a/src/renderer/features/playlists/components/add-to-playlist-context-modal.tsx +++ b/src/renderer/features/playlists/components/add-to-playlist-context-modal.tsx @@ -1,8 +1,10 @@ import { useForm } from '@mantine/form'; 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 styles from './add-to-playlist-context-modal.module.css'; + import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; 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 { queryClient } from '/@/renderer/lib/react-query'; 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 { 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 { 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 { 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 { + Playlist, PlaylistListSort, SongListQuery, SongListSort, @@ -36,7 +51,18 @@ export const AddToPlaylistContextModal = ({ const { albumId, artistId, genreId, songId } = innerProps; const server = useCurrentServer(); const [isLoading, setIsLoading] = useState(false); - const [isDropdownOpened, setIsDropdownOpened] = useState(true); + const [search, setSearch] = useState(''); + const [focusedRowIndex, setFocusedRowIndex] = useState(null); + const rowRefs = useRef<(HTMLTableRowElement | null)[]>([]); + const formRef = useRef(null); + + const form = useForm({ + initialValues: { + newPlaylists: [] as string[], + selectedPlaylistIds: [] as string[], + skipDuplicates: true, + }, + }); const addToPlaylistMutation = useAddToPlaylist({}); @@ -54,188 +80,425 @@ export const AddToPlaylistContextModal = ({ serverId: server?.id, }); - const playlistSelect = useMemo(() => { - return ( - playlistList.data?.items?.map((playlist) => ({ - label: playlist.name, - value: playlist.id, - })) || [] - ); + const [playlistSelect, playlistMap] = useMemo(() => { + const existingPlaylists = new Array(); + const playlistMap = new Map(); + + 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]); - const form = useForm({ - initialValues: { - playlistId: [], - skipDuplicates: true, + const filteredItems = useMemo(() => { + if (search) { + return playlistSelect.filter((item) => + 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 query: SongListQuery = { - albumIds: [albumId], - sortBy: SongListSort.ALBUM, - sortOrder: SortOrder.ASC, - startIndex: 0, - }; + const getSongsByArtist = useCallback( + 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 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 }); - }); + 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 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; + return songsRes; + }, + [server], + ); const handleSubmit = form.onSubmit(async (values) => { + if (isLoading) { + return; + } + setIsLoading(true); const allSongIds: string[] = []; let totalUniquesAdded = 0; - if (albumId && albumId.length > 0) { - for (const id of albumId) { - const songs = await getSongsByAlbum(id); - allSongIds.push(...(songs?.items?.map((song) => song.id) || [])); + try { + if (albumId && albumId.length > 0) { + for (const id of albumId) { + const songs = await getSongsByAlbum(id); + allSongIds.push(...(songs?.items?.map((song) => song.id) || [])); + } } - } - if (artistId && artistId.length > 0) { - for (const id of artistId) { - const songs = await getSongsByArtist(id); - allSongIds.push(...(songs?.items?.map((song) => song.id) || [])); + if (artistId && artistId.length > 0) { + for (const id of artistId) { + const songs = await getSongsByArtist(id); + allSongIds.push(...(songs?.items?.map((song) => song.id) || [])); + } } - } - if (genreId && genreId.length > 0) { - const songs = await getGenreSongsById({ - id: genreId, - queryClient, - 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, - }, - }); + if (genreId && genreId.length > 0) { + const songs = await getGenreSongsById({ + id: genreId, + queryClient, + server, }); - const playlistSongIds = playlistSongsRes?.items?.map((song) => song.id); + allSongIds.push(...(songs?.items?.map((song) => song.id) || [])); + } - for (const songId of allSongIds) { - if (!playlistSongIds?.includes(songId)) { - uniqueSongIds.push(songId); + if (songId && songId.length > 0) { + allSongIds.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) { - if (!server) return null; - 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' }), + for (const playlistId of playlistIds) { + 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); + + 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, + 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 ( -
-
+ + - setSearch(e.target.value)} + placeholder={t('form.addToPlaylist.searchOrCreate', { + postProcess: 'sentenceCase', })} - searchable - size="md" - {...form.getInputProps('playlistId')} - onChange={(e) => { - setIsDropdownOpened(false); - form.getInputProps('playlistId').onChange(e); - }} - onClick={() => setIsDropdownOpened(true)} + value={search} /> + + + + {filteredItems.map((item, index) => ( + 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} + > + + { + handleCheckboxChange( + item.value, + event.target.checked, + ); + event.preventDefault(); + }} + onClick={(e) => e.stopPropagation()} + tabIndex={-1} + /> + + + + + + ))} + +
+
+ {search && ( + + )} + + {form.values.selectedPlaylistIds.map((item) => ( + handleRemoveSelectedPlaylist(item)} + withRemoveButton + > + {playlistMap.get(item)} + + ))} + {form.values.newPlaylists.map((item, idx) => ( + handleRemoveNewPlaylist(idx)} + withRemoveButton + > + + + {item} + + + ))} + - - +
-
+ ); }; + +const PlaylistTableItem = memo( + ({ item }: { item: Playlist & { label: string; value: string } }) => { + const { t } = useTranslation(); + + return ( + + + + + {item.imageUrl && ( + + )} + + + + + + {item.label} + + + + + + + {item.songCount} + + + + + + {formatDurationString(item.duration ?? 0)} + + + + + + {item.public + ? t('common.public', { + postProcess: 'titleCase', + }) + : t('common.private', { + postProcess: 'titleCase', + })} + + + + + + + ); + }, +); diff --git a/src/renderer/features/playlists/components/create-playlist-form.tsx b/src/renderer/features/playlists/components/create-playlist-form.tsx index 01bcac38..da578cef 100644 --- a/src/renderer/features/playlists/components/create-playlist-form.tsx +++ b/src/renderer/features/playlists/components/create-playlist-form.tsx @@ -10,8 +10,8 @@ import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/crea import { convertQueryGroupToNDQuery } from '/@/renderer/features/playlists/utils'; import { useCurrentServer } from '/@/renderer/store'; import { hasFeature } from '/@/shared/api/utils'; -import { Button } from '/@/shared/components/button/button'; import { Group } from '/@/shared/components/group/group'; +import { ModalButton } from '/@/shared/components/modal/model-shared'; import { Stack } from '/@/shared/components/stack/stack'; import { Switch } from '/@/shared/components/switch/switch'; import { TextInput } from '/@/shared/components/text-input/text-input'; @@ -155,17 +155,17 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => { )} - - + {t('common.create')} + diff --git a/src/renderer/features/playlists/components/save-as-playlist-form.tsx b/src/renderer/features/playlists/components/save-as-playlist-form.tsx index 304b1a8f..4708dc2d 100644 --- a/src/renderer/features/playlists/components/save-as-playlist-form.tsx +++ b/src/renderer/features/playlists/components/save-as-playlist-form.tsx @@ -4,8 +4,8 @@ import { useTranslation } from 'react-i18next'; import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/create-playlist-mutation'; import { useCurrentServer } from '/@/renderer/store'; import { hasFeature } from '/@/shared/api/utils'; -import { Button } from '/@/shared/components/button/button'; import { Group } from '/@/shared/components/group/group'; +import { ModalButton } from '/@/shared/components/modal/model-shared'; import { Stack } from '/@/shared/components/stack/stack'; import { Switch } from '/@/shared/components/switch/switch'; import { TextInput } from '/@/shared/components/text-input/text-input'; @@ -103,17 +103,15 @@ export const SaveAsPlaylistForm = ({ /> )} - - + {t('common.save')} + diff --git a/src/renderer/features/playlists/components/update-playlist-form.tsx b/src/renderer/features/playlists/components/update-playlist-form.tsx index 51635c52..fc121f79 100644 --- a/src/renderer/features/playlists/components/update-playlist-form.tsx +++ b/src/renderer/features/playlists/components/update-playlist-form.tsx @@ -9,8 +9,8 @@ import { useUpdatePlaylist } from '/@/renderer/features/playlists/mutations/upda import { queryClient } from '/@/renderer/lib/react-query'; import { useCurrentServer } from '/@/renderer/store'; import { hasFeature } from '/@/shared/api/utils'; -import { Button } from '/@/shared/components/button/button'; import { Group } from '/@/shared/components/group/group'; +import { ModalButton } from '/@/shared/components/modal/model-shared'; import { Select } from '/@/shared/components/select/select'; import { Stack } from '/@/shared/components/stack/stack'; import { Switch } from '/@/shared/components/switch/switch'; @@ -140,17 +140,15 @@ export const UpdatePlaylistForm = ({ body, onCancel, query, users }: UpdatePlayl )} - - + {t('common.save')} + diff --git a/src/renderer/features/servers/components/add-server-form.tsx b/src/renderer/features/servers/components/add-server-form.tsx index aa8dc623..efa56d25 100644 --- a/src/renderer/features/servers/components/add-server-form.tsx +++ b/src/renderer/features/servers/components/add-server-form.tsx @@ -11,9 +11,9 @@ import JellyfinIcon from '/@/renderer/features/servers/assets/jellyfin.png'; import NavidromeIcon from '/@/renderer/features/servers/assets/navidrome.png'; import SubsonicIcon from '/@/renderer/features/servers/assets/opensubsonic.png'; import { useAuthStoreActions } from '/@/renderer/store'; -import { Button } from '/@/shared/components/button/button'; import { Checkbox } from '/@/shared/components/checkbox/checkbox'; import { Group } from '/@/shared/components/group/group'; +import { ModalButton } from '/@/shared/components/modal/model-shared'; import { Paper } from '/@/shared/components/paper/paper'; import { PasswordInput } from '/@/shared/components/password-input/password-input'; import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control'; @@ -298,20 +298,18 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => { })} /> )} - + {onCancel && ( - + {t('common.cancel')} )} - + {t('common.add')} + diff --git a/src/renderer/features/servers/components/edit-server-form.tsx b/src/renderer/features/servers/components/edit-server-form.tsx index 1231e6cb..936f35e9 100644 --- a/src/renderer/features/servers/components/edit-server-form.tsx +++ b/src/renderer/features/servers/components/edit-server-form.tsx @@ -9,10 +9,10 @@ import i18n from '/@/i18n/i18n'; import { api } from '/@/renderer/api'; import { queryClient } from '/@/renderer/lib/react-query'; import { useAuthStoreActions } from '/@/renderer/store'; -import { Button } from '/@/shared/components/button/button'; import { Checkbox } from '/@/shared/components/checkbox/checkbox'; import { Group } from '/@/shared/components/group/group'; 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 { Stack } from '/@/shared/components/stack/stack'; import { TextInput } from '/@/shared/components/text-input/text-input'; @@ -216,12 +216,10 @@ export const EditServerForm = ({ isUpdate, onCancel, password, server }: EditSer /> )} - - + {t('common.cancel')} + + {t('common.save')} + diff --git a/src/renderer/features/sharing/components/share-item-context-modal.tsx b/src/renderer/features/sharing/components/share-item-context-modal.tsx index 86ab82e0..90392f22 100644 --- a/src/renderer/features/sharing/components/share-item-context-modal.tsx +++ b/src/renderer/features/sharing/components/share-item-context-modal.tsx @@ -5,9 +5,9 @@ import { useTranslation } from 'react-i18next'; import { useShareItem } from '/@/renderer/features/sharing/mutations/share-item-mutation'; import { useCurrentServer } from '/@/renderer/store'; -import { Button } from '/@/shared/components/button/button'; import { DateTimePicker } from '/@/shared/components/date-time-picker/date-time-picker'; import { Group } from '/@/shared/components/group/group'; +import { ModalButton } from '/@/shared/components/modal/model-shared'; import { Stack } from '/@/shared/components/stack/stack'; import { Switch } from '/@/shared/components/switch/switch'; import { Textarea } from '/@/shared/components/textarea/textarea'; @@ -127,14 +127,10 @@ export const ShareItemContextModal = ({ /> - - - - + closeModal(id)}>{t('common.cancel')} + + {t('common.share')} + diff --git a/src/shared/components/button/button.module.css b/src/shared/components/button/button.module.css index b17dc529..ca1663ff 100644 --- a/src/shared/components/button/button.module.css +++ b/src/shared/components/button/button.module.css @@ -47,6 +47,11 @@ &:focus-visible { 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'] { diff --git a/src/shared/components/image/image.tsx b/src/shared/components/image/image.tsx index d5aa81c7..dabd391c 100644 --- a/src/shared/components/image/image.tsx +++ b/src/shared/components/image/image.tsx @@ -1,6 +1,5 @@ import clsx from 'clsx'; -import { MotionConfigProps } from 'motion/react'; -import { ForwardedRef, forwardRef, type ImgHTMLAttributes } from 'react'; +import { ForwardedRef, forwardRef, HTMLAttributes, type ImgHTMLAttributes, ReactNode } from 'react'; import { Img } from 'react-image'; 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 { useInViewport } from '/@/shared/hooks/use-in-viewport'; -interface ImageContainerProps extends MotionConfigProps { - children: React.ReactNode; - className?: string; +interface ImageContainerProps extends HTMLAttributes { + children: ReactNode; enableAnimation?: boolean; } @@ -44,6 +42,7 @@ export function Image({ includeLoader = true, includeUnloader = true, src, + ...props }: ImageProps) { const { inViewport, ref } = useInViewport(); @@ -78,6 +77,7 @@ export function Image({ ) : null } + {...props} /> ); } diff --git a/src/shared/components/modal/modal.module.css b/src/shared/components/modal/modal.module.css index 66aeb903..e560795e 100644 --- a/src/shared/components/modal/modal.module.css +++ b/src/shared/components/modal/modal.module.css @@ -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 { + display: flex; + justify-content: center; + padding: none; 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 { + overflow: hidden; 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); } diff --git a/src/shared/components/modal/modal.tsx b/src/shared/components/modal/modal.tsx index 135ae049..84180d89 100644 --- a/src/shared/components/modal/modal.tsx +++ b/src/shared/components/modal/modal.tsx @@ -12,6 +12,7 @@ import { Button } from '/@/shared/components/button/button'; import { Flex } from '/@/shared/components/flex/flex'; import { Group } from '/@/shared/components/group/group'; import { Icon } from '/@/shared/components/icon/icon'; +import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area'; import { Stack } from '/@/shared/components/stack/stack'; export interface ModalProps extends Omit { @@ -113,15 +114,23 @@ export const ModalsProvider = ({ children, ...rest }: ModalsProviderProps) => { centered: true, classNames: { body: styles.body, + close: styles.close, content: styles.content, header: styles.header, + inner: styles.inner, + overlay: styles.overlay, root: styles.root, title: styles.title, }, closeButtonProps: { - icon: , + icon: , }, - radius: 'lg', + overlayProps: { + backgroundOpacity: 0.8, + blur: 4, + }, + radius: 'xl', + scrollAreaComponent: ScrollArea, transitionProps: { duration: 300, exitDuration: 300, diff --git a/src/shared/components/modal/model-shared.tsx b/src/shared/components/modal/model-shared.tsx new file mode 100644 index 00000000..8b5e8448 --- /dev/null +++ b/src/shared/components/modal/model-shared.tsx @@ -0,0 +1,9 @@ +import { Button, ButtonProps } from '/@/shared/components/button/button'; + +export const ModalButton = ({ children, ...props }: ButtonProps) => { + return ( + + ); +}; diff --git a/src/shared/components/pill/pill.module.css b/src/shared/components/pill/pill.module.css new file mode 100644 index 00000000..62bfe255 --- /dev/null +++ b/src/shared/components/pill/pill.module.css @@ -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); + } +} diff --git a/src/shared/components/pill/pill.tsx b/src/shared/components/pill/pill.tsx new file mode 100644 index 00000000..46e1949f --- /dev/null +++ b/src/shared/components/pill/pill.tsx @@ -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 ( + + {children} + + ); +}; + +Pill.Group = MantinePill.Group;