mirror of
https://github.com/Sosokker/go-chi-oapi-codegen-todolist.git
synced 2025-12-19 05:54:07 +01:00
initial code commit
This commit is contained in:
commit
2e31565a8f
49
backend/.air.toml
Normal file
49
backend/.air.toml
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
# .air.toml
|
||||||
|
root = "."
|
||||||
|
# Optional! Store the binary in Air's temporary folder, defaults to ./tmp
|
||||||
|
tmp_dir = "tmp"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
# Command to build your Go application. Ensure output path is correct.
|
||||||
|
cmd = "go build -o ./bin/todolist-server ./cmd/server/main.go"
|
||||||
|
# The final binary executable Air will run. Matches the output of `cmd`.
|
||||||
|
bin = "bin/todolist-server"
|
||||||
|
# Additional arguments/flags to pass to the 'bin' command on execution
|
||||||
|
full_bin = "./bin/todolist-server -config=." # Pass config path
|
||||||
|
|
||||||
|
# Directories to watch for changes. Changes trigger a rebuild and restart.
|
||||||
|
include_dir = ["cmd", "internal", "migrations", "pkg"] # Add 'pkg' if you use it
|
||||||
|
# Files to watch specifically (e.g., config, OpenAPI spec)
|
||||||
|
include_file = ["openapi.yaml", "config.yaml", "sqlc.yaml"]
|
||||||
|
|
||||||
|
# Directories to exclude from watching. Prevents unnecessary rebuilds.
|
||||||
|
exclude_dir = ["bin", "vendor", "tmp", "scripts", "docs"]
|
||||||
|
# Files or patterns to exclude.
|
||||||
|
exclude_file = []
|
||||||
|
# Regex patterns for files/dirs to exclude.
|
||||||
|
exclude_regex = ["_test.go", "_generated.go", "mocks"] # Exclude tests, generated code, mocks
|
||||||
|
# Files to exclude just from triggering rebuilds (but still watched for other purposes?)
|
||||||
|
exclude_unchanged = []
|
||||||
|
|
||||||
|
# File extensions to watch.
|
||||||
|
include_ext = ["go", "yaml", "sql", "toml"] # Watch Go, YAML, SQL, and TOML files
|
||||||
|
|
||||||
|
# Log name prefix for Air's output
|
||||||
|
log_name = "air_todolist.log"
|
||||||
|
# Follow symlinks when watching?
|
||||||
|
follow_symlink = true
|
||||||
|
|
||||||
|
# --- Execution ---
|
||||||
|
# Delay between detecting a file change and triggering the build (milliseconds).
|
||||||
|
# Useful to debounce rapid saves.
|
||||||
|
delay = 1000 # 1 second
|
||||||
|
|
||||||
|
# --- Logging ---
|
||||||
|
# Show log colors.
|
||||||
|
log_color = true
|
||||||
|
|
||||||
|
# --- Misc ---
|
||||||
|
# Send interrupt signal instead of kill when stopping the process.
|
||||||
|
send_interrupt = true
|
||||||
|
# Kill delay (seconds) after sending interrupt signal before force killing.
|
||||||
|
kill_delay = 5 # seconds
|
||||||
25
backend/.gitignore
vendored
Normal file
25
backend/.gitignore
vendored
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# Binaries for programs and plugins
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# Test binary, build artifacts
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
bin/
|
||||||
|
vendor/
|
||||||
|
*GENERATED*
|
||||||
|
*generated*
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env*
|
||||||
|
config.yaml # Often contains secrets, check your policy
|
||||||
|
|
||||||
|
# IDE settings
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Local development artifacts
|
||||||
|
/tmp
|
||||||
107
backend/Makefile
Normal file
107
backend/Makefile
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
.PHONY: help generate build run test migrate-up migrate-down clean docker-db docker-db-stop dev
|
||||||
|
|
||||||
|
BINARY_NAME=todolist-server
|
||||||
|
CMD_PATH=./cmd/server
|
||||||
|
OUTPUT_DIR=./bin
|
||||||
|
CONFIG_PATH=.
|
||||||
|
|
||||||
|
# Tool Paths (adjust if needed)
|
||||||
|
OAPI_CODEGEN=oapi-codegen
|
||||||
|
SQLC=sqlc
|
||||||
|
MIGRATE=migrate
|
||||||
|
|
||||||
|
OPENAPI=openapi.yaml
|
||||||
|
PKG=api
|
||||||
|
|
||||||
|
DB_URL?=postgresql://postgres:@localhost:5433/postgres?sslmode=disable
|
||||||
|
MIGRATIONS_PATH=./backend/migrations
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "Usage: make <target>"
|
||||||
|
@echo ""
|
||||||
|
@echo "Targets:"
|
||||||
|
@echo " help Show this help message."
|
||||||
|
@echo " generate Generate Go code from OpenAPI spec and SQL queries."
|
||||||
|
@echo " build Build the Go application binary."
|
||||||
|
@echo " run Build and run the Go application."
|
||||||
|
@echo " test Run Go tests."
|
||||||
|
@echo " migrate-up Apply all up database migrations."
|
||||||
|
@echo " migrate-down Roll back the last database migration."
|
||||||
|
@echo " migrate-force Force set migration version (e.g., make migrate-force VERSION=1)."
|
||||||
|
@echo " clean Remove build artifacts."
|
||||||
|
@echo " docker-db Start a PostgreSQL container using Docker."
|
||||||
|
@echo " docker-db-stop Stop and remove the PostgreSQL container."
|
||||||
|
@echo ""
|
||||||
|
@echo "Environment Variables:"
|
||||||
|
@echo " DB_URL Database connection URL (used for migrations)."
|
||||||
|
@echo " Default: Attempts to get from running 'todolist-db' container."
|
||||||
|
@echo " Example: export DB_URL='postgres://user:password@localhost:5432/todolist?sslmode=disable'"
|
||||||
|
|
||||||
|
dev:
|
||||||
|
@echo ">> Starting development server with Air live reload..."
|
||||||
|
@air -c .air.toml
|
||||||
|
|
||||||
|
generate-types:
|
||||||
|
oapi-codegen --package $(PKG) --generate types -o internal/api/openapi_types.go $(OPENAPI)
|
||||||
|
|
||||||
|
generate-chi:
|
||||||
|
oapi-codegen --package $(PKG) --generate chi-server -o internal/api/openapi_generated.go $(OPENAPI)
|
||||||
|
|
||||||
|
generate-models:
|
||||||
|
oapi-codegen --package models --generate models -o internal/api/models/openapi_models_generated.go $(OPENAPI)
|
||||||
|
|
||||||
|
# generate-strict:
|
||||||
|
# oapi-codegen --package $(PKG) --generate strict-server -o internal/api/openapi_strict_server_generated.go $(OPENAPI)
|
||||||
|
|
||||||
|
generate: generate-types generate-chi generate-models
|
||||||
|
@echo ">> Generating SQLC code..."
|
||||||
|
$(SQLC) generate
|
||||||
|
@echo ">> Tidying modules..."
|
||||||
|
go mod tidy
|
||||||
|
|
||||||
|
build:
|
||||||
|
@echo ">> Building binary..."
|
||||||
|
go build -o $(OUTPUT_DIR)/$(BINARY_NAME) $(CMD_PATH)/main.go
|
||||||
|
|
||||||
|
run: build
|
||||||
|
@echo ">> Running application..."
|
||||||
|
$(OUTPUT_DIR)/$(BINARY_NAME) -config=$(CONFIG_PATH)
|
||||||
|
|
||||||
|
test:
|
||||||
|
@echo ">> Running tests..."
|
||||||
|
go test ./... -v -cover
|
||||||
|
|
||||||
|
migrate-up:
|
||||||
|
@echo ">> Applying migrations..."
|
||||||
|
@if [ -z "$(DB_URL)" ]; then echo "Error: DB_URL is not set. Run 'make docker-db' or set it manually."; exit 1; fi
|
||||||
|
$(MIGRATE) -database "$(DB_URL)" -path $(MIGRATIONS_PATH) up
|
||||||
|
|
||||||
|
migrate-down:
|
||||||
|
@echo ">> Rolling back last migration..."
|
||||||
|
@if [ -z "$(DB_URL)" ]; then echo "Error: DB_URL is not set. Run 'make docker-db' or set it manually."; exit 1; fi
|
||||||
|
$(MIGRATE) -database "$(DB_URL)" -path $(MIGRATIONS_PATH) down 1
|
||||||
|
|
||||||
|
migrate-force:
|
||||||
|
@echo ">> Forcing migration version $(VERSION)..."
|
||||||
|
@if [ -z "$(DB_URL)" ]; then echo "Error: DB_URL is not set. Run 'make docker-db' or set it manually."; exit 1; fi
|
||||||
|
@if [ -z "$(VERSION)" ]; then echo "Error: VERSION is not set. Usage: make migrate-force VERSION=<version_number>"; exit 1; fi
|
||||||
|
$(MIGRATE) -database "$(DB_URL)" -path $(MIGRATIONS_PATH) force $(VERSION)
|
||||||
|
|
||||||
|
clean:
|
||||||
|
@echo ">> Cleaning build artifacts..."
|
||||||
|
rm -rf $(OUTPUT_DIR)
|
||||||
|
|
||||||
|
docker-db:
|
||||||
|
@echo ">> Starting PostgreSQL container..."
|
||||||
|
@docker run --name todolist-db -e POSTGRES_USER=user \
|
||||||
|
-e POSTGRES_PASSWORD=password \
|
||||||
|
-e POSTGRES_DB=todolist \
|
||||||
|
-p 5432:5432 -d postgres:15-alpine
|
||||||
|
@echo ">> Waiting for DB to be ready..."
|
||||||
|
@sleep 5
|
||||||
|
@echo ">> DB_URL=$(DB_URL)"
|
||||||
|
|
||||||
|
docker-db-stop:
|
||||||
|
@echo ">> Stopping and removing PostgreSQL container..."
|
||||||
|
@docker stop todolist-db || true
|
||||||
|
@docker rm todolist-db || true
|
||||||
248
backend/cmd/server/main.go
Normal file
248
backend/cmd/server/main.go
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Sosokker/todolist-backend/internal/api"
|
||||||
|
"github.com/Sosokker/todolist-backend/internal/config"
|
||||||
|
"github.com/Sosokker/todolist-backend/internal/repository"
|
||||||
|
"github.com/Sosokker/todolist-backend/internal/service"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
"github.com/go-chi/cors"
|
||||||
|
"github.com/golang-migrate/migrate/v4"
|
||||||
|
_ "github.com/golang-migrate/migrate/v4/database/postgres"
|
||||||
|
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
configPath := flag.String("config", ".", "Path to the config directory or file")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(*configPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error loading configuration: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger := setupLogger(cfg.Log)
|
||||||
|
slog.SetDefault(logger)
|
||||||
|
|
||||||
|
logger.Info("Starting Todolist Backend Service", "version", "1.2.0")
|
||||||
|
logger.Debug("Configuration loaded", "config", cfg)
|
||||||
|
|
||||||
|
pool, err := repository.NewConnectionPool(cfg.Database)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Failed to connect to database", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
if err := runMigrations(cfg.Database.URL); err != nil {
|
||||||
|
logger.Error("Database migration failed", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
repoRegistry := repository.NewRepositoryRegistry(pool)
|
||||||
|
|
||||||
|
var storageService service.FileStorageService
|
||||||
|
switch cfg.Storage.Type {
|
||||||
|
case "local":
|
||||||
|
storageService, err = service.NewLocalStorageService(cfg.Storage.Local, logger)
|
||||||
|
case "gcs":
|
||||||
|
storageService, err = service.NewGCStorageService(cfg.Storage.GCS, logger)
|
||||||
|
default:
|
||||||
|
err = fmt.Errorf("unsupported storage type: %s", cfg.Storage.Type)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Failed to initialize storage service", "error", err, "type", cfg.Storage.Type)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
authService := service.NewAuthService(repoRegistry.UserRepo, cfg)
|
||||||
|
userService := service.NewUserService(repoRegistry.UserRepo)
|
||||||
|
tagService := service.NewTagService(repoRegistry.TagRepo)
|
||||||
|
subtaskService := service.NewSubtaskService(repoRegistry.SubtaskRepo)
|
||||||
|
todoService := service.NewTodoService(repoRegistry.TodoRepo, tagService, subtaskService, storageService)
|
||||||
|
|
||||||
|
services := &service.ServiceRegistry{
|
||||||
|
Auth: authService,
|
||||||
|
User: userService,
|
||||||
|
Tag: tagService,
|
||||||
|
Todo: todoService,
|
||||||
|
Subtask: subtaskService,
|
||||||
|
Storage: storageService,
|
||||||
|
}
|
||||||
|
|
||||||
|
apiHandler := api.NewApiHandler(services, cfg, logger)
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
r.Use(middleware.Logger)
|
||||||
|
r.Use(middleware.RequestID)
|
||||||
|
r.Use(middleware.RealIP)
|
||||||
|
r.Use(NewStructuredLogger(logger))
|
||||||
|
r.Use(middleware.Recoverer)
|
||||||
|
r.Use(cors.Handler(cors.Options{
|
||||||
|
AllowedOrigins: []string{"http://localhost:3000", "https://your-frontend-domain.com"},
|
||||||
|
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||||
|
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
||||||
|
ExposedHeaders: []string{"Link"},
|
||||||
|
AllowCredentials: true,
|
||||||
|
MaxAge: 300,
|
||||||
|
}))
|
||||||
|
r.Use(middleware.Timeout(60 * time.Second))
|
||||||
|
|
||||||
|
r.Route(cfg.Server.BasePath, func(subr chi.Router) {
|
||||||
|
subr.Post("/auth/signup", apiHandler.SignupUserApi)
|
||||||
|
subr.Post("/auth/login", apiHandler.LoginUserApi)
|
||||||
|
subr.Get("/auth/google/login", apiHandler.InitiateGoogleLogin)
|
||||||
|
subr.Get("/auth/google/callback", apiHandler.HandleGoogleCallback)
|
||||||
|
|
||||||
|
subr.Group(func(prot chi.Router) {
|
||||||
|
prot.Use(api.AuthMiddleware(authService, cfg))
|
||||||
|
api.HandlerFromMux(apiHandler, prot)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := pool.Ping(r.Context()); err != nil {
|
||||||
|
http.Error(w, "Health check failed: DB ping error", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
fmt.Fprintln(w, "OK")
|
||||||
|
})
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: fmt.Sprintf(":%d", cfg.Server.Port),
|
||||||
|
Handler: r,
|
||||||
|
ReadTimeout: cfg.Server.ReadTimeout,
|
||||||
|
WriteTimeout: cfg.Server.WriteTimeout,
|
||||||
|
IdleTimeout: cfg.Server.IdleTimeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
logger.Info("Server starting", "address", srv.Addr)
|
||||||
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
logger.Error("Server failed to start", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
quit := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-quit
|
||||||
|
logger.Info("Shutting down server...")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := srv.Shutdown(ctx); err != nil {
|
||||||
|
logger.Error("Server shutdown failed", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("Server exited gracefully")
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupLogger(cfg config.LogConfig) *slog.Logger {
|
||||||
|
var level slog.Level
|
||||||
|
switch strings.ToLower(cfg.Level) {
|
||||||
|
case "debug":
|
||||||
|
level = slog.LevelDebug
|
||||||
|
case "info":
|
||||||
|
level = slog.LevelInfo
|
||||||
|
case "warn":
|
||||||
|
level = slog.LevelWarn
|
||||||
|
case "error":
|
||||||
|
level = slog.LevelError
|
||||||
|
default:
|
||||||
|
level = slog.LevelInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := &slog.HandlerOptions{
|
||||||
|
Level: level,
|
||||||
|
AddSource: level == slog.LevelDebug,
|
||||||
|
}
|
||||||
|
|
||||||
|
var handler slog.Handler
|
||||||
|
if cfg.Format == "json" {
|
||||||
|
handler = slog.NewJSONHandler(os.Stdout, opts)
|
||||||
|
} else {
|
||||||
|
handler = slog.NewTextHandler(os.Stdout, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
return slog.New(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMigrations(databaseURL string) error {
|
||||||
|
if databaseURL == "" {
|
||||||
|
return errors.New("database URL is required for migrations")
|
||||||
|
}
|
||||||
|
migrationPath := "file://migrations"
|
||||||
|
|
||||||
|
m, err := migrate.New(migrationPath, databaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create migrate instance: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = m.Up()
|
||||||
|
if err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||||
|
sourceErr, dbErr := m.Close()
|
||||||
|
slog.Error("Migration close errors", "source_error", sourceErr, "db_error", dbErr)
|
||||||
|
return fmt.Errorf("failed to apply migrations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, migrate.ErrNoChange) {
|
||||||
|
slog.Info("No new migrations to apply")
|
||||||
|
} else {
|
||||||
|
slog.Info("Database migrations applied successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceErr, dbErr := m.Close()
|
||||||
|
if sourceErr != nil {
|
||||||
|
slog.Error("Error closing migration source", "error", sourceErr)
|
||||||
|
}
|
||||||
|
if dbErr != nil {
|
||||||
|
slog.Error("Error closing migration database connection", "error", dbErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStructuredLogger(logger *slog.Logger) func(next http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||||
|
start := time.Now()
|
||||||
|
reqLogger := logger.With(
|
||||||
|
slog.String("proto", r.Proto),
|
||||||
|
slog.String("method", r.Method),
|
||||||
|
slog.String("path", r.URL.Path),
|
||||||
|
slog.String("remote_addr", r.RemoteAddr),
|
||||||
|
slog.String("user_agent", r.UserAgent()),
|
||||||
|
slog.String("request_id", middleware.GetReqID(r.Context())),
|
||||||
|
)
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
reqLogger.Info("request completed",
|
||||||
|
slog.Int("status", ww.Status()),
|
||||||
|
slog.Duration("latency", time.Since(start)),
|
||||||
|
)
|
||||||
|
}()
|
||||||
|
|
||||||
|
next.ServeHTTP(ww, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
44
backend/config.yaml
Normal file
44
backend/config.yaml
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
server:
|
||||||
|
port: 8080
|
||||||
|
readTimeout: 15s
|
||||||
|
writeTimeout: 15s
|
||||||
|
idleTimeout: 60s
|
||||||
|
basePath: "/api/v1" # Matches OpenAPI server URL
|
||||||
|
|
||||||
|
database:
|
||||||
|
url: "postgresql://postgres:@localhost:5433/postgres?sslmode=disable" # Use env vars in prod
|
||||||
|
|
||||||
|
jwt:
|
||||||
|
secret: "your-very-secret-key-change-me" # Use env vars in prod
|
||||||
|
expiryMinutes: 60
|
||||||
|
cookieName: "jwt_token"
|
||||||
|
cookieDomain: "localhost" # Set appropriately for your domain
|
||||||
|
cookiePath: "/"
|
||||||
|
cookieSecure: false # Set true if using HTTPS
|
||||||
|
cookieHttpOnly: true
|
||||||
|
cookieSameSite: "Lax" # Lax or Strict
|
||||||
|
|
||||||
|
log:
|
||||||
|
level: "debug" # debug, info, warn, error
|
||||||
|
format: "json" # json or text
|
||||||
|
|
||||||
|
oauth:
|
||||||
|
google:
|
||||||
|
clientId: "YOUR_GOOGLE_CLIENT_ID" # Use env vars
|
||||||
|
clientSecret: "YOUR_GOOGLE_CLIENT_SECRET" # Use env vars
|
||||||
|
redirectUrl: "http://localhost:8080/api/v1/auth/google/callback" # Must match Google Console config
|
||||||
|
scopes:
|
||||||
|
- "https://www.googleapis.com/auth/userinfo.profile"
|
||||||
|
- "https://www.googleapis.com/auth/userinfo.email"
|
||||||
|
stateSecret: "your-oauth-state-secret-change-me" # For signing state cookie
|
||||||
|
|
||||||
|
cache:
|
||||||
|
defaultExpiration: 5m
|
||||||
|
cleanupInterval: 10m
|
||||||
|
|
||||||
|
storage:
|
||||||
|
local:
|
||||||
|
path: "/"
|
||||||
|
# gcs:
|
||||||
|
# bucketName: "your-gcs-bucket-name" # Env: STORAGE_GCS_BUCKETNAME
|
||||||
|
# credentialsFile: "/path/to/gcs-credentials.json" # Env: GOOGLE_APPLICATION_CREDENTIALS
|
||||||
125
backend/go.mod
Normal file
125
backend/go.mod
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
module github.com/Sosokker/todolist-backend
|
||||||
|
|
||||||
|
go 1.24.2
|
||||||
|
|
||||||
|
tool (
|
||||||
|
cloud.google.com/go/storage
|
||||||
|
github.com/go-chi/chi/v5
|
||||||
|
github.com/go-chi/cors
|
||||||
|
github.com/golang-jwt/jwt/v5
|
||||||
|
github.com/golang-migrate/migrate/v4/database/postgres
|
||||||
|
github.com/golang-migrate/migrate/v4/source/file
|
||||||
|
github.com/google/uuid
|
||||||
|
github.com/jackc/pgx/v5/pgconn
|
||||||
|
github.com/jackc/pgx/v5/pgxpool
|
||||||
|
github.com/jackc/pgx/v5/stdlib
|
||||||
|
github.com/jackc/puddle/v2
|
||||||
|
github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen
|
||||||
|
github.com/oapi-codegen/runtime
|
||||||
|
github.com/patrickmn/go-cache
|
||||||
|
github.com/spf13/viper
|
||||||
|
github.com/swaggo/http-swagger
|
||||||
|
golang.org/x/crypto/bcrypt
|
||||||
|
golang.org/x/oauth2
|
||||||
|
golang.org/x/oauth2/google
|
||||||
|
google.golang.org/api/option
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-chi/chi/v5 v5.2.1
|
||||||
|
github.com/go-chi/cors v1.2.1
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||||
|
github.com/golang-migrate/migrate/v4 v4.18.2
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/jackc/pgx/v5 v5.7.4
|
||||||
|
github.com/oapi-codegen/runtime v1.1.1
|
||||||
|
github.com/patrickmn/go-cache v2.1.0+incompatible
|
||||||
|
github.com/spf13/viper v1.20.1
|
||||||
|
golang.org/x/crypto v0.37.0
|
||||||
|
golang.org/x/oauth2 v0.29.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
cel.dev/expr v0.19.2 // indirect
|
||||||
|
cloud.google.com/go v0.118.3 // indirect
|
||||||
|
cloud.google.com/go/auth v0.16.0 // indirect
|
||||||
|
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||||
|
cloud.google.com/go/compute/metadata v0.6.0 // indirect
|
||||||
|
cloud.google.com/go/iam v1.4.1 // indirect
|
||||||
|
cloud.google.com/go/monitoring v1.24.0 // indirect
|
||||||
|
cloud.google.com/go/storage v1.51.0 // indirect
|
||||||
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0 // indirect
|
||||||
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 // indirect
|
||||||
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 // indirect
|
||||||
|
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||||
|
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42 // indirect
|
||||||
|
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
|
||||||
|
github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect
|
||||||
|
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
|
github.com/fsnotify/fsnotify v1.8.0 // indirect
|
||||||
|
github.com/getkin/kin-openapi v0.127.0 // indirect
|
||||||
|
github.com/go-logr/logr v1.4.2 // indirect
|
||||||
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
|
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||||
|
github.com/go-openapi/jsonreference v0.20.0 // indirect
|
||||||
|
github.com/go-openapi/spec v0.20.6 // indirect
|
||||||
|
github.com/go-openapi/swag v0.23.0 // indirect
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||||
|
github.com/google/s2a-go v0.1.9 // indirect
|
||||||
|
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
|
||||||
|
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
|
||||||
|
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||||
|
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||||
|
github.com/invopop/yaml v0.3.1 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
|
github.com/lib/pq v1.10.9 // indirect
|
||||||
|
github.com/mailru/easyjson v0.7.7 // indirect
|
||||||
|
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
||||||
|
github.com/oapi-codegen/oapi-codegen/v2 v2.4.1 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||||
|
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
||||||
|
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||||
|
github.com/sagikazarmark/locafero v0.7.0 // indirect
|
||||||
|
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||||
|
github.com/speakeasy-api/openapi-overlay v0.9.0 // indirect
|
||||||
|
github.com/spf13/afero v1.12.0 // indirect
|
||||||
|
github.com/spf13/cast v1.7.1 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.6 // indirect
|
||||||
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
|
github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe // indirect
|
||||||
|
github.com/swaggo/http-swagger v1.3.4 // indirect
|
||||||
|
github.com/swaggo/swag v1.8.1 // indirect
|
||||||
|
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
|
||||||
|
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||||
|
go.opentelemetry.io/contrib/detectors/gcp v1.34.0 // indirect
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
|
||||||
|
go.opentelemetry.io/otel v1.35.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/metric v1.35.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/sdk v1.35.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.35.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/trace v1.35.0 // indirect
|
||||||
|
go.uber.org/atomic v1.9.0 // indirect
|
||||||
|
go.uber.org/multierr v1.9.0 // indirect
|
||||||
|
golang.org/x/mod v0.21.0 // indirect
|
||||||
|
golang.org/x/net v0.39.0 // indirect
|
||||||
|
golang.org/x/sync v0.13.0 // indirect
|
||||||
|
golang.org/x/sys v0.32.0 // indirect
|
||||||
|
golang.org/x/text v0.24.0 // indirect
|
||||||
|
golang.org/x/time v0.11.0 // indirect
|
||||||
|
golang.org/x/tools v0.24.0 // indirect
|
||||||
|
google.golang.org/api v0.229.0 // indirect
|
||||||
|
google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb // indirect
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e // indirect
|
||||||
|
google.golang.org/grpc v1.71.1 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.6 // indirect
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
384
backend/go.sum
Normal file
384
backend/go.sum
Normal file
@ -0,0 +1,384 @@
|
|||||||
|
cel.dev/expr v0.19.2 h1:V354PbqIXr9IQdwy4SYA4xa0HXaWq1BUPAGzugBY5V4=
|
||||||
|
cel.dev/expr v0.19.2/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw=
|
||||||
|
cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
|
||||||
|
cloud.google.com/go v0.118.3 h1:jsypSnrE/w4mJysioGdMBg4MiW/hHx/sArFpaBWHdME=
|
||||||
|
cloud.google.com/go v0.118.3/go.mod h1:Lhs3YLnBlwJ4KA6nuObNMZ/fCbOQBPuWKPoE0Wa/9Vc=
|
||||||
|
cloud.google.com/go/auth v0.16.0 h1:Pd8P1s9WkcrBE2n/PhAwKsdrR35V3Sg2II9B+ndM3CU=
|
||||||
|
cloud.google.com/go/auth v0.16.0/go.mod h1:1howDHJ5IETh/LwYs3ZxvlkXF48aSqqJUM+5o02dNOI=
|
||||||
|
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
||||||
|
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||||
|
cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
|
||||||
|
cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
|
||||||
|
cloud.google.com/go/iam v1.4.1 h1:cFC25Nv+u5BkTR/BT1tXdoF2daiVbZ1RLx2eqfQ9RMM=
|
||||||
|
cloud.google.com/go/iam v1.4.1/go.mod h1:2vUEJpUG3Q9p2UdsyksaKpDzlwOrnMzS30isdReIcLM=
|
||||||
|
cloud.google.com/go/monitoring v1.24.0 h1:csSKiCJ+WVRgNkRzzz3BPoGjFhjPY23ZTcaenToJxMM=
|
||||||
|
cloud.google.com/go/monitoring v1.24.0/go.mod h1:Bd1PRK5bmQBQNnuGwHBfUamAV1ys9049oEPHnn4pcsc=
|
||||||
|
cloud.google.com/go/storage v1.51.0 h1:ZVZ11zCiD7b3k+cH5lQs/qcNaoSz3U9I0jgwVzqDlCw=
|
||||||
|
cloud.google.com/go/storage v1.51.0/go.mod h1:YEJfu/Ki3i5oHC/7jyTgsGZwdQ8P9hqMqvpi5kRKGgc=
|
||||||
|
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||||
|
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||||
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0 h1:3c8yed4lgqTt+oTQ+JNMDo+F4xprBf+O/il4ZC0nRLw=
|
||||||
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0/go.mod h1:obipzmGjfSjam60XLwGfqUkJsfiheAl+TUjG+4yzyPM=
|
||||||
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 h1:fYE9p3esPxA/C0rQ0AHhP0drtPXDRhaWiwg1DPqO7IU=
|
||||||
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0/go.mod h1:BnBReJLvVYx2CS/UHOgVz2BXKXD9wsQPxZug20nZhd0=
|
||||||
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 h1:6/0iUd0xrnX7qt+mLNRwg5c0PGv8wpE8K90ryANQwMI=
|
||||||
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0=
|
||||||
|
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||||
|
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
|
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
||||||
|
github.com/agiledragon/gomonkey/v2 v2.3.1 h1:k+UnUY0EMNYUFUAQVETGY9uUTxjMdnUkP0ARyJS1zzs=
|
||||||
|
github.com/agiledragon/gomonkey/v2 v2.3.1/go.mod h1:ap1AmDzcVOAz1YpeJ3TCzIgstoaWLA6jbbgxfB4w2iY=
|
||||||
|
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
|
||||||
|
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
|
||||||
|
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||||
|
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||||
|
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||||
|
github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42 h1:Om6kYQYDUk5wWbT0t0q6pvyM49i9XZAv9dDrkDA7gjk=
|
||||||
|
github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dhui/dktest v0.4.4 h1:+I4s6JRE1yGuqflzwqG+aIaMdgXIorCf5P98JnaAWa8=
|
||||||
|
github.com/dhui/dktest v0.4.4/go.mod h1:4+22R4lgsdAXrDyaH4Nqx2JEz2hLp49MqQmm9HLCQhM=
|
||||||
|
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
|
github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4=
|
||||||
|
github.com/docker/docker v27.2.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||||
|
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||||
|
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||||
|
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
|
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||||
|
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
|
||||||
|
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w=
|
||||||
|
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q=
|
||||||
|
github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M=
|
||||||
|
github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A=
|
||||||
|
github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=
|
||||||
|
github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
|
||||||
|
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
|
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
|
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||||
|
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
|
||||||
|
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
|
github.com/getkin/kin-openapi v0.127.0 h1:Mghqi3Dhryf3F8vR370nN67pAERW+3a95vomb3MAREY=
|
||||||
|
github.com/getkin/kin-openapi v0.127.0/go.mod h1:OZrfXzUfGrNbsKj+xmFBx6E5c6yH3At/tAKSc2UszXM=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||||
|
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
|
||||||
|
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||||
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
|
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||||
|
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||||
|
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||||
|
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
|
||||||
|
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
|
||||||
|
github.com/go-openapi/jsonreference v0.20.0 h1:MYlu0sBgChmCfJxxUKZ8g1cPWFOB37YSZqewK7OKeyA=
|
||||||
|
github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo=
|
||||||
|
github.com/go-openapi/spec v0.20.6 h1:ich1RQ3WDbfoeTqTAb+5EIxNmpKVJZWBNah9RAT0jIQ=
|
||||||
|
github.com/go-openapi/spec v0.20.6/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA=
|
||||||
|
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
|
||||||
|
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
|
||||||
|
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
||||||
|
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
|
||||||
|
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||||
|
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
|
||||||
|
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
|
github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8=
|
||||||
|
github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk=
|
||||||
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||||
|
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||||
|
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||||
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
|
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||||
|
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||||
|
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||||
|
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
|
||||||
|
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
||||||
|
github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q=
|
||||||
|
github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
|
||||||
|
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||||
|
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||||
|
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||||
|
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||||
|
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||||
|
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||||
|
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||||
|
github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso=
|
||||||
|
github.com/invopop/yaml v0.3.1/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81KkeA=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
|
||||||
|
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||||
|
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||||
|
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
|
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||||
|
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||||
|
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||||
|
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||||
|
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||||
|
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||||
|
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||||
|
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||||
|
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||||
|
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
|
||||||
|
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
|
||||||
|
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||||
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||||
|
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||||
|
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||||
|
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||||
|
github.com/oapi-codegen/oapi-codegen/v2 v2.4.1 h1:ykgG34472DWey7TSjd8vIfNykXgjOgYJZoQbKfEeY/Q=
|
||||||
|
github.com/oapi-codegen/oapi-codegen/v2 v2.4.1/go.mod h1:N5+lY1tiTDV3V1BeHtOxeWXHoPVeApvsvjJqegfoaz8=
|
||||||
|
github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro=
|
||||||
|
github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg=
|
||||||
|
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
|
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
|
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||||
|
github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
|
||||||
|
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
|
||||||
|
github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
|
||||||
|
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||||
|
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||||
|
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||||
|
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
|
||||||
|
github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
|
||||||
|
github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
|
||||||
|
github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||||
|
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||||
|
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||||
|
github.com/otiai10/copy v1.7.0 h1:hVoPiN+t+7d2nzzwMiDHPSOogsWAStewq3TwU05+clE=
|
||||||
|
github.com/otiai10/copy v1.7.0/go.mod h1:rmRl6QPdJj6EiUqXQ/4Nn2lLXoNQjFCQbbNrxgc/t3U=
|
||||||
|
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
|
||||||
|
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||||
|
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
|
||||||
|
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
|
||||||
|
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||||
|
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||||
|
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
|
||||||
|
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
|
||||||
|
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
|
||||||
|
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||||
|
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||||
|
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||||
|
github.com/speakeasy-api/openapi-overlay v0.9.0 h1:Wrz6NO02cNlLzx1fB093lBlYxSI54VRhy1aSutx0PQg=
|
||||||
|
github.com/speakeasy-api/openapi-overlay v0.9.0/go.mod h1:f5FloQrHA7MsxYg9djzMD5h6dxrHjVVByWKh7an8TRc=
|
||||||
|
github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
|
||||||
|
github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4=
|
||||||
|
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||||
|
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||||
|
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||||
|
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
|
||||||
|
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
|
||||||
|
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
|
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||||
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
|
github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe h1:K8pHPVoTgxFJt1lXuIzzOX7zZhZFldJQK/CgKx9BFIc=
|
||||||
|
github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe/go.mod h1:lKJPbtWzJ9JhsTN1k1gZgleJWY/cqq0psdoMmaThG3w=
|
||||||
|
github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64a5ww=
|
||||||
|
github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ=
|
||||||
|
github.com/swaggo/swag v1.8.1 h1:JuARzFX1Z1njbCGz+ZytBR15TFJwF2Q7fu8puJHhQYI=
|
||||||
|
github.com/swaggo/swag v1.8.1/go.mod h1:ugemnJsPZm/kRwFUnzBlbHRd0JY9zE1M4F+uy2pAaPQ=
|
||||||
|
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||||
|
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||||
|
github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
|
||||||
|
github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ=
|
||||||
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||||
|
go.opentelemetry.io/contrib/detectors/gcp v1.34.0 h1:JRxssobiPg23otYU5SbWtQC//snGVIM3Tx6QRzlQBao=
|
||||||
|
go.opentelemetry.io/contrib/detectors/gcp v1.34.0/go.mod h1:cV4BMFcscUR/ckqLkbfQmF0PRsq8w/lMGzdbCSveBHo=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
|
||||||
|
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
|
||||||
|
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
|
||||||
|
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
|
||||||
|
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
|
||||||
|
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
|
||||||
|
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
|
||||||
|
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
|
||||||
|
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
|
||||||
|
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
|
||||||
|
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
|
||||||
|
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
|
||||||
|
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
|
||||||
|
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||||
|
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||||
|
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
|
||||||
|
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||||
|
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||||
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
|
||||||
|
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||||
|
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
|
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||||
|
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||||
|
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||||
|
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||||
|
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
||||||
|
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
||||||
|
golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98=
|
||||||
|
golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
||||||
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||||
|
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
|
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||||
|
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||||
|
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||||
|
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
||||||
|
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24=
|
||||||
|
golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/api v0.229.0 h1:p98ymMtqeJ5i3lIBMj5MpR9kzIIgzpHHh8vQ+vgAzx8=
|
||||||
|
google.golang.org/api v0.229.0/go.mod h1:wyDfmq5g1wYJWn29O22FDWN48P7Xcz0xz+LBpptYvB0=
|
||||||
|
google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 h1:ToEetK57OidYuqD4Q5w+vfEnPvPpuTwedCNVohYJfNk=
|
||||||
|
google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb h1:ITgPrl429bc6+2ZraNSzMDk3I95nmQln2fuPstKwFDE=
|
||||||
|
google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:sAo5UzpjUwgFBCzupwhcLcxHVDK7vG5IqI30YnwX2eE=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e h1:ztQaXfzEXTmCBvbtWYRhJxW+0iJcz2qXfd38/e9l7bA=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||||
|
google.golang.org/grpc v1.71.1 h1:ffsFWr7ygTUscGPI0KKK6TLrGz0476KUvvsbqWK0rPI=
|
||||||
|
google.golang.org/grpc v1.71.1/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||||
|
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||||
|
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||||
|
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
|
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||||
|
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||||
|
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||||
|
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||||
|
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||||
|
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
880
backend/internal/api/handlers.go
Normal file
880
backend/internal/api/handlers.go
Normal file
@ -0,0 +1,880 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Sosokker/todolist-backend/internal/api/models" // Generated models
|
||||||
|
"github.com/Sosokker/todolist-backend/internal/auth"
|
||||||
|
"github.com/Sosokker/todolist-backend/internal/config"
|
||||||
|
"github.com/Sosokker/todolist-backend/internal/domain"
|
||||||
|
"github.com/Sosokker/todolist-backend/internal/service"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
openapi_types "github.com/oapi-codegen/runtime/types"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Compile-time check to ensure ApiHandler implements the interface
|
||||||
|
var _ ServerInterface = (*ApiHandler)(nil)
|
||||||
|
|
||||||
|
// ApiHandler holds dependencies for API handlers
|
||||||
|
type ApiHandler struct {
|
||||||
|
services *service.ServiceRegistry
|
||||||
|
cfg *config.Config
|
||||||
|
logger *slog.Logger
|
||||||
|
// Add other dependencies like cache if needed directly by handlers
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewApiHandler creates a new handler instance
|
||||||
|
func NewApiHandler(services *service.ServiceRegistry, cfg *config.Config, logger *slog.Logger) *ApiHandler {
|
||||||
|
return &ApiHandler{
|
||||||
|
services: services,
|
||||||
|
cfg: cfg,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helper Functions ---
|
||||||
|
|
||||||
|
// SendJSONResponse sends a JSON response with a status code
|
||||||
|
func SendJSONResponse(w http.ResponseWriter, statusCode int, payload interface{}, logger *slog.Logger) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(statusCode)
|
||||||
|
if payload != nil {
|
||||||
|
err := json.NewEncoder(w).Encode(payload)
|
||||||
|
if err != nil { // Log the error but response header is already sent
|
||||||
|
logger.Error("Failed to encode JSON response", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendJSONError sends a standardized JSON error response
|
||||||
|
func SendJSONError(w http.ResponseWriter, err error, defaultStatusCode int, logger *slog.Logger) {
|
||||||
|
logger.Warn("API Error", "error", err)
|
||||||
|
|
||||||
|
respErr := models.Error{
|
||||||
|
Message: err.Error(),
|
||||||
|
}
|
||||||
|
statusCode := defaultStatusCode
|
||||||
|
|
||||||
|
// Map domain errors to HTTP status codes
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, domain.ErrNotFound):
|
||||||
|
statusCode = http.StatusNotFound
|
||||||
|
case errors.Is(err, domain.ErrForbidden):
|
||||||
|
statusCode = http.StatusForbidden
|
||||||
|
case errors.Is(err, domain.ErrUnauthorized):
|
||||||
|
statusCode = http.StatusUnauthorized
|
||||||
|
case errors.Is(err, domain.ErrConflict):
|
||||||
|
statusCode = http.StatusConflict
|
||||||
|
case errors.Is(err, domain.ErrValidation), errors.Is(err, domain.ErrBadRequest):
|
||||||
|
statusCode = http.StatusBadRequest
|
||||||
|
case errors.Is(err, domain.ErrInternalServer):
|
||||||
|
statusCode = http.StatusInternalServerError
|
||||||
|
respErr.Message = "An internal error occurred."
|
||||||
|
default:
|
||||||
|
if statusCode < 500 {
|
||||||
|
logger.Error("Unhandled error type in API mapping", "error", err, "defaultStatus", defaultStatusCode)
|
||||||
|
statusCode = http.StatusInternalServerError
|
||||||
|
respErr.Message = "An unexpected error occurred."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
respErr.Code = int32(statusCode)
|
||||||
|
SendJSONResponse(w, statusCode, respErr, logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAndValidateBody decodes JSON body and logs/sends error on failure.
|
||||||
|
func parseAndValidateBody(w http.ResponseWriter, r *http.Request, body interface{}, logger *slog.Logger) bool {
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(body); err != nil {
|
||||||
|
SendJSONError(w, fmt.Errorf("invalid request body: %w", domain.ErrBadRequest), http.StatusBadRequest, logger)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// TODO: Add struct validation here if needed (e.g., using go-Code Playground/validator)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mappers (Domain <-> Generated API Models) ---
|
||||||
|
|
||||||
|
func mapDomainUserToApi(user *domain.User) *models.User {
|
||||||
|
if user == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
userID := openapi_types.UUID(user.ID)
|
||||||
|
email := openapi_types.Email(user.Email)
|
||||||
|
emailVerified := user.EmailVerified
|
||||||
|
createdAt := user.CreatedAt
|
||||||
|
updatedAt := user.UpdatedAt
|
||||||
|
|
||||||
|
return &models.User{
|
||||||
|
Id: &userID,
|
||||||
|
Username: user.Username,
|
||||||
|
Email: email,
|
||||||
|
EmailVerified: &emailVerified,
|
||||||
|
CreatedAt: &createdAt,
|
||||||
|
UpdatedAt: &updatedAt}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapDomainTagToApi(tag *domain.Tag) *models.Tag {
|
||||||
|
if tag == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tagID := openapi_types.UUID(tag.ID)
|
||||||
|
userID := openapi_types.UUID(tag.UserID)
|
||||||
|
createdAt := tag.CreatedAt
|
||||||
|
updatedAt := tag.UpdatedAt
|
||||||
|
return &models.Tag{
|
||||||
|
Id: &tagID,
|
||||||
|
UserId: &userID,
|
||||||
|
Name: tag.Name,
|
||||||
|
Color: tag.Color,
|
||||||
|
Icon: tag.Icon,
|
||||||
|
CreatedAt: &createdAt,
|
||||||
|
UpdatedAt: &updatedAt}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapDomainTodoToApi(todo *domain.Todo) *models.Todo {
|
||||||
|
if todo == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
apiSubtasks := make([]models.Subtask, len(todo.Subtasks))
|
||||||
|
for i, st := range todo.Subtasks {
|
||||||
|
mappedSubtask := mapDomainSubtaskToApi(&st)
|
||||||
|
if mappedSubtask != nil {
|
||||||
|
apiSubtasks[i] = *mappedSubtask
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tagIDs := make([]openapi_types.UUID, len(todo.TagIDs))
|
||||||
|
for i, domainID := range todo.TagIDs {
|
||||||
|
tagIDs[i] = openapi_types.UUID(domainID)
|
||||||
|
}
|
||||||
|
|
||||||
|
todoID := openapi_types.UUID(todo.ID)
|
||||||
|
userID := openapi_types.UUID(todo.UserID)
|
||||||
|
createdAt := todo.CreatedAt
|
||||||
|
updatedAt := todo.UpdatedAt
|
||||||
|
|
||||||
|
return &models.Todo{
|
||||||
|
Id: &todoID,
|
||||||
|
UserId: &userID,
|
||||||
|
Title: todo.Title,
|
||||||
|
Description: todo.Description,
|
||||||
|
Status: models.TodoStatus(todo.Status),
|
||||||
|
Deadline: todo.Deadline,
|
||||||
|
TagIds: tagIDs,
|
||||||
|
Attachments: todo.Attachments,
|
||||||
|
Subtasks: &apiSubtasks,
|
||||||
|
CreatedAt: &createdAt,
|
||||||
|
UpdatedAt: &updatedAt}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapDomainSubtaskToApi(subtask *domain.Subtask) *models.Subtask {
|
||||||
|
if subtask == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
subtaskID := openapi_types.UUID(subtask.ID)
|
||||||
|
todoID := openapi_types.UUID(subtask.TodoID)
|
||||||
|
createdAt := subtask.CreatedAt
|
||||||
|
updatedAt := subtask.UpdatedAt
|
||||||
|
|
||||||
|
return &models.Subtask{
|
||||||
|
Id: &subtaskID,
|
||||||
|
TodoId: &todoID,
|
||||||
|
Description: subtask.Description,
|
||||||
|
Completed: subtask.Completed,
|
||||||
|
CreatedAt: &createdAt,
|
||||||
|
UpdatedAt: &updatedAt}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapDomainAttachmentInfoToApi(info *domain.AttachmentInfo) *models.FileUploadResponse {
|
||||||
|
if info == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &models.FileUploadResponse{
|
||||||
|
FileId: info.FileID,
|
||||||
|
FileName: info.FileName,
|
||||||
|
FileUrl: info.FileURL,
|
||||||
|
ContentType: info.ContentType,
|
||||||
|
Size: info.Size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Auth Handlers ---
|
||||||
|
|
||||||
|
func (h *ApiHandler) SignupUserApi(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body models.SignupRequest
|
||||||
|
if !parseAndValidateBody(w, r, &body, h.logger) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
creds := service.SignupCredentials{
|
||||||
|
Username: body.Username,
|
||||||
|
Email: string(body.Email),
|
||||||
|
Password: *body.Password,
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := h.services.Auth.Signup(r.Context(), creds)
|
||||||
|
if err != nil {
|
||||||
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
SendJSONResponse(w, http.StatusCreated, mapDomainUserToApi(user), h.logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ApiHandler) LoginUserApi(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body models.LoginRequest
|
||||||
|
if !parseAndValidateBody(w, r, &body, h.logger) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
creds := service.LoginCredentials{
|
||||||
|
Email: string(body.Email),
|
||||||
|
Password: *body.Password,
|
||||||
|
}
|
||||||
|
|
||||||
|
token, _, err := h.services.Auth.Login(r.Context(), creds)
|
||||||
|
if err != nil {
|
||||||
|
SendJSONError(w, err, http.StatusUnauthorized, h.logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: h.cfg.JWT.CookieName,
|
||||||
|
Value: token,
|
||||||
|
Path: h.cfg.JWT.CookiePath,
|
||||||
|
Domain: h.cfg.JWT.CookieDomain,
|
||||||
|
Expires: time.Now().Add(time.Duration(h.cfg.JWT.ExpiryMinutes) * time.Minute),
|
||||||
|
HttpOnly: h.cfg.JWT.CookieHttpOnly,
|
||||||
|
Secure: h.cfg.JWT.CookieSecure,
|
||||||
|
SameSite: parseSameSite(h.cfg.JWT.CookieSameSite),
|
||||||
|
})
|
||||||
|
|
||||||
|
resp := models.LoginResponse{
|
||||||
|
AccessToken: token,
|
||||||
|
TokenType: "Bearer",
|
||||||
|
}
|
||||||
|
SendJSONResponse(w, http.StatusOK, resp, h.logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ApiHandler) LogoutUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: h.cfg.JWT.CookieName,
|
||||||
|
Value: "",
|
||||||
|
Path: h.cfg.JWT.CookiePath,
|
||||||
|
Domain: h.cfg.JWT.CookieDomain,
|
||||||
|
Expires: time.Unix(0, 0),
|
||||||
|
MaxAge: -1,
|
||||||
|
HttpOnly: h.cfg.JWT.CookieHttpOnly,
|
||||||
|
Secure: h.cfg.JWT.CookieSecure,
|
||||||
|
SameSite: parseSameSite(h.cfg.JWT.CookieSameSite),
|
||||||
|
})
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to parse SameSite string to http.SameSite type
|
||||||
|
func parseSameSite(s string) http.SameSite {
|
||||||
|
switch strings.ToLower(s) {
|
||||||
|
case "lax":
|
||||||
|
return http.SameSiteLaxMode
|
||||||
|
case "strict":
|
||||||
|
return http.SameSiteStrictMode
|
||||||
|
case "none":
|
||||||
|
return http.SameSiteNoneMode
|
||||||
|
default:
|
||||||
|
return http.SameSiteDefaultMode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Google OAuth Handlers ---
|
||||||
|
|
||||||
|
func (h *ApiHandler) InitiateGoogleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
oauthCfg := h.services.Auth.GetGoogleAuthConfig()
|
||||||
|
state := uuid.NewString()
|
||||||
|
|
||||||
|
signedState := auth.SignState(state, []byte(h.cfg.OAuth.Google.StateSecret))
|
||||||
|
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: auth.StateCookieName,
|
||||||
|
Value: signedState,
|
||||||
|
Path: "/",
|
||||||
|
Expires: time.Now().Add(auth.StateExpiry + 1*time.Minute),
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: h.cfg.JWT.CookieSecure,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
|
||||||
|
redirectURL := oauthCfg.AuthCodeURL(state, oauth2.AccessTypeOffline)
|
||||||
|
h.logger.Debug("Redirecting to Google OAuth", "url", redirectURL)
|
||||||
|
http.Redirect(w, r, redirectURL, http.StatusTemporaryRedirect)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ApiHandler) HandleGoogleCallback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
receivedCode := r.URL.Query().Get("code")
|
||||||
|
receivedState := r.URL.Query().Get("state")
|
||||||
|
|
||||||
|
stateCookie, err := r.Cookie(auth.StateCookieName)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.WarnContext(ctx, "OAuth state cookie missing or error", "error", err)
|
||||||
|
http.Redirect(w, r, "/login?error=state_missing", http.StatusTemporaryRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: auth.StateCookieName,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
Expires: time.Unix(0, 0),
|
||||||
|
MaxAge: -1,
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: h.cfg.JWT.CookieSecure,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
|
||||||
|
originalState, err := auth.VerifyAndExtractState(stateCookie.Value, []byte(h.cfg.OAuth.Google.StateSecret))
|
||||||
|
if err != nil {
|
||||||
|
h.logger.WarnContext(ctx, "OAuth state verification failed", "error", err, "receivedState", receivedState)
|
||||||
|
errorParam := "state_invalid"
|
||||||
|
if errors.Is(err, auth.ErrStateExpired) {
|
||||||
|
errorParam = "state_expired"
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/login?error="+errorParam, http.StatusTemporaryRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if receivedState == "" || receivedState != originalState {
|
||||||
|
h.logger.WarnContext(ctx, "OAuth state mismatch", "received", receivedState, "expected", originalState)
|
||||||
|
http.Redirect(w, r, "/login?error=state_mismatch", http.StatusTemporaryRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if receivedCode == "" {
|
||||||
|
errorDesc := r.URL.Query().Get("error_description")
|
||||||
|
h.logger.WarnContext(ctx, "Missing OAuth code parameter in callback", "error_desc", errorDesc)
|
||||||
|
errorParam := url.QueryEscape(r.URL.Query().Get("error"))
|
||||||
|
if errorParam == "" {
|
||||||
|
errorParam = "missing_code"
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/login?error="+errorParam, http.StatusTemporaryRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, user, err := h.services.Auth.HandleGoogleCallback(ctx, receivedCode)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.ErrorContext(ctx, "Google callback handling failed in service", "error", err)
|
||||||
|
errorParam := "auth_failed"
|
||||||
|
if errors.Is(err, domain.ErrConflict) {
|
||||||
|
errorParam = "auth_conflict"
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/login?error="+errorParam, http.StatusTemporaryRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: h.cfg.JWT.CookieName,
|
||||||
|
Value: token,
|
||||||
|
Path: h.cfg.JWT.CookiePath,
|
||||||
|
Domain: h.cfg.JWT.CookieDomain,
|
||||||
|
Expires: time.Now().Add(time.Duration(h.cfg.JWT.ExpiryMinutes) * time.Minute),
|
||||||
|
HttpOnly: h.cfg.JWT.CookieHttpOnly,
|
||||||
|
Secure: h.cfg.JWT.CookieSecure,
|
||||||
|
SameSite: parseSameSite(h.cfg.JWT.CookieSameSite),
|
||||||
|
})
|
||||||
|
|
||||||
|
redirectURL := "/dashboard"
|
||||||
|
h.logger.InfoContext(ctx, "Google OAuth login successful", "userId", user.ID, "email", user.Email, "redirectingTo", redirectURL)
|
||||||
|
http.Redirect(w, r, redirectURL, http.StatusTemporaryRedirect)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- User Handlers ---
|
||||||
|
|
||||||
|
func (h *ApiHandler) GetCurrentUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
logger := h.logger.With(slog.String("handler", "GetCurrentUser"))
|
||||||
|
userID, err := GetUserIDFromContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
SendJSONError(w, err, http.StatusInternalServerError, logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger = logger.With(slog.String("userId", userID.String()))
|
||||||
|
|
||||||
|
user, err := h.services.User.GetUserByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorContext(ctx, "Failed to fetch user from service", "error", err)
|
||||||
|
SendJSONError(w, err, http.StatusInternalServerError, logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
apiUser := mapDomainUserToApi(user)
|
||||||
|
if apiUser == nil {
|
||||||
|
logger.ErrorContext(ctx, "Failed to map domain user to API model")
|
||||||
|
SendJSONError(w, domain.ErrInternalServer, http.StatusInternalServerError, logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugContext(ctx, "Successfully retrieved current user")
|
||||||
|
SendJSONResponse(w, http.StatusOK, apiUser, logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ApiHandler) UpdateCurrentUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
logger := h.logger.With(slog.String("handler", "UpdateCurrentUser"))
|
||||||
|
|
||||||
|
userID, err := GetUserIDFromContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
SendJSONError(w, err, http.StatusInternalServerError, logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger = logger.With(slog.String("userId", userID.String()))
|
||||||
|
|
||||||
|
var body models.UpdateUserRequest
|
||||||
|
if !parseAndValidateBody(w, r, &body, logger) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateInput := service.UpdateUserInput{
|
||||||
|
Username: body.Username,
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedUser, err := h.services.User.UpdateUser(ctx, userID, updateInput)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorContext(ctx, "Failed to update user in service", "error", err)
|
||||||
|
SendJSONError(w, err, http.StatusInternalServerError, logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
apiUser := mapDomainUserToApi(updatedUser)
|
||||||
|
if apiUser == nil {
|
||||||
|
logger.ErrorContext(ctx, "Failed to map updated domain user to API model")
|
||||||
|
SendJSONError(w, domain.ErrInternalServer, http.StatusInternalServerError, logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.InfoContext(ctx, "Successfully updated current user")
|
||||||
|
SendJSONResponse(w, http.StatusOK, apiUser, logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tag Handlers ---
|
||||||
|
|
||||||
|
func (h *ApiHandler) CreateTag(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, err := GetUserIDFromContext(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body models.CreateTagRequest
|
||||||
|
if !parseAndValidateBody(w, r, &body, h.logger) {
|
||||||
|
// TODO: Add specific field validation checks here or rely on service layer
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
input := service.CreateTagInput{
|
||||||
|
Name: body.Name,
|
||||||
|
Color: body.Color,
|
||||||
|
Icon: body.Icon,
|
||||||
|
}
|
||||||
|
|
||||||
|
tag, err := h.services.Tag.CreateTag(r.Context(), userID, input)
|
||||||
|
if err != nil {
|
||||||
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
SendJSONResponse(w, http.StatusCreated, mapDomainTagToApi(tag), h.logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ApiHandler) ListUserTags(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, err := GetUserIDFromContext(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tags, err := h.services.Tag.ListUserTags(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
apiTags := make([]models.Tag, len(tags))
|
||||||
|
for i, tag := range tags {
|
||||||
|
apiTags[i] = *mapDomainTagToApi(&tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
SendJSONResponse(w, http.StatusOK, apiTags, h.logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ApiHandler) GetTagById(w http.ResponseWriter, r *http.Request, tagId openapi_types.UUID) {
|
||||||
|
userID, err := GetUserIDFromContext(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
domainTagID := uuid.UUID(tagId)
|
||||||
|
|
||||||
|
tag, err := h.services.Tag.GetTagByID(r.Context(), domainTagID, userID)
|
||||||
|
if err != nil {
|
||||||
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
SendJSONResponse(w, http.StatusOK, mapDomainTagToApi(tag), h.logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ApiHandler) UpdateTagById(w http.ResponseWriter, r *http.Request, tagId openapi_types.UUID) {
|
||||||
|
userID, err := GetUserIDFromContext(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
domainTagID := uuid.UUID(tagId)
|
||||||
|
|
||||||
|
var body models.UpdateTagRequest
|
||||||
|
if !parseAndValidateBody(w, r, &body, h.logger) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
input := service.UpdateTagInput{
|
||||||
|
Name: body.Name,
|
||||||
|
Color: body.Color,
|
||||||
|
Icon: body.Icon,
|
||||||
|
}
|
||||||
|
|
||||||
|
tag, err := h.services.Tag.UpdateTag(r.Context(), domainTagID, userID, input)
|
||||||
|
if err != nil {
|
||||||
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
SendJSONResponse(w, http.StatusOK, mapDomainTagToApi(tag), h.logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ApiHandler) DeleteTagById(w http.ResponseWriter, r *http.Request, tagId openapi_types.UUID) {
|
||||||
|
userID, err := GetUserIDFromContext(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
domainTagID := uuid.UUID(tagId)
|
||||||
|
|
||||||
|
err = h.services.Tag.DeleteTag(r.Context(), domainTagID, userID)
|
||||||
|
if err != nil {
|
||||||
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Todo Handlers ---
|
||||||
|
|
||||||
|
func (h *ApiHandler) CreateTodo(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, err := GetUserIDFromContext(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body models.CreateTodoRequest
|
||||||
|
if !parseAndValidateBody(w, r, &body, h.logger) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var domainTagIDs []uuid.UUID
|
||||||
|
if body.TagIds != nil {
|
||||||
|
domainTagIDs = make([]uuid.UUID, len(*body.TagIds))
|
||||||
|
for i, apiID := range *body.TagIds {
|
||||||
|
domainTagIDs[i] = uuid.UUID(apiID)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
domainTagIDs = []uuid.UUID{}
|
||||||
|
}
|
||||||
|
|
||||||
|
input := service.CreateTodoInput{
|
||||||
|
Title: body.Title,
|
||||||
|
Description: body.Description,
|
||||||
|
Deadline: body.Deadline,
|
||||||
|
TagIDs: domainTagIDs,
|
||||||
|
}
|
||||||
|
if body.Status != nil {
|
||||||
|
domainStatus := domain.TodoStatus(*body.Status)
|
||||||
|
input.Status = &domainStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
todo, err := h.services.Todo.CreateTodo(r.Context(), userID, input)
|
||||||
|
if err != nil {
|
||||||
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
apiTodo := mapDomainTodoToApi(todo)
|
||||||
|
SendJSONResponse(w, http.StatusCreated, apiTodo, h.logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ApiHandler) ListTodos(w http.ResponseWriter, r *http.Request, params ListTodosParams) {
|
||||||
|
userID, err := GetUserIDFromContext(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
input := service.ListTodosInput{
|
||||||
|
Limit: 20,
|
||||||
|
Offset: 0,
|
||||||
|
}
|
||||||
|
if params.Limit != nil {
|
||||||
|
input.Limit = *params.Limit
|
||||||
|
}
|
||||||
|
if params.Offset != nil {
|
||||||
|
input.Offset = *params.Offset
|
||||||
|
}
|
||||||
|
if params.Status != nil {
|
||||||
|
domainStatus := domain.TodoStatus(*params.Status)
|
||||||
|
input.Status = &domainStatus
|
||||||
|
}
|
||||||
|
if params.TagId != nil {
|
||||||
|
input.TagID = params.TagId
|
||||||
|
}
|
||||||
|
if params.DeadlineBefore != nil {
|
||||||
|
input.DeadlineBefore = params.DeadlineBefore
|
||||||
|
}
|
||||||
|
if params.DeadlineAfter != nil {
|
||||||
|
input.DeadlineAfter = params.DeadlineAfter
|
||||||
|
}
|
||||||
|
|
||||||
|
todos, err := h.services.Todo.ListUserTodos(r.Context(), userID, input)
|
||||||
|
if err != nil {
|
||||||
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
apiTodos := make([]models.Todo, len(todos))
|
||||||
|
for i, todo := range todos {
|
||||||
|
apiTodos[i] = *mapDomainTodoToApi(&todo)
|
||||||
|
}
|
||||||
|
|
||||||
|
SendJSONResponse(w, http.StatusOK, apiTodos, h.logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ApiHandler) GetTodoById(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) {
|
||||||
|
userID, err := GetUserIDFromContext(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
todo, err := h.services.Todo.GetTodoByID(r.Context(), todoId, userID)
|
||||||
|
if err != nil {
|
||||||
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
SendJSONResponse(w, http.StatusOK, mapDomainTodoToApi(todo), h.logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ApiHandler) UpdateTodoById(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) {
|
||||||
|
userID, err := GetUserIDFromContext(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
domainTodoID := uuid.UUID(todoId)
|
||||||
|
|
||||||
|
var body models.UpdateTodoRequest
|
||||||
|
if !parseAndValidateBody(w, r, &body, h.logger) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
input := service.UpdateTodoInput{
|
||||||
|
Title: body.Title,
|
||||||
|
Description: body.Description,
|
||||||
|
Deadline: body.Deadline,
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.Status != nil {
|
||||||
|
domainStatus := domain.TodoStatus(*body.Status)
|
||||||
|
input.Status = &domainStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.TagIds != nil {
|
||||||
|
domainTagIDs := make([]uuid.UUID, len(*body.TagIds))
|
||||||
|
for i, apiID := range *body.TagIds {
|
||||||
|
domainTagIDs[i] = uuid.UUID(apiID)
|
||||||
|
}
|
||||||
|
input.TagIDs = &domainTagIDs
|
||||||
|
}
|
||||||
|
if body.Attachments != nil {
|
||||||
|
input.Attachments = body.Attachments
|
||||||
|
}
|
||||||
|
|
||||||
|
todo, err := h.services.Todo.UpdateTodo(r.Context(), domainTodoID, userID, input)
|
||||||
|
if err != nil {
|
||||||
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
SendJSONResponse(w, http.StatusOK, mapDomainTodoToApi(todo), h.logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ApiHandler) DeleteTodoById(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) {
|
||||||
|
userID, err := GetUserIDFromContext(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.services.Todo.DeleteTodo(r.Context(), todoId, userID)
|
||||||
|
if err != nil {
|
||||||
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Subtask Handlers ---
|
||||||
|
|
||||||
|
func (h *ApiHandler) CreateSubtaskForTodo(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) {
|
||||||
|
userID, err := GetUserIDFromContext(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body models.CreateSubtaskRequest
|
||||||
|
if !parseAndValidateBody(w, r, &body, h.logger) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
input := service.CreateSubtaskInput{
|
||||||
|
Description: body.Description,
|
||||||
|
}
|
||||||
|
|
||||||
|
subtask, err := h.services.Todo.CreateSubtask(r.Context(), todoId, userID, input)
|
||||||
|
if err != nil {
|
||||||
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
SendJSONResponse(w, http.StatusCreated, mapDomainSubtaskToApi(subtask), h.logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ApiHandler) ListSubtasksForTodo(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) {
|
||||||
|
userID, err := GetUserIDFromContext(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
subtasks, err := h.services.Todo.ListSubtasks(r.Context(), todoId, userID)
|
||||||
|
if err != nil {
|
||||||
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
apiSubtasks := make([]models.Subtask, len(subtasks))
|
||||||
|
for i, st := range subtasks {
|
||||||
|
apiSubtasks[i] = *mapDomainSubtaskToApi(&st)
|
||||||
|
}
|
||||||
|
|
||||||
|
SendJSONResponse(w, http.StatusOK, apiSubtasks, h.logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ApiHandler) UpdateSubtaskById(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID, subtaskId openapi_types.UUID) {
|
||||||
|
userID, err := GetUserIDFromContext(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body models.UpdateSubtaskRequest
|
||||||
|
if !parseAndValidateBody(w, r, &body, h.logger) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
input := service.UpdateSubtaskInput{
|
||||||
|
Description: body.Description,
|
||||||
|
Completed: body.Completed,
|
||||||
|
}
|
||||||
|
|
||||||
|
subtask, err := h.services.Todo.UpdateSubtask(r.Context(), todoId, subtaskId, userID, input)
|
||||||
|
if err != nil {
|
||||||
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
SendJSONResponse(w, http.StatusOK, mapDomainSubtaskToApi(subtask), h.logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ApiHandler) DeleteSubtaskById(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID, subtaskId openapi_types.UUID) {
|
||||||
|
userID, err := GetUserIDFromContext(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.services.Todo.DeleteSubtask(r.Context(), todoId, subtaskId, userID)
|
||||||
|
if err != nil {
|
||||||
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Attachment Handlers ---
|
||||||
|
|
||||||
|
func (h *ApiHandler) UploadTodoAttachment(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) {
|
||||||
|
userID, err := GetUserIDFromContext(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = r.ParseMultipartForm(10 << 20)
|
||||||
|
if err != nil {
|
||||||
|
SendJSONError(w, fmt.Errorf("failed to parse multipart form: %w", domain.ErrBadRequest), http.StatusBadRequest, h.logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, handler, err := r.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
SendJSONError(w, fmt.Errorf("error retrieving the file from form-data: %w", domain.ErrBadRequest), http.StatusBadRequest, h.logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
fileName := handler.Filename
|
||||||
|
fileSize := handler.Size
|
||||||
|
|
||||||
|
attachmentInfo, err := h.services.Todo.AddAttachment(r.Context(), todoId, userID, fileName, fileSize, file)
|
||||||
|
if err != nil {
|
||||||
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
SendJSONResponse(w, http.StatusCreated, mapDomainAttachmentInfoToApi(attachmentInfo), h.logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ApiHandler) DeleteTodoAttachment(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID, attachmentId string) {
|
||||||
|
userID, err := GetUserIDFromContext(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.services.Todo.DeleteAttachment(r.Context(), todoId, userID, attachmentId)
|
||||||
|
if err != nil {
|
||||||
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
108
backend/internal/api/middleware.go
Normal file
108
backend/internal/api/middleware.go
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/Sosokker/todolist-backend/internal/config"
|
||||||
|
"github.com/Sosokker/todolist-backend/internal/domain"
|
||||||
|
"github.com/Sosokker/todolist-backend/internal/service"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
const UserIDKey contextKey = "userID"
|
||||||
|
|
||||||
|
var publicPaths = map[string]bool{
|
||||||
|
"/auth/signup": true,
|
||||||
|
"/auth/login": true,
|
||||||
|
"/auth/google/login": true,
|
||||||
|
"/auth/google/callback": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func AuthMiddleware(authService service.AuthService, cfg *config.Config) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requestPath := r.URL.Path
|
||||||
|
basePath := cfg.Server.BasePath
|
||||||
|
relativePath := strings.TrimPrefix(requestPath, basePath)
|
||||||
|
|
||||||
|
if _, isPublic := publicPaths[relativePath]; isPublic {
|
||||||
|
slog.DebugContext(r.Context(), "Public path accessed, skipping auth", "path", requestPath)
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenString := extractToken(r, cfg)
|
||||||
|
if tokenString == "" {
|
||||||
|
slog.WarnContext(r.Context(), "Authentication failed: missing token", "path", requestPath)
|
||||||
|
SendJSONError(w, domain.ErrUnauthorized, http.StatusUnauthorized, slog.Default())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := authService.ValidateJWT(tokenString)
|
||||||
|
if err != nil {
|
||||||
|
slog.WarnContext(r.Context(), "Authentication failed: invalid token", "error", err, "path", requestPath)
|
||||||
|
SendJSONError(w, domain.ErrUnauthorized, http.StatusUnauthorized, slog.Default())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if claims.ID == uuid.Nil {
|
||||||
|
slog.ErrorContext(r.Context(), "Authentication failed: Nil User ID in token claims", "path", requestPath)
|
||||||
|
SendJSONError(w, domain.ErrUnauthorized, http.StatusUnauthorized, slog.Default())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.WithValue(r.Context(), UserIDKey, claims.ID)
|
||||||
|
slog.DebugContext(ctx, "Authentication successful", "userId", claims.ID)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractToken(r *http.Request, cfg *config.Config) string {
|
||||||
|
bearerToken := r.Header.Get("Authorization")
|
||||||
|
if parts := strings.Split(bearerToken, " "); len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" {
|
||||||
|
slog.DebugContext(r.Context(), "Token found in Authorization header")
|
||||||
|
return parts[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
cookie, err := r.Cookie(cfg.JWT.CookieName)
|
||||||
|
if err == nil {
|
||||||
|
slog.DebugContext(r.Context(), "Token found in cookie", "cookieName", cfg.JWT.CookieName)
|
||||||
|
return cookie.Value
|
||||||
|
}
|
||||||
|
if !errors.Is(err, http.ErrNoCookie) {
|
||||||
|
slog.WarnContext(r.Context(), "Error reading auth cookie", "error", err, "cookieName", cfg.JWT.CookieName)
|
||||||
|
} else {
|
||||||
|
slog.DebugContext(r.Context(), "No token found in header or cookie")
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetUserIDFromContext(ctx context.Context) (uuid.UUID, error) {
|
||||||
|
userIDVal := ctx.Value(UserIDKey)
|
||||||
|
if userIDVal == nil {
|
||||||
|
slog.ErrorContext(ctx, "User ID not found in context. Middleware might not have run or failed.")
|
||||||
|
return uuid.Nil, fmt.Errorf("user ID not found in context: %w", domain.ErrInternalServer)
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, ok := userIDVal.(uuid.UUID)
|
||||||
|
if !ok {
|
||||||
|
slog.ErrorContext(ctx, "User ID in context has unexpected type", "type", fmt.Sprintf("%T", userIDVal))
|
||||||
|
return uuid.Nil, fmt.Errorf("user ID in context has unexpected type: %w", domain.ErrInternalServer)
|
||||||
|
}
|
||||||
|
|
||||||
|
if userID == uuid.Nil {
|
||||||
|
slog.ErrorContext(ctx, "Nil User ID found in context after authentication")
|
||||||
|
return uuid.Nil, fmt.Errorf("invalid user ID (nil) found in context: %w", domain.ErrInternalServer)
|
||||||
|
}
|
||||||
|
|
||||||
|
return userID, nil
|
||||||
|
}
|
||||||
17
backend/internal/api/oapi-codegen.cfg.yaml
Normal file
17
backend/internal/api/oapi-codegen.cfg.yaml
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
package: api
|
||||||
|
|
||||||
|
generate:
|
||||||
|
chi-server:
|
||||||
|
output: openapi_generated.go
|
||||||
|
strict-server: true
|
||||||
|
|
||||||
|
types:
|
||||||
|
output: openapi_types.go
|
||||||
|
|
||||||
|
models:
|
||||||
|
package: models
|
||||||
|
output: openapi_models_generated.go
|
||||||
|
|
||||||
|
strict-server:
|
||||||
|
package: api
|
||||||
|
output: openapi_strict_server_generated.go
|
||||||
317
backend/internal/api/openapi_types.go
Normal file
317
backend/internal/api/openapi_types.go
Normal file
@ -0,0 +1,317 @@
|
|||||||
|
// Package api provides primitives to interact with the openapi HTTP API.
|
||||||
|
//
|
||||||
|
// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.4.1 DO NOT EDIT.
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
openapi_types "github.com/oapi-codegen/runtime/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
BearerAuthScopes = "BearerAuth.Scopes"
|
||||||
|
CookieAuthScopes = "CookieAuth.Scopes"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Defines values for CreateTodoRequestStatus.
|
||||||
|
const (
|
||||||
|
CreateTodoRequestStatusCompleted CreateTodoRequestStatus = "completed"
|
||||||
|
CreateTodoRequestStatusInProgress CreateTodoRequestStatus = "in-progress"
|
||||||
|
CreateTodoRequestStatusPending CreateTodoRequestStatus = "pending"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Defines values for TodoStatus.
|
||||||
|
const (
|
||||||
|
TodoStatusCompleted TodoStatus = "completed"
|
||||||
|
TodoStatusInProgress TodoStatus = "in-progress"
|
||||||
|
TodoStatusPending TodoStatus = "pending"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Defines values for UpdateTodoRequestStatus.
|
||||||
|
const (
|
||||||
|
UpdateTodoRequestStatusCompleted UpdateTodoRequestStatus = "completed"
|
||||||
|
UpdateTodoRequestStatusInProgress UpdateTodoRequestStatus = "in-progress"
|
||||||
|
UpdateTodoRequestStatusPending UpdateTodoRequestStatus = "pending"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Defines values for ListTodosParamsStatus.
|
||||||
|
const (
|
||||||
|
ListTodosParamsStatusCompleted ListTodosParamsStatus = "completed"
|
||||||
|
ListTodosParamsStatusInProgress ListTodosParamsStatus = "in-progress"
|
||||||
|
ListTodosParamsStatusPending ListTodosParamsStatus = "pending"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateSubtaskRequest Data required to create a new Subtask.
|
||||||
|
type CreateSubtaskRequest struct {
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTagRequest Data required to create a new Tag.
|
||||||
|
type CreateTagRequest struct {
|
||||||
|
// Color Optional color code (e.g.,
|
||||||
|
Color *string `json:"color"`
|
||||||
|
|
||||||
|
// Icon Optional icon identifier.
|
||||||
|
Icon *string `json:"icon"`
|
||||||
|
|
||||||
|
// Name Name of the tag. Must be unique for the user.
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTodoRequest Data required to create a new Todo item.
|
||||||
|
type CreateTodoRequest struct {
|
||||||
|
Deadline *time.Time `json:"deadline"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
Status *CreateTodoRequestStatus `json:"status,omitempty"`
|
||||||
|
|
||||||
|
// TagIds Optional list of existing Tag IDs to associate with the new Todo. IDs must belong to the user.
|
||||||
|
TagIds *[]openapi_types.UUID `json:"tagIds,omitempty"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTodoRequestStatus defines model for CreateTodoRequest.Status.
|
||||||
|
type CreateTodoRequestStatus string
|
||||||
|
|
||||||
|
// Error Standard error response format.
|
||||||
|
type Error struct {
|
||||||
|
// Code HTTP status code or application-specific code.
|
||||||
|
Code int32 `json:"code"`
|
||||||
|
|
||||||
|
// Message Detailed error message.
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileUploadResponse Response after successfully uploading a file.
|
||||||
|
type FileUploadResponse struct {
|
||||||
|
// ContentType MIME type of the uploaded file.
|
||||||
|
ContentType string `json:"contentType"`
|
||||||
|
|
||||||
|
// FileId Unique identifier for the uploaded file.
|
||||||
|
FileId string `json:"fileId"`
|
||||||
|
|
||||||
|
// FileName Original name of the uploaded file.
|
||||||
|
FileName string `json:"fileName"`
|
||||||
|
|
||||||
|
// FileUrl URL to access the uploaded file.
|
||||||
|
FileUrl string `json:"fileUrl"`
|
||||||
|
|
||||||
|
// Size Size of the uploaded file in bytes.
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginRequest Data required for logging in via email/password.
|
||||||
|
type LoginRequest struct {
|
||||||
|
Email openapi_types.Email `json:"email"`
|
||||||
|
Password *string `json:"password,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginResponse Response containing the JWT access token for API clients. For browser clients, a cookie is typically set instead.
|
||||||
|
type LoginResponse struct {
|
||||||
|
// AccessToken JWT access token.
|
||||||
|
AccessToken string `json:"accessToken"`
|
||||||
|
|
||||||
|
// TokenType Type of the token (always Bearer).
|
||||||
|
TokenType string `json:"tokenType"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignupRequest Data required for signing up a new user via email/password.
|
||||||
|
type SignupRequest struct {
|
||||||
|
Email openapi_types.Email `json:"email"`
|
||||||
|
Password *string `json:"password,omitempty"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subtask Represents a subtask associated with a Todo item.
|
||||||
|
type Subtask struct {
|
||||||
|
// Completed Whether the subtask is completed.
|
||||||
|
Completed bool `json:"completed"`
|
||||||
|
CreatedAt *time.Time `json:"createdAt,omitempty"`
|
||||||
|
|
||||||
|
// Description Description of the subtask.
|
||||||
|
Description string `json:"description"`
|
||||||
|
Id *openapi_types.UUID `json:"id,omitempty"`
|
||||||
|
|
||||||
|
// TodoId The ID of the parent Todo item.
|
||||||
|
TodoId *openapi_types.UUID `json:"todoId,omitempty"`
|
||||||
|
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag Represents a user-defined tag for organizing Todos.
|
||||||
|
type Tag struct {
|
||||||
|
// Color Optional color associated with the tag.
|
||||||
|
Color *string `json:"color"`
|
||||||
|
CreatedAt *time.Time `json:"createdAt,omitempty"`
|
||||||
|
|
||||||
|
// Icon Optional identifier for an icon associated with the tag (e.g., 'briefcase', 'home'). Frontend maps this to actual icon display.
|
||||||
|
Icon *string `json:"icon"`
|
||||||
|
Id *openapi_types.UUID `json:"id,omitempty"`
|
||||||
|
|
||||||
|
// Name Name of the tag (e.g., "Work", "Personal"). Must be unique per user.
|
||||||
|
Name string `json:"name"`
|
||||||
|
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||||
|
|
||||||
|
// UserId The ID of the user who owns this Tag.
|
||||||
|
UserId *openapi_types.UUID `json:"userId,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Todo Represents a Todo item.
|
||||||
|
type Todo struct {
|
||||||
|
// Attachments List of identifiers (e.g., URLs or IDs) for attached files/images. Managed via upload/update endpoints.
|
||||||
|
Attachments []string `json:"attachments"`
|
||||||
|
CreatedAt *time.Time `json:"createdAt,omitempty"`
|
||||||
|
|
||||||
|
// Deadline Optional deadline for the Todo item.
|
||||||
|
Deadline *time.Time `json:"deadline"`
|
||||||
|
|
||||||
|
// Description Optional detailed description of the Todo.
|
||||||
|
Description *string `json:"description"`
|
||||||
|
Id *openapi_types.UUID `json:"id,omitempty"`
|
||||||
|
|
||||||
|
// Status Current status of the Todo item.
|
||||||
|
Status TodoStatus `json:"status"`
|
||||||
|
|
||||||
|
// Subtasks List of subtasks associated with this Todo. Usually fetched/managed via subtask endpoints.
|
||||||
|
Subtasks *[]Subtask `json:"subtasks,omitempty"`
|
||||||
|
|
||||||
|
// TagIds List of IDs of Tags associated with this Todo.
|
||||||
|
TagIds []openapi_types.UUID `json:"tagIds"`
|
||||||
|
|
||||||
|
// Title The main title or task of the Todo.
|
||||||
|
Title string `json:"title"`
|
||||||
|
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||||
|
|
||||||
|
// UserId The ID of the user who owns this Todo.
|
||||||
|
UserId *openapi_types.UUID `json:"userId,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TodoStatus Current status of the Todo item.
|
||||||
|
type TodoStatus string
|
||||||
|
|
||||||
|
// UpdateSubtaskRequest Data for updating an existing Subtask. Both fields are optional.
|
||||||
|
type UpdateSubtaskRequest struct {
|
||||||
|
Completed *bool `json:"completed,omitempty"`
|
||||||
|
Description *string `json:"description,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTagRequest Data for updating an existing Tag. All fields are optional.
|
||||||
|
type UpdateTagRequest struct {
|
||||||
|
// Color New color code.
|
||||||
|
Color *string `json:"color"`
|
||||||
|
|
||||||
|
// Icon New icon identifier.
|
||||||
|
Icon *string `json:"icon"`
|
||||||
|
|
||||||
|
// Name New name for the tag. Must be unique for the user.
|
||||||
|
Name *string `json:"name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTodoRequest Data for updating an existing Todo item. All fields are optional for partial updates.
|
||||||
|
type UpdateTodoRequest struct {
|
||||||
|
// Attachments Replace the existing list of attachment identifiers. Use upload/delete endpoints for managing actual files.
|
||||||
|
Attachments *[]string `json:"attachments,omitempty"`
|
||||||
|
Deadline *time.Time `json:"deadline"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
Status *UpdateTodoRequestStatus `json:"status,omitempty"`
|
||||||
|
|
||||||
|
// TagIds Replace the existing list of associated Tag IDs. IDs must belong to the user.
|
||||||
|
TagIds *[]openapi_types.UUID `json:"tagIds,omitempty"`
|
||||||
|
Title *string `json:"title,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTodoRequestStatus defines model for UpdateTodoRequest.Status.
|
||||||
|
type UpdateTodoRequestStatus string
|
||||||
|
|
||||||
|
// UpdateUserRequest Data for updating user details.
|
||||||
|
type UpdateUserRequest struct {
|
||||||
|
Username *string `json:"username,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// User Represents a registered user.
|
||||||
|
type User struct {
|
||||||
|
CreatedAt *time.Time `json:"createdAt,omitempty"`
|
||||||
|
Email openapi_types.Email `json:"email"`
|
||||||
|
|
||||||
|
// EmailVerified Indicates if the user's email has been verified (e.g., via OAuth or email confirmation).
|
||||||
|
EmailVerified *bool `json:"emailVerified,omitempty"`
|
||||||
|
Id *openapi_types.UUID `json:"id,omitempty"`
|
||||||
|
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BadRequest Standard error response format.
|
||||||
|
type BadRequest = Error
|
||||||
|
|
||||||
|
// Conflict Standard error response format.
|
||||||
|
type Conflict = Error
|
||||||
|
|
||||||
|
// Forbidden Standard error response format.
|
||||||
|
type Forbidden = Error
|
||||||
|
|
||||||
|
// InternalServerError Standard error response format.
|
||||||
|
type InternalServerError = Error
|
||||||
|
|
||||||
|
// NotFound Standard error response format.
|
||||||
|
type NotFound = Error
|
||||||
|
|
||||||
|
// Unauthorized Standard error response format.
|
||||||
|
type Unauthorized = Error
|
||||||
|
|
||||||
|
// ListTodosParams defines parameters for ListTodos.
|
||||||
|
type ListTodosParams struct {
|
||||||
|
// Status Filter Todos by status.
|
||||||
|
Status *ListTodosParamsStatus `form:"status,omitempty" json:"status,omitempty"`
|
||||||
|
|
||||||
|
// TagId Filter Todos by a specific Tag ID.
|
||||||
|
TagId *openapi_types.UUID `form:"tagId,omitempty" json:"tagId,omitempty"`
|
||||||
|
|
||||||
|
// DeadlineBefore Filter Todos with deadline before this date/time.
|
||||||
|
DeadlineBefore *time.Time `form:"deadline_before,omitempty" json:"deadline_before,omitempty"`
|
||||||
|
|
||||||
|
// DeadlineAfter Filter Todos with deadline after this date/time.
|
||||||
|
DeadlineAfter *time.Time `form:"deadline_after,omitempty" json:"deadline_after,omitempty"`
|
||||||
|
|
||||||
|
// Limit Maximum number of Todos to return.
|
||||||
|
Limit *int `form:"limit,omitempty" json:"limit,omitempty"`
|
||||||
|
|
||||||
|
// Offset Number of Todos to skip for pagination.
|
||||||
|
Offset *int `form:"offset,omitempty" json:"offset,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTodosParamsStatus defines parameters for ListTodos.
|
||||||
|
type ListTodosParamsStatus string
|
||||||
|
|
||||||
|
// UploadTodoAttachmentMultipartBody defines parameters for UploadTodoAttachment.
|
||||||
|
type UploadTodoAttachmentMultipartBody struct {
|
||||||
|
File openapi_types.File `json:"file"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginUserApiJSONRequestBody defines body for LoginUserApi for application/json ContentType.
|
||||||
|
type LoginUserApiJSONRequestBody = LoginRequest
|
||||||
|
|
||||||
|
// SignupUserApiJSONRequestBody defines body for SignupUserApi for application/json ContentType.
|
||||||
|
type SignupUserApiJSONRequestBody = SignupRequest
|
||||||
|
|
||||||
|
// CreateTagJSONRequestBody defines body for CreateTag for application/json ContentType.
|
||||||
|
type CreateTagJSONRequestBody = CreateTagRequest
|
||||||
|
|
||||||
|
// UpdateTagByIdJSONRequestBody defines body for UpdateTagById for application/json ContentType.
|
||||||
|
type UpdateTagByIdJSONRequestBody = UpdateTagRequest
|
||||||
|
|
||||||
|
// CreateTodoJSONRequestBody defines body for CreateTodo for application/json ContentType.
|
||||||
|
type CreateTodoJSONRequestBody = CreateTodoRequest
|
||||||
|
|
||||||
|
// UpdateTodoByIdJSONRequestBody defines body for UpdateTodoById for application/json ContentType.
|
||||||
|
type UpdateTodoByIdJSONRequestBody = UpdateTodoRequest
|
||||||
|
|
||||||
|
// UploadTodoAttachmentMultipartRequestBody defines body for UploadTodoAttachment for multipart/form-data ContentType.
|
||||||
|
type UploadTodoAttachmentMultipartRequestBody UploadTodoAttachmentMultipartBody
|
||||||
|
|
||||||
|
// CreateSubtaskForTodoJSONRequestBody defines body for CreateSubtaskForTodo for application/json ContentType.
|
||||||
|
type CreateSubtaskForTodoJSONRequestBody = CreateSubtaskRequest
|
||||||
|
|
||||||
|
// UpdateSubtaskByIdJSONRequestBody defines body for UpdateSubtaskById for application/json ContentType.
|
||||||
|
type UpdateSubtaskByIdJSONRequestBody = UpdateSubtaskRequest
|
||||||
|
|
||||||
|
// UpdateCurrentUserJSONRequestBody defines body for UpdateCurrentUser for application/json ContentType.
|
||||||
|
type UpdateCurrentUserJSONRequestBody = UpdateUserRequest
|
||||||
11
backend/internal/auth/jwt.go
Normal file
11
backend/internal/auth/jwt.go
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Claims struct {
|
||||||
|
UserID uuid.UUID `json:"user_id"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
103
backend/internal/auth/oauth.go
Normal file
103
backend/internal/auth/oauth.go
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/Sosokker/todolist-backend/internal/config"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
googleOAuth "golang.org/x/oauth2/google"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GoogleUserInfo holds user details fetched from Google.
|
||||||
|
type GoogleUserInfo struct {
|
||||||
|
ID string `json:"id"` // The unique Google ID
|
||||||
|
Email string `json:"email"` // The user's email address
|
||||||
|
VerifiedEmail bool `json:"verified_email"` // Whether Google has verified the email
|
||||||
|
Name string `json:"name"` // User's full name
|
||||||
|
GivenName string `json:"given_name"` // First name
|
||||||
|
FamilyName string `json:"family_name"` // Last name
|
||||||
|
Picture string `json:"picture"` // URL to profile picture
|
||||||
|
Locale string `json:"locale"` // User's locale (e.g., "en")
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuthProvider defines the interface for OAuth operations.
|
||||||
|
type OAuthProvider interface {
|
||||||
|
GetAuthCodeURL(state string) string
|
||||||
|
ExchangeCode(ctx context.Context, code string) (*oauth2.Token, error)
|
||||||
|
FetchUserInfo(ctx context.Context, token *oauth2.Token) (*GoogleUserInfo, error)
|
||||||
|
GetOAuth2Config() *oauth2.Config // Expose underlying config if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
// googleOAuthProvider implements OAuthProvider for Google.
|
||||||
|
type googleOAuthProvider struct {
|
||||||
|
cfg *oauth2.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGoogleOAuthProvider creates a new provider instance configured for Google.
|
||||||
|
func NewGoogleOAuthProvider(appCfg *config.Config) OAuthProvider {
|
||||||
|
return &googleOAuthProvider{
|
||||||
|
cfg: &oauth2.Config{
|
||||||
|
ClientID: appCfg.OAuth.Google.ClientID,
|
||||||
|
ClientSecret: appCfg.OAuth.Google.ClientSecret,
|
||||||
|
RedirectURL: appCfg.OAuth.Google.RedirectURL,
|
||||||
|
Scopes: appCfg.OAuth.Google.Scopes,
|
||||||
|
Endpoint: googleOAuth.Endpoint,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAuthCodeURL generates the URL for Google's consent page.
|
||||||
|
func (g *googleOAuthProvider) GetAuthCodeURL(state string) string {
|
||||||
|
// Add options like AccessTypeOffline to get a refresh token,
|
||||||
|
authURL := g.cfg.AuthCodeURL(state, oauth2.AccessTypeOffline /*, oauth2.ApprovalForce, oauth2.SetAuthURLParam("prompt", "select_account") */)
|
||||||
|
return authURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExchangeCode exchanges the authorization code for an access token and refresh token.
|
||||||
|
func (g *googleOAuthProvider) ExchangeCode(ctx context.Context, code string) (*oauth2.Token, error) {
|
||||||
|
token, err := g.cfg.Exchange(ctx, code)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to exchange google auth code '%s': %w", code, err)
|
||||||
|
}
|
||||||
|
if !token.Valid() {
|
||||||
|
return nil, fmt.Errorf("exchanged token is invalid")
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchUserInfo uses the access token to get user details from Google's UserInfo endpoint.
|
||||||
|
func (g *googleOAuthProvider) FetchUserInfo(ctx context.Context, token *oauth2.Token) (*GoogleUserInfo, error) {
|
||||||
|
client := g.cfg.Client(ctx, token)
|
||||||
|
|
||||||
|
userInfoURL := "https://www.googleapis.com/oauth2/v3/userinfo" // v3 is common
|
||||||
|
resp, err := client.Get(userInfoURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to request google user info from %s: %w", userInfoURL, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("google user info request failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
var userInfo GoogleUserInfo
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&userInfo); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode google user info response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if userInfo.ID == "" || userInfo.Email == "" {
|
||||||
|
return nil, fmt.Errorf("invalid user info received from google (missing ID or Email)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &userInfo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOAuth2Config returns the underlying oauth2.Config object.
|
||||||
|
func (g *googleOAuthProvider) GetOAuth2Config() *oauth2.Config {
|
||||||
|
return g.cfg
|
||||||
|
}
|
||||||
72
backend/internal/auth/state.go
Normal file
72
backend/internal/auth/state.go
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
StateCookieName = "oauth_state"
|
||||||
|
StateSeparator = "."
|
||||||
|
StateExpiry = 10 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrInvalidStateFormat = errors.New("invalid state format")
|
||||||
|
var ErrInvalidStateMAC = errors.New("invalid state MAC (tampered?)")
|
||||||
|
var ErrStateExpired = errors.New("state expired")
|
||||||
|
|
||||||
|
// signState generates a timestamped and HMAC-signed state string.
|
||||||
|
// Format: <original_state>.<timestamp>.<signature>
|
||||||
|
func SignState(stateValue string, secretKey []byte) string {
|
||||||
|
if len(secretKey) == 0 {
|
||||||
|
// Should not happen in production if configured correctly
|
||||||
|
panic("OAuth state signing secret cannot be empty")
|
||||||
|
}
|
||||||
|
timestamp := time.Now().Unix()
|
||||||
|
message := fmt.Sprintf("%s%s%d", stateValue, StateSeparator, timestamp)
|
||||||
|
|
||||||
|
mac := hmac.New(sha256.New, secretKey)
|
||||||
|
mac.Write([]byte(message))
|
||||||
|
signature := hex.EncodeToString(mac.Sum(nil))
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s%s%s", message, StateSeparator, signature)
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyAndExtractState checks the signature and expiry, returning the original state value.
|
||||||
|
func VerifyAndExtractState(signedState string, secretKey []byte) (string, error) {
|
||||||
|
if len(secretKey) == 0 {
|
||||||
|
panic("OAuth state signing secret cannot be empty")
|
||||||
|
}
|
||||||
|
parts := strings.Split(signedState, StateSeparator)
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return "", ErrInvalidStateFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
originalState := parts[0]
|
||||||
|
timestampStr := parts[1]
|
||||||
|
receivedSignature := parts[2]
|
||||||
|
|
||||||
|
message := fmt.Sprintf("%s%s%s", originalState, StateSeparator, timestampStr)
|
||||||
|
mac := hmac.New(sha256.New, secretKey)
|
||||||
|
mac.Write([]byte(message))
|
||||||
|
expectedSignature := hex.EncodeToString(mac.Sum(nil))
|
||||||
|
|
||||||
|
if !hmac.Equal([]byte(receivedSignature), []byte(expectedSignature)) {
|
||||||
|
return "", ErrInvalidStateMAC
|
||||||
|
}
|
||||||
|
|
||||||
|
var timestamp int64
|
||||||
|
if _, err := fmt.Sscan(timestampStr, ×tamp); err != nil {
|
||||||
|
return "", fmt.Errorf("invalid timestamp in state: %w", ErrInvalidStateFormat)
|
||||||
|
}
|
||||||
|
if time.Since(time.Unix(timestamp, 0)) > StateExpiry {
|
||||||
|
return "", ErrStateExpired
|
||||||
|
}
|
||||||
|
|
||||||
|
return originalState, nil
|
||||||
|
}
|
||||||
55
backend/internal/cache/cache.go
vendored
Normal file
55
backend/internal/cache/cache.go
vendored
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
package cache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Sosokker/todolist-backend/internal/config"
|
||||||
|
gocache "github.com/patrickmn/go-cache"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cache defines the interface for a caching layer
|
||||||
|
type Cache interface {
|
||||||
|
Get(ctx context.Context, key string) (interface{}, bool)
|
||||||
|
Set(ctx context.Context, key string, value interface{}, duration time.Duration)
|
||||||
|
Delete(ctx context.Context, key string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// memoryCache is an in-memory implementation of the Cache interface
|
||||||
|
type memoryCache struct {
|
||||||
|
client *gocache.Cache
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMemoryCache creates a new in-memory cache
|
||||||
|
func NewMemoryCache(cfg config.CacheConfig, logger *slog.Logger) Cache {
|
||||||
|
c := gocache.New(cfg.DefaultExpiration, cfg.CleanupInterval)
|
||||||
|
logger.Info("In-memory cache initialized",
|
||||||
|
"defaultExpiration", cfg.DefaultExpiration,
|
||||||
|
"cleanupInterval", cfg.CleanupInterval)
|
||||||
|
return &memoryCache{
|
||||||
|
client: c,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memoryCache) Get(ctx context.Context, key string) (interface{}, bool) {
|
||||||
|
val, found := m.client.Get(key)
|
||||||
|
if found {
|
||||||
|
m.logger.DebugContext(ctx, "Cache hit", "key", key)
|
||||||
|
} else {
|
||||||
|
m.logger.DebugContext(ctx, "Cache miss", "key", key)
|
||||||
|
}
|
||||||
|
return val, found
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memoryCache) Set(ctx context.Context, key string, value interface{}, duration time.Duration) {
|
||||||
|
m.logger.DebugContext(ctx, "Setting cache", "key", key, "duration", duration)
|
||||||
|
m.client.Set(key, value, duration) // duration=0 means use default, -1 means never expire (DefaultExpiration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memoryCache) Delete(ctx context.Context, key string) {
|
||||||
|
m.logger.DebugContext(ctx, "Deleting cache", "key", key)
|
||||||
|
m.client.Delete(key)
|
||||||
|
}
|
||||||
135
backend/internal/config/config.go
Normal file
135
backend/internal/config/config.go
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Server ServerConfig
|
||||||
|
Database DatabaseConfig
|
||||||
|
Log LogConfig
|
||||||
|
JWT JWTConfig
|
||||||
|
OAuth OAuthConfig
|
||||||
|
Cache CacheConfig
|
||||||
|
Storage StorageConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServerConfig struct {
|
||||||
|
Port int `mapstructure:"port"`
|
||||||
|
ReadTimeout time.Duration `mapstructure:"readTimeout"`
|
||||||
|
WriteTimeout time.Duration `mapstructure:"writeTimeout"`
|
||||||
|
IdleTimeout time.Duration `mapstructure:"idleTimeout"`
|
||||||
|
BasePath string `mapstructure:"basePath"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StorageConfig struct {
|
||||||
|
Type string `mapstructure:"type"` // "local", "gcs"
|
||||||
|
Local LocalStorageConfig `mapstructure:"local"`
|
||||||
|
GCS GCSStorageConfig `mapstructure:"gcs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LocalStorageConfig struct {
|
||||||
|
Path string `mapstructure:"path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GCSStorageConfig struct {
|
||||||
|
BucketName string `mapstructure:"bucketName"`
|
||||||
|
CredentialsFile string `mapstructure:"credentialsFile"`
|
||||||
|
BaseDir string `mapstructure:"baseDir"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DatabaseConfig struct {
|
||||||
|
URL string `mapstructure:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogConfig struct {
|
||||||
|
Level string `mapstructure:"level"`
|
||||||
|
Format string `mapstructure:"format"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type JWTConfig struct {
|
||||||
|
Secret string `mapstructure:"secret"`
|
||||||
|
ExpiryMinutes int `mapstructure:"expiryMinutes"`
|
||||||
|
CookieName string `mapstructure:"cookieName"`
|
||||||
|
CookieDomain string `mapstructure:"cookieDomain"`
|
||||||
|
CookiePath string `mapstructure:"cookiePath"`
|
||||||
|
CookieSecure bool `mapstructure:"cookieSecure"`
|
||||||
|
CookieHttpOnly bool `mapstructure:"cookieHttpOnly"`
|
||||||
|
CookieSameSite string `mapstructure:"cookieSameSite"` // None, Lax, Strict
|
||||||
|
}
|
||||||
|
|
||||||
|
type GoogleOAuthConfig struct {
|
||||||
|
ClientID string `mapstructure:"clientId"`
|
||||||
|
ClientSecret string `mapstructure:"clientSecret"`
|
||||||
|
RedirectURL string `mapstructure:"redirectUrl"`
|
||||||
|
Scopes []string `mapstructure:"scopes"`
|
||||||
|
StateSecret string `mapstructure:"stateSecret"` // For signing state cookie
|
||||||
|
}
|
||||||
|
|
||||||
|
type OAuthConfig struct {
|
||||||
|
Google GoogleOAuthConfig `mapstructure:"google"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CacheConfig struct {
|
||||||
|
DefaultExpiration time.Duration `mapstructure:"defaultExpiration"`
|
||||||
|
CleanupInterval time.Duration `mapstructure:"cleanupInterval"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadConfig(path string) (*Config, error) {
|
||||||
|
viper.SetConfigName("config") // name of config file (without extension)
|
||||||
|
viper.SetConfigType("yaml") // or viper.SetConfigType("YAML")
|
||||||
|
viper.AddConfigPath(path) // optionally look for config in the working directory or specified path
|
||||||
|
viper.AddConfigPath(".") // look for config in the working directory
|
||||||
|
|
||||||
|
viper.AutomaticEnv()
|
||||||
|
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||||
|
|
||||||
|
// Set defaults
|
||||||
|
viper.SetDefault("server.port", 8080)
|
||||||
|
viper.SetDefault("server.readTimeout", 15*time.Second)
|
||||||
|
viper.SetDefault("server.writeTimeout", 15*time.Second)
|
||||||
|
viper.SetDefault("server.idleTimeout", 60*time.Second)
|
||||||
|
viper.SetDefault("log.level", "info")
|
||||||
|
viper.SetDefault("log.format", "json")
|
||||||
|
viper.SetDefault("jwt.expiryMinutes", 60)
|
||||||
|
viper.SetDefault("jwt.cookieName", "jwt_token")
|
||||||
|
viper.SetDefault("jwt.cookieHttpOnly", true)
|
||||||
|
viper.SetDefault("jwt.cookieSameSite", "Lax")
|
||||||
|
viper.SetDefault("cache.defaultExpiration", 5*time.Minute)
|
||||||
|
viper.SetDefault("cache.cleanupInterval", 10*time.Minute)
|
||||||
|
viper.SetDefault("storage.type", "local") // Default to local storage
|
||||||
|
viper.SetDefault("storage.local.path", "./uploads")
|
||||||
|
|
||||||
|
err := viper.ReadInConfig()
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("Error reading config file, using defaults/env vars", "error", err)
|
||||||
|
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg Config
|
||||||
|
err = viper.Unmarshal(&cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.JWT.Secret == "" || strings.Contains(cfg.JWT.Secret, "unsafe") {
|
||||||
|
slog.Warn("JWT_SECRET environment variable not set or is using unsafe default. THIS IS INSECURE FOR PRODUCTION.")
|
||||||
|
}
|
||||||
|
if cfg.OAuth.Google.ClientID == "" || strings.Contains(cfg.OAuth.Google.ClientID, "_ENV") {
|
||||||
|
slog.Warn("OAUTH_GOOGLE_CLIENTID environment variable not set or is using placeholder.")
|
||||||
|
}
|
||||||
|
if cfg.OAuth.Google.ClientSecret == "" || strings.Contains(cfg.OAuth.Google.ClientSecret, "_ENV") {
|
||||||
|
slog.Warn("OAUTH_GOOGLE_CLIENTSECRET environment variable not set or is using placeholder.")
|
||||||
|
}
|
||||||
|
if cfg.OAuth.Google.StateSecret == "" || strings.Contains(cfg.OAuth.Google.StateSecret, "unsafe") {
|
||||||
|
slog.Warn("OAUTH_GOOGLE_STATESECRET environment variable not set or is using unsafe default. THIS IS INSECURE FOR PRODUCTION.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
10
backend/internal/domain/attachment.go
Normal file
10
backend/internal/domain/attachment.go
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
// Attachment info returned by AddAttachment
|
||||||
|
type AttachmentInfo struct {
|
||||||
|
FileID string `json:"fileId"`
|
||||||
|
FileName string `json:"fileName"`
|
||||||
|
FileURL string `json:"fileUrl"`
|
||||||
|
ContentType string `json:"contentType"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
}
|
||||||
13
backend/internal/domain/errors.go
Normal file
13
backend/internal/domain/errors.go
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNotFound = errors.New("resource not found")
|
||||||
|
ErrForbidden = errors.New("user does not have permission")
|
||||||
|
ErrBadRequest = errors.New("invalid input")
|
||||||
|
ErrConflict = errors.New("resource conflict (e.g., duplicate)")
|
||||||
|
ErrUnauthorized = errors.New("authentication required or failed")
|
||||||
|
ErrInternalServer = errors.New("internal server error")
|
||||||
|
ErrValidation = errors.New("validation failed")
|
||||||
|
)
|
||||||
16
backend/internal/domain/subtask.go
Normal file
16
backend/internal/domain/subtask.go
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Subtask struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
TodoID uuid.UUID `json:"todoId"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Completed bool `json:"completed"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
17
backend/internal/domain/tag.go
Normal file
17
backend/internal/domain/tag.go
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Tag struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
UserID uuid.UUID `json:"userId"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Color *string `json:"color"`
|
||||||
|
Icon *string `json:"icon"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
45
backend/internal/domain/todo.go
Normal file
45
backend/internal/domain/todo.go
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TodoStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusPending TodoStatus = "pending"
|
||||||
|
StatusInProgress TodoStatus = "in-progress"
|
||||||
|
StatusCompleted TodoStatus = "completed"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Todo struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
UserID uuid.UUID `json:"userId"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description *string `json:"description"` // Nullable
|
||||||
|
Status TodoStatus `json:"status"`
|
||||||
|
Deadline *time.Time `json:"deadline"` // Nullable
|
||||||
|
TagIDs []uuid.UUID `json:"tagIds"` // Populated after fetching
|
||||||
|
Tags []Tag `json:"-"` // Can hold full tag objects if needed, loaded separately
|
||||||
|
Attachments []string `json:"attachments"` // Stores identifiers (e.g., file IDs or URLs)
|
||||||
|
Subtasks []Subtask `json:"subtasks"` // Populated after fetching
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NullStringToStringPtr(ns sql.NullString) *string {
|
||||||
|
if ns.Valid {
|
||||||
|
return &ns.String
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NullTimeToTimePtr(nt sql.NullTime) *time.Time {
|
||||||
|
if nt.Valid {
|
||||||
|
return &nt.Time
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
18
backend/internal/domain/user.go
Normal file
18
backend/internal/domain/user.go
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
PasswordHash string `json:"-"`
|
||||||
|
EmailVerified bool `json:"emailVerified"`
|
||||||
|
GoogleID *string `json:"-"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
42
backend/internal/repository/db.go
Normal file
42
backend/internal/repository/db.go
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Sosokker/todolist-backend/internal/config"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewConnectionPool(cfg config.DatabaseConfig) (*pgxpool.Pool, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
poolConfig, err := pgxpool.ParseConfig(cfg.URL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to parse database URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure pool settings
|
||||||
|
// poolConfig.MaxConns = 10
|
||||||
|
// poolConfig.MinConns = 2
|
||||||
|
// poolConfig.MaxConnIdleTime = 5 * time.Minute
|
||||||
|
// poolConfig.MaxConnLifetime = 1 * time.Hour
|
||||||
|
// poolConfig.HealthCheckPeriod = 1 * time.Minute
|
||||||
|
|
||||||
|
pool, err := pgxpool.NewWithConfig(ctx, poolConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to create connection pool: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pool.Ping(ctx)
|
||||||
|
if err != nil {
|
||||||
|
pool.Close()
|
||||||
|
return nil, fmt.Errorf("unable to connect to database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("Database connection pool established")
|
||||||
|
return pool, nil
|
||||||
|
}
|
||||||
98
backend/internal/repository/interfaces.go
Normal file
98
backend/internal/repository/interfaces.go
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Sosokker/todolist-backend/internal/domain"
|
||||||
|
db "github.com/Sosokker/todolist-backend/internal/repository/sqlc/generated"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Common arguments for list methods
|
||||||
|
type ListParams struct {
|
||||||
|
Limit int
|
||||||
|
Offset int
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserRepository interface {
|
||||||
|
Create(ctx context.Context, user *domain.User) (*domain.User, error)
|
||||||
|
GetByID(ctx context.Context, id uuid.UUID) (*domain.User, error)
|
||||||
|
GetByEmail(ctx context.Context, email string) (*domain.User, error)
|
||||||
|
GetByGoogleID(ctx context.Context, googleID string) (*domain.User, error)
|
||||||
|
Update(ctx context.Context, id uuid.UUID, updateData *domain.User) (*domain.User, error)
|
||||||
|
Delete(ctx context.Context, id uuid.UUID) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type TagRepository interface {
|
||||||
|
Create(ctx context.Context, tag *domain.Tag) (*domain.Tag, error)
|
||||||
|
GetByID(ctx context.Context, id, userID uuid.UUID) (*domain.Tag, error)
|
||||||
|
GetByIDs(ctx context.Context, ids []uuid.UUID, userID uuid.UUID) ([]domain.Tag, error)
|
||||||
|
ListByUser(ctx context.Context, userID uuid.UUID) ([]domain.Tag, error)
|
||||||
|
Update(ctx context.Context, id, userID uuid.UUID, updateData *domain.Tag) (*domain.Tag, error)
|
||||||
|
Delete(ctx context.Context, id, userID uuid.UUID) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListTodosParams struct {
|
||||||
|
UserID uuid.UUID
|
||||||
|
Status *domain.TodoStatus
|
||||||
|
TagID *uuid.UUID
|
||||||
|
DeadlineBefore *time.Time
|
||||||
|
DeadlineAfter *time.Time
|
||||||
|
ListParams // Embed pagination
|
||||||
|
}
|
||||||
|
|
||||||
|
type TodoRepository interface {
|
||||||
|
Create(ctx context.Context, todo *domain.Todo) (*domain.Todo, error)
|
||||||
|
GetByID(ctx context.Context, id, userID uuid.UUID) (*domain.Todo, error)
|
||||||
|
ListByUser(ctx context.Context, params ListTodosParams) ([]domain.Todo, error)
|
||||||
|
Update(ctx context.Context, id, userID uuid.UUID, updateData *domain.Todo) (*domain.Todo, error)
|
||||||
|
Delete(ctx context.Context, id, userID uuid.UUID) error
|
||||||
|
// Tag associations
|
||||||
|
AddTag(ctx context.Context, todoID, tagID uuid.UUID) error
|
||||||
|
RemoveTag(ctx context.Context, todoID, tagID uuid.UUID) error
|
||||||
|
SetTags(ctx context.Context, todoID uuid.UUID, tagIDs []uuid.UUID) error
|
||||||
|
GetTags(ctx context.Context, todoID uuid.UUID) ([]domain.Tag, error)
|
||||||
|
// Attachment associations (using simple string array)
|
||||||
|
AddAttachment(ctx context.Context, todoID, userID uuid.UUID, attachmentID string) error
|
||||||
|
RemoveAttachment(ctx context.Context, todoID, userID uuid.UUID, attachmentID string) error
|
||||||
|
SetAttachments(ctx context.Context, todoID, userID uuid.UUID, attachmentIDs []string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubtaskRepository interface {
|
||||||
|
Create(ctx context.Context, subtask *domain.Subtask) (*domain.Subtask, error)
|
||||||
|
GetByID(ctx context.Context, id, userID uuid.UUID) (*domain.Subtask, error)
|
||||||
|
ListByTodo(ctx context.Context, todoID, userID uuid.UUID) ([]domain.Subtask, error)
|
||||||
|
Update(ctx context.Context, id, userID uuid.UUID, updateData *domain.Subtask) (*domain.Subtask, error)
|
||||||
|
Delete(ctx context.Context, id, userID uuid.UUID) error
|
||||||
|
GetParentTodoID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transactioner interface allows services to run operations within a DB transaction
|
||||||
|
type Transactioner interface {
|
||||||
|
BeginTx(ctx context.Context) (*db.Queries, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RepositoryRegistry bundles all repositories together, often useful for dependency injection
|
||||||
|
type RepositoryRegistry struct {
|
||||||
|
UserRepo UserRepository
|
||||||
|
TagRepo TagRepository
|
||||||
|
TodoRepo TodoRepository
|
||||||
|
SubtaskRepo SubtaskRepository
|
||||||
|
*db.Queries
|
||||||
|
Pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRepositoryRegistry creates a new registry
|
||||||
|
func NewRepositoryRegistry(pool *pgxpool.Pool) *RepositoryRegistry {
|
||||||
|
queries := db.New(pool)
|
||||||
|
return &RepositoryRegistry{
|
||||||
|
UserRepo: NewPgxUserRepository(queries),
|
||||||
|
TagRepo: NewPgxTagRepository(queries),
|
||||||
|
TodoRepo: NewPgxTodoRepository(queries, pool),
|
||||||
|
SubtaskRepo: NewPgxSubtaskRepository(queries),
|
||||||
|
Queries: queries,
|
||||||
|
Pool: pool,
|
||||||
|
}
|
||||||
|
}
|
||||||
17
backend/internal/repository/sqlc/queries/attachments.sql
Normal file
17
backend/internal/repository/sqlc/queries/attachments.sql
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
-- name: CreateAttachment :one
|
||||||
|
INSERT INTO attachments (todo_id, user_id, file_name, storage_path, content_type, size)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: GetAttachmentByID :one
|
||||||
|
SELECT * FROM attachments
|
||||||
|
WHERE id = $1 AND user_id = $2 LIMIT 1;
|
||||||
|
|
||||||
|
-- name: ListAttachmentsForTodo :many
|
||||||
|
SELECT * FROM attachments
|
||||||
|
WHERE todo_id = $1 AND user_id = $2
|
||||||
|
ORDER BY uploaded_at ASC;
|
||||||
|
|
||||||
|
-- name: DeleteAttachment :exec
|
||||||
|
DELETE FROM attachments
|
||||||
|
WHERE id = $1 AND user_id = $2;
|
||||||
36
backend/internal/repository/sqlc/queries/subtasks.sql
Normal file
36
backend/internal/repository/sqlc/queries/subtasks.sql
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
-- name: CreateSubtask :one
|
||||||
|
INSERT INTO subtasks (todo_id, description, completed)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: GetSubtaskByID :one
|
||||||
|
-- We need to join to check ownership via the parent todo
|
||||||
|
SELECT s.* FROM subtasks s
|
||||||
|
JOIN todos t ON s.todo_id = t.id
|
||||||
|
WHERE s.id = $1 AND t.user_id = $2 LIMIT 1;
|
||||||
|
|
||||||
|
-- name: ListSubtasksForTodo :many
|
||||||
|
SELECT s.* FROM subtasks s
|
||||||
|
JOIN todos t ON s.todo_id = t.id
|
||||||
|
WHERE s.todo_id = $1 AND t.user_id = $2
|
||||||
|
ORDER BY s.created_at ASC;
|
||||||
|
|
||||||
|
-- name: UpdateSubtask :one
|
||||||
|
-- Need to join to check ownership before updating
|
||||||
|
UPDATE subtasks s
|
||||||
|
SET
|
||||||
|
description = COALESCE(sqlc.narg(description), s.description),
|
||||||
|
completed = COALESCE(sqlc.narg(completed), s.completed)
|
||||||
|
FROM todos t -- Include todos table in FROM clause for WHERE condition
|
||||||
|
WHERE s.id = $1 AND s.todo_id = t.id AND t.user_id = $2
|
||||||
|
RETURNING s.*; -- Return columns from subtasks (aliased as s)
|
||||||
|
|
||||||
|
-- name: DeleteSubtask :exec
|
||||||
|
-- Need owner check before deleting
|
||||||
|
DELETE FROM subtasks s
|
||||||
|
USING todos t
|
||||||
|
WHERE s.id = $1 AND s.todo_id = t.id AND t.user_id = $2;
|
||||||
|
|
||||||
|
-- name: GetTodoIDForSubtask :one
|
||||||
|
-- Helper to get parent todo ID for authorization checks in service layer if needed
|
||||||
|
SELECT todo_id FROM subtasks WHERE id = $1;
|
||||||
30
backend/internal/repository/sqlc/queries/tags.sql
Normal file
30
backend/internal/repository/sqlc/queries/tags.sql
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
-- name: CreateTag :one
|
||||||
|
INSERT INTO tags (user_id, name, color, icon)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: GetTagByID :one
|
||||||
|
SELECT * FROM tags
|
||||||
|
WHERE id = $1 AND user_id = $2 LIMIT 1;
|
||||||
|
|
||||||
|
-- name: ListUserTags :many
|
||||||
|
SELECT * FROM tags
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY created_at DESC;
|
||||||
|
|
||||||
|
-- name: UpdateTag :one
|
||||||
|
UPDATE tags
|
||||||
|
SET
|
||||||
|
name = COALESCE(sqlc.narg(name), name),
|
||||||
|
color = sqlc.narg(color), -- Allow setting color to NULL
|
||||||
|
icon = sqlc.narg(icon) -- Allow setting icon to NULL
|
||||||
|
WHERE id = $1 AND user_id = $2
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: DeleteTag :exec
|
||||||
|
DELETE FROM tags
|
||||||
|
WHERE id = $1 AND user_id = $2;
|
||||||
|
|
||||||
|
-- name: GetTagsByIDs :many
|
||||||
|
SELECT * FROM tags
|
||||||
|
WHERE id = ANY(sqlc.arg(tag_ids)::uuid[]) AND user_id = $1;
|
||||||
18
backend/internal/repository/sqlc/queries/todo_tags.sql
Normal file
18
backend/internal/repository/sqlc/queries/todo_tags.sql
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
-- name: AddTagToTodo :exec
|
||||||
|
INSERT INTO todo_tags (todo_id, tag_id)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (todo_id, tag_id) DO NOTHING; -- Ignore if already exists
|
||||||
|
|
||||||
|
-- name: RemoveTagFromTodo :exec
|
||||||
|
DELETE FROM todo_tags
|
||||||
|
WHERE todo_id = $1 AND tag_id = $2;
|
||||||
|
|
||||||
|
-- name: RemoveAllTagsFromTodo :exec
|
||||||
|
DELETE FROM todo_tags
|
||||||
|
WHERE todo_id = $1;
|
||||||
|
|
||||||
|
-- name: GetTagsForTodo :many
|
||||||
|
SELECT t.*
|
||||||
|
FROM tags t
|
||||||
|
JOIN todo_tags tt ON t.id = tt.tag_id
|
||||||
|
WHERE tt.todo_id = $1;
|
||||||
47
backend/internal/repository/sqlc/queries/todos.sql
Normal file
47
backend/internal/repository/sqlc/queries/todos.sql
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
-- name: CreateTodo :one
|
||||||
|
INSERT INTO todos (user_id, title, description, status, deadline)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: GetTodoByID :one
|
||||||
|
SELECT * FROM todos
|
||||||
|
WHERE id = $1 AND user_id = $2 LIMIT 1;
|
||||||
|
|
||||||
|
-- name: ListUserTodos :many
|
||||||
|
SELECT t.* FROM todos t
|
||||||
|
LEFT JOIN todo_tags tt ON t.id = tt.todo_id
|
||||||
|
WHERE
|
||||||
|
t.user_id = sqlc.arg('user_id') -- Use sqlc.arg for required params
|
||||||
|
AND (sqlc.narg('status_filter')::todo_status IS NULL OR t.status = sqlc.narg('status_filter'))
|
||||||
|
AND (sqlc.narg('tag_id_filter')::uuid IS NULL OR tt.tag_id = sqlc.narg('tag_id_filter'))
|
||||||
|
AND (sqlc.narg('deadline_before_filter')::timestamptz IS NULL OR t.deadline < sqlc.narg('deadline_before_filter'))
|
||||||
|
AND (sqlc.narg('deadline_after_filter')::timestamptz IS NULL OR t.deadline > sqlc.narg('deadline_after_filter'))
|
||||||
|
GROUP BY t.id -- Still needed due to LEFT JOIN potentially multiplying rows if a todo has multiple tags
|
||||||
|
ORDER BY t.created_at DESC -- Or your desired order
|
||||||
|
LIMIT sqlc.arg('limit')
|
||||||
|
OFFSET sqlc.arg('offset');
|
||||||
|
|
||||||
|
-- name: UpdateTodo :one
|
||||||
|
UPDATE todos
|
||||||
|
SET
|
||||||
|
title = COALESCE(sqlc.narg(title), title),
|
||||||
|
description = sqlc.narg(description), -- Allow setting description to NULL
|
||||||
|
status = COALESCE(sqlc.narg(status), status),
|
||||||
|
deadline = sqlc.narg(deadline), -- Allow setting deadline to NULL
|
||||||
|
attachments = COALESCE(sqlc.narg(attachments), attachments)
|
||||||
|
WHERE id = $1 AND user_id = $2
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: DeleteTodo :exec
|
||||||
|
DELETE FROM todos
|
||||||
|
WHERE id = $1 AND user_id = $2;
|
||||||
|
|
||||||
|
-- name: AddAttachmentToTodo :exec
|
||||||
|
UPDATE todos
|
||||||
|
SET attachments = array_append(attachments, $1)
|
||||||
|
WHERE id = $2 AND user_id = $3;
|
||||||
|
|
||||||
|
-- name: RemoveAttachmentFromTodo :exec
|
||||||
|
UPDATE todos
|
||||||
|
SET attachments = array_remove(attachments, $1)
|
||||||
|
WHERE id = $2 AND user_id = $3;
|
||||||
31
backend/internal/repository/sqlc/queries/users.sql
Normal file
31
backend/internal/repository/sqlc/queries/users.sql
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
-- name: CreateUser :one
|
||||||
|
INSERT INTO users (username, email, password_hash, email_verified, google_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: GetUserByID :one
|
||||||
|
SELECT * FROM users
|
||||||
|
WHERE id = $1 LIMIT 1;
|
||||||
|
|
||||||
|
-- name: GetUserByEmail :one
|
||||||
|
SELECT * FROM users
|
||||||
|
WHERE email = $1 LIMIT 1;
|
||||||
|
|
||||||
|
-- name: GetUserByGoogleID :one
|
||||||
|
SELECT * FROM users
|
||||||
|
WHERE google_id = $1 LIMIT 1;
|
||||||
|
|
||||||
|
-- name: UpdateUser :one
|
||||||
|
UPDATE users
|
||||||
|
SET
|
||||||
|
username = COALESCE(sqlc.narg(username), username),
|
||||||
|
email = COALESCE(sqlc.narg(email), email),
|
||||||
|
email_verified = COALESCE(sqlc.narg(email_verified), email_verified),
|
||||||
|
google_id = COALESCE(sqlc.narg(google_id), google_id)
|
||||||
|
-- password_hash update should be handled separately if needed
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: DeleteUser :exec
|
||||||
|
DELETE FROM users
|
||||||
|
WHERE id = $1;
|
||||||
154
backend/internal/repository/subtask_repo.go
Normal file
154
backend/internal/repository/subtask_repo.go
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/Sosokker/todolist-backend/internal/domain"
|
||||||
|
db "github.com/Sosokker/todolist-backend/internal/repository/sqlc/generated"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
type pgxSubtaskRepository struct {
|
||||||
|
q *db.Queries
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPgxSubtaskRepository(queries *db.Queries) SubtaskRepository {
|
||||||
|
return &pgxSubtaskRepository{q: queries}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mapping functions ---
|
||||||
|
func mapDbSubtaskToDomain(d db.Subtask) *domain.Subtask {
|
||||||
|
return &domain.Subtask{
|
||||||
|
ID: d.ID,
|
||||||
|
TodoID: d.TodoID,
|
||||||
|
Description: d.Description,
|
||||||
|
Completed: d.Completed,
|
||||||
|
CreatedAt: d.CreatedAt,
|
||||||
|
UpdatedAt: d.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapDbSubtasksToDomain(ds []db.Subtask) []domain.Subtask {
|
||||||
|
out := make([]domain.Subtask, len(ds))
|
||||||
|
for i, d := range ds {
|
||||||
|
out[i] = *mapDbSubtaskToDomain(d)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Repository Methods ---
|
||||||
|
|
||||||
|
func (r *pgxSubtaskRepository) Create(
|
||||||
|
ctx context.Context,
|
||||||
|
subtask *domain.Subtask,
|
||||||
|
) (*domain.Subtask, error) {
|
||||||
|
params := db.CreateSubtaskParams{
|
||||||
|
TodoID: subtask.TodoID,
|
||||||
|
Description: subtask.Description,
|
||||||
|
Completed: subtask.Completed,
|
||||||
|
}
|
||||||
|
d, err := r.q.CreateSubtask(ctx, params)
|
||||||
|
if err != nil {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) && pgErr.Code == "23503" {
|
||||||
|
return nil, fmt.Errorf("parent todo %s not found: %w", subtask.TodoID, domain.ErrBadRequest)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to create subtask: %w", err)
|
||||||
|
}
|
||||||
|
return mapDbSubtaskToDomain(d), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgxSubtaskRepository) GetByID(
|
||||||
|
ctx context.Context,
|
||||||
|
id, userID uuid.UUID,
|
||||||
|
) (*domain.Subtask, error) {
|
||||||
|
d, err := r.q.GetSubtaskByID(ctx, db.GetSubtaskByIDParams{
|
||||||
|
ID: id,
|
||||||
|
UserID: userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, domain.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to get subtask: %w", err)
|
||||||
|
}
|
||||||
|
return mapDbSubtaskToDomain(d), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgxSubtaskRepository) ListByTodo(
|
||||||
|
ctx context.Context,
|
||||||
|
todoID, userID uuid.UUID,
|
||||||
|
) ([]domain.Subtask, error) {
|
||||||
|
ds, err := r.q.ListSubtasksForTodo(ctx, db.ListSubtasksForTodoParams{
|
||||||
|
TodoID: todoID,
|
||||||
|
UserID: userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return []domain.Subtask{}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to list subtasks: %w", err)
|
||||||
|
}
|
||||||
|
return mapDbSubtasksToDomain(ds), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgxSubtaskRepository) Update(
|
||||||
|
ctx context.Context,
|
||||||
|
id, userID uuid.UUID,
|
||||||
|
updateData *domain.Subtask,
|
||||||
|
) (*domain.Subtask, error) {
|
||||||
|
params := db.UpdateSubtaskParams{
|
||||||
|
ID: id,
|
||||||
|
UserID: userID,
|
||||||
|
Description: sql.NullString{
|
||||||
|
String: updateData.Description,
|
||||||
|
Valid: updateData.Description != "",
|
||||||
|
},
|
||||||
|
Completed: pgtype.Bool{
|
||||||
|
Bool: updateData.Completed,
|
||||||
|
Valid: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
d, err := r.q.UpdateSubtask(ctx, params)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, domain.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to update subtask: %w", err)
|
||||||
|
}
|
||||||
|
return mapDbSubtaskToDomain(d), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgxSubtaskRepository) Delete(
|
||||||
|
ctx context.Context,
|
||||||
|
id, userID uuid.UUID,
|
||||||
|
) error {
|
||||||
|
if err := r.q.DeleteSubtask(ctx, db.DeleteSubtaskParams{
|
||||||
|
ID: id,
|
||||||
|
UserID: userID,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("failed to delete subtask: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgxSubtaskRepository) GetParentTodoID(
|
||||||
|
ctx context.Context,
|
||||||
|
id uuid.UUID,
|
||||||
|
) (uuid.UUID, error) {
|
||||||
|
todoID, err := r.q.GetTodoIDForSubtask(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return uuid.Nil, domain.ErrNotFound
|
||||||
|
}
|
||||||
|
return uuid.Nil, fmt.Errorf("failed to get parent todo id: %w", err)
|
||||||
|
}
|
||||||
|
return todoID, nil
|
||||||
|
}
|
||||||
163
backend/internal/repository/tag_repo.go
Normal file
163
backend/internal/repository/tag_repo.go
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/Sosokker/todolist-backend/internal/domain"
|
||||||
|
db "github.com/Sosokker/todolist-backend/internal/repository/sqlc/generated"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
type pgxTagRepository struct {
|
||||||
|
q *db.Queries
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPgxTagRepository(queries *db.Queries) TagRepository {
|
||||||
|
return &pgxTagRepository{q: queries}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pgTextFromPtr(s *string) pgtype.Text {
|
||||||
|
if s == nil {
|
||||||
|
return pgtype.Text{}
|
||||||
|
}
|
||||||
|
return pgtype.Text{String: *s, Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullStringFromText(t pgtype.Text) sql.NullString {
|
||||||
|
return sql.NullString{String: t.String, Valid: t.Valid}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapDbTagToDomainTag(dbTag db.Tag) *domain.Tag {
|
||||||
|
return &domain.Tag{
|
||||||
|
ID: dbTag.ID,
|
||||||
|
UserID: dbTag.UserID,
|
||||||
|
Name: dbTag.Name,
|
||||||
|
Color: domain.NullStringToStringPtr(nullStringFromText(dbTag.Color)),
|
||||||
|
Icon: domain.NullStringToStringPtr(nullStringFromText(dbTag.Icon)),
|
||||||
|
CreatedAt: dbTag.CreatedAt,
|
||||||
|
UpdatedAt: dbTag.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapDbTagsToDomainTags(dbTags []db.Tag) []domain.Tag {
|
||||||
|
tags := make([]domain.Tag, len(dbTags))
|
||||||
|
for i, t := range dbTags {
|
||||||
|
tags[i] = *mapDbTagToDomainTag(t)
|
||||||
|
}
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgxTagRepository) Create(
|
||||||
|
ctx context.Context,
|
||||||
|
tag *domain.Tag,
|
||||||
|
) (*domain.Tag, error) {
|
||||||
|
params := db.CreateTagParams{
|
||||||
|
UserID: tag.UserID,
|
||||||
|
Name: tag.Name,
|
||||||
|
Color: pgTextFromPtr(tag.Color),
|
||||||
|
Icon: pgTextFromPtr(tag.Icon),
|
||||||
|
}
|
||||||
|
|
||||||
|
dbTag, err := r.q.CreateTag(ctx, params)
|
||||||
|
if err != nil {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||||
|
return nil, fmt.Errorf("tag name '%s' already exists: %w", tag.Name, domain.ErrConflict)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to create tag: %w", err)
|
||||||
|
}
|
||||||
|
return mapDbTagToDomainTag(dbTag), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgxTagRepository) GetByID(
|
||||||
|
ctx context.Context,
|
||||||
|
id, userID uuid.UUID,
|
||||||
|
) (*domain.Tag, error) {
|
||||||
|
dbTag, err := r.q.GetTagByID(ctx, db.GetTagByIDParams{ID: id, UserID: userID})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, domain.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to get tag by id: %w", err)
|
||||||
|
}
|
||||||
|
return mapDbTagToDomainTag(dbTag), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgxTagRepository) GetByIDs(
|
||||||
|
ctx context.Context,
|
||||||
|
ids []uuid.UUID,
|
||||||
|
userID uuid.UUID,
|
||||||
|
) ([]domain.Tag, error) {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return []domain.Tag{}, nil
|
||||||
|
}
|
||||||
|
dbTags, err := r.q.GetTagsByIDs(ctx, db.GetTagsByIDsParams{
|
||||||
|
UserID: userID,
|
||||||
|
TagIds: ids,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return []domain.Tag{}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to get tags by ids: %w", err)
|
||||||
|
}
|
||||||
|
return mapDbTagsToDomainTags(dbTags), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgxTagRepository) ListByUser(
|
||||||
|
ctx context.Context,
|
||||||
|
userID uuid.UUID,
|
||||||
|
) ([]domain.Tag, error) {
|
||||||
|
dbTags, err := r.q.ListUserTags(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return []domain.Tag{}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to list user tags: %w", err)
|
||||||
|
}
|
||||||
|
return mapDbTagsToDomainTags(dbTags), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgxTagRepository) Update(
|
||||||
|
ctx context.Context,
|
||||||
|
id, userID uuid.UUID,
|
||||||
|
updateData *domain.Tag,
|
||||||
|
) (*domain.Tag, error) {
|
||||||
|
if _, err := r.GetByID(ctx, id, userID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
params := db.UpdateTagParams{
|
||||||
|
ID: id,
|
||||||
|
UserID: userID,
|
||||||
|
Name: pgtype.Text{String: updateData.Name, Valid: true},
|
||||||
|
Color: pgTextFromPtr(updateData.Color),
|
||||||
|
Icon: pgTextFromPtr(updateData.Icon),
|
||||||
|
}
|
||||||
|
|
||||||
|
dbTag, err := r.q.UpdateTag(ctx, params)
|
||||||
|
if err != nil {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||||
|
return nil, fmt.Errorf("tag name '%s' already exists: %w", updateData.Name, domain.ErrConflict)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to update tag: %w", err)
|
||||||
|
}
|
||||||
|
return mapDbTagToDomainTag(dbTag), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgxTagRepository) Delete(
|
||||||
|
ctx context.Context,
|
||||||
|
id, userID uuid.UUID,
|
||||||
|
) error {
|
||||||
|
if err := r.q.DeleteTag(ctx, db.DeleteTagParams{ID: id, UserID: userID}); err != nil {
|
||||||
|
return fmt.Errorf("failed to delete tag: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
329
backend/internal/repository/todo_repo.go
Normal file
329
backend/internal/repository/todo_repo.go
Normal file
@ -0,0 +1,329 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/Sosokker/todolist-backend/internal/domain"
|
||||||
|
db "github.com/Sosokker/todolist-backend/internal/repository/sqlc/generated"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type pgxTodoRepository struct {
|
||||||
|
q *db.Queries
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
// Consider adding a TagRepository dependency here for batch loading if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPgxTodoRepository(queries *db.Queries, pool *pgxpool.Pool) TodoRepository {
|
||||||
|
return &pgxTodoRepository{q: queries, pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mapping functions ---
|
||||||
|
|
||||||
|
func mapDbTodoToDomain(dbTodo db.Todo) *domain.Todo {
|
||||||
|
return &domain.Todo{
|
||||||
|
ID: dbTodo.ID,
|
||||||
|
UserID: dbTodo.UserID,
|
||||||
|
Title: dbTodo.Title,
|
||||||
|
Description: domain.NullStringToStringPtr(dbTodo.Description),
|
||||||
|
Status: domain.TodoStatus(dbTodo.Status),
|
||||||
|
Deadline: dbTodo.Deadline,
|
||||||
|
Attachments: dbTodo.Attachments,
|
||||||
|
CreatedAt: dbTodo.CreatedAt,
|
||||||
|
UpdatedAt: dbTodo.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapDbTagToDomain(dbTag db.Tag) domain.Tag {
|
||||||
|
return domain.Tag{
|
||||||
|
ID: dbTag.ID,
|
||||||
|
UserID: dbTag.UserID,
|
||||||
|
Name: dbTag.Name,
|
||||||
|
Color: domain.NullStringToStringPtr(nullStringFromText(dbTag.Color)),
|
||||||
|
Icon: domain.NullStringToStringPtr(nullStringFromText(dbTag.Icon)),
|
||||||
|
CreatedAt: dbTag.CreatedAt,
|
||||||
|
UpdatedAt: dbTag.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ――― TodoRepository methods ―――
|
||||||
|
|
||||||
|
func (r *pgxTodoRepository) Create(
|
||||||
|
ctx context.Context,
|
||||||
|
todo *domain.Todo,
|
||||||
|
) (*domain.Todo, error) {
|
||||||
|
params := db.CreateTodoParams{
|
||||||
|
UserID: todo.UserID,
|
||||||
|
Title: todo.Title,
|
||||||
|
Description: sql.NullString{String: derefString(todo.Description), Valid: todo.Description != nil},
|
||||||
|
Status: db.TodoStatus(todo.Status),
|
||||||
|
Deadline: todo.Deadline,
|
||||||
|
}
|
||||||
|
dbTodo, err := r.q.CreateTodo(ctx, params)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create todo: %w", err)
|
||||||
|
}
|
||||||
|
return mapDbTodoToDomain(dbTodo), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgxTodoRepository) GetByID(
|
||||||
|
ctx context.Context,
|
||||||
|
id, userID uuid.UUID,
|
||||||
|
) (*domain.Todo, error) {
|
||||||
|
dbTodo, err := r.q.GetTodoByID(ctx, db.GetTodoByIDParams{ID: id, UserID: userID})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, domain.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to get todo: %w", err)
|
||||||
|
}
|
||||||
|
return mapDbTodoToDomain(dbTodo), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgxTodoRepository) ListByUser(
|
||||||
|
ctx context.Context,
|
||||||
|
params ListTodosParams,
|
||||||
|
) ([]domain.Todo, error) {
|
||||||
|
|
||||||
|
sqlcParams := db.ListUserTodosParams{
|
||||||
|
UserID: params.UserID,
|
||||||
|
Limit: int32(params.Limit),
|
||||||
|
Offset: int32(params.Offset),
|
||||||
|
StatusFilter: db.NullTodoStatus{Valid: false},
|
||||||
|
TagIDFilter: pgtype.UUID{Valid: false},
|
||||||
|
DeadlineBeforeFilter: nil,
|
||||||
|
DeadlineAfterFilter: nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.Status != nil {
|
||||||
|
sqlcParams.StatusFilter = db.NullTodoStatus{
|
||||||
|
TodoStatus: db.TodoStatus(*params.Status),
|
||||||
|
Valid: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.TagID != nil {
|
||||||
|
sqlcParams.TagIDFilter = pgtype.UUID{
|
||||||
|
Bytes: *params.TagID,
|
||||||
|
Valid: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.DeadlineBefore != nil {
|
||||||
|
sqlcParams.DeadlineBeforeFilter = params.DeadlineBefore
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.DeadlineAfter != nil {
|
||||||
|
sqlcParams.DeadlineAfterFilter = params.DeadlineAfter
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call the regenerated sqlc function with the correctly populated struct
|
||||||
|
dbTodos, err := r.q.ListUserTodos(ctx, sqlcParams)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return []domain.Todo{}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to list todos: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
todos := make([]domain.Todo, len(dbTodos))
|
||||||
|
for i, t := range dbTodos {
|
||||||
|
mappedTodo := mapDbTodoToDomain(t)
|
||||||
|
if mappedTodo != nil {
|
||||||
|
todos[i] = *mappedTodo
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("failed to map database todo at index %d", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return todos, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgxTodoRepository) Update(
|
||||||
|
ctx context.Context,
|
||||||
|
id, userID uuid.UUID,
|
||||||
|
updateData *domain.Todo,
|
||||||
|
) (*domain.Todo, error) {
|
||||||
|
if _, err := r.GetByID(ctx, id, userID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
params := db.UpdateTodoParams{
|
||||||
|
ID: id,
|
||||||
|
UserID: userID,
|
||||||
|
Title: pgtype.Text{String: updateData.Title, Valid: true},
|
||||||
|
Description: sql.NullString{String: derefString(updateData.Description), Valid: updateData.Description != nil},
|
||||||
|
Status: db.NullTodoStatus{TodoStatus: db.TodoStatus(updateData.Status), Valid: true},
|
||||||
|
Deadline: updateData.Deadline,
|
||||||
|
Attachments: updateData.Attachments,
|
||||||
|
}
|
||||||
|
|
||||||
|
dbTodo, err := r.q.UpdateTodo(ctx, params)
|
||||||
|
if err != nil {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) && pgErr.Code == "23503" {
|
||||||
|
return nil, fmt.Errorf("foreign key violation: %w", domain.ErrBadRequest)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to update todo: %w", err)
|
||||||
|
}
|
||||||
|
return mapDbTodoToDomain(dbTodo), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgxTodoRepository) Delete(
|
||||||
|
ctx context.Context,
|
||||||
|
id, userID uuid.UUID,
|
||||||
|
) error {
|
||||||
|
if err := r.q.DeleteTodo(ctx, db.DeleteTodoParams{ID: id, UserID: userID}); err != nil {
|
||||||
|
return fmt.Errorf("failed to delete todo: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tag Associations ---
|
||||||
|
|
||||||
|
func (r *pgxTodoRepository) AddTag(
|
||||||
|
ctx context.Context,
|
||||||
|
todoID, tagID uuid.UUID,
|
||||||
|
) error {
|
||||||
|
if err := r.q.AddTagToTodo(ctx, db.AddTagToTodoParams{TodoID: todoID, TagID: tagID}); err != nil {
|
||||||
|
return fmt.Errorf("failed to add tag: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgxTodoRepository) RemoveTag(
|
||||||
|
ctx context.Context,
|
||||||
|
todoID, tagID uuid.UUID,
|
||||||
|
) error {
|
||||||
|
if err := r.q.RemoveTagFromTodo(ctx, db.RemoveTagFromTodoParams{TodoID: todoID, TagID: tagID}); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove tag: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgxTodoRepository) SetTags(
|
||||||
|
ctx context.Context,
|
||||||
|
todoID uuid.UUID,
|
||||||
|
tagIDs []uuid.UUID,
|
||||||
|
) error {
|
||||||
|
tx, err := r.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin tx: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
qtx := r.q.WithTx(tx)
|
||||||
|
if err := qtx.RemoveAllTagsFromTodo(ctx, todoID); err != nil {
|
||||||
|
return fmt.Errorf("remove existing tags: %w", err)
|
||||||
|
}
|
||||||
|
for _, tID := range tagIDs {
|
||||||
|
if err := qtx.AddTagToTodo(ctx, db.AddTagToTodoParams{TodoID: todoID, TagID: tID}); err != nil {
|
||||||
|
return fmt.Errorf("add tag %s: %w", tID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return fmt.Errorf("commit tags tx: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgxTodoRepository) GetTags(
|
||||||
|
ctx context.Context,
|
||||||
|
todoID uuid.UUID,
|
||||||
|
) ([]domain.Tag, error) {
|
||||||
|
dbTags, err := r.q.GetTagsForTodo(ctx, todoID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return []domain.Tag{}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to get tags: %w", err)
|
||||||
|
}
|
||||||
|
tags := make([]domain.Tag, len(dbTags))
|
||||||
|
for i, t := range dbTags {
|
||||||
|
tags[i] = mapDbTagToDomain(t)
|
||||||
|
}
|
||||||
|
return tags, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Attachments (String Identifiers in Array) ---
|
||||||
|
|
||||||
|
func (r *pgxTodoRepository) AddAttachment(
|
||||||
|
ctx context.Context,
|
||||||
|
todoID, userID uuid.UUID,
|
||||||
|
attachmentID string,
|
||||||
|
) error {
|
||||||
|
if _, err := r.GetByID(ctx, todoID, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := r.q.AddAttachmentToTodo(ctx, db.AddAttachmentToTodoParams{
|
||||||
|
ArrayAppend: attachmentID,
|
||||||
|
ID: todoID,
|
||||||
|
UserID: userID,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("failed to add attachment: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgxTodoRepository) RemoveAttachment(
|
||||||
|
ctx context.Context,
|
||||||
|
todoID, userID uuid.UUID,
|
||||||
|
attachmentID string,
|
||||||
|
) error {
|
||||||
|
if _, err := r.GetByID(ctx, todoID, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := r.q.RemoveAttachmentFromTodo(ctx, db.RemoveAttachmentFromTodoParams{
|
||||||
|
ArrayRemove: attachmentID,
|
||||||
|
ID: todoID,
|
||||||
|
UserID: userID,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove attachment: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgxTodoRepository) SetAttachments(
|
||||||
|
ctx context.Context,
|
||||||
|
todoID, userID uuid.UUID,
|
||||||
|
attachmentIDs []string,
|
||||||
|
) error {
|
||||||
|
_, err := r.GetByID(ctx, todoID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
updateParams := db.UpdateTodoParams{
|
||||||
|
ID: todoID,
|
||||||
|
UserID: userID,
|
||||||
|
Attachments: attachmentIDs,
|
||||||
|
}
|
||||||
|
_, err = r.q.UpdateTodo(ctx, updateParams)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to set attachments using UpdateTodo: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ― Helpers ―
|
||||||
|
|
||||||
|
func derefString(s *string) string {
|
||||||
|
if s != nil {
|
||||||
|
return *s
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(slice []string, val string) bool {
|
||||||
|
for _, s := range slice {
|
||||||
|
if s == val {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
156
backend/internal/repository/user_repo.go
Normal file
156
backend/internal/repository/user_repo.go
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/Sosokker/todolist-backend/internal/domain"
|
||||||
|
db "github.com/Sosokker/todolist-backend/internal/repository/sqlc/generated"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
type pgxUserRepository struct {
|
||||||
|
q *db.Queries
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPgxUserRepository(queries *db.Queries) UserRepository {
|
||||||
|
return &pgxUserRepository{q: queries}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mapDbUserToDomain converts a generated User → domain.User
|
||||||
|
func mapDbUserToDomain(u db.User) *domain.User {
|
||||||
|
var googleID *string
|
||||||
|
if u.GoogleID.Valid {
|
||||||
|
googleID = &u.GoogleID.String
|
||||||
|
}
|
||||||
|
return &domain.User{
|
||||||
|
ID: u.ID,
|
||||||
|
Username: u.Username,
|
||||||
|
Email: u.Email,
|
||||||
|
PasswordHash: u.PasswordHash,
|
||||||
|
EmailVerified: u.EmailVerified,
|
||||||
|
GoogleID: googleID,
|
||||||
|
CreatedAt: u.CreatedAt,
|
||||||
|
UpdatedAt: u.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgxUserRepository) Create(
|
||||||
|
ctx context.Context,
|
||||||
|
user *domain.User,
|
||||||
|
) (*domain.User, error) {
|
||||||
|
var pgGoogleID pgtype.Text
|
||||||
|
if user.GoogleID != nil {
|
||||||
|
pgGoogleID = pgtype.Text{String: *user.GoogleID, Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
dbUser, err := r.q.CreateUser(ctx, db.CreateUserParams{
|
||||||
|
Username: user.Username,
|
||||||
|
Email: user.Email,
|
||||||
|
PasswordHash: user.PasswordHash,
|
||||||
|
EmailVerified: user.EmailVerified,
|
||||||
|
GoogleID: pgGoogleID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||||
|
return nil, domain.ErrConflict
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return mapDbUserToDomain(dbUser), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgxUserRepository) GetByID(
|
||||||
|
ctx context.Context,
|
||||||
|
id uuid.UUID,
|
||||||
|
) (*domain.User, error) {
|
||||||
|
dbUser, err := r.q.GetUserByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, domain.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return mapDbUserToDomain(dbUser), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgxUserRepository) GetByEmail(
|
||||||
|
ctx context.Context,
|
||||||
|
email string,
|
||||||
|
) (*domain.User, error) {
|
||||||
|
dbUser, err := r.q.GetUserByEmail(ctx, email)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, domain.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return mapDbUserToDomain(dbUser), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgxUserRepository) GetByGoogleID(
|
||||||
|
ctx context.Context,
|
||||||
|
googleID string,
|
||||||
|
) (*domain.User, error) {
|
||||||
|
pgGoogleID := pgtype.Text{String: googleID, Valid: true}
|
||||||
|
dbUser, err := r.q.GetUserByGoogleID(ctx, pgGoogleID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, domain.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return mapDbUserToDomain(dbUser), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgxUserRepository) Update(
|
||||||
|
ctx context.Context,
|
||||||
|
id uuid.UUID,
|
||||||
|
u *domain.User,
|
||||||
|
) (*domain.User, error) {
|
||||||
|
if _, err := r.GetByID(ctx, id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var username pgtype.Text
|
||||||
|
if u.Username != "" {
|
||||||
|
username = pgtype.Text{String: u.Username, Valid: true}
|
||||||
|
}
|
||||||
|
var email pgtype.Text
|
||||||
|
if u.Email != "" {
|
||||||
|
email = pgtype.Text{String: u.Email, Valid: true}
|
||||||
|
}
|
||||||
|
emailVerified := pgtype.Bool{Bool: u.EmailVerified, Valid: true}
|
||||||
|
|
||||||
|
var pgGoogleID pgtype.Text
|
||||||
|
if u.GoogleID != nil {
|
||||||
|
pgGoogleID = pgtype.Text{String: *u.GoogleID, Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
dbUser, err := r.q.UpdateUser(ctx, db.UpdateUserParams{
|
||||||
|
ID: id,
|
||||||
|
Username: username,
|
||||||
|
Email: email,
|
||||||
|
EmailVerified: emailVerified,
|
||||||
|
GoogleID: pgGoogleID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||||
|
return nil, domain.ErrConflict
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return mapDbUserToDomain(dbUser), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgxUserRepository) Delete(
|
||||||
|
ctx context.Context,
|
||||||
|
id uuid.UUID,
|
||||||
|
) error {
|
||||||
|
return r.q.DeleteUser(ctx, id)
|
||||||
|
}
|
||||||
258
backend/internal/service/auth_service.go
Normal file
258
backend/internal/service/auth_service.go
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Sosokker/todolist-backend/internal/auth"
|
||||||
|
"github.com/Sosokker/todolist-backend/internal/config"
|
||||||
|
"github.com/Sosokker/todolist-backend/internal/domain"
|
||||||
|
"github.com/Sosokker/todolist-backend/internal/repository"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type authService struct {
|
||||||
|
userRepo repository.UserRepository
|
||||||
|
cfg *config.Config
|
||||||
|
googleOAuthProv auth.OAuthProvider
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuthService(repo repository.UserRepository, cfg *config.Config) AuthService {
|
||||||
|
logger := slog.Default().With("service", "auth")
|
||||||
|
googleProvider := auth.NewGoogleOAuthProvider(cfg)
|
||||||
|
return &authService{
|
||||||
|
userRepo: repo,
|
||||||
|
cfg: cfg,
|
||||||
|
googleOAuthProv: googleProvider,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *authService) Signup(ctx context.Context, creds SignupCredentials) (*domain.User, error) {
|
||||||
|
if err := ValidateSignupInput(creds); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(creds.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "Failed to hash password", "error", err)
|
||||||
|
return nil, domain.ErrInternalServer
|
||||||
|
}
|
||||||
|
|
||||||
|
newUser := &domain.User{
|
||||||
|
Username: creds.Username,
|
||||||
|
Email: creds.Email,
|
||||||
|
PasswordHash: string(hashedPassword),
|
||||||
|
EmailVerified: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
createdUser, err := s.userRepo.Create(ctx, newUser)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, domain.ErrConflict) {
|
||||||
|
_, emailErr := s.userRepo.GetByEmail(ctx, creds.Email)
|
||||||
|
if emailErr == nil {
|
||||||
|
return nil, fmt.Errorf("email already exists: %w", domain.ErrConflict)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("username already exists: %w", domain.ErrConflict)
|
||||||
|
}
|
||||||
|
slog.ErrorContext(ctx, "Failed to create user in db", "error", err)
|
||||||
|
return nil, domain.ErrInternalServer
|
||||||
|
}
|
||||||
|
|
||||||
|
return createdUser, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *authService) Login(ctx context.Context, creds LoginCredentials) (string, *domain.User, error) {
|
||||||
|
if err := ValidateLoginInput(creds); err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.userRepo.GetByEmail(ctx, creds.Email)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, domain.ErrNotFound) {
|
||||||
|
return "", nil, fmt.Errorf("invalid email or password: %w", domain.ErrUnauthorized)
|
||||||
|
}
|
||||||
|
slog.ErrorContext(ctx, "Failed to get user by email", "error", err)
|
||||||
|
return "", nil, domain.ErrInternalServer
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.PasswordHash == "" && user.GoogleID != nil {
|
||||||
|
return "", nil, fmt.Errorf("please log in using Google: %w", domain.ErrUnauthorized)
|
||||||
|
}
|
||||||
|
if user.PasswordHash == "" {
|
||||||
|
slog.ErrorContext(ctx, "User found with empty password hash", "userId", user.ID)
|
||||||
|
return "", nil, fmt.Errorf("account error, please contact support: %w", domain.ErrInternalServer)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(creds.Password))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
|
||||||
|
return "", nil, fmt.Errorf("invalid email or password: %w", domain.ErrUnauthorized)
|
||||||
|
}
|
||||||
|
slog.ErrorContext(ctx, "Error comparing password hash", "error", err, "userId", user.ID)
|
||||||
|
return "", nil, domain.ErrInternalServer
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := s.GenerateJWT(user)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return token, user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *authService) GenerateJWT(user *domain.User) (string, error) {
|
||||||
|
expirationTime := time.Now().Add(time.Duration(s.cfg.JWT.ExpiryMinutes) * time.Minute)
|
||||||
|
claims := &auth.Claims{
|
||||||
|
UserID: user.ID,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(expirationTime),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
Subject: user.ID.String(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
tokenString, err := token.SignedString([]byte(s.cfg.JWT.Secret))
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to sign JWT token", "error", err, "userId", user.ID)
|
||||||
|
return "", domain.ErrInternalServer
|
||||||
|
}
|
||||||
|
return tokenString, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *authService) ValidateJWT(tokenString string) (*domain.User, error) {
|
||||||
|
claims := &auth.Claims{}
|
||||||
|
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||||
|
}
|
||||||
|
return []byte(s.cfg.JWT.Secret), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, jwt.ErrTokenExpired) {
|
||||||
|
return nil, fmt.Errorf("token has expired: %w", domain.ErrUnauthorized)
|
||||||
|
}
|
||||||
|
if errors.Is(err, jwt.ErrTokenMalformed) {
|
||||||
|
return nil, fmt.Errorf("token is malformed: %w", domain.ErrUnauthorized)
|
||||||
|
}
|
||||||
|
slog.Warn("JWT validation failed", "error", err)
|
||||||
|
return nil, fmt.Errorf("invalid token: %w", domain.ErrUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !token.Valid {
|
||||||
|
return nil, fmt.Errorf("invalid token: %w", domain.ErrUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.userRepo.GetByID(context.Background(), claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, domain.ErrNotFound) {
|
||||||
|
return nil, fmt.Errorf("user associated with token not found: %w", domain.ErrUnauthorized)
|
||||||
|
}
|
||||||
|
slog.Error("Failed to fetch user for valid JWT", "error", err, "userId", claims.UserID)
|
||||||
|
return nil, domain.ErrInternalServer
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *authService) GetGoogleAuthConfig() *oauth2.Config {
|
||||||
|
return s.googleOAuthProv.GetOAuth2Config()
|
||||||
|
}
|
||||||
|
|
||||||
|
type GoogleUserInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
VerifiedEmail bool `json:"verified_email"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *authService) HandleGoogleCallback(ctx context.Context, code string) (string, *domain.User, error) {
|
||||||
|
token, err := s.googleOAuthProv.ExchangeCode(ctx, code)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.ErrorContext(ctx, "Failed to exchange google auth code via provider", "error", err)
|
||||||
|
return "", nil, fmt.Errorf("google auth exchange failed: %w", domain.ErrUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
userInfo, err := s.googleOAuthProv.FetchUserInfo(ctx, token)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.ErrorContext(ctx, "Failed to fetch google user info via provider", "error", err)
|
||||||
|
return "", nil, fmt.Errorf("failed to get user info from google: %w", domain.ErrUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !userInfo.VerifiedEmail {
|
||||||
|
return "", nil, fmt.Errorf("google email not verified: %w", domain.ErrUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.userRepo.GetByGoogleID(ctx, userInfo.ID)
|
||||||
|
if err != nil && !errors.Is(err, domain.ErrNotFound) {
|
||||||
|
slog.ErrorContext(ctx, "Failed to check user by google ID", "error", err, "googleId", userInfo.ID)
|
||||||
|
return "", nil, domain.ErrInternalServer
|
||||||
|
}
|
||||||
|
|
||||||
|
if user != nil {
|
||||||
|
jwtToken, jwtErr := s.GenerateJWT(user)
|
||||||
|
if jwtErr != nil {
|
||||||
|
return "", nil, jwtErr
|
||||||
|
}
|
||||||
|
return jwtToken, user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err = s.userRepo.GetByEmail(ctx, userInfo.Email)
|
||||||
|
if err != nil && !errors.Is(err, domain.ErrNotFound) {
|
||||||
|
slog.ErrorContext(ctx, "Failed to check user by email during google callback", "error", err, "email", userInfo.Email)
|
||||||
|
return "", nil, domain.ErrInternalServer
|
||||||
|
}
|
||||||
|
|
||||||
|
if user != nil {
|
||||||
|
if user.GoogleID != nil && *user.GoogleID != userInfo.ID {
|
||||||
|
slog.WarnContext(ctx, "User email associated with different Google ID", "userId", user.ID, "existingGoogleId", *user.GoogleID, "newGoogleId", userInfo.ID)
|
||||||
|
return "", nil, fmt.Errorf("email already linked to a different Google account: %w", domain.ErrConflict)
|
||||||
|
}
|
||||||
|
if user.GoogleID == nil {
|
||||||
|
updateData := &domain.User{GoogleID: &userInfo.ID, EmailVerified: true}
|
||||||
|
updatedUser, updateErr := s.userRepo.Update(ctx, user.ID, updateData)
|
||||||
|
if updateErr != nil {
|
||||||
|
slog.ErrorContext(ctx, "Failed to link Google ID to existing user", "error", updateErr, "userId", user.ID)
|
||||||
|
return "", nil, domain.ErrInternalServer
|
||||||
|
}
|
||||||
|
user = updatedUser
|
||||||
|
}
|
||||||
|
|
||||||
|
jwtToken, jwtErr := s.GenerateJWT(user)
|
||||||
|
if jwtErr != nil {
|
||||||
|
return "", nil, jwtErr
|
||||||
|
}
|
||||||
|
return jwtToken, user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
newUser := &domain.User{
|
||||||
|
Username: userInfo.Name,
|
||||||
|
Email: userInfo.Email,
|
||||||
|
PasswordHash: "",
|
||||||
|
EmailVerified: true,
|
||||||
|
GoogleID: &userInfo.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
createdUser, err := s.userRepo.Create(ctx, newUser)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, domain.ErrConflict) {
|
||||||
|
return "", nil, fmt.Errorf("failed to create user, potential conflict: %w", domain.ErrConflict)
|
||||||
|
}
|
||||||
|
slog.ErrorContext(ctx, "Failed to create new user from google info", "error", err)
|
||||||
|
return "", nil, domain.ErrInternalServer
|
||||||
|
}
|
||||||
|
|
||||||
|
jwtToken, jwtErr := s.GenerateJWT(createdUser)
|
||||||
|
if jwtErr != nil {
|
||||||
|
return "", nil, jwtErr
|
||||||
|
}
|
||||||
|
return jwtToken, createdUser, nil
|
||||||
|
}
|
||||||
94
backend/internal/service/gcs_storage.go
Normal file
94
backend/internal/service/gcs_storage.go
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"mime"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"cloud.google.com/go/storage"
|
||||||
|
"github.com/Sosokker/todolist-backend/internal/config"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"google.golang.org/api/option"
|
||||||
|
)
|
||||||
|
|
||||||
|
type gcsStorageService struct {
|
||||||
|
bucket string
|
||||||
|
client *storage.Client
|
||||||
|
logger *slog.Logger
|
||||||
|
baseDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGCStorageService(cfg config.GCSStorageConfig, logger *slog.Logger) (FileStorageService, error) {
|
||||||
|
opts := []option.ClientOption{}
|
||||||
|
if cfg.CredentialsFile != "" {
|
||||||
|
opts = append(opts, option.WithCredentialsFile(cfg.CredentialsFile))
|
||||||
|
}
|
||||||
|
client, err := storage.NewClient(context.Background(), opts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create GCS client: %w", err)
|
||||||
|
}
|
||||||
|
return &gcsStorageService{
|
||||||
|
bucket: cfg.BucketName,
|
||||||
|
client: client,
|
||||||
|
logger: logger.With("service", "gcsstorage"),
|
||||||
|
baseDir: cfg.BaseDir,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *gcsStorageService) GenerateUniqueObjectName(originalFilename string) string {
|
||||||
|
ext := filepath.Ext(originalFilename)
|
||||||
|
return uuid.NewString() + ext
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *gcsStorageService) Upload(ctx context.Context, userID, todoID uuid.UUID, originalFilename string, reader io.Reader, size int64) (string, string, error) {
|
||||||
|
objectName := filepath.Join(s.baseDir, userID.String(), todoID.String(), s.GenerateUniqueObjectName(originalFilename))
|
||||||
|
wc := s.client.Bucket(s.bucket).Object(objectName).NewWriter(ctx)
|
||||||
|
wc.ContentType = mime.TypeByExtension(filepath.Ext(originalFilename))
|
||||||
|
wc.ChunkSize = 0
|
||||||
|
written, err := io.Copy(wc, reader)
|
||||||
|
if err != nil {
|
||||||
|
wc.Close()
|
||||||
|
s.logger.ErrorContext(ctx, "Failed to upload to GCS", "error", err, "object", objectName)
|
||||||
|
return "", "", fmt.Errorf("failed to upload to GCS: %w", err)
|
||||||
|
}
|
||||||
|
if written != size {
|
||||||
|
wc.Close()
|
||||||
|
s.logger.WarnContext(ctx, "File size mismatch during GCS upload", "expected", size, "written", written, "object", objectName)
|
||||||
|
return "", "", fmt.Errorf("file size mismatch during upload")
|
||||||
|
}
|
||||||
|
if err := wc.Close(); err != nil {
|
||||||
|
s.logger.ErrorContext(ctx, "Failed to finalize GCS upload", "error", err, "object", objectName)
|
||||||
|
return "", "", fmt.Errorf("failed to finalize upload: %w", err)
|
||||||
|
}
|
||||||
|
contentType := wc.ContentType
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = "application/octet-stream"
|
||||||
|
}
|
||||||
|
return objectName, contentType, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *gcsStorageService) Delete(ctx context.Context, storageID string) error {
|
||||||
|
objectName := filepath.Clean(storageID)
|
||||||
|
if strings.Contains(objectName, "..") {
|
||||||
|
s.logger.WarnContext(ctx, "Attempted directory traversal in GCS delete", "storageId", storageID)
|
||||||
|
return fmt.Errorf("invalid storage ID")
|
||||||
|
}
|
||||||
|
o := s.client.Bucket(s.bucket).Object(objectName)
|
||||||
|
err := o.Delete(ctx)
|
||||||
|
if err != nil && err != storage.ErrObjectNotExist {
|
||||||
|
s.logger.ErrorContext(ctx, "Failed to delete GCS object", "error", err, "storageId", storageID)
|
||||||
|
return fmt.Errorf("could not delete GCS object: %w", err)
|
||||||
|
}
|
||||||
|
s.logger.InfoContext(ctx, "GCS object deleted", "storageId", storageID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *gcsStorageService) GetURL(ctx context.Context, storageID string) (string, error) {
|
||||||
|
objectName := filepath.Clean(storageID)
|
||||||
|
url := fmt.Sprintf("https://storage.googleapis.com/%s/%s", s.bucket, objectName)
|
||||||
|
return url, nil
|
||||||
|
}
|
||||||
147
backend/internal/service/interfaces.go
Normal file
147
backend/internal/service/interfaces.go
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Sosokker/todolist-backend/internal/domain"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Auth Service ---
|
||||||
|
type SignupCredentials struct {
|
||||||
|
Username string
|
||||||
|
Email string
|
||||||
|
Password string
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginCredentials struct {
|
||||||
|
Email string
|
||||||
|
Password string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthService interface {
|
||||||
|
Signup(ctx context.Context, creds SignupCredentials) (*domain.User, error)
|
||||||
|
Login(ctx context.Context, creds LoginCredentials) (token string, user *domain.User, err error)
|
||||||
|
GenerateJWT(user *domain.User) (string, error)
|
||||||
|
ValidateJWT(tokenString string) (*domain.User, error)
|
||||||
|
GetGoogleAuthConfig() *oauth2.Config
|
||||||
|
HandleGoogleCallback(ctx context.Context, code string) (token string, user *domain.User, err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- User Service ---
|
||||||
|
type UpdateUserInput struct {
|
||||||
|
Username *string
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserService interface {
|
||||||
|
GetUserByID(ctx context.Context, id uuid.UUID) (*domain.User, error)
|
||||||
|
UpdateUser(ctx context.Context, userID uuid.UUID, input UpdateUserInput) (*domain.User, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tag Service ---
|
||||||
|
type CreateTagInput struct {
|
||||||
|
Name string
|
||||||
|
Color *string
|
||||||
|
Icon *string
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateTagInput struct {
|
||||||
|
Name *string
|
||||||
|
Color *string
|
||||||
|
Icon *string
|
||||||
|
}
|
||||||
|
|
||||||
|
type TagService interface {
|
||||||
|
CreateTag(ctx context.Context, userID uuid.UUID, input CreateTagInput) (*domain.Tag, error)
|
||||||
|
GetTagByID(ctx context.Context, tagID, userID uuid.UUID) (*domain.Tag, error)
|
||||||
|
ListUserTags(ctx context.Context, userID uuid.UUID) ([]domain.Tag, error)
|
||||||
|
UpdateTag(ctx context.Context, tagID, userID uuid.UUID, input UpdateTagInput) (*domain.Tag, error)
|
||||||
|
DeleteTag(ctx context.Context, tagID, userID uuid.UUID) error
|
||||||
|
ValidateUserTags(ctx context.Context, userID uuid.UUID, tagIDs []uuid.UUID) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Todo Service ---
|
||||||
|
type CreateTodoInput struct {
|
||||||
|
Title string
|
||||||
|
Description *string
|
||||||
|
Status *domain.TodoStatus
|
||||||
|
Deadline *time.Time
|
||||||
|
TagIDs []uuid.UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateTodoInput struct {
|
||||||
|
Title *string
|
||||||
|
Description *string
|
||||||
|
Status *domain.TodoStatus
|
||||||
|
Deadline *time.Time
|
||||||
|
TagIDs *[]uuid.UUID
|
||||||
|
Attachments *[]string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListTodosInput struct {
|
||||||
|
Status *domain.TodoStatus
|
||||||
|
TagID *uuid.UUID
|
||||||
|
DeadlineBefore *time.Time
|
||||||
|
DeadlineAfter *time.Time
|
||||||
|
Limit int
|
||||||
|
Offset int
|
||||||
|
}
|
||||||
|
|
||||||
|
type TodoService interface {
|
||||||
|
CreateTodo(ctx context.Context, userID uuid.UUID, input CreateTodoInput) (*domain.Todo, error)
|
||||||
|
GetTodoByID(ctx context.Context, todoID, userID uuid.UUID) (*domain.Todo, error) // Includes tags, subtasks
|
||||||
|
ListUserTodos(ctx context.Context, userID uuid.UUID, input ListTodosInput) ([]domain.Todo, error) // Includes tags
|
||||||
|
UpdateTodo(ctx context.Context, todoID, userID uuid.UUID, input UpdateTodoInput) (*domain.Todo, error)
|
||||||
|
DeleteTodo(ctx context.Context, todoID, userID uuid.UUID) error
|
||||||
|
// Subtask methods delegate to SubtaskService but check Todo ownership first
|
||||||
|
ListSubtasks(ctx context.Context, todoID, userID uuid.UUID) ([]domain.Subtask, error)
|
||||||
|
CreateSubtask(ctx context.Context, todoID, userID uuid.UUID, input CreateSubtaskInput) (*domain.Subtask, error)
|
||||||
|
UpdateSubtask(ctx context.Context, todoID, subtaskID, userID uuid.UUID, input UpdateSubtaskInput) (*domain.Subtask, error)
|
||||||
|
DeleteSubtask(ctx context.Context, todoID, subtaskID, userID uuid.UUID) error
|
||||||
|
// Attachment methods
|
||||||
|
AddAttachment(ctx context.Context, todoID, userID uuid.UUID, fileName string, fileSize int64, fileContent io.Reader) (*domain.AttachmentInfo, error) // Returns info like ID/URL
|
||||||
|
DeleteAttachment(ctx context.Context, todoID, userID uuid.UUID, attachmentID string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Subtask Service ---
|
||||||
|
type CreateSubtaskInput struct {
|
||||||
|
Description string
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateSubtaskInput struct {
|
||||||
|
Description *string
|
||||||
|
Completed *bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubtaskService operates assuming the parent Todo's ownership has already been verified
|
||||||
|
type SubtaskService interface {
|
||||||
|
Create(ctx context.Context, todoID uuid.UUID, input CreateSubtaskInput) (*domain.Subtask, error)
|
||||||
|
GetByID(ctx context.Context, subtaskID, userID uuid.UUID) (*domain.Subtask, error) // Still need userID for underlying repo call
|
||||||
|
ListByTodo(ctx context.Context, todoID, userID uuid.UUID) ([]domain.Subtask, error) // Still need userID for underlying repo call
|
||||||
|
Update(ctx context.Context, subtaskID, userID uuid.UUID, input UpdateSubtaskInput) (*domain.Subtask, error) // Still need userID
|
||||||
|
Delete(ctx context.Context, subtaskID, userID uuid.UUID) error // Still need userID
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileStorageService defines the interface for handling file uploads and deletions.
|
||||||
|
type FileStorageService interface {
|
||||||
|
// Upload saves the content from the reader and returns a unique storage identifier (e.g., path/key) and the content type.
|
||||||
|
Upload(ctx context.Context, userID, todoID uuid.UUID, originalFilename string, reader io.Reader, size int64) (storageID string, contentType string, err error)
|
||||||
|
// Delete removes the file associated with the given storage identifier.
|
||||||
|
Delete(ctx context.Context, storageID string) error
|
||||||
|
// GetURL retrieves a publicly accessible URL for the storage ID (optional, might not be needed if files are served differently).
|
||||||
|
GetURL(ctx context.Context, storageID string) (string, error)
|
||||||
|
GenerateUniqueObjectName(originalFilename string) string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceRegistry bundles services
|
||||||
|
type ServiceRegistry struct {
|
||||||
|
Auth AuthService
|
||||||
|
User UserService
|
||||||
|
Tag TagService
|
||||||
|
Todo TodoService
|
||||||
|
Subtask SubtaskService
|
||||||
|
Storage FileStorageService
|
||||||
|
}
|
||||||
185
backend/internal/service/local_storage.go
Normal file
185
backend/internal/service/local_storage.go
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/Sosokker/todolist-backend/internal/config"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type localStorageService struct {
|
||||||
|
basePath string
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLocalStorageService creates a service for storing files on the local disk.
|
||||||
|
func NewLocalStorageService(cfg config.LocalStorageConfig, logger *slog.Logger) (FileStorageService, error) {
|
||||||
|
if cfg.Path == "" {
|
||||||
|
return nil, fmt.Errorf("local storage path cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the base directory exists
|
||||||
|
err := os.MkdirAll(cfg.Path, 0750) // Use appropriate permissions
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create local storage directory '%s': %w", cfg.Path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("Local file storage initialized", "path", cfg.Path)
|
||||||
|
return &localStorageService{
|
||||||
|
basePath: cfg.Path,
|
||||||
|
logger: logger.With("service", "localstorage"),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateUniqueObjectName creates a unique path/filename for storage.
|
||||||
|
// Example: user_uuid/todo_uuid/file_uuid.ext
|
||||||
|
func (s *localStorageService) GenerateUniqueObjectName(originalFilename string) string {
|
||||||
|
ext := filepath.Ext(originalFilename)
|
||||||
|
fileName := uuid.NewString() + ext
|
||||||
|
return fileName
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *localStorageService) Upload(ctx context.Context, userID, todoID uuid.UUID, originalFilename string, reader io.Reader, size int64) (string, string, error) {
|
||||||
|
// Create a unique filename
|
||||||
|
uniqueFilename := s.GenerateUniqueObjectName(originalFilename)
|
||||||
|
|
||||||
|
// Create user/todo specific subdirectory structure
|
||||||
|
subDir := filepath.Join(userID.String(), todoID.String())
|
||||||
|
fullDir := filepath.Join(s.basePath, subDir)
|
||||||
|
if err := os.MkdirAll(fullDir, 0750); err != nil {
|
||||||
|
s.logger.ErrorContext(ctx, "Failed to create subdirectory for upload", "error", err, "path", fullDir)
|
||||||
|
return "", "", fmt.Errorf("could not create storage directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define the full path for the file
|
||||||
|
filePath := filepath.Join(fullDir, uniqueFilename)
|
||||||
|
storageID := filepath.Join(subDir, uniqueFilename) // Relative path used as ID
|
||||||
|
|
||||||
|
// Create the destination file
|
||||||
|
dst, err := os.Create(filePath)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.ErrorContext(ctx, "Failed to create destination file", "error", err, "path", filePath)
|
||||||
|
return "", "", fmt.Errorf("could not create file: %w", err)
|
||||||
|
}
|
||||||
|
defer dst.Close()
|
||||||
|
|
||||||
|
// Copy the content from the reader to the destination file
|
||||||
|
written, err := io.Copy(dst, reader)
|
||||||
|
if err != nil {
|
||||||
|
// Attempt to clean up partially written file
|
||||||
|
os.Remove(filePath)
|
||||||
|
s.logger.ErrorContext(ctx, "Failed to copy file content", "error", err, "path", filePath)
|
||||||
|
return "", "", fmt.Errorf("could not write file content: %w", err)
|
||||||
|
}
|
||||||
|
if written != size {
|
||||||
|
// Attempt to clean up file if size mismatch (could indicate truncated upload)
|
||||||
|
os.Remove(filePath)
|
||||||
|
s.logger.WarnContext(ctx, "File size mismatch during upload", "expected", size, "written", written, "path", filePath)
|
||||||
|
return "", "", fmt.Errorf("file size mismatch during upload")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect content type
|
||||||
|
contentType := s.detectContentType(filePath, originalFilename)
|
||||||
|
|
||||||
|
s.logger.InfoContext(ctx, "File uploaded successfully", "storageId", storageID, "originalName", originalFilename, "size", size, "contentType", contentType)
|
||||||
|
// Return the relative path as the storage identifier
|
||||||
|
return storageID, contentType, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *localStorageService) Delete(ctx context.Context, storageID string) error {
|
||||||
|
// Prevent directory traversal attacks
|
||||||
|
cleanStorageID := filepath.Clean(storageID)
|
||||||
|
if strings.Contains(cleanStorageID, "..") {
|
||||||
|
s.logger.WarnContext(ctx, "Attempted directory traversal in delete", "storageId", storageID)
|
||||||
|
return fmt.Errorf("invalid storage ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
fullPath := filepath.Join(s.basePath, cleanStorageID)
|
||||||
|
|
||||||
|
err := os.Remove(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
s.logger.WarnContext(ctx, "Attempted to delete non-existent file", "storageId", storageID)
|
||||||
|
// Consider returning nil here if deleting non-existent is okay
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.logger.ErrorContext(ctx, "Failed to delete file", "error", err, "storageId", storageID)
|
||||||
|
return fmt.Errorf("could not delete file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.InfoContext(ctx, "File deleted successfully", "storageId", storageID)
|
||||||
|
|
||||||
|
dir := filepath.Dir(fullPath)
|
||||||
|
if isEmpty, _ := IsDirEmpty(dir); isEmpty {
|
||||||
|
os.Remove(dir)
|
||||||
|
}
|
||||||
|
dir = filepath.Dir(dir) // Go up one more level
|
||||||
|
if isEmpty, _ := IsDirEmpty(dir); isEmpty {
|
||||||
|
os.Remove(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetURL for local storage might just return a path or require a separate file server.
|
||||||
|
// This implementation returns a placeholder indicating it's not a direct URL.
|
||||||
|
func (s *localStorageService) GetURL(ctx context.Context, storageID string) (string, error) {
|
||||||
|
// Local storage doesn't inherently provide a web URL.
|
||||||
|
// You would typically need a separate static file server pointing to `basePath`.
|
||||||
|
// For now, return the storageID itself or a placeholder path.
|
||||||
|
// Example: If you have a file server at /static/uploads mapped to basePath:
|
||||||
|
// return "/static/uploads/" + filepath.ToSlash(storageID), nil
|
||||||
|
return fmt.Sprintf("local://%s", storageID), nil // Placeholder indicating local storage
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectContentType tries to determine the MIME type of the file.
|
||||||
|
func (s *localStorageService) detectContentType(filePath string, originalFilename string) string {
|
||||||
|
// First, try based on file extension
|
||||||
|
ext := filepath.Ext(originalFilename)
|
||||||
|
mimeType := mime.TypeByExtension(ext)
|
||||||
|
if mimeType != "" {
|
||||||
|
return mimeType
|
||||||
|
}
|
||||||
|
|
||||||
|
// If extension didn't work, try reading the first 512 bytes
|
||||||
|
file, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("Could not open file for content type detection", "error", err, "path", filePath)
|
||||||
|
return "application/octet-stream" // Default fallback
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
buffer := make([]byte, 512)
|
||||||
|
n, err := file.Read(buffer)
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
s.logger.Warn("Could not read file for content type detection", "error", err, "path", filePath)
|
||||||
|
return "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
// http.DetectContentType works best with the file beginning
|
||||||
|
mimeType = http.DetectContentType(buffer[:n])
|
||||||
|
return mimeType
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsDirEmpty(name string) (bool, error) {
|
||||||
|
f, err := os.Open(name)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
// Read just one entry. If EOF, directory is empty.
|
||||||
|
_, err = f.Readdirnames(1)
|
||||||
|
if err == io.EOF {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
return false, err // Either not empty or error during read
|
||||||
|
}
|
||||||
140
backend/internal/service/subtask_service.go
Normal file
140
backend/internal/service/subtask_service.go
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/Sosokker/todolist-backend/internal/domain" // Adjust path
|
||||||
|
"github.com/Sosokker/todolist-backend/internal/repository" // Adjust path
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type subtaskService struct {
|
||||||
|
subtaskRepo repository.SubtaskRepository
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSubtaskService creates a new SubtaskService
|
||||||
|
func NewSubtaskService(repo repository.SubtaskRepository /*, todoRepo repository.TodoRepository */) SubtaskService {
|
||||||
|
return &subtaskService{
|
||||||
|
subtaskRepo: repo,
|
||||||
|
logger: slog.Default().With("service", "subtask"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *subtaskService) Create(ctx context.Context, todoID uuid.UUID, input CreateSubtaskInput) (*domain.Subtask, error) {
|
||||||
|
if err := ValidateCreateSubtaskInput(input); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Ownership check of parent todo (todoID) should be done *before* calling this method,
|
||||||
|
// typically in the TodoService which orchestrates subtask operations.
|
||||||
|
// Alternatively, the repository methods should enforce this via joins (as done in the example repo).
|
||||||
|
|
||||||
|
subtask := &domain.Subtask{
|
||||||
|
TodoID: todoID,
|
||||||
|
Description: input.Description,
|
||||||
|
Completed: false, // Default on create
|
||||||
|
}
|
||||||
|
|
||||||
|
createdSubtask, err := s.subtaskRepo.Create(ctx, subtask)
|
||||||
|
if err != nil {
|
||||||
|
// Repo handles foreign key violation check returning ErrBadRequest
|
||||||
|
if errors.Is(err, domain.ErrBadRequest) {
|
||||||
|
s.logger.WarnContext(ctx, "Subtask creation failed, invalid parent todo", "todoId", todoID)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.logger.ErrorContext(ctx, "Failed to create subtask in repo", "error", err, "todoId", todoID)
|
||||||
|
return nil, domain.ErrInternalServer
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.InfoContext(ctx, "Subtask created successfully", "subtaskId", createdSubtask.ID, "todoId", todoID)
|
||||||
|
return createdSubtask, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *subtaskService) GetByID(ctx context.Context, subtaskID, userID uuid.UUID) (*domain.Subtask, error) {
|
||||||
|
subtask, err := s.subtaskRepo.GetByID(ctx, subtaskID, userID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, domain.ErrNotFound) {
|
||||||
|
s.logger.WarnContext(ctx, "Subtask not found or access denied", "subtaskId", subtaskID, "userId", userID)
|
||||||
|
} else {
|
||||||
|
s.logger.ErrorContext(ctx, "Failed to get subtask by ID from repo", "error", err, "subtaskId", subtaskID, "userId", userID)
|
||||||
|
err = domain.ErrInternalServer
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return subtask, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *subtaskService) ListByTodo(ctx context.Context, todoID, userID uuid.UUID) ([]domain.Subtask, error) {
|
||||||
|
subtasks, err := s.subtaskRepo.ListByTodo(ctx, todoID, userID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.ErrorContext(ctx, "Failed to list subtasks by todo from repo", "error", err, "todoId", todoID, "userId", userID)
|
||||||
|
return nil, domain.ErrInternalServer
|
||||||
|
}
|
||||||
|
s.logger.DebugContext(ctx, "Listed subtasks for todo", "todoId", todoID, "userId", userID, "count", len(subtasks))
|
||||||
|
return subtasks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *subtaskService) Update(ctx context.Context, subtaskID, userID uuid.UUID, input UpdateSubtaskInput) (*domain.Subtask, error) {
|
||||||
|
if err := ValidateUpdateSubtaskInput(input); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Get existing first to ensure NotFound/Forbidden is returned correctly before attempting update,
|
||||||
|
// and to have the existing data if only partial fields are provided in input.
|
||||||
|
existingSubtask, err := s.GetByID(ctx, subtaskID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err // Handles NotFound/Forbidden/Internal
|
||||||
|
}
|
||||||
|
|
||||||
|
updateData := &domain.Subtask{
|
||||||
|
Description: existingSubtask.Description,
|
||||||
|
Completed: existingSubtask.Completed,
|
||||||
|
}
|
||||||
|
needsUpdate := false
|
||||||
|
|
||||||
|
if input.Description != nil {
|
||||||
|
updateData.Description = *input.Description
|
||||||
|
needsUpdate = true
|
||||||
|
}
|
||||||
|
if input.Completed != nil {
|
||||||
|
updateData.Completed = *input.Completed
|
||||||
|
needsUpdate = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !needsUpdate {
|
||||||
|
s.logger.InfoContext(ctx, "No fields provided for subtask update", "subtaskId", subtaskID, "userId", userID)
|
||||||
|
return existingSubtask, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedSubtask, err := s.subtaskRepo.Update(ctx, subtaskID, userID, updateData)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, domain.ErrNotFound) {
|
||||||
|
s.logger.WarnContext(ctx, "Subtask update failed, not found or access denied", "subtaskId", subtaskID, "userId", userID)
|
||||||
|
} else {
|
||||||
|
s.logger.ErrorContext(ctx, "Failed to update subtask in repo", "error", err, "subtaskId", subtaskID, "userId", userID)
|
||||||
|
err = domain.ErrInternalServer
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.InfoContext(ctx, "Subtask updated successfully", "subtaskId", subtaskID, "userId", userID)
|
||||||
|
return updatedSubtask, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *subtaskService) Delete(ctx context.Context, subtaskID, userID uuid.UUID) error {
|
||||||
|
// Check existence and ownership first to return proper NotFound/Forbidden.
|
||||||
|
_, err := s.GetByID(ctx, subtaskID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err // Handles NotFound/Forbidden/Internal
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.subtaskRepo.Delete(ctx, subtaskID, userID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.ErrorContext(ctx, "Failed to delete subtask from repo", "error", err, "subtaskId", subtaskID, "userId", userID)
|
||||||
|
return domain.ErrInternalServer
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.InfoContext(ctx, "Subtask deleted successfully", "subtaskId", subtaskID, "userId", userID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
235
backend/internal/service/tag_service.go
Normal file
235
backend/internal/service/tag_service.go
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/Sosokker/todolist-backend/internal/cache" // Adjust path
|
||||||
|
"github.com/Sosokker/todolist-backend/internal/domain" // Adjust path
|
||||||
|
"github.com/Sosokker/todolist-backend/internal/repository" // Adjust path
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type tagService struct {
|
||||||
|
tagRepo repository.TagRepository
|
||||||
|
cache cache.Cache // Inject Cache interface
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTagService creates a new TagService
|
||||||
|
func NewTagService(repo repository.TagRepository /*, cache cache.Cache */) TagService {
|
||||||
|
logger := slog.Default().With("service", "tag")
|
||||||
|
|
||||||
|
var c cache.Cache = nil // Or initialize cache here: cache.NewMemoryCache(...)
|
||||||
|
if c != nil {
|
||||||
|
logger.Info("TagService initialized with caching enabled")
|
||||||
|
} else {
|
||||||
|
logger.Info("TagService initialized without caching")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &tagService{
|
||||||
|
tagRepo: repo,
|
||||||
|
cache: c,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tagService) getCacheKey(tagID uuid.UUID) string {
|
||||||
|
// Consider user-specific keys if caching user-scoped data: fmt.Sprintf("user:%s:tag:%s", userID, tagID)
|
||||||
|
return "tag:" + tagID.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tagService) CreateTag(ctx context.Context, userID uuid.UUID, input CreateTagInput) (*domain.Tag, error) {
|
||||||
|
// Use centralized validation
|
||||||
|
if err := ValidateCreateTagInput(input); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tag := &domain.Tag{
|
||||||
|
UserID: userID,
|
||||||
|
Name: input.Name,
|
||||||
|
Color: input.Color,
|
||||||
|
Icon: input.Icon,
|
||||||
|
}
|
||||||
|
|
||||||
|
createdTag, err := s.tagRepo.Create(ctx, tag)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, domain.ErrConflict) {
|
||||||
|
s.logger.WarnContext(ctx, "Tag creation conflict", "userId", userID, "tagName", input.Name)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.logger.ErrorContext(ctx, "Failed to create tag in repo", "error", err, "userId", userID)
|
||||||
|
return nil, domain.ErrInternalServer
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.InfoContext(ctx, "Tag created successfully", "tagId", createdTag.ID, "userId", userID)
|
||||||
|
return createdTag, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tagService) GetTagByID(ctx context.Context, tagID, userID uuid.UUID) (*domain.Tag, error) {
|
||||||
|
cacheKey := s.getCacheKey(tagID)
|
||||||
|
|
||||||
|
if s.cache != nil {
|
||||||
|
if cachedTag, found := s.cache.Get(ctx, cacheKey); found {
|
||||||
|
if tag, ok := cachedTag.(*domain.Tag); ok {
|
||||||
|
// IMPORTANT: Verify ownership even on cache hit
|
||||||
|
if tag.UserID != userID {
|
||||||
|
s.logger.WarnContext(ctx, "Cache hit for tag owned by different user", "tagId", tagID, "ownerId", tag.UserID, "requesterId", userID)
|
||||||
|
return nil, domain.ErrNotFound
|
||||||
|
}
|
||||||
|
s.logger.DebugContext(ctx, "GetTagByID cache hit", "tagId", tagID, "userId", userID)
|
||||||
|
return tag, nil
|
||||||
|
} else {
|
||||||
|
s.logger.WarnContext(ctx, "Invalid type found in tag cache", "key", cacheKey)
|
||||||
|
s.cache.Delete(ctx, cacheKey)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
s.logger.DebugContext(ctx, "GetTagByID cache miss", "tagId", tagID, "userId", userID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tag, err := s.tagRepo.GetByID(ctx, tagID, userID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, domain.ErrNotFound) {
|
||||||
|
s.logger.WarnContext(ctx, "Tag not found by ID in repo", "tagId", tagID, "userId", userID)
|
||||||
|
} else {
|
||||||
|
s.logger.ErrorContext(ctx, "Failed to get tag by ID from repo", "error", err, "tagId", tagID, "userId", userID)
|
||||||
|
err = domain.ErrInternalServer
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.cache != nil {
|
||||||
|
s.cache.Set(ctx, cacheKey, tag, 0)
|
||||||
|
s.logger.DebugContext(ctx, "Set tag in cache", "tagId", tagID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tag, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tagService) ListUserTags(ctx context.Context, userID uuid.UUID) ([]domain.Tag, error) {
|
||||||
|
s.logger.DebugContext(ctx, "Listing user tags", "userId", userID)
|
||||||
|
|
||||||
|
tags, err := s.tagRepo.ListByUser(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.ErrorContext(ctx, "Failed to list user tags from repo", "error", err, "userId", userID)
|
||||||
|
return nil, domain.ErrInternalServer
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.DebugContext(ctx, "Found user tags", "userId", userID, "count", len(tags))
|
||||||
|
return tags, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tagService) UpdateTag(ctx context.Context, tagID, userID uuid.UUID, input UpdateTagInput) (*domain.Tag, error) {
|
||||||
|
// Get existing tag first to ensure it exists and belongs to user
|
||||||
|
existingTag, err := s.GetTagByID(ctx, tagID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ValidateUpdateTagInput(input); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
updateData := &domain.Tag{
|
||||||
|
Name: existingTag.Name,
|
||||||
|
Color: existingTag.Color,
|
||||||
|
Icon: existingTag.Icon,
|
||||||
|
}
|
||||||
|
needsUpdate := false
|
||||||
|
|
||||||
|
if input.Name != nil {
|
||||||
|
updateData.Name = *input.Name
|
||||||
|
needsUpdate = true
|
||||||
|
}
|
||||||
|
if input.Color != nil {
|
||||||
|
updateData.Color = input.Color
|
||||||
|
needsUpdate = true
|
||||||
|
}
|
||||||
|
if input.Icon != nil {
|
||||||
|
updateData.Icon = input.Icon
|
||||||
|
needsUpdate = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !needsUpdate {
|
||||||
|
s.logger.InfoContext(ctx, "No fields provided for tag update", "tagId", tagID, "userId", userID)
|
||||||
|
return existingTag, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedTag, err := s.tagRepo.Update(ctx, tagID, userID, updateData)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, domain.ErrConflict) {
|
||||||
|
s.logger.WarnContext(ctx, "Tag update conflict", "error", err, "tagId", tagID, "userId", userID, "conflictingName", updateData.Name)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.logger.ErrorContext(ctx, "Failed to update tag in repo", "error", err, "tagId", tagID, "userId", userID)
|
||||||
|
return nil, domain.ErrInternalServer
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.cache != nil {
|
||||||
|
cacheKey := s.getCacheKey(tagID)
|
||||||
|
s.cache.Delete(ctx, cacheKey)
|
||||||
|
s.logger.DebugContext(ctx, "Invalidated tag cache after update", "tagId", tagID)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.InfoContext(ctx, "Tag updated successfully", "tagId", tagID, "userId", userID)
|
||||||
|
return updatedTag, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tagService) DeleteTag(ctx context.Context, tagID, userID uuid.UUID) error {
|
||||||
|
_, err := s.GetTagByID(ctx, tagID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.tagRepo.Delete(ctx, tagID, userID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.ErrorContext(ctx, "Failed to delete tag from repo", "error", err, "tagId", tagID, "userId", userID)
|
||||||
|
return domain.ErrInternalServer
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.cache != nil {
|
||||||
|
cacheKey := s.getCacheKey(tagID)
|
||||||
|
s.cache.Delete(ctx, cacheKey)
|
||||||
|
s.logger.DebugContext(ctx, "Invalidated tag cache after delete", "tagId", tagID)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.InfoContext(ctx, "Tag deleted successfully", "tagId", tagID, "userId", userID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateUserTags checks if all provided tag IDs exist and belong to the user.
|
||||||
|
func (s *tagService) ValidateUserTags(ctx context.Context, userID uuid.UUID, tagIDs []uuid.UUID) error {
|
||||||
|
if len(tagIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByIDs repo method already filters by userID
|
||||||
|
foundTags, err := s.tagRepo.GetByIDs(ctx, tagIDs, userID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.ErrorContext(ctx, "Failed to get tags by IDs during validation", "error", err, "userId", userID)
|
||||||
|
return domain.ErrInternalServer
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(foundTags) != len(tagIDs) {
|
||||||
|
foundMap := make(map[uuid.UUID]bool)
|
||||||
|
for _, t := range foundTags {
|
||||||
|
foundMap[t.ID] = true
|
||||||
|
}
|
||||||
|
missing := []string{}
|
||||||
|
for _, reqID := range tagIDs {
|
||||||
|
if !foundMap[reqID] {
|
||||||
|
missing = append(missing, reqID.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
errMsg := "invalid or forbidden tag IDs: " + strings.Join(missing, ", ")
|
||||||
|
s.logger.WarnContext(ctx, "Tag validation failed", "userId", userID, "missingTags", missing)
|
||||||
|
return fmt.Errorf("%s: %w", errMsg, domain.ErrBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.DebugContext(ctx, "User tags validated successfully", "userId", userID, "tagCount", len(tagIDs))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
355
backend/internal/service/todo_service.go
Normal file
355
backend/internal/service/todo_service.go
Normal file
@ -0,0 +1,355 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/Sosokker/todolist-backend/internal/domain"
|
||||||
|
"github.com/Sosokker/todolist-backend/internal/repository"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type todoService struct {
|
||||||
|
todoRepo repository.TodoRepository
|
||||||
|
tagService TagService // Depend on TagService for validation
|
||||||
|
subtaskService SubtaskService // Depend on SubtaskService
|
||||||
|
storageService FileStorageService
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTodoService creates a new TodoService
|
||||||
|
func NewTodoService(
|
||||||
|
todoRepo repository.TodoRepository,
|
||||||
|
tagService TagService,
|
||||||
|
subtaskService SubtaskService,
|
||||||
|
storageService FileStorageService,
|
||||||
|
) TodoService {
|
||||||
|
return &todoService{
|
||||||
|
todoRepo: todoRepo,
|
||||||
|
tagService: tagService,
|
||||||
|
subtaskService: subtaskService,
|
||||||
|
storageService: storageService,
|
||||||
|
logger: slog.Default().With("service", "todo"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *todoService) CreateTodo(ctx context.Context, userID uuid.UUID, input CreateTodoInput) (*domain.Todo, error) {
|
||||||
|
// Validate input
|
||||||
|
if input.Title == "" {
|
||||||
|
return nil, fmt.Errorf("title is required: %w", domain.ErrValidation)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate associated Tag IDs belong to the user
|
||||||
|
if len(input.TagIDs) > 0 {
|
||||||
|
if err := s.tagService.ValidateUserTags(ctx, userID, input.TagIDs); err != nil {
|
||||||
|
return nil, err // Propagate validation or not found errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default status if not provided
|
||||||
|
status := domain.StatusPending
|
||||||
|
if input.Status != nil {
|
||||||
|
status = *input.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
newTodo := &domain.Todo{
|
||||||
|
UserID: userID,
|
||||||
|
Title: input.Title,
|
||||||
|
Description: input.Description,
|
||||||
|
Status: status,
|
||||||
|
Deadline: input.Deadline,
|
||||||
|
TagIDs: input.TagIDs,
|
||||||
|
Attachments: []string{},
|
||||||
|
}
|
||||||
|
|
||||||
|
createdTodo, err := s.todoRepo.Create(ctx, newTodo)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.ErrorContext(ctx, "Failed to create todo in repo", "error", err, "userId", userID)
|
||||||
|
return nil, domain.ErrInternalServer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Associate Tags if provided (after Todo creation)
|
||||||
|
if len(input.TagIDs) > 0 {
|
||||||
|
if err = s.todoRepo.SetTags(ctx, createdTodo.ID, input.TagIDs); err != nil {
|
||||||
|
s.logger.ErrorContext(ctx, "Failed to associate tags during todo creation", "error", err, "todoId", createdTodo.ID)
|
||||||
|
_ = s.todoRepo.Delete(ctx, createdTodo.ID, userID) // Best effort cleanup
|
||||||
|
return nil, domain.ErrInternalServer
|
||||||
|
}
|
||||||
|
createdTodo.TagIDs = input.TagIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
return createdTodo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *todoService) GetTodoByID(ctx context.Context, todoID, userID uuid.UUID) (*domain.Todo, error) {
|
||||||
|
todo, err := s.todoRepo.GetByID(ctx, todoID, userID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, domain.ErrNotFound) {
|
||||||
|
s.logger.WarnContext(ctx, "Todo not found or forbidden", "todoId", todoID, "userId", userID)
|
||||||
|
return nil, domain.ErrNotFound
|
||||||
|
}
|
||||||
|
s.logger.ErrorContext(ctx, "Failed to get todo from repo", "error", err, "todoId", todoID, "userId", userID)
|
||||||
|
return nil, domain.ErrInternalServer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eager load associated Tags and Subtasks
|
||||||
|
tags, err := s.todoRepo.GetTags(ctx, todoID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.WarnContext(ctx, "Failed to get tags for todo", "error", err, "todoId", todoID)
|
||||||
|
} else {
|
||||||
|
todo.Tags = tags
|
||||||
|
todo.TagIDs = make([]uuid.UUID, 0, len(tags))
|
||||||
|
for _, tag := range tags {
|
||||||
|
todo.TagIDs = append(todo.TagIDs, tag.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subtasks, err := s.subtaskService.ListByTodo(ctx, todoID, userID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.WarnContext(ctx, "Failed to get subtasks for todo", "error", err, "todoId", todoID)
|
||||||
|
} else {
|
||||||
|
todo.Subtasks = subtasks
|
||||||
|
}
|
||||||
|
|
||||||
|
return todo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *todoService) ListUserTodos(ctx context.Context, userID uuid.UUID, input ListTodosInput) ([]domain.Todo, error) {
|
||||||
|
if input.Limit <= 0 {
|
||||||
|
input.Limit = 20
|
||||||
|
}
|
||||||
|
if input.Offset < 0 {
|
||||||
|
input.Offset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
repoParams := repository.ListTodosParams{
|
||||||
|
UserID: userID,
|
||||||
|
Status: input.Status,
|
||||||
|
TagID: input.TagID,
|
||||||
|
DeadlineBefore: input.DeadlineBefore,
|
||||||
|
DeadlineAfter: input.DeadlineAfter,
|
||||||
|
ListParams: repository.ListParams{
|
||||||
|
Limit: input.Limit,
|
||||||
|
Offset: input.Offset,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
todos, err := s.todoRepo.ListByUser(ctx, repoParams)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.ErrorContext(ctx, "Failed to list todos from repo", "error", err, "userId", userID)
|
||||||
|
return nil, domain.ErrInternalServer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: Eager load Tags for each Todo in the list efficiently
|
||||||
|
// 1. Collect all Todo IDs
|
||||||
|
// 2. Make one batch query to get all tags for these todos (e.g., WHERE todo_id IN (...))
|
||||||
|
// 3. Map tags back to their respective todos
|
||||||
|
// See todo_repo.go for implementation notes.
|
||||||
|
// This avoids N+1 queries.
|
||||||
|
|
||||||
|
return todos, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *todoService) UpdateTodo(ctx context.Context, todoID, userID uuid.UUID, input UpdateTodoInput) (*domain.Todo, error) {
|
||||||
|
existingTodo, err := s.todoRepo.GetByID(ctx, todoID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
updateData := &domain.Todo{
|
||||||
|
ID: existingTodo.ID,
|
||||||
|
UserID: existingTodo.UserID,
|
||||||
|
Title: existingTodo.Title,
|
||||||
|
Description: existingTodo.Description,
|
||||||
|
Status: existingTodo.Status,
|
||||||
|
Deadline: existingTodo.Deadline,
|
||||||
|
Attachments: existingTodo.Attachments,
|
||||||
|
}
|
||||||
|
|
||||||
|
updated := false
|
||||||
|
|
||||||
|
if input.Title != nil {
|
||||||
|
if *input.Title == "" {
|
||||||
|
return nil, fmt.Errorf("title cannot be empty: %w", domain.ErrValidation)
|
||||||
|
}
|
||||||
|
updateData.Title = *input.Title
|
||||||
|
updated = true
|
||||||
|
}
|
||||||
|
if input.Description != nil {
|
||||||
|
updateData.Description = input.Description
|
||||||
|
updated = true
|
||||||
|
}
|
||||||
|
if input.Status != nil {
|
||||||
|
updateData.Status = *input.Status
|
||||||
|
updated = true
|
||||||
|
}
|
||||||
|
if input.Deadline != nil {
|
||||||
|
updateData.Deadline = input.Deadline
|
||||||
|
updated = true
|
||||||
|
}
|
||||||
|
|
||||||
|
tagsUpdated := false
|
||||||
|
if input.TagIDs != nil {
|
||||||
|
if len(*input.TagIDs) > 0 {
|
||||||
|
if err := s.tagService.ValidateUserTags(ctx, userID, *input.TagIDs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err = s.todoRepo.SetTags(ctx, todoID, *input.TagIDs)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.ErrorContext(ctx, "Failed to update tags for todo", "error", err, "todoId", todoID)
|
||||||
|
return nil, domain.ErrInternalServer
|
||||||
|
}
|
||||||
|
updateData.TagIDs = *input.TagIDs
|
||||||
|
tagsUpdated = true
|
||||||
|
}
|
||||||
|
|
||||||
|
attachmentsUpdated := false
|
||||||
|
if input.Attachments != nil {
|
||||||
|
err = s.todoRepo.SetAttachments(ctx, todoID, userID, *input.Attachments)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.ErrorContext(ctx, "Failed to update attachments list for todo", "error", err, "todoId", todoID)
|
||||||
|
return nil, domain.ErrInternalServer
|
||||||
|
}
|
||||||
|
updateData.Attachments = *input.Attachments
|
||||||
|
attachmentsUpdated = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var updatedRepoTodo *domain.Todo
|
||||||
|
if updated {
|
||||||
|
updatedRepoTodo, err = s.todoRepo.Update(ctx, todoID, userID, updateData)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.ErrorContext(ctx, "Failed to update todo in repo", "error", err, "todoId", todoID)
|
||||||
|
return nil, domain.ErrInternalServer
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
updatedRepoTodo = updateData
|
||||||
|
}
|
||||||
|
|
||||||
|
if !updated && (tagsUpdated || attachmentsUpdated) {
|
||||||
|
updatedRepoTodo.Title = existingTodo.Title
|
||||||
|
updatedRepoTodo.Description = existingTodo.Description
|
||||||
|
}
|
||||||
|
|
||||||
|
finalTodo, err := s.GetTodoByID(ctx, todoID, userID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.WarnContext(ctx, "Failed to reload todo after update, returning partial data", "error", err, "todoId", todoID)
|
||||||
|
return updatedRepoTodo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalTodo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *todoService) DeleteTodo(ctx context.Context, todoID, userID uuid.UUID) error {
|
||||||
|
existingTodo, err := s.todoRepo.GetByID(ctx, todoID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
attachmentIDsToDelete := existingTodo.Attachments
|
||||||
|
|
||||||
|
err = s.todoRepo.Delete(ctx, todoID, userID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.ErrorContext(ctx, "Failed to delete todo from repo", "error", err, "todoId", todoID, "userId", userID)
|
||||||
|
return domain.ErrInternalServer
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, storageID := range attachmentIDsToDelete {
|
||||||
|
if err := s.storageService.Delete(ctx, storageID); err != nil {
|
||||||
|
s.logger.WarnContext(ctx, "Failed to delete attachment file during todo deletion", "error", err, "storageId", storageID, "todoId", todoID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Subtask Delegation Methods ---
|
||||||
|
|
||||||
|
func (s *todoService) ListSubtasks(ctx context.Context, todoID, userID uuid.UUID) ([]domain.Subtask, error) {
|
||||||
|
_, err := s.todoRepo.GetByID(ctx, todoID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.subtaskService.ListByTodo(ctx, todoID, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *todoService) CreateSubtask(ctx context.Context, todoID, userID uuid.UUID, input CreateSubtaskInput) (*domain.Subtask, error) {
|
||||||
|
_, err := s.todoRepo.GetByID(ctx, todoID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.subtaskService.Create(ctx, todoID, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *todoService) UpdateSubtask(ctx context.Context, todoID, subtaskID, userID uuid.UUID, input UpdateSubtaskInput) (*domain.Subtask, error) {
|
||||||
|
return s.subtaskService.Update(ctx, subtaskID, userID, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *todoService) DeleteSubtask(ctx context.Context, todoID, subtaskID, userID uuid.UUID) error {
|
||||||
|
return s.subtaskService.Delete(ctx, subtaskID, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Attachment Methods --- (Implementation depends on FileStorageService)
|
||||||
|
|
||||||
|
func (s *todoService) AddAttachment(ctx context.Context, todoID, userID uuid.UUID, originalFilename string, fileSize int64, fileContent io.Reader) (*domain.AttachmentInfo, error) {
|
||||||
|
_, err := s.todoRepo.GetByID(ctx, todoID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
storageID, contentType, err := s.storageService.Upload(ctx, userID, todoID, originalFilename, fileContent, fileSize)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.ErrorContext(ctx, "Failed to upload attachment to storage", "error", err, "todoId", todoID, "fileName", originalFilename)
|
||||||
|
return nil, domain.ErrInternalServer
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = s.todoRepo.AddAttachment(ctx, todoID, userID, storageID); err != nil {
|
||||||
|
s.logger.ErrorContext(ctx, "Failed to add attachment storage ID to todo", "error", err, "todoId", todoID, "storageId", storageID)
|
||||||
|
if delErr := s.storageService.Delete(context.Background(), storageID); delErr != nil {
|
||||||
|
s.logger.ErrorContext(ctx, "Failed to delete orphaned attachment file after DB error", "deleteError", delErr, "storageId", storageID)
|
||||||
|
}
|
||||||
|
return nil, domain.ErrInternalServer
|
||||||
|
}
|
||||||
|
|
||||||
|
fileURL, _ := s.storageService.GetURL(ctx, storageID)
|
||||||
|
|
||||||
|
return &domain.AttachmentInfo{
|
||||||
|
FileID: storageID,
|
||||||
|
FileName: originalFilename,
|
||||||
|
FileURL: fileURL,
|
||||||
|
ContentType: contentType,
|
||||||
|
Size: fileSize,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *todoService) DeleteAttachment(ctx context.Context, todoID, userID uuid.UUID, storageID string) error {
|
||||||
|
todo, err := s.todoRepo.GetByID(ctx, todoID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for _, att := range todo.Attachments {
|
||||||
|
if att == storageID {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return fmt.Errorf("attachment '%s' not found on todo %s: %w", storageID, todoID, domain.ErrNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = s.todoRepo.RemoveAttachment(ctx, todoID, userID, storageID); err != nil {
|
||||||
|
s.logger.ErrorContext(ctx, "Failed to remove attachment ID from todo", "error", err, "todoId", todoID, "storageId", storageID)
|
||||||
|
return domain.ErrInternalServer
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = s.storageService.Delete(ctx, storageID); err != nil {
|
||||||
|
s.logger.ErrorContext(ctx, "Failed to delete attachment file from storage after removing DB ref", "error", err, "storageId", storageID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
83
backend/internal/service/user_service.go
Normal file
83
backend/internal/service/user_service.go
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/Sosokker/todolist-backend/internal/domain" // Adjust path
|
||||||
|
"github.com/Sosokker/todolist-backend/internal/repository" // Adjust path
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type userService struct {
|
||||||
|
userRepo repository.UserRepository
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserService(repo repository.UserRepository) UserService {
|
||||||
|
return &userService{
|
||||||
|
userRepo: repo,
|
||||||
|
logger: slog.Default().With("service", "user"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *userService) GetUserByID(ctx context.Context, id uuid.UUID) (*domain.User, error) {
|
||||||
|
user, err := s.userRepo.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, domain.ErrNotFound) {
|
||||||
|
s.logger.WarnContext(ctx, "User not found by ID", "userId", id)
|
||||||
|
return nil, domain.ErrNotFound
|
||||||
|
}
|
||||||
|
s.logger.ErrorContext(ctx, "Failed to get user by ID from repo", "error", err, "userId", id)
|
||||||
|
return nil, domain.ErrInternalServer
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *userService) UpdateUser(ctx context.Context, userID uuid.UUID, input UpdateUserInput) (*domain.User, error) {
|
||||||
|
// GetUserByID handles NotFound/Forbidden error
|
||||||
|
existingUser, err := s.GetUserByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare update data DTO for the repository
|
||||||
|
updateData := &domain.User{
|
||||||
|
// Copy non-updatable fields or handle defaults
|
||||||
|
}
|
||||||
|
needsUpdate := false
|
||||||
|
|
||||||
|
if input.Username != nil {
|
||||||
|
if err := ValidateUsername(*input.Username); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
updateData.Username = *input.Username
|
||||||
|
needsUpdate = true
|
||||||
|
} else {
|
||||||
|
updateData.Username = existingUser.Username
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Add logic for other updatable fields (e.g., email)
|
||||||
|
// Password updates should involve hashing and likely be a separate endpoint/service method.
|
||||||
|
// Email updates might require a verification flow.
|
||||||
|
|
||||||
|
if !needsUpdate {
|
||||||
|
s.logger.InfoContext(ctx, "No fields provided for user update", "userId", userID)
|
||||||
|
return existingUser, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedUser, err := s.userRepo.Update(ctx, userID, updateData)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, domain.ErrConflict) {
|
||||||
|
s.logger.WarnContext(ctx, "User update conflict", "error", err, "userId", userID, "conflictingUsername", updateData.Username)
|
||||||
|
return nil, fmt.Errorf("username '%s' is already taken: %w", updateData.Username, domain.ErrConflict)
|
||||||
|
}
|
||||||
|
s.logger.ErrorContext(ctx, "Failed to update user in repo", "error", err, "userId", userID)
|
||||||
|
return nil, domain.ErrInternalServer
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.InfoContext(ctx, "User updated successfully", "userId", userID)
|
||||||
|
return updatedUser, nil
|
||||||
|
}
|
||||||
192
backend/internal/service/validation.go
Normal file
192
backend/internal/service/validation.go
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/mail"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/Sosokker/todolist-backend/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
MinUsernameLength = 3
|
||||||
|
MaxUsernameLength = 50
|
||||||
|
MinPasswordLength = 6
|
||||||
|
MinTagNameLength = 1
|
||||||
|
MaxTagNameLength = 50
|
||||||
|
MaxTagIconLength = 30
|
||||||
|
MinTodoTitleLength = 1
|
||||||
|
MinSubtaskDescLength = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
// Regex for simple hex color validation (#RRGGBB)
|
||||||
|
var hexColorRegex = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
|
||||||
|
|
||||||
|
// ValidateUsername checks username constraints.
|
||||||
|
func ValidateUsername(username string) error {
|
||||||
|
if len(username) < MinUsernameLength || len(username) > MaxUsernameLength {
|
||||||
|
return fmt.Errorf("username must be between %d and %d characters: %w", MinUsernameLength, MaxUsernameLength, domain.ErrValidation)
|
||||||
|
}
|
||||||
|
// Add other constraints like allowed characters if needed
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateEmail checks if the email format is valid.
|
||||||
|
func ValidateEmail(email string) error {
|
||||||
|
if _, err := mail.ParseAddress(email); err != nil {
|
||||||
|
return fmt.Errorf("invalid email format: %w", domain.ErrValidation)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidatePassword checks password length.
|
||||||
|
func ValidatePassword(password string) error {
|
||||||
|
if len(password) < MinPasswordLength {
|
||||||
|
return fmt.Errorf("password must be at least %d characters: %w", MinPasswordLength, domain.ErrValidation)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateSignupInput validates the input for user registration.
|
||||||
|
func ValidateSignupInput(creds SignupCredentials) error {
|
||||||
|
if err := ValidateUsername(creds.Username); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ValidateEmail(creds.Email); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ValidatePassword(creds.Password); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateLoginInput validates the input for user login.
|
||||||
|
func ValidateLoginInput(creds LoginCredentials) error {
|
||||||
|
if err := ValidateEmail(creds.Email); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if creds.Password == "" { // Password presence check
|
||||||
|
return fmt.Errorf("password is required: %w", domain.ErrValidation)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValidHexColor checks if a string is a valid #RRGGBB hex color.
|
||||||
|
func IsValidHexColor(color string) bool {
|
||||||
|
return hexColorRegex.MatchString(color)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateTagName checks tag name constraints.
|
||||||
|
func ValidateTagName(name string) error {
|
||||||
|
trimmed := strings.TrimSpace(name)
|
||||||
|
if len(trimmed) < MinTagNameLength || len(trimmed) > MaxTagNameLength {
|
||||||
|
return fmt.Errorf("tag name must be between %d and %d characters: %w", MinTagNameLength, MaxTagNameLength, domain.ErrValidation)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateTagIcon checks tag icon constraints.
|
||||||
|
func ValidateTagIcon(icon *string) error {
|
||||||
|
if icon != nil && len(*icon) > MaxTagIconLength {
|
||||||
|
return fmt.Errorf("tag icon cannot be longer than %d characters: %w", MaxTagIconLength, domain.ErrValidation)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateCreateTagInput validates input for creating a tag.
|
||||||
|
func ValidateCreateTagInput(input CreateTagInput) error {
|
||||||
|
if err := ValidateTagName(input.Name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if input.Color != nil && !IsValidHexColor(*input.Color) {
|
||||||
|
return fmt.Errorf("invalid color format (must be #RRGGBB): %w", domain.ErrValidation)
|
||||||
|
}
|
||||||
|
if err := ValidateTagIcon(input.Icon); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateUpdateTagInput validates input for updating a tag.
|
||||||
|
func ValidateUpdateTagInput(input UpdateTagInput) error {
|
||||||
|
if input.Name != nil {
|
||||||
|
if err := ValidateTagName(*input.Name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if input.Color != nil && !IsValidHexColor(*input.Color) {
|
||||||
|
return fmt.Errorf("invalid color format (must be #RRGGBB): %w", domain.ErrValidation)
|
||||||
|
}
|
||||||
|
if err := ValidateTagIcon(input.Icon); err != nil { // Check pointer directly
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateTodoTitle checks title constraints.
|
||||||
|
func ValidateTodoTitle(title string) error {
|
||||||
|
if len(strings.TrimSpace(title)) < MinTodoTitleLength {
|
||||||
|
return fmt.Errorf("todo title cannot be empty: %w", domain.ErrValidation)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateCreateTodoInput validates input for creating a todo.
|
||||||
|
func ValidateCreateTodoInput(input CreateTodoInput) error {
|
||||||
|
if err := ValidateTodoTitle(input.Title); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Optional: Validate Status enum value if needed
|
||||||
|
// Optional: Validate Deadline is not in the past?
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateUpdateTodoInput validates input for updating a todo.
|
||||||
|
func ValidateUpdateTodoInput(input UpdateTodoInput) error {
|
||||||
|
if input.Title != nil {
|
||||||
|
if err := ValidateTodoTitle(*input.Title); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Optional: Validate Status enum value if needed
|
||||||
|
// Optional: Validate Deadline is not in the past?
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateSubtaskDescription checks description constraints.
|
||||||
|
func ValidateSubtaskDescription(desc string) error {
|
||||||
|
if len(strings.TrimSpace(desc)) < MinSubtaskDescLength {
|
||||||
|
return fmt.Errorf("subtask description cannot be empty: %w", domain.ErrValidation)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateCreateSubtaskInput validates input for creating a subtask.
|
||||||
|
func ValidateCreateSubtaskInput(input CreateSubtaskInput) error {
|
||||||
|
return ValidateSubtaskDescription(input.Description)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateUpdateSubtaskInput validates input for updating a subtask.
|
||||||
|
func ValidateUpdateSubtaskInput(input UpdateSubtaskInput) error {
|
||||||
|
if input.Description != nil {
|
||||||
|
if err := ValidateSubtaskDescription(*input.Description); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateListParams checks basic pagination parameters.
|
||||||
|
func ValidateListParams(limit, offset int) error {
|
||||||
|
if limit < 0 {
|
||||||
|
return fmt.Errorf("limit cannot be negative: %w", domain.ErrValidation)
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
return fmt.Errorf("offset cannot be negative: %w", domain.ErrValidation)
|
||||||
|
}
|
||||||
|
// Add max limit check if desired
|
||||||
|
// if limit > MaxListLimit { ... }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
23
backend/migrations/000001_init_schema.down.sql
Normal file
23
backend/migrations/000001_init_schema.down.sql
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
-- Drop triggers first
|
||||||
|
DROP TRIGGER IF EXISTS set_timestamp_users ON users;
|
||||||
|
DROP TRIGGER IF EXISTS set_timestamp_tags ON tags;
|
||||||
|
DROP TRIGGER IF EXISTS set_timestamp_todos ON todos;
|
||||||
|
DROP TRIGGER IF EXISTS set_timestamp_subtasks ON subtasks;
|
||||||
|
DROP TRIGGER IF EXISTS set_timestamp_attachments ON attachments;
|
||||||
|
|
||||||
|
-- Drop the trigger function
|
||||||
|
DROP FUNCTION IF EXISTS trigger_set_timestamp();
|
||||||
|
|
||||||
|
-- Drop tables in reverse order of creation (or based on dependencies)
|
||||||
|
DROP TABLE IF EXISTS attachments;
|
||||||
|
DROP TABLE IF EXISTS todo_tags;
|
||||||
|
DROP TABLE IF EXISTS subtasks;
|
||||||
|
DROP TABLE IF EXISTS todos;
|
||||||
|
DROP TABLE IF EXISTS tags;
|
||||||
|
DROP TABLE IF EXISTS users;
|
||||||
|
|
||||||
|
-- Drop custom types
|
||||||
|
DROP TYPE IF EXISTS todo_status;
|
||||||
|
|
||||||
|
-- Drop extensions (usually not needed in down migration unless specifically required)
|
||||||
|
DROP EXTENSION IF EXISTS "uuid-ossp";
|
||||||
128
backend/migrations/000001_init_schema.up.sql
Normal file
128
backend/migrations/000001_init_schema.up.sql
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
-- Enable UUID extension
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
-- Users Table
|
||||||
|
CREATE TABLE users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
username VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
email VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
password_hash VARCHAR(255) NOT NULL, -- Store hashed password
|
||||||
|
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
google_id VARCHAR(255) UNIQUE NULL, -- For Google OAuth association
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Add index for faster email lookup
|
||||||
|
CREATE INDEX idx_users_email ON users(email);
|
||||||
|
|
||||||
|
-- Tags Table
|
||||||
|
CREATE TABLE tags (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(50) NOT NULL,
|
||||||
|
color VARCHAR(7) NULL, -- e.g., #FF5733
|
||||||
|
icon VARCHAR(30) NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
-- Ensure tag name is unique per user
|
||||||
|
UNIQUE (user_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for faster tag lookup by user
|
||||||
|
CREATE INDEX idx_tags_user_id ON tags(user_id);
|
||||||
|
|
||||||
|
-- Todos Table
|
||||||
|
CREATE TYPE todo_status AS ENUM ('pending', 'in-progress', 'completed');
|
||||||
|
|
||||||
|
CREATE TABLE todos (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT NULL,
|
||||||
|
status todo_status NOT NULL DEFAULT 'pending',
|
||||||
|
deadline TIMESTAMPTZ NULL,
|
||||||
|
-- attachments array will store file IDs or identifiers
|
||||||
|
attachments TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for common filtering/sorting
|
||||||
|
CREATE INDEX idx_todos_user_id ON todos(user_id);
|
||||||
|
CREATE INDEX idx_todos_status ON todos(status);
|
||||||
|
CREATE INDEX idx_todos_deadline ON todos(deadline);
|
||||||
|
|
||||||
|
-- Subtasks Table
|
||||||
|
CREATE TABLE subtasks (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
todo_id UUID NOT NULL REFERENCES todos(id) ON DELETE CASCADE,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
completed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for faster subtask lookup by todo
|
||||||
|
CREATE INDEX idx_subtasks_todo_id ON subtasks(todo_id);
|
||||||
|
|
||||||
|
-- Todo_Tags Junction Table (Many-to-Many)
|
||||||
|
CREATE TABLE todo_tags (
|
||||||
|
todo_id UUID NOT NULL REFERENCES todos(id) ON DELETE CASCADE,
|
||||||
|
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (todo_id, tag_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for faster lookup when filtering todos by tag
|
||||||
|
CREATE INDEX idx_todo_tags_tag_id ON todo_tags(tag_id);
|
||||||
|
|
||||||
|
-- Optional: Attachments Metadata Table (if storing more than just IDs/URLs in Todo)
|
||||||
|
-- Consider if you need detailed tracking, otherwise the TEXT[] on todos might suffice
|
||||||
|
CREATE TABLE attachments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
todo_id UUID NOT NULL REFERENCES todos(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, -- Redundant? Maybe for direct permission checks
|
||||||
|
file_name VARCHAR(255) NOT NULL,
|
||||||
|
storage_path VARCHAR(512) NOT NULL, -- e.g., S3 key or local path
|
||||||
|
content_type VARCHAR(100) NOT NULL,
|
||||||
|
size BIGINT NOT NULL,
|
||||||
|
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_attachments_todo_id ON attachments(todo_id);
|
||||||
|
|
||||||
|
|
||||||
|
-- Function to automatically update 'updated_at' timestamp
|
||||||
|
CREATE OR REPLACE FUNCTION trigger_set_timestamp()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Apply the trigger to tables that have 'updated_at'
|
||||||
|
CREATE TRIGGER set_timestamp_users
|
||||||
|
BEFORE UPDATE ON users
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE PROCEDURE trigger_set_timestamp();
|
||||||
|
|
||||||
|
CREATE TRIGGER set_timestamp_tags
|
||||||
|
BEFORE UPDATE ON tags
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE PROCEDURE trigger_set_timestamp();
|
||||||
|
|
||||||
|
CREATE TRIGGER set_timestamp_todos
|
||||||
|
BEFORE UPDATE ON todos
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE PROCEDURE trigger_set_timestamp();
|
||||||
|
|
||||||
|
CREATE TRIGGER set_timestamp_subtasks
|
||||||
|
BEFORE UPDATE ON subtasks
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE PROCEDURE trigger_set_timestamp();
|
||||||
|
|
||||||
|
-- Optional trigger for attachments table if created
|
||||||
|
CREATE TRIGGER set_timestamp_attachments
|
||||||
|
BEFORE UPDATE ON attachments
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE PROCEDURE trigger_set_timestamp();
|
||||||
1183
backend/openapi.yaml
Normal file
1183
backend/openapi.yaml
Normal file
File diff suppressed because it is too large
Load Diff
0
backend/scripts/generate.sh
Normal file
0
backend/scripts/generate.sh
Normal file
0
backend/scripts/migrate.sh
Normal file
0
backend/scripts/migrate.sh
Normal file
40
backend/sqlc.yaml
Normal file
40
backend/sqlc.yaml
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
version: "2"
|
||||||
|
sql:
|
||||||
|
- engine: "postgresql"
|
||||||
|
queries: "internal/repository/sqlc/queries/"
|
||||||
|
schema: "migrations/" # Path to your latest schema or combined migrations
|
||||||
|
gen:
|
||||||
|
go:
|
||||||
|
package: "generated"
|
||||||
|
sql_package: "pgx/v5"
|
||||||
|
out: "internal/repository/sqlc/generated"
|
||||||
|
# Emit interfaces for easier mocking (optional but good practice)
|
||||||
|
emit_interface: true
|
||||||
|
# Use pgx/v5 types
|
||||||
|
emit_exact_table_names: false
|
||||||
|
emit_json_tags: true
|
||||||
|
json_tags_case_style: "camel"
|
||||||
|
# Map Postgres types to Go types, including nulls
|
||||||
|
overrides:
|
||||||
|
- db_type: "uuid"
|
||||||
|
go_type: "github.com/google/uuid.UUID"
|
||||||
|
nullable: false
|
||||||
|
|
||||||
|
- db_type: "timestamptz"
|
||||||
|
go_type: "time.Time"
|
||||||
|
nullable: false
|
||||||
|
|
||||||
|
- db_type: "text"
|
||||||
|
nullable: true
|
||||||
|
go_type: "database/sql.NullString"
|
||||||
|
|
||||||
|
- db_type: "timestamptz"
|
||||||
|
go_type:
|
||||||
|
import: "time"
|
||||||
|
type: "Time"
|
||||||
|
- db_type: "timestamptz"
|
||||||
|
go_type:
|
||||||
|
import: "time"
|
||||||
|
type: "Time"
|
||||||
|
pointer: true
|
||||||
|
nullable: true
|
||||||
41
frontend/.gitignore
vendored
Normal file
41
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
36
frontend/README.md
Normal file
36
frontend/README.md
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
5
frontend/app/env.d.ts
vendored
Normal file
5
frontend/app/env.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
namespace NodeJS {
|
||||||
|
interface ProcessEnv {
|
||||||
|
NEXT_PUBLIC_API_BASE_URL: string
|
||||||
|
}
|
||||||
|
}
|
||||||
46
frontend/app/error.tsx
Normal file
46
frontend/app/error.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Icons } from "@/components/icons"
|
||||||
|
|
||||||
|
export default function Error({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string }
|
||||||
|
reset: () => void
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
// Log the error to an error reporting service
|
||||||
|
console.error(error)
|
||||||
|
}, [error])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen px-6 py-12 bg-background">
|
||||||
|
<div className="flex flex-col items-center max-w-md mx-auto text-center">
|
||||||
|
<div className="flex items-center justify-center w-16 h-16 rounded-full bg-red-100 dark:bg-red-900/20 mb-6">
|
||||||
|
<Icons.warning className="h-8 w-8 text-red-600 dark:text-red-400" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight mb-4">Something went wrong!</h1>
|
||||||
|
<p className="text-muted-foreground mb-8">
|
||||||
|
We're sorry, but we encountered an unexpected error. Our team has been notified and is working to fix the
|
||||||
|
issue.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
<Button onClick={reset}>
|
||||||
|
<Icons.refresh className="mr-2 h-4 w-4" />
|
||||||
|
Try again
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href="/">
|
||||||
|
<Icons.home className="mr-2 h-4 w-4" />
|
||||||
|
Go to Home
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
BIN
frontend/app/favicon.ico
Normal file
BIN
frontend/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
168
frontend/app/globals.css
Normal file
168
frontend/app/globals.css
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--font-sans: var(--font-geist-sans);
|
||||||
|
--font-mono: var(--font-geist-mono);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--animate-accordion-down: accordion-down 0.2s ease-out;
|
||||||
|
--animate-accordion-up: accordion-up 0.2s ease-out;
|
||||||
|
|
||||||
|
@keyframes accordion-down {
|
||||||
|
from {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
height: var(--radix-accordion-content-height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes accordion-up {
|
||||||
|
from {
|
||||||
|
height: var(--radix-accordion-content-height);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.145 0 0);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.205 0 0);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.97 0 0);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.145 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.205 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.205 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.922 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.556 0 0);
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
font-feature-settings: "rlig" 1, "calt" 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Todoist-inspired styles */
|
||||||
|
.todoist-card {
|
||||||
|
@apply rounded-md border border-gray-100 shadow-sm hover:shadow-md transition-shadow;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todoist-task-row {
|
||||||
|
@apply flex items-center gap-3 px-3 py-2 rounded-md hover:bg-muted/30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-checkbox {
|
||||||
|
@apply h-5 w-5 rounded-full border border-gray-300 flex items-center justify-center hover:bg-muted/50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-checkbox.completed {
|
||||||
|
@apply bg-green-500 border-green-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.airbnb-gradient {
|
||||||
|
background: linear-gradient(90deg, #ff5a5f 0%, #ff5a5f 50%, #fc642d 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.airbnb-button {
|
||||||
|
@apply bg-[#FF5A5F] hover:bg-[#FF5A5F]/90 text-white font-medium rounded-md px-4 py-2;
|
||||||
|
}
|
||||||
36
frontend/app/layout.tsx
Normal file
36
frontend/app/layout.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import type React from "react";
|
||||||
|
import { Inter } from "next/font/google";
|
||||||
|
import { Toaster } from "sonner";
|
||||||
|
import { QueryClientProvider } from "@/components/query-client-provider";
|
||||||
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
|
import { AuthProvider } from "@/store/auth-provider";
|
||||||
|
import "@/app/globals.css";
|
||||||
|
|
||||||
|
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "Todo App",
|
||||||
|
description: "A beautiful todo app with Airbnb-inspired design",
|
||||||
|
generator: "v0.dev",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="en" suppressHydrationWarning>
|
||||||
|
<body className={`font-sans ${inter.variable}`}>
|
||||||
|
<ThemeProvider attribute="class" defaultTheme="light">
|
||||||
|
<QueryClientProvider>
|
||||||
|
<AuthProvider>
|
||||||
|
{children}
|
||||||
|
<Toaster position="top-right" />
|
||||||
|
</AuthProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
frontend/app/loading.tsx
Normal file
27
frontend/app/loading.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="container max-w-5xl mx-auto px-4 py-6 space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<Skeleton className="h-10 w-40" />
|
||||||
|
<Skeleton className="h-10 w-32" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
<Skeleton className="h-10 flex-1 max-w-md" />
|
||||||
|
<Skeleton className="h-10 w-40" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Skeleton className="h-12 w-full max-w-md" />
|
||||||
|
|
||||||
|
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{Array(6)
|
||||||
|
.fill(0)
|
||||||
|
.map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-[180px] rounded-lg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
221
frontend/app/login/page.tsx
Normal file
221
frontend/app/login/page.tsx
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type React from "react";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { loginUserApi } from "@/services/api-auth";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Icons } from "@/components/icons";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { login } = useAuth();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
});
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await loginUserApi(formData);
|
||||||
|
login(response.accessToken, {
|
||||||
|
id: "user-123",
|
||||||
|
username: "User",
|
||||||
|
email: formData.email,
|
||||||
|
emailVerified: true,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
toast.success("Logged in successfully");
|
||||||
|
router.push("/todos");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Login failed:", error);
|
||||||
|
toast.error("Login failed. Please check your credentials.");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex bg-white">
|
||||||
|
{/* Left side - Image */}
|
||||||
|
<div className="hidden lg:flex lg:w-1/2 relative bg-[#FF5A5F]/10">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-b from-[#FF5A5F]/20 to-[#FF5A5F]/5 z-10"></div>
|
||||||
|
<Image
|
||||||
|
src="/gradient-bg.jpg"
|
||||||
|
width={1080}
|
||||||
|
height={1080}
|
||||||
|
alt="Background"
|
||||||
|
className="absolute inset-0 w-full h-full object-cover brightness-60"
|
||||||
|
/>
|
||||||
|
<div className="relative z-20 flex flex-col justify-between h-full p-12">
|
||||||
|
<div>
|
||||||
|
<Link href="/" className="text-[#FF5A5F] text-2xl font-bold">
|
||||||
|
TODO
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="mb-12">
|
||||||
|
<h2 className="text-[#FF5A5F] text-3xl font-bold mb-4">
|
||||||
|
Planning,
|
||||||
|
<br />
|
||||||
|
Organizing,
|
||||||
|
</h2>
|
||||||
|
<div className="flex space-x-2 mt-6">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-[#FF5A5F]/40"></div>
|
||||||
|
<div className="w-2 h-2 rounded-full bg-[#FF5A5F]/20"></div>
|
||||||
|
<div className="w-2 h-2 rounded-full bg-[#FF5A5F]"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side - Form */}
|
||||||
|
<div className="w-full lg:w-1/2 flex items-center justify-center p-8 bg-white">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="flex justify-between items-center mb-8">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-[#FF5A5F] text-2xl font-bold lg:hidden"
|
||||||
|
>
|
||||||
|
TODO
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-sm text-gray-500 hover:text-[#FF5A5F] transition-colors flex items-center"
|
||||||
|
>
|
||||||
|
<span>Back to website</span>
|
||||||
|
<Icons.arrowRight className="ml-1 h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-gray-900 text-3xl font-bold mb-2">
|
||||||
|
Log in to your account
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 mb-8">
|
||||||
|
Don't have an account?{" "}
|
||||||
|
<Link href="/signup" className="text-[#FF5A5F] hover:underline">
|
||||||
|
Sign up
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email" className="text-gray-900">
|
||||||
|
Email
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="name@example.com"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
className="bg-white border-gray-300 text-gray-900 placeholder-gray-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="password" className="text-gray-900">
|
||||||
|
Password
|
||||||
|
</Label>
|
||||||
|
<Link
|
||||||
|
href="#"
|
||||||
|
className="text-sm text-[#FF5A5F] hover:underline"
|
||||||
|
>
|
||||||
|
Forgot password?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
className="bg-white border-gray-300 text-gray-900 pr-10 placeholder-gray-400"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<Icons.eyeOff className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Icons.eye className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="remember"
|
||||||
|
className="border-gray-300 data-[state=checked]:bg-[#FF5A5F]"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="remember"
|
||||||
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-gray-900"
|
||||||
|
>
|
||||||
|
Remember me
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full airbnb-button"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Icons.spinner className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : null}
|
||||||
|
Log in
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="relative flex items-center justify-center">
|
||||||
|
<div className="border-t border-gray-200 w-full"></div>
|
||||||
|
<span className="bg-white px-2 text-sm text-gray-400 absolute">
|
||||||
|
Or continue with
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full border-gray-300 text-gray-900 hover:bg-gray-100"
|
||||||
|
onClick={() =>
|
||||||
|
toast.info("Google signup would be implemented here")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icons.google className="mr-2 h-4 w-4" />
|
||||||
|
Google
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
frontend/app/not-found.tsx
Normal file
34
frontend/app/not-found.tsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import Link from "next/link"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Icons } from "@/components/icons"
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen px-6 py-12 bg-background">
|
||||||
|
<div className="flex flex-col items-center max-w-md mx-auto text-center">
|
||||||
|
<div className="flex items-center justify-center w-16 h-16 rounded-full bg-muted mb-6">
|
||||||
|
<Icons.warning className="h-8 w-8 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl font-bold tracking-tight mb-2">404</h1>
|
||||||
|
<h2 className="text-2xl font-semibold mb-4">Page not found</h2>
|
||||||
|
<p className="text-muted-foreground mb-8">
|
||||||
|
Sorry, we couldn't find the page you're looking for. It might have been moved, deleted, or never existed.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/">
|
||||||
|
<Icons.home className="mr-2 h-4 w-4" />
|
||||||
|
Go to Home
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href="/todos">
|
||||||
|
<Icons.list className="mr-2 h-4 w-4" />
|
||||||
|
View Todos
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
7
frontend/app/page.tsx
Normal file
7
frontend/app/page.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
// In a real app, we'd check auth status server-side
|
||||||
|
// For now, just redirect to todos
|
||||||
|
redirect("/todos")
|
||||||
|
}
|
||||||
181
frontend/app/profile/page.tsx
Normal file
181
frontend/app/profile/page.tsx
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type React from "react";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Icons } from "@/components/icons";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
||||||
|
export default function ProfilePage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { user, updateProfile } = useAuth();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [username, setUsername] = useState(user?.username || "");
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!username.trim()) {
|
||||||
|
toast.error("Username cannot be empty");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateProfile({ username });
|
||||||
|
toast.success("Username updated successfully");
|
||||||
|
router.push("/todos");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update profile:", error);
|
||||||
|
toast.error("Failed to update profile. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container max-w-2xl mx-auto px-4 py-6 space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Profile Settings</h1>
|
||||||
|
<Button variant="outline" onClick={() => router.back()}>
|
||||||
|
<Icons.arrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Account Information</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Update your account information here.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center gap-4 mb-6">
|
||||||
|
<div className="h-16 w-16 rounded-full bg-muted flex items-center justify-center">
|
||||||
|
<Icons.user className="h-8 w-8 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{user?.username || "User"}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{user?.email || "user@example.com"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="my-6" />
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="username">Username</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="Enter your username"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This is the name that will be displayed to other users.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
value={user?.email || ""}
|
||||||
|
disabled
|
||||||
|
className="bg-muted"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Your email address cannot be changed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex justify-between">
|
||||||
|
<Button variant="outline" onClick={() => router.back()}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={
|
||||||
|
isLoading || username === user?.username || !username.trim()
|
||||||
|
}
|
||||||
|
className="bg-[#FF5A5F] hover:bg-[#FF5A5F]/90"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Icons.spinner className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : null}
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Account Security</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Manage your account security settings.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium">Password</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Change your password
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline">Change Password</Button>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium">Two-Factor Authentication</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Add an extra layer of security to your account
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline">Enable</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Danger Zone</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Irreversible and destructive actions
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-destructive">Delete Account</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Permanently delete your account and all of your data
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="destructive">Delete Account</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
258
frontend/app/signup/page.tsx
Normal file
258
frontend/app/signup/page.tsx
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type React from "react";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { signupUserApi } from "@/services/api-auth";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Icons } from "@/components/icons";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
export default function SignupPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { login } = useAuth();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
firstName: "",
|
||||||
|
lastName: "",
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
});
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!agreedToTerms) {
|
||||||
|
toast.error("You must agree to the Terms & Conditions");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { firstName, lastName, email, password } = formData;
|
||||||
|
const username = `${firstName} ${lastName}`.trim();
|
||||||
|
const user = await signupUserApi({ username, email, password });
|
||||||
|
|
||||||
|
// In a real app, we'd get a token back from signup or do a separate login
|
||||||
|
// For now, we'll simulate getting a token
|
||||||
|
login("dummy-token", user);
|
||||||
|
|
||||||
|
toast.success("Account created successfully");
|
||||||
|
router.push("/todos");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Signup failed:", error);
|
||||||
|
toast.error("Signup failed. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex bg-white">
|
||||||
|
{/* Left side - Image */}
|
||||||
|
<div className="hidden lg:flex lg:w-1/2 relative bg-[#FF5A5F]/10">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-b from-[#FF5A5F]/20 to-[#FF5A5F]/5 z-10"></div>
|
||||||
|
<Image
|
||||||
|
src="/gradient-bg.jpg"
|
||||||
|
width={1080}
|
||||||
|
height={1080}
|
||||||
|
alt="Background"
|
||||||
|
className="absolute inset-0 w-full h-full object-cover brightness-60"
|
||||||
|
/>
|
||||||
|
<div className="relative z-20 flex flex-col justify-between h-full p-12">
|
||||||
|
<div>
|
||||||
|
<Link href="/" className="text-[#FF5A5F] text-2xl font-bold">
|
||||||
|
TODO
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="mb-12">
|
||||||
|
<h2 className="text-[#FF5A5F] text-3xl font-bold mb-4">
|
||||||
|
Capturing Moments,
|
||||||
|
<br />
|
||||||
|
Creating Memories
|
||||||
|
</h2>
|
||||||
|
<div className="flex space-x-2 mt-6">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-[#FF5A5F]/40"></div>
|
||||||
|
<div className="w-2 h-2 rounded-full bg-[#FF5A5F]/20"></div>
|
||||||
|
<div className="w-2 h-2 rounded-full bg-[#FF5A5F]"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side - Form */}
|
||||||
|
<div className="w-full lg:w-1/2 flex items-center justify-center p-8 bg-white">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="flex justify-between items-center mb-8">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-[#FF5A5F] text-2xl font-bold lg:hidden"
|
||||||
|
>
|
||||||
|
TODO
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-sm text-gray-500 hover:text-[#FF5A5F] transition-colors flex items-center"
|
||||||
|
>
|
||||||
|
<span>Back to website</span>
|
||||||
|
<Icons.arrowRight className="ml-1 h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-gray-900 text-3xl font-bold mb-2">
|
||||||
|
Create an account
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 mb-8">
|
||||||
|
Already have an account?{" "}
|
||||||
|
<Link href="/login" className="text-[#FF5A5F] hover:underline">
|
||||||
|
Log in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="firstName" className="text-gray-900">
|
||||||
|
First name
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="firstName"
|
||||||
|
name="firstName"
|
||||||
|
placeholder="John"
|
||||||
|
value={formData.firstName}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
className="bg-white border-gray-300 text-gray-900 placeholder-gray-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="lastName" className="text-gray-900">
|
||||||
|
Last name
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="lastName"
|
||||||
|
name="lastName"
|
||||||
|
placeholder="Doe"
|
||||||
|
value={formData.lastName}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
className="bg-white border-gray-300 text-gray-900 placeholder-gray-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email" className="text-gray-900">
|
||||||
|
Email
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="name@example.com"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
className="bg-white border-gray-300 text-gray-900 placeholder-gray-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password" className="text-gray-900">
|
||||||
|
Password
|
||||||
|
</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
className="bg-white border-gray-300 text-gray-900 pr-10 placeholder-gray-400"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<Icons.eyeOff className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Icons.eye className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="terms"
|
||||||
|
checked={agreedToTerms}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setAgreedToTerms(checked as boolean)
|
||||||
|
}
|
||||||
|
className="border-gray-300 data-[state=checked]:bg-[#FF5A5F]"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="terms"
|
||||||
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-gray-900"
|
||||||
|
>
|
||||||
|
I agree to the{" "}
|
||||||
|
<Link href="#" className="text-[#FF5A5F] hover:underline">
|
||||||
|
Terms & Conditions
|
||||||
|
</Link>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full airbnb-button"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Icons.spinner className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : null}
|
||||||
|
Create account
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="relative flex items-center justify-center">
|
||||||
|
<div className="border-t border-gray-200 w-full"></div>
|
||||||
|
<span className="bg-white px-2 text-sm text-gray-400 absolute">
|
||||||
|
Or register with
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full border-gray-300 text-gray-900 hover:bg-gray-100"
|
||||||
|
onClick={() =>
|
||||||
|
toast.info("Google signup would be implemented here")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icons.google className="mr-2 h-4 w-4" />
|
||||||
|
Google
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
frontend/app/todos/kanban/page.tsx
Normal file
139
frontend/app/todos/kanban/page.tsx
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { DndContext, type DragEndEvent, closestCenter } from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
import { useTodos, useUpdateTodo } from "@/hooks/use-todos";
|
||||||
|
import { useTags } from "@/hooks/use-tags";
|
||||||
|
import { KanbanColumn } from "@/components/kanban-column";
|
||||||
|
import { TodoForm } from "@/components/todo-form";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Icons } from "@/components/icons";
|
||||||
|
|
||||||
|
export default function KanbanPage() {
|
||||||
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||||
|
const { data: todos = [], isLoading } = useTodos();
|
||||||
|
const { data: tags = [] } = useTags();
|
||||||
|
const updateTodoMutation = useUpdateTodo();
|
||||||
|
|
||||||
|
const pendingTodos = todos.filter((todo) => todo.status === "pending");
|
||||||
|
const inProgressTodos = todos.filter((todo) => todo.status === "in-progress");
|
||||||
|
const completedTodos = todos.filter((todo) => todo.status === "completed");
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
|
||||||
|
if (!over) return;
|
||||||
|
|
||||||
|
const todoId = active.id as string;
|
||||||
|
const newStatus = over.id as "pending" | "in-progress" | "completed";
|
||||||
|
|
||||||
|
const todo = todos.find((t) => t.id === todoId);
|
||||||
|
if (!todo || todo.status === newStatus) return;
|
||||||
|
|
||||||
|
updateTodoMutation.mutate(
|
||||||
|
{
|
||||||
|
id: todoId,
|
||||||
|
todo: { status: newStatus },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(`Todo moved to ${newStatus.replace("-", " ")}`);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error("Failed to update todo status");
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateTodo = async () => {
|
||||||
|
try {
|
||||||
|
// This would be handled by the createTodo mutation in a real app
|
||||||
|
toast.success("Todo created successfully");
|
||||||
|
setIsCreateDialogOpen(false);
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to create todo");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 pt-5 mx-auto max-w-5xl">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Kanban Board</h1>
|
||||||
|
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button className="bg-[#FF5A5F] hover:bg-[#FF5A5F]/90">
|
||||||
|
<Icons.plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Todo
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create New Todo</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Add a new task to your todo list
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<TodoForm tags={tags} onSubmit={handleCreateTodo} />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<SortableContext
|
||||||
|
items={pendingTodos.map((t) => t.id)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<KanbanColumn
|
||||||
|
id="pending"
|
||||||
|
title="Pending"
|
||||||
|
todos={pendingTodos}
|
||||||
|
tags={tags}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
</SortableContext>
|
||||||
|
|
||||||
|
<SortableContext
|
||||||
|
items={inProgressTodos.map((t) => t.id)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<KanbanColumn
|
||||||
|
id="in-progress"
|
||||||
|
title="In Progress"
|
||||||
|
todos={inProgressTodos}
|
||||||
|
tags={tags}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
</SortableContext>
|
||||||
|
|
||||||
|
<SortableContext
|
||||||
|
items={completedTodos.map((t) => t.id)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<KanbanColumn
|
||||||
|
id="completed"
|
||||||
|
title="Completed"
|
||||||
|
todos={completedTodos}
|
||||||
|
tags={tags}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
</SortableContext>
|
||||||
|
</div>
|
||||||
|
</DndContext>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
frontend/app/todos/layout.tsx
Normal file
41
frontend/app/todos/layout.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useAuthStore } from "@/store/auth-store";
|
||||||
|
import { Navbar } from "@/components/navbar";
|
||||||
|
|
||||||
|
export default function TodosLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const { isAuthenticated, hydrated } = useAuthStore();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hydrated && !isAuthenticated) {
|
||||||
|
router.push("/login");
|
||||||
|
}
|
||||||
|
}, [hydrated, isAuthenticated, router]);
|
||||||
|
|
||||||
|
if (!hydrated) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center w-full h-full">
|
||||||
|
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-500" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
router.push("/login");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col items-start mx-auto">
|
||||||
|
<Navbar />
|
||||||
|
<main className="flex-1 w-full py-2">{children}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
344
frontend/app/todos/list/page.tsx
Normal file
344
frontend/app/todos/list/page.tsx
Normal file
@ -0,0 +1,344 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import {
|
||||||
|
useTodos,
|
||||||
|
useCreateTodo,
|
||||||
|
useUpdateTodo,
|
||||||
|
useDeleteTodo,
|
||||||
|
} from "@/hooks/use-todos";
|
||||||
|
import { useTags } from "@/hooks/use-tags";
|
||||||
|
import { TodoCard } from "@/components/todo-card";
|
||||||
|
import { TodoRow } from "@/components/todo-row";
|
||||||
|
import { TodoForm } from "@/components/todo-form";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Icons } from "@/components/icons";
|
||||||
|
import type { Tag, Todo } from "@/services/api-types";
|
||||||
|
|
||||||
|
export default function TodoListPage() {
|
||||||
|
const [status, setStatus] = useState<string | undefined>(undefined);
|
||||||
|
const [tagFilter, setTagFilter] = useState<string | undefined>(undefined);
|
||||||
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: todos = [],
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
} = useTodos({ status, tagId: tagFilter });
|
||||||
|
const { data: tags = [] } = useTags();
|
||||||
|
const createTodoMutation = useCreateTodo();
|
||||||
|
const updateTodoMutation = useUpdateTodo();
|
||||||
|
const deleteTodoMutation = useDeleteTodo();
|
||||||
|
|
||||||
|
const handleCreateTodo = async (todo: Partial<Todo>) => {
|
||||||
|
try {
|
||||||
|
await createTodoMutation.mutateAsync(todo);
|
||||||
|
setIsCreateDialogOpen(false);
|
||||||
|
toast.success("Todo created successfully");
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to create todo");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateTodo = async (id: string, todo: Partial<Todo>) => {
|
||||||
|
try {
|
||||||
|
await updateTodoMutation.mutateAsync({ id, todo });
|
||||||
|
toast.success("Todo updated successfully");
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to update todo");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteTodo = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await deleteTodoMutation.mutateAsync(id);
|
||||||
|
toast.success("Todo deleted successfully");
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to delete todo");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredTodos = todos.filter(
|
||||||
|
(todo) =>
|
||||||
|
todo.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
(todo.description &&
|
||||||
|
todo.description.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="container max-w-5xl mx-auto px-4 py-6">
|
||||||
|
<div className="text-center py-10 bg-muted/20 rounded-lg border border-dashed">
|
||||||
|
<Icons.warning className="h-12 w-12 mx-auto text-muted-foreground/50 mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-muted-foreground mb-2">
|
||||||
|
Error loading todos
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Please try again later
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => window.location.reload()} variant="outline">
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container max-w-5xl mx-auto px-4 py-6 space-y-6">
|
||||||
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Todos</h1>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex border rounded-md overflow-hidden">
|
||||||
|
<Button
|
||||||
|
variant={viewMode === "grid" ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
className="rounded-none h-9 px-3"
|
||||||
|
onClick={() => setViewMode("grid")}
|
||||||
|
>
|
||||||
|
<Icons.layoutGrid className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Grid View</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={viewMode === "list" ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
className="rounded-none h-9 px-3"
|
||||||
|
onClick={() => setViewMode("list")}
|
||||||
|
>
|
||||||
|
<Icons.list className="h-4 w-4" />
|
||||||
|
<span className="sr-only">List View</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Dialog
|
||||||
|
open={isCreateDialogOpen}
|
||||||
|
onOpenChange={setIsCreateDialogOpen}
|
||||||
|
>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button className="bg-[#FF5A5F] hover:bg-[#FF5A5F]/90">
|
||||||
|
<Icons.plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Todo
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create New Todo</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Add a new task to your todo list
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<TodoForm tags={tags} onSubmit={handleCreateTodo} />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
placeholder="Search todos..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="max-w-md"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select
|
||||||
|
value={tagFilter ?? "all"}
|
||||||
|
onValueChange={(val) =>
|
||||||
|
setTagFilter(val === "all" ? undefined : val)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue placeholder="Filter by tag" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All tags</SelectItem>
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<SelectItem key={tag.id} value={tag.id}>
|
||||||
|
{tag.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
defaultValue="all"
|
||||||
|
className="w-full"
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setStatus(value === "all" ? undefined : value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TabsList className="grid w-full max-w-md grid-cols-3">
|
||||||
|
<TabsTrigger value="all">All</TabsTrigger>
|
||||||
|
<TabsTrigger value="pending">Pending</TabsTrigger>
|
||||||
|
<TabsTrigger value="in-progress">In Progress</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="all" className="mt-6">
|
||||||
|
<TodoList
|
||||||
|
todos={filteredTodos}
|
||||||
|
tags={tags}
|
||||||
|
isLoading={isLoading}
|
||||||
|
onUpdate={handleUpdateTodo}
|
||||||
|
onDelete={handleDeleteTodo}
|
||||||
|
viewMode={viewMode}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="pending" className="mt-6">
|
||||||
|
<TodoList
|
||||||
|
todos={filteredTodos.filter((todo) => todo.status === "pending")}
|
||||||
|
tags={tags}
|
||||||
|
isLoading={isLoading}
|
||||||
|
onUpdate={handleUpdateTodo}
|
||||||
|
onDelete={handleDeleteTodo}
|
||||||
|
viewMode={viewMode}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="in-progress" className="mt-6">
|
||||||
|
<TodoList
|
||||||
|
todos={filteredTodos.filter(
|
||||||
|
(todo) => todo.status === "in-progress"
|
||||||
|
)}
|
||||||
|
tags={tags}
|
||||||
|
isLoading={isLoading}
|
||||||
|
onUpdate={handleUpdateTodo}
|
||||||
|
onDelete={handleDeleteTodo}
|
||||||
|
viewMode={viewMode}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TodoList({
|
||||||
|
todos,
|
||||||
|
tags,
|
||||||
|
isLoading,
|
||||||
|
onUpdate,
|
||||||
|
onDelete,
|
||||||
|
viewMode,
|
||||||
|
}: {
|
||||||
|
todos: Todo[];
|
||||||
|
tags: Tag[];
|
||||||
|
isLoading: boolean;
|
||||||
|
onUpdate: (id: string, todo: Partial<Todo>) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
viewMode: "grid" | "list";
|
||||||
|
}) {
|
||||||
|
const container = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
show: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.05,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const item = {
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
show: { opacity: 1, y: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
if (viewMode === "grid") {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-[150px] rounded-lg bg-muted animate-pulse"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||||
|
<div key={i} className="h-12 rounded-md bg-muted animate-pulse" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (todos.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-10 bg-muted/20 rounded-lg border border-dashed">
|
||||||
|
<Icons.inbox className="h-12 w-12 mx-auto text-muted-foreground/50 mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-muted-foreground mb-2">
|
||||||
|
No todos found
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Create one to get started!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewMode === "grid") {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"
|
||||||
|
variants={container}
|
||||||
|
initial="hidden"
|
||||||
|
animate="show"
|
||||||
|
>
|
||||||
|
{todos.map((todo) => (
|
||||||
|
<motion.div key={todo.id} variants={item}>
|
||||||
|
<TodoCard
|
||||||
|
todo={todo}
|
||||||
|
tags={tags}
|
||||||
|
onUpdate={(updatedTodo) => onUpdate(todo.id, updatedTodo)}
|
||||||
|
onDelete={() => onDelete(todo.id)}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="space-y-1 border rounded-md overflow-hidden bg-card"
|
||||||
|
variants={container}
|
||||||
|
initial="hidden"
|
||||||
|
animate="show"
|
||||||
|
>
|
||||||
|
{todos.map((todo) => (
|
||||||
|
<motion.div key={todo.id} variants={item}>
|
||||||
|
<TodoRow
|
||||||
|
todo={todo}
|
||||||
|
tags={tags}
|
||||||
|
onUpdate={(updatedTodo) => onUpdate(todo.id, updatedTodo)}
|
||||||
|
onDelete={() => onDelete(todo.id)}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
232
frontend/app/todos/notifications/page.tsx
Normal file
232
frontend/app/todos/notifications/page.tsx
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Icons } from "@/components/icons";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
// Mock notification data - in a real app, this would come from an API
|
||||||
|
const mockNotifications = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: "New task assigned",
|
||||||
|
description: "You have been assigned a new task 'Update documentation'",
|
||||||
|
time: "5 minutes ago",
|
||||||
|
read: false,
|
||||||
|
type: "task",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: "Task completed",
|
||||||
|
description: "Your task 'Update documentation' has been completed",
|
||||||
|
time: "1 hour ago",
|
||||||
|
read: false,
|
||||||
|
type: "task",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: "Meeting reminder",
|
||||||
|
description: "Team meeting starts in 30 minutes",
|
||||||
|
time: "2 hours ago",
|
||||||
|
read: false,
|
||||||
|
type: "reminder",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
title: "New comment",
|
||||||
|
description: "John commented on your task 'Design homepage'",
|
||||||
|
time: "1 day ago",
|
||||||
|
read: true,
|
||||||
|
type: "comment",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
title: "Task deadline approaching",
|
||||||
|
description: "Task 'Finalize project proposal' is due tomorrow",
|
||||||
|
time: "1 day ago",
|
||||||
|
read: true,
|
||||||
|
type: "reminder",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
title: "System update",
|
||||||
|
description: "The system will be updated tonight at 2 AM",
|
||||||
|
time: "2 days ago",
|
||||||
|
read: true,
|
||||||
|
type: "system",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function NotificationsPage() {
|
||||||
|
const [notifications, setNotifications] = useState(mockNotifications);
|
||||||
|
const [activeTab, setActiveTab] = useState("all");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const unreadCount = notifications.filter((n) => !n.read).length;
|
||||||
|
|
||||||
|
const filteredNotifications = notifications.filter((notification) => {
|
||||||
|
if (activeTab === "all") return true;
|
||||||
|
if (activeTab === "unread") return !notification.read;
|
||||||
|
if (activeTab === "read") return notification.read;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const markAsRead = (id: number) => {
|
||||||
|
setNotifications(
|
||||||
|
notifications.map((notification) =>
|
||||||
|
notification.id === id ? { ...notification, read: true } : notification
|
||||||
|
)
|
||||||
|
);
|
||||||
|
toast.success("Notification marked as read");
|
||||||
|
};
|
||||||
|
|
||||||
|
const markAllAsRead = () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
// Simulate API call
|
||||||
|
setTimeout(() => {
|
||||||
|
setNotifications(
|
||||||
|
notifications.map((notification) => ({ ...notification, read: true }))
|
||||||
|
);
|
||||||
|
setIsLoading(false);
|
||||||
|
toast.success("All notifications marked as read");
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNotificationIcon = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case "task":
|
||||||
|
return <Icons.list className="h-5 w-5 text-blue-500" />;
|
||||||
|
case "reminder":
|
||||||
|
return <Icons.calendar className="h-5 w-5 text-yellow-500" />;
|
||||||
|
case "comment":
|
||||||
|
return <Icons.messageCircle className="h-5 w-5 text-green-500" />;
|
||||||
|
case "system":
|
||||||
|
return <Icons.settings className="h-5 w-5 text-purple-500" />;
|
||||||
|
default:
|
||||||
|
return <Icons.bell className="h-5 w-5 text-gray-500" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container max-w-4xl mx-auto px-4 py-6 space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Notifications</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
You have {unreadCount} unread notification{unreadCount !== 1 && "s"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<Button
|
||||||
|
onClick={markAllAsRead}
|
||||||
|
variant="outline"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Icons.spinner className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Icons.check className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Mark all as read
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
defaultValue="all"
|
||||||
|
value={activeTab}
|
||||||
|
onValueChange={setActiveTab}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<TabsList className="grid w-full max-w-md grid-cols-3">
|
||||||
|
<TabsTrigger value="all">
|
||||||
|
All
|
||||||
|
<Badge className="ml-2 bg-gray-500">{notifications.length}</Badge>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="unread">
|
||||||
|
Unread
|
||||||
|
<Badge className="ml-2 bg-blue-500">{unreadCount}</Badge>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="read">
|
||||||
|
Read
|
||||||
|
<Badge className="ml-2 bg-green-500">
|
||||||
|
{notifications.length - unreadCount}
|
||||||
|
</Badge>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value={activeTab} className="mt-6">
|
||||||
|
{filteredNotifications.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-10">
|
||||||
|
<Icons.inbox className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-muted-foreground mb-2">
|
||||||
|
No notifications
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
{activeTab === "unread"
|
||||||
|
? "You have no unread notifications"
|
||||||
|
: activeTab === "read"
|
||||||
|
? "You have no read notifications"
|
||||||
|
: "You have no notifications"}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{filteredNotifications.map((notification) => (
|
||||||
|
<Card
|
||||||
|
key={notification.id}
|
||||||
|
className={`transition-colors ${
|
||||||
|
!notification.read ? "border-l-4 border-l-blue-500" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CardContent className="p-4 flex gap-4">
|
||||||
|
<div className="flex-shrink-0 mt-1">
|
||||||
|
{getNotificationIcon(notification.type)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-grow">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
{notification.title}
|
||||||
|
</CardTitle>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{notification.time}
|
||||||
|
</span>
|
||||||
|
{!notification.read && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => markAsRead(notification.id)}
|
||||||
|
>
|
||||||
|
<Icons.check className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Mark as read</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CardDescription className="mt-1">
|
||||||
|
{notification.description}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
frontend/app/todos/page.tsx
Normal file
14
frontend/app/todos/page.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useEffect } from "react"
|
||||||
|
|
||||||
|
export default function TodosPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
router.push("/todos/list")
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
298
frontend/app/todos/tags/page.tsx
Normal file
298
frontend/app/todos/tags/page.tsx
Normal file
@ -0,0 +1,298 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
useTags,
|
||||||
|
useCreateTag,
|
||||||
|
useUpdateTag,
|
||||||
|
useDeleteTag,
|
||||||
|
} from "@/hooks/use-tags";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Icons } from "@/components/icons";
|
||||||
|
import type { Tag } from "@/services/api-types";
|
||||||
|
|
||||||
|
export default function TagsPage() {
|
||||||
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||||
|
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||||
|
const [currentTag, setCurrentTag] = useState<Tag | null>(null);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: "",
|
||||||
|
color: "#FF5A5F",
|
||||||
|
icon: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: tags = [], isLoading } = useTags();
|
||||||
|
const createTagMutation = useCreateTag();
|
||||||
|
const updateTagMutation = useUpdateTag();
|
||||||
|
const deleteTagMutation = useDeleteTag();
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateTag = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await createTagMutation.mutateAsync(formData);
|
||||||
|
setIsCreateDialogOpen(false);
|
||||||
|
setFormData({ name: "", color: "#FF5A5F", icon: "" });
|
||||||
|
toast.success("Tag created successfully");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to create tag");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditTag = (tag: Tag) => {
|
||||||
|
setCurrentTag(tag);
|
||||||
|
setFormData({
|
||||||
|
name: tag.name,
|
||||||
|
color: tag.color || "#FF5A5F",
|
||||||
|
icon: tag.icon || "",
|
||||||
|
});
|
||||||
|
setIsEditDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateTag = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!currentTag) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateTagMutation.mutateAsync({
|
||||||
|
id: currentTag.id,
|
||||||
|
tag: formData,
|
||||||
|
});
|
||||||
|
setIsEditDialogOpen(false);
|
||||||
|
toast.success("Tag updated successfully");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to update tag");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteTag = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await deleteTagMutation.mutateAsync(id);
|
||||||
|
toast.success("Tag deleted successfully");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to delete tag");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container max-w-3xl mx-auto px-4 py-6 space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Labels</h1>
|
||||||
|
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="rounded-full">
|
||||||
|
<Icons.plus className="h-5 w-5" />
|
||||||
|
<span className="sr-only">Add Label</span>
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create New Label</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Add a new label to organize your todos
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleCreateTag} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="color">Color</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="color"
|
||||||
|
name="color"
|
||||||
|
type="color"
|
||||||
|
value={formData.color}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-12 h-10 p-1"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
name="color"
|
||||||
|
value={formData.color}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="icon">Icon (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="icon"
|
||||||
|
name="icon"
|
||||||
|
value={formData.icon}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="e.g., home, work, star"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full bg-[#FF5A5F] hover:bg-[#FF5A5F]/90"
|
||||||
|
>
|
||||||
|
Create Label
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border bg-card">
|
||||||
|
<div className="flex items-center justify-between p-3 border-b">
|
||||||
|
<h2 className="text-sm font-medium">Labels</h2>
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
{Array(6)
|
||||||
|
.fill(0)
|
||||||
|
.map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-8 animate-pulse bg-muted rounded-md"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : tags.length === 0 ? (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<Icons.tag className="h-12 w-12 mx-auto text-muted-foreground/50 mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-muted-foreground mb-2">
|
||||||
|
No labels found
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Create a label to organize your todos
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => setIsCreateDialogOpen(true)}
|
||||||
|
className="bg-[#FF5A5F] hover:bg-[#FF5A5F]/90"
|
||||||
|
>
|
||||||
|
<Icons.plus className="mr-2 h-4 w-4" />
|
||||||
|
Create your first label
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<div
|
||||||
|
key={tag.id}
|
||||||
|
className="flex items-center justify-between py-3 px-4 hover:bg-muted/50 transition-colors cursor-pointer"
|
||||||
|
onClick={() => handleEditTag(tag)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="w-5 h-5 flex items-center justify-center"
|
||||||
|
style={{ color: tag.color || "#FF5A5F" }}
|
||||||
|
>
|
||||||
|
<Icons.tag className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium">{tag.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="text-xs text-muted-foreground mr-2">
|
||||||
|
{Math.floor(Math.random() * 10)}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 opacity-0 group-hover:opacity-100"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDeleteTag(tag.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icons.trash className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Delete</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Label</DialogTitle>
|
||||||
|
<DialogDescription>Update your label details</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleUpdateTag} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-name"
|
||||||
|
name="name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-color">Color</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="edit-color"
|
||||||
|
name="color"
|
||||||
|
type="color"
|
||||||
|
value={formData.color}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-12 h-10 p-1"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
name="color"
|
||||||
|
value={formData.color}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-icon">Icon (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-icon"
|
||||||
|
name="icon"
|
||||||
|
value={formData.icon}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="e.g., home, work, star"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1 bg-[#FF5A5F] hover:bg-[#FF5A5F]/90"
|
||||||
|
>
|
||||||
|
Update Label
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleDeleteTag(currentTag?.id || "")}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Delete Label
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
frontend/components.json
Normal file
21
frontend/components.json
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
|
}
|
||||||
103
frontend/components/icons.tsx
Normal file
103
frontend/components/icons.tsx
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import {
|
||||||
|
IconAlertCircle,
|
||||||
|
IconArrowRight,
|
||||||
|
IconCheck,
|
||||||
|
IconChevronLeft,
|
||||||
|
IconChevronRight,
|
||||||
|
IconCommand,
|
||||||
|
IconCreditCard,
|
||||||
|
IconFile,
|
||||||
|
IconFileText,
|
||||||
|
IconHelpCircle,
|
||||||
|
IconPhoto,
|
||||||
|
IconLoader2,
|
||||||
|
IconMoon,
|
||||||
|
IconDotsVertical,
|
||||||
|
IconPizza,
|
||||||
|
IconPlus,
|
||||||
|
IconSettings,
|
||||||
|
IconSun,
|
||||||
|
IconTrash,
|
||||||
|
IconUser,
|
||||||
|
IconX,
|
||||||
|
IconEdit,
|
||||||
|
IconCalendar,
|
||||||
|
IconTag,
|
||||||
|
IconLogout,
|
||||||
|
IconHome,
|
||||||
|
IconList,
|
||||||
|
IconLayoutKanban,
|
||||||
|
IconInbox,
|
||||||
|
IconSearch,
|
||||||
|
IconEye,
|
||||||
|
IconEyeOff,
|
||||||
|
IconBrandGoogle,
|
||||||
|
IconBrandApple,
|
||||||
|
IconPaperclip,
|
||||||
|
IconImageInPicture,
|
||||||
|
IconLayoutGrid,
|
||||||
|
IconBell,
|
||||||
|
IconRefresh,
|
||||||
|
IconCheckbox,
|
||||||
|
IconStar,
|
||||||
|
IconMessageCircle,
|
||||||
|
IconArrowLeft,
|
||||||
|
IconClock,
|
||||||
|
IconLoader,
|
||||||
|
IconCircle,
|
||||||
|
type Icon as TablerIconType,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
|
||||||
|
export type Icon = TablerIconType;
|
||||||
|
|
||||||
|
export const Icons = {
|
||||||
|
logo: IconCommand,
|
||||||
|
close: IconX,
|
||||||
|
spinner: IconLoader2,
|
||||||
|
chevronLeft: IconChevronLeft,
|
||||||
|
chevronRight: IconChevronRight,
|
||||||
|
trash: IconTrash,
|
||||||
|
edit: IconEdit,
|
||||||
|
post: IconFileText,
|
||||||
|
page: IconFile,
|
||||||
|
media: IconPhoto,
|
||||||
|
settings: IconSettings,
|
||||||
|
billing: IconCreditCard,
|
||||||
|
ellipsis: IconDotsVertical,
|
||||||
|
add: IconPlus,
|
||||||
|
warning: IconAlertCircle,
|
||||||
|
user: IconUser,
|
||||||
|
arrowRight: IconArrowRight,
|
||||||
|
help: IconHelpCircle,
|
||||||
|
pizza: IconPizza,
|
||||||
|
sun: IconSun,
|
||||||
|
moon: IconMoon,
|
||||||
|
calendar: IconCalendar,
|
||||||
|
tag: IconTag,
|
||||||
|
logout: IconLogout,
|
||||||
|
home: IconHome,
|
||||||
|
list: IconList,
|
||||||
|
kanban: IconLayoutKanban,
|
||||||
|
inbox: IconInbox,
|
||||||
|
search: IconSearch,
|
||||||
|
plus: IconPlus,
|
||||||
|
check: IconCheck,
|
||||||
|
eye: IconEye,
|
||||||
|
eyeOff: IconEyeOff,
|
||||||
|
google: IconBrandGoogle,
|
||||||
|
apple: IconBrandApple,
|
||||||
|
paperclip: IconPaperclip,
|
||||||
|
image: IconImageInPicture,
|
||||||
|
layoutGrid: IconLayoutGrid,
|
||||||
|
x: IconX,
|
||||||
|
bell: IconBell,
|
||||||
|
refresh: IconRefresh,
|
||||||
|
checkSquare: IconCheckbox,
|
||||||
|
star: IconStar,
|
||||||
|
messageCircle: IconMessageCircle,
|
||||||
|
arrowLeft: IconArrowLeft,
|
||||||
|
clock: IconClock,
|
||||||
|
loader: IconLoader,
|
||||||
|
circle: IconCircle,
|
||||||
|
moreVertical: IconDotsVertical,
|
||||||
|
};
|
||||||
99
frontend/components/kanban-column.tsx
Normal file
99
frontend/components/kanban-column.tsx
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useDroppable } from "@dnd-kit/core";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { TodoCard } from "@/components/todo-card";
|
||||||
|
import type { Todo, Tag } from "@/services/api-types";
|
||||||
|
|
||||||
|
interface KanbanColumnProps {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
todos: Todo[];
|
||||||
|
tags: Tag[];
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KanbanColumn({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
todos,
|
||||||
|
tags,
|
||||||
|
isLoading,
|
||||||
|
}: KanbanColumnProps) {
|
||||||
|
const { setNodeRef, isOver } = useDroppable({
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getColumnColor = () => {
|
||||||
|
switch (id) {
|
||||||
|
case "pending":
|
||||||
|
return "border-yellow-200 bg-yellow-50 dark:bg-yellow-950/20 dark:border-yellow-900/50";
|
||||||
|
case "in-progress":
|
||||||
|
return "border-blue-200 bg-blue-50 dark:bg-blue-950/20 dark:border-blue-900/50";
|
||||||
|
case "completed":
|
||||||
|
return "border-green-200 bg-green-50 dark:bg-green-950/20 dark:border-green-900/50";
|
||||||
|
default:
|
||||||
|
return "border-gray-200 bg-gray-50 dark:bg-gray-800/20 dark:border-gray-700";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
className={`flex flex-col h-[calc(100vh-200px)] rounded-lg border p-2 transition-colors ${getColumnColor()} ${
|
||||||
|
isOver ? "ring-2 ring-primary ring-opacity-50" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2 px-1">
|
||||||
|
<h3 className="font-medium text-sm uppercase tracking-wider">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<div className="bg-background text-muted-foreground rounded-full px-2 py-0.5 text-xs font-medium">
|
||||||
|
{todos.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-2 pr-1">
|
||||||
|
{isLoading ? (
|
||||||
|
Array(3)
|
||||||
|
.fill(0)
|
||||||
|
.map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-[100px] rounded-lg bg-muted animate-pulse"
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : todos.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-24 text-center py-4 px-2 border border-dashed rounded-md bg-background/50">
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
No todos in this column
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||||
|
Drag and drop tasks here
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<AnimatePresence>
|
||||||
|
{todos.map((todo) => (
|
||||||
|
<motion.div
|
||||||
|
key={todo.id}
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.9 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<TodoCard
|
||||||
|
todo={todo}
|
||||||
|
tags={tags}
|
||||||
|
onUpdate={() => {}}
|
||||||
|
onDelete={() => {}}
|
||||||
|
isDraggable={true}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
frontend/components/multi-select.tsx
Normal file
121
frontend/components/multi-select.tsx
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Command, CommandGroup, CommandItem } from "@/components/ui/command"
|
||||||
|
import { Command as CommandPrimitive } from "cmdk"
|
||||||
|
|
||||||
|
interface MultiSelectProps {
|
||||||
|
options: { label: string; value: string }[]
|
||||||
|
selected: string[]
|
||||||
|
onChange: (selected: string[]) => void
|
||||||
|
placeholder?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MultiSelect({ options, selected, onChange, placeholder = "Select options" }: MultiSelectProps) {
|
||||||
|
const inputRef = React.useRef<HTMLInputElement>(null)
|
||||||
|
const [open, setOpen] = React.useState(false)
|
||||||
|
const [inputValue, setInputValue] = React.useState("")
|
||||||
|
|
||||||
|
const handleUnselect = (value: string) => {
|
||||||
|
onChange(selected.filter((s) => s !== value))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelect = (value: string) => {
|
||||||
|
if (selected.includes(value)) {
|
||||||
|
onChange(selected.filter((s) => s !== value))
|
||||||
|
} else {
|
||||||
|
onChange([...selected, value])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
const input = inputRef.current
|
||||||
|
if (input) {
|
||||||
|
if (e.key === "Delete" || e.key === "Backspace") {
|
||||||
|
if (input.value === "" && selected.length > 0) {
|
||||||
|
onChange(selected.slice(0, -1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
input.blur()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedOptions = options.filter((option) => selected.includes(option.value))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Command onKeyDown={handleKeyDown} className="overflow-visible bg-transparent">
|
||||||
|
<div className="group border rounded-md px-3 py-2 text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{selectedOptions.map((option) => (
|
||||||
|
<Badge key={option.value} variant="secondary" className="rounded-sm">
|
||||||
|
{option.label}
|
||||||
|
<button
|
||||||
|
className="ml-1 rounded-full outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
handleUnselect(option.value)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
}}
|
||||||
|
onClick={() => handleUnselect(option.value)}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3 text-muted-foreground hover:text-foreground" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
ref={inputRef}
|
||||||
|
value={inputValue}
|
||||||
|
onValueChange={setInputValue}
|
||||||
|
onBlur={() => setOpen(false)}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
placeholder={selected.length === 0 ? placeholder : undefined}
|
||||||
|
className="ml-2 flex-1 bg-transparent outline-none placeholder:text-muted-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative mt-2">
|
||||||
|
{open && options.length > 0 && (
|
||||||
|
<div className="absolute top-0 z-10 w-full rounded-md border bg-popover text-popover-foreground shadow-md outline-none animate-in">
|
||||||
|
<CommandGroup className="h-full overflow-auto max-h-[200px]">
|
||||||
|
{options.map((option) => {
|
||||||
|
const isSelected = selected.includes(option.value)
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={option.value}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
}}
|
||||||
|
onSelect={() => handleSelect(option.value)}
|
||||||
|
className={`flex items-center gap-2 ${isSelected ? "bg-muted" : ""}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`border mr-2 flex h-4 w-4 items-center justify-center rounded-sm ${
|
||||||
|
isSelected
|
||||||
|
? "bg-primary border-primary text-primary-foreground"
|
||||||
|
: "opacity-50 [&_svg]:invisible"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
</CommandItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Command>
|
||||||
|
)
|
||||||
|
}
|
||||||
226
frontend/components/navbar.tsx
Normal file
226
frontend/components/navbar.tsx
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Icons } from "@/components/icons";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
export function Navbar() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const [notificationCount, setNotificationCount] = useState(3);
|
||||||
|
|
||||||
|
const notifications = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: "New task assigned",
|
||||||
|
description: "You have been assigned a new task",
|
||||||
|
time: "5 minutes ago",
|
||||||
|
read: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: "Task completed",
|
||||||
|
description: "Your task 'Update documentation' has been completed",
|
||||||
|
time: "1 hour ago",
|
||||||
|
read: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: "Meeting reminder",
|
||||||
|
description: "Team meeting starts in 30 minutes",
|
||||||
|
time: "2 hours ago",
|
||||||
|
read: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const markAsRead = (id: number) => {
|
||||||
|
setNotificationCount(Math.max(0, notificationCount - 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const markAllAsRead = () => {
|
||||||
|
setNotificationCount(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
|
<div className="container flex h-14 items-center max-w-5xl mx-auto">
|
||||||
|
<div className="mr-4 hidden md:flex">
|
||||||
|
<Link href="/" className="mr-6 flex items-center space-x-2">
|
||||||
|
<Icons.logo className="h-6 w-6 text-[#FF5A5F]" />
|
||||||
|
<span className="hidden font-bold sm:inline-block">Todo App</span>
|
||||||
|
</Link>
|
||||||
|
<nav className="flex items-center space-x-6 text-sm font-medium">
|
||||||
|
<Link
|
||||||
|
href="/todos/list"
|
||||||
|
className={cn(
|
||||||
|
"transition-colors hover:text-foreground/80",
|
||||||
|
pathname?.startsWith("/todos/list")
|
||||||
|
? "text-foreground"
|
||||||
|
: "text-foreground/60"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Icons.list className="h-4 w-4" />
|
||||||
|
<span>List</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/todos/kanban"
|
||||||
|
className={cn(
|
||||||
|
"transition-colors hover:text-foreground/80",
|
||||||
|
pathname?.startsWith("/todos/kanban")
|
||||||
|
? "text-foreground"
|
||||||
|
: "text-foreground/60"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Icons.kanban className="h-4 w-4" />
|
||||||
|
<span>Kanban</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/todos/tags"
|
||||||
|
className={cn(
|
||||||
|
"transition-colors hover:text-foreground/80",
|
||||||
|
pathname?.startsWith("/todos/tags")
|
||||||
|
? "text-foreground"
|
||||||
|
: "text-foreground/60"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Icons.tag className="h-4 w-4" />
|
||||||
|
<span>Tags</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 items-center justify-between space-x-2 md:justify-end">
|
||||||
|
{/* Notifications Dropdown */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="relative h-8 w-8 rounded-full"
|
||||||
|
>
|
||||||
|
<Icons.bell className="h-4 w-4" />
|
||||||
|
{notificationCount > 0 && (
|
||||||
|
<Badge className="absolute -top-1 -right-1 h-4 w-4 p-0 flex items-center justify-center bg-red-500 text-white text-[10px]">
|
||||||
|
{notificationCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-80">
|
||||||
|
<DropdownMenuLabel className="flex items-center justify-between">
|
||||||
|
<span>Notifications</span>
|
||||||
|
{notificationCount > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={markAllAsRead}
|
||||||
|
className="h-auto p-0 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
Mark all as read
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{notifications.length === 0 ? (
|
||||||
|
<div className="py-4 text-center text-sm text-muted-foreground">
|
||||||
|
No new notifications
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
notifications.map((notification) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={notification.id}
|
||||||
|
className="flex flex-col items-start p-4 cursor-pointer"
|
||||||
|
onClick={() => markAsRead(notification.id)}
|
||||||
|
>
|
||||||
|
<div className="flex w-full justify-between">
|
||||||
|
<span className="font-medium">{notification.title}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{notification.time}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
{notification.description}
|
||||||
|
</p>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem asChild className="justify-center">
|
||||||
|
<Link
|
||||||
|
href="/todos/notifications"
|
||||||
|
className="w-full text-center text-sm"
|
||||||
|
>
|
||||||
|
View all notifications
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="relative h-8 w-8 rounded-full"
|
||||||
|
>
|
||||||
|
<Icons.user className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>{user?.username || "User"}</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/profile">
|
||||||
|
<Icons.user className="mr-2 h-4 w-4" />
|
||||||
|
<span>Profile</span>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/todos/list">
|
||||||
|
<Icons.list className="mr-2 h-4 w-4" />
|
||||||
|
<span>List View</span>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/todos/kanban">
|
||||||
|
<Icons.kanban className="mr-2 h-4 w-4" />
|
||||||
|
<span>Kanban View</span>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/tags">
|
||||||
|
<Icons.tag className="mr-2 h-4 w-4" />
|
||||||
|
<span>Manage Tags</span>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={logout}>
|
||||||
|
<Icons.logout className="mr-2 h-4 w-4" />
|
||||||
|
<span>Log out</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
frontend/components/query-client-provider.tsx
Normal file
29
frontend/components/query-client-provider.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type React from "react"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { QueryClient, QueryClientProvider as TanstackQueryClientProvider } from "@tanstack/react-query"
|
||||||
|
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"
|
||||||
|
|
||||||
|
export function QueryClientProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [queryClient] = useState(
|
||||||
|
() =>
|
||||||
|
new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 60 * 1000, // 1 minute
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TanstackQueryClientProvider client={queryClient}>
|
||||||
|
{children}
|
||||||
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
|
</TanstackQueryClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
11
frontend/components/theme-provider.tsx
Normal file
11
frontend/components/theme-provider.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import {
|
||||||
|
ThemeProvider as NextThemesProvider,
|
||||||
|
type ThemeProviderProps,
|
||||||
|
} from 'next-themes'
|
||||||
|
|
||||||
|
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||||
|
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||||
|
}
|
||||||
439
frontend/components/todo-card.tsx
Normal file
439
frontend/components/todo-card.tsx
Normal file
@ -0,0 +1,439 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useSortable } from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { TodoForm } from "@/components/todo-form";
|
||||||
|
import { Icons } from "@/components/icons";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { Todo, Tag } from "@/services/api-types";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
interface TodoCardProps {
|
||||||
|
todo: Todo;
|
||||||
|
tags: Tag[];
|
||||||
|
onUpdate: (todo: Partial<Todo>) => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
isDraggable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TodoCard({
|
||||||
|
todo,
|
||||||
|
tags,
|
||||||
|
onUpdate,
|
||||||
|
onDelete,
|
||||||
|
isDraggable = false,
|
||||||
|
}: TodoCardProps) {
|
||||||
|
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||||
|
const [isViewDialogOpen, setIsViewDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
// Drag and drop logic
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({
|
||||||
|
id: todo.id,
|
||||||
|
disabled: !isDraggable,
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Style helpers
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case "pending":
|
||||||
|
return "border-l-4 border-l-amber-500";
|
||||||
|
case "in-progress":
|
||||||
|
return "border-l-4 border-l-sky-500";
|
||||||
|
case "completed":
|
||||||
|
return "border-l-4 border-l-emerald-500";
|
||||||
|
default:
|
||||||
|
return "border-l-4 border-l-slate-400";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case "pending":
|
||||||
|
return <Icons.clock className="h-5 w-5 text-amber-500" />;
|
||||||
|
case "in-progress":
|
||||||
|
return <Icons.loader className="h-5 w-5 text-sky-500" />;
|
||||||
|
case "completed":
|
||||||
|
return <Icons.checkSquare className="h-5 w-5 text-emerald-500" />;
|
||||||
|
default:
|
||||||
|
return <Icons.circle className="h-5 w-5 text-slate-400" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const todoTags = tags.filter((tag) => todo.tagIds.includes(tag.id));
|
||||||
|
const hasImage = !!todo.image;
|
||||||
|
const hasAttachments = todo.attachments && todo.attachments.length > 0;
|
||||||
|
const hasSubtasks = todo.subtasks && todo.subtasks.length > 0;
|
||||||
|
const completedSubtasks = todo.subtasks
|
||||||
|
? todo.subtasks.filter((subtask) => subtask.completed).length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const handleStatusToggle = () => {
|
||||||
|
const newStatus = todo.status === "completed" ? "pending" : "completed";
|
||||||
|
onUpdate({ status: newStatus });
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString?: string | null) => {
|
||||||
|
if (!dateString) return "";
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={cn(
|
||||||
|
"transition-colors",
|
||||||
|
getStatusColor(todo.status),
|
||||||
|
"shadow-sm min-w-[220px] max-w-[420px]",
|
||||||
|
isDraggable ? "cursor-grab active:cursor-grabbing" : "",
|
||||||
|
isDragging ? "shadow-lg" : ""
|
||||||
|
)}
|
||||||
|
{...(isDraggable ? { ...attributes, ...listeners } : {})}
|
||||||
|
>
|
||||||
|
<CardContent className="p-4 flex gap-4">
|
||||||
|
{/* Left icon, like notification card */}
|
||||||
|
<div className="flex-shrink-0 mt-1">{getStatusIcon(todo.status)}</div>
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="flex-grow">
|
||||||
|
<CardHeader className="p-0">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle
|
||||||
|
className={cn(
|
||||||
|
"text-base",
|
||||||
|
todo.status === "completed" &&
|
||||||
|
"line-through text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{todo.title}
|
||||||
|
</CardTitle>
|
||||||
|
{!isDraggable && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
aria-label="More actions"
|
||||||
|
>
|
||||||
|
<Icons.moreVertical className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setIsViewDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<Icons.eye className="h-4 w-4 mr-2" />
|
||||||
|
View details
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setIsEditDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<Icons.edit className="h-4 w-4 mr-2" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={onDelete}
|
||||||
|
className="text-red-600"
|
||||||
|
>
|
||||||
|
<Icons.trash className="h-4 w-4 mr-2" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Actions and deadline on a new line */}
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
{todo.deadline && (
|
||||||
|
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
<Icons.calendar className="h-3 w-3" />
|
||||||
|
{formatDate(todo.deadline)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardDescription
|
||||||
|
className={cn(
|
||||||
|
"mt-1",
|
||||||
|
todo.status === "completed" &&
|
||||||
|
"line-through text-muted-foreground/70"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{todo.description}
|
||||||
|
</CardDescription>
|
||||||
|
{/* Tags and indicators */}
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5 mt-2">
|
||||||
|
{todoTags.map((tag) => (
|
||||||
|
<Badge
|
||||||
|
key={tag.id}
|
||||||
|
variant="outline"
|
||||||
|
className="px-1.5 py-0 h-4 text-[10px] font-normal border-0"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${tag.color}20` || "#FF5A5F20",
|
||||||
|
color: tag.color || "#FF5A5F",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{hasSubtasks && (
|
||||||
|
<span className="flex items-center gap-0.5 text-[10px] text-muted-foreground ml-2">
|
||||||
|
<Icons.checkSquare className="h-3 w-3" />
|
||||||
|
{completedSubtasks}/{todo.subtasks.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{hasAttachments && (
|
||||||
|
<span className="flex items-center gap-0.5 text-[10px] text-muted-foreground ml-2">
|
||||||
|
<Icons.paperclip className="h-3 w-3" />
|
||||||
|
{todo.attachments.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{hasImage && (
|
||||||
|
<span className="flex items-center gap-0.5 text-[10px] text-muted-foreground ml-2">
|
||||||
|
<Icons.image className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Bottom row: created date and status toggle */}
|
||||||
|
<div className="flex justify-between items-center mt-3">
|
||||||
|
<span className="text-[10px] text-muted-foreground/70">
|
||||||
|
{todo.createdAt ? formatDate(todo.createdAt) : ""}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant={todo.status === "completed" ? "ghost" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className="h-6 text-[10px] px-2 py-0 rounded-full"
|
||||||
|
onClick={handleStatusToggle}
|
||||||
|
>
|
||||||
|
{todo.status === "completed" ? (
|
||||||
|
<Icons.x className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<Icons.check className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Edit Dialog */}
|
||||||
|
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader className="space-y-1">
|
||||||
|
<DialogTitle className="text-xl">Edit Todo</DialogTitle>
|
||||||
|
<DialogDescription className="text-sm">
|
||||||
|
Make changes to your task and save when you're done
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<TodoForm
|
||||||
|
todo={todo}
|
||||||
|
tags={tags}
|
||||||
|
onSubmit={(updatedTodo) => {
|
||||||
|
onUpdate(updatedTodo);
|
||||||
|
setIsEditDialogOpen(false);
|
||||||
|
toast.success("Todo updated successfully");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* View Dialog */}
|
||||||
|
<Dialog open={isViewDialogOpen} onOpenChange={setIsViewDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[450px] p-0 overflow-hidden">
|
||||||
|
<DialogHeader className="px-6 pt-6 pb-2">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Badge
|
||||||
|
className={cn(
|
||||||
|
"px-2.5 py-1 capitalize font-medium text-sm",
|
||||||
|
todo.status === "pending"
|
||||||
|
? "bg-amber-50 text-amber-700"
|
||||||
|
: todo.status === "in-progress"
|
||||||
|
? "bg-sky-50 text-sky-700"
|
||||||
|
: todo.status === "completed"
|
||||||
|
? "bg-emerald-50 text-emerald-700"
|
||||||
|
: "bg-slate-50 text-slate-700"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-2.5 h-2.5 rounded-full mr-1.5",
|
||||||
|
todo.status === "pending"
|
||||||
|
? "bg-amber-500"
|
||||||
|
: todo.status === "in-progress"
|
||||||
|
? "bg-sky-500"
|
||||||
|
: todo.status === "completed"
|
||||||
|
? "bg-emerald-500"
|
||||||
|
: "bg-slate-400"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{todo.status.replace("-", " ")}
|
||||||
|
</Badge>
|
||||||
|
{todo.deadline && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs font-normal px-2 py-0 ml-auto"
|
||||||
|
>
|
||||||
|
<Icons.calendar className="h-3 w-3 mr-1" />
|
||||||
|
{formatDate(todo.deadline)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogTitle className="text-xl font-semibold">
|
||||||
|
{todo.title}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs mt-1">
|
||||||
|
Created {formatDate(todo.createdAt)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{hasImage && (
|
||||||
|
<div className="w-full h-48 overflow-hidden relative">
|
||||||
|
<Image
|
||||||
|
src={todo.image || "/placeholder.svg?height=192&width=450"}
|
||||||
|
alt={todo.title}
|
||||||
|
fill
|
||||||
|
style={{ objectFit: "cover" }}
|
||||||
|
sizes="450px"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="px-6 py-4 space-y-4">
|
||||||
|
{todo.description && (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{todo.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{todoTags.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-medium mb-1.5">Tags</h4>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{todoTags.map((tag) => (
|
||||||
|
<Badge
|
||||||
|
key={tag.id}
|
||||||
|
variant="outline"
|
||||||
|
className="px-2 py-0.5 text-xs font-normal border-0"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${tag.color}20` || "#FF5A5F20",
|
||||||
|
color: tag.color || "#FF5A5F",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasAttachments && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-medium mb-1.5">Attachments</h4>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{todo.attachments.map((a, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-center gap-1.5 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Icons.paperclip className="h-3.5 w-3.5" />
|
||||||
|
<span>{a}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasSubtasks && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-medium mb-1.5">
|
||||||
|
Subtasks ({completedSubtasks}/{todo.subtasks.length})
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-1.5 text-sm">
|
||||||
|
{todo.subtasks.map((subtask) => (
|
||||||
|
<li key={subtask.id} className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-4 h-4 rounded-full flex items-center justify-center border",
|
||||||
|
subtask.completed
|
||||||
|
? "border-emerald-500 bg-emerald-50"
|
||||||
|
: "border-gray-200"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{subtask.completed && (
|
||||||
|
<Icons.check className="h-2.5 w-2.5 text-emerald-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xs",
|
||||||
|
subtask.completed
|
||||||
|
? "line-through text-muted-foreground"
|
||||||
|
: ""
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{subtask.description}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="border-t px-6 py-4 flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsViewDialogOpen(false)}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setIsViewDialogOpen(false);
|
||||||
|
setIsEditDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
196
frontend/components/todo-form.tsx
Normal file
196
frontend/components/todo-form.tsx
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type React from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { MultiSelect } from "@/components/multi-select";
|
||||||
|
import { Icons } from "@/components/icons";
|
||||||
|
import type { Todo, Tag } from "@/services/api-types";
|
||||||
|
|
||||||
|
interface TodoFormProps {
|
||||||
|
todo?: Todo;
|
||||||
|
tags: Tag[];
|
||||||
|
onSubmit: (todo: Partial<Todo>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TodoForm({ todo, tags, onSubmit }: TodoFormProps) {
|
||||||
|
const [formData, setFormData] = useState<Partial<Todo>>({
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
status: "pending",
|
||||||
|
deadline: undefined,
|
||||||
|
tagIds: [],
|
||||||
|
image: null,
|
||||||
|
});
|
||||||
|
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (todo) {
|
||||||
|
setFormData({
|
||||||
|
title: todo.title,
|
||||||
|
description: todo.description || "",
|
||||||
|
status: todo.status,
|
||||||
|
deadline: todo.deadline,
|
||||||
|
tagIds: todo.tagIds || [],
|
||||||
|
image: todo.image || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (todo.image) {
|
||||||
|
setImagePreview(todo.image);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [todo]);
|
||||||
|
|
||||||
|
const handleChange = (
|
||||||
|
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||||
|
) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectChange = (name: string, value: string) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTagsChange = (selected: string[]) => {
|
||||||
|
setFormData((prev) => ({ ...prev, tagIds: selected }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// In a real app, we would upload the file to a server and get a URL back
|
||||||
|
// For now, we'll create a local object URL
|
||||||
|
const imageUrl = URL.createObjectURL(file);
|
||||||
|
setImagePreview(imageUrl);
|
||||||
|
setFormData((prev) => ({ ...prev, image: imageUrl }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveImage = () => {
|
||||||
|
setImagePreview(null);
|
||||||
|
setFormData((prev) => ({ ...prev, image: null }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit(formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="title">Title</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
name="title"
|
||||||
|
value={formData.title}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">Description (optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
value={formData.description ?? ""}
|
||||||
|
onChange={handleChange}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="status">Status</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.status}
|
||||||
|
onValueChange={(value) => handleSelectChange("status", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="pending">Pending</SelectItem>
|
||||||
|
<SelectItem value="in-progress">In Progress</SelectItem>
|
||||||
|
<SelectItem value="completed">Completed</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="deadline">Deadline (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="deadline"
|
||||||
|
name="deadline"
|
||||||
|
type="datetime-local"
|
||||||
|
value={
|
||||||
|
formData.deadline
|
||||||
|
? new Date(formData.deadline).toISOString().slice(0, 16)
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Tags</Label>
|
||||||
|
<MultiSelect
|
||||||
|
options={tags.map((tag) => ({ label: tag.name, value: tag.id }))}
|
||||||
|
selected={formData.tagIds || []}
|
||||||
|
onChange={handleTagsChange}
|
||||||
|
placeholder="Select tags"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="image">Image (optional)</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
id="image"
|
||||||
|
name="image"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleImageChange}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
{imagePreview && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleRemoveImage}
|
||||||
|
>
|
||||||
|
<Icons.trash className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Remove image</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{imagePreview && (
|
||||||
|
<div className="mt-2 relative w-full h-32 rounded-md overflow-hidden border">
|
||||||
|
<Image
|
||||||
|
src={imagePreview || "/placeholder.svg"}
|
||||||
|
alt="Preview"
|
||||||
|
width={400}
|
||||||
|
height={128}
|
||||||
|
className="w-full h-full object-cover rounded-md"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full bg-[#FF5A5F] hover:bg-[#FF5A5F]/90"
|
||||||
|
>
|
||||||
|
{todo ? "Update" : "Create"} Todo
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
230
frontend/components/todo-row.tsx
Normal file
230
frontend/components/todo-row.tsx
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { useSortable } from "@dnd-kit/sortable"
|
||||||
|
import { CSS } from "@dnd-kit/utilities"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
|
import { TodoForm } from "@/components/todo-form"
|
||||||
|
import { Icons } from "@/components/icons"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import type { Todo, Tag } from "@/services/api-types"
|
||||||
|
|
||||||
|
interface TodoRowProps {
|
||||||
|
todo: Todo
|
||||||
|
tags: Tag[]
|
||||||
|
onUpdate: (todo: Partial<Todo>) => void
|
||||||
|
onDelete: () => void
|
||||||
|
isDraggable?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TodoRow({ todo, tags, onUpdate, onDelete, isDraggable = false }: TodoRowProps) {
|
||||||
|
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
|
||||||
|
const [isViewDialogOpen, setIsViewDialogOpen] = useState(false)
|
||||||
|
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
|
id: todo.id,
|
||||||
|
disabled: !isDraggable,
|
||||||
|
})
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
const todoTags = tags.filter((tag) => todo.tagIds.includes(tag.id))
|
||||||
|
|
||||||
|
const handleStatusToggle = () => {
|
||||||
|
const newStatus = todo.status === "completed" ? "pending" : "completed"
|
||||||
|
onUpdate({ status: newStatus })
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateString?: string | null) => {
|
||||||
|
if (!dateString) return null
|
||||||
|
const date = new Date(dateString)
|
||||||
|
return date.toLocaleDateString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if todo has image or attachments
|
||||||
|
const hasAttachments = todo.attachments && todo.attachments.length > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={cn(
|
||||||
|
"group flex items-center gap-3 px-4 py-2 hover:bg-muted/50 rounded-md transition-colors",
|
||||||
|
todo.status === "completed" ? "text-muted-foreground" : "",
|
||||||
|
isDraggable ? "cursor-grab active:cursor-grabbing" : "",
|
||||||
|
isDragging ? "shadow-lg bg-muted" : "",
|
||||||
|
)}
|
||||||
|
{...(isDraggable ? { ...attributes, ...listeners } : {})}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={todo.status === "completed"}
|
||||||
|
onCheckedChange={() => handleStatusToggle()}
|
||||||
|
className="h-5 w-5 rounded-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={cn("text-sm font-medium truncate", todo.status === "completed" ? "line-through" : "")}>
|
||||||
|
{todo.title}
|
||||||
|
</span>
|
||||||
|
{todo.image && <Icons.image className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||||
|
{hasAttachments && <Icons.paperclip className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{todo.description && <p className="text-xs text-muted-foreground truncate">{todo.description}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{todoTags.length > 0 && (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{todoTags.slice(0, 2).map((tag) => (
|
||||||
|
<div
|
||||||
|
key={tag.id}
|
||||||
|
className="w-2 h-2 rounded-full"
|
||||||
|
style={{ backgroundColor: tag.color || "#FF5A5F" }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{todoTags.length > 2 && <span className="text-xs text-muted-foreground">+{todoTags.length - 2}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{todo.deadline && (
|
||||||
|
<span className="text-xs text-muted-foreground whitespace-nowrap">{formatDate(todo.deadline)}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => setIsViewDialogOpen(true)} className="h-7 w-7">
|
||||||
|
<Icons.eye className="h-3.5 w-3.5" />
|
||||||
|
<span className="sr-only">View</span>
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => setIsEditDialogOpen(true)} className="h-7 w-7">
|
||||||
|
<Icons.edit className="h-3.5 w-3.5" />
|
||||||
|
<span className="sr-only">Edit</span>
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" onClick={onDelete} className="h-7 w-7">
|
||||||
|
<Icons.trash className="h-3.5 w-3.5" />
|
||||||
|
<span className="sr-only">Delete</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Todo</DialogTitle>
|
||||||
|
<DialogDescription>Update your todo details</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<TodoForm
|
||||||
|
todo={todo}
|
||||||
|
tags={tags}
|
||||||
|
onSubmit={(updatedTodo) => {
|
||||||
|
onUpdate(updatedTodo)
|
||||||
|
setIsEditDialogOpen(false)
|
||||||
|
toast.success("Todo updated successfully")
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={isViewDialogOpen} onOpenChange={setIsViewDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{todo.title}</DialogTitle>
|
||||||
|
<DialogDescription>Created on {new Date(todo.createdAt).toLocaleDateString()}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{todo.image && (
|
||||||
|
<div className="w-full h-48 overflow-hidden rounded-md mb-4">
|
||||||
|
<img
|
||||||
|
src={todo.image || "/placeholder.svg?height=192&width=448"}
|
||||||
|
alt={todo.title}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium mb-1">Status</h4>
|
||||||
|
<Badge
|
||||||
|
className={cn(
|
||||||
|
"text-white",
|
||||||
|
todo.status === "pending"
|
||||||
|
? "bg-yellow-500"
|
||||||
|
: todo.status === "in-progress"
|
||||||
|
? "bg-blue-500"
|
||||||
|
: "bg-green-500",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{todo.status.replace("-", " ")}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{todo.deadline && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium mb-1">Deadline</h4>
|
||||||
|
<p className="text-sm flex items-center gap-1">
|
||||||
|
<Icons.calendar className="h-4 w-4" />
|
||||||
|
{formatDate(todo.deadline)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{todo.description && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium mb-1">Description</h4>
|
||||||
|
<p className="text-sm">{todo.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{todoTags.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium mb-1">Tags</h4>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{todoTags.map((tag) => (
|
||||||
|
<Badge key={tag.id} style={{ backgroundColor: tag.color || "#FF5A5F" }} className="text-white">
|
||||||
|
{tag.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasAttachments && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium mb-1">Attachments</h4>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{todo.attachments.map((attachment, index) => (
|
||||||
|
<li key={index} className="flex items-center gap-2 text-sm">
|
||||||
|
<Icons.paperclip className="h-4 w-4" />
|
||||||
|
<span>{attachment}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{todo.subtasks && todo.subtasks.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium mb-1">Subtasks</h4>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{todo.subtasks.map((subtask) => (
|
||||||
|
<li key={subtask.id} className="flex items-center gap-2 text-sm">
|
||||||
|
<Icons.check className={`h-4 w-4 ${subtask.completed ? "text-green-500" : "text-gray-300"}`} />
|
||||||
|
<span className={subtask.completed ? "line-through text-muted-foreground" : ""}>
|
||||||
|
{subtask.description}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
66
frontend/components/ui/accordion.tsx
Normal file
66
frontend/components/ui/accordion.tsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||||
|
import { ChevronDownIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Accordion({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||||
|
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Item
|
||||||
|
data-slot="accordion-item"
|
||||||
|
className={cn("border-b last:border-b-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionTrigger({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Header className="flex">
|
||||||
|
<AccordionPrimitive.Trigger
|
||||||
|
data-slot="accordion-trigger"
|
||||||
|
className={cn(
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||||
|
</AccordionPrimitive.Trigger>
|
||||||
|
</AccordionPrimitive.Header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Content
|
||||||
|
data-slot="accordion-content"
|
||||||
|
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||||
|
</AccordionPrimitive.Content>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||||
157
frontend/components/ui/alert-dialog.tsx
Normal file
157
frontend/components/ui/alert-dialog.tsx
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function AlertDialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||||
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
className={cn("text-lg font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
className={cn(buttonVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
}
|
||||||
66
frontend/components/ui/alert.tsx
Normal file
66
frontend/components/ui/alert.tsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-card text-card-foreground",
|
||||||
|
destructive:
|
||||||
|
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Alert({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert"
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-title"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription }
|
||||||
11
frontend/components/ui/aspect-ratio.tsx
Normal file
11
frontend/components/ui/aspect-ratio.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
|
||||||
|
|
||||||
|
function AspectRatio({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
|
||||||
|
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export { AspectRatio }
|
||||||
53
frontend/components/ui/avatar.tsx
Normal file
53
frontend/components/ui/avatar.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Avatar({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
data-slot="avatar"
|
||||||
|
className={cn(
|
||||||
|
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarImage({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
data-slot="avatar-image"
|
||||||
|
className={cn("aspect-square size-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarFallback({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
data-slot="avatar-fallback"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Avatar, AvatarImage, AvatarFallback }
|
||||||
46
frontend/components/ui/badge.tsx
Normal file
46
frontend/components/ui/badge.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span"> &
|
||||||
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : "span"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="badge"
|
||||||
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
109
frontend/components/ui/breadcrumb.tsx
Normal file
109
frontend/components/ui/breadcrumb.tsx
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||||
|
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||||
|
return (
|
||||||
|
<ol
|
||||||
|
data-slot="breadcrumb-list"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-item"
|
||||||
|
className={cn("inline-flex items-center gap-1.5", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbLink({
|
||||||
|
asChild,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"a"> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "a"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="breadcrumb-link"
|
||||||
|
className={cn("hover:text-foreground transition-colors", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-page"
|
||||||
|
role="link"
|
||||||
|
aria-disabled="true"
|
||||||
|
aria-current="page"
|
||||||
|
className={cn("text-foreground font-normal", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbSeparator({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-separator"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("[&>svg]:size-3.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? <ChevronRight />}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbEllipsis({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-ellipsis"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("flex size-9 items-center justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
<span className="sr-only">More</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
BreadcrumbEllipsis,
|
||||||
|
}
|
||||||
59
frontend/components/ui/button.tsx
Normal file
59
frontend/components/ui/button.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
75
frontend/components/ui/calendar.tsx
Normal file
75
frontend/components/ui/calendar.tsx
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||||
|
import { DayPicker } from "react-day-picker"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function Calendar({
|
||||||
|
className,
|
||||||
|
classNames,
|
||||||
|
showOutsideDays = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayPicker>) {
|
||||||
|
return (
|
||||||
|
<DayPicker
|
||||||
|
showOutsideDays={showOutsideDays}
|
||||||
|
className={cn("p-3", className)}
|
||||||
|
classNames={{
|
||||||
|
months: "flex flex-col sm:flex-row gap-2",
|
||||||
|
month: "flex flex-col gap-4",
|
||||||
|
caption: "flex justify-center pt-1 relative items-center w-full",
|
||||||
|
caption_label: "text-sm font-medium",
|
||||||
|
nav: "flex items-center gap-1",
|
||||||
|
nav_button: cn(
|
||||||
|
buttonVariants({ variant: "outline" }),
|
||||||
|
"size-7 bg-transparent p-0 opacity-50 hover:opacity-100"
|
||||||
|
),
|
||||||
|
nav_button_previous: "absolute left-1",
|
||||||
|
nav_button_next: "absolute right-1",
|
||||||
|
table: "w-full border-collapse space-x-1",
|
||||||
|
head_row: "flex",
|
||||||
|
head_cell:
|
||||||
|
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
|
||||||
|
row: "flex w-full mt-2",
|
||||||
|
cell: cn(
|
||||||
|
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-range-end)]:rounded-r-md",
|
||||||
|
props.mode === "range"
|
||||||
|
? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
|
||||||
|
: "[&:has([aria-selected])]:rounded-md"
|
||||||
|
),
|
||||||
|
day: cn(
|
||||||
|
buttonVariants({ variant: "ghost" }),
|
||||||
|
"size-8 p-0 font-normal aria-selected:opacity-100"
|
||||||
|
),
|
||||||
|
day_range_start:
|
||||||
|
"day-range-start aria-selected:bg-primary aria-selected:text-primary-foreground",
|
||||||
|
day_range_end:
|
||||||
|
"day-range-end aria-selected:bg-primary aria-selected:text-primary-foreground",
|
||||||
|
day_selected:
|
||||||
|
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
|
||||||
|
day_today: "bg-accent text-accent-foreground",
|
||||||
|
day_outside:
|
||||||
|
"day-outside text-muted-foreground aria-selected:text-muted-foreground",
|
||||||
|
day_disabled: "text-muted-foreground opacity-50",
|
||||||
|
day_range_middle:
|
||||||
|
"aria-selected:bg-accent aria-selected:text-accent-foreground",
|
||||||
|
day_hidden: "invisible",
|
||||||
|
...classNames,
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
IconLeft: ({ className, ...props }) => (
|
||||||
|
<ChevronLeft className={cn("size-4", className)} {...props} />
|
||||||
|
),
|
||||||
|
IconRight: ({ className, ...props }) => (
|
||||||
|
<ChevronRight className={cn("size-4", className)} {...props} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Calendar }
|
||||||
92
frontend/components/ui/card.tsx
Normal file
92
frontend/components/ui/card.tsx
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn("leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
241
frontend/components/ui/carousel.tsx
Normal file
241
frontend/components/ui/carousel.tsx
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import useEmblaCarousel, {
|
||||||
|
type UseEmblaCarouselType,
|
||||||
|
} from "embla-carousel-react"
|
||||||
|
import { ArrowLeft, ArrowRight } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
type CarouselApi = UseEmblaCarouselType[1]
|
||||||
|
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
||||||
|
type CarouselOptions = UseCarouselParameters[0]
|
||||||
|
type CarouselPlugin = UseCarouselParameters[1]
|
||||||
|
|
||||||
|
type CarouselProps = {
|
||||||
|
opts?: CarouselOptions
|
||||||
|
plugins?: CarouselPlugin
|
||||||
|
orientation?: "horizontal" | "vertical"
|
||||||
|
setApi?: (api: CarouselApi) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type CarouselContextProps = {
|
||||||
|
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
|
||||||
|
api: ReturnType<typeof useEmblaCarousel>[1]
|
||||||
|
scrollPrev: () => void
|
||||||
|
scrollNext: () => void
|
||||||
|
canScrollPrev: boolean
|
||||||
|
canScrollNext: boolean
|
||||||
|
} & CarouselProps
|
||||||
|
|
||||||
|
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
|
||||||
|
|
||||||
|
function useCarousel() {
|
||||||
|
const context = React.useContext(CarouselContext)
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useCarousel must be used within a <Carousel />")
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
function Carousel({
|
||||||
|
orientation = "horizontal",
|
||||||
|
opts,
|
||||||
|
setApi,
|
||||||
|
plugins,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & CarouselProps) {
|
||||||
|
const [carouselRef, api] = useEmblaCarousel(
|
||||||
|
{
|
||||||
|
...opts,
|
||||||
|
axis: orientation === "horizontal" ? "x" : "y",
|
||||||
|
},
|
||||||
|
plugins
|
||||||
|
)
|
||||||
|
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
||||||
|
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
||||||
|
|
||||||
|
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||||
|
if (!api) return
|
||||||
|
setCanScrollPrev(api.canScrollPrev())
|
||||||
|
setCanScrollNext(api.canScrollNext())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const scrollPrev = React.useCallback(() => {
|
||||||
|
api?.scrollPrev()
|
||||||
|
}, [api])
|
||||||
|
|
||||||
|
const scrollNext = React.useCallback(() => {
|
||||||
|
api?.scrollNext()
|
||||||
|
}, [api])
|
||||||
|
|
||||||
|
const handleKeyDown = React.useCallback(
|
||||||
|
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (event.key === "ArrowLeft") {
|
||||||
|
event.preventDefault()
|
||||||
|
scrollPrev()
|
||||||
|
} else if (event.key === "ArrowRight") {
|
||||||
|
event.preventDefault()
|
||||||
|
scrollNext()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[scrollPrev, scrollNext]
|
||||||
|
)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!api || !setApi) return
|
||||||
|
setApi(api)
|
||||||
|
}, [api, setApi])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!api) return
|
||||||
|
onSelect(api)
|
||||||
|
api.on("reInit", onSelect)
|
||||||
|
api.on("select", onSelect)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
api?.off("select", onSelect)
|
||||||
|
}
|
||||||
|
}, [api, onSelect])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CarouselContext.Provider
|
||||||
|
value={{
|
||||||
|
carouselRef,
|
||||||
|
api: api,
|
||||||
|
opts,
|
||||||
|
orientation:
|
||||||
|
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||||
|
scrollPrev,
|
||||||
|
scrollNext,
|
||||||
|
canScrollPrev,
|
||||||
|
canScrollNext,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onKeyDownCapture={handleKeyDown}
|
||||||
|
className={cn("relative", className)}
|
||||||
|
role="region"
|
||||||
|
aria-roledescription="carousel"
|
||||||
|
data-slot="carousel"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</CarouselContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
const { carouselRef, orientation } = useCarousel()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={carouselRef}
|
||||||
|
className="overflow-hidden"
|
||||||
|
data-slot="carousel-content"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex",
|
||||||
|
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
const { orientation } = useCarousel()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-roledescription="slide"
|
||||||
|
data-slot="carousel-item"
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 shrink-0 grow-0 basis-full",
|
||||||
|
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CarouselPrevious({
|
||||||
|
className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "icon",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Button>) {
|
||||||
|
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-slot="carousel-previous"
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
className={cn(
|
||||||
|
"absolute size-8 rounded-full",
|
||||||
|
orientation === "horizontal"
|
||||||
|
? "top-1/2 -left-12 -translate-y-1/2"
|
||||||
|
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
disabled={!canScrollPrev}
|
||||||
|
onClick={scrollPrev}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ArrowLeft />
|
||||||
|
<span className="sr-only">Previous slide</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CarouselNext({
|
||||||
|
className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "icon",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Button>) {
|
||||||
|
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-slot="carousel-next"
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
className={cn(
|
||||||
|
"absolute size-8 rounded-full",
|
||||||
|
orientation === "horizontal"
|
||||||
|
? "top-1/2 -right-12 -translate-y-1/2"
|
||||||
|
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
disabled={!canScrollNext}
|
||||||
|
onClick={scrollNext}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ArrowRight />
|
||||||
|
<span className="sr-only">Next slide</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
type CarouselApi,
|
||||||
|
Carousel,
|
||||||
|
CarouselContent,
|
||||||
|
CarouselItem,
|
||||||
|
CarouselPrevious,
|
||||||
|
CarouselNext,
|
||||||
|
}
|
||||||
353
frontend/components/ui/chart.tsx
Normal file
353
frontend/components/ui/chart.tsx
Normal file
@ -0,0 +1,353 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as RechartsPrimitive from "recharts"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||||
|
const THEMES = { light: "", dark: ".dark" } as const
|
||||||
|
|
||||||
|
export type ChartConfig = {
|
||||||
|
[k in string]: {
|
||||||
|
label?: React.ReactNode
|
||||||
|
icon?: React.ComponentType
|
||||||
|
} & (
|
||||||
|
| { color?: string; theme?: never }
|
||||||
|
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChartContextProps = {
|
||||||
|
config: ChartConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||||
|
|
||||||
|
function useChart() {
|
||||||
|
const context = React.useContext(ChartContext)
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useChart must be used within a <ChartContainer />")
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartContainer({
|
||||||
|
id,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
config,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
config: ChartConfig
|
||||||
|
children: React.ComponentProps<
|
||||||
|
typeof RechartsPrimitive.ResponsiveContainer
|
||||||
|
>["children"]
|
||||||
|
}) {
|
||||||
|
const uniqueId = React.useId()
|
||||||
|
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartContext.Provider value={{ config }}>
|
||||||
|
<div
|
||||||
|
data-slot="chart"
|
||||||
|
data-chart={chartId}
|
||||||
|
className={cn(
|
||||||
|
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChartStyle id={chartId} config={config} />
|
||||||
|
<RechartsPrimitive.ResponsiveContainer>
|
||||||
|
{children}
|
||||||
|
</RechartsPrimitive.ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</ChartContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||||
|
const colorConfig = Object.entries(config).filter(
|
||||||
|
([, config]) => config.theme || config.color
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!colorConfig.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<style
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: Object.entries(THEMES)
|
||||||
|
.map(
|
||||||
|
([theme, prefix]) => `
|
||||||
|
${prefix} [data-chart=${id}] {
|
||||||
|
${colorConfig
|
||||||
|
.map(([key, itemConfig]) => {
|
||||||
|
const color =
|
||||||
|
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||||
|
itemConfig.color
|
||||||
|
return color ? ` --color-${key}: ${color};` : null
|
||||||
|
})
|
||||||
|
.join("\n")}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.join("\n"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||||
|
|
||||||
|
function ChartTooltipContent({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
className,
|
||||||
|
indicator = "dot",
|
||||||
|
hideLabel = false,
|
||||||
|
hideIndicator = false,
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
labelClassName,
|
||||||
|
formatter,
|
||||||
|
color,
|
||||||
|
nameKey,
|
||||||
|
labelKey,
|
||||||
|
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||||
|
React.ComponentProps<"div"> & {
|
||||||
|
hideLabel?: boolean
|
||||||
|
hideIndicator?: boolean
|
||||||
|
indicator?: "line" | "dot" | "dashed"
|
||||||
|
nameKey?: string
|
||||||
|
labelKey?: string
|
||||||
|
}) {
|
||||||
|
const { config } = useChart()
|
||||||
|
|
||||||
|
const tooltipLabel = React.useMemo(() => {
|
||||||
|
if (hideLabel || !payload?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const [item] = payload
|
||||||
|
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||||
|
const value =
|
||||||
|
!labelKey && typeof label === "string"
|
||||||
|
? config[label as keyof typeof config]?.label || label
|
||||||
|
: itemConfig?.label
|
||||||
|
|
||||||
|
if (labelFormatter) {
|
||||||
|
return (
|
||||||
|
<div className={cn("font-medium", labelClassName)}>
|
||||||
|
{labelFormatter(value, payload)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
||||||
|
}, [
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
payload,
|
||||||
|
hideLabel,
|
||||||
|
labelClassName,
|
||||||
|
config,
|
||||||
|
labelKey,
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!active || !payload?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!nestLabel ? tooltipLabel : null}
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{payload.map((item, index) => {
|
||||||
|
const key = `${nameKey || item.name || item.dataKey || "value"}`
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||||
|
const indicatorColor = color || item.payload.fill || item.color
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.dataKey}
|
||||||
|
className={cn(
|
||||||
|
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
||||||
|
indicator === "dot" && "items-center"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatter && item?.value !== undefined && item.name ? (
|
||||||
|
formatter(item.value, item.name, item, index, item.payload)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{itemConfig?.icon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
!hideIndicator && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||||
|
{
|
||||||
|
"h-2.5 w-2.5": indicator === "dot",
|
||||||
|
"w-1": indicator === "line",
|
||||||
|
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||||
|
indicator === "dashed",
|
||||||
|
"my-0.5": nestLabel && indicator === "dashed",
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--color-bg": indicatorColor,
|
||||||
|
"--color-border": indicatorColor,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 justify-between leading-none",
|
||||||
|
nestLabel ? "items-end" : "items-center"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{nestLabel ? tooltipLabel : null}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{itemConfig?.label || item.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{item.value && (
|
||||||
|
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||||
|
{item.value.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartLegend = RechartsPrimitive.Legend
|
||||||
|
|
||||||
|
function ChartLegendContent({
|
||||||
|
className,
|
||||||
|
hideIcon = false,
|
||||||
|
payload,
|
||||||
|
verticalAlign = "bottom",
|
||||||
|
nameKey,
|
||||||
|
}: React.ComponentProps<"div"> &
|
||||||
|
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||||
|
hideIcon?: boolean
|
||||||
|
nameKey?: string
|
||||||
|
}) {
|
||||||
|
const { config } = useChart()
|
||||||
|
|
||||||
|
if (!payload?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center gap-4",
|
||||||
|
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{payload.map((item) => {
|
||||||
|
const key = `${nameKey || item.dataKey || "value"}`
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.value}
|
||||||
|
className={cn(
|
||||||
|
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{itemConfig?.icon && !hideIcon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: item.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{itemConfig?.label}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to extract item config from a payload.
|
||||||
|
function getPayloadConfigFromPayload(
|
||||||
|
config: ChartConfig,
|
||||||
|
payload: unknown,
|
||||||
|
key: string
|
||||||
|
) {
|
||||||
|
if (typeof payload !== "object" || payload === null) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadPayload =
|
||||||
|
"payload" in payload &&
|
||||||
|
typeof payload.payload === "object" &&
|
||||||
|
payload.payload !== null
|
||||||
|
? payload.payload
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
let configLabelKey: string = key
|
||||||
|
|
||||||
|
if (
|
||||||
|
key in payload &&
|
||||||
|
typeof payload[key as keyof typeof payload] === "string"
|
||||||
|
) {
|
||||||
|
configLabelKey = payload[key as keyof typeof payload] as string
|
||||||
|
} else if (
|
||||||
|
payloadPayload &&
|
||||||
|
key in payloadPayload &&
|
||||||
|
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||||
|
) {
|
||||||
|
configLabelKey = payloadPayload[
|
||||||
|
key as keyof typeof payloadPayload
|
||||||
|
] as string
|
||||||
|
}
|
||||||
|
|
||||||
|
return configLabelKey in config
|
||||||
|
? config[configLabelKey]
|
||||||
|
: config[key as keyof typeof config]
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartStyle,
|
||||||
|
}
|
||||||
32
frontend/components/ui/checkbox.tsx
Normal file
32
frontend/components/ui/checkbox.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||||
|
import { CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Checkbox({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
data-slot="checkbox"
|
||||||
|
className={cn(
|
||||||
|
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
className="flex items-center justify-center text-current transition-none"
|
||||||
|
>
|
||||||
|
<CheckIcon className="size-3.5" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
33
frontend/components/ui/collapsible.tsx
Normal file
33
frontend/components/ui/collapsible.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||||
|
|
||||||
|
function Collapsible({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||||
|
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.CollapsibleTrigger
|
||||||
|
data-slot="collapsible-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleContent({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.CollapsibleContent
|
||||||
|
data-slot="collapsible-content"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||||
177
frontend/components/ui/command.tsx
Normal file
177
frontend/components/ui/command.tsx
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Command as CommandPrimitive } from "cmdk"
|
||||||
|
import { SearchIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
|
||||||
|
function Command({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive
|
||||||
|
data-slot="command"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandDialog({
|
||||||
|
title = "Command Palette",
|
||||||
|
description = "Search for a command to run...",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Dialog> & {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog {...props}>
|
||||||
|
<DialogHeader className="sr-only">
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogContent className="overflow-hidden p-0">
|
||||||
|
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||||
|
{children}
|
||||||
|
</Command>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="command-input-wrapper"
|
||||||
|
className="flex h-9 items-center gap-2 border-b px-3"
|
||||||
|
>
|
||||||
|
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
data-slot="command-input"
|
||||||
|
className={cn(
|
||||||
|
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.List
|
||||||
|
data-slot="command-list"
|
||||||
|
className={cn(
|
||||||
|
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandEmpty({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Empty
|
||||||
|
data-slot="command-empty"
|
||||||
|
className="py-6 text-center text-sm"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Group
|
||||||
|
data-slot="command-group"
|
||||||
|
className={cn(
|
||||||
|
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Separator
|
||||||
|
data-slot="command-separator"
|
||||||
|
className={cn("bg-border -mx-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
data-slot="command-item"
|
||||||
|
className={cn(
|
||||||
|
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="command-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandShortcut,
|
||||||
|
CommandSeparator,
|
||||||
|
}
|
||||||
252
frontend/components/ui/context-menu.tsx
Normal file
252
frontend/components/ui/context-menu.tsx
Normal file
@ -0,0 +1,252 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
|
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function ContextMenu({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
||||||
|
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
||||||
|
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.RadioGroup
|
||||||
|
data-slot="context-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.SubTrigger
|
||||||
|
data-slot="context-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto" />
|
||||||
|
</ContextMenuPrimitive.SubTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.SubContent
|
||||||
|
data-slot="context-menu-sub-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Portal>
|
||||||
|
<ContextMenuPrimitive.Content
|
||||||
|
data-slot="context-menu-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</ContextMenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
variant?: "default" | "destructive"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Item
|
||||||
|
data-slot="context-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.CheckboxItem
|
||||||
|
data-slot="context-menu-checkbox-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.RadioItem
|
||||||
|
data-slot="context-menu-radio-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
|
<CircleIcon className="size-2 fill-current" />
|
||||||
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Label
|
||||||
|
data-slot="context-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Separator
|
||||||
|
data-slot="context-menu-separator"
|
||||||
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="context-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuCheckboxItem,
|
||||||
|
ContextMenuRadioItem,
|
||||||
|
ContextMenuLabel,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuShortcut,
|
||||||
|
ContextMenuGroup,
|
||||||
|
ContextMenuPortal,
|
||||||
|
ContextMenuSub,
|
||||||
|
ContextMenuSubContent,
|
||||||
|
ContextMenuSubTrigger,
|
||||||
|
ContextMenuRadioGroup,
|
||||||
|
}
|
||||||
135
frontend/components/ui/dialog.tsx
Normal file
135
frontend/components/ui/dialog.tsx
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Dialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DialogPortal data-slot="dialog-portal">
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn("text-lg leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
||||||
132
frontend/components/ui/drawer.tsx
Normal file
132
frontend/components/ui/drawer.tsx
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Drawer as DrawerPrimitive } from "vaul"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Drawer({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||||
|
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
||||||
|
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
||||||
|
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
||||||
|
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Overlay
|
||||||
|
data-slot="drawer-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DrawerPortal data-slot="drawer-portal">
|
||||||
|
<DrawerOverlay />
|
||||||
|
<DrawerPrimitive.Content
|
||||||
|
data-slot="drawer-content"
|
||||||
|
className={cn(
|
||||||
|
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
|
||||||
|
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
|
||||||
|
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
|
||||||
|
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
||||||
|
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
|
||||||
|
{children}
|
||||||
|
</DrawerPrimitive.Content>
|
||||||
|
</DrawerPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="drawer-header"
|
||||||
|
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="drawer-footer"
|
||||||
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Title
|
||||||
|
data-slot="drawer-title"
|
||||||
|
className={cn("text-foreground font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Description
|
||||||
|
data-slot="drawer-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Drawer,
|
||||||
|
DrawerPortal,
|
||||||
|
DrawerOverlay,
|
||||||
|
DrawerTrigger,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerDescription,
|
||||||
|
}
|
||||||
257
frontend/components/ui/dropdown-menu.tsx
Normal file
257
frontend/components/ui/dropdown-menu.tsx
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function DropdownMenu({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||||
|
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Trigger
|
||||||
|
data-slot="dropdown-menu-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
variant?: "default" | "destructive"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioGroup
|
||||||
|
data-slot="dropdown-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CircleIcon className="size-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||||
|
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto size-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
}
|
||||||
167
frontend/components/ui/form.tsx
Normal file
167
frontend/components/ui/form.tsx
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
FormProvider,
|
||||||
|
useFormContext,
|
||||||
|
useFormState,
|
||||||
|
type ControllerProps,
|
||||||
|
type FieldPath,
|
||||||
|
type FieldValues,
|
||||||
|
} from "react-hook-form"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
|
||||||
|
const Form = FormProvider
|
||||||
|
|
||||||
|
type FormFieldContextValue<
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
> = {
|
||||||
|
name: TName
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||||
|
{} as FormFieldContextValue
|
||||||
|
)
|
||||||
|
|
||||||
|
const FormField = <
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
>({
|
||||||
|
...props
|
||||||
|
}: ControllerProps<TFieldValues, TName>) => {
|
||||||
|
return (
|
||||||
|
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||||
|
<Controller {...props} />
|
||||||
|
</FormFieldContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const useFormField = () => {
|
||||||
|
const fieldContext = React.useContext(FormFieldContext)
|
||||||
|
const itemContext = React.useContext(FormItemContext)
|
||||||
|
const { getFieldState } = useFormContext()
|
||||||
|
const formState = useFormState({ name: fieldContext.name })
|
||||||
|
const fieldState = getFieldState(fieldContext.name, formState)
|
||||||
|
|
||||||
|
if (!fieldContext) {
|
||||||
|
throw new Error("useFormField should be used within <FormField>")
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = itemContext
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: fieldContext.name,
|
||||||
|
formItemId: `${id}-form-item`,
|
||||||
|
formDescriptionId: `${id}-form-item-description`,
|
||||||
|
formMessageId: `${id}-form-item-message`,
|
||||||
|
...fieldState,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormItemContextValue = {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||||
|
{} as FormItemContextValue
|
||||||
|
)
|
||||||
|
|
||||||
|
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
const id = React.useId()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItemContext.Provider value={{ id }}>
|
||||||
|
<div
|
||||||
|
data-slot="form-item"
|
||||||
|
className={cn("grid gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</FormItemContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
const { error, formItemId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
data-slot="form-label"
|
||||||
|
data-error={!!error}
|
||||||
|
className={cn("data-[error=true]:text-destructive", className)}
|
||||||
|
htmlFor={formItemId}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||||
|
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Slot
|
||||||
|
data-slot="form-control"
|
||||||
|
id={formItemId}
|
||||||
|
aria-describedby={
|
||||||
|
!error
|
||||||
|
? `${formDescriptionId}`
|
||||||
|
: `${formDescriptionId} ${formMessageId}`
|
||||||
|
}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
const { formDescriptionId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="form-description"
|
||||||
|
id={formDescriptionId}
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
const { error, formMessageId } = useFormField()
|
||||||
|
const body = error ? String(error?.message ?? "") : props.children
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="form-message"
|
||||||
|
id={formMessageId}
|
||||||
|
className={cn("text-destructive text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
useFormField,
|
||||||
|
Form,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormMessage,
|
||||||
|
FormField,
|
||||||
|
}
|
||||||
44
frontend/components/ui/hover-card.tsx
Normal file
44
frontend/components/ui/hover-card.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function HoverCard({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
||||||
|
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function HoverCardTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HoverCardContent({
|
||||||
|
className,
|
||||||
|
align = "center",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
||||||
|
<HoverCardPrimitive.Content
|
||||||
|
data-slot="hover-card-content"
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</HoverCardPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||||
77
frontend/components/ui/input-otp.tsx
Normal file
77
frontend/components/ui/input-otp.tsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { OTPInput, OTPInputContext } from "input-otp"
|
||||||
|
import { MinusIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function InputOTP({
|
||||||
|
className,
|
||||||
|
containerClassName,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof OTPInput> & {
|
||||||
|
containerClassName?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<OTPInput
|
||||||
|
data-slot="input-otp"
|
||||||
|
containerClassName={cn(
|
||||||
|
"flex items-center gap-2 has-disabled:opacity-50",
|
||||||
|
containerClassName
|
||||||
|
)}
|
||||||
|
className={cn("disabled:cursor-not-allowed", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="input-otp-group"
|
||||||
|
className={cn("flex items-center", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputOTPSlot({
|
||||||
|
index,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
index: number
|
||||||
|
}) {
|
||||||
|
const inputOTPContext = React.useContext(OTPInputContext)
|
||||||
|
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="input-otp-slot"
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(
|
||||||
|
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{char}
|
||||||
|
{hasFakeCaret && (
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div data-slot="input-otp-separator" role="separator" {...props}>
|
||||||
|
<MinusIcon />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user