diff --git a/cmd/oslstats/httpserver.go b/cmd/oslstats/httpserver.go index bd3b743..d1dbf24 100644 --- a/cmd/oslstats/httpserver.go +++ b/cmd/oslstats/httpserver.go @@ -15,7 +15,7 @@ import ( "git.haelnorr.com/h/oslstats/internal/store" ) -func setupHttpServer( +func setupHTTPServer( staticFS *fs.FS, cfg *config.Config, logger *hlog.Logger, diff --git a/cmd/oslstats/routes.go b/cmd/oslstats/routes.go index 11979cb..90a4932 100644 --- a/cmd/oslstats/routes.go +++ b/cmd/oslstats/routes.go @@ -72,6 +72,16 @@ func addRoutes( Method: hws.MethodPOST, Handler: handlers.SeasonsList(s, conn), }, + { + Path: "/seasons/new", + Methods: []hws.Method{hws.MethodGET, hws.MethodPOST}, + Handler: handlers.NewSeason(s, conn), + }, + { + Path: "/seasons/{season_short_name}", + Method: hws.MethodGET, + Handler: handlers.SeasonPage(s, conn), + }, } htmxRoutes := []hws.Route{ @@ -80,6 +90,16 @@ func addRoutes( Method: hws.MethodPOST, Handler: handlers.IsUsernameUnique(s, conn, cfg, store), }, + { + Path: "/htmx/isseasonnameunique", + Method: hws.MethodPOST, + Handler: handlers.IsSeasonNameUnique(s, conn), + }, + { + Path: "/htmx/isseasonshortnameunique", + Method: hws.MethodPOST, + Handler: handlers.IsSeasonShortNameUnique(s, conn), + }, } wsRoutes := []hws.Route{ diff --git a/cmd/oslstats/run.go b/cmd/oslstats/run.go index acb46f4..90babaf 100644 --- a/cmd/oslstats/run.go +++ b/cmd/oslstats/run.go @@ -29,7 +29,6 @@ func run(ctx context.Context, logger *hlog.Logger, cfg *config.Config) error { if err != nil { return errors.Wrap(err, "setupDBConn") } - defer closedb() // Setup embedded files logger.Debug().Msg("Getting embedded files") @@ -50,7 +49,7 @@ func run(ctx context.Context, logger *hlog.Logger, cfg *config.Config) error { } logger.Debug().Msg("Setting up HTTP server") - httpServer, err := setupHttpServer(&staticFS, cfg, logger, bun, store, discordAPI) + httpServer, err := setupHTTPServer(&staticFS, cfg, logger, bun, store, discordAPI) if err != nil { return errors.Wrap(err, "setupHttpServer") } @@ -72,7 +71,11 @@ func run(ctx context.Context, logger *hlog.Logger, cfg *config.Config) error { logger.Info().Msg("Shut down requested, waiting 60 seconds...") err := httpServer.Shutdown(shutdownCtx) if err != nil { - logger.Error().Err(err).Str("stacktrace", fmt.Sprintf("%+v", errors.Wrap(err, "httpServer.Shutdown"))).Msg("Graceful shutdown failed") + logger.Error().Err(err).Str("stacktrace", fmt.Sprintf("%+v", errors.Wrap(err, "httpServer.Shutdown"))).Msg("Error during HTTP server shutdown") + } + err = closedb() + if err != nil { + logger.Error().Err(err).Str("stacktrace", fmt.Sprintf("%+v", errors.Wrap(err, "closedb"))).Msg("Error during database close") } }) wg.Wait() diff --git a/go.sum b/go.sum index a0d75de..cd54517 100644 --- a/go.sum +++ b/go.sum @@ -6,12 +6,8 @@ git.haelnorr.com/h/golib/ezconf v0.1.1 h1:4euTSDb9jvuQQkVq+x5gHoYPYyUZPWxoOSlWCI 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.4.3 h1:rpqe0Dcbm3b5XZ/Bfy0LUhph6RR7+bmANrSA/W81l0A= -git.haelnorr.com/h/golib/hws v0.4.3/go.mod h1:UqB83p9lGjidDkk0pWRqxxOFrCkg8t+9J6uGtBOjNLo= git.haelnorr.com/h/golib/hws v0.4.4 h1:tV9UjZ4q96UlOdJKsC7b3kDV+bpQYqKVPQuaV1n3U3k= git.haelnorr.com/h/golib/hws v0.4.4/go.mod h1:dxAbbGGNzqLXhZXwgt091QsvsPBdrS+1YsNQNldNVoM= -git.haelnorr.com/h/golib/hwsauth v0.5.3 h1:Vgw8khDQZJRCc3m7z9QlbL9CYPyFB9JXUC3+omKRZPc= -git.haelnorr.com/h/golib/hwsauth v0.5.3/go.mod h1:NOonrVU/lX8lzuV77eDEiTwBjn7RrzYVcSdXUJWeHmQ= git.haelnorr.com/h/golib/hwsauth v0.5.4 h1:nuaiVpJHHXgKVRPoQSE/v3CJHSkivViK5h3SVhEcbbM= git.haelnorr.com/h/golib/hwsauth v0.5.4/go.mod h1:eIjRPeGycvxRWERkxCoRVMEEhHuUdiPDvjpzzZOhQ0w= git.haelnorr.com/h/golib/jwt v0.10.1 h1:1Adxt9H3Y4fWFvFjWpvg/vSFhbgCMDMxgiE3m7KvDMI= diff --git a/internal/db/doc.go b/internal/db/doc.go new file mode 100644 index 0000000..3557935 --- /dev/null +++ b/internal/db/doc.go @@ -0,0 +1,2 @@ +// Package db is an internal package for all the database models and related methods +package db diff --git a/internal/db/season.go b/internal/db/season.go index fab36ae..a976b3d 100644 --- a/internal/db/season.go +++ b/internal/db/season.go @@ -3,6 +3,8 @@ package db import ( "context" "database/sql" + "strings" + "time" "github.com/pkg/errors" "github.com/uptrace/bun" @@ -11,9 +13,13 @@ import ( type Season struct { bun.BaseModel `bun:"table:seasons,alias:s"` - ID int `bun:"id,pk,autoincrement"` - Name string `bun:"name,unique"` - ShortName string `bun:"short_name,unique"` + ID int `bun:"id,pk,autoincrement"` + Name string `bun:"name,unique"` + ShortName string `bun:"short_name,unique"` + StartDate time.Time `bun:"start_date,notnull"` + EndDate bun.NullTime `bun:"end_date"` + FinalsStartDate bun.NullTime `bun:"finals_start_date"` + FinalsEndDate bun.NullTime `bun:"finals_end_date"` } type SeasonList struct { @@ -22,7 +28,7 @@ type SeasonList struct { PageOpts PageOpts } -func NewSeason(ctx context.Context, tx bun.Tx, name, shortname string) (*Season, error) { +func NewSeason(ctx context.Context, tx bun.Tx, name, shortname string, start time.Time) (*Season, error) { if name == "" { return nil, errors.New("name cannot be empty") } @@ -31,7 +37,8 @@ func NewSeason(ctx context.Context, tx bun.Tx, name, shortname string) (*Season, } season := &Season{ Name: name, - ShortName: shortname, + ShortName: strings.ToUpper(shortname), + StartDate: start.Truncate(time.Hour * 24), } _, err := tx.NewInsert(). Model(season). @@ -81,3 +88,38 @@ func ListSeasons(ctx context.Context, tx bun.Tx, pageOpts *PageOpts) (*SeasonLis } return sl, nil } + +func GetSeason(ctx context.Context, tx bun.Tx, shortname string) (*Season, error) { + var season *Season + err := tx.NewSelect(). + Model(season). + Where("short_name = ?", strings.ToUpper(shortname)). + Limit(1). + Scan(ctx) + if err != nil && err != sql.ErrNoRows { + return nil, errors.Wrap(err, "tx.NewSelect") + } + return season, nil +} + +func IsSeasonNameUnique(ctx context.Context, tx bun.Tx, name string) (bool, error) { + count, err := tx.NewSelect(). + Model((*Season)(nil)). + Where("name = ?", name). + Count(ctx) + if err != nil { + return false, errors.Wrap(err, "tx.NewSelect") + } + return count == 0, nil +} + +func IsSeasonShortNameUnique(ctx context.Context, tx bun.Tx, shortname string) (bool, error) { + count, err := tx.NewSelect(). + Model((*Season)(nil)). + Where("short_name = ?", shortname). + Count(ctx) + if err != nil { + return false, errors.Wrap(err, "tx.NewSelect") + } + return count == 0, nil +} diff --git a/internal/db/user.go b/internal/db/user.go index 8546c1e..5af8092 100644 --- a/internal/db/user.go +++ b/internal/db/user.go @@ -26,20 +26,6 @@ func (user *User) GetID() int { return user.ID } -// 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.NewUpdate") - } - user.Username = newUsername - return nil -} - // CreateUser creates a new user with the given username and password func CreateUser(ctx context.Context, tx bun.Tx, username string, discorduser *discordgo.User) (*User, error) { if discorduser == nil { diff --git a/internal/handlers/doc.go b/internal/handlers/doc.go new file mode 100644 index 0000000..af7fe59 --- /dev/null +++ b/internal/handlers/doc.go @@ -0,0 +1,2 @@ +// Package handlers contains all the functions for handling http requests and serving content +package handlers diff --git a/internal/handlers/errors.go b/internal/handlers/errors.go index 1a3f1a9..a647607 100644 --- a/internal/handlers/errors.go +++ b/internal/handlers/errors.go @@ -6,6 +6,7 @@ import ( "net/http" "git.haelnorr.com/h/golib/hws" + "github.com/a-h/templ" "github.com/pkg/errors" ) @@ -166,3 +167,10 @@ func parseErrorDetails(details string) (int, string) { return errDetails.Code, errDetails.Stacktrace } + +func renderSafely(page templ.Component, s *hws.Server, r *http.Request, w http.ResponseWriter) { + err := page.Render(r.Context(), w) + if err != nil { + throwInternalServiceError(s, w, r, "Failed to render page", errors.Wrap(err, "page.")) + } +} diff --git a/internal/handlers/index.go b/internal/handlers/index.go index 25e7ac5..ec91c5f 100644 --- a/internal/handlers/index.go +++ b/internal/handlers/index.go @@ -8,15 +8,15 @@ import ( "git.haelnorr.com/h/golib/hws" ) -// Handles responses to the / path. Also serves a 404 Page for paths that +// Index 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 { +func Index(s *hws.Server) http.Handler { return http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { - throwNotFound(server, w, r, r.URL.Path) + throwNotFound(s, w, r, r.URL.Path) } - page.Index().Render(r.Context(), w) + renderSafely(page.Index(), s, r, w) }, ) } diff --git a/internal/handlers/newseason.go b/internal/handlers/newseason.go new file mode 100644 index 0000000..f019568 --- /dev/null +++ b/internal/handlers/newseason.go @@ -0,0 +1,105 @@ +package handlers + +import ( + "context" + "net/http" + "time" + + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/oslstats/internal/db" + "git.haelnorr.com/h/oslstats/internal/view/page" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +func NewSeason( + s *hws.Server, + conn *bun.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" { + renderSafely(page.NewSeason(), s, r, w) + return + } + }) +} + +func NewSeasonSubmit( + s *hws.Server, + conn *bun.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + }) +} + +func IsSeasonNameUnique( + s *hws.Server, + conn *bun.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err := r.ParseForm() + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + tx, err := conn.BeginTx(ctx, nil) + if err != nil { + err = notifyInternalServiceError(s, r, "Database error", errors.Wrap(err, "conn.BeginTx")) + if err != nil { + throwInternalServiceError(s, w, r, "Error notifying client", err) + } + return + } + name := r.FormValue("name") + unique, err := db.IsSeasonNameUnique(ctx, tx, name) + if err != nil { + err = notifyInternalServiceError(s, r, "Database error", errors.Wrap(err, "db.IsSeasonNameUnique")) + if err != nil { + throwInternalServiceError(s, w, r, "Error notifying client", err) + } + return + } + if !unique { + w.WriteHeader(http.StatusConflict) + return + } + }) +} + +func IsSeasonShortNameUnique( + s *hws.Server, + conn *bun.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err := r.ParseForm() + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + tx, err := conn.BeginTx(ctx, nil) + if err != nil { + err = notifyInternalServiceError(s, r, "Database error", errors.Wrap(err, "conn.BeginTx")) + if err != nil { + throwInternalServiceError(s, w, r, "Error notifying client", err) + } + return + } + shortname := r.FormValue("short_name") + unique, err := db.IsSeasonShortNameUnique(ctx, tx, shortname) + if err != nil { + err = notifyInternalServiceError(s, r, "Database error", errors.Wrap(err, "db.IsSeasonShortNameUnique")) + if err != nil { + throwInternalServiceError(s, w, r, "Error notifying client", err) + } + return + } + if !unique { + w.WriteHeader(http.StatusConflict) + return + } + }) +} diff --git a/internal/handlers/register.go b/internal/handlers/register.go index 352d17f..c0fa59a 100644 --- a/internal/handlers/register.go +++ b/internal/handlers/register.go @@ -76,7 +76,7 @@ func Register( method := r.Method if method == "GET" { tx.Commit() - page.Register(details.DiscordUser.Username).Render(r.Context(), w) + renderSafely(page.Register(details.DiscordUser.Username), s, r, w) return } if method == "POST" { diff --git a/internal/handlers/season.go b/internal/handlers/season.go new file mode 100644 index 0000000..bfd7c93 --- /dev/null +++ b/internal/handlers/season.go @@ -0,0 +1,41 @@ +package handlers + +import ( + "context" + "net/http" + "time" + + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/oslstats/internal/db" + "git.haelnorr.com/h/oslstats/internal/view/page" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +func SeasonPage( + s *hws.Server, + conn *bun.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + tx, err := conn.BeginTx(ctx, nil) + if err != nil { + throwInternalServiceError(s, w, r, "Database error", errors.Wrap(err, "conn.BeginTx")) + return + } + defer tx.Rollback() + seasonStr := r.PathValue("season_short_name") + season, err := db.GetSeason(ctx, tx, seasonStr) + if err != nil { + throwInternalServiceError(s, w, r, "Database error", errors.Wrap(err, "db.GetSeason")) + return + } + tx.Commit() + if season == nil { + throwNotFound(s, w, r, r.URL.Path) + return + } + renderSafely(page.SeasonPage(season), s, r, w) + }) +} diff --git a/internal/handlers/seasons.go b/internal/handlers/seasons.go index 930db6d..92840ae 100644 --- a/internal/handlers/seasons.go +++ b/internal/handlers/seasons.go @@ -58,7 +58,7 @@ func SeasonsPage( return } tx.Commit() - page.SeasonsPage(seasons).Render(r.Context(), w) + renderSafely(page.SeasonsList(seasons), s, r, w) }) } @@ -123,6 +123,6 @@ func SeasonsList( tx.Commit() // Return only the list component (hx-push-url handles URL update client-side) - page.SeasonsList(seasons).Render(r.Context(), w) + renderSafely(page.SeasonsList(seasons), s, r, w) }) } diff --git a/internal/handlers/static.go b/internal/handlers/static.go index 3176129..3f4d2ff 100644 --- a/internal/handlers/static.go +++ b/internal/handlers/static.go @@ -1,13 +1,14 @@ package handlers import ( - "git.haelnorr.com/h/golib/hws" "net/http" "path/filepath" "strings" + + "git.haelnorr.com/h/golib/hws" ) -// Handles requests for static files, without allowing access to the +// StaticFS 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 diff --git a/internal/handlers/test.go b/internal/handlers/test.go index c05714e..78d33ed 100644 --- a/internal/handlers/test.go +++ b/internal/handlers/test.go @@ -9,7 +9,7 @@ import ( "git.haelnorr.com/h/oslstats/internal/view/page" ) -// Handles responses to the / path. Also serves a 404 Page for paths that +// NotifyTester handles responses to the / path. Also serves a 404 Page for paths that // don't have explicit handlers func NotifyTester(s *hws.Server) http.Handler { return http.HandlerFunc( @@ -22,9 +22,9 @@ func NotifyTester(s *hws.Server) http.Handler { // Error: testErr, // }) // page.Render(r.Context(), w) - page.Test().Render(r.Context(), w) + renderSafely(page.Test(), s, r, w) } else { - r.ParseForm() + _ = r.ParseForm() // target := r.Form.Get("target") title := r.Form.Get("title") level := r.Form.Get("type") diff --git a/internal/view/component/form/new_season.templ b/internal/view/component/form/new_season.templ new file mode 100644 index 0000000..9bda1b3 --- /dev/null +++ b/internal/view/component/form/new_season.templ @@ -0,0 +1,4 @@ +package form + +templ NewSeason() { +} diff --git a/internal/view/page/new_season.templ b/internal/view/page/new_season.templ new file mode 100644 index 0000000..ac3eb8b --- /dev/null +++ b/internal/view/page/new_season.templ @@ -0,0 +1,9 @@ +package page + +import "git.haelnorr.com/h/oslstats/internal/view/component/form" +import "git.haelnorr.com/h/oslstats/internal/view/layout" + +templ NewSeason() { + @layout.Global("New Season") + @form.NewSeason() +} diff --git a/internal/view/page/season.templ b/internal/view/page/season.templ new file mode 100644 index 0000000..80e10a9 --- /dev/null +++ b/internal/view/page/season.templ @@ -0,0 +1,202 @@ +package page + +import "git.haelnorr.com/h/oslstats/internal/db" +import "git.haelnorr.com/h/oslstats/internal/view/layout" +import "time" +import "strconv" + +templ SeasonPage(season *db.Season) { + @layout.Global(season.Name) { +
+ @SeasonDetails(season) +
+ } +} + +templ SeasonDetails(season *db.Season) { +
+ +
+
+
+

{ season.Name }

+ + { season.ShortName } + +
+ +
+
+ +
+ +
+

+ + Regular Season +

+
+
+ Start Date: + + { formatDate(season.StartDate) } + +
+
+ End Date: + + if !season.EndDate.IsZero() { + { formatDate(season.EndDate.Time) } + } else { + Not set + } + +
+
+ Duration: + + if !season.EndDate.IsZero() { + { formatDuration(season.StartDate, season.EndDate.Time) } + } else { + Ongoing + } + +
+
+
+ +
+

+ + Finals +

+
+
+ Start Date: + + if !season.FinalsStartDate.IsZero() { + { formatDate(season.FinalsStartDate.Time) } + } else { + Not set + } + +
+
+ End Date: + + if !season.FinalsEndDate.IsZero() { + { formatDate(season.FinalsEndDate.Time) } + } else { + Not set + } + +
+
+ Duration: + + if !season.FinalsStartDate.IsZero() && !season.FinalsEndDate.IsZero() { + { formatDuration(season.FinalsStartDate.Time, season.FinalsEndDate.Time) } + } else { + Not scheduled + } + +
+
+
+
+ +
+
+

Status

+
+ @SeasonStatus(season) +
+
+
+
+} + +templ SeasonStatus(season *db.Season) { + {{ + now := time.Now() + status := "" + statusColor := "" + statusBg := "" + + if now.Before(season.StartDate) { + status = "Upcoming" + statusColor = "text-blue" + statusBg = "bg-blue/10 border-blue" + } else if !season.EndDate.IsZero() && now.After(season.EndDate.Time) { + status = "Completed" + statusColor = "text-green" + statusBg = "bg-green/10 border-green" + } else if !season.FinalsStartDate.IsZero() && now.After(season.FinalsStartDate.Time) { + if !season.FinalsEndDate.IsZero() && now.After(season.FinalsEndDate.Time) { + status = "Completed" + statusColor = "text-green" + statusBg = "bg-green/10 border-green" + } else { + status = "Finals in Progress" + statusColor = "text-yellow" + statusBg = "bg-yellow/10 border-yellow" + } + } else { + status = "In Progress" + statusColor = "text-green" + statusBg = "bg-green/10 border-green" + } + }} +
+ + { status } +
+} + +func formatDate(t time.Time) string { + return t.Format("January 2, 2006") +} + +func formatDuration(start, end time.Time) string { + days := int(end.Sub(start).Hours() / 24) + if days == 0 { + return "Same day" + } else if days == 1 { + return "1 day" + } else if days < 7 { + return strconv.Itoa(days) + " days" + } else if days < 30 { + weeks := days / 7 + if weeks == 1 { + return "1 week" + } + return strconv.Itoa(weeks) + " weeks" + } else if days < 365 { + months := days / 30 + if months == 1 { + return "1 month" + } + return strconv.Itoa(months) + " months" + } else { + years := days / 365 + if years == 1 { + return "1 year" + } + return strconv.Itoa(years) + " years" + } +} diff --git a/internal/view/page/seasons_list.templ b/internal/view/page/seasons_list.templ index b12601a..5bd60cc 100644 --- a/internal/view/page/seasons_list.templ +++ b/internal/view/page/seasons_list.templ @@ -55,8 +55,15 @@ templ SeasonsList(seasons *db.SeasonList) { seasons.PageOpts.OrderBy).CallInline } > -
-

Seasons

+
+
+ Seasons + Add season +
@sort.Dropdown(seasons.PageOpts, sortOpts)
diff --git a/pkg/embedfs/files/css/output.css b/pkg/embedfs/files/css/output.css index 8ad1afd..47ae661 100644 --- a/pkg/embedfs/files/css/output.css +++ b/pkg/embedfs/files/css/output.css @@ -310,6 +310,9 @@ .mb-2 { margin-bottom: calc(var(--spacing) * 2); } + .mb-4 { + margin-bottom: calc(var(--spacing) * 4); + } .mb-6 { margin-bottom: calc(var(--spacing) * 6); } @@ -343,6 +346,9 @@ .inline-flex { display: inline-flex; } + .table { + display: table; + } .size-5 { width: calc(var(--spacing) * 5); height: calc(var(--spacing) * 5); @@ -477,6 +483,13 @@ margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse))); } } + .space-y-3 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse))); + } + } .gap-x-2 { column-gap: calc(var(--spacing) * 2); } @@ -529,6 +542,10 @@ border-style: var(--tw-border-style); border-width: 2px; } + .border-b { + border-bottom-style: var(--tw-border-style); + border-bottom-width: 1px; + } .border-blue { border-color: var(--blue); } @@ -556,6 +573,12 @@ .bg-blue { background-color: var(--blue); } + .bg-blue\/10 { + background-color: var(--blue); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--blue) 10%, transparent); + } + } .bg-crust { background-color: var(--crust); } @@ -577,6 +600,12 @@ .bg-green { background-color: var(--green); } + .bg-green\/10 { + background-color: var(--green); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--green) 10%, transparent); + } + } .bg-mantle { background-color: var(--mantle); } @@ -589,12 +618,21 @@ .bg-surface0 { background-color: var(--surface0); } + .bg-surface1 { + background-color: var(--surface1); + } .bg-teal { background-color: var(--teal); } .bg-yellow { background-color: var(--yellow); } + .bg-yellow\/10 { + background-color: var(--yellow); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--yellow) 10%, transparent); + } + } .p-2 { padding: calc(var(--spacing) * 2); } @@ -742,6 +780,9 @@ .text-yellow { color: var(--yellow); } + .italic { + font-style: italic; + } .opacity-50 { opacity: 50%; } @@ -789,6 +830,16 @@ } } } + .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) {