feat: 实现前端卡密管理界面
- 卡密列表展示与分页功能 - 单个/批量创建卡密 - 卡密删除与批量删除 - 卡密导出功能 (file-saver) - 启用/禁用状态切换 - 状态判断 (有效/已使用/已失效) - Toast 通知系统 (vue-sonner) - 登录页面错误提示优化 - 后端登录错误消息中文化
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -37,3 +37,8 @@ logs/
|
|||||||
# Test coverage
|
# Test coverage
|
||||||
coverage.out
|
coverage.out
|
||||||
coverage.html
|
coverage.html
|
||||||
|
|
||||||
|
|
||||||
|
# 开发文档
|
||||||
|
document/
|
||||||
|
database_schema.md
|
||||||
@@ -21,8 +21,6 @@ func Migrate(db *sql.DB) error {
|
|||||||
createCardKeysTable,
|
createCardKeysTable,
|
||||||
createInvitationsTable,
|
createInvitationsTable,
|
||||||
createAPIKeysTable,
|
createAPIKeysTable,
|
||||||
createSystemSettingsTable,
|
|
||||||
insertDefaultSettings,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, migration := range migrations {
|
for i, migration := range migrations {
|
||||||
@@ -177,28 +175,3 @@ CREATE TABLE IF NOT EXISTS api_keys (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_api_keys_key ON api_keys(key);
|
CREATE INDEX IF NOT EXISTS idx_api_keys_key ON api_keys(key);
|
||||||
CREATE INDEX IF NOT EXISTS idx_api_keys_is_active ON api_keys(is_active);
|
CREATE INDEX IF NOT EXISTS idx_api_keys_is_active ON api_keys(is_active);
|
||||||
`
|
`
|
||||||
|
|
||||||
const createSystemSettingsTable = `
|
|
||||||
CREATE TABLE IF NOT EXISTS system_settings (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
key VARCHAR(100) NOT NULL UNIQUE,
|
|
||||||
value TEXT NOT NULL,
|
|
||||||
value_type VARCHAR(20) NOT NULL DEFAULT 'string',
|
|
||||||
description VARCHAR(255),
|
|
||||||
updated_at TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_system_settings_key ON system_settings(key);
|
|
||||||
`
|
|
||||||
|
|
||||||
const insertDefaultSettings = `
|
|
||||||
INSERT INTO system_settings (key, value, value_type, description) VALUES
|
|
||||||
('turnstile_enabled', 'false', 'bool', 'Cloudflare Turnstile 开关'),
|
|
||||||
('turnstile_site_key', '', 'string', 'Turnstile Site Key'),
|
|
||||||
('turnstile_secret_key', '', 'string', 'Turnstile Secret Key'),
|
|
||||||
('token_check_interval', '6', 'int', 'Token 检测间隔(小时)'),
|
|
||||||
('token_failure_threshold', '2', 'int', '连续失败禁用阈值'),
|
|
||||||
('invitation_validity_days', '30', 'int', '邀请有效期(天)'),
|
|
||||||
('site_title', 'ChatGPT Team 邀请', 'string', '站点标题')
|
|
||||||
ON CONFLICT (key) DO NOTHING;
|
|
||||||
`
|
|
||||||
@@ -81,7 +81,7 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
|||||||
if admin == nil {
|
if admin == nil {
|
||||||
respondJSON(w, http.StatusUnauthorized, LoginResponse{
|
respondJSON(w, http.StatusUnauthorized, LoginResponse{
|
||||||
Success: false,
|
Success: false,
|
||||||
Message: "Invalid username or password",
|
Message: "用户名或密码错误",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -90,7 +90,7 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !admin.IsActive {
|
if !admin.IsActive {
|
||||||
respondJSON(w, http.StatusForbidden, LoginResponse{
|
respondJSON(w, http.StatusForbidden, LoginResponse{
|
||||||
Success: false,
|
Success: false,
|
||||||
Message: "Account is disabled",
|
Message: "账号已被禁用",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -99,7 +99,7 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !auth.CheckPassword(req.Password, admin.PasswordHash) {
|
if !auth.CheckPassword(req.Password, admin.PasswordHash) {
|
||||||
respondJSON(w, http.StatusUnauthorized, LoginResponse{
|
respondJSON(w, http.StatusUnauthorized, LoginResponse{
|
||||||
Success: false,
|
Success: false,
|
||||||
Message: "Invalid username or password",
|
Message: "用户名或密码错误",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -42,6 +43,9 @@ type CardKeyResponse struct {
|
|||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Data *models.CardKey `json:"data,omitempty"`
|
Data *models.CardKey `json:"data,omitempty"`
|
||||||
Keys []*models.CardKey `json:"keys,omitempty"`
|
Keys []*models.CardKey `json:"keys,omitempty"`
|
||||||
|
Total int `json:"total,omitempty"`
|
||||||
|
Page int `json:"page,omitempty"`
|
||||||
|
PageSize int `json:"page_size,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle 处理卡密接口 (GET: 列表, POST: 创建)
|
// Handle 处理卡密接口 (GET: 列表, POST: 创建)
|
||||||
@@ -187,8 +191,36 @@ func (h *CardKeyHandler) BatchCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// list 获取卡密列表
|
// list 获取卡密列表
|
||||||
func (h *CardKeyHandler) list(w http.ResponseWriter, r *http.Request) {
|
func (h *CardKeyHandler) list(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// 获取分页参数
|
||||||
|
pageStr := r.URL.Query().Get("page")
|
||||||
|
pageSizeStr := r.URL.Query().Get("page_size")
|
||||||
|
|
||||||
cardKeys, err := h.repo.FindAll()
|
page, _ := strconv.Atoi(pageStr)
|
||||||
|
pageSize, _ := strconv.Atoi(pageSizeStr)
|
||||||
|
|
||||||
|
// 默认值
|
||||||
|
if page <= 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize <= 0 {
|
||||||
|
pageSize = 10
|
||||||
|
}
|
||||||
|
if pageSize > 100 {
|
||||||
|
pageSize = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取总数
|
||||||
|
total, err := h.repo.Count()
|
||||||
|
if err != nil {
|
||||||
|
respondJSON(w, http.StatusInternalServerError, CardKeyResponse{
|
||||||
|
Success: false,
|
||||||
|
Message: "Failed to count card keys",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取分页数据
|
||||||
|
cardKeys, err := h.repo.FindAllPaginated(page, pageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondJSON(w, http.StatusInternalServerError, CardKeyResponse{
|
respondJSON(w, http.StatusInternalServerError, CardKeyResponse{
|
||||||
Success: false,
|
Success: false,
|
||||||
@@ -201,6 +233,9 @@ func (h *CardKeyHandler) list(w http.ResponseWriter, r *http.Request) {
|
|||||||
Success: true,
|
Success: true,
|
||||||
Message: "OK",
|
Message: "OK",
|
||||||
Keys: cardKeys,
|
Keys: cardKeys,
|
||||||
|
Total: total,
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,3 +246,122 @@ func generateCardKey() string {
|
|||||||
hex := strings.ToUpper(hex.EncodeToString(bytes))
|
hex := strings.ToUpper(hex.EncodeToString(bytes))
|
||||||
return hex[0:4] + "-" + hex[4:8] + "-" + hex[8:12] + "-" + hex[12:16]
|
return hex[0:4] + "-" + hex[4:8] + "-" + hex[8:12] + "-" + hex[12:16]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete 删除单个卡密 (DELETE /api/cardkeys/delete?id=xxx)
|
||||||
|
func (h *CardKeyHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodDelete {
|
||||||
|
respondJSON(w, http.StatusMethodNotAllowed, CardKeyResponse{
|
||||||
|
Success: false,
|
||||||
|
Message: "Method not allowed",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
idStr := r.URL.Query().Get("id")
|
||||||
|
id, err := strconv.Atoi(idStr)
|
||||||
|
if err != nil {
|
||||||
|
respondJSON(w, http.StatusBadRequest, CardKeyResponse{
|
||||||
|
Success: false,
|
||||||
|
Message: "Invalid card key ID",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.repo.Delete(id); err != nil {
|
||||||
|
respondJSON(w, http.StatusInternalServerError, CardKeyResponse{
|
||||||
|
Success: false,
|
||||||
|
Message: "Failed to delete card key",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusOK, CardKeyResponse{
|
||||||
|
Success: true,
|
||||||
|
Message: "Card key deleted successfully",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchDeleteRequest 批量删除请求
|
||||||
|
type BatchDeleteRequest struct {
|
||||||
|
IDs []int `json:"ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchDelete 批量删除卡密 (DELETE /api/cardkeys/batch)
|
||||||
|
func (h *CardKeyHandler) BatchDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodDelete {
|
||||||
|
respondJSON(w, http.StatusMethodNotAllowed, CardKeyResponse{
|
||||||
|
Success: false,
|
||||||
|
Message: "Method not allowed",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req BatchDeleteRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
respondJSON(w, http.StatusBadRequest, CardKeyResponse{
|
||||||
|
Success: false,
|
||||||
|
Message: "Invalid request body",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(req.IDs) == 0 {
|
||||||
|
respondJSON(w, http.StatusBadRequest, CardKeyResponse{
|
||||||
|
Success: false,
|
||||||
|
Message: "No IDs provided",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.repo.BatchDelete(req.IDs); err != nil {
|
||||||
|
respondJSON(w, http.StatusInternalServerError, CardKeyResponse{
|
||||||
|
Success: false,
|
||||||
|
Message: "Failed to delete card keys",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusOK, CardKeyResponse{
|
||||||
|
Success: true,
|
||||||
|
Message: "Card keys deleted successfully",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToggleActiveRequest 切换激活状态请求
|
||||||
|
type ToggleActiveRequest struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToggleActive 切换卡密激活状态 (POST /api/cardkeys/toggle)
|
||||||
|
func (h *CardKeyHandler) ToggleActive(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
respondJSON(w, http.StatusMethodNotAllowed, CardKeyResponse{
|
||||||
|
Success: false,
|
||||||
|
Message: "Method not allowed",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req ToggleActiveRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
respondJSON(w, http.StatusBadRequest, CardKeyResponse{
|
||||||
|
Success: false,
|
||||||
|
Message: "Invalid request body",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.repo.ToggleActive(req.ID, req.IsActive); err != nil {
|
||||||
|
respondJSON(w, http.StatusInternalServerError, CardKeyResponse{
|
||||||
|
Success: false,
|
||||||
|
Message: "Failed to toggle card key status",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusOK, CardKeyResponse{
|
||||||
|
Success: true,
|
||||||
|
Message: "Card key status updated successfully",
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -45,6 +45,9 @@ type ListResponse struct {
|
|||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Data []*models.ChatGPTAccount `json:"data,omitempty"`
|
Data []*models.ChatGPTAccount `json:"data,omitempty"`
|
||||||
|
Total int `json:"total,omitempty"`
|
||||||
|
Page int `json:"page,omitempty"`
|
||||||
|
PageSize int `json:"page_size,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create 创建账号
|
// Create 创建账号
|
||||||
@@ -153,7 +156,36 @@ func (h *ChatGPTAccountHandler) List(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
accounts, err := h.repo.FindAll()
|
// 获取分页参数
|
||||||
|
pageStr := r.URL.Query().Get("page")
|
||||||
|
pageSizeStr := r.URL.Query().Get("page_size")
|
||||||
|
|
||||||
|
page, _ := strconv.Atoi(pageStr)
|
||||||
|
pageSize, _ := strconv.Atoi(pageSizeStr)
|
||||||
|
|
||||||
|
// 默认值
|
||||||
|
if page <= 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize <= 0 {
|
||||||
|
pageSize = 10
|
||||||
|
}
|
||||||
|
if pageSize > 100 {
|
||||||
|
pageSize = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取总数
|
||||||
|
total, err := h.repo.Count()
|
||||||
|
if err != nil {
|
||||||
|
respondJSON(w, http.StatusInternalServerError, ListResponse{
|
||||||
|
Success: false,
|
||||||
|
Message: "Failed to count accounts",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取分页数据
|
||||||
|
accounts, err := h.repo.FindAllPaginated(page, pageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondJSON(w, http.StatusInternalServerError, ListResponse{
|
respondJSON(w, http.StatusInternalServerError, ListResponse{
|
||||||
Success: false,
|
Success: false,
|
||||||
@@ -171,6 +203,9 @@ func (h *ChatGPTAccountHandler) List(w http.ResponseWriter, r *http.Request) {
|
|||||||
Success: true,
|
Success: true,
|
||||||
Message: "OK",
|
Message: "OK",
|
||||||
Data: accounts,
|
Data: accounts,
|
||||||
|
Total: total,
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3,6 +3,7 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -54,6 +55,57 @@ type InviteResponse struct {
|
|||||||
AccountName string `json:"account_name,omitempty"`
|
AccountName string `json:"account_name,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InviteListResponse 邀请列表响应
|
||||||
|
type InviteListResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data []*models.Invitation `json:"data,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListByAccount 获取账号的邀请列表 (GET /api/invite?account_id=xxx)
|
||||||
|
func (h *InviteHandler) ListByAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
respondJSON(w, http.StatusMethodNotAllowed, InviteListResponse{
|
||||||
|
Success: false,
|
||||||
|
Message: "Method not allowed",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
accountIDStr := r.URL.Query().Get("account_id")
|
||||||
|
if accountIDStr == "" {
|
||||||
|
respondJSON(w, http.StatusBadRequest, InviteListResponse{
|
||||||
|
Success: false,
|
||||||
|
Message: "account_id is required",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var accountID int
|
||||||
|
if _, err := fmt.Sscanf(accountIDStr, "%d", &accountID); err != nil {
|
||||||
|
respondJSON(w, http.StatusBadRequest, InviteListResponse{
|
||||||
|
Success: false,
|
||||||
|
Message: "Invalid account_id",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
invitations, err := h.invitationRepo.FindByAccountID(accountID)
|
||||||
|
if err != nil {
|
||||||
|
respondJSON(w, http.StatusInternalServerError, InviteListResponse{
|
||||||
|
Success: false,
|
||||||
|
Message: "Failed to fetch invitations",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusOK, InviteListResponse{
|
||||||
|
Success: true,
|
||||||
|
Message: "OK",
|
||||||
|
Data: invitations,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// InviteByAdmin 管理员邀请/移除接口 (POST/DELETE /api/invite)
|
// InviteByAdmin 管理员邀请/移除接口 (POST/DELETE /api/invite)
|
||||||
func (h *InviteHandler) InviteByAdmin(w http.ResponseWriter, r *http.Request) {
|
func (h *InviteHandler) InviteByAdmin(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
|
|
||||||
"gpt-manager-go/internal/models"
|
"gpt-manager-go/internal/models"
|
||||||
|
|
||||||
|
"github.com/lib/pq"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CardKeyRepository 卡密仓储
|
// CardKeyRepository 卡密仓储
|
||||||
@@ -87,3 +89,61 @@ func (r *CardKeyRepository) FindAll() ([]*models.CardKey, error) {
|
|||||||
}
|
}
|
||||||
return cardKeys, nil
|
return cardKeys, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FindAllPaginated 分页获取卡密
|
||||||
|
func (r *CardKeyRepository) FindAllPaginated(page, pageSize int) ([]*models.CardKey, error) {
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
rows, err := r.db.Query(`
|
||||||
|
SELECT id, key, max_uses, used_count, validity_type, expires_at, is_active, created_by_id, created_at
|
||||||
|
FROM card_keys ORDER BY created_at DESC
|
||||||
|
LIMIT $1 OFFSET $2
|
||||||
|
`, pageSize, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var cardKeys []*models.CardKey
|
||||||
|
for rows.Next() {
|
||||||
|
ck := &models.CardKey{}
|
||||||
|
if err := rows.Scan(
|
||||||
|
&ck.ID, &ck.Key, &ck.MaxUses, &ck.UsedCount,
|
||||||
|
&ck.ValidityType, &ck.ExpiresAt, &ck.IsActive,
|
||||||
|
&ck.CreatedByID, &ck.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cardKeys = append(cardKeys, ck)
|
||||||
|
}
|
||||||
|
return cardKeys, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count 获取卡密总数
|
||||||
|
func (r *CardKeyRepository) Count() (int, error) {
|
||||||
|
var count int
|
||||||
|
err := r.db.QueryRow(`SELECT COUNT(*) FROM card_keys`).Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 删除单个卡密
|
||||||
|
func (r *CardKeyRepository) Delete(id int) error {
|
||||||
|
_, err := r.db.Exec(`DELETE FROM card_keys WHERE id = $1`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchDelete 批量删除卡密
|
||||||
|
func (r *CardKeyRepository) BatchDelete(ids []int) error {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// 构建 IN 子句
|
||||||
|
query := `DELETE FROM card_keys WHERE id = ANY($1)`
|
||||||
|
_, err := r.db.Exec(query, pq.Array(ids))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToggleActive 切换卡密激活状态
|
||||||
|
func (r *CardKeyRepository) ToggleActive(id int, isActive bool) error {
|
||||||
|
_, err := r.db.Exec(`UPDATE card_keys SET is_active = $1 WHERE id = $2`, isActive, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -205,3 +205,41 @@ func (r *ChatGPTAccountRepository) UpdateLastUsed(id int) error {
|
|||||||
`, time.Now(), id)
|
`, time.Now(), id)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FindAllPaginated 分页获取账号
|
||||||
|
func (r *ChatGPTAccountRepository) FindAllPaginated(page, pageSize int) ([]*models.ChatGPTAccount, error) {
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
rows, err := r.db.Query(`
|
||||||
|
SELECT id, name, auth_token, team_account_id, seats_in_use, seats_entitled,
|
||||||
|
active_start, active_until, is_active, consecutive_failures,
|
||||||
|
last_check, last_used, created_at, updated_at
|
||||||
|
FROM chatgpt_accounts ORDER BY created_at DESC
|
||||||
|
LIMIT $1 OFFSET $2
|
||||||
|
`, pageSize, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var accounts []*models.ChatGPTAccount
|
||||||
|
for rows.Next() {
|
||||||
|
account := &models.ChatGPTAccount{}
|
||||||
|
if err := rows.Scan(
|
||||||
|
&account.ID, &account.Name, &account.AuthToken, &account.TeamAccountID,
|
||||||
|
&account.SeatsInUse, &account.SeatsEntitled, &account.ActiveStart, &account.ActiveUntil,
|
||||||
|
&account.IsActive, &account.ConsecutiveFailures, &account.LastCheck,
|
||||||
|
&account.LastUsed, &account.CreatedAt, &account.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
accounts = append(accounts, account)
|
||||||
|
}
|
||||||
|
return accounts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count 获取账号总数
|
||||||
|
func (r *ChatGPTAccountRepository) Count() (int, error) {
|
||||||
|
var count int
|
||||||
|
err := r.db.QueryRow(`SELECT COUNT(*) FROM chatgpt_accounts`).Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
@@ -81,3 +81,29 @@ func (r *InvitationRepository) DeleteByEmailAndAccountID(email string, accountID
|
|||||||
`, email, accountID)
|
`, email, accountID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FindByAccountID 根据账号ID查找邀请记录
|
||||||
|
func (r *InvitationRepository) FindByAccountID(accountID int) ([]*models.Invitation, error) {
|
||||||
|
rows, err := r.db.Query(`
|
||||||
|
SELECT id, card_key_id, account_id, invited_email, status, error_message, expires_at, created_at, updated_at
|
||||||
|
FROM invitations WHERE account_id = $1 ORDER BY created_at DESC
|
||||||
|
`, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var invitations []*models.Invitation
|
||||||
|
for rows.Next() {
|
||||||
|
inv := &models.Invitation{}
|
||||||
|
if err := rows.Scan(
|
||||||
|
&inv.ID, &inv.CardKeyID, &inv.AccountID, &inv.InvitedEmail,
|
||||||
|
&inv.Status, &inv.ErrorMessage, &inv.ExpiresAt,
|
||||||
|
&inv.CreatedAt, &inv.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
invitations = append(invitations, inv)
|
||||||
|
}
|
||||||
|
return invitations, nil
|
||||||
|
}
|
||||||
@@ -47,12 +47,36 @@ func SetupRoutes(db *sql.DB) http.Handler {
|
|||||||
protectedMux.HandleFunc("/api/accounts/refresh", accountHandler.Refresh)
|
protectedMux.HandleFunc("/api/accounts/refresh", accountHandler.Refresh)
|
||||||
protectedMux.HandleFunc("/api/accounts/delete", accountHandler.Delete)
|
protectedMux.HandleFunc("/api/accounts/delete", accountHandler.Delete)
|
||||||
|
|
||||||
// 邀请接口 (管理员) - POST: 邀请, DELETE: 移除
|
// 邀请接口 (管理员) - GET: 列表, POST: 邀请, DELETE: 移除
|
||||||
protectedMux.HandleFunc("/api/invite", inviteHandler.InviteByAdmin)
|
protectedMux.HandleFunc("/api/invite", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
inviteHandler.ListByAccount(w, r)
|
||||||
|
case http.MethodPost, http.MethodDelete:
|
||||||
|
inviteHandler.InviteByAdmin(w, r)
|
||||||
|
default:
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
w.Write([]byte(`{"success":false,"message":"Method not allowed"}`))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// 卡密管理接口
|
// 卡密管理接口
|
||||||
protectedMux.HandleFunc("/api/cardkeys", cardKeyHandler.Handle) // GET: 列表, POST: 创建
|
protectedMux.HandleFunc("/api/cardkeys", cardKeyHandler.Handle) // GET: 列表, POST: 创建
|
||||||
protectedMux.HandleFunc("/api/cardkeys/batch", cardKeyHandler.BatchCreate) // POST: 批量创建
|
protectedMux.HandleFunc("/api/cardkeys/batch", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodPost:
|
||||||
|
cardKeyHandler.BatchCreate(w, r)
|
||||||
|
case http.MethodDelete:
|
||||||
|
cardKeyHandler.BatchDelete(w, r)
|
||||||
|
default:
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
w.Write([]byte(`{"success":false,"message":"Method not allowed"}`))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
protectedMux.HandleFunc("/api/cardkeys/delete", cardKeyHandler.Delete) // DELETE: 单个删除
|
||||||
|
protectedMux.HandleFunc("/api/cardkeys/toggle", cardKeyHandler.ToggleActive) // POST: 切换激活状态
|
||||||
|
|
||||||
// 挂载受保护的路由
|
// 挂载受保护的路由
|
||||||
mux.Handle("/api/profile", middleware.AuthMiddleware(protectedMux))
|
mux.Handle("/api/profile", middleware.AuthMiddleware(protectedMux))
|
||||||
1
frontend/.env.example
Normal file
1
frontend/.env.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_BASE_URL=http://localhost:8080
|
||||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
5
frontend/README.md
Normal file
5
frontend/README.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Vue 3 + TypeScript + Vite
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||||
|
|
||||||
|
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||||
21
frontend/components.json
Normal file
21
frontend/components.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://shadcn-vue.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"typescript": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/style.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"composables": "@/composables"
|
||||||
|
},
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>frontend</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
38
frontend/package.json
Normal file
38
frontend/package.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
|
"@vueuse/core": "^14.1.0",
|
||||||
|
"axios": "^1.13.2",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"file-saver": "^2.0.5",
|
||||||
|
"lucide-vue-next": "^0.562.0",
|
||||||
|
"pinia": "^3.0.4",
|
||||||
|
"reka-ui": "^2.7.0",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
"vue": "^3.5.24",
|
||||||
|
"vue-router": "4",
|
||||||
|
"vue-sonner": "^2.0.9"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/file-saver": "^2.0.7",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
"@vue/tsconfig": "^0.8.1",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"vite": "^7.2.4",
|
||||||
|
"vue-tsc": "^3.1.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
1891
frontend/pnpm-lock.yaml
generated
Normal file
1891
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
23
frontend/src/App.vue
Normal file
23
frontend/src/App.vue
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import AdminLayout from '@/layouts/AdminLayout.vue'
|
||||||
|
import PublicLayout from '@/layouts/PublicLayout.vue'
|
||||||
|
import { Toaster } from 'vue-sonner'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const layout = computed(() => {
|
||||||
|
if (route.meta.layout === 'admin') {
|
||||||
|
return AdminLayout
|
||||||
|
}
|
||||||
|
return PublicLayout
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<component :is="layout">
|
||||||
|
<router-view />
|
||||||
|
</component>
|
||||||
|
<Toaster position="top-center" rich-colors />
|
||||||
|
</template>
|
||||||
58
frontend/src/api/accounts.ts
Normal file
58
frontend/src/api/accounts.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import request from './request'
|
||||||
|
|
||||||
|
export interface Account {
|
||||||
|
id: number
|
||||||
|
team_account_id: string
|
||||||
|
name: string
|
||||||
|
is_active: boolean
|
||||||
|
seats_in_use: number
|
||||||
|
seats_entitled: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountsResponse {
|
||||||
|
success: boolean
|
||||||
|
data?: Account[]
|
||||||
|
total?: number
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountResponse {
|
||||||
|
success: boolean
|
||||||
|
data?: Account
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateAccountRequest {
|
||||||
|
team_account_id: string
|
||||||
|
auth_token: string
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginationParams {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAccounts(params?: PaginationParams) {
|
||||||
|
const searchParams = new URLSearchParams()
|
||||||
|
if (params?.page) searchParams.set('page', String(params.page))
|
||||||
|
if (params?.page_size) searchParams.set('page_size', String(params.page_size))
|
||||||
|
const query = searchParams.toString()
|
||||||
|
return request.get<AccountsResponse>(`/api/accounts${query ? `?${query}` : ''}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAccount(data: CreateAccountRequest) {
|
||||||
|
return request.post<AccountResponse>('/api/accounts/create', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function refreshAccount(id: number) {
|
||||||
|
return request.post<AccountResponse>(`/api/accounts/refresh?id=${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteAccount(id: number) {
|
||||||
|
return request.delete(`/api/accounts/delete?id=${id}`)
|
||||||
|
}
|
||||||
29
frontend/src/api/auth.ts
Normal file
29
frontend/src/api/auth.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import request from './request'
|
||||||
|
|
||||||
|
export interface LoginRequest {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
success: boolean
|
||||||
|
token?: string
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileResponse {
|
||||||
|
success: boolean
|
||||||
|
user?: {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function login(data: LoginRequest) {
|
||||||
|
return request.post<LoginResponse>('/api/login', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProfile() {
|
||||||
|
return request.get<ProfileResponse>('/api/profile')
|
||||||
|
}
|
||||||
73
frontend/src/api/cardkeys.ts
Normal file
73
frontend/src/api/cardkeys.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import request from './request'
|
||||||
|
|
||||||
|
export interface CardKey {
|
||||||
|
id: number
|
||||||
|
key: string
|
||||||
|
max_uses: number
|
||||||
|
used_count: number
|
||||||
|
validity_type: string
|
||||||
|
expires_at: string
|
||||||
|
is_active: boolean
|
||||||
|
created_by_id: number
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CardKeysResponse {
|
||||||
|
success: boolean
|
||||||
|
keys?: CardKey[]
|
||||||
|
total?: number
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CardKeyResponse {
|
||||||
|
success: boolean
|
||||||
|
data?: CardKey
|
||||||
|
keys?: CardKey[]
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCardKeyRequest {
|
||||||
|
validity_days?: number
|
||||||
|
max_uses?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchCreateCardKeyRequest {
|
||||||
|
count: number
|
||||||
|
validity_days?: number
|
||||||
|
max_uses?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginationParams {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCardKeys(params?: PaginationParams) {
|
||||||
|
const searchParams = new URLSearchParams()
|
||||||
|
if (params?.page) searchParams.set('page', String(params.page))
|
||||||
|
if (params?.page_size) searchParams.set('page_size', String(params.page_size))
|
||||||
|
const query = searchParams.toString()
|
||||||
|
return request.get<CardKeysResponse>(`/api/cardkeys${query ? `?${query}` : ''}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCardKey(data: CreateCardKeyRequest) {
|
||||||
|
return request.post<CardKeyResponse>('/api/cardkeys', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function batchCreateCardKeys(data: BatchCreateCardKeyRequest) {
|
||||||
|
return request.post<CardKeyResponse>('/api/cardkeys/batch', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteCardKey(id: number) {
|
||||||
|
return request.delete<CardKeyResponse>(`/api/cardkeys/delete?id=${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function batchDeleteCardKeys(ids: number[]) {
|
||||||
|
return request.delete<CardKeyResponse>('/api/cardkeys/batch', { data: { ids } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleCardKeyActive(id: number, is_active: boolean) {
|
||||||
|
return request.post<CardKeyResponse>('/api/cardkeys/toggle', { id, is_active })
|
||||||
|
}
|
||||||
39
frontend/src/api/invite.ts
Normal file
39
frontend/src/api/invite.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import request from './request'
|
||||||
|
|
||||||
|
export interface InviteByCardRequest {
|
||||||
|
email: string
|
||||||
|
card_key: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Invitation {
|
||||||
|
id: number
|
||||||
|
email: string
|
||||||
|
account_id: number
|
||||||
|
status: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvitationsResponse {
|
||||||
|
success: boolean
|
||||||
|
invitations?: Invitation[]
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeleteInviteRequest {
|
||||||
|
email: string
|
||||||
|
account_id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public endpoint - no auth required
|
||||||
|
export function inviteByCard(data: InviteByCardRequest) {
|
||||||
|
return request.post('/api/invite/card', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin endpoints - auth required
|
||||||
|
export function listInvitations(accountId: number) {
|
||||||
|
return request.get<InvitationsResponse>(`/api/invite?account_id=${accountId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteInvite(data: DeleteInviteRequest) {
|
||||||
|
return request.delete('/api/invite', { data })
|
||||||
|
}
|
||||||
42
frontend/src/api/request.ts
Normal file
42
frontend/src/api/request.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import axios, { type InternalAxiosRequestConfig, type AxiosResponse, type AxiosError } from 'axios'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import router from '@/router'
|
||||||
|
|
||||||
|
const request = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_BASE_URL || '',
|
||||||
|
timeout: 30000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Request interceptor - add Authorization header
|
||||||
|
request.interceptors.request.use(
|
||||||
|
(config: InternalAxiosRequestConfig) => {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
if (authStore.token) {
|
||||||
|
config.headers.Authorization = `Bearer ${authStore.token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
},
|
||||||
|
(error: AxiosError) => {
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Response interceptor - handle 401/403
|
||||||
|
request.interceptors.response.use(
|
||||||
|
(response: AxiosResponse) => {
|
||||||
|
return response
|
||||||
|
},
|
||||||
|
(error: AxiosError) => {
|
||||||
|
if (error.response?.status === 401 || error.response?.status === 403) {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
authStore.logout()
|
||||||
|
router.push('/admin/login')
|
||||||
|
}
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default request
|
||||||
1
frontend/src/assets/vue.svg
Normal file
1
frontend/src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 496 B |
41
frontend/src/components/HelloWorld.vue
Normal file
41
frontend/src/components/HelloWorld.vue
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
defineProps<{ msg: string }>()
|
||||||
|
|
||||||
|
const count = ref(0)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<h1>{{ msg }}</h1>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<button type="button" @click="count++">count is {{ count }}</button>
|
||||||
|
<p>
|
||||||
|
Edit
|
||||||
|
<code>components/HelloWorld.vue</code> to test HMR
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Check out
|
||||||
|
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
||||||
|
>create-vue</a
|
||||||
|
>, the official Vue + Vite starter
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Learn more about IDE Support for Vue in the
|
||||||
|
<a
|
||||||
|
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
||||||
|
target="_blank"
|
||||||
|
>Vue Docs Scaling up Guide</a
|
||||||
|
>.
|
||||||
|
</p>
|
||||||
|
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.read-the-docs {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
15
frontend/src/components/ui/alert-dialog/AlertDialog.vue
Normal file
15
frontend/src/components/ui/alert-dialog/AlertDialog.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { AlertDialogEmits, AlertDialogProps } from "reka-ui"
|
||||||
|
import { AlertDialogRoot, useForwardPropsEmits } from "reka-ui"
|
||||||
|
|
||||||
|
const props = defineProps<AlertDialogProps>()
|
||||||
|
const emits = defineEmits<AlertDialogEmits>()
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AlertDialogRoot v-slot="slotProps" data-slot="alert-dialog" v-bind="forwarded">
|
||||||
|
<slot v-bind="slotProps" />
|
||||||
|
</AlertDialogRoot>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { AlertDialogActionProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { AlertDialogAction } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from '@/components/ui/button'
|
||||||
|
|
||||||
|
const props = defineProps<AlertDialogActionProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AlertDialogAction v-bind="delegatedProps" :class="cn(buttonVariants(), props.class)">
|
||||||
|
<slot />
|
||||||
|
</AlertDialogAction>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { AlertDialogCancelProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { AlertDialogCancel } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from '@/components/ui/button'
|
||||||
|
|
||||||
|
const props = defineProps<AlertDialogCancelProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AlertDialogCancel
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="cn(
|
||||||
|
buttonVariants({ variant: 'outline' }),
|
||||||
|
'mt-2 sm:mt-0',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</AlertDialogCancel>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { AlertDialogContentEmits, AlertDialogContentProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import {
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogPortal,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps<AlertDialogContentProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
const emits = defineEmits<AlertDialogContentEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
class="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/80"
|
||||||
|
/>
|
||||||
|
<AlertDialogContent
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
|
:class="
|
||||||
|
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',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { AlertDialogDescriptionProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import {
|
||||||
|
AlertDialogDescription,
|
||||||
|
} from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<AlertDialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AlertDialogDescription
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
21
frontend/src/components/ui/alert-dialog/AlertDialogTitle.vue
Normal file
21
frontend/src/components/ui/alert-dialog/AlertDialogTitle.vue
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { AlertDialogTitleProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { AlertDialogTitle } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<AlertDialogTitleProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AlertDialogTitle
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="cn('text-lg font-semibold', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</AlertDialogTitle>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { AlertDialogTriggerProps } from "reka-ui"
|
||||||
|
import { AlertDialogTrigger } from "reka-ui"
|
||||||
|
|
||||||
|
const props = defineProps<AlertDialogTriggerProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AlertDialogTrigger data-slot="alert-dialog-trigger" v-bind="props">
|
||||||
|
<slot />
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
</template>
|
||||||
9
frontend/src/components/ui/alert-dialog/index.ts
Normal file
9
frontend/src/components/ui/alert-dialog/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export { default as AlertDialog } from "./AlertDialog.vue"
|
||||||
|
export { default as AlertDialogAction } from "./AlertDialogAction.vue"
|
||||||
|
export { default as AlertDialogCancel } from "./AlertDialogCancel.vue"
|
||||||
|
export { default as AlertDialogContent } from "./AlertDialogContent.vue"
|
||||||
|
export { default as AlertDialogDescription } from "./AlertDialogDescription.vue"
|
||||||
|
export { default as AlertDialogFooter } from "./AlertDialogFooter.vue"
|
||||||
|
export { default as AlertDialogHeader } from "./AlertDialogHeader.vue"
|
||||||
|
export { default as AlertDialogTitle } from "./AlertDialogTitle.vue"
|
||||||
|
export { default as AlertDialogTrigger } from "./AlertDialogTrigger.vue"
|
||||||
21
frontend/src/components/ui/alert/Alert.vue
Normal file
21
frontend/src/components/ui/alert/Alert.vue
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import type { AlertVariants } from "."
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { alertVariants } from "."
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
variant?: AlertVariants["variant"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="alert"
|
||||||
|
:class="cn(alertVariants({ variant }), props.class)"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
17
frontend/src/components/ui/alert/AlertDescription.vue
Normal file
17
frontend/src/components/ui/alert/AlertDescription.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
:class="cn('text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
17
frontend/src/components/ui/alert/AlertTitle.vue
Normal file
17
frontend/src/components/ui/alert/AlertTitle.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="alert-title"
|
||||||
|
:class="cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
24
frontend/src/components/ui/alert/index.ts
Normal file
24
frontend/src/components/ui/alert/index.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import type { VariantProps } from "class-variance-authority"
|
||||||
|
import { cva } from "class-variance-authority"
|
||||||
|
|
||||||
|
export { default as Alert } from "./Alert.vue"
|
||||||
|
export { default as AlertDescription } from "./AlertDescription.vue"
|
||||||
|
export { default as AlertTitle } from "./AlertTitle.vue"
|
||||||
|
|
||||||
|
export 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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export type AlertVariants = VariantProps<typeof alertVariants>
|
||||||
26
frontend/src/components/ui/badge/Badge.vue
Normal file
26
frontend/src/components/ui/badge/Badge.vue
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PrimitiveProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import type { BadgeVariants } from "."
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { Primitive } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { badgeVariants } from "."
|
||||||
|
|
||||||
|
const props = defineProps<PrimitiveProps & {
|
||||||
|
variant?: BadgeVariants["variant"]
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Primitive
|
||||||
|
data-slot="badge"
|
||||||
|
:class="cn(badgeVariants({ variant }), props.class)"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Primitive>
|
||||||
|
</template>
|
||||||
26
frontend/src/components/ui/badge/index.ts
Normal file
26
frontend/src/components/ui/badge/index.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { VariantProps } from "class-variance-authority"
|
||||||
|
import { cva } from "class-variance-authority"
|
||||||
|
|
||||||
|
export { default as Badge } from "./Badge.vue"
|
||||||
|
|
||||||
|
export const badgeVariants = cva(
|
||||||
|
"inline-flex items-center justify-center rounded-full 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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
export type BadgeVariants = VariantProps<typeof badgeVariants>
|
||||||
29
frontend/src/components/ui/button/Button.vue
Normal file
29
frontend/src/components/ui/button/Button.vue
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PrimitiveProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import type { ButtonVariants } from "."
|
||||||
|
import { Primitive } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from "."
|
||||||
|
|
||||||
|
interface Props extends PrimitiveProps {
|
||||||
|
variant?: ButtonVariants["variant"]
|
||||||
|
size?: ButtonVariants["size"]
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
as: "button",
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Primitive
|
||||||
|
data-slot="button"
|
||||||
|
:as="as"
|
||||||
|
:as-child="asChild"
|
||||||
|
:class="cn(buttonVariants({ variant, size }), props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Primitive>
|
||||||
|
</template>
|
||||||
38
frontend/src/components/ui/button/index.ts
Normal file
38
frontend/src/components/ui/button/index.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import type { VariantProps } from "class-variance-authority"
|
||||||
|
import { cva } from "class-variance-authority"
|
||||||
|
|
||||||
|
export { default as Button } from "./Button.vue"
|
||||||
|
|
||||||
|
export 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 hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white 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 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",
|
||||||
|
"icon-sm": "size-8",
|
||||||
|
"icon-lg": "size-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
||||||
22
frontend/src/components/ui/card/Card.vue
Normal file
22
frontend/src/components/ui/card/Card.vue
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
17
frontend/src/components/ui/card/CardAction.vue
Normal file
17
frontend/src/components/ui/card/CardAction.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
:class="cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
17
frontend/src/components/ui/card/CardContent.vue
Normal file
17
frontend/src/components/ui/card/CardContent.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
:class="cn('px-6', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
17
frontend/src/components/ui/card/CardDescription.vue
Normal file
17
frontend/src/components/ui/card/CardDescription.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<p
|
||||||
|
data-slot="card-description"
|
||||||
|
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
17
frontend/src/components/ui/card/CardFooter.vue
Normal file
17
frontend/src/components/ui/card/CardFooter.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
:class="cn('flex items-center px-6 [.border-t]:pt-6', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
17
frontend/src/components/ui/card/CardHeader.vue
Normal file
17
frontend/src/components/ui/card/CardHeader.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
:class="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', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
17
frontend/src/components/ui/card/CardTitle.vue
Normal file
17
frontend/src/components/ui/card/CardTitle.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<h3
|
||||||
|
data-slot="card-title"
|
||||||
|
:class="cn('leading-none font-semibold', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</h3>
|
||||||
|
</template>
|
||||||
7
frontend/src/components/ui/card/index.ts
Normal file
7
frontend/src/components/ui/card/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export { default as Card } from "./Card.vue"
|
||||||
|
export { default as CardAction } from "./CardAction.vue"
|
||||||
|
export { default as CardContent } from "./CardContent.vue"
|
||||||
|
export { default as CardDescription } from "./CardDescription.vue"
|
||||||
|
export { default as CardFooter } from "./CardFooter.vue"
|
||||||
|
export { default as CardHeader } from "./CardHeader.vue"
|
||||||
|
export { default as CardTitle } from "./CardTitle.vue"
|
||||||
35
frontend/src/components/ui/checkbox/Checkbox.vue
Normal file
35
frontend/src/components/ui/checkbox/Checkbox.vue
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { CheckboxRootEmits, CheckboxRootProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { Check } from "lucide-vue-next"
|
||||||
|
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
const emits = defineEmits<CheckboxRootEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CheckboxRoot
|
||||||
|
v-slot="slotProps"
|
||||||
|
data-slot="checkbox"
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="
|
||||||
|
cn('peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground 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',
|
||||||
|
props.class)"
|
||||||
|
>
|
||||||
|
<CheckboxIndicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
class="grid place-content-center text-current transition-none"
|
||||||
|
>
|
||||||
|
<slot v-bind="slotProps">
|
||||||
|
<Check class="size-3.5" />
|
||||||
|
</slot>
|
||||||
|
</CheckboxIndicator>
|
||||||
|
</CheckboxRoot>
|
||||||
|
</template>
|
||||||
1
frontend/src/components/ui/checkbox/index.ts
Normal file
1
frontend/src/components/ui/checkbox/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as Checkbox } from "./Checkbox.vue"
|
||||||
19
frontend/src/components/ui/dialog/Dialog.vue
Normal file
19
frontend/src/components/ui/dialog/Dialog.vue
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogRootEmits, DialogRootProps } from "reka-ui"
|
||||||
|
import { DialogRoot, useForwardPropsEmits } from "reka-ui"
|
||||||
|
|
||||||
|
const props = defineProps<DialogRootProps>()
|
||||||
|
const emits = defineEmits<DialogRootEmits>()
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogRoot
|
||||||
|
v-slot="slotProps"
|
||||||
|
data-slot="dialog"
|
||||||
|
v-bind="forwarded"
|
||||||
|
>
|
||||||
|
<slot v-bind="slotProps" />
|
||||||
|
</DialogRoot>
|
||||||
|
</template>
|
||||||
15
frontend/src/components/ui/dialog/DialogClose.vue
Normal file
15
frontend/src/components/ui/dialog/DialogClose.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogCloseProps } from "reka-ui"
|
||||||
|
import { DialogClose } from "reka-ui"
|
||||||
|
|
||||||
|
const props = defineProps<DialogCloseProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogClose
|
||||||
|
data-slot="dialog-close"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogClose>
|
||||||
|
</template>
|
||||||
53
frontend/src/components/ui/dialog/DialogContent.vue
Normal file
53
frontend/src/components/ui/dialog/DialogContent.vue
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { X } from "lucide-vue-next"
|
||||||
|
import {
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogPortal,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import DialogOverlay from "./DialogOverlay.vue"
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes["class"], showCloseButton?: boolean }>(), {
|
||||||
|
showCloseButton: true,
|
||||||
|
})
|
||||||
|
const emits = defineEmits<DialogContentEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogContent
|
||||||
|
data-slot="dialog-content"
|
||||||
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
|
:class="
|
||||||
|
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',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
|
||||||
|
<DialogClose
|
||||||
|
v-if="showCloseButton"
|
||||||
|
data-slot="dialog-close"
|
||||||
|
class="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"
|
||||||
|
>
|
||||||
|
<X />
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogPortal>
|
||||||
|
</template>
|
||||||
23
frontend/src/components/ui/dialog/DialogDescription.vue
Normal file
23
frontend/src/components/ui/dialog/DialogDescription.vue
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogDescriptionProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { DialogDescription, useForwardProps } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogDescription
|
||||||
|
data-slot="dialog-description"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogDescription>
|
||||||
|
</template>
|
||||||
15
frontend/src/components/ui/dialog/DialogFooter.vue
Normal file
15
frontend/src/components/ui/dialog/DialogFooter.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
:class="cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
17
frontend/src/components/ui/dialog/DialogHeader.vue
Normal file
17
frontend/src/components/ui/dialog/DialogHeader.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
21
frontend/src/components/ui/dialog/DialogOverlay.vue
Normal file
21
frontend/src/components/ui/dialog/DialogOverlay.vue
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogOverlayProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { DialogOverlay } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogOverlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="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/80', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogOverlay>
|
||||||
|
</template>
|
||||||
59
frontend/src/components/ui/dialog/DialogScrollContent.vue
Normal file
59
frontend/src/components/ui/dialog/DialogScrollContent.vue
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { X } from "lucide-vue-next"
|
||||||
|
import {
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
const emits = defineEmits<DialogContentEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay
|
||||||
|
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||||
|
>
|
||||||
|
<DialogContent
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
|
@pointer-down-outside="(event) => {
|
||||||
|
const originalEvent = event.detail.originalEvent;
|
||||||
|
const target = originalEvent.target as HTMLElement;
|
||||||
|
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
|
||||||
|
<DialogClose
|
||||||
|
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
|
||||||
|
>
|
||||||
|
<X class="w-4 h-4" />
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogOverlay>
|
||||||
|
</DialogPortal>
|
||||||
|
</template>
|
||||||
23
frontend/src/components/ui/dialog/DialogTitle.vue
Normal file
23
frontend/src/components/ui/dialog/DialogTitle.vue
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogTitleProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { DialogTitle, useForwardProps } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogTitle
|
||||||
|
data-slot="dialog-title"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="cn('text-lg leading-none font-semibold', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogTitle>
|
||||||
|
</template>
|
||||||
15
frontend/src/components/ui/dialog/DialogTrigger.vue
Normal file
15
frontend/src/components/ui/dialog/DialogTrigger.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogTriggerProps } from "reka-ui"
|
||||||
|
import { DialogTrigger } from "reka-ui"
|
||||||
|
|
||||||
|
const props = defineProps<DialogTriggerProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogTrigger
|
||||||
|
data-slot="dialog-trigger"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogTrigger>
|
||||||
|
</template>
|
||||||
10
frontend/src/components/ui/dialog/index.ts
Normal file
10
frontend/src/components/ui/dialog/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export { default as Dialog } from "./Dialog.vue"
|
||||||
|
export { default as DialogClose } from "./DialogClose.vue"
|
||||||
|
export { default as DialogContent } from "./DialogContent.vue"
|
||||||
|
export { default as DialogDescription } from "./DialogDescription.vue"
|
||||||
|
export { default as DialogFooter } from "./DialogFooter.vue"
|
||||||
|
export { default as DialogHeader } from "./DialogHeader.vue"
|
||||||
|
export { default as DialogOverlay } from "./DialogOverlay.vue"
|
||||||
|
export { default as DialogScrollContent } from "./DialogScrollContent.vue"
|
||||||
|
export { default as DialogTitle } from "./DialogTitle.vue"
|
||||||
|
export { default as DialogTrigger } from "./DialogTrigger.vue"
|
||||||
33
frontend/src/components/ui/input/Input.vue
Normal file
33
frontend/src/components/ui/input/Input.vue
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { useVModel } from "@vueuse/core"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
defaultValue?: string | number
|
||||||
|
modelValue?: string | number
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emits = defineEmits<{
|
||||||
|
(e: "update:modelValue", payload: string | number): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const modelValue = useVModel(props, "modelValue", emits, {
|
||||||
|
passive: true,
|
||||||
|
defaultValue: props.defaultValue,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<input
|
||||||
|
v-model="modelValue"
|
||||||
|
data-slot="input"
|
||||||
|
:class="cn(
|
||||||
|
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||||
|
'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',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
</template>
|
||||||
1
frontend/src/components/ui/input/index.ts
Normal file
1
frontend/src/components/ui/input/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as Input } from "./Input.vue"
|
||||||
26
frontend/src/components/ui/label/Label.vue
Normal file
26
frontend/src/components/ui/label/Label.vue
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { LabelProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { Label } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<LabelProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Label
|
||||||
|
data-slot="label"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Label>
|
||||||
|
</template>
|
||||||
1
frontend/src/components/ui/label/index.ts
Normal file
1
frontend/src/components/ui/label/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as Label } from "./Label.vue"
|
||||||
26
frontend/src/components/ui/pagination/Pagination.vue
Normal file
26
frontend/src/components/ui/pagination/Pagination.vue
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PaginationRootEmits, PaginationRootProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { PaginationRoot, useForwardPropsEmits } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<PaginationRootProps & {
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
const emits = defineEmits<PaginationRootEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PaginationRoot
|
||||||
|
v-slot="slotProps"
|
||||||
|
data-slot="pagination"
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="cn('mx-auto flex w-full justify-center', props.class)"
|
||||||
|
>
|
||||||
|
<slot v-bind="slotProps" />
|
||||||
|
</PaginationRoot>
|
||||||
|
</template>
|
||||||
22
frontend/src/components/ui/pagination/PaginationContent.vue
Normal file
22
frontend/src/components/ui/pagination/PaginationContent.vue
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PaginationListProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { PaginationList } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<PaginationListProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PaginationList
|
||||||
|
v-slot="slotProps"
|
||||||
|
data-slot="pagination-content"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="cn('flex flex-row items-center gap-1', props.class)"
|
||||||
|
>
|
||||||
|
<slot v-bind="slotProps" />
|
||||||
|
</PaginationList>
|
||||||
|
</template>
|
||||||
25
frontend/src/components/ui/pagination/PaginationEllipsis.vue
Normal file
25
frontend/src/components/ui/pagination/PaginationEllipsis.vue
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PaginationEllipsisProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { MoreHorizontal } from "lucide-vue-next"
|
||||||
|
import { PaginationEllipsis } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<PaginationEllipsisProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PaginationEllipsis
|
||||||
|
data-slot="pagination-ellipsis"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="cn('flex size-9 items-center justify-center', props.class)"
|
||||||
|
>
|
||||||
|
<slot>
|
||||||
|
<MoreHorizontal class="size-4" />
|
||||||
|
<span class="sr-only">More pages</span>
|
||||||
|
</slot>
|
||||||
|
</PaginationEllipsis>
|
||||||
|
</template>
|
||||||
33
frontend/src/components/ui/pagination/PaginationFirst.vue
Normal file
33
frontend/src/components/ui/pagination/PaginationFirst.vue
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PaginationFirstProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import type { ButtonVariants } from '@/components/ui/button'
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { ChevronLeftIcon } from "lucide-vue-next"
|
||||||
|
import { PaginationFirst, useForwardProps } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from '@/components/ui/button'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<PaginationFirstProps & {
|
||||||
|
size?: ButtonVariants["size"]
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>(), {
|
||||||
|
size: "default",
|
||||||
|
})
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class", "size")
|
||||||
|
const forwarded = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PaginationFirst
|
||||||
|
data-slot="pagination-first"
|
||||||
|
:class="cn(buttonVariants({ variant: 'ghost', size }), 'gap-1 px-2.5 sm:pr-2.5', props.class)"
|
||||||
|
v-bind="forwarded"
|
||||||
|
>
|
||||||
|
<slot>
|
||||||
|
<ChevronLeftIcon />
|
||||||
|
<span class="hidden sm:block">First</span>
|
||||||
|
</slot>
|
||||||
|
</PaginationFirst>
|
||||||
|
</template>
|
||||||
34
frontend/src/components/ui/pagination/PaginationItem.vue
Normal file
34
frontend/src/components/ui/pagination/PaginationItem.vue
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PaginationListItemProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import type { ButtonVariants } from '@/components/ui/button'
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { PaginationListItem } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from '@/components/ui/button'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<PaginationListItemProps & {
|
||||||
|
size?: ButtonVariants["size"]
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
isActive?: boolean
|
||||||
|
}>(), {
|
||||||
|
size: "icon",
|
||||||
|
})
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class", "size", "isActive")
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PaginationListItem
|
||||||
|
data-slot="pagination-item"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="cn(
|
||||||
|
buttonVariants({
|
||||||
|
variant: isActive ? 'outline' : 'ghost',
|
||||||
|
size,
|
||||||
|
}),
|
||||||
|
props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</PaginationListItem>
|
||||||
|
</template>
|
||||||
33
frontend/src/components/ui/pagination/PaginationLast.vue
Normal file
33
frontend/src/components/ui/pagination/PaginationLast.vue
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PaginationLastProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import type { ButtonVariants } from '@/components/ui/button'
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { ChevronRightIcon } from "lucide-vue-next"
|
||||||
|
import { PaginationLast, useForwardProps } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from '@/components/ui/button'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<PaginationLastProps & {
|
||||||
|
size?: ButtonVariants["size"]
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>(), {
|
||||||
|
size: "default",
|
||||||
|
})
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class", "size")
|
||||||
|
const forwarded = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PaginationLast
|
||||||
|
data-slot="pagination-last"
|
||||||
|
:class="cn(buttonVariants({ variant: 'ghost', size }), 'gap-1 px-2.5 sm:pr-2.5', props.class)"
|
||||||
|
v-bind="forwarded"
|
||||||
|
>
|
||||||
|
<slot>
|
||||||
|
<span class="hidden sm:block">Last</span>
|
||||||
|
<ChevronRightIcon />
|
||||||
|
</slot>
|
||||||
|
</PaginationLast>
|
||||||
|
</template>
|
||||||
33
frontend/src/components/ui/pagination/PaginationNext.vue
Normal file
33
frontend/src/components/ui/pagination/PaginationNext.vue
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PaginationNextProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import type { ButtonVariants } from '@/components/ui/button'
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { ChevronRightIcon } from "lucide-vue-next"
|
||||||
|
import { PaginationNext, useForwardProps } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from '@/components/ui/button'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<PaginationNextProps & {
|
||||||
|
size?: ButtonVariants["size"]
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>(), {
|
||||||
|
size: "default",
|
||||||
|
})
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class", "size")
|
||||||
|
const forwarded = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PaginationNext
|
||||||
|
data-slot="pagination-next"
|
||||||
|
:class="cn(buttonVariants({ variant: 'ghost', size }), 'gap-1 px-2.5 sm:pr-2.5', props.class)"
|
||||||
|
v-bind="forwarded"
|
||||||
|
>
|
||||||
|
<slot>
|
||||||
|
<span class="hidden sm:block">Next</span>
|
||||||
|
<ChevronRightIcon />
|
||||||
|
</slot>
|
||||||
|
</PaginationNext>
|
||||||
|
</template>
|
||||||
33
frontend/src/components/ui/pagination/PaginationPrevious.vue
Normal file
33
frontend/src/components/ui/pagination/PaginationPrevious.vue
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PaginationPrevProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import type { ButtonVariants } from '@/components/ui/button'
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { ChevronLeftIcon } from "lucide-vue-next"
|
||||||
|
import { PaginationPrev, useForwardProps } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from '@/components/ui/button'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<PaginationPrevProps & {
|
||||||
|
size?: ButtonVariants["size"]
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>(), {
|
||||||
|
size: "default",
|
||||||
|
})
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class", "size")
|
||||||
|
const forwarded = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PaginationPrev
|
||||||
|
data-slot="pagination-previous"
|
||||||
|
:class="cn(buttonVariants({ variant: 'ghost', size }), 'gap-1 px-2.5 sm:pr-2.5', props.class)"
|
||||||
|
v-bind="forwarded"
|
||||||
|
>
|
||||||
|
<slot>
|
||||||
|
<ChevronLeftIcon />
|
||||||
|
<span class="hidden sm:block">Previous</span>
|
||||||
|
</slot>
|
||||||
|
</PaginationPrev>
|
||||||
|
</template>
|
||||||
8
frontend/src/components/ui/pagination/index.ts
Normal file
8
frontend/src/components/ui/pagination/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export { default as Pagination } from "./Pagination.vue"
|
||||||
|
export { default as PaginationContent } from "./PaginationContent.vue"
|
||||||
|
export { default as PaginationEllipsis } from "./PaginationEllipsis.vue"
|
||||||
|
export { default as PaginationFirst } from "./PaginationFirst.vue"
|
||||||
|
export { default as PaginationItem } from "./PaginationItem.vue"
|
||||||
|
export { default as PaginationLast } from "./PaginationLast.vue"
|
||||||
|
export { default as PaginationNext } from "./PaginationNext.vue"
|
||||||
|
export { default as PaginationPrevious } from "./PaginationPrevious.vue"
|
||||||
19
frontend/src/components/ui/select/Select.vue
Normal file
19
frontend/src/components/ui/select/Select.vue
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectRootEmits, SelectRootProps } from "reka-ui"
|
||||||
|
import { SelectRoot, useForwardPropsEmits } from "reka-ui"
|
||||||
|
|
||||||
|
const props = defineProps<SelectRootProps>()
|
||||||
|
const emits = defineEmits<SelectRootEmits>()
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectRoot
|
||||||
|
v-slot="slotProps"
|
||||||
|
data-slot="select"
|
||||||
|
v-bind="forwarded"
|
||||||
|
>
|
||||||
|
<slot v-bind="slotProps" />
|
||||||
|
</SelectRoot>
|
||||||
|
</template>
|
||||||
51
frontend/src/components/ui/select/SelectContent.vue
Normal file
51
frontend/src/components/ui/select/SelectContent.vue
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectContentEmits, SelectContentProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import {
|
||||||
|
SelectContent,
|
||||||
|
SelectPortal,
|
||||||
|
SelectViewport,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { SelectScrollDownButton, SelectScrollUpButton } from "."
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<SelectContentProps & { class?: HTMLAttributes["class"] }>(),
|
||||||
|
{
|
||||||
|
position: "popper",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const emits = defineEmits<SelectContentEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectPortal>
|
||||||
|
<SelectContent
|
||||||
|
data-slot="select-content"
|
||||||
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
|
:class="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 relative z-50 max-h-(--reka-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
|
||||||
|
position === 'popper'
|
||||||
|
&& 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectViewport :class="cn('p-1', position === 'popper' && 'h-[var(--reka-select-trigger-height)] w-full min-w-[var(--reka-select-trigger-width)] scroll-my-1')">
|
||||||
|
<slot />
|
||||||
|
</SelectViewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectContent>
|
||||||
|
</SelectPortal>
|
||||||
|
</template>
|
||||||
15
frontend/src/components/ui/select/SelectGroup.vue
Normal file
15
frontend/src/components/ui/select/SelectGroup.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectGroupProps } from "reka-ui"
|
||||||
|
import { SelectGroup } from "reka-ui"
|
||||||
|
|
||||||
|
const props = defineProps<SelectGroupProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectGroup
|
||||||
|
data-slot="select-group"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</SelectGroup>
|
||||||
|
</template>
|
||||||
44
frontend/src/components/ui/select/SelectItem.vue
Normal file
44
frontend/src/components/ui/select/SelectItem.vue
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectItemProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { Check } from "lucide-vue-next"
|
||||||
|
import {
|
||||||
|
SelectItem,
|
||||||
|
SelectItemIndicator,
|
||||||
|
SelectItemText,
|
||||||
|
useForwardProps,
|
||||||
|
} from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<SelectItemProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectItem
|
||||||
|
data-slot="select-item"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'focus:bg-accent focus:text-accent-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 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 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span class="absolute right-2 flex size-3.5 items-center justify-center">
|
||||||
|
<SelectItemIndicator>
|
||||||
|
<slot name="indicator-icon">
|
||||||
|
<Check class="size-4" />
|
||||||
|
</slot>
|
||||||
|
</SelectItemIndicator>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<SelectItemText>
|
||||||
|
<slot />
|
||||||
|
</SelectItemText>
|
||||||
|
</SelectItem>
|
||||||
|
</template>
|
||||||
15
frontend/src/components/ui/select/SelectItemText.vue
Normal file
15
frontend/src/components/ui/select/SelectItemText.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectItemTextProps } from "reka-ui"
|
||||||
|
import { SelectItemText } from "reka-ui"
|
||||||
|
|
||||||
|
const props = defineProps<SelectItemTextProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectItemText
|
||||||
|
data-slot="select-item-text"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</SelectItemText>
|
||||||
|
</template>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user