Compare commits

...

31 Commits

Author SHA1 Message Date
573568ee6d Update 'README.md' 2023-07-27 10:44:08 +00:00
9308ebba54 Updated README.md 2023-07-27 12:39:03 +02:00
793d464eda Fix: full image urls in docker-compose.yml 2023-07-27 12:38:51 +02:00
bfdc915631 Fixed CORS and trait issue Kratos 2022-10-21 01:27:02 +02:00
4d92bc3e86 Add max amount of messages in scroll & auto-scroll 2022-10-20 19:47:06 +02:00
b1a4470c68 Updated ChatMessage bigint to number 2022-10-20 19:46:43 +02:00
f15eb98192 Updated message padding and text size 2022-10-20 19:46:10 +02:00
f606706ba3 Disable chat input when user is not logged in 2022-10-20 18:48:13 +02:00
bff6cdd434 Added chat microservice, removed air 2022-10-20 18:45:58 +02:00
bdd1a655d4 Initial commit chat microservice 2022-10-19 17:51:51 +02:00
f30f9090b3 Added faker and removed some placeholder data 2022-10-19 15:52:44 +02:00
3fbe04896e Added Sentry to frontend 2022-10-19 15:24:56 +02:00
a627addad5 Added login flow 2022-10-16 21:02:41 +02:00
4afe2f906d Made onClose for login modal optionall 2022-10-16 19:29:11 +02:00
b87df50328 Save modal prop changes 2022-10-16 19:28:57 +02:00
582455933e Created seperate logo component 2022-10-16 19:18:47 +02:00
a408abdb97 Created seperate hook for signup flow 2022-10-16 19:18:34 +02:00
f4eadc34c7 Updated ory-prettier-styles 2022-10-16 19:06:11 +02:00
38f3caa524 Grouped components into folders 2022-10-16 16:26:24 +02:00
ea603804a2 WIP bunch of stuff 2022-10-16 15:58:23 +02:00
b4ff0c8f77 Migrated from React to NextJS 2022-10-14 13:58:57 +02:00
b2a16e5181 Added ory kratos to docker compose file 2022-10-14 12:15:07 +02:00
ebf1dd5adc Moved prettier config 2022-10-14 12:14:44 +02:00
bd171a10bf Moved types into seperate folder 2022-10-04 18:03:07 +02:00
76a20d7685 Moved routes from App.tsx and stores from index.tsx 2022-10-04 18:01:10 +02:00
d1c0ae0a15 Moved numFormatter to utils 2022-10-04 17:51:21 +02:00
5a7e37077a Moved from React proxy to config 2022-10-04 17:51:07 +02:00
92ee1cfd64 Added @ import path 2022-10-04 17:36:54 +02:00
837516f0e6 Updated prettier config 2022-10-04 17:31:54 +02:00
add9cdabcb Updated deprecated tailwind-scrollbar config 2022-10-04 17:29:35 +02:00
c768d5ccd5 Added eslint, removed react-query 2022-10-04 17:27:40 +02:00
123 changed files with 4047 additions and 2197 deletions

View File

@ -1,47 +0,0 @@
# Config file for [Air](https://github.com/cosmtrek/air) in TOML format
# Working directory
# . or absolute path, please note that the directories following must be under root.
root = "."
tmp_dir = "tmp"
[build]
# Just plain old shell command. You could use `make` as well.
cmd = "go build -o ./application ./main.go"
# Binary file yields from `cmd`.
bin = "application"
# Customize binary.
full_bin = "application"
# Watch these filename extensions.
include_ext = ["go", "tpl", "tmpl", "html", "mustache", "hbs", "pug"]
# Ignore these filename extensions or directories.
exclude_dir = ["tmp", "vendor", "node_modules"]
# Watch these directories if you specified.
include_dir = []
# Exclude files.
exclude_file = []
# This log file places in your tmp_dir.
log = "air.log"
# It's not necessary to trigger build each time file changes if it's too frequent.
delay = 1000 # ms
# Stop running old binary when build errors occur.
stop_on_error = true
# Send Interrupt signal before killing process (windows does not support this feature)
send_interrupt = false
# Delay after sending Interrupt signal
kill_delay = 500 # ms
[log]
# Show log time
time = false
[color]
# Customize each part's color. If no color found, use the raw app log.
main = "magenta"
watcher = "cyan"
build = "yellow"
runner = "green"
[misc]
# Delete tmp directory on exit
clean_on_exit = true

View File

@ -1,47 +0,0 @@
# Config file for [Air](https://github.com/cosmtrek/air) in TOML format
# Working directory
# . or absolute path, please note that the directories following must be under root.
root = "."
tmp_dir = "tmp"
[build]
# Just plain old shell command. You could use `make` as well.
cmd = "go build -o ./application.exe ./main.go"
# Binary file yields from `cmd`.
bin = "application.exe"
# Customize binary.
full_bin = "application.exe"
# Watch these filename extensions.
include_ext = ["go", "tpl", "tmpl", "html", "mustache", "hbs", "pug"]
# Ignore these filename extensions or directories.
exclude_dir = ["tmp", "vendor", "node_modules"]
# Watch these directories if you specified.
include_dir = []
# Exclude files.
exclude_file = []
# This log file places in your tmp_dir.
log = "air.log"
# It's not necessary to trigger build each time file changes if it's too frequent.
delay = 1000 # ms
# Stop running old binary when build errors occur.
stop_on_error = true
# Send Interrupt signal before killing process (windows does not support this feature)
send_interrupt = false
# Delay after sending Interrupt signal
kill_delay = 500 # ms
[log]
# Show log time
time = false
[color]
# Customize each part's color. If no color found, use the raw app log.
main = "magenta"
watcher = "cyan"
build = "yellow"
runner = "green"
[misc]
# Delete tmp directory on exit
clean_on_exit = true

View File

@ -0,0 +1,39 @@
{
"$id": "file:///etc/config/kratos/default.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "default",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email",
"title": "E-Mail",
"minLength": 3,
"ory.sh/kratos": {
"credentials": {
"password": {
"identifier": true
}
},
"verification": {
"via": "email"
},
"recovery": {
"via": "email"
}
}
},
"username": {
"title": "Username",
"type": "string",
"minLength": 3
}
},
"required": ["email", "username"],
"additionalProperties": false
}
}
}

94
.docker/kratos/kratos.yml Normal file
View File

@ -0,0 +1,94 @@
version: v0.10.1
dsn: memory
dev: true
serve:
public:
base_url: http://127.0.0.1:4433/
cors:
enabled: true
allowed_origins:
- http://127.0.0.1:3000
allowed_methods:
- POST
- GET
- PUT
- PATCH
- DELETE
allowed_headers:
- Authorization
- Cookie
- Content-Type
exposed_headers:
- Content-Type
- Set-Cookie
admin:
base_url: http://kratos:4434/
selfservice:
default_browser_return_url: http://127.0.0.1:3000/
allowed_return_urls:
- http://127.0.0.1:3000/
methods:
password:
enabled: true
flows:
error:
ui_url: http://127.0.0.1:3000/error
settings:
ui_url: http://127.0.0.1:3000/settings
privileged_session_max_age: 15m
recovery:
enabled: false
ui_url: http://127.0.0.1:3000/recovery
verification:
enabled: false
ui_url: http://127.0.0.1:3000/verification
after:
default_browser_return_url: http://127.0.0.1:3000/
logout:
after:
default_browser_return_url: http://127.0.0.1:3000/login
login:
ui_url: http://127.0.0.1:3000/login
lifespan: 10m
registration:
lifespan: 10m
ui_url: http://127.0.0.1:3000/signup
log:
level: debug
format: text
leak_sensitive_values: true
secrets:
cookie:
- PLEASE-CHANGE-ME-I-AM-VERY-INSECURE
cipher:
- 32-LONG-SECRET-NOT-SECURE-AT-ALL
ciphers:
algorithm: xchacha20-poly1305
hashers:
algorithm: bcrypt
bcrypt:
cost: 8
identity:
default_schema_id: default
schemas:
- id: default
url: file:///etc/config/kratos/default.schema.json
courier:
smtp:
connection_uri: smtps://test:test@mailslurper:1025/?skip_ssl_verify=true

13
.env.sample Normal file
View File

@ -0,0 +1,13 @@
# twitch-clone postgres
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=postgres
# Ory Kratos postgres
KRATOS_POSTGRES_USER=kratos
KRATOS_POSTGRES_PASSWORD=secret
KRATOS_POSTGRES_DB=kratos
# Ory Kratos secrets
KRATOS_COOKIE_SECRET=dnIgMTQgb2t0IDIwMjIgMTI6Mzg6NTMg
KRATOS_CIPHER_SECRET=dnIgMTQgb2t0IDIwMjIgMTI6Mzg6NTMg

3
.gitignore vendored
View File

@ -22,3 +22,6 @@ go.work
tmp/
dist/
# Secrets
.env

7
.prettierrc Normal file
View File

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

4
Makefile Normal file
View File

@ -0,0 +1,4 @@
.PHONY: chat-service
chat-service:
go build -o ./tmp/main ./cmd/chat-service/main.go

View File

@ -1 +1,41 @@
![](assets/logo.png)
# twitch-clone
A Twitch clone to experiment with various technologies.
![](assets/screenshot.png)
## Technologies
- ScyllaDB for storing messages, compatible with Cassandra but much faster. Usage is inspired by Discord's usage of the technology.
- Ory Kratos for auth, a very comprehensive open-source Auth0 competitor.
- Sentry for client-side monitoring, to help find bugs or other issues early.
- Next.JS for the frontend, React with SSR important for SEO and to speed up the initial load.
- Tailwind for styling, eliminating the need to manage stylesheets.
## Dependencies
- [podman](https://github.com/containers/podman)
- [pnpm](https://github.com/pnpm/pnpm)
- [node](https://github.com/nodejs/node)
- [go](https://github.com/golang/go)
## Usage
Start the necessary infrastructure:
```sh
podman-compose up
```
Start the frontend:
```sh
cd client
pnpm i
pnpm run dev
```
Start the chat-service:
```
go run ./cmd/chat-service/main.go
```
## Disclaimer
I am in no way affiliated with Amazon or Twitch.

View File

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
assets/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

43
chat-service/app/app.go Normal file
View File

@ -0,0 +1,43 @@
package app
import (
"twitch-clone/chat-service/logic"
"twitch-clone/chat-service/models"
"twitch-clone/chat-service/repository"
"twitch-clone/chat-service/repository/scylla"
service "twitch-clone/chat-service/services"
"github.com/joho/godotenv"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
func Run() {
err := godotenv.Load()
if err != nil {
panic(err)
}
e := echo.New()
var c repository.ChatRepository = scylla.NewChatRepository()
var s service.ChatService = logic.NewChatService(c)
e.Use(middleware.Logger())
e.Use(middleware.Recover())
e.GET("*", func(c echo.Context) error {
return s.Subscribe(c.Response().Writer, c.Request())
})
e.POST("*", func(c echo.Context) error {
return s.Publish(c.Request().URL.Path, &models.ChatMessage{
FromUserID: 0,
FromUser: "niku",
ToUserID: 0,
ToUser: "niku",
Content: "Welcome",
})
})
e.Logger.Fatal(e.Start(":1323"))
}

View File

@ -0,0 +1,70 @@
package logic
import (
"encoding/json"
"net/http"
"time"
"twitch-clone/chat-service/models"
"twitch-clone/chat-service/repository"
"twitch-clone/chat-service/serializer"
"github.com/bwmarrin/snowflake"
"github.com/olahol/melody"
)
type chatService struct {
ChatRepo repository.ChatRepository
Melody melody.Melody
MsgSerializer serializer.JsonMessageSerializer
Snowflake snowflake.Node
}
func NewChatService(chatRepo repository.ChatRepository) *chatService {
flakeGen, err := snowflake.NewNode(0)
if err != nil {
panic(err)
}
c := &chatService{
ChatRepo: chatRepo,
Melody: *melody.New(),
MsgSerializer: serializer.JsonMessageSerializer{},
Snowflake: *flakeGen,
}
c.Melody.HandleMessage(func(s *melody.Session, b []byte) {
msg, err := c.MsgSerializer.Decode(b)
if err != nil {
bytes, _ := json.Marshal(err.Error())
s.Write(bytes)
return
}
c.Publish(s.Request.URL.Path, msg)
})
return c
}
func (c *chatService) Subscribe(w http.ResponseWriter, r *http.Request) error {
return c.Melody.HandleRequest(w, r)
}
func (c *chatService) Publish(namespace string, msg *models.ChatMessage) error {
msg.MessageID = c.Snowflake.Generate().Int64()
msg.CreatedAt = time.Now().Unix()
rawMsg, err := c.MsgSerializer.Encode(msg)
if err != nil {
return err
}
err = c.Melody.BroadcastFilter(rawMsg, func(q *melody.Session) bool {
return q.Request.URL.Path == namespace
})
if err != nil {
return err
}
err = c.ChatRepo.Store(msg)
return err
}

View File

@ -0,0 +1,11 @@
package models
type ChatMessage struct {
MessageID int64 `json:"messageId,omitempty"`
FromUserID int64 `validate:"required" json:"fromUserID"`
FromUser string `validate:"required" json:"fromUser"`
ToUserID int64 `validate:"required" json:"toUserID"`
ToUser string `validate:"required" json:"toUser"`
Content string `validate:"required" json:"content"`
CreatedAt int64 `json:"createdAt,omitempty"`
}

View File

@ -0,0 +1,9 @@
package repository
import (
"twitch-clone/chat-service/models"
)
type ChatRepository interface {
Store(*models.ChatMessage) error
}

View File

@ -0,0 +1,48 @@
package scylla
import (
"os"
"twitch-clone/chat-service/models"
"github.com/gocql/gocql"
"github.com/scylladb/gocqlx/v2"
"github.com/scylladb/gocqlx/v2/table"
)
var messageMetadata = table.Metadata{
Name: "chat_service.messages",
Columns: []string{"message_id", "from_user_id", "from_user", "to_user_id", "to_user", "content", "created_at"},
}
var messageTable = table.New(messageMetadata)
type ChatRepository struct {
cluster gocql.ClusterConfig
}
func (r *ChatRepository) Store(msg *models.ChatMessage) error {
session, err := gocqlx.WrapSession(r.cluster.CreateSession())
if err != nil {
return err
}
defer session.Close()
q := session.Query(messageTable.Insert()).BindStruct(msg)
if err := q.ExecRelease(); err != nil {
return err
}
return nil
}
func NewChatRepository() *ChatRepository {
cluster := gocql.NewCluster(os.Getenv("CHAT_SCYLLA_HOSTS"))
session, _ := gocqlx.WrapSession(cluster.CreateSession())
Seed(session)
session.Close()
return &ChatRepository{
cluster: *cluster,
}
}

View File

@ -0,0 +1,30 @@
package scylla
import "github.com/scylladb/gocqlx/v2"
func Seed(session gocqlx.Session) error {
err := session.ExecStmt(`
CREATE KEYSPACE IF NOT EXISTS chat_service
WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 1}`)
if err != nil {
return err
}
err = session.ExecStmt(`
CREATE TABLE IF NOT EXISTS chat_service.messages (
message_id bigint,
from_user_id bigint,
from_user text,
to_user_id bigint,
to_user text,
content text,
created_at timestamp,
PRIMARY KEY (to_user_id, message_id)
)`)
if err != nil {
return err
}
return nil
}

View File

@ -0,0 +1,31 @@
package serializer
import (
"encoding/json"
"twitch-clone/chat-service/models"
"twitch-clone/chat-service/utils"
)
type JsonMessageSerializer struct{}
func (s *JsonMessageSerializer) Decode(input []byte) (*models.ChatMessage, error) {
msg := &models.ChatMessage{}
if err := json.Unmarshal(input, msg); err != nil {
return nil, err
}
if err := utils.Validate.Struct(msg); err != nil {
return nil, err
}
return msg, nil
}
func (s *JsonMessageSerializer) Encode(input *models.ChatMessage) ([]byte, error) {
msg, err := json.Marshal(input)
if err != nil {
return nil, err
}
return msg, nil
}

View File

@ -0,0 +1,8 @@
package serializer
import "twitch-clone/chat-service/models"
type MessageSerializer interface {
Decode(input []byte) (*models.ChatMessage, error)
Encode(input *models.ChatMessage) ([]byte, error)
}

View File

@ -0,0 +1,11 @@
package service
import (
"net/http"
"twitch-clone/chat-service/models"
)
type ChatService interface {
Subscribe(http.ResponseWriter, *http.Request) error
Publish(string, *models.ChatMessage) error
}

View File

@ -1,4 +1,4 @@
package models
package utils
import "github.com/go-playground/validator/v10"

1
client/.env.sample Normal file
View File

@ -0,0 +1 @@
ORY_SDK_URL=http://localhost:4433

3
client/.eslintrc.json Normal file
View File

@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

57
client/.gitignore vendored
View File

@ -1,24 +1,43 @@
# Logs
logs
*.log
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# local env files
.env.local
.env.development.local
.env.test.local
.env.production.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# vercel
.vercel
# typescript
*.tsbuildinfo
cypress/screenshots
cypress/videos
# Sentry
.sentryclirc

54
client/.package.json Normal file
View File

@ -0,0 +1,54 @@
{
"name": "client",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build --outDir ../dist",
"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"
},
"dependencies": {
"@headlessui/react": "^1.7.2",
"@heroicons/react": "^2.0.11",
"@hookform/resolvers": "^2.9.8",
"@ory/client": "0.2.0-alpha.48",
"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": {
"@testing-library/cypress": "^8.0.3",
"@types/react": "^18.0.17",
"@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",
"autoprefixer": "^10.4.12",
"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",
"prettier": "^2.7.1",
"tailwindcss": "^3.1.8",
"typescript": "^4.6.4",
"vite": "^3.1.0"
}
}

View File

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

6
client/.prettierrc.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
...require('ory-prettier-styles'),
importOrder: ['^\\.\\./(?!.*\\.[a-z]+$)(.*)$', '^\\./(?!.*\\.[a-z]+$)(.*)$'],
importOrderSeparation: true,
experimentalBabelParserPluginsList: ['jsx', 'typescript']
}

View File

@ -0,0 +1,71 @@
import { FC, KeyboardEventHandler, useEffect, useRef, useState } from "react"
import { CHAT_URL, MAX_CHAT_MESSAGES } from "../../config"
import useSession from "../../hooks/useSession"
import { ChatMessage as Message } from "../../types"
import Input from "../common/Input"
import ChatMessage from "../message/ChatMessage"
const Chat: FC = () => {
const { session } = useSession()
const [messages, setMessages] = useState<Message[]>([])
const wsRef = useRef<WebSocket | null>(null)
const messagesEndRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
wsRef.current = new WebSocket(CHAT_URL)
wsRef.current.onmessage = (ev) => {
const newMsg = JSON.parse(ev.data) as Message
if (typeof newMsg === "string") return
setMessages((old) => {
if (old.length >= MAX_CHAT_MESSAGES) old.shift()
return [...old, newMsg]
})
}
return () => {
if (wsRef.current) wsRef.current.close()
}
}, [])
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}, [messages])
const handleChatInput: KeyboardEventHandler<HTMLInputElement> = (event) => {
if (event.key === "Enter" && wsRef.current) {
const msg = JSON.stringify({
fromUser: "niku",
fromUserID: 10,
toUser: "niku",
toUserID: 10,
content: event.currentTarget.value,
})
wsRef.current.send(msg)
event.currentTarget.value = ""
}
}
return (
<div className="bg-zinc-900 w-80 border-l border-l-zinc-700 flex flex-col">
<div className="flex flex-row justify-center items-center border-b border-b-zinc-700 p-2 h-12">
<p className="uppercase font-semibold text-sm">Stream Chat</p>
</div>
<div className="flex-1 overflow-scrollbar">
{messages.map((message) => (
<ChatMessage key={message.messageId.toString()} message={message} />
))}
<div ref={messagesEndRef} />
</div>
<div className="m-2">
<Input
disabled={!session}
className="w-full p-2"
placeholder="Send a message"
onKeyDown={handleChatInput}
/>
</div>
</div>
)
}
export default Chat

View File

@ -0,0 +1,25 @@
import clsx from 'clsx';
import { FC } from 'react';
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,17 @@
import clsx from 'clsx';
import { FC } from 'react';
import Link from 'next/link';
export interface InlineLinkProps extends React.ComponentPropsWithoutRef<'span'> {
to: string;
}
const InlineLink: FC<InlineLinkProps> = ({ to, className, ...rest }) => {
return (
<Link href={to} passHref={true}>
<span className={clsx('text-violet-400 cursor-pointer text-sm', className)} {...rest} />
</Link>
);
};
export default InlineLink;

View File

@ -1,7 +1,8 @@
import clsx from "clsx";
import { forwardRef } from "react";
/* eslint-disable react/display-name */
import clsx from "clsx"
import { forwardRef } from "react"
interface InputProps extends React.ComponentPropsWithoutRef<"input"> {}
type InputProps = React.ComponentPropsWithoutRef<"input">
const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, ...rest }, ref) => {
@ -9,13 +10,13 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
<input
className={clsx(
"bg-zinc-700 rounded-md box-border focus:outline outline-violet-400 text-sm",
className
className,
)}
{...rest}
ref={ref}
/>
);
}
);
)
},
)
export default Input;
export default Input

View File

@ -0,0 +1,11 @@
import { FC } from "react"
interface LogoProps {
className?: string
}
const Logo: FC<LogoProps> = ({ className }) => {
return <img src="./assets/images/logo.png" className={className} alt="logo" />
}
export default Logo

View File

@ -1,24 +1,25 @@
import { FC, forwardRef, ReactNode } from "react";
import Input from "./Input";
/* eslint-disable react/display-name */
import { forwardRef, ReactNode } from "react"
import Input from "../Input"
interface FormFieldProps extends React.ComponentPropsWithoutRef<"input"> {
label: string;
bottomElement?: ReactNode;
label?: string
bottomElement?: ReactNode
}
const FormField = forwardRef<HTMLInputElement, FormFieldProps>(
({ label, bottomElement, ...inputProps }, ref) => {
({ label, bottomElement, hidden, ...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;
export default FormField

View File

@ -0,0 +1,19 @@
import clsx from "clsx"
import { FC } from "react"
type ButtonProps = React.ComponentPropsWithoutRef<"input">
const SubmitButton: FC<ButtonProps> = ({ className, ...rest }) => {
return (
<input
type="submit"
className={clsx(
"cursor-pointer rounded-md bg-violet-500 font-semibold py-2 text-sm",
className,
)}
{...rest}
/>
)
}
export default SubmitButton

View File

@ -0,0 +1,13 @@
import NavBar from "../nav/NavBar"
import { NextPage } from "next"
const BrowseLayout: NextPage = ({ children }) => {
return (
<div className="font-inter flex flex-col h-screen text-gray-100">
<NavBar />
<main className="flex-1 flex flex-row overflow-hidden">{children}</main>
</div>
)
}
export default BrowseLayout

View File

@ -0,0 +1,72 @@
import { FC } from "react"
import { useForm, SubmitHandler } from "react-hook-form"
import FormField from "../common/form/FormField"
import InlineLink from "../common/InlineLink"
import SubmitButton from "../common/form/SubmitButton"
import * as validation from "../../config/validation"
import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod"
import useLogInFlow from "../../hooks/useLogInFlow"
import Input from "../common/Input"
const LogInFormSchema = z.object({
csrfToken: z.string(),
password: validation.password,
email: z.string().email({ message: "Not a valid email address" }).trim(),
})
type LogInFormValues = z.infer<typeof LogInFormSchema>
const formFields = [
{ id: "email", label: "Email", type: "email" },
{ id: "password", label: "Password", type: "password" },
]
const LoginForm: FC = () => {
const logInFlow = useLogInFlow()
const { register, handleSubmit, formState } = useForm<LogInFormValues>({
resolver: zodResolver(LogInFormSchema),
})
const onSubmit: SubmitHandler<LogInFormValues> = (data) =>
logInFlow.submitData({
csrf_token: data.csrfToken,
method: "password",
identifier: data.email,
password: data.password,
})
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{logInFlow.flow && (
<Input
hidden={true}
value={logInFlow.flow.ui.nodes[0].attributes.value}
{...register("csrfToken")}
/>
)}
{formFields.map((field) => (
<FormField
key={field.id}
id={field.id}
type={field.type}
label={field.label}
{...register(field.id as any)}
className="py-2 px-2 outline-2 w-full"
bottomElement={
<p className="text-xs">
{formState.errors[(field.id as any) || ""]?.message}
</p>
}
/>
))}
<SubmitButton className="w-full" value="Log In" />
<div className="text-center">
<InlineLink to="#">Trouble logging in?</InlineLink>
</div>
</form>
)
}
export default LoginForm

View File

@ -1,27 +1,29 @@
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";
import { Dialog, Tab } from "@headlessui/react"
import { FC } from "react"
import Logo from "../common/Logo"
import LoginForm from "./LoginForm"
import LoginModalTab from "./LoginModalTab"
import SignupForm from "./SignupForm"
export interface LoginModelProps {
isOpen: boolean;
onClose: () => any;
defaultPage?: number;
isOpen: boolean
onClose?: () => any
defaultPage?: number
}
const LoginModal: FC<LoginModelProps> = ({ defaultPage, isOpen, onClose }) => {
return createPortal(
const LoginModal: FC<LoginModelProps> = ({
defaultPage,
isOpen,
onClose = (b: boolean) => {},
}) => {
return (
<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
<Logo className="inline w-12 h-12" /> Log in to twitch-clone
</Dialog.Title>
</div>
<Tab.Group defaultIndex={defaultPage}>
@ -48,9 +50,8 @@ const LoginModal: FC<LoginModelProps> = ({ defaultPage, isOpen, onClose }) => {
</Tab.Group>
</Dialog.Panel>
</div>
</Dialog>,
document.body
);
};
</Dialog>
)
}
export default LoginModal;
export default LoginModal

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;
}
@ -9,8 +9,8 @@ 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"
'font-semibold p-1',
selected && 'text-violet-400 border-b-2 border-b-violet-400'
)}
{...rest}
/>

View File

@ -0,0 +1,97 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { FC } from "react"
import { SubmitHandler, useForm } from "react-hook-form"
import { z } from "zod"
import FormField from "../common/form/FormField"
import InlineLink from "../common/InlineLink"
import Input from "../common/Input"
import SubmitButton from "../common/form/SubmitButton"
import useSignUpFlow from "../../hooks/useSignUpFlow"
import * as validation from "../../config/validation"
const SignupFormSchema = z
.object({
csrfToken: z.string(),
username: validation.username,
password: validation.password,
passwordRepeat: z.string().trim(),
email: z.string().email({ message: "Not a valid email address" }).trim(),
})
.refine((data) => data.password === data.passwordRepeat, {
message: "Passwords do not match.",
path: ["passwordRepeat"],
})
type SignupFormValues = z.infer<typeof SignupFormSchema>
const formFields = [
{ id: "username", label: "Username", type: "text" },
{ id: "password", label: "Password", type: "password" },
{ id: "passwordRepeat", label: "Confirm Password", type: "password" },
{ id: "email", label: "Email", type: "email" },
]
const SignupForm: FC = () => {
const signUpFlow = useSignUpFlow()
const { register, handleSubmit, formState } = useForm<SignupFormValues>({
resolver: zodResolver(SignupFormSchema),
})
const onSubmit: SubmitHandler<SignupFormValues> = async (data) => {
await signUpFlow.submitData({
csrf_token: data.csrfToken,
method: "password",
password: data.password,
traits: {
email: data.email,
username: data.username,
},
})
}
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>
{signUpFlow.flow && (
<Input
hidden={true}
value={signUpFlow.flow.ui.nodes[0].attributes.value}
{...register("csrfToken")}
/>
)}
{formFields.map((field) => (
<FormField
key={field.id}
id={field.id}
type={field.type}
label={field.label}
{...register(field.id as any)}
className="py-2 px-2 outline-2 w-full"
bottomElement={
<p className="text-xs">
{formState.errors[(field.id as any) || ""]?.message}
</p>
}
/>
))}
<p className="text-sm text-center">
By clicking Sign Up, you are agreeing to twitch-clone&apos;s{" "}
<InlineLink to="https://tosdr.org/en/service/200">
Terms of Service
</InlineLink>
.
</p>
<SubmitButton
disabled={!signUpFlow.flow}
className="w-full"
value="Sign Up"
/>
</form>
)
}
export default SignupForm

View File

@ -0,0 +1,21 @@
import { FC } from "react"
import { ChatMessage } from "../../types"
export interface ChatMessageProps {
message: ChatMessage
}
const ChatMessage: FC<ChatMessageProps> = ({
message: { fromUser, content },
}) => {
return (
<div className="mx-2 p-[0.4rem] hover:bg-neutral-700 text-xs rounded-md">
<div className="space-x-1 inline">
<span className="align-middle">{fromUser}: </span>
</div>
<span className="break-all align-middle">{content}</span>
</div>
)
}
export default ChatMessage

View File

@ -0,0 +1,93 @@
import {
ArrowRightIcon,
ArrowRightOnRectangleIcon,
UserIcon,
} from "@heroicons/react/24/outline"
import { FC, useState } from "react"
import { useLogout } from "../../hooks/useLogout"
import useSession from "../../hooks/useSession"
import Button from "../common/Button"
import Logo from "../common/Logo"
import LoginModal, { LoginModelProps } from "../login/LoginModal"
const NavBar: FC = () => {
const logout = useLogout()
const session = useSession((state) => state.session)
const [modalProps, setModalProps] = useState<LoginModelProps>({
isOpen: false,
defaultPage: 0,
})
const showLoginTab = () =>
setModalProps({
defaultPage: 0,
isOpen: true,
})
const showSignupTab = () =>
setModalProps({
defaultPage: 1,
isOpen: true,
})
console.log({ session })
return (
<nav className="bg-zinc-800 w-screen font-semibold border-b border-b-black">
<div className="flex flex-row justify-between items-center h-12 mx-2">
<div>
<ul className="flex flex-row space-x-8 items-center">
<li>
<Logo className="w-8 h-8" />
</li>
</ul>
</div>
<div>
<ul className="justify-end flex flex-row space-x-3 items-center">
{session ? (
<>
<li>
<UserIcon className="h-5 w-5 inline-block" />
</li>
<li>
<Button
variant="subtle"
className="p-[0.4rem]"
onClick={logout}
>
<ArrowRightOnRectangleIcon className="h-5 w-5 inline-block" />
</Button>
</li>
</>
) : (
<>
<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>
</>
)}
</ul>
</div>
</div>
<LoginModal
{...modalProps}
onClose={() => setModalProps((old) => ({ ...old, isOpen: false }))}
/>
</nav>
)
}
export default NavBar

3
client/config/index.ts Normal file
View File

@ -0,0 +1,3 @@
export const KRATOS_URL = "http://127.0.0.1:4433"
export const CHAT_URL = "ws://localhost:1323"
export const MAX_CHAT_MESSAGES = 50

View File

@ -0,0 +1,18 @@
import { z } from "zod"
export const PASSWORD_REGEX =
/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$ %^&*-]).{8,64}$/
export const username = z
.string()
.trim()
.min(3, { message: "Username must be at least 3 character long." })
.max(16, { message: "Username can't be longer than 16 characters.." })
export const password = z
.string()
.trim()
.regex(
PASSWORD_REGEX,
"Password must be 8-64 long and must contain a number, uppercase, lowercase and special character.",
)

View File

@ -1,16 +0,0 @@
import { defineConfig } from "cypress";
export default defineConfig({
component: {
devServer: {
framework: "react",
bundler: "vite",
},
},
e2e: {
setupNodeEvents(on, config) {
// implement node event listeners here
},
},
});

3
client/cypress.json Normal file
View File

@ -0,0 +1,3 @@
{
"baseUrl": "http://localhost:3000"
}

View File

@ -0,0 +1,64 @@
const randomString = () => (Math.random() + 1).toString(36).substring(7)
const randomPassword = () => randomString() + randomString()
const randomEmail = () => randomString() + '@' + randomString() + '.com'
const login = (email, password) => {
cy.visit('/api/.ory/ui/login')
cy.get('[name="identifier"]').type(email)
cy.get('[name="password"]').type(password)
cy.get('[name="method"]').click()
loggedIn(email)
}
const loggedIn = (email) => {
cy.location('pathname').should('eq', '/')
cy.get('[data-testid="session-content"]').should('contain.text', email)
cy.get('[data-testid="logout"]').should('have.attr', 'aria-disabled', 'false')
}
context('Basic UI interactions', () => {
const email = randomEmail()
const password = randomPassword()
beforeEach(() => {
cy.clearCookies({ domain: null })
})
it('can load the start page', () => {
cy.visit('/')
cy.get('a[href="/api/.ory/self-service/login/browser"]').should('exist')
cy.get('a[href="/api/.ory/self-service/registration/browser"]').should(
'exist'
)
})
it('redirects to login when accessing settings without session', () => {
cy.visit('/api/.ory/ui/settings')
cy.location('pathname').should('contain', 'api/.ory/ui/login')
cy.get('[name="method"]').should('exist')
})
it('can submit registration', () => {
cy.visit('/api/.ory/ui/registration')
cy.get('[name="traits.email"]').type(email)
cy.get('[name="password"]').type(password)
cy.get('[name="method"]').click()
loggedIn(email)
})
it('can load the login page', () => {
login(email, password)
})
it('goes to registration and clicks on log in and redirect works', () => {
cy.visit('/api/.ory/ui/registration')
cy.get('[data-testid="cta-link"]').click()
login(email, password)
})
it('can log out', () => {
login(email, password)
cy.get('a[data-testid="logout"]').click()
cy.get('[data-testid="logout"]').should('not.exist')
})
})

View File

@ -0,0 +1,22 @@
/// <reference types="cypress" />
// ***********************************************************
// This example plugins/index.js can be used to load plugins
//
// You can change the location of this file or turn off loading
// the plugins file with the 'pluginsFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/plugins-guide
// ***********************************************************
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
/**
* @type {Cypress.PluginConfig}
*/
// eslint-disable-next-line no-unused-vars
module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
}

View File

@ -0,0 +1,25 @@
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })

View File

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

View File

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

View File

@ -0,0 +1,19 @@
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands'
// Alternatively you can use CommonJS syntax:
// require('./commands')

View File

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

View File

@ -0,0 +1,53 @@
import {
SelfServiceRegistrationFlow,
SubmitSelfServiceLoginFlowBody,
} from "@ory/client"
import { useRouter } from "next/router"
import { useEffect, useState } from "react"
import ory from "../services/ory"
import useSession from "./useSession"
const useLogInFlow = () => {
const router = useRouter()
const [flow, setFlow] = useState<SelfServiceRegistrationFlow>()
const { flow: flowId } = router.query
const updateSession = useSession((state) => state.update)
useEffect(() => {
const func = async () => {
if (!router.isReady || flow) {
return
}
let serviceFlow
if (flowId) {
serviceFlow = await ory.getSelfServiceLoginFlow(String(flowId))
} else {
serviceFlow = await ory.initializeSelfServiceLoginFlowForBrowsers()
}
setFlow(serviceFlow.data)
}
func()
}, [flowId, router, router.isReady, flow])
const submitData = async (data: SubmitSelfServiceLoginFlowBody) => {
await router.push(`/login?flow=${flow?.id}`, undefined, {
shallow: true,
})
ory
.submitSelfServiceLoginFlow(String(flow?.id), undefined, data)
.then(async ({ data }) => {
updateSession(data.session)
await router.push(flow?.return_to || "/")
})
.catch((err) => {
console.log({ err })
})
}
return { flow, submitData }
}
export default useLogInFlow

38
client/hooks/useLogout.ts Normal file
View File

@ -0,0 +1,38 @@
import { AxiosError } from "axios"
import { useRouter } from "next/router"
import { useState, useEffect, DependencyList } from "react"
import ory from "../services/ory"
import useSession from "./useSession"
export function useLogout(deps?: DependencyList) {
const session = useSession()
const [logoutToken, setLogoutToken] = useState<string>("")
const router = useRouter()
useEffect(() => {
ory
.createSelfServiceLogoutFlowUrlForBrowsers()
.then(({ data }) => {
setLogoutToken(data.logout_token)
})
.catch((err: AxiosError) => {
switch (err.response?.status) {
case 401:
return
}
return Promise.reject(err)
})
}, deps)
return () => {
if (logoutToken) {
session.drop()
ory
.submitSelfServiceLogoutFlow(logoutToken)
.then(() => router.push("/"))
.then(() => router.reload())
}
}
}

View File

@ -0,0 +1,31 @@
import create from "zustand"
import { Session } from "@ory/client"
import ory from "../services/ory"
import { AxiosError } from "axios"
export interface SessionState {
session?: Session
load: () => void
update: (data: Session) => void
drop: () => void
}
const useSession = create<SessionState>((set) => ({
session: undefined,
load: () => {
ory
.toSession()
.then(({ data }) => {
set({ session: data })
})
.catch((err: AxiosError) => {})
},
update: (session) => set({ session }),
drop: () => {
set({ session: undefined })
},
}))
export default useSession

View File

@ -0,0 +1,55 @@
import {
SelfServiceRegistrationFlow,
Session,
SubmitSelfServiceRegistrationFlowBody,
} from "@ory/client"
import { useRouter } from "next/router"
import { useEffect, useState } from "react"
import ory from "../services/ory"
import useSession from "./useSession"
export const useSignUpFlow = () => {
const router = useRouter()
const [flow, setFlow] = useState<SelfServiceRegistrationFlow>()
const { flow: flowId } = router.query
const updateSession = useSession((state) => state.update)
useEffect(() => {
const func = async () => {
if (!router.isReady || flow) {
return
}
let serviceFlow
if (flowId) {
serviceFlow = await ory.getSelfServiceRegistrationFlow(String(flowId))
} else {
serviceFlow =
await ory.initializeSelfServiceRegistrationFlowForBrowsers()
}
setFlow(serviceFlow.data)
}
func()
}, [flowId, router, router.isReady, flow])
const submitData = async (data: SubmitSelfServiceRegistrationFlowBody) => {
await router.push(`/signup?flow=${flow?.id}`, undefined, {
shallow: true,
})
ory
.submitSelfServiceRegistrationFlow(String(flow?.id), data)
.then(async ({ data }) => {
updateSession(data.session as Session)
await router.push(flow?.return_to || "/")
})
.catch((err) => {
console.log({ err })
})
}
return { flow, submitData }
}
export default useSignUpFlow

View File

@ -1,13 +0,0 @@
<!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>

5
client/next-env.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

36
client/next.config.js Normal file
View File

@ -0,0 +1,36 @@
// This file sets a custom webpack configuration to use your Next.js app
// with Sentry.
// https://nextjs.org/docs/api-reference/next.config.js/introduction
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
const { withSentryConfig } = require("@sentry/nextjs")
const moduleExports = {
// Your existing module.exports
reactStrictMode: true,
sentry: {
// Use `hidden-source-map` rather than `source-map` as the Webpack `devtool`
// for client-side builds. (This will be the default starting in
// `@sentry/nextjs` version 8.0.0.) See
// https://webpack.js.org/configuration/devtool/ and
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#use-hidden-source-map
// for more information.
hideSourceMaps: true,
},
}
const sentryWebpackPluginOptions = {
// Additional config options for the Sentry Webpack plugin. Keep in mind that
// the following options are set automatically, and overriding them is not
// recommended:
// release, url, org, project, authToken, configFile, stripPrefix,
// urlPrefix, include, ignore
silent: true, // Suppresses all logs
// For all available options, see:
// https://github.com/getsentry/sentry-webpack-plugin#options.
}
// Make sure adding Sentry options is the last code to run before exporting, to
// ensure that your source maps include changes from all other Webpack plugins
module.exports = withSentryConfig(moduleExports, sentryWebpackPluginOptions)

View File

@ -1,40 +1,51 @@
{
"name": "client",
"name": "kratos-nextjs-react-example",
"version": "0.1.0",
"private": true,
"version": "0.0.0",
"type": "module",
"config": {
"prettierTarget": "{cypress,pages,styles,public,}{/**/,}*.{tsx,ts,json,md,js,css}"
},
"scripts": {
"dev": "vite",
"build": "tsc && vite build --outDir ../dist",
"preview": "vite preview",
"cypress": "cypress"
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"format": "prettier --write ${npm_package_config_prettierTarget}",
"format:check": "prettier --check ${npm_package_config_prettierTarget}",
"test": "cypress run",
"test:dev": "cypress open"
},
"dependencies": {
"@headlessui/react": "^1.7.2",
"@heroicons/react": "^2.0.11",
"@headlessui/react": "^1.7.3",
"@heroicons/react": "^2.0.12",
"@hookform/resolvers": "^2.9.8",
"@tanstack/react-query": "^4.7.2",
"axios": "^0.27.2",
"@ory/client": "0.0.1-alpha.169",
"@ory/integrations": "0.2.5",
"@sentry/nextjs": "^7.16.0",
"axios": "^1.1.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"
"next": "12.1.5",
"react": "17.0.2",
"react-dom": "17.0.2",
"react-hook-form": "^7.37.0",
"tailwind-scrollbar": "2.1.0-preview.0",
"zod": "^3.19.1",
"zustand": "^4.1.2"
},
"devDependencies": {
"@testing-library/cypress": "^8.0.3",
"@types/react": "^18.0.17",
"@types/react-dom": "^18.0.6",
"@vitejs/plugin-react": "^2.1.0",
"@faker-js/faker": "^7.6.0",
"@sentry/cli": "^2.7.0",
"@trivago/prettier-plugin-sort-imports": "^2.0.2",
"@types/node": "16.11.6",
"@types/react": "17.0.33",
"autoprefixer": "^10.4.12",
"cypress": "^10.9.0",
"postcss": "^8.4.16",
"prettier": "^2.7.1",
"cypress": "^9.6.0",
"eslint": "7.32.0",
"eslint-config-next": "12.0.1",
"ory-prettier-styles": "^1.3.0",
"postcss": "^8.4.18",
"prettier": "^2.3.2",
"tailwindcss": "^3.1.8",
"typescript": "^4.6.4",
"vite": "^3.1.0"
},
"proxy": "http://localhost:5000"
}
"typescript": "4.4.4"
}
}

View File

@ -0,0 +1,28 @@
import ChatMessage from "../../components/message/ChatMessage"
import Input from "../../components/common/Input"
import { NextPage } from "next"
import BrowseLayout from "../../components/layout/BrowseLayout"
import { createRandomMessage } from "../../placeholder/chatMessages"
import Chat from "../../components/chat"
const ChannelPage: NextPage = () => {
return (
<BrowseLayout>
<div className="flex-1 flex flex-row">
<div className="bg-neutral-900 flex-1 flex flex-col">
<div className="w-full bg-red-200 flex-1" />
<div className="flex flex-row p-2 items-center justify-between">
<div className="flex flex-row items-center space-x-3">
<span className="w-8 h-8 bg-yellow-300 rounded-full" />
<span className="font-bold">niku</span>
</div>
<div>1:14:32</div>
</div>
</div>
<Chat />
</div>
</BrowseLayout>
)
}
export default ChannelPage

15
client/pages/_app.tsx Normal file
View File

@ -0,0 +1,15 @@
import "../styles/globals.css"
import type { AppProps } from "next/app"
import useSession from "../hooks/useSession"
import { useEffect } from "react"
function MyApp({ Component, pageProps }: AppProps) {
const loadSession = useSession((state) => state.load)
useEffect(() => {
loadSession()
}, [loadSession])
return <Component {...pageProps} />
}
export default MyApp

39
client/pages/_error.js Normal file
View File

@ -0,0 +1,39 @@
/**
* NOTE: This requires `@sentry/nextjs` version 7.3.0 or higher.
*
* NOTE: If using this with `next` version 12.2.0 or lower, uncomment the
* penultimate line in `CustomErrorComponent`.
*
* This page is loaded by Nextjs:
* - on the server, when data-fetching methods throw or reject
* - on the client, when `getInitialProps` throws or rejects
* - on the client, when a React lifecycle method throws or rejects, and it's
* caught by the built-in Nextjs error boundary
*
* See:
* - https://nextjs.org/docs/basic-features/data-fetching/overview
* - https://nextjs.org/docs/api-reference/data-fetching/get-initial-props
* - https://reactjs.org/docs/error-boundaries.html
*/
import * as Sentry from "@sentry/nextjs"
import NextErrorComponent from "next/error"
const CustomErrorComponent = (props) => {
// If you're using a Nextjs version prior to 12.2.1, uncomment this to
// compensate for https://github.com/vercel/next.js/issues/8592
// Sentry.captureUnderscoreErrorException(props);
return <NextErrorComponent statusCode={props.statusCode} />
}
CustomErrorComponent.getInitialProps = async (contextData) => {
// In case this is running in a serverless function, await this in order to give Sentry
// time to send the error before the lambda exits
await Sentry.captureUnderscoreErrorException(contextData)
// This will contain the status code of the response
return NextErrorComponent.getInitialProps(contextData)
}
export default CustomErrorComponent

View File

@ -0,0 +1,13 @@
// @ory/integrations offers a package for integrating with NextJS.
import { config, createApiHandler } from "@ory/integrations/next-edge"
// We need to export the config.
export { config }
// And create the Ory Cloud API "bridge".
export default createApiHandler({
fallbackToPlayground: true,
// Because vercel.app is a public suffix and setting cookies for
// vercel.app is not possible.
dontUseTldForCookieDomain: true,
})

9
client/pages/index.tsx Normal file
View File

@ -0,0 +1,9 @@
import { NextPage } from "next"
import useSession from "../hooks/useSession"
const IndexPage: NextPage = () => {
const session = useSession()
return <div>{JSON.stringify(session.session || {})}</div>
}
export default IndexPage

13
client/pages/login.tsx Normal file
View File

@ -0,0 +1,13 @@
import { NextPage } from "next"
import LoginModal from "../components/login/LoginModal"
const LoginPage: NextPage = () => {
return (
<div className="bg-neutral-900 w-screen h-screen">
<LoginModal isOpen={true} defaultPage={0} />
</div>
)
}
export default LoginPage

12
client/pages/signup.tsx Normal file
View File

@ -0,0 +1,12 @@
import { NextPage } from "next"
import LoginModal from "../components/login/LoginModal"
const SignupPage: NextPage = () => {
return (
<div className="bg-neutral-900 w-screen h-screen">
<LoginModal isOpen={true} defaultPage={1} />
</div>
)
}
export default SignupPage

2877
client/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
client/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

4
client/public/vercel.svg Normal file
View File

@ -0,0 +1,4 @@
<svg width="283" height="64" viewBox="0 0 283 64" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M141.04 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM248.72 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM200.24 34c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9V5h9zM36.95 0L73.9 64H0L36.95 0zm92.38 5l-27.71 48L73.91 5H84.3l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10V51h-9V17h9v9.2c0-5.08 5.91-9.2 13.2-9.2z" fill="#000"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,17 @@
// This file configures the initialization of Sentry on the browser.
// The config you add here will be used whenever a page is visited.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from '@sentry/nextjs';
const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN;
Sentry.init({
dsn: SENTRY_DSN || 'https://69c12baf84284b558f1aaea95321b6e5@o4504010426941440.ingest.sentry.io/4504010431397888',
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 1.0,
// ...
// Note: if you want to override the automatic release value, do not set a
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
// that it will also get attached to your source maps
});

4
client/sentry.properties Normal file
View File

@ -0,0 +1,4 @@
defaults.url=https://sentry.io/
defaults.org=niku-94
defaults.project=twitch-clone-frontend
cli.executable=./node_modules/.bin/sentry-cli

View File

@ -0,0 +1,17 @@
// This file configures the initialization of Sentry on the server.
// The config you add here will be used whenever the server handles a request.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from '@sentry/nextjs';
const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN;
Sentry.init({
dsn: SENTRY_DSN || 'https://69c12baf84284b558f1aaea95321b6e5@o4504010426941440.ingest.sentry.io/4504010431397888',
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 1.0,
// ...
// Note: if you want to override the automatic release value, do not set a
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
// that it will also get attached to your source maps
});

View File

@ -0,0 +1,4 @@
import { Configuration, V0alpha2Api } from "@ory/client"
import { edgeConfig } from "@ory/integrations/next"
export default new V0alpha2Api(new Configuration(edgeConfig))

View File

@ -1,22 +0,0 @@
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";
function App() {
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 App;

View File

@ -1,37 +0,0 @@
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

@ -1,30 +0,0 @@
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

@ -1,9 +0,0 @@
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

@ -1,23 +0,0 @@
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

@ -1,38 +0,0 @@
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

@ -1,41 +0,0 @@
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

@ -1,75 +0,0 @@
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

@ -1,27 +0,0 @@
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

@ -1,95 +0,0 @@
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

@ -1,19 +0,0 @@
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;

View File

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

View File

@ -1,18 +0,0 @@
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

@ -1,26 +0,0 @@
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

@ -1,67 +0,0 @@
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

@ -1,12 +0,0 @@
import { FC } from "react";
import LoginModal from "../components/LoginModal";
const LoginPage: FC = () => {
return (
<div className="bg-neutral-900 w-screen h-screen">
<LoginModal isOpen={true} defaultPage={0} onClose={() => {}} />
</div>
);
};
export default LoginPage;

View File

@ -1,12 +0,0 @@
import { FC } from "react";
import LoginModal from "../components/LoginModal";
const SignupPage: FC = () => {
return (
<div className="bg-neutral-900 w-screen h-screen">
<LoginModal isOpen={true} defaultPage={1} onClose={() => {}} />
</div>
);
};
export default SignupPage;

View File

@ -1,46 +0,0 @@
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

@ -1,28 +0,0 @@
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

@ -1,12 +0,0 @@
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

@ -1,52 +0,0 @@
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;
}

View File

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

View File

@ -1,15 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./index.html", "./src/**/*.tsx"],
theme: {
extend: {
fontFamily: {
inter: ["Inter"],
},
},
},
variants: {
scrollbar: ["rounded"],
},
plugins: [require("tailwind-scrollbar")],
};

Some files were not shown because too many files have changed in this diff Show More