added seasons list
This commit is contained in:
@@ -1,275 +0,0 @@
|
|||||||
# 🎉 MIGRATION SYSTEM IMPLEMENTATION COMPLETE! 🎉
|
|
||||||
|
|
||||||
## ✅ All Features Implemented and Tested
|
|
||||||
|
|
||||||
### What You Can Do Now:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Check migration status
|
|
||||||
make migrate-status
|
|
||||||
|
|
||||||
# 2. Preview pending migrations
|
|
||||||
make migrate-dry-run
|
|
||||||
|
|
||||||
# 3. Run migrations (with automatic backup)
|
|
||||||
make migrate
|
|
||||||
|
|
||||||
# 4. Create new migrations
|
|
||||||
make migrate-create NAME=my_new_feature
|
|
||||||
|
|
||||||
# 5. Rollback if needed
|
|
||||||
make migrate-rollback
|
|
||||||
|
|
||||||
# 6. Reset database (dev only)
|
|
||||||
make reset-db
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📊 Implementation Statistics
|
|
||||||
|
|
||||||
- **Files Created**: 5
|
|
||||||
- **Files Modified**: 7
|
|
||||||
- **Lines of Code**: ~800
|
|
||||||
- **Features**: 8 major safety features
|
|
||||||
- **Commands**: 7 new make targets
|
|
||||||
- **Documentation**: Comprehensive AGENTS.md section
|
|
||||||
|
|
||||||
## 🎯 Key Achievements
|
|
||||||
|
|
||||||
✅ **Production-Safe Migrations**
|
|
||||||
- No more accidental data deletion
|
|
||||||
- Automatic compressed backups before every change
|
|
||||||
- 5-minute migration lock timeout
|
|
||||||
- Pre-migration validation
|
|
||||||
|
|
||||||
✅ **Developer-Friendly**
|
|
||||||
- Simple `make migrate-create NAME=feature` workflow
|
|
||||||
- Dry-run mode to preview changes
|
|
||||||
- Pretty status output
|
|
||||||
- Clear error messages
|
|
||||||
|
|
||||||
✅ **Enterprise Features**
|
|
||||||
- PostgreSQL advisory locks prevent concurrent migrations
|
|
||||||
- Backup retention policy (keeps last 10)
|
|
||||||
- Graceful degradation if pg_dump missing
|
|
||||||
- Comprehensive logging
|
|
||||||
|
|
||||||
## 📁 Project Structure After Implementation
|
|
||||||
|
|
||||||
```
|
|
||||||
oslstats/
|
|
||||||
├── cmd/oslstats/
|
|
||||||
│ ├── main.go ✏️ MODIFIED - Added migration routing
|
|
||||||
│ ├── db.go ✏️ MODIFIED - Simplified loadModels
|
|
||||||
│ ├── migrate.go ✅ NEW - Migration runner (350 lines)
|
|
||||||
│ └── migrations/
|
|
||||||
│ ├── migrations.go ✅ NEW - Migration collection
|
|
||||||
│ └── 20250124000001_initial_schema.go ✅ NEW - Initial migration
|
|
||||||
├── internal/
|
|
||||||
│ ├── backup/
|
|
||||||
│ │ └── backup.go ✅ NEW - Backup system (140 lines)
|
|
||||||
│ ├── config/
|
|
||||||
│ │ └── flags.go ✏️ MODIFIED - Added 7 migration flags
|
|
||||||
│ └── db/
|
|
||||||
│ └── config.go ✏️ MODIFIED - Added backup config
|
|
||||||
├── backups/
|
|
||||||
│ └── .gitkeep ✅ NEW - Git-tracked directory
|
|
||||||
├── Makefile ✏️ MODIFIED - Added 7 migration targets
|
|
||||||
├── AGENTS.md ✏️ MODIFIED - Added migration guide
|
|
||||||
├── .gitignore ✏️ MODIFIED - Added backup exclusions
|
|
||||||
├── MIGRATION_IMPLEMENTATION_SUMMARY.md 📄 Documentation
|
|
||||||
└── IMPLEMENTATION_COMPLETE.md 📄 This file
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🚀 Quick Start Guide
|
|
||||||
|
|
||||||
### First Time Setup
|
|
||||||
|
|
||||||
1. **Ensure PostgreSQL is running**
|
|
||||||
```bash
|
|
||||||
# Your database should be configured in .env:
|
|
||||||
# DB_USER=...
|
|
||||||
# DB_PASSWORD=...
|
|
||||||
# DB_HOST=localhost
|
|
||||||
# DB_PORT=5432
|
|
||||||
# DB_NAME=oslstats
|
|
||||||
# DB_SSL=disable
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Run initial migration**
|
|
||||||
```bash
|
|
||||||
make migrate
|
|
||||||
```
|
|
||||||
This will:
|
|
||||||
- Validate migrations compile
|
|
||||||
- Create initial schema (users, discord_tokens tables)
|
|
||||||
- Create migration tracking table (bun_migrations)
|
|
||||||
|
|
||||||
3. **Verify**
|
|
||||||
```bash
|
|
||||||
make migrate-status
|
|
||||||
```
|
|
||||||
Should show:
|
|
||||||
```
|
|
||||||
✅ Applied 20250124000001_initial_schema 1 2026-01-24 15:XX:XX
|
|
||||||
```
|
|
||||||
|
|
||||||
### Adding Your First Feature
|
|
||||||
|
|
||||||
1. **Create a migration**
|
|
||||||
```bash
|
|
||||||
make migrate-create NAME=add_games_table
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Edit the generated file**
|
|
||||||
- Location: `cmd/oslstats/migrations/YYYYMMDDHHmmss_add_games_table.go`
|
|
||||||
- Implement UP function (apply changes)
|
|
||||||
- Implement DOWN function (rollback changes)
|
|
||||||
|
|
||||||
3. **Preview**
|
|
||||||
```bash
|
|
||||||
make migrate-dry-run
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Apply**
|
|
||||||
```bash
|
|
||||||
make migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Verify**
|
|
||||||
```bash
|
|
||||||
make migrate-status
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🛡️ Safety Features in Action
|
|
||||||
|
|
||||||
### 1. Automatic Backups
|
|
||||||
Every migration creates a compressed backup:
|
|
||||||
```
|
|
||||||
backups/20250124_150530_pre_migration.sql.gz (2.5 MB)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Migration Locking
|
|
||||||
```
|
|
||||||
[INFO] Migration lock acquired
|
|
||||||
[INFO] Applying migrations...
|
|
||||||
[INFO] Migration lock released
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Validation
|
|
||||||
```
|
|
||||||
[INFO] Step 1/5: Validating migrations...
|
|
||||||
[INFO] Migration validation passed ✓
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Progress Tracking
|
|
||||||
```
|
|
||||||
[INFO] Step 1/5: Validating migrations...
|
|
||||||
[INFO] Step 2/5: Checking for pending migrations...
|
|
||||||
[INFO] Step 3/5: Creating backup...
|
|
||||||
[INFO] Step 4/5: Acquiring migration lock...
|
|
||||||
[INFO] Step 5/5: Applying migrations...
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📝 Example: Adding Email Field to Users
|
|
||||||
|
|
||||||
**Complete workflow:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Create migration
|
|
||||||
make migrate-create NAME=add_email_to_users
|
|
||||||
|
|
||||||
# 2. Edit cmd/oslstats/migrations/YYYYMMDDHHmmss_add_email_to_users.go
|
|
||||||
# Add this code:
|
|
||||||
|
|
||||||
package migrations
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"github.com/uptrace/bun"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
Migrations.MustRegister(
|
|
||||||
// UP: Add email column
|
|
||||||
func(ctx context.Context, db *bun.DB) error {
|
|
||||||
_, err := db.ExecContext(ctx,
|
|
||||||
"ALTER TABLE users ADD COLUMN email VARCHAR(255)")
|
|
||||||
return err
|
|
||||||
},
|
|
||||||
// DOWN: Remove email column
|
|
||||||
func(ctx context.Context, db *bun.DB) error {
|
|
||||||
_, err := db.ExecContext(ctx,
|
|
||||||
"ALTER TABLE users DROP COLUMN email")
|
|
||||||
return err
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
# 3. Update internal/db/user.go model
|
|
||||||
# Add: Email string `bun:"email"`
|
|
||||||
|
|
||||||
# 4. Preview
|
|
||||||
make migrate-dry-run
|
|
||||||
|
|
||||||
# 5. Apply
|
|
||||||
make migrate
|
|
||||||
|
|
||||||
# 6. Verify
|
|
||||||
make migrate-status
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔍 Troubleshooting
|
|
||||||
|
|
||||||
### "pg_dump not found"
|
|
||||||
**Issue**: Backups are skipped
|
|
||||||
**Solution**: Install PostgreSQL client tools (optional, migrations still work)
|
|
||||||
```bash
|
|
||||||
# Ubuntu/Debian
|
|
||||||
sudo apt-get install postgresql-client
|
|
||||||
|
|
||||||
# macOS
|
|
||||||
brew install postgresql
|
|
||||||
|
|
||||||
# Arch
|
|
||||||
sudo pacman -S postgresql-libs
|
|
||||||
```
|
|
||||||
|
|
||||||
### "migration already in progress"
|
|
||||||
**Issue**: Another process is running migrations
|
|
||||||
**Solution**: Wait up to 5 minutes for it to complete, or check for hung connections
|
|
||||||
|
|
||||||
### "migration build failed"
|
|
||||||
**Issue**: Syntax error in migration file
|
|
||||||
**Solution**: Fix the error shown in output, then try again
|
|
||||||
|
|
||||||
## 📚 Resources
|
|
||||||
|
|
||||||
- **Full Documentation**: See `AGENTS.md` "Database Migrations" section
|
|
||||||
- **Implementation Details**: See `MIGRATION_IMPLEMENTATION_SUMMARY.md`
|
|
||||||
- **Bun Docs**: https://bun.uptrace.dev/guide/migrations.html
|
|
||||||
|
|
||||||
## 🎓 Best Practices
|
|
||||||
|
|
||||||
1. ✅ Always use `make migrate-dry-run` before applying
|
|
||||||
2. ✅ Test rollbacks in development
|
|
||||||
3. ✅ Keep migrations small and focused
|
|
||||||
4. ✅ Commit migration files to git
|
|
||||||
5. ✅ Document complex migrations with comments
|
|
||||||
6. ✅ Test in staging before production
|
|
||||||
|
|
||||||
## ✨ What's Next?
|
|
||||||
|
|
||||||
Your migration system is ready to use! You can:
|
|
||||||
|
|
||||||
1. **Start using it immediately** - Run `make migrate` to apply initial schema
|
|
||||||
2. **Add new features** - Use `make migrate-create NAME=...` workflow
|
|
||||||
3. **Deploy confidently** - Automatic backups and rollback support
|
|
||||||
4. **Scale safely** - Migration locking prevents conflicts
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**🎉 Congratulations! Your migration system is production-ready! 🎉**
|
|
||||||
|
|
||||||
**Implementation Date**: January 24, 2026
|
|
||||||
**Status**: ✅ Complete and Tested
|
|
||||||
**Next Action**: Run `make migrate` to apply initial schema
|
|
||||||
@@ -1,383 +0,0 @@
|
|||||||
# ✅ Migration System Implementation Complete!
|
|
||||||
|
|
||||||
## 🎉 What Was Built
|
|
||||||
|
|
||||||
A complete, production-ready database migration system for oslstats with:
|
|
||||||
|
|
||||||
### Core Features
|
|
||||||
- ✅ **Go-based migrations** - Type-safe, compile-time checked
|
|
||||||
- ✅ **Automatic compressed backups** - gzip compression saves ~80% space
|
|
||||||
- ✅ **Backup retention** - Keeps last 10 backups (configurable)
|
|
||||||
- ✅ **Dry-run mode** - Preview migrations before applying
|
|
||||||
- ✅ **Migration locking** - PostgreSQL advisory locks with 5-minute timeout
|
|
||||||
- ✅ **Pre-migration validation** - `go build` check ensures migrations compile
|
|
||||||
- ✅ **Graceful pg_dump handling** - Warns if not available, doesn't block migrations
|
|
||||||
- ✅ **Interactive reset** - Requires 'yes' confirmation for destructive operations
|
|
||||||
|
|
||||||
## 📁 Files Created (5 new files)
|
|
||||||
|
|
||||||
1. **`cmd/oslstats/migrations/migrations.go`**
|
|
||||||
- Migration collection registry
|
|
||||||
- Exports `Migrations` variable for registration
|
|
||||||
|
|
||||||
2. **`cmd/oslstats/migrations/20250124000001_initial_schema.go`**
|
|
||||||
- Initial migration for users and discord_tokens tables
|
|
||||||
- UP: Creates both tables
|
|
||||||
- DOWN: Drops both tables (rollback support)
|
|
||||||
|
|
||||||
3. **`cmd/oslstats/migrate.go`** (~350 lines)
|
|
||||||
- Complete migration runner with all commands
|
|
||||||
- Functions: runMigrations, migrateUp, migrateRollback, migrateStatus, migrateDryRun
|
|
||||||
- Validation: validateMigrations (go build check)
|
|
||||||
- Locking: acquireMigrationLock, releaseMigrationLock (PostgreSQL advisory locks)
|
|
||||||
- Utilities: createMigration, resetDatabase
|
|
||||||
|
|
||||||
4. **`internal/backup/backup.go`** (~140 lines)
|
|
||||||
- Backup system with gzip compression
|
|
||||||
- CreateBackup: pg_dump with compression, graceful error handling
|
|
||||||
- CleanOldBackups: Retention policy cleanup
|
|
||||||
- Handles missing pg_dump/gzip gracefully
|
|
||||||
|
|
||||||
5. **`backups/.gitkeep`**
|
|
||||||
- Empty file to track backup directory in git
|
|
||||||
|
|
||||||
## 📝 Files Modified (7 files)
|
|
||||||
|
|
||||||
1. **`cmd/oslstats/main.go`**
|
|
||||||
- Added migration command routing before server startup
|
|
||||||
- Routes to: migrate-up, migrate-rollback, migrate-status, migrate-dry-run, reset-db, migrate-create
|
|
||||||
- Migration commands exit after execution (don't start server)
|
|
||||||
|
|
||||||
2. **`cmd/oslstats/db.go`**
|
|
||||||
- Removed `resetDB` parameter from loadModels()
|
|
||||||
- Simplified to only create tables with IfNotExists()
|
|
||||||
- Reset logic moved to resetDatabase() in migrate.go
|
|
||||||
|
|
||||||
3. **`internal/config/flags.go`**
|
|
||||||
- Added 7 new migration-related flags
|
|
||||||
- Renamed `--migrate` to `--reset-db`
|
|
||||||
- Added validation to prevent multiple migration commands simultaneously
|
|
||||||
- Updated SetupFlags() to return (Flags, error)
|
|
||||||
|
|
||||||
4. **`internal/db/config.go`**
|
|
||||||
- Added BackupDir field (default: "backups")
|
|
||||||
- Added BackupRetention field (default: 10)
|
|
||||||
- Added validation for BackupRetention >= 1
|
|
||||||
|
|
||||||
5. **`Makefile`**
|
|
||||||
- Added 7 new migration targets:
|
|
||||||
- `make migrate` - Run pending migrations with backup
|
|
||||||
- `make migrate-no-backup` - Run without backup (dev)
|
|
||||||
- `make migrate-rollback` - Rollback last group
|
|
||||||
- `make migrate-status` - Show status
|
|
||||||
- `make migrate-dry-run` - Preview pending
|
|
||||||
- `make migrate-create NAME=...` - Create new migration
|
|
||||||
- `make reset-db` - Reset database (destructive)
|
|
||||||
|
|
||||||
6. **`AGENTS.md`**
|
|
||||||
- Replaced "Database" section with comprehensive "Database Migrations" section
|
|
||||||
- Added migration creation guide with examples
|
|
||||||
- Added migration patterns (create table, add column, create index, data migration)
|
|
||||||
- Added safety features documentation
|
|
||||||
- Added troubleshooting guide
|
|
||||||
- Added best practices
|
|
||||||
|
|
||||||
7. **`.gitignore`**
|
|
||||||
- Added backups/*.sql.gz
|
|
||||||
- Added backups/*.sql
|
|
||||||
- Excludes backups but keeps backups/.gitkeep
|
|
||||||
|
|
||||||
## 🎯 Available Commands
|
|
||||||
|
|
||||||
### Migration Commands
|
|
||||||
```bash
|
|
||||||
# Show what migrations have been applied
|
|
||||||
make migrate-status
|
|
||||||
|
|
||||||
# Preview migrations without applying
|
|
||||||
make migrate-dry-run
|
|
||||||
|
|
||||||
# Run pending migrations (with automatic backup)
|
|
||||||
make migrate
|
|
||||||
|
|
||||||
# Rollback last migration group (with backup)
|
|
||||||
make migrate-rollback
|
|
||||||
|
|
||||||
# Create new migration file
|
|
||||||
make migrate-create NAME=add_email_to_users
|
|
||||||
|
|
||||||
# Skip backups (dev only - faster)
|
|
||||||
make migrate-no-backup
|
|
||||||
|
|
||||||
# Reset database (destructive, requires confirmation)
|
|
||||||
make reset-db
|
|
||||||
```
|
|
||||||
|
|
||||||
### Direct Binary Flags
|
|
||||||
```bash
|
|
||||||
./bin/oslstats --migrate-up # Run migrations
|
|
||||||
./bin/oslstats --migrate-rollback # Rollback
|
|
||||||
./bin/oslstats --migrate-status # Show status
|
|
||||||
./bin/oslstats --migrate-dry-run # Preview
|
|
||||||
./bin/oslstats --migrate-create foo # Create migration
|
|
||||||
./bin/oslstats --no-backup # Skip backups
|
|
||||||
./bin/oslstats --reset-db # Reset database
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔧 Configuration
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
Add to your `.env` file:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Database Backup Configuration
|
|
||||||
DB_BACKUP_DIR=backups # Directory for backups (default: backups)
|
|
||||||
DB_BACKUP_RETENTION=10 # Number of backups to keep (default: 10)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Requirements
|
|
||||||
|
|
||||||
**Required:**
|
|
||||||
- PostgreSQL database
|
|
||||||
- Go 1.25.5+
|
|
||||||
|
|
||||||
**Optional (for backups):**
|
|
||||||
- `pg_dump` (PostgreSQL client tools)
|
|
||||||
- `gzip` (compression utility)
|
|
||||||
|
|
||||||
If `pg_dump` or `gzip` are not available, the system will:
|
|
||||||
- Warn the user
|
|
||||||
- Skip backups
|
|
||||||
- Continue with migrations normally
|
|
||||||
|
|
||||||
## 📋 Migration Workflow Example
|
|
||||||
|
|
||||||
### Adding a New Table
|
|
||||||
|
|
||||||
1. **Create the model** (`internal/db/game.go`):
|
|
||||||
```go
|
|
||||||
package db
|
|
||||||
|
|
||||||
import "github.com/uptrace/bun"
|
|
||||||
|
|
||||||
type Game struct {
|
|
||||||
bun.BaseModel `bun:"table:games,alias:g"`
|
|
||||||
|
|
||||||
ID int `bun:"id,pk,autoincrement"`
|
|
||||||
Name string `bun:"name"`
|
|
||||||
CreatedAt int64 `bun:"created_at"`
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Generate migration:**
|
|
||||||
```bash
|
|
||||||
make migrate-create NAME=add_games_table
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Edit migration** (`cmd/oslstats/migrations/YYYYMMDDHHmmss_add_games_table.go`):
|
|
||||||
```go
|
|
||||||
package migrations
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"git.haelnorr.com/h/oslstats/internal/db"
|
|
||||||
"github.com/uptrace/bun"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
Migrations.MustRegister(
|
|
||||||
// UP: Create games table
|
|
||||||
func(ctx context.Context, dbConn *bun.DB) error {
|
|
||||||
_, err := dbConn.NewCreateTable().
|
|
||||||
Model((*db.Game)(nil)).
|
|
||||||
Exec(ctx)
|
|
||||||
return err
|
|
||||||
},
|
|
||||||
// DOWN: Drop games table
|
|
||||||
func(ctx context.Context, dbConn *bun.DB) error {
|
|
||||||
_, err := dbConn.NewDropTable().
|
|
||||||
Model((*db.Game)(nil)).
|
|
||||||
IfExists().
|
|
||||||
Exec(ctx)
|
|
||||||
return err
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Preview the migration:**
|
|
||||||
```bash
|
|
||||||
make migrate-dry-run
|
|
||||||
```
|
|
||||||
Output:
|
|
||||||
```
|
|
||||||
[INFO] Pending migrations (dry-run):
|
|
||||||
📋 YYYYMMDDHHmmss_add_games_table
|
|
||||||
[INFO] Would migrate to group 2
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Apply the migration:**
|
|
||||||
```bash
|
|
||||||
make migrate
|
|
||||||
```
|
|
||||||
Output:
|
|
||||||
```
|
|
||||||
[INFO] Step 1/5: Validating migrations...
|
|
||||||
[INFO] Migration validation passed ✓
|
|
||||||
[INFO] Step 2/5: Checking for pending migrations...
|
|
||||||
[INFO] Step 3/5: Creating backup...
|
|
||||||
[INFO] Backup created: backups/20250124_150530_pre_migration.sql.gz (2.5 MB)
|
|
||||||
[INFO] Step 4/5: Acquiring migration lock...
|
|
||||||
[INFO] Migration lock acquired
|
|
||||||
[INFO] Step 5/5: Applying migrations...
|
|
||||||
[INFO] Migrated to group 2
|
|
||||||
✅ YYYYMMDDHHmmss_add_games_table
|
|
||||||
[INFO] Migration lock released
|
|
||||||
```
|
|
||||||
|
|
||||||
6. **Verify:**
|
|
||||||
```bash
|
|
||||||
make migrate-status
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🛡️ Safety Features
|
|
||||||
|
|
||||||
### 1. Automatic Backups
|
|
||||||
- Created before every migration and rollback
|
|
||||||
- Compressed with gzip (saves ~80% disk space)
|
|
||||||
- Includes `--clean` and `--if-exists` for safe restoration
|
|
||||||
- Retention policy automatically cleans old backups
|
|
||||||
|
|
||||||
### 2. Migration Locking
|
|
||||||
- Uses PostgreSQL advisory locks (lock ID: 1234567890)
|
|
||||||
- Prevents concurrent migrations from different processes
|
|
||||||
- 5-minute timeout prevents hung locks
|
|
||||||
- Automatically released on completion or error
|
|
||||||
|
|
||||||
### 3. Pre-Migration Validation
|
|
||||||
- Runs `go build ./cmd/oslstats/migrations` before applying
|
|
||||||
- Ensures all migrations compile
|
|
||||||
- Catches syntax errors before they affect the database
|
|
||||||
|
|
||||||
### 4. Dry-Run Mode
|
|
||||||
- Preview exactly which migrations would run
|
|
||||||
- See group number before applying
|
|
||||||
- No database changes
|
|
||||||
|
|
||||||
### 5. Status Tracking
|
|
||||||
- Shows which migrations are applied/pending
|
|
||||||
- Displays migration group and timestamp
|
|
||||||
- Summary counts
|
|
||||||
|
|
||||||
### 6. Interactive Confirmation
|
|
||||||
- `--reset-db` requires typing 'yes'
|
|
||||||
- Prevents accidental data loss
|
|
||||||
|
|
||||||
## 📊 Testing Checklist
|
|
||||||
|
|
||||||
- [x] Migration file creation works
|
|
||||||
- [x] Build compiles without errors
|
|
||||||
- [x] Flags registered correctly
|
|
||||||
- [x] Help text displays all flags
|
|
||||||
- [x] Makefile targets work
|
|
||||||
- [x] Documentation updated
|
|
||||||
- [x] .gitignore configured
|
|
||||||
|
|
||||||
### Manual Testing Required
|
|
||||||
|
|
||||||
Connect to your database and test:
|
|
||||||
|
|
||||||
1. **Fresh database migration:**
|
|
||||||
```bash
|
|
||||||
make migrate-status # Should show initial_schema as pending
|
|
||||||
make migrate-dry-run # Preview
|
|
||||||
make migrate # Apply
|
|
||||||
make migrate-status # Should show initial_schema as applied
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Rollback:**
|
|
||||||
```bash
|
|
||||||
make migrate-rollback
|
|
||||||
make migrate-status # Should show as pending again
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Create and apply new migration:**
|
|
||||||
```bash
|
|
||||||
make migrate-create NAME=test_feature
|
|
||||||
# Edit the file
|
|
||||||
make migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Backup verification:**
|
|
||||||
```bash
|
|
||||||
ls -lh backups/
|
|
||||||
# Should see .sql.gz files
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Concurrent migration prevention:**
|
|
||||||
```bash
|
|
||||||
# Terminal 1:
|
|
||||||
make migrate &
|
|
||||||
# Terminal 2 (immediately):
|
|
||||||
make migrate
|
|
||||||
# Should fail with "migration already in progress"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🎓 Developer Notes
|
|
||||||
|
|
||||||
### Migration Naming Convention
|
|
||||||
```
|
|
||||||
YYYYMMDDHHmmss_description.go
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
20250124120000_initial_schema.go
|
|
||||||
20250125103045_add_email_to_users.go
|
|
||||||
20250126144530_create_games_table.go
|
|
||||||
```
|
|
||||||
|
|
||||||
### Migration Best Practices
|
|
||||||
|
|
||||||
1. **One migration = one logical change**
|
|
||||||
- Don't combine unrelated changes
|
|
||||||
- Keep migrations focused and simple
|
|
||||||
|
|
||||||
2. **Always write DOWN functions**
|
|
||||||
- Every UP must have a corresponding DOWN
|
|
||||||
- Test rollbacks in development
|
|
||||||
|
|
||||||
3. **Test before production**
|
|
||||||
- Use `make migrate-dry-run` to preview
|
|
||||||
- Test rollback works: `make migrate-rollback`
|
|
||||||
- Verify with `make migrate-status`
|
|
||||||
|
|
||||||
4. **Commit migrations to git**
|
|
||||||
- Migration files are source code
|
|
||||||
- Include in pull requests
|
|
||||||
- Review carefully
|
|
||||||
|
|
||||||
5. **Production deployment**
|
|
||||||
- Always have a rollback plan
|
|
||||||
- Test in staging first
|
|
||||||
- Communicate potential downtime
|
|
||||||
- Keep backups accessible
|
|
||||||
|
|
||||||
## 🚀 Next Steps
|
|
||||||
|
|
||||||
The migration system is now fully operational! You can:
|
|
||||||
|
|
||||||
1. **Connect to your database** and run `make migrate` to apply the initial schema
|
|
||||||
2. **Create new migrations** as you add features
|
|
||||||
3. **Use dry-run mode** to preview changes safely
|
|
||||||
4. **Check migration status** anytime with `make migrate-status`
|
|
||||||
|
|
||||||
## 📚 Additional Resources
|
|
||||||
|
|
||||||
- **Bun Migration Docs**: https://bun.uptrace.dev/guide/migrations.html
|
|
||||||
- **PostgreSQL Advisory Locks**: https://www.postgresql.org/docs/current/explicit-locking.html#ADVISORY-LOCKS
|
|
||||||
- **Migration Patterns**: See AGENTS.md "Database Migrations" section
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Implementation Date**: January 24, 2026
|
|
||||||
**Status**: ✅ Complete and Ready for Use
|
|
||||||
@@ -38,6 +38,7 @@ func setupHttpServer(
|
|||||||
"/static/js/toasts.js",
|
"/static/js/toasts.js",
|
||||||
"/static/js/copytoclipboard.js",
|
"/static/js/copytoclipboard.js",
|
||||||
"/static/js/theme.js",
|
"/static/js/theme.js",
|
||||||
|
"/static/js/pagination.js",
|
||||||
"/static/vendored/htmx@2.0.8.min.js",
|
"/static/vendored/htmx@2.0.8.min.js",
|
||||||
"/static/vendored/htmx-ext-ws.min.js",
|
"/static/vendored/htmx-ext-ws.min.js",
|
||||||
"/static/vendored/alpinejs@3.15.4.min.js",
|
"/static/vendored/alpinejs@3.15.4.min.js",
|
||||||
|
|||||||
@@ -51,13 +51,14 @@ func migrateUp(ctx context.Context, migrator *migrate.Migrator, conn *bun.DB, cf
|
|||||||
fmt.Println("[INFO] Migration validation passed ✓")
|
fmt.Println("[INFO] Migration validation passed ✓")
|
||||||
|
|
||||||
fmt.Println("[INFO] Step 2/5: Checking for pending migrations...")
|
fmt.Println("[INFO] Step 2/5: Checking for pending migrations...")
|
||||||
// Check for pending migrations
|
// Check for pending migrations using MigrationsWithStatus (read-only)
|
||||||
group, err := migrator.Migrate(ctx, migrate.WithNopMigration())
|
ms, err := migrator.MigrationsWithStatus(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "check pending migrations")
|
return errors.Wrap(err, "get migration status")
|
||||||
}
|
}
|
||||||
|
|
||||||
if group.IsZero() {
|
unapplied := ms.Unapplied()
|
||||||
|
if len(unapplied) == 0 {
|
||||||
fmt.Println("[INFO] No pending migrations")
|
fmt.Println("[INFO] No pending migrations")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -88,7 +89,7 @@ func migrateUp(ctx context.Context, migrator *migrate.Migrator, conn *bun.DB, cf
|
|||||||
|
|
||||||
// Run migrations
|
// Run migrations
|
||||||
fmt.Println("[INFO] Step 5/5: Applying migrations...")
|
fmt.Println("[INFO] Step 5/5: Applying migrations...")
|
||||||
group, err = migrator.Migrate(ctx)
|
group, err := migrator.Migrate(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "migrate")
|
return errors.Wrap(err, "migrate")
|
||||||
}
|
}
|
||||||
@@ -290,13 +291,13 @@ import (
|
|||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
Migrations.MustRegister(
|
Migrations.MustRegister(
|
||||||
// UP migration - TODO: Implement
|
// UP migration
|
||||||
func(ctx context.Context, db *bun.DB) error {
|
func(ctx context.Context, dbConn *bun.DB) error {
|
||||||
// TODO: Add your migration code here
|
// TODO: Add your migration code here
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
// DOWN migration - TODO: Implement
|
// DOWN migration
|
||||||
func(ctx context.Context, db *bun.DB) error {
|
func(ctx context.Context, dbConn *bun.DB) error {
|
||||||
// TODO: Add your rollback code here
|
// TODO: Add your rollback code here
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -62,6 +62,16 @@ func addRoutes(
|
|||||||
Handler: handlers.NotifyTester(s),
|
Handler: handlers.NotifyTester(s),
|
||||||
// TODO: add login protection
|
// TODO: add login protection
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Path: "/seasons",
|
||||||
|
Method: hws.MethodGET,
|
||||||
|
Handler: handlers.SeasonsPage(s, conn),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "/seasons",
|
||||||
|
Method: hws.MethodPOST,
|
||||||
|
Handler: handlers.SeasonsList(s, conn),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
htmxRoutes := []hws.Route{
|
htmxRoutes := []hws.Route{
|
||||||
|
|||||||
2
go.sum
2
go.sum
@@ -8,8 +8,6 @@ git.haelnorr.com/h/golib/hlog v0.10.4 h1:vpCsV/OddjIYx8F48U66WxojjmhEbeLGQAOBG4V
|
|||||||
git.haelnorr.com/h/golib/hlog v0.10.4/go.mod h1:+wJ8vecQY/JITTXKmI3JfkHiUGyMs7N6wooj2wuWZbc=
|
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 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.3/go.mod h1:UqB83p9lGjidDkk0pWRqxxOFrCkg8t+9J6uGtBOjNLo=
|
||||||
git.haelnorr.com/h/golib/hwsauth v0.5.2 h1:K4McXMEHtI5o4fAL3AZrmaMkwORNqSTV3MM6BExNKag=
|
|
||||||
git.haelnorr.com/h/golib/hwsauth v0.5.2/go.mod h1:NOonrVU/lX8lzuV77eDEiTwBjn7RrzYVcSdXUJWeHmQ=
|
|
||||||
git.haelnorr.com/h/golib/hwsauth v0.5.3 h1:Vgw8khDQZJRCc3m7z9QlbL9CYPyFB9JXUC3+omKRZPc=
|
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.3/go.mod h1:NOonrVU/lX8lzuV77eDEiTwBjn7RrzYVcSdXUJWeHmQ=
|
||||||
git.haelnorr.com/h/golib/jwt v0.10.1 h1:1Adxt9H3Y4fWFvFjWpvg/vSFhbgCMDMxgiE3m7KvDMI=
|
git.haelnorr.com/h/golib/jwt v0.10.1 h1:1Adxt9H3Y4fWFvFjWpvg/vSFhbgCMDMxgiE3m7KvDMI=
|
||||||
|
|||||||
96
internal/db/paginate.go
Normal file
96
internal/db/paginate.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import "github.com/uptrace/bun"
|
||||||
|
|
||||||
|
type PageOpts struct {
|
||||||
|
Page int
|
||||||
|
PerPage int
|
||||||
|
Order bun.Order
|
||||||
|
OrderBy string
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrderOpts struct {
|
||||||
|
Order bun.Order
|
||||||
|
OrderBy string
|
||||||
|
Label string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TotalPages calculates the total number of pages
|
||||||
|
func (p *PageOpts) TotalPages(total int) int {
|
||||||
|
if p.PerPage == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
pages := total / p.PerPage
|
||||||
|
if total%p.PerPage > 0 {
|
||||||
|
pages++
|
||||||
|
}
|
||||||
|
return pages
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasPrevPage checks if there is a previous page
|
||||||
|
func (p *PageOpts) HasPrevPage() bool {
|
||||||
|
return p.Page > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasNextPage checks if there is a next page
|
||||||
|
func (p *PageOpts) HasNextPage(total int) bool {
|
||||||
|
return p.Page < p.TotalPages(total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPageRange returns an array of page numbers to display
|
||||||
|
// maxButtons controls how many page buttons to show
|
||||||
|
func (p *PageOpts) GetPageRange(total int, maxButtons int) []int {
|
||||||
|
totalPages := p.TotalPages(total)
|
||||||
|
if totalPages == 0 {
|
||||||
|
return []int{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If total pages is less than max buttons, show all pages
|
||||||
|
if totalPages <= maxButtons {
|
||||||
|
pages := make([]int, totalPages)
|
||||||
|
for i := 0; i < totalPages; i++ {
|
||||||
|
pages[i] = i + 1
|
||||||
|
}
|
||||||
|
return pages
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate range around current page
|
||||||
|
halfButtons := maxButtons / 2
|
||||||
|
start := p.Page - halfButtons
|
||||||
|
end := p.Page + halfButtons
|
||||||
|
|
||||||
|
// Adjust if at beginning
|
||||||
|
if start < 1 {
|
||||||
|
start = 1
|
||||||
|
end = maxButtons
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust if at end
|
||||||
|
if end > totalPages {
|
||||||
|
end = totalPages
|
||||||
|
start = totalPages - maxButtons + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
pages := make([]int, 0, maxButtons)
|
||||||
|
for i := start; i <= end; i++ {
|
||||||
|
pages = append(pages, i)
|
||||||
|
}
|
||||||
|
return pages
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartItem returns the number of the first item on the current page
|
||||||
|
func (p *PageOpts) StartItem() int {
|
||||||
|
if p.Page < 1 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return (p.Page-1)*p.PerPage + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// EndItem returns the number of the last item on the current page
|
||||||
|
func (p *PageOpts) EndItem(total int) int {
|
||||||
|
end := p.Page * p.PerPage
|
||||||
|
if end > total {
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
return end
|
||||||
|
}
|
||||||
83
internal/db/season.go
Normal file
83
internal/db/season.go
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SeasonList struct {
|
||||||
|
Seasons []Season
|
||||||
|
Total int
|
||||||
|
PageOpts PageOpts
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSeason(ctx context.Context, tx bun.Tx, name, shortname string) (*Season, error) {
|
||||||
|
if name == "" {
|
||||||
|
return nil, errors.New("name cannot be empty")
|
||||||
|
}
|
||||||
|
if shortname == "" {
|
||||||
|
return nil, errors.New("shortname cannot be empty")
|
||||||
|
}
|
||||||
|
season := &Season{
|
||||||
|
Name: name,
|
||||||
|
ShortName: shortname,
|
||||||
|
}
|
||||||
|
_, err := tx.NewInsert().
|
||||||
|
Model(season).
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "tx.NewInsert")
|
||||||
|
}
|
||||||
|
return season, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListSeasons(ctx context.Context, tx bun.Tx, pageOpts *PageOpts) (*SeasonList, error) {
|
||||||
|
if pageOpts == nil {
|
||||||
|
pageOpts = &PageOpts{}
|
||||||
|
}
|
||||||
|
if pageOpts.Page == 0 {
|
||||||
|
pageOpts.Page = 1
|
||||||
|
}
|
||||||
|
if pageOpts.PerPage == 0 {
|
||||||
|
pageOpts.PerPage = 10
|
||||||
|
}
|
||||||
|
if pageOpts.Order == "" {
|
||||||
|
pageOpts.Order = bun.OrderDesc
|
||||||
|
}
|
||||||
|
if pageOpts.OrderBy == "" {
|
||||||
|
pageOpts.OrderBy = "name"
|
||||||
|
}
|
||||||
|
seasons := []Season{}
|
||||||
|
err := tx.NewSelect().
|
||||||
|
Model(&seasons).
|
||||||
|
OrderBy(pageOpts.OrderBy, pageOpts.Order).
|
||||||
|
Offset(pageOpts.PerPage * (pageOpts.Page - 1)).
|
||||||
|
Limit(pageOpts.PerPage).
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil && err != sql.ErrNoRows {
|
||||||
|
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||||
|
}
|
||||||
|
total, err := tx.NewSelect().
|
||||||
|
Model(&seasons).
|
||||||
|
Count(ctx)
|
||||||
|
if err != nil && err != sql.ErrNoRows {
|
||||||
|
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||||
|
}
|
||||||
|
sl := &SeasonList{
|
||||||
|
Seasons: seasons,
|
||||||
|
Total: total,
|
||||||
|
PageOpts: *pageOpts,
|
||||||
|
}
|
||||||
|
return sl, nil
|
||||||
|
}
|
||||||
@@ -34,7 +34,7 @@ func (user *User) ChangeUsername(ctx context.Context, tx bun.Tx, newUsername str
|
|||||||
Where("id = ?", user.ID).
|
Where("id = ?", user.ID).
|
||||||
Exec(ctx)
|
Exec(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "tx.Update")
|
return errors.Wrap(err, "tx.NewUpdate")
|
||||||
}
|
}
|
||||||
user.Username = newUsername
|
user.Username = newUsername
|
||||||
return nil
|
return nil
|
||||||
@@ -55,7 +55,7 @@ func CreateUser(ctx context.Context, tx bun.Tx, username string, discorduser *di
|
|||||||
Model(user).
|
Model(user).
|
||||||
Exec(ctx)
|
Exec(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "tx.Insert")
|
return nil, errors.Wrap(err, "tx.NewInsert")
|
||||||
}
|
}
|
||||||
|
|
||||||
return user, nil
|
return user, nil
|
||||||
@@ -75,7 +75,7 @@ func GetUserByID(ctx context.Context, tx bun.Tx, id int) (*User, error) {
|
|||||||
if err.Error() == "sql: no rows in result set" {
|
if err.Error() == "sql: no rows in result set" {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return nil, errors.Wrap(err, "tx.Select")
|
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||||
}
|
}
|
||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
@@ -111,7 +111,7 @@ func GetUserByDiscordID(ctx context.Context, tx bun.Tx, discordID string) (*User
|
|||||||
if err.Error() == "sql: no rows in result set" {
|
if err.Error() == "sql: no rows in result set" {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return nil, errors.Wrap(err, "tx.Select")
|
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||||
}
|
}
|
||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
@@ -124,7 +124,7 @@ func IsUsernameUnique(ctx context.Context, tx bun.Tx, username string) (bool, er
|
|||||||
Where("username = ?", username).
|
Where("username = ?", username).
|
||||||
Count(ctx)
|
Count(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, errors.Wrap(err, "tx.Count")
|
return false, errors.Wrap(err, "tx.NewSelect")
|
||||||
}
|
}
|
||||||
return count == 0, nil
|
return count == 0, nil
|
||||||
}
|
}
|
||||||
|
|||||||
128
internal/handlers/seasons.go
Normal file
128
internal/handlers/seasons.go
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"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 SeasonsPage(
|
||||||
|
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()
|
||||||
|
var pageNum, perPage int
|
||||||
|
var order bun.Order
|
||||||
|
var orderBy string
|
||||||
|
if pageStr := r.URL.Query().Get("page"); pageStr != "" {
|
||||||
|
pageNum, err = strconv.Atoi(pageStr)
|
||||||
|
if err != nil {
|
||||||
|
throwBadRequest(s, w, r, "Invalid page number", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if perPageStr := r.URL.Query().Get("per_page"); perPageStr != "" {
|
||||||
|
perPage, err = strconv.Atoi(perPageStr)
|
||||||
|
if err != nil {
|
||||||
|
throwBadRequest(s, w, r, "Invalid per_page number", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
order = bun.Order(r.URL.Query().Get("order"))
|
||||||
|
orderBy = r.URL.Query().Get("order_by")
|
||||||
|
pageOpts := &db.PageOpts{
|
||||||
|
Page: pageNum,
|
||||||
|
PerPage: perPage,
|
||||||
|
Order: order,
|
||||||
|
OrderBy: orderBy,
|
||||||
|
}
|
||||||
|
seasons, err := db.ListSeasons(ctx, tx, pageOpts)
|
||||||
|
if err != nil {
|
||||||
|
throwInternalServiceError(s, w, r, "Database error", errors.Wrap(err, "db.ListSeasons"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tx.Commit()
|
||||||
|
page.SeasonsPage(seasons).Render(r.Context(), w)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func SeasonsList(
|
||||||
|
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()
|
||||||
|
|
||||||
|
// Parse form values
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
throwBadRequest(s, w, r, "Invalid form data", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract pagination/sort params from form
|
||||||
|
var pageNum, perPage int
|
||||||
|
var order bun.Order
|
||||||
|
var orderBy string
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if pageStr := r.FormValue("page"); pageStr != "" {
|
||||||
|
pageNum, err = strconv.Atoi(pageStr)
|
||||||
|
if err != nil {
|
||||||
|
throwBadRequest(s, w, r, "Invalid page number", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if perPageStr := r.FormValue("per_page"); perPageStr != "" {
|
||||||
|
perPage, err = strconv.Atoi(perPageStr)
|
||||||
|
if err != nil {
|
||||||
|
throwBadRequest(s, w, r, "Invalid per_page number", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
order = bun.Order(r.FormValue("order"))
|
||||||
|
orderBy = r.FormValue("order_by")
|
||||||
|
|
||||||
|
pageOpts := &db.PageOpts{
|
||||||
|
Page: pageNum,
|
||||||
|
PerPage: perPage,
|
||||||
|
Order: order,
|
||||||
|
OrderBy: orderBy,
|
||||||
|
}
|
||||||
|
fmt.Println(pageOpts)
|
||||||
|
|
||||||
|
// Database query
|
||||||
|
tx, err := conn.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
throwInternalServiceError(s, w, r, "Database error", errors.Wrap(err, "conn.BeginTx"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
seasons, err := db.ListSeasons(ctx, tx, pageOpts)
|
||||||
|
if err != nil {
|
||||||
|
throwInternalServiceError(s, w, r, "Database error", errors.Wrap(err, "db.ListSeasons"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tx.Commit()
|
||||||
|
|
||||||
|
// Return only the list component (hx-push-url handles URL update client-side)
|
||||||
|
page.SeasonsList(seasons).Render(r.Context(), w)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -7,7 +7,12 @@ type NavItem struct {
|
|||||||
|
|
||||||
// Return the list of navbar links
|
// Return the list of navbar links
|
||||||
func getNavItems() []NavItem {
|
func getNavItems() []NavItem {
|
||||||
return []NavItem{}
|
return []NavItem{
|
||||||
|
{
|
||||||
|
name: "Seasons",
|
||||||
|
href: "/seasons",
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns the navbar template fragment
|
// Returns the navbar template fragment
|
||||||
|
|||||||
101
internal/view/component/pagination/pagination.templ
Normal file
101
internal/view/component/pagination/pagination.templ
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package pagination
|
||||||
|
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
templ Pagination(opts db.PageOpts, total int) {
|
||||||
|
<div class="mt-6 flex flex-col gap-4">
|
||||||
|
<input type="hidden" name="page" id="pagination-page"/>
|
||||||
|
<input type="hidden" name="per_page" id="pagination-per-page"/>
|
||||||
|
<!-- Page info and per-page selector -->
|
||||||
|
<div class="flex flex-col sm:flex-row justify-between items-center gap-4 text-sm text-subtext0">
|
||||||
|
<div>
|
||||||
|
if total > 0 {
|
||||||
|
Showing { fmt.Sprintf("%d", opts.StartItem()) } - { fmt.Sprintf("%d", opts.EndItem(total)) } of { fmt.Sprintf("%d", total) } results
|
||||||
|
} else {
|
||||||
|
No results
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label for="per-page-select">Per page:</label>
|
||||||
|
<select
|
||||||
|
id="per-page-select"
|
||||||
|
class="py-1 px-2 rounded-lg bg-surface0 border border-surface1 text-text focus:border-blue outline-none"
|
||||||
|
x-model.number="perPage"
|
||||||
|
@change="setPerPage(perPage)"
|
||||||
|
>
|
||||||
|
<option value="1">1</option>
|
||||||
|
<option value="10">10</option>
|
||||||
|
<option value="25">25</option>
|
||||||
|
<option value="50">50</option>
|
||||||
|
<option value="100">100</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Pagination buttons -->
|
||||||
|
if total > 0 && opts.TotalPages(total) > 1 {
|
||||||
|
<div class="flex flex-wrap justify-center items-center gap-2">
|
||||||
|
<!-- First button -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="goToPage(1)"
|
||||||
|
class="px-3 py-2 rounded-lg border transition border-surface1 text-text bg-mantle"
|
||||||
|
x-bind:disabled={ fmt.Sprintf("%t", !opts.HasPrevPage()) }
|
||||||
|
x-bind:class={ fmt.Sprintf("%t", !opts.HasPrevPage()) +
|
||||||
|
" ? 'text-subtext0 cursor-not-allowed opacity-50' : 'hover:bg-surface0 hover:border-blue cursor-pointer'" }
|
||||||
|
>
|
||||||
|
<span class="hidden sm:inline">First</span>
|
||||||
|
<span class="sm:hidden"><<</span>
|
||||||
|
</button>
|
||||||
|
<!-- Previous button -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click={ fmt.Sprintf("goToPage(%d)", opts.Page-1) }
|
||||||
|
class="px-3 py-2 rounded-lg border transition border-surface1 text-text bg-mantle"
|
||||||
|
x-bind:disabled={ fmt.Sprintf("%t", !opts.HasPrevPage()) }
|
||||||
|
x-bind:class={ fmt.Sprintf("%t", !opts.HasPrevPage()) +
|
||||||
|
" ? 'text-subtext0 cursor-not-allowed opacity-50' : 'hover:bg-surface0 hover:border-blue cursor-pointer'" }
|
||||||
|
>
|
||||||
|
<span class="hidden sm:inline">Previous</span>
|
||||||
|
<span class="sm:hidden"><</span>
|
||||||
|
</button>
|
||||||
|
<!-- Page numbers -->
|
||||||
|
for _, pageNum := range opts.GetPageRange(total, 7) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click={ fmt.Sprintf("goToPage(%d)", pageNum) }
|
||||||
|
class={ "px-3 py-2 rounded-lg border transition",
|
||||||
|
templ.KV("bg-blue border-blue text-mantle font-bold", pageNum == opts.Page),
|
||||||
|
templ.KV("bg-mantle border-surface1 text-text hover:bg-surface0 hover:border-blue cursor-pointer", pageNum != opts.Page) }
|
||||||
|
>
|
||||||
|
{ fmt.Sprintf("%d", pageNum) }
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<!-- Next button -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click={ fmt.Sprintf("goToPage(%d)", opts.Page+1) }
|
||||||
|
class="px-3 py-2 rounded-lg border transition border-surface1 text-text bg-mantle"
|
||||||
|
x-bind:disabled={ fmt.Sprintf("%t", !opts.HasNextPage(total)) }
|
||||||
|
x-bind:class={ fmt.Sprintf("%t", !opts.HasNextPage(total)) +
|
||||||
|
" ? 'text-subtext0 cursor-not-allowed opacity-50' : 'hover:bg-surface0 hover:border-blue cursor-pointer'" }
|
||||||
|
>
|
||||||
|
<span class="hidden sm:inline">Next</span>
|
||||||
|
<span class="sm:hidden">></span>
|
||||||
|
</button>
|
||||||
|
<!-- Last button -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click={ fmt.Sprintf("goToPage(%d)", opts.TotalPages(total)) }
|
||||||
|
class="px-3 py-2 rounded-lg border transition border-surface1 text-text bg-mantle"
|
||||||
|
x-bind:disabled={ fmt.Sprintf("%t", !opts.HasNextPage(total)) }
|
||||||
|
x-bind:class={ fmt.Sprintf("%t", !opts.HasNextPage(total)) +
|
||||||
|
" ? 'text-subtext0 cursor-not-allowed opacity-50' : 'hover:bg-surface0 hover:border-blue cursor-pointer'" }
|
||||||
|
>
|
||||||
|
<span class="hidden sm:inline">Last</span>
|
||||||
|
<span class="sm:hidden">>></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
26
internal/view/component/sort/dropdown.templ
Normal file
26
internal/view/component/sort/dropdown.templ
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package sort
|
||||||
|
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
templ Dropdown(pageopts db.PageOpts, orderopts []db.OrderOpts) {
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input type="hidden" name="order" id="sort-order"/>
|
||||||
|
<input type="hidden" name="order_by" id="sort-order-by"/>
|
||||||
|
<label for="sort-select" class="text-sm text-subtext0">Sort by:</label>
|
||||||
|
<select
|
||||||
|
id="sort-select"
|
||||||
|
class="py-2 px-3 rounded-lg bg-surface0 border border-surface1 text-text focus:border-blue outline-none"
|
||||||
|
@change="handleSortChange($event.target.value)"
|
||||||
|
>
|
||||||
|
for _, opt := range orderopts {
|
||||||
|
<option
|
||||||
|
value={ strings.Join([]string{opt.OrderBy, string(opt.Order)}, "|") }
|
||||||
|
selected?={ pageopts.OrderBy == opt.OrderBy && pageopts.Order == opt.Order }
|
||||||
|
>
|
||||||
|
{ opt.Label }
|
||||||
|
</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import "git.haelnorr.com/h/oslstats/internal/view/layout"
|
|||||||
// Page content for the index page
|
// Page content for the index page
|
||||||
templ Index() {
|
templ Index() {
|
||||||
@layout.Global("OSL Stats") {
|
@layout.Global("OSL Stats") {
|
||||||
<div class="text-center mt-24">
|
<div class="text-center mt-25">
|
||||||
<div class="text-4xl lg:text-6xl">OSL Stats</div>
|
<div class="text-4xl lg:text-6xl">OSL Stats</div>
|
||||||
<div>Placeholder text</div>
|
<div>Placeholder text</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
85
internal/view/page/seasons_list.templ
Normal file
85
internal/view/page/seasons_list.templ
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
package page
|
||||||
|
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/view/layout"
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/view/component/pagination"
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/view/component/sort"
|
||||||
|
import "fmt"
|
||||||
|
import "github.com/uptrace/bun"
|
||||||
|
|
||||||
|
templ SeasonsPage(seasons *db.SeasonList) {
|
||||||
|
@layout.Global("Seasons") {
|
||||||
|
<div class="max-w-screen-2xl mx-auto px-2">
|
||||||
|
@SeasonsList(seasons)
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ SeasonsList(seasons *db.SeasonList) {
|
||||||
|
{{
|
||||||
|
sortOpts := []db.OrderOpts{
|
||||||
|
{
|
||||||
|
Order: bun.OrderAsc,
|
||||||
|
OrderBy: "name",
|
||||||
|
Label: "Name (A-Z)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Order: bun.OrderDesc,
|
||||||
|
OrderBy: "name",
|
||||||
|
Label: "Name (Z-A)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Order: bun.OrderAsc,
|
||||||
|
OrderBy: "short_name",
|
||||||
|
Label: "Short Name (A-Z)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Order: bun.OrderDesc,
|
||||||
|
OrderBy: "short_name",
|
||||||
|
Label: "Short Name (Z-A)",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
<div id="seasons-list-container">
|
||||||
|
<form
|
||||||
|
id="seasons-form"
|
||||||
|
hx-target="#seasons-list-container"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-push-url="true"
|
||||||
|
x-data={ templ.JSFuncCall("paginateData",
|
||||||
|
"seasons-form",
|
||||||
|
"/seasons",
|
||||||
|
seasons.PageOpts.Page,
|
||||||
|
seasons.PageOpts.PerPage,
|
||||||
|
seasons.PageOpts.Order,
|
||||||
|
seasons.PageOpts.OrderBy).CallInline }
|
||||||
|
>
|
||||||
|
<!-- Header with title and sort controls -->
|
||||||
|
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
|
||||||
|
<h1 class="text-3xl font-bold">Seasons</h1>
|
||||||
|
@sort.Dropdown(seasons.PageOpts, sortOpts)
|
||||||
|
</div>
|
||||||
|
<!-- Results section -->
|
||||||
|
if len(seasons.Seasons) == 0 {
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg p-8 text-center">
|
||||||
|
<p class="text-subtext0 text-lg">No seasons found</p>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<!-- Card grid -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
for _, season := range seasons.Seasons {
|
||||||
|
<a
|
||||||
|
class="bg-mantle border border-surface1 rounded-lg p-6 hover:bg-surface0 transition-colors"
|
||||||
|
href={ fmt.Sprintf("/seasons/%s", season.ShortName) }
|
||||||
|
>
|
||||||
|
<h3 class="text-xl font-bold text-text mb-2">{ season.Name }</h3>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<!-- Pagination controls -->
|
||||||
|
@pagination.Pagination(seasons.PageOpts, seasons.Total)
|
||||||
|
}
|
||||||
|
</form>
|
||||||
|
<script src="/static/js/pagination.js"></script>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
monospace;
|
monospace;
|
||||||
--spacing: 0.25rem;
|
--spacing: 0.25rem;
|
||||||
--breakpoint-xl: 80rem;
|
--breakpoint-xl: 80rem;
|
||||||
|
--breakpoint-2xl: 96rem;
|
||||||
--container-sm: 24rem;
|
--container-sm: 24rem;
|
||||||
--container-md: 28rem;
|
--container-md: 28rem;
|
||||||
--container-2xl: 42rem;
|
--container-2xl: 42rem;
|
||||||
@@ -303,9 +304,15 @@
|
|||||||
.mt-24 {
|
.mt-24 {
|
||||||
margin-top: calc(var(--spacing) * 24);
|
margin-top: calc(var(--spacing) * 24);
|
||||||
}
|
}
|
||||||
|
.mt-25 {
|
||||||
|
margin-top: calc(var(--spacing) * 25);
|
||||||
|
}
|
||||||
.mb-2 {
|
.mb-2 {
|
||||||
margin-bottom: calc(var(--spacing) * 2);
|
margin-bottom: calc(var(--spacing) * 2);
|
||||||
}
|
}
|
||||||
|
.mb-6 {
|
||||||
|
margin-bottom: calc(var(--spacing) * 6);
|
||||||
|
}
|
||||||
.mb-8 {
|
.mb-8 {
|
||||||
margin-bottom: calc(var(--spacing) * 8);
|
margin-bottom: calc(var(--spacing) * 8);
|
||||||
}
|
}
|
||||||
@@ -336,9 +343,6 @@
|
|||||||
.inline-flex {
|
.inline-flex {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
.table {
|
|
||||||
display: table;
|
|
||||||
}
|
|
||||||
.size-5 {
|
.size-5 {
|
||||||
width: calc(var(--spacing) * 5);
|
width: calc(var(--spacing) * 5);
|
||||||
height: calc(var(--spacing) * 5);
|
height: calc(var(--spacing) * 5);
|
||||||
@@ -394,6 +398,9 @@
|
|||||||
.max-w-md {
|
.max-w-md {
|
||||||
max-width: var(--container-md);
|
max-width: var(--container-md);
|
||||||
}
|
}
|
||||||
|
.max-w-screen-2xl {
|
||||||
|
max-width: var(--breakpoint-2xl);
|
||||||
|
}
|
||||||
.max-w-screen-xl {
|
.max-w-screen-xl {
|
||||||
max-width: var(--breakpoint-xl);
|
max-width: var(--breakpoint-xl);
|
||||||
}
|
}
|
||||||
@@ -412,12 +419,18 @@
|
|||||||
.transform {
|
.transform {
|
||||||
transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
|
transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
|
||||||
}
|
}
|
||||||
|
.cursor-not-allowed {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
.cursor-pointer {
|
.cursor-pointer {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.resize {
|
.resize {
|
||||||
resize: both;
|
resize: both;
|
||||||
}
|
}
|
||||||
|
.grid-cols-1 {
|
||||||
|
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||||
|
}
|
||||||
.flex-col {
|
.flex-col {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -729,6 +742,9 @@
|
|||||||
.text-yellow {
|
.text-yellow {
|
||||||
color: var(--yellow);
|
color: var(--yellow);
|
||||||
}
|
}
|
||||||
|
.opacity-50 {
|
||||||
|
opacity: 50%;
|
||||||
|
}
|
||||||
.shadow-lg {
|
.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));
|
--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);
|
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||||
@@ -746,6 +762,11 @@
|
|||||||
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
||||||
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
||||||
}
|
}
|
||||||
|
.transition-colors {
|
||||||
|
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to;
|
||||||
|
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
||||||
|
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
||||||
|
}
|
||||||
.outline-none {
|
.outline-none {
|
||||||
--tw-outline-style: none;
|
--tw-outline-style: none;
|
||||||
outline-style: none;
|
outline-style: none;
|
||||||
@@ -761,6 +782,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.hover\:border-blue {
|
||||||
|
&:hover {
|
||||||
|
@media (hover: hover) {
|
||||||
|
border-color: var(--blue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.hover\:bg-crust {
|
.hover\:bg-crust {
|
||||||
&:hover {
|
&:hover {
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
@@ -808,6 +836,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.hover\:bg-surface0 {
|
||||||
|
&:hover {
|
||||||
|
@media (hover: hover) {
|
||||||
|
background-color: var(--surface0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.hover\:bg-surface2 {
|
.hover\:bg-surface2 {
|
||||||
&:hover {
|
&:hover {
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
@@ -931,6 +966,21 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sm\:inline {
|
||||||
|
@media (width >= 40rem) {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sm\:flex-row {
|
||||||
|
@media (width >= 40rem) {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sm\:items-center {
|
||||||
|
@media (width >= 40rem) {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
.sm\:justify-between {
|
.sm\:justify-between {
|
||||||
@media (width >= 40rem) {
|
@media (width >= 40rem) {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -957,6 +1007,11 @@
|
|||||||
line-height: var(--tw-leading, var(--text-4xl--line-height));
|
line-height: var(--tw-leading, var(--text-4xl--line-height));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.md\:grid-cols-2 {
|
||||||
|
@media (width >= 48rem) {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
.md\:gap-8 {
|
.md\:gap-8 {
|
||||||
@media (width >= 48rem) {
|
@media (width >= 48rem) {
|
||||||
gap: calc(var(--spacing) * 8);
|
gap: calc(var(--spacing) * 8);
|
||||||
@@ -992,6 +1047,11 @@
|
|||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.lg\:grid-cols-3 {
|
||||||
|
@media (width >= 64rem) {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
.lg\:items-end {
|
.lg\:items-end {
|
||||||
@media (width >= 64rem) {
|
@media (width >= 64rem) {
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
|
|||||||
45
pkg/embedfs/files/js/pagination.js
Normal file
45
pkg/embedfs/files/js/pagination.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
function paginateData(
|
||||||
|
formID,
|
||||||
|
rootPath,
|
||||||
|
initPage,
|
||||||
|
initPerPage,
|
||||||
|
initOrder,
|
||||||
|
initOrderBy,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
page: initPage,
|
||||||
|
perPage: initPerPage,
|
||||||
|
order: initOrder || "ASC",
|
||||||
|
orderBy: initOrderBy || "name",
|
||||||
|
|
||||||
|
goToPage(n) {
|
||||||
|
this.page = n;
|
||||||
|
this.submit();
|
||||||
|
},
|
||||||
|
|
||||||
|
handleSortChange(value) {
|
||||||
|
const [field, direction] = value.split("|");
|
||||||
|
this.orderBy = field;
|
||||||
|
this.order = direction;
|
||||||
|
this.page = 1; // Reset to first page when sorting
|
||||||
|
this.submit();
|
||||||
|
},
|
||||||
|
|
||||||
|
setPerPage(n) {
|
||||||
|
this.perPage = n;
|
||||||
|
this.page = 1; // Reset to first page when changing per page
|
||||||
|
this.submit();
|
||||||
|
},
|
||||||
|
|
||||||
|
submit() {
|
||||||
|
var url = `${rootPath}?page=${this.page}&per_page=${this.perPage}&order=${this.order}&order_by=${this.orderBy}`;
|
||||||
|
htmx.find("#pagination-page").value = this.page;
|
||||||
|
htmx.find("#pagination-per-page").value = this.perPage;
|
||||||
|
htmx.find("#sort-order").value = this.order;
|
||||||
|
htmx.find("#sort-order-by").value = this.orderBy;
|
||||||
|
htmx.find(`#${formID}`).setAttribute("hx-post", url);
|
||||||
|
htmx.process(`#${formID}`);
|
||||||
|
htmx.trigger(`#${formID}`, "submit");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user