Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/app/(main)/(secondary)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { PropsWithChildren } from 'react';

export default function Layout({ children }: PropsWithChildren) {
return <div className="mx-auto max-w-screen-md py-8">{children}</div>;
return <div className="mx-auto w-full max-w-screen-md py-8">{children}</div>;
}
103 changes: 103 additions & 0 deletions src/app/(main)/(secondary)/search/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
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}`, {
cache: 'no-cache', // seconds
}).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);

let versions = await fetch(
`${API_DOMAIN}/api/v3/artists/${song?.slim_artist?.uuid}/songs/${searchParams.songUuid}`,
{ cache: 'no-cache' }
);

versions = await versions.json();

resultsType = 'versions';

versionsData = {
...versions,
artistName: song?.slim_artist?.name || '',
artistSlug: song?.slim_artist?.slug || '',
};
}

return (
<div className="mx-auto w-full max-w-screen-md flex-1">
<SearchBar resultsType={resultsType} />
<div className="flex justify-between pb-4">
{data !== null && resultsType === 'versions' && (
<div className="font-semibold">
Showing all versions of “{versionsData?.name}” by {versionsData?.artistName}
</div>
)}
{data !== null && resultsType !== 'versions' && (
<ul className="search-filters">
<li>
<SearchFilterPill
buttonType="all"
resultsType={resultsType}
searchParams={searchParams}
>
All {data && `(${data?.Artists?.length + data?.Songs?.length})`}
</SearchFilterPill>
</li>
<li>
<SearchFilterPill
buttonType="artists"
resultsType={resultsType}
searchParams={searchParams}
>
Artists {data && `(${data?.Artists?.length})`}
</SearchFilterPill>
</li>
<li>
<SearchFilterPill
buttonType="songs"
resultsType={resultsType}
searchParams={searchParams}
>
Songs {data && `(${data?.Songs?.length})`}
</SearchFilterPill>
</li>
</ul>
)}
{resultsType === 'versions' && (
<SimplePopover
content={<SearchSortByMenu resultsType={resultsType} searchParams={searchParams} />}
position="bottom-end"
>
<button>Sort by ⏷</button>
</SimplePopover>
)}
</div>
<SearchResultsCpt
data={data}
resultsType={resultsType}
searchParams={searchParams}
versionsData={versionsData}
/>
</div>
);
}

export const metadata = {
title: 'Search',
};
5 changes: 5 additions & 0 deletions src/app/(main)/NavBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ export default async function NavBar() {
</Flex>
</SimplePopover>
<div className="nav hidden h-full flex-[2] cursor-pointer items-center justify-end text-center font-medium 2xl:flex">
<div className="h-full px-1">
<Link href="/search" legacyBehavior prefetch={false}>
<a className="nav-btn">SEARCH</a>
</Link>
</div>
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Running out of room on the nav. This goes behind the player

<div className="h-full px-1">
<Link href="/today" legacyBehavior prefetch={false}>
<a className="nav-btn">TIH</a>
Expand Down
68 changes: 68 additions & 0 deletions src/components/search/SearchBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
'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<string>(searchParams?.get('q')?.toString() || '');
const [isPending, startTransition] = useTransition();

function handleSubmit(e?: FormEvent<HTMLFormElement>) {
if (e) {
e.preventDefault();
}

if (value) {
writableParams.set('q', value);
} else {
writableParams.delete('q');
}

startTransition(() => router.replace(`${pathname}?${writableParams.toString()}`));
}

function clearSearch() {
writableParams.delete('q');
writableParams.delete('resultsType');
writableParams.delete('songUuid');
writableParams.delete('sortBy');
startTransition(() => {
setValue('');
router.replace(`${pathname}?${writableParams.toString()}`);
});
}

return (
<form className="w-screen max-w-screen-md" onSubmit={handleSubmit}>
{resultsType === 'versions' ? (
<button
className="mb-2 h-[42px] font-semibold"
onClick={clearSearch}
aria-label="clear search"
type="button" // Technically a reset button, but maybe best to use "button" to avoid unexpected behavior
>
<i className="fa fa-times" /> Clear search
</button>
) : (
<div className="search-bar mb-2 flex items-center p-2">
<i className={isPending ? 'fa fa-spinner px-2' : 'fa fa-search px-2'} />
<input
className="grow"
type="text"
placeholder="Search..."
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<button onClick={clearSearch} aria-label="clear search" className="flex" type="button">
<i className="fa fa-times px-2" />
</button>
</div>
)}
</form>
);
}
29 changes: 29 additions & 0 deletions src/components/search/SearchFilterPill.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Link
className={`mr-1 mt-1 inline-block rounded-full px-2 py-1 ${resultsType === buttonType ? 'search-filters-item--active' : ''}`}
href={`search?${writableParams.toString()}`}
replace={true}
>
{children}
</Link>
);
}
169 changes: 169 additions & 0 deletions src/components/search/SearchResults.tsx
Original file line number Diff line number Diff line change
@@ -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
<div>// TODO: There is no search query, so put the featured artists list here</div>
);
}

if (resultsType === 'artists' && sortedData?.Artists.length) {
return (
<Column>
{sortedData?.Artists.map(({ name, uuid, slug }) => (
<Row key={uuid} href={`/${slug}`}>
<div>
<div>{name}</div>
</div>
<div className="min-w-[20%] text-right text-xs text-[#979797]">
<div>ARTIST</div>
</div>
</Row>
))}
</Column>
);
}

if (resultsType === 'songs' && sortedData?.Songs.length) {
return (
<Column>
{sortByKey('shows_played_at', sortedData?.Songs).map(
({ name, uuid, slim_artist }: Song) => (
<Link key={uuid} className="d-flex" href={getHref(slim_artist, uuid)} replace={true}>
<Row>
<div className="text-left">
<div>{name}</div>
<div className="text-xxs text-gray-400">{slim_artist?.name}</div>
</div>
<div className="min-w-[20%] text-right text-xxs text-gray-400">SONG</div>
</Row>
</Link>
)
)}
</Column>
);
}

if (resultsType === 'all' && (sortedData?.Artists.length || sortedData?.Songs.length)) {
return (
<Column>
{sortedData?.Artists.map(({ name, uuid, slug }) => (
<Row key={uuid} href={`/${slug}`}>
<div>
<div>{name}</div>
</div>
<div className="min-w-[20%] text-right text-xs text-[#979797]">
<div>ARTIST</div>
</div>
</Row>
))}
{sortByKey('shows_played_at', sortedData?.Songs).map(
({ name, uuid, slim_artist }: Song) => (
<Link key={uuid} className="d-flex" href={getHref(slim_artist, uuid)} replace={true}>
<Row>
<div className="text-left">
<div>{name}</div>
<div className="text-xxs text-gray-400">{slim_artist?.name}</div>
</div>
<div className="min-w-[20%] text-right text-xxs text-gray-400">SONG</div>
</Row>
</Link>
)
)}
</Column>
);
}

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 (
<Row
key={uuid}
href={`/${sortedVersions?.artistSlug}/${formatInTimeZone(date as string, 'UTC', 'yyyy/MM/dd')}/${sortedVersions?.slug}`}
>
<div>
<div>{sortedVersions?.name}</div>
<div className="text-xxs text-[#979797]">
<div>
{venue?.sortName} · {venue?.location}
</div>
<div>{display_date}</div>
</div>
</div>
{/* // TODO: where is the duration? */}
<div className="ml-auto">12:00</div>
</Row>
);
});
}

return <div>No results</div>;
}
Loading