Merge
This commit is contained in:
commit
523a211933
24 changed files with 490 additions and 55 deletions
|
@ -1,4 +1,5 @@
|
|||
**/target
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/.sqlite3
|
||||
**/bin
|
||||
**/*.sqlite3
|
||||
|
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -48,5 +48,6 @@ dist/
|
|||
*.7z
|
||||
*.bak
|
||||
|
||||
backend/backups
|
||||
|
||||
|
||||
config.toml
|
||||
|
|
2
BUILD.md
2
BUILD.md
|
@ -2,7 +2,7 @@
|
|||
|
||||
The build is fairly simple, and I intend to keep it that way. The project is split into two main parts: the backend and the frontend.
|
||||
|
||||
Making all of these parts work properly under linux/macOS is fairly simple, but Windows is untested territory. You can always contact [Billy G™®©](https://support.microsoft.com) for help. (If there is demand for Windows support, I will make it work)
|
||||
Making all of these parts work properly under linux/macOS is fairly simple, but Windows will require [WSL](https://learn.microsoft.com/en-us/windows/wsl/).
|
||||
|
||||
When working with these tools, keep in mind that make is sensitive to working directory. If you're in the wrong directory, make will not work as expected. Always make sure you're in the right directory when running make commands.
|
||||
|
||||
|
|
7
Justfile
7
Justfile
|
@ -28,4 +28,9 @@ clean: remove-podman-containers
|
|||
rm -rf frontend/node_modules
|
||||
rm -f ttime-server.tar.gz
|
||||
cd backend && make clean
|
||||
@echo "Cleaned up!"
|
||||
@echo "Cleaned up!"
|
||||
|
||||
# Cleans up everything related to podman, not just the project. Make sure you understand what this means.
|
||||
[confirm]
|
||||
podman-clean:
|
||||
podman system reset --force
|
|
@ -19,6 +19,9 @@ build:
|
|||
run: build
|
||||
./bin/server
|
||||
|
||||
watch: build
|
||||
watchexec -w . -r make run
|
||||
|
||||
# Clean target
|
||||
clean:
|
||||
$(GOCLEAN)
|
||||
|
@ -26,8 +29,8 @@ clean:
|
|||
rm -f db.sqlite3
|
||||
|
||||
# Test target
|
||||
test:
|
||||
$(GOTEST) ./...
|
||||
test: db.sqlite3
|
||||
$(GOTEST) ./... -count=1 -v
|
||||
|
||||
# Get dependencies target
|
||||
deps:
|
||||
|
@ -46,9 +49,19 @@ migrate:
|
|||
sqlite3 $(DB_FILE) < $$file; \
|
||||
done
|
||||
|
||||
# Target added primarily for CI/CD to ensure that the database is created before running tests
|
||||
db.sqlite3:
|
||||
make migrate
|
||||
|
||||
backup:
|
||||
mkdir -p backups
|
||||
sqlite3 $(DB_FILE) .dump | gzip -9 > ./backups/BACKUP_$(DB_FILE)_$(shell date +"%Y-%m-%d_%H:%M:%S").sql.gz
|
||||
# Restore with:
|
||||
# gzip -cd BACKUP_FILE.sql.gz | sqlite3 $(DB_FILE)
|
||||
|
||||
# Format
|
||||
fmt:
|
||||
$(GOCMD) fmt ./...
|
||||
|
||||
|
||||
# Default target
|
||||
default: build
|
||||
default: build
|
||||
|
|
|
@ -5,6 +5,7 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"ttime/internal/config"
|
||||
"ttime/internal/database"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
|
@ -12,15 +13,18 @@ import (
|
|||
|
||||
// The button state as represented in memory
|
||||
type ButtonState struct {
|
||||
pressCount int
|
||||
PressCount int `json:"pressCount"`
|
||||
}
|
||||
|
||||
// This is what a handler with a receiver looks like
|
||||
// Keep in mind that concurrent state access is not (usually) safe
|
||||
// And will in pracice be guarded by a mutex
|
||||
// And will in practice be guarded by a mutex
|
||||
func (b *ButtonState) pressHandler(w http.ResponseWriter, r *http.Request) {
|
||||
b.pressCount++
|
||||
response, err := json.Marshal(b.pressCount)
|
||||
if r.Method == "POST" {
|
||||
b.PressCount++
|
||||
}
|
||||
|
||||
response, err := json.Marshal(b)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
@ -37,21 +41,33 @@ func handler(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
func main() {
|
||||
database.DbConnect()
|
||||
b := &ButtonState{pressCount: 0}
|
||||
conf, err := config.ReadConfigFromFile("config.toml")
|
||||
if err != nil {
|
||||
conf = config.NewConfig()
|
||||
conf.WriteConfigToFile("config.toml")
|
||||
}
|
||||
|
||||
// Pretty print the current config
|
||||
str, _ := json.MarshalIndent(conf, "", " ")
|
||||
fmt.Println(string(str))
|
||||
|
||||
database.DbConnect(conf.DbPath)
|
||||
b := &ButtonState{PressCount: 0}
|
||||
|
||||
// Mounting the handlers
|
||||
fs := http.FileServer(http.Dir("static"))
|
||||
http.Handle("/", fs)
|
||||
http.HandleFunc("/hello", handler)
|
||||
http.HandleFunc("/button", b.pressHandler)
|
||||
http.HandleFunc("/api/button", b.pressHandler)
|
||||
|
||||
// Start the server on port 8080
|
||||
println("Currently listening on http://localhost:8080")
|
||||
println("Visit http://localhost:8080/hello to see the hello handler in action")
|
||||
println("Visit http://localhost:8080/button to see the button handler in action")
|
||||
// Construct a server URL
|
||||
server_url := fmt.Sprintf(":%d", conf.Port)
|
||||
|
||||
println("Server running on port", conf.Port)
|
||||
println("Visit http://localhost" + server_url)
|
||||
println("Press Ctrl+C to stop the server")
|
||||
err := http.ListenAndServe(":8080", nil)
|
||||
|
||||
err = http.ListenAndServe(server_url, nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
|
|
@ -5,4 +5,5 @@ go 1.21.1
|
|||
require (
|
||||
github.com/jmoiron/sqlx v1.3.5
|
||||
github.com/mattn/go-sqlite3 v1.14.22
|
||||
github.com/BurntSushi/toml v1.3.2
|
||||
)
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
|
||||
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
|
||||
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
|
||||
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||
github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=
|
||||
|
|
55
backend/internal/config/config.go
Normal file
55
backend/internal/config/config.go
Normal file
|
@ -0,0 +1,55 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
// Config is the configuration for the application
|
||||
// It defines how the TOML file should be structured
|
||||
type Config struct {
|
||||
// The port to listen on
|
||||
Port int `toml:"port"`
|
||||
// The path to the SQLite database
|
||||
DbPath string `toml:"db_path"`
|
||||
// The username to use for the database
|
||||
DbUser string `toml:"db_user"`
|
||||
// The password to use for the database
|
||||
DbPass string `toml:"db_pass"`
|
||||
// The name of the database
|
||||
DbName string `toml:"db_name"`
|
||||
}
|
||||
|
||||
// WriteConfigToFile writes a Config to a file
|
||||
func (c *Config) WriteConfigToFile(filename string) error {
|
||||
f, err := os.Create(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
return toml.NewEncoder(f).Encode(c)
|
||||
}
|
||||
|
||||
// ReadConfigFromFile reads a Config from a file
|
||||
func ReadConfigFromFile(filename string) (*Config, error) {
|
||||
c := NewConfig()
|
||||
_, err := toml.DecodeFile(filename, c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// NewConfig returns a new Config
|
||||
func NewConfig() *Config {
|
||||
return &Config{
|
||||
Port: 8080,
|
||||
DbPath: "./db.sqlite3",
|
||||
DbUser: "username",
|
||||
DbPass: "password",
|
||||
DbName: "ttime",
|
||||
}
|
||||
}
|
68
backend/internal/config/config_test.go
Normal file
68
backend/internal/config/config_test.go
Normal file
|
@ -0,0 +1,68 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewConfig(t *testing.T) {
|
||||
c := NewConfig()
|
||||
if c.Port != 8080 {
|
||||
t.Errorf("Expected port to be 8080, got %d", c.Port)
|
||||
}
|
||||
if c.DbPath != "./db.sqlite3" {
|
||||
t.Errorf("Expected db path to be ./db.sqlite3, got %s", c.DbPath)
|
||||
}
|
||||
if c.DbUser != "username" {
|
||||
t.Errorf("Expected db user to be username, got %s", c.DbUser)
|
||||
}
|
||||
if c.DbPass != "password" {
|
||||
t.Errorf("Expected db pass to be password, got %s", c.DbPass)
|
||||
}
|
||||
if c.DbName != "ttime" {
|
||||
t.Errorf("Expected db name to be ttime, got %s", c.DbName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteConfig(t *testing.T) {
|
||||
c := NewConfig()
|
||||
err := c.WriteConfigToFile("test.toml")
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got %s", err)
|
||||
}
|
||||
|
||||
// Remove the file after the test
|
||||
_ = os.Remove("test.toml")
|
||||
}
|
||||
|
||||
func TestReadConfig(t *testing.T) {
|
||||
c := NewConfig()
|
||||
err := c.WriteConfigToFile("test.toml")
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got %s", err)
|
||||
}
|
||||
|
||||
c2, err := ReadConfigFromFile("test.toml")
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got %s", err)
|
||||
}
|
||||
|
||||
if c.Port != c2.Port {
|
||||
t.Errorf("Expected port to be %d, got %d", c.Port, c2.Port)
|
||||
}
|
||||
if c.DbPath != c2.DbPath {
|
||||
t.Errorf("Expected db path to be %s, got %s", c.DbPath, c2.DbPath)
|
||||
}
|
||||
if c.DbUser != c2.DbUser {
|
||||
t.Errorf("Expected db user to be %s, got %s", c.DbUser, c2.DbUser)
|
||||
}
|
||||
if c.DbPass != c2.DbPass {
|
||||
t.Errorf("Expected db pass to be %s, got %s", c.DbPass, c2.DbPass)
|
||||
}
|
||||
if c.DbName != c2.DbName {
|
||||
t.Errorf("Expected db name to be %s, got %s", c.DbName, c2.DbName)
|
||||
}
|
||||
|
||||
// Remove the file after the test
|
||||
_ = os.Remove("test.toml")
|
||||
}
|
|
@ -1,32 +1,100 @@
|
|||
package database
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
func DbConnect() *sqlx.DB {
|
||||
// Check for the environment variable
|
||||
dbpath := os.Getenv("SQLITE_DB_PATH")
|
||||
// This struct is a wrapper type that holds the database connection
|
||||
// Internally DB holds a connection pool, so it's safe for concurrent use
|
||||
type Db struct {
|
||||
*sqlx.DB
|
||||
}
|
||||
|
||||
// Default to something reasonable
|
||||
if dbpath == "" {
|
||||
dbpath = "./db.sqlite3"
|
||||
}
|
||||
//go:embed migrations
|
||||
var scripts embed.FS
|
||||
|
||||
const userInsert = "INSERT INTO users (username, password) VALUES (?, ?)"
|
||||
const projectInsert = "INSERT INTO projects (name, description, user_id) SELECT ?, ?, id FROM users WHERE username = ?"
|
||||
|
||||
// DbConnect connects to the database
|
||||
func DbConnect(dbpath string) *Db {
|
||||
// Open the database
|
||||
// db, err := sqlx.Connect("sqlite3", ":memory:")
|
||||
db, err := sqlx.Connect("sqlite3", dbpath)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Ping forces the connection to be established
|
||||
err = db.Ping()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return db
|
||||
return &Db{db}
|
||||
}
|
||||
|
||||
// AddUser adds a user to the database
|
||||
func (d *Db) AddUser(username string, password string) error {
|
||||
_, err := d.Exec(userInsert, username, password)
|
||||
return err
|
||||
}
|
||||
|
||||
// Removes a user from the database
|
||||
func (d *Db) RemoveUser(username string) error {
|
||||
_, err := d.Exec("DELETE FROM users WHERE username = ?", username)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Db) GetUserId(username string) (int, error) {
|
||||
var id int
|
||||
err := d.Get(&id, "SELECT id FROM users WHERE username = ?", username)
|
||||
return id, err
|
||||
}
|
||||
|
||||
// Creates a new project in the database, associated with a user
|
||||
func (d *Db) AddProject(name string, description string, username string) error {
|
||||
_, err := d.Exec(projectInsert, name, description, username)
|
||||
return err
|
||||
}
|
||||
|
||||
// Reads a directory of migration files and applies them to the database.
|
||||
// This will eventually be used on an embedded directory
|
||||
func (d *Db) Migrate(dirname string) error {
|
||||
// Read the embedded scripts directory
|
||||
files, err := scripts.ReadDir("migrations")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tr := d.MustBegin()
|
||||
|
||||
// Iterate over each SQL file and execute it
|
||||
for _, file := range files {
|
||||
if file.IsDir() || filepath.Ext(file.Name()) != ".sql" {
|
||||
continue
|
||||
}
|
||||
|
||||
// This is perhaps not the most elegant way to do this
|
||||
sqlFile := filepath.Join("migrations", file.Name())
|
||||
sqlBytes, err := os.ReadFile(sqlFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sqlQuery := string(sqlBytes)
|
||||
_, err = tr.Exec(sqlQuery)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Println("Executed SQL file:", file.Name())
|
||||
}
|
||||
|
||||
tr.Commit()
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -4,7 +4,71 @@ import (
|
|||
"testing"
|
||||
)
|
||||
|
||||
// Tests are not guaranteed to be sequential
|
||||
|
||||
func setupState() (*Db, error) {
|
||||
db := DbConnect(":memory:")
|
||||
err := db.Migrate("../../migrations")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func TestDbConnect(t *testing.T) {
|
||||
db := DbConnect()
|
||||
db := DbConnect(":memory:")
|
||||
_ = db
|
||||
}
|
||||
|
||||
func TestDbAddUser(t *testing.T) {
|
||||
db, err := setupState()
|
||||
if err != nil {
|
||||
t.Error("setupState failed:", err)
|
||||
}
|
||||
err = db.AddUser("test", "password")
|
||||
if err != nil {
|
||||
t.Error("AddUser failed:", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDbGetUserId(t *testing.T) {
|
||||
db, err := setupState()
|
||||
if err != nil {
|
||||
t.Error("setupState failed:", err)
|
||||
}
|
||||
db.AddUser("test", "password")
|
||||
|
||||
var id int
|
||||
|
||||
id, err = db.GetUserId("test")
|
||||
if err != nil {
|
||||
t.Error("GetUserId failed:", err)
|
||||
}
|
||||
if id != 1 {
|
||||
t.Error("GetUserId failed: expected 1, got", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDbAddProject(t *testing.T) {
|
||||
db, err := setupState()
|
||||
if err != nil {
|
||||
t.Error("setupState failed:", err)
|
||||
}
|
||||
|
||||
err = db.AddProject("test", "description", "test")
|
||||
if err != nil {
|
||||
t.Error("AddProject failed:", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDbRemoveUser(t *testing.T) {
|
||||
db, err := setupState()
|
||||
if err != nil {
|
||||
t.Error("setupState failed:", err)
|
||||
}
|
||||
|
||||
err = db.RemoveUser("test")
|
||||
if err != nil {
|
||||
t.Error("RemoveUser failed:", err)
|
||||
}
|
||||
}
|
||||
|
|
9
backend/internal/database/migrations/0010_users.sql
Normal file
9
backend/internal/database/migrations/0010_users.sql
Normal file
|
@ -0,0 +1,9 @@
|
|||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY,
|
||||
userId TEXT DEFAULT (HEX(RANDOMBLOB(4))) NOT NULL UNIQUE,
|
||||
username VARCHAR(255) NOT NULL UNIQUE,
|
||||
password VARCHAR(255) NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS users_username_index ON users (username);
|
||||
CREATE INDEX IF NOT EXISTS users_userId_index ON users (userId);
|
11
backend/internal/database/migrations/0020_projects.sql
Normal file
11
backend/internal/database/migrations/0020_projects.sql
Normal file
|
@ -0,0 +1,11 @@
|
|||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id INTEGER PRIMARY KEY,
|
||||
projectId TEXT DEFAULT (HEX(RANDOMBLOB(4))) NOT NULL UNIQUE,
|
||||
name VARCHAR(255) NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS projects_projectId_index ON projects (projectId);
|
||||
CREATE INDEX IF NOT EXISTS projects_user_id_index ON projects (user_id);
|
19
backend/internal/database/migrations/0030_time_reports.sql
Normal file
19
backend/internal/database/migrations/0030_time_reports.sql
Normal file
|
@ -0,0 +1,19 @@
|
|||
CREATE TABLE IF NOT EXISTS time_reports (
|
||||
id INTEGER PRIMARY KEY,
|
||||
reportId TEXT DEFAULT (HEX(RANDOMBLOB(6))) NOT NULL UNIQUE,
|
||||
project_id INTEGER NOT NULL,
|
||||
start DATETIME NOT NULL,
|
||||
end DATETIME NOT NULL,
|
||||
FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS time_reports_start_before_end
|
||||
BEFORE INSERT ON time_reports
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
SELECT
|
||||
CASE
|
||||
WHEN NEW.start >= NEW.end THEN
|
||||
RAISE (ABORT, 'start must be before end')
|
||||
END;
|
||||
END;
|
|
@ -0,0 +1,9 @@
|
|||
CREATE TABLE IF NOT EXISTS report_collection (
|
||||
id INTEGER PRIMARY KEY,
|
||||
owner_id INTEGER NOT NULL,
|
||||
project_id INTEGER NOT NULL,
|
||||
date DATE NOT NULL,
|
||||
signed_by INTEGER, -- NULL if not signed
|
||||
FOREIGN KEY (owner_id) REFERENCES users (id)
|
||||
FOREIGN KEY (signed_by) REFERENCES users (id)
|
||||
);
|
7
backend/internal/database/migrations/0050_user_roles.sql
Normal file
7
backend/internal/database/migrations/0050_user_roles.sql
Normal file
|
@ -0,0 +1,7 @@
|
|||
CREATE TABLE IF NOT EXISTS user_roles (
|
||||
user_id INTEGER NOT NULL,
|
||||
project_id INTEGER NOT NULL,
|
||||
role STRING NOT NULL, -- 'admin' or 'member'
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
FOREIGN KEY (project_id) REFERENCES projects (id)
|
||||
);
|
14
backend/internal/database/migrations/README.md
Normal file
14
backend/internal/database/migrations/README.md
Normal file
|
@ -0,0 +1,14 @@
|
|||
# Database migrations
|
||||
|
||||
This directory contains all the database migrations for the backend.
|
||||
|
||||
[!WARNING]
|
||||
Keep in mind that these migrations are **not yet stable**.
|
||||
|
||||
## Running migrations
|
||||
|
||||
In the root of the backend directory, run:
|
||||
|
||||
```bash
|
||||
make migrate
|
||||
```
|
|
@ -1,7 +0,0 @@
|
|||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY,
|
||||
username VARCHAR(255) NOT NULL,
|
||||
password VARCHAR(255) NOT NULL
|
||||
);
|
|
@ -4,7 +4,7 @@
|
|||
# make it available in the public directory.
|
||||
FROM docker.io/node:alpine as client
|
||||
WORKDIR /build
|
||||
ADD frontend /build
|
||||
ADD frontend ./
|
||||
RUN npm install
|
||||
RUN npm run build
|
||||
|
||||
|
@ -13,31 +13,45 @@ FROM docker.io/golang:alpine as go
|
|||
RUN apk add gcompat
|
||||
RUN apk add gcc
|
||||
RUN apk add musl-dev
|
||||
ADD backend /build
|
||||
RUN apk add make
|
||||
RUN apk add sqlite
|
||||
WORKDIR /build
|
||||
ADD backend/go.mod backend/go.sum ./
|
||||
|
||||
# Get the dependencies
|
||||
RUN go mod download
|
||||
|
||||
# Add the source code
|
||||
ADD backend .
|
||||
|
||||
RUN make migrate
|
||||
|
||||
# RUN go build -o server
|
||||
RUN CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -o ./server ./cmd/
|
||||
|
||||
# Strip the binary for a smaller image
|
||||
RUN strip ./server
|
||||
|
||||
# The final stage for building a minimal image
|
||||
FROM docker.io/alpine:latest as runner
|
||||
RUN adduser -D nonroot
|
||||
RUN addgroup nonroot nonroot
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the frontend SPA build into public
|
||||
COPY --from=client /build/dist /app/static
|
||||
COPY --from=client /build/dist static
|
||||
|
||||
# Copy the server binary
|
||||
COPY --from=go /build/server /app/server
|
||||
COPY --from=go /build/server server
|
||||
|
||||
# Copy the migration scripts
|
||||
COPY --from=go /build/migrations /app/migrations
|
||||
# Copy the database
|
||||
COPY --from=go /build/db.sqlite3 db.sqlite3
|
||||
|
||||
# Expose port 8080
|
||||
EXPOSE 8080
|
||||
|
||||
# Set the user to nonroot
|
||||
USER nonroot:nonroot
|
||||
|
||||
# Run the server
|
||||
CMD ["./server"]
|
|
@ -3,10 +3,30 @@
|
|||
This is a React + TypeScript + Vite project. It is a single-page application (SPA) that communicates with the backend via a REST API.
|
||||
Most of this looks very complex, but it's mostly just a lot of boilerplate due to the ugodly amount of abstraction that comes with TypeScript and React, and just modern web development in general.
|
||||
|
||||
**Keep in mind** that the tooling for the frontend is configured to be extra strict (see the [eslint config](.eslintrc.cjs)). This is to make it hard to write bad code, by encouraging actual typing and not just using [any](https://www.typescriptlang.org/docs/handbook/basic-types.html#any) everywhere. This can be a bit of a struggle to work with at first, and I might have to backpedal on this if it becomes too much of a hassle. (Those familiar with Java will be more confused by the lack of types than the presence of them.)
|
||||
|
||||
## Build Instructions
|
||||
|
||||
Our build tool, npm, is the only dependency required to build this project. It handles fetching the dependencies and placing them in the famously huge `node_modules` directory, also known as the heaviest object in the known universe. It also handles the build process, which is just a matter of running `npm run build`.
|
||||
Our build tool, npm, is the only dependency required to build this project. It handles fetching the dependencies and placing them in the famously huge `node_modules` directory, also known as the heaviest object in the known universe.
|
||||
|
||||
The build target results in a `dist` directory, which contains the static assets that can be served by a web server. This is the directory that will be served by the backend. Think of this like compiling a program, the .exe/elf/binary is the `dist` directory.
|
||||
### Development
|
||||
|
||||
For a development session, in the `frontend` directory, run:
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
For development, you use `npm run dev` to start a development server. This server will automatically rebuild the project when you make changes to the source code, and it will also automatically reload the page in your browser. The issue is that the development server will run without the backend, **so you will need to run the backend separately during development** if you want any meaningful data to be displayed.
|
||||
|
||||
### Release
|
||||
|
||||
The release build is usually automated by the CI/CD pipeline, but you can also build it manually. In the `frontend` directory, run:
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
The build target results in a `dist` directory, which contains the static assets that can be served by a web server. This is the directory that will be served by the backend. Think of this like compiling a program, the .exe/elf/binary is the `dist` directory.
|
||||
|
|
|
@ -1,11 +1,9 @@
|
|||
import { useState } from "react";
|
||||
import reactLogo from "./assets/react.svg";
|
||||
import viteLogo from "/vite.svg";
|
||||
import "./App.css";
|
||||
import { CountButton } from "./Components/CountButton";
|
||||
|
||||
function App(): JSX.Element {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
|
@ -18,13 +16,7 @@ function App(): JSX.Element {
|
|||
</div>
|
||||
<h1>Vite + React</h1>
|
||||
<div className="card">
|
||||
<button
|
||||
onClick={() => {
|
||||
setCount((count): number => count + 1);
|
||||
}}
|
||||
>
|
||||
count is {count}
|
||||
</button>
|
||||
<CountButton />
|
||||
<p>
|
||||
Edit <code>src/App.tsx</code> and save to test HMR
|
||||
</p>
|
||||
|
|
38
frontend/src/Components/CountButton.tsx
Normal file
38
frontend/src/Components/CountButton.tsx
Normal file
|
@ -0,0 +1,38 @@
|
|||
import { useState, useEffect } from "react";
|
||||
|
||||
// Interface for the response from the server
|
||||
// This should eventually reside in a dedicated file
|
||||
interface CountResponse {
|
||||
pressCount: number;
|
||||
}
|
||||
|
||||
// Some constants for the button
|
||||
const BUTTON_ENDPOINT = "/api/button";
|
||||
|
||||
// A simple button that counts how many times it's been pressed
|
||||
export function CountButton(): JSX.Element {
|
||||
const [count, setCount] = useState<number>(NaN);
|
||||
|
||||
// useEffect with a [] dependency array runs only once
|
||||
useEffect(() => {
|
||||
async function getCount(): Promise<void> {
|
||||
const response = await fetch(BUTTON_ENDPOINT);
|
||||
const data = (await response.json()) as CountResponse;
|
||||
setCount(data.pressCount);
|
||||
}
|
||||
void getCount();
|
||||
}, []);
|
||||
|
||||
// This is what runs on every button click
|
||||
function press(): void {
|
||||
async function pressPost(): Promise<void> {
|
||||
const response = await fetch(BUTTON_ENDPOINT, { method: "POST" });
|
||||
const data = (await response.json()) as CountResponse;
|
||||
setCount(data.pressCount);
|
||||
}
|
||||
void pressPost();
|
||||
}
|
||||
|
||||
// Return some JSX with the button and associated handler
|
||||
return <button onClick={press}>count is {count}</button>;
|
||||
}
|
|
@ -4,4 +4,19 @@ import react from "@vitejs/plugin-react-swc";
|
|||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
// build: {
|
||||
// outDir: '../server/public' // Override default outDir('dist')
|
||||
// },
|
||||
server: {
|
||||
port: 3000,
|
||||
open: true,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:8080/api",
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
rewrite: (path): string => path.replace(/^\/api/, ""),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue