diff --git a/frontend/src/app.tsx b/frontend/src/app.tsx
index f07bbb2..3ef98c0 100644
--- a/frontend/src/app.tsx
+++ b/frontend/src/app.tsx
@@ -19,6 +19,7 @@ import CreateCollectionPage from "./pages/create_collection_page.tsx";
import CollectionPage from "./pages/collection_page.tsx";
import { i18nData } from "./i18n.ts";
import { i18nContext } from "./utils/i18n.ts";
+import NotificationPage from "./pages/notification_page.tsx";
export default function App() {
return (
@@ -49,6 +50,7 @@ export default function App() {
element={}
/>
} />
+ } />
diff --git a/frontend/src/components/navigator.tsx b/frontend/src/components/navigator.tsx
index 8716054..96b20b4 100644
--- a/frontend/src/components/navigator.tsx
+++ b/frontend/src/components/navigator.tsx
@@ -2,11 +2,10 @@ import { app } from "../app.ts";
import { network } from "../network/network.ts";
import { useNavigate, useOutlet } from "react-router";
import { createContext, useContext, useEffect, useState } from "react";
-import { MdArrowUpward, MdOutlinePerson, MdSearch } from "react-icons/md";
+import { MdArrowUpward, MdOutlinePerson, MdSearch, MdNotifications } from "react-icons/md";
import { useTranslation } from "../utils/i18n";
import UploadingSideBar from "./uploading_side_bar.tsx";
import { ThemeSwitcher } from "./theme_switcher.tsx";
-import { IoLogoGithub } from "react-icons/io";
import { useAppContext } from "./AppContext.tsx";
import { AnimatePresence, motion } from "framer-motion";
@@ -234,16 +233,7 @@ export default function Navigator() {
-
-
-
+ {app.isLoggedIn() && }
{app.isLoggedIn() ? (
) : (
@@ -554,3 +544,43 @@ function FloatingToTopButton() {
);
}
+
+function NotificationButton() {
+ const [count, setCount] = useState(0);
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ const fetchCount = async () => {
+ if (!app.isLoggedIn()) {
+ return;
+ }
+ const res = await network.getUserNotificationsCount();
+ if (res.success && res.data !== undefined) {
+ setCount(res.data);
+ }
+ };
+
+ fetchCount();
+ const interval = setInterval(fetchCount, 60000); // 每分钟请求一次
+
+ return () => clearInterval(interval);
+ }, []);
+
+ return (
+
+ {count > 0 && (
+
+ {count > 99 ? "99+" : count}
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts
index d0dec47..b26902b 100644
--- a/frontend/src/i18n.ts
+++ b/frontend/src/i18n.ts
@@ -262,6 +262,7 @@ export const i18nData = {
"Tag": "标签",
"Optional": "可选",
"Download": "下载",
+ "Notifications": "通知",
},
},
"zh-TW": {
@@ -527,6 +528,7 @@ export const i18nData = {
"Tag": "標籤",
"Optional": "可選",
"Download": "下載",
+ "Notifications": "通知",
},
},
};
diff --git a/frontend/src/network/network.ts b/frontend/src/network/network.ts
index 327a214..164d237 100644
--- a/frontend/src/network/network.ts
+++ b/frontend/src/network/network.ts
@@ -730,6 +730,26 @@ class Network {
);
}
+ async getUserNotifications(page: number = 1): Promise> {
+ return this._callApi(() =>
+ axios.get(`${this.apiBaseUrl}/notification`, {
+ params: { page },
+ }),
+ );
+ }
+
+ async resetUserNotificationsCount(): Promise> {
+ return this._callApi(() =>
+ axios.post(`${this.apiBaseUrl}/notification/reset`),
+ );
+ }
+
+ async getUserNotificationsCount(): Promise> {
+ return this._callApi(() =>
+ axios.get(`${this.apiBaseUrl}/notification/count`),
+ );
+ }
+
async createCollection(
title: string,
article: string,
diff --git a/frontend/src/pages/notification_page.tsx b/frontend/src/pages/notification_page.tsx
new file mode 100644
index 0000000..155ede7
--- /dev/null
+++ b/frontend/src/pages/notification_page.tsx
@@ -0,0 +1,209 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { Activity, ActivityType } from "../network/models.ts";
+import { network } from "../network/network.ts";
+import showToast from "../components/toast.ts";
+import { useTranslation } from "../utils/i18n";
+import { useNavigate } from "react-router";
+import Loading from "../components/loading.tsx";
+import { CommentContent } from "../components/comment_tile.tsx";
+import { MdOutlineArchive, MdOutlinePhotoAlbum } from "react-icons/md";
+import Badge from "../components/badge.tsx";
+import Markdown from "react-markdown";
+import { ErrorAlert } from "../components/alert.tsx";
+import { app } from "../app.ts";
+
+export default function NotificationPage() {
+ const [activities, setActivities] = useState([]);
+ const pageRef = useRef(0);
+ const maxPageRef = useRef(1);
+ const isLoadingRef = useRef(false);
+ const { t } = useTranslation();
+
+ const fetchNextPage = useCallback(async () => {
+ if (isLoadingRef.current || pageRef.current >= maxPageRef.current) return;
+ isLoadingRef.current = true;
+ const response = await network.getUserNotifications(pageRef.current + 1);
+ if (response.success) {
+ setActivities((prev) => [...prev, ...response.data!]);
+ pageRef.current += 1;
+ maxPageRef.current = response.totalPages!;
+ } else {
+ showToast({
+ type: "error",
+ message: response.message || "Failed to load activities",
+ });
+ }
+ isLoadingRef.current = false;
+ }, []);
+
+ useEffect(() => {
+ fetchNextPage();
+ }, [fetchNextPage]);
+
+ useEffect(() => {
+ network.resetUserNotificationsCount();
+ }, []);
+
+ useEffect(() => {
+ document.title = t("Notifications");
+ }, [])
+
+ useEffect(() => {
+ const handleScroll = () => {
+ if (
+ window.innerHeight + window.scrollY >=
+ document.documentElement.scrollHeight - 100 &&
+ !isLoadingRef.current &&
+ pageRef.current < maxPageRef.current
+ ) {
+ fetchNextPage();
+ }
+ };
+ window.addEventListener("scroll", handleScroll);
+ return () => window.removeEventListener("scroll", handleScroll);
+ }, [fetchNextPage]);
+
+ if (!app.user) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {activities.map((activity) => (
+
+ ))}
+ {pageRef.current < maxPageRef.current &&
}
+
+ );
+}
+
+function fileSizeToString(size: number) {
+ if (size < 1024) {
+ return size + "B";
+ } else if (size < 1024 * 1024) {
+ return (size / 1024).toFixed(2) + "KB";
+ } else if (size < 1024 * 1024 * 1024) {
+ return (size / 1024 / 1024).toFixed(2) + "MB";
+ } else {
+ return (size / 1024 / 1024 / 1024).toFixed(2) + "GB";
+ }
+}
+
+function ActivityCard({ activity }: { activity: Activity }) {
+ const { t } = useTranslation();
+
+ const messages = [
+ "Unknown activity",
+ t("Published a resource"),
+ t("Updated a resource"),
+ t("Posted a comment"),
+ t("Added a new file"),
+ ];
+
+ const navigate = useNavigate();
+
+ let content = <>>;
+
+ if (
+ activity.type === ActivityType.ResourcePublished ||
+ activity.type === ActivityType.ResourceUpdated
+ ) {
+ content = (
+
+
+ {activity.resource?.title}
+
+ {activity.resource?.image && (
+
+
})
+
+ )}
+
+ );
+ } else if (activity.type === ActivityType.NewComment) {
+ content = (
+
+
+
+ );
+ } else if (activity.type === ActivityType.NewFile) {
+ content = (
+
+
+ {activity.file!.filename}
+
+
+
+ {activity.file!.description.replaceAll("\n", " \n")}
+
+
+
+
+
+ {activity.file!.is_redirect
+ ? t("Redirect")
+ : fileSizeToString(activity.file!.size)}
+
+
+
+ {(() => {
+ let title = activity.resource!.title;
+ if (title.length > 20) {
+ title = title.slice(0, 20) + "...";
+ }
+ return title;
+ })()}
+
+
+
+ );
+ }
+
+ return (
+ {
+ if (
+ activity.type === ActivityType.ResourcePublished ||
+ activity.type === ActivityType.ResourceUpdated
+ ) {
+ navigate(`/resources/${activity.resource?.id}`);
+ } else if (activity.type === ActivityType.NewComment) {
+ navigate(`/comments/${activity.comment?.id}`);
+ } else if (activity.type === ActivityType.NewFile) {
+ navigate(`/resources/${activity.resource?.id}#files`);
+ }
+ }}
+ >
+
+
+
})
+
+
+ {activity.user?.username}
+
+
+ {messages[activity.type]}
+
+
+ {content}
+
+ );
+}
diff --git a/server/api/activity.go b/server/api/activity.go
index 1789fc2..e141697 100644
--- a/server/api/activity.go
+++ b/server/api/activity.go
@@ -1,10 +1,11 @@
package api
import (
- "github.com/gofiber/fiber/v3"
"nysoure/server/model"
"nysoure/server/service"
"strconv"
+
+ "github.com/gofiber/fiber/v3"
)
func handleGetActivity(c fiber.Ctx) error {
@@ -28,6 +29,68 @@ func handleGetActivity(c fiber.Ctx) error {
})
}
+func handleGetUserNotifications(c fiber.Ctx) error {
+ uid, ok := c.Locals("uid").(uint)
+ if !ok {
+ return model.NewUnAuthorizedError("Unauthorized")
+ }
+ pageStr := c.Query("page", "1")
+ page, err := strconv.Atoi(pageStr)
+ if err != nil {
+ return model.NewRequestError("Invalid page number")
+ }
+ notifications, totalPages, err := service.GetUserNotifications(uid, page)
+ if err != nil {
+ return err
+ }
+ if notifications == nil {
+ notifications = []model.ActivityView{}
+ }
+ return c.JSON(model.PageResponse[model.ActivityView]{
+ Success: true,
+ Data: notifications,
+ TotalPages: totalPages,
+ Message: "User notifications retrieved successfully",
+ })
+}
+
+func handleResetUserNotificationsCount(c fiber.Ctx) error {
+ uid, ok := c.Locals("uid").(uint)
+ if !ok {
+ return model.NewUnAuthorizedError("Unauthorized")
+ }
+ err := service.ResetUserNotificationsCount(uid)
+ if err != nil {
+ return err
+ }
+ return c.JSON(model.Response[any]{
+ Success: true,
+ Message: "User notifications count reset successfully",
+ })
+}
+
+func handleGetUserNotificationsCount(c fiber.Ctx) error {
+ uid, ok := c.Locals("uid").(uint)
+ if !ok {
+ return model.NewUnAuthorizedError("Unauthorized")
+ }
+ count, err := service.GetUserNotificationsCount(uid)
+ if err != nil {
+ return err
+ }
+ return c.JSON(model.Response[uint]{
+ Success: true,
+ Data: count,
+ Message: "User notifications count retrieved successfully",
+ })
+}
+
func AddActivityRoutes(router fiber.Router) {
router.Get("/activity", handleGetActivity)
+ notificationrouter := router.Group("/notification")
+ {
+ notificationrouter.Get("/", handleGetUserNotifications)
+ notificationrouter.Post("/reset", handleResetUserNotificationsCount)
+ notificationrouter.Get("/count", handleGetUserNotificationsCount)
+ }
}
diff --git a/server/dao/activity.go b/server/dao/activity.go
index 3a04831..98d7823 100644
--- a/server/dao/activity.go
+++ b/server/dao/activity.go
@@ -2,9 +2,10 @@ package dao
import (
"errors"
- "gorm.io/gorm"
"nysoure/server/model"
"time"
+
+ "gorm.io/gorm"
)
func AddNewResourceActivity(userID, resourceID uint) error {
@@ -42,13 +43,20 @@ func AddUpdateResourceActivity(userID, resourceID uint) error {
return db.Create(activity).Error
}
-func AddNewCommentActivity(userID, commentID uint) error {
- activity := &model.Activity{
- UserID: userID,
- Type: model.ActivityTypeNewComment,
- RefID: commentID,
- }
- return db.Create(activity).Error
+func AddNewCommentActivity(userID, commentID, notifyTo uint) error {
+ return db.Transaction(func(tx *gorm.DB) error {
+ activity := &model.Activity{
+ UserID: userID,
+ Type: model.ActivityTypeNewComment,
+ RefID: commentID,
+ NotifyTo: notifyTo,
+ }
+ err := tx.Create(activity).Error
+ if err != nil {
+ return err
+ }
+ return tx.Model(&model.User{}).Where("id = ?", notifyTo).UpdateColumn("unread_notifications_count", gorm.Expr("unread_notifications_count + ?", 1)).Error
+ })
}
func AddNewFileActivity(userID, fileID uint) error {
@@ -82,3 +90,18 @@ func GetActivityList(offset, limit int) ([]model.Activity, int, error) {
return activities, int(total), nil
}
+
+func GetUserNotifications(userID uint, offset, limit int) ([]model.Activity, int, error) {
+ var activities []model.Activity
+ var total int64
+
+ if err := db.Model(&model.Activity{}).Count(&total).Error; err != nil {
+ return nil, 0, err
+ }
+
+ if err := db.Where("notify_to = ?", userID).Offset(offset).Limit(limit).Order("id DESC").Find(&activities).Error; err != nil {
+ return nil, 0, err
+ }
+
+ return activities, int(total), nil
+}
diff --git a/server/dao/resource.go b/server/dao/resource.go
index 82e3d10..660415a 100644
--- a/server/dao/resource.go
+++ b/server/dao/resource.go
@@ -693,3 +693,14 @@ func UpdateResourceImage(resourceID, oldImageID, newImageID uint) error {
return nil
})
}
+
+func GetResourceOwnerID(resourceID uint) (uint, error) {
+ var uid uint
+ if err := db.Model(&model.Resource{}).Select("user_id").Where("id = ?", resourceID).First(&uid).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return 0, model.NewNotFoundError("Resource not found")
+ }
+ return 0, err
+ }
+ return uid, nil
+}
diff --git a/server/dao/user.go b/server/dao/user.go
index b7c33d9..034f2d6 100644
--- a/server/dao/user.go
+++ b/server/dao/user.go
@@ -2,8 +2,9 @@ package dao
import (
"errors"
- "gorm.io/gorm"
"nysoure/server/model"
+
+ "gorm.io/gorm"
)
func CreateUser(username string, hashedPassword []byte) (model.User, error) {
@@ -132,3 +133,15 @@ func DeleteUser(id uint) error {
}
return db.Delete(&model.User{}, id).Error
}
+
+func ResetUserNotificationsCount(userID uint) error {
+ return db.Model(&model.User{}).Where("id = ?", userID).Update("unread_notifications_count", 0).Error
+}
+
+func GetUserNotificationCount(userID uint) (uint, error) {
+ var count uint
+ if err := db.Model(&model.User{}).Where("id = ?", userID).Select("unread_notifications_count").Scan(&count).Error; err != nil {
+ return 0, err
+ }
+ return count, nil
+}
diff --git a/server/model/activity.go b/server/model/activity.go
index 43294e4..b8b2d3e 100644
--- a/server/model/activity.go
+++ b/server/model/activity.go
@@ -18,9 +18,10 @@ const (
type Activity struct {
gorm.Model
- UserID uint `gorm:"not null"`
- Type ActivityType `gorm:"not null;index:idx_type_refid"`
- RefID uint `gorm:"not null;index:idx_type_refid"`
+ UserID uint `gorm:"not null"`
+ Type ActivityType `gorm:"not null;index:idx_type_refid"`
+ RefID uint `gorm:"not null;index:idx_type_refid"`
+ NotifyTo uint `gorm:"default:null;index"`
}
type ActivityView struct {
diff --git a/server/model/user.go b/server/model/user.go
index 8d9cf69..5e256dc 100644
--- a/server/model/user.go
+++ b/server/model/user.go
@@ -9,16 +9,17 @@ import (
type User struct {
gorm.Model
- Username string `gorm:"uniqueIndex;not null"`
- PasswordHash []byte
- IsAdmin bool
- CanUpload bool
- AvatarVersion int
- ResourcesCount int
- FilesCount int
- CommentsCount int
- Resources []Resource `gorm:"foreignKey:UserID"`
- Bio string
+ Username string `gorm:"uniqueIndex;not null"`
+ PasswordHash []byte
+ IsAdmin bool
+ CanUpload bool
+ AvatarVersion int
+ ResourcesCount int
+ FilesCount int
+ CommentsCount int
+ Resources []Resource `gorm:"foreignKey:UserID"`
+ Bio string
+ UnreadNotificationsCount uint
}
type UserView struct {
diff --git a/server/service/activity.go b/server/service/activity.go
index 064a4e3..cb9f2f8 100644
--- a/server/service/activity.go
+++ b/server/service/activity.go
@@ -68,3 +68,67 @@ func GetActivityList(page int) ([]model.ActivityView, int, error) {
return views, totalPages, nil
}
+
+func GetUserNotifications(userID uint, page int) ([]model.ActivityView, int, error) {
+ offset := (page - 1) * pageSize
+ limit := pageSize
+
+ activities, total, err := dao.GetUserNotifications(userID, offset, limit)
+ if err != nil {
+ return nil, 0, err
+ }
+
+ var views []model.ActivityView
+ for _, activity := range activities {
+ user, err := dao.GetUserByID(activity.UserID)
+ if err != nil {
+ return nil, 0, err
+ }
+ var comment *model.CommentView
+ var resource *model.ResourceView
+ var file *model.FileView
+ switch activity.Type {
+ case model.ActivityTypeNewComment:
+ c, err := dao.GetCommentByID(activity.RefID)
+ if err != nil {
+ return nil, 0, err
+ }
+ comment = c.ToView()
+ comment.Content, comment.ContentTruncated = restrictCommentLength(c.Content)
+ case model.ActivityTypeNewResource, model.ActivityTypeUpdateResource:
+ r, err := dao.GetResourceByID(activity.RefID)
+ if err != nil {
+ return nil, 0, err
+ }
+ rv := r.ToView()
+ resource = &rv
+ case model.ActivityTypeNewFile:
+ f, err := dao.GetFileByID(activity.RefID)
+ if err != nil {
+ return nil, 0, err
+ }
+ fv := f.ToView()
+ file = fv
+ r, err := dao.GetResourceByID(f.ResourceID)
+ if err != nil {
+ return nil, 0, err
+ }
+ rv := r.ToView()
+ resource = &rv
+ }
+ view := model.ActivityView{
+ ID: activity.ID,
+ User: user.ToView(),
+ Type: activity.Type,
+ Time: activity.CreatedAt,
+ Comment: comment,
+ Resource: resource,
+ File: file,
+ }
+ views = append(views, view)
+ }
+
+ totalPages := (total + pageSize - 1) / pageSize
+
+ return views, totalPages, nil
+}
diff --git a/server/service/comment.go b/server/service/comment.go
index 86b4150..c63c28f 100644
--- a/server/service/comment.go
+++ b/server/service/comment.go
@@ -29,6 +29,8 @@ func CreateComment(req CommentRequest, userID uint, refID uint, ip string, cType
return nil, model.NewRequestError("Comment content exceeds maximum length of 1024 characters")
}
+ var notifyTo uint
+
switch cType {
case model.CommentTypeResource:
resourceExists, err := dao.ExistsResource(refID)
@@ -39,12 +41,18 @@ func CreateComment(req CommentRequest, userID uint, refID uint, ip string, cType
if !resourceExists {
return nil, model.NewNotFoundError("Resource not found")
}
+ notifyTo, err = dao.GetResourceOwnerID(refID)
+ if err != nil {
+ log.Error("Error getting resource owner ID:", err)
+ return nil, model.NewInternalServerError("Error getting resource owner ID")
+ }
case model.CommentTypeReply:
- _, err := dao.GetCommentByID(refID)
+ comment, err := dao.GetCommentByID(refID)
if err != nil {
log.Error("Error getting reply comment:", err)
return nil, model.NewNotFoundError("Reply comment not found")
}
+ notifyTo = comment.UserID
}
userExists, err := dao.ExistsUserByID(userID)
@@ -63,7 +71,7 @@ func CreateComment(req CommentRequest, userID uint, refID uint, ip string, cType
log.Error("Error creating comment:", err)
return nil, model.NewInternalServerError("Error creating comment")
}
- err = dao.AddNewCommentActivity(userID, c.ID)
+ err = dao.AddNewCommentActivity(userID, c.ID, notifyTo)
if err != nil {
log.Error("Error creating comment activity:", err)
}
diff --git a/server/service/user.go b/server/service/user.go
index ef7102e..2c19090 100644
--- a/server/service/user.go
+++ b/server/service/user.go
@@ -390,3 +390,11 @@ func validateUsername(username string) error {
}
return nil
}
+
+func ResetUserNotificationsCount(userID uint) error {
+ return dao.ResetUserNotificationsCount(userID)
+}
+
+func GetUserNotificationsCount(userID uint) (uint, error) {
+ return dao.GetUserNotificationCount(userID)
+}