From 680ba3fe5009af48db6f31bfbdc891b195d06592 Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Sat, 21 Feb 2026 22:25:21 +1100 Subject: [PATCH] added log file uploading and match results --- internal/db/fixture_result.go | 363 +++++++++++++ .../20260221140000_fixture_results.go | 91 ++++ internal/db/setup.go | 3 + internal/embedfs/web/css/output.css | 206 ++++++++ internal/embedfs/web/js/localtime.js | 84 +++ internal/handlers/fixture_detail.go | 27 +- internal/handlers/fixture_result.go | 415 +++++++++++++++ .../handlers/fixture_result_validation.go | 187 +++++++ internal/handlers/season_league_fixtures.go | 9 +- internal/server/routes.go | 26 + internal/validation/forms.go | 5 + internal/validation/timefield.go | 19 + internal/view/baseview/layout.templ | 1 + .../view/seasonsview/fixture_detail.templ | 496 +++++++++++++++++- .../seasonsview/fixture_review_result.templ | 234 +++++++++ .../seasonsview/fixture_upload_result.templ | 118 +++++ internal/view/seasonsview/localtime.templ | 43 ++ .../seasonsview/season_league_fixtures.templ | 147 ++++-- .../season_league_team_detail.templ | 2 +- pkg/slapshotapi/tampering.go | 180 +++++++ 20 files changed, 2595 insertions(+), 61 deletions(-) create mode 100644 internal/db/fixture_result.go create mode 100644 internal/db/migrations/20260221140000_fixture_results.go create mode 100644 internal/embedfs/web/js/localtime.js create mode 100644 internal/handlers/fixture_result.go create mode 100644 internal/handlers/fixture_result_validation.go create mode 100644 internal/view/seasonsview/fixture_review_result.templ create mode 100644 internal/view/seasonsview/fixture_upload_result.templ create mode 100644 internal/view/seasonsview/localtime.templ create mode 100644 pkg/slapshotapi/tampering.go diff --git a/internal/db/fixture_result.go b/internal/db/fixture_result.go new file mode 100644 index 0000000..511417c --- /dev/null +++ b/internal/db/fixture_result.go @@ -0,0 +1,363 @@ +package db + +import ( + "context" + "time" + + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +type FixtureResult struct { + bun.BaseModel `bun:"table:fixture_results,alias:fr"` + + ID int `bun:"id,pk,autoincrement"` + FixtureID int `bun:",notnull,unique"` + Winner string `bun:",notnull"` + HomeScore int `bun:",notnull"` + AwayScore int `bun:",notnull"` + MatchType string + Arena string + EndReason string + PeriodsEnabled bool + CustomMercyRule int + MatchLength int + CreatedAt int64 `bun:",notnull"` + UpdatedAt *int64 + UploadedByUserID int `bun:",notnull"` + Finalized bool `bun:",default:false"` + TamperingDetected bool `bun:",default:false"` + TamperingReason *string + + Fixture *Fixture `bun:"rel:belongs-to,join:fixture_id=id"` + UploadedBy *User `bun:"rel:belongs-to,join:uploaded_by_user_id=id"` + PlayerStats []*FixtureResultPlayerStats `bun:"rel:has-many,join:id=fixture_result_id"` +} + +type FixtureResultPlayerStats struct { + bun.BaseModel `bun:"table:fixture_result_player_stats,alias:frps"` + + ID int `bun:"id,pk,autoincrement"` + FixtureResultID int `bun:",notnull"` + PeriodNum int `bun:",notnull"` + PlayerID *int // NULL for unmapped/free agents + PlayerGameUserID string `bun:",notnull"` + PlayerUsername string `bun:",notnull"` + TeamID *int // NULL for unmapped + Team string `bun:",notnull"` // 'home' or 'away' + + // All stats as INT (nullable) + Goals *int + Assists *int + PrimaryAssists *int + SecondaryAssists *int + Saves *int + Blocks *int + Shots *int + Turnovers *int + Takeaways *int + Passes *int + PossessionTimeSec *int + FaceoffsWon *int + FaceoffsLost *int + PostHits *int + OvertimeGoals *int + GameWinningGoals *int + Score *int + ContributedGoals *int + ConcededGoals *int + GamesPlayed *int + Wins *int + Losses *int + OvertimeWins *int + OvertimeLosses *int + Ties *int + Shutouts *int + ShutoutsAgainst *int + HasMercyRuled *int + WasMercyRuled *int + PeriodsPlayed *int + + FixtureResult *FixtureResult `bun:"rel:belongs-to,join:fixture_result_id=id"` + Player *Player `bun:"rel:belongs-to,join:player_id=id"` + TeamRel *Team `bun:"rel:belongs-to,join:team_id=id"` +} + +// PlayerWithPlayStatus is a helper struct for overview display +type PlayerWithPlayStatus struct { + Player *Player + Played bool + IsManager bool + Stats *FixtureResultPlayerStats // Period 3 (final cumulative) stats, nil if no result +} + +// InsertFixtureResult stores a new match result with all player stats in a single transaction. +func InsertFixtureResult( + ctx context.Context, + tx bun.Tx, + result *FixtureResult, + playerStats []*FixtureResultPlayerStats, + audit *AuditMeta, +) (*FixtureResult, error) { + if result == nil { + return nil, errors.New("result cannot be nil") + } + + result.CreatedAt = time.Now().Unix() + + err := Insert(tx, result).WithAudit(audit, &AuditInfo{ + Action: "fixture_results.create", + ResourceType: "fixture_result", + ResourceID: nil, + Details: map[string]any{ + "fixture_id": result.FixtureID, + "winner": result.Winner, + "home_score": result.HomeScore, + "away_score": result.AwayScore, + "tampering_detected": result.TamperingDetected, + }, + }).Exec(ctx) + if err != nil { + return nil, errors.Wrap(err, "Insert result") + } + + // Set the fixture_result_id on all player stats + for _, ps := range playerStats { + ps.FixtureResultID = result.ID + } + + // Insert player stats in bulk + if len(playerStats) > 0 { + err = InsertMultiple(tx, playerStats).Exec(ctx) + if err != nil { + return nil, errors.Wrap(err, "InsertMultiple player stats") + } + } + + return result, nil +} + +// GetFixtureResult retrieves a result with all player stats for a fixture. +// Returns nil, nil if no result exists. +func GetFixtureResult(ctx context.Context, tx bun.Tx, fixtureID int) (*FixtureResult, error) { + result := new(FixtureResult) + err := tx.NewSelect(). + Model(result). + Where("fr.fixture_id = ?", fixtureID). + Relation("Fixture"). + Relation("UploadedBy"). + Relation("PlayerStats", func(q *bun.SelectQuery) *bun.SelectQuery { + return q.Order("frps.team ASC", "frps.period_num ASC", "frps.player_username ASC") + }). + Relation("PlayerStats.Player"). + Scan(ctx) + if err != nil { + if err.Error() == "sql: no rows in result set" { + return nil, nil + } + return nil, errors.Wrap(err, "tx.NewSelect") + } + return result, nil +} + +// GetPendingFixtureResult retrieves a non-finalized result for review/edit. +// Returns nil, nil if no pending result exists. +func GetPendingFixtureResult(ctx context.Context, tx bun.Tx, fixtureID int) (*FixtureResult, error) { + result := new(FixtureResult) + err := tx.NewSelect(). + Model(result). + Where("fr.fixture_id = ?", fixtureID). + Where("fr.finalized = false"). + Relation("Fixture"). + Relation("UploadedBy"). + Relation("PlayerStats", func(q *bun.SelectQuery) *bun.SelectQuery { + return q.Order("frps.team ASC", "frps.period_num ASC", "frps.player_username ASC") + }). + Relation("PlayerStats.Player"). + Scan(ctx) + if err != nil { + if err.Error() == "sql: no rows in result set" { + return nil, nil + } + return nil, errors.Wrap(err, "tx.NewSelect") + } + return result, nil +} + +// FinalizeFixtureResult marks a pending result as finalized. +func FinalizeFixtureResult( + ctx context.Context, + tx bun.Tx, + fixtureID int, + audit *AuditMeta, +) error { + result, err := GetPendingFixtureResult(ctx, tx, fixtureID) + if err != nil { + return errors.Wrap(err, "GetPendingFixtureResult") + } + if result == nil { + return BadRequest("no pending result to finalize") + } + + now := time.Now().Unix() + result.Finalized = true + result.UpdatedAt = &now + + err = UpdateByID(tx, result.ID, result). + Column("finalized", "updated_at"). + WithAudit(audit, &AuditInfo{ + Action: "fixture_results.finalize", + ResourceType: "fixture_result", + ResourceID: result.ID, + Details: map[string]any{ + "fixture_id": fixtureID, + }, + }).Exec(ctx) + if err != nil { + return errors.Wrap(err, "UpdateByID") + } + return nil +} + +// DeleteFixtureResult removes a pending result and all associated player stats (CASCADE). +func DeleteFixtureResult( + ctx context.Context, + tx bun.Tx, + fixtureID int, + audit *AuditMeta, +) error { + result, err := GetPendingFixtureResult(ctx, tx, fixtureID) + if err != nil { + return errors.Wrap(err, "GetPendingFixtureResult") + } + if result == nil { + return BadRequest("no pending result to discard") + } + + err = DeleteByID[FixtureResult](tx, result.ID). + WithAudit(audit, &AuditInfo{ + Action: "fixture_results.discard", + ResourceType: "fixture_result", + ResourceID: result.ID, + Details: map[string]any{ + "fixture_id": fixtureID, + }, + }).Delete(ctx) + if err != nil { + return errors.Wrap(err, "DeleteByID") + } + return nil +} + +// GetFinalizedResultsForFixtures returns finalized results for a list of fixture IDs. +// Returns a map of fixtureID -> *FixtureResult (without player stats for efficiency). +func GetFinalizedResultsForFixtures(ctx context.Context, tx bun.Tx, fixtureIDs []int) (map[int]*FixtureResult, error) { + if len(fixtureIDs) == 0 { + return map[int]*FixtureResult{}, nil + } + results, err := GetList[FixtureResult](tx). + Where("fixture_id IN (?)", bun.In(fixtureIDs)). + Where("finalized = true"). + GetAll(ctx) + if err != nil { + return nil, errors.Wrap(err, "GetList") + } + resultMap := make(map[int]*FixtureResult, len(results)) + for _, r := range results { + resultMap[r.FixtureID] = r + } + return resultMap, nil +} + +// GetFixtureTeamRosters returns all team players with participation status for a fixture. +// Returns: map["home"|"away"] -> []*PlayerWithPlayStatus +func GetFixtureTeamRosters( + ctx context.Context, + tx bun.Tx, + fixture *Fixture, + result *FixtureResult, +) (map[string][]*PlayerWithPlayStatus, error) { + if fixture == nil { + return nil, errors.New("fixture cannot be nil") + } + + rosters := map[string][]*PlayerWithPlayStatus{} + + // Get home team roster + homeRosters := []*TeamRoster{} + err := tx.NewSelect(). + Model(&homeRosters). + Where("tr.team_id = ?", fixture.HomeTeamID). + Where("tr.season_id = ?", fixture.SeasonID). + Where("tr.league_id = ?", fixture.LeagueID). + Relation("Player", func(q *bun.SelectQuery) *bun.SelectQuery { + return q.Relation("User") + }). + Scan(ctx) + if err != nil { + return nil, errors.Wrap(err, "tx.NewSelect home roster") + } + + // Get away team roster + awayRosters := []*TeamRoster{} + err = tx.NewSelect(). + Model(&awayRosters). + Where("tr.team_id = ?", fixture.AwayTeamID). + Where("tr.season_id = ?", fixture.SeasonID). + Where("tr.league_id = ?", fixture.LeagueID). + Relation("Player", func(q *bun.SelectQuery) *bun.SelectQuery { + return q.Relation("User") + }). + Scan(ctx) + if err != nil { + return nil, errors.Wrap(err, "tx.NewSelect away roster") + } + + // Build maps of player IDs that played and their period 3 stats + playedPlayerIDs := map[int]bool{} + playerStatsByID := map[int]*FixtureResultPlayerStats{} + if result != nil { + for _, ps := range result.PlayerStats { + if ps.PlayerID != nil { + playedPlayerIDs[*ps.PlayerID] = true + if ps.PeriodNum == 3 { + playerStatsByID[*ps.PlayerID] = ps + } + } + } + } + + // Build home roster with play status and stats + for _, r := range homeRosters { + played := false + var stats *FixtureResultPlayerStats + if result != nil && r.Player != nil { + played = playedPlayerIDs[r.Player.ID] + stats = playerStatsByID[r.Player.ID] + } + rosters["home"] = append(rosters["home"], &PlayerWithPlayStatus{ + Player: r.Player, + Played: played, + IsManager: r.IsManager, + Stats: stats, + }) + } + + // Build away roster with play status and stats + for _, r := range awayRosters { + played := false + var stats *FixtureResultPlayerStats + if result != nil && r.Player != nil { + played = playedPlayerIDs[r.Player.ID] + stats = playerStatsByID[r.Player.ID] + } + rosters["away"] = append(rosters["away"], &PlayerWithPlayStatus{ + Player: r.Player, + Played: played, + IsManager: r.IsManager, + Stats: stats, + }) + } + + return rosters, nil +} diff --git a/internal/db/migrations/20260221140000_fixture_results.go b/internal/db/migrations/20260221140000_fixture_results.go new file mode 100644 index 0000000..071f488 --- /dev/null +++ b/internal/db/migrations/20260221140000_fixture_results.go @@ -0,0 +1,91 @@ +package migrations + +import ( + "context" + + "git.haelnorr.com/h/oslstats/internal/db" + "github.com/uptrace/bun" +) + +func init() { + Migrations.MustRegister( + // UP migration + func(ctx context.Context, conn *bun.DB) error { + // Create fixture_results table + _, err := conn.NewCreateTable(). + Model((*db.FixtureResult)(nil)). + IfNotExists(). + ForeignKey(`("fixture_id") REFERENCES "fixtures" ("id") ON DELETE CASCADE`). + ForeignKey(`("uploaded_by_user_id") REFERENCES "users" ("id")`). + Exec(ctx) + if err != nil { + return err + } + + // Create fixture_result_player_stats table + _, err = conn.NewCreateTable(). + Model((*db.FixtureResultPlayerStats)(nil)). + IfNotExists(). + ForeignKey(`("fixture_result_id") REFERENCES "fixture_results" ("id") ON DELETE CASCADE`). + ForeignKey(`("player_id") REFERENCES "players" ("id") ON DELETE SET NULL`). + ForeignKey(`("team_id") REFERENCES "teams" ("id") ON DELETE SET NULL`). + Exec(ctx) + if err != nil { + return err + } + + // Create index on fixture_result_id for faster lookups + _, err = conn.NewCreateIndex(). + Model((*db.FixtureResultPlayerStats)(nil)). + Index("idx_frps_fixture_result_id"). + Column("fixture_result_id"). + IfNotExists(). + Exec(ctx) + if err != nil { + return err + } + + // Create index on player_id for stats queries + _, err = conn.NewCreateIndex(). + Model((*db.FixtureResultPlayerStats)(nil)). + Index("idx_frps_player_id"). + Column("player_id"). + IfNotExists(). + Exec(ctx) + if err != nil { + return err + } + + // Create composite index for period+team filtering + _, err = conn.NewCreateIndex(). + Model((*db.FixtureResultPlayerStats)(nil)). + Index("idx_frps_result_period_team"). + Column("fixture_result_id", "period_num", "team"). + IfNotExists(). + Exec(ctx) + if err != nil { + return err + } + + return nil + }, + // DOWN migration + func(ctx context.Context, conn *bun.DB) error { + // Drop fixture_result_player_stats first (has FK to fixture_results) + _, err := conn.NewDropTable(). + Model((*db.FixtureResultPlayerStats)(nil)). + IfExists(). + Exec(ctx) + if err != nil { + return err + } + + // Drop fixture_results + _, err = conn.NewDropTable(). + Model((*db.FixtureResult)(nil)). + IfExists(). + Exec(ctx) + return err + }, + ) +} diff --git a/internal/db/setup.go b/internal/db/setup.go index 164ba3a..7d7cb68 100644 --- a/internal/db/setup.go +++ b/internal/db/setup.go @@ -34,7 +34,10 @@ func (db *DB) RegisterModels() []any { (*Permission)(nil), (*AuditLog)(nil), (*Fixture)(nil), + (*FixtureSchedule)(nil), (*Player)(nil), + (*FixtureResult)(nil), + (*FixtureResultPlayerStats)(nil), } db.RegisterModel(models...) return models diff --git a/internal/embedfs/web/css/output.css b/internal/embedfs/web/css/output.css index fbfcd5d..493e403 100644 --- a/internal/embedfs/web/css/output.css +++ b/internal/embedfs/web/css/output.css @@ -9,6 +9,7 @@ --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; --spacing: 0.25rem; + --breakpoint-md: 48rem; --breakpoint-lg: 64rem; --breakpoint-xl: 80rem; --breakpoint-2xl: 96rem; @@ -38,6 +39,7 @@ --text-6xl--line-height: 1; --text-9xl: 8rem; --text-9xl--line-height: 1; + --font-weight-light: 300; --font-weight-normal: 400; --font-weight-medium: 500; --font-weight-semibold: 600; @@ -50,6 +52,7 @@ --ease-in: cubic-bezier(0.4, 0, 1, 1); --ease-out: cubic-bezier(0, 0, 0.2, 1); --animate-spin: spin 1s linear infinite; + --blur-sm: 8px; --default-transition-duration: 150ms; --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); --default-font-family: var(--font-sans); @@ -214,6 +217,9 @@ .collapse { visibility: collapse; } + .invisible { + visibility: hidden; + } .visible { visibility: visible; } @@ -261,6 +267,9 @@ .top-20 { top: calc(var(--spacing) * 20); } + .top-full { + top: 100%; + } .right-0 { right: calc(var(--spacing) * 0); } @@ -451,6 +460,9 @@ .h-16 { height: calc(var(--spacing) * 16); } + .h-\[calc\(100\%-3rem\)\] { + height: calc(100% - 3rem); + } .h-full { height: 100%; } @@ -514,6 +526,12 @@ .w-48 { width: calc(var(--spacing) * 48); } + .w-56 { + width: calc(var(--spacing) * 56); + } + .w-72 { + width: calc(var(--spacing) * 72); + } .w-80 { width: calc(var(--spacing) * 80); } @@ -559,6 +577,9 @@ .max-w-screen-lg { max-width: var(--breakpoint-lg); } + .max-w-screen-md { + max-width: var(--breakpoint-md); + } .max-w-screen-xl { max-width: var(--breakpoint-xl); } @@ -610,6 +631,9 @@ .cursor-grab { cursor: grab; } + .cursor-help { + cursor: help; + } .cursor-not-allowed { cursor: not-allowed; } @@ -622,6 +646,12 @@ .resize-none { resize: none; } + .list-inside { + list-style-position: inside; + } + .list-disc { + list-style-type: disc; + } .appearance-none { appearance: none; } @@ -667,6 +697,9 @@ .gap-1 { gap: calc(var(--spacing) * 1); } + .gap-1\.5 { + gap: calc(var(--spacing) * 1.5); + } .gap-2 { gap: calc(var(--spacing) * 2); } @@ -682,6 +715,13 @@ .gap-8 { gap: calc(var(--spacing) * 8); } + .space-y-0\.5 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 0.5) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 0.5) * calc(1 - var(--tw-space-y-reverse))); + } + } .space-y-1 { :where(& > :not(:last-child)) { --tw-space-y-reverse: 0; @@ -1036,6 +1076,9 @@ .px-2 { padding-inline: calc(var(--spacing) * 2); } + .px-2\.5 { + padding-inline: calc(var(--spacing) * 2.5); + } .px-3 { padding-inline: calc(var(--spacing) * 3); } @@ -1054,6 +1097,9 @@ .py-1 { padding-block: calc(var(--spacing) * 1); } + .py-1\.5 { + padding-block: calc(var(--spacing) * 1.5); + } .py-2 { padding-block: calc(var(--spacing) * 2); } @@ -1150,6 +1196,10 @@ --tw-leading: calc(var(--spacing) * 6); line-height: calc(var(--spacing) * 6); } + .leading-none { + --tw-leading: 1; + line-height: 1; + } .leading-relaxed { --tw-leading: var(--leading-relaxed); line-height: var(--leading-relaxed); @@ -1158,6 +1208,10 @@ --tw-font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold); } + .font-light { + --tw-font-weight: var(--font-weight-light); + font-weight: var(--font-weight-light); + } .font-medium { --tw-font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium); @@ -1214,6 +1268,12 @@ .text-red { color: var(--red); } + .text-red\/60 { + color: var(--red); + @supports (color: color-mix(in lab, red, red)) { + color: color-mix(in oklab, var(--red) 60%, transparent); + } + } .text-red\/80 { color: var(--red); @supports (color: color-mix(in lab, red, red)) { @@ -1232,6 +1292,12 @@ .text-yellow { color: var(--yellow); } + .text-yellow\/70 { + color: var(--yellow); + @supports (color: color-mix(in lab, red, red)) { + color: color-mix(in oklab, var(--yellow) 70%, transparent); + } + } .text-yellow\/80 { color: var(--yellow); @supports (color: color-mix(in lab, red, red)) { @@ -1287,6 +1353,11 @@ .filter { filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } + .backdrop-blur-sm { + --tw-backdrop-blur: blur(var(--blur-sm)); + -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); + backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); + } .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)); @@ -1335,6 +1406,75 @@ -webkit-user-select: none; user-select: none; } + .group-hover\:visible { + &:is(:where(.group):hover *) { + @media (hover: hover) { + visibility: visible; + } + } + } + .group-hover\:opacity-100 { + &:is(:where(.group):hover *) { + @media (hover: hover) { + opacity: 100%; + } + } + } + .file\:mr-4 { + &::file-selector-button { + margin-right: calc(var(--spacing) * 4); + } + } + .file\:rounded { + &::file-selector-button { + border-radius: 0.25rem; + } + } + .file\:border-0 { + &::file-selector-button { + border-style: var(--tw-border-style); + border-width: 0px; + } + } + .file\:bg-blue { + &::file-selector-button { + background-color: var(--blue); + } + } + .file\:px-3 { + &::file-selector-button { + padding-inline: calc(var(--spacing) * 3); + } + } + .file\:py-1 { + &::file-selector-button { + padding-block: calc(var(--spacing) * 1); + } + } + .file\:text-sm { + &::file-selector-button { + font-size: var(--text-sm); + line-height: var(--tw-leading, var(--text-sm--line-height)); + } + } + .file\:font-medium { + &::file-selector-button { + --tw-font-weight: var(--font-weight-medium); + font-weight: var(--font-weight-medium); + } + } + .file\:text-mantle { + &::file-selector-button { + color: var(--mantle); + } + } + .file\:transition { + &::file-selector-button { + 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)); + } + } .hover\:-translate-y-0\.5 { &:hover { @media (hover: hover) { @@ -1616,6 +1756,27 @@ } } } + .file\:hover\:cursor-pointer { + &::file-selector-button { + &:hover { + @media (hover: hover) { + cursor: pointer; + } + } + } + } + .file\:hover\:bg-blue\/80 { + &::file-selector-button { + &:hover { + @media (hover: hover) { + background-color: var(--blue); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--blue) 80%, transparent); + } + } + } + } + } .focus\:border-blue { &:focus { border-color: var(--blue); @@ -2301,6 +2462,42 @@ syntax: "*"; inherits: false; } +@property --tw-backdrop-blur { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-brightness { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-contrast { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-grayscale { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-hue-rotate { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-invert { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-opacity { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-saturate { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-sepia { + syntax: "*"; + inherits: false; +} @property --tw-duration { syntax: "*"; inherits: false; @@ -2362,6 +2559,15 @@ --tw-drop-shadow-color: initial; --tw-drop-shadow-alpha: 100%; --tw-drop-shadow-size: initial; + --tw-backdrop-blur: initial; + --tw-backdrop-brightness: initial; + --tw-backdrop-contrast: initial; + --tw-backdrop-grayscale: initial; + --tw-backdrop-hue-rotate: initial; + --tw-backdrop-invert: initial; + --tw-backdrop-opacity: initial; + --tw-backdrop-saturate: initial; + --tw-backdrop-sepia: initial; --tw-duration: initial; --tw-ease: initial; } diff --git a/internal/embedfs/web/js/localtime.js b/internal/embedfs/web/js/localtime.js new file mode 100644 index 0000000..38a233c --- /dev/null +++ b/internal/embedfs/web/js/localtime.js @@ -0,0 +1,84 @@ +// localtime.js - Converts UTC