initial code commit

This commit is contained in:
Sosokker 2025-04-20 15:58:52 +07:00
commit 2e31565a8f
157 changed files with 22588 additions and 0 deletions

49
backend/.air.toml Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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=

View 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)
}

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

View 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

View 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

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

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

View 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, &timestamp); 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
View 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)
}

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

View 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"`
}

View 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")
)

View 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"`
}

View 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"`
}

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

View 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"`
}

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

View 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,
}
}

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

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

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

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

View 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)
}

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

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

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

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

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

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

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

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

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

View 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";

View 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

File diff suppressed because it is too large Load Diff

View File

View File

40
backend/sqlc.yaml Normal file
View 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
View 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
View 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
View File

@ -0,0 +1,5 @@
namespace NodeJS {
interface ProcessEnv {
NEXT_PUBLIC_API_BASE_URL: string
}
}

46
frontend/app/error.tsx Normal file
View 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&apos;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

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

168
frontend/app/globals.css Normal file
View 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
View 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
View 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
View 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&apos;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>
);
}

View 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&apos;t find the page you&apos;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
View 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")
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}
}

View 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>
);
}

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

View 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
View 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"
}

View 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,
};

View 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>
);
}

View 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>
)
}

View 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>
);
}

View 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>
)
}

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

View 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&apos;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>
</>
);
}

View 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>
);
}

View 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>
</>
)
}

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

View 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,
}

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

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

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

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

View 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,
}

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

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

View 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,
}

View 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,
}

View 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,
}

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

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

View 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,
}

View 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,
}

View 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,
}

View 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,
}

View 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,
}

View 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,
}

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

View 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