├── LICENSE ├── Makefile ├── README.md ├── application ├── app.go ├── echo.go └── router.go ├── config ├── config.go └── config.yaml ├── database ├── db.go ├── migrate │ ├── 000001_init_schema.down.sql │ └── 000001_init_schema.up.sql └── query │ ├── url.sql │ └── user.sql ├── frontend ├── .gitignore ├── README.md ├── app │ ├── auth │ │ ├── forgot-password │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── login │ │ │ └── page.tsx │ │ └── register │ │ │ └── page.tsx │ ├── favicon.ico │ ├── globals.css │ ├── help │ │ └── page.tsx │ ├── layout.tsx │ ├── page.tsx │ └── urls │ │ └── page.tsx ├── components.json ├── components │ ├── auth │ │ ├── forgot-password-form.tsx │ │ ├── login-form.tsx │ │ └── register-form.tsx │ ├── context.tsx │ ├── env.tsx │ ├── loading.tsx │ ├── logout.tsx │ ├── mode-toggle.tsx │ ├── nav │ │ └── navbar.tsx │ ├── own │ │ └── radio.tsx │ ├── theme-provider.tsx │ ├── token.ts │ └── ui │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── checkbox.tsx │ │ ├── dialog.tsx │ │ ├── dropdown-menu.tsx │ │ ├── form.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── pagination.tsx │ │ ├── radio-group.tsx │ │ └── table.tsx ├── eslint.config.mjs ├── hooks │ └── use-localstorage.tsx ├── lib │ ├── utils.ts │ └── validations │ │ └── auth.ts ├── next.config.ts ├── package.json ├── pnpm-lock.yaml ├── postcss.config.mjs ├── public │ ├── file.svg │ ├── globe.svg │ ├── next.svg │ ├── vercel.svg │ └── window.svg ├── tailwind.config.ts └── tsconfig.json ├── go.mod ├── go.sum ├── internal ├── api │ ├── url.go │ └── user.go ├── cache │ ├── redis.go │ ├── user.go │ └── view.go ├── model │ ├── error.go │ ├── url.go │ └── user.go ├── mw │ ├── jwt.go │ └── logger.go ├── repo │ ├── db.go │ ├── models.go │ ├── querier.go │ ├── url.sql.go │ └── user.sql.go └── service │ ├── url.go │ └── user.go ├── main.go ├── pkg ├── emailsender │ └── email.go ├── hasher │ ├── hasher_test.go │ └── password.go ├── jwt │ └── jwt.go ├── logger │ └── logger.go ├── randnum │ └── randnum.go ├── shortcode │ └── shortcode.go └── validator │ └── valid.go └── sqlc.yaml /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 aeilang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # migrate 2 | install_migrate: 3 | go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest 4 | 5 | # sqlc 6 | install_sqlc: 7 | go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest 8 | 9 | # postgres 10 | lanch_postgres: 11 | docker run --name postgres_urls \ 12 | -e POSTGRES_USER=lang \ 13 | -e POSTGRES_PASSWORD=password \ 14 | -e POSTGRES_DB=urldb \ 15 | -p 5432:5432 \ 16 | -d postgres 17 | 18 | # redis 19 | lanch_redis: 20 | docker run --name=reids_urls \ 21 | -p 6379:6379 \ 22 | -d redis 23 | 24 | databaseURL="postgres://lang:password@localhost:5432/urldb?sslmode=disable" 25 | 26 | migrate_up: 27 | migrate -path="./database/migrate" -database=${databaseURL} up 28 | 29 | migrate_drop: 30 | migrate -path="./database/migrate" -database=${databaseURL} drop -f -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## 短链接生成器 2 | 3 | 短链接生成器是将一个长链接例如 4 | 5 | - (https://www.google.com/search?q=sad&sca_esv=e058c774e46ad98b&sxsrf=ADLYWIJR81hSYagdglnIMd2c2qHxZFsmMg%3A1733131120255&ei=cHtNZ6-VD92C2roP_uT9iAo&ved=0ahUKEwivjZbG4IiKAxVdgVYBHX5yH6EQ4dUDCA8&uact=5&oq=sad&gs_lp=Egxnd3Mtd2l6LXNlcnAiA3NhZDIFEC4YgAQyBRAuGIAEMgUQABiABDIFEC4YgAQyBRAuGIAEMgUQABiABDIFEAAYgAQyCBAuGIAEGNQCMgUQABiABDIFEC4YgAQyFBAuGIAEGJcFGNwEGN4EGN8E2AEBSOsTUIYJWJwScAJ4AZABAJgBwwGgAckIqgEDMC42uAEDyAEA-AEBmAIHoAK0B6gCE8ICChAAGLADGNYEGEfCAgQQIxgnwgIKEAAYgAQYQxiKBcICCxAAGIAEGLEDGIMBwgIIEAAYgAQYsQPCAgcQIxgnGOoCwgITEAAYgAQYQxi0AhiKBRjqAtgBAcICFhAuGIAEGEMYtAIYyAMYigUY6gLYAQHCAgoQIxiABBgnGIoFwgILEAAYgAQYkQIYigXCAgsQLhiABBiRAhiKBcICCxAuGIAEGNEDGMcBwgILEC4YgAQYxwEYrwHCAhQQLhiABBiXBRjcBBjeBBjgBNgBAZgDBogGAZAGAroGBggBEAEYAZIHAzIuNaAHx1M&sclient=gws-wiz-serp) 6 | 7 | 转为短链接 8 | 9 | - http://bit.ly/3Z9T0Em 10 | 11 | 这在一些需要分享URL的场景非常有用,例如在限制URL长度的twitter, 和各大评论区,电子书等媒介中。 12 | 13 | ### 短链接生成器非常适合初学者入门Web开发 14 | 15 | 1. 该项目相对简单,接口只有两个: 16 | - POST /api/url 接口, 接受长URL 17 | - GET /:code 接口: 把短URL重定向到长URL 18 | 19 | 2. 该项目非常实用,bitly和tinyURL公司就是以此为主要业务。 20 | 21 | ### 项目难点 22 | 23 | 这是一个读多写少的项目。 24 | 25 | 1. 难点1: GET请求需要服务时延低,响应速度快,使重定向的用户没有痛感。如果请求都要访问数据库,涉及到磁盘IO会增加响应时间,同时大量的请求会给数据库很多压力。所以需要使用redis进行缓存。 26 | 27 | 2. 难点2: 短URL的id如何生成,短id是可能重复的,需要使用重试机制提高成功率。 28 | 29 | ### 项目特点 30 | 31 | 1. 追求最佳实践,按照依赖倒置的原则,使用接口对重要的依赖进行解耦合,方便未来进行重构升级。 32 | 2. web框架使用echo,这纯粹是自己的喜好,因为echo使用装饰器的设计模式,我能看懂,同时天生支持全局错误处理。 33 | 3. 使用sqlc而不是orm, 因为我更偏爱直接使用sql与数据库打交道,而不是再学其他orm的语法。orm不适用于复杂的sql 34 | 4. 使用viper加载项目配置, postgres数据库, 并使用redis进行缓存 35 | 36 | 37 | ### 开发环境 38 | 39 | 1. 下载golang migrate[https://github.com/golang-migrate/migrate], 数据库迁移工具 40 | 41 | ```sh 42 | go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest 43 | ``` 44 | 45 | 2. 下载sqlc[https://github.com/sqlc-dev/sqlc], 将sql转为go代码 46 | ```sh 47 | go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest 48 | ``` 49 | 50 | 3. 启动postgres 数据库 (使用docker) 51 | ```sh 52 | docker run --name postgres-url \ 53 | -e POSTGRES_USER=lang \ 54 | -e POSTGRES_PASSWORD=password \ 55 | -e POSTGRES_DB=urldb \ 56 | -p 5432:5432 \ 57 | -d postgres 58 | ``` 59 | 60 | 4. 启动redis (使用docker) 61 | ```sh 62 | docker run --name redis \ 63 | -p 6379:6379 \ 64 | -d redis 65 | ``` 66 | 67 | ### MCV架构 68 | 69 | 1. View(视图): 人机交互接口,就是浏览器看到的界面。 70 | 71 | 2. Controller(控制器): 负责处理用户请求,将请求中的数据反序列化,验证数据的格式是否正确,调用模型层处理,并把结果序列化返回给用户。 72 | 73 | 3. Model(模型):负责程序的数据逻辑和业务规则,通常包含数据结构、数据库交互以及与应用逻辑相关的功能。 74 | 75 | 该项目是前后端分离的项目,后端不涉及V层,只提供接口返回json数据,只包含C和M层。M层太大,通常为了方便开发,又把M层分为repository和service层。 76 | 后端包含: 77 | 78 | - repository: 持久层,与数据库交互。 79 | - service: 逻辑层,汇总业务逻辑。依赖repository 80 | - controller: 控制层,功能不变,如上。 依赖service 81 | 82 | 各个层级职责清晰,使用接口进行解耦合;controller层不应调用repository层的方法,而是通过service层进行调用。 83 | 84 | ### 运行项目 85 | 86 | 后端: 87 | ```sh 88 | make migrate_up 89 | sqlc generate 90 | go mod tidy 91 | go run main.go 92 | ``` 93 | 94 | 前端: 95 | ```sh 96 | cd frontend 97 | pnpm install 98 | npm run dev 99 | ``` -------------------------------------------------------------------------------- /application/app.go: -------------------------------------------------------------------------------- 1 | package application 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "os" 7 | "os/signal" 8 | "syscall" 9 | "time" 10 | 11 | "github.com/aeilang/urlshortener/config" 12 | "github.com/aeilang/urlshortener/database" 13 | "github.com/aeilang/urlshortener/internal/api" 14 | "github.com/aeilang/urlshortener/internal/cache" 15 | "github.com/aeilang/urlshortener/internal/service" 16 | "github.com/aeilang/urlshortener/pkg/emailsender" 17 | "github.com/aeilang/urlshortener/pkg/hasher" 18 | "github.com/aeilang/urlshortener/pkg/jwt" 19 | "github.com/aeilang/urlshortener/pkg/logger" 20 | "github.com/aeilang/urlshortener/pkg/randnum" 21 | "github.com/aeilang/urlshortener/pkg/shortcode" 22 | "github.com/aeilang/urlshortener/pkg/validator" 23 | "github.com/labstack/echo/v4" 24 | ) 25 | 26 | type Application struct { 27 | e *echo.Echo 28 | db *sql.DB 29 | redisClient *cache.RedisCache 30 | urlService *service.URLService 31 | urlHandler *api.URLHandler 32 | userHandler *api.UserHandler 33 | cfg *config.Config 34 | jwt *jwt.JWT 35 | } 36 | 37 | func InitApp(filePath string) (*Application, error) { 38 | // 加载配置 39 | cfg, err := config.NewConfig(filePath) 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | // 初始化logger 45 | logger.InitLogger(cfg.Logger) 46 | 47 | // 初始化数据库 48 | db, err := database.NewDB(cfg.Database) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | // 初始化redis 54 | redisClient, err := cache.NewRedisCache(cfg.Redis) 55 | if err != nil { 56 | return nil, err 57 | } 58 | 59 | // 初始化pkg 60 | emailSender, err := emailsender.NewEmailSend(cfg.Email) 61 | if err != nil { 62 | return nil, err 63 | } 64 | 65 | passwordHash := hasher.NewPasswordHash() 66 | 67 | jwt := jwt.NewJWT(cfg.JWT) 68 | 69 | randNum := randnum.NewRandNum(cfg.RandNum) 70 | 71 | shortCode := shortcode.NewShortCode(cfg.ShortCode) 72 | 73 | customValidator := validator.NewCustomValidator() 74 | 75 | // service 76 | urlService := service.NewURLService(db, shortCode, redisClient, cfg.App) 77 | userService := service.NewUserService(db, passwordHash, jwt, redisClient, emailSender, randNum) 78 | 79 | // handler 80 | urlHandler := api.NewURLHandler(urlService) 81 | userHandler := api.NewUserHandler(userService) 82 | 83 | // echo 84 | e := NewEcho(cfg.Server, customValidator) 85 | 86 | a := &Application{ 87 | e: e, 88 | db: db, 89 | redisClient: redisClient, 90 | urlService: urlService, 91 | urlHandler: urlHandler, 92 | userHandler: userHandler, 93 | cfg: cfg, 94 | jwt: jwt, 95 | } 96 | 97 | a.initRouter() 98 | return a, nil 99 | } 100 | 101 | func (a *Application) Start() { 102 | go a.syncViewsToDB() 103 | 104 | go func() { 105 | if err := a.e.Start(a.cfg.Server.Addr); err != nil { 106 | logger.Fatal(err.Error()) 107 | } 108 | }() 109 | 110 | // wait to gracefully shutdown 111 | a.shutdown() 112 | } 113 | 114 | func (a *Application) syncViewsToDB() { 115 | ticker := time.NewTicker(a.cfg.App.SyncViewDuration) 116 | defer ticker.Stop() 117 | 118 | for range ticker.C { 119 | func() { 120 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) 121 | defer cancel() 122 | 123 | if err := a.urlService.SyncViewsToDB(ctx); err != nil { 124 | logger.Error(err.Error()) 125 | } 126 | }() 127 | } 128 | 129 | } 130 | 131 | // gracefully shutdown 132 | func (a *Application) shutdown() { 133 | defer func() { 134 | if err := a.db.Close(); err != nil { 135 | logger.Error(err.Error()) 136 | } 137 | }() 138 | defer func() { 139 | if err := a.db.Close(); err != nil { 140 | logger.Error(err.Error()) 141 | } 142 | }() 143 | 144 | quit := make(chan os.Signal, 1) 145 | signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) 146 | <-quit 147 | 148 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 149 | defer cancel() 150 | 151 | if err := a.e.Shutdown(ctx); err != nil { 152 | logger.Error(err.Error()) 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /application/echo.go: -------------------------------------------------------------------------------- 1 | // echo.go 负责配置和初始化Echo Web框架 2 | 3 | package application 4 | 5 | import ( 6 | "github.com/aeilang/urlshortener/config" 7 | "github.com/aeilang/urlshortener/internal/mw" 8 | "github.com/labstack/echo/v4" 9 | "github.com/labstack/echo/v4/middleware" 10 | echoSwagger "github.com/swaggo/echo-swagger" 11 | ) 12 | 13 | // NewEcho 创建并配置一个新的Echo实例 14 | func NewEcho(cfg config.ServerConfig, validator echo.Validator) *echo.Echo { 15 | e := echo.New() 16 | 17 | // 基本配置 18 | e.HideBanner = true // 隐藏Echo的启动Banner 19 | e.HidePort = true // 隐藏端口信息 20 | e.Server.WriteTimeout = cfg.WriteTimeout // 设置写超时 21 | e.Server.ReadTimeout = cfg.ReadTimeout // 设置读超时 22 | 23 | // 设置请求参数验证器 24 | e.Validator = validator 25 | 26 | // 添加中间件 27 | e.Use(mw.Logger) // 日志中间件 28 | e.Use(middleware.Recover()) // 恢复中间件,用于处理panic 29 | e.Use(middleware.CORS()) // CORS中间件,处理跨域请求 30 | 31 | // 添加Swagger文档路由 32 | e.GET("/swagger/*", echoSwagger.WrapHandler) 33 | 34 | return e 35 | } 36 | -------------------------------------------------------------------------------- /application/router.go: -------------------------------------------------------------------------------- 1 | // router.go 定义了应用程序的所有HTTP路由 2 | 3 | package application 4 | 5 | import ( 6 | "github.com/aeilang/urlshortener/internal/mw" 7 | ) 8 | 9 | // initRouter 初始化所有HTTP路由 10 | func (a *Application) initRouter() { 11 | // 用户认证相关路由组 /api/auth 12 | u := a.e.Group("/api/auth") 13 | 14 | // 用户认证相关端点 15 | u.POST("/login", a.userHandler.Login) // 用户登录 16 | u.POST("/register", a.userHandler.Register) // 用户注册 17 | u.POST("/forget", a.userHandler.ForgetPassword) // 忘记密码 18 | u.GET("/register/:email", a.userHandler.SendEmailCode) // 发送注册验证码 19 | 20 | // URL缩短服务相关路由 21 | a.e.GET("/:code", a.urlHandler.RedirectURL) // 短链接重定向 22 | 23 | // 需要JWT认证的URL管理API 24 | url := a.e.Group("/api", mw.JWTAuther(a.jwt)) 25 | url.POST("/url", a.urlHandler.CreateURL) // 创建短链接 26 | url.GET("/urls", a.urlHandler.GetURLs) // 获取用户的所有短链接 27 | url.DELETE("/url/:code", a.urlHandler.DeleteURL) // 删除短链接 28 | url.PATCH("/url/:code", a.urlHandler.UpdateURLDuration) // 更新短链接的有效期 29 | 30 | } 31 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | "github.com/spf13/viper" 9 | ) 10 | 11 | type Config struct { 12 | Database DatabaseConfig `mapstructure:"database"` 13 | Redis RedisConfig `mapstructure:"redis"` 14 | Server ServerConfig `mapstructure:"server"` 15 | App AppConfig `mapstructure:"app"` 16 | ShortCode ShortCodeConfig `mapstructure:"shortcode"` 17 | Logger LogConfig `mapstructure:"logger"` 18 | Email EmailConfig `mapstructure:"email"` 19 | JWT JWTConfig `mapstructure:"jwt"` 20 | RandNum RandNumConfig `mapstructure:"rand_num"` 21 | } 22 | 23 | var Cfg *Config 24 | 25 | func NewConfig(filePath string) (*Config, error) { 26 | viper.SetConfigFile(filePath) 27 | 28 | viper.SetEnvPrefix("URL_SHORTENER") 29 | viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) 30 | viper.AutomaticEnv() 31 | 32 | if err := viper.ReadInConfig(); err != nil { 33 | return nil, err 34 | } 35 | 36 | var cfg Config 37 | if err := viper.Unmarshal(&cfg); err != nil { 38 | return nil, err 39 | } 40 | 41 | return &cfg, nil 42 | } 43 | 44 | type DatabaseConfig struct { 45 | Driver string `mapstructure:"driver"` 46 | Host string `mapstructure:"host"` 47 | Port int `mapstructure:"port"` 48 | User string `mapstructure:"user"` 49 | Password string `mapstructure:"password"` 50 | DBName string `mapstructure:"dbname"` 51 | SSLMode string `mapstructure:"ssl_mode"` 52 | MaxIdleConns int `mapstructure:"max_idle_conns"` 53 | MaxOpenConns int `mapstructure:"max_open_conns"` 54 | } 55 | 56 | func (d DatabaseConfig) DSN() string { 57 | return fmt.Sprintf("%s://%s:%s@%s:%d/%s?sslmode=%s", d.Driver, d.User, d.Password, d.Host, d.Port, d.DBName, d.SSLMode) 58 | } 59 | 60 | type LogConfig struct { 61 | Level string `mapstructure:"level"` 62 | } 63 | 64 | type RandNumConfig struct { 65 | Length int `mapstructure:"length"` 66 | } 67 | 68 | type JWTConfig struct { 69 | Secret string `mapstructure:"secret"` 70 | Duration time.Duration `mapstructure:"duration"` 71 | } 72 | 73 | type RedisConfig struct { 74 | Address string `mapstructure:"address"` 75 | Password string `mapstructure:"password"` 76 | DB int `mapstructure:"db"` 77 | UrlDuration time.Duration `mapstructure:"url_duration"` 78 | EmailCodeDuration time.Duration `mapstructure:"email_code_duration"` 79 | } 80 | 81 | type EmailConfig struct { 82 | Password string `mapstructure:"password"` 83 | Username string `mapstructure:"username"` 84 | HostAddress string `mapstructure:"host_address"` 85 | HostPort string `mapstructure:"host_port"` 86 | Subject string `mapstructure:"subject"` 87 | TestMail string `mapstructure:"test_mail"` 88 | } 89 | 90 | type ServerConfig struct { 91 | Addr string `mapstructure:"addr"` 92 | WriteTimeout time.Duration `mapstructure:"write_timeout"` 93 | ReadTimeout time.Duration `mapstructure:"read_timeout"` 94 | } 95 | 96 | type AppConfig struct { 97 | BaseURL string `mapstructure:"base_url"` 98 | DefaultDuration time.Duration `mapstructure:"default_duration"` 99 | SyncViewDuration time.Duration `mapstructure:"sync_view_duration"` 100 | } 101 | 102 | type ShortCodeConfig struct { 103 | Length int `mapstructure:"length"` 104 | } 105 | -------------------------------------------------------------------------------- /config/config.yaml: -------------------------------------------------------------------------------- 1 | database: 2 | driver: postgres 3 | host: localhost 4 | port: 5432 5 | user: lang 6 | password: password 7 | dbname: urldb 8 | ssl_mode: disable 9 | max_idle_conns: 5 10 | max_open_conns: 5 11 | 12 | redis: 13 | address: localhost:6379 14 | password: 15 | db: 0 16 | url_duration: 1h 17 | email_code_duration: 2m 18 | 19 | rand_num: 20 | length: 6 21 | 22 | email: 23 | password: lqiPivBJPLnukHpy 24 | username: MS_VyrzQl@trial-o65qngkw5owgwr12.mlsender.net 25 | host_address: smtp.mailersend.net 26 | host_port: 587 27 | subject: "This is my test mail" 28 | test_mail: "jingyifsy@gmail.com" 29 | 30 | server: 31 | addr: ":8080" 32 | read_timeout: 5s 33 | write_timeout: 5s 34 | 35 | app: 36 | base_url: "http://localhost:8080" 37 | default_duration: 10h 38 | sync_view_duration: 2h 39 | 40 | shortcode: 41 | length: 6 42 | 43 | logger: 44 | level: info 45 | 46 | jwt: 47 | secret: "mycompletedsecret" 48 | duration: 24h 49 | -------------------------------------------------------------------------------- /database/db.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/aeilang/urlshortener/config" 7 | _ "github.com/lib/pq" 8 | ) 9 | 10 | func NewDB(cfg config.DatabaseConfig) (*sql.DB, error) { 11 | db, err := sql.Open(cfg.Driver, cfg.DSN()) 12 | if err != nil { 13 | return nil, err 14 | } 15 | 16 | if err := db.Ping(); err != nil { 17 | return nil, err 18 | } 19 | 20 | return db, nil 21 | } 22 | -------------------------------------------------------------------------------- /database/migrate/000001_init_schema.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS users; 2 | DROP TABLE IF EXISTS urls; 3 | -------------------------------------------------------------------------------- /database/migrate/000001_init_schema.up.sql: -------------------------------------------------------------------------------- 1 | 2 | CREATE EXTENSION IF NOT EXISTS citext; 3 | 4 | CREATE TABLE IF NOT EXISTS users ( 5 | "id" SERIAL PRIMARY KEY, 6 | "email" CITEXT NOT NULL UNIQUE, -- 比较时忽略大小写 7 | "password_hash" TEXT NOT NULL, 8 | "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 9 | "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 10 | ); 11 | 12 | CREATE INDEX idx_email ON users(email); 13 | 14 | 15 | CREATE TABLE IF NOT EXISTS urls ( 16 | "id" BIGSERIAL PRIMARY KEY, 17 | "user_id" INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, 18 | "original_url" TEXT NOT NULL, 19 | "short_code" TEXT NOT NULL UNIQUE, 20 | "is_custom" BOOLEAN NOT NULL DEFAULT FALSE, 21 | "views" INT NOT NULL DEFAULT 0, 22 | "expired_at" TIMESTAMP NOT NULL, 23 | "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 24 | ); 25 | 26 | CREATE INDEX idx_short_code ON urls(short_code); 27 | CREATE INDEX idx_expired_at_short_code ON urls(short_code, expired_at); -- 设置复合索引 28 | 29 | CREATE INDEX idx_user_id ON urls(user_id); 30 | CREATE INDEX idx_expired_at_user_id ON urls(user_id, expired_at); -- 设置复合索引 -------------------------------------------------------------------------------- /database/query/url.sql: -------------------------------------------------------------------------------- 1 | -- name: CreateURL :exec 2 | INSERT INTO urls ( 3 | original_url, 4 | short_code, 5 | is_custom, 6 | expired_at, 7 | user_id 8 | ) VALUES ( 9 | $1, $2, $3, $4, $5 10 | ); 11 | 12 | -- name: IsShortCodeAvailable :one 13 | SELECT NOT EXISTS( 14 | SELECT 1 FROM urls 15 | WHERE short_code = $1 16 | ) AS is_available; 17 | 18 | -- name: GetUrlByShortCode :one 19 | SELECT original_url, short_code, views, is_custom FROM urls 20 | WHERE short_code = $1 21 | AND expired_at > CURRENT_TIMESTAMP; 22 | 23 | 24 | -- name: UpdateViewsByShortCode :exec 25 | UPDATE urls 26 | SET views = views + $1 27 | WHERE short_code = $2; 28 | 29 | -- name: GetURLsByUserID :many 30 | SELECT id, original_url, short_code, views, is_custom, expired_at, COUNT(*) OVER() AS total 31 | FROM urls r 32 | WHERE r.user_id = $1 33 | ORDER BY created_at DESC 34 | LIMIT $2 OFFSET $3; 35 | 36 | -- name: DeleteURLByShortCode :exec 37 | DELETE FROM urls 38 | WHERE short_code = $1; 39 | 40 | -- name: UpdateURLExpiredByShortCode :exec 41 | UPDATE urls 42 | SET expired_at = $1 43 | WHERE short_code = $2; -------------------------------------------------------------------------------- /database/query/user.sql: -------------------------------------------------------------------------------- 1 | -- name: GetUserByEmail :one 2 | SELECT id, password_hash, email 3 | FROM users 4 | WHERE email = $1; 5 | 6 | -- name: IsEmailAvaliable :one 7 | SELECT NOT EXISTS ( 8 | SELECT 1 from users 9 | WHERE email = $1 10 | ); 11 | 12 | -- name: CreateUser :one 13 | INSERT INTO users ( 14 | email, 15 | password_hash 16 | ) VALUES ( 17 | $1, $2 18 | ) RETURNING id,email; 19 | 20 | -- name: UpdatePasswordByEmail :one 21 | UPDATE users 22 | SET password_hash = $1, updated_at = $2 23 | WHERE email = $3 24 | RETURNING id, email; 25 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | 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). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | 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. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | 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. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. 37 | -------------------------------------------------------------------------------- /frontend/app/auth/forgot-password/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { ForgotPasswordForm } from "@/components/auth/forgot-password-form"; 3 | 4 | export default function ForgotPasswordPage() { 5 | return ( 6 |
7 | 8 |
9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /frontend/app/auth/layout.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useRouter } from "next/navigation"; 4 | import { useAuth } from "@/components/context"; 5 | 6 | export default function AuthLayout({ 7 | children, 8 | }: Readonly<{ 9 | children: React.ReactNode; 10 | }>) { 11 | const router = useRouter(); 12 | const { isAuth } = useAuth(); 13 | 14 | if (isAuth) { 15 | router.push("/"); 16 | return; 17 | } 18 | 19 | return <>{children}; 20 | } 21 | -------------------------------------------------------------------------------- /frontend/app/auth/login/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { LoginForm } from "@/components/auth/login-form"; 3 | 4 | export default function LoginPage() { 5 | return ( 6 |
7 | 8 |
9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /frontend/app/auth/register/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { RegisterForm } from "@/components/auth/register-form"; 3 | 4 | export default function RegisterPage() { 5 | return ( 6 |
7 | 8 |
9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /frontend/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aeilang/urlshortener/887dbeba4d99779cd94ba4e6a3ba00d85850d59a/frontend/app/favicon.ico -------------------------------------------------------------------------------- /frontend/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | font-family: Arial, Helvetica, sans-serif; 7 | } 8 | 9 | @layer base { 10 | :root { 11 | --background: 0 0% 100%; 12 | --foreground: 240 10% 3.9%; 13 | --card: 0 0% 100%; 14 | --card-foreground: 240 10% 3.9%; 15 | --popover: 0 0% 100%; 16 | --popover-foreground: 240 10% 3.9%; 17 | --primary: 240 5.9% 10%; 18 | --primary-foreground: 0 0% 98%; 19 | --secondary: 240 4.8% 95.9%; 20 | --secondary-foreground: 240 5.9% 10%; 21 | --muted: 240 4.8% 95.9%; 22 | --muted-foreground: 240 3.8% 46.1%; 23 | --accent: 240 4.8% 95.9%; 24 | --accent-foreground: 240 5.9% 10%; 25 | --destructive: 0 84.2% 60.2%; 26 | --destructive-foreground: 0 0% 98%; 27 | --border: 240 5.9% 90%; 28 | --input: 240 5.9% 90%; 29 | --ring: 240 10% 3.9%; 30 | --chart-1: 12 76% 61%; 31 | --chart-2: 173 58% 39%; 32 | --chart-3: 197 37% 24%; 33 | --chart-4: 43 74% 66%; 34 | --chart-5: 27 87% 67%; 35 | --radius: 0.5rem; 36 | } 37 | .dark { 38 | --background: 240 10% 3.9%; 39 | --foreground: 0 0% 98%; 40 | --card: 240 10% 3.9%; 41 | --card-foreground: 0 0% 98%; 42 | --popover: 240 10% 3.9%; 43 | --popover-foreground: 0 0% 98%; 44 | --primary: 0 0% 98%; 45 | --primary-foreground: 240 5.9% 10%; 46 | --secondary: 240 3.7% 15.9%; 47 | --secondary-foreground: 0 0% 98%; 48 | --muted: 240 3.7% 15.9%; 49 | --muted-foreground: 240 5% 64.9%; 50 | --accent: 240 3.7% 15.9%; 51 | --accent-foreground: 0 0% 98%; 52 | --destructive: 0 62.8% 30.6%; 53 | --destructive-foreground: 0 0% 98%; 54 | --border: 240 3.7% 15.9%; 55 | --input: 240 3.7% 15.9%; 56 | --ring: 240 4.9% 83.9%; 57 | --chart-1: 220 70% 50%; 58 | --chart-2: 160 60% 45%; 59 | --chart-3: 30 80% 55%; 60 | --chart-4: 280 65% 60%; 61 | --chart-5: 340 75% 55%; 62 | } 63 | } 64 | 65 | @layer base { 66 | * { 67 | @apply border-border; 68 | } 69 | body { 70 | @apply bg-background text-foreground; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /frontend/app/help/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useReducer, useState } from "react"; 3 | import { RadioGroupDemo } from "@/components/own/radio"; 4 | import { Button } from "@/components/ui/button"; 5 | import z from "zod"; 6 | 7 | import { 8 | Dialog, 9 | DialogContent, 10 | DialogDescription, 11 | DialogHeader, 12 | DialogTitle, 13 | DialogTrigger, 14 | } from "@/components/ui/dialog"; 15 | 16 | type Action = 17 | | { type: "v1"; value: string } 18 | | { type: "v2"; value: string } 19 | | { type: "v3"; value: string } 20 | | { type: "v4"; value: string } 21 | | { type: "v5"; value: string } 22 | | { type: "v6"; value: string } 23 | | { type: "v7"; value: string } 24 | | { type: "v8"; value: string } 25 | | { type: "v9"; value: string }; 26 | 27 | const answer = z.object({ 28 | v1: z.string().min(1), 29 | v2: z.string().min(1), 30 | v3: z.string().min(1), 31 | v4: z.string().min(1), 32 | v5: z.string().min(1), 33 | v6: z.string().min(1), 34 | v7: z.string().min(1), 35 | v8: z.string().min(1), 36 | v9: z.string().min(1), 37 | }); 38 | 39 | type Answer = z.infer; 40 | 41 | function reducer(state: Answer, action: Action): Answer { 42 | switch (action.type) { 43 | case "v1": 44 | return { ...state, v1: action.value }; 45 | case "v2": 46 | return { ...state, v2: action.value }; 47 | case "v3": 48 | return { ...state, v3: action.value }; 49 | case "v4": 50 | return { ...state, v4: action.value }; 51 | case "v5": 52 | return { ...state, v5: action.value }; 53 | case "v6": 54 | return { ...state, v6: action.value }; 55 | case "v7": 56 | return { ...state, v7: action.value }; 57 | case "v8": 58 | return { ...state, v8: action.value }; 59 | case "v9": 60 | return { ...state, v9: action.value }; 61 | default: 62 | throw new Error("unkown action type"); 63 | } 64 | } 65 | 66 | const Depresss = (score: number): string => { 67 | if (score < 5) { 68 | return "心理健康"; 69 | } else if (score < 10) { 70 | return "轻度抑郁"; 71 | } else if (score < 15) { 72 | return "中度抑郁"; 73 | } else if (score < 20) { 74 | return "重度抑郁"; 75 | } else { 76 | return "严重抑郁"; 77 | } 78 | }; 79 | 80 | export default function HelpPage() { 81 | const [state, dispath] = useReducer(reducer, { 82 | v1: "", 83 | v2: "", 84 | v3: "", 85 | v4: "", 86 | v5: "", 87 | v6: "", 88 | v7: "", 89 | v8: "", 90 | v9: "", 91 | }); 92 | const [score, setScore] = useState(-1); 93 | 94 | const handlerClick = () => { 95 | const { success } = answer.safeParse(state); 96 | if (!success) { 97 | return; 98 | } 99 | 100 | const sum = Object.values(state).reduce((acc, cur) => acc + Number(cur), 0); 101 | setScore(sum); 102 | }; 103 | 104 | return ( 105 |
106 |
107 |
108 |
109 |

PHQ-9 心理健康/抑郁症自测

110 |
111 |
112 |

113 | 在过去的两周 114 | 里,大概有多少天符合下列描述的状况呢? 115 |

116 |
117 | 118 |
119 | 122 | dispath({ type: "v1", value: value }) 123 | } 124 | /> 125 | 128 | dispath({ type: "v2", value: value }) 129 | } 130 | /> 131 | 134 | dispath({ type: "v3", value: value }) 135 | } 136 | /> 137 | 140 | dispath({ type: "v4", value: value }) 141 | } 142 | /> 143 | 146 | dispath({ type: "v5", value: value }) 147 | } 148 | /> 149 | 152 | dispath({ type: "v6", value: value }) 153 | } 154 | /> 155 | 158 | dispath({ type: "v7", value: value }) 159 | } 160 | /> 161 | 164 | dispath({ type: "v8", value: value }) 165 | } 166 | /> 167 | 170 | dispath({ type: "v9", value: value }) 171 | } 172 | /> 173 |
174 | 175 |
176 | 177 | 178 | 179 | 180 | 181 | {score !== -1 && ( 182 | 183 | 184 | 你的测试分数为 185 | {score},结果为 186 | {Depresss(score)} 187 | 188 | 189 | {score >= 5 && ( 190 |
191 |

192 | 别担心,我们只是生病了,就像感冒了一样,这不是我们的错。 193 | 科学用药,向心理医生求助,抑郁症可以被治愈。 194 |

195 |
196 | )} 197 |
198 |

国际心理健康标准:

199 |
    200 |
  • 5-9分为轻度抑郁
  • 201 |
  • 10-14分为中度抑郁
  • 202 |
  • 15-19分为重度抑郁
  • 203 |
  • 大于20分为严重抑郁
  • 204 |
205 |
206 |
207 |
208 | )} 209 | {score === -1 && ( 210 | 211 | 请填完所有问题 212 | 213 | )} 214 |
215 |
216 | 217 | 225 |
226 |
227 |
228 |
229 | ); 230 | } 231 | -------------------------------------------------------------------------------- /frontend/app/layout.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { Geist, Geist_Mono } from "next/font/google"; 3 | import "./globals.css"; 4 | import { ThemeProvider } from "@/components/theme-provider"; 5 | import { Toaster } from "sonner"; 6 | import { AuthProvider } from "@/components/context"; 7 | import { Navbar } from "@/components/nav/navbar"; 8 | 9 | const geistSans = Geist({ 10 | variable: "--font-geist-sans", 11 | subsets: ["latin"], 12 | }); 13 | 14 | const geistMono = Geist_Mono({ 15 | variable: "--font-geist-mono", 16 | subsets: ["latin"], 17 | }); 18 | 19 | export default function RootLayout({ 20 | children, 21 | }: Readonly<{ 22 | children: React.ReactNode; 23 | }>) { 24 | return ( 25 | 26 | 29 | 30 | 31 | 32 | {children} 33 | 34 | 35 | 36 | 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /frontend/app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState, useEffect } from "react"; 4 | import { zodResolver } from "@hookform/resolvers/zod"; 5 | import { useForm } from "react-hook-form"; 6 | import * as z from "zod"; 7 | import { Copy } from "lucide-react"; 8 | 9 | import { Button } from "@/components/ui/button"; 10 | import { 11 | Form, 12 | FormControl, 13 | FormDescription, 14 | FormField, 15 | FormItem, 16 | FormLabel, 17 | FormMessage, 18 | } from "@/components/ui/form"; 19 | import { Input } from "@/components/ui/input"; 20 | 21 | import { toast } from "sonner"; 22 | import { useAuth } from "@/components/context"; 23 | import { useRouter } from "next/navigation"; 24 | 25 | const formSchema = z.object({ 26 | originalUrl: z.string().url({ 27 | message: "请输入一个有效的URL", 28 | }), 29 | customCode: z.string().optional(), 30 | duration: z.string().optional(), 31 | }); 32 | 33 | export default function Home() { 34 | const { token, userID, isAuth } = useAuth(); 35 | const [shortUrl, setShortUrl] = useState(""); 36 | const router = useRouter(); 37 | 38 | useEffect(() => { 39 | if (!isAuth) { 40 | router.push("/auth/login"); 41 | } 42 | }, [router, isAuth]); 43 | 44 | const form = useForm>({ 45 | resolver: zodResolver(formSchema), 46 | defaultValues: { 47 | originalUrl: "", 48 | customCode: "", 49 | }, 50 | }); 51 | 52 | async function onSubmit(values: z.infer) { 53 | try { 54 | const response = await fetch("http://localhost:8080/api/url", { 55 | method: "POST", 56 | headers: { 57 | "Content-Type": "application/json", 58 | Authorization: `Bearer ${token}`, 59 | }, 60 | body: JSON.stringify({ 61 | original_url: values.originalUrl, 62 | custom_code: values.customCode || undefined, 63 | duration: values.duration ? Number(values.duration) : undefined, 64 | user_id: userID, 65 | }), 66 | }); 67 | 68 | if (!response.ok) { 69 | throw new Error("创建失败"); 70 | } 71 | 72 | const data = await response.json(); 73 | setShortUrl(data.short_url); 74 | toast.success("短链接生成完成"); 75 | } catch { 76 | toast.error("出现错误,请重试"); 77 | } 78 | } 79 | 80 | return ( 81 |
82 |
83 |
84 |

85 | 短链接生成器 86 |

87 | 88 |
89 | 93 | ( 97 | 98 | 长链接 99 | 100 | 101 | 102 | 103 | 104 | )} 105 | /> 106 |
107 | ( 111 | 112 | 自定义别名(可选) 113 | 114 | 115 | 116 | 117 | 输入一个4-10个字符的自定义别名 118 | 119 | 120 | 121 | )} 122 | /> 123 | 124 | ( 128 | 129 | 有效时长(可选) 130 | 131 | 132 | 133 | 134 | 135 | )} 136 | /> 137 |
138 | 139 |
140 | 143 |
144 | 145 | 146 |
147 | {shortUrl && ( 148 |
149 |

Your Short URL:

150 |
151 | 156 |

{shortUrl}

157 |
158 | 159 | { 161 | navigator.clipboard.writeText(shortUrl); 162 | toast.success("复制到粘贴板"); 163 | }} 164 | size={15} 165 | className="hover:cursor-pointer hover:shadow-lg hover:scale-105" 166 | /> 167 |
168 |
169 | )} 170 |
171 |
172 | ); 173 | } 174 | -------------------------------------------------------------------------------- /frontend/app/urls/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useCallback, useEffect, useState } from "react"; 4 | import { 5 | Table, 6 | TableBody, 7 | TableCell, 8 | TableHead, 9 | TableHeader, 10 | TableRow, 11 | } from "@/components/ui/table"; 12 | import { 13 | Pagination, 14 | PaginationContent, 15 | PaginationEllipsis, 16 | PaginationItem, 17 | PaginationLink, 18 | PaginationNext, 19 | PaginationPrevious, 20 | } from "@/components/ui/pagination"; 21 | import { base_url } from "@/components/env"; 22 | import { useAuth } from "@/components/context"; 23 | import Link from "next/link"; 24 | import { Button } from "@/components/ui/button"; 25 | import { 26 | Dialog, 27 | DialogContent, 28 | DialogHeader, 29 | DialogTitle, 30 | DialogTrigger, 31 | } from "@/components/ui/dialog"; 32 | import { Input } from "@/components/ui/input"; 33 | import { toast } from "sonner"; 34 | 35 | interface UrlData { 36 | views: number; 37 | original_url: string; 38 | expired_at: string; 39 | short_url: string; 40 | id: number; 41 | } 42 | 43 | export default function MyUrls() { 44 | const [urls, setUrls] = useState([]); 45 | const [currentPage, setCurrentPage] = useState(1); 46 | const [totalPages, setTotalPages] = useState(1); 47 | const [selectedUrl, setSelectedUrl] = useState(null); 48 | const [newExpiryDate, setNewExpiryDate] = useState(""); 49 | const [isUpdateDialogOpen, setIsUpdateDialogOpen] = useState(false); 50 | const pageSize = 10; 51 | const { token } = useAuth(); 52 | 53 | const fetchUrls = useCallback( 54 | async (page: number) => { 55 | try { 56 | const response = await fetch( 57 | `${base_url}/api/urls?page=${page}&size=${pageSize}`, 58 | { 59 | method: "GET", 60 | headers: { 61 | Authorization: `Bearer ${token}`, 62 | }, 63 | } 64 | ); 65 | 66 | const data = await response.json(); 67 | setUrls(data.items || []); 68 | setTotalPages(Math.ceil((data.total || 0) / pageSize)); 69 | } catch (error) { 70 | console.error("Failed to fetch URLs:", error); 71 | } 72 | }, 73 | [token, pageSize] 74 | ); 75 | 76 | const handleDelete = async (shortUrl: string) => { 77 | const code = shortUrl.split("/").pop(); 78 | try { 79 | const response = await fetch(`${base_url}/api/url/${code}`, { 80 | method: "DELETE", 81 | headers: { 82 | Authorization: `Bearer ${token}`, 83 | }, 84 | }); 85 | 86 | if (response.ok) { 87 | toast.success("短链接已删除"); 88 | fetchUrls(currentPage); 89 | } else { 90 | toast.error("删除失败"); 91 | } 92 | } catch (error) { 93 | toast.error("删除失败"); 94 | console.error("Failed to delete URL:", error); 95 | } 96 | }; 97 | 98 | const handleUpdate = async (shortUrl: string, newExpiredAt: string) => { 99 | const code = shortUrl.split("/").pop(); 100 | try { 101 | const response = await fetch(`${base_url}/api/url/${code}`, { 102 | method: "PATCH", 103 | headers: { 104 | Authorization: `Bearer ${token}`, 105 | "Content-Type": "application/json", 106 | }, 107 | body: JSON.stringify({ 108 | expired_at: new Date(newExpiredAt).toISOString(), 109 | }), 110 | }); 111 | 112 | if (response.ok) { 113 | toast.success("过期时间已更新"); 114 | fetchUrls(currentPage); 115 | setIsUpdateDialogOpen(false); 116 | } else { 117 | toast.error("更新失败"); 118 | } 119 | } catch (error) { 120 | toast.error("更新失败"); 121 | console.error("Failed to update URL:", error); 122 | } 123 | }; 124 | 125 | useEffect(() => { 126 | fetchUrls(currentPage); 127 | }, [currentPage, fetchUrls]); 128 | 129 | return ( 130 |
131 |

我的短链接

132 |
133 | 134 | 135 | 136 | ID 137 | 短链接 138 | 原始链接 139 | 访问次数 140 | 过期时间 141 | 操作 142 | 143 | 144 | 145 | {urls.map((url) => ( 146 | 147 | {url.id} 148 | 149 | 150 | {url.short_url} 151 | 152 | 153 | 154 | 155 | {url.original_url} 156 | 157 | 158 | {url.views} 159 | 160 | {new Date(url.expired_at).toLocaleString("zh-CN", { 161 | year: "numeric", 162 | month: "2-digit", 163 | day: "2-digit", 164 | hour: "2-digit", 165 | minute: "2-digit", 166 | second: "2-digit", 167 | hour12: false, 168 | })} 169 | 170 | 171 |
172 | { 175 | setIsUpdateDialogOpen(open); 176 | if (!open) { 177 | setSelectedUrl(null); 178 | setNewExpiryDate(""); 179 | } 180 | }} 181 | > 182 | 183 | 210 | 211 | 212 | 213 | 更新过期时间 214 | 215 |
216 | setNewExpiryDate(e.target.value)} 220 | /> 221 | 228 |
229 |
230 |
231 | 238 |
239 |
240 |
241 | ))} 242 |
243 |
244 |
245 |
246 | 247 | 248 | 249 | setCurrentPage((p) => Math.max(1, p - 1))} 251 | /> 252 | 253 | 254 | {/* First page */} 255 | {totalPages > 0 && ( 256 | 257 | setCurrentPage(1)} 259 | isActive={currentPage === 1} 260 | > 261 | 1 262 | 263 | 264 | )} 265 | 266 | {/* Left ellipsis */} 267 | {currentPage > 3 && ( 268 | 269 | 270 | 271 | )} 272 | 273 | {/* Middle pages */} 274 | {Array.from({ length: totalPages }, (_, i) => i + 1) 275 | .filter((page) => { 276 | if (totalPages <= 7) return true; 277 | if (page === 1 || page === totalPages) return false; 278 | return Math.abs(currentPage - page) <= 1; 279 | }) 280 | .map((page) => ( 281 | 282 | setCurrentPage(page)} 284 | isActive={currentPage === page} 285 | > 286 | {page} 287 | 288 | 289 | ))} 290 | 291 | {/* Right ellipsis */} 292 | {currentPage < totalPages - 2 && ( 293 | 294 | 295 | 296 | )} 297 | 298 | {/* Last page */} 299 | {totalPages > 1 && ( 300 | 301 | setCurrentPage(totalPages)} 303 | isActive={currentPage === totalPages} 304 | > 305 | {totalPages} 306 | 307 | 308 | )} 309 | 310 | 311 | 313 | setCurrentPage((p) => Math.min(totalPages, p + 1)) 314 | } 315 | /> 316 | 317 | 318 | 319 |
320 |
321 | ); 322 | } 323 | -------------------------------------------------------------------------------- /frontend/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /frontend/components/auth/forgot-password-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { useRouter } from "next/navigation"; 5 | import { useForm } from "react-hook-form"; 6 | import { zodResolver } from "@hookform/resolvers/zod"; 7 | import * as z from "zod"; 8 | import Link from "next/link"; 9 | import { toast } from "sonner"; 10 | 11 | import { Button } from "@/components/ui/button"; 12 | import { Input } from "@/components/ui/input"; 13 | import { 14 | Form, 15 | FormControl, 16 | FormField, 17 | FormItem, 18 | FormLabel, 19 | FormMessage, 20 | } from "@/components/ui/form"; 21 | import { forgotPasswordSchema } from "@/lib/validations/auth"; 22 | import { base_url } from "../env"; 23 | import { useAuth } from "../context"; 24 | import { Loading } from "../loading"; 25 | 26 | type FormData = z.infer; 27 | 28 | export function ForgotPasswordForm() { 29 | const router = useRouter(); 30 | const [isSendingCode, setIsSendingCode] = useState(false); 31 | const [countdown, setCountdown] = useState(0); 32 | const { setAuth } = useAuth(); 33 | 34 | const form = useForm({ 35 | resolver: zodResolver(forgotPasswordSchema), 36 | defaultValues: { 37 | email: "", 38 | verificationCode: "", 39 | password: "", 40 | confirmPassword: "", 41 | }, 42 | }); 43 | 44 | async function sendVerificationCode(email: string) { 45 | if (!email) { 46 | toast.error("请先输入邮箱"); 47 | return; 48 | } 49 | 50 | setIsSendingCode(true); 51 | try { 52 | const response = await fetch(`${base_url}/api/auth/register/${email}`, { 53 | method: "GET", 54 | }); 55 | 56 | const payload = await response.json(); 57 | 58 | if (!response.ok) { 59 | toast.error(payload?.message); 60 | return; 61 | } 62 | 63 | toast.success("验证码已发送到您的邮箱"); 64 | setCountdown(60); 65 | const timer = setInterval(() => { 66 | setCountdown((prev) => { 67 | if (prev <= 1) { 68 | clearInterval(timer); 69 | return 0; 70 | } 71 | return prev - 1; 72 | }); 73 | }, 1000); 74 | } catch { 75 | toast.error("发送验证码失败,请稍后重试"); 76 | } finally { 77 | setIsSendingCode(false); 78 | } 79 | } 80 | 81 | async function onSubmit(data: FormData) { 82 | try { 83 | const response = await fetch(`${base_url}/api/auth/forget`, { 84 | method: "POST", 85 | headers: { "Content-Type": "application/json" }, 86 | body: JSON.stringify({ 87 | email: data.email, 88 | password: data.password, 89 | email_code: data.verificationCode, 90 | }), 91 | }); 92 | 93 | const payload = await response.json(); 94 | 95 | if (!response.ok) { 96 | toast.error(payload?.message); 97 | return; 98 | } 99 | 100 | setAuth(payload?.access_token, payload?.email, payload?.user_id); 101 | 102 | router.push("/"); 103 | toast.success("密码重置成功"); 104 | } catch { 105 | toast.error("密码重置失败,请稍后重试"); 106 | } 107 | } 108 | 109 | return ( 110 |
111 |
112 |

重置密码

113 |

114 | 请输入您的邮箱,我们将发送验证码帮助您重置密码 115 |

116 |
117 |
118 | 119 | ( 123 | 124 | 邮箱 125 | 126 | 127 | 128 | 129 | 130 | )} 131 | /> 132 | ( 136 | 137 | 验证码 138 |
139 | 140 | 141 | 142 | 161 |
162 | 163 |
164 | )} 165 | /> 166 | ( 170 | 171 | 新密码 172 | 173 | 174 | 175 | 176 | 177 | )} 178 | /> 179 | ( 183 | 184 | 确认新密码 185 | 186 | 187 | 188 | 189 | 190 | )} 191 | /> 192 | 199 | 200 | 201 |
202 | 206 | 返回登录 207 | 208 |
209 |
210 | ); 211 | } 212 | -------------------------------------------------------------------------------- /frontend/components/auth/login-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useRouter } from "next/navigation"; 4 | import { useForm } from "react-hook-form"; 5 | import { zodResolver } from "@hookform/resolvers/zod"; 6 | import * as z from "zod"; 7 | import Link from "next/link"; 8 | import { toast } from "sonner"; 9 | 10 | import { Button } from "@/components/ui/button"; 11 | import { Input } from "@/components/ui/input"; 12 | import { 13 | Form, 14 | FormControl, 15 | FormField, 16 | FormItem, 17 | FormLabel, 18 | FormMessage, 19 | } from "@/components/ui/form"; 20 | import { loginSchema } from "@/lib/validations/auth"; 21 | import { base_url } from "../env"; 22 | import { useAuth } from "../context"; 23 | import { Loading } from "../loading"; 24 | 25 | type FormData = z.infer; 26 | 27 | export function LoginForm() { 28 | const router = useRouter(); 29 | const { setAuth } = useAuth(); 30 | const form = useForm({ 31 | resolver: zodResolver(loginSchema), 32 | defaultValues: { 33 | email: "", 34 | password: "", 35 | }, 36 | }); 37 | 38 | async function onSubmit(data: FormData) { 39 | try { 40 | const response = await fetch(base_url + "/api/auth/login", { 41 | method: "POST", 42 | headers: { "Content-Type": "application/json" }, 43 | body: JSON.stringify(data), 44 | }); 45 | 46 | const payload = await response.json(); 47 | 48 | if (!response.ok) { 49 | toast.error(payload?.message); 50 | return; 51 | } 52 | 53 | setAuth(payload?.access_token, payload?.email, payload?.user_id); 54 | 55 | router.push("/"); 56 | toast.success("登录成功"); 57 | } catch { 58 | toast.error("服务请出错请重试"); 59 | } 60 | } 61 | 62 | return ( 63 |
64 |
65 |

登录

66 |

欢迎回来

67 |
68 |
69 | 70 | ( 74 | 75 | 邮箱 76 | 77 | 78 | 79 | 80 | 81 | )} 82 | /> 83 | ( 87 | 88 | 密码 89 | 90 | 91 | 92 | 93 | 94 | )} 95 | /> 96 |
97 | 101 | 忘记密码? 102 | 103 |
104 | 111 | 112 | 113 |
114 |
115 | 还没有账号?{" "} 116 | 117 | 立即注册 118 | 119 |
120 |
121 |
122 | ); 123 | } 124 | -------------------------------------------------------------------------------- /frontend/components/auth/register-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { useRouter } from "next/navigation"; 5 | import { useForm } from "react-hook-form"; 6 | import { zodResolver } from "@hookform/resolvers/zod"; 7 | import * as z from "zod"; 8 | import Link from "next/link"; 9 | import { toast } from "sonner"; 10 | 11 | import { Button } from "@/components/ui/button"; 12 | import { Input } from "@/components/ui/input"; 13 | import { 14 | Form, 15 | FormControl, 16 | FormField, 17 | FormItem, 18 | FormLabel, 19 | FormMessage, 20 | } from "@/components/ui/form"; 21 | import { registerSchema } from "@/lib/validations/auth"; 22 | import { base_url } from "../env"; 23 | import { useAuth } from "../context"; 24 | import { Loading } from "../loading"; 25 | 26 | type FormData = z.infer; 27 | 28 | export function RegisterForm() { 29 | const router = useRouter(); 30 | const [isSendingCode, setIsSendingCode] = useState(false); 31 | const [countdown, setCountdown] = useState(0); 32 | const { setAuth } = useAuth(); 33 | 34 | const form = useForm({ 35 | resolver: zodResolver(registerSchema), 36 | defaultValues: { 37 | email: "", 38 | verificationCode: "", 39 | password: "", 40 | confirmPassword: "", 41 | }, 42 | }); 43 | 44 | async function sendVerificationCode(email: string) { 45 | if (!email) { 46 | toast.error("请先输入邮箱"); 47 | return; 48 | } 49 | 50 | setIsSendingCode(true); 51 | try { 52 | const response = await fetch(`${base_url}/api/auth/register/${email}`, { 53 | method: "GET", 54 | }); 55 | 56 | if (!response.ok) { 57 | throw new Error("Failed to send code"); 58 | } 59 | 60 | toast.success("验证码已发送到您的邮箱"); 61 | setCountdown(60); 62 | const timer = setInterval(() => { 63 | setCountdown((prev) => { 64 | if (prev <= 1) { 65 | clearInterval(timer); 66 | return 0; 67 | } 68 | return prev - 1; 69 | }); 70 | }, 1000); 71 | } catch { 72 | toast.error("发送验证码失败,请稍后重试"); 73 | } finally { 74 | setIsSendingCode(false); 75 | } 76 | } 77 | 78 | async function onSubmit(data: FormData) { 79 | try { 80 | const response = await fetch(base_url + "/api/auth/register", { 81 | method: "POST", 82 | headers: { "Content-Type": "application/json" }, 83 | body: JSON.stringify({ 84 | email: data.email, 85 | password: data.password, 86 | email_code: data.verificationCode, 87 | }), 88 | }); 89 | 90 | const payload = await response.json(); 91 | 92 | if (!response.ok) { 93 | toast.error(payload?.message); 94 | return; 95 | } 96 | 97 | setAuth(payload?.access_token, payload?.email, payload?.user_id); 98 | 99 | router.push("/"); 100 | toast.success("注册成功"); 101 | } catch { 102 | toast.error("注册失败,请稍后重试"); 103 | } 104 | } 105 | 106 | return ( 107 |
108 |
109 |

注册

110 |

创建您的账号

111 |
112 |
113 | 114 | ( 118 | 119 | 邮箱 120 | 121 | 122 | 123 | 124 | 125 | )} 126 | /> 127 | ( 131 | 132 | 验证码 133 |
134 | 135 | 136 | 137 | 156 |
157 | 158 |
159 | )} 160 | /> 161 | ( 165 | 166 | 密码 167 | 168 | 169 | 170 | 171 | 172 | )} 173 | /> 174 | ( 178 | 179 | 确认密码 180 | 181 | 182 | 183 | 184 | 185 | )} 186 | /> 187 | 194 | 195 | 196 |
197 |
198 | 已有账号?{" "} 199 | 200 | 立即登录 201 | 202 |
203 |
204 |
205 | ); 206 | } 207 | -------------------------------------------------------------------------------- /frontend/components/context.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { createContext, use } from "react"; 4 | import { isTokenExpired } from "./token"; 5 | import useLocalStorage from "@/hooks/use-localstorage"; 6 | 7 | type AuthProviderProps = { 8 | children: React.ReactNode; 9 | }; 10 | 11 | type AuthProviderState = { 12 | token: string; 13 | email: string; 14 | isAuth: boolean; 15 | userID: number; 16 | setAuth: (token: string, email: string, userID: number) => void; 17 | }; 18 | 19 | const AuthProviderContext = createContext({ 20 | token: "", 21 | email: "", 22 | userID: 0, 23 | isAuth: false, 24 | setAuth: () => null, 25 | }); 26 | 27 | export function AuthProvider({ children }: AuthProviderProps) { 28 | const [email, setEmail] = useLocalStorage("email", ""); 29 | const [token, setToken] = useLocalStorage("token", ""); 30 | const [userID, setUserID] = useLocalStorage("user_id", ""); 31 | const user_id = parseInt(userID); 32 | 33 | const isAuth = !isTokenExpired(token) && user_id !== 0 && email !== ""; 34 | 35 | const value = { 36 | token: token, 37 | email: email, 38 | userID: user_id, 39 | isAuth: isAuth, 40 | setAuth: (token: string, email: string, userID: number) => { 41 | setToken(token); 42 | setEmail(email); 43 | setUserID(String(userID)); 44 | }, 45 | }; 46 | 47 | return {children}; 48 | } 49 | 50 | export const useAuth = () => { 51 | const context = use(AuthProviderContext); 52 | 53 | if (context === undefined) { 54 | throw new Error("useAuth must be use within a AuthProvider"); 55 | } 56 | 57 | return context; 58 | }; 59 | -------------------------------------------------------------------------------- /frontend/components/env.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | export const base_url = process.env.NEXT_PUBLIC_API_URL; 3 | -------------------------------------------------------------------------------- /frontend/components/loading.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { Loader2Icon } from "lucide-react"; 3 | 4 | export const Loading = () => { 5 | return ( 6 |
7 | 8 |
9 | ); 10 | }; 11 | -------------------------------------------------------------------------------- /frontend/components/logout.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { LogOutIcon } from "lucide-react"; 3 | import { useAuth } from "./context"; 4 | import { Button } from "./ui/button"; 5 | import { useRouter } from "next/navigation"; 6 | 7 | export default function Logout() { 8 | const { setAuth } = useAuth(); 9 | const router = useRouter(); 10 | 11 | return ( 12 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /frontend/components/mode-toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { Moon, Sun } from "lucide-react" 5 | import { useTheme } from "next-themes" 6 | 7 | import { Button } from "@/components/ui/button" 8 | import { 9 | DropdownMenu, 10 | DropdownMenuContent, 11 | DropdownMenuItem, 12 | DropdownMenuTrigger, 13 | } from "@/components/ui/dropdown-menu" 14 | 15 | export function ModeToggle() { 16 | const { setTheme } = useTheme() 17 | 18 | return ( 19 | 20 | 21 | 26 | 27 | 28 | setTheme("light")}> 29 | Light 30 | 31 | setTheme("dark")}> 32 | Dark 33 | 34 | setTheme("system")}> 35 | System 36 | 37 | 38 | 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /frontend/components/nav/navbar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import { usePathname } from "next/navigation"; 5 | import { cn } from "@/lib/utils"; 6 | import { useAuth } from "../context"; 7 | import Logout from "../logout"; 8 | import { ModeToggle } from "../mode-toggle"; 9 | 10 | export function Navbar() { 11 | const pathname = usePathname(); 12 | const { email } = useAuth(); 13 | 14 | // Hide navbar on auth pages 15 | if (pathname?.startsWith("/auth")) { 16 | return null; 17 | } 18 | 19 | const navItems = [ 20 | { 21 | name: "创建短链接", 22 | href: "/", 23 | }, 24 | { 25 | name: "我的短链接", 26 | href: "/urls", 27 | }, 28 | { 29 | name: "抑郁症自测", 30 | href: "/help", 31 | }, 32 | ]; 33 | 34 | return ( 35 | 60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /frontend/components/own/radio.tsx: -------------------------------------------------------------------------------- 1 | import { Label } from "@/components/ui/label"; 2 | import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; 3 | import { useId } from "react"; 4 | 5 | export function RadioGroupDemo({ 6 | title, 7 | onValueChange, 8 | }: { 9 | title: string; 10 | onValueChange: (value: string) => void; 11 | }) { 12 | const r1 = useId(); 13 | const r2 = useId(); 14 | const r3 = useId(); 15 | const r4 = useId(); 16 | 17 | return ( 18 |
19 |

{title}

20 | 21 |
22 | 23 | 24 |
25 |
26 | 27 | 28 |
29 |
30 | 31 | 32 |
33 |
34 | 35 | 36 |
37 |
38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /frontend/components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { ThemeProvider as NextThemesProvider } from "next-themes" 5 | 6 | export function ThemeProvider({ 7 | children, 8 | ...props 9 | }: React.ComponentProps) { 10 | return {children} 11 | } 12 | -------------------------------------------------------------------------------- /frontend/components/token.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { jwtDecode } from "jwt-decode"; 3 | 4 | export function isTokenExpired(token: string | null) { 5 | try { 6 | if (!token) { 7 | return true; 8 | } 9 | 10 | const pay = jwtDecode(token); 11 | 12 | if (!pay) { 13 | return true; 14 | } 15 | const currentTime = Math.floor(Date.now() / 1000); // s 16 | if (pay.exp && pay.exp > currentTime) { 17 | return false; 18 | } 19 | 20 | return true; 21 | } catch { 22 | return true; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /frontend/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | 52 | ) 53 | } 54 | ) 55 | Button.displayName = "Button" 56 | 57 | export { Button, buttonVariants } 58 | -------------------------------------------------------------------------------- /frontend/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLDivElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |
41 | )) 42 | CardTitle.displayName = "CardTitle" 43 | 44 | const CardDescription = React.forwardRef< 45 | HTMLDivElement, 46 | React.HTMLAttributes 47 | >(({ className, ...props }, ref) => ( 48 |
53 | )) 54 | CardDescription.displayName = "CardDescription" 55 | 56 | const CardContent = React.forwardRef< 57 | HTMLDivElement, 58 | React.HTMLAttributes 59 | >(({ className, ...props }, ref) => ( 60 |
61 | )) 62 | CardContent.displayName = "CardContent" 63 | 64 | const CardFooter = React.forwardRef< 65 | HTMLDivElement, 66 | React.HTMLAttributes 67 | >(({ className, ...props }, ref) => ( 68 |
73 | )) 74 | CardFooter.displayName = "CardFooter" 75 | 76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 77 | -------------------------------------------------------------------------------- /frontend/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox" 5 | import { Check } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Checkbox = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => ( 13 | 21 | 24 | 25 | 26 | 27 | )) 28 | Checkbox.displayName = CheckboxPrimitive.Root.displayName 29 | 30 | export { Checkbox } 31 | -------------------------------------------------------------------------------- /frontend/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DialogPrimitive from "@radix-ui/react-dialog" 5 | import { X } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Dialog = DialogPrimitive.Root 10 | 11 | const DialogTrigger = DialogPrimitive.Trigger 12 | 13 | const DialogPortal = DialogPrimitive.Portal 14 | 15 | const DialogClose = DialogPrimitive.Close 16 | 17 | const DialogOverlay = React.forwardRef< 18 | React.ElementRef, 19 | React.ComponentPropsWithoutRef 20 | >(({ className, ...props }, ref) => ( 21 | 29 | )) 30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 31 | 32 | const DialogContent = React.forwardRef< 33 | React.ElementRef, 34 | React.ComponentPropsWithoutRef 35 | >(({ className, children, ...props }, ref) => ( 36 | 37 | 38 | 46 | {children} 47 | 48 | 49 | Close 50 | 51 | 52 | 53 | )) 54 | DialogContent.displayName = DialogPrimitive.Content.displayName 55 | 56 | const DialogHeader = ({ 57 | className, 58 | ...props 59 | }: React.HTMLAttributes) => ( 60 |
67 | ) 68 | DialogHeader.displayName = "DialogHeader" 69 | 70 | const DialogFooter = ({ 71 | className, 72 | ...props 73 | }: React.HTMLAttributes) => ( 74 |
81 | ) 82 | DialogFooter.displayName = "DialogFooter" 83 | 84 | const DialogTitle = React.forwardRef< 85 | React.ElementRef, 86 | React.ComponentPropsWithoutRef 87 | >(({ className, ...props }, ref) => ( 88 | 96 | )) 97 | DialogTitle.displayName = DialogPrimitive.Title.displayName 98 | 99 | const DialogDescription = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | DialogDescription.displayName = DialogPrimitive.Description.displayName 110 | 111 | export { 112 | Dialog, 113 | DialogPortal, 114 | DialogOverlay, 115 | DialogTrigger, 116 | DialogClose, 117 | DialogContent, 118 | DialogHeader, 119 | DialogFooter, 120 | DialogTitle, 121 | DialogDescription, 122 | } 123 | -------------------------------------------------------------------------------- /frontend/components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" 5 | import { Check, ChevronRight, Circle } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const DropdownMenu = DropdownMenuPrimitive.Root 10 | 11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger 12 | 13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group 14 | 15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal 16 | 17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub 18 | 19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup 20 | 21 | const DropdownMenuSubTrigger = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef & { 24 | inset?: boolean 25 | } 26 | >(({ className, inset, children, ...props }, ref) => ( 27 | 36 | {children} 37 | 38 | 39 | )) 40 | DropdownMenuSubTrigger.displayName = 41 | DropdownMenuPrimitive.SubTrigger.displayName 42 | 43 | const DropdownMenuSubContent = React.forwardRef< 44 | React.ElementRef, 45 | React.ComponentPropsWithoutRef 46 | >(({ className, ...props }, ref) => ( 47 | 55 | )) 56 | DropdownMenuSubContent.displayName = 57 | DropdownMenuPrimitive.SubContent.displayName 58 | 59 | const DropdownMenuContent = React.forwardRef< 60 | React.ElementRef, 61 | React.ComponentPropsWithoutRef 62 | >(({ className, sideOffset = 4, ...props }, ref) => ( 63 | 64 | 74 | 75 | )) 76 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName 77 | 78 | const DropdownMenuItem = React.forwardRef< 79 | React.ElementRef, 80 | React.ComponentPropsWithoutRef & { 81 | inset?: boolean 82 | } 83 | >(({ className, inset, ...props }, ref) => ( 84 | svg]:size-4 [&>svg]:shrink-0", 88 | inset && "pl-8", 89 | className 90 | )} 91 | {...props} 92 | /> 93 | )) 94 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName 95 | 96 | const DropdownMenuCheckboxItem = React.forwardRef< 97 | React.ElementRef, 98 | React.ComponentPropsWithoutRef 99 | >(({ className, children, checked, ...props }, ref) => ( 100 | 109 | 110 | 111 | 112 | 113 | 114 | {children} 115 | 116 | )) 117 | DropdownMenuCheckboxItem.displayName = 118 | DropdownMenuPrimitive.CheckboxItem.displayName 119 | 120 | const DropdownMenuRadioItem = React.forwardRef< 121 | React.ElementRef, 122 | React.ComponentPropsWithoutRef 123 | >(({ className, children, ...props }, ref) => ( 124 | 132 | 133 | 134 | 135 | 136 | 137 | {children} 138 | 139 | )) 140 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName 141 | 142 | const DropdownMenuLabel = React.forwardRef< 143 | React.ElementRef, 144 | React.ComponentPropsWithoutRef & { 145 | inset?: boolean 146 | } 147 | >(({ className, inset, ...props }, ref) => ( 148 | 157 | )) 158 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName 159 | 160 | const DropdownMenuSeparator = React.forwardRef< 161 | React.ElementRef, 162 | React.ComponentPropsWithoutRef 163 | >(({ className, ...props }, ref) => ( 164 | 169 | )) 170 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName 171 | 172 | const DropdownMenuShortcut = ({ 173 | className, 174 | ...props 175 | }: React.HTMLAttributes) => { 176 | return ( 177 | 181 | ) 182 | } 183 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut" 184 | 185 | export { 186 | DropdownMenu, 187 | DropdownMenuTrigger, 188 | DropdownMenuContent, 189 | DropdownMenuItem, 190 | DropdownMenuCheckboxItem, 191 | DropdownMenuRadioItem, 192 | DropdownMenuLabel, 193 | DropdownMenuSeparator, 194 | DropdownMenuShortcut, 195 | DropdownMenuGroup, 196 | DropdownMenuPortal, 197 | DropdownMenuSub, 198 | DropdownMenuSubContent, 199 | DropdownMenuSubTrigger, 200 | DropdownMenuRadioGroup, 201 | } 202 | -------------------------------------------------------------------------------- /frontend/components/ui/form.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { Slot } from "@radix-ui/react-slot" 6 | import { 7 | Controller, 8 | ControllerProps, 9 | FieldPath, 10 | FieldValues, 11 | FormProvider, 12 | useFormContext, 13 | } from "react-hook-form" 14 | 15 | import { cn } from "@/lib/utils" 16 | import { Label } from "@/components/ui/label" 17 | 18 | const Form = FormProvider 19 | 20 | type FormFieldContextValue< 21 | TFieldValues extends FieldValues = FieldValues, 22 | TName extends FieldPath = FieldPath 23 | > = { 24 | name: TName 25 | } 26 | 27 | const FormFieldContext = React.createContext( 28 | {} as FormFieldContextValue 29 | ) 30 | 31 | const FormField = < 32 | TFieldValues extends FieldValues = FieldValues, 33 | TName extends FieldPath = FieldPath 34 | >({ 35 | ...props 36 | }: ControllerProps) => { 37 | return ( 38 | 39 | 40 | 41 | ) 42 | } 43 | 44 | const useFormField = () => { 45 | const fieldContext = React.useContext(FormFieldContext) 46 | const itemContext = React.useContext(FormItemContext) 47 | const { getFieldState, formState } = useFormContext() 48 | 49 | const fieldState = getFieldState(fieldContext.name, formState) 50 | 51 | if (!fieldContext) { 52 | throw new Error("useFormField should be used within ") 53 | } 54 | 55 | const { id } = itemContext 56 | 57 | return { 58 | id, 59 | name: fieldContext.name, 60 | formItemId: `${id}-form-item`, 61 | formDescriptionId: `${id}-form-item-description`, 62 | formMessageId: `${id}-form-item-message`, 63 | ...fieldState, 64 | } 65 | } 66 | 67 | type FormItemContextValue = { 68 | id: string 69 | } 70 | 71 | const FormItemContext = React.createContext( 72 | {} as FormItemContextValue 73 | ) 74 | 75 | const FormItem = React.forwardRef< 76 | HTMLDivElement, 77 | React.HTMLAttributes 78 | >(({ className, ...props }, ref) => { 79 | const id = React.useId() 80 | 81 | return ( 82 | 83 |
84 | 85 | ) 86 | }) 87 | FormItem.displayName = "FormItem" 88 | 89 | const FormLabel = React.forwardRef< 90 | React.ElementRef, 91 | React.ComponentPropsWithoutRef 92 | >(({ className, ...props }, ref) => { 93 | const { error, formItemId } = useFormField() 94 | 95 | return ( 96 |