Skip to content

Commit

Permalink
feat: add a genre + artist search in the header
Browse files Browse the repository at this point in the history
  • Loading branch information
eligundry committed Jul 12, 2024
1 parent 7dbb9b7 commit fe93b1b
Show file tree
Hide file tree
Showing 7 changed files with 253 additions and 15 deletions.
12 changes: 6 additions & 6 deletions app/components/Base/LayoutV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@ import { ClientOnly } from 'remix-utils/client-only'
import { cn } from '~/lib/util'

import AutoAlert from '~/components/AutoAlert'
import RelatedArtistSearchForm from '~/components/Forms/RelatedArtistSearch'
import SuperHeaderSearch from '~/components/Forms/SuperHeaderSearch'
import { DesktopLoader, MobileLoader } from '~/components/Loading'
import SearchBreadcrumbs, {
SearchBreadcrumbsProps,
} from '~/components/SearchBreadcrumbs'
import useLoading from '~/hooks/useLoading'
import { useIsMobile } from '~/hooks/useMediaQuery'

import { A, ButtonLink, Container, EmojiText, Link } from './index'
import { A, Container, EmojiText, Link } from './index'

interface LayoutProps {
className?: string
Expand Down Expand Up @@ -47,6 +47,7 @@ const Layout: React.FC<React.PropsWithChildren<LayoutProps>> = ({
'flex-wrap',
['pt-0', 'md:pt-2'],
'align-center',
'has-[input:focus]:[&>:not(.super-search)]:hidden',
)}
>
<h1
Expand All @@ -68,7 +69,7 @@ const Layout: React.FC<React.PropsWithChildren<LayoutProps>> = ({
<EmojiText emoji="🎉" label="party streamer" noPadding />
</Link>
</h1>
<div
<SuperHeaderSearch
className={cn(
'navbar-end',
'flex',
Expand All @@ -78,10 +79,9 @@ const Layout: React.FC<React.PropsWithChildren<LayoutProps>> = ({
'order-2 md:order-3',
'flex-1',
'font-bold',
'super-search',
)}
>
<RelatedArtistSearchForm />
</div>
/>
</Container>
</header>
<main
Expand Down
22 changes: 15 additions & 7 deletions app/components/Forms/FunSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,24 @@ import { useCallback, useState } from 'react'

import { cn } from '~/lib/util'

export interface Option {
export type Option<
T extends Record<string, unknown> = Record<string, unknown>,
> = T & {
label?: string
labelElement?: React.ReactNode
value: string
}

interface FunSelectProps {
interface FunSelectProps<
T extends Record<string, unknown> = Record<string, unknown>,
> {
name: string
label?: string
value?: Option
value?: Option<T>
className?: string
placeholder?: string
loadOptions: (query?: string) => Promise<Option[]>
onChange: (value?: Option) => void
loadOptions: (query?: string) => Promise<Option<T>[]>
onChange: (value?: Option<T>) => void
}

const FunSelect: React.FC<FunSelectProps> = ({
Expand All @@ -34,6 +38,7 @@ const FunSelect: React.FC<FunSelectProps> = ({
loadOptions,
onChange,
}) => {
const [input, setInput] = useState('')
const [options, setOptions] = useState<Option[]>([])
const [selectedOption, setSelectedOption] = useState<Option | null>(null)

Expand All @@ -47,6 +52,7 @@ const FunSelect: React.FC<FunSelectProps> = ({
event.preventDefault()
}

setInput(event.target.value)
loadOptions(event.target.value).then(setOptions)
},
[loadOptions],
Expand All @@ -57,18 +63,20 @@ const FunSelect: React.FC<FunSelectProps> = ({
immediate
value={value}
onChange={(option) => {
console.log({ option })
onChange(option ?? undefined)
setSelectedOption(option)
setInput(option?.label ?? option?.value ?? '')
}}
as="div"
>
<input type="hidden" name={name} value={selectedOption?.value} />
<ComboboxInput
name={name}
aria-label={label}
displayValue={(option: Option) =>
option?.label ?? option?.value ?? undefined
}
value={selectedOption?.value}
value={input}
onChange={handleChange}
placeholder={placeholder}
autoComplete="off"
Expand Down
101 changes: 101 additions & 0 deletions app/components/Forms/SuperHeaderSearch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { Form, useSubmit } from '@remix-run/react'
import clsx from 'clsx'
import { useCallback, useRef } from 'react'

import type { SpotifyArtist } from '~/lib/types/spotify'
import { cn } from '~/lib/util'

import useUser from '~/hooks/useUser'

import FunSelect, { type Option } from './FunSelect'

interface Props {
className?: string
}

const SuperHeaderSearch: React.FC<Props> = ({ className }) => {
const user = useUser()
const submit = useSubmit()
const formRef = useRef<HTMLFormElement>(null)

const search = useCallback(
async (term?: string) => {
const url = new URL(`${window.location.origin}/api/user-search`)

if (term) {
url.searchParams.set('search', term)
}

const resp = await fetch(url.toString(), {
credentials: 'include',
})
const {
artists,
genres,
}: { artists: SpotifyArtist[]; genres: string[] } = await resp.json()

let items: Option<{ itemType: string }>[] = []

if (artists.length) {
items = items.concat(
artists.map((artist) => ({
value: artist.id,
label: artist.name,
itemType: 'artist',
labelElement: (
<div className={clsx('flex', 'flex-row', 'items-center')}>
{artist.image && (
<img
className={clsx('w-16', 'mr-2', 'rounded-lg')}
src={artist.image.url}
alt={artist.name}
width={artist.image.width}
height={artist.image.height}
/>
)}
<span>{artist.name}</span>
</div>
),
})),
)
}

if (genres.length) {
items = items.concat(
genres.map((genre) => ({
value: genre,
label: genre,
itemType: 'genre',
})),
)
}

return items
},
[user],
)

return (
<Form method="get" action="/search" className={cn(className)} ref={formRef}>
<input type="hidden" name="itemType" />
<FunSelect
name="itemID"
placeholder="Search"
onChange={(option: Option<{ itemType: string }>) => {
setTimeout(() => {
if (!formRef.current) {
return
}
// @ts-ignore
formRef.current.firstChild!.value = option.itemType
submit(formRef.current)
}, 5)
}}
loadOptions={search}
className={className}
/>
</Form>
)
}

export default SuperHeaderSearch
4 changes: 2 additions & 2 deletions app/lib/database/index.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,13 +133,13 @@ export class DatabaseClient {
.all()
.then((genres) => genres.map((genre) => genre.name))

searchGenres = async (q: string) =>
searchGenres = async (q: string, limit?: number) =>
this.db
.select({ name: spotifyGenres.name })
.from(spotifyGenres)
.where(like(spotifyGenres.name, q + '%'))
.orderBy(spotifyGenres.id)
.limit(100 * q.length)
.limit(limit ?? 100 * q.length)
.all()
.then((genres) => genres.map((genre) => genre.name))

Expand Down
52 changes: 52 additions & 0 deletions app/routes/api.search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { LoaderFunction, json } from '@remix-run/node'
import { z } from 'zod'
import { zfd } from 'zod-form-data'

import { getRequestContextValues } from '~/lib/context.server'
import { badRequest } from '~/lib/responses.server'
import spotifyLib from '~/lib/spotify.server'

import config from '~/config'

const paramsSchema = zfd.formData({
search: zfd.text().optional(),
artistLimit: z.coerce.number().min(1).max(50).optional().default(5),
genreLimit: z.coerce.number().min(1).max(50).optional().default(10),
})

export const loader: LoaderFunction = async ({ request, context }) => {
const { serverTiming, logger, database } = getRequestContextValues(
request,
context,
)
const paramsParse = paramsSchema.safeParse(new URL(request.url).searchParams)

if (!paramsParse.success) {
throw badRequest({
logger,
error: 'invalid query paramters',
issues: paramsParse.error.issues,
})
}

const params = paramsParse.data
const spotify = await spotifyLib.initializeFromRequest(request, context)
const [artists, genres] = await Promise.all([
params.search
? spotify.searchArists(params.search, params.artistLimit)
: spotify.getUserTopArtists(params.artistLimit),
params.search
? database.searchGenres(params.search, params.genreLimit)
: database.getTopGenres(params.genreLimit),
])

return json(
{ artists, genres },
{
headers: {
'cache-control': config.cacheControl.private,
[serverTiming.headerKey]: serverTiming.toString(),
},
},
)
}
56 changes: 56 additions & 0 deletions app/routes/api.user-search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { LoaderFunction, json } from '@remix-run/node'
import { z } from 'zod'
import { zfd } from 'zod-form-data'

import { spotifyStrategy } from '~/lib/auth.server'
import { getRequestContextValues } from '~/lib/context.server'
import { badRequest } from '~/lib/responses.server'
import spotifyLib from '~/lib/spotify.server'

import config from '~/config'

const paramsSchema = zfd.formData({
search: zfd.text().optional(),
artistLimit: z.coerce.number().min(1).max(50).optional().default(5),
genreLimit: z.coerce.number().min(1).max(50).optional().default(10),
})

export const loader: LoaderFunction = async ({ request, context }) => {
const { serverTiming, logger, database } = getRequestContextValues(
request,
context,
)
await spotifyStrategy.getSession(request, {
failureRedirect: config.requiredLoginFailureRedirect,
})
const paramsParse = paramsSchema.safeParse(new URL(request.url).searchParams)

if (!paramsParse.success) {
throw badRequest({
logger,
error: 'invalid query paramters',
issues: paramsParse.error.issues,
})
}

const params = paramsParse.data
const spotify = await spotifyLib.initializeFromRequest(request, context)
const [artists, genres] = await Promise.all([
params.search
? spotify.searchArists(params.search, params.artistLimit)
: spotify.getUserTopArtists(params.artistLimit),
params.search
? database.searchGenres(params.search, params.genreLimit)
: database.getTopGenres(params.genreLimit),
])

return json(
{ artists, genres },
{
headers: {
'cache-control': config.cacheControl.private,
[serverTiming.headerKey]: serverTiming.toString(),
},
},
)
}
21 changes: 21 additions & 0 deletions app/routes/search.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { LoaderFunctionArgs, redirect } from '@remix-run/node'
import { z } from 'zod'
import { zfd } from 'zod-form-data'

const queryParamSchema = z.object({
itemID: z.string(),
itemType: z.enum(['artist', 'genre']),
})

export async function loader({ request }: LoaderFunctionArgs) {
const params = zfd
.formData(queryParamSchema)
.parse(new URL(request.url).searchParams)

switch (params.itemType) {
case 'artist':
return redirect(`/spotify/artist-id/${params.itemID}`)
case 'genre':
return redirect(`/genre/${params.itemID}`)
}
}

0 comments on commit fe93b1b

Please sign in to comment.