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
This commit is contained in:
Hyoban 2022-07-07 20:22:36 +08:00 committed by GitHub
parent e202d7b8d6
commit 6b5d5e757e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 286 additions and 99 deletions

View File

@ -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 != "" {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -42,3 +42,16 @@ VALUES
'好好学习,天天向上。🤜🤛',
101
);
INSERT INTO
memo (
`id`,
`content`,
`creator_id`
)
VALUES
(
104,
'好好学习,天天向上。🤜🤛',
102
);

View File

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

View File

@ -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: 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: Props) => {
<span className="ml-2">PINNED</span>
</Only>
</span>
<div className="btns-container">
<span className="btn more-action-btn">
<img className="icon-img" src="/icons/more.svg" />
</span>
<div className="more-action-btns-wrapper">
<div className="more-action-btns-container">
<div className="btns-container">
<div className="btn" onClick={handleTogglePinMemoBtnClick}>
<img className="icon-img" src="/icons/pin.svg" alt="" />
<span className="tip-text">{memo.pinned ? "Unpin" : "Pin"}</span>
</div>
<div className="btn" onClick={handleEditMemoClick}>
<img className="icon-img" src="/icons/edit.svg" alt="" />
<span className="tip-text">Edit</span>
</div>
<div className="btn" onClick={handleGenMemoImageBtnClick}>
<img className="icon-img" src="/icons/share.svg" alt="" />
<span className="tip-text">Share</span>
{userService.isNotVisitor() && (
<div className="btns-container">
<span className="btn more-action-btn">
<img className="icon-img" src="/icons/more.svg" />
</span>
<div className="more-action-btns-wrapper">
<div className="more-action-btns-container">
<div className="btns-container">
<div className="btn" onClick={handleTogglePinMemoBtnClick}>
<img className="icon-img" src="/icons/pin.svg" alt="" />
<span className="tip-text">{memo.pinned ? "Unpin" : "Pin"}</span>
</div>
<div className="btn" onClick={handleEditMemoClick}>
<img className="icon-img" src="/icons/edit.svg" alt="" />
<span className="tip-text">Edit</span>
</div>
<div className="btn" onClick={handleGenMemoImageBtnClick}>
<img className="icon-img" src="/icons/share.svg" alt="" />
<span className="tip-text">Share</span>
</div>
</div>
<span className="btn" onClick={handleMarkMemoClick}>
Mark
</span>
<span className="btn" onClick={handleShowMemoStoryDialog}>
View Story
</span>
<span className="btn archive-btn" onClick={handleArchiveMemoClick}>
Archive
</span>
</div>
<span className="btn" onClick={handleMarkMemoClick}>
Mark
</span>
<span className="btn" onClick={handleShowMemoStoryDialog}>
View Story
</span>
<span className="btn archive-btn" onClick={handleArchiveMemoClick}>
Archive
</span>
</div>
</div>
</div>
)}
</div>
<div
ref={memoContainerRef}

View File

@ -55,6 +55,10 @@ const MenuBtnsPopup: React.FC<Props> = (props: Props) => {
window.location.reload();
};
const handleSignInBtnClick = async () => {
locationService.replaceHistory("/signin");
};
return (
<div className={`menu-btns-popup ${shownStatus ? "" : "hidden"}`} ref={popupElRef}>
<button className="btn action-btn" onClick={handleAboutBtnClick}>
@ -63,8 +67,8 @@ const MenuBtnsPopup: React.FC<Props> = (props: Props) => {
<button className="btn action-btn" onClick={handlePingBtnClick}>
<span className="icon">🎯</span> Ping
</button>
<button className="btn action-btn" onClick={handleSignOutBtnClick}>
<span className="icon">👋</span> Sign out
<button className="btn action-btn" onClick={userService.isNotVisitor() ? handleSignOutBtnClick : handleSignInBtnClick}>
<span className="icon">👋</span> {userService.isNotVisitor() ? "Sign out" : "Sign in"}
</button>
</div>
);

View File

@ -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<Props> = () => {
<div className="shortcuts-wrapper">
<p className="title-text">
<span className="normal-text">Shortcuts</span>
<span className="btn" onClick={() => showCreateShortcutDialog()}>
<img src="/icons/add.svg" alt="add shortcut" />
</span>
{userService.isNotVisitor() && (
<span className="btn" onClick={() => showCreateShortcutDialog()}>
<img src="/icons/add.svg" alt="add shortcut" />
</span>
)}
</p>
<div className="shortcuts-container">
{sortedShortcuts.map((s) => {
@ -114,28 +116,30 @@ const ShortcutContainer: React.FC<ShortcutContainerProps> = (props: ShortcutCont
<div className="shortcut-text-container">
<span className="shortcut-text">{shortcut.title}</span>
</div>
<div className="btns-container">
<span className="action-btn toggle-btn">
<img className="icon-img" src="/icons/more.svg" />
</span>
<div className="action-btns-wrapper">
<div className="action-btns-container">
<span className="btn" onClick={handlePinShortcutBtnClick}>
{shortcut.rowStatus === "ARCHIVED" ? "Unpin" : "Pin"}
</span>
<span className="btn" onClick={handleEditShortcutBtnClick}>
Edit
</span>
<span
className={`btn delete-btn ${showConfirmDeleteBtn ? "final-confirm" : ""}`}
onClick={handleDeleteMemoClick}
onMouseLeave={handleDeleteBtnMouseLeave}
>
{showConfirmDeleteBtn ? "Delete!" : "Delete"}
</span>
{userService.isNotVisitor() && (
<div className="btns-container">
<span className="action-btn toggle-btn">
<img className="icon-img" src="/icons/more.svg" />
</span>
<div className="action-btns-wrapper">
<div className="action-btns-container">
<span className="btn" onClick={handlePinShortcutBtnClick}>
{shortcut.rowStatus === "ARCHIVED" ? "Unpin" : "Pin"}
</span>
<span className="btn" onClick={handleEditShortcutBtnClick}>
Edit
</span>
<span
className={`btn delete-btn ${showConfirmDeleteBtn ? "final-confirm" : ""}`}
onClick={handleDeleteMemoClick}
onMouseLeave={handleDeleteBtnMouseLeave}
>
{showConfirmDeleteBtn ? "Delete!" : "Delete"}
</span>
</div>
</div>
</div>
</div>
)}
</div>
</>
);

View File

@ -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<Props> = () => {
</div>
</div>
<UsageHeatMap />
<div className="action-btns-container">
<button className="btn action-btn" onClick={() => showDailyReviewDialog()}>
<span className="icon">📅</span> Daily Review
</button>
<button className="btn action-btn" onClick={handleMyAccountBtnClick}>
<span className="icon"></span> Setting
</button>
<button className="btn action-btn" onClick={handleArchivedBtnClick}>
<span className="icon">🗂</span> Archived
</button>
</div>
{userService.isNotVisitor() && (
<>
<div className="action-btns-container">
<button className="btn action-btn" onClick={() => showDailyReviewDialog()}>
<span className="icon">📅</span> Daily Review
</button>
<button className="btn action-btn" onClick={handleMyAccountBtnClick}>
<span className="icon"></span> Setting
</button>
<button className="btn action-btn" onClick={handleArchivedBtnClick}>
<span className="icon">🗂</span> Archived
</button>
</div>
</>
)}
<ShortcutList />
<TagList />
</aside>

View File

@ -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<Props> = () => {
{tags.map((t, idx) => (
<TagItemContainer key={t.text + "-" + idx} tag={t} tagQuery={query?.tag} />
))}
<Only when={tags.length < 5}>
<Only when={userService.isNotVisitor() && tags.length < 5}>
<p className="tag-tip-container">
Enter <span className="code-text">#tag </span> to create a tag
</p>

View File

@ -73,9 +73,6 @@ const UsageHeatMap: React.FC<Props> = () => {
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);
}

View File

@ -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<Props> = () => {
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<Props> = () => {
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 (
<div className="user-banner-container">
<div className="username-container" onClick={handleUsernameClick}>

View File

@ -44,16 +44,20 @@ export function getUserList() {
return axios.get<ResponseObject<User[]>>("/api/user");
}
export function getUserNameById(id: number) {
return axios.get<ResponseObject<string>>(`/api/user/${id}/name`);
}
export function patchUser(userPatch: UserPatch) {
return axios.patch<ResponseObject<User>>("/api/user/me", userPatch);
}
export function getMemoList() {
return axios.get<ResponseObject<Memo[]>>("/api/memo");
export function getMemoList(userId?: number) {
return axios.get<ResponseObject<Memo[]>>(`/api/memo${userId ? "?userID=" + userId : ""}`);
}
export function getArchivedMemoList() {
return axios.get<ResponseObject<Memo[]>>("/api/memo?rowStatus=ARCHIVED");
export function getArchivedMemoList(userId?: number) {
return axios.get<ResponseObject<Memo[]>>(`/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<ResponseObject<Shortcut[]>>("/api/shortcut");
export function getShortcutList(userId?: number) {
return axios.get<ResponseObject<Shortcut[]>>(`/api/shortcut${userId ? "?userID=" + userId : ""}`);
}
export function createShortcut(shortcutCreate: ShortcutCreate) {
@ -100,6 +104,6 @@ export function uploadFile(formData: FormData) {
return axios.post<ResponseObject<Resource>>("/api/resource", formData);
}
export function getTagList() {
return axios.get<ResponseObject<string[]>>("/api/tag");
export function getTagList(userId?: number) {
return axios.get<ResponseObject<string[]>>(`/api/tag${userId ? "?userID=" + userId : ""}`);
}

View File

@ -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() {
<main className="memos-wrapper">
<div className="memos-editor-wrapper">
<MemosHeader />
<MemoEditor />
{userService.isNotVisitor() && <MemoEditor />}
<MemoFilter />
</div>
<MemoList />

View File

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

View File

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

View File

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

View File

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