Compare commits
23 Commits
bd171a10bf
...
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 |
@ -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/
|
tmp/
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
|
# Secrets
|
||||||
|
.env
|
||||||
|
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
|
# 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"
|
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
|
@ -1,84 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
root: true,
|
|
||||||
env: {
|
|
||||||
node: true,
|
|
||||||
es6: true,
|
|
||||||
},
|
|
||||||
parserOptions: { ecmaVersion: 8, sourceType: "module" },
|
|
||||||
ignorePatterns: ["node_modules/*"],
|
|
||||||
extends: ["eslint:recommended"],
|
|
||||||
overrides: [
|
|
||||||
{
|
|
||||||
files: ["**/*.ts", "**/*.tsx"],
|
|
||||||
parser: "@typescript-eslint/parser",
|
|
||||||
settings: {
|
|
||||||
react: { version: "detect" },
|
|
||||||
"import/resolver": {
|
|
||||||
typescript: {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
env: {
|
|
||||||
browser: true,
|
|
||||||
node: true,
|
|
||||||
es6: true,
|
|
||||||
},
|
|
||||||
extends: [
|
|
||||||
"eslint:recommended",
|
|
||||||
"plugin:import/errors",
|
|
||||||
"plugin:import/warnings",
|
|
||||||
"plugin:import/typescript",
|
|
||||||
"plugin:@typescript-eslint/recommended",
|
|
||||||
"plugin:react/recommended",
|
|
||||||
"plugin:react-hooks/recommended",
|
|
||||||
"plugin:jsx-a11y/recommended",
|
|
||||||
"plugin:prettier/recommended",
|
|
||||||
"plugin:testing-library/react",
|
|
||||||
"plugin:jest-dom/recommended",
|
|
||||||
],
|
|
||||||
rules: {
|
|
||||||
"react/display-name": "off",
|
|
||||||
"no-restricted-imports": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
patterns: ["@/features/*/*"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"linebreak-style": ["error", "unix"],
|
|
||||||
"react/prop-types": "off",
|
|
||||||
|
|
||||||
"import/order": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
groups: [
|
|
||||||
"builtin",
|
|
||||||
"external",
|
|
||||||
"internal",
|
|
||||||
"parent",
|
|
||||||
"sibling",
|
|
||||||
"index",
|
|
||||||
"object",
|
|
||||||
],
|
|
||||||
"newlines-between": "always",
|
|
||||||
alphabetize: { order: "asc", caseInsensitive: true },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"import/default": "off",
|
|
||||||
"import/no-named-as-default-member": "off",
|
|
||||||
"import/no-named-as-default": "off",
|
|
||||||
|
|
||||||
"react/react-in-jsx-scope": "off",
|
|
||||||
|
|
||||||
"jsx-a11y/anchor-is-valid": "off",
|
|
||||||
|
|
||||||
"@typescript-eslint/no-unused-vars": ["error"],
|
|
||||||
|
|
||||||
"@typescript-eslint/explicit-function-return-type": ["off"],
|
|
||||||
"@typescript-eslint/explicit-module-boundary-types": ["off"],
|
|
||||||
"@typescript-eslint/no-empty-function": ["off"],
|
|
||||||
"@typescript-eslint/no-explicit-any": ["off"],
|
|
||||||
|
|
||||||
"prettier/prettier": ["error", {}, { usePrettierrc: true }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
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
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
logs
|
|
||||||
*.log
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
node_modules
|
# local env files
|
||||||
dist
|
.env.local
|
||||||
dist-ssr
|
.env.development.local
|
||||||
*.local
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
# Editor directories and files
|
# vercel
|
||||||
.vscode/*
|
.vercel
|
||||||
!.vscode/extensions.json
|
|
||||||
.idea
|
# typescript
|
||||||
.DS_Store
|
*.tsbuildinfo
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
cypress/screenshots
|
||||||
*.njsproj
|
cypress/videos
|
||||||
*.sln
|
|
||||||
*.sw?
|
# 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"
|
||||||
|
}
|
||||||
|
}
|
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
|
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;
|
22
client/components/common/Input.tsx
Normal file
22
client/components/common/Input.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
/* eslint-disable react/display-name */
|
||||||
|
import clsx from "clsx"
|
||||||
|
import { forwardRef } from "react"
|
||||||
|
|
||||||
|
type InputProps = React.ComponentPropsWithoutRef<"input">
|
||||||
|
|
||||||
|
const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, ...rest }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
className={clsx(
|
||||||
|
"bg-zinc-700 rounded-md box-border focus:outline outline-violet-400 text-sm",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export default Input
|
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,25 +1,25 @@
|
|||||||
import { forwardRef, ReactNode } from 'react';
|
/* eslint-disable react/display-name */
|
||||||
|
import { forwardRef, ReactNode } from "react"
|
||||||
|
|
||||||
import Input from './Input';
|
import Input from "../Input"
|
||||||
|
|
||||||
interface FormFieldProps extends React.ComponentPropsWithoutRef<'input'> {
|
interface FormFieldProps extends React.ComponentPropsWithoutRef<"input"> {
|
||||||
label: string;
|
label?: string
|
||||||
bottomElement?: ReactNode;
|
bottomElement?: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
const FormField = forwardRef<HTMLInputElement, FormFieldProps>(
|
const FormField = forwardRef<HTMLInputElement, FormFieldProps>(
|
||||||
({ label, bottomElement, ...inputProps }, ref) => {
|
({ label, bottomElement, hidden, ...inputProps }, ref) => {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label htmlFor={inputProps.id} className="font-semibold text-sm">
|
<label htmlFor={inputProps.id} className="font-semibold text-sm">
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
<br />
|
|
||||||
<Input {...inputProps} ref={ref} />
|
<Input {...inputProps} ref={ref} />
|
||||||
{bottomElement}
|
{bottomElement}
|
||||||
</div>
|
</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,36 +1,42 @@
|
|||||||
import { Dialog, Tab } from '@headlessui/react';
|
import { Dialog, Tab } from "@headlessui/react"
|
||||||
import { FC } from 'react';
|
import { FC } from "react"
|
||||||
import { createPortal } from 'react-dom';
|
import Logo from "../common/Logo"
|
||||||
|
|
||||||
import logo from '../assets/images/logo.png';
|
import LoginForm from "./LoginForm"
|
||||||
|
import LoginModalTab from "./LoginModalTab"
|
||||||
import LoginForm from './LoginForm';
|
import SignupForm from "./SignupForm"
|
||||||
import LoginModalTab from './LoginModalTab';
|
|
||||||
import SignupForm from './SignupForm';
|
|
||||||
|
|
||||||
export interface LoginModelProps {
|
export interface LoginModelProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean
|
||||||
onClose: () => any;
|
onClose?: () => any
|
||||||
defaultPage?: number;
|
defaultPage?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const LoginModal: FC<LoginModelProps> = ({ defaultPage, isOpen, onClose }) => {
|
const LoginModal: FC<LoginModelProps> = ({
|
||||||
return createPortal(
|
defaultPage,
|
||||||
|
isOpen,
|
||||||
|
onClose = (b: boolean) => {},
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
<Dialog open={isOpen} onClose={onClose} className="relative z-50">
|
<Dialog open={isOpen} onClose={onClose} className="relative z-50">
|
||||||
<div className="bg-black/80 fixed inset-0 flex items-center justify-center">
|
<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">
|
<Dialog.Panel className="bg-zinc-900 text-gray-100 w-[420px] rounded-md py-12 px-6">
|
||||||
<div className="flex flex-row items-center justify-center">
|
<div className="flex flex-row items-center justify-center">
|
||||||
<Dialog.Title className="text-xl">
|
<Dialog.Title className="text-xl">
|
||||||
<img src={logo} className="inline w-12 h-12" alt="logo" /> Log in to twitch-clone
|
<Logo className="inline w-12 h-12" /> Log in to twitch-clone
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
</div>
|
</div>
|
||||||
<Tab.Group defaultIndex={defaultPage}>
|
<Tab.Group defaultIndex={defaultPage}>
|
||||||
<Tab.List className="space-x-4 border-b border-b-neutral-100/40 mt-4">
|
<Tab.List className="space-x-4 border-b border-b-neutral-100/40 mt-4">
|
||||||
<Tab>
|
<Tab>
|
||||||
{({ selected }) => <LoginModalTab selected={selected}>Log In</LoginModalTab>}
|
{({ selected }) => (
|
||||||
|
<LoginModalTab selected={selected}>Log In</LoginModalTab>
|
||||||
|
)}
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab>
|
<Tab>
|
||||||
{({ selected }) => <LoginModalTab selected={selected}>Sign Up</LoginModalTab>}
|
{({ selected }) => (
|
||||||
|
<LoginModalTab selected={selected}>Sign Up</LoginModalTab>
|
||||||
|
)}
|
||||||
</Tab>
|
</Tab>
|
||||||
</Tab.List>
|
</Tab.List>
|
||||||
<Tab.Panels className="mt-4">
|
<Tab.Panels className="mt-4">
|
||||||
@ -44,9 +50,8 @@ const LoginModal: FC<LoginModelProps> = ({ defaultPage, isOpen, onClose }) => {
|
|||||||
</Tab.Group>
|
</Tab.Group>
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>,
|
</Dialog>
|
||||||
document.body
|
)
|
||||||
);
|
}
|
||||||
};
|
|
||||||
|
|
||||||
export default LoginModal;
|
export default LoginModal
|
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) => { ... })
|
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')
|
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,53 +1,51 @@
|
|||||||
{
|
{
|
||||||
"name": "client",
|
"name": "kratos-nextjs-react-example",
|
||||||
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"config": {
|
||||||
"type": "module",
|
"prettierTarget": "{cypress,pages,styles,public,}{/**/,}*.{tsx,ts,json,md,js,css}"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "next dev",
|
||||||
"build": "tsc && vite build --outDir ../dist",
|
"build": "next build",
|
||||||
"preview": "vite preview",
|
"start": "next start",
|
||||||
"lint": "eslint --fix --ext .js,.ts,.tsx ./src --ignore-path .gitignore",
|
"lint": "next lint",
|
||||||
"prettier": "prettier --ignore-path .gitignore --write \"**/*.+(js|json|ts|tsx)\"",
|
"format": "prettier --write ${npm_package_config_prettierTarget}",
|
||||||
"cypress": "cypress"
|
"format:check": "prettier --check ${npm_package_config_prettierTarget}",
|
||||||
|
"test": "cypress run",
|
||||||
|
"test:dev": "cypress open"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^1.7.2",
|
"@headlessui/react": "^1.7.3",
|
||||||
"@heroicons/react": "^2.0.11",
|
"@heroicons/react": "^2.0.12",
|
||||||
"@hookform/resolvers": "^2.9.8",
|
"@hookform/resolvers": "^2.9.8",
|
||||||
"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",
|
"clsx": "^1.2.1",
|
||||||
"react": "^18.2.0",
|
"next": "12.1.5",
|
||||||
"react-dom": "^18.2.0",
|
"react": "17.0.2",
|
||||||
"react-hook-form": "^7.36.1",
|
"react-dom": "17.0.2",
|
||||||
"react-router-dom": "^6.4.1",
|
"react-hook-form": "^7.37.0",
|
||||||
"tailwind-scrollbar": "^2.0.1",
|
"tailwind-scrollbar": "2.1.0-preview.0",
|
||||||
"zod": "^3.19.1"
|
"zod": "^3.19.1",
|
||||||
|
"zustand": "^4.1.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/cypress": "^8.0.3",
|
"@faker-js/faker": "^7.6.0",
|
||||||
"@types/react": "^18.0.17",
|
"@sentry/cli": "^2.7.0",
|
||||||
"@types/react-dom": "^18.0.6",
|
"@trivago/prettier-plugin-sort-imports": "^2.0.2",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.39.0",
|
"@types/node": "16.11.6",
|
||||||
"@typescript-eslint/parser": "^5.39.0",
|
"@types/react": "17.0.33",
|
||||||
"@vitejs/plugin-react": "^2.1.0",
|
|
||||||
"autoprefixer": "^10.4.12",
|
"autoprefixer": "^10.4.12",
|
||||||
"cypress": "^10.9.0",
|
"cypress": "^9.6.0",
|
||||||
"eslint": "^8.24.0",
|
"eslint": "7.32.0",
|
||||||
"eslint-config-prettier": "^8.5.0",
|
"eslint-config-next": "12.0.1",
|
||||||
"eslint-import-resolver-typescript": "^3.5.1",
|
"ory-prettier-styles": "^1.3.0",
|
||||||
"eslint-plugin-cypress": "^2.12.1",
|
"postcss": "^8.4.18",
|
||||||
"eslint-plugin-import": "^2.26.0",
|
"prettier": "^2.3.2",
|
||||||
"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",
|
"tailwindcss": "^3.1.8",
|
||||||
"typescript": "^4.6.4",
|
"typescript": "4.4.4"
|
||||||
"vite": "^3.1.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
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
|
2434
client/pnpm-lock.yaml
generated
2434
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,14 +0,0 @@
|
|||||||
import { BrowserRouter } from 'react-router-dom';
|
|
||||||
|
|
||||||
import Routes from './routes';
|
|
||||||
import './styles/global.css';
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
return (
|
|
||||||
<BrowserRouter>
|
|
||||||
<Routes />
|
|
||||||
</BrowserRouter>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
|
@ -1,37 +0,0 @@
|
|||||||
import { ArrowLeftIcon } from '@heroicons/react/24/outline';
|
|
||||||
import { Outlet, NavLink } from 'react-router-dom';
|
|
||||||
|
|
||||||
import Button from '../components/Button';
|
|
||||||
import NavBar from '../components/NavBar';
|
|
||||||
import SideNavChannel from '../components/SideNavChannel';
|
|
||||||
import streamData from '../placeholder/GetStreams';
|
|
||||||
|
|
||||||
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,7 +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,25 +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"
|
|
||||||
alt="Poggies"
|
|
||||||
className="inline w-7 h-7"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ChatMessage;
|
|
@ -1,26 +0,0 @@
|
|||||||
import clsx from 'clsx';
|
|
||||||
import { FC } from 'react';
|
|
||||||
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,19 +0,0 @@
|
|||||||
import clsx from 'clsx';
|
|
||||||
import { forwardRef } from 'react';
|
|
||||||
|
|
||||||
type InputProps = React.ComponentPropsWithoutRef<'input'>;
|
|
||||||
|
|
||||||
const Input = forwardRef<HTMLInputElement, InputProps>(({ className, ...rest }, ref) => {
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
className={clsx(
|
|
||||||
'bg-zinc-700 rounded-md box-border focus:outline outline-violet-400 text-sm',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...rest}
|
|
||||||
ref={ref}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default Input;
|
|
@ -1,40 +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')}
|
|
||||||
/>
|
|
||||||
<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,67 +0,0 @@
|
|||||||
import { UserIcon } from '@heroicons/react/24/outline';
|
|
||||||
import { FC, useState } from 'react';
|
|
||||||
|
|
||||||
import logo from '../assets/images/logo.png';
|
|
||||||
|
|
||||||
import Button from './Button';
|
|
||||||
import Input from './Input';
|
|
||||||
import LoginModal from './LoginModal';
|
|
||||||
|
|
||||||
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" alt="logo" />
|
|
||||||
</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,28 +0,0 @@
|
|||||||
import { FC } from 'react';
|
|
||||||
|
|
||||||
import { Stream } from '../types';
|
|
||||||
import { numFormatter } from '../utils/format';
|
|
||||||
|
|
||||||
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} alt="avatar" />
|
|
||||||
<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,76 +0,0 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { FC } from 'react';
|
|
||||||
import { SubmitHandler, useForm } from 'react-hook-form';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import FormField from './FormField';
|
|
||||||
import InlineLink from './InlineLink';
|
|
||||||
import SubmitButton from './SubmitButton';
|
|
||||||
|
|
||||||
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 { register, handleSubmit } = useForm<SignupFormValues>({
|
|
||||||
resolver: zodResolver(SignupFormSchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSubmit: SubmitHandler<SignupFormValues> = (data) => {
|
|
||||||
console.log({ 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"
|
|
||||||
/>
|
|
||||||
<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,16 +0,0 @@
|
|||||||
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('rounded-md bg-violet-500 font-semibold py-2 text-sm', className)}
|
|
||||||
{...rest}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SubmitButton;
|
|
@ -1 +0,0 @@
|
|||||||
export const API_URL = 'http://localhost:5000';
|
|
@ -1,7 +0,0 @@
|
|||||||
import { API_URL } from '@/config';
|
|
||||||
import Axios from 'axios';
|
|
||||||
|
|
||||||
export const axios = Axios.create({
|
|
||||||
baseURL: API_URL,
|
|
||||||
withCredentials: true,
|
|
||||||
});
|
|
@ -1,10 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import ReactDOM from 'react-dom/client';
|
|
||||||
|
|
||||||
import App from './App';
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<App />
|
|
||||||
</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} alt={category.name} />
|
|
||||||
<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,64 +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 '../utils/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,13 +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,13 +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,44 +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,24 +0,0 @@
|
|||||||
import { FC } from 'react';
|
|
||||||
import { Routes, Route } from 'react-router-dom';
|
|
||||||
|
|
||||||
import BrowseLayout from '../components/BrowseLayout';
|
|
||||||
import CategoryPage from '../pages/CategoryPage';
|
|
||||||
import ChannelPage from '../pages/ChannelPage';
|
|
||||||
import LoginPage from '../pages/LoginPage';
|
|
||||||
import SignupPage from '../pages/SignupPage';
|
|
||||||
|
|
||||||
const Router: FC = () => {
|
|
||||||
return (
|
|
||||||
<Routes>
|
|
||||||
<Route path="/login" element={<LoginPage />} />
|
|
||||||
<Route path="/signup" element={<SignupPage />} />
|
|
||||||
<Route element={<BrowseLayout />}>
|
|
||||||
<Route path="/:channel" element={<ChannelPage />} />
|
|
||||||
<Route path="/category/:category" element={<CategoryPage />} />
|
|
||||||
<Route path="/" element={<h1>Hi</h1>} />
|
|
||||||
</Route>
|
|
||||||
</Routes>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Router;
|
|
@ -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,6 +1,9 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
content: ["./index.html", "./src/**/*.tsx"],
|
content: [
|
||||||
|
"./pages/**/*.{js,ts,jsx,tsx}",
|
||||||
|
"./components/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
@ -9,4 +12,4 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [require("tailwind-scrollbar")({ nocompatible: true })],
|
plugins: [require("tailwind-scrollbar")({ nocompatible: true })],
|
||||||
};
|
}
|
@ -1,26 +1,20 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ESNext",
|
"target": "es5",
|
||||||
"useDefineForClassFields": true,
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
"allowJs": true,
|
||||||
"allowJs": false,
|
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"esModuleInterop": false,
|
|
||||||
"allowSyntheticDefaultImports": true,
|
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"module": "ESNext",
|
"noEmit": true,
|
||||||
"moduleResolution": "Node",
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "node",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"noEmit": true,
|
"jsx": "preserve",
|
||||||
"jsx": "react-jsx",
|
"incremental": true
|
||||||
"types": ["@testing-library/crypress", "cypress"],
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./src/*"]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"include": ["src", "cypress"],
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user