diff --git a/package.json b/package.json index fc4f915..8d5b631 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "@tanstack/react-query-next-experimental": "^5.35.1", "@types/react-redux": "^7.1.33", "clsx": "^2.1.1", + "date-fns": "^3.6.0", + "date-fns-tz": "^3.1.3", "framer-motion": "^11.1.9", "isomorphic-fetch": "3.0.0", "ky": "^1.2.4", diff --git a/src/app/(main)/(secondary)/layout.tsx b/src/app/(main)/(secondary)/layout.tsx index fb4ea61..4be89be 100644 --- a/src/app/(main)/(secondary)/layout.tsx +++ b/src/app/(main)/(secondary)/layout.tsx @@ -1,5 +1,5 @@ import { PropsWithChildren } from 'react'; export default function Layout({ children }: PropsWithChildren) { - return
{children}
; + return
{children}
; } diff --git a/src/app/(main)/(secondary)/search/page.tsx b/src/app/(main)/(secondary)/search/page.tsx new file mode 100644 index 0000000..842126a --- /dev/null +++ b/src/app/(main)/(secondary)/search/page.tsx @@ -0,0 +1,100 @@ +import { SearchParams, SearchResults, SearchResultsType, SongVersions } from '@/types'; +import { API_DOMAIN } from '@/lib/constants'; +import { SimplePopover } from '@/components/Popover'; +import SearchBar from '@/components/search/SearchBar'; +import SearchFilterPill from '@/components/search/SearchFilterPill'; +import SearchResultsCpt from '@/components/search/SearchResults'; +import SearchSortByMenu from '@/components/search/SearchSortByMenu'; + +export default async function Page({ searchParams }: { searchParams: SearchParams }) { + let data: SearchResults | null = null; + let resultsType: SearchResultsType = searchParams.resultsType || 'all'; + let versionsData: SongVersions | null = null; + + if (searchParams.q) { + const response = await fetch(`${API_DOMAIN}/api/v2/search?q=${searchParams.q}`).then((res) => + res.json() + ); + + // API sometimes returns null instead of [] + data = { ...response, Songs: !response.Songs ? [] : response.Songs }; + } + + if (data?.Songs.length && searchParams.songUuid) { + const song = data.Songs.find(({ uuid }) => uuid === searchParams.songUuid); + + const versions = await fetch( + `${API_DOMAIN}/api/v3/artists/${song?.slim_artist?.uuid}/songs/${searchParams.songUuid}` + ).then((res) => res.json()); + + resultsType = 'versions'; + + versionsData = { + ...versions, + artistName: song?.slim_artist?.name || '', + artistSlug: song?.slim_artist?.slug || '', + }; + } + + return ( +
+ +
+ {data !== null && resultsType === 'versions' && ( +
+ Showing all versions of “{versionsData?.name}” by {versionsData?.artistName} +
+ )} + {data !== null && resultsType !== 'versions' && ( +
    +
  • + + All {data && `(${data?.Artists?.length + data?.Songs?.length})`} + +
  • +
  • + + Artists {data && `(${data?.Artists?.length})`} + +
  • +
  • + + Songs {data && `(${data?.Songs?.length})`} + +
  • +
+ )} + {resultsType === 'versions' && ( + } + position="bottom-end" + > + + + )} +
+ +
+ ); +} + +export const metadata = { + title: 'Search', +}; diff --git a/src/app/(main)/NavBar.tsx b/src/app/(main)/NavBar.tsx index c0da6aa..0167d09 100644 --- a/src/app/(main)/NavBar.tsx +++ b/src/app/(main)/NavBar.tsx @@ -32,6 +32,11 @@ export default async function NavBar() {
+
+ + SEARCH + +
TIH diff --git a/src/components/search/SearchBar.tsx b/src/components/search/SearchBar.tsx new file mode 100644 index 0000000..a7d6774 --- /dev/null +++ b/src/components/search/SearchBar.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { SearchResultsType } from '@/types'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import { FormEvent, useState, useTransition } from 'react'; + +export default function SearchBar({ resultsType }: { resultsType: SearchResultsType }) { + const pathname = usePathname(); + const router = useRouter(); + const searchParams = useSearchParams(); + const writableParams = new URLSearchParams(searchParams); + const [value, setValue] = useState(searchParams?.get('q')?.toString() || ''); + const [isPending, startTransition] = useTransition(); + + function handleSubmit(e?: FormEvent) { + if (e) { + e.preventDefault(); + } + + /** If there are query params, back button should go to /search, otherwise leave the search page */ + const shouldPush = writableParams.toString() === ''; + + if (value) { + writableParams.set('q', value); + } else { + writableParams.delete('q'); + } + + const path = `${pathname}?${writableParams.toString()}`; + + startTransition(() => { + if (shouldPush) { + router.push(path); + } else { + router.replace(path); + } + }); + } + + function clearSearch() { + writableParams.delete('q'); + writableParams.delete('resultsType'); + writableParams.delete('songUuid'); + writableParams.delete('sortBy'); + startTransition(() => { + setValue(''); + router.replace(`${pathname}?${writableParams.toString()}`); + }); + } + + return ( +
+ {resultsType === 'versions' ? ( + + ) : ( +
+ + setValue(e.target.value)} + /> + +
+ )} + + ); +} diff --git a/src/components/search/SearchFilterPill.tsx b/src/components/search/SearchFilterPill.tsx new file mode 100644 index 0000000..dbf514a --- /dev/null +++ b/src/components/search/SearchFilterPill.tsx @@ -0,0 +1,29 @@ +import Link from 'next/link'; +import { SearchParams, SearchResultsType } from '@/types'; +import React from 'react'; + +export default function SearchFilterPill({ + buttonType, + children, + resultsType, + searchParams, +}: { + buttonType: SearchResultsType; + children: React.ReactNode; + resultsType: SearchResultsType; + searchParams: SearchParams; +}) { + const writableParams = new URLSearchParams(searchParams); + + writableParams.set('resultsType', buttonType); + + return ( + + {children} + + ); +} diff --git a/src/components/search/SearchResults.tsx b/src/components/search/SearchResults.tsx new file mode 100644 index 0000000..affd84e --- /dev/null +++ b/src/components/search/SearchResults.tsx @@ -0,0 +1,169 @@ +import { + Artist, + SearchParams, + SearchResults as TSearchResults, + SearchResultsType, + SongVersions, + Song, +} from '../../types'; +import Link from 'next/link'; +import { sortByKey } from '@/lib/utils'; +import Column from '../Column'; +import Row from '../Row'; +import { formatInTimeZone } from 'date-fns-tz'; + +export default function SearchResults({ + data, + resultsType, + searchParams, + versionsData, +}: { + data: TSearchResults | null; + resultsType: SearchResultsType; + searchParams: SearchParams; + versionsData: SongVersions | null; +}) { + const writableParams = new URLSearchParams(searchParams); + let sortedData: TSearchResults | null = null; + let sortedVersions: SongVersions | null = null; + + if (data) { + sortedData = { + ...data, + Artists: sortByKey('sort_name', data.Artists) as Artist[], + Songs: sortByKey('shows_played_at', data.Songs) as Song[], // sortByKey('sortName', data.Songs) + }; + } + + if (versionsData) { + sortedVersions = { + ...versionsData, + shows: [...versionsData.shows].sort((a, b) => { + if (a.date === undefined || b.date === undefined) { + return 0; + } + + if (new Date(a.date).getTime() > new Date(b.date).getTime()) { + return searchParams.sortBy === 'DATE_ASC' ? 1 : -1; + } + + if (new Date(a.date).getTime() < new Date(b.date).getTime()) { + return searchParams.sortBy === 'DATE_ASC' ? -1 : 1; + } + + return 0; + }), + }; + } + + function getHref(slim_artist: Artist | undefined, songUuid: string | undefined) { + if (!slim_artist || !slim_artist.uuid || !songUuid) { + // TODO: handle error + return 'search'; + } + + writableParams.set('songUuid', songUuid); + + return `search?${writableParams.toString()}`; + } + + if (sortedData === null) { + return ( + // eslint-disable-next-line react/jsx-no-comment-textnodes +
// TODO: There is no search query, so put the featured artists list here
+ ); + } + + if (resultsType === 'artists' && sortedData?.Artists.length) { + return ( + + {sortedData?.Artists.map(({ name, uuid, slug }) => ( + +
+
{name}
+
+
+
ARTIST
+
+
+ ))} +
+ ); + } + + if (resultsType === 'songs' && sortedData?.Songs.length) { + return ( + + {sortByKey('shows_played_at', sortedData?.Songs).map( + ({ name, uuid, slim_artist }: Song) => ( + + +
+
{name}
+
{slim_artist?.name}
+
+
SONG
+
+ + ) + )} +
+ ); + } + + if (resultsType === 'all' && (sortedData?.Artists.length || sortedData?.Songs.length)) { + return ( + + {sortedData?.Artists.map(({ name, uuid, slug }) => ( + +
+
{name}
+
+
+
ARTIST
+
+
+ ))} + {sortByKey('shows_played_at', sortedData?.Songs).map( + ({ name, uuid, slim_artist }: Song) => ( + + +
+
{name}
+
{slim_artist?.name}
+
+
SONG
+
+ + ) + )} +
+ ); + } + + if (resultsType === 'versions' && sortedVersions) { + return sortedVersions?.shows?.map(({ date, display_date, venue, uuid }) => { + // https://relisten.net/grateful-dead/1994/12/15/me-and-my-uncle?source=346445 + return ( + +
+
{sortedVersions?.name}
+
+
+ {venue?.sortName} · {venue?.location} +
+
{display_date}
+
+
+ {/* // TODO: where is the duration? */} +
12:00
+
+ ); + }); + } + + return
No results
; +} diff --git a/src/components/search/SearchSortByMenu.tsx b/src/components/search/SearchSortByMenu.tsx new file mode 100644 index 0000000..567b0b7 --- /dev/null +++ b/src/components/search/SearchSortByMenu.tsx @@ -0,0 +1,34 @@ +import { SearchParams, SearchResultsType } from '@/types'; +import Link from 'next/link'; +import Column from '../Column'; +import Row from '../Row'; + +export default function SortByMenu({ + resultsType, + searchParams, +}: { + resultsType: SearchResultsType; + searchParams: SearchParams; +}) { + const writableParams = new URLSearchParams(searchParams); + + function getHref(sortBy) { + writableParams.set('sortBy', sortBy); + return `search?${writableParams.toString()}`; + } + + if (resultsType !== 'versions') { + return null; + } + + return ( + + + Date (Ascending) + + + Date (Descending) + + + ); +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 53e3bc1..4e9e971 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -55,9 +55,41 @@ export const simplePluralize = (str: string, count = 0): string => { return `${count?.toLocaleString()} ${count === 1 ? str : str + 's'}`; }; +/** example input and output: + * + * [ { id: 1, category: 'A' }, { id: 2, category: 'B' }, { id: 3, category: 'A' } ] + * + * { + * A: [ + * { id: 1, category: 'A' }, + * { id: 3, category: 'A' } + * ], + * B: [ { id: 2, category: 'B' } ] + * } + * + */ export const groupBy = function (xs, key) { return xs.reduce((rv, x) => { (rv[x[key]] = rv[x[key]] || []).push(x); return rv; }, {}); }; + +// Sort data[] by data.key +export function sortByKey(key: string, data: object[]) { + return [...data].sort((a, b) => { + if (typeof a[key] === 'string' && typeof b[key] === 'string') { + return b[key].localeCompare(a[key]); + } + + if (a[key] < b[key]) { + return 1; + } + + if (a[key] > b[key]) { + return -1; + } + + return 0; + }); +} diff --git a/src/styles/globals.css b/src/styles/globals.css index a8f761e..225513e 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -21,6 +21,45 @@ .button { @apply rounded bg-green-400 p-4 py-2 border border-green-100 my-4 inline-block font-medium text-black/70 tracking-wide; } + + .search-bar { + @apply rounded-full border border-gray-400; + } + + .search-bar:focus-within { + @apply border-gray-600; + } + + .search-bar > input:focus { + outline: none; + } + + .search-bar > .fa-spinner { + animation: spin .7s linear infinite; + } + + .search-filters > li { + @apply inline-block; + } + + .search-filters > li > button { + @apply rounded-full px-2 py-1; + } + + .search-filters > li > .search-filters-item--active { + @apply bg-relisten-100 text-white; + } +} + +@layer utilities { + @keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } + } } body { margin: 0; font-family: Roboto, Helvetica, Helvetica Neue, sans-serif; -webkit-font-smoothing: antialiased; color: #333; } diff --git a/src/types.ts b/src/types.ts index ad9a63f..34f719c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -274,6 +274,7 @@ export type Source = { num_ratings?: number | null; avg_rating_weighted?: number; duration?: number; + slim_artist?: Artist; upstream_identifier?: string; uuid?: string; created_at?: string; @@ -337,6 +338,21 @@ export type Playback = { activeTrack?: ActiveTrack; }; +export type Song = { + artist_id?: number; + artist_uuid?: string; + created_at?: string; + id: number; + name?: string; + shows_played_at?: number; + slim_artist?: Artist; + slug?: string; + sortName?: string; + updated_at?: string; + upstream_identifier?: string; + uuid?: string; +}; + export type ActiveTrack = { currentTime?: number; duration?: number; @@ -345,3 +361,34 @@ export type ActiveTrack = { playbackType?: string; webAudioLoadingState?: string; }; + +// query-string parameters for search page +export type SearchParams = { + artistName: string; + artistSlug: string; + artistUuid: string; + q: string; + resultsType: SearchResultsType; + songUuid: string; + sortBy?: 'DATE_ASC' | 'DATE_DESC'; +}; + +export type SearchResults = { + Artists: Artist[]; + Shows: Show[]; + Songs: Song[]; + Sources: Source[]; + Tours: Tour[]; + Venues: Venue[]; +}; + +// Which type of search results are being shown on the search page +export type SearchResultsType = 'all' | 'songs' | 'artists' | 'versions'; + +export type SongVersions = { + name: string; + shows: Show[]; + slug: string; + artistName: string; + artistSlug: string; +};