WEB43: Diary website
This commit is contained in:
parent
1f1385dd45
commit
60ad345bce
WEB43-diary
.gitignore.prettierrcmain.gopackage-lock.jsonpackage.jsontailwind.config.js
.vscode
Makefilecontext
go.modgo.suminternal
config
database
middleware
models
routes
session
web/templates
25
WEB43-diary/.gitignore
vendored
Normal file
25
WEB43-diary/.gitignore
vendored
Normal file
@ -0,0 +1,25 @@
|
||||
# generated
|
||||
__debug_bin*
|
||||
assets/
|
||||
uploads/
|
||||
tmp/
|
||||
web43-diary
|
||||
*.db
|
||||
web/**/*_templ.go
|
||||
web/**/*_templ.txt
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# logs
|
||||
*.log*
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
|
||||
# jetbrains setting folder
|
||||
.idea/
|
10
WEB43-diary/.prettierrc
Normal file
10
WEB43-diary/.prettierrc
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"useTabs": true,
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"bracketSameLine": false,
|
||||
"arrowParens": "avoid",
|
||||
"endOfLine": "lf",
|
||||
"printWidth": 140
|
||||
}
|
14
WEB43-diary/.vscode/launch.json
vendored
Normal file
14
WEB43-diary/.vscode/launch.json
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Attach to Go Application",
|
||||
"type": "go",
|
||||
"request": "attach",
|
||||
"mode": "remote",
|
||||
"remotePath": "${workspaceFolder}",
|
||||
"port": 2345,
|
||||
"host": "127.0.0.1"
|
||||
}
|
||||
]
|
||||
}
|
22
WEB43-diary/.vscode/settings.json
vendored
Normal file
22
WEB43-diary/.vscode/settings.json
vendored
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"[go.mod]": {
|
||||
"editor.defaultFormatter": "golang.go"
|
||||
},
|
||||
"[go]": {
|
||||
"editor.defaultFormatter": "golang.go"
|
||||
},
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": "always"
|
||||
},
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.insertSpaces": false,
|
||||
"editor.tabSize": 2,
|
||||
"gopls": {
|
||||
"ui.semanticTokens": true
|
||||
},
|
||||
"tailwindCSS.includeLanguages": {
|
||||
"templ": "gohtmltmpl"
|
||||
},
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
}
|
79
WEB43-diary/Makefile
Normal file
79
WEB43-diary/Makefile
Normal file
@ -0,0 +1,79 @@
|
||||
.PHONY: clean live/watch_images debug
|
||||
|
||||
clean/assets:
|
||||
rm -rf assets/
|
||||
|
||||
clean:
|
||||
make -j1 clean/assets
|
||||
|
||||
# run templ generation in watch mode to detect all .templ files and
|
||||
# re-create _templ.txt files on change, then send reload event to browser.
|
||||
# Default url: http://localhost:7331
|
||||
live/templ:
|
||||
templ generate --watch --proxy="http://localhost:8080" --open-browser=false -v
|
||||
|
||||
build/templ:
|
||||
templ generate
|
||||
|
||||
# run air to detect any go file changes to re-build and re-run the server.
|
||||
live/server:
|
||||
go run github.com/air-verse/air@v1.61.1 \
|
||||
--build.cmd "go build -o tmp/bin/main" --build.bin "tmp/bin/main" --build.delay "100" \
|
||||
--build.exclude_dir "node_modules" \
|
||||
--build.include_ext "go" \
|
||||
--build.stop_on_error "false" \
|
||||
--misc.clean_on_exit true
|
||||
|
||||
# run tailwindcss to generate the *.css bundle in watch mode.
|
||||
live/tailwind:
|
||||
npx tailwindcss -i ./web/assets/css/global.css -o ./assets/css/global.css
|
||||
npx tailwindcss -o ./assets/css/styles.css --watch
|
||||
|
||||
build/tailwind:
|
||||
npx tailwindcss -i ./web/assets/css/global.css -o ./assets/css/global.css --minify
|
||||
npx tailwindcss -o ./assets/css/styles.css --minify
|
||||
|
||||
# run esbuild to generate the index.js bundle in watch mode.
|
||||
live/esbuild:
|
||||
npx esbuild web/assets/js/**/*.ts --bundle --outdir=assets/js/ --watch=forever
|
||||
|
||||
build/esbuild:
|
||||
npx esbuild web/assets/js/**/*.ts --bundle --outdir=assets/js/ --minify
|
||||
|
||||
# watch for any js or css change in the assets/ folder, then reload the browser via templ proxy.
|
||||
live/sync_assets:
|
||||
go run github.com/air-verse/air@v1.61.1 \
|
||||
--build.cmd "templ generate --notify-proxy" \
|
||||
--build.bin "true" \
|
||||
--build.delay "100" \
|
||||
--build.exclude_dir "" \
|
||||
--build.include_dir "assets" \
|
||||
--build.include_ext "js,css"
|
||||
|
||||
live/watch_images:
|
||||
make build/images
|
||||
inotifywait -m -r -e modify,create,delete "web/assets/images" | while read -r line; do \
|
||||
make build/images; \
|
||||
done
|
||||
|
||||
build/images:
|
||||
rm -rf assets/images/
|
||||
mkdir -p assets/images/
|
||||
cp web/assets/images/* assets/images/ || true
|
||||
|
||||
debug:
|
||||
make build
|
||||
dlv debug --headless --listen=:2345 --api-version=2
|
||||
|
||||
# start all 6 watch processes in parallel.
|
||||
live:
|
||||
make clean
|
||||
APP_ENVIRONMENT="DEVELOPMENT" make -j6 live/tailwind live/templ live/server live/esbuild live/sync_assets live/watch_images
|
||||
|
||||
# ENABLE: https://templ.guide/syntax-and-usage/raw-go/
|
||||
# https://templ.guide/commands-and-tools/live-reload-with-other-tools/
|
||||
|
||||
build:
|
||||
make clean
|
||||
make -j4 build/tailwind build/templ build/esbuild build/images
|
||||
go build -ldflags="-s -w"
|
17
WEB43-diary/context/context.go
Normal file
17
WEB43-diary/context/context.go
Normal file
@ -0,0 +1,17 @@
|
||||
package context
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/http"
|
||||
|
||||
"gitea.com/go-chi/session"
|
||||
)
|
||||
|
||||
type Context struct {
|
||||
context.Context
|
||||
DB *sql.DB
|
||||
ResponseWriter http.ResponseWriter
|
||||
Request *http.Request
|
||||
Session session.Store
|
||||
}
|
18
WEB43-diary/go.mod
Normal file
18
WEB43-diary/go.mod
Normal file
@ -0,0 +1,18 @@
|
||||
module gitea.zokki.net/zokki/uni/web43-diary
|
||||
|
||||
go 1.23
|
||||
|
||||
require (
|
||||
github.com/a-h/templ v0.2.793
|
||||
github.com/go-chi/chi/v5 v5.1.0
|
||||
github.com/go-sql-driver/mysql v1.8.1
|
||||
)
|
||||
|
||||
require golang.org/x/sys v0.27.0 // indirect
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
gitea.com/go-chi/session v0.0.0-20240316035857-16768d98ec96 // indirect
|
||||
github.com/unknwon/com v1.0.1 // indirect
|
||||
golang.org/x/crypto v0.29.0
|
||||
)
|
22
WEB43-diary/go.sum
Normal file
22
WEB43-diary/go.sum
Normal file
@ -0,0 +1,22 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
gitea.com/go-chi/session v0.0.0-20240316035857-16768d98ec96 h1:IFDiMBObsP6CZIRaDLd54SR6zPYAffPXiXck5Xslu0Q=
|
||||
gitea.com/go-chi/session v0.0.0-20240316035857-16768d98ec96/go.mod h1:0iEpFKnwO5dG0aF98O4eq6FMsAiXkNBaDIlUOlq4BtM=
|
||||
github.com/a-h/templ v0.2.793 h1:Io+/ocnfGWYO4VHdR0zBbf39PQlnzVCVVD+wEEs6/qY=
|
||||
github.com/a-h/templ v0.2.793/go.mod h1:lq48JXoUvuQrU0VThrK31yFwdRjTCnIE5bcPCM9IP1w=
|
||||
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
||||
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s=
|
||||
github.com/unknwon/com v1.0.1 h1:3d1LTxD+Lnf3soQiD4Cp/0BRB+Rsa/+RTvz8GMMzIXs=
|
||||
github.com/unknwon/com v1.0.1/go.mod h1:tOOxU81rwgoCLoOVVPHb6T/wt8HZygqH5id+GNnlCXM=
|
||||
golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ=
|
||||
golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
|
||||
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
|
||||
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
52
WEB43-diary/internal/config/config.go
Normal file
52
WEB43-diary/internal/config/config.go
Normal file
@ -0,0 +1,52 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"os"
|
||||
"os/user"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type TConfig struct {
|
||||
Port int
|
||||
Environment Environment
|
||||
DatabaseDsn string
|
||||
}
|
||||
|
||||
var Config *TConfig
|
||||
|
||||
func init() {
|
||||
currUser, err := user.Current()
|
||||
if err != nil {
|
||||
currUser = &user.User{Username: ""}
|
||||
}
|
||||
defaultDsn := currUser.Username + "@/web43"
|
||||
|
||||
port := flag.Int("port", getEnvAsInt("APP_PORT", 8080), "Port for the web-server to listen on")
|
||||
environment := flag.String("env", getEnv("APP_ENVIRONMENT", "PRODUCTION"), "Environment (DEVELOPMENT|PRODUCTION)")
|
||||
dbUsn := flag.String("dsn", getEnv("DATABASE_DSN", defaultDsn), "Database connection Data Source Name ([username[:password]@][protocol[(address)]]/dbname)")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
Config = &TConfig{
|
||||
Port: *port,
|
||||
Environment: EnvironmentFromString(*environment),
|
||||
DatabaseDsn: *dbUsn,
|
||||
}
|
||||
}
|
||||
|
||||
func getEnv(key string, defaultValue string) string {
|
||||
value, exists := os.LookupEnv(key)
|
||||
if !exists {
|
||||
return defaultValue
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func getEnvAsInt(key string, defaultValue int) int {
|
||||
valueStr := getEnv(key, "")
|
||||
if value, err := strconv.Atoi(valueStr); err == nil {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
31
WEB43-diary/internal/config/environment.go
Normal file
31
WEB43-diary/internal/config/environment.go
Normal file
@ -0,0 +1,31 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Environment int
|
||||
|
||||
const (
|
||||
EnvironmentProduction Environment = iota
|
||||
|
||||
EnvironmentDevelopment
|
||||
)
|
||||
|
||||
var environments = map[string]Environment{
|
||||
"production": EnvironmentProduction,
|
||||
"development": EnvironmentDevelopment,
|
||||
}
|
||||
|
||||
func EnvironmentFromString(env string) Environment {
|
||||
environment := environments[strings.ToLower(env)]
|
||||
return environment // default -> 0 -> prod
|
||||
}
|
||||
|
||||
func (env Environment) IsProduction() bool {
|
||||
return env == EnvironmentProduction
|
||||
}
|
||||
|
||||
func (env Environment) IsDevelopment() bool {
|
||||
return env == EnvironmentDevelopment
|
||||
}
|
61
WEB43-diary/internal/database/column.go
Normal file
61
WEB43-diary/internal/database/column.go
Normal file
@ -0,0 +1,61 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"log"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type sqlColumn struct {
|
||||
Name string
|
||||
Statement string
|
||||
ForeignKey string
|
||||
}
|
||||
type sqlColumns []sqlColumn
|
||||
|
||||
func (columns sqlColumns) hasAnyForeignKey() bool {
|
||||
return slices.ContainsFunc(columns, func(column sqlColumn) bool { return column.ForeignKey != "" })
|
||||
}
|
||||
|
||||
func (columns sqlColumns) hasForeignKey(name string) bool {
|
||||
return slices.ContainsFunc(columns, func(column sqlColumn) bool { return strings.HasPrefix(column.ForeignKey, name) })
|
||||
}
|
||||
|
||||
type sqlTable struct {
|
||||
Name string
|
||||
Columns sqlColumns
|
||||
PrimaryKey string
|
||||
}
|
||||
|
||||
type ByTableName []*sqlTable
|
||||
|
||||
func (tables ByTableName) Len() int { return len(tables) }
|
||||
func (tables ByTableName) Swap(i, j int) { tables[i], tables[j] = tables[j], tables[i] }
|
||||
func (tables ByTableName) Less(i, j int) bool {
|
||||
// bool -> true: i before j
|
||||
iHasForeignKey := tables[i].Columns.hasAnyForeignKey()
|
||||
jHasForeignKey := tables[j].Columns.hasAnyForeignKey()
|
||||
|
||||
// If one table has no foreign keys and the other does, prioritize the one without
|
||||
if !iHasForeignKey && jHasForeignKey {
|
||||
return true
|
||||
} else if iHasForeignKey && !jHasForeignKey {
|
||||
return false
|
||||
}
|
||||
|
||||
// If both tables have foreign keys, check dependency order
|
||||
iDependsOnJ := tables[i].Columns.hasForeignKey(tables[j].Name)
|
||||
jDependsOnI := tables[j].Columns.hasForeignKey(tables[i].Name)
|
||||
|
||||
if iDependsOnJ && jDependsOnI {
|
||||
log.Fatal("circular refs in sql-tables")
|
||||
}
|
||||
|
||||
if iDependsOnJ {
|
||||
return false
|
||||
} else if jDependsOnI {
|
||||
return true
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
202
WEB43-diary/internal/database/database.go
Normal file
202
WEB43-diary/internal/database/database.go
Normal file
@ -0,0 +1,202 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql" // Import for registration
|
||||
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/context"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/internal/config"
|
||||
)
|
||||
|
||||
type SQLTable interface {
|
||||
LoadForeignValues(*context.Context) error
|
||||
}
|
||||
|
||||
type SQLPrimary interface {
|
||||
GetPrimaryKeys() []string
|
||||
}
|
||||
|
||||
var tablesToCreate = []*sqlTable{}
|
||||
|
||||
func NewDB() (*sql.DB, error) {
|
||||
db, err := sql.Open("mysql", config.Config.DatabaseDsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db.SetConnMaxLifetime(time.Minute * 3)
|
||||
db.SetConnMaxIdleTime(time.Minute * 3)
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func AddCreateTableQueue(table any) {
|
||||
tablesToCreate = append(tablesToCreate, getSQLTableFromInterface(table))
|
||||
}
|
||||
|
||||
func CreateTablesFromQueue() {
|
||||
db, err := NewDB()
|
||||
if err != nil {
|
||||
log.Fatal("[CreateTable] connect db: ", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
sort.Sort(ByTableName(tablesToCreate))
|
||||
|
||||
for _, table := range tablesToCreate {
|
||||
sqlStatement := fmt.Sprintf("CREATE TABLE IF NOT EXISTS `%s` (", table.Name)
|
||||
sqlForeignKeys := ""
|
||||
|
||||
for _, column := range table.Columns {
|
||||
sqlStatement += fmt.Sprintf("%s %s, ", column.Name, column.Statement)
|
||||
|
||||
if column.ForeignKey != "" {
|
||||
sqlForeignKeys += fmt.Sprintf("FOREIGN KEY (%s) REFERENCES %s, ", column.Name, column.ForeignKey)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := db.Exec(strings.TrimSuffix(sqlStatement+sqlForeignKeys+table.PrimaryKey, ", ") + ")"); err != nil {
|
||||
log.Fatal(fmt.Sprintf("[CreateTable] create table `%s`: ", table.Name), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func GetOne[TableVal SQLTable](ctx *context.Context, val TableVal) (TableVal, error) {
|
||||
return GetOneWhere(ctx, QueryBuilderFromInterface(val))
|
||||
}
|
||||
|
||||
func GetOneWhere[TableVal SQLTable](ctx *context.Context, queryBuilder *queryBuilder[TableVal]) (TableVal, error) {
|
||||
var empty TableVal
|
||||
|
||||
sqlQuery, sqlValues := queryBuilder.Limit(1).BuildSelect()
|
||||
sqlRows, err := ctx.DB.QueryContext(ctx, sqlQuery, sqlValues...)
|
||||
if err != nil {
|
||||
return empty, err
|
||||
}
|
||||
defer sqlRows.Close()
|
||||
|
||||
if !sqlRows.Next() {
|
||||
return empty, fmt.Errorf("no data found for table '%s'", queryBuilder.table.Name)
|
||||
}
|
||||
|
||||
columns, err := sqlRows.Columns()
|
||||
if err != nil {
|
||||
return empty, err
|
||||
}
|
||||
|
||||
values := make([]any, len(columns))
|
||||
columnPointers := make([]any, len(columns))
|
||||
for i := range values {
|
||||
columnPointers[i] = &values[i]
|
||||
}
|
||||
|
||||
if err := sqlRows.Scan(columnPointers...); err != nil {
|
||||
return empty, err
|
||||
}
|
||||
|
||||
val := queryBuilder.object
|
||||
deserialize(val, columns, values)
|
||||
|
||||
sqlRows.Close() // needs to be closed before using db again
|
||||
err = val.LoadForeignValues(ctx)
|
||||
if err != nil {
|
||||
return empty, err
|
||||
}
|
||||
|
||||
return val, nil
|
||||
}
|
||||
|
||||
func GetAll[TableVal SQLTable](ctx *context.Context, val TableVal) ([]TableVal, error) {
|
||||
return GetAllWhere(ctx, QueryBuilderFromInterface(val))
|
||||
}
|
||||
|
||||
func GetAllWhere[TableVal SQLTable](ctx *context.Context, queryBuilder *queryBuilder[TableVal]) ([]TableVal, error) {
|
||||
sqlQuery, sqlValues := queryBuilder.BuildSelect()
|
||||
sqlRows, err := ctx.DB.QueryContext(ctx, sqlQuery, sqlValues...)
|
||||
if err != nil {
|
||||
log.Println("err", err, sqlQuery, sqlValues)
|
||||
return nil, err
|
||||
}
|
||||
defer sqlRows.Close()
|
||||
|
||||
columns, err := sqlRows.Columns()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
values := make([]any, len(columns))
|
||||
columnPointers := make([]any, len(columns))
|
||||
for i := range values {
|
||||
columnPointers[i] = &values[i]
|
||||
}
|
||||
|
||||
tableRows := []TableVal{}
|
||||
reflectType := reflect.TypeOf(queryBuilder.object).Elem()
|
||||
for sqlRows.Next() {
|
||||
if err := sqlRows.Scan(columnPointers...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tableVal := reflect.New(reflectType).Interface().(TableVal)
|
||||
deserialize(tableVal, columns, values)
|
||||
|
||||
tableRows = append(tableRows, tableVal)
|
||||
}
|
||||
|
||||
sqlRows.Close() // needs to be closed before using db again
|
||||
for _, row := range tableRows {
|
||||
err = row.LoadForeignValues(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return tableRows, nil
|
||||
}
|
||||
|
||||
func InsertInto(ctx *context.Context, table any) (int64, error) {
|
||||
sqlTableName := getSQLTableName(table)
|
||||
columns, tableValues := serialize(table)
|
||||
if len(columns) < 1 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
res, err := ctx.DB.ExecContext(ctx, fmt.Sprintf("INSERT INTO `%s` (%s) VALUES (%s ?)", sqlTableName, strings.Join(columns, ", "), strings.Repeat("?, ", len(tableValues)-1)), tableValues...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
func Update[TableVal SQLTable](ctx *context.Context, searchTable TableVal, newTable TableVal) error {
|
||||
newTableColumns, newTableValues := serialize(newTable)
|
||||
if len(newTableColumns) < 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
queryBuilder := QueryBuilderFromInterface(searchTable)
|
||||
where, whereValues := queryBuilder.where.Build(true)
|
||||
|
||||
queryValues := append(newTableValues, whereValues...)
|
||||
_, err := ctx.DB.ExecContext(ctx, fmt.Sprintf("UPDATE `%s` SET %s = ? %s", queryBuilder.table.Name, strings.Join(newTableColumns, " = ?, "), where), queryValues...)
|
||||
return err
|
||||
}
|
||||
|
||||
func Delete(ctx *context.Context, table SQLTable) error {
|
||||
sqlQuery, sqlValues := QueryBuilderFromInterface(table).BuildDelete()
|
||||
_, err := ctx.DB.ExecContext(ctx, sqlQuery, sqlValues...)
|
||||
return err
|
||||
}
|
162
WEB43-diary/internal/database/helper.go
Normal file
162
WEB43-diary/internal/database/helper.go
Normal file
@ -0,0 +1,162 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func getSQLTableName(table any) string {
|
||||
reflectType := reflect.TypeOf(table)
|
||||
if reflectType.Kind() == reflect.Pointer {
|
||||
reflectType = reflectType.Elem()
|
||||
}
|
||||
return toSnakeCase(reflectType.Name())
|
||||
}
|
||||
|
||||
func toSnakeCase(str string) string {
|
||||
var result []rune
|
||||
for i, r := range str {
|
||||
if i > 0 && r >= 'A' && r <= 'Z' {
|
||||
result = append(result, '_')
|
||||
}
|
||||
result = append(result, r)
|
||||
}
|
||||
return strings.ToLower(string(result))
|
||||
}
|
||||
|
||||
func getSQLColumns(table any) []sqlColumn {
|
||||
columns := []sqlColumn{}
|
||||
|
||||
reflectValue := reflect.ValueOf(table)
|
||||
if reflectValue.Kind() == reflect.Pointer {
|
||||
reflectValue = reflectValue.Elem()
|
||||
}
|
||||
reflectType := reflectValue.Type()
|
||||
|
||||
for i := 0; i < reflectType.NumField(); i++ {
|
||||
field := reflectType.Field(i)
|
||||
dbTag := field.Tag.Get("db")
|
||||
if len(dbTag) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
if dbType := field.Tag.Get("dbType"); len(dbType) > 0 {
|
||||
dbTag = dbType + " " + dbTag
|
||||
} else if dbType = goToMySQLTypes[field.Type.String()]; len(dbType) > 0 {
|
||||
dbTag = dbType + " " + dbTag
|
||||
} else {
|
||||
log.Fatalf("[getSQLColumns] no type found for column '%s' -> '%s'", field.Name, field.Type)
|
||||
}
|
||||
|
||||
foreignKey := field.Tag.Get("foreignKey")
|
||||
columns = append(columns, sqlColumn{Name: field.Name, Statement: dbTag, ForeignKey: foreignKey})
|
||||
}
|
||||
|
||||
return columns
|
||||
}
|
||||
|
||||
// @TODO: maybe save result from the func globally to avoid reflect overuse
|
||||
func getSQLTableFromInterface(table any) *sqlTable {
|
||||
tableName := getSQLTableName(table)
|
||||
columns := getSQLColumns(table)
|
||||
|
||||
var primaryKey string
|
||||
if primary, ok := table.(SQLPrimary); ok {
|
||||
primaryKeys := primary.GetPrimaryKeys()
|
||||
|
||||
if len(primaryKeys) > 0 {
|
||||
if slices.ContainsFunc(primaryKeys, func(key string) bool {
|
||||
return !slices.ContainsFunc(columns, func(column sqlColumn) bool { return column.Name == key })
|
||||
}) {
|
||||
log.Fatalf("[AddToCreateTable]: invalid primary key for table '%s'", tableName)
|
||||
}
|
||||
|
||||
primaryKey = fmt.Sprintf("PRIMARY KEY (%s), ", strings.Join(primaryKeys, ", "))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return &sqlTable{Name: tableName, Columns: columns, PrimaryKey: primaryKey}
|
||||
}
|
||||
|
||||
// to db
|
||||
func serialize(val any) ([]string, []any) {
|
||||
reflectValue := reflect.ValueOf(val)
|
||||
if reflectValue.Kind() == reflect.Pointer {
|
||||
reflectValue = reflectValue.Elem()
|
||||
}
|
||||
reflectType := reflectValue.Type()
|
||||
|
||||
var columns []string
|
||||
var values []any
|
||||
|
||||
for i := 0; i < reflectType.NumField(); i++ {
|
||||
field := reflectType.Field(i)
|
||||
dbTag := field.Tag.Get("db")
|
||||
if len(dbTag) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
reflectField := reflectValue.Field(i)
|
||||
if !reflectField.IsValid() || reflectField.IsZero() {
|
||||
continue
|
||||
}
|
||||
|
||||
columns = append(columns, field.Name)
|
||||
|
||||
switch field.Type {
|
||||
case reflect.TypeOf(time.Time{}):
|
||||
const mysqlTimeFormat = "2006-01-02 15:04:05"
|
||||
values = append(values, reflectField.Interface().(time.Time).Format(mysqlTimeFormat))
|
||||
case reflect.TypeOf(true):
|
||||
if reflectField.Bool() {
|
||||
values = append(values, 1)
|
||||
} else {
|
||||
values = append(values, 0)
|
||||
}
|
||||
default:
|
||||
values = append(values, reflectField.Interface())
|
||||
}
|
||||
}
|
||||
|
||||
return columns, values
|
||||
}
|
||||
|
||||
// from db
|
||||
func deserialize(val any, columns []string, values []any) {
|
||||
reflectElem := reflect.ValueOf(val)
|
||||
if reflectElem.Kind() == reflect.Pointer {
|
||||
reflectElem = reflectElem.Elem()
|
||||
}
|
||||
for i, colName := range columns {
|
||||
field := reflectElem.FieldByName(colName)
|
||||
if !field.IsValid() || !field.CanSet() {
|
||||
continue
|
||||
}
|
||||
|
||||
reflectVal := reflect.ValueOf(values[i])
|
||||
if !reflectVal.IsValid() || reflectVal.IsZero() {
|
||||
field.SetZero()
|
||||
continue
|
||||
}
|
||||
|
||||
switch field.Type() {
|
||||
case reflect.TypeOf(time.Time{}):
|
||||
const mysqlTimeFormat = "2006-01-02 15:04:05"
|
||||
parsed, err := time.Parse(mysqlTimeFormat, string(reflectVal.Bytes()))
|
||||
if err != nil {
|
||||
log.Println("[ERROR] could not parse time from db")
|
||||
continue
|
||||
}
|
||||
field.Set(reflect.ValueOf(parsed))
|
||||
case reflect.TypeOf(true):
|
||||
field.SetBool(reflectVal.Interface() == 1)
|
||||
default:
|
||||
field.Set(reflectVal.Convert(field.Type()))
|
||||
}
|
||||
}
|
||||
}
|
207
WEB43-diary/internal/database/queryBuilder.go
Normal file
207
WEB43-diary/internal/database/queryBuilder.go
Normal file
@ -0,0 +1,207 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type JoinType string
|
||||
type QueryKind string
|
||||
|
||||
const (
|
||||
Join JoinType = "JOIN"
|
||||
NaturalJoin JoinType = "NATURAL JOIN"
|
||||
InnerJoin JoinType = "INNER JOIN"
|
||||
LeftJoin JoinType = "LEFT JOIN"
|
||||
RightJoin JoinType = "RIGHT JOIN"
|
||||
|
||||
SelectQuery QueryKind = "SELECT"
|
||||
DeleteQuery QueryKind = "DELETE"
|
||||
)
|
||||
|
||||
type JoinCondition struct {
|
||||
JoinType JoinType
|
||||
Table string
|
||||
On string
|
||||
}
|
||||
|
||||
type queryBuilder[Obj SQLTable] struct {
|
||||
object Obj
|
||||
table *sqlTable
|
||||
selectedRows []string
|
||||
distinct bool
|
||||
highPriority bool
|
||||
joins []*JoinCondition
|
||||
where WhereGroups
|
||||
havingStr string
|
||||
havingValues []any
|
||||
groupBy []string
|
||||
orderBy []string
|
||||
limit int
|
||||
offset int
|
||||
}
|
||||
|
||||
func NewQueryBuilder[Val SQLTable](obj Val) *queryBuilder[Val] {
|
||||
return &queryBuilder[Val]{object: obj, table: getSQLTableFromInterface(obj)}
|
||||
}
|
||||
|
||||
func QueryBuilderFromInterface[Val SQLTable](obj Val) *queryBuilder[Val] {
|
||||
builder := queryBuilder[Val]{object: obj, table: getSQLTableFromInterface(obj)}
|
||||
|
||||
reflectElem := reflect.ValueOf(obj).Elem()
|
||||
reflectType := reflectElem.Type()
|
||||
for i := 0; i < reflectElem.NumField(); i++ {
|
||||
field := reflectElem.Field(i)
|
||||
if len(reflectType.Field(i).Tag.Get("db")) < 2 || !field.IsValid() || field.IsZero() {
|
||||
continue
|
||||
}
|
||||
|
||||
builder.Where(&QueryCondition{Row: reflectType.Field(i).Name, Operator: Equal, Value: field.Interface()})
|
||||
}
|
||||
|
||||
return &builder
|
||||
}
|
||||
|
||||
func (builder *queryBuilder[TVal]) Select(rows ...string) *queryBuilder[TVal] {
|
||||
if len(rows) > 0 {
|
||||
builder.selectedRows = append(builder.selectedRows, rows...)
|
||||
}
|
||||
return builder
|
||||
}
|
||||
|
||||
func (builder *queryBuilder[TVal]) Distinct() *queryBuilder[TVal] {
|
||||
builder.distinct = true
|
||||
return builder
|
||||
}
|
||||
|
||||
func (builder *queryBuilder[TVal]) HighPriority() *queryBuilder[TVal] {
|
||||
builder.highPriority = true
|
||||
return builder
|
||||
}
|
||||
|
||||
func (builder *queryBuilder[TVal]) Join(joinType JoinType, table SQLTable, on string) *queryBuilder[TVal] {
|
||||
builder.joins = append(builder.joins, &JoinCondition{JoinType: joinType, Table: getSQLTableName(table), On: on})
|
||||
return builder
|
||||
}
|
||||
|
||||
func (builder *queryBuilder[TVal]) Where(conditions ...*QueryCondition) *queryBuilder[TVal] {
|
||||
if len(conditions) > 0 {
|
||||
builder.where = append(builder.where, &WhereGroup{conditions, AND, nil})
|
||||
}
|
||||
return builder
|
||||
}
|
||||
|
||||
func (builder *queryBuilder[TVal]) WhereGroup(groups ...*WhereGroup) *queryBuilder[TVal] {
|
||||
if len(groups) > 0 {
|
||||
builder.where = append(builder.where, groups...)
|
||||
}
|
||||
return builder
|
||||
}
|
||||
|
||||
func (builder *queryBuilder[TVal]) Having(str string, values ...any) *queryBuilder[TVal] {
|
||||
builder.havingStr = str
|
||||
builder.havingValues = values
|
||||
return builder
|
||||
}
|
||||
|
||||
func (builder *queryBuilder[TVal]) GroupBy(columns ...string) *queryBuilder[TVal] {
|
||||
if len(columns) > 0 {
|
||||
builder.groupBy = append(builder.groupBy, columns...)
|
||||
}
|
||||
return builder
|
||||
}
|
||||
|
||||
func (builder *queryBuilder[TVal]) OrderBy(columns ...string) *queryBuilder[TVal] {
|
||||
if len(columns) > 0 {
|
||||
builder.orderBy = append(builder.orderBy, columns...)
|
||||
}
|
||||
return builder
|
||||
}
|
||||
|
||||
func (builder *queryBuilder[TVal]) Limit(limit int) *queryBuilder[TVal] {
|
||||
builder.limit = limit
|
||||
return builder
|
||||
}
|
||||
|
||||
func (builder *queryBuilder[TVal]) Offset(offset int) *queryBuilder[TVal] {
|
||||
builder.offset = offset
|
||||
return builder
|
||||
}
|
||||
|
||||
func (builder *queryBuilder[TVal]) BuildSelect() (string, []any) {
|
||||
return builder.build(SelectQuery)
|
||||
}
|
||||
|
||||
func (builder *queryBuilder[TVal]) BuildDelete() (string, []any) {
|
||||
return builder.build(DeleteQuery)
|
||||
}
|
||||
|
||||
func (builder *queryBuilder[TVal]) build(queryKind QueryKind) (string, []any) {
|
||||
var query strings.Builder
|
||||
|
||||
query.WriteString(fmt.Sprintf("%s ", queryKind))
|
||||
if queryKind == SelectQuery {
|
||||
if builder.distinct {
|
||||
query.WriteString("DISTINCT ")
|
||||
}
|
||||
if builder.highPriority {
|
||||
query.WriteString("HIGH_PRIORITY ")
|
||||
}
|
||||
if len(builder.selectedRows) > 0 {
|
||||
query.WriteString(strings.Join(builder.selectedRows, ", "))
|
||||
} else {
|
||||
query.WriteString("*")
|
||||
}
|
||||
}
|
||||
|
||||
// FROM clause
|
||||
query.WriteString(" FROM ")
|
||||
query.WriteString(builder.table.Name)
|
||||
|
||||
// JOIN clauses
|
||||
for _, join := range builder.joins {
|
||||
if join.On == "" {
|
||||
query.WriteString(fmt.Sprintf(" %s %s", join.JoinType, join.Table))
|
||||
} else {
|
||||
query.WriteString(fmt.Sprintf(" %s %s ON %s", join.JoinType, join.Table, join.On))
|
||||
}
|
||||
}
|
||||
|
||||
// WHERE clause
|
||||
whereStatement, queryValues := builder.where.Build(true)
|
||||
if len(queryValues) > 0 {
|
||||
query.WriteString(whereStatement)
|
||||
}
|
||||
|
||||
// GROUP BY clause
|
||||
if len(builder.groupBy) > 0 {
|
||||
query.WriteString(" GROUP BY ")
|
||||
query.WriteString(strings.Join(builder.groupBy, ", "))
|
||||
}
|
||||
|
||||
// ORDER BY clause
|
||||
if len(builder.orderBy) > 0 {
|
||||
query.WriteString(" ORDER BY ")
|
||||
query.WriteString(strings.Join(builder.orderBy, ", "))
|
||||
}
|
||||
|
||||
// HAVING clause
|
||||
if builder.havingStr != "" {
|
||||
query.WriteString(" HAVING ")
|
||||
query.WriteString(builder.havingStr)
|
||||
queryValues = append(queryValues, builder.havingValues...)
|
||||
}
|
||||
|
||||
// LIMIT clause
|
||||
if builder.limit > 0 {
|
||||
query.WriteString(fmt.Sprintf(" LIMIT %d", builder.limit))
|
||||
}
|
||||
|
||||
// OFFSET clause
|
||||
if builder.offset > 0 {
|
||||
query.WriteString(fmt.Sprintf(" OFFSET %d", builder.offset))
|
||||
}
|
||||
|
||||
return query.String(), queryValues
|
||||
}
|
22
WEB43-diary/internal/database/types.go
Normal file
22
WEB43-diary/internal/database/types.go
Normal file
@ -0,0 +1,22 @@
|
||||
package database
|
||||
|
||||
var goToMySQLTypes = map[string]string{
|
||||
"int": "INT",
|
||||
"int8": "TINYINT",
|
||||
"int16": "SMALLINT",
|
||||
"int32": "INT",
|
||||
"int64": "BIGINT",
|
||||
"uint": "INT UNSIGNED",
|
||||
"uint8": "TINYINT UNSIGNED",
|
||||
"uint16": "SMALLINT UNSIGNED",
|
||||
"uint32": "INT UNSIGNED",
|
||||
"uint64": "BIGINT UNSIGNED",
|
||||
"float32": "FLOAT",
|
||||
"float64": "DOUBLE",
|
||||
"bool": "TINYINT(1)",
|
||||
"string": "VARCHAR(256)",
|
||||
"time.Time": "TIMESTAMP",
|
||||
|
||||
// own types
|
||||
"models.UserRole": "TINYINT",
|
||||
}
|
116
WEB43-diary/internal/database/whereBuilder.go
Normal file
116
WEB43-diary/internal/database/whereBuilder.go
Normal file
@ -0,0 +1,116 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type SQLOperator string
|
||||
type LogicalOperator string
|
||||
|
||||
func GetLogicFromString(operator string) LogicalOperator {
|
||||
switch strings.ToUpper(operator) {
|
||||
case "OR":
|
||||
return OR
|
||||
default:
|
||||
return AND
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
Equal SQLOperator = "="
|
||||
GreaterThan SQLOperator = ">"
|
||||
LessThan SQLOperator = "<"
|
||||
GreaterOrEqual SQLOperator = ">="
|
||||
LessOrEqual SQLOperator = "<="
|
||||
NotEqual SQLOperator = "<>"
|
||||
Like SQLOperator = "LIKE"
|
||||
In SQLOperator = "IN"
|
||||
|
||||
AND LogicalOperator = "AND"
|
||||
OR LogicalOperator = "OR"
|
||||
)
|
||||
|
||||
type QueryCondition struct {
|
||||
Row string
|
||||
Operator SQLOperator
|
||||
Value any
|
||||
}
|
||||
|
||||
type WhereGroup struct {
|
||||
Conditions []*QueryCondition
|
||||
Logic LogicalOperator
|
||||
SubGroups WhereGroups
|
||||
}
|
||||
|
||||
type WhereGroups []*WhereGroup
|
||||
|
||||
func (group *WhereGroup) AddCondition(condition *QueryCondition) {
|
||||
group.Conditions = append(group.Conditions, condition)
|
||||
}
|
||||
|
||||
func (group *WhereGroup) AddSubGroup(subGroup *WhereGroup) {
|
||||
group.SubGroups = append(group.SubGroups, subGroup)
|
||||
}
|
||||
|
||||
func (groups WhereGroups) Build(prependWhere bool) (string, []any) {
|
||||
var whereStatement string
|
||||
var queryValues []any
|
||||
|
||||
groupsLen := len(groups)
|
||||
if prependWhere && groupsLen > 0 {
|
||||
whereStatement = " WHERE "
|
||||
}
|
||||
|
||||
groupStrings := []string{}
|
||||
for _, group := range groups {
|
||||
conditionsLen := len(group.Conditions)
|
||||
subsLen := len(group.SubGroups)
|
||||
if conditionsLen == 0 && subsLen == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
subQuery := strings.Builder{}
|
||||
subQuery.WriteString("(")
|
||||
|
||||
if conditionsLen > 0 {
|
||||
groupConditions := make([]string, conditionsLen)
|
||||
for i, condition := range group.Conditions {
|
||||
switch condition.Operator {
|
||||
case In:
|
||||
values, ok := condition.Value.([]string)
|
||||
if ok {
|
||||
placeholders := strings.Repeat("?,", len(values))
|
||||
placeholders = placeholders[:len(placeholders)-1] // Remove trailing comma
|
||||
groupConditions[i] = fmt.Sprintf("%s %s (%s)", condition.Row, condition.Operator, placeholders)
|
||||
for _, v := range values {
|
||||
queryValues = append(queryValues, v)
|
||||
}
|
||||
continue
|
||||
}
|
||||
fallthrough
|
||||
default:
|
||||
groupConditions[i] = fmt.Sprintf("%s %s ?", condition.Row, condition.Operator)
|
||||
queryValues = append(queryValues, condition.Value)
|
||||
}
|
||||
}
|
||||
|
||||
subQuery.WriteString(strings.Join(groupConditions, fmt.Sprintf(" %s ", group.Logic)))
|
||||
}
|
||||
|
||||
if subsLen > 0 {
|
||||
if conditionsLen > 0 {
|
||||
subQuery.WriteString(fmt.Sprintf(" %s ", group.Logic))
|
||||
}
|
||||
subGroupStatement, values := group.SubGroups.Build(false)
|
||||
subQuery.WriteString(subGroupStatement)
|
||||
queryValues = append(queryValues, values...)
|
||||
}
|
||||
|
||||
subQuery.WriteString(")")
|
||||
groupStrings = append(groupStrings, subQuery.String())
|
||||
}
|
||||
whereStatement += strings.Join(groupStrings, fmt.Sprintf(" %s ", AND))
|
||||
|
||||
return whereStatement, queryValues
|
||||
}
|
31
WEB43-diary/internal/middleware/context.go
Normal file
31
WEB43-diary/internal/middleware/context.go
Normal file
@ -0,0 +1,31 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"gitea.com/go-chi/session"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/context"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/internal/database"
|
||||
)
|
||||
|
||||
func WrapContext(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, req *http.Request) {
|
||||
db, err := database.NewDB()
|
||||
if err != nil {
|
||||
http.Error(writer, fmt.Sprintf("can't connect to database: %s", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
req = req.WithContext(&context.Context{
|
||||
Context: req.Context(),
|
||||
DB: db,
|
||||
ResponseWriter: writer,
|
||||
Request: req,
|
||||
Session: session.GetSession(req),
|
||||
})
|
||||
|
||||
next.ServeHTTP(writer, req)
|
||||
})
|
||||
}
|
45
WEB43-diary/internal/models/diary.go
Normal file
45
WEB43-diary/internal/models/diary.go
Normal file
@ -0,0 +1,45 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/context"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/internal/database"
|
||||
)
|
||||
|
||||
type Diary struct {
|
||||
database.SQLTable
|
||||
ID uint32 `json:"id,omitempty" db:"NOT NULL PRIMARY KEY AUTO_INCREMENT"`
|
||||
CreationTime time.Time `json:"creationTime,omitempty" db:"NOT NULL DEFAULT CURRENT_TIMESTAMP"`
|
||||
OwnerID uint32 `json:"ownerId,omitempty" db:"NOT NULL" foreignKey:"user(ID) ON DELETE CASCADE"`
|
||||
Owner *User `json:"owner,omitempty" db:"-"`
|
||||
Title string `json:"title,omitempty" db:"NOT NULL"`
|
||||
Content string `json:"content,omitempty" dbType:"TEXT" db:"NOT NULL"`
|
||||
Tags []*Tag `json:"tags,omitempty" db:"-"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
database.AddCreateTableQueue(&Diary{})
|
||||
}
|
||||
|
||||
func (diary *Diary) LoadForeignValues(ctx *context.Context) error {
|
||||
var err error
|
||||
diary.Owner, err = database.GetOne(ctx, &User{ID: diary.OwnerID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
diary.Tags, err = database.GetAllWhere(ctx, database.NewQueryBuilder(&Tag{}).
|
||||
Select("tag.*").
|
||||
Join(database.Join, &DiaryTags{}, "`tag`.`ID` = `diary_tags`.`TagID`").
|
||||
Where(&database.QueryCondition{Row: "`diary_tags`.`DiaryID`", Operator: database.Equal, Value: diary.ID}),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (diary *Diary) ShortContent() string {
|
||||
if len(diary.Content) > 200 {
|
||||
return diary.Content[:197] + "..."
|
||||
}
|
||||
return diary.Content
|
||||
}
|
35
WEB43-diary/internal/models/diaryTags.go
Normal file
35
WEB43-diary/internal/models/diaryTags.go
Normal file
@ -0,0 +1,35 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/context"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/internal/database"
|
||||
)
|
||||
|
||||
type DiaryTags struct {
|
||||
database.SQLTable
|
||||
DiaryID uint32 `json:"diaryId,omitempty" db:"NOT NULL" foreignKey:"diary(ID) ON DELETE CASCADE"`
|
||||
Diary *Diary `json:"diary,omitempty" db:"-"`
|
||||
TagID uint32 `json:"tagId,omitempty" db:"NOT NULL" foreignKey:"tag(ID)"`
|
||||
Tag *Tag `json:"tag,omitempty" db:"-"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
database.AddCreateTableQueue(&DiaryTags{})
|
||||
}
|
||||
|
||||
func (diaryTags *DiaryTags) GetPrimaryKeys() []string {
|
||||
return []string{"DiaryID", "TagID"}
|
||||
}
|
||||
|
||||
// @TODO: 2. parameter 'force bool'
|
||||
func (diaryTags *DiaryTags) LoadForeignValues(ctx *context.Context) error {
|
||||
/* var err error
|
||||
diaryTags.Diary, err = database.GetOne(ctx, &Diary{ID: diaryTags.DiaryID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
diaryTags.Tag, err = database.GetOne(ctx, &Tag{ID: diaryTags.TagID})
|
||||
return err */
|
||||
return nil
|
||||
}
|
53
WEB43-diary/internal/models/httpErrors.go
Normal file
53
WEB43-diary/internal/models/httpErrors.go
Normal file
@ -0,0 +1,53 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type HTTPError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data any `json:"data"`
|
||||
}
|
||||
|
||||
func NewHTTPError(code int) *HTTPError {
|
||||
return &HTTPError{
|
||||
Code: code,
|
||||
Message: http.StatusText(code),
|
||||
}
|
||||
}
|
||||
|
||||
func ParseHTTPError(err error) *HTTPError {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if httpErr, ok := err.(*HTTPError); ok {
|
||||
return httpErr
|
||||
}
|
||||
|
||||
return NewHTTPError(http.StatusInternalServerError).SetMessage(err.Error())
|
||||
}
|
||||
|
||||
func IsHTTPError(err error) bool {
|
||||
_, ok := err.(*HTTPError)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (e *HTTPError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
func (err *HTTPError) String() string {
|
||||
return fmt.Sprintf("[ERROR] %d: %s, Data: %+v", err.Code, err.Message, err.Data)
|
||||
}
|
||||
|
||||
func (err *HTTPError) Is(code int) bool {
|
||||
return err.Code == code
|
||||
}
|
||||
|
||||
func (err *HTTPError) SetMessage(message string) *HTTPError {
|
||||
err.Message = message
|
||||
return err
|
||||
}
|
39
WEB43-diary/internal/models/image.go
Normal file
39
WEB43-diary/internal/models/image.go
Normal file
@ -0,0 +1,39 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/context"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/internal/database"
|
||||
)
|
||||
|
||||
type Image struct {
|
||||
database.SQLTable
|
||||
ID uint32 `json:"id,omitempty" db:"NOT NULL PRIMARY KEY AUTO_INCREMENT"`
|
||||
CreationTime time.Time `json:"creationTime,omitempty" db:"NOT NULL DEFAULT CURRENT_TIMESTAMP"`
|
||||
OwnerID uint32 `json:"ownerId,omitempty" db:"NOT NULL" foreignKey:"user(ID) ON DELETE CASCADE"`
|
||||
Owner *User `json:"owner,omitempty" db:"-"`
|
||||
Title string `json:"title,omitempty" db:"NOT NULL"`
|
||||
Type string `json:"type,omitempty" db:"NOT NULL"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
database.AddCreateTableQueue(&Image{})
|
||||
}
|
||||
|
||||
func (image *Image) LoadForeignValues(ctx *context.Context) error {
|
||||
var err error
|
||||
image.Owner, err = database.GetOne(ctx, &User{ID: image.OwnerID})
|
||||
return err
|
||||
}
|
||||
|
||||
func (image *Image) Path() string {
|
||||
path, _ := filepath.Abs(filepath.Join("uploads", strconv.FormatUint(uint64(image.ID), 10)))
|
||||
return path
|
||||
}
|
||||
|
||||
func (image *Image) Dir() string {
|
||||
return filepath.Dir(image.Path())
|
||||
}
|
26
WEB43-diary/internal/models/tag.go
Normal file
26
WEB43-diary/internal/models/tag.go
Normal file
@ -0,0 +1,26 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/context"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/internal/database"
|
||||
)
|
||||
|
||||
type Tag struct {
|
||||
database.SQLTable
|
||||
ID uint32 `json:"id,omitempty" db:"NOT NULL PRIMARY KEY AUTO_INCREMENT"`
|
||||
Title string `json:"title,omitempty" db:"NOT NULL UNIQUE"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
database.AddCreateTableQueue(&Tag{})
|
||||
}
|
||||
|
||||
func (tag *Tag) LoadForeignValues(ctx *context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tag *Tag) IDString() string {
|
||||
return strconv.FormatUint(uint64(tag.ID), 10)
|
||||
}
|
44
WEB43-diary/internal/models/user.go
Normal file
44
WEB43-diary/internal/models/user.go
Normal file
@ -0,0 +1,44 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/context"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/internal/database"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
database.SQLTable
|
||||
ID uint32 `json:"id,omitempty" db:"NOT NULL PRIMARY KEY AUTO_INCREMENT"`
|
||||
CreationTime time.Time `json:"creationTime,omitempty" db:"NOT NULL DEFAULT CURRENT_TIMESTAMP"`
|
||||
FirstName string `json:"firstName,omitempty" db:"NOT NULL"`
|
||||
LastName string `json:"lastName,omitempty" db:"NOT NULL"`
|
||||
Username string `json:"username,omitempty" db:"NOT NULL UNIQUE"`
|
||||
Role UserRole `json:"role,omitempty" db:"NOT NULL DEFAULT 0"`
|
||||
|
||||
Password string `json:"password,omitempty" dbType:"VARCHAR(128)" db:"NOT NULL"` // Hashed password
|
||||
Salt string `json:"salt,omitempty" dbType:"VARCHAR(64)" db:"NOT NULL"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
database.AddCreateTableQueue(&User{})
|
||||
gob.Register(&User{})
|
||||
}
|
||||
|
||||
func (user *User) LoadForeignValues(ctx *context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (user *User) IDString() string {
|
||||
return strconv.FormatUint(uint64(user.ID), 10)
|
||||
}
|
||||
|
||||
func (user *User) Initials() string {
|
||||
return user.FirstName[:1] + user.LastName[:1]
|
||||
}
|
||||
|
||||
func (user *User) FullName() string {
|
||||
return user.FirstName + " " + user.LastName
|
||||
}
|
31
WEB43-diary/internal/models/userRole.go
Normal file
31
WEB43-diary/internal/models/userRole.go
Normal file
@ -0,0 +1,31 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
type UserRole int
|
||||
|
||||
const (
|
||||
NormalUser UserRole = iota
|
||||
|
||||
AdminUser
|
||||
)
|
||||
|
||||
var userRoles = map[string]UserRole{
|
||||
"normal": NormalUser,
|
||||
"admin": AdminUser,
|
||||
}
|
||||
|
||||
func UserRoleFromString(role string) UserRole {
|
||||
userRole := userRoles[strings.ToLower(role)]
|
||||
return userRole // default -> 0 -> normal
|
||||
}
|
||||
|
||||
func (role UserRole) IsNormalUser() bool {
|
||||
return role == NormalUser
|
||||
}
|
||||
|
||||
func (role UserRole) IsAdminUser() bool {
|
||||
return role == AdminUser
|
||||
}
|
35
WEB43-diary/internal/models/webTheme.go
Normal file
35
WEB43-diary/internal/models/webTheme.go
Normal file
@ -0,0 +1,35 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type WebTheme int
|
||||
|
||||
const (
|
||||
DarkTheme WebTheme = iota
|
||||
LightTheme
|
||||
)
|
||||
|
||||
var webThemes = map[string]WebTheme{
|
||||
"dark": DarkTheme,
|
||||
"light": LightTheme,
|
||||
}
|
||||
|
||||
func init() {
|
||||
gob.Register(WebTheme(DarkTheme))
|
||||
}
|
||||
|
||||
func WebThemeFromString(role string) WebTheme {
|
||||
webTheme := webThemes[strings.ToLower(role)]
|
||||
return webTheme // default -> 0 -> dark
|
||||
}
|
||||
|
||||
func (theme WebTheme) IsDarkTheme() bool {
|
||||
return theme == DarkTheme
|
||||
}
|
||||
|
||||
func (theme WebTheme) IsLightTheme() bool {
|
||||
return theme == LightTheme
|
||||
}
|
86
WEB43-diary/internal/routes/base.go
Normal file
86
WEB43-diary/internal/routes/base.go
Normal file
@ -0,0 +1,86 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"gitea.com/go-chi/session"
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/internal/config"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/internal/middleware"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/web/templates"
|
||||
diaryTemplates "gitea.zokki.net/zokki/uni/web43-diary/web/templates/diary"
|
||||
userTemplates "gitea.zokki.net/zokki/uni/web43-diary/web/templates/user"
|
||||
)
|
||||
|
||||
const (
|
||||
DiaryIDValue = "diaryID"
|
||||
UserIDValue = "userID"
|
||||
ImageIDValue = "imageID"
|
||||
)
|
||||
|
||||
func RegisterRoutes(router *chi.Mux, assetFS *embed.FS) {
|
||||
if config.Config.Environment.IsDevelopment() {
|
||||
router.Handle("GET /assets/*", http.StripPrefix("/assets/", http.FileServer(http.Dir("assets"))))
|
||||
} else {
|
||||
router.Handle("GET /assets/*", http.FileServer(http.FS(assetFS)))
|
||||
}
|
||||
|
||||
router.Route("/", func(router chi.Router) {
|
||||
router.Use(session.Sessioner(session.Options{
|
||||
Provider: "file",
|
||||
ProviderConfig: path.Join(os.TempDir(), "web43", "sessions"),
|
||||
CookieName: "i-love-cookies",
|
||||
Domain: "localhost",
|
||||
Maxlifetime: 86400 * 30, // 30 days
|
||||
}))
|
||||
router.Use(middleware.WrapContext)
|
||||
|
||||
router.Get("/", render(templates.Index))
|
||||
router.Get("/settings", render(templates.Settings))
|
||||
|
||||
router.Route("/diary", func(router chi.Router) {
|
||||
router.Get("/", render(diaryTemplates.CreateDiary))
|
||||
router.Post("/", createDiary)
|
||||
|
||||
router.Route(fmt.Sprintf("/{%s:[0-9]+}", DiaryIDValue), func(router chi.Router) {
|
||||
router.Get("/", render(diaryTemplates.ViewDiary))
|
||||
router.Put("/", updateDiary)
|
||||
router.Delete("/", deleteDiary)
|
||||
|
||||
router.Get("/edit", render(diaryTemplates.EditDiary))
|
||||
})
|
||||
})
|
||||
|
||||
router.Route("/user", func(router chi.Router) {
|
||||
router.Post("/theme", updateTheme)
|
||||
|
||||
router.Get("/register", render(userTemplates.Register))
|
||||
router.Post("/register", createUser)
|
||||
|
||||
router.Get("/login", render(userTemplates.Login))
|
||||
router.Post("/login", loginUser)
|
||||
|
||||
router.Route(fmt.Sprintf("/{%s:[0-9]+}", UserIDValue), func(router chi.Router) {
|
||||
router.Put("/", updateUser)
|
||||
router.Delete("/", deleteUser)
|
||||
|
||||
router.Put("/password", updateUserPassword)
|
||||
|
||||
router.Get("/logout", logoutUser)
|
||||
})
|
||||
})
|
||||
|
||||
router.Route("/image", func(router chi.Router) {
|
||||
router.Post("/", createImage)
|
||||
|
||||
router.Route(fmt.Sprintf("/{%s:[0-9]+}", ImageIDValue), func(router chi.Router) {
|
||||
router.Get("/", getImage)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
259
WEB43-diary/internal/routes/diary.go
Normal file
259
WEB43-diary/internal/routes/diary.go
Normal file
@ -0,0 +1,259 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/context"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/internal/database"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/internal/models"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/internal/session"
|
||||
)
|
||||
|
||||
func createDiary(writer http.ResponseWriter, req *http.Request) {
|
||||
sess := session.GetSession(req)
|
||||
currentUser := sess.GetUser()
|
||||
|
||||
if currentUser.ID <= 0 {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Sie müssen angemeldet sein",
|
||||
Code: http.StatusUnauthorized,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
title := req.FormValue("title")
|
||||
if title == "" {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Titel darf nicht leer sein",
|
||||
Code: http.StatusBadRequest,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
markdown := req.FormValue("markdown")
|
||||
if markdown == "" {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Markdown darf nicht leer sein",
|
||||
Code: http.StatusBadRequest,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
diary := &models.Diary{
|
||||
Owner: currentUser,
|
||||
OwnerID: currentUser.ID,
|
||||
Title: title,
|
||||
Content: markdown,
|
||||
}
|
||||
insertedId, err := database.InsertInto(req.Context().(*context.Context), diary)
|
||||
if err != nil {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Fehler beim Einfügen vom Tagebucheintrag in die Datenbank",
|
||||
Code: http.StatusInternalServerError,
|
||||
Data: err,
|
||||
})
|
||||
}
|
||||
diary.ID = uint32(insertedId)
|
||||
|
||||
tags := req.Form["tags"]
|
||||
if len(tags) > 0 {
|
||||
dbTags, err := database.GetAll(req.Context().(*context.Context), &models.Tag{})
|
||||
if err != nil {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Fehler beim Lesen von Tags aus der Datenbank",
|
||||
Code: http.StatusInternalServerError,
|
||||
Data: err,
|
||||
})
|
||||
}
|
||||
|
||||
for _, tag := range tags {
|
||||
if slices.ContainsFunc(dbTags, func(t *models.Tag) bool { return t.Title == tag }) {
|
||||
continue
|
||||
}
|
||||
|
||||
tagId, err := database.InsertInto(req.Context().(*context.Context), &models.Tag{Title: tag})
|
||||
if err != nil {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Fehler beim Erstellen von Tag",
|
||||
Code: http.StatusInternalServerError,
|
||||
Data: err,
|
||||
})
|
||||
}
|
||||
|
||||
_, err = database.InsertInto(req.Context().(*context.Context), &models.DiaryTags{DiaryID: diary.ID, TagID: uint32(tagId)})
|
||||
if err != nil {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Fehler beim Erstellen von Tag",
|
||||
Code: http.StatusInternalServerError,
|
||||
Data: err,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
http.Redirect(writer, req, fmt.Sprintf("/diary/%d", diary.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func updateDiary(writer http.ResponseWriter, req *http.Request) {
|
||||
sess := session.GetSession(req)
|
||||
currentUser := sess.GetUser()
|
||||
|
||||
if currentUser.ID <= 0 {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Sie müssen angemeldet sein",
|
||||
Code: http.StatusUnauthorized,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
idToGet, err := strconv.ParseUint(req.PathValue(DiaryIDValue), 10, 32)
|
||||
if err != nil || idToGet == 0 {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Malformed Tagebuch ID",
|
||||
Code: http.StatusBadRequest,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
diary, err := database.GetOne(req.Context().(*context.Context), &models.Diary{ID: uint32(idToGet)})
|
||||
if err != nil {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Tagebucheintrag nicht gefunden",
|
||||
Code: http.StatusBadRequest,
|
||||
Data: err,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if diary.OwnerID != currentUser.ID && !currentUser.Role.IsAdminUser() {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Sie dürfen diesen Tagebucheintrag nicht bearbeiten",
|
||||
Code: http.StatusUnauthorized,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
title := req.FormValue("title")
|
||||
if title == "" {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Titel darf nicht leer sein",
|
||||
Code: http.StatusBadRequest,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
markdown := req.FormValue("markdown")
|
||||
if markdown == "" {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Markdown darf nicht leer sein",
|
||||
Code: http.StatusBadRequest,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
newDiary := &models.Diary{
|
||||
Owner: currentUser,
|
||||
OwnerID: currentUser.ID,
|
||||
Title: title,
|
||||
Content: markdown,
|
||||
}
|
||||
err = database.Update(req.Context().(*context.Context), diary, newDiary)
|
||||
if err != nil {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Fehler beim Einfügen vom Tagebucheintrag in die Datenbank",
|
||||
Code: http.StatusInternalServerError,
|
||||
Data: err,
|
||||
})
|
||||
}
|
||||
|
||||
err = database.Delete(req.Context().(*context.Context), &models.DiaryTags{DiaryID: diary.ID})
|
||||
if err != nil {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Fehler beim bearbeiten von Tags",
|
||||
Code: http.StatusInternalServerError,
|
||||
Data: err,
|
||||
})
|
||||
}
|
||||
|
||||
tags := req.Form["tags"]
|
||||
if len(tags) > 0 {
|
||||
dbTags, err := database.GetAll(req.Context().(*context.Context), &models.Tag{})
|
||||
if err != nil {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Fehler beim Lesen von Tags aus der Datenbank",
|
||||
Code: http.StatusInternalServerError,
|
||||
Data: err,
|
||||
})
|
||||
}
|
||||
|
||||
for _, tag := range tags {
|
||||
index := slices.IndexFunc(dbTags, func(t *models.Tag) bool { return t.Title == tag })
|
||||
if index >= 0 {
|
||||
_, err = database.InsertInto(req.Context().(*context.Context), &models.DiaryTags{DiaryID: diary.ID, TagID: dbTags[index].ID})
|
||||
if err != nil {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Fehler beim Erstellen von Tag",
|
||||
Code: http.StatusInternalServerError,
|
||||
Data: err,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
tagId, err := database.InsertInto(req.Context().(*context.Context), &models.Tag{Title: tag})
|
||||
if err != nil {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Fehler beim Erstellen von Tag",
|
||||
Code: http.StatusInternalServerError,
|
||||
Data: err,
|
||||
})
|
||||
}
|
||||
|
||||
_, err = database.InsertInto(req.Context().(*context.Context), &models.DiaryTags{DiaryID: diary.ID, TagID: uint32(tagId)})
|
||||
if err != nil {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Fehler beim Erstellen von Tag",
|
||||
Code: http.StatusInternalServerError,
|
||||
Data: err,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
http.Redirect(writer, req, fmt.Sprintf("/diary/%d", diary.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func deleteDiary(writer http.ResponseWriter, req *http.Request) {
|
||||
sess := session.GetSession(req)
|
||||
currentUser := sess.GetUser()
|
||||
|
||||
if currentUser.ID <= 0 {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Sie müssen angemeldet sein",
|
||||
Code: http.StatusUnauthorized,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
idToDelete, err := strconv.ParseUint(req.PathValue(DiaryIDValue), 10, 32)
|
||||
if err != nil || idToDelete == 0 {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Malformed Tagebuch ID",
|
||||
Code: http.StatusBadRequest,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err = database.Delete(req.Context().(*context.Context), &models.Diary{ID: uint32(idToDelete)})
|
||||
if err != nil {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Fehler beim Löschen des Tagebucheintrags",
|
||||
Code: http.StatusBadRequest,
|
||||
Data: err,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
91
WEB43-diary/internal/routes/helper.go
Normal file
91
WEB43-diary/internal/routes/helper.go
Normal file
@ -0,0 +1,91 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/a-h/templ"
|
||||
"golang.org/x/crypto/sha3"
|
||||
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/context"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/internal/models"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/web/templates"
|
||||
)
|
||||
|
||||
func render(component func(*context.Context) templ.Component) http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, req *http.Request) {
|
||||
err := component(req.Context().(*context.Context)).Render(req.Context(), writer)
|
||||
if httpErr, ok := err.(*models.HTTPError); ok {
|
||||
log.Println(httpErr.String())
|
||||
templates.Unauthorized(req.Context().(*context.Context)).Render(req.Context(), writer)
|
||||
} else if err != nil {
|
||||
log.Println(err)
|
||||
http.Error(writer, fmt.Sprintf("can't render component: %s", err), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func errorJson(writer http.ResponseWriter, err *models.HTTPError) {
|
||||
log.Println(err.String())
|
||||
|
||||
if err.Code != http.StatusInternalServerError {
|
||||
header := writer.Header()
|
||||
|
||||
header.Del("Content-Length")
|
||||
header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
writer.WriteHeader(err.Code)
|
||||
json.NewEncoder(writer).Encode(err)
|
||||
} else {
|
||||
http.Error(writer, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func hashPassword(password string, salt string) (string, string, *models.HTTPError) {
|
||||
var saltBytes []byte
|
||||
var err error
|
||||
|
||||
if salt == "" {
|
||||
saltBytes = make([]byte, 32)
|
||||
if _, err = io.ReadFull(rand.Reader, saltBytes); err != nil {
|
||||
return "", "", &models.HTTPError{
|
||||
Message: "unable to generate salt",
|
||||
Code: http.StatusInternalServerError,
|
||||
Data: err,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
saltBytes, err = base64.StdEncoding.DecodeString(salt)
|
||||
if err != nil {
|
||||
return "", "", &models.HTTPError{
|
||||
Message: "unable to decode provided salt",
|
||||
Code: http.StatusInternalServerError,
|
||||
Data: err,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hasher := sha3.New512()
|
||||
_, err = hasher.Write(saltBytes)
|
||||
if err != nil {
|
||||
return "", "", &models.HTTPError{
|
||||
Message: "unable to write salt to hasher",
|
||||
Code: http.StatusInternalServerError,
|
||||
Data: err,
|
||||
}
|
||||
}
|
||||
_, err = hasher.Write([]byte(password))
|
||||
if err != nil {
|
||||
return "", "", &models.HTTPError{
|
||||
Message: "unable to write password to hasher",
|
||||
Code: http.StatusInternalServerError,
|
||||
Data: err,
|
||||
}
|
||||
}
|
||||
|
||||
return base64.StdEncoding.EncodeToString(hasher.Sum(nil)), base64.StdEncoding.EncodeToString(saltBytes), nil
|
||||
}
|
133
WEB43-diary/internal/routes/image.go
Normal file
133
WEB43-diary/internal/routes/image.go
Normal file
@ -0,0 +1,133 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/context"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/internal/database"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/internal/models"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/internal/session"
|
||||
)
|
||||
|
||||
func createImage(writer http.ResponseWriter, req *http.Request) {
|
||||
sess := session.GetSession(req)
|
||||
currentUser := sess.GetUser()
|
||||
|
||||
if currentUser.ID <= 0 {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Sie müssen angemeldet sein",
|
||||
Code: http.StatusUnauthorized,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
title := req.FormValue("title")
|
||||
if title == "" {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Titel darf nicht leer sein",
|
||||
Code: http.StatusBadRequest,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the multipart form, with a max memory of 1GB
|
||||
err := req.ParseMultipartForm(1 << 23)
|
||||
if err != nil {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Fehler beim Parsen der Daten",
|
||||
Code: http.StatusBadRequest,
|
||||
Data: err,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Retrieve the file from form data
|
||||
file, handler, err := req.FormFile("file")
|
||||
if err != nil {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Fehler beim Parsen vom Bild",
|
||||
Code: http.StatusBadRequest,
|
||||
Data: err,
|
||||
})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
image := &models.Image{
|
||||
Owner: currentUser,
|
||||
OwnerID: currentUser.ID,
|
||||
Title: title,
|
||||
Type: handler.Header.Get("Content-Type"),
|
||||
}
|
||||
insertedId, err := database.InsertInto(req.Context().(*context.Context), image)
|
||||
if err != nil {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Fehler beim Einfügen des Bildes in die Datenbank",
|
||||
Code: http.StatusInternalServerError,
|
||||
Data: err,
|
||||
})
|
||||
}
|
||||
image.ID = uint32(insertedId)
|
||||
|
||||
err = os.MkdirAll(image.Dir(), 0777)
|
||||
if err != nil {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Fehler beim Erstellen vom Pfad auf dem Server",
|
||||
Code: http.StatusInternalServerError,
|
||||
Data: err,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
dst, err := os.Create(image.Path())
|
||||
if err != nil {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Fehler beim Erstellen der Datei auf dem Server",
|
||||
Code: http.StatusInternalServerError,
|
||||
Data: err,
|
||||
})
|
||||
return
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
_, err = io.Copy(dst, file)
|
||||
if err != nil {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Fehler beim Kopieren der Datei auf den Server",
|
||||
Code: http.StatusInternalServerError,
|
||||
Data: err,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprint(writer, image.ID)
|
||||
}
|
||||
|
||||
func getImage(writer http.ResponseWriter, req *http.Request) {
|
||||
idToGet, err := strconv.ParseUint(req.PathValue(ImageIDValue), 10, 32)
|
||||
if err != nil || idToGet == 0 {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Malformed image ID",
|
||||
Code: http.StatusBadRequest,
|
||||
})
|
||||
return
|
||||
}
|
||||
getID := uint32(idToGet)
|
||||
|
||||
image, err := database.GetOne(req.Context().(*context.Context), &models.Image{ID: getID})
|
||||
if err != nil {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Bild nicht gefunden",
|
||||
Code: http.StatusNotFound,
|
||||
Data: err,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writer.Header().Set("Content-Type", image.Type)
|
||||
http.ServeFile(writer, req, image.Path())
|
||||
}
|
288
WEB43-diary/internal/routes/user.go
Normal file
288
WEB43-diary/internal/routes/user.go
Normal file
@ -0,0 +1,288 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/context"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/internal/database"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/internal/models"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/internal/session"
|
||||
)
|
||||
|
||||
func createUser(writer http.ResponseWriter, req *http.Request) {
|
||||
user := models.User{
|
||||
Username: req.FormValue("username"),
|
||||
FirstName: req.FormValue("firstName"),
|
||||
LastName: req.FormValue("lastName"),
|
||||
Password: req.FormValue("password"),
|
||||
}
|
||||
|
||||
if user.Username == "" || user.FirstName == "" || user.LastName == "" || user.Password == "" {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Es müssen alle Felder gefüllt sein",
|
||||
Code: http.StatusBadRequest,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if user.Password != req.FormValue("passwordRepeat") {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Passwörter müssen übereinstimmen",
|
||||
Code: http.StatusBadRequest,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var httpErr *models.HTTPError
|
||||
user.Password, user.Salt, httpErr = hashPassword(user.Password, "")
|
||||
if httpErr != nil {
|
||||
errorJson(writer, httpErr)
|
||||
return
|
||||
}
|
||||
|
||||
_, err := database.GetOne(req.Context().(*context.Context), &models.User{Username: user.Username})
|
||||
if err == nil {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Benutzername bereits vergeben",
|
||||
Code: http.StatusBadRequest,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
userId, err := database.InsertInto(req.Context().(*context.Context), &user)
|
||||
if err != nil {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "User konnte nicht in die Datenbank gespeichert werden",
|
||||
Code: http.StatusInternalServerError,
|
||||
Data: err,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if userId == 1 {
|
||||
// set first user as admin
|
||||
user.Role = models.AdminUser
|
||||
if err := database.Update(req.Context().(*context.Context), &models.User{ID: uint32(userId)}, &user); err != nil {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Fehler beim Aktualisieren des Benutzers",
|
||||
Code: http.StatusInternalServerError,
|
||||
Data: err,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateTheme(writer http.ResponseWriter, req *http.Request) {
|
||||
body, err := io.ReadAll(io.LimitReader(req.Body, 8))
|
||||
if err != nil {
|
||||
http.Error(writer, "Unable to read request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer req.Body.Close()
|
||||
|
||||
theme := models.WebThemeFromString(string(body))
|
||||
session.GetSession(req).SetTheme(theme)
|
||||
}
|
||||
|
||||
func loginUser(writer http.ResponseWriter, req *http.Request) {
|
||||
username := req.FormValue("username")
|
||||
password := req.FormValue("password")
|
||||
|
||||
if username == "" || password == "" {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Es müssen alle Felder gefüllt sein",
|
||||
Code: http.StatusBadRequest,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := database.GetOne(req.Context().(*context.Context), &models.User{Username: username})
|
||||
if err != nil {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Benutzername oder Passwort ist falsch",
|
||||
Code: http.StatusUnauthorized,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
hashedPassword, _, httpErr := hashPassword(password, user.Salt)
|
||||
if httpErr != nil {
|
||||
errorJson(writer, httpErr)
|
||||
return
|
||||
}
|
||||
if hashedPassword != user.Password {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Benutzername oder Passwort ist falsch - " + hashedPassword + " - " + user.Password,
|
||||
Code: http.StatusUnauthorized,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
session.GetSession(req).SetUser(user)
|
||||
}
|
||||
|
||||
func logoutUser(writer http.ResponseWriter, req *http.Request) {
|
||||
session.GetSession(req).Destroy(writer, req)
|
||||
http.Redirect(writer, req, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func updateUser(writer http.ResponseWriter, req *http.Request) {
|
||||
idToUpdate, err := strconv.ParseUint(req.PathValue(UserIDValue), 10, 32)
|
||||
if err != nil || idToUpdate == 0 {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Malformed user ID",
|
||||
Code: http.StatusBadRequest,
|
||||
})
|
||||
return
|
||||
}
|
||||
updateID := uint32(idToUpdate)
|
||||
|
||||
user := models.User{}
|
||||
|
||||
if username := req.FormValue("username"); username != "" {
|
||||
user.Username = username
|
||||
}
|
||||
if firstName := req.FormValue("firstName"); firstName != "" {
|
||||
user.FirstName = firstName
|
||||
}
|
||||
if lastName := req.FormValue("lastName"); lastName != "" {
|
||||
user.LastName = lastName
|
||||
}
|
||||
|
||||
if user.Username != "" || user.FirstName != "" || user.LastName != "" {
|
||||
sess := session.GetSession(req)
|
||||
currentUser := sess.GetUser()
|
||||
|
||||
if currentUser.ID != updateID && !currentUser.Role.IsAdminUser() {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Sie haben keine Berechtigung, diesen Benutzer zu bearbeiten",
|
||||
Code: http.StatusForbidden,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
_, err := database.GetOne(req.Context().(*context.Context), &models.User{Username: user.Username})
|
||||
if err == nil {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Benutzername bereits vergeben",
|
||||
Code: http.StatusBadRequest,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Update user in the database
|
||||
if err := database.Update(req.Context().(*context.Context), &models.User{ID: updateID}, &user); err != nil {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Fehler beim Aktualisieren des Benutzers",
|
||||
Code: http.StatusInternalServerError,
|
||||
Data: err,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Update user in the session
|
||||
currentUser.Username = user.Username
|
||||
currentUser.FirstName = user.FirstName
|
||||
currentUser.LastName = user.LastName
|
||||
sess.SetUser(currentUser)
|
||||
|
||||
writer.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func updateUserPassword(writer http.ResponseWriter, req *http.Request) {
|
||||
currentPassword := req.FormValue("currentPassword")
|
||||
newPassword := req.FormValue("newPassword")
|
||||
repeatPassword := req.FormValue("passwordRepeat")
|
||||
|
||||
if currentPassword == "" || newPassword == "" || repeatPassword == "" {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Es müssen alle Felder gefüllt sein",
|
||||
Code: http.StatusBadRequest,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if newPassword != repeatPassword {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Passwörter müssen übereinstimmen",
|
||||
Code: http.StatusBadRequest,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
sess := session.GetSession(req)
|
||||
user := sess.GetUser()
|
||||
|
||||
hashedCurrentPassword, _, err := hashPassword(currentPassword, user.Salt)
|
||||
if err != nil {
|
||||
errorJson(writer, err)
|
||||
return
|
||||
}
|
||||
if hashedCurrentPassword != user.Password {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Aktuelles Passwort ist falsch",
|
||||
Code: http.StatusUnauthorized,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
hashedNewPassword, salt, httpErr := hashPassword(newPassword, "")
|
||||
if httpErr != nil {
|
||||
errorJson(writer, httpErr)
|
||||
return
|
||||
}
|
||||
|
||||
newPasswordUser := &models.User{
|
||||
Password: hashedNewPassword,
|
||||
Salt: salt,
|
||||
}
|
||||
|
||||
if err := database.Update(req.Context().(*context.Context), &models.User{ID: user.ID}, newPasswordUser); err != nil {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Fehler beim Aktualisieren des Passworts",
|
||||
Code: http.StatusInternalServerError,
|
||||
Data: err,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
sess.SetUser(user)
|
||||
writer.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func deleteUser(writer http.ResponseWriter, req *http.Request) {
|
||||
idToDelete, err := strconv.ParseUint(req.PathValue(UserIDValue), 10, 32)
|
||||
if err != nil || idToDelete == 0 {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Malformed user ID",
|
||||
Code: http.StatusBadRequest,
|
||||
})
|
||||
return
|
||||
}
|
||||
deleteID := uint32(idToDelete)
|
||||
|
||||
sess := session.GetSession(req)
|
||||
user := sess.GetUser()
|
||||
if user.ID != deleteID && !user.Role.IsAdminUser() {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Sie haben keine Berechtigung, diesen Benutzer zu löschen",
|
||||
Code: http.StatusForbidden,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.Delete(req.Context().(*context.Context), &models.User{ID: deleteID}); err != nil {
|
||||
errorJson(writer, &models.HTTPError{
|
||||
Message: "Fehler beim Löschen des Benutzers",
|
||||
Code: http.StatusInternalServerError,
|
||||
Data: err,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
sess.DeleteUser()
|
||||
}
|
75
WEB43-diary/internal/session/session.go
Normal file
75
WEB43-diary/internal/session/session.go
Normal file
@ -0,0 +1,75 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
ctx "context"
|
||||
"net/http"
|
||||
|
||||
"gitea.com/go-chi/session"
|
||||
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/context"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
SessionThemeKey = "session.user.theme"
|
||||
SessionUserKey = "session.user.user"
|
||||
)
|
||||
|
||||
type Session struct {
|
||||
session.Store
|
||||
}
|
||||
|
||||
func GetSession(sess any) *Session {
|
||||
switch s := sess.(type) {
|
||||
case session.Store:
|
||||
return &Session{s}
|
||||
case *session.Store:
|
||||
return &Session{*s}
|
||||
case context.Context:
|
||||
return &Session{s.Session}
|
||||
case *context.Context:
|
||||
return &Session{s.Session}
|
||||
case ctx.Context:
|
||||
if c, ok := s.(*context.Context); ok {
|
||||
return &Session{c.Session}
|
||||
}
|
||||
case *http.Request:
|
||||
if c, ok := s.Context().(*context.Context); ok {
|
||||
return &Session{c.Session}
|
||||
}
|
||||
case http.Request:
|
||||
if c, ok := s.Context().(*context.Context); ok {
|
||||
return &Session{c.Session}
|
||||
}
|
||||
}
|
||||
|
||||
return &Session{}
|
||||
}
|
||||
|
||||
func (session *Session) GetTheme() models.WebTheme {
|
||||
theme := session.Get(SessionThemeKey)
|
||||
if theme == nil {
|
||||
return models.DarkTheme
|
||||
}
|
||||
return theme.(models.WebTheme)
|
||||
}
|
||||
|
||||
func (session *Session) SetTheme(theme models.WebTheme) {
|
||||
session.Set(SessionThemeKey, theme)
|
||||
}
|
||||
|
||||
func (session *Session) GetUser() *models.User {
|
||||
user := session.Get(SessionUserKey)
|
||||
if user == nil {
|
||||
return &models.User{}
|
||||
}
|
||||
return user.(*models.User)
|
||||
}
|
||||
|
||||
func (session *Session) SetUser(user *models.User) {
|
||||
session.Set(SessionUserKey, user)
|
||||
}
|
||||
|
||||
func (session *Session) DeleteUser() {
|
||||
session.Delete(SessionUserKey)
|
||||
}
|
42
WEB43-diary/main.go
Normal file
42
WEB43-diary/main.go
Normal file
@ -0,0 +1,42 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/internal/config"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/internal/database"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/internal/routes"
|
||||
)
|
||||
|
||||
//go:embed assets
|
||||
var assetFS embed.FS
|
||||
|
||||
func main() {
|
||||
database.CreateTablesFromQueue()
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Use(
|
||||
middleware.Logger,
|
||||
middleware.CleanPath,
|
||||
// middleware.SupressNotFound(router),
|
||||
middleware.Recoverer,
|
||||
middleware.Maybe(middleware.NoCache, func(r *http.Request) bool { return config.EnvironmentDevelopment.IsDevelopment() }),
|
||||
middleware.StripSlashes,
|
||||
)
|
||||
|
||||
routes.RegisterRoutes(router, &assetFS)
|
||||
|
||||
server := http.Server{
|
||||
Addr: fmt.Sprintf(":%d", config.Config.Port),
|
||||
Handler: router,
|
||||
}
|
||||
|
||||
log.Fatal(server.ListenAndServe())
|
||||
|
||||
}
|
2161
WEB43-diary/package-lock.json
generated
Normal file
2161
WEB43-diary/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
11
WEB43-diary/package.json
Normal file
11
WEB43-diary/package.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"devDependencies": {
|
||||
"@types/showdown": "^2.0.6",
|
||||
"esbuild": "^0.24.0",
|
||||
"flowbite": "^2.5.2",
|
||||
"prettier": "^3.3.3",
|
||||
"showdown": "^2.1.0",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
165
WEB43-diary/tailwind.config.js
Normal file
165
WEB43-diary/tailwind.config.js
Normal file
@ -0,0 +1,165 @@
|
||||
import plugin from 'tailwindcss/plugin';
|
||||
|
||||
const colors = {
|
||||
primary: 'var(--clr-primary)',
|
||||
'surface-tint': 'var(--clr-surface-tint)',
|
||||
'on-primary': 'var(--clr-on-primary)',
|
||||
'primary-container': 'var(--clr-primary-container)',
|
||||
'on-primary-container': 'var(--clr-on-primary-container)',
|
||||
secondary: 'var(--clr-secondary)',
|
||||
'on-secondary': 'var(--clr-on-secondary)',
|
||||
'secondary-container': 'var(--clr-secondary-container)',
|
||||
'on-secondary-container': 'var(--clr-on-secondary-container)',
|
||||
tertiary: 'var(--clr-tertiary)',
|
||||
'on-tertiary': 'var(--clr-on-tertiary)',
|
||||
'tertiary-container': 'var(--clr-tertiary-container)',
|
||||
'on-tertiary-container': 'var(--clr-on-tertiary-container)',
|
||||
error: 'var(--clr-error)',
|
||||
'on-error': 'var(--clr-on-error)',
|
||||
'error-container': 'var(--clr-error-container)',
|
||||
'on-error-container': 'var(--clr-on-error-container)',
|
||||
background: 'var(--clr-background)',
|
||||
'on-background': 'var(--clr-on-background)',
|
||||
surface: 'var(--clr-surface)',
|
||||
'on-surface': 'var(--clr-on-surface)',
|
||||
'surface-variant': 'var(--clr-surface-variant)',
|
||||
'on-surface-variant': 'var(--clr-on-surface-variant)',
|
||||
outline: 'var(--clr-outline)',
|
||||
'outline-variant': 'var(--clr-outline-variant)',
|
||||
shadow: 'var(--clr-shadow)',
|
||||
scrim: 'var(--clr-scrim)',
|
||||
'inverse-surface': 'var(--clr-inverse-surface)',
|
||||
'inverse-on-surface': 'var(--clr-inverse-on-surface)',
|
||||
'inverse-primary': 'var(--clr-inverse-primary)',
|
||||
'primary-fixed': 'var(--clr-primary-fixed)',
|
||||
'on-primary-fixed': 'var(--clr-on-primary-fixed)',
|
||||
'primary-fixed-dim': 'var(--clr-primary-fixed-dim)',
|
||||
'on-primary-fixed-variant': 'var(--clr-on-primary-fixed-variant)',
|
||||
'secondary-fixed': 'var(--clr-secondary-fixed)',
|
||||
'on-secondary-fixed': 'var(--clr-on-secondary-fixed)',
|
||||
'secondary-fixed-dim': 'var(--clr-secondary-fixed-dim)',
|
||||
'on-secondary-fixed-variant': 'var(--clr-on-secondary-fixed-variant)',
|
||||
'tertiary-fixed': 'var(--clr-tertiary-fixed)',
|
||||
'on-tertiary-fixed': 'var(--clr-on-tertiary-fixed)',
|
||||
'tertiary-fixed-dim': 'var(--clr-tertiary-fixed-dim)',
|
||||
'on-tertiary-fixed-variant': 'var(--clr-on-tertiary-fixed-variant)',
|
||||
'surface-dim': 'var(--clr-surface-dim)',
|
||||
'surface-bright': 'var(--clr-surface-bright)',
|
||||
'surface-container-lowest': 'var(--clr-surface-container-lowest)',
|
||||
'surface-container-low': 'var(--clr-surface-container-low)',
|
||||
'surface-container': 'var(--clr-surface-container)',
|
||||
'surface-container-high': 'var(--clr-surface-container-high)',
|
||||
'surface-container-highest': 'var(--clr-surface-container-highest)',
|
||||
|
||||
// --- OWN ---
|
||||
bright: 'var(--clr-bright)',
|
||||
dark: 'var(--clr-dark)',
|
||||
};
|
||||
|
||||
const width = {
|
||||
prose: '80ch',
|
||||
};
|
||||
|
||||
export default {
|
||||
content: ['./web/templates/**/*.templ', './node_modules/flowbite/**/*.js'],
|
||||
blocklist: ['container'],
|
||||
/* added 'dark:' styles, only for dark-mode */
|
||||
darkMode: ['variant', ':root:not(:has(#theme-switcher:checked)) &'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors() {
|
||||
const colorExtensions = Object.keys(colors).reduce((prev, curr) => {
|
||||
const color = colors[curr];
|
||||
|
||||
prev[curr] = `rgba(${color}, var(--tw-bg-opacity, 1))`;
|
||||
for (let i = 0; i <= 100; i += 5) {
|
||||
prev[`${curr}/${i}`] = `rgba(${color}, ${(i / 100).toFixed(2)})`;
|
||||
prev[`${curr}-${i * 10}`] = `color-mix(in srgb, rgb(${color}) ${i}%, rgb(var(--clr-bright)))`;
|
||||
}
|
||||
|
||||
return prev;
|
||||
}, {});
|
||||
|
||||
colorExtensions['on-bg'] = 'var(--on-bg-color)';
|
||||
|
||||
return colorExtensions;
|
||||
},
|
||||
width,
|
||||
minWidth: width,
|
||||
maxWidth: width,
|
||||
borderWidth: {
|
||||
3: '3px',
|
||||
},
|
||||
},
|
||||
screens: {
|
||||
'2xl': '1400px',
|
||||
xl: '1200px',
|
||||
l: '992px',
|
||||
m: '768px',
|
||||
s: '576px',
|
||||
xs: '420px',
|
||||
xxs: '340px',
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
'flowbite/plugin',
|
||||
plugin(({ addBase, addUtilities, addVariant, theme }) => {
|
||||
/* added 'light:' styles */
|
||||
addVariant('light', ':root:has(#theme-switcher:checked) &');
|
||||
|
||||
addUtilities({
|
||||
'.h-full-grid': {
|
||||
display: 'grid',
|
||||
minHeight: '0',
|
||||
height: '100%',
|
||||
},
|
||||
'.grid': {
|
||||
minHeight: '0',
|
||||
},
|
||||
/* '.flex': {
|
||||
minHeight: '0',
|
||||
}, */
|
||||
'.flex-center': {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
'.absolute-center': {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
},
|
||||
'.absolute-center-x': {
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
},
|
||||
'.absolute-center-y': {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
},
|
||||
'.focusable': {
|
||||
// its centered and prevents scrolling
|
||||
position: 'absolute',
|
||||
display: 'block !important',
|
||||
top: '50%',
|
||||
left: '-150vw',
|
||||
height: '1px',
|
||||
width: '1px',
|
||||
overflow: 'hidden',
|
||||
opacity: '0',
|
||||
},
|
||||
});
|
||||
|
||||
const base = {};
|
||||
|
||||
Object.keys(colors).forEach(color => {
|
||||
const themeVal = theme(`colors.on-${color}`);
|
||||
if (themeVal) base[`.bg-${color}`] = { '--on-bg-color': themeVal };
|
||||
});
|
||||
|
||||
addBase(base);
|
||||
}),
|
||||
],
|
||||
};
|
182
WEB43-diary/web/templates/diary/create.templ
Normal file
182
WEB43-diary/web/templates/diary/create.templ
Normal file
@ -0,0 +1,182 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/context"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/internal/database"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/internal/models"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/internal/session"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/web/templates/shared"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
templ CreateDiary(context *context.Context) {
|
||||
{{
|
||||
sessionUser := session.GetSession(context).GetUser()
|
||||
if sessionUser == nil || sessionUser.ID <= 0 {
|
||||
return models.NewHTTPError(http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
tags, err := database.GetAll(context, &models.Tag{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}}
|
||||
@shared.Layout(context) {
|
||||
<form method="POST" action="/diary" class="relative flex flex-col gap-4 p-6">
|
||||
<div>
|
||||
<label for="title-input" class="font-medium">Title*</label>
|
||||
<input type="text" id="title-input" name="title" class="outline-gray-400 rounded-lg w-full p-2.5 text-ellipsis" required/>
|
||||
</div>
|
||||
<div class="relative flex flex-col gap-4 items-center">
|
||||
/* tag */
|
||||
<div class="relative w-full">
|
||||
<input data-dropdown-toggle="tags-dropdown" id="tags-input" placeholder="Tags" class="outline-gray-400 rounded-lg w-full p-2.5 text-ellipsis" maxlength="256"/>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute top-0 end-0 p-2.5 h-full text-sm font-medium rounded-e-lg text-dark bg-primary"
|
||||
>
|
||||
<i class="ic--outline-add size-6"></i>
|
||||
</button>
|
||||
</div>
|
||||
/* tag dropdown */
|
||||
<div id="tags-dropdown" class="z-10 hidden rounded-lg shadow w-full bg-surface-container-highest">
|
||||
<ul class="max-h-64 overflow-y-auto text-sm">
|
||||
for _, tag := range tags {
|
||||
<li class="line-clamp-2 p-2 rounded cursor-pointer hover:bg-dark-650">{ tag.Title }</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
/* tags */
|
||||
<div id="tags" class="flex flex-wrap gap-2 w-full"></div>
|
||||
</div>
|
||||
<div advanced-textarea class="flex flex-col size-full border border-dark-450 rounded-lg bg-surface-bright">
|
||||
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-600">
|
||||
<div class="flex flex-wrap items-center divide-x divide-bright/25 [&>:not(:first-child)]:pl-2 [&>:not(:last-child)]:pr-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<button type="button" class="modifier-bold p-1 size-8 text-bright/45 rounded hover:outline-outline" title="Fett">
|
||||
<i class="ic--outline-format-bold size-6"></i>
|
||||
</button>
|
||||
<button type="button" class="modifier-italic p-1 size-8 text-bright/45 rounded hover:outline-outline" title="Kursiv">
|
||||
<i class="ic--outline-format-italic size-6"></i>
|
||||
</button>
|
||||
<button type="button" class="modifier-underline p-1 size-8 text-bright/45 rounded hover:outline-outline" title="Unterstrichen">
|
||||
<i class="ic--outline-format-underlined size-6"></i>
|
||||
</button>
|
||||
<button type="button" class="modifier-strikethrough p-1 size-8 text-bright/45 rounded hover:outline-outline" title="Durchgestrichen">
|
||||
<i class="ic--outline-format-strikethrough size-6"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button type="button" class="modifier-heading-1 size-8 text-bright/45 rounded hover:outline-outline" title="Überschrift 1">
|
||||
<i class="gridicons--heading-h1 size-8"></i>
|
||||
</button>
|
||||
<button type="button" class="modifier-heading-2 size-8 text-bright/45 rounded hover:outline-outline" title="Überschrift 2">
|
||||
<i class="gridicons--heading-h2 size-8"></i>
|
||||
</button>
|
||||
<button type="button" class="modifier-heading-3 size-8 text-bright/45 rounded hover:outline-outline" title="Überschrift 3">
|
||||
<i class="gridicons--heading-h3 size-8"></i>
|
||||
</button>
|
||||
<button type="button" class="modifier-heading-4 size-8 text-bright/45 rounded hover:outline-outline" title="Überschrift 4">
|
||||
<i class="gridicons--heading-h4 size-8"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button type="button" class="modifier-quote p-1 size-8 text-bright/45 rounded hover:outline-outline" title="Zitat">
|
||||
<i class="ic--outline-format-quote size-6"></i>
|
||||
</button>
|
||||
<button type="button" class="modifier-checkbox p-1 size-8 text-bright/45 rounded hover:outline-outline" title="Checkbox">
|
||||
<i class="ic--outline-check-box-outline-blank size-6"></i>
|
||||
</button>
|
||||
<button type="button" class="modifier-checkbox-checked p-1 size-8 text-bright/45 rounded hover:outline-outline" title="Checkbox (gechecked)">
|
||||
<i class="ic--outline-check-box size-6"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button type="button" class="modifier-unordered-list p-1 size-8 text-bright/45 rounded hover:outline-outline" title="Unsorterte Liste">
|
||||
<i class="ic--outline-format-list-bulleted size-6"></i>
|
||||
</button>
|
||||
<button type="button" class="modifier-ordered-list p-1 size-8 text-bright/45 rounded hover:outline-outline" title="Sortierte Liste">
|
||||
<i class="ic--outline-format-list-numbered size-6"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button type="button" class="modifier-link p-1 size-8 text-bright/45 rounded hover:outline-outline" title="Link">
|
||||
<i class="ic--outline-insert-link size-6"></i>
|
||||
</button>
|
||||
<button type="button" data-modal-target="photo-modal" data-modal-toggle="photo-modal" class="modifier-photo p-1 size-8 text-bright/45 rounded hover:outline-outline" title="Bild">
|
||||
<i class="ic--outline-insert-photo size-6"></i>
|
||||
</button>
|
||||
<button type="button" class="modifier-horizontal-line p-1 size-8 text-bright/45 rounded hover:outline-outline" title="Horizontale Linie">
|
||||
<i class="ic--outline-horizontal-rule size-6"></i>
|
||||
</button>
|
||||
<button type="button" data-dropdown-toggle="emoticons-dropdown" class="modifier-emoticon p-1 size-8 text-bright/45 rounded hover:outline-outline" title="Emoticon">
|
||||
<i class="ic--outline-insert-emoticon size-6"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="p-2 rounded ml-4 text-on-bg bg-primary hover:bg-primary-650">Tagebucheintrag hochladen</button>
|
||||
</div>
|
||||
/* emoticons dropdown */
|
||||
<div id="emoticons-dropdown" class="z-10 hidden rounded-lg shadow w-72 bg-surface-container-highest">
|
||||
@iconsList()
|
||||
</div>
|
||||
<div class="flex size-full pl-4 pr-2 py-2 rounded-b-lg bg-surface-dim divide-x divide-bright/25">
|
||||
<div class="flex flex-col h-full w-full max-w-[50%] pr-2 divide-y divide-bright/25">
|
||||
<span class="pb-2 text-xl">Tagebucheintrag mit Markdown*</span>
|
||||
<textarea id="editor" name="markdown" class="size-full pt-2 bg-transparent outline-none resize-none [scrollbar-gutter:stable]" placeholder="Schreibe ein Tagebucheintrag..." title="Tagebucheintrag*" required></textarea>
|
||||
</div>
|
||||
<div class="flex flex-col h-full w-full max-w-[50%] pl-4 divide-y divide-bright/25">
|
||||
<span class="pb-2 text-xl">Vorschau</span>
|
||||
<iframe allowtransparency="true" class="preview size-full pt-2 bg-transparent"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<!-- photo modal - outside of form -->
|
||||
<div id="photo-modal" data-modal-backdrop="static" tabindex="-1" aria-hidden="true" class="hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full">
|
||||
<div class="relative p-4 w-full max-w-md max-h-full">
|
||||
<!-- Modal content -->
|
||||
<div class="rounded-lg bg-surface-container-highest">
|
||||
<!-- Modal header -->
|
||||
<div class="flex items-center justify-between p-4 border-b border-dark-450">
|
||||
<h3 class="m-0">Bild hinzufügen</h3>
|
||||
<button type="button" class="rounded-lg size-8 p-1 text-bright/45 hover:outline-outline" data-modal-hide="photo-modal">
|
||||
<i class="ic--outline-close size-6"></i>
|
||||
<span class="sr-only">Dialog schließen</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Modal body -->
|
||||
<form class="space-y-4 p-4" action="/image" method="POST">
|
||||
/* photo */
|
||||
<div class="flex flex-center w-full">
|
||||
<label for="dropzone-file" class="flex flex-col flex-center w-full h-64 border-2 border-bright/45 border-dashed rounded-lg bg-bright/15 hover:bg-bright/25">
|
||||
<div class="flex flex-col flex-center py-6">
|
||||
<i class="ic--outline-cloud-upload size-8 mb-4"></i>
|
||||
<p class="text-sm text-bright/65"><span class="font-semibold">Zum hochladen klicken</span> oder drag and drop</p>
|
||||
</div>
|
||||
<input id="dropzone-file" type="file" name="file" accept="image/*" class="hidden" required/>
|
||||
</label>
|
||||
</div>
|
||||
/* title */
|
||||
<div>
|
||||
<label for="title-input" class="text-sm font-medium">Title*</label>
|
||||
<input type="text" id="title-input" name="title" class="outline-gray-400 rounded-lg w-full p-2.5 text-ellipsis" required/>
|
||||
</div>
|
||||
/* dimensions */
|
||||
<div class="flex gap-4 items-center">
|
||||
<div>
|
||||
<label for="height-input" class="text-sm font-medium">Höhe*</label>
|
||||
<input type="number" id="height-input" name="height" class="outline-gray-400 rounded-lg w-full p-2.5 text-ellipsis text-xs" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="width-input" class="text-sm font-medium">Breite*</label>
|
||||
<input type="number" id="width-input" name="width" class="outline-gray-400 rounded-lg w-full p-2.5 text-ellipsis text-xs" required/>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="text-on-bg bg-primary hover:bg-primary-650 font-medium rounded-lg text-sm px-5 py-2.5 w-full">Hochladen</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
209
WEB43-diary/web/templates/diary/edit.templ
Normal file
209
WEB43-diary/web/templates/diary/edit.templ
Normal file
@ -0,0 +1,209 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/context"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/internal/database"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/internal/models"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/internal/session"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/web/templates/shared"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
templ EditDiary(context *context.Context) {
|
||||
{{
|
||||
idToGet, err := strconv.ParseUint(context.Request.PathValue("diaryID"), 10, 32)
|
||||
if err != nil || idToGet == 0 {
|
||||
return &models.HTTPError{
|
||||
Message: "Malformed Tagebuch ID",
|
||||
Code: http.StatusBadRequest,
|
||||
}
|
||||
}
|
||||
|
||||
diary, err := database.GetOne(context, &models.Diary{ID: uint32(idToGet)})
|
||||
if err != nil {
|
||||
return &models.HTTPError{
|
||||
Message: "Tagebucheintrag nicht gefunden",
|
||||
Code: http.StatusBadRequest,
|
||||
Data: err,
|
||||
}
|
||||
}
|
||||
|
||||
sessionUser := session.GetSession(context).GetUser()
|
||||
if sessionUser == nil || sessionUser.ID <= 0 || (sessionUser.ID != diary.OwnerID && !sessionUser.Role.IsAdminUser()) {
|
||||
return models.NewHTTPError(http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
tags, err := database.GetAll(context, &models.Tag{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}}
|
||||
@shared.Layout(context) {
|
||||
<form method="PUT" action={ templ.URL(fmt.Sprintf("/diary/%d", diary.ID)) } class="relative flex flex-col gap-4 p-6">
|
||||
<div>
|
||||
<label for="title-input" class="font-medium">Title*</label>
|
||||
<input type="text" id="title-input" name="title" class="outline-gray-400 rounded-lg w-full p-2.5 text-ellipsis" required value={ diary.Title }/>
|
||||
</div>
|
||||
<div class="relative flex flex-col gap-4 items-center">
|
||||
/* tag */
|
||||
<div class="relative w-full">
|
||||
<input data-dropdown-toggle="tags-dropdown" id="tags-input" placeholder="Tags" class="outline-gray-400 rounded-lg w-full p-2.5 text-ellipsis" maxlength="256"/>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute top-0 end-0 p-2.5 h-full text-sm font-medium rounded-e-lg text-dark bg-primary"
|
||||
>
|
||||
<i class="ic--outline-add size-6"></i>
|
||||
</button>
|
||||
</div>
|
||||
/* tag dropdown */
|
||||
<div id="tags-dropdown" class="z-10 hidden rounded-lg shadow w-full bg-surface-container-highest">
|
||||
<ul class="max-h-64 overflow-y-auto text-sm">
|
||||
for _, tag := range tags {
|
||||
<li class="line-clamp-2 p-2 rounded cursor-pointer hover:bg-dark-650">{ tag.Title }</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
/* tags */
|
||||
<div id="tags" class="flex flex-wrap gap-2 w-full">
|
||||
for _, tag := range diary.Tags {
|
||||
<span class="tag flex flex-center gap-1 w-fit bg-dark-750 text-bright/75 text-xs font-medium px-2.5 py-0.5 rounded hover:bg-dark-650">
|
||||
{ tag.Title }
|
||||
<i class="ic--outline-close"></i>
|
||||
<input value={ tag.Title } name="tags" class="sr-only" checked/>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div advanced-textarea class="flex flex-col size-full border border-dark-450 rounded-lg bg-surface-bright">
|
||||
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-600">
|
||||
<div class="flex flex-wrap items-center divide-x divide-bright/25 [&>:not(:first-child)]:pl-2 [&>:not(:last-child)]:pr-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<button type="button" class="modifier-bold p-1 size-8 text-bright/45 rounded hover:outline-outline" title="Fett">
|
||||
<i class="ic--outline-format-bold size-6"></i>
|
||||
</button>
|
||||
<button type="button" class="modifier-italic p-1 size-8 text-bright/45 rounded hover:outline-outline" title="Kursiv">
|
||||
<i class="ic--outline-format-italic size-6"></i>
|
||||
</button>
|
||||
<button type="button" class="modifier-underline p-1 size-8 text-bright/45 rounded hover:outline-outline" title="Unterstrichen">
|
||||
<i class="ic--outline-format-underlined size-6"></i>
|
||||
</button>
|
||||
<button type="button" class="modifier-strikethrough p-1 size-8 text-bright/45 rounded hover:outline-outline" title="Durchgestrichen">
|
||||
<i class="ic--outline-format-strikethrough size-6"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button type="button" class="modifier-heading-1 size-8 text-bright/45 rounded hover:outline-outline" title="Überschrift 1">
|
||||
<i class="gridicons--heading-h1 size-8"></i>
|
||||
</button>
|
||||
<button type="button" class="modifier-heading-2 size-8 text-bright/45 rounded hover:outline-outline" title="Überschrift 2">
|
||||
<i class="gridicons--heading-h2 size-8"></i>
|
||||
</button>
|
||||
<button type="button" class="modifier-heading-3 size-8 text-bright/45 rounded hover:outline-outline" title="Überschrift 3">
|
||||
<i class="gridicons--heading-h3 size-8"></i>
|
||||
</button>
|
||||
<button type="button" class="modifier-heading-4 size-8 text-bright/45 rounded hover:outline-outline" title="Überschrift 4">
|
||||
<i class="gridicons--heading-h4 size-8"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button type="button" class="modifier-quote p-1 size-8 text-bright/45 rounded hover:outline-outline" title="Zitat">
|
||||
<i class="ic--outline-format-quote size-6"></i>
|
||||
</button>
|
||||
<button type="button" class="modifier-checkbox p-1 size-8 text-bright/45 rounded hover:outline-outline" title="Checkbox">
|
||||
<i class="ic--outline-check-box-outline-blank size-6"></i>
|
||||
</button>
|
||||
<button type="button" class="modifier-checkbox-checked p-1 size-8 text-bright/45 rounded hover:outline-outline" title="Checkbox (gechecked)">
|
||||
<i class="ic--outline-check-box size-6"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button type="button" class="modifier-unordered-list p-1 size-8 text-bright/45 rounded hover:outline-outline" title="Unsorterte Liste">
|
||||
<i class="ic--outline-format-list-bulleted size-6"></i>
|
||||
</button>
|
||||
<button type="button" class="modifier-ordered-list p-1 size-8 text-bright/45 rounded hover:outline-outline" title="Sortierte Liste">
|
||||
<i class="ic--outline-format-list-numbered size-6"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button type="button" class="modifier-link p-1 size-8 text-bright/45 rounded hover:outline-outline" title="Link">
|
||||
<i class="ic--outline-insert-link size-6"></i>
|
||||
</button>
|
||||
<button type="button" data-modal-target="photo-modal" data-modal-toggle="photo-modal" class="modifier-photo p-1 size-8 text-bright/45 rounded hover:outline-outline" title="Bild">
|
||||
<i class="ic--outline-insert-photo size-6"></i>
|
||||
</button>
|
||||
<button type="button" class="modifier-horizontal-line p-1 size-8 text-bright/45 rounded hover:outline-outline" title="Horizontale Linie">
|
||||
<i class="ic--outline-horizontal-rule size-6"></i>
|
||||
</button>
|
||||
<button type="button" data-dropdown-toggle="emoticons-dropdown" class="modifier-emoticon p-1 size-8 text-bright/45 rounded hover:outline-outline" title="Emoticon">
|
||||
<i class="ic--outline-insert-emoticon size-6"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="p-2 rounded ml-4 text-on-bg bg-primary hover:bg-primary-650">Tagebucheintrag bearbeiten</button>
|
||||
</div>
|
||||
/* emoticons dropdown */
|
||||
<div id="emoticons-dropdown" class="z-10 hidden rounded-lg shadow w-72 bg-surface-container-highest">
|
||||
@iconsList()
|
||||
</div>
|
||||
<div class="flex size-full pl-4 pr-2 py-2 rounded-b-lg bg-surface-dim divide-x divide-bright/25">
|
||||
<div class="flex flex-col h-full w-full max-w-[50%] pr-2 divide-y divide-bright/25">
|
||||
<span class="pb-2 text-xl">Tagebucheintrag mit Markdown*</span>
|
||||
<textarea id="editor" name="markdown" class="size-full pt-2 bg-transparent outline-none resize-none [scrollbar-gutter:stable]" placeholder="Schreibe ein Tagebucheintrag..." title="Tagebucheintrag*" required>{ diary.Content }</textarea>
|
||||
</div>
|
||||
<div class="flex flex-col h-full w-full max-w-[50%] pl-4 divide-y divide-bright/25">
|
||||
<span class="pb-2 text-xl">Vorschau</span>
|
||||
<iframe allowtransparency="true" class="preview size-full pt-2 bg-transparent"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<!-- photo modal - outside of form -->
|
||||
<div id="photo-modal" data-modal-backdrop="static" tabindex="-1" aria-hidden="true" class="hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full">
|
||||
<div class="relative p-4 w-full max-w-md max-h-full">
|
||||
<!-- Modal content -->
|
||||
<div class="rounded-lg bg-surface-container-highest">
|
||||
<!-- Modal header -->
|
||||
<div class="flex items-center justify-between p-4 border-b border-dark-450">
|
||||
<h3 class="m-0">Bild hinzufügen</h3>
|
||||
<button type="button" class="rounded-lg size-8 p-1 text-bright/45 hover:outline-outline" data-modal-hide="photo-modal">
|
||||
<i class="ic--outline-close size-6"></i>
|
||||
<span class="sr-only">Dialog schließen</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Modal body -->
|
||||
<form class="space-y-4 p-4" action="/image" method="POST">
|
||||
/* photo */
|
||||
<div class="flex flex-center w-full">
|
||||
<label for="dropzone-file" class="flex flex-col flex-center w-full h-64 border-2 border-bright/45 border-dashed rounded-lg bg-bright/15 hover:bg-bright/25">
|
||||
<div class="flex flex-col flex-center py-6">
|
||||
<i class="ic--outline-cloud-upload size-8 mb-4"></i>
|
||||
<p class="text-sm text-bright/65"><span class="font-semibold">Zum hochladen klicken</span> oder drag and drop</p>
|
||||
</div>
|
||||
<input id="dropzone-file" type="file" name="file" accept="image/*" class="hidden" required/>
|
||||
</label>
|
||||
</div>
|
||||
/* title */
|
||||
<div>
|
||||
<label for="title-input" class="text-sm font-medium">Title*</label>
|
||||
<input type="text" id="title-input" name="title" class="outline-gray-400 rounded-lg w-full p-2.5 text-ellipsis" required/>
|
||||
</div>
|
||||
/* dimensions */
|
||||
<div class="flex gap-4 items-center">
|
||||
<div>
|
||||
<label for="height-input" class="text-sm font-medium">Höhe*</label>
|
||||
<input type="number" id="height-input" name="height" class="outline-gray-400 rounded-lg w-full p-2.5 text-ellipsis text-xs" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="width-input" class="text-sm font-medium">Breite*</label>
|
||||
<input type="number" id="width-input" name="width" class="outline-gray-400 rounded-lg w-full p-2.5 text-ellipsis text-xs" required/>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="text-on-bg bg-primary hover:bg-primary-650 font-medium rounded-lg text-sm px-5 py-2.5 w-full">Hochladen</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
1240
WEB43-diary/web/templates/diary/iconsList.templ
Normal file
1240
WEB43-diary/web/templates/diary/iconsList.templ
Normal file
File diff suppressed because it is too large
Load Diff
69
WEB43-diary/web/templates/diary/view.templ
Normal file
69
WEB43-diary/web/templates/diary/view.templ
Normal file
@ -0,0 +1,69 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/context"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/internal/database"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/internal/models"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/internal/session"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/web/templates/shared"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
templ ViewDiary(context *context.Context) {
|
||||
{{
|
||||
idToGet, err := strconv.ParseUint(context.Request.PathValue("diaryID"), 10, 32)
|
||||
if err != nil || idToGet == 0 {
|
||||
return &models.HTTPError{
|
||||
Message: "Malformed Tagebuch ID",
|
||||
Code: http.StatusBadRequest,
|
||||
}
|
||||
}
|
||||
|
||||
diary, err := database.GetOne(context, &models.Diary{ID: uint32(idToGet)})
|
||||
if err != nil {
|
||||
return &models.HTTPError{
|
||||
Message: "Tagebucheintrag nicht gefunden",
|
||||
Code: http.StatusBadRequest,
|
||||
Data: err,
|
||||
}
|
||||
}
|
||||
|
||||
user := session.GetSession(context).GetUser()
|
||||
}}
|
||||
@shared.Layout(context) {
|
||||
<div class="flex flex-col gap-8 h-full p-6">
|
||||
<div class="flex items-center justify-between gap-4 mx-auto">
|
||||
<h1>{ diary.Title }</h1>
|
||||
if diary.OwnerID == user.ID || user.Role.IsAdminUser() {
|
||||
<div class="flex">
|
||||
<form method="DELETE" data-redirect-url="/">
|
||||
<button class="flex flex-center p-2 rounded-full text-red-600 hover:bg-dark-750">
|
||||
<i class="ic--outline-delete size-8"></i>
|
||||
</button>
|
||||
</form>
|
||||
<a href={ templ.URL(fmt.Sprintf("/diary/%d/edit", diary.ID)) } class="flex flex-center p-2 rounded-full hover:bg-dark-750">
|
||||
<i class="ic--outline-edit size-8"></i>
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="flex justify-center gap-4 size-full">
|
||||
<iframe allowtransparency="true" class="preview size-full max-w-3xl bg-transparent" data-markdown={ diary.Content }></iframe>
|
||||
<div class="flex flex-col gap-4">
|
||||
<span><strong>Erstellt von</strong>: { diary.Owner.Username }</span>
|
||||
<span><strong>Erstellt am</strong>: { diary.CreationTime.Format("02.01.2006 15:04") }Uhr</span>
|
||||
<label class="font-medium">Tags</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
for _, tag := range diary.Tags {
|
||||
<span class="tag flex flex-center gap-1 w-fit bg-dark-750 text-bright/75 text-xs font-medium px-2.5 py-0.5 rounded hover:bg-dark-650">
|
||||
{ tag.Title }
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
170
WEB43-diary/web/templates/index.templ
Normal file
170
WEB43-diary/web/templates/index.templ
Normal file
@ -0,0 +1,170 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/context"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/internal/database"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/internal/models"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/web/templates/shared"
|
||||
)
|
||||
|
||||
templ Index(context *context.Context) {
|
||||
{{
|
||||
query := context.Request.URL.Query()
|
||||
queryOwners := query["owners"]
|
||||
queryTags := query["tags"]
|
||||
|
||||
whereGroup := &database.WhereGroup{Logic: database.GetLogicFromString(query.Get("operator"))}
|
||||
diariesQueryBuilder := database.NewQueryBuilder(&models.Diary{}).Distinct().Select("`diary`.*")
|
||||
|
||||
if len(queryOwners) > 0 {
|
||||
whereGroup.AddCondition(&database.QueryCondition{Row: "OwnerID", Operator: database.In, Value: queryOwners})
|
||||
}
|
||||
if fromDate := query.Get("fromDate"); fromDate != "" {
|
||||
whereGroup.AddCondition(&database.QueryCondition{Row: "CreationTime", Operator: database.GreaterOrEqual, Value: fromDate + " 00:00"})
|
||||
}
|
||||
if toDate := query.Get("toDate"); toDate != "" {
|
||||
whereGroup.AddCondition(&database.QueryCondition{Row: "CreationTime", Operator: database.LessOrEqual, Value: toDate + " 23:59"})
|
||||
}
|
||||
if title := query.Get("title"); title != "" {
|
||||
whereGroup.AddCondition(&database.QueryCondition{Row: "Title", Operator: database.Like, Value: title})
|
||||
}
|
||||
|
||||
if len(queryTags) > 0 {
|
||||
diariesQueryBuilder.Join(database.InnerJoin, &models.DiaryTags{}, "`diary`.`ID` = `diary_tags`.`DiaryId`")
|
||||
|
||||
switch database.GetLogicFromString(query.Get("tagsOperator")) {
|
||||
case database.AND:
|
||||
havingString, havingValues := database.WhereGroups{whereGroup}.Build(false)
|
||||
havingValues = append([]interface{}{len(queryTags)}, havingValues...)
|
||||
|
||||
if havingString != "" {
|
||||
havingString = string(whereGroup.Logic) + " " + havingString
|
||||
}
|
||||
diariesQueryBuilder.
|
||||
GroupBy("`diary`.`ID`").
|
||||
Having(fmt.Sprintf("COUNT(DISTINCT `diary_tags`.`TagID`) = ? %s", havingString), havingValues...)
|
||||
fallthrough
|
||||
default:
|
||||
whereGroup.AddCondition(&database.QueryCondition{Row: "`diary_tags`.`TagID`", Operator: database.In, Value: queryTags})
|
||||
}
|
||||
}
|
||||
|
||||
diaries, err := database.GetAllWhere(context, diariesQueryBuilder.WhereGroup(whereGroup))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
users, err := database.GetAll(context, &models.User{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tags, err := database.GetAll(context, &models.Tag{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}}
|
||||
@shared.Layout(context) {
|
||||
<div class="e2 h-full-grid grid-flow-col gap-8 p-6">
|
||||
<div>
|
||||
<h2>Filter</h2>
|
||||
<form method="GET" id="filter-form" class="flex flex-col gap-4 relative" enctype="application/x-www-form-urlencoded">
|
||||
/* owner/user */
|
||||
<div>
|
||||
<label for="owners-input" class="text-sm font-medium">Ersteller</label>
|
||||
<input data-dropdown-toggle="users-dropdown" id="owners-input" class="outline-gray-400 rounded-lg w-full p-2.5 text-ellipsis" readonly/>
|
||||
</div>
|
||||
/* owner/user dropdown */
|
||||
<div id="users-dropdown" class="z-10 hidden rounded-lg shadow w-full bg-surface-container-highest">
|
||||
<ul class="max-h-64 overflow-y-auto text-sm">
|
||||
for _, user := range users {
|
||||
<li>
|
||||
<label for={ "user-" + user.IDString() } class="flex items-center gap-2 p-2 rounded hover:bg-dark-650">
|
||||
<input id={ "user-" + user.IDString() } type="checkbox" value={ user.IDString() } name="owners" class="w-4 h-4" checked?={ slices.Contains(queryOwners, user.IDString()) }/>
|
||||
<span class="w-full text-sm font-medium line-clamp-2">{ user.Username }</span>
|
||||
</label>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
/* creation date */
|
||||
<div class="flex gap-4 items-center">
|
||||
<div>
|
||||
<label for="from-date" class="text-sm font-medium">Erstelldatum von</label>
|
||||
<input type="date" id="from-date" name="fromDate" class="outline-gray-400 rounded-lg w-full p-2.5 text-ellipsis" value={ query.Get("fromDate") }/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="to-date" class="text-sm font-medium">Erstelldatum bis</label>
|
||||
<input type="date" id="to-date" name="toDate" class="outline-gray-400 rounded-lg w-full p-2.5 text-ellipsis" value={ query.Get("toDate") }/>
|
||||
</div>
|
||||
</div>
|
||||
/* title */
|
||||
<div>
|
||||
<label for="title-input" class="text-sm font-medium">Title</label>
|
||||
<input type="text" id="title-input" name="title" class="outline-gray-400 rounded-lg w-full p-2.5 text-ellipsis" value={ query.Get("title") }/>
|
||||
</div>
|
||||
/* tag */
|
||||
<div>
|
||||
<label for="tags-input" class="text-sm font-medium">Tags</label>
|
||||
<div class="flex gap-2">
|
||||
<input data-dropdown-toggle="tags-dropdown" id="tags-input" class="outline-gray-400 rounded-lg w-full p-2.5 text-ellipsis" readonly/>
|
||||
<select data-tooltip-target="tags-operator-tooltip" name="tagsOperator" class="outline-gray-400 rounded-lg w-fit p-2.5">
|
||||
<option value="and" selected?={ query.Get("tagsOperator") == "and" }>Alle</option>
|
||||
<option value="or" selected?={ query.Get("tagsOperator") == "or" }>Einer</option>
|
||||
</select>
|
||||
<div id="tags-operator-tooltip" role="tooltip" class="absolute z-10 invisible inline-block px-3 py-2 text-sm transition-opacity duration-300 bg-surface-variant rounded-lg shadow-sm opacity-0 tooltip">
|
||||
Sollen alle ausgewählten Tags im gesuchten Tagebucheintrag sein oder nur mindestens ein Tag
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
/* tag dropdown */
|
||||
<div id="tags-dropdown" class="z-10 hidden rounded-lg shadow w-full bg-surface-container-highest">
|
||||
<ul class="max-h-64 overflow-y-auto text-sm">
|
||||
for _, tag := range tags {
|
||||
<li>
|
||||
<label for={ "tag-" + tag.IDString() } class="flex items-center gap-2 p-2 rounded hover:bg-dark-650">
|
||||
<input id={ "tag-" + tag.IDString() } type="checkbox" value={ tag.IDString() } name="tags" class="w-4 h-4" checked?={ slices.Contains(queryTags, tag.IDString()) }/>
|
||||
<span class="w-full text-sm font-medium line-clamp-2">{ tag.Title }</span>
|
||||
</label>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="text-on-bg bg-secondary hover:bg-secondary-650 font-medium rounded-lg text-sm px-5 py-2.5 w-full">Filter anwenden</button>
|
||||
<select data-tooltip-target="operator-tooltip" name="operator" class="outline-gray-400 rounded-lg w-fit p-2.5">
|
||||
<option value="and" selected?={ query.Get("operator") == "and" }>Und</option>
|
||||
<option value="or" selected?={ query.Get("operator") == "or" }>Oder</option>
|
||||
</select>
|
||||
<div id="operator-tooltip" role="tooltip" class="absolute z-10 invisible inline-block px-3 py-2 text-sm transition-opacity duration-300 bg-surface-variant rounded-lg shadow-sm opacity-0 tooltip">
|
||||
Sollen alle Filter auf den gesuchten Tagebucheintrag zutreffen oder nur mindestens ein Filter
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="h-full-grid">
|
||||
<div class="flex">
|
||||
<h2>Tagebucheinträge</h2>
|
||||
<a href="/diary" class="text-on-bg bg-primary hover:bg-primary-650 font-medium rounded-lg text-sm px-5 py-2.5 h-fit ml-auto">Erfassen</a>
|
||||
</div>
|
||||
<div class="h-full-grid overflow-auto grid-cols-3 gap-4">
|
||||
for _, diary := range diaries {
|
||||
<a href={ templ.URL(fmt.Sprintf("/diary/%d", diary.ID)) } class="relative p-6 border-2 border-outline rounded-lg shadow bg-surface-container-highest hover:bg-surface-container-highest-850">
|
||||
/* Just a dummy to make the anchor clickable after adding the iframe */
|
||||
<div class="absolute inset-0"></div>
|
||||
<h4 class="mb-1 line-clamp-2">{ diary.Title }</h4>
|
||||
<div class="text-sm text-bright/75 w-full text-right mb-2 line-clamp-2">am { diary.CreationTime.Format("02.01.2006 15:04") }Uhr von { diary.Owner.Username }</div>
|
||||
<iframe allowtransparency="true" class="preview size-full max-w-3xl bg-transparent" data-markdown={ diary.ShortContent() }></iframe>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
for _, tag := range diary.Tags {
|
||||
<span class="line-clamp-1 w-fit bg-dark-750 text-bright/75 text-xs font-medium px-2.5 py-0.5 rounded">{ tag.Title }</span>
|
||||
}
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
106
WEB43-diary/web/templates/settings.templ
Normal file
106
WEB43-diary/web/templates/settings.templ
Normal file
@ -0,0 +1,106 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/context"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/internal/database"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/internal/models"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/internal/session"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/web/templates/shared"
|
||||
)
|
||||
|
||||
templ Settings(context *context.Context) {
|
||||
{{
|
||||
users, err := database.GetAll(context, &models.User{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sessionUser := session.GetSession(context).GetUser()
|
||||
if sessionUser == nil || sessionUser.ID <= 0 {
|
||||
return models.NewHTTPError(http.StatusUnauthorized)
|
||||
}
|
||||
}}
|
||||
@shared.Layout(context) {
|
||||
<div class="flex gap-4 w-[76rem] m-auto pt-6">
|
||||
<form method="PUT" action={ templ.URL(fmt.Sprintf("/user/%d", sessionUser.ID)) } enctype="multipart/form-data" data-redirect-url="/settings" class="flex flex-col gap-4 w-1/2 p-6 bg-surface-container-highest rounded-lg">
|
||||
<h2 class="text-center">Nutzerinformationen</h2>
|
||||
<div>
|
||||
<label for="firstName" class="text-sm font-medium">Vorname*</label>
|
||||
<input type="firstName" name="firstName" id="firstName" class="outline-gray-400 rounded-lg w-full p-2.5 text-ellipsis" maxlength="256" value={ sessionUser.FirstName } required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="lastName" class="text-sm font-medium">Nachname*</label>
|
||||
<input type="lastName" name="lastName" id="lastName" class="outline-gray-400 rounded-lg w-full p-2.5 text-ellipsis" maxlength="256" value={ sessionUser.LastName } required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="username" class="text-sm font-medium">Nutzername*</label>
|
||||
<input type="username" name="username" id="username" class="outline-gray-400 rounded-lg w-full p-2.5 text-ellipsis" maxlength="256" value={ sessionUser.Username } required/>
|
||||
</div>
|
||||
<button type="submit" class="w-full text-on-bg bg-primary hover:bg-primary-650 font-medium rounded-lg text-sm px-5 py-2.5">Informationen ändern</button>
|
||||
</form>
|
||||
<form method="PUT" action={ templ.URL(fmt.Sprintf("/user/%d/password", sessionUser.ID)) } enctype="multipart/form-data" data-redirect-url="/settings" class="flex flex-col gap-4 w-1/2 p-6 bg-surface-container-highest rounded-lg">
|
||||
<h2 class="text-center">Passwort ändern</h2>
|
||||
<div>
|
||||
<label for="current-password" class="text-sm font-medium">Aktuelles Passwort*</label>
|
||||
<input type="password" name="currentPassword" id="current-password" class="outline-gray-400 rounded-lg w-full p-2.5 text-ellipsis" maxlength="256" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="new-password" class="text-sm font-medium">Neues Passwort*</label>
|
||||
<input type="password" name="newPassword" id="new-password" class="outline-gray-400 rounded-lg w-full p-2.5 text-ellipsis" maxlength="256" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password-repeat" class="text-sm font-medium">Passwort wiederholen*</label>
|
||||
<input type="password" name="passwordRepeat" id="password-repeat" class="outline-gray-400 rounded-lg w-full p-2.5 text-ellipsis" maxlength="256" required/>
|
||||
</div>
|
||||
<button type="submit" class="w-full text-on-bg bg-primary hover:bg-primary-650 font-medium rounded-lg text-sm px-5 py-2.5">Passwort ändern</button>
|
||||
</form>
|
||||
</div>
|
||||
<form method="DELETE" action={ templ.URL(fmt.Sprintf("/user/%d", sessionUser.ID)) } data-redirect-url="/" class="w-1/3 my-6 mx-auto p-6 bg-surface-container-highest rounded-lg h-fit">
|
||||
<h2 class="text-center">Nutzerkonto löschen</h2>
|
||||
<button type="submit" class="w-full text-on-bg bg-red-700 hover:bg-red-500 font-medium rounded-lg text-sm px-5 py-2.5">!Löschen!</button>
|
||||
</form>
|
||||
if sessionUser.Role.IsAdminUser() {
|
||||
<div class="mx-auto pb-6">
|
||||
<table id="users-table" class="overflow-hidden rounded-2xl w-[76rem] text-sm text-left text-dark-250">
|
||||
<thead class="text-xs text-dark-350 uppercase bg-surface-variant">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3">Nutzername</th>
|
||||
<th scope="col" class="px-6 py-3">Vorname</th>
|
||||
<th scope="col" class="px-6 py-3">Nachname</th>
|
||||
<th scope="col" class="px-6 py-3">Admin</th>
|
||||
<th scope="col" class="px-6 py-3">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y">
|
||||
{{ i := 0 }}
|
||||
for _, user := range users {
|
||||
{{
|
||||
if user.ID == sessionUser.ID {
|
||||
continue
|
||||
}
|
||||
|
||||
class := "bg-surface-variant-950 hover:bg-surface-variant-650"
|
||||
if i%2 != 0 {
|
||||
class = "bg-surface-variant-800 hover:bg-surface-variant-500"
|
||||
}
|
||||
i++
|
||||
}}
|
||||
<tr class={ class }>
|
||||
<th scope="row" class="px-6 py-4 font-medium text-bright">{ user.Username }</th>
|
||||
<td class="px-6 py-4">{ user.FirstName }</td>
|
||||
<td class="px-6 py-4">{ user.LastName }</td>
|
||||
<td class="px-6 py-4"><input type="checkbox" class="w-4 h-4" checked?={ user.Role.IsAdminUser() } disabled/></td>
|
||||
<td>
|
||||
<button data-user-id={ user.IDString() } class="delete-button font-medium rounded-lg text-sm px-5 py-2.5 w-full text-red-600 hover:bg-dark-450">Löschen</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
65
WEB43-diary/web/templates/shared/header.templ
Normal file
65
WEB43-diary/web/templates/shared/header.templ
Normal file
@ -0,0 +1,65 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/context"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/internal/session"
|
||||
)
|
||||
|
||||
templ header(context *context.Context) {
|
||||
{{ sess := session.GetSession(context) }}
|
||||
<header class="sticky top-0 bg-surface-container shadow-[0_.5rem_.5rem_#00000080]">
|
||||
<nav class="max-w-screen-2xl flex flex-wrap items-center justify-between mx-auto px-2 py-1 gap-4">
|
||||
<a href="/" class="flex flex-center p-2 gap-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="2rem" height="2rem" viewBox="0 0 16 16">
|
||||
<g fill="currentColor">
|
||||
<path d="M5 10.5a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 0 1h-2a.5.5 0 0 1-.5-.5m0-2a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5m0-2a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5m0-2a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5"></path>
|
||||
<path d="M3 0h10a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2v-1h1v1a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v1H1V2a2 2 0 0 1 2-2"></path>
|
||||
<path d="M1 5v-.5a.5.5 0 0 1 1 0V5h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1zm0 3v-.5a.5.5 0 0 1 1 0V8h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1zm0 3v-.5a.5.5 0 0 1 1 0v.5h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1z"></path>
|
||||
</g>
|
||||
</svg>
|
||||
<h1 class="text-2xl m-0">WEB43 Diary</h1>
|
||||
</a>
|
||||
<div class="flex items-center m:order-2 gap-4">
|
||||
<label class="inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="theme-switcher" class="sr-only peer" checked?={ sess.GetTheme().IsLightTheme() }/>
|
||||
<div class="relative w-11 h-6 bg-dark-450 rounded-full peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-bright after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-dark-600"></div>
|
||||
</label>
|
||||
if user := sess.GetUser(); user.ID > 0 {
|
||||
<button type="button" class="flex text-highl flex-center bg-surface-variant rounded-full overflow-hidden w-10 h-10" id="user-menu-button" aria-expanded="false" data-dropdown-toggle="user-dropdown" data-dropdown-placement="bottom">
|
||||
<span class="sr-only">Nutzermenu öffnen</span>
|
||||
<span class="font-medium">{ user.Initials() }</span>
|
||||
</button>
|
||||
} else {
|
||||
{{
|
||||
redirectURL := context.Request.URL.RawQuery
|
||||
if redirectURL == "" {
|
||||
redirectURL = context.Request.URL.Path
|
||||
}
|
||||
}}
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/user/login/?%s", redirectURL)) } class="text-on-bg bg-primary hover:bg-primary-650 font-medium rounded-lg text-sm px-5 py-2.5">Login</a>
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/user/register/?%s", redirectURL)) } class="text-on-bg bg-secondary hover:bg-secondary-650 font-medium rounded-lg text-sm px-5 py-2.5">Registrieren</a>
|
||||
}
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
if user := sess.GetUser(); user.ID > 0 {
|
||||
<!-- Dropdown menu -->
|
||||
<div class="z-50 w-64 hidden bg-surface-container-highest divide-y divide-bright/50 rounded-lg shadow" id="user-dropdown">
|
||||
<div class="flex flex-center gap-2 px-4 py-3">
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium line-clamp-2">{ user.Username }</span>
|
||||
<span class="text-sm text-dark-350 line-clamp-2">{ user.FullName() }</span>
|
||||
</div>
|
||||
<a href="/settings" class="size-10 p-2 text-bright/80 rounded-full hover:bg-dark/35"><i class="ic--outline-settings size-full"></i></a>
|
||||
</div>
|
||||
<ul class="py-2 text-bright/80 text-sm" aria-labelledby="user-menu-button">
|
||||
<li><a href="/" class="block px-4 py-2 hover:bg-dark/35">Startseite</a></li>
|
||||
<li><a href={ templ.URL(fmt.Sprintf("/?owners=%d", user.ID)) } class="block px-4 py-2 hover:bg-dark/35">Meine Einträge</a></li>
|
||||
</ul>
|
||||
<ul class="py-2 text-sm" aria-labelledby="user-menu-button">
|
||||
<li><a href={ templ.URL(fmt.Sprintf("/user/%d/logout", user.ID)) } class="block px-4 py-2 text-red-600 hover:bg-dark/35">Ausloggen</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
}
|
35
WEB43-diary/web/templates/shared/layout.templ
Normal file
35
WEB43-diary/web/templates/shared/layout.templ
Normal file
@ -0,0 +1,35 @@
|
||||
package shared
|
||||
|
||||
import "gitea.zokki.net/zokki/uni/web43-diary/context"
|
||||
|
||||
templ Layout(context *context.Context) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" class="bg-background">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<link rel="stylesheet" href="/assets/css/styles.css"/>
|
||||
<link rel="stylesheet" href="/assets/css/global.css"/>
|
||||
<script src="/assets/js/index.js" defer></script>
|
||||
<script src="/assets/js/textarea.js" defer></script>
|
||||
<script src="/assets/js/createTags.js" defer></script>
|
||||
<title>WEB43-Diary</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="error-alert" class="hidden absolute-center-x top-2 z-50 max-w-screen-l mx-auto flex gap-4 items-center p-4 text-red-700 rounded-lg bg-dark-850" role="alert">
|
||||
<i class="ic--outline-error-outline flex-shrink-0 w-6 h-6" aria-hidden="true"></i>
|
||||
<span class="sr-only">Fehler</span>
|
||||
<div class="alert-content text-sm font-medium"></div>
|
||||
<button class="ml-auto -mx-1.5 -my-1.5 text-red-500 hover:bg-dark-450 rounded-lg inline-flex items-center justify-center h-8 w-8" data-dismiss-target="#error-alert" aria-label="Close">
|
||||
<span class="sr-only">Close</span>
|
||||
<i class="ic--outline-close w-4 h-4" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
@header(context)
|
||||
<main>
|
||||
{ children... }
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
}
|
17
WEB43-diary/web/templates/unauthorized.templ
Normal file
17
WEB43-diary/web/templates/unauthorized.templ
Normal file
@ -0,0 +1,17 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/context"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/web/templates/shared"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
templ Unauthorized(context *context.Context) {
|
||||
{{ context.ResponseWriter.WriteHeader(http.StatusUnauthorized) }}
|
||||
@shared.Layout(context) {
|
||||
<div class="m-auto p-6 border-2 border-outline rounded-lg shadow bg-surface-container-highest">
|
||||
<h1>Sie haben keine Berechtigung, diese Seite zu sehen.</h1>
|
||||
<p class="text-2xl text-center">Kehren Sie zur <a href="/" class="underline text-blue-600">Startseite</a> zurück oder <a href="/login" class="underline text-blue-600">loggen</a> Sie sich ein.</p>
|
||||
</div>
|
||||
}
|
||||
}
|
29
WEB43-diary/web/templates/user/login.templ
Normal file
29
WEB43-diary/web/templates/user/login.templ
Normal file
@ -0,0 +1,29 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/context"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/web/templates/shared"
|
||||
)
|
||||
|
||||
templ Login(context *context.Context) {
|
||||
{{
|
||||
redirectURL := context.Request.URL.RawQuery
|
||||
if redirectURL == "" {
|
||||
redirectURL = "/"
|
||||
}
|
||||
}}
|
||||
@shared.Layout(context) {
|
||||
<form method="POST" action="/user/login" enctype="multipart/form-data" data-redirect-url={ redirectURL } class="flex flex-col gap-4 w-[56rem] m-auto p-6 bg-surface-container-highest rounded-lg">
|
||||
<h2 class="text-center">Login</h2>
|
||||
<div>
|
||||
<label for="username" class="text-sm font-medium">Nutzername*</label>
|
||||
<input type="username" name="username" id="username" class="outline-gray-400 rounded-lg w-full p-2.5 text-ellipsis" maxlength="256" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="text-sm font-medium">Passwort*</label>
|
||||
<input type="password" name="password" id="password" class="outline-gray-400 rounded-lg w-full p-2.5 text-ellipsis" maxlength="256" required/>
|
||||
</div>
|
||||
<button type="submit" class="w-full text-on-bg bg-primary hover:bg-primary-650 font-medium rounded-lg text-sm px-5 py-2.5">Einloggen</button>
|
||||
</form>
|
||||
}
|
||||
}
|
35
WEB43-diary/web/templates/user/register.templ
Normal file
35
WEB43-diary/web/templates/user/register.templ
Normal file
@ -0,0 +1,35 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/context"
|
||||
"gitea.zokki.net/zokki/uni/web43-diary/web/templates/shared"
|
||||
)
|
||||
|
||||
templ Register(context *context.Context) {
|
||||
@shared.Layout(context) {
|
||||
<form method="POST" action="/user/register" enctype="multipart/form-data" data-redirect-url={ "/user/login/?" + context.Request.URL.RawQuery } class="flex flex-col gap-4 w-[56rem] m-auto p-6 bg-surface-container-highest rounded-lg">
|
||||
<h2 class="text-center">Registrieren</h2>
|
||||
<div>
|
||||
<label for="firstName" class="text-sm font-medium">Vorname*</label>
|
||||
<input type="firstName" name="firstName" id="firstName" class="outline-gray-400 rounded-lg w-full p-2.5 text-ellipsis" maxlength="256" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="lastName" class="text-sm font-medium">Nachname*</label>
|
||||
<input type="lastName" name="lastName" id="lastName" class="outline-gray-400 rounded-lg w-full p-2.5 text-ellipsis" maxlength="256" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="username" class="text-sm font-medium">Nutzername*</label>
|
||||
<input type="username" name="username" id="username" class="outline-gray-400 rounded-lg w-full p-2.5 text-ellipsis" maxlength="256" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="text-sm font-medium">Passwort*</label>
|
||||
<input type="password" name="password" id="password" class="outline-gray-400 rounded-lg w-full p-2.5 text-ellipsis" maxlength="256" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password-repeat" class="text-sm font-medium">Passwort wiederholen*</label>
|
||||
<input type="password" name="passwordRepeat" id="password-repeat" class="outline-gray-400 rounded-lg w-full p-2.5 text-ellipsis" maxlength="256" required/>
|
||||
</div>
|
||||
<button type="submit" class="w-full text-on-bg bg-primary hover:bg-primary-650 font-medium rounded-lg text-sm px-5 py-2.5">Registrieren</button>
|
||||
</form>
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user