Compare commits

..

8 Commits

40 changed files with 1762 additions and 309 deletions

84
client/.eslintrc.cjs Normal file
View File

@ -0,0 +1,84 @@
module.exports = {
root: true,
env: {
node: true,
es6: true,
},
parserOptions: { ecmaVersion: 8, sourceType: "module" },
ignorePatterns: ["node_modules/*"],
extends: ["eslint:recommended"],
overrides: [
{
files: ["**/*.ts", "**/*.tsx"],
parser: "@typescript-eslint/parser",
settings: {
react: { version: "detect" },
"import/resolver": {
typescript: {},
},
},
env: {
browser: true,
node: true,
es6: true,
},
extends: [
"eslint:recommended",
"plugin:import/errors",
"plugin:import/warnings",
"plugin:import/typescript",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"plugin:jsx-a11y/recommended",
"plugin:prettier/recommended",
"plugin:testing-library/react",
"plugin:jest-dom/recommended",
],
rules: {
"react/display-name": "off",
"no-restricted-imports": [
"error",
{
patterns: ["@/features/*/*"],
},
],
"linebreak-style": ["error", "unix"],
"react/prop-types": "off",
"import/order": [
"error",
{
groups: [
"builtin",
"external",
"internal",
"parent",
"sibling",
"index",
"object",
],
"newlines-between": "always",
alphabetize: { order: "asc", caseInsensitive: true },
},
],
"import/default": "off",
"import/no-named-as-default-member": "off",
"import/no-named-as-default": "off",
"react/react-in-jsx-scope": "off",
"jsx-a11y/anchor-is-valid": "off",
"@typescript-eslint/no-unused-vars": ["error"],
"@typescript-eslint/explicit-function-return-type": ["off"],
"@typescript-eslint/explicit-module-boundary-types": ["off"],
"@typescript-eslint/no-empty-function": ["off"],
"@typescript-eslint/no-explicit-any": ["off"],
"prettier/prettier": ["error", {}, { usePrettierrc: true }],
},
},
],
};

View File

@ -1,4 +1,7 @@
{ {
"singleQuote": true,
"trailingComma": "es5",
"printWidth": 100,
"tabWidth": 2, "tabWidth": 2,
"useTabs": false "useTabs": false
} }

View File

@ -1,10 +1,10 @@
import { defineConfig } from "cypress"; import { defineConfig } from 'cypress';
export default defineConfig({ export default defineConfig({
component: { component: {
devServer: { devServer: {
framework: "react", framework: 'react',
bundler: "vite", bundler: 'vite',
}, },
}, },

View File

@ -14,12 +14,12 @@
// *********************************************************** // ***********************************************************
// Import commands.js using ES2015 syntax: // Import commands.js using ES2015 syntax:
import './commands' import './commands';
// Alternatively you can use CommonJS syntax: // Alternatively you can use CommonJS syntax:
// require('./commands') // require('./commands')
import { mount } from 'cypress/react18' import { mount } from 'cypress/react18';
// Augment the Cypress namespace to include type definitions for // Augment the Cypress namespace to include type definitions for
// your custom command. // your custom command.
@ -28,12 +28,12 @@ import { mount } from 'cypress/react18'
declare global { declare global {
namespace Cypress { namespace Cypress {
interface Chainable { interface Chainable {
mount: typeof mount mount: typeof mount;
} }
} }
} }
Cypress.Commands.add('mount', mount) Cypress.Commands.add('mount', mount);
// Example use: // Example use:
// cy.mount(<MyComponent />) // cy.mount(<MyComponent />)

View File

@ -14,7 +14,7 @@
// *********************************************************** // ***********************************************************
// Import commands.js using ES2015 syntax: // Import commands.js using ES2015 syntax:
import "./commands"; import './commands';
// Alternatively you can use CommonJS syntax: // Alternatively you can use CommonJS syntax:
// require('./commands') // require('./commands')

View File

@ -3,4 +3,4 @@
"compilerOptions": { "compilerOptions": {
"isolatedModules": false "isolatedModules": false
} }
} }

View File

@ -7,13 +7,14 @@
"dev": "vite", "dev": "vite",
"build": "tsc && vite build --outDir ../dist", "build": "tsc && vite build --outDir ../dist",
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint --fix --ext .js,.ts,.tsx ./src --ignore-path .gitignore",
"prettier": "prettier --ignore-path .gitignore --write \"**/*.+(js|json|ts|tsx)\"",
"cypress": "cypress" "cypress": "cypress"
}, },
"dependencies": { "dependencies": {
"@headlessui/react": "^1.7.2", "@headlessui/react": "^1.7.2",
"@heroicons/react": "^2.0.11", "@heroicons/react": "^2.0.11",
"@hookform/resolvers": "^2.9.8", "@hookform/resolvers": "^2.9.8",
"@tanstack/react-query": "^4.7.2",
"axios": "^0.27.2", "axios": "^0.27.2",
"clsx": "^1.2.1", "clsx": "^1.2.1",
"react": "^18.2.0", "react": "^18.2.0",
@ -27,14 +28,26 @@
"@testing-library/cypress": "^8.0.3", "@testing-library/cypress": "^8.0.3",
"@types/react": "^18.0.17", "@types/react": "^18.0.17",
"@types/react-dom": "^18.0.6", "@types/react-dom": "^18.0.6",
"@typescript-eslint/eslint-plugin": "^5.39.0",
"@typescript-eslint/parser": "^5.39.0",
"@vitejs/plugin-react": "^2.1.0", "@vitejs/plugin-react": "^2.1.0",
"autoprefixer": "^10.4.12", "autoprefixer": "^10.4.12",
"cypress": "^10.9.0", "cypress": "^10.9.0",
"eslint": "^8.24.0",
"eslint-config-prettier": "^8.5.0",
"eslint-import-resolver-typescript": "^3.5.1",
"eslint-plugin-cypress": "^2.12.1",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jest-dom": "^4.0.2",
"eslint-plugin-jsx-a11y": "^6.6.1",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.31.8",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-testing-library": "^5.7.2",
"postcss": "^8.4.16", "postcss": "^8.4.16",
"prettier": "^2.7.1", "prettier": "^2.7.1",
"tailwindcss": "^3.1.8", "tailwindcss": "^3.1.8",
"typescript": "^4.6.4", "typescript": "^4.6.4",
"vite": "^3.1.0" "vite": "^3.1.0"
}, }
"proxy": "http://localhost:5000"
} }

1453
client/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,21 +1,13 @@
import { Routes, Route } from "react-router-dom"; import { BrowserRouter } from 'react-router-dom';
import BrowseLayout from "./components/BrowseLayout";
import CategoryPage from "./pages/CategoryPage"; import Routes from './routes';
import ChannelPage from "./pages/ChannelPage"; import './styles/global.css';
import LoginPage from "./pages/LoginPage";
import SignupPage from "./pages/SignupPage";
function App() { function App() {
return ( return (
<Routes> <BrowserRouter>
<Route path="/login" element={<LoginPage />} /> <Routes />
<Route path="/signup" element={<SignupPage />} /> </BrowserRouter>
<Route element={<BrowseLayout />}>
<Route path="/:channel" element={<ChannelPage />} />
<Route path="/category/:category" element={<CategoryPage />} />
<Route path="/" element={<h1>Hi</h1>} />
</Route>
</Routes>
); );
} }

View File

@ -1,10 +1,10 @@
import { ArrowLeftIcon } from "@heroicons/react/24/outline"; import { ArrowLeftIcon } from '@heroicons/react/24/outline';
import Button from "../components/Button"; import { Outlet, NavLink } from 'react-router-dom';
import NavBar from "../components/NavBar";
import SideNavChannel from "../components/SideNavChannel"; import Button from '../components/Button';
import { Outlet } from "react-router-dom"; import NavBar from '../components/NavBar';
import streamData from "../placeholder/GetStreams"; import SideNavChannel from '../components/SideNavChannel';
import { NavLink } from "react-router-dom"; import streamData from '../placeholder/GetStreams';
function BrowseLayout() { function BrowseLayout() {
return ( return (

View File

@ -1,30 +1,25 @@
import { FC } from "react"; import clsx from 'clsx';
import clsx from "clsx"; import { FC } from 'react';
type ButtonVariants = "filled" | "subtle"; type ButtonVariants = 'filled' | 'subtle';
interface ButtonProps extends React.ComponentPropsWithoutRef<"button"> { interface ButtonProps extends React.ComponentPropsWithoutRef<'button'> {
variant?: ButtonVariants; variant?: ButtonVariants;
} }
const getStyling = (variant?: ButtonVariants) => { const getStyling = (variant?: ButtonVariants) => {
switch (variant) { switch (variant) {
case "filled": case 'filled':
return "bg-neutral-700"; return 'bg-neutral-700';
case "subtle": case 'subtle':
return "hover:bg-neutral-500"; return 'hover:bg-neutral-500';
default: default:
return "bg-neutral-700"; return 'bg-neutral-700';
} }
}; };
const Button: FC<ButtonProps> = ({ className, variant, ...rest }) => { const Button: FC<ButtonProps> = ({ className, variant, ...rest }) => {
return ( return <button className={clsx('rounded-md', getStyling(variant), className)} {...rest} />;
<button
className={clsx("rounded-md", getStyling(variant), className)}
{...rest}
/>
);
}; };
export default Button; export default Button;

View File

@ -1,9 +1,7 @@
import { FC } from "react"; import { FC } from 'react';
const ChatBadge: FC = () => { const ChatBadge: FC = () => {
return ( return <span className="w-5 h-5 rounded-sm bg-pink-300 inline-block align-middle" />;
<span className="w-5 h-5 rounded-sm bg-pink-300 inline-block align-middle" />
);
}; };
export default ChatBadge; export default ChatBadge;

View File

@ -1,5 +1,6 @@
import { FC } from "react"; import { FC } from 'react';
import ChatBadge from "./ChatBadge";
import ChatBadge from './ChatBadge';
const ChatMessage: FC = () => { const ChatMessage: FC = () => {
return ( return (
@ -13,6 +14,7 @@ const ChatMessage: FC = () => {
<span className="break-all align-middle"> <span className="break-all align-middle">
<img <img
src="https://cdn.7tv.app/emote/60afbe0599923bbe7fe9bae1/2x" src="https://cdn.7tv.app/emote/60afbe0599923bbe7fe9bae1/2x"
alt="Poggies"
className="inline w-7 h-7" className="inline w-7 h-7"
/> />
</span> </span>

View File

@ -1,7 +1,8 @@
import { FC, forwardRef, ReactNode } from "react"; import { forwardRef, ReactNode } from 'react';
import Input from "./Input";
interface FormFieldProps extends React.ComponentPropsWithoutRef<"input"> { import Input from './Input';
interface FormFieldProps extends React.ComponentPropsWithoutRef<'input'> {
label: string; label: string;
bottomElement?: ReactNode; bottomElement?: ReactNode;
} }

View File

@ -1,36 +1,24 @@
import { FC } from "react"; import clsx from 'clsx';
import clsx from "clsx"; import { FC } from 'react';
import { NavLink } from "react-router-dom"; import { NavLink } from 'react-router-dom';
export interface InlineLinkProps export interface InlineLinkProps extends React.ComponentPropsWithoutRef<'span'> {
extends React.ComponentPropsWithoutRef<"span"> {
to: string; to: string;
external?: boolean; external?: boolean;
} }
const InlineLink: FC<InlineLinkProps> = ({ const InlineLink: FC<InlineLinkProps> = ({ to, external, className, ...rest }) => {
to,
external,
className,
...rest
}) => {
if (external === true) { if (external === true) {
return ( return (
<a href={to}> <a href={to}>
<span <span className={clsx('text-violet-400 cursor-pointer text-sm', className)} {...rest} />
className={clsx("text-violet-400 cursor-pointer text-sm", className)}
{...rest}
/>
</a> </a>
); );
} }
return ( return (
<NavLink to={to}> <NavLink to={to}>
<span <span className={clsx('text-violet-400 cursor-pointer text-sm', className)} {...rest} />
className={clsx("text-violet-400 cursor-pointer text-sm", className)}
{...rest}
/>
</NavLink> </NavLink>
); );
}; };

View File

@ -1,21 +1,19 @@
import clsx from "clsx"; import clsx from 'clsx';
import { forwardRef } from "react"; import { forwardRef } from 'react';
interface InputProps extends React.ComponentPropsWithoutRef<"input"> {} type InputProps = React.ComponentPropsWithoutRef<'input'>;
const Input = forwardRef<HTMLInputElement, InputProps>( const Input = forwardRef<HTMLInputElement, InputProps>(({ className, ...rest }, ref) => {
({ className, ...rest }, ref) => { return (
return ( <input
<input className={clsx(
className={clsx( 'bg-zinc-700 rounded-md box-border focus:outline outline-violet-400 text-sm',
"bg-zinc-700 rounded-md box-border focus:outline outline-violet-400 text-sm", className
className )}
)} {...rest}
{...rest} ref={ref}
ref={ref} />
/> );
); });
}
);
export default Input; export default Input;

View File

@ -1,9 +1,9 @@
import { FC } from "react"; import { FC } from 'react';
import { useForm, SubmitHandler } from "react-hook-form"; import { useForm, SubmitHandler } from 'react-hook-form';
import FormField from "./FormField"; import FormField from './FormField';
import InlineLink from "./InlineLink"; import InlineLink from './InlineLink';
import SubmitButton from "./SubmitButton"; import SubmitButton from './SubmitButton';
interface LoginFormValues { interface LoginFormValues {
username: string; username: string;
@ -19,13 +19,12 @@ const LoginForm: FC = () => {
<FormField <FormField
label="Username" label="Username"
className="py-2 px-2 outline-2 w-full" className="py-2 px-2 outline-2 w-full"
{...register("username")} {...register('username')}
autoFocus
/> />
<FormField <FormField
label="Password" label="Password"
type="password" type="password"
{...register("password")} {...register('password')}
className="py-2 px-2 outline-2 w-full" className="py-2 px-2 outline-2 w-full"
bottomElement={ bottomElement={
<InlineLink to="#" className="block mt-2"> <InlineLink to="#" className="block mt-2">

View File

@ -1,11 +1,12 @@
import { FC } from "react"; import { Dialog, Tab } from '@headlessui/react';
import { Dialog } from "@headlessui/react"; import { FC } from 'react';
import { createPortal } from "react-dom"; import { createPortal } from 'react-dom';
import { Tab } from "@headlessui/react";
import logo from "../assets/images/logo.png"; import logo from '../assets/images/logo.png';
import LoginForm from "./LoginForm";
import LoginModalTab from "./LoginModalTab"; import LoginForm from './LoginForm';
import SignupForm from "./SignupForm"; import LoginModalTab from './LoginModalTab';
import SignupForm from './SignupForm';
export interface LoginModelProps { export interface LoginModelProps {
isOpen: boolean; isOpen: boolean;
@ -20,21 +21,16 @@ const LoginModal: FC<LoginModelProps> = ({ defaultPage, isOpen, onClose }) => {
<Dialog.Panel className="bg-zinc-900 text-gray-100 w-[420px] rounded-md py-12 px-6"> <Dialog.Panel className="bg-zinc-900 text-gray-100 w-[420px] rounded-md py-12 px-6">
<div className="flex flex-row items-center justify-center"> <div className="flex flex-row items-center justify-center">
<Dialog.Title className="text-xl"> <Dialog.Title className="text-xl">
<img src={logo} className="inline w-12 h-12" /> Log in to <img src={logo} className="inline w-12 h-12" alt="logo" /> Log in to twitch-clone
twitch-clone
</Dialog.Title> </Dialog.Title>
</div> </div>
<Tab.Group defaultIndex={defaultPage}> <Tab.Group defaultIndex={defaultPage}>
<Tab.List className="space-x-4 border-b border-b-neutral-100/40 mt-4"> <Tab.List className="space-x-4 border-b border-b-neutral-100/40 mt-4">
<Tab> <Tab>
{({ selected }) => ( {({ selected }) => <LoginModalTab selected={selected}>Log In</LoginModalTab>}
<LoginModalTab selected={selected}>Log In</LoginModalTab>
)}
</Tab> </Tab>
<Tab> <Tab>
{({ selected }) => ( {({ selected }) => <LoginModalTab selected={selected}>Sign Up</LoginModalTab>}
<LoginModalTab selected={selected}>Sign Up</LoginModalTab>
)}
</Tab> </Tab>
</Tab.List> </Tab.List>
<Tab.Panels className="mt-4"> <Tab.Panels className="mt-4">

View File

@ -1,7 +1,7 @@
import { FC } from "react"; import clsx from 'clsx';
import clsx from "clsx"; import { FC } from 'react';
interface LoginModalTabProps extends React.ComponentPropsWithoutRef<"p"> { interface LoginModalTabProps extends React.ComponentPropsWithoutRef<'p'> {
selected: boolean; selected: boolean;
} }
@ -9,8 +9,8 @@ const LoginModalTab: FC<LoginModalTabProps> = ({ selected, ...rest }) => {
return ( return (
<p <p
className={clsx( className={clsx(
"font-semibold p-1", 'font-semibold p-1',
selected && "text-violet-400 border-b-2 border-b-violet-400" selected && 'text-violet-400 border-b-2 border-b-violet-400'
)} )}
{...rest} {...rest}
/> />

View File

@ -1,9 +1,11 @@
import { TvIcon, UserIcon } from "@heroicons/react/24/outline"; import { UserIcon } from '@heroicons/react/24/outline';
import { FC, useState } from "react"; import { FC, useState } from 'react';
import Button from "./Button";
import Input from "./Input"; import logo from '../assets/images/logo.png';
import LoginModal from "./LoginModal";
import logo from "../assets/images/logo.png"; import Button from './Button';
import Input from './Input';
import LoginModal from './LoginModal';
const NavBar: FC = () => { const NavBar: FC = () => {
const [showLogin, setShowLogin] = useState(false); const [showLogin, setShowLogin] = useState(false);
@ -25,7 +27,7 @@ const NavBar: FC = () => {
<div className="basis-1/4"> <div className="basis-1/4">
<ul className="flex flex-row space-x-8 items-center"> <ul className="flex flex-row space-x-8 items-center">
<li> <li>
<img src={logo} className="w-8 h-8" /> <img src={logo} className="w-8 h-8" alt="logo" />
</li> </li>
<li> <li>
<p className="text-lg">Browse</p> <p className="text-lg">Browse</p>
@ -40,18 +42,12 @@ const NavBar: FC = () => {
<div className="basis-1/4"> <div className="basis-1/4">
<ul className="justify-end flex flex-row space-x-3 items-center"> <ul className="justify-end flex flex-row space-x-3 items-center">
<li> <li>
<Button <Button className="text-sm px-3 py-2 bg-neutral-700" onClick={showLoginTab}>
className="text-sm px-3 py-2 bg-neutral-700"
onClick={showLoginTab}
>
Log In Log In
</Button> </Button>
</li> </li>
<li> <li>
<Button <Button className="text-sm px-3 py-2 bg-violet-500" onClick={showSignupTab}>
className="text-sm px-3 py-2 bg-violet-500"
onClick={showSignupTab}
>
Sign Up Sign Up
</Button> </Button>
</li> </li>
@ -63,11 +59,7 @@ const NavBar: FC = () => {
</ul> </ul>
</div> </div>
</div> </div>
<LoginModal <LoginModal isOpen={showLogin} defaultPage={showTab} onClose={() => setShowLogin(false)} />
isOpen={showLogin}
defaultPage={showTab}
onClose={() => setShowLogin(false)}
/>
</nav> </nav>
); );
}; };

View File

@ -1,6 +1,7 @@
import { FC } from "react"; import { FC } from 'react';
import { numFormatter } from "../lib/format";
import { Stream } from "../types"; import { Stream } from '../types';
import { numFormatter } from '../utils/format';
interface SideNavChannelProps { interface SideNavChannelProps {
stream: Stream; stream: Stream;
@ -9,7 +10,7 @@ interface SideNavChannelProps {
const SideNavChannel: FC<SideNavChannelProps> = ({ stream }) => { const SideNavChannel: FC<SideNavChannelProps> = ({ stream }) => {
return ( return (
<div className="flex flex-row px-3 py-2 text-sm leading-4 space-x-2 hover:bg-neutral-700/40 cursor-pointer"> <div className="flex flex-row px-3 py-2 text-sm leading-4 space-x-2 hover:bg-neutral-700/40 cursor-pointer">
<img className="rounded-full w-8 h-8" src={stream.thumbnail_url} /> <img className="rounded-full w-8 h-8" src={stream.thumbnail_url} alt="avatar" />
<div className="flex flex-col flex-1"> <div className="flex flex-col flex-1">
<div className="flex flex-row justify-between"> <div className="flex flex-row justify-between">
<div className="font-bold">{stream.user_name}</div> <div className="font-bold">{stream.user_name}</div>

View File

@ -1,15 +1,13 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { zodResolver } from '@hookform/resolvers/zod';
import { FC } from "react"; import { FC } from 'react';
import { SubmitHandler, useForm } from "react-hook-form"; import { SubmitHandler, useForm } from 'react-hook-form';
import FormField from "./FormField"; import { z } from 'zod';
import InlineLink from "./InlineLink";
import SubmitButton from "./SubmitButton";
import axios from "axios";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
const PASSWORD_REGEX = import FormField from './FormField';
/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$ %^&*-]).{8,64}$/; import InlineLink from './InlineLink';
import SubmitButton from './SubmitButton';
const PASSWORD_REGEX = /^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$ %^&*-]).{8,64}$/;
const SignupFormSchema = z const SignupFormSchema = z
.object({ .object({
@ -20,68 +18,51 @@ const SignupFormSchema = z
}) })
.refine((data) => data.password === data.passwordRepeat, { .refine((data) => data.password === data.passwordRepeat, {
message: "Passwords don't match", message: "Passwords don't match",
path: ["passwordRepeat"], path: ['passwordRepeat'],
}); });
type SignupFormValues = z.infer<typeof SignupFormSchema>; type SignupFormValues = z.infer<typeof SignupFormSchema>;
const SignupForm: FC = () => { const SignupForm: FC = () => {
const queryClient = useQueryClient();
const signUp = useMutation(
({ username, password, email }: SignupFormValues) => {
return axios.post<{ access_token: string }>("/auth/signup", {
username,
password,
email,
});
},
{
onSuccess: (resp) => {
// TODO: store access token as HTTP-Only cookie
},
}
);
const { register, handleSubmit } = useForm<SignupFormValues>({ const { register, handleSubmit } = useForm<SignupFormValues>({
resolver: zodResolver(SignupFormSchema), resolver: zodResolver(SignupFormSchema),
}); });
const onSubmit: SubmitHandler<SignupFormValues> = (data) => { const onSubmit: SubmitHandler<SignupFormValues> = (data) => {
signUp.mutate(data); console.log({ data });
}; };
return ( return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<p className="text-sm"> <p className="text-sm">
Creating an account allows you to participate in chat, follow your Creating an account allows you to participate in chat, follow your favorite channels, and
favorite channels, and broadcast from your own channel. broadcast from your own channel.
</p> </p>
<FormField <FormField
label="Username" label="Username"
{...register("username")} {...register('username')}
className="py-2 px-2 outline-2 w-full" className="py-2 px-2 outline-2 w-full"
autoFocus
/> />
<FormField <FormField
label="Password" label="Password"
{...register("password")} {...register('password')}
type="password" type="password"
className="py-2 px-2 outline-2 w-full" className="py-2 px-2 outline-2 w-full"
/> />
<FormField <FormField
label="Confirm Password" label="Confirm Password"
{...register("passwordRepeat")} {...register('passwordRepeat')}
type="password" type="password"
className="py-2 px-2 outline-2 w-full" className="py-2 px-2 outline-2 w-full"
/> />
<FormField <FormField
label="Email" label="Email"
{...register("email")} {...register('email')}
type="email" type="email"
className="py-2 px-2 outline-2 w-full" className="py-2 px-2 outline-2 w-full"
/> />
<p className="text-sm text-center"> <p className="text-sm text-center">
By clicking Sign Up, you are agreeing to twitch-clone's{" "} By clicking Sign Up, you are agreeing to twitch-clone&apos;s{' '}
<InlineLink to="https://tosdr.org/en/service/200" external> <InlineLink to="https://tosdr.org/en/service/200" external>
Terms of Service Terms of Service
</InlineLink> </InlineLink>

View File

@ -1,16 +1,13 @@
import { FC } from "react"; import clsx from 'clsx';
import clsx from "clsx"; import { FC } from 'react';
interface ButtonProps extends React.ComponentPropsWithoutRef<"input"> {} type ButtonProps = React.ComponentPropsWithoutRef<'input'>;
const SubmitButton: FC<ButtonProps> = ({ className, ...rest }) => { const SubmitButton: FC<ButtonProps> = ({ className, ...rest }) => {
return ( return (
<input <input
type="submit" type="submit"
className={clsx( className={clsx('rounded-md bg-violet-500 font-semibold py-2 text-sm', className)}
"rounded-md bg-violet-500 font-semibold py-2 text-sm",
className
)}
{...rest} {...rest}
/> />
); );

View File

@ -0,0 +1 @@
export const API_URL = 'http://localhost:5000';

7
client/src/lib/axios.ts Normal file
View File

@ -0,0 +1,7 @@
import { API_URL } from '@/config';
import Axios from 'axios';
export const axios = Axios.create({
baseURL: API_URL,
withCredentials: true,
});

View File

@ -1 +0,0 @@
export const numFormatter = Intl.NumberFormat("en", { notation: "compact" });

View File

@ -1,18 +1,10 @@
import React from "react"; import React from 'react';
import ReactDOM from "react-dom/client"; import ReactDOM from 'react-dom/client';
import { BrowserRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App";
import "./styles/global.css";
const queryClient = new QueryClient(); import App from './App';
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode> <React.StrictMode>
<BrowserRouter> <App />
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</BrowserRouter>
</React.StrictMode> </React.StrictMode>
); );

View File

@ -1,4 +1,4 @@
import { categories } from "../placeholder/SearchCategories"; import { categories } from '../placeholder/SearchCategories';
function ChannelPage() { function ChannelPage() {
const category = categories.data[0]; const category = categories.data[0];
@ -7,7 +7,7 @@ function ChannelPage() {
<div className="bg-neutral-900 flex-1 text-gray-100"> <div className="bg-neutral-900 flex-1 text-gray-100">
<div className="max-w-[200rem] mx-12 mt-12"> <div className="max-w-[200rem] mx-12 mt-12">
<div className="flex flex-row items-center space-x-4"> <div className="flex flex-row items-center space-x-4">
<img src={category.box_art_url} /> <img src={category.box_art_url} alt={category.name} />
<div className=""> <div className="">
<h1>{category.name}</h1> <h1>{category.name}</h1>
<div> <div>

View File

@ -1,13 +1,10 @@
import { import { ArrowRightIcon, HeartIcon, UserIcon } from '@heroicons/react/24/outline';
ArrowRightIcon,
HeartIcon, import Button from '../components/Button';
UserIcon, import ChatMessage from '../components/ChatMessage';
} from "@heroicons/react/24/outline"; import Input from '../components/Input';
import Button from "../components/Button"; import { numFormatter } from '../utils/format';
import ChatMessage from "../components/ChatMessage"; import streams from '../placeholder/GetStreams';
import Input from "../components/Input";
import { numFormatter } from "../lib/format";
import streams from "../placeholder/GetStreams";
function ChannelPage() { function ChannelPage() {
const stream = streams.data[1]; const stream = streams.data[1];

View File

@ -1,5 +1,6 @@
import { FC } from "react"; import { FC } from 'react';
import LoginModal from "../components/LoginModal";
import LoginModal from '../components/LoginModal';
const LoginPage: FC = () => { const LoginPage: FC = () => {
return ( return (

View File

@ -1,5 +1,6 @@
import { FC } from "react"; import { FC } from 'react';
import LoginModal from "../components/LoginModal";
import LoginModal from '../components/LoginModal';
const SignupPage: FC = () => { const SignupPage: FC = () => {
return ( return (

View File

@ -1,45 +1,43 @@
import { FollowedStreams } from "../types"; import { FollowedStreams } from '../types';
const streams: FollowedStreams = { const streams: FollowedStreams = {
data: [ data: [
{ {
id: "41375541868", id: '41375541868',
user_id: "459331509", user_id: '459331509',
user_login: "auronplay", user_login: 'auronplay',
user_name: "auronplay", user_name: 'auronplay',
game_id: "494131", game_id: '494131',
game_name: "Little Nightmares", game_name: 'Little Nightmares',
type: "live", type: 'live',
title: "hablamos y le damos a Little Nightmares 1", title: 'hablamos y le damos a Little Nightmares 1',
viewer_count: 78365, viewer_count: 78365,
started_at: "2021-03-10T15:04:21Z", started_at: '2021-03-10T15:04:21Z',
language: "es", language: 'es',
thumbnail_url: thumbnail_url: 'https://static-cdn.jtvnw.net/previews-ttv/live_user_auronplay-250x250.jpg',
"https://static-cdn.jtvnw.net/previews-ttv/live_user_auronplay-250x250.jpg",
tag_ids: [], tag_ids: [],
is_mature: false, is_mature: false,
}, },
{ {
id: "41375541869", id: '41375541869',
user_id: "459331510", user_id: '459331510',
user_login: "xqcow", user_login: 'xqcow',
user_name: "xqcow", user_name: 'xqcow',
game_id: "494131", game_id: '494131',
game_name: "Just Chatting", game_name: 'Just Chatting',
type: "live", type: 'live',
title: "slam", title: 'slam',
viewer_count: 56230, viewer_count: 56230,
started_at: "2022-09-29T14:04:21Z", started_at: '2022-09-29T14:04:21Z',
language: "en", language: 'en',
thumbnail_url: thumbnail_url: 'https://static-cdn.jtvnw.net/previews-ttv/live_user_xqcow-250x250.jpg',
"https://static-cdn.jtvnw.net/previews-ttv/live_user_xqcow-250x250.jpg",
tag_ids: [], tag_ids: [],
is_mature: false, is_mature: false,
}, },
], ],
pagination: { pagination: {
cursor: cursor:
"eyJiIjp7IkN1cnNvciI6ImV5SnpJam8zT0RNMk5TNDBORFF4TlRjMU1UY3hOU3dpWkNJNlptRnNjMlVzSW5RaU9uUnlkV1Y5In0sImEiOnsiQ3Vyc29yIjoiZXlKeklqb3hOVGs0TkM0MU56RXhNekExTVRZNU1ESXNJbVFpT21aaGJITmxMQ0owSWpwMGNuVmxmUT09In19", 'eyJiIjp7IkN1cnNvciI6ImV5SnpJam8zT0RNMk5TNDBORFF4TlRjMU1UY3hOU3dpWkNJNlptRnNjMlVzSW5RaU9uUnlkV1Y5In0sImEiOnsiQ3Vyc29yIjoiZXlKeklqb3hOVGs0TkM0MU56RXhNekExTVRZNU1ESXNJbVFpT21aaGJITmxMQ0owSWpwMGNuVmxmUT09In19',
}, },
}; };

View File

@ -1,28 +1,28 @@
import { UserFollows } from "../types"; import { UserFollows } from '../types';
export const following: UserFollows = { export const following: UserFollows = {
total: 4, total: 4,
data: [ data: [
{ {
from_id: "171003792", from_id: '171003792',
from_login: "niku", from_login: 'niku',
from_name: "niku", from_name: 'niku',
to_id: "23161357", to_id: '23161357',
to_name: "LIRIK", to_name: 'LIRIK',
to_login: "lirik", to_login: 'lirik',
followed_at: "2017-08-22T22:55:24Z", followed_at: '2017-08-22T22:55:24Z',
}, },
{ {
from_id: "171003792", from_id: '171003792',
from_login: "niku", from_login: 'niku',
from_name: "niku", from_name: 'niku',
to_id: "23161358", to_id: '23161358',
to_name: "Cowser", to_name: 'Cowser',
to_login: "cowser", to_login: 'cowser',
followed_at: "2017-08-22T22:55:24Z", followed_at: '2017-08-22T22:55:24Z',
}, },
], ],
pagination: { pagination: {
cursor: "eyJiIjpudWxsLCJhIjoiMTUwMzQ0MTc3NjQyNDQyMjAwMCJ9", cursor: 'eyJiIjpudWxsLCJhIjoiMTUwMzQ0MTc3NjQyNDQyMjAwMCJ9',
}, },
}; };

View File

@ -1,12 +1,12 @@
export const categories = { export const categories = {
data: [ data: [
{ {
id: "33214", id: '33214',
name: "Just Chatting", name: 'Just Chatting',
box_art_url: "https://static-cdn.jtvnw.net/ttv-boxart/509658-144x192.jpg", box_art_url: 'https://static-cdn.jtvnw.net/ttv-boxart/509658-144x192.jpg',
}, },
], ],
pagination: { pagination: {
cursor: "eyJiIjpudWxsLCJhIjp7IkN", cursor: 'eyJiIjpudWxsLCJhIjp7IkN',
}, },
}; };

View File

@ -0,0 +1,24 @@
import { FC } from 'react';
import { Routes, Route } from 'react-router-dom';
import BrowseLayout from '../components/BrowseLayout';
import CategoryPage from '../pages/CategoryPage';
import ChannelPage from '../pages/ChannelPage';
import LoginPage from '../pages/LoginPage';
import SignupPage from '../pages/SignupPage';
const Router: FC = () => {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/signup" element={<SignupPage />} />
<Route element={<BrowseLayout />}>
<Route path="/:channel" element={<ChannelPage />} />
<Route path="/category/:category" element={<CategoryPage />} />
<Route path="/" element={<h1>Hi</h1>} />
</Route>
</Routes>
);
};
export default Router;

View File

@ -0,0 +1 @@
export const numFormatter = Intl.NumberFormat('en', { notation: 'compact' });

View File

@ -8,8 +8,5 @@ module.exports = {
}, },
}, },
}, },
variants: { plugins: [require("tailwind-scrollbar")({ nocompatible: true })],
scrollbar: ["rounded"],
},
plugins: [require("tailwind-scrollbar")],
}; };

View File

@ -15,7 +15,11 @@
"isolatedModules": true, "isolatedModules": true,
"noEmit": true, "noEmit": true,
"jsx": "react-jsx", "jsx": "react-jsx",
"types": ["@testing-library/crypress", "cypress"] "types": ["@testing-library/crypress", "cypress"],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}, },
"include": ["src", "cypress"], "include": ["src", "cypress"],
"references": [{ "path": "./tsconfig.node.json" }] "references": [{ "path": "./tsconfig.node.json" }]

View File

@ -1,7 +1,7 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()] plugins: [react()],
}) });