initial commit

This commit is contained in:
2026-01-21 20:03:02 +11:00
commit e0ec6d06d3
41 changed files with 3069 additions and 0 deletions

77
internal/config/config.go Normal file
View File

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

32
internal/config/flags.go Normal file
View File

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

55
internal/db/config.go Normal file
View File

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

41
internal/db/ezconf.go Normal file
View File

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

163
internal/db/user.go Normal file
View File

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

View File

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

View File

@@ -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)
},
)
}

View File

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

View File

@@ -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() {
<footer class="bg-mantle mt-10">
<div
class="relative mx-auto max-w-screen-xl px-4 py-8 sm:px-6 lg:px-8"
>
<div class="absolute end-4 top-4 sm:end-6 lg:end-8">
<a
class="inline-block rounded-full bg-teal p-2 text-crust
shadow-sm transition hover:bg-teal/75"
href="#main-content"
>
<span class="sr-only">Back to top</span>
<svg
xmlns="http://www.w3.org/2000/svg"
class="size-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293
3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4
4a1 1 0 010 1.414z"
clip-rule="evenodd"
></path>
</svg>
</a>
</div>
<div class="lg:flex lg:items-end lg:justify-between">
<div>
<div class="flex justify-center text-text lg:justify-start">
// TODO: logo/branding here
<span class="text-2xl">OSL Stats</span>
</div>
<p
class="mx-auto max-w-md text-center leading-relaxed
text-subtext0"
>placeholder text</p>
</div>
<ul
class="mt-12 flex flex-wrap justify-center gap-6 md:gap-8
lg:mt-0 lg:justify-end lg:gap-12"
>
for _, item := range getFooterItems() {
<li>
<a
class="transition hover:text-subtext1"
href={ templ.SafeURL(item.href) }
>{ item.name }</a>
</li>
}
</ul>
</div>
<div class="lg:flex lg:items-end lg:justify-between">
<div>
<p class="mt-4 text-center text-sm text-overlay0">
by Haelnorr | placeholder text
</p>
</div>
<div>
<div class="mt-2 text-center">
<label
for="theme-select"
class="hidden lg:inline"
>Theme</label>
<select
name="ThemeSelect"
id="theme-select"
class="mt-1.5 inline rounded-lg bg-surface0 p-2 w-fit"
x-model="theme"
>
<template
x-for="themeopt in [
'dark',
'light',
'system',
]"
>
<option
x-text="displayThemeName(themeopt)"
:value="themeopt"
:selected="theme === themeopt"
></option>
</template>
</select>
<script>
const displayThemeName = (value) => {
if (value === "dark") return "Dark (Mocha)";
if (value === "light") return "Light (Latte)";
if (value === "system") return "System";
};
</script>
</div>
</div>
</div>
</div>
</footer>
}

View File

@@ -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() }}
<div x-data="{ open: false }">
<header class="bg-crust">
<div
class="mx-auto flex h-16 max-w-7xl items-center gap-8
px-4 sm:px-6 lg:px-8"
>
<a class="block" href="/">
<!-- logo here -->
<span class="text-3xl font-bold transition hover:text-green">
OSL Stats
</span>
</a>
<div class="flex flex-1 items-center justify-end sm:justify-between">
@navLeft(navItems)
@navRight()
</div>
</div>
</header>
@sideNav(navItems)
</div>
}

View File

@@ -0,0 +1,19 @@
package nav
// Returns the left portion of the navbar
templ navLeft(navItems []NavItem) {
<nav aria-label="Global" class="hidden sm:block">
<ul class="flex items-center gap-6 text-xl">
for _, item := range navItems {
<li>
<a
class="text-subtext1 hover:text-green transition"
href={ templ.SafeURL(item.href) }
>
{ item.name }
</a>
</li>
}
</ul>
</nav>
}

View File

@@ -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() }}
<div class="flex items-center gap-2">
<div class="sm:flex sm:gap-2">
if user != nil {
<div x-data="{ isActive: false }" class="relative">
<div
class="inline-flex items-center overflow-hidden
rounded-lg bg-sapphire hover:bg-sapphire/75 transition"
>
<button
x-on:click="isActive = !isActive"
class="h-full py-2 px-4 text-mantle hover:cursor-pointer"
>
<span class="sr-only">Profile</span>
{ user.Username }
</button>
</div>
<div
class="absolute end-0 z-10 mt-2 w-36 divide-y
divide-surface2 rounded-lg border border-surface1
bg-surface0 shadow-lg"
role="menu"
x-cloak
x-transition
x-show="isActive"
x-on:click.away="isActive = false"
x-on:keydown.escape.window="isActive = false"
>
<div class="p-2">
for _, item := range items {
<a
href={ templ.SafeURL(item.href) }
class="block rounded-lg px-4 py-2 text-md
hover:bg-crust"
role="menuitem"
>
{ item.name }
</a>
}
</div>
<div class="p-2">
<form hx-post="/logout">
<button
type="submit"
class="flex w-full items-center gap-2
rounded-lg px-4 py-2 text-md text-red
hover:bg-red/25 hover:cursor-pointer"
role="menuitem"
@click="isActive=false"
>
Logout
</button>
</form>
</div>
</div>
</div>
} else {
<a
class="hidden rounded-lg px-4 py-2 sm:block
bg-green hover:bg-green/75 text-mantle transition"
href="/login"
>
Login
</a>
}
</div>
<button
@click="open = !open"
class="block rounded-lg p-2.5 sm:hidden transition
bg-surface0 text-subtext0 hover:text-overlay2/75"
>
<span class="sr-only">Toggle menu</span>
<svg
xmlns="http://www.w3.org/2000/svg"
class="size-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M4 6h16M4 12h16M4 18h16"
></path>
</svg>
</button>
</div>
}

View File

@@ -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) }}
<div
x-show="open"
x-transition
class="absolute w-full bg-mantle sm:hidden z-10"
>
<div class="px-4 py-6">
<ul class="space-y-1">
for _, item := range navItems {
<li>
<a
href={ templ.SafeURL(item.href) }
class="block rounded-lg px-4 py-2 text-lg
bg-surface0 text-text transition hover:bg-surface2"
>
{ item.name }
</a>
</li>
}
</ul>
</div>
if user == nil {
<div class="px-4 pb-6">
<ul class="space-y-1">
<li class="flex justify-center items-center gap-2">
<a
class="w-26 px-4 py-2 rounded-lg
bg-green text-mantle transition hover:bg-green/75
text-center"
href="/login"
>
Login
</a>
</li>
</ul>
</div>
}
</div>
}

View File

@@ -0,0 +1,63 @@
package popup
templ Error500Popup() {
<div
x-cloak
x-show="showError500"
class="absolute w-82 left-0 right-0 mt-20 mr-5 ml-auto"
x-transition:enter="transform translate-x-[100%] opacity-0 duration-200"
x-transition:enter-start="opacity-0 translate-x-[100%]"
x-transition:enter-end="opacity-100 translate-x-0"
x-transition:leave="opacity-0 duration-200"
x-transition:leave-start="opacity-100 translate-x-0"
x-transition:leave-end="opacity-0 translate-x-[100%]"
>
<div
role="alert"
class="rounded-sm bg-dark-red p-4"
>
<div class="flex justify-between">
<div class="flex items-center gap-2 text-red w-fit">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="size-5"
>
<path
fill-rule="evenodd"
d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355
12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309
0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75
0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0
01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z"
clip-rule="evenodd"
></path>
</svg>
<strong class="block font-medium">Something went wrong </strong>
</div>
<div class="flex">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6 text-subtext0 hover:cursor-pointer"
@click="showError500=false"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6 18L18 6M6 6l12 12"
></path>
</svg>
</div>
</div>
<p class="mt-2 text-sm text-red">
An error occured on the server. Please try again later,
or contact an administrator
</p>
</div>
</div>
}

View File

@@ -0,0 +1,63 @@
package popup
templ Error503Popup() {
<div
x-cloak
x-show="showError503"
class="absolute w-82 left-0 right-0 mt-20 mr-5 ml-auto"
x-transition:enter="transform translate-x-[100%] opacity-0 duration-200"
x-transition:enter-start="opacity-0 translate-x-[100%]"
x-transition:enter-end="opacity-100 translate-x-0"
x-transition:leave="opacity-0 duration-200"
x-transition:leave-start="opacity-100 translate-x-0"
x-transition:leave-end="opacity-0 translate-x-[100%]"
>
<div
role="alert"
class="rounded-sm bg-dark-red p-4"
>
<div class="flex justify-between">
<div class="flex items-center gap-2 text-red w-fit">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="size-5"
>
<path
fill-rule="evenodd"
d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355
12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309
0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75
0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0
01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z"
clip-rule="evenodd"
></path>
</svg>
<strong class="block font-medium">Service Unavailable</strong>
</div>
<div class="flex">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6 text-subtext0 hover:cursor-pointer"
@click="showError503=false"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6 18L18 6M6 6l12 12"
></path>
</svg>
</div>
</div>
<p class="mt-2 text-sm text-red">
The service is currently available. It could be down for maintenance.
Please try again later.
</p>
</div>
</div>
}

View File

@@ -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) {
<!DOCTYPE html>
<html
lang="en"
x-data="{
theme: localStorage.getItem('theme')
|| 'system'}"
x-init="$watch('theme', (val) => localStorage.setItem('theme', val))"
x-bind:class="{'dark': theme === 'dark' || (theme === 'system' &&
window.matchMedia('(prefers-color-scheme: dark)').matches)}"
>
<head>
<!-- <script src="/static/js/theme.js"></script> -->
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{ title }</title>
<link rel="icon" type="image/x-icon" href="/static/favicon.ico"/>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous"/>
<link href="https://fonts.googleapis.com/css2?family=Ubuntu+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap" rel="stylesheet"/>
<link href="/static/css/output.css" rel="stylesheet"/>
<script src="https://unpkg.com/htmx.org@2.0.4" integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+" crossorigin="anonymous"></script>
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/persist@3.x.x/dist/cdn.min.js"></script>
<script src="https://unpkg.com/alpinejs" defer></script>
<script>
// uncomment this line to enable logging of htmx events
// htmx.logAll();
</script>
<script>
const bodyData = {
showError500: false,
showError503: false,
showConfirmPasswordModal: false,
handleHtmxBeforeOnLoad(event) {
const requestPath = event.detail.pathInfo.requestPath;
if (requestPath === "/reauthenticate") {
// handle password incorrect on refresh attempt
if (event.detail.xhr.status === 445) {
event.detail.shouldSwap = true;
event.detail.isError = false;
} else if (event.detail.xhr.status === 200) {
this.showConfirmPasswordModal = false;
}
}
},
// handle errors from the server on HTMX requests
handleHtmxError(event) {
const errorCode = event.detail.errorInfo.error;
// internal server error
if (errorCode.includes("Code 500")) {
this.showError500 = true;
setTimeout(() => (this.showError500 = false), 6000);
}
// service not available error
if (errorCode.includes("Code 503")) {
this.showError503 = true;
setTimeout(() => (this.showError503 = false), 6000);
}
// user is authorized but needs to refresh their login
if (errorCode.includes("Code 444")) {
this.showConfirmPasswordModal = true;
}
},
};
</script>
</head>
<body
class="bg-base text-text ubuntu-mono-regular overflow-x-hidden"
x-data="bodyData"
x-on:htmx:error="handleHtmxError($event)"
x-on:htmx:before-on-load="handleHtmxBeforeOnLoad($event)"
>
@popup.Error500Popup()
@popup.Error503Popup()
<div
id="main-content"
class="flex flex-col h-screen justify-between"
>
@nav.Navbar()
<div id="page-content" class="mb-auto md:px-5 md:pt-5">
{ children... }
</div>
@footer.Footer()
</div>
</body>
</html>
}

View File

@@ -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) {
<div
class="grid mt-24 left-0 right-0 top-0 bottom-0
place-content-center bg-base px-4"
>
<div class="text-center">
<h1
class="text-9xl text-text"
>{ strconv.Itoa(code) }</h1>
<p
class="text-2xl font-bold tracking-tight text-subtext1
sm:text-4xl"
>{ err }</p>
<p
class="mt-4 text-subtext0"
>{ message }</p>
<a
href="/"
class="mt-6 inline-block rounded-lg bg-mauve px-5 py-3
text-sm text-crust transition hover:bg-mauve/75"
>Go to homepage</a>
</div>
</div>
}
}

View File

@@ -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") {
<div class="text-center mt-24">
<div class="text-4xl lg:text-6xl">OSL Stats</div>
<div>Placeholder text</div>
</div>
}
}