From c4b1c4842ae4cca5ca6a8416c5011d46f7f3a536 Mon Sep 17 00:00:00 2001 From: Luke Bunselmeyer <luke@bunselmeyer.net> Date: Mon, 8 May 2023 14:47:00 -0400 Subject: [PATCH] Minor refactoring to breakup large components into smaller ones. --- README.md | 10 +- app/components/page-layout.tsx | 146 +++++++------------------ app/components/side-nav.tsx | 63 +++++++++++ app/components/station-player.tsx | 2 +- app/components/top-navbar.tsx | 63 +++++++++++ app/routes/listen.channel.$channel.tsx | 59 ---------- 6 files changed, 174 insertions(+), 169 deletions(-) create mode 100644 app/components/side-nav.tsx create mode 100644 app/components/top-navbar.tsx delete mode 100644 app/routes/listen.channel.$channel.tsx diff --git a/README.md b/README.md index a90cdff..856fbfe 100644 --- a/README.md +++ b/README.md @@ -64,13 +64,21 @@ Awesome Radio is a personal internet radio station aggregator. See the [demo](ht cp .env.example .env ``` -2. Migrate & Seed the SQLite DB +2. Install dependencies + +```shell +npm install +``` + +3. Migrate & Seed the SQLite DB ```shell npx prisma migrate deploy npx prisma db seed ``` +Note: The db schema, migrations, and seed steps are located in the [prisma](prisma) folder. + ### Running ```shell diff --git a/app/components/page-layout.tsx b/app/components/page-layout.tsx index d58fa1b..4d2da88 100644 --- a/app/components/page-layout.tsx +++ b/app/components/page-layout.tsx @@ -1,13 +1,46 @@ -import { RadioIcon, UserCircleIcon } from "@heroicons/react/24/solid"; import { Link, NavLink } from "@remix-run/react"; import type { RemixLinkProps, RemixNavLinkProps } from "@remix-run/react/dist/components"; -import type { ReactNode } from "react"; -import * as React from "react"; +import type { ReactNode, RefAttributes } from "react"; import { createContext, useContext } from "react"; +import { SideNav } from "~/components/side-nav"; +import { TopNavbar } from "~/components/top-navbar"; import type { StationWithTagsClientSide } from "~/models/station.server"; import type { TagWithStationsClientSide } from "~/models/tag.server"; import type { UserWithFavoriteStationsClientSide } from "~/models/user.server"; +export type StationContextType = { + station: StationWithTagsClientSide | null +} + +export const StationContext = createContext<StationContextType>({ station: null }); + +/** + * Hook that provides the active radio station. Station is null if one is not selected. + */ +export function useStationContext() { + return useContext(StationContext); +} + +/** + * Helper component that wraps <Link> and appends `?station={station.id}` to the link `to` prop if + * there's an active station playing + */ +export function ListenLink(props: RemixLinkProps & RefAttributes<HTMLAnchorElement>) { + const { station } = useStationContext(); + const url = props.to + (station ? `?station=${station.id}` : ""); + return <Link {...props} to={url}>{props.children}</Link>; +} + +/** + * Helper component that wraps <NavLink> and appends `?station={station.id}` to the link `to` prop if + * there's an active station playing + */ +export function ListenNavLink(props: RemixNavLinkProps & RefAttributes<HTMLAnchorElement>) { + const { station } = useStationContext(); + const url = props.to + (station ? `?station=${station.id}` : ""); + return <NavLink {...props} to={url}>{props.children}</NavLink>; +} + export type PageLayoutProps = { children: ReactNode; tags: TagWithStationsClientSide[]; @@ -15,123 +48,20 @@ export type PageLayoutProps = { station: StationWithTagsClientSide | null; } -export type StationContextType = { - station: StationWithTagsClientSide | null -} - -const StationContext = createContext<StationContextType>({ station: null }); - -export function useStationContext() { - return useContext(StationContext); -} - - -export function ListenLink(props: RemixLinkProps & React.RefAttributes<HTMLAnchorElement>) { - const { station } = useStationContext(); - const url = props.to + (station ? `?station=${station.id}` : ""); - return <Link {...props} to={url}>{props.children}</Link>; -} - -export function ListenNavLink(props: RemixNavLinkProps & React.RefAttributes<HTMLAnchorElement>) { - const { station } = useStationContext(); - const url = props.to + (station ? `?station=${station.id}` : ""); - return <NavLink {...props} to={url}>{props.children}</NavLink>; -} - -export type ManageContentNavProps = { - user?: UserWithFavoriteStationsClientSide; -}; - -export function ManageContentNav({ user }: ManageContentNavProps) { - if (!user) { - return <></>; - } - return ( - <> - <li className="menu-title"> - <span>Manage Content</span> - </li> - <li> - <NavLink to="/listen/sources">Sources</NavLink> - </li> - </> - ); -} - export function PageLayout({ children, tags, user, station }: PageLayoutProps) { - return ( <StationContext.Provider value={{ station }}> <div className="drawer drawer-mobile"> <input id="primary-drawer" type="checkbox" className="drawer-toggle" /> <div className="drawer-content flex flex-col"> - <div className="w-full navbar bg-base-300 lg:justify-end"> - <div className="flex-none lg:hidden"> - <label htmlFor="primary-drawer" className="btn btn-square btn-ghost"> - <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" - className="inline-block w-6 h-6 stroke-current"> - <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" - d="M4 6h16M4 12h16M4 18h16"></path> - </svg> - </label> - </div> - <div className="flex-1 px-2 mx-2 gap-2 lg:hidden"> - <RadioIcon className="h-8 w-8 p-0" /> - <h1 className="text-2xl p-0">Awesome Radio</h1> - </div> - <div className="flex-none"> - {user ? - <div className="dropdown dropdown-end"> - <label tabIndex={0} className="btn btn-ghost btn-circle avatar placeholder"> - <UserCircleIcon className="w-8 h-8" /> - </label> - <ul tabIndex={0} - className="menu menu-compact dropdown-content mt-3 p-2 shadow bg-base-200 rounded-box w-30"> - <li> - <Link to="/logout">Logout</Link> - </li> - </ul> - </div> : - <Link to="/join" className="btn">Join</Link> - } - - </div> - </div> + <TopNavbar user={user} /> <div className="py-2 px-6"> {children} </div> </div> <div className="drawer-side"> <label htmlFor="primary-drawer" className="drawer-overlay"></label> - <ul className="menu menu-compact flex w-80 flex-col bg-base-200 text-base-content p-0 px-4 pt-4"> - <li className="flex flex-row justify-start gap-2 mb-4"> - <RadioIcon className="h-8 w-8 p-0" /> - <h1 className="text-2xl p-0">Awesome Radio</h1> - </li> - <li className="menu-title"> - <span>Listen</span> - </li> - <li> - <ListenNavLink to="/listen" end>Home</ListenNavLink> - </li> - <li className="menu-title"> - <span>Tags</span> - </li> - {tags - .filter(tag => tag.stations.length > 0) - .map((tag) => { - return ( - <li key={tag.slug}> - <ListenNavLink to={`/listen/tag/${tag.slug}`} className="capitalize"> - {tag.name} - <span className="badge badge-outline">{tag.stations?.length ?? 0}</span> - </ListenNavLink> - </li> - ); - })} - <ManageContentNav user={user} /> - </ul> - + <SideNav tags={tags} user={user} /> </div> </div> </StationContext.Provider> diff --git a/app/components/side-nav.tsx b/app/components/side-nav.tsx new file mode 100644 index 0000000..3dd9186 --- /dev/null +++ b/app/components/side-nav.tsx @@ -0,0 +1,63 @@ +import { RadioIcon } from "@heroicons/react/24/solid"; +import { NavLink } from "@remix-run/react"; +import { ListenNavLink } from "~/components/page-layout"; +import type { TagWithStationsClientSide } from "~/models/tag.server"; +import type { UserWithFavoriteStationsClientSide } from "~/models/user.server"; + +export type ManageContentNavProps = { + user?: UserWithFavoriteStationsClientSide; +}; + +export function ManageContentNav({ user }: ManageContentNavProps) { + if (!user) { + return <></>; + } + return ( + <> + <li className="menu-title"> + <span>Manage Content</span> + </li> + <li> + <NavLink to="/listen/sources">Sources</NavLink> + </li> + </> + ); +} + +export type SideNavProps = { + tags: TagWithStationsClientSide[]; + user?: UserWithFavoriteStationsClientSide; +}; + +export function SideNav({ tags, user }: SideNavProps) { + return ( + <ul className="menu menu-compact flex w-80 flex-col bg-base-200 text-base-content p-0 px-4 pt-4"> + <li className="flex flex-row justify-start gap-2 mb-4"> + <RadioIcon className="h-8 w-8 p-0" /> + <h1 className="text-2xl p-0">Awesome Radio</h1> + </li> + <li className="menu-title"> + <span>Listen</span> + </li> + <li> + <ListenNavLink to="/listen" end>Home</ListenNavLink> + </li> + <li className="menu-title"> + <span>Tags</span> + </li> + {tags + .filter(tag => tag.stations.length > 0) + .map((tag) => { + return ( + <li key={tag.slug}> + <ListenNavLink to={`/listen/tag/${tag.slug}`} className="capitalize"> + {tag.name} + <span className="badge badge-outline">{tag.stations?.length ?? 0}</span> + </ListenNavLink> + </li> + ); + })} + <ManageContentNav user={user} /> + </ul> + ); +} diff --git a/app/components/station-player.tsx b/app/components/station-player.tsx index 8f8b534..b11731d 100644 --- a/app/components/station-player.tsx +++ b/app/components/station-player.tsx @@ -13,7 +13,7 @@ export function StationPlayer({ station }: StationPlayerProps) { className="fixed bottom-0 left-0 w-full h-[70px] px-4 py-2 z-50 flex justify-end content-center items-center gap-2 bg-accent text-accent-content"> <h3 className="text-xl">Now Playing: <strong>{station.name}</strong></h3> - <audio controls autoPlay src={station.streamUrl}> + <audio controls autoPlay src={station.streamUrl} title={station.name}> Your browser does not support the audio element. </audio> </div> diff --git a/app/components/top-navbar.tsx b/app/components/top-navbar.tsx new file mode 100644 index 0000000..2ec4494 --- /dev/null +++ b/app/components/top-navbar.tsx @@ -0,0 +1,63 @@ +import { RadioIcon, UserCircleIcon } from "@heroicons/react/24/solid"; +import { Link } from "@remix-run/react"; +import type { UserWithFavoriteStationsClientSide } from "~/models/user.server"; + +export function GithubIconLink() { + return ( + <a aria-label="Github | Awesome Radio" target="_blank" href="https://github.com/wmluke/awesome-radio" + rel="noopener noreferrer" + className="btn btn-ghost drawer-button btn-square normal-case"> + <svg width="20" height="20" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" + className="inline-block h-8 w-8 fill-current"> + <path + d="M256,32C132.3,32,32,134.9,32,261.7c0,101.5,64.2,187.5,153.2,217.9a17.56,17.56,0,0,0,3.8.4c8.3,0,11.5-6.1,11.5-11.4,0-5.5-.2-19.9-.3-39.1a102.4,102.4,0,0,1-22.6,2.7c-43.1,0-52.9-33.5-52.9-33.5-10.2-26.5-24.9-33.6-24.9-33.6-19.5-13.7-.1-14.1,1.4-14.1h.1c22.5,2,34.3,23.8,34.3,23.8,11.2,19.6,26.2,25.1,39.6,25.1a63,63,0,0,0,25.6-6c2-14.8,7.8-24.9,14.2-30.7-49.7-5.8-102-25.5-102-113.5,0-25.1,8.7-45.6,23-61.6-2.3-5.8-10-29.2,2.2-60.8a18.64,18.64,0,0,1,5-.5c8.1,0,26.4,3.1,56.6,24.1a208.21,208.21,0,0,1,112.2,0c30.2-21,48.5-24.1,56.6-24.1a18.64,18.64,0,0,1,5,.5c12.2,31.6,4.5,55,2.2,60.8,14.3,16.1,23,36.6,23,61.6,0,88.2-52.4,107.6-102.3,113.3,8,7.1,15.2,21.1,15.2,42.5,0,30.7-.3,55.5-.3,63,0,5.4,3.1,11.5,11.4,11.5a19.35,19.35,0,0,0,4-.4C415.9,449.2,480,363.1,480,261.7,480,134.9,379.7,32,256,32Z"></path> + </svg> + </a> + ); +} + +export function UserDropdown() { + return ( + <div className="dropdown dropdown-end"> + <label tabIndex={0} className="btn btn-ghost btn-circle avatar placeholder"> + <UserCircleIcon className="w-8 h-8" /> + </label> + <ul tabIndex={0} + className="menu menu-compact dropdown-content mt-3 p-2 shadow bg-base-200 rounded-box w-30"> + <li> + <Link to="/logout">Logout</Link> + </li> + </ul> + </div> + ); +} + +export type TopNavbarProps = { user?: UserWithFavoriteStationsClientSide; }; + +export function TopNavbar({ user }: TopNavbarProps) { + return ( + <div className="w-full navbar bg-base-300 lg:justify-end"> + <div className="flex-none lg:hidden"> + <label htmlFor="primary-drawer" className="btn btn-square btn-ghost"> + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" + className="inline-block w-6 h-6 stroke-current"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" + d="M4 6h16M4 12h16M4 18h16"></path> + </svg> + </label> + </div> + <div className="flex-1 px-2 mx-2 gap-2 lg:hidden"> + <RadioIcon className="h-8 w-8 p-0" /> + <h1 className="text-2xl p-0">Awesome Radio</h1> + </div> + <div className="flex-none gap-4"> + <GithubIconLink /> + {user ? + <UserDropdown /> : + <Link to="/join" className="btn btn-accent btn-sm">Join</Link> + } + + </div> + </div> + ); +} diff --git a/app/routes/listen.channel.$channel.tsx b/app/routes/listen.channel.$channel.tsx deleted file mode 100644 index 42d83ce..0000000 --- a/app/routes/listen.channel.$channel.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import type { LoaderArgs } from "@remix-run/node"; -import { json } from "@remix-run/node"; -import { Link, Outlet, useLoaderData } from "@remix-run/react"; -import { Breadcrumbs } from "~/components/breadcrumbs"; -import { StationsGallery } from "~/components/stations-gallery"; -import type { StationWithTags } from "~/models/station.server"; -import { findStationsByTags } from "~/models/station.server"; -import { notFound } from "~/utils"; - - -export type Channel = { - tags: string[]; - slug: string; - name: string; -} - -const channels: { [channel: string]: Channel } = { - "music": { - tags: ["music"], - slug: "music", - name: "Music" - }, - "news": { - tags: ["news"], - slug: "news", - name: "News" - } -}; - - -export async function loader({ params }: LoaderArgs) { - if (!params.channel) { - throw notFound(); - } - - const channel = channels[params.channel]; - if (!channel) { - throw notFound(); - } - - const stations: StationWithTags[] = await findStationsByTags(channel.tags); - return json({ channel, stations }); -} - -export default function ListenChannel() { - const { channel, stations } = useLoaderData<typeof loader>(); - - return ( - <> - <Breadcrumbs> - <Link to="/listen">Home</Link> - <Link to={`/listen/${channel.slug}`}>{channel.name}</Link> - </Breadcrumbs> - <StationsGallery stations={stations} channel={channel} /> - <Outlet /> - </> - ); - -}