Compare commits

..

10 Commits

Author SHA1 Message Date
root
3a95c3e93f frontend: skip body in proxy for 204/205/304 responses
Response constructor rejects bodies on these statuses, so DELETE
returning 204 was triggering a 500 in the proxy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 23:12:34 +00:00
root
694b02e848 frontend: rebuild bookmark page with drag-and-drop, search, and theme system
- bookmark management with dnd-kit reordering, bulk edit, search,
  category filter/rename, and meta auto-fetch
- migrate /bookmarks → /dashboard/bookmarks under (main) layout
- homepage redesign with category grid, /-key search, dock tooltips
- theme toggle + use-theme, sonner toasts, alert-dialog/skeleton,
  visual refresh of auth pages

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 22:53:17 +00:00
root
832512469a backend: expand bookmark API with bulk ops and metadata fetcher
- bulk create/delete/move, reorder, rename-category endpoints
- /bookmarks/meta with SSRF-safe fetcher (blocks private/loopback IPs,
  8s timeout, 1 MiB body cap)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 22:52:43 +00:00
487b4c42c4 deploy: switch frontend to standalone production build
Frontend Dockerfile becomes multi-stage (deps/builder/production/dev)
with a Next.js standalone runtime and a 1GB heap cap to fit this host.
Compose targets the production stage, binds the frontend to
127.0.0.1:3001 for the 1Panel openresty proxy, drops dev volume
mounts and the publicly exposed postgres/backend ports, and passes
AUTH_URL/NEXTAUTH_URL/AUTH_TRUST_HOST so NextAuth works behind the
reverse proxy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 01:52:06 +08:00
37cecaa1ce frontend: add bookmark management and homepage navigation
Admin-only /bookmarks page for managing entries; homepage now renders
public bookmarks as a category-grouped navigation grid (empty state
links admin to the manager). Dashboard gains a recent-bookmarks card,
dock and main layout get a bookmark entry for admins, and the
middleware protects /bookmarks.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 01:51:55 +08:00
ffecc9451d backend: add bookmark CRUD with public list endpoint
New bookmarks feature: domain model, repository, service and handler
supporting list/create/update/delete. Public endpoint exposes the
admin user's bookmarks for the homepage navigation grid; authenticated
endpoints scope by user. Dev Dockerfile drops air for plain `go run`
and uses goproxy.cn to avoid build failures on the deploy host.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 01:51:43 +08:00
efd644dc67 frontend: remove admin user management page and nav link
- Delete admin user management page
- Remove 管理后台 link from main layout header
2026-04-16 16:55:38 +00:00
f9499c0795 frontend: convert login and register to homepage modals
- Remove register link from login form
- Redirect /login and /register to /?login=1 and /?register=1
- Open login/register as dialogs on homepage instead of separate pages
2026-04-16 16:55:29 +00:00
9f9f57b379 frontend: redesign homepage with magic dock and login dialog
- Replace top login/register buttons with magic dock navigation
- Add dock items: home, downloads, blog, and conditional login/logout
- Show dashboard icon in dock when authenticated
- Extract HomePageClient for client-side dialog state
2026-04-16 16:55:20 +00:00
baf2b26de0 frontend: add MagicUI dock component
- Install dock component via shadcn
- Fix import path from motion/react to framer-motion
2026-04-16 16:55:11 +00:00
38 changed files with 3822 additions and 552 deletions

View File

@@ -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

View File

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

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

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

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

View File

@@ -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

View File

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

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

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

View File

@@ -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()
}

View File

@@ -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:

View File

@@ -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"]

View File

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

File diff suppressed because it is too large Load Diff

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

View File

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

View File

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

View File

@@ -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,

View File

@@ -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>

View File

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

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

View File

@@ -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>

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -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("/");
}

View File

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

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

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

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

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

View File

@@ -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",

View File

@@ -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"
},