WEB43: Diary website

This commit is contained in:
Tim-Niclas Oelschläger 2025-03-01 21:27:12 +01:00
parent 1f1385dd45
commit 60ad345bce
Signed by: zokki
SSH Key Fingerprint: SHA256:HxmVKMFSukiF1LvbgazUKRFiTky2CzbvN72B8U1yhXo
46 changed files with 6867 additions and 0 deletions

25
WEB43-diary/.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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"

View 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
View 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
View 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=

View 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
}

View 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
}

View 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
}

View 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
}

View 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()))
}
}
}

View 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
}

View 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",
}

View 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
}

View 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)
})
}

View 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
}

View 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
}

View 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
}

View 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())
}

View 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)
}

View 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
}

View 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
}

View 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
}

View 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)
})
})
})
}

View 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
}
}

View 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
}

View 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())
}

View 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()
}

View 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
View 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

File diff suppressed because it is too large Load Diff

11
WEB43-diary/package.json Normal file
View 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"
}
}

View 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);
}),
],
};

View 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>
}
}

View 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>
}
}

File diff suppressed because it is too large Load Diff

View 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>
}
}

View 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>
}
}

View 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>
}
}
}

View 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>
}
}

View 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>
}

View 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>
}
}

View 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>
}
}

View 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>
}
}