Compare commits

...

4 Commits

14 changed files with 493 additions and 37 deletions

View File

@@ -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={<CreateCollectionPage />}
/>
<Route path={"/collection/:id"} element={<CollectionPage />} />
<Route path={"/notifications"} element={<NotificationPage />} />
</Route>
</Routes>
</BrowserRouter>

View File

@@ -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() {
<SearchBar />
<UploadingSideBar />
<ThemeSwitcher />
<a
className={"hidden sm:inline"}
href="https://github.com/wgh136/nysoure"
target="_blank"
rel="noopener noreferrer"
>
<button className={"btn btn-circle btn-ghost"}>
<IoLogoGithub size={24} />
</button>
</a>
{app.isLoggedIn() && <NotificationButton />}
{app.isLoggedIn() ? (
<UserButton />
) : (
@@ -554,3 +544,41 @@ function FloatingToTopButton() {
</button>
);
}
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 (
<div className="indicator">
{count > 0 && <span className="bg-error text-white text-xs rounded-full px-1 indicator-item">
{count > 99 ? "99+" : count}
</span>}
<button
className="btn btn-ghost btn-circle"
onClick={() => {
navigate("/notifications");
}}
>
<MdNotifications size={24} />
</button>
</div>
);
}

View File

@@ -262,6 +262,7 @@ export const i18nData = {
"Tag": "标签",
"Optional": "可选",
"Download": "下载",
"Notifications": "通知",
},
},
"zh-TW": {
@@ -527,6 +528,7 @@ export const i18nData = {
"Tag": "標籤",
"Optional": "可選",
"Download": "下載",
"Notifications": "通知",
},
},
};

View File

@@ -730,6 +730,26 @@ class Network {
);
}
async getUserNotifications(page: number = 1): Promise<PageResponse<Activity>> {
return this._callApi(() =>
axios.get(`${this.apiBaseUrl}/notification`, {
params: { page },
}),
);
}
async resetUserNotificationsCount(): Promise<Response<void>> {
return this._callApi(() =>
axios.post(`${this.apiBaseUrl}/notification/reset`),
);
}
async getUserNotificationsCount(): Promise<Response<number>> {
return this._callApi(() =>
axios.get(`${this.apiBaseUrl}/notification/count`),
);
}
async createCollection(
title: string,
article: string,

View File

@@ -0,0 +1,212 @@
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";
import { useNavigator } from "../components/navigator.tsx";
export default function NotificationPage() {
const [activities, setActivities] = useState<Activity[]>([]);
const pageRef = useRef(0);
const maxPageRef = useRef(1);
const isLoadingRef = useRef(false);
const { t } = useTranslation();
const navigator = useNavigator();
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();
navigator.refresh();
}, [navigator]);
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 (
<ErrorAlert
className={"m-4"}
message={t("You are not logged in. Please log in to access this page.")}
/>
);
}
return (
<div className={"pb-2"}>
{activities.map((activity) => (
<ActivityCard key={activity.id} activity={activity} />
))}
{pageRef.current < maxPageRef.current && <Loading />}
</div>
);
}
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 = (
<div className={"mx-1"}>
<div className={"font-bold my-4 break-all"}>
{activity.resource?.title}
</div>
{activity.resource?.image && (
<div>
<img
className={"object-contain max-h-52 mt-2 rounded-lg"}
src={network.getResampledImageUrl(activity.resource.image.id)}
alt={activity.resource.title}
/>
</div>
)}
</div>
);
} else if (activity.type === ActivityType.NewComment) {
content = (
<div className="comment_tile">
<CommentContent content={activity.comment!.content} />
</div>
);
} else if (activity.type === ActivityType.NewFile) {
content = (
<div>
<h4 className={"font-bold py-2 break-all"}>
{activity.file!.filename}
</h4>
<div className={"text-sm my-1 comment_tile"}>
<Markdown>
{activity.file!.description.replaceAll("\n", " \n")}
</Markdown>
</div>
<p className={"pt-1"}>
<Badge className={"badge-soft badge-secondary text-xs mr-2"}>
<MdOutlineArchive size={16} className={"inline-block"} />
{activity.file!.is_redirect
? t("Redirect")
: fileSizeToString(activity.file!.size)}
</Badge>
<Badge className={"badge-soft badge-accent text-xs mr-2"}>
<MdOutlinePhotoAlbum size={16} className={"inline-block"} />
{(() => {
let title = activity.resource!.title;
if (title.length > 20) {
title = title.slice(0, 20) + "...";
}
return title;
})()}
</Badge>
</p>
</div>
);
}
return (
<div
className={
"card shadow m-4 p-4 hover:shadow-md transition-shadow cursor-pointer bg-base-100-tr82"
}
onClick={() => {
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`);
}
}}
>
<div className={"flex items-center"}>
<div className={"avatar w-9 h-9 rounded-full"}>
<img
className={"rounded-full"}
alt={"avatar"}
src={network.getUserAvatar(activity.user!)}
/>
</div>
<span className={"mx-2 font-bold text-sm"}>
{activity.user?.username}
</span>
<span
className={"ml-2 badge-sm sm:badge-md badge badge-primary badge-soft"}
>
{messages[activity.type]}
</span>
</div>
{content}
</div>
);
}

View File

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

View File

@@ -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 {
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,
}
return db.Create(activity).Error
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{}).Where("notify_to = ?", userID).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
}

View File

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

View File

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

View File

@@ -21,6 +21,7 @@ type Activity struct {
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 {

View File

@@ -19,6 +19,7 @@ type User struct {
CommentsCount int
Resources []Resource `gorm:"foreignKey:UserID"`
Bio string
UnreadNotificationsCount uint `gorm:"not null;default:0"`
}
type UserView struct {

View File

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

View File

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

View File

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