Compare commits
10 Commits
300039b14e
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a95c3e93f | ||
|
|
694b02e848 | ||
|
|
832512469a | ||
| 487b4c42c4 | |||
| 37cecaa1ce | |||
| ffecc9451d | |||
| efd644dc67 | |||
| f9499c0795 | |||
| 9f9f57b379 | |||
| baf2b26de0 |
@@ -9,14 +9,15 @@ RUN CGO_ENABLED=0 GOOS=linux go build -o server ./cmd/api/main.go
|
||||
|
||||
FROM golang:1.25-alpine AS dev
|
||||
|
||||
RUN go install github.com/air-verse/air@latest
|
||||
ENV GOPROXY=https://goproxy.cn,direct
|
||||
ENV GOSUMDB=off
|
||||
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
EXPOSE 8080
|
||||
CMD ["air", "-c", ".air.toml"]
|
||||
CMD ["go", "run", "./cmd/api/main.go"]
|
||||
|
||||
FROM alpine:latest AS production
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ func Init(cfg *config.Config) *gorm.DB {
|
||||
log.Fatalf("failed to connect database: %v", err)
|
||||
}
|
||||
|
||||
if err := DB.AutoMigrate(&domain.User{}); err != nil {
|
||||
if err := DB.AutoMigrate(&domain.User{}, &domain.Bookmark{}); err != nil {
|
||||
log.Fatalf("failed to migrate database: %v", err)
|
||||
}
|
||||
|
||||
|
||||
16
backend/internal/domain/bookmark.go
Normal file
16
backend/internal/domain/bookmark.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type Bookmark struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"userId"`
|
||||
Title string `gorm:"not null" json:"title"`
|
||||
URL string `gorm:"not null" json:"url"`
|
||||
Description string `json:"description"`
|
||||
Icon string `json:"icon"`
|
||||
Category string `gorm:"default:'默认'" json:"category"`
|
||||
SortOrder int `gorm:"default:0" json:"sortOrder"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
262
backend/internal/handler/bookmark.go
Normal file
262
backend/internal/handler/bookmark.go
Normal file
@@ -0,0 +1,262 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"evanpage-backend/internal/domain"
|
||||
"evanpage-backend/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type BookmarkHandler struct {
|
||||
bookmarkService *service.BookmarkService
|
||||
userService *service.UserService
|
||||
}
|
||||
|
||||
func NewBookmarkHandler(bookmarkService *service.BookmarkService, userService *service.UserService) *BookmarkHandler {
|
||||
return &BookmarkHandler{bookmarkService: bookmarkService, userService: userService}
|
||||
}
|
||||
|
||||
func getUserID(c *gin.Context) uint {
|
||||
uid, _ := c.Get("userID")
|
||||
id, _ := strconv.ParseUint(uid.(string), 10, 64)
|
||||
return uint(id)
|
||||
}
|
||||
|
||||
type bookmarkPayload struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Description string `json:"description"`
|
||||
Icon string `json:"icon"`
|
||||
Category string `json:"category"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
}
|
||||
|
||||
func (p bookmarkPayload) toInput() service.BookmarkInput {
|
||||
return service.BookmarkInput{
|
||||
Title: p.Title,
|
||||
URL: p.URL,
|
||||
Description: p.Description,
|
||||
Icon: p.Icon,
|
||||
Category: p.Category,
|
||||
SortOrder: p.SortOrder,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *BookmarkHandler) Create(c *gin.Context) {
|
||||
var req bookmarkPayload
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Title == "" || req.URL == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "title and url are required"})
|
||||
return
|
||||
}
|
||||
|
||||
bm, err := h.bookmarkService.Create(getUserID(c), req.toInput())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, bm)
|
||||
}
|
||||
|
||||
func (h *BookmarkHandler) List(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
category := c.Query("category")
|
||||
|
||||
var bms []domain.Bookmark
|
||||
var err error
|
||||
if category != "" {
|
||||
bms, err = h.bookmarkService.ListByUserAndCategory(userID, category)
|
||||
} else {
|
||||
bms, err = h.bookmarkService.ListByUser(userID)
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
categories, _ := h.bookmarkService.ListCategoriesByUser(userID)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"bookmarks": bms,
|
||||
"categories": categories,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *BookmarkHandler) PublicList(c *gin.Context) {
|
||||
admin, err := h.userService.FindByRole("admin")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"bookmarks": []domain.Bookmark{},
|
||||
"categories": []string{},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
category := c.Query("category")
|
||||
var bms []domain.Bookmark
|
||||
if category != "" {
|
||||
bms, err = h.bookmarkService.ListByUserAndCategory(admin.ID, category)
|
||||
} else {
|
||||
bms, err = h.bookmarkService.ListByUser(admin.ID)
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
categories, _ := h.bookmarkService.ListCategoriesByUser(admin.ID)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"bookmarks": bms,
|
||||
"categories": categories,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *BookmarkHandler) Update(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
var req bookmarkPayload
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
bm, err := h.bookmarkService.Update(getUserID(c), uint(id), req.toInput())
|
||||
if err != nil {
|
||||
if err.Error() == "forbidden" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, bm)
|
||||
}
|
||||
|
||||
func (h *BookmarkHandler) Delete(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.bookmarkService.Delete(getUserID(c), uint(id)); err != nil {
|
||||
if err.Error() == "forbidden" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (h *BookmarkHandler) FetchMeta(c *gin.Context) {
|
||||
var req struct {
|
||||
URL string `json:"url" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
meta, err := service.FetchURLMeta(c.Request.Context(), req.URL)
|
||||
if err != nil {
|
||||
log.Printf("FetchMeta failed: url=%q err=%v", req.URL, err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, meta)
|
||||
}
|
||||
|
||||
func (h *BookmarkHandler) BulkCreate(c *gin.Context) {
|
||||
var req struct {
|
||||
Bookmarks []bookmarkPayload `json:"bookmarks" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
items := make([]service.BookmarkInput, len(req.Bookmarks))
|
||||
for i, p := range req.Bookmarks {
|
||||
items[i] = p.toInput()
|
||||
}
|
||||
created, err := h.bookmarkService.BulkCreate(getUserID(c), items)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"created": created})
|
||||
}
|
||||
|
||||
func (h *BookmarkHandler) BulkDelete(c *gin.Context) {
|
||||
var req struct {
|
||||
IDs []uint `json:"ids" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
deleted, err := h.bookmarkService.BulkDelete(getUserID(c), req.IDs)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": deleted})
|
||||
}
|
||||
|
||||
func (h *BookmarkHandler) BulkMove(c *gin.Context) {
|
||||
var req struct {
|
||||
IDs []uint `json:"ids" binding:"required"`
|
||||
Category string `json:"category"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
updated, err := h.bookmarkService.BulkUpdateCategory(getUserID(c), req.IDs, req.Category)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"updated": updated})
|
||||
}
|
||||
|
||||
func (h *BookmarkHandler) RenameCategory(c *gin.Context) {
|
||||
var req struct {
|
||||
From string `json:"from" binding:"required"`
|
||||
To string `json:"to" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
updated, err := h.bookmarkService.RenameCategory(getUserID(c), req.From, req.To)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"updated": updated})
|
||||
}
|
||||
|
||||
func (h *BookmarkHandler) Reorder(c *gin.Context) {
|
||||
var req struct {
|
||||
Order []uint `json:"order" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.bookmarkService.Reorder(getUserID(c), req.Order); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
95
backend/internal/repository/bookmark.go
Normal file
95
backend/internal/repository/bookmark.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"evanpage-backend/internal/domain"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type BookmarkRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewBookmarkRepository(db *gorm.DB) *BookmarkRepository {
|
||||
return &BookmarkRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *BookmarkRepository) Create(bm *domain.Bookmark) error {
|
||||
return r.db.Create(bm).Error
|
||||
}
|
||||
|
||||
func (r *BookmarkRepository) BulkCreate(bms []*domain.Bookmark) error {
|
||||
if len(bms) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.Create(&bms).Error
|
||||
}
|
||||
|
||||
func (r *BookmarkRepository) FindByID(id uint) (*domain.Bookmark, error) {
|
||||
var bm domain.Bookmark
|
||||
err := r.db.First(&bm, id).Error
|
||||
return &bm, err
|
||||
}
|
||||
|
||||
func (r *BookmarkRepository) ListByUser(userID uint) ([]domain.Bookmark, error) {
|
||||
var bms []domain.Bookmark
|
||||
err := r.db.Where("user_id = ?", userID).Order("sort_order asc, created_at desc").Find(&bms).Error
|
||||
return bms, err
|
||||
}
|
||||
|
||||
func (r *BookmarkRepository) ListByUserAndCategory(userID uint, category string) ([]domain.Bookmark, error) {
|
||||
var bms []domain.Bookmark
|
||||
err := r.db.Where("user_id = ? AND category = ?", userID, category).Order("sort_order asc, created_at desc").Find(&bms).Error
|
||||
return bms, err
|
||||
}
|
||||
|
||||
func (r *BookmarkRepository) ListCategoriesByUser(userID uint) ([]string, error) {
|
||||
var categories []string
|
||||
err := r.db.Model(&domain.Bookmark{}).Where("user_id = ?", userID).Distinct().Pluck("category", &categories).Error
|
||||
return categories, err
|
||||
}
|
||||
|
||||
func (r *BookmarkRepository) Update(bm *domain.Bookmark) error {
|
||||
return r.db.Save(bm).Error
|
||||
}
|
||||
|
||||
func (r *BookmarkRepository) Delete(id uint) error {
|
||||
return r.db.Delete(&domain.Bookmark{}, id).Error
|
||||
}
|
||||
|
||||
func (r *BookmarkRepository) BulkDeleteByUser(userID uint, ids []uint) (int64, error) {
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
res := r.db.Where("user_id = ? AND id IN ?", userID, ids).Delete(&domain.Bookmark{})
|
||||
return res.RowsAffected, res.Error
|
||||
}
|
||||
|
||||
func (r *BookmarkRepository) BulkUpdateCategoryByUser(userID uint, ids []uint, category string) (int64, error) {
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
res := r.db.Model(&domain.Bookmark{}).Where("user_id = ? AND id IN ?", userID, ids).Update("category", category)
|
||||
return res.RowsAffected, res.Error
|
||||
}
|
||||
|
||||
func (r *BookmarkRepository) RenameCategoryByUser(userID uint, from, to string) (int64, error) {
|
||||
res := r.db.Model(&domain.Bookmark{}).Where("user_id = ? AND category = ?", userID, from).Update("category", to)
|
||||
return res.RowsAffected, res.Error
|
||||
}
|
||||
|
||||
func (r *BookmarkRepository) ReorderByUser(userID uint, orderedIDs []uint) error {
|
||||
if len(orderedIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
for i, id := range orderedIDs {
|
||||
if err := tx.Model(&domain.Bookmark{}).
|
||||
Where("id = ? AND user_id = ?", id, userID).
|
||||
Update("sort_order", i).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -41,6 +41,12 @@ func (r *UserRepository) FindByKeycloakID(keycloakID string) (*domain.User, erro
|
||||
return &user, err
|
||||
}
|
||||
|
||||
func (r *UserRepository) FindByRole(role string) (*domain.User, error) {
|
||||
var user domain.User
|
||||
err := r.db.Where("role = ?", role).First(&user).Error
|
||||
return &user, err
|
||||
}
|
||||
|
||||
func (r *UserRepository) ListAll() ([]domain.User, error) {
|
||||
var users []domain.User
|
||||
err := r.db.Select("id", "username", "email", "role", "keycloak_id", "created_at", "updated_at").Find(&users).Error
|
||||
|
||||
@@ -20,8 +20,12 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
userRepo := repository.NewUserRepository(db.DB)
|
||||
userService := service.NewUserService(userRepo)
|
||||
|
||||
bookmarkRepo := repository.NewBookmarkRepository(db.DB)
|
||||
bookmarkService := service.NewBookmarkService(bookmarkRepo)
|
||||
|
||||
authHandler := handler.NewAuthHandler(userService)
|
||||
healthHandler := handler.NewHealthHandler(db.DB)
|
||||
bookmarkHandler := handler.NewBookmarkHandler(bookmarkService, userService)
|
||||
|
||||
// Public routes
|
||||
r.POST("/api/auth/local-login", authHandler.LocalLogin)
|
||||
@@ -29,6 +33,23 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
r.POST("/api/auth/bind-keycloak", authHandler.BindKeycloak)
|
||||
r.POST("/api/auth/init", authHandler.InitAdmin)
|
||||
r.GET("/api/health", healthHandler.Check)
|
||||
r.GET("/api/bookmarks/public", bookmarkHandler.PublicList)
|
||||
|
||||
// Authenticated routes
|
||||
auth := r.Group("/api")
|
||||
auth.Use(middleware.AuthProxy())
|
||||
{
|
||||
auth.GET("/bookmarks", bookmarkHandler.List)
|
||||
auth.POST("/bookmarks", bookmarkHandler.Create)
|
||||
auth.POST("/bookmarks/meta", bookmarkHandler.FetchMeta)
|
||||
auth.POST("/bookmarks/bulk", bookmarkHandler.BulkCreate)
|
||||
auth.POST("/bookmarks/bulk-delete", bookmarkHandler.BulkDelete)
|
||||
auth.POST("/bookmarks/bulk-move", bookmarkHandler.BulkMove)
|
||||
auth.POST("/bookmarks/reorder", bookmarkHandler.Reorder)
|
||||
auth.POST("/bookmarks/rename-category", bookmarkHandler.RenameCategory)
|
||||
auth.PUT("/bookmarks/:id", bookmarkHandler.Update)
|
||||
auth.DELETE("/bookmarks/:id", bookmarkHandler.Delete)
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
147
backend/internal/service/bookmark.go
Normal file
147
backend/internal/service/bookmark.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"evanpage-backend/internal/domain"
|
||||
"evanpage-backend/internal/repository"
|
||||
)
|
||||
|
||||
type BookmarkService struct {
|
||||
repo *repository.BookmarkRepository
|
||||
}
|
||||
|
||||
type BookmarkInput struct {
|
||||
Title string
|
||||
URL string
|
||||
Description string
|
||||
Icon string
|
||||
Category string
|
||||
SortOrder int
|
||||
}
|
||||
|
||||
func NewBookmarkService(repo *repository.BookmarkRepository) *BookmarkService {
|
||||
return &BookmarkService{repo: repo}
|
||||
}
|
||||
|
||||
func (s *BookmarkService) Create(userID uint, in BookmarkInput) (*domain.Bookmark, error) {
|
||||
if in.Title == "" || in.URL == "" {
|
||||
return nil, errors.New("title and url are required")
|
||||
}
|
||||
if in.Category == "" {
|
||||
in.Category = "默认"
|
||||
}
|
||||
bm := &domain.Bookmark{
|
||||
UserID: userID,
|
||||
Title: in.Title,
|
||||
URL: in.URL,
|
||||
Description: in.Description,
|
||||
Icon: in.Icon,
|
||||
Category: in.Category,
|
||||
SortOrder: in.SortOrder,
|
||||
}
|
||||
if err := s.repo.Create(bm); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bm, nil
|
||||
}
|
||||
|
||||
func (s *BookmarkService) BulkCreate(userID uint, items []BookmarkInput) (int, error) {
|
||||
bms := make([]*domain.Bookmark, 0, len(items))
|
||||
for _, in := range items {
|
||||
if in.Title == "" || in.URL == "" {
|
||||
continue
|
||||
}
|
||||
cat := in.Category
|
||||
if cat == "" {
|
||||
cat = "默认"
|
||||
}
|
||||
bms = append(bms, &domain.Bookmark{
|
||||
UserID: userID,
|
||||
Title: in.Title,
|
||||
URL: in.URL,
|
||||
Description: in.Description,
|
||||
Icon: in.Icon,
|
||||
Category: cat,
|
||||
SortOrder: in.SortOrder,
|
||||
})
|
||||
}
|
||||
if err := s.repo.BulkCreate(bms); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(bms), nil
|
||||
}
|
||||
|
||||
func (s *BookmarkService) ListByUser(userID uint) ([]domain.Bookmark, error) {
|
||||
return s.repo.ListByUser(userID)
|
||||
}
|
||||
|
||||
func (s *BookmarkService) ListByUserAndCategory(userID uint, category string) ([]domain.Bookmark, error) {
|
||||
return s.repo.ListByUserAndCategory(userID, category)
|
||||
}
|
||||
|
||||
func (s *BookmarkService) ListCategoriesByUser(userID uint) ([]string, error) {
|
||||
return s.repo.ListCategoriesByUser(userID)
|
||||
}
|
||||
|
||||
func (s *BookmarkService) Update(userID, bookmarkID uint, in BookmarkInput) (*domain.Bookmark, error) {
|
||||
bm, err := s.repo.FindByID(bookmarkID)
|
||||
if err != nil {
|
||||
return nil, errors.New("bookmark not found")
|
||||
}
|
||||
if bm.UserID != userID {
|
||||
return nil, errors.New("forbidden")
|
||||
}
|
||||
if in.Title != "" {
|
||||
bm.Title = in.Title
|
||||
}
|
||||
if in.URL != "" {
|
||||
bm.URL = in.URL
|
||||
}
|
||||
bm.Description = in.Description
|
||||
bm.Icon = in.Icon
|
||||
if in.Category != "" {
|
||||
bm.Category = in.Category
|
||||
}
|
||||
bm.SortOrder = in.SortOrder
|
||||
if err := s.repo.Update(bm); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bm, nil
|
||||
}
|
||||
|
||||
func (s *BookmarkService) Delete(userID, bookmarkID uint) error {
|
||||
bm, err := s.repo.FindByID(bookmarkID)
|
||||
if err != nil {
|
||||
return errors.New("bookmark not found")
|
||||
}
|
||||
if bm.UserID != userID {
|
||||
return errors.New("forbidden")
|
||||
}
|
||||
return s.repo.Delete(bookmarkID)
|
||||
}
|
||||
|
||||
func (s *BookmarkService) BulkDelete(userID uint, ids []uint) (int64, error) {
|
||||
return s.repo.BulkDeleteByUser(userID, ids)
|
||||
}
|
||||
|
||||
func (s *BookmarkService) BulkUpdateCategory(userID uint, ids []uint, category string) (int64, error) {
|
||||
if category == "" {
|
||||
category = "默认"
|
||||
}
|
||||
return s.repo.BulkUpdateCategoryByUser(userID, ids, category)
|
||||
}
|
||||
|
||||
func (s *BookmarkService) RenameCategory(userID uint, from, to string) (int64, error) {
|
||||
if from == "" || to == "" {
|
||||
return 0, errors.New("from and to are required")
|
||||
}
|
||||
if from == to {
|
||||
return 0, nil
|
||||
}
|
||||
return s.repo.RenameCategoryByUser(userID, from, to)
|
||||
}
|
||||
|
||||
func (s *BookmarkService) Reorder(userID uint, orderedIDs []uint) error {
|
||||
return s.repo.ReorderByUser(userID, orderedIDs)
|
||||
}
|
||||
210
backend/internal/service/meta.go
Normal file
210
backend/internal/service/meta.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
type FetchedMeta struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Icon string `json:"icon"`
|
||||
}
|
||||
|
||||
const (
|
||||
metaTimeout = 8 * time.Second
|
||||
metaMaxBodyBytes = 1 << 20 // 1 MiB
|
||||
)
|
||||
|
||||
var safeHTTPClient = &http.Client{
|
||||
Timeout: metaTimeout,
|
||||
Transport: &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 5 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
Control: restrictAddress,
|
||||
}).DialContext,
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxIdleConns: 10,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
TLSHandshakeTimeout: 5 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
},
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 5 {
|
||||
return errors.New("too many redirects")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func FetchURLMeta(ctx context.Context, raw string) (*FetchedMeta, error) {
|
||||
u, err := normalizeURL(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; EvanPageBot/1.0)")
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml")
|
||||
|
||||
resp, err := safeHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, errors.New("upstream returned " + resp.Status)
|
||||
}
|
||||
|
||||
doc, err := html.Parse(io.LimitReader(resp.Body, metaMaxBodyBytes))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
meta := extractMeta(doc)
|
||||
meta.Icon = resolveIcon(u, meta.Icon)
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
func normalizeURL(raw string) (*url.URL, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, errors.New("url is required")
|
||||
}
|
||||
if !strings.Contains(raw, "://") {
|
||||
raw = "https://" + raw
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid url")
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return nil, errors.New("only http(s) urls are allowed")
|
||||
}
|
||||
if u.Host == "" {
|
||||
return nil, errors.New("invalid url")
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func restrictAddress(network, address string, _ syscall.RawConn) error {
|
||||
if network != "tcp" && network != "tcp4" && network != "tcp6" {
|
||||
return errors.New("disallowed network")
|
||||
}
|
||||
host, _, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
return errors.New("address is not an ip")
|
||||
}
|
||||
if isBlockedIP(ip) {
|
||||
return errors.New("blocked ip range")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isBlockedIP(ip net.IP) bool {
|
||||
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
|
||||
ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() ||
|
||||
ip.IsMulticast() || ip.IsUnspecified() {
|
||||
return true
|
||||
}
|
||||
// Block 100.64.0.0/10 (CGNAT) and 169.254.0.0/16 (link-local) explicitly
|
||||
cgnat := net.IPNet{IP: net.IPv4(100, 64, 0, 0), Mask: net.CIDRMask(10, 32)}
|
||||
if v4 := ip.To4(); v4 != nil && cgnat.Contains(v4) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func extractMeta(n *html.Node) *FetchedMeta {
|
||||
m := &FetchedMeta{}
|
||||
var walk func(*html.Node)
|
||||
walk = func(node *html.Node) {
|
||||
if node.Type == html.ElementNode {
|
||||
switch strings.ToLower(node.Data) {
|
||||
case "title":
|
||||
if m.Title == "" && node.FirstChild != nil {
|
||||
m.Title = strings.TrimSpace(textOf(node))
|
||||
}
|
||||
case "meta":
|
||||
name := strings.ToLower(attr(node, "name"))
|
||||
prop := strings.ToLower(attr(node, "property"))
|
||||
content := attr(node, "content")
|
||||
switch {
|
||||
case name == "description" && m.Description == "":
|
||||
m.Description = strings.TrimSpace(content)
|
||||
case prop == "og:description" && m.Description == "":
|
||||
m.Description = strings.TrimSpace(content)
|
||||
case prop == "og:title" && m.Title == "":
|
||||
m.Title = strings.TrimSpace(content)
|
||||
}
|
||||
case "link":
|
||||
rel := strings.ToLower(attr(node, "rel"))
|
||||
href := attr(node, "href")
|
||||
if href == "" {
|
||||
return
|
||||
}
|
||||
if strings.Contains(rel, "icon") && m.Icon == "" {
|
||||
m.Icon = href
|
||||
}
|
||||
}
|
||||
}
|
||||
for c := node.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(n)
|
||||
return m
|
||||
}
|
||||
|
||||
func attr(n *html.Node, key string) string {
|
||||
for _, a := range n.Attr {
|
||||
if strings.EqualFold(a.Key, key) {
|
||||
return a.Val
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func textOf(n *html.Node) string {
|
||||
var b strings.Builder
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
if c.Type == html.TextNode {
|
||||
b.WriteString(c.Data)
|
||||
} else {
|
||||
b.WriteString(textOf(c))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func resolveIcon(base *url.URL, icon string) string {
|
||||
if icon == "" {
|
||||
return base.Scheme + "://" + base.Host + "/favicon.ico"
|
||||
}
|
||||
ref, err := url.Parse(icon)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if ref.IsAbs() {
|
||||
return ref.String()
|
||||
}
|
||||
return base.ResolveReference(ref).String()
|
||||
}
|
||||
@@ -93,6 +93,10 @@ func (s *UserService) BindKeycloak(username, password, keycloakID, keycloakEmail
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *UserService) FindByRole(role string) (*domain.User, error) {
|
||||
return s.repo.FindByRole(role)
|
||||
}
|
||||
|
||||
func (s *UserService) ListUsers() ([]domain.User, error) {
|
||||
return s.repo.ListAll()
|
||||
}
|
||||
|
||||
@@ -6,8 +6,6 @@ services:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-evan}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-evanpass}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-evanpage}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
@@ -31,8 +29,6 @@ services:
|
||||
AUTH_KEYCLOAK_ISSUER: ${AUTH_KEYCLOAK_ISSUER:-}
|
||||
AUTH_KEYCLOAK_ID: ${AUTH_KEYCLOAK_ID:-}
|
||||
AUTH_KEYCLOAK_SECRET: ${AUTH_KEYCLOAK_SECRET:-}
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- /app/tmp
|
||||
@@ -45,6 +41,7 @@ services:
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
target: production
|
||||
container_name: evanpage-frontend
|
||||
environment:
|
||||
SERVER_API_URL: ${SERVER_API_URL:-http://backend:8080}
|
||||
@@ -53,11 +50,11 @@ services:
|
||||
AUTH_KEYCLOAK_ISSUER: ${AUTH_KEYCLOAK_ISSUER:-}
|
||||
AUTH_KEYCLOAK_ID: ${AUTH_KEYCLOAK_ID:-}
|
||||
AUTH_KEYCLOAK_SECRET: ${AUTH_KEYCLOAK_SECRET:-}
|
||||
AUTH_URL: ${AUTH_URL:-https://www.liukersun.com}
|
||||
NEXTAUTH_URL: ${NEXTAUTH_URL:-https://www.liukersun.com}
|
||||
AUTH_TRUST_HOST: ${AUTH_TRUST_HOST:-true}
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- /app/node_modules
|
||||
- "127.0.0.1:3001:3000"
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
|
||||
@@ -1,15 +1,37 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
FROM node:20-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_OPTIONS=--max-old-space-size=1024
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine AS production
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
FROM node:20-alpine AS dev
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 3000
|
||||
ENV NODE_ENV=development
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
|
||||
CMD ["npm", "run", "dev"]
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
role: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
username: "",
|
||||
email: "",
|
||||
password: "",
|
||||
role: "user",
|
||||
});
|
||||
|
||||
async function fetchUsers() {
|
||||
const res = await fetch("/api/proxy/admin/users");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setUsers(data.users || []);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const res = await fetch("/api/proxy/admin/users", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setOpen(false);
|
||||
setForm({ username: "", email: "", password: "", role: "user" });
|
||||
fetchUsers();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
if (!confirm("确定删除该用户?")) return;
|
||||
const res = await fetch(`/api/proxy/admin/users/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (res.ok) {
|
||||
fetchUsers();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">用户管理</h1>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger render={<Button>创建用户</Button>} />
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建用户</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
<div>
|
||||
<Label>用户名</Label>
|
||||
<Input
|
||||
value={form.username}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, username: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>邮箱</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, email: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>密码</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, password: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>角色</Label>
|
||||
<select
|
||||
className="w-full rounded-md border px-3 py-2 text-sm"
|
||||
value={form.role}
|
||||
onChange={(e) => setForm({ ...form, role: e.target.value })}
|
||||
>
|
||||
<option value="user">user</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<Button type="submit" className="w-full">
|
||||
创建
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>用户列表</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<p>加载中...</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>用户名</TableHead>
|
||||
<TableHead>邮箱</TableHead>
|
||||
<TableHead>角色</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>{user.id}</TableCell>
|
||||
<TableCell>{user.username}</TableCell>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell>{user.role}</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(user.id)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1395
frontend/app/(main)/dashboard/bookmarks/bookmark-manager.tsx
Normal file
1395
frontend/app/(main)/dashboard/bookmarks/bookmark-manager.tsx
Normal file
File diff suppressed because it is too large
Load Diff
16
frontend/app/(main)/dashboard/bookmarks/page.tsx
Normal file
16
frontend/app/(main)/dashboard/bookmarks/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { BookmarkManager } from "./bookmark-manager";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "书签管理",
|
||||
};
|
||||
|
||||
export default async function BookmarksPage() {
|
||||
const session = await auth();
|
||||
const role = (session?.user as { role?: string } | undefined)?.role;
|
||||
if (role !== "admin") redirect("/unauthorized");
|
||||
|
||||
return <BookmarkManager />;
|
||||
}
|
||||
@@ -1,13 +1,50 @@
|
||||
import type { Metadata } from "next";
|
||||
import { auth } from "@/auth";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Globe, ExternalLink } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "仪表盘",
|
||||
};
|
||||
|
||||
const SERVER_API_URL = process.env.SERVER_API_URL || "http://backend:8080";
|
||||
|
||||
interface Bookmark {
|
||||
id: number;
|
||||
title: string;
|
||||
url: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
async function fetchPublicBookmarks(): Promise<Bookmark[]> {
|
||||
try {
|
||||
const res = await fetch(`${SERVER_API_URL}/api/bookmarks/public`, {
|
||||
next: { revalidate: 0 },
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data.bookmarks?.slice(0, 6) || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await auth();
|
||||
const user = session?.user as any;
|
||||
const isAdmin = user?.role === "admin";
|
||||
const bookmarks = await fetchPublicBookmarks();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold">欢迎,{user?.name || user?.email}</h1>
|
||||
<h1 className="text-2xl font-bold">
|
||||
欢迎,{user?.name || user?.email}
|
||||
</h1>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>用户信息</CardTitle>
|
||||
@@ -27,6 +64,57 @@ export default async function DashboardPage() {
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>最近书签</CardTitle>
|
||||
{isAdmin && (
|
||||
<Link
|
||||
href="/dashboard/bookmarks"
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
管理 →
|
||||
</Link>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{bookmarks.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
还没有公开书签
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{bookmarks.map((bm) => (
|
||||
<a
|
||||
key={bm.id}
|
||||
href={bm.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 rounded-lg p-2 text-sm transition-colors hover:bg-muted"
|
||||
>
|
||||
<div className="flex size-6 shrink-0 items-center justify-center">
|
||||
{bm.icon ? (
|
||||
<img
|
||||
src={bm.icon}
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
loading="lazy"
|
||||
className="size-4 object-contain"
|
||||
/>
|
||||
) : (
|
||||
<Globe className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<span className="truncate font-medium">{bm.title}</span>
|
||||
<ExternalLink className="ml-auto size-3 shrink-0 text-muted-foreground" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { auth } from "@/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { signOut } from "@/auth";
|
||||
import { HomeDock } from "../home-dock";
|
||||
|
||||
export default async function MainLayout({
|
||||
children,
|
||||
@@ -9,38 +7,15 @@ export default async function MainLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const session = await auth();
|
||||
const user = session?.user as any;
|
||||
const user = session?.user as { role?: string } | undefined;
|
||||
const isAdmin = user?.role === "admin";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<header className="border-b bg-white">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-3">
|
||||
<Link href="/" className="text-lg font-bold">
|
||||
EvanPage
|
||||
</Link>
|
||||
<nav className="flex items-center gap-4">
|
||||
<Link href="/dashboard" className="text-sm hover:underline">
|
||||
仪表盘
|
||||
</Link>
|
||||
{user?.role === "admin" && (
|
||||
<Link href="/admin" className="text-sm hover:underline">
|
||||
管理后台
|
||||
</Link>
|
||||
)}
|
||||
<form
|
||||
action={async () => {
|
||||
"use server";
|
||||
await signOut({ redirectTo: "/login" });
|
||||
}}
|
||||
>
|
||||
<Button variant="ghost" size="sm" type="submit">
|
||||
退出
|
||||
</Button>
|
||||
</form>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto max-w-6xl px-4 py-6">{children}</main>
|
||||
<div className="min-h-screen bg-gradient-to-br from-background via-background to-muted/40">
|
||||
<main className="mx-auto max-w-6xl px-4 pt-8 pb-32 sm:px-6 sm:pt-12 lg:px-8">
|
||||
{children}
|
||||
</main>
|
||||
<HomeDock isAuthenticated={!!session?.user} isAdmin={isAdmin} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,9 @@ async function handler(
|
||||
body,
|
||||
});
|
||||
|
||||
const data = await res.arrayBuffer();
|
||||
// Per Fetch spec, Response with 204/205/304 must not have a body.
|
||||
const hasBody = res.status !== 204 && res.status !== 205 && res.status !== 304;
|
||||
const data = hasBody ? await res.arrayBuffer() : null;
|
||||
|
||||
return new NextResponse(data, {
|
||||
status: res.status,
|
||||
|
||||
@@ -52,7 +52,7 @@ function BindForm() {
|
||||
<CardTitle className="text-center">绑定本地账号</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
您的 Keycloak 账号({keycloakEmail || keycloakId})尚未绑定本地账户。
|
||||
请输入已有的本地账号密码完成绑定。
|
||||
</p>
|
||||
@@ -76,7 +76,7 @@ function BindForm() {
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<Button type="submit" className="w-full">
|
||||
绑定并登录
|
||||
</Button>
|
||||
@@ -88,8 +88,8 @@ function BindForm() {
|
||||
|
||||
export default function BindAccountPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-4">
|
||||
<Suspense fallback={<div>加载中...</div>}>
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-background via-background to-muted/40 p-4">
|
||||
<Suspense fallback={<div className="text-sm text-muted-foreground">加载中...</div>}>
|
||||
<BindForm />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
@@ -49,72 +49,72 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--background: oklch(0.99 0.002 240);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.546 0.218 263);
|
||||
--primary-foreground: oklch(0.99 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.965 0.005 240);
|
||||
--muted-foreground: oklch(0.5 0.02 240);
|
||||
--accent: oklch(0.95 0.03 263);
|
||||
--accent-foreground: oklch(0.35 0.18 263);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--border: oklch(0.92 0.01 240);
|
||||
--input: oklch(0.92 0.01 240);
|
||||
--ring: oklch(0.546 0.218 263);
|
||||
--chart-1: oklch(0.546 0.218 263);
|
||||
--chart-2: oklch(0.65 0.16 200);
|
||||
--chart-3: oklch(0.7 0.18 145);
|
||||
--chart-4: oklch(0.75 0.18 80);
|
||||
--chart-5: oklch(0.65 0.22 25);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--sidebar-primary: oklch(0.546 0.218 263);
|
||||
--sidebar-primary-foreground: oklch(0.99 0 0);
|
||||
--sidebar-accent: oklch(0.95 0.03 263);
|
||||
--sidebar-accent-foreground: oklch(0.35 0.18 263);
|
||||
--sidebar-border: oklch(0.92 0.01 240);
|
||||
--sidebar-ring: oklch(0.546 0.218 263);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--background: oklch(0.16 0.01 240);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card: oklch(0.21 0.012 240);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover: oklch(0.21 0.012 240);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--primary: oklch(0.65 0.21 263);
|
||||
--primary-foreground: oklch(0.16 0.01 240);
|
||||
--secondary: oklch(0.27 0.012 240);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.25 0.012 240);
|
||||
--muted-foreground: oklch(0.72 0.015 240);
|
||||
--accent: oklch(0.3 0.05 263);
|
||||
--accent-foreground: oklch(0.85 0.1 263);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--border: oklch(1 0 0 / 14%);
|
||||
--input: oklch(1 0 0 / 18%);
|
||||
--ring: oklch(0.65 0.21 263);
|
||||
--chart-1: oklch(0.65 0.21 263);
|
||||
--chart-2: oklch(0.7 0.16 200);
|
||||
--chart-3: oklch(0.72 0.18 145);
|
||||
--chart-4: oklch(0.78 0.18 80);
|
||||
--chart-5: oklch(0.7 0.22 25);
|
||||
--sidebar: oklch(0.21 0.012 240);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--sidebar-primary: oklch(0.65 0.21 263);
|
||||
--sidebar-primary-foreground: oklch(0.16 0.01 240);
|
||||
--sidebar-accent: oklch(0.3 0.05 263);
|
||||
--sidebar-accent-foreground: oklch(0.85 0.1 263);
|
||||
--sidebar-border: oklch(1 0 0 / 14%);
|
||||
--sidebar-ring: oklch(0.65 0.21 263);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -126,5 +126,19 @@
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
color-scheme: light;
|
||||
}
|
||||
html.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
205
frontend/app/home-dock.tsx
Normal file
205
frontend/app/home-dock.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Dock, DockIcon } from "@/components/ui/dock";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { signOut } from "next-auth/react";
|
||||
import {
|
||||
Home,
|
||||
LogIn,
|
||||
LogOut,
|
||||
Download,
|
||||
BookOpen,
|
||||
LayoutDashboard,
|
||||
Bookmark,
|
||||
} from "lucide-react";
|
||||
|
||||
function useIsTouch() {
|
||||
const [touch, setTouch] = useState(false);
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const mq = window.matchMedia("(hover: none) and (pointer: coarse)");
|
||||
const update = () => setTouch(mq.matches);
|
||||
update();
|
||||
mq.addEventListener?.("change", update);
|
||||
return () => mq.removeEventListener?.("change", update);
|
||||
}, []);
|
||||
return touch;
|
||||
}
|
||||
|
||||
export function HomeDock({
|
||||
isAuthenticated,
|
||||
isAdmin,
|
||||
onLoginClick,
|
||||
}: {
|
||||
isAuthenticated: boolean;
|
||||
isAdmin: boolean;
|
||||
onLoginClick?: () => void;
|
||||
}) {
|
||||
const isTouch = useIsTouch();
|
||||
const pathname = usePathname() || "/";
|
||||
const isActive = (path: string) =>
|
||||
path === "/" ? pathname === "/" : pathname.startsWith(path);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed left-1/2 z-40 -translate-x-1/2"
|
||||
style={{ bottom: "max(1.25rem, env(safe-area-inset-bottom))" }}
|
||||
>
|
||||
<Dock disableMagnification={isTouch}>
|
||||
<DockIcon>
|
||||
<NavLink href="/" label="首页" active={isActive("/")}>
|
||||
<Home className="h-5 w-5" />
|
||||
</NavLink>
|
||||
</DockIcon>
|
||||
<DockIcon>
|
||||
<ExternalNavLink href="https://file.liukersun.com" label="下载网站">
|
||||
<Download className="h-5 w-5" />
|
||||
</ExternalNavLink>
|
||||
</DockIcon>
|
||||
<DockIcon>
|
||||
<ExternalNavLink href="https://blog.liukersun.com" label="博客">
|
||||
<BookOpen className="h-5 w-5" />
|
||||
</ExternalNavLink>
|
||||
</DockIcon>
|
||||
{isAuthenticated && (
|
||||
<DockIcon>
|
||||
<NavLink
|
||||
href="/dashboard"
|
||||
label="仪表盘"
|
||||
active={pathname === "/dashboard"}
|
||||
>
|
||||
<LayoutDashboard className="h-5 w-5" />
|
||||
</NavLink>
|
||||
</DockIcon>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DockIcon>
|
||||
<NavLink
|
||||
href="/dashboard/bookmarks"
|
||||
label="书签管理"
|
||||
active={pathname.startsWith("/dashboard/bookmarks")}
|
||||
>
|
||||
<Bookmark className="h-5 w-5" />
|
||||
</NavLink>
|
||||
</DockIcon>
|
||||
)}
|
||||
<DockIcon>
|
||||
<DockTooltip label="主题">
|
||||
<ThemeToggle />
|
||||
</DockTooltip>
|
||||
</DockIcon>
|
||||
<DockIcon>
|
||||
{isAuthenticated ? (
|
||||
<DockTooltip label="退出登录">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => signOut({ redirectTo: "/" })}
|
||||
aria-label="退出登录"
|
||||
className="flex h-full w-full items-center justify-center text-foreground/80 hover:text-foreground"
|
||||
>
|
||||
<LogOut className="h-5 w-5" />
|
||||
</button>
|
||||
</DockTooltip>
|
||||
) : (
|
||||
<DockTooltip label="登录">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLoginClick}
|
||||
aria-label="登录"
|
||||
className="flex h-full w-full items-center justify-center text-foreground/80 hover:text-foreground"
|
||||
>
|
||||
<LogIn className="h-5 w-5" />
|
||||
</button>
|
||||
</DockTooltip>
|
||||
)}
|
||||
</DockIcon>
|
||||
</Dock>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DockTooltip({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="group relative flex h-full w-full items-center justify-center">
|
||||
{children}
|
||||
<TooltipBubble>{label}</TooltipBubble>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TooltipBubble({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<span
|
||||
role="tooltip"
|
||||
className="pointer-events-none absolute -top-9 left-1/2 z-50 -translate-x-1/2 translate-y-1 whitespace-nowrap rounded-md border border-border bg-popover px-2 py-1 text-xs font-medium text-popover-foreground opacity-0 shadow-md transition-[opacity,transform] duration-150 group-hover:translate-y-0 group-hover:opacity-100 group-focus-within:translate-y-0 group-focus-within:opacity-100"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function NavLink({
|
||||
href,
|
||||
label,
|
||||
active,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
label: string;
|
||||
active: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
aria-label={label}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={`group relative flex h-full w-full items-center justify-center transition-colors ${
|
||||
active
|
||||
? "text-primary"
|
||||
: "text-foreground/80 hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
{active && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute -bottom-1 left-1/2 h-1 w-1 -translate-x-1/2 rounded-full bg-primary"
|
||||
/>
|
||||
)}
|
||||
<TooltipBubble>{label}</TooltipBubble>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function ExternalNavLink({
|
||||
href,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={label}
|
||||
className="group relative flex h-full w-full items-center justify-center text-foreground/80 transition-colors hover:text-foreground"
|
||||
>
|
||||
{children}
|
||||
<TooltipBubble>{label}</TooltipBubble>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
365
frontend/app/home-page-client.tsx
Normal file
365
frontend/app/home-page-client.tsx
Normal file
@@ -0,0 +1,365 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { BlurFade } from "@/components/magicui/blur-fade";
|
||||
import { HomeDock } from "./home-dock";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { LoginForm } from "./login/login-form";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Globe,
|
||||
ExternalLink,
|
||||
Link2,
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { hostnameOf, faviconFallback } from "@/lib/bookmarks";
|
||||
|
||||
interface Bookmark {
|
||||
id: number;
|
||||
title: string;
|
||||
url: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
const DEFAULT_CATEGORY = "默认";
|
||||
const MAX_DELAY = 0.6;
|
||||
|
||||
function clampDelay(value: number) {
|
||||
return Math.min(value, MAX_DELAY);
|
||||
}
|
||||
|
||||
export function HomePageClient({
|
||||
isAuthenticated,
|
||||
isAdmin,
|
||||
hasKeycloak,
|
||||
bookmarks,
|
||||
}: {
|
||||
isAuthenticated: boolean;
|
||||
isAdmin: boolean;
|
||||
hasKeycloak: boolean;
|
||||
bookmarks: Bookmark[];
|
||||
}) {
|
||||
const searchParams = useSearchParams();
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [activeCategory, setActiveCategory] = useState<string | null>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const wantLogin = searchParams.get("login") === "1" && !isAuthenticated;
|
||||
const [prevWantLogin, setPrevWantLogin] = useState(false);
|
||||
if (wantLogin !== prevWantLogin) {
|
||||
setPrevWantLogin(wantLogin);
|
||||
if (wantLogin) setLoginOpen(true);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key !== "/" || e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (
|
||||
target &&
|
||||
(target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.isContentEditable)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
searchInputRef.current?.focus();
|
||||
}
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const b of bookmarks) set.add(b.category || DEFAULT_CATEGORY);
|
||||
return Array.from(set).sort((a, b) => a.localeCompare(b, "zh"));
|
||||
}, [bookmarks]);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const m = new Map<string, number>();
|
||||
for (const b of bookmarks) {
|
||||
const cat = b.category || DEFAULT_CATEGORY;
|
||||
m.set(cat, (m.get(cat) || 0) + 1);
|
||||
}
|
||||
return m;
|
||||
}, [bookmarks]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return bookmarks.filter((b) => {
|
||||
if (
|
||||
activeCategory &&
|
||||
(b.category || DEFAULT_CATEGORY) !== activeCategory
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (!q) return true;
|
||||
return (
|
||||
b.title.toLowerCase().includes(q) ||
|
||||
b.url.toLowerCase().includes(q) ||
|
||||
(b.description || "").toLowerCase().includes(q) ||
|
||||
hostnameOf(b.url).toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}, [bookmarks, search, activeCategory]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, Bookmark[]>();
|
||||
for (const b of filtered) {
|
||||
const cat = b.category || DEFAULT_CATEGORY;
|
||||
if (!map.has(cat)) map.set(cat, []);
|
||||
map.get(cat)!.push(b);
|
||||
}
|
||||
return Array.from(map.entries()).sort(([a], [b]) =>
|
||||
a.localeCompare(b, "zh"),
|
||||
);
|
||||
}, [filtered]);
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen flex-col items-center bg-gradient-to-br from-background via-background to-muted/40 px-4 pb-32 pt-4">
|
||||
<div className="flex flex-col items-center justify-center pb-8 pt-12">
|
||||
<BlurFade inView delay={0.1}>
|
||||
<h1 className="mb-3 text-center text-4xl font-extrabold tracking-tight text-foreground">
|
||||
EvanPage
|
||||
</h1>
|
||||
</BlurFade>
|
||||
</div>
|
||||
|
||||
{bookmarks.length > 0 && (
|
||||
<div className="w-full max-w-5xl space-y-5">
|
||||
<BlurFade inView delay={0.3}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="relative w-full max-w-sm">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="搜索书签… 按 / 聚焦"
|
||||
className="bg-background/70 pl-9 backdrop-blur"
|
||||
aria-label="搜索书签"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearch("")}
|
||||
aria-label="清空搜索"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-md p-1 text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<Link
|
||||
href="/dashboard/bookmarks"
|
||||
className="text-xs text-muted-foreground transition-colors hover:text-primary hover:underline"
|
||||
>
|
||||
管理书签 →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</BlurFade>
|
||||
|
||||
<BlurFade inView delay={0.35}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<CategoryChip
|
||||
label="全部"
|
||||
count={bookmarks.length}
|
||||
active={activeCategory === null}
|
||||
onClick={() => setActiveCategory(null)}
|
||||
/>
|
||||
{categories.map((c) => (
|
||||
<CategoryChip
|
||||
key={c}
|
||||
label={c}
|
||||
count={counts.get(c) || 0}
|
||||
active={activeCategory === c}
|
||||
onClick={() => setActiveCategory(c)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</BlurFade>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<BlurFade inView delay={0.4}>
|
||||
<div className="rounded-xl border border-dashed border-border bg-card/50 py-10 text-center text-sm text-muted-foreground backdrop-blur">
|
||||
没有匹配「{search || activeCategory}」的书签
|
||||
</div>
|
||||
</BlurFade>
|
||||
) : (
|
||||
grouped.map(([cat, items], catIdx) => (
|
||||
<BlurFade key={cat} inView delay={clampDelay(0.4 + catIdx * 0.05)}>
|
||||
<div>
|
||||
<h3 className="mb-3 px-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{cat}
|
||||
<span className="ml-2 text-muted-foreground/60">
|
||||
{items.length}
|
||||
</span>
|
||||
</h3>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((bm) => (
|
||||
<BookmarkCard key={bm.id} bookmark={bm} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</BlurFade>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bookmarks.length === 0 && (
|
||||
<BlurFade inView delay={0.4}>
|
||||
<div className="w-full max-w-md rounded-2xl border border-dashed border-border bg-card/60 py-10 text-center backdrop-blur">
|
||||
<Link2 className="mx-auto mb-3 size-8 text-muted-foreground" />
|
||||
<p className="mb-2 text-muted-foreground">还没有书签</p>
|
||||
{isAdmin && (
|
||||
<Link
|
||||
href="/dashboard/bookmarks"
|
||||
className="text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
添加第一个书签 →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</BlurFade>
|
||||
)}
|
||||
|
||||
<HomeDock
|
||||
isAuthenticated={isAuthenticated}
|
||||
isAdmin={isAdmin}
|
||||
onLoginClick={() => !isAuthenticated && setLoginOpen(true)}
|
||||
/>
|
||||
|
||||
<Dialog open={loginOpen} onOpenChange={setLoginOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-center">登录</DialogTitle>
|
||||
</DialogHeader>
|
||||
<LoginForm
|
||||
hasKeycloak={hasKeycloak}
|
||||
onSuccess={() => setLoginOpen(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryChip({
|
||||
label,
|
||||
count,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
count: number;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs transition-colors ${
|
||||
active
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border bg-card/60 text-foreground/70 hover:bg-muted hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<span>{label}</span>
|
||||
<span
|
||||
className={`rounded-full px-1.5 text-[10px] ${
|
||||
active
|
||||
? "bg-primary-foreground/20"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function BookmarkCard({ bookmark }: { bookmark: Bookmark }) {
|
||||
const host = hostnameOf(bookmark.url);
|
||||
return (
|
||||
<Card className="group transition-all hover:-translate-y-0.5 hover:shadow-md focus-within:ring-2 focus-within:ring-ring/40 active:translate-y-0">
|
||||
<CardContent className="p-0">
|
||||
<a
|
||||
href={bookmark.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 p-3 outline-none"
|
||||
>
|
||||
<BookmarkIcon icon={bookmark.icon} url={bookmark.url} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-foreground">
|
||||
{bookmark.title}
|
||||
</p>
|
||||
{host && (
|
||||
<p className="truncate text-[11px] text-muted-foreground/70">{host}</p>
|
||||
)}
|
||||
{bookmark.description && (
|
||||
<p className="mt-0.5 line-clamp-1 text-xs text-muted-foreground">
|
||||
{bookmark.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ExternalLink className="size-4 shrink-0 text-muted-foreground/50 transition-colors group-hover:text-primary" />
|
||||
</a>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function BookmarkIcon({ icon, url }: { icon: string; url: string }) {
|
||||
const initial = icon || faviconFallback(url);
|
||||
const [prevInitial, setPrevInitial] = useState(initial);
|
||||
const [src, setSrc] = useState(initial);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
if (prevInitial !== initial) {
|
||||
setPrevInitial(initial);
|
||||
setSrc(initial);
|
||||
setFailed(false);
|
||||
}
|
||||
|
||||
if (!src || failed) {
|
||||
return (
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||
<Globe className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted">
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
loading="lazy"
|
||||
className="size-5 object-contain"
|
||||
onError={() => {
|
||||
if (icon && src === icon) setSrc(faviconFallback(url));
|
||||
else setFailed(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -64,7 +64,7 @@ export default function InitPage() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-background via-background to-muted/40 text-sm text-muted-foreground">
|
||||
加载中...
|
||||
</div>
|
||||
);
|
||||
@@ -72,13 +72,13 @@ export default function InitPage() {
|
||||
|
||||
if (initialized) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-background via-background to-muted/40 p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center">系统已初始化</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-center text-gray-500">
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
系统中已有用户,无法再次初始化。
|
||||
</p>
|
||||
</CardContent>
|
||||
@@ -88,13 +88,13 @@ export default function InitPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-4">
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-background via-background to-muted/40 p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center">系统初始化</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
这是系统首次启动,请创建第一个管理员账号。
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
@@ -139,7 +139,7 @@ export default function InitPage() {
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<Button type="submit" className="w-full">
|
||||
创建管理员
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Toaster } from "sonner";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
@@ -13,10 +14,36 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: {
|
||||
default: "EvanPage",
|
||||
template: "%s · EvanPage",
|
||||
},
|
||||
description: "个人主页与导航",
|
||||
icons: {
|
||||
icon: "/favicon.ico",
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#fafbff" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#0e1117" },
|
||||
],
|
||||
};
|
||||
|
||||
const themeInitScript = `
|
||||
(function() {
|
||||
try {
|
||||
var stored = localStorage.getItem('theme');
|
||||
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
var resolved = stored === 'dark' || stored === 'light' ? stored : (prefersDark ? 'dark' : 'light');
|
||||
if (resolved === 'dark') document.documentElement.classList.add('dark');
|
||||
} catch (e) {}
|
||||
})();
|
||||
`;
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
@@ -24,10 +51,17 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
lang="zh-CN"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
|
||||
</head>
|
||||
<body className="min-h-full flex flex-col bg-background text-foreground">
|
||||
{children}
|
||||
<Toaster richColors position="top-right" />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
38
frontend/app/login-dialog.tsx
Normal file
38
frontend/app/login-dialog.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { LoginForm } from "./login/login-form";
|
||||
import { ReactNode, useState } from "react";
|
||||
|
||||
export function LoginDialog({
|
||||
hasKeycloak,
|
||||
children,
|
||||
}: {
|
||||
hasKeycloak: boolean;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger className="rounded-lg bg-primary px-5 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
|
||||
{children ?? "登录"}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-center">登录</DialogTitle>
|
||||
</DialogHeader>
|
||||
<LoginForm
|
||||
hasKeycloak={hasKeycloak}
|
||||
onSuccess={() => setOpen(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -6,11 +6,18 @@ import { useSearchParams } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export function LoginForm({ hasKeycloak }: { hasKeycloak: boolean }) {
|
||||
export function LoginForm({
|
||||
hasKeycloak,
|
||||
callbackUrl: callbackUrlProp,
|
||||
onSuccess,
|
||||
}: {
|
||||
hasKeycloak: boolean;
|
||||
callbackUrl?: string;
|
||||
onSuccess?: () => void;
|
||||
}) {
|
||||
const searchParams = useSearchParams();
|
||||
const callbackUrl = searchParams.get("callbackUrl") || "/dashboard";
|
||||
const callbackUrl = callbackUrlProp ?? searchParams.get("callbackUrl") ?? "/dashboard";
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
@@ -29,6 +36,7 @@ export function LoginForm({ hasKeycloak }: { hasKeycloak: boolean }) {
|
||||
if (res?.error) {
|
||||
setError("登录失败,请检查用户名和密码");
|
||||
} else {
|
||||
onSuccess?.();
|
||||
const redirectUrl = callbackUrl.startsWith("http")
|
||||
? callbackUrl
|
||||
: window.location.origin + callbackUrl;
|
||||
@@ -37,11 +45,7 @@ export function LoginForm({ hasKeycloak }: { hasKeycloak: boolean }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center">登录</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="username">用户名</Label>
|
||||
@@ -62,7 +66,7 @@ export function LoginForm({ hasKeycloak }: { hasKeycloak: boolean }) {
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<Button type="submit" className="w-full">
|
||||
本地账号登录
|
||||
</Button>
|
||||
@@ -72,14 +76,15 @@ export function LoginForm({ hasKeycloak }: { hasKeycloak: boolean }) {
|
||||
<>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
<span className="w-full border-t border-border" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-white px-2 text-gray-500">或者</span>
|
||||
<span className="bg-popover px-2 text-muted-foreground">或者</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => signIn("keycloak", { callbackUrl })}
|
||||
@@ -88,14 +93,6 @@ export function LoginForm({ hasKeycloak }: { hasKeycloak: boolean }) {
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
还没有账号?{" "}
|
||||
<a href="/register" className="text-blue-600 hover:underline">
|
||||
立即注册
|
||||
</a>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,50 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { LoginForm } from "./login-form";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "登录",
|
||||
};
|
||||
|
||||
const hasKeycloak = !!process.env.AUTH_KEYCLOAK_ISSUER;
|
||||
|
||||
export default function LoginPage() {
|
||||
export default async function LoginPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
const callbackUrl = typeof sp.callbackUrl === "string" ? sp.callbackUrl : null;
|
||||
const error = typeof sp.error === "string" ? sp.error : null;
|
||||
|
||||
if (!callbackUrl && !error) {
|
||||
redirect("/?login=1");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-4">
|
||||
<Suspense fallback={<div>加载中...</div>}>
|
||||
<LoginForm hasKeycloak={hasKeycloak} />
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-background via-background to-muted/40 p-4">
|
||||
<Dialog defaultOpen>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-center">登录</DialogTitle>
|
||||
</DialogHeader>
|
||||
{error && (
|
||||
<p className="rounded-md border border-destructive/30 bg-destructive/10 p-2 text-center text-xs text-destructive">
|
||||
登录过程中出现错误,请重试
|
||||
</p>
|
||||
)}
|
||||
<Suspense fallback={<div className="text-sm text-muted-foreground">加载中...</div>}>
|
||||
<LoginForm hasKeycloak={hasKeycloak} callbackUrl={callbackUrl ?? undefined} />
|
||||
</Suspense>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,64 +1,48 @@
|
||||
import { BlurFade } from "@/components/magicui/blur-fade";
|
||||
import { Suspense } from "react";
|
||||
import { auth } from "@/auth";
|
||||
import { HomePageClient } from "./home-page-client";
|
||||
|
||||
const SERVER_API_URL = process.env.SERVER_API_URL || "http://backend:8080";
|
||||
const hasKeycloak = !!process.env.AUTH_KEYCLOAK_ISSUER;
|
||||
|
||||
interface Bookmark {
|
||||
id: number;
|
||||
title: string;
|
||||
url: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
async function fetchPublicBookmarks(): Promise<Bookmark[]> {
|
||||
try {
|
||||
const res = await fetch(`${SERVER_API_URL}/api/bookmarks/public`, {
|
||||
next: { revalidate: 0 },
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data.bookmarks || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export default async function HomePage() {
|
||||
let healthText = "无法连接到后端服务";
|
||||
const session = await auth();
|
||||
const isAuthenticated = !!session?.user;
|
||||
const role = (session?.user as { role?: string } | undefined)?.role;
|
||||
const isAdmin = role === "admin";
|
||||
|
||||
try {
|
||||
const res = await fetch(`${SERVER_API_URL}/api/health`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (res.ok) {
|
||||
healthText = await res.text();
|
||||
} else {
|
||||
healthText = `后端异常: ${res.status}`;
|
||||
}
|
||||
} catch (err) {
|
||||
healthText = "后端连接失败";
|
||||
}
|
||||
const bookmarks = await fetchPublicBookmarks();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 p-4">
|
||||
<BlurFade inView delay={0.1}>
|
||||
<h1 className="mb-4 text-center text-4xl font-extrabold tracking-tight text-slate-900">
|
||||
EvanPage
|
||||
</h1>
|
||||
</BlurFade>
|
||||
|
||||
<BlurFade inView delay={0.2}>
|
||||
<p className="mb-8 max-w-md text-center text-lg text-slate-600">
|
||||
全栈基础框架
|
||||
</p>
|
||||
</BlurFade>
|
||||
|
||||
<BlurFade inView delay={0.3}>
|
||||
<div className="rounded-2xl border bg-white/80 px-8 py-6 shadow-lg backdrop-blur">
|
||||
<p className="text-center text-sm font-medium text-slate-500">
|
||||
后端状态
|
||||
</p>
|
||||
<p className="mt-2 text-center text-xl font-semibold text-slate-800">
|
||||
{healthText}
|
||||
</p>
|
||||
</div>
|
||||
</BlurFade>
|
||||
|
||||
<BlurFade inView delay={0.4}>
|
||||
<div className="mt-8 flex gap-4">
|
||||
<a
|
||||
href="/login"
|
||||
className="rounded-lg bg-slate-900 px-5 py-2.5 text-sm font-medium text-white hover:bg-slate-800"
|
||||
>
|
||||
登录
|
||||
</a>
|
||||
<a
|
||||
href="/register"
|
||||
className="rounded-lg border border-slate-300 bg-white px-5 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
注册
|
||||
</a>
|
||||
</div>
|
||||
</BlurFade>
|
||||
</div>
|
||||
<Suspense>
|
||||
<HomePageClient
|
||||
isAuthenticated={isAuthenticated}
|
||||
isAdmin={isAdmin}
|
||||
hasKeycloak={hasKeycloak}
|
||||
bookmarks={bookmarks}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,113 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
const [form, setForm] = useState({
|
||||
username: "",
|
||||
email: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
});
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (form.password !== form.confirmPassword) {
|
||||
setError("两次输入的密码不一致");
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch("/api/proxy/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
username: form.username,
|
||||
email: form.email,
|
||||
password: form.password,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
setError(data.error || "注册失败");
|
||||
return;
|
||||
}
|
||||
|
||||
router.push("/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center">注册</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="username">用户名</Label>
|
||||
<Input
|
||||
id="username"
|
||||
value={form.username}
|
||||
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="email">邮箱</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="confirmPassword">确认密码</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={form.confirmPassword}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, confirmPassword: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<Button type="submit" className="w-full">
|
||||
注册
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
已有账号?{" "}
|
||||
<a href="/login" className="text-blue-600 hover:underline">
|
||||
去登录
|
||||
</a>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "无权访问",
|
||||
};
|
||||
|
||||
export default function UnauthorizedPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center p-4">
|
||||
<h1 className="text-3xl font-bold">403</h1>
|
||||
<p className="mt-2 text-gray-500">您没有权限访问该页面</p>
|
||||
<a href="/dashboard" className="mt-4 text-blue-600 hover:underline">
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-gradient-to-br from-background via-background to-muted/40 p-4 text-center">
|
||||
<h1 className="text-5xl font-bold tracking-tight">403</h1>
|
||||
<p className="mt-3 text-muted-foreground">您没有权限访问该页面</p>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="mt-6 text-sm font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
返回仪表盘
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
26
frontend/components/theme-toggle.tsx
Normal file
26
frontend/components/theme-toggle.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { useTheme } from "@/lib/use-theme";
|
||||
|
||||
export function ThemeToggle({ className }: { className?: string }) {
|
||||
const { theme, toggle, mounted } = useTheme();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-label={mounted ? (theme === "dark" ? "切换到浅色" : "切换到深色") : "切换主题"}
|
||||
className={
|
||||
className ??
|
||||
"flex h-full w-full items-center justify-center text-foreground/80 hover:text-foreground"
|
||||
}
|
||||
>
|
||||
{mounted && theme === "dark" ? (
|
||||
<Sun className="h-5 w-5" />
|
||||
) : (
|
||||
<Moon className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
134
frontend/components/ui/alert-dialog.tsx
Normal file
134
frontend/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger
|
||||
data-slot="alert-dialog-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: AlertDialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Backdrop
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/30 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: AlertDialogPrimitive.Popup.Props) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Popup
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-3 rounded-xl bg-popover p-5 text-sm text-popover-foreground ring-1 ring-foreground/10 outline-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 sm:max-w-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn("flex flex-col gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"mt-2 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: AlertDialogPrimitive.Title.Props) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn("text-base font-medium leading-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: AlertDialogPrimitive.Description.Props) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogClose({ ...props }: AlertDialogPrimitive.Close.Props) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Close data-slot="alert-dialog-close" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogClose,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
};
|
||||
154
frontend/components/ui/dock.tsx
Normal file
154
frontend/components/ui/dock.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
"use client"
|
||||
|
||||
import React, { PropsWithChildren, useRef } from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import {
|
||||
motion,
|
||||
MotionValue,
|
||||
useMotionValue,
|
||||
useSpring,
|
||||
useTransform,
|
||||
} from "framer-motion"
|
||||
import type { MotionProps } from "framer-motion"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface DockProps extends VariantProps<typeof dockVariants> {
|
||||
className?: string
|
||||
iconSize?: number
|
||||
iconMagnification?: number
|
||||
disableMagnification?: boolean
|
||||
iconDistance?: number
|
||||
direction?: "top" | "middle" | "bottom"
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const DEFAULT_SIZE = 40
|
||||
const DEFAULT_MAGNIFICATION = 60
|
||||
const DEFAULT_DISTANCE = 140
|
||||
const DEFAULT_DISABLEMAGNIFICATION = false
|
||||
|
||||
const dockVariants = cva(
|
||||
"mx-auto flex h-[58px] w-max items-center justify-center gap-2 rounded-2xl border border-border bg-background/70 p-2 shadow-lg shadow-black/5 backdrop-blur-md supports-backdrop-blur:bg-background/60 dark:shadow-black/40"
|
||||
)
|
||||
|
||||
const Dock = React.forwardRef<HTMLDivElement, DockProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
children,
|
||||
iconSize = DEFAULT_SIZE,
|
||||
iconMagnification = DEFAULT_MAGNIFICATION,
|
||||
disableMagnification = DEFAULT_DISABLEMAGNIFICATION,
|
||||
iconDistance = DEFAULT_DISTANCE,
|
||||
direction = "middle",
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const mouseX = useMotionValue(Infinity)
|
||||
|
||||
const renderChildren = () => {
|
||||
return React.Children.map(children, (child) => {
|
||||
if (
|
||||
React.isValidElement<DockIconProps>(child) &&
|
||||
child.type === DockIcon
|
||||
) {
|
||||
return React.cloneElement(child, {
|
||||
...child.props,
|
||||
mouseX: mouseX,
|
||||
size: iconSize,
|
||||
magnification: iconMagnification,
|
||||
disableMagnification: disableMagnification,
|
||||
distance: iconDistance,
|
||||
})
|
||||
}
|
||||
return child
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
onMouseMove={(e) => mouseX.set(e.pageX)}
|
||||
onMouseLeave={() => mouseX.set(Infinity)}
|
||||
{...props}
|
||||
className={cn(dockVariants({ className }), {
|
||||
"items-start": direction === "top",
|
||||
"items-center": direction === "middle",
|
||||
"items-end": direction === "bottom",
|
||||
})}
|
||||
>
|
||||
{renderChildren()}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Dock.displayName = "Dock"
|
||||
|
||||
export interface DockIconProps extends Omit<
|
||||
MotionProps & React.HTMLAttributes<HTMLDivElement>,
|
||||
"children"
|
||||
> {
|
||||
size?: number
|
||||
magnification?: number
|
||||
disableMagnification?: boolean
|
||||
distance?: number
|
||||
mouseX?: MotionValue<number>
|
||||
className?: string
|
||||
children?: React.ReactNode
|
||||
props?: PropsWithChildren
|
||||
}
|
||||
|
||||
const DockIcon = ({
|
||||
size = DEFAULT_SIZE,
|
||||
magnification = DEFAULT_MAGNIFICATION,
|
||||
disableMagnification,
|
||||
distance = DEFAULT_DISTANCE,
|
||||
mouseX,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: DockIconProps) => {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const padding = Math.max(6, size * 0.2)
|
||||
const defaultMouseX = useMotionValue(Infinity)
|
||||
|
||||
const distanceCalc = useTransform(mouseX ?? defaultMouseX, (val: number) => {
|
||||
const bounds = ref.current?.getBoundingClientRect() ?? { x: 0, width: 0 }
|
||||
return val - bounds.x - bounds.width / 2
|
||||
})
|
||||
|
||||
const targetSize = disableMagnification ? size : magnification
|
||||
|
||||
const sizeTransform = useTransform(
|
||||
distanceCalc,
|
||||
[-distance, 0, distance],
|
||||
[size, targetSize, size]
|
||||
)
|
||||
|
||||
const scaleSize = useSpring(sizeTransform, {
|
||||
mass: 0.1,
|
||||
stiffness: 150,
|
||||
damping: 12,
|
||||
})
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
style={{ width: scaleSize, height: scaleSize, padding }}
|
||||
className={cn(
|
||||
"flex aspect-square cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div>{children}</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
DockIcon.displayName = "DockIcon"
|
||||
|
||||
export { Dock, DockIcon, dockVariants }
|
||||
13
frontend/components/ui/skeleton.tsx
Normal file
13
frontend/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
143
frontend/lib/bookmarks.ts
Normal file
143
frontend/lib/bookmarks.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
export interface BookmarkLite {
|
||||
title: string;
|
||||
url: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
category?: string;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export function hostnameOf(raw: string): string {
|
||||
try {
|
||||
const u = new URL(raw.includes("://") ? raw : `https://${raw}`);
|
||||
return u.hostname.replace(/^www\./, "");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function faviconFallback(url: string, size = 64): string {
|
||||
const host = hostnameOf(url);
|
||||
if (!host) return "";
|
||||
return `https://www.google.com/s2/favicons?domain=${host}&sz=${size}`;
|
||||
}
|
||||
|
||||
export function normalizeURL(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return "";
|
||||
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
||||
return `https://${trimmed}`;
|
||||
}
|
||||
|
||||
export function exportToJSON(bookmarks: BookmarkLite[]): string {
|
||||
return JSON.stringify(
|
||||
{
|
||||
exportedAt: new Date().toISOString(),
|
||||
bookmarks: bookmarks.map((b) => ({
|
||||
title: b.title,
|
||||
url: b.url,
|
||||
description: b.description ?? "",
|
||||
icon: b.icon ?? "",
|
||||
category: b.category ?? "默认",
|
||||
sortOrder: b.sortOrder ?? 0,
|
||||
})),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
export function downloadFile(filename: string, content: string, mime = "application/json") {
|
||||
const blob = new Blob([content], { type: `${mime};charset=utf-8` });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
export function parseImport(text: string): BookmarkLite[] {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||
return parseJSON(trimmed);
|
||||
}
|
||||
if (trimmed.toLowerCase().includes("<!doctype netscape-bookmark") || trimmed.toLowerCase().includes("<dl")) {
|
||||
return parseNetscapeHTML(trimmed);
|
||||
}
|
||||
|
||||
return parseJSON(trimmed);
|
||||
}
|
||||
|
||||
function parseJSON(raw: string): BookmarkLite[] {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
const list: unknown[] = Array.isArray(parsed)
|
||||
? parsed
|
||||
: Array.isArray((parsed as { bookmarks?: unknown[] })?.bookmarks)
|
||||
? (parsed as { bookmarks: unknown[] }).bookmarks
|
||||
: [];
|
||||
return list
|
||||
.map((item) => {
|
||||
const i = item as Record<string, unknown>;
|
||||
return {
|
||||
title: String(i.title ?? i.name ?? "").trim(),
|
||||
url: String(i.url ?? i.href ?? "").trim(),
|
||||
description: i.description ? String(i.description) : "",
|
||||
icon: i.icon ? String(i.icon) : "",
|
||||
category: i.category ? String(i.category) : "默认",
|
||||
sortOrder: typeof i.sortOrder === "number" ? i.sortOrder : 0,
|
||||
};
|
||||
})
|
||||
.filter((b) => b.title && b.url);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function parseNetscapeHTML(raw: string): BookmarkLite[] {
|
||||
if (typeof DOMParser === "undefined") return [];
|
||||
const doc = new DOMParser().parseFromString(raw, "text/html");
|
||||
const out: BookmarkLite[] = [];
|
||||
|
||||
const walk = (node: Element, currentCategory: string) => {
|
||||
const children = Array.from(node.children);
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const el = children[i];
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (tag === "dt") {
|
||||
const inner = el.firstElementChild;
|
||||
if (!inner) continue;
|
||||
const innerTag = inner.tagName.toLowerCase();
|
||||
if (innerTag === "a") {
|
||||
const a = inner as HTMLAnchorElement;
|
||||
const href = a.getAttribute("href") || "";
|
||||
const title = (a.textContent || "").trim() || href;
|
||||
const icon = a.getAttribute("icon") || "";
|
||||
if (href && /^https?:\/\//i.test(href)) {
|
||||
out.push({
|
||||
title,
|
||||
url: href,
|
||||
description: "",
|
||||
icon,
|
||||
category: currentCategory || "默认",
|
||||
sortOrder: 0,
|
||||
});
|
||||
}
|
||||
} else if (innerTag === "h3") {
|
||||
const folderName = (inner.textContent || "").trim() || currentCategory;
|
||||
const dl = el.querySelector("dl");
|
||||
if (dl) walk(dl, folderName);
|
||||
}
|
||||
} else if (tag === "dl") {
|
||||
walk(el, currentCategory);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const root = doc.querySelector("dl");
|
||||
if (root) walk(root, "默认");
|
||||
return out;
|
||||
}
|
||||
56
frontend/lib/use-theme.ts
Normal file
56
frontend/lib/use-theme.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Theme = "light" | "dark";
|
||||
|
||||
function readTheme(): Theme {
|
||||
if (typeof window === "undefined") return "light";
|
||||
const stored = localStorage.getItem("theme");
|
||||
if (stored === "light" || stored === "dark") return stored;
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
|
||||
function applyTheme(theme: Theme) {
|
||||
const root = document.documentElement;
|
||||
if (theme === "dark") root.classList.add("dark");
|
||||
else root.classList.remove("dark");
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setThemeState] = useState<Theme>("light");
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setThemeState(readTheme());
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === "theme" && (e.newValue === "light" || e.newValue === "dark")) {
|
||||
setThemeState(e.newValue);
|
||||
applyTheme(e.newValue);
|
||||
}
|
||||
};
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => window.removeEventListener("storage", onStorage);
|
||||
}, []);
|
||||
|
||||
function setTheme(next: Theme) {
|
||||
setThemeState(next);
|
||||
try {
|
||||
localStorage.setItem("theme", next);
|
||||
} catch {}
|
||||
applyTheme(next);
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
setTheme(theme === "dark" ? "light" : "dark");
|
||||
}
|
||||
|
||||
return { theme, setTheme, toggle, mounted };
|
||||
}
|
||||
93
frontend/package-lock.json
generated
93
frontend/package-lock.json
generated
@@ -9,16 +9,21 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.4.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.38.0",
|
||||
"jose": "^6.2.2",
|
||||
"lucide-react": "^1.8.0",
|
||||
"motion": "^12.38.0",
|
||||
"next": "16.2.4",
|
||||
"next-auth": "^5.0.0-beta.31",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"shadcn": "^4.2.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
@@ -516,6 +521,59 @@
|
||||
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/sortable": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.3.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dotenvx/dotenvx": {
|
||||
"version": "1.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.61.0.tgz",
|
||||
@@ -6531,6 +6589,31 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/motion": {
|
||||
"version": "12.38.0",
|
||||
"resolved": "https://registry.npmjs.org/motion/-/motion-12.38.0.tgz",
|
||||
"integrity": "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==",
|
||||
"dependencies": {
|
||||
"framer-motion": "^12.38.0",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "12.38.0",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz",
|
||||
@@ -8084,6 +8167,16 @@
|
||||
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
|
||||
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="
|
||||
},
|
||||
"node_modules/sonner": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
|
||||
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
|
||||
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
|
||||
@@ -10,16 +10,21 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.4.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.38.0",
|
||||
"jose": "^6.2.2",
|
||||
"lucide-react": "^1.8.0",
|
||||
"motion": "^12.38.0",
|
||||
"next": "16.2.4",
|
||||
"next-auth": "^5.0.0-beta.31",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"shadcn": "^4.2.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user