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() {
+
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 (
+
+ );
+}
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 }) => (
+
+
+
+
+ ))}
+
+ );
+ }
+
+ 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 }) => (
+
+
+
+
+ ))}
+ {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;
+};