From 6b5d5e757ea9909eb7a96c85fe801f690fd7a1cc Mon Sep 17 00:00:00 2001 From: Hyoban Date: Thu, 7 Jul 2022 20:22:36 +0800 Subject: [PATCH] feat: personal memos page (#105) * feat: no need to log in to view memos * chore: add a normal user to seed * feat: page for other members * fix: replace window.location * fix: can not get username on home * fix: check userID * fix: can visit other user's page after login * fix: do not redirect on wrong path * fix: path error when clicked heatmap * refactor: revise for review * chore: remove unused import * refactor: revise for review * feat: update each user's route to /u/:userId. * chore: eslint for import sort * refactor: revise for review --- server/basic_auth.go | 4 ++ server/memo.go | 24 ++++++++++- server/shortcut.go | 24 ++++++++++- server/tag.go | 26 +++++++++++- server/user.go | 23 +++++++++++ store/db/seed/10001__user.sql | 20 +++++++++ store/db/seed/10002__memo.sql | 13 ++++++ web/.eslintrc.json | 8 +++- web/src/components/Memo.tsx | 62 ++++++++++++++-------------- web/src/components/MenuBtnsPopup.tsx | 8 +++- web/src/components/ShortcutList.tsx | 52 ++++++++++++----------- web/src/components/Sidebar.tsx | 27 +++++++----- web/src/components/TagList.tsx | 4 +- web/src/components/UsageHeatMap.tsx | 3 -- web/src/components/UserBanner.tsx | 33 ++++++++++++--- web/src/helpers/api.ts | 20 +++++---- web/src/pages/Home.tsx | 12 +++--- web/src/services/memoService.ts | 7 ++-- web/src/services/shortcutService.ts | 3 +- web/src/services/userService.ts | 10 +++++ web/src/store/modules/location.ts | 2 +- 21 files changed, 286 insertions(+), 99 deletions(-) diff --git a/server/basic_auth.go b/server/basic_auth.go index 777f80d4..f6026ee6 100644 --- a/server/basic_auth.go +++ b/server/basic_auth.go @@ -59,6 +59,10 @@ func BasicAuthMiddleware(s *Server, next echo.HandlerFunc) echo.HandlerFunc { return next(c) } + if common.HasPrefixes(c.Path(), "/api/memo", "/api/tag", "/api/shortcut", "/api/user/:id/name") && c.Request().Method == http.MethodGet { + return next(c) + } + // If there is openId in query string and related user is found, then skip auth. openID := c.QueryParam("openId") if openID != "" { diff --git a/server/memo.go b/server/memo.go index fc7d1951..107e4714 100644 --- a/server/memo.go +++ b/server/memo.go @@ -60,7 +60,29 @@ func (s *Server) registerMemoRoutes(g *echo.Group) { }) g.GET("/memo", func(c echo.Context) error { - userID := c.Get(getUserIDContextKey()).(int) + userID, ok := c.Get(getUserIDContextKey()).(int) + if !ok { + if c.QueryParam("userID") != "" { + var err error + userID, err = strconv.Atoi(c.QueryParam("userID")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.QueryParam("userID"))) + } + } else { + ownerUserType := api.Owner + ownerUser, err := s.Store.FindUser(&api.UserFind{ + Role: &ownerUserType, + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find owner user").SetInternal(err) + } + if ownerUser == nil { + return echo.NewHTTPError(http.StatusNotFound, "Owner user do not exist") + } + userID = ownerUser.ID + } + } + memoFind := &api.MemoFind{ CreatorID: &userID, } diff --git a/server/shortcut.go b/server/shortcut.go index b2e555f9..5728fa56 100644 --- a/server/shortcut.go +++ b/server/shortcut.go @@ -59,7 +59,29 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) { }) g.GET("/shortcut", func(c echo.Context) error { - userID := c.Get(getUserIDContextKey()).(int) + userID, ok := c.Get(getUserIDContextKey()).(int) + if !ok { + if c.QueryParam("userID") != "" { + var err error + userID, err = strconv.Atoi(c.QueryParam("userID")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.QueryParam("userID"))) + } + } else { + ownerUserType := api.Owner + ownerUser, err := s.Store.FindUser(&api.UserFind{ + Role: &ownerUserType, + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find owner user").SetInternal(err) + } + if ownerUser == nil { + return echo.NewHTTPError(http.StatusNotFound, "Owner user do not exist") + } + userID = ownerUser.ID + } + } + shortcutFind := &api.ShortcutFind{ CreatorID: &userID, } diff --git a/server/tag.go b/server/tag.go index 463effb0..0887c048 100644 --- a/server/tag.go +++ b/server/tag.go @@ -2,9 +2,11 @@ package server import ( "encoding/json" + "fmt" "net/http" "regexp" "sort" + "strconv" "github.com/usememos/memos/api" @@ -13,7 +15,29 @@ import ( func (s *Server) registerTagRoutes(g *echo.Group) { g.GET("/tag", func(c echo.Context) error { - userID := c.Get(getUserIDContextKey()).(int) + userID, ok := c.Get(getUserIDContextKey()).(int) + if !ok { + if c.QueryParam("userID") != "" { + var err error + userID, err = strconv.Atoi(c.QueryParam("userID")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.QueryParam("userID"))) + } + } else { + ownerUserType := api.Owner + ownerUser, err := s.Store.FindUser(&api.UserFind{ + Role: &ownerUserType, + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find owner user").SetInternal(err) + } + if ownerUser == nil { + return echo.NewHTTPError(http.StatusNotFound, "Owner user do not exist") + } + userID = ownerUser.ID + } + } + contentSearch := "#" normalRowStatus := api.Normal memoFind := api.MemoFind{ diff --git a/server/user.go b/server/user.go index f266b400..cf51b94d 100644 --- a/server/user.go +++ b/server/user.go @@ -51,6 +51,29 @@ func (s *Server) registerUserRoutes(g *echo.Group) { return nil }) + g.GET("/user/:id/name", func(c echo.Context) error { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Malformatted user id").SetInternal(err) + } + + user, err := s.Store.FindUser(&api.UserFind{ + ID: &id, + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch user").SetInternal(err) + } + if user == nil { + return echo.NewHTTPError(http.StatusNotFound, "User not found") + } + + c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8) + if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(user.Name)); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode user response").SetInternal(err) + } + return nil + }) + // GET /api/user/me is used to check if the user is logged in. g.GET("/user/me", func(c echo.Context) error { userSessionID := c.Get(getUserIDContextKey()) diff --git a/store/db/seed/10001__user.sql b/store/db/seed/10001__user.sql index 5557dad1..9c3e9f16 100644 --- a/store/db/seed/10001__user.sql +++ b/store/db/seed/10001__user.sql @@ -17,3 +17,23 @@ VALUES -- raw password: secret '$2a$14$ajq8Q7fbtFRQvXpdCq7Jcuy.Rx1h/L4J60Otx.gyNLbAYctGMJ9tK' ); + +INSERT INTO + user ( + `id`, + `email`, + `role`, + `name`, + `open_id`, + `password_hash` + ) +VALUES + ( + 102, + 'jack@usememos.com', + 'USER', + 'Jack', + 'jack_open_id', + -- raw password: secret + '$2a$14$ajq8Q7fbtFRQvXpdCq7Jcuy.Rx1h/L4J60Otx.gyNLbAYctGMJ9tK' + ); diff --git a/store/db/seed/10002__memo.sql b/store/db/seed/10002__memo.sql index 27d94c58..3ed4dfba 100644 --- a/store/db/seed/10002__memo.sql +++ b/store/db/seed/10002__memo.sql @@ -42,3 +42,16 @@ VALUES '好好学习,天天向上。🤜🤛', 101 ); + +INSERT INTO + memo ( + `id`, + `content`, + `creator_id` + ) +VALUES + ( + 104, + '好好学习,天天向上。🤜🤛', + 102 + ); diff --git a/web/.eslintrc.json b/web/.eslintrc.json index b083b79c..173b34c6 100644 --- a/web/.eslintrc.json +++ b/web/.eslintrc.json @@ -23,6 +23,12 @@ ], "@typescript-eslint/no-empty-interface": ["off"], "@typescript-eslint/no-explicit-any": ["off"], - "react/react-in-jsx-scope": "off" + "react/react-in-jsx-scope": "off", + "sort-imports": [ + "error", + { + "memberSyntaxSortOrder": ["all", "multiple", "single", "none"] + } + ] } } diff --git a/web/src/components/Memo.tsx b/web/src/components/Memo.tsx index 07ec4375..6d15730b 100644 --- a/web/src/components/Memo.tsx +++ b/web/src/components/Memo.tsx @@ -3,7 +3,7 @@ import { escape, indexOf } from "lodash-es"; import { IMAGE_URL_REG, LINK_REG, MEMO_LINK_REG, TAG_REG, UNKNOWN_ID } from "../helpers/consts"; import { DONE_BLOCK_REG, parseMarkedToHtml, TODO_BLOCK_REG } from "../helpers/marked"; import * as utils from "../helpers/utils"; -import { editorStateService, locationService, memoService } from "../services"; +import { editorStateService, locationService, memoService, userService } from "../services"; import Only from "./common/OnlyWhen"; import Image from "./Image"; import showMemoCardDialog from "./MemoCardDialog"; @@ -112,7 +112,7 @@ const Memo: React.FC = (props: Props) => { } else { locationService.setTagQuery(tagName); } - } else if (targetEl.classList.contains("todo-block")) { + } else if (targetEl.classList.contains("todo-block") && userService.isNotVisitor()) { const status = targetEl.dataset?.value; const todoElementList = [...(memoContainerRef.current?.querySelectorAll(`span.todo-block[data-value=${status}]`) ?? [])]; for (const element of todoElementList) { @@ -158,38 +158,40 @@ const Memo: React.FC = (props: Props) => { PINNED -
- - - -
-
-
-
- - {memo.pinned ? "Unpin" : "Pin"} -
-
- - Edit -
-
- - Share + {userService.isNotVisitor() && ( +
+ + + +
+
+
+
+ + {memo.pinned ? "Unpin" : "Pin"} +
+
+ + Edit +
+
+ + Share +
+ + Mark + + + View Story + + + Archive +
- - Mark - - - View Story - - - Archive -
-
+ )}
= (props: Props) => { window.location.reload(); }; + const handleSignInBtnClick = async () => { + locationService.replaceHistory("/signin"); + }; + return (
-
); diff --git a/web/src/components/ShortcutList.tsx b/web/src/components/ShortcutList.tsx index 2474692d..b79736d9 100644 --- a/web/src/components/ShortcutList.tsx +++ b/web/src/components/ShortcutList.tsx @@ -1,5 +1,5 @@ import { useEffect } from "react"; -import { locationService, shortcutService } from "../services"; +import { locationService, shortcutService, userService } from "../services"; import { useAppSelector } from "../store"; import * as utils from "../helpers/utils"; import useToggle from "../hooks/useToggle"; @@ -38,9 +38,11 @@ const ShortcutList: React.FC = () => {

Shortcuts - showCreateShortcutDialog()}> - add shortcut - + {userService.isNotVisitor() && ( + showCreateShortcutDialog()}> + add shortcut + + )}

{sortedShortcuts.map((s) => { @@ -114,28 +116,30 @@ const ShortcutContainer: React.FC = (props: ShortcutCont
{shortcut.title}
-
- - - -
-
- - {shortcut.rowStatus === "ARCHIVED" ? "Unpin" : "Pin"} - - - Edit - - - {showConfirmDeleteBtn ? "Delete!" : "Delete"} - + {userService.isNotVisitor() && ( +
+ + + +
+
+ + {shortcut.rowStatus === "ARCHIVED" ? "Unpin" : "Pin"} + + + Edit + + + {showConfirmDeleteBtn ? "Delete!" : "Delete"} + +
-
+ )}
); diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index 9c406d24..01ecd0a2 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -7,6 +7,7 @@ import UserBanner from "./UserBanner"; import UsageHeatMap from "./UsageHeatMap"; import ShortcutList from "./ShortcutList"; import TagList from "./TagList"; +import { userService } from "../services"; import "../less/siderbar.less"; interface Props {} @@ -48,17 +49,21 @@ const Sidebar: React.FC = () => {
-
- - - -
+ {userService.isNotVisitor() && ( + <> +
+ + + +
+ + )} diff --git a/web/src/components/TagList.tsx b/web/src/components/TagList.tsx index 66c3cddc..522bfec0 100644 --- a/web/src/components/TagList.tsx +++ b/web/src/components/TagList.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { useAppSelector } from "../store"; -import { locationService, memoService } from "../services"; +import { locationService, memoService, userService } from "../services"; import useToggle from "../hooks/useToggle"; import Only from "./common/OnlyWhen"; import * as utils from "../helpers/utils"; @@ -71,7 +71,7 @@ const TagList: React.FC = () => { {tags.map((t, idx) => ( ))} - +

Enter #tag to create a tag

diff --git a/web/src/components/UsageHeatMap.tsx b/web/src/components/UsageHeatMap.tsx index 4b621a50..f9e85c2c 100644 --- a/web/src/components/UsageHeatMap.tsx +++ b/web/src/components/UsageHeatMap.tsx @@ -73,9 +73,6 @@ const UsageHeatMap: React.FC = () => { locationService.setFromAndToQuery(); setCurrentStat(null); } else if (item.count > 0) { - if (!["/"].includes(locationService.getState().pathname)) { - locationService.setPathname("/"); - } locationService.setFromAndToQuery(item.timestamp, item.timestamp + DAILY_TIMESTAMP); setCurrentStat(item); } diff --git a/web/src/components/UserBanner.tsx b/web/src/components/UserBanner.tsx index 9e020b34..f9e54f4a 100644 --- a/web/src/components/UserBanner.tsx +++ b/web/src/components/UserBanner.tsx @@ -1,7 +1,10 @@ -import { useCallback, useState } from "react"; -import { useAppSelector } from "../store"; -import { locationService } from "../services"; +import * as api from "../helpers/api"; +import { useCallback, useEffect, useState } from "react"; import MenuBtnsPopup from "./MenuBtnsPopup"; +import { getUserIdFromPath } from "../services/userService"; +import { locationService } from "../services"; +import toastHelper from "./Toast"; +import { useAppSelector } from "../store"; import "../less/user-banner.less"; interface Props {} @@ -10,10 +13,9 @@ const UserBanner: React.FC = () => { const user = useAppSelector((state) => state.user.user); const [shouldShowPopupBtns, setShouldShowPopupBtns] = useState(false); - const username = user ? user.name : "Memos"; + const [username, setUsername] = useState(user ? user.name : "Memos"); const handleUsernameClick = useCallback(() => { - locationService.pushHistory("/"); locationService.clearQuery(); }, []); @@ -21,6 +23,27 @@ const UserBanner: React.FC = () => { setShouldShowPopupBtns(true); }; + useEffect(() => { + if (username === "Memos") { + if (locationService.getState().pathname === "/") { + api.getSystemStatus().then(({ data }) => { + const { data: status } = data; + setUsername(status.owner.name); + }); + } else { + api + .getUserNameById(Number(getUserIdFromPath())) + .then(({ data }) => { + const { data: username } = data; + setUsername(username); + }) + .catch(() => { + toastHelper.error("User not found"); + }); + } + } + }, []); + return (
diff --git a/web/src/helpers/api.ts b/web/src/helpers/api.ts index c5a9c563..5da6c325 100644 --- a/web/src/helpers/api.ts +++ b/web/src/helpers/api.ts @@ -44,16 +44,20 @@ export function getUserList() { return axios.get>("/api/user"); } +export function getUserNameById(id: number) { + return axios.get>(`/api/user/${id}/name`); +} + export function patchUser(userPatch: UserPatch) { return axios.patch>("/api/user/me", userPatch); } -export function getMemoList() { - return axios.get>("/api/memo"); +export function getMemoList(userId?: number) { + return axios.get>(`/api/memo${userId ? "?userID=" + userId : ""}`); } -export function getArchivedMemoList() { - return axios.get>("/api/memo?rowStatus=ARCHIVED"); +export function getArchivedMemoList(userId?: number) { + return axios.get>(`/api/memo?rowStatus=ARCHIVED${userId ? "&userID=" + userId : ""}`); } export function createMemo(memoCreate: MemoCreate) { @@ -80,8 +84,8 @@ export function deleteMemo(memoId: MemoId) { return axios.delete(`/api/memo/${memoId}`); } -export function getShortcutList() { - return axios.get>("/api/shortcut"); +export function getShortcutList(userId?: number) { + return axios.get>(`/api/shortcut${userId ? "?userID=" + userId : ""}`); } export function createShortcut(shortcutCreate: ShortcutCreate) { @@ -100,6 +104,6 @@ export function uploadFile(formData: FormData) { return axios.post>("/api/resource", formData); } -export function getTagList() { - return axios.get>("/api/tag"); +export function getTagList(userId?: number) { + return axios.get>(`/api/tag${userId ? "?userID=" + userId : ""}`); } diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx index b8cd578d..7bd2d37f 100644 --- a/web/src/pages/Home.tsx +++ b/web/src/pages/Home.tsx @@ -12,6 +12,9 @@ function Home() { const loadingState = useLoading(); useEffect(() => { + if (window.location.pathname !== locationService.getState().pathname) { + locationService.replaceHistory("/"); + } const { user } = userService.getState(); if (!user) { userService @@ -20,11 +23,10 @@ function Home() { // do nth }) .finally(() => { - if (userService.getState().user) { - loadingState.setFinish(); - } else { - locationService.replaceHistory("/signin"); + if (userService.getState().user && locationService.getState().pathname !== "/") { + locationService.replaceHistory("/"); } + loadingState.setFinish(); }); } else { loadingState.setFinish(); @@ -39,7 +41,7 @@ function Home() {
- + {userService.isNotVisitor() && }
diff --git a/web/src/services/memoService.ts b/web/src/services/memoService.ts index 097bde00..72864668 100644 --- a/web/src/services/memoService.ts +++ b/web/src/services/memoService.ts @@ -1,6 +1,7 @@ import * as api from "../helpers/api"; import { createMemo, patchMemo, setMemos, setTags } from "../store/modules/memo"; import store from "../store"; +import { getUserIdFromPath } from "./userService"; const convertResponseModelMemo = (memo: Memo): Memo => { return { @@ -16,7 +17,7 @@ const memoService = { }, fetchAllMemos: async () => { - const { data } = (await api.getMemoList()).data; + const { data } = (await api.getMemoList(getUserIdFromPath())).data; const memos = data.filter((m) => m.rowStatus !== "ARCHIVED").map((m) => convertResponseModelMemo(m)); store.dispatch(setMemos(memos)); @@ -24,7 +25,7 @@ const memoService = { }, fetchArchivedMemos: async () => { - const { data } = (await api.getArchivedMemoList()).data; + const { data } = (await api.getArchivedMemoList(getUserIdFromPath())).data; const archivedMemos = data.map((m) => { return convertResponseModelMemo(m); }); @@ -42,7 +43,7 @@ const memoService = { }, updateTagsState: async () => { - const { data } = (await api.getTagList()).data; + const { data } = (await api.getTagList(getUserIdFromPath())).data; store.dispatch(setTags(data)); }, diff --git a/web/src/services/shortcutService.ts b/web/src/services/shortcutService.ts index 4126dc2f..9a0af527 100644 --- a/web/src/services/shortcutService.ts +++ b/web/src/services/shortcutService.ts @@ -1,6 +1,7 @@ import * as api from "../helpers/api"; import store from "../store/"; import { createShortcut, deleteShortcut, patchShortcut, setShortcuts } from "../store/modules/shortcut"; +import { getUserIdFromPath } from "./userService"; const convertResponseModelShortcut = (shortcut: Shortcut): Shortcut => { return { @@ -16,7 +17,7 @@ const shortcutService = { }, getMyAllShortcuts: async () => { - const { data } = (await api.getShortcutList()).data; + const { data } = (await api.getShortcutList(getUserIdFromPath())).data; const shortcuts = data.map((s) => convertResponseModelShortcut(s)); store.dispatch(setShortcuts(shortcuts)); }, diff --git a/web/src/services/userService.ts b/web/src/services/userService.ts index 98365d21..74ec1298 100644 --- a/web/src/services/userService.ts +++ b/web/src/services/userService.ts @@ -1,3 +1,4 @@ +import { locationService } from "."; import * as api from "../helpers/api"; import store from "../store"; import { setUser, patchUser } from "../store/modules/user"; @@ -10,11 +11,20 @@ const convertResponseModelUser = (user: User): User => { }; }; +export const getUserIdFromPath = () => { + const path = locationService.getState().pathname.slice(3); + return !isNaN(Number(path)) ? Number(path) : undefined; +}; + const userService = { getState: () => { return store.getState().user; }, + isNotVisitor: () => { + return store.getState().user.user !== undefined; + }, + doSignIn: async () => { const { data: user } = (await api.getUser()).data; if (user) { diff --git a/web/src/store/modules/location.ts b/web/src/store/modules/location.ts index 21239156..2c2bdc76 100644 --- a/web/src/store/modules/location.ts +++ b/web/src/store/modules/location.ts @@ -20,7 +20,7 @@ interface State { } const getValidPathname = (pathname: string): string => { - if (["/", "/signin"].includes(pathname)) { + if (["/", "/signin"].includes(pathname) || pathname.match(/^\/u\/(\d+)/)) { return pathname; } else { return "/";