commit e0ec6d06d3fc22578958f202dd55055ebf7be084 Author: Haelnorr Date: Wed Jan 21 20:03:02 2026 +1100 initial commit diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..ad9f38b --- /dev/null +++ b/.air.toml @@ -0,0 +1,52 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "./tmp/main" + cmd = "go build -o ./tmp/main ./cmd/oslstats" + delay = 1000 + exclude_dir = [] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "templ"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = [] + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + silent = false + time = false + +[misc] + clean_on_exit = false + +[proxy] + app_port = 3333 + enabled = true + proxy_port = 3000 + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..33692cc --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.env +*.db* +.logs/ +server.log +bin/ +tmp/ +static/css/output.css +internal/view/**/*_templ.go +internal/view/**/*_templ.txt diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f3f2a8b --- /dev/null +++ b/Makefile @@ -0,0 +1,38 @@ +# Makefile +.PHONY: build + +BINARY_NAME=oslstats + +build: + ./scripts/generate-css-sources.sh && \ + tailwindcss -i ./pkg/embedfs/files/css/input.css -o ./pkg/embedfs/files/css/output.css && \ + go mod tidy && \ + templ generate && \ + go generate ./cmd/${BINARY_NAME} && \ + go build -ldflags="-w -s" -o ./bin/${BINARY_NAME}${SUFFIX} ./cmd/${BINARY_NAME} + +run: + make build + ./bin/${BINARY_NAME}${SUFFIX} + +dev: + ./scripts/generate-css-sources.sh && \ + templ generate --watch &\ + air &\ + tailwindcss -i ./pkg/embedfs/files/css/input.css -o ./pkg/embedfs/files/css/output.css --watch + +clean: + go clean + +genenv: + make build + ./bin/${BINARY_NAME} --genenv ${OUT} + +envdoc: + make build + ./bin/${BINARY_NAME} --envdoc + +showenv: + make build + ./bin/${BINARY_NAME} --showenv + diff --git a/cmd/oslstats/auth.go b/cmd/oslstats/auth.go new file mode 100644 index 0000000..4f57359 --- /dev/null +++ b/cmd/oslstats/auth.go @@ -0,0 +1,44 @@ +package main + +import ( + "context" + + "git.haelnorr.com/h/golib/hlog" + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/golib/hwsauth" + "git.haelnorr.com/h/oslstats/internal/db" + "git.haelnorr.com/h/oslstats/internal/handlers" + "git.haelnorr.com/h/oslstats/pkg/contexts" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +func setupAuth( + config *hwsauth.Config, + logger *hlog.Logger, + conn *bun.DB, + server *hws.Server, + ignoredPaths []string, +) (*hwsauth.Authenticator[*db.User, bun.Tx], error) { + beginTx := func(ctx context.Context) (hwsauth.DBTransaction, error) { + tx, err := conn.BeginTx(ctx, nil) + return tx, err + } + auth, err := hwsauth.NewAuthenticator( + config, + db.GetUserByID, + server, + beginTx, + logger, + handlers.ErrorPage, + ) + if err != nil { + return nil, errors.Wrap(err, "hwsauth.NewAuthenticator") + } + + auth.IgnorePaths(ignoredPaths...) + + contexts.CurrentUser = auth.CurrentModel + + return auth, nil +} diff --git a/cmd/oslstats/db.go b/cmd/oslstats/db.go new file mode 100644 index 0000000..1df8f57 --- /dev/null +++ b/cmd/oslstats/db.go @@ -0,0 +1,53 @@ +package main + +import ( + "context" + "database/sql" + "fmt" + + "git.haelnorr.com/h/oslstats/internal/config" + "git.haelnorr.com/h/oslstats/internal/db" + "github.com/pkg/errors" + "github.com/uptrace/bun" + "github.com/uptrace/bun/dialect/pgdialect" + "github.com/uptrace/bun/driver/pgdriver" +) + +func setupBun(ctx context.Context, cfg *config.Config) (conn *bun.DB, close func() error, err error) { + dsn := fmt.Sprintf("postgres://%s:%s@%s:%v/%s?sslmode=%s", + cfg.DB.User, cfg.DB.Password, cfg.DB.Host, cfg.DB.Port, cfg.DB.DB, cfg.DB.SSL) + sqldb := sql.OpenDB(pgdriver.NewConnector(pgdriver.WithDSN(dsn))) + conn = bun.NewDB(sqldb, pgdialect.New()) + close = sqldb.Close + + err = loadModels(ctx, conn, cfg.Flags.ResetDB) + if err != nil { + return nil, nil, errors.Wrap(err, "loadModels") + } + + return conn, close, nil +} + +func loadModels(ctx context.Context, conn *bun.DB, resetDB bool) error { + models := []any{ + (*db.User)(nil), + } + + for _, model := range models { + _, err := conn.NewCreateTable(). + Model(model). + IfNotExists(). + Exec(ctx) + if err != nil { + return errors.Wrap(err, "db.NewCreateTable") + } + if resetDB { + err = conn.ResetModel(ctx, model) + if err != nil { + return errors.Wrap(err, "db.ResetModel") + } + } + } + + return nil +} diff --git a/cmd/oslstats/httpserver.go b/cmd/oslstats/httpserver.go new file mode 100644 index 0000000..fa0461d --- /dev/null +++ b/cmd/oslstats/httpserver.go @@ -0,0 +1,67 @@ +package main + +import ( + "io/fs" + "net/http" + + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/oslstats/internal/config" + "git.haelnorr.com/h/oslstats/internal/handlers" + + "git.haelnorr.com/h/golib/hlog" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +func setupHttpServer( + staticFS *fs.FS, + config *config.Config, + logger *hlog.Logger, + bun *bun.DB, +) (server *hws.Server, err error) { + if staticFS == nil { + return nil, errors.New("No filesystem provided") + } + fs := http.FS(*staticFS) + httpServer, err := hws.NewServer(config.HWS) + if err != nil { + return nil, errors.Wrap(err, "hws.NewServer") + } + + ignoredPaths := []string{ + "/static/css/output.css", + "/static/favicon.ico", + } + + auth, err := setupAuth(config.HWSAuth, logger, bun, httpServer, ignoredPaths) + if err != nil { + return nil, errors.Wrap(err, "setupAuth") + } + + err = httpServer.AddErrorPage(handlers.ErrorPage) + if err != nil { + return nil, errors.Wrap(err, "httpServer.AddErrorPage") + } + + err = httpServer.AddLogger(logger) + if err != nil { + return nil, errors.Wrap(err, "httpServer.AddLogger") + } + + err = httpServer.LoggerIgnorePaths(ignoredPaths...) + if err != nil { + return nil, errors.Wrap(err, "httpServer.LoggerIgnorePaths") + } + + err = addRoutes(httpServer, &fs, config, logger, bun, auth) + if err != nil { + return nil, errors.Wrap(err, "addRoutes") + } + + err = addMiddleware(httpServer, auth) + if err != nil { + return nil, errors.Wrap(err, "httpServer.AddMiddleware") + } + + return httpServer, nil +} diff --git a/cmd/oslstats/main.go b/cmd/oslstats/main.go new file mode 100644 index 0000000..e92d126 --- /dev/null +++ b/cmd/oslstats/main.go @@ -0,0 +1,36 @@ +package main + +import ( + "context" + "fmt" + "os" + + "git.haelnorr.com/h/oslstats/internal/config" + "github.com/pkg/errors" +) + +func main() { + flags := config.SetupFlags() + ctx := context.Background() + + cfg, loader, err := config.GetConfig(flags) + if err != nil { + fmt.Fprintf(os.Stderr, "%s\n", errors.Wrap(err, "Failed to load config")) + os.Exit(1) + } + + if flags.EnvDoc || flags.ShowEnv { + loader.PrintEnvVarsStdout(flags.ShowEnv) + return + } + + if flags.GenEnv != "" { + loader.GenerateEnvFile(flags.GenEnv, true) + return + } + + if err := run(ctx, os.Stdout, cfg); err != nil { + fmt.Fprintf(os.Stderr, "%s\n", err) + os.Exit(1) + } +} diff --git a/cmd/oslstats/middleware.go b/cmd/oslstats/middleware.go new file mode 100644 index 0000000..cd4ea78 --- /dev/null +++ b/cmd/oslstats/middleware.go @@ -0,0 +1,24 @@ +package main + +import ( + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/golib/hwsauth" + "git.haelnorr.com/h/oslstats/internal/db" + + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +func addMiddleware( + server *hws.Server, + auth *hwsauth.Authenticator[*db.User, bun.Tx], +) error { + + err := server.AddMiddleware( + auth.Authenticate(), + ) + if err != nil { + return errors.Wrap(err, "server.AddMiddleware") + } + return nil +} diff --git a/cmd/oslstats/routes.go b/cmd/oslstats/routes.go new file mode 100644 index 0000000..efea343 --- /dev/null +++ b/cmd/oslstats/routes.go @@ -0,0 +1,45 @@ +package main + +import ( + "net/http" + + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/golib/hwsauth" + "git.haelnorr.com/h/oslstats/internal/config" + "git.haelnorr.com/h/oslstats/internal/db" + "git.haelnorr.com/h/oslstats/internal/handlers" + + "git.haelnorr.com/h/golib/hlog" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +func addRoutes( + server *hws.Server, + staticFS *http.FileSystem, + config *config.Config, + logger *hlog.Logger, + conn *bun.DB, + auth *hwsauth.Authenticator[*db.User, bun.Tx], +) error { + // Create the routes + routes := []hws.Route{ + { + Path: "/static/", + Method: hws.MethodGET, + Handler: http.StripPrefix("/static/", handlers.StaticFS(staticFS, server)), + }, + { + Path: "/", + Method: hws.MethodGET, + Handler: handlers.Index(server), + }, + } + + // Register the routes with the server + err := server.AddRoutes(routes...) + if err != nil { + return errors.Wrap(err, "server.AddRoutes") + } + return nil +} diff --git a/cmd/oslstats/run.go b/cmd/oslstats/run.go new file mode 100644 index 0000000..7906c26 --- /dev/null +++ b/cmd/oslstats/run.go @@ -0,0 +1,72 @@ +package main + +import ( + "context" + "io" + "os" + "os/signal" + "sync" + "time" + + "git.haelnorr.com/h/golib/hlog" + "git.haelnorr.com/h/oslstats/internal/config" + "git.haelnorr.com/h/oslstats/pkg/embedfs" + "github.com/pkg/errors" +) + +// Initializes and runs the server +func run(ctx context.Context, w io.Writer, config *config.Config) error { + ctx, cancel := signal.NotifyContext(ctx, os.Interrupt) + defer cancel() + + // Setup the logger + logger, err := hlog.NewLogger(config.HLOG, w) + if err != nil { + return errors.Wrap(err, "hlog.NewLogger") + } + + // Setup the database connection + logger.Debug().Msg("Config loaded and logger started") + logger.Debug().Msg("Connecting to database") + bun, closedb, err := setupBun(ctx, config) + if err != nil { + return errors.Wrap(err, "setupDBConn") + } + defer closedb() + + // Setup embedded files + logger.Debug().Msg("Getting embedded files") + staticFS, err := embedfs.GetEmbeddedFS() + if err != nil { + return errors.Wrap(err, "getStaticFiles") + } + + logger.Debug().Msg("Setting up HTTP server") + httpServer, err := setupHttpServer(&staticFS, config, logger, bun) + if err != nil { + return errors.Wrap(err, "setupHttpServer") + } + + // Runs the http server + logger.Debug().Msg("Starting up the HTTP server") + err = httpServer.Start(ctx) + if err != nil { + return errors.Wrap(err, "httpServer.Start") + } + + // Handles graceful shutdown + var wg sync.WaitGroup + wg.Go(func() { + <-ctx.Done() + shutdownCtx := context.Background() + shutdownCtx, cancel := context.WithTimeout(shutdownCtx, 10*time.Second) + defer cancel() + err := httpServer.Shutdown(shutdownCtx) + if err != nil { + logger.Error().Err(err).Msg("Graceful shutdown failed") + } + }) + wg.Wait() + logger.Info().Msg("Shutting down") + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..26e14e9 --- /dev/null +++ b/go.mod @@ -0,0 +1,41 @@ +module git.haelnorr.com/h/oslstats + +go 1.25.5 + +require ( + git.haelnorr.com/h/golib/env v0.9.1 + git.haelnorr.com/h/golib/ezconf v0.1.1 + git.haelnorr.com/h/golib/hlog v0.10.4 + git.haelnorr.com/h/golib/hws v0.2.3 + git.haelnorr.com/h/golib/hwsauth v0.3.4 + github.com/a-h/templ v0.3.977 + github.com/joho/godotenv v1.5.1 + github.com/pkg/errors v0.9.1 + github.com/uptrace/bun v1.2.16 + github.com/uptrace/bun/dialect/pgdialect v1.2.16 + github.com/uptrace/bun/driver/pgdriver v1.2.16 + golang.org/x/crypto v0.45.0 +) + +require ( + git.haelnorr.com/h/golib/cookies v0.9.0 // indirect + git.haelnorr.com/h/golib/jwt v0.10.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/golang-jwt/jwt v3.2.2+incompatible // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect + github.com/rs/zerolog v1.34.0 // indirect + github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect + golang.org/x/sys v0.40.0 // indirect + k8s.io/apimachinery v0.35.0 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/utils v0.0.0-20260108192941-914a6e750570 // indirect + mellium.im/sasl v0.3.2 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..78c8ff7 --- /dev/null +++ b/go.sum @@ -0,0 +1,85 @@ +git.haelnorr.com/h/golib/cookies v0.9.0 h1:Vf+eX1prHkKuGrQon1BHY87yaPc1H+HJFRXDOV/AuWs= +git.haelnorr.com/h/golib/cookies v0.9.0/go.mod h1:y1385YExI9gLwckCVDCYVcsFXr6N7T3brJjnJD2QIuo= +git.haelnorr.com/h/golib/env v0.9.1 h1:2Vsj+mJKnO5f1Md1GO5v9ggLN5zWa0baCewcSHTjoNY= +git.haelnorr.com/h/golib/env v0.9.1/go.mod h1:glUQVdA1HMKX1avTDyTyuhcr36SSxZtlJxKDT5KTztg= +git.haelnorr.com/h/golib/ezconf v0.1.1 h1:4euTSDb9jvuQQkVq+x5gHoYPYyUZPWxoOSlWCIxTZOs= +git.haelnorr.com/h/golib/ezconf v0.1.1/go.mod h1:rETDcjpcEyyeBgCiZSU617wc0XycwZSC5+IAOtXmwP8= +git.haelnorr.com/h/golib/hlog v0.10.4 h1:vpCsV/OddjIYx8F48U66WxojjmhEbeLGQAOBG4ViSRQ= +git.haelnorr.com/h/golib/hlog v0.10.4/go.mod h1:+wJ8vecQY/JITTXKmI3JfkHiUGyMs7N6wooj2wuWZbc= +git.haelnorr.com/h/golib/hws v0.2.3 h1:gZQkBciXKh3jYw05vZncSR2lvIqi0H2MVfIWySySsmw= +git.haelnorr.com/h/golib/hws v0.2.3/go.mod h1:6ZlRKnt8YMpv5XcMXmyBGmD1/euvBo3d1azEvHJjOLo= +git.haelnorr.com/h/golib/hwsauth v0.3.4 h1:wwYBb6cQQ+x9hxmYuZBF4mVmCv/n4PjJV//e1+SgPOo= +git.haelnorr.com/h/golib/hwsauth v0.3.4/go.mod h1:LI7Qz68GPNIW8732Zwptb//ybjiFJOoXf4tgUuUEqHI= +git.haelnorr.com/h/golib/jwt v0.10.0 h1:8cI8mSnb8X+EmJtrBO/5UZwuBMtib0IE9dv85gkm94E= +git.haelnorr.com/h/golib/jwt v0.10.0/go.mod h1:fbuPrfucT9lL0faV5+Q5Gk9WFJxPlwzRPpbMQKYZok4= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/a-h/templ v0.3.977 h1:kiKAPXTZE2Iaf8JbtM21r54A8bCNsncrfnokZZSrSDg= +github.com/a-h/templ v0.3.977/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg= +github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo= +github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs= +github.com/uptrace/bun v1.2.16 h1:QlObi6ZIK5Ao7kAALnh91HWYNZUBbVwye52fmlQM9kc= +github.com/uptrace/bun v1.2.16/go.mod h1:jMoNg2n56ckaawi/O/J92BHaECmrz6IRjuMWqlMaMTM= +github.com/uptrace/bun/dialect/pgdialect v1.2.16 h1:KFNZ0LxAyczKNfK/IJWMyaleO6eI9/Z5tUv3DE1NVL4= +github.com/uptrace/bun/dialect/pgdialect v1.2.16/go.mod h1:IJdMeV4sLfh0LDUZl7TIxLI0LipF1vwTK3hBC7p5qLo= +github.com/uptrace/bun/driver/pgdriver v1.2.16 h1:b1kpXKUxtTSGYow5Vlsb+dKV3z0R7aSAJNfMfKp61ZU= +github.com/uptrace/bun/driver/pgdriver v1.2.16/go.mod h1:H6lUZ9CBfp1X5Vq62YGSV7q96/v94ja9AYFjKvdoTk0= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8= +k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/utils v0.0.0-20260108192941-914a6e750570 h1:JT4W8lsdrGENg9W+YwwdLJxklIuKWdRm+BC+xt33FOY= +k8s.io/utils v0.0.0-20260108192941-914a6e750570/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +mellium.im/sasl v0.3.2 h1:PT6Xp7ccn9XaXAnJ03FcEjmAn7kK1x7aoXV6F+Vmrl0= +mellium.im/sasl v0.3.2/go.mod h1:NKXDi1zkr+BlMHLQjY3ofYuU4KSPFxknb8mfEu6SveY= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..87b9f15 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,77 @@ +package config + +import ( + "git.haelnorr.com/h/golib/ezconf" + "git.haelnorr.com/h/golib/hlog" + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/golib/hwsauth" + "git.haelnorr.com/h/oslstats/internal/db" + "github.com/joho/godotenv" + "github.com/pkg/errors" +) + +type Config struct { + DB *db.Config + HWS *hws.Config + HWSAuth *hwsauth.Config + HLOG *hlog.Config + Flags *Flags +} + +// Load the application configuration and get a pointer to the Config object +// If doconly is specified, only the loader will be returned +func GetConfig(flags *Flags) (*Config, *ezconf.ConfigLoader, error) { + err := godotenv.Load(flags.EnvFile) + if err != nil && flags.GenEnv == "" && !flags.EnvDoc { + return nil, nil, errors.Wrap(err, "gotgodotenv.Load") + } + + loader := ezconf.New() + loader.RegisterIntegrations( + hlog.NewEZConfIntegration(), + hws.NewEZConfIntegration(), + hwsauth.NewEZConfIntegration(), + db.NewEZConfIntegration(), + ) + if err := loader.ParseEnvVars(); err != nil { + return nil, nil, errors.Wrap(err, "loader.ParseEnvVars") + } + + if flags.GenEnv != "" || flags.EnvDoc { + return nil, loader, nil + } + + if err := loader.LoadConfigs(); err != nil { + return nil, nil, errors.Wrap(err, "loader.LoadConfigs") + } + + hwscfg, ok := loader.GetConfig("hws") + if !ok { + return nil, nil, errors.New("HWS Config not loaded") + } + + hwsauthcfg, ok := loader.GetConfig("hwsauth") + if !ok { + return nil, nil, errors.New("HWSAuth Config not loaded") + } + + hlogcfg, ok := loader.GetConfig("hlog") + if !ok { + return nil, nil, errors.New("HLog Config not loaded") + } + + dbcfg, ok := loader.GetConfig("db") + if !ok { + return nil, nil, errors.New("DB Config not loaded") + } + + config := &Config{ + DB: dbcfg.(*db.Config), + HWS: hwscfg.(*hws.Config), + HWSAuth: hwsauthcfg.(*hwsauth.Config), + HLOG: hlogcfg.(*hlog.Config), + Flags: flags, + } + + return config, loader, nil +} diff --git a/internal/config/flags.go b/internal/config/flags.go new file mode 100644 index 0000000..7d89108 --- /dev/null +++ b/internal/config/flags.go @@ -0,0 +1,32 @@ +package config + +import ( + "flag" +) + +type Flags struct { + ResetDB bool + EnvDoc bool + ShowEnv bool + GenEnv string + EnvFile string +} + +func SetupFlags() *Flags { + // Parse commandline args + resetDB := flag.Bool("resetdb", false, "Reset all the database tables with the updated models") + envDoc := flag.Bool("envdoc", false, "Print all environment variables and their documentation") + showEnv := flag.Bool("showenv", false, "Print all environment variable values and their documentation") + genEnv := flag.String("genenv", "", "Generate a .env file with all environment variables (specify filename)") + envfile := flag.String("envfile", ".env", "Specify a .env file to use for the configuration") + flag.Parse() + + flags := &Flags{ + ResetDB: *resetDB, + EnvDoc: *envDoc, + ShowEnv: *showEnv, + GenEnv: *genEnv, + EnvFile: *envfile, + } + return flags +} diff --git a/internal/db/config.go b/internal/db/config.go new file mode 100644 index 0000000..bc50be7 --- /dev/null +++ b/internal/db/config.go @@ -0,0 +1,55 @@ +package db + +import ( + "git.haelnorr.com/h/golib/env" + "github.com/pkg/errors" +) + +type Config struct { + User string // ENV DB_USER: Database user for authentication (required) + Password string // ENV DB_PASSWORD: Database password for authentication (required) + Host string // ENV DB_HOST: Database host address (required) + Port uint16 // ENV DB_PORT: Database port (default: 5432) + DB string // ENV DB_NAME: Database name to connect to (required) + SSL string // ENV DB_SSL: SSL mode for connection (default: disable) +} + +func ConfigFromEnv() (any, error) { + cfg := &Config{ + User: env.String("DB_USER", ""), + Password: env.String("DB_PASSWORD", ""), + Host: env.String("DB_HOST", ""), + Port: env.UInt16("DB_PORT", 5432), + DB: env.String("DB_NAME", ""), + SSL: env.String("DB_SSL", "disable"), + } + + // Validate SSL mode + validSSLModes := map[string]bool{ + "disable": true, + "require": true, + "verify-ca": true, + "verify-full": true, + "allow": true, + "prefer": true, + } + if !validSSLModes[cfg.SSL] { + return nil, errors.Errorf("Invalid DB_SSL value: %s. Must be one of: disable, allow, prefer, require, verify-ca, verify-full", cfg.SSL) + } + + // Check required fields + if cfg.User == "" { + return nil, errors.New("Envar not set: DB_USER") + } + if cfg.Password == "" { + return nil, errors.New("Envar not set: DB_PASSWORD") + } + if cfg.Host == "" { + return nil, errors.New("Envar not set: DB_HOST") + } + if cfg.DB == "" { + return nil, errors.New("Envar not set: DB_NAME") + } + + return cfg, nil +} diff --git a/internal/db/ezconf.go b/internal/db/ezconf.go new file mode 100644 index 0000000..db4eac9 --- /dev/null +++ b/internal/db/ezconf.go @@ -0,0 +1,41 @@ +package db + +import ( + "runtime" + "strings" +) + +// EZConfIntegration provides integration with ezconf for automatic configuration +type EZConfIntegration struct { + configFunc func() (any, error) + name string +} + +// PackagePath returns the path to the config package for source parsing +func (e EZConfIntegration) PackagePath() string { + _, filename, _, _ := runtime.Caller(0) + // Return directory of this file + return filename[:len(filename)-len("/ezconf.go")] +} + +// ConfigFunc returns the ConfigFromEnv function for ezconf +func (e EZConfIntegration) ConfigFunc() func() (any, error) { + return func() (any, error) { + return e.configFunc() + } +} + +// Name returns the name to use when registering with ezconf +func (e EZConfIntegration) Name() string { + return strings.ToLower(e.name) +} + +// GroupName returns the display name for grouping environment variables +func (e EZConfIntegration) GroupName() string { + return e.name +} + +// NewEZConfIntegration creates a new EZConf integration helper +func NewEZConfIntegration() EZConfIntegration { + return EZConfIntegration{name: "db", configFunc: ConfigFromEnv} +} diff --git a/internal/db/user.go b/internal/db/user.go new file mode 100644 index 0000000..01764eb --- /dev/null +++ b/internal/db/user.go @@ -0,0 +1,163 @@ +package db + +import ( + "context" + + "github.com/pkg/errors" + "github.com/uptrace/bun" + "golang.org/x/crypto/bcrypt" +) + +type User struct { + bun.BaseModel `bun:"table:users,alias:u"` + + ID int `bun:"id,pk,autoincrement"` // Integer ID (index primary key) + Username string `bun:"username,unique"` // Username (unique) + PasswordHash string `bun:"password_hash,nullzero"` // Bcrypt hashed password (not exported in JSON) + CreatedAt int64 `bun:"created_at"` // Epoch timestamp when the user was added to the database + Bio string `bun:"bio"` // Short byline set by the user +} + +func (user *User) GetID() int { + return user.ID +} + +// Uses bcrypt to set the users password_hash from the given password +func (user *User) SetPassword(ctx context.Context, tx bun.Tx, password string) error { + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return errors.Wrap(err, "bcrypt.GenerateFromPassword") + } + newPassword := string(hashedPassword) + + _, err = tx.NewUpdate(). + Model(user). + Set("password_hash = ?", newPassword). + Where("id = ?", user.ID). + Exec(ctx) + if err != nil { + return errors.Wrap(err, "tx.Update") + } + return nil +} + +// Uses bcrypt to check if the given password matches the users password_hash +func (user *User) CheckPassword(ctx context.Context, tx bun.Tx, password string) error { + var hashedPassword string + err := tx.NewSelect(). + Table("users"). + Column("password_hash"). + Where("id = ?", user.ID). + Limit(1). + Scan(ctx, &hashedPassword) + if err != nil { + return errors.Wrap(err, "tx.Select") + } + + err = bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) + if err != nil { + return errors.Wrap(err, "Username or password incorrect") + } + return nil +} + +// Change the user's username +func (user *User) ChangeUsername(ctx context.Context, tx bun.Tx, newUsername string) error { + _, err := tx.NewUpdate(). + Model(user). + Set("username = ?", newUsername). + Where("id = ?", user.ID). + Exec(ctx) + if err != nil { + return errors.Wrap(err, "tx.Update") + } + user.Username = newUsername + return nil +} + +// Change the user's bio +func (user *User) ChangeBio(ctx context.Context, tx bun.Tx, newBio string) error { + _, err := tx.NewUpdate(). + Model(user). + Set("bio = ?", newBio). + Where("id = ?", user.ID). + Exec(ctx) + if err != nil { + return errors.Wrap(err, "tx.Update") + } + user.Bio = newBio + return nil +} + +// CreateUser creates a new user with the given username and password +func CreateUser(ctx context.Context, tx bun.Tx, username, password string) (*User, error) { + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return nil, errors.Wrap(err, "bcrypt.GenerateFromPassword") + } + + user := &User{ + Username: username, + PasswordHash: string(hashedPassword), + CreatedAt: 0, // You may want to set this to time.Now().Unix() + Bio: "", + } + + _, err = tx.NewInsert(). + Model(user). + Exec(ctx) + if err != nil { + return nil, errors.Wrap(err, "tx.Insert") + } + + return user, nil +} + +// GetUserByID queries the database for a user matching the given ID +// Returns nil, nil if no user is found +func GetUserByID(ctx context.Context, tx bun.Tx, id int) (*User, error) { + user := new(User) + err := tx.NewSelect(). + Model(user). + Where("id = ?", id). + Limit(1). + Scan(ctx) + if err != nil { + if err.Error() == "sql: no rows in result set" { + return nil, nil + } + return nil, errors.Wrap(err, "tx.Select") + } + return user, nil +} + +// GetUserByUsername queries the database for a user matching the given username +// Returns nil, nil if no user is found +func GetUserByUsername(ctx context.Context, tx bun.Tx, username string) (*User, error) { + user := new(User) + err := tx.NewSelect(). + Model(user). + Where("username = ?", username). + Limit(1). + Scan(ctx) + if err != nil { + if err.Error() == "sql: no rows in result set" { + return nil, nil + } + return nil, errors.Wrap(err, "tx.Select") + } + return user, nil +} + +// IsUsernameUnique checks if the given username is unique (not already taken) +// Returns true if the username is available, false if it's taken +func IsUsernameUnique(ctx context.Context, tx bun.Tx, username string) (bool, error) { + count, err := tx.NewSelect(). + Model((*User)(nil)). + Where("username = ?", username). + Count(ctx) + if err != nil { + return false, errors.Wrap(err, "tx.Count") + } + return count == 0, nil +} diff --git a/internal/handlers/errorpage.go b/internal/handlers/errorpage.go new file mode 100644 index 0000000..2fa7699 --- /dev/null +++ b/internal/handlers/errorpage.go @@ -0,0 +1,28 @@ +package handlers + +import ( + "net/http" + + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/oslstats/internal/view/page" + "github.com/pkg/errors" +) + +func ErrorPage( + errorCode int, +) (hws.ErrorPage, error) { + messages := map[int]string{ + 400: "The request you made was malformed or unexpected.", + 401: "You need to login to view this page.", + 403: "You do not have permission to view this page.", + 404: "The page or resource you have requested does not exist.", + 500: `An error occured on the server. Please try again, and if this + continues to happen contact an administrator.`, + 503: "The server is currently down for maintenance and should be back soon. =)", + } + msg, exists := messages[errorCode] + if !exists { + return nil, errors.New("No valid message for the given code") + } + return page.Error(errorCode, http.StatusText(errorCode), msg), nil +} diff --git a/internal/handlers/index.go b/internal/handlers/index.go new file mode 100644 index 0000000..3274302 --- /dev/null +++ b/internal/handlers/index.go @@ -0,0 +1,49 @@ +package handlers + +import ( + "net/http" + + "git.haelnorr.com/h/oslstats/internal/view/page" + + "git.haelnorr.com/h/golib/hws" +) + +// Handles responses to the / path. Also serves a 404 Page for paths that +// don't have explicit handlers +func Index(server *hws.Server) http.Handler { + return http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + page, err := ErrorPage(http.StatusNotFound) + if err != nil { + err = server.ThrowError(w, r, hws.HWSError{ + StatusCode: http.StatusInternalServerError, + Message: "An error occured trying to generate the error page", + Error: err, + Level: hws.ErrorLevel("error"), + RenderErrorPage: false, + }) + if err != nil { + server.ThrowFatal(w, err) + } + return + } + err = page.Render(r.Context(), w) + if err != nil { + err = server.ThrowError(w, r, hws.HWSError{ + StatusCode: http.StatusInternalServerError, + Message: "An error occured trying to render the error page", + Error: err, + Level: hws.ErrorLevel("error"), + RenderErrorPage: false, + }) + if err != nil { + server.ThrowFatal(w, err) + } + return + } + } + page.Index().Render(r.Context(), w) + }, + ) +} diff --git a/internal/handlers/static.go b/internal/handlers/static.go new file mode 100644 index 0000000..fb444c9 --- /dev/null +++ b/internal/handlers/static.go @@ -0,0 +1,70 @@ +package handlers + +import ( + "git.haelnorr.com/h/golib/hws" + "net/http" + "path/filepath" + "strings" +) + +// Handles requests for static files, without allowing access to the +// directory viewer and returning 404 if an exact file is not found +func StaticFS(staticFS *http.FileSystem, server *hws.Server) http.Handler { + // Create the file server once, not on every request + fs, err := hws.SafeFileServer(staticFS) + if err != nil { + // If we can't create the file server, return a handler that always errors + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err = server.ThrowError(w, r, hws.HWSError{ + StatusCode: http.StatusInternalServerError, + Message: "An error occured trying to load the file system", + Error: err, + Level: hws.ErrorLevel("error"), + RenderErrorPage: true, + }) + if err != nil { + server.ThrowFatal(w, err) + } + }) + } + + return http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + // Explicitly set Content-Type for CSS files + if strings.HasSuffix(r.URL.Path, ".css") { + w.Header().Set("Content-Type", "text/css; charset=utf-8") + } else if strings.HasSuffix(r.URL.Path, ".js") { + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") + } else if strings.HasSuffix(r.URL.Path, ".ico") { + w.Header().Set("Content-Type", "image/x-icon") + } else { + // Let Go detect the content type for other files + ext := filepath.Ext(r.URL.Path) + if contentType := mimeTypes[ext]; contentType != "" { + w.Header().Set("Content-Type", contentType) + } + } + fs.ServeHTTP(w, r) + }, + ) +} + +// Common MIME types for static files +var mimeTypes = map[string]string{ + ".html": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "application/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".xml": "application/xml; charset=utf-8", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", + ".webp": "image/webp", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + ".eot": "application/vnd.ms-fontobject", +} diff --git a/internal/view/component/footer/footer.templ b/internal/view/component/footer/footer.templ new file mode 100644 index 0000000..59a19a1 --- /dev/null +++ b/internal/view/component/footer/footer.templ @@ -0,0 +1,116 @@ +package footer + +type FooterItem struct { + name string + href string +} + +// Specify the links to show in the footer +func getFooterItems() []FooterItem { + return []FooterItem{ + { + name: "About", + href: "/about", + }, + } +} + +// Returns the template fragment for the Footer +templ Footer() { + +} diff --git a/internal/view/component/nav/navbar.templ b/internal/view/component/nav/navbar.templ new file mode 100644 index 0000000..e9471a0 --- /dev/null +++ b/internal/view/component/nav/navbar.templ @@ -0,0 +1,36 @@ +package nav + +type NavItem struct { + name string // Label to display + href string // Link reference +} + +// Return the list of navbar links +func getNavItems() []NavItem { + return []NavItem{} +} + +// Returns the navbar template fragment +templ Navbar() { + {{ navItems := getNavItems() }} +
+
+
+ + + + OSL Stats + + +
+ @navLeft(navItems) + @navRight() +
+
+
+ @sideNav(navItems) +
+} diff --git a/internal/view/component/nav/navbarleft.templ b/internal/view/component/nav/navbarleft.templ new file mode 100644 index 0000000..fec9443 --- /dev/null +++ b/internal/view/component/nav/navbarleft.templ @@ -0,0 +1,19 @@ +package nav + +// Returns the left portion of the navbar +templ navLeft(navItems []NavItem) { + +} diff --git a/internal/view/component/nav/navbarright.templ b/internal/view/component/nav/navbarright.templ new file mode 100644 index 0000000..5f1fc22 --- /dev/null +++ b/internal/view/component/nav/navbarright.templ @@ -0,0 +1,115 @@ +package nav + +import "git.haelnorr.com/h/oslstats/pkg/contexts" + +type ProfileItem struct { + name string // Label to display + href string // Link reference +} + +// Return the list of profile links +func getProfileItems() []ProfileItem { + return []ProfileItem{ + { + name: "Profile", + href: "/profile", + }, + { + name: "Account", + href: "/account", + }, + } +} + +// Returns the right portion of the navbar +templ navRight() { + {{ user := contexts.CurrentUser(ctx) }} + {{ items := getProfileItems() }} +
+
+ if user != nil { +
+
+ +
+ +
+ } else { + + } +
+ +
+} diff --git a/internal/view/component/nav/sidenav.templ b/internal/view/component/nav/sidenav.templ new file mode 100644 index 0000000..23fb9f0 --- /dev/null +++ b/internal/view/component/nav/sidenav.templ @@ -0,0 +1,45 @@ +package nav + +import "git.haelnorr.com/h/oslstats/pkg/contexts" + +// Returns the mobile version of the navbar thats only visible when activated +templ sideNav(navItems []NavItem) { + {{ user := contexts.CurrentUser(ctx) }} +
+
+ +
+ if user == nil { +
+ +
+ } +
+} diff --git a/internal/view/component/popup/error500Popup.templ b/internal/view/component/popup/error500Popup.templ new file mode 100644 index 0000000..45573e0 --- /dev/null +++ b/internal/view/component/popup/error500Popup.templ @@ -0,0 +1,63 @@ +package popup + +templ Error500Popup() { +
+ +
+} diff --git a/internal/view/component/popup/error503Popup.templ b/internal/view/component/popup/error503Popup.templ new file mode 100644 index 0000000..254659d --- /dev/null +++ b/internal/view/component/popup/error503Popup.templ @@ -0,0 +1,63 @@ +package popup + +templ Error503Popup() { +
+ +
+} diff --git a/internal/view/layout/global.templ b/internal/view/layout/global.templ new file mode 100644 index 0000000..1750010 --- /dev/null +++ b/internal/view/layout/global.templ @@ -0,0 +1,97 @@ +package layout + +import "git.haelnorr.com/h/oslstats/internal/view/component/popup" +import "git.haelnorr.com/h/oslstats/internal/view/component/nav" +import "git.haelnorr.com/h/oslstats/internal/view/component/footer" + +// Global page layout. Includes HTML document settings, header tags +// navbar and footer +templ Global(title string) { + + + + + + + { title } + + + + + + + + + + + + + @popup.Error500Popup() + @popup.Error503Popup() +
+ @nav.Navbar() +
+ { children... } +
+ @footer.Footer() +
+ + +} diff --git a/internal/view/page/error.templ b/internal/view/page/error.templ new file mode 100644 index 0000000..1561478 --- /dev/null +++ b/internal/view/page/error.templ @@ -0,0 +1,34 @@ +package page + +import "git.haelnorr.com/h/oslstats/internal/view/layout" +import "strconv" + +// Page template for Error pages. Error code should be a HTTP status code as +// a string, and err should be the corresponding response title. +// Message is a custom error message displayed below the code and error. +templ Error(code int, err string, message string) { + @layout.Global(err) { +
+
+

{ strconv.Itoa(code) }

+

{ err }

+

{ message }

+ Go to homepage +
+
+ } +} diff --git a/internal/view/page/index.templ b/internal/view/page/index.templ new file mode 100644 index 0000000..c18d5ba --- /dev/null +++ b/internal/view/page/index.templ @@ -0,0 +1,13 @@ +package page + +import "git.haelnorr.com/h/oslstats/internal/view/layout" + +// Page content for the index page +templ Index() { + @layout.Global("OSL Stats") { +
+
OSL Stats
+
Placeholder text
+
+ } +} diff --git a/pkg/contexts/currentuser.go b/pkg/contexts/currentuser.go new file mode 100644 index 0000000..f522563 --- /dev/null +++ b/pkg/contexts/currentuser.go @@ -0,0 +1,8 @@ +package contexts + +import ( + "git.haelnorr.com/h/golib/hwsauth" + "git.haelnorr.com/h/oslstats/internal/db" +) + +var CurrentUser hwsauth.ContextLoader[*db.User] diff --git a/pkg/contexts/keys.go b/pkg/contexts/keys.go new file mode 100644 index 0000000..996cba7 --- /dev/null +++ b/pkg/contexts/keys.go @@ -0,0 +1,7 @@ +package contexts + +type contextKey string + +func (c contextKey) String() string { + return "oslstats context key " + string(c) +} diff --git a/pkg/embedfs/embedfs.go b/pkg/embedfs/embedfs.go new file mode 100644 index 0000000..e730359 --- /dev/null +++ b/pkg/embedfs/embedfs.go @@ -0,0 +1,20 @@ +package embedfs + +import ( + "embed" + "io/fs" + + "github.com/pkg/errors" +) + +//go:embed files/* +var embeddedFiles embed.FS + +// Gets the embedded files +func GetEmbeddedFS() (fs.FS, error) { + subFS, err := fs.Sub(embeddedFiles, "files") + if err != nil { + return nil, errors.Wrap(err, "fs.Sub") + } + return subFS, nil +} diff --git a/pkg/embedfs/files/assets/error.png b/pkg/embedfs/files/assets/error.png new file mode 100644 index 0000000..d1ffa7d Binary files /dev/null and b/pkg/embedfs/files/assets/error.png differ diff --git a/pkg/embedfs/files/css/input.css b/pkg/embedfs/files/css/input.css new file mode 100644 index 0000000..8839df9 --- /dev/null +++ b/pkg/embedfs/files/css/input.css @@ -0,0 +1,127 @@ +@import "tailwindcss"; + +@source "../../../../internal/view/component/footer/footer.templ"; +@source "../../../../internal/view/component/nav/navbarleft.templ"; +@source "../../../../internal/view/component/nav/navbarright.templ"; +@source "../../../../internal/view/component/nav/navbar.templ"; +@source "../../../../internal/view/component/nav/sidenav.templ"; +@source "../../../../internal/view/component/popup/error500Popup.templ"; +@source "../../../../internal/view/component/popup/error503Popup.templ"; +@source "../../../../internal/view/layout/global.templ"; +@source "../../../../internal/view/page/error.templ"; +@source "../../../../internal/view/page/index.templ"; + +[x-cloak] { + display: none !important; +} +@theme inline { + --color-rosewater: var(--rosewater); + --color-flamingo: var(--flamingo); + --color-pink: var(--pink); + --color-mauve: var(--mauve); + --color-red: var(--red); + --color-dark-red: var(--dark-red); + --color-maroon: var(--maroon); + --color-peach: var(--peach); + --color-yellow: var(--yellow); + --color-green: var(--green); + --color-teal: var(--teal); + --color-sky: var(--sky); + --color-sapphire: var(--sapphire); + --color-blue: var(--blue); + --color-lavender: var(--lavender); + --color-text: var(--text); + --color-subtext1: var(--subtext1); + --color-subtext0: var(--subtext0); + --color-overlay2: var(--overlay2); + --color-overlay1: var(--overlay1); + --color-overlay0: var(--overlay0); + --color-surface2: var(--surface2); + --color-surface1: var(--surface1); + --color-surface0: var(--surface0); + --color-base: var(--base); + --color-mantle: var(--mantle); + --color-crust: var(--crust); +} +:root { + --rosewater: hsl(11, 59%, 67%); + --flamingo: hsl(0, 60%, 67%); + --pink: hsl(316, 73%, 69%); + --mauve: hsl(266, 85%, 58%); + --red: hsl(347, 87%, 44%); + --dark-red: hsl(343, 50%, 82%); + --maroon: hsl(355, 76%, 59%); + --peach: hsl(22, 99%, 52%); + --yellow: hsl(35, 77%, 49%); + --green: hsl(109, 58%, 40%); + --teal: hsl(183, 74%, 35%); + --sky: hsl(197, 97%, 46%); + --sapphire: hsl(189, 70%, 42%); + --blue: hsl(220, 91%, 54%); + --lavender: hsl(231, 97%, 72%); + --text: hsl(234, 16%, 35%); + --subtext1: hsl(233, 13%, 41%); + --subtext0: hsl(233, 10%, 47%); + --overlay2: hsl(232, 10%, 53%); + --overlay1: hsl(231, 10%, 59%); + --overlay0: hsl(228, 11%, 65%); + --surface2: hsl(227, 12%, 71%); + --surface1: hsl(225, 14%, 77%); + --surface0: hsl(223, 16%, 83%); + --base: hsl(220, 23%, 95%); + --mantle: hsl(220, 22%, 92%); + --crust: hsl(220, 21%, 89%); +} + +.dark { + --rosewater: hsl(10, 56%, 91%); + --flamingo: hsl(0, 59%, 88%); + --pink: hsl(316, 72%, 86%); + --mauve: hsl(267, 84%, 81%); + --red: hsl(343, 81%, 75%); + --dark-red: hsl(316, 19%, 27%); + --maroon: hsl(350, 65%, 77%); + --peach: hsl(23, 92%, 75%); + --yellow: hsl(41, 86%, 83%); + --green: hsl(115, 54%, 76%); + --teal: hsl(170, 57%, 73%); + --sky: hsl(189, 71%, 73%); + --sapphire: hsl(199, 76%, 69%); + --blue: hsl(217, 92%, 76%); + --lavender: hsl(232, 97%, 85%); + --text: hsl(226, 64%, 88%); + --subtext1: hsl(227, 35%, 80%); + --subtext0: hsl(228, 24%, 72%); + --overlay2: hsl(228, 17%, 64%); + --overlay1: hsl(230, 13%, 55%); + --overlay0: hsl(231, 11%, 47%); + --surface2: hsl(233, 12%, 39%); + --surface1: hsl(234, 13%, 31%); + --surface0: hsl(237, 16%, 23%); + --base: hsl(240, 21%, 15%); + --mantle: hsl(240, 21%, 12%); + --crust: hsl(240, 23%, 9%); +} +.ubuntu-mono-regular { + font-family: "Ubuntu Mono", serif; + font-weight: 400; + font-style: normal; +} + +.ubuntu-mono-bold { + font-family: "Ubuntu Mono", serif; + font-weight: 700; + font-style: normal; +} + +.ubuntu-mono-regular-italic { + font-family: "Ubuntu Mono", serif; + font-weight: 400; + font-style: italic; +} + +.ubuntu-mono-bold-italic { + font-family: "Ubuntu Mono", serif; + font-weight: 700; + font-style: italic; +} diff --git a/pkg/embedfs/files/css/output.css b/pkg/embedfs/files/css/output.css new file mode 100644 index 0000000..c506704 --- /dev/null +++ b/pkg/embedfs/files/css/output.css @@ -0,0 +1,1040 @@ +/*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */ +@layer properties; +@layer theme, base, components, utilities; +@layer theme { + :root, :host { + --font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', + 'Noto Color Emoji'; + --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', + monospace; + --spacing: 0.25rem; + --breakpoint-xl: 80rem; + --container-md: 28rem; + --container-7xl: 80rem; + --text-sm: 0.875rem; + --text-sm--line-height: calc(1.25 / 0.875); + --text-lg: 1.125rem; + --text-lg--line-height: calc(1.75 / 1.125); + --text-xl: 1.25rem; + --text-xl--line-height: calc(1.75 / 1.25); + --text-2xl: 1.5rem; + --text-2xl--line-height: calc(2 / 1.5); + --text-3xl: 1.875rem; + --text-3xl--line-height: calc(2.25 / 1.875); + --text-4xl: 2.25rem; + --text-4xl--line-height: calc(2.5 / 2.25); + --text-6xl: 3.75rem; + --text-6xl--line-height: 1; + --text-9xl: 8rem; + --text-9xl--line-height: 1; + --font-weight-medium: 500; + --font-weight-bold: 700; + --tracking-tight: -0.025em; + --leading-relaxed: 1.625; + --radius-sm: 0.25rem; + --radius-lg: 0.5rem; + --default-transition-duration: 150ms; + --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + --default-font-family: var(--font-sans); + --default-mono-font-family: var(--font-mono); + } +} +@layer base { + *, ::after, ::before, ::backdrop, ::file-selector-button { + box-sizing: border-box; + margin: 0; + padding: 0; + border: 0 solid; + } + html, :host { + line-height: 1.5; + -webkit-text-size-adjust: 100%; + tab-size: 4; + font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'); + font-feature-settings: var(--default-font-feature-settings, normal); + font-variation-settings: var(--default-font-variation-settings, normal); + -webkit-tap-highlight-color: transparent; + } + hr { + height: 0; + color: inherit; + border-top-width: 1px; + } + abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; + } + h1, h2, h3, h4, h5, h6 { + font-size: inherit; + font-weight: inherit; + } + a { + color: inherit; + -webkit-text-decoration: inherit; + text-decoration: inherit; + } + b, strong { + font-weight: bolder; + } + code, kbd, samp, pre { + font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace); + font-feature-settings: var(--default-mono-font-feature-settings, normal); + font-variation-settings: var(--default-mono-font-variation-settings, normal); + font-size: 1em; + } + small { + font-size: 80%; + } + sub, sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; + } + sub { + bottom: -0.25em; + } + sup { + top: -0.5em; + } + table { + text-indent: 0; + border-color: inherit; + border-collapse: collapse; + } + :-moz-focusring { + outline: auto; + } + progress { + vertical-align: baseline; + } + summary { + display: list-item; + } + ol, ul, menu { + list-style: none; + } + img, svg, video, canvas, audio, iframe, embed, object { + display: block; + vertical-align: middle; + } + img, video { + max-width: 100%; + height: auto; + } + button, input, select, optgroup, textarea, ::file-selector-button { + font: inherit; + font-feature-settings: inherit; + font-variation-settings: inherit; + letter-spacing: inherit; + color: inherit; + border-radius: 0; + background-color: transparent; + opacity: 1; + } + :where(select:is([multiple], [size])) optgroup { + font-weight: bolder; + } + :where(select:is([multiple], [size])) optgroup option { + padding-inline-start: 20px; + } + ::file-selector-button { + margin-inline-end: 4px; + } + ::placeholder { + opacity: 1; + } + @supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) { + ::placeholder { + color: currentcolor; + @supports (color: color-mix(in lab, red, red)) { + color: color-mix(in oklab, currentcolor 50%, transparent); + } + } + } + textarea { + resize: vertical; + } + ::-webkit-search-decoration { + -webkit-appearance: none; + } + ::-webkit-date-and-time-value { + min-height: 1lh; + text-align: inherit; + } + ::-webkit-datetime-edit { + display: inline-flex; + } + ::-webkit-datetime-edit-fields-wrapper { + padding: 0; + } + ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field { + padding-block: 0; + } + ::-webkit-calendar-picker-indicator { + line-height: 1; + } + :-moz-ui-invalid { + box-shadow: none; + } + button, input:where([type='button'], [type='reset'], [type='submit']), ::file-selector-button { + appearance: button; + } + ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { + height: auto; + } + [hidden]:where(:not([hidden='until-found'])) { + display: none !important; + } +} +@layer utilities { + .visible { + visibility: visible; + } + .sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border-width: 0; + } + .absolute { + position: absolute; + } + .relative { + position: relative; + } + .end-0 { + inset-inline-end: calc(var(--spacing) * 0); + } + .end-4 { + inset-inline-end: calc(var(--spacing) * 4); + } + .top-0 { + top: calc(var(--spacing) * 0); + } + .top-4 { + top: calc(var(--spacing) * 4); + } + .right-0 { + right: calc(var(--spacing) * 0); + } + .bottom-0 { + bottom: calc(var(--spacing) * 0); + } + .left-0 { + left: calc(var(--spacing) * 0); + } + .z-10 { + z-index: 10; + } + .mx-auto { + margin-inline: auto; + } + .mt-1\.5 { + margin-top: calc(var(--spacing) * 1.5); + } + .mt-2 { + margin-top: calc(var(--spacing) * 2); + } + .mt-4 { + margin-top: calc(var(--spacing) * 4); + } + .mt-6 { + margin-top: calc(var(--spacing) * 6); + } + .mt-10 { + margin-top: calc(var(--spacing) * 10); + } + .mt-12 { + margin-top: calc(var(--spacing) * 12); + } + .mt-20 { + margin-top: calc(var(--spacing) * 20); + } + .mt-24 { + margin-top: calc(var(--spacing) * 24); + } + .mr-5 { + margin-right: calc(var(--spacing) * 5); + } + .mb-auto { + margin-bottom: auto; + } + .ml-auto { + margin-left: auto; + } + .block { + display: block; + } + .flex { + display: flex; + } + .grid { + display: grid; + } + .hidden { + display: none; + } + .inline { + display: inline; + } + .inline-block { + display: inline-block; + } + .inline-flex { + display: inline-flex; + } + .size-5 { + width: calc(var(--spacing) * 5); + height: calc(var(--spacing) * 5); + } + .size-6 { + width: calc(var(--spacing) * 6); + height: calc(var(--spacing) * 6); + } + .h-16 { + height: calc(var(--spacing) * 16); + } + .h-full { + height: 100%; + } + .h-screen { + height: 100vh; + } + .w-26 { + width: calc(var(--spacing) * 26); + } + .w-36 { + width: calc(var(--spacing) * 36); + } + .w-82 { + width: calc(var(--spacing) * 82); + } + .w-fit { + width: fit-content; + } + .w-full { + width: 100%; + } + .max-w-7xl { + max-width: var(--container-7xl); + } + .max-w-md { + max-width: var(--container-md); + } + .max-w-screen-xl { + max-width: var(--breakpoint-xl); + } + .flex-1 { + flex: 1; + } + .translate-x-0 { + --tw-translate-x: calc(var(--spacing) * 0); + translate: var(--tw-translate-x) var(--tw-translate-y); + } + .translate-x-\[100\%\] { + --tw-translate-x: 100%; + translate: var(--tw-translate-x) var(--tw-translate-y); + } + .transform { + transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); + } + .flex-col { + flex-direction: column; + } + .flex-wrap { + flex-wrap: wrap; + } + .place-content-center { + place-content: center; + } + .items-center { + align-items: center; + } + .justify-between { + justify-content: space-between; + } + .justify-center { + justify-content: center; + } + .justify-end { + justify-content: flex-end; + } + .gap-2 { + gap: calc(var(--spacing) * 2); + } + .gap-6 { + gap: calc(var(--spacing) * 6); + } + .gap-8 { + gap: calc(var(--spacing) * 8); + } + .space-y-1 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse))); + } + } + .divide-y { + :where(& > :not(:last-child)) { + --tw-divide-y-reverse: 0; + border-bottom-style: var(--tw-border-style); + border-top-style: var(--tw-border-style); + border-top-width: calc(1px * var(--tw-divide-y-reverse)); + border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse))); + } + } + .divide-surface2 { + :where(& > :not(:last-child)) { + border-color: var(--surface2); + } + } + .overflow-hidden { + overflow: hidden; + } + .overflow-x-hidden { + overflow-x: hidden; + } + .rounded-full { + border-radius: calc(infinity * 1px); + } + .rounded-lg { + border-radius: var(--radius-lg); + } + .rounded-sm { + border-radius: var(--radius-sm); + } + .border { + border-style: var(--tw-border-style); + border-width: 1px; + } + .border-surface1 { + border-color: var(--surface1); + } + .bg-base { + background-color: var(--base); + } + .bg-blue { + background-color: var(--blue); + } + .bg-crust { + background-color: var(--crust); + } + .bg-dark-red { + background-color: var(--dark-red); + } + .bg-green { + background-color: var(--green); + } + .bg-mantle { + background-color: var(--mantle); + } + .bg-mauve { + background-color: var(--mauve); + } + .bg-sapphire { + background-color: var(--sapphire); + } + .bg-surface0 { + background-color: var(--surface0); + } + .bg-teal { + background-color: var(--teal); + } + .p-2 { + padding: calc(var(--spacing) * 2); + } + .p-2\.5 { + padding: calc(var(--spacing) * 2.5); + } + .p-4 { + padding: calc(var(--spacing) * 4); + } + .px-4 { + padding-inline: calc(var(--spacing) * 4); + } + .px-5 { + padding-inline: calc(var(--spacing) * 5); + } + .py-2 { + padding-block: calc(var(--spacing) * 2); + } + .py-3 { + padding-block: calc(var(--spacing) * 3); + } + .py-6 { + padding-block: calc(var(--spacing) * 6); + } + .py-8 { + padding-block: calc(var(--spacing) * 8); + } + .pb-6 { + padding-bottom: calc(var(--spacing) * 6); + } + .text-center { + text-align: center; + } + .text-2xl { + font-size: var(--text-2xl); + line-height: var(--tw-leading, var(--text-2xl--line-height)); + } + .text-3xl { + font-size: var(--text-3xl); + line-height: var(--tw-leading, var(--text-3xl--line-height)); + } + .text-4xl { + font-size: var(--text-4xl); + line-height: var(--tw-leading, var(--text-4xl--line-height)); + } + .text-9xl { + font-size: var(--text-9xl); + line-height: var(--tw-leading, var(--text-9xl--line-height)); + } + .text-lg { + font-size: var(--text-lg); + line-height: var(--tw-leading, var(--text-lg--line-height)); + } + .text-sm { + font-size: var(--text-sm); + line-height: var(--tw-leading, var(--text-sm--line-height)); + } + .text-xl { + font-size: var(--text-xl); + line-height: var(--tw-leading, var(--text-xl--line-height)); + } + .leading-relaxed { + --tw-leading: var(--leading-relaxed); + line-height: var(--leading-relaxed); + } + .font-bold { + --tw-font-weight: var(--font-weight-bold); + font-weight: var(--font-weight-bold); + } + .font-medium { + --tw-font-weight: var(--font-weight-medium); + font-weight: var(--font-weight-medium); + } + .tracking-tight { + --tw-tracking: var(--tracking-tight); + letter-spacing: var(--tracking-tight); + } + .text-crust { + color: var(--crust); + } + .text-mantle { + color: var(--mantle); + } + .text-overlay0 { + color: var(--overlay0); + } + .text-red { + color: var(--red); + } + .text-subtext0 { + color: var(--subtext0); + } + .text-subtext1 { + color: var(--subtext1); + } + .text-text { + color: var(--text); + } + .opacity-0 { + opacity: 0%; + } + .opacity-100 { + opacity: 100%; + } + .shadow-lg { + --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .shadow-sm { + --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .transition { + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + .duration-200 { + --tw-duration: 200ms; + transition-duration: 200ms; + } + .hover\:cursor-pointer { + &:hover { + @media (hover: hover) { + cursor: pointer; + } + } + } + .hover\:bg-blue\/75 { + &:hover { + @media (hover: hover) { + background-color: var(--blue); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--blue) 75%, transparent); + } + } + } + } + .hover\:bg-crust { + &:hover { + @media (hover: hover) { + background-color: var(--crust); + } + } + } + .hover\:bg-green\/75 { + &:hover { + @media (hover: hover) { + background-color: var(--green); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--green) 75%, transparent); + } + } + } + } + .hover\:bg-mauve\/75 { + &:hover { + @media (hover: hover) { + background-color: var(--mauve); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--mauve) 75%, transparent); + } + } + } + } + .hover\:bg-red\/25 { + &:hover { + @media (hover: hover) { + background-color: var(--red); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--red) 25%, transparent); + } + } + } + } + .hover\:bg-sapphire\/75 { + &:hover { + @media (hover: hover) { + background-color: var(--sapphire); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--sapphire) 75%, transparent); + } + } + } + } + .hover\:bg-surface2 { + &:hover { + @media (hover: hover) { + background-color: var(--surface2); + } + } + } + .hover\:bg-teal\/75 { + &:hover { + @media (hover: hover) { + background-color: var(--teal); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--teal) 75%, transparent); + } + } + } + } + .hover\:text-green { + &:hover { + @media (hover: hover) { + color: var(--green); + } + } + } + .hover\:text-overlay2\/75 { + &:hover { + @media (hover: hover) { + color: var(--overlay2); + @supports (color: color-mix(in lab, red, red)) { + color: color-mix(in oklab, var(--overlay2) 75%, transparent); + } + } + } + } + .hover\:text-subtext1 { + &:hover { + @media (hover: hover) { + color: var(--subtext1); + } + } + } + .sm\:end-6 { + @media (width >= 40rem) { + inset-inline-end: calc(var(--spacing) * 6); + } + } + .sm\:block { + @media (width >= 40rem) { + display: block; + } + } + .sm\:flex { + @media (width >= 40rem) { + display: flex; + } + } + .sm\:hidden { + @media (width >= 40rem) { + display: none; + } + } + .sm\:inline { + @media (width >= 40rem) { + display: inline; + } + } + .sm\:justify-between { + @media (width >= 40rem) { + justify-content: space-between; + } + } + .sm\:gap-2 { + @media (width >= 40rem) { + gap: calc(var(--spacing) * 2); + } + } + .sm\:px-6 { + @media (width >= 40rem) { + padding-inline: calc(var(--spacing) * 6); + } + } + .sm\:text-4xl { + @media (width >= 40rem) { + font-size: var(--text-4xl); + line-height: var(--tw-leading, var(--text-4xl--line-height)); + } + } + .md\:gap-8 { + @media (width >= 48rem) { + gap: calc(var(--spacing) * 8); + } + } + .md\:px-5 { + @media (width >= 48rem) { + padding-inline: calc(var(--spacing) * 5); + } + } + .md\:pt-5 { + @media (width >= 48rem) { + padding-top: calc(var(--spacing) * 5); + } + } + .lg\:end-8 { + @media (width >= 64rem) { + inset-inline-end: calc(var(--spacing) * 8); + } + } + .lg\:mt-0 { + @media (width >= 64rem) { + margin-top: calc(var(--spacing) * 0); + } + } + .lg\:flex { + @media (width >= 64rem) { + display: flex; + } + } + .lg\:inline { + @media (width >= 64rem) { + display: inline; + } + } + .lg\:items-end { + @media (width >= 64rem) { + align-items: flex-end; + } + } + .lg\:justify-between { + @media (width >= 64rem) { + justify-content: space-between; + } + } + .lg\:justify-end { + @media (width >= 64rem) { + justify-content: flex-end; + } + } + .lg\:justify-start { + @media (width >= 64rem) { + justify-content: flex-start; + } + } + .lg\:gap-12 { + @media (width >= 64rem) { + gap: calc(var(--spacing) * 12); + } + } + .lg\:px-8 { + @media (width >= 64rem) { + padding-inline: calc(var(--spacing) * 8); + } + } + .lg\:text-6xl { + @media (width >= 64rem) { + font-size: var(--text-6xl); + line-height: var(--tw-leading, var(--text-6xl--line-height)); + } + } +} +[x-cloak] { + display: none !important; +} +:root { + --rosewater: hsl(11, 59%, 67%); + --flamingo: hsl(0, 60%, 67%); + --pink: hsl(316, 73%, 69%); + --mauve: hsl(266, 85%, 58%); + --red: hsl(347, 87%, 44%); + --dark-red: hsl(343, 50%, 82%); + --maroon: hsl(355, 76%, 59%); + --peach: hsl(22, 99%, 52%); + --yellow: hsl(35, 77%, 49%); + --green: hsl(109, 58%, 40%); + --teal: hsl(183, 74%, 35%); + --sky: hsl(197, 97%, 46%); + --sapphire: hsl(189, 70%, 42%); + --blue: hsl(220, 91%, 54%); + --lavender: hsl(231, 97%, 72%); + --text: hsl(234, 16%, 35%); + --subtext1: hsl(233, 13%, 41%); + --subtext0: hsl(233, 10%, 47%); + --overlay2: hsl(232, 10%, 53%); + --overlay1: hsl(231, 10%, 59%); + --overlay0: hsl(228, 11%, 65%); + --surface2: hsl(227, 12%, 71%); + --surface1: hsl(225, 14%, 77%); + --surface0: hsl(223, 16%, 83%); + --base: hsl(220, 23%, 95%); + --mantle: hsl(220, 22%, 92%); + --crust: hsl(220, 21%, 89%); +} +.dark { + --rosewater: hsl(10, 56%, 91%); + --flamingo: hsl(0, 59%, 88%); + --pink: hsl(316, 72%, 86%); + --mauve: hsl(267, 84%, 81%); + --red: hsl(343, 81%, 75%); + --dark-red: hsl(316, 19%, 27%); + --maroon: hsl(350, 65%, 77%); + --peach: hsl(23, 92%, 75%); + --yellow: hsl(41, 86%, 83%); + --green: hsl(115, 54%, 76%); + --teal: hsl(170, 57%, 73%); + --sky: hsl(189, 71%, 73%); + --sapphire: hsl(199, 76%, 69%); + --blue: hsl(217, 92%, 76%); + --lavender: hsl(232, 97%, 85%); + --text: hsl(226, 64%, 88%); + --subtext1: hsl(227, 35%, 80%); + --subtext0: hsl(228, 24%, 72%); + --overlay2: hsl(228, 17%, 64%); + --overlay1: hsl(230, 13%, 55%); + --overlay0: hsl(231, 11%, 47%); + --surface2: hsl(233, 12%, 39%); + --surface1: hsl(234, 13%, 31%); + --surface0: hsl(237, 16%, 23%); + --base: hsl(240, 21%, 15%); + --mantle: hsl(240, 21%, 12%); + --crust: hsl(240, 23%, 9%); +} +.ubuntu-mono-regular { + font-family: "Ubuntu Mono", serif; + font-weight: 400; + font-style: normal; +} +.ubuntu-mono-bold { + font-family: "Ubuntu Mono", serif; + font-weight: 700; + font-style: normal; +} +.ubuntu-mono-regular-italic { + font-family: "Ubuntu Mono", serif; + font-weight: 400; + font-style: italic; +} +.ubuntu-mono-bold-italic { + font-family: "Ubuntu Mono", serif; + font-weight: 700; + font-style: italic; +} +@property --tw-translate-x { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-translate-y { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-translate-z { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-rotate-x { + syntax: "*"; + inherits: false; +} +@property --tw-rotate-y { + syntax: "*"; + inherits: false; +} +@property --tw-rotate-z { + syntax: "*"; + inherits: false; +} +@property --tw-skew-x { + syntax: "*"; + inherits: false; +} +@property --tw-skew-y { + syntax: "*"; + inherits: false; +} +@property --tw-space-y-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-divide-y-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-border-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} +@property --tw-leading { + syntax: "*"; + inherits: false; +} +@property --tw-font-weight { + syntax: "*"; + inherits: false; +} +@property --tw-tracking { + syntax: "*"; + inherits: false; +} +@property --tw-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-inset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-inset-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-ring-color { + syntax: "*"; + inherits: false; +} +@property --tw-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-ring-color { + syntax: "*"; + inherits: false; +} +@property --tw-inset-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-ring-inset { + syntax: "*"; + inherits: false; +} +@property --tw-ring-offset-width { + syntax: ""; + inherits: false; + initial-value: 0px; +} +@property --tw-ring-offset-color { + syntax: "*"; + inherits: false; + initial-value: #fff; +} +@property --tw-ring-offset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-duration { + syntax: "*"; + inherits: false; +} +@layer properties { + @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { + *, ::before, ::after, ::backdrop { + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-translate-z: 0; + --tw-rotate-x: initial; + --tw-rotate-y: initial; + --tw-rotate-z: initial; + --tw-skew-x: initial; + --tw-skew-y: initial; + --tw-space-y-reverse: 0; + --tw-divide-y-reverse: 0; + --tw-border-style: solid; + --tw-leading: initial; + --tw-font-weight: initial; + --tw-tracking: initial; + --tw-shadow: 0 0 #0000; + --tw-shadow-color: initial; + --tw-shadow-alpha: 100%; + --tw-inset-shadow: 0 0 #0000; + --tw-inset-shadow-color: initial; + --tw-inset-shadow-alpha: 100%; + --tw-ring-color: initial; + --tw-ring-shadow: 0 0 #0000; + --tw-inset-ring-color: initial; + --tw-inset-ring-shadow: 0 0 #0000; + --tw-ring-inset: initial; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-offset-shadow: 0 0 #0000; + --tw-duration: initial; + } + } +} diff --git a/pkg/embedfs/files/favicon.ico b/pkg/embedfs/files/favicon.ico new file mode 100644 index 0000000..f052478 Binary files /dev/null and b/pkg/embedfs/files/favicon.ico differ diff --git a/pkg/embedfs/files/js/popups.js b/pkg/embedfs/files/js/popups.js new file mode 100644 index 0000000..e69de29 diff --git a/pkg/embedfs/files/js/theme.js b/pkg/embedfs/files/js/theme.js new file mode 100644 index 0000000..291c41f --- /dev/null +++ b/pkg/embedfs/files/js/theme.js @@ -0,0 +1,13 @@ +(function() { + let theme = localStorage.getItem("theme") || "system"; + if (theme === "system") { + theme = window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; + } + if (theme === "dark") { + document.documentElement.classList.add("dark"); + } else { + document.documentElement.classList.remove("dark"); + } +})(); diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..ae6b33d --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,32 @@ +# Scripts + +## generate-css-sources.sh + +Automatically generates the `pkg/embedfs/files/css/input.css` file with `@source` directives for all `.templ` files in the project. + +### Why is this needed? + +Tailwind CSS v4 requires explicit `@source` directives to know which files to scan for utility classes. Glob patterns like `**/*.templ` don't work in `@source` directives, so each file must be listed individually. + +This script: +1. Finds all `.templ` files in the `internal/` directory +2. Generates `@source` directives with relative paths from the CSS file location +3. Adds your custom theme and utility classes + +### When does it run? + +The script runs automatically as part of: +- `make build` - Before building the CSS +- `make dev` - Before starting watch mode + +### Manual usage + +If you need to regenerate the sources manually: + +```bash +./scripts/generate-css-sources.sh +``` + +### Adding new template files + +When you add a new `.templ` file, you don't need to do anything special - just run `make build` or `make dev` and the script will automatically pick up the new file. diff --git a/scripts/generate-css-sources.sh b/scripts/generate-css-sources.sh new file mode 100755 index 0000000..4704473 --- /dev/null +++ b/scripts/generate-css-sources.sh @@ -0,0 +1,140 @@ +#!/bin/bash + +# Generate @source directives for all .templ files +# Paths are relative to pkg/embedfs/files/css/input.css + +INPUT_CSS="pkg/embedfs/files/css/input.css" + +# Start with the base imports +cat > "$INPUT_CSS" <<'CSSHEAD' +@import "tailwindcss"; + +CSSHEAD + +# Find all .templ files and add @source directives +find internal -name "*.templ" -type f | sort | while read -r file; do + # Convert to relative path from pkg/embedfs/files/css/ + rel_path="../../../../$file" + echo "@source \"$rel_path\";" >> "$INPUT_CSS" +done + +# Add the custom theme and utility classes +cat >> "$INPUT_CSS" <<'CSSBODY' + +[x-cloak] { + display: none !important; +} +@theme inline { + --color-rosewater: var(--rosewater); + --color-flamingo: var(--flamingo); + --color-pink: var(--pink); + --color-mauve: var(--mauve); + --color-red: var(--red); + --color-dark-red: var(--dark-red); + --color-maroon: var(--maroon); + --color-peach: var(--peach); + --color-yellow: var(--yellow); + --color-green: var(--green); + --color-teal: var(--teal); + --color-sky: var(--sky); + --color-sapphire: var(--sapphire); + --color-blue: var(--blue); + --color-lavender: var(--lavender); + --color-text: var(--text); + --color-subtext1: var(--subtext1); + --color-subtext0: var(--subtext0); + --color-overlay2: var(--overlay2); + --color-overlay1: var(--overlay1); + --color-overlay0: var(--overlay0); + --color-surface2: var(--surface2); + --color-surface1: var(--surface1); + --color-surface0: var(--surface0); + --color-base: var(--base); + --color-mantle: var(--mantle); + --color-crust: var(--crust); +} +:root { + --rosewater: hsl(11, 59%, 67%); + --flamingo: hsl(0, 60%, 67%); + --pink: hsl(316, 73%, 69%); + --mauve: hsl(266, 85%, 58%); + --red: hsl(347, 87%, 44%); + --dark-red: hsl(343, 50%, 82%); + --maroon: hsl(355, 76%, 59%); + --peach: hsl(22, 99%, 52%); + --yellow: hsl(35, 77%, 49%); + --green: hsl(109, 58%, 40%); + --teal: hsl(183, 74%, 35%); + --sky: hsl(197, 97%, 46%); + --sapphire: hsl(189, 70%, 42%); + --blue: hsl(220, 91%, 54%); + --lavender: hsl(231, 97%, 72%); + --text: hsl(234, 16%, 35%); + --subtext1: hsl(233, 13%, 41%); + --subtext0: hsl(233, 10%, 47%); + --overlay2: hsl(232, 10%, 53%); + --overlay1: hsl(231, 10%, 59%); + --overlay0: hsl(228, 11%, 65%); + --surface2: hsl(227, 12%, 71%); + --surface1: hsl(225, 14%, 77%); + --surface0: hsl(223, 16%, 83%); + --base: hsl(220, 23%, 95%); + --mantle: hsl(220, 22%, 92%); + --crust: hsl(220, 21%, 89%); +} + +.dark { + --rosewater: hsl(10, 56%, 91%); + --flamingo: hsl(0, 59%, 88%); + --pink: hsl(316, 72%, 86%); + --mauve: hsl(267, 84%, 81%); + --red: hsl(343, 81%, 75%); + --dark-red: hsl(316, 19%, 27%); + --maroon: hsl(350, 65%, 77%); + --peach: hsl(23, 92%, 75%); + --yellow: hsl(41, 86%, 83%); + --green: hsl(115, 54%, 76%); + --teal: hsl(170, 57%, 73%); + --sky: hsl(189, 71%, 73%); + --sapphire: hsl(199, 76%, 69%); + --blue: hsl(217, 92%, 76%); + --lavender: hsl(232, 97%, 85%); + --text: hsl(226, 64%, 88%); + --subtext1: hsl(227, 35%, 80%); + --subtext0: hsl(228, 24%, 72%); + --overlay2: hsl(228, 17%, 64%); + --overlay1: hsl(230, 13%, 55%); + --overlay0: hsl(231, 11%, 47%); + --surface2: hsl(233, 12%, 39%); + --surface1: hsl(234, 13%, 31%); + --surface0: hsl(237, 16%, 23%); + --base: hsl(240, 21%, 15%); + --mantle: hsl(240, 21%, 12%); + --crust: hsl(240, 23%, 9%); +} +.ubuntu-mono-regular { + font-family: "Ubuntu Mono", serif; + font-weight: 400; + font-style: normal; +} + +.ubuntu-mono-bold { + font-family: "Ubuntu Mono", serif; + font-weight: 700; + font-style: normal; +} + +.ubuntu-mono-regular-italic { + font-family: "Ubuntu Mono", serif; + font-weight: 400; + font-style: italic; +} + +.ubuntu-mono-bold-italic { + font-family: "Ubuntu Mono", serif; + font-weight: 700; + font-style: italic; +} +CSSBODY + +echo "Generated $INPUT_CSS with $(grep -c '@source' "$INPUT_CSS") source files"