diff --git a/.husky/_/husky.sh b/.husky/_/husky.sh deleted file mode 100644 index 756185a..0000000 --- a/.husky/_/husky.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env sh -if [ -z "$husky_skip_init" ]; then - debug () { - if [ "$HUSKY_DEBUG" = "1" ]; then - echo "husky (debug) - $1" - fi - } - - readonly hook_name="$(basename -- "$0")" - debug "starting $hook_name..." - - if [ "$HUSKY" = "0" ]; then - debug "HUSKY env variable is set to 0, skipping hook" - exit 0 - fi - - if [ -f ~/.huskyrc ]; then - debug "sourcing ~/.huskyrc" - . ~/.huskyrc - fi - - readonly husky_skip_init=1 - export husky_skip_init - sh -e "$0" "$@" - exitCode="$?" - - if [ $exitCode != 0 ]; then - echo "husky - $hook_name hook exited with code $exitCode (error)" - fi - - if [ $exitCode = 127 ]; then - echo "husky - command not found in PATH=$PATH" - fi - - exit $exitCode -fi \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit deleted file mode 100644 index 267e359..0000000 --- a/.husky/pre-commit +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env sh -. "$(dirname -- "$0")/_/husky.sh" - -cd frontend && npm run lint && npm run format:check \ No newline at end of file diff --git a/frontend/src/app.tsx b/frontend/src/app.tsx index 9d6eb31..a82c283 100644 --- a/frontend/src/app.tsx +++ b/frontend/src/app.tsx @@ -13,6 +13,7 @@ import EditResourcePage from "./pages/edit_resource_page.tsx"; import AboutPage from "./pages/about_page.tsx"; import TagsPage from "./pages/tags_page.tsx"; import RandomPage from "./pages/random_page.tsx"; +import ActivitiesPage from "./pages/activities_page.tsx"; export default function App() { return ( @@ -32,6 +33,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/frontend/src/components/navigator.tsx b/frontend/src/components/navigator.tsx index 360ceba..a470446 100644 --- a/frontend/src/components/navigator.tsx +++ b/frontend/src/components/navigator.tsx @@ -2,12 +2,7 @@ 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, - MdSettings, -} from "react-icons/md"; +import { MdArrowUpward, MdOutlinePerson, MdSearch } from "react-icons/md"; import { useTranslation } from "react-i18next"; import UploadingSideBar from "./uploading_side_bar.tsx"; import { ThemeSwitcher } from "./theme_switcher.tsx"; @@ -87,20 +82,17 @@ export default function Navigator() { > {t("Tags")} -
  • { - const menu = document.getElementById( - "navi_menu", - ) as HTMLElement; - menu.blur(); - navigate("/manage"); - }} - > - {t("Settings")} -
  • - - {"Github"} + { + const menu = document.getElementById( + "navi_menu", + ) as HTMLElement; + menu.blur(); + navigate("/activity"); + }} + > + {t("Activity")}
  • {t("Random")}
  • +
  • { + navigate("/activity"); + }} + > + {t("Activity")} +
  • { navigate("/about"); @@ -175,16 +174,6 @@ export default function Navigator() { - {app.isLoggedIn() && ( - - )}
  • +
  • + { + navigate(`/manage`); + const menu = document.getElementById( + "navi_dropdown_menu", + ) as HTMLUListElement; + menu.blur(); + }} + > + {t("Settings")} + +
  • { diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index ab057cd..a34c3d1 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -47,6 +47,7 @@ export const i18nData = { "Server": "Server", // Management page translations + "Settings": "Settings", "Manage": "Manage", "Storage": "Storage", "Users": "Users", @@ -212,6 +213,12 @@ export const i18nData = { "Mint Leaf": "Mint Leaf", "Golden Glow": "Golden Glow", "Random": "Random", + + // Activity Page + "Activity": "Activity", + "Published a resource": "Published a resource", + "Updated a resource": "Updated a resource", + "Commented on a resource": "Commented on a resource", }, }, "zh-CN": { @@ -260,6 +267,7 @@ export const i18nData = { "Server": "服务器", // Management page translations + "Settings": "设置", "Manage": "管理", "Storage": "存储", "Users": "用户", @@ -417,6 +425,12 @@ export const i18nData = { "Golden Glow": "微光", "Random": "随机", + + // Activity Page + "Activity": "动态", + "Published a resource": "发布了一个资源", + "Updated a resource": "更新了一个资源", + "Commented on a resource": "评论了一个资源", }, }, "zh-TW": { @@ -465,6 +479,7 @@ export const i18nData = { "Server": "伺服器", // Management page translations + "Settings": "設置", "Manage": "管理", "Storage": "儲存", "Users": "用戶", @@ -622,6 +637,12 @@ export const i18nData = { "Golden Glow": "微光", "Random": "隨機", + + // Activity Page + "Activity": "動態", + "Published a resource": "發布了資源", + "Updated a resource": "更新了資源", + "Commented on a resource": "評論了資源", }, }, }; diff --git a/frontend/src/network/models.ts b/frontend/src/network/models.ts index 06276e7..eea7ecb 100644 --- a/frontend/src/network/models.ts +++ b/frontend/src/network/models.ts @@ -141,3 +141,20 @@ export enum RSort { DownloadsAsc = 4, DownloadsDesc = 5, } + +export enum ActivityType { + Unknown = 0, + ResourcePublished = 1, + ResourceUpdated = 2, + ResourceCommented = 3, +} + +export interface Activity { + id: number; + type: ActivityType; + user_id: number; + created_at: string; + resource?: Resource; + user?: User; + comment?: CommentWithResource; +} diff --git a/frontend/src/network/network.ts b/frontend/src/network/network.ts index 638ceac..ccf9301 100644 --- a/frontend/src/network/network.ts +++ b/frontend/src/network/network.ts @@ -17,6 +17,7 @@ import { ServerConfig, RSort, TagWithCount, + Activity, } from "./models.ts"; class Network { @@ -994,6 +995,18 @@ class Network { }; } } + + async getActivities(page: number = 1): Promise> { + try { + const response = await axios.get(`${this.apiBaseUrl}/activity`, { + params: { page }, + }); + return response.data; + } catch (e: any) { + console.error(e); + return { success: false, message: e.toString() }; + } + } } export const network = new Network(); diff --git a/frontend/src/pages/activities_page.tsx b/frontend/src/pages/activities_page.tsx new file mode 100644 index 0000000..5f2dffe --- /dev/null +++ b/frontend/src/pages/activities_page.tsx @@ -0,0 +1,136 @@ +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 "react-i18next"; +import { MdArrowRight } from "react-icons/md"; +import { useNavigate } from "react-router"; +import Loading from "../components/loading.tsx"; + +export default function ActivitiesPage() { + const [activities, setActivities] = useState([]); + const pageRef = useRef(0); + const maxPageRef = useRef(1); + const isLoadingRef = useRef(false); + + const fetchNextPage = useCallback(async () => { + if (isLoadingRef.current || pageRef.current >= maxPageRef.current) return; + isLoadingRef.current = true; + const response = await network.getActivities(pageRef.current); + 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(() => { + 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]); + + return ( +
    + {activities.map((activity) => ( + + ))} + {pageRef.current < maxPageRef.current && } +
    + ); +} + +function ActivityCard({ activity }: { activity: Activity }) { + const { t } = useTranslation(); + + const messages = [ + "Unknown activity", + t("Published a resource"), + t("Updated a resource"), + t("Commented on a resource"), + ]; + + const navigate = useNavigate(); + + let content = <>; + + if ( + activity.type === ActivityType.ResourcePublished || + activity.type === ActivityType.ResourceUpdated + ) { + content = ( +
    +
    {activity.resource?.title}
    + {activity.resource?.image && ( +
    + {activity.resource.title} +
    + )} +
    + ); + } else if (activity.type === ActivityType.ResourceCommented) { + content = ( +
    +
    {activity.comment?.content}
    +
    + + + {activity.comment?.resource?.title} + +
    +
    + ); + } + + return ( +
    { + if ( + activity.type === ActivityType.ResourcePublished || + activity.type === ActivityType.ResourceUpdated + ) { + navigate(`/resources/${activity.resource?.id}`); + } else if (activity.type === ActivityType.ResourceCommented) { + navigate(`/resources/${activity.comment?.resource.id}`); + } + }} + > +
    +
    + {"avatar"} +
    + {activity.user?.username} + + {messages[activity.type]} + +
    + {content} +
    + ); +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 7667f2a..7986745 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ proxy: { "/api": { target: "http://localhost:3000", + // target: "https://res.nyne.dev", changeOrigin: true, }, }, diff --git a/main.go b/main.go index 4525080..1e363b3 100644 --- a/main.go +++ b/main.go @@ -34,6 +34,7 @@ func main() { api.AddFileRoutes(apiG) api.AddCommentRoutes(apiG) api.AddConfigRoutes(apiG) + api.AddActivityRoutes(apiG) } log.Fatal(app.Listen(":3000")) diff --git a/server/api/activity.go b/server/api/activity.go new file mode 100644 index 0000000..1789fc2 --- /dev/null +++ b/server/api/activity.go @@ -0,0 +1,33 @@ +package api + +import ( + "github.com/gofiber/fiber/v3" + "nysoure/server/model" + "nysoure/server/service" + "strconv" +) + +func handleGetActivity(c fiber.Ctx) error { + pageStr := c.Query("page", "1") + page, err := strconv.Atoi(pageStr) + if err != nil { + return model.NewRequestError("Invalid page number") + } + activities, totalPages, err := service.GetActivityList(page) + if err != nil { + return err + } + if activities == nil { + activities = []model.ActivityView{} + } + return c.JSON(model.PageResponse[model.ActivityView]{ + Success: true, + Data: activities, + TotalPages: totalPages, + Message: "Activities retrieved successfully", + }) +} + +func AddActivityRoutes(router fiber.Router) { + router.Get("/activity", handleGetActivity) +} diff --git a/server/dao/activity.go b/server/dao/activity.go new file mode 100644 index 0000000..8ce5598 --- /dev/null +++ b/server/dao/activity.go @@ -0,0 +1,53 @@ +package dao + +import "nysoure/server/model" + +func AddNewResourceActivity(userID, resourceID uint) error { + activity := &model.Activity{ + UserID: userID, + Type: model.ActivityTypeNewResource, + RefID: resourceID, + } + return db.Create(activity).Error +} + +func AddUpdateResourceActivity(userID, resourceID uint) error { + activity := &model.Activity{ + UserID: userID, + Type: model.ActivityTypeUpdateResource, + RefID: resourceID, + } + 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 DeleteResourceActivity(resourceID uint) error { + return db.Where("ref_id = ? AND (type = ? OR type = ?)", resourceID, model.ActivityTypeNewResource, model.ActivityTypeUpdateResource).Delete(&model.Activity{}).Error +} + +func DeleteCommentActivity(commentID uint) error { + return db.Where("ref_id = ? AND type = ?", commentID, model.ActivityTypeNewComment).Delete(&model.Activity{}).Error +} + +func GetActivityList(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.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/comment.go b/server/dao/comment.go index feea21c..0960008 100644 --- a/server/dao/comment.go +++ b/server/dao/comment.go @@ -62,3 +62,11 @@ func GetCommentsWithUser(username string, page, pageSize int) ([]model.Comment, totalPages := (int(total) + pageSize - 1) / pageSize return comments, totalPages, nil } + +func GetCommentByID(commentID uint) (*model.Comment, error) { + var comment model.Comment + if err := db.Preload("User").Preload("Resource").First(&comment, commentID).Error; err != nil { + return nil, err + } + return &comment, nil +} diff --git a/server/dao/db.go b/server/dao/db.go index f24cfe7..efb52d0 100644 --- a/server/dao/db.go +++ b/server/dao/db.go @@ -34,7 +34,18 @@ func init() { } } - _ = db.AutoMigrate(&model.User{}, &model.Resource{}, &model.Image{}, &model.Tag{}, &model.Storage{}, &model.File{}, &model.UploadingFile{}, &model.Statistic{}, &model.Comment{}) + _ = db.AutoMigrate( + &model.User{}, + &model.Resource{}, + &model.Image{}, + &model.Tag{}, + &model.Storage{}, + &model.File{}, + &model.UploadingFile{}, + &model.Statistic{}, + &model.Comment{}, + &model.Activity{}, + ) } func GetDB() *gorm.DB { diff --git a/server/model/activity.go b/server/model/activity.go new file mode 100644 index 0000000..4b1c50b --- /dev/null +++ b/server/model/activity.go @@ -0,0 +1,31 @@ +package model + +import ( + "gorm.io/gorm" + "time" +) + +type ActivityType uint + +const ( + ActivityTypeUnknown ActivityType = iota + ActivityTypeNewResource + ActivityTypeUpdateResource + ActivityTypeNewComment +) + +type Activity struct { + gorm.Model + UserID uint `gorm:"not null"` + Type ActivityType `gorm:"not null"` + RefID uint `gorm:"not null"` // Reference ID for the resource or comment +} + +type ActivityView struct { + ID uint `json:"id"` + Time time.Time `json:"time"` + Type ActivityType `json:"type"` + User UserView `json:"user"` + Comment *CommentWithResourceView `json:"comment,omitempty"` + Resource *ResourceView `json:"resource,omitempty"` +} diff --git a/server/service/activity.go b/server/service/activity.go new file mode 100644 index 0000000..713c7f6 --- /dev/null +++ b/server/service/activity.go @@ -0,0 +1,53 @@ +package service + +import ( + "nysoure/server/dao" + "nysoure/server/model" +) + +func GetActivityList(page int) ([]model.ActivityView, int, error) { + offset := (page - 1) * pageSize + limit := pageSize + + activities, total, err := dao.GetActivityList(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.CommentWithResourceView + var resource *model.ResourceView + if activity.Type == model.ActivityTypeNewComment { + c, err := dao.GetCommentByID(activity.RefID) + if err != nil { + return nil, 0, err + } + comment = c.ToViewWithResource() + } else if activity.Type == model.ActivityTypeNewResource || activity.Type == model.ActivityTypeUpdateResource { + r, err := dao.GetResourceByID(activity.RefID) + 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, + } + 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 5ec1409..be9609b 100644 --- a/server/service/comment.go +++ b/server/service/comment.go @@ -29,6 +29,10 @@ func CreateComment(content string, userID uint, resourceID uint) (*model.Comment log.Error("Error creating comment:", err) return nil, model.NewInternalServerError("Error creating comment") } + err = dao.AddNewCommentActivity(userID, c.ID) + if err != nil { + log.Error("Error creating comment activity:", err) + } return c.ToView(), nil } diff --git a/server/service/resource.go b/server/service/resource.go index 28e13b5..f06efd4 100644 --- a/server/service/resource.go +++ b/server/service/resource.go @@ -60,6 +60,10 @@ func CreateResource(uid uint, params *ResourceCreateParams) (uint, error) { if err != nil { log.Error("Error updating cached tag list:", err) } + err = dao.AddNewResourceActivity(uid, r.ID) + if err != nil { + log.Error("AddNewResourceActivity error: ", err) + } return r.ID, nil } @@ -253,6 +257,10 @@ func EditResource(uid, rid uint, params *ResourceCreateParams) error { if err != nil { log.Error("Error updating cached tag list:", err) } + err = dao.AddUpdateResourceActivity(uid, r.ID) + if err != nil { + log.Error("AddUpdateResourceActivity error: ", err) + } return nil }