Compare commits
5 Commits
4f8d553923
...
11f84b9755
Author | SHA1 | Date | |
---|---|---|---|
11f84b9755 | |||
7255e22315 | |||
2bbe65ce3e | |||
2433d0c225 | |||
db1bee79ac |
1
.gitignore
vendored
1
.gitignore
vendored
@ -21,3 +21,4 @@
|
||||
go.work
|
||||
|
||||
tmp/
|
||||
dist/
|
||||
|
24
client/.gitignore
vendored
Normal file
24
client/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
4
client/.prettierrc
Normal file
4
client/.prettierrc
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"tabWidth": 2,
|
||||
"useTabs": false
|
||||
}
|
13
client/index.html
Normal file
13
client/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React + TS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
37
client/package.json
Normal file
37
client/package.json
Normal file
@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build --outDir ../dist",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^1.7.2",
|
||||
"@heroicons/react": "^2.0.11",
|
||||
"@hookform/resolvers": "^2.9.8",
|
||||
"@tanstack/react-query": "^4.7.2",
|
||||
"axios": "^0.27.2",
|
||||
"clsx": "^1.2.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.36.1",
|
||||
"react-router-dom": "^6.4.1",
|
||||
"tailwind-scrollbar": "^2.0.1",
|
||||
"zod": "^3.19.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.17",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"@vitejs/plugin-react": "^2.1.0",
|
||||
"autoprefixer": "^10.4.12",
|
||||
"postcss": "^8.4.16",
|
||||
"prettier": "^2.7.1",
|
||||
"tailwindcss": "^3.1.8",
|
||||
"typescript": "^4.6.4",
|
||||
"vite": "^3.1.0"
|
||||
},
|
||||
"proxy": "http://localhost:5000"
|
||||
}
|
1427
client/pnpm-lock.yaml
generated
Normal file
1427
client/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
client/postcss.config.cjs
Normal file
6
client/postcss.config.cjs
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
1
client/public/vite.svg
Normal file
1
client/public/vite.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
After Width: | Height: | Size: 1.5 KiB |
18
client/src/App.tsx
Normal file
18
client/src/App.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import BrowseLayout from "./components/BrowseLayout";
|
||||
import CategoryPage from "./pages/CategoryPage";
|
||||
import ChannelPage from "./pages/ChannelPage";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<BrowseLayout />}>
|
||||
<Route path="/:channel" element={<ChannelPage />} />
|
||||
<Route path="/category/:category" element={<CategoryPage />} />
|
||||
<Route path="/" element={<h1>Hi</h1>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
BIN
client/src/assets/images/logo.png
Normal file
BIN
client/src/assets/images/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.0 KiB |
37
client/src/components/BrowseLayout.tsx
Normal file
37
client/src/components/BrowseLayout.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { ArrowLeftIcon } from "@heroicons/react/24/outline";
|
||||
import Button from "../components/Button";
|
||||
import NavBar from "../components/NavBar";
|
||||
import SideNavChannel from "../components/SideNavChannel";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import streamData from "../placeholder/GetStreams";
|
||||
import { NavLink } from "react-router-dom";
|
||||
|
||||
function BrowseLayout() {
|
||||
return (
|
||||
<div className="font-inter flex flex-col h-screen text-gray-100">
|
||||
<NavBar />
|
||||
<main className="flex-1 flex flex-row overflow-hidden">
|
||||
<div className="bg-neutral-800 w-60 flex flex-col">
|
||||
<div className="flex flex-row justify-between p-2 items-center">
|
||||
<p className="uppercase font-semibold text-sm">Trending channels</p>
|
||||
<Button variant="subtle" className="p-2">
|
||||
<ArrowLeftIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<ul className="flex-1 overflow-scrollbar">
|
||||
{streamData.data.map((stream) => (
|
||||
<li key={stream.id}>
|
||||
<NavLink to={`/${stream.user_login}`}>
|
||||
<SideNavChannel stream={stream} />
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BrowseLayout;
|
30
client/src/components/Button.tsx
Normal file
30
client/src/components/Button.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { FC } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
type ButtonVariants = "filled" | "subtle";
|
||||
|
||||
interface ButtonProps extends React.ComponentPropsWithoutRef<"button"> {
|
||||
variant?: ButtonVariants;
|
||||
}
|
||||
|
||||
const getStyling = (variant?: ButtonVariants) => {
|
||||
switch (variant) {
|
||||
case "filled":
|
||||
return "bg-neutral-700";
|
||||
case "subtle":
|
||||
return "hover:bg-neutral-500";
|
||||
default:
|
||||
return "bg-neutral-700";
|
||||
}
|
||||
};
|
||||
|
||||
const Button: FC<ButtonProps> = ({ className, variant, ...rest }) => {
|
||||
return (
|
||||
<button
|
||||
className={clsx("rounded-md", getStyling(variant), className)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Button;
|
9
client/src/components/ChatBadge.tsx
Normal file
9
client/src/components/ChatBadge.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import { FC } from "react";
|
||||
|
||||
const ChatBadge: FC = () => {
|
||||
return (
|
||||
<span className="w-5 h-5 rounded-sm bg-pink-300 inline-block align-middle" />
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatBadge;
|
23
client/src/components/ChatMessage.tsx
Normal file
23
client/src/components/ChatMessage.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { FC } from "react";
|
||||
import ChatBadge from "./ChatBadge";
|
||||
|
||||
const ChatMessage: FC = () => {
|
||||
return (
|
||||
<p className="mx-2 p-2 hover:bg-neutral-700 text-sm rounded-md">
|
||||
<div className="space-x-1 inline">
|
||||
<ChatBadge />
|
||||
<ChatBadge />
|
||||
<span className="align-middle">Username</span>
|
||||
</div>
|
||||
<span className="align-middle">: </span>
|
||||
<span className="break-all align-middle">
|
||||
<img
|
||||
src="https://cdn.7tv.app/emote/60afbe0599923bbe7fe9bae1/2x"
|
||||
className="inline w-7 h-7"
|
||||
/>
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatMessage;
|
24
client/src/components/FormField.tsx
Normal file
24
client/src/components/FormField.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { FC, forwardRef, ReactNode } from "react";
|
||||
import Input from "./Input";
|
||||
|
||||
interface FormFieldProps extends React.ComponentPropsWithoutRef<"input"> {
|
||||
label: string;
|
||||
bottomElement?: ReactNode;
|
||||
}
|
||||
|
||||
const FormField = forwardRef<HTMLInputElement, FormFieldProps>(
|
||||
({ label, bottomElement, ...inputProps }, ref) => {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label htmlFor={inputProps.id} className="font-semibold text-sm">
|
||||
{label}
|
||||
</label>
|
||||
<br />
|
||||
<Input {...inputProps} ref={ref} />
|
||||
{bottomElement}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default FormField;
|
38
client/src/components/InlineLink.tsx
Normal file
38
client/src/components/InlineLink.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import { FC } from "react";
|
||||
import clsx from "clsx";
|
||||
import { NavLink } from "react-router-dom";
|
||||
|
||||
export interface InlineLinkProps
|
||||
extends React.ComponentPropsWithoutRef<"span"> {
|
||||
to: string;
|
||||
external?: boolean;
|
||||
}
|
||||
|
||||
const InlineLink: FC<InlineLinkProps> = ({
|
||||
to,
|
||||
external,
|
||||
className,
|
||||
...rest
|
||||
}) => {
|
||||
if (external === true) {
|
||||
return (
|
||||
<a href={to}>
|
||||
<span
|
||||
className={clsx("text-violet-400 cursor-pointer text-sm", className)}
|
||||
{...rest}
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavLink to={to}>
|
||||
<span
|
||||
className={clsx("text-violet-400 cursor-pointer text-sm", className)}
|
||||
{...rest}
|
||||
/>
|
||||
</NavLink>
|
||||
);
|
||||
};
|
||||
|
||||
export default InlineLink;
|
21
client/src/components/Input.tsx
Normal file
21
client/src/components/Input.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import clsx from "clsx";
|
||||
import { forwardRef } from "react";
|
||||
|
||||
interface InputProps extends React.ComponentPropsWithoutRef<"input"> {}
|
||||
|
||||
const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, ...rest }, ref) => {
|
||||
return (
|
||||
<input
|
||||
className={clsx(
|
||||
"bg-zinc-700 rounded-md box-border focus:outline outline-violet-400 text-sm",
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default Input;
|
41
client/src/components/LoginForm.tsx
Normal file
41
client/src/components/LoginForm.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { FC } from "react";
|
||||
import { useForm, SubmitHandler } from "react-hook-form";
|
||||
|
||||
import FormField from "./FormField";
|
||||
import InlineLink from "./InlineLink";
|
||||
import SubmitButton from "./SubmitButton";
|
||||
|
||||
interface LoginFormValues {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
const LoginForm: FC = () => {
|
||||
const { register, handleSubmit } = useForm<LoginFormValues>();
|
||||
const onSubmit: SubmitHandler<LoginFormValues> = (data) => console.log(data);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
label="Username"
|
||||
className="py-2 px-2 outline-2 w-full"
|
||||
{...register("username")}
|
||||
autoFocus
|
||||
/>
|
||||
<FormField
|
||||
label="Password"
|
||||
type="password"
|
||||
{...register("password")}
|
||||
className="py-2 px-2 outline-2 w-full"
|
||||
bottomElement={
|
||||
<InlineLink to="#" className="block mt-2">
|
||||
Trouble logging in?
|
||||
</InlineLink>
|
||||
}
|
||||
/>
|
||||
<SubmitButton className="w-full" value="Log In" />
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginForm;
|
56
client/src/components/LoginModal.tsx
Normal file
56
client/src/components/LoginModal.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import { FC } from "react";
|
||||
import { Dialog } from "@headlessui/react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Tab } from "@headlessui/react";
|
||||
import logo from "../assets/images/logo.png";
|
||||
import LoginForm from "./LoginForm";
|
||||
import LoginModalTab from "./LoginModalTab";
|
||||
import SignupForm from "./SignupForm";
|
||||
|
||||
export interface LoginModelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => any;
|
||||
defaultPage?: number;
|
||||
}
|
||||
|
||||
const LoginModal: FC<LoginModelProps> = ({ defaultPage, isOpen, onClose }) => {
|
||||
return createPortal(
|
||||
<Dialog open={isOpen} onClose={onClose} className="relative z-50">
|
||||
<div className="bg-black/80 fixed inset-0 flex items-center justify-center">
|
||||
<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">
|
||||
<Dialog.Title className="text-xl">
|
||||
<img src={logo} className="inline w-12 h-12" /> Log in to
|
||||
twitch-clone
|
||||
</Dialog.Title>
|
||||
</div>
|
||||
<Tab.Group defaultIndex={defaultPage}>
|
||||
<Tab.List className="space-x-4 border-b border-b-neutral-100/40 mt-4">
|
||||
<Tab>
|
||||
{({ selected }) => (
|
||||
<LoginModalTab selected={selected}>Log In</LoginModalTab>
|
||||
)}
|
||||
</Tab>
|
||||
<Tab>
|
||||
{({ selected }) => (
|
||||
<LoginModalTab selected={selected}>Sign Up</LoginModalTab>
|
||||
)}
|
||||
</Tab>
|
||||
</Tab.List>
|
||||
<Tab.Panels className="mt-4">
|
||||
<Tab.Panel>
|
||||
<LoginForm />
|
||||
</Tab.Panel>
|
||||
<Tab.Panel>
|
||||
<SignupForm />
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</Dialog.Panel>
|
||||
</div>
|
||||
</Dialog>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginModal;
|
20
client/src/components/LoginModalTab.tsx
Normal file
20
client/src/components/LoginModalTab.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { FC } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface LoginModalTabProps extends React.ComponentPropsWithoutRef<"p"> {
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
const LoginModalTab: FC<LoginModalTabProps> = ({ selected, ...rest }) => {
|
||||
return (
|
||||
<p
|
||||
className={clsx(
|
||||
"font-semibold p-1",
|
||||
selected && "text-violet-400 border-b-2 border-b-violet-400"
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginModalTab;
|
75
client/src/components/NavBar.tsx
Normal file
75
client/src/components/NavBar.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
import { TvIcon, UserIcon } from "@heroicons/react/24/outline";
|
||||
import { FC, useState } from "react";
|
||||
import Button from "./Button";
|
||||
import Input from "./Input";
|
||||
import LoginModal from "./LoginModal";
|
||||
import logo from "../assets/images/logo.png";
|
||||
|
||||
const NavBar: FC = () => {
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
const [showTab, setShowTab] = useState(0);
|
||||
|
||||
const showLoginTab = () => {
|
||||
setShowTab(0);
|
||||
setShowLogin(true);
|
||||
};
|
||||
|
||||
const showSignupTab = () => {
|
||||
setShowTab(1);
|
||||
setShowLogin(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="bg-zinc-800 w-screen font-semibold border-b border-b-black">
|
||||
<div className="flex flex-row justify-between items-center mx-2">
|
||||
<div className="basis-1/4">
|
||||
<ul className="flex flex-row space-x-8 items-center">
|
||||
<li>
|
||||
<img src={logo} className="w-8 h-8" />
|
||||
</li>
|
||||
<li>
|
||||
<p className="text-lg">Browse</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="basis-2/4">
|
||||
<div className="flex flex-row space-x-3 items-center justify-center">
|
||||
<Input className=" w-72 my-2 p-2" placeholder="Search" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="basis-1/4">
|
||||
<ul className="justify-end flex flex-row space-x-3 items-center">
|
||||
<li>
|
||||
<Button
|
||||
className="text-sm px-3 py-2 bg-neutral-700"
|
||||
onClick={showLoginTab}
|
||||
>
|
||||
Log In
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button
|
||||
className="text-sm px-3 py-2 bg-violet-500"
|
||||
onClick={showSignupTab}
|
||||
>
|
||||
Sign Up
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button variant="subtle" className="p-1">
|
||||
<UserIcon className="h-5 w-5 inline-block" />
|
||||
</Button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<LoginModal
|
||||
isOpen={showLogin}
|
||||
defaultPage={showTab}
|
||||
onClose={() => setShowLogin(false)}
|
||||
/>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavBar;
|
27
client/src/components/SideNavChannel.tsx
Normal file
27
client/src/components/SideNavChannel.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { FC } from "react";
|
||||
import { numFormatter } from "../lib/format";
|
||||
import { Stream } from "../types";
|
||||
|
||||
interface SideNavChannelProps {
|
||||
stream: Stream;
|
||||
}
|
||||
|
||||
const SideNavChannel: FC<SideNavChannelProps> = ({ stream }) => {
|
||||
return (
|
||||
<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} />
|
||||
<div className="flex flex-col flex-1">
|
||||
<div className="flex flex-row justify-between">
|
||||
<div className="font-bold">{stream.user_name}</div>
|
||||
<div className="space-x-1 flex flex-row items-center">
|
||||
<div className="w-2 h-2 bg-red-600 rounded-full inline-block" />
|
||||
<span>{numFormatter.format(stream.viewer_count)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-gray-300">{stream.game_name}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SideNavChannel;
|
95
client/src/components/SignupForm.tsx
Normal file
95
client/src/components/SignupForm.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { FC } from "react";
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
import FormField from "./FormField";
|
||||
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 =
|
||||
/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$ %^&*-]).{8,64}$/;
|
||||
|
||||
const SignupFormSchema = z
|
||||
.object({
|
||||
username: z.string().trim().min(1).max(16),
|
||||
password: z.string().trim().regex(PASSWORD_REGEX),
|
||||
passwordRepeat: z.string().trim(),
|
||||
email: z.string().email().trim(),
|
||||
})
|
||||
.refine((data) => data.password === data.passwordRepeat, {
|
||||
message: "Passwords don't match",
|
||||
path: ["passwordRepeat"],
|
||||
});
|
||||
|
||||
type SignupFormValues = z.infer<typeof SignupFormSchema>;
|
||||
|
||||
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>({
|
||||
resolver: zodResolver(SignupFormSchema),
|
||||
});
|
||||
|
||||
const onSubmit: SubmitHandler<SignupFormValues> = (data) => {
|
||||
signUp.mutate(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<p className="text-sm">
|
||||
Creating an account allows you to participate in chat, follow your
|
||||
favorite channels, and broadcast from your own channel.
|
||||
</p>
|
||||
<FormField
|
||||
label="Username"
|
||||
{...register("username")}
|
||||
className="py-2 px-2 outline-2 w-full"
|
||||
autoFocus
|
||||
/>
|
||||
<FormField
|
||||
label="Password"
|
||||
{...register("password")}
|
||||
type="password"
|
||||
className="py-2 px-2 outline-2 w-full"
|
||||
/>
|
||||
<FormField
|
||||
label="Confirm Password"
|
||||
{...register("passwordRepeat")}
|
||||
type="password"
|
||||
className="py-2 px-2 outline-2 w-full"
|
||||
/>
|
||||
<FormField
|
||||
label="Email"
|
||||
{...register("email")}
|
||||
type="email"
|
||||
className="py-2 px-2 outline-2 w-full"
|
||||
/>
|
||||
<p className="text-sm text-center">
|
||||
By clicking Sign Up, you are agreeing to twitch-clone's{" "}
|
||||
<InlineLink to="https://tosdr.org/en/service/200" external>
|
||||
Terms of Service
|
||||
</InlineLink>
|
||||
.
|
||||
</p>
|
||||
<SubmitButton className="w-full" value="Sign Up" />
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignupForm;
|
19
client/src/components/SubmitButton.tsx
Normal file
19
client/src/components/SubmitButton.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { FC } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface ButtonProps extends React.ComponentPropsWithoutRef<"input"> {}
|
||||
|
||||
const SubmitButton: FC<ButtonProps> = ({ className, ...rest }) => {
|
||||
return (
|
||||
<input
|
||||
type="submit"
|
||||
className={clsx(
|
||||
"rounded-md bg-violet-500 font-semibold py-2 text-sm",
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubmitButton;
|
1
client/src/lib/format.ts
Normal file
1
client/src/lib/format.ts
Normal file
@ -0,0 +1 @@
|
||||
export const numFormatter = Intl.NumberFormat("en", { notation: "compact" });
|
18
client/src/main.tsx
Normal file
18
client/src/main.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
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();
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
26
client/src/pages/CategoryPage.tsx
Normal file
26
client/src/pages/CategoryPage.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { categories } from "../placeholder/SearchCategories";
|
||||
|
||||
function ChannelPage() {
|
||||
const category = categories.data[0];
|
||||
return (
|
||||
<div className="flex-1 flex flex-row">
|
||||
<div className="bg-neutral-900 flex-1 text-gray-100">
|
||||
<div className="max-w-[200rem] mx-12 mt-12">
|
||||
<div className="flex flex-row items-center space-x-4">
|
||||
<img src={category.box_art_url} />
|
||||
<div className="">
|
||||
<h1>{category.name}</h1>
|
||||
<div>
|
||||
<p>
|
||||
<span>603K</span> Viewers * <span>20.8M</span> Followers
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChannelPage;
|
67
client/src/pages/ChannelPage.tsx
Normal file
67
client/src/pages/ChannelPage.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
HeartIcon,
|
||||
UserIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import Button from "../components/Button";
|
||||
import ChatMessage from "../components/ChatMessage";
|
||||
import Input from "../components/Input";
|
||||
import { numFormatter } from "../lib/format";
|
||||
import streams from "../placeholder/GetStreams";
|
||||
|
||||
function ChannelPage() {
|
||||
const stream = streams.data[1];
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-row">
|
||||
<div className="bg-neutral-900 flex-1">
|
||||
<div className="w-full h-auto aspect-video bg-red-200 " />
|
||||
<div className="flex flex-row p-4 space-x-3">
|
||||
<div className="w-20 h-20 bg-yellow-300 rounded-full" />
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
<div className="font-bold">{stream.user_name}</div>
|
||||
<div>
|
||||
<Button className="h-8 w-10">
|
||||
<HeartIcon className="text-gray-100 h-5 w-5 mx-auto" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
<div className="space-y-1">
|
||||
<div className="font-bold">{stream.title}</div>
|
||||
<div className="text-violet-400">{stream.game_name}</div>
|
||||
</div>
|
||||
<div className="flex flex-row items-center text-sm space-x-3">
|
||||
<span>
|
||||
<UserIcon className="h-5 w-5 inline-block" />
|
||||
<span>{numFormatter.format(stream.viewer_count)}</span>
|
||||
</span>
|
||||
<span>{stream.started_at}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-zinc-900 w-80 border-l border-l-zinc-700 flex flex-col">
|
||||
<div className="flex flex-row justify-between items-center border-b border-b-zinc-700 p-2">
|
||||
<Button variant="subtle" className="p-2">
|
||||
<ArrowRightIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<p className="uppercase font-semibold text-sm">Stream Chat</p>
|
||||
<div className="w-5" />
|
||||
</div>
|
||||
<div className="flex-1 overflow-scrollbar">
|
||||
{new Array(60).fill(0).map((_, i) => (
|
||||
<ChatMessage key={i} />
|
||||
))}
|
||||
</div>
|
||||
<div className="m-2">
|
||||
<Input className="w-full p-2" placeholder="Send a message" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChannelPage;
|
46
client/src/placeholder/GetStreams.ts
Normal file
46
client/src/placeholder/GetStreams.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { FollowedStreams } from "../types";
|
||||
|
||||
const streams: FollowedStreams = {
|
||||
data: [
|
||||
{
|
||||
id: "41375541868",
|
||||
user_id: "459331509",
|
||||
user_login: "auronplay",
|
||||
user_name: "auronplay",
|
||||
game_id: "494131",
|
||||
game_name: "Little Nightmares",
|
||||
type: "live",
|
||||
title: "hablamos y le damos a Little Nightmares 1",
|
||||
viewer_count: 78365,
|
||||
started_at: "2021-03-10T15:04:21Z",
|
||||
language: "es",
|
||||
thumbnail_url:
|
||||
"https://static-cdn.jtvnw.net/previews-ttv/live_user_auronplay-250x250.jpg",
|
||||
tag_ids: [],
|
||||
is_mature: false,
|
||||
},
|
||||
{
|
||||
id: "41375541869",
|
||||
user_id: "459331510",
|
||||
user_login: "xqcow",
|
||||
user_name: "xqcow",
|
||||
game_id: "494131",
|
||||
game_name: "Just Chatting",
|
||||
type: "live",
|
||||
title: "slam",
|
||||
viewer_count: 56230,
|
||||
started_at: "2022-09-29T14:04:21Z",
|
||||
language: "en",
|
||||
thumbnail_url:
|
||||
"https://static-cdn.jtvnw.net/previews-ttv/live_user_xqcow-250x250.jpg",
|
||||
tag_ids: [],
|
||||
is_mature: false,
|
||||
},
|
||||
],
|
||||
pagination: {
|
||||
cursor:
|
||||
"eyJiIjp7IkN1cnNvciI6ImV5SnpJam8zT0RNMk5TNDBORFF4TlRjMU1UY3hOU3dpWkNJNlptRnNjMlVzSW5RaU9uUnlkV1Y5In0sImEiOnsiQ3Vyc29yIjoiZXlKeklqb3hOVGs0TkM0MU56RXhNekExTVRZNU1ESXNJbVFpT21aaGJITmxMQ0owSWpwMGNuVmxmUT09In19",
|
||||
},
|
||||
};
|
||||
|
||||
export default streams;
|
28
client/src/placeholder/GetUserFollows.ts
Normal file
28
client/src/placeholder/GetUserFollows.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { UserFollows } from "../types";
|
||||
|
||||
export const following: UserFollows = {
|
||||
total: 4,
|
||||
data: [
|
||||
{
|
||||
from_id: "171003792",
|
||||
from_login: "niku",
|
||||
from_name: "niku",
|
||||
to_id: "23161357",
|
||||
to_name: "LIRIK",
|
||||
to_login: "lirik",
|
||||
followed_at: "2017-08-22T22:55:24Z",
|
||||
},
|
||||
{
|
||||
from_id: "171003792",
|
||||
from_login: "niku",
|
||||
from_name: "niku",
|
||||
to_id: "23161358",
|
||||
to_name: "Cowser",
|
||||
to_login: "cowser",
|
||||
followed_at: "2017-08-22T22:55:24Z",
|
||||
},
|
||||
],
|
||||
pagination: {
|
||||
cursor: "eyJiIjpudWxsLCJhIjoiMTUwMzQ0MTc3NjQyNDQyMjAwMCJ9",
|
||||
},
|
||||
};
|
12
client/src/placeholder/SearchCategories.ts
Normal file
12
client/src/placeholder/SearchCategories.ts
Normal file
@ -0,0 +1,12 @@
|
||||
export const categories = {
|
||||
data: [
|
||||
{
|
||||
id: "33214",
|
||||
name: "Just Chatting",
|
||||
box_art_url: "https://static-cdn.jtvnw.net/ttv-boxart/509658-144x192.jpg",
|
||||
},
|
||||
],
|
||||
pagination: {
|
||||
cursor: "eyJiIjpudWxsLCJhIjp7IkN",
|
||||
},
|
||||
};
|
9
client/src/styles/global.css
Normal file
9
client/src/styles/global.css
Normal file
@ -0,0 +1,9 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter&display=swap");
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
.overflow-scrollbar {
|
||||
@apply scrollbar-thin scrollbar-thumb-neutral-600 scrollbar-thumb-rounded-full overflow-y-auto;
|
||||
}
|
52
client/src/types.ts
Normal file
52
client/src/types.ts
Normal file
@ -0,0 +1,52 @@
|
||||
export interface Pagination {
|
||||
cursor: string;
|
||||
}
|
||||
|
||||
export interface UserFollow {
|
||||
followed_at: string;
|
||||
from_id: string;
|
||||
from_login: string;
|
||||
from_name: string;
|
||||
to_id: string;
|
||||
to_login: string;
|
||||
to_name: string;
|
||||
}
|
||||
|
||||
export interface UserFollows {
|
||||
total: number;
|
||||
data: UserFollow[];
|
||||
pagination: Pagination;
|
||||
}
|
||||
|
||||
export interface Stream {
|
||||
game_id: string;
|
||||
game_name: string;
|
||||
id: string;
|
||||
language: string;
|
||||
started_at: string;
|
||||
tag_ids: string[];
|
||||
thumbnail_url: string;
|
||||
title: string;
|
||||
type: string;
|
||||
user_id: string;
|
||||
user_login: string;
|
||||
user_name: string;
|
||||
viewer_count: number;
|
||||
is_mature: boolean;
|
||||
}
|
||||
|
||||
export interface FollowedStreams {
|
||||
data: Stream[];
|
||||
pagination: Pagination;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
box_art_url: string;
|
||||
}
|
||||
|
||||
export interface SearchCategories {
|
||||
data: Category[];
|
||||
pagination: Pagination;
|
||||
}
|
1
client/src/vite-env.d.ts
vendored
Normal file
1
client/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
15
client/tailwind.config.cjs
Normal file
15
client/tailwind.config.cjs
Normal file
@ -0,0 +1,15 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ["./index.html", "./src/**/*.tsx"],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
inter: ["Inter"],
|
||||
},
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
scrollbar: ["rounded"],
|
||||
},
|
||||
plugins: [require("tailwind-scrollbar")],
|
||||
};
|
21
client/tsconfig.json
Normal file
21
client/tsconfig.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
9
client/tsconfig.node.json
Normal file
9
client/tsconfig.node.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
7
client/vite.config.ts
Normal file
7
client/vite.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()]
|
||||
})
|
21
dist/index.html
vendored
21
dist/index.html
vendored
@ -1,12 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Document</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Hello there!</h1>
|
||||
</body>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React + TS</title>
|
||||
<script type="module" crossorigin src="/assets/index.08ff9814.js"></script>
|
||||
<link rel="stylesheet" href="/assets/index.3fce1f81.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
2
go.mod
2
go.mod
@ -13,7 +13,7 @@ require (
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.0.4 // indirect
|
||||
github.com/bwmarrin/snowflake v0.3.0 // indirect
|
||||
github.com/bwmarrin/snowflake v0.3.0 // direct
|
||||
github.com/go-playground/locales v0.14.0 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.0 // indirect
|
||||
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
|
||||
|
@ -25,7 +25,7 @@ func ConnectDb() {
|
||||
log.Fatal("Failed to setup snowflake generator. \n", err)
|
||||
}
|
||||
|
||||
dsn := "host=postgres user=postgres password=postgres dbname=postgres port=5432 sslmode=disable"
|
||||
dsn := "host=localhost user=postgres password=postgres dbname=postgres port=5432 sslmode=disable"
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Info),
|
||||
})
|
||||
|
@ -31,7 +31,7 @@ func Login(ctx *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
user := new(models.User)
|
||||
result := db.Where(&models.User{Username: body.Username, Password: body.Password}).Select("id").First(user)
|
||||
result := db.Where(&models.User{Login: body.Username, Password: body.Password}).Select("id").First(user)
|
||||
|
||||
if result.Error != nil {
|
||||
return errors.New("invalid combination of username and password")
|
||||
|
@ -30,7 +30,7 @@ func Register(ctx *fiber.Ctx) error {
|
||||
return err
|
||||
}
|
||||
|
||||
user := models.User{ID: database.GetID(), Username: body.Username, Password: body.Password, Email: body.Email}
|
||||
user := models.User{ID: database.GetID(), Login: body.Username, Password: body.Password, Email: body.Email}
|
||||
result := db.Create(&user)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
|
@ -6,10 +6,9 @@ import (
|
||||
|
||||
type User struct {
|
||||
ID int64 `json:"id,omitempty" gorm:"primaryKey"`
|
||||
Username string `json:"name,omitempty" gorm:"unique"`
|
||||
Login string `json:"name,omitempty" gorm:"unique"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password,omitempty"`
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
|
Reference in New Issue
Block a user