- UI
- Updated side nav to list tags
This commit is contained in:
parent
5d0a7623e3
commit
27cc0ddeb8
@ -1,12 +1,16 @@
|
|||||||
import { RadioIcon } from "@heroicons/react/24/solid";
|
import { RadioIcon } from "@heroicons/react/24/solid";
|
||||||
import { NavLink } from "@remix-run/react";
|
import { NavLink } from "@remix-run/react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import type { TagWithStationsClientSide } from "~/models/tag.server";
|
||||||
|
import type { UserWithFavoriteStationsClientSide } from "~/models/user.server";
|
||||||
|
|
||||||
export type PageLayoutProps = {
|
export type PageLayoutProps = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
tags: TagWithStationsClientSide[];
|
||||||
|
user?: UserWithFavoriteStationsClientSide
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PageLayout({ children }: PageLayoutProps) {
|
export function PageLayout({ children, tags, user }: PageLayoutProps) {
|
||||||
return (
|
return (
|
||||||
<div className="drawer drawer-mobile">
|
<div className="drawer drawer-mobile">
|
||||||
<input id="primary-drawer" type="checkbox" className="drawer-toggle" />
|
<input id="primary-drawer" type="checkbox" className="drawer-toggle" />
|
||||||
@ -26,10 +30,6 @@ export function PageLayout({ children }: PageLayoutProps) {
|
|||||||
<h1 className="text-2xl p-0">Awesome Radio</h1>
|
<h1 className="text-2xl p-0">Awesome Radio</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-none hidden lg:block">
|
<div className="flex-none hidden lg:block">
|
||||||
<ul className="menu menu-horizontal">
|
|
||||||
<li><a>Topnav Item 1</a></li>
|
|
||||||
<li><a>Topnav Item 2</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="py-2 px-6">
|
<div className="py-2 px-6">
|
||||||
@ -49,12 +49,21 @@ export function PageLayout({ children }: PageLayoutProps) {
|
|||||||
<li>
|
<li>
|
||||||
<NavLink to="/listen/home">Home</NavLink>
|
<NavLink to="/listen/home">Home</NavLink>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li className="menu-title">
|
||||||
<NavLink to="/listen/channel/music">Music</NavLink>
|
<span>Tags</span>
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<NavLink to="/listen/channel/news">News & Talk</NavLink>
|
|
||||||
</li>
|
</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">
|
<li className="menu-title">
|
||||||
<span>Manage Content</span>
|
<span>Manage Content</span>
|
||||||
</li>
|
</li>
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
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 type { StationWithTagsClientSide } from "~/models/station.server";
|
import type { StationWithTagsClientSide } from "~/models/station.server";
|
||||||
@ -40,7 +41,10 @@ export function StationsGallery({ stations, tag, channel }: StationsGalleryProps
|
|||||||
</h2>
|
</h2>
|
||||||
<p>{station.description}</p>
|
<p>{station.description}</p>
|
||||||
<div className="card-actions justify-end">
|
<div className="card-actions justify-end">
|
||||||
<Link to={getStationUrl(station.id)} className="btn btn-primary">Listen Now</Link>
|
<Link to={getStationUrl(station.id)} className="btn btn-primary gap-2">
|
||||||
|
<PlayIcon className="h-6 w-6" />
|
||||||
|
Listen Now
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -28,6 +28,7 @@ export function getStations(reliability: number = 80) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
orderBy: [
|
orderBy: [
|
||||||
|
{ reliability: "desc" },
|
||||||
{ popularity: "desc" }
|
{ popularity: "desc" }
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
@ -54,6 +55,7 @@ export function findStationsByTags(tags: string[], reliability: number = 80) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
orderBy: [
|
orderBy: [
|
||||||
|
{ reliability: "desc" },
|
||||||
{ popularity: "desc" }
|
{ popularity: "desc" }
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
@ -1,9 +1,53 @@
|
|||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
import { prisma } from "~/db.server";
|
import { prisma } from "~/db.server";
|
||||||
import type { PrismaTxClient } from "~/models/station.server";
|
import type { PrismaTxClient } from "~/models/station.server";
|
||||||
|
import type { ConvertDatesToStrings } from "~/utils";
|
||||||
import { slugify } from "~/utils";
|
import { slugify } from "~/utils";
|
||||||
|
|
||||||
export function findTagBySlug(slug: string) {
|
export type TagWithStations = NonNullable<Prisma.PromiseReturnType<typeof findTagBySlug>>;
|
||||||
return prisma.tag.findUnique({ where: { slug } });
|
export type TagWithStationsClientSide = ConvertDatesToStrings<TagWithStations>;
|
||||||
|
|
||||||
|
|
||||||
|
export function findTagBySlug(slug: string, reliability = 80) {
|
||||||
|
return prisma.tag.findUnique({
|
||||||
|
where: { slug },
|
||||||
|
include: {
|
||||||
|
stations: {
|
||||||
|
where: {
|
||||||
|
station: {
|
||||||
|
reliability: {
|
||||||
|
gte: reliability
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
station: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTags(reliability = 80) {
|
||||||
|
return prisma.tag.findMany({
|
||||||
|
include: {
|
||||||
|
stations: {
|
||||||
|
where: {
|
||||||
|
station: {
|
||||||
|
reliability: {
|
||||||
|
gte: reliability
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
station: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
name: "asc"
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function upsertTagOnName(tag: string, p: PrismaTxClient = prisma) {
|
export function upsertTagOnName(tag: string, p: PrismaTxClient = prisma) {
|
||||||
|
@ -1,62 +1,72 @@
|
|||||||
import type { Password, User } from "@prisma/client";
|
import type { Password, Prisma, User } from "@prisma/client";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
|
|
||||||
import { prisma } from "~/db.server";
|
import { prisma } from "~/db.server";
|
||||||
|
import type { ConvertDatesToStrings } from "~/utils";
|
||||||
|
|
||||||
export type { User } from "@prisma/client";
|
export type { User } from "@prisma/client";
|
||||||
|
export type UserWithFavoriteStations = NonNullable<Prisma.PromiseReturnType<typeof getUserById>>;
|
||||||
|
export type UserWithFavoriteStationsClientSide = ConvertDatesToStrings<UserWithFavoriteStations>;
|
||||||
|
|
||||||
|
|
||||||
export async function getUserById(id: User["id"]) {
|
export async function getUserById(id: User["id"]) {
|
||||||
return prisma.user.findUnique({ where: { id } });
|
return prisma.user.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
favoriteStations: {
|
||||||
|
include: { station: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUserByEmail(email: User["email"]) {
|
export async function getUserByEmail(email: User["email"]) {
|
||||||
return prisma.user.findUnique({ where: { email } });
|
return prisma.user.findUnique({ where: { email } });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createUser(email: User["email"], password: string) {
|
export async function createUser(email: User["email"], password: string) {
|
||||||
const hashedPassword = await bcrypt.hash(password, 10);
|
const hashedPassword = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
return prisma.user.create({
|
return prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
email,
|
email,
|
||||||
password: {
|
password: {
|
||||||
create: {
|
create: {
|
||||||
hash: hashedPassword,
|
hash: hashedPassword
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteUserByEmail(email: User["email"]) {
|
export async function deleteUserByEmail(email: User["email"]) {
|
||||||
return prisma.user.delete({ where: { email } });
|
return prisma.user.delete({ where: { email } });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyLogin(
|
export async function verifyLogin(
|
||||||
email: User["email"],
|
email: User["email"],
|
||||||
password: Password["hash"]
|
password: Password["hash"]
|
||||||
) {
|
) {
|
||||||
const userWithPassword = await prisma.user.findUnique({
|
const userWithPassword = await prisma.user.findUnique({
|
||||||
where: { email },
|
where: { email },
|
||||||
include: {
|
include: {
|
||||||
password: true,
|
password: true
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!userWithPassword || !userWithPassword.password) {
|
if (!userWithPassword || !userWithPassword.password) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isValid = await bcrypt.compare(
|
const isValid = await bcrypt.compare(
|
||||||
password,
|
password,
|
||||||
userWithPassword.password.hash
|
userWithPassword.password.hash
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { password: _password, ...userWithoutPassword } = userWithPassword;
|
const { password: _password, ...userWithoutPassword } = userWithPassword;
|
||||||
|
|
||||||
return userWithoutPassword;
|
return userWithoutPassword;
|
||||||
}
|
}
|
||||||
|
13
app/root.tsx
13
app/root.tsx
@ -9,10 +9,12 @@ 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 { PageLayout } from "~/components/page-layout";
|
||||||
|
import { getTags, TagWithStations } from "~/models/tag.server";
|
||||||
|
|
||||||
import { getUser } from "~/session.server";
|
import { getUser } from "~/session.server";
|
||||||
import stylesheet from "~/tailwind.css";
|
import stylesheet from "~/tailwind.css";
|
||||||
@ -22,8 +24,10 @@ export const links: LinksFunction = () => [
|
|||||||
...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : [])
|
...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : [])
|
||||||
];
|
];
|
||||||
|
|
||||||
export const loader = async ({ request }: LoaderArgs) => {
|
export async function loader({ request }: LoaderArgs) {
|
||||||
return json({ user: await getUser(request) });
|
const tags: TagWithStations[] = await getTags();
|
||||||
|
const user = await getUser(request);
|
||||||
|
return json({ user, tags });
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DocumentProps = {
|
export type DocumentProps = {
|
||||||
@ -32,7 +36,7 @@ export type DocumentProps = {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function Document({ title, children }: DocumentProps) {
|
export function Document({ title, children }: DocumentProps) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className="h-full">
|
<html lang="en" className="h-full">
|
||||||
<head>
|
<head>
|
||||||
@ -53,9 +57,10 @@ function Document({ title, children }: DocumentProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const { tags, user } = useLoaderData<typeof loader>();
|
||||||
return (
|
return (
|
||||||
<Document>
|
<Document>
|
||||||
<PageLayout>
|
<PageLayout tags={tags} user={user}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
</Document>
|
</Document>
|
||||||
|
@ -1,97 +1,98 @@
|
|||||||
import { createCookieSessionStorage, redirect } from "@remix-run/node";
|
import { createCookieSessionStorage, redirect } from "@remix-run/node";
|
||||||
import invariant from "tiny-invariant";
|
import invariant from "tiny-invariant";
|
||||||
|
|
||||||
import type { User } from "~/models/user.server";
|
import type { User, UserWithFavoriteStations } from "~/models/user.server";
|
||||||
import { getUserById } from "~/models/user.server";
|
import { getUserById } from "~/models/user.server";
|
||||||
|
|
||||||
invariant(process.env.SESSION_SECRET, "SESSION_SECRET must be set");
|
invariant(process.env.SESSION_SECRET, "SESSION_SECRET must be set");
|
||||||
|
|
||||||
export const sessionStorage = createCookieSessionStorage({
|
export const sessionStorage = createCookieSessionStorage({
|
||||||
cookie: {
|
cookie: {
|
||||||
name: "__session",
|
name: "__session",
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
path: "/",
|
path: "/",
|
||||||
sameSite: "lax",
|
sameSite: "lax",
|
||||||
secrets: [process.env.SESSION_SECRET],
|
secrets: [process.env.SESSION_SECRET],
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: process.env.NODE_ENV === "production"
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const USER_SESSION_KEY = "userId";
|
const USER_SESSION_KEY = "userId";
|
||||||
|
|
||||||
export async function getSession(request: Request) {
|
export async function getSession(request: Request) {
|
||||||
const cookie = request.headers.get("Cookie");
|
const cookie = request.headers.get("Cookie");
|
||||||
return sessionStorage.getSession(cookie);
|
return sessionStorage.getSession(cookie);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUserId(
|
export async function getUserId(
|
||||||
request: Request
|
request: Request
|
||||||
): Promise<User["id"] | undefined> {
|
): Promise<User["id"] | undefined> {
|
||||||
const session = await getSession(request);
|
const session = await getSession(request);
|
||||||
const userId = session.get(USER_SESSION_KEY);
|
return session.get(USER_SESSION_KEY);
|
||||||
return userId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUser(request: Request) {
|
export async function getUser(request: Request) {
|
||||||
const userId = await getUserId(request);
|
const userId = await getUserId(request);
|
||||||
if (userId === undefined) return null;
|
if (!userId) {
|
||||||
|
return;
|
||||||
const user = await getUserById(userId);
|
}
|
||||||
if (user) return user;
|
const user: UserWithFavoriteStations | null = await getUserById(userId);
|
||||||
|
if (!user) {
|
||||||
throw await logout(request);
|
throw await logout(request);
|
||||||
|
}
|
||||||
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function requireUserId(
|
export async function requireUserId(
|
||||||
request: Request,
|
request: Request,
|
||||||
redirectTo: string = new URL(request.url).pathname
|
redirectTo: string = new URL(request.url).pathname
|
||||||
) {
|
) {
|
||||||
const userId = await getUserId(request);
|
const userId = await getUserId(request);
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
const searchParams = new URLSearchParams([["redirectTo", redirectTo]]);
|
const searchParams = new URLSearchParams([["redirectTo", redirectTo]]);
|
||||||
throw redirect(`/login?${searchParams}`);
|
throw redirect(`/login?${searchParams}`);
|
||||||
}
|
}
|
||||||
return userId;
|
return userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function requireUser(request: Request) {
|
export async function requireUser(request: Request) {
|
||||||
const userId = await requireUserId(request);
|
const userId = await requireUserId(request);
|
||||||
|
|
||||||
const user = await getUserById(userId);
|
const user = await getUserById(userId);
|
||||||
if (user) return user;
|
if (user) return user;
|
||||||
|
|
||||||
throw await logout(request);
|
throw await logout(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createUserSession({
|
export async function createUserSession({
|
||||||
request,
|
request,
|
||||||
userId,
|
userId,
|
||||||
remember,
|
remember,
|
||||||
redirectTo,
|
redirectTo
|
||||||
}: {
|
}: {
|
||||||
request: Request;
|
request: Request;
|
||||||
userId: string;
|
userId: string;
|
||||||
remember: boolean;
|
remember: boolean;
|
||||||
redirectTo: string;
|
redirectTo: string;
|
||||||
}) {
|
}) {
|
||||||
const session = await getSession(request);
|
const session = await getSession(request);
|
||||||
session.set(USER_SESSION_KEY, userId);
|
session.set(USER_SESSION_KEY, userId);
|
||||||
return redirect(redirectTo, {
|
return redirect(redirectTo, {
|
||||||
headers: {
|
headers: {
|
||||||
"Set-Cookie": await sessionStorage.commitSession(session, {
|
"Set-Cookie": await sessionStorage.commitSession(session, {
|
||||||
maxAge: remember
|
maxAge: remember
|
||||||
? 60 * 60 * 24 * 7 // 7 days
|
? 60 * 60 * 24 * 7 // 7 days
|
||||||
: undefined,
|
: undefined
|
||||||
}),
|
})
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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("/", {
|
||||||
headers: {
|
headers: {
|
||||||
"Set-Cookie": await sessionStorage.destroySession(session),
|
"Set-Cookie": await sessionStorage.destroySession(session)
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user