Compare commits

...

5 Commits

44 changed files with 2375 additions and 15 deletions

1
.gitignore vendored
View File

@ -21,3 +21,4 @@
go.work
tmp/
dist/

24
client/.gitignore vendored Normal file
View 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
View File

@ -0,0 +1,4 @@
{
"tabWidth": 2,
"useTabs": false
}

13
client/index.html Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

1
client/public/vite.svg Normal file
View 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
View 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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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
View File

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

18
client/src/main.tsx Normal file
View 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>
);

View 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;

View 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;

View 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;

View 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",
},
};

View 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",
},
};

View 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
View 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
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View 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
View 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" }]
}

View 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
View 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
View File

@ -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
View File

@ -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

View File

@ -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),
})

View File

@ -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")

View File

@ -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

View File

@ -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