Compare commits
31 Commits
3025245a79
...
main
Author | SHA1 | Date | |
---|---|---|---|
573568ee6d | |||
9308ebba54 | |||
793d464eda | |||
bfdc915631 | |||
4d92bc3e86 | |||
b1a4470c68 | |||
f15eb98192 | |||
f606706ba3 | |||
bff6cdd434 | |||
bdd1a655d4 | |||
f30f9090b3 | |||
3fbe04896e | |||
a627addad5 | |||
4afe2f906d | |||
b87df50328 | |||
582455933e | |||
a408abdb97 | |||
f4eadc34c7 | |||
38f3caa524 | |||
ea603804a2 | |||
b4ff0c8f77 | |||
b2a16e5181 | |||
ebf1dd5adc | |||
bd171a10bf | |||
76a20d7685 | |||
d1c0ae0a15 | |||
5a7e37077a | |||
92ee1cfd64 | |||
837516f0e6 | |||
add9cdabcb | |||
c768d5ccd5 |
@ -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
|
@ -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
|
39
.docker/kratos/default.schema.json
Normal file
39
.docker/kratos/default.schema.json
Normal 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
94
.docker/kratos/kratos.yml
Normal 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
13
.env.sample
Normal 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
3
.gitignore
vendored
@ -22,3 +22,6 @@ go.work
|
||||
|
||||
tmp/
|
||||
dist/
|
||||
|
||||
# Secrets
|
||||
.env
|
||||
|
7
.prettierrc
Normal file
7
.prettierrc
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false
|
||||
}
|
4
Makefile
Normal file
4
Makefile
Normal file
@ -0,0 +1,4 @@
|
||||
.PHONY: chat-service
|
||||
|
||||
chat-service:
|
||||
go build -o ./tmp/main ./cmd/chat-service/main.go
|
40
README.md
40
README.md
@ -1 +1,41 @@
|
||||

|
||||
|
||||
# twitch-clone
|
||||
|
||||
A Twitch clone to experiment with various technologies.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
BIN
assets/screenshot.png
Normal file
BIN
assets/screenshot.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 77 KiB |
43
chat-service/app/app.go
Normal file
43
chat-service/app/app.go
Normal 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"))
|
||||
}
|
70
chat-service/logic/logic.go
Normal file
70
chat-service/logic/logic.go
Normal 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
|
||||
}
|
11
chat-service/models/models.go
Normal file
11
chat-service/models/models.go
Normal 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"`
|
||||
}
|
9
chat-service/repository/chat.go
Normal file
9
chat-service/repository/chat.go
Normal file
@ -0,0 +1,9 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"twitch-clone/chat-service/models"
|
||||
)
|
||||
|
||||
type ChatRepository interface {
|
||||
Store(*models.ChatMessage) error
|
||||
}
|
48
chat-service/repository/scylla/chat.go
Normal file
48
chat-service/repository/scylla/chat.go
Normal 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,
|
||||
}
|
||||
}
|
30
chat-service/repository/scylla/seed.go
Normal file
30
chat-service/repository/scylla/seed.go
Normal 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
|
||||
}
|
31
chat-service/serializer/json.go
Normal file
31
chat-service/serializer/json.go
Normal 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
|
||||
}
|
8
chat-service/serializer/serializer.go
Normal file
8
chat-service/serializer/serializer.go
Normal 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)
|
||||
}
|
11
chat-service/services/chat.go
Normal file
11
chat-service/services/chat.go
Normal 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
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
package models
|
||||
package utils
|
||||
|
||||
import "github.com/go-playground/validator/v10"
|
||||
|
1
client/.env.sample
Normal file
1
client/.env.sample
Normal file
@ -0,0 +1 @@
|
||||
ORY_SDK_URL=http://localhost:4433
|
3
client/.eslintrc.json
Normal file
3
client/.eslintrc.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
57
client/.gitignore
vendored
57
client/.gitignore
vendored
@ -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
54
client/.package.json
Normal 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"
|
||||
}
|
||||
}
|
@ -1,4 +0,0 @@
|
||||
{
|
||||
"tabWidth": 2,
|
||||
"useTabs": false
|
||||
}
|
6
client/.prettierrc.js
Normal file
6
client/.prettierrc.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
...require('ory-prettier-styles'),
|
||||
importOrder: ['^\\.\\./(?!.*\\.[a-z]+$)(.*)$', '^\\./(?!.*\\.[a-z]+$)(.*)$'],
|
||||
importOrderSeparation: true,
|
||||
experimentalBabelParserPluginsList: ['jsx', 'typescript']
|
||||
}
|
71
client/components/chat/index.tsx
Normal file
71
client/components/chat/index.tsx
Normal 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
|
25
client/components/common/Button.tsx
Normal file
25
client/components/common/Button.tsx
Normal 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;
|
17
client/components/common/InlineLink.tsx
Normal file
17
client/components/common/InlineLink.tsx
Normal 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;
|
@ -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
|
11
client/components/common/Logo.tsx
Normal file
11
client/components/common/Logo.tsx
Normal 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
|
@ -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
|
19
client/components/common/form/SubmitButton.tsx
Normal file
19
client/components/common/form/SubmitButton.tsx
Normal 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
|
13
client/components/layout/BrowseLayout.tsx
Normal file
13
client/components/layout/BrowseLayout.tsx
Normal 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
|
72
client/components/login/LoginForm.tsx
Normal file
72
client/components/login/LoginForm.tsx
Normal 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
|
@ -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
|
@ -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}
|
||||
/>
|
97
client/components/login/SignupForm.tsx
Normal file
97
client/components/login/SignupForm.tsx
Normal 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'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
|
21
client/components/message/ChatMessage.tsx
Normal file
21
client/components/message/ChatMessage.tsx
Normal 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
|
93
client/components/nav/NavBar.tsx
Normal file
93
client/components/nav/NavBar.tsx
Normal 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
3
client/config/index.ts
Normal 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
|
18
client/config/validation.ts
Normal file
18
client/config/validation.ts
Normal 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.",
|
||||
)
|
@ -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
3
client/cypress.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"baseUrl": "http://localhost:3000"
|
||||
}
|
64
client/cypress/integration/pages.spec.js
Normal file
64
client/cypress/integration/pages.spec.js
Normal 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')
|
||||
})
|
||||
})
|
22
client/cypress/plugins/index.js
Normal file
22
client/cypress/plugins/index.js
Normal 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
|
||||
}
|
25
client/cypress/support/commands.js
Normal file
25
client/cypress/support/commands.js
Normal 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) => { ... })
|
@ -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 />)
|
||||
|
@ -14,7 +14,7 @@
|
||||
// ***********************************************************
|
||||
|
||||
// Import commands.js using ES2015 syntax:
|
||||
import "./commands";
|
||||
import './commands';
|
||||
|
||||
// Alternatively you can use CommonJS syntax:
|
||||
// require('./commands')
|
||||
|
19
client/cypress/support/index.js
Normal file
19
client/cypress/support/index.js
Normal 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')
|
@ -3,4 +3,4 @@
|
||||
"compilerOptions": {
|
||||
"isolatedModules": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
53
client/hooks/useLogInFlow.ts
Normal file
53
client/hooks/useLogInFlow.ts
Normal 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
38
client/hooks/useLogout.ts
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
31
client/hooks/useSession.ts
Normal file
31
client/hooks/useSession.ts
Normal 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
|
55
client/hooks/useSignUpFlow.ts
Normal file
55
client/hooks/useSignUpFlow.ts
Normal 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
|
@ -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
5
client/next-env.d.ts
vendored
Normal 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
36
client/next.config.js
Normal 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)
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
28
client/pages/[channel]/index.tsx
Normal file
28
client/pages/[channel]/index.tsx
Normal 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
15
client/pages/_app.tsx
Normal 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
39
client/pages/_error.js
Normal 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
|
13
client/pages/api/.ory/[...paths].ts
Normal file
13
client/pages/api/.ory/[...paths].ts
Normal 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
9
client/pages/index.tsx
Normal 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
13
client/pages/login.tsx
Normal 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
12
client/pages/signup.tsx
Normal 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
2877
client/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
BIN
client/public/assets/images/logo.png
Normal file
BIN
client/public/assets/images/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.0 KiB |
BIN
client/public/favicon.ico
Normal file
BIN
client/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
4
client/public/vercel.svg
Normal file
4
client/public/vercel.svg
Normal 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 |
17
client/sentry.client.config.js
Normal file
17
client/sentry.client.config.js
Normal 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
4
client/sentry.properties
Normal 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
|
17
client/sentry.server.config.js
Normal file
17
client/sentry.server.config.js
Normal 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
|
||||
});
|
4
client/services/ory/index.ts
Normal file
4
client/services/ory/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { Configuration, V0alpha2Api } from "@ory/client"
|
||||
import { edgeConfig } from "@ory/integrations/next"
|
||||
|
||||
export default new V0alpha2Api(new Configuration(edgeConfig))
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -1 +0,0 @@
|
||||
export const numFormatter = Intl.NumberFormat("en", { notation: "compact" });
|
@ -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>
|
||||
);
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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",
|
||||
},
|
||||
};
|
@ -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",
|
||||
},
|
||||
};
|
@ -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;
|
||||
}
|
1
client/src/vite-env.d.ts
vendored
1
client/src/vite-env.d.ts
vendored
@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
@ -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
Reference in New Issue
Block a user