Compare commits
31 Commits
3025245a79
...
main
Author | SHA1 | Date | |
---|---|---|---|
573568ee6d | |||
9308ebba54 | |||
793d464eda | |||
bfdc915631 | |||
4d92bc3e86 | |||
b1a4470c68 | |||
f15eb98192 | |||
f606706ba3 | |||
bff6cdd434 | |||
bdd1a655d4 | |||
f30f9090b3 | |||
3fbe04896e | |||
a627addad5 | |||
4afe2f906d | |||
b87df50328 | |||
582455933e | |||
a408abdb97 | |||
f4eadc34c7 | |||
38f3caa524 | |||
ea603804a2 | |||
b4ff0c8f77 | |||
b2a16e5181 | |||
ebf1dd5adc | |||
bd171a10bf | |||
76a20d7685 | |||
d1c0ae0a15 | |||
5a7e37077a | |||
92ee1cfd64 | |||
837516f0e6 | |||
add9cdabcb | |||
c768d5ccd5 |
@ -1,47 +0,0 @@
|
|||||||
# Config file for [Air](https://github.com/cosmtrek/air) in TOML format
|
|
||||||
|
|
||||||
# Working directory
|
|
||||||
# . or absolute path, please note that the directories following must be under root.
|
|
||||||
root = "."
|
|
||||||
tmp_dir = "tmp"
|
|
||||||
|
|
||||||
[build]
|
|
||||||
# Just plain old shell command. You could use `make` as well.
|
|
||||||
cmd = "go build -o ./application ./main.go"
|
|
||||||
# Binary file yields from `cmd`.
|
|
||||||
bin = "application"
|
|
||||||
# Customize binary.
|
|
||||||
full_bin = "application"
|
|
||||||
# Watch these filename extensions.
|
|
||||||
include_ext = ["go", "tpl", "tmpl", "html", "mustache", "hbs", "pug"]
|
|
||||||
# Ignore these filename extensions or directories.
|
|
||||||
exclude_dir = ["tmp", "vendor", "node_modules"]
|
|
||||||
# Watch these directories if you specified.
|
|
||||||
include_dir = []
|
|
||||||
# Exclude files.
|
|
||||||
exclude_file = []
|
|
||||||
# This log file places in your tmp_dir.
|
|
||||||
log = "air.log"
|
|
||||||
# It's not necessary to trigger build each time file changes if it's too frequent.
|
|
||||||
delay = 1000 # ms
|
|
||||||
# Stop running old binary when build errors occur.
|
|
||||||
stop_on_error = true
|
|
||||||
# Send Interrupt signal before killing process (windows does not support this feature)
|
|
||||||
send_interrupt = false
|
|
||||||
# Delay after sending Interrupt signal
|
|
||||||
kill_delay = 500 # ms
|
|
||||||
|
|
||||||
[log]
|
|
||||||
# Show log time
|
|
||||||
time = false
|
|
||||||
|
|
||||||
[color]
|
|
||||||
# Customize each part's color. If no color found, use the raw app log.
|
|
||||||
main = "magenta"
|
|
||||||
watcher = "cyan"
|
|
||||||
build = "yellow"
|
|
||||||
runner = "green"
|
|
||||||
|
|
||||||
[misc]
|
|
||||||
# Delete tmp directory on exit
|
|
||||||
clean_on_exit = true
|
|
@ -1,47 +0,0 @@
|
|||||||
# Config file for [Air](https://github.com/cosmtrek/air) in TOML format
|
|
||||||
|
|
||||||
# Working directory
|
|
||||||
# . or absolute path, please note that the directories following must be under root.
|
|
||||||
root = "."
|
|
||||||
tmp_dir = "tmp"
|
|
||||||
|
|
||||||
[build]
|
|
||||||
# Just plain old shell command. You could use `make` as well.
|
|
||||||
cmd = "go build -o ./application.exe ./main.go"
|
|
||||||
# Binary file yields from `cmd`.
|
|
||||||
bin = "application.exe"
|
|
||||||
# Customize binary.
|
|
||||||
full_bin = "application.exe"
|
|
||||||
# Watch these filename extensions.
|
|
||||||
include_ext = ["go", "tpl", "tmpl", "html", "mustache", "hbs", "pug"]
|
|
||||||
# Ignore these filename extensions or directories.
|
|
||||||
exclude_dir = ["tmp", "vendor", "node_modules"]
|
|
||||||
# Watch these directories if you specified.
|
|
||||||
include_dir = []
|
|
||||||
# Exclude files.
|
|
||||||
exclude_file = []
|
|
||||||
# This log file places in your tmp_dir.
|
|
||||||
log = "air.log"
|
|
||||||
# It's not necessary to trigger build each time file changes if it's too frequent.
|
|
||||||
delay = 1000 # ms
|
|
||||||
# Stop running old binary when build errors occur.
|
|
||||||
stop_on_error = true
|
|
||||||
# Send Interrupt signal before killing process (windows does not support this feature)
|
|
||||||
send_interrupt = false
|
|
||||||
# Delay after sending Interrupt signal
|
|
||||||
kill_delay = 500 # ms
|
|
||||||
|
|
||||||
[log]
|
|
||||||
# Show log time
|
|
||||||
time = false
|
|
||||||
|
|
||||||
[color]
|
|
||||||
# Customize each part's color. If no color found, use the raw app log.
|
|
||||||
main = "magenta"
|
|
||||||
watcher = "cyan"
|
|
||||||
build = "yellow"
|
|
||||||
runner = "green"
|
|
||||||
|
|
||||||
[misc]
|
|
||||||
# Delete tmp directory on exit
|
|
||||||
clean_on_exit = true
|
|
39
.docker/kratos/default.schema.json
Normal file
39
.docker/kratos/default.schema.json
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"$id": "file:///etc/config/kratos/default.schema.json",
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"title": "default",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"traits": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"email": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "email",
|
||||||
|
"title": "E-Mail",
|
||||||
|
"minLength": 3,
|
||||||
|
"ory.sh/kratos": {
|
||||||
|
"credentials": {
|
||||||
|
"password": {
|
||||||
|
"identifier": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"verification": {
|
||||||
|
"via": "email"
|
||||||
|
},
|
||||||
|
"recovery": {
|
||||||
|
"via": "email"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"title": "Username",
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["email", "username"],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
94
.docker/kratos/kratos.yml
Normal file
94
.docker/kratos/kratos.yml
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
version: v0.10.1
|
||||||
|
|
||||||
|
dsn: memory
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
serve:
|
||||||
|
public:
|
||||||
|
base_url: http://127.0.0.1:4433/
|
||||||
|
cors:
|
||||||
|
enabled: true
|
||||||
|
allowed_origins:
|
||||||
|
- http://127.0.0.1:3000
|
||||||
|
allowed_methods:
|
||||||
|
- POST
|
||||||
|
- GET
|
||||||
|
- PUT
|
||||||
|
- PATCH
|
||||||
|
- DELETE
|
||||||
|
allowed_headers:
|
||||||
|
- Authorization
|
||||||
|
- Cookie
|
||||||
|
- Content-Type
|
||||||
|
exposed_headers:
|
||||||
|
- Content-Type
|
||||||
|
- Set-Cookie
|
||||||
|
admin:
|
||||||
|
base_url: http://kratos:4434/
|
||||||
|
|
||||||
|
selfservice:
|
||||||
|
default_browser_return_url: http://127.0.0.1:3000/
|
||||||
|
allowed_return_urls:
|
||||||
|
- http://127.0.0.1:3000/
|
||||||
|
methods:
|
||||||
|
password:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
flows:
|
||||||
|
error:
|
||||||
|
ui_url: http://127.0.0.1:3000/error
|
||||||
|
|
||||||
|
settings:
|
||||||
|
ui_url: http://127.0.0.1:3000/settings
|
||||||
|
privileged_session_max_age: 15m
|
||||||
|
|
||||||
|
recovery:
|
||||||
|
enabled: false
|
||||||
|
ui_url: http://127.0.0.1:3000/recovery
|
||||||
|
|
||||||
|
verification:
|
||||||
|
enabled: false
|
||||||
|
ui_url: http://127.0.0.1:3000/verification
|
||||||
|
after:
|
||||||
|
default_browser_return_url: http://127.0.0.1:3000/
|
||||||
|
|
||||||
|
logout:
|
||||||
|
after:
|
||||||
|
default_browser_return_url: http://127.0.0.1:3000/login
|
||||||
|
|
||||||
|
login:
|
||||||
|
ui_url: http://127.0.0.1:3000/login
|
||||||
|
lifespan: 10m
|
||||||
|
|
||||||
|
registration:
|
||||||
|
lifespan: 10m
|
||||||
|
ui_url: http://127.0.0.1:3000/signup
|
||||||
|
|
||||||
|
log:
|
||||||
|
level: debug
|
||||||
|
format: text
|
||||||
|
leak_sensitive_values: true
|
||||||
|
|
||||||
|
secrets:
|
||||||
|
cookie:
|
||||||
|
- PLEASE-CHANGE-ME-I-AM-VERY-INSECURE
|
||||||
|
cipher:
|
||||||
|
- 32-LONG-SECRET-NOT-SECURE-AT-ALL
|
||||||
|
|
||||||
|
ciphers:
|
||||||
|
algorithm: xchacha20-poly1305
|
||||||
|
|
||||||
|
hashers:
|
||||||
|
algorithm: bcrypt
|
||||||
|
bcrypt:
|
||||||
|
cost: 8
|
||||||
|
|
||||||
|
identity:
|
||||||
|
default_schema_id: default
|
||||||
|
schemas:
|
||||||
|
- id: default
|
||||||
|
url: file:///etc/config/kratos/default.schema.json
|
||||||
|
|
||||||
|
courier:
|
||||||
|
smtp:
|
||||||
|
connection_uri: smtps://test:test@mailslurper:1025/?skip_ssl_verify=true
|
13
.env.sample
Normal file
13
.env.sample
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
# twitch-clone postgres
|
||||||
|
POSTGRES_USER=postgres
|
||||||
|
POSTGRES_PASSWORD=postgres
|
||||||
|
POSTGRES_DB=postgres
|
||||||
|
|
||||||
|
# Ory Kratos postgres
|
||||||
|
KRATOS_POSTGRES_USER=kratos
|
||||||
|
KRATOS_POSTGRES_PASSWORD=secret
|
||||||
|
KRATOS_POSTGRES_DB=kratos
|
||||||
|
|
||||||
|
# Ory Kratos secrets
|
||||||
|
KRATOS_COOKIE_SECRET=dnIgMTQgb2t0IDIwMjIgMTI6Mzg6NTMg
|
||||||
|
KRATOS_CIPHER_SECRET=dnIgMTQgb2t0IDIwMjIgMTI6Mzg6NTMg
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -22,3 +22,6 @@ go.work
|
|||||||
|
|
||||||
tmp/
|
tmp/
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
|
# Secrets
|
||||||
|
.env
|
||||||
|
7
.prettierrc
Normal file
7
.prettierrc
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false
|
||||||
|
}
|
4
Makefile
Normal file
4
Makefile
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
.PHONY: chat-service
|
||||||
|
|
||||||
|
chat-service:
|
||||||
|
go build -o ./tmp/main ./cmd/chat-service/main.go
|
40
README.md
40
README.md
@ -1 +1,41 @@
|
|||||||
|

|
||||||
|
|
||||||
# twitch-clone
|
# 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
|
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"
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"tabWidth": 2,
|
|
||||||
"useTabs": false
|
|
||||||
}
|
|
6
client/.prettierrc.js
Normal file
6
client/.prettierrc.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
...require('ory-prettier-styles'),
|
||||||
|
importOrder: ['^\\.\\./(?!.*\\.[a-z]+$)(.*)$', '^\\./(?!.*\\.[a-z]+$)(.*)$'],
|
||||||
|
importOrderSeparation: true,
|
||||||
|
experimentalBabelParserPluginsList: ['jsx', 'typescript']
|
||||||
|
}
|
71
client/components/chat/index.tsx
Normal file
71
client/components/chat/index.tsx
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { FC, KeyboardEventHandler, useEffect, useRef, useState } from "react"
|
||||||
|
import { CHAT_URL, MAX_CHAT_MESSAGES } from "../../config"
|
||||||
|
import useSession from "../../hooks/useSession"
|
||||||
|
import { ChatMessage as Message } from "../../types"
|
||||||
|
import Input from "../common/Input"
|
||||||
|
import ChatMessage from "../message/ChatMessage"
|
||||||
|
|
||||||
|
const Chat: FC = () => {
|
||||||
|
const { session } = useSession()
|
||||||
|
const [messages, setMessages] = useState<Message[]>([])
|
||||||
|
const wsRef = useRef<WebSocket | null>(null)
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
wsRef.current = new WebSocket(CHAT_URL)
|
||||||
|
wsRef.current.onmessage = (ev) => {
|
||||||
|
const newMsg = JSON.parse(ev.data) as Message
|
||||||
|
if (typeof newMsg === "string") return
|
||||||
|
setMessages((old) => {
|
||||||
|
if (old.length >= MAX_CHAT_MESSAGES) old.shift()
|
||||||
|
return [...old, newMsg]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (wsRef.current) wsRef.current.close()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||||
|
}, [messages])
|
||||||
|
|
||||||
|
const handleChatInput: KeyboardEventHandler<HTMLInputElement> = (event) => {
|
||||||
|
if (event.key === "Enter" && wsRef.current) {
|
||||||
|
const msg = JSON.stringify({
|
||||||
|
fromUser: "niku",
|
||||||
|
fromUserID: 10,
|
||||||
|
toUser: "niku",
|
||||||
|
toUserID: 10,
|
||||||
|
content: event.currentTarget.value,
|
||||||
|
})
|
||||||
|
wsRef.current.send(msg)
|
||||||
|
event.currentTarget.value = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-zinc-900 w-80 border-l border-l-zinc-700 flex flex-col">
|
||||||
|
<div className="flex flex-row justify-center items-center border-b border-b-zinc-700 p-2 h-12">
|
||||||
|
<p className="uppercase font-semibold text-sm">Stream Chat</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-scrollbar">
|
||||||
|
{messages.map((message) => (
|
||||||
|
<ChatMessage key={message.messageId.toString()} message={message} />
|
||||||
|
))}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
<div className="m-2">
|
||||||
|
<Input
|
||||||
|
disabled={!session}
|
||||||
|
className="w-full p-2"
|
||||||
|
placeholder="Send a message"
|
||||||
|
onKeyDown={handleChatInput}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Chat
|
25
client/components/common/Button.tsx
Normal file
25
client/components/common/Button.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
import { FC } from 'react';
|
||||||
|
|
||||||
|
type ButtonVariants = 'filled' | 'subtle';
|
||||||
|
|
||||||
|
interface ButtonProps extends React.ComponentPropsWithoutRef<'button'> {
|
||||||
|
variant?: ButtonVariants;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStyling = (variant?: ButtonVariants) => {
|
||||||
|
switch (variant) {
|
||||||
|
case 'filled':
|
||||||
|
return 'bg-neutral-700';
|
||||||
|
case 'subtle':
|
||||||
|
return 'hover:bg-neutral-500';
|
||||||
|
default:
|
||||||
|
return 'bg-neutral-700';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const Button: FC<ButtonProps> = ({ className, variant, ...rest }) => {
|
||||||
|
return <button className={clsx('rounded-md', getStyling(variant), className)} {...rest} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Button;
|
17
client/components/common/InlineLink.tsx
Normal file
17
client/components/common/InlineLink.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
import { FC } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
export interface InlineLinkProps extends React.ComponentPropsWithoutRef<'span'> {
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const InlineLink: FC<InlineLinkProps> = ({ to, className, ...rest }) => {
|
||||||
|
return (
|
||||||
|
<Link href={to} passHref={true}>
|
||||||
|
<span className={clsx('text-violet-400 cursor-pointer text-sm', className)} {...rest} />
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InlineLink;
|
@ -1,7 +1,8 @@
|
|||||||
import clsx from "clsx";
|
/* eslint-disable react/display-name */
|
||||||
import { forwardRef } from "react";
|
import clsx from "clsx"
|
||||||
|
import { forwardRef } from "react"
|
||||||
|
|
||||||
interface InputProps extends React.ComponentPropsWithoutRef<"input"> {}
|
type InputProps = React.ComponentPropsWithoutRef<"input">
|
||||||
|
|
||||||
const Input = forwardRef<HTMLInputElement, InputProps>(
|
const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||||
({ className, ...rest }, ref) => {
|
({ className, ...rest }, ref) => {
|
||||||
@ -9,13 +10,13 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
|||||||
<input
|
<input
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"bg-zinc-700 rounded-md box-border focus:outline outline-violet-400 text-sm",
|
"bg-zinc-700 rounded-md box-border focus:outline outline-violet-400 text-sm",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...rest}
|
{...rest}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
}
|
},
|
||||||
);
|
)
|
||||||
|
|
||||||
export default Input;
|
export default Input
|
11
client/components/common/Logo.tsx
Normal file
11
client/components/common/Logo.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { FC } from "react"
|
||||||
|
|
||||||
|
interface LogoProps {
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const Logo: FC<LogoProps> = ({ className }) => {
|
||||||
|
return <img src="./assets/images/logo.png" className={className} alt="logo" />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Logo
|
@ -1,24 +1,25 @@
|
|||||||
import { FC, forwardRef, ReactNode } from "react";
|
/* eslint-disable react/display-name */
|
||||||
import Input from "./Input";
|
import { forwardRef, ReactNode } from "react"
|
||||||
|
|
||||||
|
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,27 +1,29 @@
|
|||||||
import { FC } from "react";
|
import { Dialog, Tab } from "@headlessui/react"
|
||||||
import { Dialog } from "@headlessui/react";
|
import { FC } from "react"
|
||||||
import { createPortal } from "react-dom";
|
import Logo from "../common/Logo"
|
||||||
import { Tab } from "@headlessui/react";
|
|
||||||
import logo from "../assets/images/logo.png";
|
import LoginForm from "./LoginForm"
|
||||||
import LoginForm from "./LoginForm";
|
import LoginModalTab from "./LoginModalTab"
|
||||||
import LoginModalTab from "./LoginModalTab";
|
import SignupForm from "./SignupForm"
|
||||||
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" /> Log in to
|
<Logo className="inline w-12 h-12" /> Log in to twitch-clone
|
||||||
twitch-clone
|
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
</div>
|
</div>
|
||||||
<Tab.Group defaultIndex={defaultPage}>
|
<Tab.Group defaultIndex={defaultPage}>
|
||||||
@ -48,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
|
@ -1,7 +1,7 @@
|
|||||||
import { FC } from "react";
|
import clsx from 'clsx';
|
||||||
import clsx from "clsx";
|
import { FC } from 'react';
|
||||||
|
|
||||||
interface LoginModalTabProps extends React.ComponentPropsWithoutRef<"p"> {
|
interface LoginModalTabProps extends React.ComponentPropsWithoutRef<'p'> {
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -9,8 +9,8 @@ const LoginModalTab: FC<LoginModalTabProps> = ({ selected, ...rest }) => {
|
|||||||
return (
|
return (
|
||||||
<p
|
<p
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"font-semibold p-1",
|
'font-semibold p-1',
|
||||||
selected && "text-violet-400 border-b-2 border-b-violet-400"
|
selected && 'text-violet-400 border-b-2 border-b-violet-400'
|
||||||
)}
|
)}
|
||||||
{...rest}
|
{...rest}
|
||||||
/>
|
/>
|
97
client/components/login/SignupForm.tsx
Normal file
97
client/components/login/SignupForm.tsx
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { FC } from "react"
|
||||||
|
import { SubmitHandler, useForm } from "react-hook-form"
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
import FormField from "../common/form/FormField"
|
||||||
|
import InlineLink from "../common/InlineLink"
|
||||||
|
import Input from "../common/Input"
|
||||||
|
import SubmitButton from "../common/form/SubmitButton"
|
||||||
|
import useSignUpFlow from "../../hooks/useSignUpFlow"
|
||||||
|
import * as validation from "../../config/validation"
|
||||||
|
|
||||||
|
const SignupFormSchema = z
|
||||||
|
.object({
|
||||||
|
csrfToken: z.string(),
|
||||||
|
username: validation.username,
|
||||||
|
password: validation.password,
|
||||||
|
passwordRepeat: z.string().trim(),
|
||||||
|
email: z.string().email({ message: "Not a valid email address" }).trim(),
|
||||||
|
})
|
||||||
|
.refine((data) => data.password === data.passwordRepeat, {
|
||||||
|
message: "Passwords do not match.",
|
||||||
|
path: ["passwordRepeat"],
|
||||||
|
})
|
||||||
|
|
||||||
|
type SignupFormValues = z.infer<typeof SignupFormSchema>
|
||||||
|
|
||||||
|
const formFields = [
|
||||||
|
{ id: "username", label: "Username", type: "text" },
|
||||||
|
{ id: "password", label: "Password", type: "password" },
|
||||||
|
{ id: "passwordRepeat", label: "Confirm Password", type: "password" },
|
||||||
|
{ id: "email", label: "Email", type: "email" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const SignupForm: FC = () => {
|
||||||
|
const signUpFlow = useSignUpFlow()
|
||||||
|
const { register, handleSubmit, formState } = useForm<SignupFormValues>({
|
||||||
|
resolver: zodResolver(SignupFormSchema),
|
||||||
|
})
|
||||||
|
|
||||||
|
const onSubmit: SubmitHandler<SignupFormValues> = async (data) => {
|
||||||
|
await signUpFlow.submitData({
|
||||||
|
csrf_token: data.csrfToken,
|
||||||
|
method: "password",
|
||||||
|
password: data.password,
|
||||||
|
traits: {
|
||||||
|
email: data.email,
|
||||||
|
username: data.username,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<p className="text-sm">
|
||||||
|
Creating an account allows you to participate in chat, follow your
|
||||||
|
favorite channels, and broadcast from your own channel.
|
||||||
|
</p>
|
||||||
|
{signUpFlow.flow && (
|
||||||
|
<Input
|
||||||
|
hidden={true}
|
||||||
|
value={signUpFlow.flow.ui.nodes[0].attributes.value}
|
||||||
|
{...register("csrfToken")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{formFields.map((field) => (
|
||||||
|
<FormField
|
||||||
|
key={field.id}
|
||||||
|
id={field.id}
|
||||||
|
type={field.type}
|
||||||
|
label={field.label}
|
||||||
|
{...register(field.id as any)}
|
||||||
|
className="py-2 px-2 outline-2 w-full"
|
||||||
|
bottomElement={
|
||||||
|
<p className="text-xs">
|
||||||
|
{formState.errors[(field.id as any) || ""]?.message}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<p className="text-sm text-center">
|
||||||
|
By clicking Sign Up, you are agreeing to twitch-clone's{" "}
|
||||||
|
<InlineLink to="https://tosdr.org/en/service/200">
|
||||||
|
Terms of Service
|
||||||
|
</InlineLink>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
<SubmitButton
|
||||||
|
disabled={!signUpFlow.flow}
|
||||||
|
className="w-full"
|
||||||
|
value="Sign Up"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SignupForm
|
21
client/components/message/ChatMessage.tsx
Normal file
21
client/components/message/ChatMessage.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { FC } from "react"
|
||||||
|
import { ChatMessage } from "../../types"
|
||||||
|
|
||||||
|
export interface ChatMessageProps {
|
||||||
|
message: ChatMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChatMessage: FC<ChatMessageProps> = ({
|
||||||
|
message: { fromUser, content },
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="mx-2 p-[0.4rem] hover:bg-neutral-700 text-xs rounded-md">
|
||||||
|
<div className="space-x-1 inline">
|
||||||
|
<span className="align-middle">{fromUser}: </span>
|
||||||
|
</div>
|
||||||
|
<span className="break-all align-middle">{content}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChatMessage
|
93
client/components/nav/NavBar.tsx
Normal file
93
client/components/nav/NavBar.tsx
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import {
|
||||||
|
ArrowRightIcon,
|
||||||
|
ArrowRightOnRectangleIcon,
|
||||||
|
UserIcon,
|
||||||
|
} from "@heroicons/react/24/outline"
|
||||||
|
import { FC, useState } from "react"
|
||||||
|
import { useLogout } from "../../hooks/useLogout"
|
||||||
|
import useSession from "../../hooks/useSession"
|
||||||
|
|
||||||
|
import Button from "../common/Button"
|
||||||
|
import Logo from "../common/Logo"
|
||||||
|
import LoginModal, { LoginModelProps } from "../login/LoginModal"
|
||||||
|
|
||||||
|
const NavBar: FC = () => {
|
||||||
|
const logout = useLogout()
|
||||||
|
const session = useSession((state) => state.session)
|
||||||
|
const [modalProps, setModalProps] = useState<LoginModelProps>({
|
||||||
|
isOpen: false,
|
||||||
|
defaultPage: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const showLoginTab = () =>
|
||||||
|
setModalProps({
|
||||||
|
defaultPage: 0,
|
||||||
|
isOpen: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const showSignupTab = () =>
|
||||||
|
setModalProps({
|
||||||
|
defaultPage: 1,
|
||||||
|
isOpen: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log({ session })
|
||||||
|
return (
|
||||||
|
<nav className="bg-zinc-800 w-screen font-semibold border-b border-b-black">
|
||||||
|
<div className="flex flex-row justify-between items-center h-12 mx-2">
|
||||||
|
<div>
|
||||||
|
<ul className="flex flex-row space-x-8 items-center">
|
||||||
|
<li>
|
||||||
|
<Logo className="w-8 h-8" />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<ul className="justify-end flex flex-row space-x-3 items-center">
|
||||||
|
{session ? (
|
||||||
|
<>
|
||||||
|
<li>
|
||||||
|
<UserIcon className="h-5 w-5 inline-block" />
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
className="p-[0.4rem]"
|
||||||
|
onClick={logout}
|
||||||
|
>
|
||||||
|
<ArrowRightOnRectangleIcon className="h-5 w-5 inline-block" />
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<li>
|
||||||
|
<Button
|
||||||
|
className="text-sm px-3 py-2 bg-neutral-700"
|
||||||
|
onClick={showLoginTab}
|
||||||
|
>
|
||||||
|
Log In
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Button
|
||||||
|
className="text-sm px-3 py-2 bg-violet-500"
|
||||||
|
onClick={showSignupTab}
|
||||||
|
>
|
||||||
|
Sign Up
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<LoginModal
|
||||||
|
{...modalProps}
|
||||||
|
onClose={() => setModalProps((old) => ({ ...old, isOpen: false }))}
|
||||||
|
/>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NavBar
|
3
client/config/index.ts
Normal file
3
client/config/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export const KRATOS_URL = "http://127.0.0.1:4433"
|
||||||
|
export const CHAT_URL = "ws://localhost:1323"
|
||||||
|
export const MAX_CHAT_MESSAGES = 50
|
18
client/config/validation.ts
Normal file
18
client/config/validation.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
export const PASSWORD_REGEX =
|
||||||
|
/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$ %^&*-]).{8,64}$/
|
||||||
|
|
||||||
|
export const username = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(3, { message: "Username must be at least 3 character long." })
|
||||||
|
.max(16, { message: "Username can't be longer than 16 characters.." })
|
||||||
|
|
||||||
|
export const password = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.regex(
|
||||||
|
PASSWORD_REGEX,
|
||||||
|
"Password must be 8-64 long and must contain a number, uppercase, lowercase and special character.",
|
||||||
|
)
|
@ -1,16 +0,0 @@
|
|||||||
import { defineConfig } from "cypress";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
component: {
|
|
||||||
devServer: {
|
|
||||||
framework: "react",
|
|
||||||
bundler: "vite",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
e2e: {
|
|
||||||
setupNodeEvents(on, config) {
|
|
||||||
// implement node event listeners here
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
3
client/cypress.json
Normal file
3
client/cypress.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"baseUrl": "http://localhost:3000"
|
||||||
|
}
|
64
client/cypress/integration/pages.spec.js
Normal file
64
client/cypress/integration/pages.spec.js
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
const randomString = () => (Math.random() + 1).toString(36).substring(7)
|
||||||
|
const randomPassword = () => randomString() + randomString()
|
||||||
|
const randomEmail = () => randomString() + '@' + randomString() + '.com'
|
||||||
|
|
||||||
|
const login = (email, password) => {
|
||||||
|
cy.visit('/api/.ory/ui/login')
|
||||||
|
cy.get('[name="identifier"]').type(email)
|
||||||
|
cy.get('[name="password"]').type(password)
|
||||||
|
cy.get('[name="method"]').click()
|
||||||
|
loggedIn(email)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loggedIn = (email) => {
|
||||||
|
cy.location('pathname').should('eq', '/')
|
||||||
|
cy.get('[data-testid="session-content"]').should('contain.text', email)
|
||||||
|
cy.get('[data-testid="logout"]').should('have.attr', 'aria-disabled', 'false')
|
||||||
|
}
|
||||||
|
|
||||||
|
context('Basic UI interactions', () => {
|
||||||
|
const email = randomEmail()
|
||||||
|
const password = randomPassword()
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.clearCookies({ domain: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can load the start page', () => {
|
||||||
|
cy.visit('/')
|
||||||
|
cy.get('a[href="/api/.ory/self-service/login/browser"]').should('exist')
|
||||||
|
cy.get('a[href="/api/.ory/self-service/registration/browser"]').should(
|
||||||
|
'exist'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirects to login when accessing settings without session', () => {
|
||||||
|
cy.visit('/api/.ory/ui/settings')
|
||||||
|
cy.location('pathname').should('contain', 'api/.ory/ui/login')
|
||||||
|
cy.get('[name="method"]').should('exist')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can submit registration', () => {
|
||||||
|
cy.visit('/api/.ory/ui/registration')
|
||||||
|
cy.get('[name="traits.email"]').type(email)
|
||||||
|
cy.get('[name="password"]').type(password)
|
||||||
|
cy.get('[name="method"]').click()
|
||||||
|
loggedIn(email)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can load the login page', () => {
|
||||||
|
login(email, password)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('goes to registration and clicks on log in and redirect works', () => {
|
||||||
|
cy.visit('/api/.ory/ui/registration')
|
||||||
|
cy.get('[data-testid="cta-link"]').click()
|
||||||
|
login(email, password)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can log out', () => {
|
||||||
|
login(email, password)
|
||||||
|
cy.get('a[data-testid="logout"]').click()
|
||||||
|
cy.get('[data-testid="logout"]').should('not.exist')
|
||||||
|
})
|
||||||
|
})
|
22
client/cypress/plugins/index.js
Normal file
22
client/cypress/plugins/index.js
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
/// <reference types="cypress" />
|
||||||
|
// ***********************************************************
|
||||||
|
// This example plugins/index.js can be used to load plugins
|
||||||
|
//
|
||||||
|
// You can change the location of this file or turn off loading
|
||||||
|
// the plugins file with the 'pluginsFile' configuration option.
|
||||||
|
//
|
||||||
|
// You can read more here:
|
||||||
|
// https://on.cypress.io/plugins-guide
|
||||||
|
// ***********************************************************
|
||||||
|
|
||||||
|
// This function is called when a project is opened or re-opened (e.g. due to
|
||||||
|
// the project's config changing)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {Cypress.PluginConfig}
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
module.exports = (on, config) => {
|
||||||
|
// `on` is used to hook into various events Cypress emits
|
||||||
|
// `config` is the resolved Cypress config
|
||||||
|
}
|
25
client/cypress/support/commands.js
Normal file
25
client/cypress/support/commands.js
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
// ***********************************************
|
||||||
|
// This example commands.js shows you how to
|
||||||
|
// create various custom commands and overwrite
|
||||||
|
// existing commands.
|
||||||
|
//
|
||||||
|
// For more comprehensive examples of custom
|
||||||
|
// commands please read more here:
|
||||||
|
// https://on.cypress.io/custom-commands
|
||||||
|
// ***********************************************
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// -- This is a parent command --
|
||||||
|
// Cypress.Commands.add('login', (email, password) => { ... })
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// -- This is a child command --
|
||||||
|
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// -- This is a dual command --
|
||||||
|
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// -- This will overwrite an existing command --
|
||||||
|
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
@ -14,12 +14,12 @@
|
|||||||
// ***********************************************************
|
// ***********************************************************
|
||||||
|
|
||||||
// Import commands.js using ES2015 syntax:
|
// Import commands.js using ES2015 syntax:
|
||||||
import './commands'
|
import './commands';
|
||||||
|
|
||||||
// Alternatively you can use CommonJS syntax:
|
// Alternatively you can use CommonJS syntax:
|
||||||
// require('./commands')
|
// require('./commands')
|
||||||
|
|
||||||
import { mount } from 'cypress/react18'
|
import { mount } from 'cypress/react18';
|
||||||
|
|
||||||
// Augment the Cypress namespace to include type definitions for
|
// Augment the Cypress namespace to include type definitions for
|
||||||
// your custom command.
|
// your custom command.
|
||||||
@ -28,12 +28,12 @@ import { mount } from 'cypress/react18'
|
|||||||
declare global {
|
declare global {
|
||||||
namespace Cypress {
|
namespace Cypress {
|
||||||
interface Chainable {
|
interface Chainable {
|
||||||
mount: typeof mount
|
mount: typeof mount;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Cypress.Commands.add('mount', mount)
|
Cypress.Commands.add('mount', mount);
|
||||||
|
|
||||||
// Example use:
|
// Example use:
|
||||||
// cy.mount(<MyComponent />)
|
// cy.mount(<MyComponent />)
|
||||||
|
@ -14,7 +14,7 @@
|
|||||||
// ***********************************************************
|
// ***********************************************************
|
||||||
|
|
||||||
// Import commands.js using ES2015 syntax:
|
// Import commands.js using ES2015 syntax:
|
||||||
import "./commands";
|
import './commands';
|
||||||
|
|
||||||
// Alternatively you can use CommonJS syntax:
|
// Alternatively you can use CommonJS syntax:
|
||||||
// require('./commands')
|
// require('./commands')
|
||||||
|
19
client/cypress/support/index.js
Normal file
19
client/cypress/support/index.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
// ***********************************************************
|
||||||
|
// This example support/index.js is processed and
|
||||||
|
// loaded automatically before your test files.
|
||||||
|
//
|
||||||
|
// This is a great place to put global configuration and
|
||||||
|
// behavior that modifies Cypress.
|
||||||
|
//
|
||||||
|
// You can change the location of this file or turn off
|
||||||
|
// automatically serving support files with the
|
||||||
|
// 'supportFile' configuration option.
|
||||||
|
//
|
||||||
|
// You can read more here:
|
||||||
|
// https://on.cypress.io/configuration
|
||||||
|
// ***********************************************************
|
||||||
|
// Import commands.js using ES2015 syntax:
|
||||||
|
import './commands'
|
||||||
|
|
||||||
|
// Alternatively you can use CommonJS syntax:
|
||||||
|
// require('./commands')
|
@ -3,4 +3,4 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"isolatedModules": false
|
"isolatedModules": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
53
client/hooks/useLogInFlow.ts
Normal file
53
client/hooks/useLogInFlow.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import {
|
||||||
|
SelfServiceRegistrationFlow,
|
||||||
|
SubmitSelfServiceLoginFlowBody,
|
||||||
|
} from "@ory/client"
|
||||||
|
import { useRouter } from "next/router"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import ory from "../services/ory"
|
||||||
|
import useSession from "./useSession"
|
||||||
|
|
||||||
|
const useLogInFlow = () => {
|
||||||
|
const router = useRouter()
|
||||||
|
const [flow, setFlow] = useState<SelfServiceRegistrationFlow>()
|
||||||
|
const { flow: flowId } = router.query
|
||||||
|
const updateSession = useSession((state) => state.update)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const func = async () => {
|
||||||
|
if (!router.isReady || flow) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let serviceFlow
|
||||||
|
if (flowId) {
|
||||||
|
serviceFlow = await ory.getSelfServiceLoginFlow(String(flowId))
|
||||||
|
} else {
|
||||||
|
serviceFlow = await ory.initializeSelfServiceLoginFlowForBrowsers()
|
||||||
|
}
|
||||||
|
|
||||||
|
setFlow(serviceFlow.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func()
|
||||||
|
}, [flowId, router, router.isReady, flow])
|
||||||
|
|
||||||
|
const submitData = async (data: SubmitSelfServiceLoginFlowBody) => {
|
||||||
|
await router.push(`/login?flow=${flow?.id}`, undefined, {
|
||||||
|
shallow: true,
|
||||||
|
})
|
||||||
|
ory
|
||||||
|
.submitSelfServiceLoginFlow(String(flow?.id), undefined, data)
|
||||||
|
.then(async ({ data }) => {
|
||||||
|
updateSession(data.session)
|
||||||
|
await router.push(flow?.return_to || "/")
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.log({ err })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { flow, submitData }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useLogInFlow
|
38
client/hooks/useLogout.ts
Normal file
38
client/hooks/useLogout.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { AxiosError } from "axios"
|
||||||
|
import { useRouter } from "next/router"
|
||||||
|
import { useState, useEffect, DependencyList } from "react"
|
||||||
|
|
||||||
|
import ory from "../services/ory"
|
||||||
|
import useSession from "./useSession"
|
||||||
|
|
||||||
|
export function useLogout(deps?: DependencyList) {
|
||||||
|
const session = useSession()
|
||||||
|
const [logoutToken, setLogoutToken] = useState<string>("")
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
ory
|
||||||
|
.createSelfServiceLogoutFlowUrlForBrowsers()
|
||||||
|
.then(({ data }) => {
|
||||||
|
setLogoutToken(data.logout_token)
|
||||||
|
})
|
||||||
|
.catch((err: AxiosError) => {
|
||||||
|
switch (err.response?.status) {
|
||||||
|
case 401:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(err)
|
||||||
|
})
|
||||||
|
}, deps)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (logoutToken) {
|
||||||
|
session.drop()
|
||||||
|
ory
|
||||||
|
.submitSelfServiceLogoutFlow(logoutToken)
|
||||||
|
.then(() => router.push("/"))
|
||||||
|
.then(() => router.reload())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
31
client/hooks/useSession.ts
Normal file
31
client/hooks/useSession.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import create from "zustand"
|
||||||
|
|
||||||
|
import { Session } from "@ory/client"
|
||||||
|
import ory from "../services/ory"
|
||||||
|
import { AxiosError } from "axios"
|
||||||
|
|
||||||
|
export interface SessionState {
|
||||||
|
session?: Session
|
||||||
|
|
||||||
|
load: () => void
|
||||||
|
update: (data: Session) => void
|
||||||
|
drop: () => void
|
||||||
|
}
|
||||||
|
const useSession = create<SessionState>((set) => ({
|
||||||
|
session: undefined,
|
||||||
|
|
||||||
|
load: () => {
|
||||||
|
ory
|
||||||
|
.toSession()
|
||||||
|
.then(({ data }) => {
|
||||||
|
set({ session: data })
|
||||||
|
})
|
||||||
|
.catch((err: AxiosError) => {})
|
||||||
|
},
|
||||||
|
update: (session) => set({ session }),
|
||||||
|
drop: () => {
|
||||||
|
set({ session: undefined })
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
export default useSession
|
55
client/hooks/useSignUpFlow.ts
Normal file
55
client/hooks/useSignUpFlow.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import {
|
||||||
|
SelfServiceRegistrationFlow,
|
||||||
|
Session,
|
||||||
|
SubmitSelfServiceRegistrationFlowBody,
|
||||||
|
} from "@ory/client"
|
||||||
|
import { useRouter } from "next/router"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import ory from "../services/ory"
|
||||||
|
import useSession from "./useSession"
|
||||||
|
|
||||||
|
export const useSignUpFlow = () => {
|
||||||
|
const router = useRouter()
|
||||||
|
const [flow, setFlow] = useState<SelfServiceRegistrationFlow>()
|
||||||
|
const { flow: flowId } = router.query
|
||||||
|
const updateSession = useSession((state) => state.update)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const func = async () => {
|
||||||
|
if (!router.isReady || flow) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let serviceFlow
|
||||||
|
if (flowId) {
|
||||||
|
serviceFlow = await ory.getSelfServiceRegistrationFlow(String(flowId))
|
||||||
|
} else {
|
||||||
|
serviceFlow =
|
||||||
|
await ory.initializeSelfServiceRegistrationFlowForBrowsers()
|
||||||
|
}
|
||||||
|
|
||||||
|
setFlow(serviceFlow.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func()
|
||||||
|
}, [flowId, router, router.isReady, flow])
|
||||||
|
|
||||||
|
const submitData = async (data: SubmitSelfServiceRegistrationFlowBody) => {
|
||||||
|
await router.push(`/signup?flow=${flow?.id}`, undefined, {
|
||||||
|
shallow: true,
|
||||||
|
})
|
||||||
|
ory
|
||||||
|
.submitSelfServiceRegistrationFlow(String(flow?.id), data)
|
||||||
|
.then(async ({ data }) => {
|
||||||
|
updateSession(data.session as Session)
|
||||||
|
await router.push(flow?.return_to || "/")
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.log({ err })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { flow, submitData }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useSignUpFlow
|
@ -1,13 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Vite + React + TS</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
5
client/next-env.d.ts
vendored
Normal file
5
client/next-env.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
36
client/next.config.js
Normal file
36
client/next.config.js
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
// This file sets a custom webpack configuration to use your Next.js app
|
||||||
|
// with Sentry.
|
||||||
|
// https://nextjs.org/docs/api-reference/next.config.js/introduction
|
||||||
|
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||||
|
|
||||||
|
const { withSentryConfig } = require("@sentry/nextjs")
|
||||||
|
|
||||||
|
const moduleExports = {
|
||||||
|
// Your existing module.exports
|
||||||
|
reactStrictMode: true,
|
||||||
|
sentry: {
|
||||||
|
// Use `hidden-source-map` rather than `source-map` as the Webpack `devtool`
|
||||||
|
// for client-side builds. (This will be the default starting in
|
||||||
|
// `@sentry/nextjs` version 8.0.0.) See
|
||||||
|
// https://webpack.js.org/configuration/devtool/ and
|
||||||
|
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#use-hidden-source-map
|
||||||
|
// for more information.
|
||||||
|
hideSourceMaps: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const sentryWebpackPluginOptions = {
|
||||||
|
// Additional config options for the Sentry Webpack plugin. Keep in mind that
|
||||||
|
// the following options are set automatically, and overriding them is not
|
||||||
|
// recommended:
|
||||||
|
// release, url, org, project, authToken, configFile, stripPrefix,
|
||||||
|
// urlPrefix, include, ignore
|
||||||
|
|
||||||
|
silent: true, // Suppresses all logs
|
||||||
|
// For all available options, see:
|
||||||
|
// https://github.com/getsentry/sentry-webpack-plugin#options.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure adding Sentry options is the last code to run before exporting, to
|
||||||
|
// ensure that your source maps include changes from all other Webpack plugins
|
||||||
|
module.exports = withSentryConfig(moduleExports, sentryWebpackPluginOptions)
|
@ -1,40 +1,51 @@
|
|||||||
{
|
{
|
||||||
"name": "client",
|
"name": "kratos-nextjs-react-example",
|
||||||
|
"version": "0.1.0",
|
||||||
"private": true,
|
"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",
|
||||||
"cypress": "cypress"
|
"lint": "next lint",
|
||||||
|
"format": "prettier --write ${npm_package_config_prettierTarget}",
|
||||||
|
"format:check": "prettier --check ${npm_package_config_prettierTarget}",
|
||||||
|
"test": "cypress run",
|
||||||
|
"test:dev": "cypress open"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
||||||
"@tanstack/react-query": "^4.7.2",
|
"@ory/client": "0.0.1-alpha.169",
|
||||||
"axios": "^0.27.2",
|
"@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",
|
||||||
"@vitejs/plugin-react": "^2.1.0",
|
"@types/node": "16.11.6",
|
||||||
|
"@types/react": "17.0.33",
|
||||||
"autoprefixer": "^10.4.12",
|
"autoprefixer": "^10.4.12",
|
||||||
"cypress": "^10.9.0",
|
"cypress": "^9.6.0",
|
||||||
"postcss": "^8.4.16",
|
"eslint": "7.32.0",
|
||||||
"prettier": "^2.7.1",
|
"eslint-config-next": "12.0.1",
|
||||||
|
"ory-prettier-styles": "^1.3.0",
|
||||||
|
"postcss": "^8.4.18",
|
||||||
|
"prettier": "^2.3.2",
|
||||||
"tailwindcss": "^3.1.8",
|
"tailwindcss": "^3.1.8",
|
||||||
"typescript": "^4.6.4",
|
"typescript": "4.4.4"
|
||||||
"vite": "^3.1.0"
|
}
|
||||||
},
|
}
|
||||||
"proxy": "http://localhost:5000"
|
|
||||||
}
|
|
||||||
|
28
client/pages/[channel]/index.tsx
Normal file
28
client/pages/[channel]/index.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import ChatMessage from "../../components/message/ChatMessage"
|
||||||
|
import Input from "../../components/common/Input"
|
||||||
|
import { NextPage } from "next"
|
||||||
|
import BrowseLayout from "../../components/layout/BrowseLayout"
|
||||||
|
import { createRandomMessage } from "../../placeholder/chatMessages"
|
||||||
|
import Chat from "../../components/chat"
|
||||||
|
|
||||||
|
const ChannelPage: NextPage = () => {
|
||||||
|
return (
|
||||||
|
<BrowseLayout>
|
||||||
|
<div className="flex-1 flex flex-row">
|
||||||
|
<div className="bg-neutral-900 flex-1 flex flex-col">
|
||||||
|
<div className="w-full bg-red-200 flex-1" />
|
||||||
|
<div className="flex flex-row p-2 items-center justify-between">
|
||||||
|
<div className="flex flex-row items-center space-x-3">
|
||||||
|
<span className="w-8 h-8 bg-yellow-300 rounded-full" />
|
||||||
|
<span className="font-bold">niku</span>
|
||||||
|
</div>
|
||||||
|
<div>1:14:32</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Chat />
|
||||||
|
</div>
|
||||||
|
</BrowseLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChannelPage
|
15
client/pages/_app.tsx
Normal file
15
client/pages/_app.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import "../styles/globals.css"
|
||||||
|
import type { AppProps } from "next/app"
|
||||||
|
import useSession from "../hooks/useSession"
|
||||||
|
import { useEffect } from "react"
|
||||||
|
|
||||||
|
function MyApp({ Component, pageProps }: AppProps) {
|
||||||
|
const loadSession = useSession((state) => state.load)
|
||||||
|
useEffect(() => {
|
||||||
|
loadSession()
|
||||||
|
}, [loadSession])
|
||||||
|
|
||||||
|
return <Component {...pageProps} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MyApp
|
39
client/pages/_error.js
Normal file
39
client/pages/_error.js
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* NOTE: This requires `@sentry/nextjs` version 7.3.0 or higher.
|
||||||
|
*
|
||||||
|
* NOTE: If using this with `next` version 12.2.0 or lower, uncomment the
|
||||||
|
* penultimate line in `CustomErrorComponent`.
|
||||||
|
*
|
||||||
|
* This page is loaded by Nextjs:
|
||||||
|
* - on the server, when data-fetching methods throw or reject
|
||||||
|
* - on the client, when `getInitialProps` throws or rejects
|
||||||
|
* - on the client, when a React lifecycle method throws or rejects, and it's
|
||||||
|
* caught by the built-in Nextjs error boundary
|
||||||
|
*
|
||||||
|
* See:
|
||||||
|
* - https://nextjs.org/docs/basic-features/data-fetching/overview
|
||||||
|
* - https://nextjs.org/docs/api-reference/data-fetching/get-initial-props
|
||||||
|
* - https://reactjs.org/docs/error-boundaries.html
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as Sentry from "@sentry/nextjs"
|
||||||
|
import NextErrorComponent from "next/error"
|
||||||
|
|
||||||
|
const CustomErrorComponent = (props) => {
|
||||||
|
// If you're using a Nextjs version prior to 12.2.1, uncomment this to
|
||||||
|
// compensate for https://github.com/vercel/next.js/issues/8592
|
||||||
|
// Sentry.captureUnderscoreErrorException(props);
|
||||||
|
|
||||||
|
return <NextErrorComponent statusCode={props.statusCode} />
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomErrorComponent.getInitialProps = async (contextData) => {
|
||||||
|
// In case this is running in a serverless function, await this in order to give Sentry
|
||||||
|
// time to send the error before the lambda exits
|
||||||
|
await Sentry.captureUnderscoreErrorException(contextData)
|
||||||
|
|
||||||
|
// This will contain the status code of the response
|
||||||
|
return NextErrorComponent.getInitialProps(contextData)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CustomErrorComponent
|
13
client/pages/api/.ory/[...paths].ts
Normal file
13
client/pages/api/.ory/[...paths].ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
// @ory/integrations offers a package for integrating with NextJS.
|
||||||
|
import { config, createApiHandler } from "@ory/integrations/next-edge"
|
||||||
|
|
||||||
|
// We need to export the config.
|
||||||
|
export { config }
|
||||||
|
|
||||||
|
// And create the Ory Cloud API "bridge".
|
||||||
|
export default createApiHandler({
|
||||||
|
fallbackToPlayground: true,
|
||||||
|
// Because vercel.app is a public suffix and setting cookies for
|
||||||
|
// vercel.app is not possible.
|
||||||
|
dontUseTldForCookieDomain: true,
|
||||||
|
})
|
9
client/pages/index.tsx
Normal file
9
client/pages/index.tsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { NextPage } from "next"
|
||||||
|
import useSession from "../hooks/useSession"
|
||||||
|
|
||||||
|
const IndexPage: NextPage = () => {
|
||||||
|
const session = useSession()
|
||||||
|
return <div>{JSON.stringify(session.session || {})}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default IndexPage
|
13
client/pages/login.tsx
Normal file
13
client/pages/login.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { NextPage } from "next"
|
||||||
|
|
||||||
|
import LoginModal from "../components/login/LoginModal"
|
||||||
|
|
||||||
|
const LoginPage: NextPage = () => {
|
||||||
|
return (
|
||||||
|
<div className="bg-neutral-900 w-screen h-screen">
|
||||||
|
<LoginModal isOpen={true} defaultPage={0} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoginPage
|
12
client/pages/signup.tsx
Normal file
12
client/pages/signup.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { NextPage } from "next"
|
||||||
|
import LoginModal from "../components/login/LoginModal"
|
||||||
|
|
||||||
|
const SignupPage: NextPage = () => {
|
||||||
|
return (
|
||||||
|
<div className="bg-neutral-900 w-screen h-screen">
|
||||||
|
<LoginModal isOpen={true} defaultPage={1} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SignupPage
|
2877
client/pnpm-lock.yaml
generated
2877
client/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
BIN
client/public/assets/images/logo.png
Normal file
BIN
client/public/assets/images/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.0 KiB |
BIN
client/public/favicon.ico
Normal file
BIN
client/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
4
client/public/vercel.svg
Normal file
4
client/public/vercel.svg
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<svg width="283" height="64" viewBox="0 0 283 64" fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M141.04 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM248.72 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM200.24 34c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9V5h9zM36.95 0L73.9 64H0L36.95 0zm92.38 5l-27.71 48L73.91 5H84.3l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10V51h-9V17h9v9.2c0-5.08 5.91-9.2 13.2-9.2z" fill="#000"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
17
client/sentry.client.config.js
Normal file
17
client/sentry.client.config.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
// This file configures the initialization of Sentry on the browser.
|
||||||
|
// The config you add here will be used whenever a page is visited.
|
||||||
|
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||||
|
|
||||||
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
|
||||||
|
const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN;
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn: SENTRY_DSN || 'https://69c12baf84284b558f1aaea95321b6e5@o4504010426941440.ingest.sentry.io/4504010431397888',
|
||||||
|
// Adjust this value in production, or use tracesSampler for greater control
|
||||||
|
tracesSampleRate: 1.0,
|
||||||
|
// ...
|
||||||
|
// Note: if you want to override the automatic release value, do not set a
|
||||||
|
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
|
||||||
|
// that it will also get attached to your source maps
|
||||||
|
});
|
4
client/sentry.properties
Normal file
4
client/sentry.properties
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
defaults.url=https://sentry.io/
|
||||||
|
defaults.org=niku-94
|
||||||
|
defaults.project=twitch-clone-frontend
|
||||||
|
cli.executable=./node_modules/.bin/sentry-cli
|
17
client/sentry.server.config.js
Normal file
17
client/sentry.server.config.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
// This file configures the initialization of Sentry on the server.
|
||||||
|
// The config you add here will be used whenever the server handles a request.
|
||||||
|
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||||
|
|
||||||
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
|
||||||
|
const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN;
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn: SENTRY_DSN || 'https://69c12baf84284b558f1aaea95321b6e5@o4504010426941440.ingest.sentry.io/4504010431397888',
|
||||||
|
// Adjust this value in production, or use tracesSampler for greater control
|
||||||
|
tracesSampleRate: 1.0,
|
||||||
|
// ...
|
||||||
|
// Note: if you want to override the automatic release value, do not set a
|
||||||
|
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
|
||||||
|
// that it will also get attached to your source maps
|
||||||
|
});
|
4
client/services/ory/index.ts
Normal file
4
client/services/ory/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { Configuration, V0alpha2Api } from "@ory/client"
|
||||||
|
import { edgeConfig } from "@ory/integrations/next"
|
||||||
|
|
||||||
|
export default new V0alpha2Api(new Configuration(edgeConfig))
|
@ -1,22 +0,0 @@
|
|||||||
import { Routes, Route } from "react-router-dom";
|
|
||||||
import BrowseLayout from "./components/BrowseLayout";
|
|
||||||
import CategoryPage from "./pages/CategoryPage";
|
|
||||||
import ChannelPage from "./pages/ChannelPage";
|
|
||||||
import LoginPage from "./pages/LoginPage";
|
|
||||||
import SignupPage from "./pages/SignupPage";
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
return (
|
|
||||||
<Routes>
|
|
||||||
<Route path="/login" element={<LoginPage />} />
|
|
||||||
<Route path="/signup" element={<SignupPage />} />
|
|
||||||
<Route element={<BrowseLayout />}>
|
|
||||||
<Route path="/:channel" element={<ChannelPage />} />
|
|
||||||
<Route path="/category/:category" element={<CategoryPage />} />
|
|
||||||
<Route path="/" element={<h1>Hi</h1>} />
|
|
||||||
</Route>
|
|
||||||
</Routes>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
|
@ -1,37 +0,0 @@
|
|||||||
import { ArrowLeftIcon } from "@heroicons/react/24/outline";
|
|
||||||
import Button from "../components/Button";
|
|
||||||
import NavBar from "../components/NavBar";
|
|
||||||
import SideNavChannel from "../components/SideNavChannel";
|
|
||||||
import { Outlet } from "react-router-dom";
|
|
||||||
import streamData from "../placeholder/GetStreams";
|
|
||||||
import { NavLink } from "react-router-dom";
|
|
||||||
|
|
||||||
function BrowseLayout() {
|
|
||||||
return (
|
|
||||||
<div className="font-inter flex flex-col h-screen text-gray-100">
|
|
||||||
<NavBar />
|
|
||||||
<main className="flex-1 flex flex-row overflow-hidden">
|
|
||||||
<div className="bg-neutral-800 w-60 flex flex-col">
|
|
||||||
<div className="flex flex-row justify-between p-2 items-center">
|
|
||||||
<p className="uppercase font-semibold text-sm">Trending channels</p>
|
|
||||||
<Button variant="subtle" className="p-2">
|
|
||||||
<ArrowLeftIcon className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<ul className="flex-1 overflow-scrollbar">
|
|
||||||
{streamData.data.map((stream) => (
|
|
||||||
<li key={stream.id}>
|
|
||||||
<NavLink to={`/${stream.user_login}`}>
|
|
||||||
<SideNavChannel stream={stream} />
|
|
||||||
</NavLink>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<Outlet />
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default BrowseLayout;
|
|
@ -1,30 +0,0 @@
|
|||||||
import { FC } from "react";
|
|
||||||
import clsx from "clsx";
|
|
||||||
|
|
||||||
type ButtonVariants = "filled" | "subtle";
|
|
||||||
|
|
||||||
interface ButtonProps extends React.ComponentPropsWithoutRef<"button"> {
|
|
||||||
variant?: ButtonVariants;
|
|
||||||
}
|
|
||||||
|
|
||||||
const getStyling = (variant?: ButtonVariants) => {
|
|
||||||
switch (variant) {
|
|
||||||
case "filled":
|
|
||||||
return "bg-neutral-700";
|
|
||||||
case "subtle":
|
|
||||||
return "hover:bg-neutral-500";
|
|
||||||
default:
|
|
||||||
return "bg-neutral-700";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const Button: FC<ButtonProps> = ({ className, variant, ...rest }) => {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={clsx("rounded-md", getStyling(variant), className)}
|
|
||||||
{...rest}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Button;
|
|
@ -1,9 +0,0 @@
|
|||||||
import { FC } from "react";
|
|
||||||
|
|
||||||
const ChatBadge: FC = () => {
|
|
||||||
return (
|
|
||||||
<span className="w-5 h-5 rounded-sm bg-pink-300 inline-block align-middle" />
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ChatBadge;
|
|
@ -1,23 +0,0 @@
|
|||||||
import { FC } from "react";
|
|
||||||
import ChatBadge from "./ChatBadge";
|
|
||||||
|
|
||||||
const ChatMessage: FC = () => {
|
|
||||||
return (
|
|
||||||
<p className="mx-2 p-2 hover:bg-neutral-700 text-sm rounded-md">
|
|
||||||
<div className="space-x-1 inline">
|
|
||||||
<ChatBadge />
|
|
||||||
<ChatBadge />
|
|
||||||
<span className="align-middle">Username</span>
|
|
||||||
</div>
|
|
||||||
<span className="align-middle">: </span>
|
|
||||||
<span className="break-all align-middle">
|
|
||||||
<img
|
|
||||||
src="https://cdn.7tv.app/emote/60afbe0599923bbe7fe9bae1/2x"
|
|
||||||
className="inline w-7 h-7"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ChatMessage;
|
|
@ -1,38 +0,0 @@
|
|||||||
import { FC } from "react";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import { NavLink } from "react-router-dom";
|
|
||||||
|
|
||||||
export interface InlineLinkProps
|
|
||||||
extends React.ComponentPropsWithoutRef<"span"> {
|
|
||||||
to: string;
|
|
||||||
external?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const InlineLink: FC<InlineLinkProps> = ({
|
|
||||||
to,
|
|
||||||
external,
|
|
||||||
className,
|
|
||||||
...rest
|
|
||||||
}) => {
|
|
||||||
if (external === true) {
|
|
||||||
return (
|
|
||||||
<a href={to}>
|
|
||||||
<span
|
|
||||||
className={clsx("text-violet-400 cursor-pointer text-sm", className)}
|
|
||||||
{...rest}
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NavLink to={to}>
|
|
||||||
<span
|
|
||||||
className={clsx("text-violet-400 cursor-pointer text-sm", className)}
|
|
||||||
{...rest}
|
|
||||||
/>
|
|
||||||
</NavLink>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default InlineLink;
|
|
@ -1,41 +0,0 @@
|
|||||||
import { FC } from "react";
|
|
||||||
import { useForm, SubmitHandler } from "react-hook-form";
|
|
||||||
|
|
||||||
import FormField from "./FormField";
|
|
||||||
import InlineLink from "./InlineLink";
|
|
||||||
import SubmitButton from "./SubmitButton";
|
|
||||||
|
|
||||||
interface LoginFormValues {
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LoginForm: FC = () => {
|
|
||||||
const { register, handleSubmit } = useForm<LoginFormValues>();
|
|
||||||
const onSubmit: SubmitHandler<LoginFormValues> = (data) => console.log(data);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
|
||||||
<FormField
|
|
||||||
label="Username"
|
|
||||||
className="py-2 px-2 outline-2 w-full"
|
|
||||||
{...register("username")}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
label="Password"
|
|
||||||
type="password"
|
|
||||||
{...register("password")}
|
|
||||||
className="py-2 px-2 outline-2 w-full"
|
|
||||||
bottomElement={
|
|
||||||
<InlineLink to="#" className="block mt-2">
|
|
||||||
Trouble logging in?
|
|
||||||
</InlineLink>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<SubmitButton className="w-full" value="Log In" />
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default LoginForm;
|
|
@ -1,75 +0,0 @@
|
|||||||
import { TvIcon, UserIcon } from "@heroicons/react/24/outline";
|
|
||||||
import { FC, useState } from "react";
|
|
||||||
import Button from "./Button";
|
|
||||||
import Input from "./Input";
|
|
||||||
import LoginModal from "./LoginModal";
|
|
||||||
import logo from "../assets/images/logo.png";
|
|
||||||
|
|
||||||
const NavBar: FC = () => {
|
|
||||||
const [showLogin, setShowLogin] = useState(false);
|
|
||||||
const [showTab, setShowTab] = useState(0);
|
|
||||||
|
|
||||||
const showLoginTab = () => {
|
|
||||||
setShowTab(0);
|
|
||||||
setShowLogin(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const showSignupTab = () => {
|
|
||||||
setShowTab(1);
|
|
||||||
setShowLogin(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav className="bg-zinc-800 w-screen font-semibold border-b border-b-black">
|
|
||||||
<div className="flex flex-row justify-between items-center mx-2">
|
|
||||||
<div className="basis-1/4">
|
|
||||||
<ul className="flex flex-row space-x-8 items-center">
|
|
||||||
<li>
|
|
||||||
<img src={logo} className="w-8 h-8" />
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<p className="text-lg">Browse</p>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div className="basis-2/4">
|
|
||||||
<div className="flex flex-row space-x-3 items-center justify-center">
|
|
||||||
<Input className=" w-72 my-2 p-2" placeholder="Search" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="basis-1/4">
|
|
||||||
<ul className="justify-end flex flex-row space-x-3 items-center">
|
|
||||||
<li>
|
|
||||||
<Button
|
|
||||||
className="text-sm px-3 py-2 bg-neutral-700"
|
|
||||||
onClick={showLoginTab}
|
|
||||||
>
|
|
||||||
Log In
|
|
||||||
</Button>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<Button
|
|
||||||
className="text-sm px-3 py-2 bg-violet-500"
|
|
||||||
onClick={showSignupTab}
|
|
||||||
>
|
|
||||||
Sign Up
|
|
||||||
</Button>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<Button variant="subtle" className="p-1">
|
|
||||||
<UserIcon className="h-5 w-5 inline-block" />
|
|
||||||
</Button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<LoginModal
|
|
||||||
isOpen={showLogin}
|
|
||||||
defaultPage={showTab}
|
|
||||||
onClose={() => setShowLogin(false)}
|
|
||||||
/>
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NavBar;
|
|
@ -1,27 +0,0 @@
|
|||||||
import { FC } from "react";
|
|
||||||
import { numFormatter } from "../lib/format";
|
|
||||||
import { Stream } from "../types";
|
|
||||||
|
|
||||||
interface SideNavChannelProps {
|
|
||||||
stream: Stream;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SideNavChannel: FC<SideNavChannelProps> = ({ stream }) => {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-row px-3 py-2 text-sm leading-4 space-x-2 hover:bg-neutral-700/40 cursor-pointer">
|
|
||||||
<img className="rounded-full w-8 h-8" src={stream.thumbnail_url} />
|
|
||||||
<div className="flex flex-col flex-1">
|
|
||||||
<div className="flex flex-row justify-between">
|
|
||||||
<div className="font-bold">{stream.user_name}</div>
|
|
||||||
<div className="space-x-1 flex flex-row items-center">
|
|
||||||
<div className="w-2 h-2 bg-red-600 rounded-full inline-block" />
|
|
||||||
<span>{numFormatter.format(stream.viewer_count)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-gray-300">{stream.game_name}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SideNavChannel;
|
|
@ -1,95 +0,0 @@
|
|||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { FC } from "react";
|
|
||||||
import { SubmitHandler, useForm } from "react-hook-form";
|
|
||||||
import FormField from "./FormField";
|
|
||||||
import InlineLink from "./InlineLink";
|
|
||||||
import SubmitButton from "./SubmitButton";
|
|
||||||
import axios from "axios";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
|
|
||||||
const PASSWORD_REGEX =
|
|
||||||
/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$ %^&*-]).{8,64}$/;
|
|
||||||
|
|
||||||
const SignupFormSchema = z
|
|
||||||
.object({
|
|
||||||
username: z.string().trim().min(1).max(16),
|
|
||||||
password: z.string().trim().regex(PASSWORD_REGEX),
|
|
||||||
passwordRepeat: z.string().trim(),
|
|
||||||
email: z.string().email().trim(),
|
|
||||||
})
|
|
||||||
.refine((data) => data.password === data.passwordRepeat, {
|
|
||||||
message: "Passwords don't match",
|
|
||||||
path: ["passwordRepeat"],
|
|
||||||
});
|
|
||||||
|
|
||||||
type SignupFormValues = z.infer<typeof SignupFormSchema>;
|
|
||||||
|
|
||||||
const SignupForm: FC = () => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const signUp = useMutation(
|
|
||||||
({ username, password, email }: SignupFormValues) => {
|
|
||||||
return axios.post<{ access_token: string }>("/auth/signup", {
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
email,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess: (resp) => {
|
|
||||||
// TODO: store access token as HTTP-Only cookie
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const { register, handleSubmit } = useForm<SignupFormValues>({
|
|
||||||
resolver: zodResolver(SignupFormSchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSubmit: SubmitHandler<SignupFormValues> = (data) => {
|
|
||||||
signUp.mutate(data);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
|
||||||
<p className="text-sm">
|
|
||||||
Creating an account allows you to participate in chat, follow your
|
|
||||||
favorite channels, and broadcast from your own channel.
|
|
||||||
</p>
|
|
||||||
<FormField
|
|
||||||
label="Username"
|
|
||||||
{...register("username")}
|
|
||||||
className="py-2 px-2 outline-2 w-full"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
label="Password"
|
|
||||||
{...register("password")}
|
|
||||||
type="password"
|
|
||||||
className="py-2 px-2 outline-2 w-full"
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
label="Confirm Password"
|
|
||||||
{...register("passwordRepeat")}
|
|
||||||
type="password"
|
|
||||||
className="py-2 px-2 outline-2 w-full"
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
label="Email"
|
|
||||||
{...register("email")}
|
|
||||||
type="email"
|
|
||||||
className="py-2 px-2 outline-2 w-full"
|
|
||||||
/>
|
|
||||||
<p className="text-sm text-center">
|
|
||||||
By clicking Sign Up, you are agreeing to twitch-clone's{" "}
|
|
||||||
<InlineLink to="https://tosdr.org/en/service/200" external>
|
|
||||||
Terms of Service
|
|
||||||
</InlineLink>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
<SubmitButton className="w-full" value="Sign Up" />
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SignupForm;
|
|
@ -1,19 +0,0 @@
|
|||||||
import { FC } from "react";
|
|
||||||
import clsx from "clsx";
|
|
||||||
|
|
||||||
interface ButtonProps extends React.ComponentPropsWithoutRef<"input"> {}
|
|
||||||
|
|
||||||
const SubmitButton: FC<ButtonProps> = ({ className, ...rest }) => {
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
type="submit"
|
|
||||||
className={clsx(
|
|
||||||
"rounded-md bg-violet-500 font-semibold py-2 text-sm",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...rest}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SubmitButton;
|
|
@ -1 +0,0 @@
|
|||||||
export const numFormatter = Intl.NumberFormat("en", { notation: "compact" });
|
|
@ -1,18 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import ReactDOM from "react-dom/client";
|
|
||||||
import { BrowserRouter } from "react-router-dom";
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
||||||
import App from "./App";
|
|
||||||
import "./styles/global.css";
|
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<BrowserRouter>
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<App />
|
|
||||||
</QueryClientProvider>
|
|
||||||
</BrowserRouter>
|
|
||||||
</React.StrictMode>
|
|
||||||
);
|
|
@ -1,26 +0,0 @@
|
|||||||
import { categories } from "../placeholder/SearchCategories";
|
|
||||||
|
|
||||||
function ChannelPage() {
|
|
||||||
const category = categories.data[0];
|
|
||||||
return (
|
|
||||||
<div className="flex-1 flex flex-row">
|
|
||||||
<div className="bg-neutral-900 flex-1 text-gray-100">
|
|
||||||
<div className="max-w-[200rem] mx-12 mt-12">
|
|
||||||
<div className="flex flex-row items-center space-x-4">
|
|
||||||
<img src={category.box_art_url} />
|
|
||||||
<div className="">
|
|
||||||
<h1>{category.name}</h1>
|
|
||||||
<div>
|
|
||||||
<p>
|
|
||||||
<span>603K</span> Viewers * <span>20.8M</span> Followers
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ChannelPage;
|
|
@ -1,67 +0,0 @@
|
|||||||
import {
|
|
||||||
ArrowRightIcon,
|
|
||||||
HeartIcon,
|
|
||||||
UserIcon,
|
|
||||||
} from "@heroicons/react/24/outline";
|
|
||||||
import Button from "../components/Button";
|
|
||||||
import ChatMessage from "../components/ChatMessage";
|
|
||||||
import Input from "../components/Input";
|
|
||||||
import { numFormatter } from "../lib/format";
|
|
||||||
import streams from "../placeholder/GetStreams";
|
|
||||||
|
|
||||||
function ChannelPage() {
|
|
||||||
const stream = streams.data[1];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex-1 flex flex-row">
|
|
||||||
<div className="bg-neutral-900 flex-1">
|
|
||||||
<div className="w-full h-auto aspect-video bg-red-200 " />
|
|
||||||
<div className="flex flex-row p-4 space-x-3">
|
|
||||||
<div className="w-20 h-20 bg-yellow-300 rounded-full" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex flex-row justify-between items-center">
|
|
||||||
<div className="font-bold">{stream.user_name}</div>
|
|
||||||
<div>
|
|
||||||
<Button className="h-8 w-10">
|
|
||||||
<HeartIcon className="text-gray-100 h-5 w-5 mx-auto" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-row justify-between items-center">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="font-bold">{stream.title}</div>
|
|
||||||
<div className="text-violet-400">{stream.game_name}</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-row items-center text-sm space-x-3">
|
|
||||||
<span>
|
|
||||||
<UserIcon className="h-5 w-5 inline-block" />
|
|
||||||
<span>{numFormatter.format(stream.viewer_count)}</span>
|
|
||||||
</span>
|
|
||||||
<span>{stream.started_at}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-zinc-900 w-80 border-l border-l-zinc-700 flex flex-col">
|
|
||||||
<div className="flex flex-row justify-between items-center border-b border-b-zinc-700 p-2">
|
|
||||||
<Button variant="subtle" className="p-2">
|
|
||||||
<ArrowRightIcon className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<p className="uppercase font-semibold text-sm">Stream Chat</p>
|
|
||||||
<div className="w-5" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 overflow-scrollbar">
|
|
||||||
{new Array(60).fill(0).map((_, i) => (
|
|
||||||
<ChatMessage key={i} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="m-2">
|
|
||||||
<Input className="w-full p-2" placeholder="Send a message" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ChannelPage;
|
|
@ -1,12 +0,0 @@
|
|||||||
import { FC } from "react";
|
|
||||||
import LoginModal from "../components/LoginModal";
|
|
||||||
|
|
||||||
const LoginPage: FC = () => {
|
|
||||||
return (
|
|
||||||
<div className="bg-neutral-900 w-screen h-screen">
|
|
||||||
<LoginModal isOpen={true} defaultPage={0} onClose={() => {}} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default LoginPage;
|
|
@ -1,12 +0,0 @@
|
|||||||
import { FC } from "react";
|
|
||||||
import LoginModal from "../components/LoginModal";
|
|
||||||
|
|
||||||
const SignupPage: FC = () => {
|
|
||||||
return (
|
|
||||||
<div className="bg-neutral-900 w-screen h-screen">
|
|
||||||
<LoginModal isOpen={true} defaultPage={1} onClose={() => {}} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SignupPage;
|
|
@ -1,46 +0,0 @@
|
|||||||
import { FollowedStreams } from "../types";
|
|
||||||
|
|
||||||
const streams: FollowedStreams = {
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
id: "41375541868",
|
|
||||||
user_id: "459331509",
|
|
||||||
user_login: "auronplay",
|
|
||||||
user_name: "auronplay",
|
|
||||||
game_id: "494131",
|
|
||||||
game_name: "Little Nightmares",
|
|
||||||
type: "live",
|
|
||||||
title: "hablamos y le damos a Little Nightmares 1",
|
|
||||||
viewer_count: 78365,
|
|
||||||
started_at: "2021-03-10T15:04:21Z",
|
|
||||||
language: "es",
|
|
||||||
thumbnail_url:
|
|
||||||
"https://static-cdn.jtvnw.net/previews-ttv/live_user_auronplay-250x250.jpg",
|
|
||||||
tag_ids: [],
|
|
||||||
is_mature: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "41375541869",
|
|
||||||
user_id: "459331510",
|
|
||||||
user_login: "xqcow",
|
|
||||||
user_name: "xqcow",
|
|
||||||
game_id: "494131",
|
|
||||||
game_name: "Just Chatting",
|
|
||||||
type: "live",
|
|
||||||
title: "slam",
|
|
||||||
viewer_count: 56230,
|
|
||||||
started_at: "2022-09-29T14:04:21Z",
|
|
||||||
language: "en",
|
|
||||||
thumbnail_url:
|
|
||||||
"https://static-cdn.jtvnw.net/previews-ttv/live_user_xqcow-250x250.jpg",
|
|
||||||
tag_ids: [],
|
|
||||||
is_mature: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
pagination: {
|
|
||||||
cursor:
|
|
||||||
"eyJiIjp7IkN1cnNvciI6ImV5SnpJam8zT0RNMk5TNDBORFF4TlRjMU1UY3hOU3dpWkNJNlptRnNjMlVzSW5RaU9uUnlkV1Y5In0sImEiOnsiQ3Vyc29yIjoiZXlKeklqb3hOVGs0TkM0MU56RXhNekExTVRZNU1ESXNJbVFpT21aaGJITmxMQ0owSWpwMGNuVmxmUT09In19",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default streams;
|
|
@ -1,28 +0,0 @@
|
|||||||
import { UserFollows } from "../types";
|
|
||||||
|
|
||||||
export const following: UserFollows = {
|
|
||||||
total: 4,
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
from_id: "171003792",
|
|
||||||
from_login: "niku",
|
|
||||||
from_name: "niku",
|
|
||||||
to_id: "23161357",
|
|
||||||
to_name: "LIRIK",
|
|
||||||
to_login: "lirik",
|
|
||||||
followed_at: "2017-08-22T22:55:24Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
from_id: "171003792",
|
|
||||||
from_login: "niku",
|
|
||||||
from_name: "niku",
|
|
||||||
to_id: "23161358",
|
|
||||||
to_name: "Cowser",
|
|
||||||
to_login: "cowser",
|
|
||||||
followed_at: "2017-08-22T22:55:24Z",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
pagination: {
|
|
||||||
cursor: "eyJiIjpudWxsLCJhIjoiMTUwMzQ0MTc3NjQyNDQyMjAwMCJ9",
|
|
||||||
},
|
|
||||||
};
|
|
@ -1,12 +0,0 @@
|
|||||||
export const categories = {
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
id: "33214",
|
|
||||||
name: "Just Chatting",
|
|
||||||
box_art_url: "https://static-cdn.jtvnw.net/ttv-boxart/509658-144x192.jpg",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
pagination: {
|
|
||||||
cursor: "eyJiIjpudWxsLCJhIjp7IkN",
|
|
||||||
},
|
|
||||||
};
|
|
@ -1,52 +0,0 @@
|
|||||||
export interface Pagination {
|
|
||||||
cursor: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserFollow {
|
|
||||||
followed_at: string;
|
|
||||||
from_id: string;
|
|
||||||
from_login: string;
|
|
||||||
from_name: string;
|
|
||||||
to_id: string;
|
|
||||||
to_login: string;
|
|
||||||
to_name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserFollows {
|
|
||||||
total: number;
|
|
||||||
data: UserFollow[];
|
|
||||||
pagination: Pagination;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Stream {
|
|
||||||
game_id: string;
|
|
||||||
game_name: string;
|
|
||||||
id: string;
|
|
||||||
language: string;
|
|
||||||
started_at: string;
|
|
||||||
tag_ids: string[];
|
|
||||||
thumbnail_url: string;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
user_id: string;
|
|
||||||
user_login: string;
|
|
||||||
user_name: string;
|
|
||||||
viewer_count: number;
|
|
||||||
is_mature: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FollowedStreams {
|
|
||||||
data: Stream[];
|
|
||||||
pagination: Pagination;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Category {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
box_art_url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SearchCategories {
|
|
||||||
data: Category[];
|
|
||||||
pagination: Pagination;
|
|
||||||
}
|
|
1
client/src/vite-env.d.ts
vendored
1
client/src/vite-env.d.ts
vendored
@ -1 +0,0 @@
|
|||||||
/// <reference types="vite/client" />
|
|
@ -1,15 +0,0 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
module.exports = {
|
|
||||||
content: ["./index.html", "./src/**/*.tsx"],
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
fontFamily: {
|
|
||||||
inter: ["Inter"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
variants: {
|
|
||||||
scrollbar: ["rounded"],
|
|
||||||
},
|
|
||||||
plugins: [require("tailwind-scrollbar")],
|
|
||||||
};
|
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user