diff --git a/apps/main/src/library/library.resolver.spec.ts b/apps/main/src/library/library.resolver.spec.ts index 371932d..7c9ce69 100644 --- a/apps/main/src/library/library.resolver.spec.ts +++ b/apps/main/src/library/library.resolver.spec.ts @@ -2,10 +2,12 @@ import { Test, TestingModule } from "@nestjs/testing"; import { LibraryResolver } from "@library/library.resolver"; import { LibraryScannerService } from "@library/library.scanner.service"; +import { LibraryService } from "@library/library.service"; describe("LibraryResolver", () => { let resolver: LibraryResolver; let libraryScannerService: Record; + let libraryService: Record; beforeEach(async () => { libraryScannerService = { @@ -13,8 +15,17 @@ describe("LibraryResolver", () => { subscribeToLibraryScanningStateChanged: jest.fn(), }; + libraryService = { + getSearchSuggestions: jest.fn(), + search: jest.fn(), + }; + const module: TestingModule = await Test.createTestingModule({ - providers: [LibraryResolver, { provide: LibraryScannerService, useValue: libraryScannerService }], + providers: [ + LibraryResolver, + { provide: LibraryScannerService, useValue: libraryScannerService }, + { provide: LibraryService, useValue: libraryService }, + ], }).compile(); resolver = module.get(LibraryResolver); @@ -35,4 +46,16 @@ describe("LibraryResolver", () => { expect(libraryScannerService.subscribeToLibraryScanningStateChanged).toHaveBeenCalled(); }); + + it("should be able to get search suggestions", async () => { + await resolver.searchSuggestions(); + + expect(libraryService.getSearchSuggestions).toHaveBeenCalled(); + }); + + it("should be able to search library", async () => { + await resolver.search("test"); + + expect(libraryService.search).toHaveBeenCalledWith("test"); + }); }); diff --git a/apps/main/src/library/library.resolver.ts b/apps/main/src/library/library.resolver.ts index c4eca11..5db46f6 100644 --- a/apps/main/src/library/library.resolver.ts +++ b/apps/main/src/library/library.resolver.ts @@ -1,11 +1,28 @@ import { Inject } from "@nestjs/common"; -import { Mutation, Resolver, Subscription } from "@nestjs/graphql"; +import { Args, Mutation, Query, Resolver, Subscription } from "@nestjs/graphql"; import { LibraryScannerService } from "@library/library.scanner.service"; +import { LibraryService } from "@library/library.service"; + +import { SearchSuggestion } from "@library/models/search-suggestion.model"; +import { SearchResult } from "@library/models/search-result.model"; @Resolver() export class LibraryResolver { - public constructor(@Inject(LibraryScannerService) private readonly libraryScannerService: LibraryScannerService) {} + public constructor( + @Inject(LibraryScannerService) private readonly libraryScannerService: LibraryScannerService, + @Inject(LibraryService) private readonly libraryService: LibraryService, + ) {} + + @Query(() => SearchResult) + public async search(@Args("query", { type: () => String }) query: string): Promise { + return this.libraryService.search(query); + } + + @Query(() => [SearchSuggestion]) + public async searchSuggestions(): Promise { + return this.libraryService.getSearchSuggestions(); + } @Mutation(() => Boolean) public async scanLibrary(): Promise { diff --git a/apps/main/src/library/library.service.spec.ts b/apps/main/src/library/library.service.spec.ts index 14132ed..0ea61f0 100644 --- a/apps/main/src/library/library.service.spec.ts +++ b/apps/main/src/library/library.service.spec.ts @@ -1,13 +1,34 @@ import { Test, TestingModule } from "@nestjs/testing"; import { LibraryService } from "@library/library.service"; +import { AlbumService } from "@album/album.service"; +import { MusicService } from "@music/music.service"; +import { ArtistService } from "@artist/artist.service"; describe("LibraryService", () => { let service: LibraryService; + let musicService: Record; + let albumService: Record; + let artistService: Record; beforeEach(async () => { + musicService = { + findAll: jest.fn().mockResolvedValue([{ id: 0, title: "title", artistIds: [0], albumId: 0 }]), + }; + albumService = { + findAll: jest.fn().mockResolvedValue([{ id: 0, title: "title", musicIds: [0] }]), + }; + artistService = { + findAll: jest.fn().mockResolvedValue([{ id: 0, name: "name", albumIds: [0], musicIds: [0] }]), + }; + const module: TestingModule = await Test.createTestingModule({ - providers: [LibraryService], + providers: [ + LibraryService, + { provide: MusicService, useValue: musicService }, + { provide: AlbumService, useValue: albumService }, + { provide: ArtistService, useValue: artistService }, + ], }).compile(); service = module.get(LibraryService); @@ -16,4 +37,26 @@ describe("LibraryService", () => { it("should be defined", () => { expect(service).toBeDefined(); }); + + it("should be able to get search suggestion items", async () => { + await service.getSearchSuggestions(); + + expect(musicService.findAll).toHaveBeenCalled(); + expect(albumService.findAll).toHaveBeenCalled(); + expect(artistService.findAll).toHaveBeenCalled(); + }); + + it("should be able to search library", async () => { + const result = await service.search("e"); + + expect(musicService.findAll).toHaveBeenCalled(); + expect(albumService.findAll).toHaveBeenCalled(); + expect(artistService.findAll).toHaveBeenCalled(); + + expect(result).toEqual({ + musics: [expect.objectContaining({ id: 0 })], + albums: [expect.objectContaining({ id: 0 })], + artists: [expect.objectContaining({ id: 0 })], + }); + }); }); diff --git a/apps/main/src/library/library.service.ts b/apps/main/src/library/library.service.ts index 253f382..daec66d 100644 --- a/apps/main/src/library/library.service.ts +++ b/apps/main/src/library/library.service.ts @@ -1,4 +1,98 @@ -import { Injectable } from "@nestjs/common"; +import _ from "lodash"; + +import { Inject, Injectable } from "@nestjs/common"; + +import { MusicService } from "@music/music.service"; +import { ArtistService } from "@artist/artist.service"; +import { AlbumService } from "@album/album.service"; + +import { SearchSuggestion, SearchSuggestionType } from "@library/models/search-suggestion.model"; +import { SearchResult } from "@library/models/search-result.model"; @Injectable() -export class LibraryService {} +export class LibraryService { + public constructor( + @Inject(MusicService) private readonly musicService: MusicService, + @Inject(ArtistService) private readonly artistService: ArtistService, + @Inject(AlbumService) private readonly albumService: AlbumService, + ) {} + + public async getSearchSuggestions(): Promise { + const musics = await this.musicService.findAll(); + const artists = await this.artistService.findAll(); + const albums = await this.albumService.findAll(); + + return [ + ...musics + .map(music => ({ id: music.id, title: music.title, type: SearchSuggestionType.Music })) + .filter((item): item is SearchSuggestion => Boolean(item.title)), + ...artists.map(artist => ({ id: artist.id, title: artist.name, type: SearchSuggestionType.Artist })), + ...albums.map(album => ({ id: album.id, title: album.title, type: SearchSuggestionType.Album })), + ]; + } + + public async search(query: string) { + const result = new SearchResult(); + const musics = await this.musicService.findAll(); + const artists = await this.artistService.findAll(); + const albums = await this.albumService.findAll(); + + const musicMap = _.chain(musics).keyBy("id").mapValues().value(); + const artistMap = _.chain(artists).keyBy("id").mapValues().value(); + const albumMap = _.chain(albums).keyBy("id").mapValues().value(); + + query = query.toLowerCase(); + + const matchedMusics = _.chain(musics) + .filter(m => !!m.title?.toLowerCase()?.includes(query)) + .value(); + + const matchedArtists = _.chain(artists) + .filter(a => !!a.name?.toLowerCase()?.includes(query)) + .value(); + + const matchedAlbums = _.chain(albums) + .filter(a => !!a.title?.toLowerCase()?.includes(query)) + .value(); + + for (const album of matchedAlbums) { + matchedMusics.push( + ..._.chain(album.musicIds) + .map(id => musicMap[id]) + .value(), + ); + } + + for (const artist of matchedArtists) { + matchedAlbums.push( + ..._.chain(artist.albumIds) + .map(id => albumMap[id]) + .value(), + ); + + matchedMusics.push( + ..._.chain(artist.musicIds) + .map(id => musicMap[id]) + .value(), + ); + } + + for (const music of matchedMusics) { + matchedArtists.push( + ..._.chain(music.artistIds) + .map(id => artistMap[id]) + .value(), + ); + + if (music.albumId) { + matchedAlbums.push(albumMap[music.albumId]); + } + } + + result.musics = _.chain(matchedMusics).uniqBy("id").value(); + result.artists = _.chain(matchedArtists).uniqBy("id").value(); + result.albums = _.chain(matchedAlbums).uniqBy("id").value(); + + return result; + } +} diff --git a/apps/main/src/library/models/search-result.model.ts b/apps/main/src/library/models/search-result.model.ts new file mode 100644 index 0000000..9202cb8 --- /dev/null +++ b/apps/main/src/library/models/search-result.model.ts @@ -0,0 +1,17 @@ +import { Field, ObjectType } from "@nestjs/graphql"; + +import { Music } from "@music/models/music.model"; +import { Artist } from "@artist/models/artist.model"; +import { Album } from "@album/models/album.model"; + +@ObjectType() +export class SearchResult { + @Field(() => [Music]) + public musics!: Music[]; + + @Field(() => [Artist]) + public artists!: Artist[]; + + @Field(() => [Album]) + public albums!: Album[]; +} diff --git a/apps/main/src/library/models/search-suggestion.model.ts b/apps/main/src/library/models/search-suggestion.model.ts new file mode 100644 index 0000000..db34b7e --- /dev/null +++ b/apps/main/src/library/models/search-suggestion.model.ts @@ -0,0 +1,21 @@ +import { Field, Int, ObjectType, registerEnumType } from "@nestjs/graphql"; + +export enum SearchSuggestionType { + Music = "Music", + Album = "Album", + Artist = "Artist", +} + +registerEnumType(SearchSuggestionType, { name: "SearchSuggestionType" }); + +@ObjectType() +export class SearchSuggestion { + @Field(() => Int) + public id!: number; + + @Field(() => String) + public title!: string; + + @Field(() => SearchSuggestionType) + public type!: SearchSuggestionType; +} diff --git a/apps/renderer/src/components/AlbumArtist/List.tsx b/apps/renderer/src/components/AlbumArtist/List.tsx index 0ec53ad..50417ae 100644 --- a/apps/renderer/src/components/AlbumArtist/List.tsx +++ b/apps/renderer/src/components/AlbumArtist/List.tsx @@ -56,7 +56,7 @@ export function AlbumArtistList({ items, onPlayItem, type }: AlbumArtistListProp key={item.id} item={item} onPlay={onPlayItem} - onSelectChange={handleSelectChange} + onSelectChange={selection ? handleSelectChange : undefined} /> ))} diff --git a/apps/renderer/src/components/Layout/SideBar.tsx b/apps/renderer/src/components/Layout/SideBar.tsx index 1638cb0..f8d8983 100644 --- a/apps/renderer/src/components/Layout/SideBar.tsx +++ b/apps/renderer/src/components/Layout/SideBar.tsx @@ -12,6 +12,7 @@ import AddRoundedIcon from "@mui/icons-material/AddRounded"; import DeleteIcon from "@mui/icons-material/Delete"; import AlbumRoundedIcon from "@mui/icons-material/AlbumRounded"; import PeopleAltRoundedIcon from "@mui/icons-material/PeopleAltRounded"; +import SearchRoundedIcon from "@mui/icons-material/SearchRounded"; import { ScrollbarThumb } from "@components/ScrollbarThumb"; import { Content, Root } from "@components/Layout/SideBar.styles"; @@ -32,6 +33,12 @@ export function SideBar() { label: t("pages.home"), icon: , }, + { + id: "/search", + type: "button", + label: t("pages.search"), + icon: , + }, { id: "/settings", type: "button", diff --git a/apps/renderer/src/components/Layout/index.styles.tsx b/apps/renderer/src/components/Layout/index.styles.tsx index e978768..9374368 100644 --- a/apps/renderer/src/components/Layout/index.styles.tsx +++ b/apps/renderer/src/components/Layout/index.styles.tsx @@ -10,6 +10,8 @@ export const GlobalStyles = css` body, #app { height: 100vh; + + overflow: hidden; } @font-face { diff --git a/apps/renderer/src/components/Library/index.ts b/apps/renderer/src/components/Library/index.ts index 2451a24..fe85d0a 100644 --- a/apps/renderer/src/components/Library/index.ts +++ b/apps/renderer/src/components/Library/index.ts @@ -13,6 +13,8 @@ import { executeDeletePlaylist, executeDeletePlaylistItems, executeRenamePlaylist, + querySearch, + querySearchSuggestions, useAlbumQuery, useAlbumsQuery, useArtistQuery, @@ -47,6 +49,28 @@ export class Library { this.dialog = dialog; } + public async search(query: string) { + const { data, error } = await querySearch(this.client, { + variables: { query }, + }); + + if (error) { + throw error; + } else if (!data) { + throw new Error(this.t("search.errors.undefined-error")); + } + + return data.search; + } + public async getSearchSuggestions() { + const { data, error } = await querySearchSuggestions(this.client); + if (error) { + throw error; + } + + return data?.searchSuggestions ?? []; + } + public useAlbum(id: number) { const { data, error } = useAlbumQuery({ variables: { id } }); diff --git a/apps/renderer/src/components/Page/index.tsx b/apps/renderer/src/components/Page/index.tsx index 245b684..de49e31 100644 --- a/apps/renderer/src/components/Page/index.tsx +++ b/apps/renderer/src/components/Page/index.tsx @@ -13,9 +13,18 @@ export interface PageProps { headerRef?: React.Ref; headerPosition?: "fixed" | "sticky"; toolbar?: React.ReactNode; + contentKey?: string; } -export function Page({ children, header, headerRef, loading = false, headerPosition = "sticky", toolbar }: PageProps) { +export function Page({ + children, + header, + headerRef, + loading = false, + headerPosition = "sticky", + toolbar, + contentKey, +}: PageProps) { const [ref, { height: headerHeight }] = useMeasure(); const [initialHeight, setInitialHeight] = React.useState(null); @@ -71,10 +80,23 @@ export function Page({ children, header, headerRef, loading = false, headerPosit return ( {headerNode} - - {toolbar && } - {content} - + {loading && ( + + {content} + + )} + {!loading && ( + + {toolbar && } + {content} + + )} ); } diff --git a/apps/renderer/src/components/SearchInput.styles.tsx b/apps/renderer/src/components/SearchInput.styles.tsx new file mode 100644 index 0000000..5d5954a --- /dev/null +++ b/apps/renderer/src/components/SearchInput.styles.tsx @@ -0,0 +1,74 @@ +import styled from "@emotion/styled"; +import { backgroundColors } from "ui"; + +export const Root = styled.div` + width: 100%; + height: ${({ theme }) => theme.spacing(5.5)}; + + margin: 0; + padding: ${({ theme }) => theme.spacing(0, 6)}; + border: 0; + border-radius: ${({ theme }) => theme.spacing(2.75)}; + + position: relative; + + &:has(> input:focus-visible) { + box-shadow: inset 0 0 0 2px ${({ theme }) => theme.palette.primary.main}; + } + + ${({ theme }) => theme.getColorSchemeSelector("dark")} { + color: white; + background-color: ${backgroundColors["950"]}; + } + + ${({ theme }) => theme.getColorSchemeSelector("light")} { + border: 1px solid ${({ theme }) => theme.palette.divider}; + + color: black; + background-color: ${backgroundColors["50"]}; + } +`; + +export const Icon = styled.div` + position: absolute; + top: 0; + left: ${({ theme }) => theme.spacing(2)}; + bottom: 0; + + display: flex; + align-items: center; + + color: ${({ theme }) => theme.palette.text.disabled}; +`; + +export const Loading = styled.div` + position: absolute; + top: 0; + right: ${({ theme }) => theme.spacing(2)}; + bottom: 0; + + display: flex; + align-items: center; + + transition: ${({ theme }) => theme.transitions.create("opacity")}; +`; + +export const Input = styled.input` + width: 100%; + height: ${({ theme }) => theme.spacing(5.5)}; + + margin: 0; + padding: 0; + border: 0; + + display: block; + + font-family: inherit; + font-size: inherit; + font-weight: inherit; + + color: inherit; + background-color: transparent; + + outline: none; +`; diff --git a/apps/renderer/src/components/SearchInput.tsx b/apps/renderer/src/components/SearchInput.tsx new file mode 100644 index 0000000..53a9f73 --- /dev/null +++ b/apps/renderer/src/components/SearchInput.tsx @@ -0,0 +1,28 @@ +import React from "react"; + +import { CircularProgress } from "@mui/material"; +import SearchRoundedIcon from "@mui/icons-material/SearchRounded"; + +import { Icon, Input, Loading, Root } from "@components/SearchInput.styles"; + +interface SearchInputProps extends React.InputHTMLAttributes { + loading: boolean; +} + +export const SearchInput = React.forwardRef( + ({ loading, ...props }: SearchInputProps, ref: React.Ref) => { + return ( + + + + + + + + + + ); + }, +); + +SearchInput.displayName = "SearchInput"; diff --git a/apps/renderer/src/components/SearchSection.tsx b/apps/renderer/src/components/SearchSection.tsx new file mode 100644 index 0000000..b35a005 --- /dev/null +++ b/apps/renderer/src/components/SearchSection.tsx @@ -0,0 +1,65 @@ +import React from "react"; + +import { Box, Button, Typography } from "@mui/material"; + +import { MusicList } from "@components/MusicList"; +import { AlbumArtistList } from "@components/AlbumArtist/List"; + +import { FullArtist, MinimalAlbum, MinimalMusic } from "@utils/types"; +import { useTranslation } from "react-i18next"; + +export interface BaseSearchSectionProps { + title: string; + count: number; + maxCount?: number; + onShowMore?(): void; +} + +export interface MusicSearchSectionProps extends BaseSearchSectionProps { + type: "music"; + items: MinimalMusic[]; +} + +export interface ArtistSearchSectionProps extends BaseSearchSectionProps { + type: "artist"; + items: FullArtist[]; + onPlayItem: (item: FullArtist) => void; +} + +export interface AlbumSearchSectionProps extends BaseSearchSectionProps { + type: "album"; + items: MinimalAlbum[]; + onPlayItem: (item: MinimalAlbum) => void; +} + +export type SearchSectionProps = MusicSearchSectionProps | ArtistSearchSectionProps | AlbumSearchSectionProps; + +export function SearchSection(props: SearchSectionProps) { + const { title, count, maxCount = props.items.length, onShowMore } = props; + const { t } = useTranslation(); + const items = React.useMemo(() => props.items.slice(0, maxCount), [maxCount, props.items]); + + return ( + + + + {title} ({count}) + + {count > maxCount && ( + + )} + + + {props.type === "music" && } + {props.type === "album" && ( + + )} + {props.type === "artist" && ( + + )} + + + ); +} diff --git a/apps/renderer/src/pages/Home.tsx b/apps/renderer/src/pages/Home.tsx index dedc326..d2b340d 100644 --- a/apps/renderer/src/pages/Home.tsx +++ b/apps/renderer/src/pages/Home.tsx @@ -6,5 +6,5 @@ import { Page } from "@components/Page"; export function Home() { const { t } = useTranslation(); - return Hello World!; + return ; } diff --git a/apps/renderer/src/pages/Search.tsx b/apps/renderer/src/pages/Search.tsx new file mode 100644 index 0000000..db3f0d2 --- /dev/null +++ b/apps/renderer/src/pages/Search.tsx @@ -0,0 +1,195 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; + +import { Autocomplete, AutocompleteController, ChipRadio, ChipRadioItem } from "ui"; + +import { Box, Stack, Typography } from "@mui/material"; +import PeopleAltRoundedIcon from "@mui/icons-material/PeopleAltRounded"; +import MusicNoteRoundedIcon from "@mui/icons-material/MusicNoteRounded"; +import AlbumRoundedIcon from "@mui/icons-material/AlbumRounded"; + +import { Page } from "@components/Page"; +import { SearchInput } from "@components/SearchInput"; +import { useLibrary } from "@components/Library/context"; +import { usePlayer } from "@components/Player/context"; +import { SearchSection } from "@components/SearchSection"; + +import { MinimalAlbum, SearchResult, SearchSuggestionItem } from "@utils/types"; +import { FullArtistFragment, SearchSuggestionType } from "@graphql/queries"; + +export interface SearchProps {} + +export enum SearchType { + All = "all", + Musics = "musics", + Artists = "artists", + Albums = "albums", +} + +export function Search({}: SearchProps) { + const { t } = useTranslation(); + const player = usePlayer(); + const library = useLibrary(); + const lastQuery = React.useRef(""); + const [searchResult, setSearchResult] = React.useState(null); + const [isSearching, setIsSearching] = React.useState(false); + const [searchType, setSearchType] = React.useState(SearchType.All); + const searchTypeItems = React.useMemo[]>(() => { + return [ + { label: t("common.all"), value: SearchType.All }, + { label: t("pages.musics"), value: SearchType.Musics }, + { label: t("pages.albums"), value: SearchType.Albums }, + { label: t("pages.artists"), value: SearchType.Artists }, + ]; + }, [t]); + + const getItems = React.useCallback(async () => { + return library.getSearchSuggestions(); + }, [library]); + + const getItemIcon = React.useCallback((item: SearchSuggestionItem) => { + switch (item.type) { + case SearchSuggestionType.Artist: + return ; + + case SearchSuggestionType.Album: + return ; + + case SearchSuggestionType.Music: + return ; + } + }, []); + + const handleKeyDown = React.useCallback( + async (event: React.KeyboardEvent, controller: AutocompleteController) => { + if (event.key !== "Enter") { + return; + } + + controller.clearInput(); + + const query = event.currentTarget.value; + if (!query || query === lastQuery.current) { + return; + } + + lastQuery.current = query; + + setIsSearching(true); + setSearchResult(null); + + const data = await library.search(query); + + setIsSearching(false); + setSearchResult(data); + }, + [library], + ); + + const handlePlayAlbum = React.useCallback( + (item: MinimalAlbum) => { + player.playPlaylist(item.musics, 0); + }, + [player], + ); + + const handlePlayArtist = React.useCallback( + (item: FullArtistFragment) => { + player.playPlaylist(item.musics, 0); + }, + [player], + ); + + const header = ( + <> + + {t("pages.search")} + + + item.title} + getItemKey={item => `${item.type}_${item.id}`} + renderInput={(props, loading) => } + onKeyDown={handleKeyDown} + /> + + + + ); + + let totalCounts = 0; + if (searchResult) { + if (searchType === SearchType.Musics || searchType === SearchType.All) { + totalCounts += searchResult.musics.length; + } + + if (searchType === SearchType.Albums || searchType === SearchType.All) { + totalCounts += searchResult.albums.length; + } + + if (searchType === SearchType.Artists || searchType === SearchType.All) { + totalCounts += searchResult.artists.length; + } + } + + return ( + + {searchResult && totalCounts > 0 && ( + + {searchResult.musics.length > 0 && + (searchType === SearchType.Musics || searchType === SearchType.All) && ( + setSearchType(SearchType.Musics)} + /> + )} + {searchResult.albums.length > 0 && + (searchType === SearchType.Albums || searchType === SearchType.All) && ( + setSearchType(SearchType.Albums)} + /> + )} + {searchResult.artists.length > 0 && + (searchType === SearchType.Artists || searchType === SearchType.All) && ( + setSearchType(SearchType.Artists)} + /> + )} + + )} + {searchResult && totalCounts === 0 && ( + + + {t("search.messages.no-result", { query: lastQuery.current })} + + + )} + {!searchResult && ( + + + {t("search.messages.not-searched")} + + + )} + + ); +} diff --git a/apps/renderer/src/pages/index.tsx b/apps/renderer/src/pages/index.tsx index b091239..9d55c77 100644 --- a/apps/renderer/src/pages/index.tsx +++ b/apps/renderer/src/pages/index.tsx @@ -12,11 +12,13 @@ import { Albums } from "@pages/Albums"; import { Album } from "@pages/Album"; import { Artists } from "@pages/Artists"; import { Artist } from "@pages/Artist"; +import { Search } from "@pages/Search"; const router = createHashRouter( createRoutesFromElements( }> } /> + } /> } /> } /> } /> diff --git a/apps/renderer/src/queries/library.graphql b/apps/renderer/src/queries/library.graphql index 1dff0a7..a5e6b71 100644 --- a/apps/renderer/src/queries/library.graphql +++ b/apps/renderer/src/queries/library.graphql @@ -1,3 +1,33 @@ +fragment FullSearchSuggestion on SearchSuggestion { + id + type + title +} + +fragment FullSearchResult on SearchResult { + albums { + ...MinimalAlbum + } + musics { + ...MinimalMusic + } + artists { + ...FullArtist + } +} + +query searchSuggestions { + searchSuggestions { + ...FullSearchSuggestion + } +} + +query search($query: String!) { + search(query: $query) { + ...FullSearchResult + } +} + mutation scanLibrary { scanLibrary } diff --git a/apps/renderer/src/utils/types.ts b/apps/renderer/src/utils/types.ts index e1debba..d9c6454 100644 --- a/apps/renderer/src/utils/types.ts +++ b/apps/renderer/src/utils/types.ts @@ -1,6 +1,8 @@ import { ConfigDataFragment, FullArtistFragment, + FullSearchResultFragment, + FullSearchSuggestionFragment, MinimalAlbumArtFragment, MinimalAlbumFragment, MinimalArtistFragment, @@ -18,3 +20,5 @@ export type MinimalPlaylist = FromGraphQL; export type MinimalAlbum = FromGraphQL; export type MinimalArtist = FromGraphQL; export type FullArtist = FromGraphQL; +export type SearchSuggestionItem = FromGraphQL; +export type SearchResult = FromGraphQL; diff --git a/locales/en/translation.json b/locales/en/translation.json index 51480a7..28cff35 100644 --- a/locales/en/translation.json +++ b/locales/en/translation.json @@ -2,8 +2,10 @@ "pages": { "home": "Home", "settings": "Settings", + "search": "Search", "musics": "Musics", - "albums": "Albums" + "albums": "Albums", + "artists": "Artists" }, "settings": { "library": { @@ -64,7 +66,9 @@ "shuffle-all": "Shuffle All", "album-list": "Album List", "artist-list": "Artist List", - "track-list": "Track List" + "track-list": "Track List", + "show-more": "Show More", + "all": "All" }, "playlist": { "create": { @@ -110,5 +114,14 @@ "title-tooLong": "The new name of the playlist cannot be longer than 20 characters." } } + }, + "search": { + "errors": { + "undefined-error": "Unknown error has occurred during search library." + }, + "messages": { + "not-searched": "To search for find library items...", + "no-result": "There was no search result with '{{query}}'." + } } } diff --git a/locales/ko/translation.json b/locales/ko/translation.json index 3758c82..1157053 100644 --- a/locales/ko/translation.json +++ b/locales/ko/translation.json @@ -2,6 +2,7 @@ "pages": { "home": "홈", "settings": "설정", + "search": "검색", "musics": "음악", "albums": "앨범", "artists": "아티스트" @@ -65,7 +66,9 @@ "shuffle-all": "무작위 재생", "album-list": "앨범 목록", "artist-list": "아티스트 목록", - "track-list": "곡 목록" + "track-list": "곡 목록", + "show-more": "더 보기", + "all": "전체" }, "playlist": { "create": { @@ -116,5 +119,14 @@ "success": "재생목록 항목이 삭제되었습니다.", "error": "재생목록 항목 삭제 중 오류가 발생했습니다." } + }, + "search": { + "errors": { + "undefined-error": "검색 도중 알 수 없는 오류가 발생 하였습니다." + }, + "messages": { + "not-searched": "검색하여 라이브러리 탐색...", + "no-result": "'{{query}}'에 대한 검색 결과가 없습니다." + } } } diff --git a/packages/ui/package.json b/packages/ui/package.json index a0be6a0..91cc199 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -19,6 +19,7 @@ "@tanstack/react-virtual": "3.0.0-beta.54", "material-ui-popup-state": "^5.0.9", "nanoid": "^4.0.2", + "rc-scrollbars": "^1.1.6", "react": "^18.2.0", "react-hook-form": "^7.45.4", "react-use-measure": "^2.1.1", diff --git a/packages/ui/src/Autocomplete/Autocomplete.spec.tsx b/packages/ui/src/Autocomplete/Autocomplete.spec.tsx new file mode 100644 index 0000000..30265d5 --- /dev/null +++ b/packages/ui/src/Autocomplete/Autocomplete.spec.tsx @@ -0,0 +1,235 @@ +import React from "react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; + +import { Autocomplete, AutocompleteController } from "./Autocomplete"; +import { Wrapper } from "../../__test__/Wrapper"; + +describe("", () => { + it("should render Autocomplete component properly", () => { + render( } />, { + wrapper: Wrapper, + }); + + const item = screen.getByTestId("Autocomplete"); + expect(item).toBeInTheDocument(); + }); + + it("should show suggestion items when input is focused", () => { + render( + item.label} + items={[{ label: "test", value: "test" }]} + renderInput={props => } + />, + { wrapper: Wrapper }, + ); + + const input = screen.getByTestId("Input"); + + act(() => { + fireEvent.focus(input); + }); + + act(() => { + fireEvent.input(input, { target: { value: "test" } }); + }); + + const item = screen.getByTestId("suggestion-option"); + expect(item).toBeInTheDocument(); + }); + + it("should call item resolving function if a function is passed", async () => { + const getItems = jest.fn().mockResolvedValueOnce([{ label: "test", value: "test" }]); + + render( + + getItemLabel={item => item.label} + items={getItems} + renderInput={props => } + />, + { wrapper: Wrapper }, + ); + + const input = screen.getByTestId("Input"); + + await act(async () => { + fireEvent.focus(input); + }); + + act(() => { + fireEvent.input(input, { target: { value: "test" } }); + }); + + expect(getItems).toHaveBeenCalledTimes(1); + }); + + it("should pass loading state to input renderer function", async () => { + const getItems = jest.fn().mockImplementation(async () => { + await new Promise(resolve => setTimeout(resolve, 100)); + return [{ label: "test", value: "test" }]; + }); + + const renderInput = jest.fn(props => ); + + render( + + getItemLabel={item => item.label} + items={getItems} + renderInput={renderInput} + />, + { wrapper: Wrapper }, + ); + + const input = screen.getByTestId("Input"); + + await act(async () => { + fireEvent.focus(input); + }); + + act(() => { + fireEvent.input(input, { target: { value: "test" } }); + }); + + expect(getItems).toHaveBeenCalledTimes(1); + expect(renderInput).toHaveBeenCalledWith(expect.any(Object), true); + }); + + it("should use custom item key resolver function if provided", async () => { + const getItems = jest.fn().mockResolvedValueOnce([{ label: "test", value: "test" }]); + const getItemKey = jest.fn().mockReturnValue("test"); + + render( + + getItemLabel={item => item.label} + items={getItems} + getItemKey={getItemKey} + renderInput={props => } + />, + { wrapper: Wrapper }, + ); + + const input = screen.getByTestId("Input"); + + await act(async () => { + fireEvent.focus(input); + }); + + act(() => { + fireEvent.input(input, { target: { value: "test" } }); + }); + + expect(getItemKey).toHaveBeenCalled(); + }); + + it("should be able to render auto completion item with icons if function is provided", async () => { + const getItems = jest.fn().mockResolvedValueOnce([{ label: "test", value: "test" }]); + const getItemIcon = jest.fn().mockReturnValue(test); + + render( + + getItemLabel={item => item.label} + items={getItems} + getItemIcon={getItemIcon} + renderInput={props => } + />, + { wrapper: Wrapper }, + ); + + const input = screen.getByTestId("Input"); + + await act(async () => { + fireEvent.focus(input); + }); + + act(() => { + fireEvent.input(input, { target: { value: "test" } }); + }); + + const item = screen.getByTestId("test"); + expect(item).toBeInTheDocument(); + }); + + it("should hide auto completion dropdown if input control is blurred", async () => { + render( + item.label} + items={[{ label: "test", value: "test" }]} + renderInput={props => } + />, + { wrapper: Wrapper }, + ); + + const input = screen.getByTestId("Input"); + + act(() => { + fireEvent.focus(input); + }); + + act(() => { + fireEvent.blur(input); + }); + + const item = screen.queryByTestId("suggestion-option"); + expect(item).not.toBeInTheDocument(); + }); + + it("should call onKeyDown event handler if provided", async () => { + const onKeyDown = jest.fn(); + + render( + item.label} + items={[{ label: "test", value: "test" }]} + onKeyDown={onKeyDown} + renderInput={props => } + />, + { wrapper: Wrapper }, + ); + + const input = screen.getByTestId("Input"); + + act(() => { + fireEvent.focus(input); + }); + + act(() => { + fireEvent.keyDown(input, { key: "Enter" }); + }); + + expect(onKeyDown).toHaveBeenCalled(); + }); + + it("should be able to clear input value through controller", async () => { + const onKeyDown = jest + .fn() + .mockImplementation((e: React.KeyboardEvent, controller: AutocompleteController) => { + if (e.key === "Enter") { + controller.clearInput(); + } + }); + + render( + item.label} + items={[{ label: "test", value: "test" }]} + onKeyDown={onKeyDown} + renderInput={props => } + />, + { wrapper: Wrapper }, + ); + + const input = screen.getByTestId("Input"); + + act(() => { + fireEvent.focus(input); + }); + + act(() => { + fireEvent.input(input, { target: { value: "test" } }); + fireEvent.keyDown(input, { key: "Enter" }); + }); + + expect(onKeyDown).toHaveBeenCalled(); + expect(input).toHaveValue(""); + }); +}); diff --git a/packages/ui/src/Autocomplete/Autocomplete.styles.tsx b/packages/ui/src/Autocomplete/Autocomplete.styles.tsx new file mode 100644 index 0000000..1c9e9ce --- /dev/null +++ b/packages/ui/src/Autocomplete/Autocomplete.styles.tsx @@ -0,0 +1,57 @@ +import styled from "@emotion/styled"; +import { backgroundColors } from "../theme"; + +export const Root = styled.div<{ fullWidth: boolean }>` + margin: 0; + padding: 0; + + position: relative; + display: ${({ fullWidth }) => (fullWidth ? "block" : "inline-block")}; +`; + +export const List = styled.ul` + max-height: ${({ theme }) => theme.spacing(40)}; + + margin: ${({ theme }) => theme.spacing(1, 0)}; + padding: ${({ theme }) => theme.spacing(0.5)}; + border-radius: ${({ theme }) => theme.shape.borderRadius}px; + + overflow-y: auto; + + ${({ theme }) => theme.getColorSchemeSelector("dark")} { + background-color: ${backgroundColors["950"]}; + } + + ${({ theme }) => theme.getColorSchemeSelector("light")} { + background-color: ${backgroundColors["50"]}; + } +`; + +export const Option = styled.li` + margin: 0; + padding: ${({ theme }) => theme.spacing(1)}; + border-radius: ${({ theme }) => theme.shape.borderRadius}px; + + list-style: none; + + display: flex; + align-items: center; + + font-size: ${({ theme }) => theme.typography.body2.fontSize}; + color: ${({ theme }) => theme.palette.text.secondary}; + + &:hover, + &.Mui-focused, + &.Mui-focusVisible { + color: ${({ theme }) => theme.palette.text.primary}; + background-color: ${({ theme }) => theme.palette.action.hover}; + } +`; + +export const Icon = styled.div` + margin-right: ${({ theme }) => theme.spacing(1)}; + + display: flex; + align-items: center; + justify-content: center; +`; diff --git a/packages/ui/src/Autocomplete/Autocomplete.tsx b/packages/ui/src/Autocomplete/Autocomplete.tsx new file mode 100644 index 0000000..2397920 --- /dev/null +++ b/packages/ui/src/Autocomplete/Autocomplete.tsx @@ -0,0 +1,175 @@ +import React from "react"; +import useMeasure from "react-use-measure"; + +import { AsyncFn, Fn, Nullable } from "types"; + +import { createFilterOptions, Popper, Typography, useAutocomplete } from "@mui/material"; + +import { Icon, List, Option, Root } from "./Autocomplete.styles"; +import { mergeRefs } from "../utils/mergeRefs"; + +export interface AutocompleteController { + clearInput(): void; +} + +export interface BaseAutocompleteProps { + getItemLabel: Fn<[TItem], string>; + getItemIcon?: Fn<[TItem], React.ReactNode>; + getItemKey?: Fn<[TItem], string>; + renderInput: Fn<[React.InputHTMLAttributes, boolean], React.ReactNode>; + fullWidth?: boolean; + + onKeyDown?(e: React.KeyboardEvent, controller: AutocompleteController): void; +} + +export interface StaticAutocompleteProps extends BaseAutocompleteProps { + items: ReadonlyArray; +} +export interface AsyncAutocompleteProps extends BaseAutocompleteProps { + items: AsyncFn<[], TItem[]>; +} + +export type AutocompleteProps = StaticAutocompleteProps | AsyncAutocompleteProps; + +export function Autocomplete({ + items, + getItemLabel, + renderInput, + fullWidth = false, + getItemIcon, + getItemKey, + onKeyDown, +}: AutocompleteProps) { + const [currentItems, setCurrentItems] = React.useState>>(null); + const [rootRef, setRootRef] = React.useState>(null); + const [measureRef, { width }] = useMeasure(); + const [loading, setLoading] = React.useState(false); + const [focused, setFocused] = React.useState(false); + const [inputValue, setInputValue] = React.useState(""); + + React.useEffect(() => { + if (!focused || currentItems !== null) { + return; + } + + let removed = false; + if (typeof items === "function") { + setLoading(true); + items().then(items => { + if (removed) { + return; + } + + setCurrentItems(items); + setLoading(false); + }); + } else { + setCurrentItems(items); + } + + return () => { + removed = true; + }; + }, [items, focused, currentItems]); + + const { getRootProps, getInputProps, getListboxProps, getOptionProps, groupedOptions } = useAutocomplete({ + options: currentItems ?? [], + getOptionLabel: getItemLabel, + clearOnBlur: false, + clearOnEscape: false, + autoComplete: true, + inputValue, + onInputChange: (_, value) => setInputValue(value), + filterOptions: createFilterOptions({ + limit: 50, + matchFrom: "start", + }), + }); + + const clearInput = React.useCallback(() => { + setInputValue(""); + }, []); + + const controller = React.useMemo( + () => ({ + clearInput, + }), + [clearInput], + ); + + let inputProps = getInputProps(); + const oldFocus = inputProps.onFocus; + const handleFocus = React.useCallback( + (e: React.FocusEvent) => { + setFocused(true); + oldFocus?.(e); + }, + [oldFocus], + ); + + const oldBlur = inputProps.onBlur; + const handleBlur = React.useCallback( + (e: React.FocusEvent) => { + setFocused(false); + oldBlur?.(e); + }, + [oldBlur], + ); + + const oldKeyDown = inputProps.onKeyDown; + const handleKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + onKeyDown?.(e, controller); + oldKeyDown?.(e); + }, + [oldKeyDown, onKeyDown, controller], + ); + + inputProps = { + ...inputProps, + onFocus: handleFocus, + onBlur: handleBlur, + onKeyDown: handleKeyDown, + }; + + const opened = groupedOptions.length > 0; + const rootRefObject = mergeRefs([measureRef, setRootRef as Fn<[HTMLDivElement | null]>]); + + return ( + <> + + {renderInput(inputProps, loading)} + + + + {(groupedOptions as TItem[]).map((option, index) => { + const props: React.HTMLAttributes & { key?: string } = getOptionProps({ + option, + index, + }); + + if (getItemKey) { + props.key = getItemKey(option); + } + + return ( + + ); + })} + + + + ); +} diff --git a/packages/ui/src/Autocomplete/index.ts b/packages/ui/src/Autocomplete/index.ts new file mode 100644 index 0000000..2e951eb --- /dev/null +++ b/packages/ui/src/Autocomplete/index.ts @@ -0,0 +1 @@ +export * from "./Autocomplete"; diff --git a/packages/ui/src/ChipRadio/ChipRadio.spec.tsx b/packages/ui/src/ChipRadio/ChipRadio.spec.tsx new file mode 100644 index 0000000..6df004f --- /dev/null +++ b/packages/ui/src/ChipRadio/ChipRadio.spec.tsx @@ -0,0 +1,93 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; + +import { ChipRadio } from "./ChipRadio"; +import { Wrapper } from "../../__test__/Wrapper"; + +describe("", () => { + it("should render ChipRadio component properly", () => { + render(, { + wrapper: Wrapper, + }); + + const item = screen.getByTestId("ChipRadio"); + expect(item).toBeInTheDocument(); + }); + + it("should able to render given items correctly", () => { + render( + , + { wrapper: Wrapper }, + ); + + const item1 = screen.getByText("item1"); + const item2 = screen.getByText("item2"); + + expect(item1).toBeInTheDocument(); + expect(item2).toBeInTheDocument(); + }); + + it("should call onChange function when item is clicked", () => { + const onChange = jest.fn(); + render( + , + { wrapper: Wrapper }, + ); + + const item1 = screen.getByText("item1"); + const item2 = screen.getByText("item2"); + + item1.click(); + item2.click(); + + expect(onChange).toHaveBeenNthCalledWith(1, "item1"); + expect(onChange).toHaveBeenNthCalledWith(2, "item2"); + }); + + it("should not call onChange function if the callback function is not given", () => { + render( + , + { wrapper: Wrapper }, + ); + + const item1 = screen.getByText("item1"); + const item2 = screen.getByText("item2"); + + expect(() => { + item1.click(); + item2.click(); + }).not.toThrow(); + }); + + it("should highlights selected item", () => { + render( + , + { wrapper: Wrapper }, + ); + + const selectedItem = screen.getByTestId("chip-radio-item-selected"); + expect(selectedItem).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/ChipRadio/ChipRadio.styles.tsx b/packages/ui/src/ChipRadio/ChipRadio.styles.tsx new file mode 100644 index 0000000..b267235 --- /dev/null +++ b/packages/ui/src/ChipRadio/ChipRadio.styles.tsx @@ -0,0 +1,13 @@ +import styled from "@emotion/styled"; + +export const Root = styled.div` + margin: 0; + padding: 0; + + display: flex; + flex-wrap: wrap; + + > .MuiChip-root { + margin-right: ${({ theme }) => theme.spacing(1)}; + } +`; diff --git a/packages/ui/src/ChipRadio/ChipRadio.tsx b/packages/ui/src/ChipRadio/ChipRadio.tsx new file mode 100644 index 0000000..09d3bf6 --- /dev/null +++ b/packages/ui/src/ChipRadio/ChipRadio.tsx @@ -0,0 +1,34 @@ +import React from "react"; + +import { Root } from "./ChipRadio.styles"; +import { Chip } from "@mui/material"; + +export interface ChipRadioItem { + label: string; + value: T; +} + +export interface ChipRadioProps { + items: ChipRadioItem[]; + value?: T; + onChange?(value: T): void; + size?: "small" | "medium"; +} + +export function ChipRadio({ items, value, onChange, size }: ChipRadioProps) { + return ( + + {items.map(item => ( + onChange?.(item.value)} + /> + ))} + + ); +} diff --git a/packages/ui/src/ChipRadio/index.ts b/packages/ui/src/ChipRadio/index.ts new file mode 100644 index 0000000..edacb9b --- /dev/null +++ b/packages/ui/src/ChipRadio/index.ts @@ -0,0 +1 @@ +export * from "./ChipRadio"; diff --git a/packages/ui/src/index.tsx b/packages/ui/src/index.tsx index 78970ae..476ec0c 100644 --- a/packages/ui/src/index.tsx +++ b/packages/ui/src/index.tsx @@ -10,3 +10,5 @@ export * from "./Dialog"; export * from "./theme"; export * from "./Button"; export * from "./IconTab"; +export * from "./Autocomplete"; +export * from "./ChipRadio"; diff --git a/packages/ui/src/utils/mergeRefs.ts b/packages/ui/src/utils/mergeRefs.ts new file mode 100644 index 0000000..b1271c0 --- /dev/null +++ b/packages/ui/src/utils/mergeRefs.ts @@ -0,0 +1,13 @@ +import type * as React from "react"; + +export function mergeRefs(refs: Array | React.LegacyRef>): React.RefCallback { + return value => { + refs.forEach(ref => { + if (typeof ref === "function") { + ref(value); + } else if (ref != null) { + (ref as React.MutableRefObject).current = value; + } + }); + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a374f4e..0dea539 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -520,6 +520,9 @@ importers: nanoid: specifier: ^4.0.2 version: 4.0.2 + rc-scrollbars: + specifier: ^1.1.6 + version: 1.1.6(react-dom@18.2.0)(react@18.2.0) react: specifier: ^18.2.0 version: 18.2.0 diff --git a/schema.graphql b/schema.graphql index 8ed3283..e1a468a 100644 --- a/schema.graphql +++ b/schema.graphql @@ -71,6 +71,24 @@ type Music { url: String! } +type SearchSuggestion { + id: Int! + title: String! + type: SearchSuggestionType! +} + +enum SearchSuggestionType { + Music + Album + Artist +} + +type SearchResult { + musics: [Music!]! + artists: [Artist!]! + albums: [Album!]! +} + type Playlist { id: Int! name: String! @@ -89,6 +107,8 @@ type Query { isMaximized: Boolean! isMinimized: Boolean! config: ConfigData! + search(query: String!): SearchResult! + searchSuggestions: [SearchSuggestion!]! playlist(id: Int!): Playlist playlists: [Playlist!]! }