- Moved PageLayout to /listen routes
  - Enabled persistent player across navgiation
This commit is contained in:
Luke Bunselmeyer 2023-05-07 20:46:19 -04:00
parent 27cc0ddeb8
commit b107af9bd9
13 changed files with 294 additions and 277 deletions

View File

@ -1,79 +1,128 @@
import { RadioIcon } from "@heroicons/react/24/solid"; import { RadioIcon } from "@heroicons/react/24/solid";
import { NavLink } from "@remix-run/react"; import { Link, NavLink } from "@remix-run/react";
import type { RemixLinkProps } from "@remix-run/react/dist/components";
import { RemixNavLinkProps } from "@remix-run/react/dist/components";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import * as React from "react";
import { createContext, useContext } from "react";
import type { StationWithTagsClientSide } from "~/models/station.server";
import type { TagWithStationsClientSide } from "~/models/tag.server"; import type { TagWithStationsClientSide } from "~/models/tag.server";
import type { UserWithFavoriteStationsClientSide } from "~/models/user.server"; import type { UserWithFavoriteStationsClientSide } from "~/models/user.server";
export type PageLayoutProps = { export type PageLayoutProps = {
children: ReactNode; children: ReactNode;
tags: TagWithStationsClientSide[]; tags: TagWithStationsClientSide[];
user?: UserWithFavoriteStationsClientSide user?: UserWithFavoriteStationsClientSide;
station: StationWithTagsClientSide | null;
} }
export function PageLayout({ children, tags, user }: PageLayoutProps) { 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 function PageLayout({ children, tags, user, station }: PageLayoutProps) {
return ( return (
<div className="drawer drawer-mobile"> <StationContext.Provider value={{ station }}>
<input id="primary-drawer" type="checkbox" className="drawer-toggle" /> <div className="drawer drawer-mobile">
<div className="drawer-content flex flex-col"> <input id="primary-drawer" type="checkbox" className="drawer-toggle" />
<div className="w-full navbar bg-base-300"> <div className="drawer-content flex flex-col">
<div className="flex-none lg:hidden"> <div className="w-full navbar bg-base-300 lg:justify-end">
<label htmlFor="primary-drawer" className="btn btn-square btn-ghost"> <div className="flex-none lg:hidden">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" <label htmlFor="primary-drawer" className="btn btn-square btn-ghost">
className="inline-block w-6 h-6 stroke-current"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" className="inline-block w-6 h-6 stroke-current">
d="M4 6h16M4 12h16M4 18h16"></path> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2"
</svg> d="M4 6h16M4 12h16M4 18h16"></path>
</label> </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">
<div className="bg-neutral-focus text-neutral-content rounded-full w-12">
<span>LB</span>
</div>
</label>
<ul tabIndex={0}
className="menu menu-compact dropdown-content mt-3 p-2 shadow bg-base-100 rounded-box w-52">
<li>
<Link to="/logout">Logout</Link>
</li>
</ul>
</div> :
<Link to="/join" className="btn">Join</Link>
}
</div>
</div> </div>
<div className="flex-1 px-2 mx-2 gap-2 lg:hidden"> <div className="py-2 px-6">
<RadioIcon className="h-8 w-8 p-0" /> {children}
<h1 className="text-2xl p-0">Awesome Radio</h1>
</div>
<div className="flex-none hidden lg:block">
</div> </div>
</div> </div>
<div className="py-2 px-6"> <div className="drawer-side">
{children} <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>
);
})}
<li className="menu-title">
<span>Manage Content</span>
</li>
<li>
<NavLink to="/sources">Sources</NavLink>
</li>
</ul>
</div> </div>
</div> </div>
<div className="drawer-side"> </StationContext.Provider>
<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>
<NavLink to="/listen/home">Home</NavLink>
</li>
<li className="menu-title">
<span>Tags</span>
</li>
{tags
.filter(tag => tag.stations.length > 0)
.map((tag) => {
return (
<li key={tag.slug}>
<NavLink to={`/listen/tag/${tag.slug}`} className="capitalize">
{tag.name}
<span className="badge badge-outline">{tag.stations?.length ?? 0}</span>
</NavLink>
</li>
);
})}
<li className="menu-title">
<span>Manage Content</span>
</li>
<li>
<NavLink to="/sources">Sources</NavLink>
</li>
</ul>
</div>
</div>
); );
} }

View File

@ -1,10 +1,13 @@
import type { StationWithTagsClientSide } from "~/models/station.server"; import type { StationWithTagsClientSide } from "~/models/station.server";
export type StationPlayerProps = { export type StationPlayerProps = {
station: StationWithTagsClientSide station: StationWithTagsClientSide | null
}; };
export function StationPlayer({ station }: StationPlayerProps) { export function StationPlayer({ station }: StationPlayerProps) {
if (!station) {
return <></>;
}
return ( return (
<div <div
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" 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"

View File

@ -1,6 +1,7 @@
import { PlayIcon } from "@heroicons/react/24/solid"; import { PlayIcon } from "@heroicons/react/24/solid";
import type { Tag } from "@prisma/client"; import type { Tag } from "@prisma/client";
import { Link } from "@remix-run/react"; import { Link } from "@remix-run/react";
import { ListenLink } from "~/components/page-layout";
import type { StationWithTagsClientSide } from "~/models/station.server"; import type { StationWithTagsClientSide } from "~/models/station.server";
import type { Channel } from "~/routes/listen.channel.$channel"; import type { Channel } from "~/routes/listen.channel.$channel";
import type { ConvertDatesToStrings } from "~/utils"; import type { ConvertDatesToStrings } from "~/utils";
@ -15,12 +16,12 @@ export function StationsGallery({ stations, tag, channel }: StationsGalleryProps
function getStationUrl(id: string): string { function getStationUrl(id: string): string {
if (channel) { if (channel) {
return `/listen/channel/${channel.slug}/${id}`; return `/listen/channel/${channel.slug}?station=${id}`;
} }
if (tag) { if (tag) {
return `/listen/tag/${tag?.slug}/${id}`; return `/listen/tag/${tag?.slug}?station=${id}`;
} }
return `/listen/home/${id}`; return `/listen?station=${id}`;
} }
return ( return (
@ -35,8 +36,8 @@ export function StationsGallery({ stations, tag, channel }: StationsGalleryProps
</h2> </h2>
<h2 className="flex gap-1"> <h2 className="flex gap-1">
{station.tags.map((t, id) => { {station.tags.map((t, id) => {
return <Link key={id} to={`/listen/tag/${t.tag.slug}`} return <ListenLink key={id} to={`/listen/tag/${t.tag.slug}`}
className="badge badge-secondary">{t.tag.name}</Link>; className="badge badge-secondary">{t.tag.name}</ListenLink>;
})} })}
</h2> </h2>
<p>{station.description}</p> <p>{station.description}</p>

View File

@ -1,6 +1,5 @@
import { cssBundleHref } from "@remix-run/css-bundle"; import { cssBundleHref } from "@remix-run/css-bundle";
import type { LinksFunction, LoaderArgs } from "@remix-run/node"; import type { LinksFunction } from "@remix-run/node";
import { json } from "@remix-run/node";
import { import {
isRouteErrorResponse, isRouteErrorResponse,
Links, Links,
@ -9,14 +8,9 @@ import {
Outlet, Outlet,
Scripts, Scripts,
ScrollRestoration, ScrollRestoration,
useLoaderData,
useRouteError useRouteError
} from "@remix-run/react"; } from "@remix-run/react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { PageLayout } from "~/components/page-layout";
import { getTags, TagWithStations } from "~/models/tag.server";
import { getUser } from "~/session.server";
import stylesheet from "~/tailwind.css"; import stylesheet from "~/tailwind.css";
export const links: LinksFunction = () => [ export const links: LinksFunction = () => [
@ -24,12 +18,6 @@ export const links: LinksFunction = () => [
...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []) ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : [])
]; ];
export async function loader({ request }: LoaderArgs) {
const tags: TagWithStations[] = await getTags();
const user = await getUser(request);
return json({ user, tags });
};
export type DocumentProps = { export type DocumentProps = {
children: ReactNode; children: ReactNode;
title?: string; title?: string;
@ -57,12 +45,9 @@ export function Document({ title, children }: DocumentProps) {
} }
export default function App() { export default function App() {
const { tags, user } = useLoaderData<typeof loader>();
return ( return (
<Document> <Document>
<PageLayout tags={tags} user={user}> <Outlet />
<Outlet />
</PageLayout>
</Document> </Document>
); );
} }

View File

@ -1,11 +1,8 @@
import type { V2_MetaFunction } from "@remix-run/node"; import type { V2_MetaFunction } from "@remix-run/node";
import { useOptionalUser } from "~/utils";
export const meta: V2_MetaFunction = () => [{ title: "Awesome Radio" }]; export const meta: V2_MetaFunction = () => [{ title: "Awesome Radio" }];
export default function Index() { export default function Index() {
const user = useOptionalUser();
return ( return (
<div className="hero bg-base-200"> <div className="hero bg-base-200">
<div className="hero-content text-center"> <div className="hero-content text-center">

View File

@ -1,27 +0,0 @@
import type { LoaderArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { StationPlayer } from "~/components/station-player";
import { getStationById } from "~/models/station.server";
import { notFound } from "~/utils";
export async function loader({ params }: LoaderArgs) {
if (!params.station) {
throw notFound();
}
const station = await getStationById(params.station);
if (!station) {
throw notFound();
}
return json({ station });
}
export default function ListenChanelStation() {
const { station } = useLoaderData<typeof loader>();
return (
<StationPlayer station={station} />
);
}

View File

@ -1,4 +0,0 @@
import ListenChanelStation from "~/routes/listen.channel.$channel.$station";
export { loader } from "~/routes/listen.channel.$channel.$station";
export default ListenChanelStation;

View File

@ -1,7 +0,0 @@
import ListenChanelStation from "~/routes/listen.channel.$channel.$station";
export { loader } from "~/routes/listen.channel.$channel.$station";
export default ListenChanelStation;

View File

@ -1,8 +1,29 @@
import { Outlet } from "@remix-run/react"; import type { LoaderArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { Outlet, useLoaderData } from "@remix-run/react";
import { PageLayout } from "~/components/page-layout";
import { StationPlayer } from "~/components/station-player";
import type { StationWithTags } from "~/models/station.server";
import { getStationById } from "~/models/station.server";
import type { TagWithStations } from "~/models/tag.server";
import { getTags } from "~/models/tag.server";
import { getUser } from "~/session.server";
export async function loader({ request }: LoaderArgs) {
const tags: TagWithStations[] = await getTags();
const user = await getUser(request);
const url = new URL(request.url);
const stationId = url.searchParams.get("station");
const station: StationWithTags | null = stationId ? await getStationById(stationId) : null;
return json({ user, tags, station });
}
export default function ListenLayout() { export default function ListenLayout() {
const { tags, user, station } = useLoaderData<typeof loader>();
return ( return (
<Outlet /> <PageLayout tags={tags} user={user} station={station}>
<Outlet />
<StationPlayer station={station} />
</PageLayout>
); );
} }

View File

@ -8,168 +8,168 @@ import { createUserSession, getUserId } from "~/session.server";
import { safeRedirect, validateEmail } from "~/utils"; import { safeRedirect, validateEmail } from "~/utils";
export const loader = async ({ request }: LoaderArgs) => { export const loader = async ({ request }: LoaderArgs) => {
const userId = await getUserId(request); const userId = await getUserId(request);
if (userId) return redirect("/"); if (userId) return redirect("/");
return json({}); return json({});
}; };
export const action = async ({ request }: ActionArgs) => { export const action = async ({ request }: ActionArgs) => {
const formData = await request.formData(); const formData = await request.formData();
const email = formData.get("email"); const email = formData.get("email");
const password = formData.get("password"); const password = formData.get("password");
const redirectTo = safeRedirect(formData.get("redirectTo"), "/"); const redirectTo = safeRedirect(formData.get("redirectTo"), "/");
const remember = formData.get("remember"); const remember = formData.get("remember");
if (!validateEmail(email)) { if (!validateEmail(email)) {
return json( return json(
{ errors: { email: "Email is invalid", password: null } }, { errors: { email: "Email is invalid", password: null } },
{ status: 400 } { status: 400 }
); );
} }
if (typeof password !== "string" || password.length === 0) { if (typeof password !== "string" || password.length === 0) {
return json( return json(
{ errors: { email: null, password: "Password is required" } }, { errors: { email: null, password: "Password is required" } },
{ status: 400 } { status: 400 }
); );
} }
if (password.length < 8) { if (password.length < 8) {
return json( return json(
{ errors: { email: null, password: "Password is too short" } }, { errors: { email: null, password: "Password is too short" } },
{ status: 400 } { status: 400 }
); );
} }
const user = await verifyLogin(email, password); const user = await verifyLogin(email, password);
if (!user) { if (!user) {
return json( return json(
{ errors: { email: "Invalid email or password", password: null } }, { errors: { email: "Invalid email or password", password: null } },
{ status: 400 } { status: 400 }
); );
} }
return createUserSession({ return createUserSession({
redirectTo, redirectTo,
remember: remember === "on" ? true : false, remember: remember === "on",
request, request,
userId: user.id, userId: user.id
}); });
}; };
export const meta: V2_MetaFunction = () => [{ title: "Login" }]; export const meta: V2_MetaFunction = () => [{ title: "Login" }];
export default function LoginPage() { export default function LoginPage() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const redirectTo = searchParams.get("redirectTo") || "/"; const redirectTo = searchParams.get("redirectTo") || "/listen";
const actionData = useActionData<typeof action>(); const actionData = useActionData<typeof action>();
const emailRef = useRef<HTMLInputElement>(null); const emailRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null); const passwordRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
if (actionData?.errors?.email) { if (actionData?.errors?.email) {
emailRef.current?.focus(); emailRef.current?.focus();
} else if (actionData?.errors?.password) { } else if (actionData?.errors?.password) {
passwordRef.current?.focus(); passwordRef.current?.focus();
} }
}, [actionData]); }, [actionData]);
return ( return (
<div className="flex min-h-full flex-col justify-center"> <div className="flex min-h-full flex-col justify-center">
<div className="mx-auto w-full max-w-md px-8"> <div className="mx-auto w-full max-w-md px-8">
<Form method="post" className="space-y-6"> <Form method="post" className="space-y-6">
<div> <div>
<label <label
htmlFor="email" htmlFor="email"
className="block text-sm font-medium text-gray-700" className="block text-sm font-medium text-gray-700"
> >
Email address Email address
</label> </label>
<div className="mt-1"> <div className="mt-1">
<input <input
ref={emailRef} ref={emailRef}
id="email" id="email"
required required
autoFocus={true} autoFocus={true}
name="email" name="email"
type="email" type="email"
autoComplete="email" autoComplete="email"
aria-invalid={actionData?.errors?.email ? true : undefined} aria-invalid={actionData?.errors?.email ? true : undefined}
aria-describedby="email-error" aria-describedby="email-error"
className="w-full rounded border border-gray-500 px-2 py-1 text-lg" className="w-full rounded border border-gray-500 px-2 py-1 text-lg"
/> />
{actionData?.errors?.email ? ( {actionData?.errors?.email ? (
<div className="pt-1 text-red-700" id="email-error"> <div className="pt-1 text-red-700" id="email-error">
{actionData.errors.email} {actionData.errors.email}
</div> </div>
) : null} ) : null}
</div> </div>
</div> </div>
<div> <div>
<label <label
htmlFor="password" htmlFor="password"
className="block text-sm font-medium text-gray-700" className="block text-sm font-medium text-gray-700"
> >
Password Password
</label> </label>
<div className="mt-1"> <div className="mt-1">
<input <input
id="password" id="password"
ref={passwordRef} ref={passwordRef}
name="password" name="password"
type="password" type="password"
autoComplete="current-password" autoComplete="current-password"
aria-invalid={actionData?.errors?.password ? true : undefined} aria-invalid={actionData?.errors?.password ? true : undefined}
aria-describedby="password-error" aria-describedby="password-error"
className="w-full rounded border border-gray-500 px-2 py-1 text-lg" className="w-full rounded border border-gray-500 px-2 py-1 text-lg"
/> />
{actionData?.errors?.password ? ( {actionData?.errors?.password ? (
<div className="pt-1 text-red-700" id="password-error"> <div className="pt-1 text-red-700" id="password-error">
{actionData.errors.password} {actionData.errors.password}
</div> </div>
) : null} ) : null}
</div> </div>
</div> </div>
<input type="hidden" name="redirectTo" value={redirectTo} /> <input type="hidden" name="redirectTo" value={redirectTo} />
<button <button
type="submit" type="submit"
className="w-full rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600 focus:bg-blue-400" className="w-full rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600 focus:bg-blue-400"
> >
Log in Log in
</button> </button>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center"> <div className="flex items-center">
<input <input
id="remember" id="remember"
name="remember" name="remember"
type="checkbox" type="checkbox"
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/> />
<label <label
htmlFor="remember" htmlFor="remember"
className="ml-2 block text-sm text-gray-900" className="ml-2 block text-sm text-gray-900"
> >
Remember me Remember me
</label> </label>
</div>
<div className="text-center text-sm text-gray-500">
Don't have an account?{" "}
<Link
className="text-blue-500 underline"
to={{
pathname: "/join",
search: searchParams.toString()
}}
>
Sign up
</Link>
</div>
</div>
</Form>
</div> </div>
<div className="text-center text-sm text-gray-500"> </div>
Don't have an account?{" "} );
<Link
className="text-blue-500 underline"
to={{
pathname: "/join",
search: searchParams.toString(),
}}
>
Sign up
</Link>
</div>
</div>
</Form>
</div>
</div>
);
} }

View File

@ -1,8 +1,7 @@
import type { ActionArgs } from "@remix-run/node"; import type { LoaderArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { logout } from "~/session.server"; import { logout } from "~/session.server";
export const action = async ({ request }: ActionArgs) => logout(request); export async function loader({ request }: LoaderArgs) {
return logout(request);
export const loader = async () => redirect("/"); };

View File

@ -90,7 +90,7 @@ export async function createUserSession({
export async function logout(request: Request) { export async function logout(request: Request) {
const session = await getSession(request); const session = await getSession(request);
return redirect("/", { return redirect("/listen", {
headers: { headers: {
"Set-Cookie": await sessionStorage.destroySession(session) "Set-Cookie": await sessionStorage.destroySession(session)
} }