initial commit
3
.env
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
POSTGRES_USER=root
|
||||||
|
POSTGRES_PASSWORD=root
|
||||||
|
POSTGRES_DB=info
|
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
frontend/node_modules
|
||||||
|
go.sum
|
||||||
|
social-network
|
||||||
|
users.db
|
||||||
|
fiber
|
||||||
|
frontend/.next/
|
||||||
|
/out/
|
7
Dockerfile
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
FROM nginx:latest
|
||||||
|
|
||||||
|
WORKDIR /etc/nginx/
|
||||||
|
|
||||||
|
COPY nginx.conf .
|
||||||
|
|
||||||
|
EXPOSE 80
|
50
README.md
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# CopyBook
|
||||||
|
|
||||||
|
A messageboard type social website
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
Fully coded in golang utilizing the following technologies:
|
||||||
|
- https://github.com/gofiber/fiber
|
||||||
|
- https://gorm.io/
|
||||||
|
|
||||||
|
The backend consists of multiple modules:
|
||||||
|
- db.go: database modules setup with gorm
|
||||||
|
- auth.go: authorization module with jwt tokens setup
|
||||||
|
- server.go: main buisness logic functionality
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
Typescript + nextjs + tailwindcss
|
||||||
|
|
||||||
|
the frontend uses nextjs for server side rendering.
|
||||||
|
|
||||||
|
there are multiple pages each responsible for its own functionality:
|
||||||
|
- index.tsx: mounted on `/` is the homepage for the messageboard.
|
||||||
|
- login.tsx: mounted on `/login` the login page
|
||||||
|
- logout.tsx: mounted on `/logout` for logging out and removing the jwt token.
|
||||||
|
- profile.tsx: mounted on `/profile` is for the user profile info.
|
||||||
|
- signup.tsx: mounted on `/signup` is for registering new users.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Devops
|
||||||
|
Fully dockerized and ready for deployment on any environment
|
||||||
|
|
||||||
|
backend setup consists of 2 containers one that builds the golang binary for and another that runs the server
|
||||||
|
|
||||||
|
the database for the backend is running a postgresql server with persistent storage enabled
|
||||||
|
|
||||||
|
frontend setup has 3 containers:
|
||||||
|
`deps`: pulls the dependecies required for compiling the typescript files.
|
||||||
|
`builder`: compiles the typescript files.
|
||||||
|
`runner`: runs the nextjs server.
|
||||||
|
|
||||||
|
all of the previous services are running on `internalnet` virtual network and then afterwards its served with a nginx reverse proxy for traffic management and redundancy purposes
|
||||||
|
|
||||||
|
## TODO
|
||||||
|
- better db management
|
||||||
|
- more documentation
|
||||||
|
- user personalization
|
||||||
|
- more ui changes
|
13
backend/Dockerfile
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
FROM golang AS build
|
||||||
|
# RUN apk --no-cache add gcc g++ make git
|
||||||
|
WORKDIR /go/src/app
|
||||||
|
COPY . .
|
||||||
|
RUN go mod tidy
|
||||||
|
# RUN GOOS=linux go build -ldflags="-s -w" -o ./bin/web-app
|
||||||
|
RUN GOOS=linux go build -o ./bin/web-app
|
||||||
|
|
||||||
|
FROM ubuntu
|
||||||
|
WORKDIR /usr/bin
|
||||||
|
COPY --from=build /go/src/app/bin /go/bin
|
||||||
|
EXPOSE 3000
|
||||||
|
ENTRYPOINT /go/bin/web-app
|
3
backend/README.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Backend API for the social network
|
||||||
|
|
||||||
|
rest api utlizing golang and the gofiber framework
|
35
backend/auth.go
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
var jwtKey = []byte("ShXYLRYfFOw+upPD")
|
||||||
|
|
||||||
|
type Claim struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
func Sign(username string) (string, error) {
|
||||||
|
expirationTime := jwt.NewNumericDate(time.Now().Add(5 * time.Hour))
|
||||||
|
|
||||||
|
claims := &Claim{
|
||||||
|
Username: username,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: expirationTime,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
|
||||||
|
tokenString, err := token.SignedString(jwtKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokenString, err
|
||||||
|
|
||||||
|
}
|
75
backend/db.go
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID uint `gorm:"primaryKey"`
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
Email string
|
||||||
|
Active bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type APIUser struct {
|
||||||
|
ID uint
|
||||||
|
Username string
|
||||||
|
Email string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Comment struct {
|
||||||
|
UID uint `gorm:"primaryKey"`
|
||||||
|
PostId uint
|
||||||
|
Commenter string
|
||||||
|
Text string
|
||||||
|
}
|
||||||
|
|
||||||
|
type APIComment struct {
|
||||||
|
PostId uint `json:"post_id"`
|
||||||
|
Text string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Post struct {
|
||||||
|
ID uint `gorm:"primaryKey"`
|
||||||
|
Content string
|
||||||
|
Username string
|
||||||
|
Comments string
|
||||||
|
}
|
||||||
|
|
||||||
|
func initDB() *gorm.DB {
|
||||||
|
dburl := "host=db user=root password=root dbname=info port=5432 sslmode=disable TimeZone=Asia/Shanghai"
|
||||||
|
db, err := gorm.Open(postgres.Open(dburl), &gorm.Config{})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
panic("failed to open db")
|
||||||
|
}
|
||||||
|
|
||||||
|
db.AutoMigrate(&User{})
|
||||||
|
db.AutoMigrate(&Post{})
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateUser(u User) bool {
|
||||||
|
if len(u.Username) < 3 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(u.Password) < 3 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func HashPassword(password string) (string, error) {
|
||||||
|
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14)
|
||||||
|
return string(bytes), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func CheckPasswordHash(password, hash string) bool {
|
||||||
|
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||||
|
return err == nil
|
||||||
|
}
|
26
backend/go.mod
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
module social-network
|
||||||
|
|
||||||
|
go 1.13
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/charmbracelet/bubbles v0.11.0 // indirect
|
||||||
|
github.com/fsnotify/fsnotify v1.5.4 // indirect
|
||||||
|
github.com/gofiber/cli v0.0.9 // indirect
|
||||||
|
github.com/gofiber/fiber/v2 v2.34.0
|
||||||
|
github.com/gofiber/jwt/v3 v3.2.12 // indirect
|
||||||
|
github.com/golang-jwt/jwt/v4 v4.4.1 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 // indirect
|
||||||
|
github.com/jackc/pgx/v5 v5.5.5 // indirect
|
||||||
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
github.com/klauspost/compress v1.15.6 // indirect
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.13 // indirect
|
||||||
|
github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 // indirect
|
||||||
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
|
github.com/muesli/termenv v0.12.0 // indirect
|
||||||
|
github.com/spf13/cobra v1.5.0 // indirect
|
||||||
|
golang.org/x/crypto v0.22.0 // indirect
|
||||||
|
golang.org/x/sync v0.7.0 // indirect
|
||||||
|
gorm.io/driver/postgres v1.5.7 // indirect
|
||||||
|
gorm.io/driver/sqlite v1.3.4 // indirect
|
||||||
|
gorm.io/gorm v1.25.9 // indirect
|
||||||
|
)
|
232
backend/server.go
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/gob"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||||
|
jwtware "github.com/gofiber/jwt/v3"
|
||||||
|
"github.com/golang-jwt/jwt/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Creds struct {
|
||||||
|
Username string `json:"username" xml:"username" form:"username"`
|
||||||
|
Pass string `json:"pass" xml:"pass" form:"pass"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Response struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type JsonComment struct {
|
||||||
|
Array []Comment
|
||||||
|
}
|
||||||
|
|
||||||
|
// go binary encoder
|
||||||
|
func ToGOB64(m JsonComment) string {
|
||||||
|
b := bytes.Buffer{}
|
||||||
|
e := gob.NewEncoder(&b)
|
||||||
|
err := e.Encode(m)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(`failed gob Encode`, err)
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(b.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
// go binary decoder
|
||||||
|
func FromGOB64(str string) JsonComment {
|
||||||
|
m := JsonComment{}
|
||||||
|
by, err := base64.StdEncoding.DecodeString(str)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(`failed base64 Decode`, err)
|
||||||
|
}
|
||||||
|
b := bytes.Buffer{}
|
||||||
|
b.Write(by)
|
||||||
|
d := gob.NewDecoder(&b)
|
||||||
|
err = d.Decode(&m)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(`failed gob Decode`, err)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func malformedToken(c *fiber.Ctx, w error) error {
|
||||||
|
res := Response{Status: "bad", Text: "Malformed token"}
|
||||||
|
return c.JSON(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
app := fiber.New()
|
||||||
|
db := initDB()
|
||||||
|
bad := Response{Status: "bad", Text: "Unauthorized"}
|
||||||
|
errResponse := Response{Status: "bad", Text: "Woops something went wrong ¯\\_(ツ)_/¯"}
|
||||||
|
app.Use(cors.New(cors.Config{
|
||||||
|
AllowOrigins: "*",
|
||||||
|
AllowHeaders: "Origin, Content-Type, Accept, Authorization",
|
||||||
|
}))
|
||||||
|
app.Post("/login", func(c *fiber.Ctx) error {
|
||||||
|
p := new(Creds)
|
||||||
|
if err := c.BodyParser(p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var res User
|
||||||
|
if err := db.First(&res, "Username = ?", p.Username).Error; err != nil {
|
||||||
|
return c.JSON(bad)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !CheckPasswordHash(p.Pass, res.Password) {
|
||||||
|
return c.JSON(bad)
|
||||||
|
}
|
||||||
|
|
||||||
|
token, _ := Sign(p.Username)
|
||||||
|
response := Response{Status: "ok", Text: token}
|
||||||
|
return c.JSON(response)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.Post("/signup", func(c *fiber.Ctx) error {
|
||||||
|
details := new(User)
|
||||||
|
|
||||||
|
if err := c.BodyParser(details); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !validateUser(*details) {
|
||||||
|
return c.JSON(errResponse)
|
||||||
|
}
|
||||||
|
details.Active = true
|
||||||
|
|
||||||
|
var query User
|
||||||
|
if err := db.First(&query, "Username = ?", details.Username).Error; err != nil && query.Username != "" {
|
||||||
|
return c.JSON(errResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
hashedPass, err := HashPassword(details.Password)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(errResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
details.Password = hashedPass
|
||||||
|
|
||||||
|
db.Create(details)
|
||||||
|
newUser := Response{Status: "ok", Text: "user created successfully"}
|
||||||
|
return c.JSON(newUser)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.Use(jwtware.New(jwtware.Config{
|
||||||
|
SigningKey: []byte("ShXYLRYfFOw+upPD"),
|
||||||
|
ErrorHandler: malformedToken,
|
||||||
|
}))
|
||||||
|
|
||||||
|
app.Get("/posts", func(c *fiber.Ctx) error {
|
||||||
|
var query []Post
|
||||||
|
c.Set(fiber.HeaderContentType, fiber.MIMEApplicationJSONCharsetUTF8)
|
||||||
|
|
||||||
|
db.Find(&query)
|
||||||
|
|
||||||
|
res, err := json.Marshal(query)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(errResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.SendString(string(res))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.Get("/posts/:uid", func(c *fiber.Ctx) error {
|
||||||
|
var post Post
|
||||||
|
|
||||||
|
db.Select("content").Find(&post, "ID = ?", c.Params("uid"))
|
||||||
|
res := Response{Status: "ok", Text: post.Content}
|
||||||
|
return c.JSON(res)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.Get("/profile/:uid", func(c *fiber.Ctx) error {
|
||||||
|
var user APIUser
|
||||||
|
if err := db.Model(&User{}).Find(&user, "Username = ?", c.Params("uid")).Error; err != nil {
|
||||||
|
return c.JSON(errResponse)
|
||||||
|
}
|
||||||
|
return c.JSON(user)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.Get("/profile", func(c *fiber.Ctx) error {
|
||||||
|
var user APIUser
|
||||||
|
token := c.Locals("user").(*jwt.Token)
|
||||||
|
claims := token.Claims.(jwt.MapClaims)
|
||||||
|
name := claims["username"].(string)
|
||||||
|
|
||||||
|
if err := db.Model(&User{}).Find(&user, "Username = ?", name).Error; err != nil {
|
||||||
|
return c.JSON(errResponse)
|
||||||
|
}
|
||||||
|
return c.JSON(user)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.Post("/create", func(c *fiber.Ctx) error {
|
||||||
|
info := new(Post)
|
||||||
|
|
||||||
|
if err := c.BodyParser(info); err != nil {
|
||||||
|
return c.JSON(errResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
user := c.Locals("user").(*jwt.Token)
|
||||||
|
claims := user.Claims.(jwt.MapClaims)
|
||||||
|
name := claims["username"].(string)
|
||||||
|
|
||||||
|
//figure out how to store user id's instead of usernames
|
||||||
|
/*
|
||||||
|
var query User
|
||||||
|
if err := db.First(&query, "Username = ?", name).Error; err != nil && query.Username != "" {
|
||||||
|
return c.JSON(errResponse)
|
||||||
|
}
|
||||||
|
info.Uuid = query.ID
|
||||||
|
*/
|
||||||
|
info.Username = name
|
||||||
|
db.Create(info)
|
||||||
|
newPost := Response{Status: "ok", Text: "post created successfully"}
|
||||||
|
return c.JSON(newPost)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.Post("/comment", func(c *fiber.Ctx) error {
|
||||||
|
var comment Comment
|
||||||
|
apiComment := new(APIComment)
|
||||||
|
if err := c.BodyParser(apiComment); err != nil {
|
||||||
|
return c.JSON(errResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
user := c.Locals("user").(*jwt.Token)
|
||||||
|
claims := user.Claims.(jwt.MapClaims)
|
||||||
|
name := claims["username"].(string)
|
||||||
|
|
||||||
|
comment.PostId = apiComment.PostId
|
||||||
|
comment.Text = apiComment.Text
|
||||||
|
comment.Commenter = name
|
||||||
|
|
||||||
|
var post Post
|
||||||
|
|
||||||
|
db.Select("comments").Find(&post, "ID = ?", comment.PostId)
|
||||||
|
dbComments := FromGOB64(post.Comments)
|
||||||
|
|
||||||
|
comments := JsonComment{Array: append([]Comment{comment}, dbComments.Array...)}
|
||||||
|
store := ToGOB64(comments)
|
||||||
|
db.Model(&Post{}).Where("ID = ?", comment.PostId).Update("comments", store)
|
||||||
|
newComment := Response{Status: "ok", Text: "Comment created successfully"}
|
||||||
|
return c.JSON(newComment)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.Get("/comments/:uid", func(c *fiber.Ctx) error {
|
||||||
|
var post Post
|
||||||
|
|
||||||
|
db.Select("comments").Find(&post, "ID = ?", c.Params("uid"))
|
||||||
|
comments := FromGOB64(post.Comments)
|
||||||
|
return c.JSON(comments)
|
||||||
|
})
|
||||||
|
|
||||||
|
log.Fatal(app.Listen(":3000"))
|
||||||
|
}
|
58
docker-compose.yml
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
services:
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
build: .
|
||||||
|
container_name: nginx
|
||||||
|
restart: "always"
|
||||||
|
privileged: true
|
||||||
|
links:
|
||||||
|
- "go-api:api"
|
||||||
|
- "frontend:frontend"
|
||||||
|
ports:
|
||||||
|
- 80:80
|
||||||
|
networks:
|
||||||
|
- internalnet
|
||||||
|
|
||||||
|
go-api:
|
||||||
|
build: ./backend
|
||||||
|
restart: "always"
|
||||||
|
container_name: api
|
||||||
|
environment:
|
||||||
|
- DB_USER=${POSTGRES_USER}
|
||||||
|
- DB_PASSWORD=${POSTGRES_PASSWORD}
|
||||||
|
- DB_NAME=${POSTGRES_DB}
|
||||||
|
- DB_HOST=db
|
||||||
|
- DB_PORT=5432
|
||||||
|
ports:
|
||||||
|
- 3000:3000
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
networks:
|
||||||
|
- internalnet
|
||||||
|
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
restart: "always"
|
||||||
|
container_name: frontend
|
||||||
|
ports:
|
||||||
|
- 3001:3000
|
||||||
|
networks:
|
||||||
|
- internalnet
|
||||||
|
db:
|
||||||
|
image: postgres:latest
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=${POSTGRES_USER}
|
||||||
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||||
|
- POSTGRES_DB=${POSTGRES_DB}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- internalnet
|
||||||
|
|
||||||
|
networks:
|
||||||
|
internalnet:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
7
frontend/.dockerignore
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
README.md
|
||||||
|
.next
|
||||||
|
.git
|
1
frontend/.env
Normal file
@ -0,0 +1 @@
|
|||||||
|
NEXT_PUBLIC_URL=127.0.0.1:3000
|
60
frontend/Dockerfile
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
# Install dependencies only when needed
|
||||||
|
FROM node:16-alpine AS deps
|
||||||
|
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies based on the preferred package manager
|
||||||
|
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
|
||||||
|
RUN \
|
||||||
|
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
|
||||||
|
elif [ -f package-lock.json ]; then npm ci; \
|
||||||
|
elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i; \
|
||||||
|
else echo "Lockfile not found." && exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Rebuild the source code only when needed
|
||||||
|
FROM node:16-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Next.js collects completely anonymous telemetry data about general usage.
|
||||||
|
# Learn more here: https://nextjs.org/telemetry
|
||||||
|
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
|
||||||
|
#RUN yarn build
|
||||||
|
|
||||||
|
# If using npm comment out above and use below instead
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production image, copy all the files and run next
|
||||||
|
FROM node:16-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV production
|
||||||
|
# Uncomment the following line in case you want to disable telemetry during runtime.
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
# You only need to copy next.config.js if you are NOT using the default configuration
|
||||||
|
# COPY --from=builder /app/next.config.js ./
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder /app/package.json ./package.json
|
||||||
|
|
||||||
|
# Automatically leverage output traces to reduce image size
|
||||||
|
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENV PORT 3000
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
3
frontend/README.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Frontend for the social network
|
||||||
|
|
||||||
|
the frontend uses typscript + nextjs
|
22
frontend/components/comment.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
export default function CommentBox({
|
||||||
|
content,
|
||||||
|
user,
|
||||||
|
id
|
||||||
|
}: {
|
||||||
|
content: string
|
||||||
|
user: string
|
||||||
|
id: number
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="card w-96 card-side text-white hover:bg-base-300">
|
||||||
|
<div className="card-body">
|
||||||
|
<Link href={`/profile/${user}`}>
|
||||||
|
<h2 className="hover:text-primary card-title">{user}</h2>
|
||||||
|
</Link>
|
||||||
|
<p className="text-xl">{content}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
27
frontend/components/post.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
export default function PostBox({
|
||||||
|
content,
|
||||||
|
user,
|
||||||
|
id
|
||||||
|
}: {
|
||||||
|
content: string
|
||||||
|
user: string
|
||||||
|
id: number
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Link href={`/post/${id}`}>
|
||||||
|
<div className="card w-96 card-side text-white hover:bg-base-300">
|
||||||
|
<div className="card-body">
|
||||||
|
<Link href={`/profile/${user}`}>
|
||||||
|
<h2 className="hover:text-primary card-title">{user}</h2>
|
||||||
|
</Link>
|
||||||
|
<p className="text-xl">{content}</p>
|
||||||
|
<div className="card-actions justify-end">
|
||||||
|
{/*<button className="btn btn-primary">Like</button>*/ }
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
32
frontend/components/ui.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ul className="menu bg-base-300 rounded-box fixed min-h-full py-6 space-y-9">
|
||||||
|
<li>
|
||||||
|
<Link href="/">
|
||||||
|
<a>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /></svg>
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href="/profile">
|
||||||
|
<a>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<header className='grid place-items-center pt-5 pb-20'>
|
||||||
|
<h1>Copybook</h1>
|
||||||
|
</header>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
5
frontend/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.
|
15
frontend/next.config.js
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
|
||||||
|
const nextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
nx: {
|
||||||
|
svgr: false,
|
||||||
|
},
|
||||||
|
// fix for running the docker container
|
||||||
|
output: 'standalone',
|
||||||
|
experimental: {
|
||||||
|
outputStandalone: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = nextConfig
|
5914
frontend/package-lock.json
generated
Normal file
27
frontend/package.json
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "social-app",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"daisyui": "^2.19.0",
|
||||||
|
"next": "12.1.6",
|
||||||
|
"react": "18.1.0",
|
||||||
|
"react-dom": "18.1.0",
|
||||||
|
"react-router-dom": "^6.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^17.0.40",
|
||||||
|
"@types/react": "^18.0.11",
|
||||||
|
"autoprefixer": "^10.4.7",
|
||||||
|
"eslint": "8.17.0",
|
||||||
|
"eslint-config-next": "12.1.6",
|
||||||
|
"postcss": "^8.4.14",
|
||||||
|
"tailwindcss": "^3.0.24"
|
||||||
|
}
|
||||||
|
}
|
6
frontend/pages/_app.tsx
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import '../styles/globals.css'
|
||||||
|
import { AppProps } from 'next/app'
|
||||||
|
|
||||||
|
export default function MyApp({ Component, pageProps }: AppProps) {
|
||||||
|
return <Component {...pageProps} />
|
||||||
|
}
|
60
frontend/pages/index.tsx
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import PostBox from '../components/post';
|
||||||
|
import Dashboard from '../components/ui';
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
let url = process.env.NEXT_PUBLIC_URL;
|
||||||
|
export default function Home(props) {
|
||||||
|
const router = useRouter();
|
||||||
|
let [posts, setPosts] = useState([]);
|
||||||
|
useEffect(() => {
|
||||||
|
let token = localStorage.getItem("Token");
|
||||||
|
let ps = async () => {
|
||||||
|
setPosts(await fetch(`http://${url}/posts`, { headers: { "Authorization": `Bearer ${token}` }, mode: "cors" }).then(r => r.json()));
|
||||||
|
}
|
||||||
|
ps().catch(console.error);
|
||||||
|
setInterval(refreshPosts, 20000)
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (posts["status"] == "bad") {
|
||||||
|
localStorage.removeItem("Token");
|
||||||
|
router.push("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = [];
|
||||||
|
if (posts.length != 0 && posts["status"] == undefined) {
|
||||||
|
content = Object.assign([], posts.map(post => {
|
||||||
|
return [post.Content, post.ID, post.Username]
|
||||||
|
}));
|
||||||
|
content.reverse()
|
||||||
|
}
|
||||||
|
|
||||||
|
let refreshPosts = async() => {
|
||||||
|
let token = localStorage.getItem("Token");
|
||||||
|
let ps = async () => {
|
||||||
|
setPosts(await fetch(`http://${url}/posts`, { headers: { "Authorization": `Bearer ${token}` }, mode: "cors" }).then(r => r.json()));
|
||||||
|
}
|
||||||
|
ps().catch(console.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
let newPost = async () => {
|
||||||
|
let token = localStorage.getItem("Token");
|
||||||
|
let y = document.getElementById("postContent") as HTMLInputElement;
|
||||||
|
await fetch(`http://${url}/create`, { method: "POST", headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/json" }, mode: "cors", body: JSON.stringify({ Title: "UI", Content: y.value }) });
|
||||||
|
y.value = "";
|
||||||
|
await refreshPosts();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Dashboard />
|
||||||
|
<main className='flex flex-col place-items-center space-y-3'>
|
||||||
|
<textarea id="postContent" className="textarea textarea-secondary text-xl" placeholder="What's on your mind ?"></textarea>
|
||||||
|
<button className="btn btn-outline btn-secondary w-20" onClick={newPost}>Post</button>
|
||||||
|
{content.length != 0 ? content.map(post => {
|
||||||
|
return <PostBox id={post[1]} content={post[0]} key={post[1]} user={post[2]}/>
|
||||||
|
}) : ""}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
)
|
||||||
|
}
|
50
frontend/pages/login.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import Link from 'next/link'
|
||||||
|
let url = process.env.NEXT_PUBLIC_URL
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let stuff = localStorage.getItem("Token");
|
||||||
|
if (stuff != null) {
|
||||||
|
router.push("/")
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
let grab_token = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
let username = (document.getElementById("Username") as HTMLInputElement).value;
|
||||||
|
let password = (document.getElementById("Password") as HTMLInputElement).value;
|
||||||
|
|
||||||
|
let token = await fetch(`http://${url}/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, mode: "cors", body: JSON.stringify({ "username": username, "pass": password }) }).then(r => r.json());
|
||||||
|
if (token["status"] == "ok") {
|
||||||
|
await localStorage.setItem("Token", token["text"]);
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
alert("bad username/password");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<header className='grid place-items-center pt-5 pb-20 text-white text-xl'>
|
||||||
|
<h1>Copybook</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form className="grid place-items-center space-y-5" action='#' onSubmit={grab_token}>
|
||||||
|
<h1 className="text-xl pb-5">Login</h1>
|
||||||
|
<input id="Username" type="text" placeholder="Username" className="input input-bordered input-accent w-full max-w-xs text-xl" />
|
||||||
|
<input id="Password" type="password" placeholder="Password" className="input input-bordered input-primary w-full max-w-xs text-xl" />
|
||||||
|
<button className="btn btn-outline btn-accent w-40">Login</button>
|
||||||
|
<Link href="/signup">
|
||||||
|
<a className="link link-accent">Signup ?</a>
|
||||||
|
</Link>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
15
frontend/pages/logout.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function Logout() {
|
||||||
|
const router = useRouter();
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.removeItem("Token");
|
||||||
|
router.push("/login");
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Logging you out ...</h1>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
56
frontend/pages/post/[pid].tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import Dashboard from '../../components/ui';
|
||||||
|
import CommentBox from '../../components/comment';
|
||||||
|
|
||||||
|
export default function Profile() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
let pid = router.query.pid as string;
|
||||||
|
let [post, setPost] = useState({text: ""});
|
||||||
|
let [comments, setComments] = useState([]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!router.isReady) return;
|
||||||
|
let token = localStorage.getItem("Token");
|
||||||
|
let ps = async () => {
|
||||||
|
pid = router.query.pid as string;
|
||||||
|
setPost(await fetch(`http://127.0.0.1:3000/posts/${pid}`, { headers: { "Authorization": `Bearer ${token}` }, mode: "cors" }).then(r => r.json()));
|
||||||
|
setComments(await fetch(`http://127.0.0.1:3000/comments/${pid}`, { headers: { "Authorization": `Bearer ${token}` }, mode: "cors" }).then(r => r.json()));
|
||||||
|
}
|
||||||
|
ps().catch(console.error);
|
||||||
|
}, [router.isReady]);
|
||||||
|
|
||||||
|
let refreshComments = () => {
|
||||||
|
let token = localStorage.getItem("Token");
|
||||||
|
let ps = async () => {
|
||||||
|
setComments(await fetch(`http://127.0.0.1:3000/comments/${pid}`, { headers: { "Authorization": `Bearer ${token}` }, mode: "cors" }).then(r => r.json()));
|
||||||
|
}
|
||||||
|
ps().catch(console.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
let newComment = async () => {
|
||||||
|
let token = localStorage.getItem("Token");
|
||||||
|
let y = document.getElementById("commentContent") as HTMLInputElement;
|
||||||
|
await fetch("http://127.0.0.1:3000/comment", { method: "POST", headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/json" }, mode: "cors", body: JSON.stringify({ "post_id": parseInt(pid), "content": y.value }) });
|
||||||
|
y.value = "";
|
||||||
|
refreshComments();
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Dashboard />
|
||||||
|
<main className='flex flex-col place-items-center space-y-3'>
|
||||||
|
<h1 className='text-2xl text-white'>{post.text}</h1>
|
||||||
|
<textarea id="commentContent" className="textarea textarea-secondary text-xl" placeholder="New comment..."></textarea>
|
||||||
|
<button className="btn btn-outline btn-secondary w-20" onClick={newComment}>Comment</button>
|
||||||
|
<div className="divider"></div>
|
||||||
|
<div className='pt-10'>
|
||||||
|
{comments["Array"] != undefined ? comments["Array"].map(comment => {
|
||||||
|
return (
|
||||||
|
<CommentBox content={comment["Text"]} user={comment["Commenter"]} id={comment["PostId"]} />
|
||||||
|
);
|
||||||
|
}) : ""}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
27
frontend/pages/profile.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import Dashboard from '../components/ui';
|
||||||
|
let url = process.env.NEXT_PUBLIC_URL
|
||||||
|
|
||||||
|
export default function Profile() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
let [info, setInfo] = useState({});
|
||||||
|
useEffect(() => {
|
||||||
|
let token = localStorage.getItem("Token");
|
||||||
|
let ps = async () => {
|
||||||
|
setInfo(await fetch(`http://${url}/profile`, { headers: { "Authorization": `Bearer ${token}` }, mode: "cors" }).then(r => r.json()));
|
||||||
|
}
|
||||||
|
ps().catch(console.error);
|
||||||
|
}, [router.isReady]);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Dashboard />
|
||||||
|
<main className='flex flex-col place-items-center space-y-3'>
|
||||||
|
<h1 className='text-2xl text-white'>{info["Username"]}</h1>
|
||||||
|
<p>About me :</p>
|
||||||
|
<p className='text-xl'>woops... </p>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
29
frontend/pages/profile/[pid].tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import Dashboard from '../../components/ui';
|
||||||
|
|
||||||
|
|
||||||
|
export default function Profile() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
let [info, setInfo] = useState({});
|
||||||
|
useEffect(() => {
|
||||||
|
if (!router.isReady) return;
|
||||||
|
let token = localStorage.getItem("Token");
|
||||||
|
let ps = async () => {
|
||||||
|
const { pid } = router.query
|
||||||
|
setInfo(await fetch(`http://127.0.0.1:3000/profile/${pid}`, { headers: { "Authorization": `Bearer ${token}` }, mode: "cors" }).then(r => r.json()));
|
||||||
|
}
|
||||||
|
ps().catch(console.error);
|
||||||
|
}, [router.isReady]);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Dashboard />
|
||||||
|
<main className='flex flex-col place-items-center space-y-3'>
|
||||||
|
<h1 className='text-2xl text-white'>{info["Username"]}</h1>
|
||||||
|
<p>About me :</p>
|
||||||
|
<p className='text-xl'>woops... </p>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
38
frontend/pages/signup.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { useRouter } from 'next/router';
|
||||||
|
let url = process.env.NEXT_PUBLIC_URL
|
||||||
|
|
||||||
|
export default function Signup() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
let signup = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
let username = (document.getElementById("Username") as HTMLInputElement).value;
|
||||||
|
let email = (document.getElementById("Email") as HTMLInputElement).value;
|
||||||
|
let password = (document.getElementById("Password") as HTMLInputElement).value;
|
||||||
|
|
||||||
|
let token = await fetch(`http://${url}/signup`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, mode: "cors", body: JSON.stringify({ "Username": username, "Email": email, "Password": password }) }).then(r => r.json());
|
||||||
|
if (token["status"] == "ok") {
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
alert("Username already exists");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<header className='grid place-items-center pt-5 pb-20 text-white text-xl'>
|
||||||
|
<h1>Copybook</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form className="grid place-items-center space-y-5" action="#" onSubmit={signup}>
|
||||||
|
<h1 className="text-xl pb-5">Signup</h1>
|
||||||
|
<input id="Email" type="text" placeholder="Email" className="input input-bordered input-accent w-full max-w-xs text-xl" />
|
||||||
|
<input id="Username" type="text" placeholder="Username" className="input input-bordered input-accent w-full max-w-xs text-xl" />
|
||||||
|
<input id="Password" type="password" placeholder="Password" className="input input-bordered input-primary w-full max-w-xs text-xl" />
|
||||||
|
<button className="btn btn-outline btn-accent w-40">Signup</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
)
|
||||||
|
}
|
6
frontend/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
BIN
frontend/public/facebook_cover_photo_1.png
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
frontend/public/facebook_cover_photo_2.png
Normal file
After Width: | Height: | Size: 5.9 KiB |
BIN
frontend/public/facebook_profile_image.png
Normal file
After Width: | Height: | Size: 13 KiB |
BIN
frontend/public/favicon.png
Normal file
After Width: | Height: | Size: 268 B |
BIN
frontend/public/instagram_profile_image.png
Normal file
After Width: | Height: | Size: 9.4 KiB |
BIN
frontend/public/linkedin_banner_image_1.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
frontend/public/linkedin_banner_image_2.png
Normal file
After Width: | Height: | Size: 7.4 KiB |
BIN
frontend/public/linkedin_profile_image.png
Normal file
After Width: | Height: | Size: 9.4 KiB |
BIN
frontend/public/logo.png
Normal file
After Width: | Height: | Size: 13 KiB |
BIN
frontend/public/logo_transparent.png
Normal file
After Width: | Height: | Size: 8.9 KiB |
BIN
frontend/public/pinterest_board_photo.png
Normal file
After Width: | Height: | Size: 5.9 KiB |
BIN
frontend/public/pinterest_profile_image.png
Normal file
After Width: | Height: | Size: 2.1 KiB |
BIN
frontend/public/twitter_header_photo_1.png
Normal file
After Width: | Height: | Size: 10 KiB |
BIN
frontend/public/twitter_header_photo_2.png
Normal file
After Width: | Height: | Size: 5.1 KiB |
BIN
frontend/public/twitter_profile_image.png
Normal file
After Width: | Height: | Size: 5.9 KiB |
BIN
frontend/public/youtube_profile_image.png
Normal file
After Width: | Height: | Size: 5.9 KiB |
116
frontend/styles/Home.module.css
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
.container {
|
||||||
|
padding: 0 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 4rem 0;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
padding: 2rem 0;
|
||||||
|
border-top: 1px solid #eaeaea;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title a {
|
||||||
|
color: #0070f3;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title a:hover,
|
||||||
|
.title a:focus,
|
||||||
|
.title a:active {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.15;
|
||||||
|
font-size: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title,
|
||||||
|
.description {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
margin: 4rem 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code {
|
||||||
|
background: #fafafa;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
|
||||||
|
Bitstream Vera Sans Mono, Courier New, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
max-width: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
margin: 1rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
text-align: left;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
border: 1px solid #eaeaea;
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: color 0.15s ease, border-color 0.15s ease;
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover,
|
||||||
|
.card:focus,
|
||||||
|
.card:active {
|
||||||
|
color: #0070f3;
|
||||||
|
border-color: #0070f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 1em;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.grid {
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
20
frontend/styles/globals.css
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
|
||||||
|
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
27
frontend/tailwind.config.js
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
"./pages/**/*.{js,ts,jsx,tsx}",
|
||||||
|
"./components/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
daisyui: {
|
||||||
|
themes: [
|
||||||
|
{
|
||||||
|
mytheme: {
|
||||||
|
"primary": "#e26394",
|
||||||
|
"secondary": "#76e8f7",
|
||||||
|
"accent": "#ed44a6",
|
||||||
|
"neutral": "#16191D",
|
||||||
|
"base-100": "#2F354C",
|
||||||
|
"info": "#95AAE4",
|
||||||
|
"success": "#12735F",
|
||||||
|
"warning": "#F3DA58",
|
||||||
|
"error": "#EB333F",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
plugins: [require("daisyui")],
|
||||||
|
}
|
30
frontend/tsconfig.json
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es5",
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": false,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"incremental": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
22
nginx.conf
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name social.org;
|
||||||
|
location / {
|
||||||
|
proxy_pass http://frontend:3000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name api.social.org;
|
||||||
|
location / {
|
||||||
|
proxy_pass http://api:3000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
3
remove-app.sh
Executable file
@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
sudo docker rmi social-network-nginx social-network-frontend
|
BIN
screenshots/1.png
Normal file
After Width: | Height: | Size: 19 KiB |
BIN
screenshots/2.png
Normal file
After Width: | Height: | Size: 26 KiB |
BIN
screenshots/3.png
Normal file
After Width: | Height: | Size: 29 KiB |
BIN
screenshots/4.png
Normal file
After Width: | Height: | Size: 46 KiB |
3
start-app.sh
Executable file
@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
sudo docker compose up -d
|
3
stop-app.sh
Executable file
@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
sudo docker compose down
|