feature/version-1 #1

Open
behnam wants to merge 9 commits from feature/version-1 into main
15 changed files with 9713 additions and 17573 deletions

View File

@ -6,7 +6,7 @@ on:
- main - main
- dev - dev
pull_request: pull_request:
- main
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true

View File

@ -3,7 +3,7 @@ import type { RemixLinkProps, RemixNavLinkProps } from "@remix-run/react/dist/co
import type { ReactNode, RefAttributes } from "react"; import type { ReactNode, RefAttributes } from "react";
import { createContext, useContext } from "react"; import { createContext, useContext } from "react";
import { SideNav } from "~/components/side-nav"; import { SideNav } from "~/components/side-nav";
import { TopNavbar } from "~/components/top-navbar"; // import { TopNavbar } from "~/components/top-navbar";
import type { StationWithTagsClientSide } from "~/models/station.server"; 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";
@ -28,7 +28,7 @@ export function useStationContext() {
export function ListenLink(props: RemixLinkProps & RefAttributes<HTMLAnchorElement>) { export function ListenLink(props: RemixLinkProps & RefAttributes<HTMLAnchorElement>) {
const { station } = useStationContext(); const { station } = useStationContext();
const url = props.to + (station ? `?station=${station.id}` : ""); const url = props.to + (station ? `?station=${station.id}` : "");
return <Link {...props} to={url}>{props.children}</Link>; return <Link preventScrollReset={true} {...props} to={url}>{props.children}</Link>;
} }
/** /**
@ -54,7 +54,7 @@ export function PageLayout({ children, tags, user, station }: PageLayoutProps) {
<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" />
<div className="drawer-content flex flex-col"> <div className="drawer-content flex flex-col">
<TopNavbar user={user} /> {/* <TopNavbar user={user} /> */}
<div className="py-2 px-6"> <div className="py-2 px-6">
{children} {children}
</div> </div>

View File

@ -1,13 +1,20 @@
import { useNavigate } from "@remix-run/react";
import type { ReactNode} from "react"; import type { ReactNode} from "react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { StationWithTagsClientSide } from "~/models/station.server"; import type { StationWithTagsClientSide } from "~/models/station.server";
import { getStationUrl } from "~/utils";
export type StationPlayerProps = { export type StationPlayerProps = {
station: StationWithTagsClientSide | null station: StationWithTagsClientSide | null,
nextPrevStationIds: {
prev?: string;
next?: string;
},
}; };
export function StationPlayer({ station }: StationPlayerProps) { export function StationPlayer({ station, nextPrevStationIds }: StationPlayerProps) {
const [player, setPlayer] = useState<ReactNode | null>(null); const [player, setPlayer] = useState<ReactNode | null>(null);
const route = useNavigate();
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
@ -22,21 +29,31 @@ export function StationPlayer({ station }: StationPlayerProps) {
title={station.name} title={station.name}
description={station.description || undefined} description={station.description || undefined}
image={station.imgUrl} image={station.imgUrl}
secondDescription={`* ${station}`} isNextButtonDisabled={!nextPrevStationIds.next}
isPrevButtonDisabled={!nextPrevStationIds.prev}
onNextButtonClicked={() => {
if (!nextPrevStationIds.next) return;
route(getStationUrl(nextPrevStationIds.next))
}}
onPrevButtonClicked={() => {
if (!nextPrevStationIds.prev) return;
route(getStationUrl(nextPrevStationIds.prev))
}}
secondDescription={station.popularity ? <>&#9733; {station.popularity}</> : undefined}
/>); />);
} catch (error) { } catch (error) {
console.log('rerrerer', error) console.log('rerrerer', error)
} }
}; };
importComponent() importComponent()
}, [station]) }, [station, nextPrevStationIds, route])
if (!station || !player) { if (!station || !player) {
return <></>; return <></>;
} }
return ( return (
<div <div
className="fixed bottom-0 right-[-1rem] w-[76%] h-[70px] px-4 py-2 z-50 flex justify-end content-center items-center gap-2 text-accent-content"> className="fixed bottom-0 right-0 w-full lg:w-[calc(100vw-20rem)] h-[90px] z-50">
{player} {player}
</div> </div>
); );

View File

@ -1,9 +1,9 @@
import { PlayIcon } from "@heroicons/react/24/solid"; import { PlayIcon, SpeakerWaveIcon } 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, useLocation } from "@remix-run/react";
import { ListenLink } from "~/components/page-layout"; import { ListenLink } from "~/components/page-layout";
import type { StationWithTagsClientSide } from "~/models/station.server"; import type { StationWithTagsClientSide } from "~/models/station.server";
import type { ConvertDatesToStrings } from "~/utils"; import { getStationUrl, type ConvertDatesToStrings } from "~/utils";
export type StationsGalleryProps = { export type StationsGalleryProps = {
stations: StationWithTagsClientSide[]; stations: StationWithTagsClientSide[];
@ -11,14 +11,8 @@ export type StationsGalleryProps = {
}; };
export function StationsGallery({ stations, tag }: StationsGalleryProps) { export function StationsGallery({ stations, tag }: StationsGalleryProps) {
const location = useLocation();
function getStationUrl(id: string): string { const currentStationId = new URLSearchParams(location.search).get('station')?.trim()
if (tag) {
return `/listen/tag/${tag?.slug}?station=${id}`;
}
return `/listen?station=${id}`;
}
return ( return (
<div className="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-4 gap-4">
{stations.map((station) => { {stations.map((station) => {
@ -26,9 +20,14 @@ export function StationsGallery({ stations, tag }: StationsGalleryProps) {
<div key={station.id} className="card card-compact bg-base-100 shadow-xl mb-[70px]"> <div key={station.id} className="card card-compact bg-base-100 shadow-xl mb-[70px]">
<figure><img src={station.imgUrl} alt="Radio Station" /></figure> <figure><img src={station.imgUrl} alt="Radio Station" /></figure>
<div className="card-body"> <div className="card-body">
<h2 className="card-title"> <div className="flex items-center justify-start gap-2">
{station.name} <h2 className="card-title">
</h2> {station.name}
</h2>
{station.id === currentStationId ? <div className="w-4 h-4">
<SpeakerWaveIcon />
</div> : null}
</div>
<h2 className="flex gap-1"> <h2 className="flex gap-1">
{station.tags.map((t, id) => { {station.tags.map((t, id) => {
return <ListenLink key={id} to={`/listen/tag/${t.tag.slug}`} return <ListenLink key={id} to={`/listen/tag/${t.tag.slug}`}
@ -37,7 +36,7 @@ export function StationsGallery({ stations, tag }: 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)} <Link preventScrollReset to={getStationUrl(station.id, tag)}
className={`btn btn-primary gap-2 plausible-event-name=play-station plausible-event-station=${station.slug}`}> className={`btn btn-primary gap-2 plausible-event-name=play-station plausible-event-station=${station.slug}`}>
<PlayIcon className="h-6 w-6" /> <PlayIcon className="h-6 w-6" />
Listen Now Listen Now

View File

@ -37,6 +37,34 @@ export function getStations(reliability: number = 80) {
}); });
} }
/**
*
* @param stationId current station id that we want to get next and prev
* @returns
*/
export const getPrevNextStations = async (stationId?: string) => {
const defaultData = {
prev: undefined,
next: undefined
}
if (!stationId) return defaultData;
const stations = await getStations();
return stations.reduce((prev, currStation, index, array) => {
if (currStation.id === stationId) {
return {
prev: array[index - 1]?.id,
next: array[index + 1]?.id,
}
}
return prev
}, defaultData as {
prev?: string,
next?: string,
})
}
/** /**
* Fetch stations tagged with `tags` and a reliability score GTE to the `reliability` parameter. * Fetch stations tagged with `tags` and a reliability score GTE to the `reliability` parameter.
*/ */

View File

@ -1,166 +0,0 @@
import type { ActionArgs, LoaderArgs, V2_MetaFunction } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, Link, useActionData, useSearchParams } from "@remix-run/react";
import { useEffect, useRef } from "react";
import { createUser, getUserByEmail } from "~/models/user.server";
import { createUserSession, getUserId } from "~/session.server";
import { safeRedirect, validateEmail } from "~/utils";
export const loader = async ({ request }: LoaderArgs) => {
const userId = await getUserId(request);
if (userId) return redirect("/");
return json({});
};
export const action = async ({ request }: ActionArgs) => {
const formData = await request.formData();
const email = formData.get("email");
const password = formData.get("password");
const redirectTo = safeRedirect(formData.get("redirectTo"), "/");
if (!validateEmail(email)) {
return json(
{ errors: { email: "Email is invalid", password: null } },
{ status: 400 }
);
}
if (typeof password !== "string" || password.length === 0) {
return json(
{ errors: { email: null, password: "Password is required" } },
{ status: 400 }
);
}
if (password.length < 8) {
return json(
{ errors: { email: null, password: "Password is too short" } },
{ status: 400 }
);
}
const existingUser = await getUserByEmail(email);
if (existingUser) {
return json(
{
errors: {
email: "A user already exists with this email",
password: null,
},
},
{ status: 400 }
);
}
const user = await createUser(email, password);
return createUserSession({
redirectTo,
remember: false,
request,
userId: user.id,
});
};
export const meta: V2_MetaFunction = () => [{ title: "Sign Up" }];
export default function Join() {
const [searchParams] = useSearchParams();
const redirectTo = searchParams.get("redirectTo") ?? undefined;
const actionData = useActionData<typeof action>();
const emailRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (actionData?.errors?.email) {
emailRef.current?.focus();
} else if (actionData?.errors?.password) {
passwordRef.current?.focus();
}
}, [actionData]);
return (
<div className="flex min-h-full flex-col justify-center">
<div className="mx-auto w-full max-w-md px-8">
<Form method="post" className="space-y-6">
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700"
>
Email address
</label>
<div className="mt-1">
<input
ref={emailRef}
id="email"
required
autoFocus={true}
name="email"
type="email"
autoComplete="email"
aria-invalid={actionData?.errors?.email ? true : undefined}
aria-describedby="email-error"
className="w-full rounded border border-gray-500 px-2 py-1 text-lg"
/>
{actionData?.errors?.email ? (
<div className="pt-1 text-red-700" id="email-error">
{actionData.errors.email}
</div>
) : null}
</div>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700"
>
Password
</label>
<div className="mt-1">
<input
id="password"
ref={passwordRef}
name="password"
type="password"
autoComplete="new-password"
aria-invalid={actionData?.errors?.password ? true : undefined}
aria-describedby="password-error"
className="w-full rounded border border-gray-500 px-2 py-1 text-lg"
/>
{actionData?.errors?.password ? (
<div className="pt-1 text-red-700" id="password-error">
{actionData.errors.password}
</div>
) : null}
</div>
</div>
<input type="hidden" name="redirectTo" value={redirectTo} />
<button
type="submit"
className="w-full rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600 focus:bg-blue-400"
>
Create Account
</button>
<div className="flex items-center justify-center">
<div className="text-center text-sm text-gray-500">
Already have an account?{" "}
<Link
className="text-blue-500 underline"
to={{
pathname: "/login",
search: searchParams.toString(),
}}
>
Log in
</Link>
</div>
</div>
</Form>
</div>
</div>
);
}

View File

@ -17,7 +17,7 @@ export default function ListenHome() {
return ( return (
<> <>
<Breadcrumbs> <Breadcrumbs>
<Link to="/listen">Home</Link> <Link preventScrollReset to="/listen">Home</Link>
</Breadcrumbs> </Breadcrumbs>
<StationsGallery stations={stations} /> <StationsGallery stations={stations} />
<Outlet /> <Outlet />

View File

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

View File

@ -1,6 +1,6 @@
import type { ActionArgs, LoaderArgs, V2_MetaFunction } from "@remix-run/node"; import type { ActionArgs, LoaderArgs, V2_MetaFunction } from "@remix-run/node";
import { json, redirect } from "@remix-run/node"; import { json, redirect } from "@remix-run/node";
import { Form, Link, useActionData, useSearchParams } from "@remix-run/react"; import { Form, useActionData, useSearchParams } from "@remix-run/react";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { verifyLogin } from "~/models/user.server"; import { verifyLogin } from "~/models/user.server";
@ -150,23 +150,12 @@ export default function LoginPage() {
/> />
<label <label
htmlFor="remember" htmlFor="remember"
className="ml-2 block text-sm text-gray-900" className="ml-2 block text-sm text-secondary"
> >
Remember me Remember me
</label> </label>
</div> </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> </div>
</Form> </Form>
</div> </div>

View File

@ -27,6 +27,13 @@ export function safeRedirect(
return to; return to;
} }
export function getStationUrl(id: string, tag?: {slug: string}): string {
if (tag) {
return `/listen/tag/${tag?.slug}?station=${id}`;
}
return `/listen?station=${id}`;
}
/** /**
* This base hook is used in other hooks to quickly search for specific data * This base hook is used in other hooks to quickly search for specific data
* across all loader data using useMatches. * across all loader data using useMatches.

17359
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -39,7 +39,7 @@
"isbot": "^3.6.8", "isbot": "^3.6.8",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-radio-player": "^0.0.7", "react-radio-player": "^0.1.8",
"tiny-invariant": "^1.3.1" "tiny-invariant": "^1.3.1"
}, },
"devDependencies": { "devDependencies": {

View File

@ -7,7 +7,7 @@ export default {
}, },
plugins: [require("@tailwindcss/typography"), require("daisyui")], plugins: [require("@tailwindcss/typography"), require("daisyui")],
daisyui: { daisyui: {
darkTheme: "night", darkTheme: "black",
themes: ["light", "night"] themes: ["light", "black"]
} }
} satisfies Config; } satisfies Config;

View File

@ -1,5 +1,5 @@
Arguments: Arguments:
C:\Program Files\nodejs\node.exe C:\Users\98921\AppData\Roaming\npm\node_modules\yarn\bin\yarn.js add react-radio-player C:\Program Files\nodejs\node.exe C:\Users\98921\AppData\Roaming\npm\node_modules\yarn\bin\yarn.js
PATH: PATH:
C:\Program Files\Eclipse Adoptium\jre-21.0.1.12-hotspot\bin;C:\Program Files (x86)\Common Files\Oracle\Java\javapath;C:\Windows\system32;C:\Windows;C:\Windows\System32\Wbem;C:\Windows\System32\WindowsPowerShell\v1.0\;C:\Windows\System32\OpenSSH\;C:\Program Files (x86)\NVIDIA Corporation\PhysX\Common;C:\Program Files\NVIDIA Corporation\NVIDIA NvDLISR;C:\Program Files\Git\cmd;C:\Program Files\nodejs\;C:\ProgramData\chocolatey\bin;C:\Users\98921\AppData\Local\Microsoft\WindowsApps;;C:\Users\98921\AppData\Local\Programs\Microsoft VS Code\bin;C:\Users\98921\AppData\Roaming\npm C:\Program Files\Eclipse Adoptium\jre-21.0.1.12-hotspot\bin;C:\Program Files (x86)\Common Files\Oracle\Java\javapath;C:\Windows\system32;C:\Windows;C:\Windows\System32\Wbem;C:\Windows\System32\WindowsPowerShell\v1.0\;C:\Windows\System32\OpenSSH\;C:\Program Files (x86)\NVIDIA Corporation\PhysX\Common;C:\Program Files\NVIDIA Corporation\NVIDIA NvDLISR;C:\Program Files\Git\cmd;C:\Program Files\nodejs\;C:\ProgramData\chocolatey\bin;C:\Users\98921\AppData\Local\Microsoft\WindowsApps;;C:\Users\98921\AppData\Local\Programs\Microsoft VS Code\bin;C:\Users\98921\AppData\Roaming\npm
@ -58,7 +58,7 @@ npm manifest:
"isbot": "^3.6.8", "isbot": "^3.6.8",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-radio-player": "^0.0.7", "react-radio-player": "^0.1.4",
"tiny-invariant": "^1.3.1" "tiny-invariant": "^1.3.1"
}, },
"devDependencies": { "devDependencies": {

9623
yarn.lock Normal file

File diff suppressed because it is too large Load Diff