Compare commits

..

1 Commits

Author SHA1 Message Date
boojack eea826dd5c chore: release v0.2.2 2022-07-22 23:42:26 +08:00
93 changed files with 652 additions and 1429 deletions
@@ -0,0 +1,37 @@
name: build-and-push-dev-image
on:
push:
branches:
- "main"
jobs:
build-and-push-dev-image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: neosmemo
password: ${{ secrets.DOCKER_NEOSMEMO_TOKEN }}
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
with:
install: true
- name: Build and Push
id: docker_build
uses: docker/build-push-action@v3
with:
context: ./
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: neosmemo/memos:dev
+10 -9
View File
@@ -3,13 +3,13 @@
<p align="center">An open source, self-hosted knowledge base that works with a SQLite db file.</p>
<p align="center">
<a href="https://github.com/usememos/memos/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/usememos/memos" /></a>
<img alt="GitHub stars" src="https://img.shields.io/github/stars/usememos/memos" />
<a href="https://hub.docker.com/r/neosmemo/memos"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/neosmemo/memos.svg" /></a>
<img alt="Go report" src="https://goreportcard.com/badge/github.com/usememos/memos" />
</p>
<p align="center">
<a href="https://demo.usememos.com/">Live Demo</a> •
<a href="https://memos.onrender.com/">Live Demo</a> •
<a href="https://t.me/+-_tNF1k70UU4ZTc9">Discuss in Telegram 👾</a>
</p>
@@ -36,7 +36,9 @@ docker run \
--port 5230
```
Memos should be running at [http://localhost:5230](http://localhost:5230). If the `~/.memos/` does not have a `memos_prod.db` file, then memos will auto generate it.
Memos should now be running at [http://localhost:5230](http://localhost:5230). If the `~/.memos/` does not have a `memos_prod.db` file, then `memos` will auto generate it.
⚠️ Please DO NOT use `dev` tag of docker image if you have no experience.
## 🏗 Development
@@ -52,9 +54,8 @@ Memos is built with a curated tech stack. It is optimized for developer experien
### Prerequisites
- [Go](https://golang.org/doc/install)
- [Go](https://golang.org/doc/install) (1.16 or later)
- [Air](https://github.com/cosmtrek/air#installation) for backend live reload
- [Node.js](https://nodejs.org/)
- [yarn](https://yarnpkg.com/getting-started/install)
### Steps
@@ -79,10 +80,10 @@ Memos is built with a curated tech stack. It is optimized for developer experien
Memos should now be running at [http://localhost:3000](http://localhost:3000) and change either frontend or backend code would trigger live reload.
### Contributing
Contributions are what make the open source community such an amazing place to be learn, inspire, and create. Any contributions you make are greatly appreciated. 🥰
## 🌟 Star history
[![Star History Chart](https://api.star-history.com/svg?repos=usememos/memos&type=Date)](https://star-history.com/#usememos/memos&Date)
---
Just enjoy it.
-4
View File
@@ -1,9 +1,5 @@
package api
var (
UNKNOWN_ID = 0
)
type Signin struct {
Email string `json:"email"`
Password string `json:"password"`
+3 -7
View File
@@ -6,8 +6,6 @@ type Visibility string
const (
// Public is the PUBLIC visibility.
Public Visibility = "PUBLIC"
// Protected is the PROTECTED visibility.
Protected Visibility = "PROTECTED"
// Privite is the PRIVATE visibility.
Privite Visibility = "PRIVATE"
)
@@ -16,8 +14,6 @@ func (e Visibility) String() string {
switch e {
case Public:
return "PUBLIC"
case Protected:
return "PROTECTED"
case Privite:
return "PRIVATE"
}
@@ -69,9 +65,9 @@ type MemoFind struct {
CreatorID *int `json:"creatorId"`
// Domain specific fields
Pinned *bool
ContentSearch *string
VisibilityList []Visibility
Pinned *bool
ContentSearch *string
Visibility *Visibility
// Pagination
Limit int
-3
View File
@@ -38,7 +38,4 @@ type ResourceFind struct {
type ResourceDelete struct {
ID int
// Standard fields
CreatorID int
}
-4
View File
@@ -73,7 +73,3 @@ type UserFind struct {
Name *string `json:"name"`
OpenID *string
}
type UserDelete struct {
ID int
}
+2 -2
View File
@@ -7,10 +7,10 @@ import (
// Version is the service current released version.
// Semantic versioning: https://semver.org/
var Version = "0.3.1"
var Version = "0.2.2"
// DevVersion is the service current development version.
var DevVersion = "0.3.1"
var DevVersion = "0.2.2"
func GetCurrentVersion(mode string) string {
if mode == "dev" {
Executable → Regular
+1 -4
View File
@@ -1,7 +1,4 @@
#!/bin/bash
# Usage: ./scripts/build.sh
# Usage: sh ./scripts/build.sh
set -e
cd "$(dirname "$0")/../"
-116
View File
@@ -1,116 +0,0 @@
package server
import (
"fmt"
"net/http"
"strconv"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
"github.com/gorilla/sessions"
"github.com/labstack/echo-contrib/session"
"github.com/labstack/echo/v4"
)
var (
userIDContextKey = "user-id"
)
func getUserIDContextKey() string {
return userIDContextKey
}
func setUserSession(ctx echo.Context, user *api.User) error {
sess, _ := session.Get("session", ctx)
sess.Options = &sessions.Options{
Path: "/",
MaxAge: 1000 * 3600 * 24 * 30,
HttpOnly: true,
}
sess.Values[userIDContextKey] = user.ID
err := sess.Save(ctx.Request(), ctx.Response())
if err != nil {
return fmt.Errorf("failed to set session, err: %w", err)
}
return nil
}
func removeUserSession(ctx echo.Context) error {
sess, _ := session.Get("session", ctx)
sess.Options = &sessions.Options{
Path: "/",
MaxAge: 0,
HttpOnly: true,
}
sess.Values[userIDContextKey] = nil
err := sess.Save(ctx.Request(), ctx.Response())
if err != nil {
return fmt.Errorf("failed to set session, err: %w", err)
}
return nil
}
func aclMiddleware(s *Server, next echo.HandlerFunc) echo.HandlerFunc {
return func(ctx echo.Context) error {
// Skip auth.
if common.HasPrefixes(ctx.Path(), "/api/auth") {
return next(ctx)
}
if common.HasPrefixes(ctx.Path(), "/api/ping", "/api/status", "/api/user/:id") && ctx.Request().Method == http.MethodGet {
return next(ctx)
}
// If there is openId in query string and related user is found, then skip auth.
openID := ctx.QueryParam("openId")
if openID != "" {
userFind := &api.UserFind{
OpenID: &openID,
}
user, err := s.Store.FindUser(userFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user by open_id").SetInternal(err)
}
if user != nil {
// Stores userID into context.
ctx.Set(getUserIDContextKey(), user.ID)
return next(ctx)
}
}
{
sess, _ := session.Get("session", ctx)
userIDValue := sess.Values[userIDContextKey]
if userIDValue != nil {
userID, _ := strconv.Atoi(fmt.Sprintf("%v", userIDValue))
userFind := &api.UserFind{
ID: &userID,
}
user, err := s.Store.FindUser(userFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user by ID: %d", userID)).SetInternal(err)
}
if user != nil {
if user.RowStatus == api.Archived {
return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("User has been archived with email %s", user.Email))
}
ctx.Set(getUserIDContextKey(), userID)
}
}
}
if common.HasPrefixes(ctx.Path(), "/api/memo", "/api/tag", "/api/shortcut") && ctx.Request().Method == http.MethodGet {
if _, err := strconv.Atoi(ctx.QueryParam("creatorId")); err == nil {
return next(ctx)
}
}
userID := ctx.Get(getUserIDContextKey())
if userID == nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
return next(ctx)
}
}
+119
View File
@@ -0,0 +1,119 @@
package server
import (
"fmt"
"net/http"
"strconv"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
"github.com/gorilla/sessions"
"github.com/labstack/echo-contrib/session"
"github.com/labstack/echo/v4"
)
var (
userIDContextKey = "user-id"
)
func getUserIDContextKey() string {
return userIDContextKey
}
func setUserSession(c echo.Context, user *api.User) error {
sess, _ := session.Get("session", c)
sess.Options = &sessions.Options{
Path: "/",
MaxAge: 1000 * 3600 * 24 * 30,
HttpOnly: true,
}
sess.Values[userIDContextKey] = user.ID
err := sess.Save(c.Request(), c.Response())
if err != nil {
return fmt.Errorf("failed to set session, err: %w", err)
}
return nil
}
func removeUserSession(c echo.Context) error {
sess, _ := session.Get("session", c)
sess.Options = &sessions.Options{
Path: "/",
MaxAge: 0,
HttpOnly: true,
}
sess.Values[userIDContextKey] = nil
err := sess.Save(c.Request(), c.Response())
if err != nil {
return fmt.Errorf("failed to set session, err: %w", err)
}
return nil
}
// Use session to store user.id.
func BasicAuthMiddleware(s *Server, next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// Skip auth for some paths.
if common.HasPrefixes(c.Path(), "/api/auth", "/api/ping", "/api/status", "/api/user/:userId") {
return next(c)
}
// If there is openId in query string and related user is found, then skip auth.
openID := c.QueryParam("openId")
if openID != "" {
userFind := &api.UserFind{
OpenID: &openID,
}
user, err := s.Store.FindUser(userFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user by open_id").SetInternal(err)
}
if user != nil {
// Stores userID into context.
c.Set(getUserIDContextKey(), user.ID)
return next(c)
}
}
if common.HasPrefixes(c.Path(), "/api/memo", "/api/tag", "/api/shortcut") && c.Request().Method == http.MethodGet {
if _, err := strconv.Atoi(c.QueryParam("creatorId")); err == nil {
return next(c)
}
}
sess, err := session.Get("session", c)
if err != nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing session").SetInternal(err)
}
userIDValue := sess.Values[userIDContextKey]
if userIDValue == nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing userID in session")
}
userID, err := strconv.Atoi(fmt.Sprintf("%v", userIDValue))
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to malformatted user id in the session.").SetInternal(err)
}
// Even if there is no error, we still need to make sure the user still exists.
userFind := &api.UserFind{
ID: &userID,
}
user, err := s.Store.FindUser(userFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user by ID: %d", userID)).SetInternal(err)
}
if user == nil {
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("Not found user ID: %d", userID))
} else if user.RowStatus == api.Archived {
return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("User has been archived with email %s", user.Email))
}
// Stores userID into context.
c.Set(getUserIDContextKey(), userID)
return next(c)
}
}
+18 -34
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"net/http"
"strconv"
"strings"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
@@ -15,10 +14,7 @@ import (
func (s *Server) registerMemoRoutes(g *echo.Group) {
g.POST("/memo", func(c echo.Context) error {
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
userID := c.Get(getUserIDContextKey()).(int)
memoCreate := &api.MemoCreate{
CreatorID: userID,
}
@@ -73,20 +69,20 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
if userID, err := strconv.Atoi(c.QueryParam("creatorId")); err == nil {
memoFind.CreatorID = &userID
}
currentUserID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
if memoFind.CreatorID == nil {
} else {
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find memo")
}
memoFind.VisibilityList = []api.Visibility{api.Public}
} else {
if memoFind.CreatorID == nil {
memoFind.CreatorID = &currentUserID
} else {
memoFind.VisibilityList = []api.Visibility{api.Public, api.Protected}
}
memoFind.CreatorID = &userID
}
// Only can get PUBLIC memos in visitor mode
_, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
publicVisibility := api.Public
memoFind.Visibility = &publicVisibility
}
rowStatus := api.RowStatus(c.QueryParam("rowStatus"))
@@ -103,14 +99,6 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
contentSearch := "#" + tag + " "
memoFind.ContentSearch = &contentSearch
}
visibilitListStr := c.QueryParam("visibility")
if visibilitListStr != "" {
visibilityList := []api.Visibility{}
for _, visibility := range strings.Split(visibilitListStr, ",") {
visibilityList = append(visibilityList, api.Visibility(visibility))
}
memoFind.VisibilityList = visibilityList
}
if limit, err := strconv.Atoi(c.QueryParam("limit")); err == nil {
memoFind.Limit = limit
}
@@ -136,10 +124,7 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
}
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
userID := c.Get(getUserIDContextKey()).(int)
memoOrganizerUpsert := &api.MemoOrganizerUpsert{
MemoID: memoID,
UserID: userID,
@@ -205,7 +190,9 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
memoDelete := &api.MemoDelete{
ID: memoID,
}
if err := s.Store.DeleteMemo(memoDelete); err != nil {
err = s.Store.DeleteMemo(memoDelete)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to delete memo ID: %v", memoID)).SetInternal(err)
}
@@ -213,10 +200,7 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
})
g.GET("/memo/amount", func(c echo.Context) error {
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
userID := c.Get(getUserIDContextKey()).(int)
normalRowStatus := api.Normal
memoFind := &api.MemoFind{
CreatorID: &userID,
+5 -23
View File
@@ -14,10 +14,7 @@ import (
func (s *Server) registerResourceRoutes(g *echo.Group) {
g.POST("/resource", func(c echo.Context) error {
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
userID := c.Get(getUserIDContextKey()).(int)
err := c.Request().ParseMultipartForm(64 << 20)
if err != nil {
@@ -64,10 +61,7 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
})
g.GET("/resource", func(c echo.Context) error {
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
userID := c.Get(getUserIDContextKey()).(int)
resourceFind := &api.ResourceFind{
CreatorID: &userID,
}
@@ -89,10 +83,7 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
}
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
userID := c.Get(getUserIDContextKey()).(int)
resourceFind := &api.ResourceFind{
ID: &resourceID,
CreatorID: &userID,
@@ -115,10 +106,7 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
}
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
userID := c.Get(getUserIDContextKey()).(int)
resourceFind := &api.ResourceFind{
ID: &resourceID,
CreatorID: &userID,
@@ -138,19 +126,13 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
})
g.DELETE("/resource/:resourceId", func(c echo.Context) error {
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
resourceID, err := strconv.Atoi(c.Param("resourceId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
}
resourceDelete := &api.ResourceDelete{
ID: resourceID,
CreatorID: userID,
ID: resourceID,
}
if err := s.Store.DeleteResource(resourceDelete); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete resource").SetInternal(err)
+1 -3
View File
@@ -32,8 +32,6 @@ func NewServer(profile *profile.Profile) *Server {
Format: "${method} ${uri} ${status}\n",
}))
e.Use(middleware.CORS())
e.Use(middleware.TimeoutWithConfig(middleware.TimeoutConfig{
Skipper: middleware.DefaultSkipper,
ErrorMessage: "Request timeout",
@@ -60,7 +58,7 @@ func NewServer(profile *profile.Profile) *Server {
apiGroup := e.Group("/api")
apiGroup.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return aclMiddleware(s, next)
return BasicAuthMiddleware(s, next)
})
s.registerSystemRoutes(apiGroup)
s.registerAuthRoutes(apiGroup)
+1 -4
View File
@@ -13,10 +13,7 @@ import (
func (s *Server) registerShortcutRoutes(g *echo.Group) {
g.POST("/shortcut", func(c echo.Context) error {
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
userID := c.Get(getUserIDContextKey()).(int)
shortcutCreate := &api.ShortcutCreate{
CreatorID: userID,
}
+11 -11
View File
@@ -22,21 +22,21 @@ func (s *Server) registerTagRoutes(g *echo.Group) {
}
if userID, err := strconv.Atoi(c.QueryParam("creatorId")); err == nil {
memoFind.CreatorID = &userID
} else {
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find tag")
}
memoFind.CreatorID = &userID
}
currentUserID, ok := c.Get(getUserIDContextKey()).(int)
// Only can get PUBLIC memos in visitor mode
_, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
if memoFind.CreatorID == nil {
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find memo")
}
memoFind.VisibilityList = []api.Visibility{api.Public}
} else {
if memoFind.CreatorID == nil {
memoFind.CreatorID = &currentUserID
} else {
memoFind.VisibilityList = []api.Visibility{api.Public, api.Protected}
}
publicVisibility := api.Public
memoFind.Visibility = &publicVisibility
}
memoList, err := s.Store.FindMemoList(&memoFind)
+32 -39
View File
@@ -44,11 +44,6 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch user list").SetInternal(err)
}
for _, user := range userList {
// data desensitize
user.OpenID = ""
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(userList)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode user list response").SetInternal(err)
@@ -83,11 +78,12 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
// GET /api/user/me is used to check if the user is logged in.
g.GET("/user/me", func(c echo.Context) error {
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
userSessionID := c.Get(getUserIDContextKey())
if userSessionID == nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
}
userID := userSessionID.(int)
userFind := &api.UserFind{
ID: &userID,
}
@@ -103,27 +99,8 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
return nil
})
g.PATCH("/user/:id", func(c echo.Context) error {
userID, err := strconv.Atoi(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("id"))).SetInternal(err)
}
currentUserID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
currentUser, err := s.Store.FindUser(&api.UserFind{
ID: &currentUserID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
}
if currentUser == nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Current session user not found with ID: %d", currentUserID)).SetInternal(err)
} else if currentUser.Role != api.Host && currentUserID != userID {
return echo.NewHTTPError(http.StatusForbidden, "Access forbidden for current session user").SetInternal(err)
}
g.PATCH("/user/me", func(c echo.Context) error {
userID := c.Get(getUserIDContextKey()).(int)
userPatch := &api.UserPatch{
ID: userID,
}
@@ -158,11 +135,8 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
return nil
})
g.DELETE("/user/:id", func(c echo.Context) error {
currentUserID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
g.PATCH("/user/:userId", func(c echo.Context) error {
currentUserID := c.Get(getUserIDContextKey()).(int)
currentUser, err := s.Store.FindUser(&api.UserFind{
ID: &currentUserID,
})
@@ -175,18 +149,37 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusForbidden, "Access forbidden for current session user").SetInternal(err)
}
userID, err := strconv.Atoi(c.Param("id"))
userID, err := strconv.Atoi(c.Param("userId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("id"))).SetInternal(err)
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("userId"))).SetInternal(err)
}
userDelete := &api.UserDelete{
userPatch := &api.UserPatch{
ID: userID,
}
if err := s.Store.DeleteUser(userDelete); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete user").SetInternal(err)
if err := json.NewDecoder(c.Request().Body).Decode(userPatch); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch user request").SetInternal(err)
}
return c.JSON(http.StatusOK, true)
if userPatch.Password != nil && *userPatch.Password != "" {
passwordHash, err := bcrypt.GenerateFromPassword([]byte(*userPatch.Password), bcrypt.DefaultCost)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate password hash").SetInternal(err)
}
passwordHashStr := string(passwordHash)
userPatch.PasswordHash = &passwordHashStr
}
user, err := s.Store.PatchUser(userPatch)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch user").SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(user)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode user response").SetInternal(err)
}
return nil
})
}
+3 -3
View File
@@ -128,7 +128,7 @@ const (
)
func (db *DB) applyLatestSchema() error {
latestSchemaPath := fmt.Sprintf("%s/%s/%s", "migration", db.profile.Mode, latestSchemaFileName)
latestSchemaPath := fmt.Sprintf("%s/%s", "migration", latestSchemaFileName)
buf, err := migrationFS.ReadFile(latestSchemaPath)
if err != nil {
return fmt.Errorf("failed to read latest schema %q, error %w", latestSchemaPath, err)
@@ -141,7 +141,7 @@ func (db *DB) applyLatestSchema() error {
}
func (db *DB) applyMigrationForMinorVersion(minorVersion string) error {
filenames, err := fs.Glob(migrationFS, fmt.Sprintf("%s/%s/*.sql", "migration/prod", minorVersion))
filenames, err := fs.Glob(migrationFS, fmt.Sprintf("%s/%s/*.sql", "migration", minorVersion))
if err != nil {
return err
}
@@ -210,7 +210,7 @@ func (db *DB) execute(stmt string) error {
}
// minorDirRegexp is a regular expression for minor version directory.
var minorDirRegexp = regexp.MustCompile(`^migration/prod/[0-9]+\.[0-9]+$`)
var minorDirRegexp = regexp.MustCompile(`^migration/[0-9]+\.[0-9]+$`)
func getMinorVersionList() []string {
minorVersionList := []string{}
@@ -4,7 +4,8 @@ PRAGMA foreign_keys = off;
DROP TABLE IF EXISTS _user_old;
ALTER TABLE user RENAME TO _user_old;
ALTER TABLE
user RENAME TO _user_old;
CREATE TABLE user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -44,7 +44,7 @@ CREATE TABLE memo (
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
row_status TEXT NOT NULL CHECK (row_status IN ('NORMAL', 'ARCHIVED')) DEFAULT 'NORMAL',
content TEXT NOT NULL DEFAULT '',
visibility TEXT NOT NULL CHECK (visibility IN ('PUBLIC', 'PROTECTED', 'PRIVATE')) DEFAULT 'PRIVATE',
visibility TEXT NOT NULL CHECK (visibility IN ('PUBLIC', 'PRIVATE')) DEFAULT 'PRIVATE',
FOREIGN KEY(creator_id) REFERENCES user(id) ON DELETE CASCADE
);
@@ -1,37 +0,0 @@
-- change memo visibility field from "PRIVATE"/"PUBLIC" to "PRIVATE"/"PROTECTED"/"PUBLIC".
PRAGMA foreign_keys = off;
DROP TABLE IF EXISTS _memo_old;
ALTER TABLE memo RENAME TO _memo_old;
CREATE TABLE memo (
id INTEGER PRIMARY KEY AUTOINCREMENT,
creator_id INTEGER NOT NULL,
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
row_status TEXT NOT NULL CHECK (row_status IN ('NORMAL', 'ARCHIVED')) DEFAULT 'NORMAL',
content TEXT NOT NULL DEFAULT '',
visibility TEXT NOT NULL CHECK (visibility IN ('PUBLIC', 'PROTECTED', 'PRIVATE')) DEFAULT 'PRIVATE',
FOREIGN KEY(creator_id) REFERENCES user(id) ON DELETE CASCADE
);
INSERT INTO memo (
id, creator_id, created_ts, updated_ts,
row_status, content, visibility
)
SELECT
id,
creator_id,
created_ts,
updated_ts,
row_status,
content,
visibility
FROM
_memo_old;
DROP TABLE IF EXISTS _memo_old;
PRAGMA foreign_keys = on;
-141
View File
@@ -1,141 +0,0 @@
-- drop all tables
DROP TABLE IF EXISTS `memo_organizer`;
DROP TABLE IF EXISTS `memo`;
DROP TABLE IF EXISTS `shortcut`;
DROP TABLE IF EXISTS `resource`;
DROP TABLE IF EXISTS `user`;
-- user
CREATE TABLE user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
-- allowed row status are 'NORMAL', 'ARCHIVED'.
row_status TEXT NOT NULL CHECK (row_status IN ('NORMAL', 'ARCHIVED')) DEFAULT 'NORMAL',
email TEXT NOT NULL UNIQUE,
role TEXT NOT NULL CHECK (role IN ('HOST', 'USER')) DEFAULT 'USER',
name TEXT NOT NULL,
password_hash TEXT NOT NULL,
open_id TEXT NOT NULL UNIQUE
);
INSERT INTO
sqlite_sequence (name, seq)
VALUES
('user', 100);
CREATE TRIGGER IF NOT EXISTS `trigger_update_user_modification_time`
AFTER
UPDATE
ON `user` FOR EACH ROW BEGIN
UPDATE
`user`
SET
updated_ts = (strftime('%s', 'now'))
WHERE
rowid = old.rowid;
END;
-- memo
CREATE TABLE memo (
id INTEGER PRIMARY KEY AUTOINCREMENT,
creator_id INTEGER NOT NULL,
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
row_status TEXT NOT NULL CHECK (row_status IN ('NORMAL', 'ARCHIVED')) DEFAULT 'NORMAL',
content TEXT NOT NULL DEFAULT '',
visibility TEXT NOT NULL CHECK (visibility IN ('PUBLIC', 'PROTECTED', 'PRIVATE')) DEFAULT 'PRIVATE',
FOREIGN KEY(creator_id) REFERENCES user(id) ON DELETE CASCADE
);
INSERT INTO
sqlite_sequence (name, seq)
VALUES
('memo', 1000);
CREATE TRIGGER IF NOT EXISTS `trigger_update_memo_modification_time`
AFTER
UPDATE
ON `memo` FOR EACH ROW BEGIN
UPDATE
`memo`
SET
updated_ts = (strftime('%s', 'now'))
WHERE
rowid = old.rowid;
END;
-- memo_organizer
CREATE TABLE memo_organizer (
id INTEGER PRIMARY KEY AUTOINCREMENT,
memo_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
pinned INTEGER NOT NULL CHECK (pinned IN (0, 1)) DEFAULT 0,
FOREIGN KEY(memo_id) REFERENCES memo(id) ON DELETE CASCADE,
FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE,
UNIQUE(memo_id, user_id)
);
INSERT INTO
sqlite_sequence (name, seq)
VALUES
('memo_organizer', 1000);
-- shortcut
CREATE TABLE shortcut (
id INTEGER PRIMARY KEY AUTOINCREMENT,
creator_id INTEGER NOT NULL,
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
row_status TEXT NOT NULL CHECK (row_status IN ('NORMAL', 'ARCHIVED')) DEFAULT 'NORMAL',
title TEXT NOT NULL DEFAULT '',
payload TEXT NOT NULL DEFAULT '{}',
FOREIGN KEY(creator_id) REFERENCES user(id) ON DELETE CASCADE
);
INSERT INTO
sqlite_sequence (name, seq)
VALUES
('shortcut', 10000);
CREATE TRIGGER IF NOT EXISTS `trigger_update_shortcut_modification_time`
AFTER
UPDATE
ON `shortcut` FOR EACH ROW BEGIN
UPDATE
`shortcut`
SET
updated_ts = (strftime('%s', 'now'))
WHERE
rowid = old.rowid;
END;
-- resource
CREATE TABLE resource (
id INTEGER PRIMARY KEY AUTOINCREMENT,
creator_id INTEGER NOT NULL,
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
filename TEXT NOT NULL DEFAULT '',
blob BLOB NOT NULL,
type TEXT NOT NULL DEFAULT '',
size INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(creator_id) REFERENCES user(id) ON DELETE CASCADE
);
INSERT INTO
sqlite_sequence (name, seq)
VALUES
('resource', 10000);
CREATE TRIGGER IF NOT EXISTS `trigger_update_resource_modification_time`
AFTER
UPDATE
ON `resource` FOR EACH ROW BEGIN
UPDATE
`resource`
SET
updated_ts = (strftime('%s', 'now'))
WHERE
rowid = old.rowid;
END;
-22
View File
@@ -37,25 +37,3 @@ VALUES
-- raw password: secret
'$2a$14$ajq8Q7fbtFRQvXpdCq7Jcuy.Rx1h/L4J60Otx.gyNLbAYctGMJ9tK'
);
INSERT INTO
user (
`id`,
`row_status`,
`email`,
`role`,
`name`,
`open_id`,
`password_hash`
)
VALUES
(
103,
'ARCHIVED',
'bob@usememos.com',
'USER',
'Bob',
'bob_open_id',
-- raw password: secret
'$2a$14$ajq8Q7fbtFRQvXpdCq7Jcuy.Rx1h/L4J60Otx.gyNLbAYctGMJ9tK'
);
+4 -8
View File
@@ -16,8 +16,7 @@ INSERT INTO
memo (
`id`,
`content`,
`creator_id`,
`visibility`
`creator_id`
)
VALUES
(
@@ -27,8 +26,7 @@ VALUES
- [x] Clean the room;
- [x] Read *📖 The Little Prince*;
(👆 click to toggle status)',
101,
'PROTECTED'
101
);
INSERT INTO
@@ -50,8 +48,7 @@ INSERT INTO
memo (
`id`,
`content`,
`creator_id`,
`visibility`
`creator_id`
)
VALUES
(
@@ -62,8 +59,7 @@ VALUES
- [ ] Watch *👦 The Boys*;
(👆 click to toggle status)
',
102,
'PROTECTED'
102
);
INSERT INTO
+3 -11
View File
@@ -222,13 +222,8 @@ func findMemoRawList(db *sql.DB, find *api.MemoFind) ([]*memoRaw, error) {
if v := find.ContentSearch; v != nil {
where, args = append(where, "content LIKE ?"), append(args, "%"+*v+"%")
}
if v := find.VisibilityList; len(v) != 0 {
list := []string{}
for _, visibility := range v {
list = append(list, fmt.Sprintf("$%d", len(args)+1))
args = append(args, visibility)
}
where = append(where, fmt.Sprintf("visibility in (%s)", strings.Join(list, ",")))
if v := find.Visibility; v != nil {
where, args = append(where, "visibility = ?"), append(args, *v)
}
pagination := ""
@@ -284,10 +279,7 @@ func findMemoRawList(db *sql.DB, find *api.MemoFind) ([]*memoRaw, error) {
}
func deleteMemo(db *sql.DB, delete *api.MemoDelete) error {
result, err := db.Exec(`
PRAGMA foreign_keys = ON;
DELETE FROM memo WHERE id = ?
`, delete.ID)
result, err := db.Exec(`DELETE FROM memo WHERE id = ?`, delete.ID)
if err != nil {
return FormatError(err)
}
+2 -8
View File
@@ -102,7 +102,7 @@ func createResource(db *sql.DB, create *api.ResourceCreate) (*resourceRaw, error
creator_id
)
VALUES (?, ?, ?, ?, ?)
RETURNING id, filename, blob, type, size, creator_id, created_ts, updated_ts
RETURNING id, filename, blob, type, size, created_ts, updated_ts
`,
create.Filename,
create.Blob,
@@ -123,7 +123,6 @@ func createResource(db *sql.DB, create *api.ResourceCreate) (*resourceRaw, error
&resourceRaw.Blob,
&resourceRaw.Type,
&resourceRaw.Size,
&resourceRaw.CreatorID,
&resourceRaw.CreatedTs,
&resourceRaw.UpdatedTs,
); err != nil {
@@ -153,7 +152,6 @@ func findResourceList(db *sql.DB, find *api.ResourceFind) ([]*resourceRaw, error
blob,
type,
size,
creator_id,
created_ts,
updated_ts
FROM resource
@@ -175,7 +173,6 @@ func findResourceList(db *sql.DB, find *api.ResourceFind) ([]*resourceRaw, error
&resourceRaw.Blob,
&resourceRaw.Type,
&resourceRaw.Size,
&resourceRaw.CreatorID,
&resourceRaw.CreatedTs,
&resourceRaw.UpdatedTs,
); err != nil {
@@ -193,10 +190,7 @@ func findResourceList(db *sql.DB, find *api.ResourceFind) ([]*resourceRaw, error
}
func deleteResource(db *sql.DB, delete *api.ResourceDelete) error {
result, err := db.Exec(`
PRAGMA foreign_keys = ON;
DELETE FROM resource WHERE id = ? AND creator_id = ?
`, delete.ID, delete.CreatorID)
result, err := db.Exec(`DELETE FROM resource WHERE id = ?`, delete.ID)
if err != nil {
return FormatError(err)
}
+1 -4
View File
@@ -238,10 +238,7 @@ func findShortcutList(db *sql.DB, find *api.ShortcutFind) ([]*shortcutRaw, error
}
func deleteShortcut(db *sql.DB, delete *api.ShortcutDelete) error {
result, err := db.Exec(`
PRAGMA foreign_keys = ON;
DELETE FROM shortcut WHERE id = ?
`, delete.ID)
result, err := db.Exec(`DELETE FROM shortcut WHERE id = ?`, delete.ID)
if err != nil {
return FormatError(err)
}
+2 -27
View File
@@ -96,15 +96,6 @@ func (s *Store) FindUser(find *api.UserFind) (*api.User, error) {
return user, nil
}
func (s *Store) DeleteUser(delete *api.UserDelete) error {
err := deleteUser(s.db, delete)
if err != nil {
return FormatError(err)
}
return nil
}
func createUser(db *sql.DB, create *api.UserCreate) (*userRaw, error) {
row, err := db.Query(`
INSERT INTO user (
@@ -233,7 +224,7 @@ func findUserList(db *sql.DB, find *api.UserFind) ([]*userRaw, error) {
row_status
FROM user
WHERE `+strings.Join(where, " AND ")+`
ORDER BY created_ts DESC, row_status DESC`,
ORDER BY created_ts DESC`,
args...,
)
if err != nil {
@@ -255,6 +246,7 @@ func findUserList(db *sql.DB, find *api.UserFind) ([]*userRaw, error) {
&userRaw.UpdatedTs,
&userRaw.RowStatus,
); err != nil {
fmt.Println(err)
return nil, FormatError(err)
}
@@ -267,20 +259,3 @@ func findUserList(db *sql.DB, find *api.UserFind) ([]*userRaw, error) {
return userRawList, nil
}
func deleteUser(db *sql.DB, delete *api.UserDelete) error {
result, err := db.Exec(`
PRAGMA foreign_keys = ON;
DELETE FROM user WHERE id = ?
`, delete.ID)
if err != nil {
return FormatError(err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return &common.Error{Code: common.NotFound, Err: fmt.Errorf("user ID not found: %d", delete.ID)}
}
return nil
}
+3 -2
View File
@@ -2,10 +2,11 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/logo.png" type="image/*" />
<link rel="icon" href="/favicon.svg" sizes="64x64" type="image/*" />
<meta name="theme-color" content="#f6f5f4" />
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no" />
<title>Memos</title>
<script src="https://kit.fontawesome.com/41e3aaa6af.js" crossorigin="anonymous"></script>
</head>
<body>
<div id="root"></div>
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "memos",
"version": "0.3.1",
"version": "0.2.2",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@@ -15,7 +15,6 @@
"qs": "^6.11.0",
"react": "^18.1.0",
"react-dom": "^18.1.0",
"react-feather": "^2.0.10",
"react-redux": "^8.0.1"
},
"devDependencies": {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

+1 -12
View File
@@ -1,21 +1,10 @@
import { useEffect, useState } from "react";
import { appRouterSwitch } from "./routers";
import { locationService } from "./services";
import { useAppSelector } from "./store";
function App() {
const pathname = useAppSelector((state) => state.location.pathname);
const [isLoading, setLoading] = useState(true);
useEffect(() => {
locationService.updateStateWithLocation();
window.onpopstate = () => {
locationService.updateStateWithLocation();
};
setLoading(false);
}, []);
return <>{isLoading ? null : appRouterSwitch(pathname)}</>;
return <>{appRouterSwitch(pathname)}</>;
}
export default App;
+3 -4
View File
@@ -1,8 +1,7 @@
import { useEffect, useState } from "react";
import * as api from "../helpers/api";
import Only from "./common/OnlyWhen";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import { showDialog } from "./Dialog";
import GitHubBadge from "./GitHubBadge";
import "../less/about-site-dialog.less";
@@ -38,7 +37,7 @@ const AboutSiteDialog: React.FC<Props> = ({ destroy }: Props) => {
<span className="icon-text">🤠</span>About <b>Memos</b>
</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<Icon.X />
<i className="fa-solid fa-xmark fa-lg"></i>
</button>
</div>
<div className="dialog-content-container">
@@ -64,7 +63,7 @@ const AboutSiteDialog: React.FC<Props> = ({ destroy }: Props) => {
};
export default function showAboutSiteDialog(): void {
generateDialog(
showDialog(
{
className: "about-site-dialog",
},
+1 -1
View File
@@ -2,10 +2,10 @@ import { IMAGE_URL_REG } from "../helpers/consts";
import * as utils from "../helpers/utils";
import useToggle from "../hooks/useToggle";
import { memoService } from "../services";
import { formatMemoContent } from "../helpers/marked";
import Only from "./common/OnlyWhen";
import Image from "./Image";
import toastHelper from "./Toast";
import { formatMemoContent } from "./Memo";
import "../less/memo.less";
interface Props {
+5 -6
View File
@@ -2,8 +2,7 @@ import { useEffect, useState } from "react";
import useLoading from "../hooks/useLoading";
import { memoService } from "../services";
import { useAppSelector } from "../store";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import { showDialog } from "./Dialog";
import toastHelper from "./Toast";
import ArchivedMemo from "./ArchivedMemo";
import "../less/archived-memo-dialog.less";
@@ -38,7 +37,7 @@ const ArchivedMemoDialog: React.FC<Props> = (props: Props) => {
Archived Memos
</p>
<button className="btn close-btn" onClick={destroy}>
<Icon.X className="icon-img" />
<i className="fa-solid fa-xmark fa-lg icon-img"></i>
</button>
</div>
<div className="dialog-content-container">
@@ -48,7 +47,7 @@ const ArchivedMemoDialog: React.FC<Props> = (props: Props) => {
</div>
) : archivedMemos.length === 0 ? (
<div className="tip-text-container">
<p className="tip-text">No archived memos.</p>
<p className="tip-text">Here is No Zettels.</p>
</div>
) : (
<div className="archived-memos-container">
@@ -62,8 +61,8 @@ const ArchivedMemoDialog: React.FC<Props> = (props: Props) => {
);
};
export default function showArchivedMemoDialog(): void {
generateDialog(
export default function showArchivedMemo(): void {
showDialog(
{
className: "archived-memo-dialog",
useAppContext: true,
+3 -6
View File
@@ -1,8 +1,7 @@
import { useEffect, useState } from "react";
import { validate, ValidatorConfig } from "../helpers/validator";
import { userService } from "../services";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import { showDialog } from "./Dialog";
import toastHelper from "./Toast";
import "../less/change-password-dialog.less";
@@ -56,9 +55,7 @@ const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => {
}
try {
const user = userService.getState().user as User;
await userService.patchUser({
id: user.id,
password: newPassword,
});
toastHelper.info("Password changed.");
@@ -73,7 +70,7 @@ const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => {
<div className="dialog-header-container">
<p className="title-text">Change Password</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<Icon.X />
<i className="fa-solid fa-xmark fa-lg"></i>
</button>
</div>
<div className="dialog-content-container">
@@ -97,7 +94,7 @@ const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => {
};
function showChangePasswordDialog() {
generateDialog(
showDialog(
{
className: "change-password-dialog",
},
@@ -0,0 +1,73 @@
import { useEffect } from "react";
import { userService } from "../services";
import useLoading from "../hooks/useLoading";
import { showDialog } from "./Dialog";
import toastHelper from "./Toast";
import "../less/confirm-reset-openid-dialog.less";
interface Props extends DialogProps {}
const ConfirmResetOpenIdDialog: React.FC<Props> = ({ destroy }: Props) => {
const resetBtnClickLoadingState = useLoading(false);
useEffect(() => {
// do nth
}, []);
const handleCloseBtnClick = () => {
destroy();
};
const handleConfirmBtnClick = async () => {
if (resetBtnClickLoadingState.isLoading) {
return;
}
resetBtnClickLoadingState.setLoading();
try {
await userService.patchUser({
resetOpenId: true,
});
} catch (error) {
toastHelper.error("Request reset open API failed.");
return;
}
toastHelper.success("Reset open API succeeded.");
handleCloseBtnClick();
};
return (
<>
<div className="dialog-header-container">
<p className="title-text">Reset Open API</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<i className="fa-solid fa-xmark fa-lg"></i>
</button>
</div>
<div className="dialog-content-container">
<p className="warn-text">
The existing API will be invalidated and a new one will be generated, are you sure you want to reset?
</p>
<div className="btns-container">
<span className="btn cancel-btn" onClick={handleCloseBtnClick}>
Cancel
</span>
<span className={`btn confirm-btn ${resetBtnClickLoadingState.isLoading ? "loading" : ""}`} onClick={handleConfirmBtnClick}>
Reset!
</span>
</div>
</div>
</>
);
};
function showConfirmResetOpenIdDialog() {
showDialog(
{
className: "confirm-reset-openid-dialog",
},
ConfirmResetOpenIdDialog
);
}
export default showConfirmResetOpenIdDialog;
+4 -5
View File
@@ -2,8 +2,7 @@ import { memo, useCallback, useEffect, useState } from "react";
import { memoService, shortcutService } from "../services";
import { checkShouldShowMemoWithFilters, filterConsts, getDefaultFilter, relationConsts } from "../helpers/filter";
import useLoading from "../hooks/useLoading";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import { showDialog } from "./Dialog";
import toastHelper from "./Toast";
import Selector from "./common/Selector";
import "../less/create-shortcut-dialog.less";
@@ -101,7 +100,7 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
{shortcutId ? "Edit Shortcut" : "Create Shortcut"}
</p>
<button className="btn close-btn" onClick={destroy}>
<Icon.X />
<i className="fa-solid fa-xmark fa-lg"></i>
</button>
</div>
<div className="dialog-content-container">
@@ -297,7 +296,7 @@ const FilterInputer: React.FC<MemoFilterInputerProps> = (props: MemoFilterInpute
/>
{inputElements}
<Icon.X className="remove-btn" onClick={handleRemoveBtnClick} />
<i className="fa-solid fa-xmark remove-btn" onClick={handleRemoveBtnClick}></i>
</div>
);
};
@@ -305,7 +304,7 @@ const FilterInputer: React.FC<MemoFilterInputerProps> = (props: MemoFilterInpute
const MemoFilterInputer: React.FC<MemoFilterInputerProps> = memo(FilterInputer);
export default function showCreateShortcutDialog(shortcutId?: ShortcutId): void {
generateDialog(
showDialog(
{
className: "create-shortcut-dialog",
},
+12 -9
View File
@@ -1,5 +1,7 @@
import { IMAGE_URL_REG } from "../helpers/consts";
import * as utils from "../helpers/utils";
import { formatMemoContent } from "../helpers/marked";
import Only from "./common/OnlyWhen";
import { formatMemoContent } from "./Memo";
import "../less/daily-memo.less";
interface DailyMemo extends Memo {
@@ -18,6 +20,7 @@ const DailyMemo: React.FC<Props> = (props: Props) => {
createdAtStr: utils.getDateTimeString(propsMemo.createdTs),
timeStr: utils.getTimeString(propsMemo.createdTs),
};
const imageUrls = Array.from(memo.content.match(IMAGE_URL_REG) ?? []).map((s) => s.replace(IMAGE_URL_REG, "$1"));
return (
<div className="daily-memo-wrapper">
@@ -25,14 +28,14 @@ const DailyMemo: React.FC<Props> = (props: Props) => {
<span className="normal-text">{memo.timeStr}</span>
</div>
<div className="memo-content-container">
<div
className="memo-content-text"
dangerouslySetInnerHTML={{
__html: formatMemoContent(memo.content, {
inlineImage: true,
}),
}}
></div>
<div className="memo-content-text" dangerouslySetInnerHTML={{ __html: formatMemoContent(memo.content) }}></div>
<Only when={imageUrls.length > 0}>
<div className="images-container">
{imageUrls.map((imgUrl, idx) => (
<img key={idx} src={imgUrl} decoding="async" />
))}
</div>
</Only>
</div>
<div className="split-line"></div>
</div>
+7 -9
View File
@@ -4,8 +4,7 @@ import toImage from "../labs/html2image";
import useToggle from "../hooks/useToggle";
import { DAILY_TIMESTAMP } from "../helpers/consts";
import * as utils from "../helpers/utils";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import { showDialog } from "./Dialog";
import DatePicker from "./common/DatePicker";
import showPreviewImageDialog from "./PreviewImageDialog";
import DailyMemo from "./DailyMemo";
@@ -65,17 +64,16 @@ const DailyReviewDialog: React.FC<Props> = (props: Props) => {
</p>
<div className="btns-container">
<button className="btn-text" onClick={() => setCurrentDateStamp(currentDateStamp - DAILY_TIMESTAMP)}>
<Icon.ChevronLeft className="icon-img" />
<i className="fa-solid fa-chevron-left icon-img"></i>
</button>
<button className="btn-text" onClick={() => setCurrentDateStamp(currentDateStamp + DAILY_TIMESTAMP)}>
<Icon.ChevronRight className="icon-img" />
<i className="fa-solid fa-chevron-right icon-img"></i>
</button>
<button className="btn-text share" onClick={handleShareBtnClick}>
<Icon.Share className="icon-img" />
<button className="btn-text" onClick={handleShareBtnClick}>
<i className="fa-solid fa-share-nodes icon-img"></i>
</button>
<span className="split-line">/</span>
<button className="btn-text" onClick={() => props.destroy()}>
<Icon.X className="icon-img" />
<i className="fa-solid fa-xmark fa-lg icon-img"></i>
</button>
</div>
<DatePicker
@@ -110,7 +108,7 @@ const DailyReviewDialog: React.FC<Props> = (props: Props) => {
};
export default function showDailyReviewDialog(datestamp: DateStamp = Date.now()): void {
generateDialog(
showDialog(
{
className: "daily-review-dialog",
useAppContext: true,
@@ -1,8 +1,8 @@
import { createRoot } from "react-dom/client";
import { ANIMATION_DURATION } from "../helpers/consts";
import { Provider } from "react-redux";
import { ANIMATION_DURATION } from "../../helpers/consts";
import store from "../../store";
import "../../less/base-dialog.less";
import store from "../store";
import "../less/dialog.less";
interface DialogConfig {
className: string;
@@ -32,7 +32,7 @@ const BaseDialog: React.FC<Props> = (props: Props) => {
);
};
export function generateDialog<T extends DialogProps>(
export function showDialog<T extends DialogProps>(
config: DialogConfig,
DialogComponent: React.FC<T>,
props?: Omit<T, "destroy">
@@ -1,85 +0,0 @@
import Icon from "../Icon";
import { generateDialog } from "./BaseDialog";
import "../../less/common-dialog.less";
type DialogStyle = "info" | "warning";
interface Props extends DialogProps {
title: string;
content: string;
style?: DialogStyle;
closeBtnText?: string;
confirmBtnText?: string;
onClose?: () => void;
onConfirm?: () => void;
}
const defaultProps = {
title: "",
content: "",
style: "info",
closeBtnText: "Close",
confirmBtnText: "Confirm",
onClose: () => null,
onConfirm: () => null,
};
const CommonDialog: React.FC<Props> = (props: Props) => {
const { title, content, destroy, closeBtnText, confirmBtnText, onClose, onConfirm, style } = {
...defaultProps,
...props,
};
const handleCloseBtnClick = () => {
onClose();
destroy();
};
const handleConfirmBtnClick = async () => {
onConfirm();
destroy();
};
return (
<>
<div className="dialog-header-container">
<p className="title-text">{title}</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<Icon.X />
</button>
</div>
<div className="dialog-content-container">
<p className="content-text">{content}</p>
<div className="btns-container">
<span className="btn cancel-btn" onClick={handleCloseBtnClick}>
{closeBtnText}
</span>
<span className={`btn confirm-btn ${style}`} onClick={handleConfirmBtnClick}>
{confirmBtnText}
</span>
</div>
</div>
</>
);
};
interface CommonDialogProps {
title: string;
content: string;
className?: string;
style?: DialogStyle;
closeBtnText?: string;
confirmBtnText?: string;
onClose?: () => void;
onConfirm?: () => void;
}
export const showCommonDialog = (props: CommonDialogProps) => {
generateDialog(
{
className: `common-dialog ${props?.className ?? ""}`,
},
CommonDialog,
props
);
};
-1
View File
@@ -1 +0,0 @@
export { generateDialog } from "./BaseDialog";
+1 -2
View File
@@ -1,6 +1,5 @@
import { useEffect, useState } from "react";
import * as api from "../helpers/api";
import Icon from "./Icon";
import "../less/github-badge.less";
interface Props {}
@@ -17,7 +16,7 @@ const GitHubBadge: React.FC<Props> = () => {
return (
<a className="github-badge-container" href="https://github.com/usememos/memos">
<div className="github-icon">
<Icon.GitHub className="icon-img" />
<i className="fa-brands fa-github fa-lg icon-img"></i>
Star
</div>
<div className="count-text">
-3
View File
@@ -1,3 +0,0 @@
import * as Icon from "react-feather";
export default Icon;
+24 -11
View File
@@ -1,11 +1,10 @@
import { memo, useEffect, useRef, useState } from "react";
import { indexOf } from "lodash-es";
import { escape, indexOf } from "lodash-es";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { IMAGE_URL_REG, UNKNOWN_ID } from "../helpers/consts";
import { DONE_BLOCK_REG, formatMemoContent, TODO_BLOCK_REG } from "../helpers/marked";
import { IMAGE_URL_REG, LINK_URL_REG, MEMO_LINK_REG, TAG_REG, UNKNOWN_ID } from "../helpers/consts";
import { DONE_BLOCK_REG, parseMarkedToHtml, TODO_BLOCK_REG } from "../helpers/marked";
import { editorStateService, locationService, memoService, userService } from "../services";
import Icon from "./Icon";
import Only from "./common/OnlyWhen";
import toastHelper from "./Toast";
import Image from "./Image";
@@ -173,27 +172,30 @@ const Memo: React.FC<Props> = (props: Props) => {
<div className="memo-top-wrapper">
<div className="status-text-container" onClick={handleShowMemoStoryDialog}>
<span className="time-text">{createdAtStr}</span>
<Only when={memo.visibility !== "PRIVATE" && !isVisitorMode}>
<span className={`status-text ${memo.visibility.toLocaleLowerCase()}`}>{memo.visibility}</span>
<Only when={memo.pinned}>
<span className="status-text">PINNED</span>
</Only>
<Only when={memo.visibility === "PUBLIC" && !isVisitorMode}>
<span className="status-text">PUBLIC</span>
</Only>
</div>
<div className={`btns-container ${userService.isVisitorMode() ? "!hidden" : ""}`}>
<span className="btn more-action-btn">
<Icon.MoreHorizontal className="icon-img" />
<i className="fa-solid fa-ellipsis icon-img"></i>
</span>
<div className="more-action-btns-wrapper">
<div className="more-action-btns-container">
<div className="btns-container">
<div className="btn" onClick={handleTogglePinMemoBtnClick}>
<Icon.MapPin className={`icon-img ${memo.pinned ? "" : "opacity-20"}`} />
<i className={`fa-solid fa-thumbtack icon-img ${memo.pinned ? "" : "opacity-20"}`}></i>
<span className="tip-text">{memo.pinned ? "Unpin" : "Pin"}</span>
</div>
<div className="btn" onClick={handleEditMemoClick}>
<Icon.Edit3 className="icon-img" />
<i className="fa-solid fa-pen-to-square icon-img"></i>
<span className="tip-text">Edit</span>
</div>
<div className="btn" onClick={handleGenMemoImageBtnClick}>
<Icon.Share className="icon-img" />
<i className="fa-solid fa-share-nodes icon-img"></i>
<span className="tip-text">Share</span>
</div>
</div>
@@ -220,7 +222,7 @@ const Memo: React.FC<Props> = (props: Props) => {
<div className="expand-btn-container">
<span className={`btn ${state.expandButtonStatus === 0 ? "expand-btn" : "fold-btn"}`} onClick={handleExpandBtnClick}>
{state.expandButtonStatus === 0 ? "Expand" : "Fold"}
<Icon.ChevronRight className="icon-img" />
<i className="fa-solid fa-chevron-right icon-img"></i>
</span>
</div>
)}
@@ -235,4 +237,15 @@ const Memo: React.FC<Props> = (props: Props) => {
);
};
export function formatMemoContent(content: string) {
const tempElement = document.createElement("div");
tempElement.innerHTML = parseMarkedToHtml(escape(content));
return tempElement.innerHTML
.replace(IMAGE_URL_REG, "")
.replace(MEMO_LINK_REG, "<span class='memo-link-text' data-value='$2'>$1</span>")
.replace(LINK_URL_REG, "<a class='link' target='_blank' rel='noreferrer' href='$2'>$1</a>")
.replace(TAG_REG, "<span class='tag-span'>#$1</span> ");
}
export default memo(Memo);
+28 -33
View File
@@ -2,14 +2,13 @@ import { useState, useEffect, useCallback } from "react";
import { editorStateService, memoService, userService } from "../services";
import { IMAGE_URL_REG, MEMO_LINK_REG, UNKNOWN_ID } from "../helpers/consts";
import * as utils from "../helpers/utils";
import { formatMemoContent, parseHtmlToRawText } from "../helpers/marked";
import { parseHtmlToRawText } from "../helpers/marked";
import Only from "./common/OnlyWhen";
import toastHelper from "./Toast";
import { generateDialog } from "./Dialog";
import { showDialog } from "./Dialog";
import Image from "./Image";
import { formatMemoContent } from "./Memo";
import "../less/memo-card-dialog.less";
import Selector from "./common/Selector";
import Icon from "./Icon";
interface LinkedMemo extends Memo {
createdAtStr: string;
@@ -27,11 +26,6 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
const [linkMemos, setLinkMemos] = useState<LinkedMemo[]>([]);
const [linkedMemos, setLinkedMemos] = useState<LinkedMemo[]>([]);
const imageUrls = Array.from(memo.content.match(IMAGE_URL_REG) ?? []).map((s) => s.replace(IMAGE_URL_REG, "$1"));
const visibilityList = [
{ text: "PUBLIC", value: "PUBLIC" },
{ text: "PROTECTED", value: "PROTECTED" },
{ text: "PRIVATE", value: "PRIVATE" },
];
useEffect(() => {
const fetchLinkedMemos = async () => {
@@ -104,16 +98,25 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
setMemo(memo);
}, []);
const handleEditMemoBtnClick = () => {
const handleEditMemoBtnClick = useCallback(() => {
props.destroy();
editorStateService.setEditMemoWithId(memo.id);
}, [memo.id]);
const handlePinClick = async () => {
if (memo.pinned) {
await memoService.unpinMemo(memo.id);
} else {
await memoService.pinMemo(memo.id);
}
setMemo({
...memo,
pinned: !memo.pinned,
});
};
const handleVisibilitySelectorChange = async (visibility: Visibility) => {
if (memo.visibility === visibility) {
return;
}
const handleVisibilityClick = async () => {
const visibility = memo.visibility === "PRIVATE" ? "PUBLIC" : "PRIVATE";
await memoService.patchMemo({
id: memo.id,
visibility: visibility,
@@ -126,33 +129,25 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
return (
<>
<Only when={!userService.isVisitorMode()}>
<div className="card-header-container">
<div className="visibility-selector-container">
<Icon.Eye className="icon-img" />
<Selector
className="visibility-selector"
dataSource={visibilityList}
value={memo.visibility}
handleValueChanged={(value) => handleVisibilitySelectorChange(value as Visibility)}
/>
</div>
</div>
</Only>
<div className="memo-card-container">
<div className="header-container">
<p className="time-text">{utils.getDateTimeString(memo.createdTs)}</p>
<div className="btns-container">
<Only when={!userService.isVisitorMode()}>
<>
<button className="btn edit-btn" onClick={handleEditMemoBtnClick}>
<Icon.Edit3 className="icon-img" />
<button className="btn" onClick={handlePinClick}>
<i className={`fa-solid fa-thumbtack icon-img ${memo.pinned ? "" : "opacity-20"}`}></i>
</button>
<button className="btn" onClick={handleVisibilityClick}>
<i className={`fa-solid fa-eye icon-img ${memo.visibility === "PUBLIC" ? "" : "opacity-20"}`}></i>
</button>
<button className="btn edit-btn" onClick={handleEditMemoBtnClick}>
<i className="fa-solid fa-pen-to-square icon-img"></i>
</button>
<span className="split-line">/</span>
</>
</Only>
<button className="btn close-btn" onClick={props.destroy}>
<Icon.X className="icon-img" />
<i className="fa-solid fa-xmark fa-lg icon-img"></i>
</button>
</div>
</div>
@@ -223,7 +218,7 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
};
export default function showMemoCardDialog(memo: Memo): void {
generateDialog(
showDialog(
{
className: "memo-card-dialog",
},
+5 -6
View File
@@ -3,7 +3,6 @@ import { UNKNOWN_ID } from "../helpers/consts";
import { editorStateService, locationService, memoService, resourceService } from "../services";
import { useAppSelector } from "../store";
import * as storage from "../helpers/storage";
import Icon from "./Icon";
import toastHelper from "./Toast";
import Editor, { EditorRefActions } from "./Editor/Editor";
import "../less/memo-editor.less";
@@ -108,16 +107,16 @@ const MemoEditor: React.FC<Props> = () => {
const { type } = file;
if (!type.startsWith("image")) {
toastHelper.error("Only image file supported.");
return;
}
try {
const image = await resourceService.upload(file);
const url = `/h/r/${image.id}/${image.filename}`;
return url;
} catch (error: any) {
toastHelper.error("Failed to upload image\n" + JSON.stringify(error, null, 4));
toastHelper.error(error);
} finally {
setState({
...state,
@@ -232,7 +231,7 @@ const MemoEditor: React.FC<Props> = () => {
tools={
<>
<div className="action-btn tag-action">
<Icon.Hash className="icon-img" />
<i className="fa-solid fa-hashtag icon-img"></i>
<div ref={tagSeletorRef} className="tag-list" onClick={handleTagSeletorClick}>
{tags.map((t) => {
return <span key={t}>{t}</span>;
@@ -240,11 +239,11 @@ const MemoEditor: React.FC<Props> = () => {
</div>
</div>
<button className="action-btn">
<Icon.Image className="icon-img" onClick={handleUploadFileBtnClick} />
<i className="fa-solid fa-image icon-img" onClick={handleUploadFileBtnClick}></i>
<span className={`tip-text ${state.isUploadingResource ? "!block" : ""}`}>Uploading</span>
</button>
<button className="action-btn" onClick={handleFullscreenBtnClick}>
{state.fullscreen ? <Icon.Minimize className="icon-img" /> : <Icon.Maximize className="icon-img" />}
<i className={`fa-solid fa-${state.fullscreen ? "compress" : "expand"} icon-img`}></i>
</button>
</>
}
+1
View File
@@ -83,6 +83,7 @@ const MemoList: React.FC<Props> = () => {
.fetchAllMemos()
.then(() => {
setFetchStatus(false);
memoService.updateTagsState();
})
.catch(() => {
toastHelper.error("😭 Fetching failed, please try again later.");
+1 -2
View File
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useState } from "react";
import { memoService, shortcutService } from "../services";
import { useAppSelector } from "../store";
import Icon from "./Icon";
import SearchBar from "./SearchBar";
import { toggleSiderbar } from "./Sidebar";
import "../less/memos-header.less";
@@ -41,7 +40,7 @@ const MemosHeader: React.FC<Props> = () => {
<div className="section-header-container memos-header-container">
<div className="title-container">
<div className="action-btn" onClick={toggleSiderbar}>
<Icon.Menu className="icon-img" />
<i className="fa-solid fa-bars icon-img"></i>
</div>
<span className="title-text" onClick={handleTitleTextClick}>
{titleText}
+4 -5
View File
@@ -1,6 +1,5 @@
import { showDialog } from "./Dialog";
import * as utils from "../helpers/utils";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import "../less/preview-image-dialog.less";
interface Props extends DialogProps {
@@ -23,10 +22,10 @@ const PreviewImageDialog: React.FC<Props> = ({ destroy, imgUrl }: Props) => {
<>
<div className="btns-container">
<button className="btn" onClick={handleCloseBtnClick}>
<Icon.X className="icon-img" />
<i className="fa-solid fa-xmark fa-lg icon-img"></i>
</button>
<button className="btn" onClick={handleDownloadBtnClick}>
<Icon.Download className="icon-img" />
<i className="fa-solid fa-download icon-img"></i>
</button>
</div>
<div className="img-container">
@@ -37,7 +36,7 @@ const PreviewImageDialog: React.FC<Props> = ({ destroy, imgUrl }: Props) => {
};
export default function showPreviewImageDialog(imgUrl: string): void {
generateDialog(
showDialog(
{
className: "preview-image-dialog",
},
-163
View File
@@ -1,163 +0,0 @@
import { useEffect, useState } from "react";
import * as utils from "../helpers/utils";
import useLoading from "../hooks/useLoading";
import { resourceService } from "../services";
import Dropdown from "./common/Dropdown";
import { generateDialog } from "./Dialog";
import { showCommonDialog } from "./Dialog/CommonDialog";
import toastHelper from "./Toast";
import Icon from "./Icon";
import "../less/resources-dialog.less";
interface Props extends DialogProps {}
interface State {
resources: Resource[];
isUploadingResource: boolean;
}
const ResourcesDialog: React.FC<Props> = (props: Props) => {
const { destroy } = props;
const loadingState = useLoading();
const [state, setState] = useState<State>({
resources: [],
isUploadingResource: false,
});
useEffect(() => {
fetchResources()
.catch((error) => {
toastHelper.error("Failed to fetch archived memos: ", error);
})
.finally(() => {
loadingState.setFinish();
});
}, []);
const fetchResources = async () => {
const data = await resourceService.getResourceList();
setState({
...state,
resources: data,
});
};
const handleUploadFileBtnClick = async () => {
if (state.isUploadingResource) {
return;
}
const inputEl = document.createElement("input");
inputEl.type = "file";
inputEl.multiple = false;
inputEl.accept = "image/png, image/gif, image/jpeg";
inputEl.onchange = async () => {
if (!inputEl.files || inputEl.files.length === 0) {
return;
}
setState({
...state,
isUploadingResource: true,
});
const file = inputEl.files[0];
try {
await resourceService.upload(file);
} catch (error: any) {
toastHelper.error("Failed to upload resource\n" + JSON.stringify(error, null, 4));
} finally {
setState({
...state,
isUploadingResource: false,
});
await fetchResources();
}
};
inputEl.click();
};
const handleCopyResourceLinkBtnClick = (resource: Resource) => {
utils.copyTextToClipboard(`${window.location.origin}/h/r/${resource.id}/${resource.filename}`);
toastHelper.success("Succeed to copy resource link to clipboard");
};
const handleDeleteResourceBtnClick = (resource: Resource) => {
showCommonDialog({
title: `Delete Resource`,
content: `Are you sure to delete this resource? THIS ACTION IS IRREVERSIABLE.❗️`,
style: "warning",
onConfirm: async () => {
await resourceService.deleteResourceById(resource.id);
await fetchResources();
},
});
};
return (
<>
<div className="dialog-header-container">
<p className="title-text">
<span className="icon-text">🌄</span>
Resources
</p>
<button className="btn close-btn" onClick={destroy}>
<Icon.X className="icon-img" />
</button>
</div>
<div className="dialog-content-container">
<div className="tip-text-container">(👨💻WIP) View your static resources in memos. e.g. images</div>
<div className="upload-resource-container" onClick={() => handleUploadFileBtnClick()}>
<div className="upload-resource-btn">
<Icon.File className="icon-img" />
<span>Upload</span>
</div>
</div>
{loadingState.isLoading ? (
<div className="loading-text-container">
<p className="tip-text">fetching data...</p>
</div>
) : (
<div className="resource-table-container">
<div className="fields-container">
<span className="field-text">ID</span>
<span className="field-text name-text">NAME</span>
<span className="field-text">TYPE</span>
<span></span>
</div>
{state.resources.length === 0 ? (
<p className="tip-text">No resource.</p>
) : (
state.resources.map((resource) => (
<div key={resource.id} className="resource-container">
<span className="field-text">{resource.id}</span>
<span className="field-text name-text">{resource.filename}</span>
<span className="field-text">{resource.type}</span>
<div className="buttons-container">
<Dropdown className="actions-dropdown">
<button onClick={() => handleCopyResourceLinkBtnClick(resource)}>Copy Link</button>
<button className="delete-btn" onClick={() => handleDeleteResourceBtnClick(resource)}>
Delete
</button>
</Dropdown>
</div>
</div>
))
)}
</div>
)}
</div>
</>
);
};
export default function showResourcesDialog() {
generateDialog(
{
className: "resources-dialog",
useAppContext: true,
},
ResourcesDialog,
{}
);
}
+1 -2
View File
@@ -2,7 +2,6 @@ import { locationService } from "../services";
import { useAppSelector } from "../store";
import { memoSpecialTypes } from "../helpers/filter";
import "../less/search-bar.less";
import Icon from "./Icon";
interface Props {}
@@ -25,7 +24,7 @@ const SearchBar: React.FC<Props> = () => {
return (
<div className="search-bar-container">
<div className="search-bar-inputer">
<Icon.Search className="icon-img" />
<i className="fa-solid fa-magnifying-glass fa-sm icon-img"></i>
<input className="text-input" type="text" placeholder="" onChange={handleTextQueryInput} />
</div>
<div className="quickly-action-wrapper">
+3 -4
View File
@@ -1,11 +1,10 @@
import { useState } from "react";
import { useAppSelector } from "../store";
import { generateDialog } from "./Dialog";
import { showDialog } from "./Dialog";
import MyAccountSection from "./Settings/MyAccountSection";
import PreferencesSection from "./Settings/PreferencesSection";
import MemberSection from "./Settings/MemberSection";
import "../less/setting-dialog.less";
import Icon from "./Icon";
interface Props extends DialogProps {}
@@ -31,7 +30,7 @@ const SettingDialog: React.FC<Props> = (props: Props) => {
return (
<div className="dialog-content-container">
<button className="btn close-btn" onClick={destroy}>
<Icon.X className="icon-img" />
<i className="fa-solid fa-xmark fa-lg icon-img"></i>
</button>
<div className="section-selector-container">
<span className="section-title">Basic</span>
@@ -77,7 +76,7 @@ const SettingDialog: React.FC<Props> = (props: Props) => {
};
export default function showSettingDialog(): void {
generateDialog(
showDialog(
{
className: "setting-dialog",
useAppContext: true,
+5 -62
View File
@@ -1,11 +1,7 @@
import React, { useEffect, useState } from "react";
import { isEmpty } from "lodash-es";
import { userService } from "../../services";
import { useAppSelector } from "../../store";
import * as api from "../../helpers/api";
import toastHelper from "../Toast";
import Dropdown from "../common/Dropdown";
import { showCommonDialog } from "../Dialog/CommonDialog";
import "../../less/settings/member-section.less";
interface Props {}
@@ -16,7 +12,6 @@ interface State {
}
const PreferencesSection: React.FC<Props> = () => {
const currentUser = useAppSelector((state) => state.user.user);
const [state, setState] = useState<State>({
createUserEmail: "",
createUserPassword: "",
@@ -71,43 +66,6 @@ const PreferencesSection: React.FC<Props> = () => {
});
};
const handleArchiveUserClick = (user: User) => {
showCommonDialog({
title: `Archive Member`,
content: `❗️Are you sure to archive ${user.name}?`,
style: "warning",
onConfirm: async () => {
await userService.patchUser({
id: user.id,
rowStatus: "ARCHIVED",
});
fetchUserList();
},
});
};
const handleRestoreUserClick = async (user: User) => {
await userService.patchUser({
id: user.id,
rowStatus: "NORMAL",
});
fetchUserList();
};
const handleDeleteUserClick = (user: User) => {
showCommonDialog({
title: `Delete Member`,
content: `Are you sure to delete ${user.name}? THIS ACTION IS IRREVERSIABLE.❗️`,
style: "warning",
onConfirm: async () => {
await userService.deleteUser({
id: user.id,
});
fetchUserList();
},
});
};
return (
<div className="section-container member-section-container">
<p className="title-text">Create a member</p>
@@ -128,30 +86,15 @@ const PreferencesSection: React.FC<Props> = () => {
<div className="member-container field-container">
<span className="field-text">ID</span>
<span className="field-text">EMAIL</span>
<span></span>
</div>
{userList.map((user) => (
<div key={user.id} className={`member-container ${user.rowStatus === "ARCHIVED" ? "archived" : ""}`}>
<div key={user.id} className="member-container">
<span className="field-text id-text">{user.id}</span>
<span className="field-text email-text">{user.email}</span>
<div className="buttons-container">
{currentUser?.id === user.id ? (
<span className="tip-text">Yourself</span>
) : (
<Dropdown className="actions-dropdown">
{user.rowStatus === "NORMAL" ? (
<button onClick={() => handleArchiveUserClick(user)}>Archive</button>
) : (
<>
<button onClick={() => handleRestoreUserClick(user)}>Restore</button>
<button className="delete" onClick={() => handleDeleteUserClick(user)}>
Delete
</button>
</>
)}
</Dropdown>
)}
</div>
{/* TODO */}
{/* <div className="buttons-container">
<span>delete</span>
</div> */}
</div>
))}
</div>
@@ -3,8 +3,8 @@ import { useAppSelector } from "../../store";
import { userService } from "../../services";
import { validate, ValidatorConfig } from "../../helpers/validator";
import toastHelper from "../Toast";
import { showCommonDialog } from "../Dialog/CommonDialog";
import showChangePasswordDialog from "../ChangePasswordDialog";
import showConfirmResetOpenIdDialog from "../ConfirmResetOpenIdDialog";
import "../../less/settings/my-account-section.less";
const validateConfig: ValidatorConfig = {
@@ -39,7 +39,6 @@ const MyAccountSection: React.FC<Props> = () => {
try {
await userService.patchUser({
id: user.id,
name: username,
});
toastHelper.info("Username changed");
@@ -53,11 +52,7 @@ const MyAccountSection: React.FC<Props> = () => {
};
const handleResetOpenIdBtnClick = async () => {
showCommonDialog({
title: "Reset Open API",
content: "❗️The existing API will be invalidated and a new one will be generated, are you sure you want to reset?",
style: "warning",
});
showConfirmResetOpenIdDialog();
};
const handlePreventDefault = (e: React.MouseEvent) => {
+4 -5
View File
@@ -3,11 +3,10 @@ import { userService } from "../services";
import toImage from "../labs/html2image";
import { ANIMATION_DURATION, IMAGE_URL_REG } from "../helpers/consts";
import * as utils from "../helpers/utils";
import { formatMemoContent } from "../helpers/marked";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import { showDialog } from "./Dialog";
import Only from "./common/OnlyWhen";
import toastHelper from "./Toast";
import { formatMemoContent } from "./Memo";
import "../less/share-memo-image-dialog.less";
interface Props extends DialogProps {
@@ -76,7 +75,7 @@ const ShareMemoImageDialog: React.FC<Props> = (props: Props) => {
<span className="icon-text">🌄</span>Share Memo
</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<Icon.X className="icon-img" />
<i className="fa-solid fa-xmark fa-lg icon-img"></i>
</button>
</div>
<div className="dialog-content-container">
@@ -108,7 +107,7 @@ const ShareMemoImageDialog: React.FC<Props> = (props: Props) => {
};
export default function showShareMemoImageDialog(memo: Memo): void {
generateDialog(
showDialog(
{
className: "share-memo-image-dialog",
},
+2 -3
View File
@@ -4,7 +4,6 @@ import { useAppSelector } from "../store";
import * as utils from "../helpers/utils";
import useToggle from "../hooks/useToggle";
import useLoading from "../hooks/useLoading";
import Icon from "./Icon";
import toastHelper from "./Toast";
import showCreateShortcutDialog from "./CreateShortcutDialog";
import "../less/shortcut-list.less";
@@ -40,7 +39,7 @@ const ShortcutList: React.FC<Props> = () => {
<p className="title-text">
<span className="normal-text">Shortcuts</span>
<button className="btn" onClick={() => showCreateShortcutDialog()}>
<Icon.Plus className="icon-img" />
<i className="fa-solid fa-plus icon-img fa-xs"></i>
</button>
</p>
<div className="shortcuts-container">
@@ -114,7 +113,7 @@ const ShortcutContainer: React.FC<ShortcutContainerProps> = (props: ShortcutCont
</div>
<div className="btns-container">
<span className="action-btn toggle-btn">
<Icon.MoreHorizontal className="icon-img" />
<i className="fa-solid fa-ellipsis fa-sm icon-img"></i>
</span>
<div className="action-btns-wrapper">
<div className="action-btns-container">
+15 -10
View File
@@ -1,10 +1,9 @@
import { userService } from "../services";
import Icon from "./Icon";
import { useAppSelector } from "../store";
import Only from "./common/OnlyWhen";
import showDailyReviewDialog from "./DailyReviewDialog";
import showSettingDialog from "./SettingDialog";
import showArchivedMemoDialog from "./ArchivedMemoDialog";
import showResourcesDialog from "./ResourcesDialog";
import UserBanner from "./UserBanner";
import UsageHeatMap from "./UsageHeatMap";
import ShortcutList from "./ShortcutList";
@@ -14,14 +13,12 @@ import "../less/siderbar.less";
interface Props {}
const Sidebar: React.FC<Props> = () => {
const user = useAppSelector((state) => state.user.user);
const handleMyAccountBtnClick = () => {
showSettingDialog();
};
const handleResourcesBtnClick = () => {
showResourcesDialog();
};
const handleArchivedBtnClick = () => {
showArchivedMemoDialog();
};
@@ -30,7 +27,7 @@ const Sidebar: React.FC<Props> = () => {
<aside className="sidebar-wrapper">
<div className="close-container">
<span className="action-btn" onClick={toggleSiderbar}>
<Icon.X className="icon-img" />
<i className="fa-solid fa-xmark fa-lg icon-img"></i>
</span>
</div>
<UserBanner />
@@ -40,9 +37,6 @@ const Sidebar: React.FC<Props> = () => {
<span className="icon">📅</span> Daily Review
</button>
<Only when={!userService.isVisitorMode()}>
<button className="btn action-btn" onClick={handleResourcesBtnClick}>
<span className="icon">🌄</span> Resources
</button>
<button className="btn action-btn" onClick={handleMyAccountBtnClick}>
<span className="icon"></span> Setting
</button>
@@ -50,6 +44,17 @@ const Sidebar: React.FC<Props> = () => {
<button className="btn action-btn" onClick={handleArchivedBtnClick}>
<span className="icon">🗂</span> Archived
</button>
<Only when={userService.isVisitorMode()}>
{user ? (
<button className="btn action-btn" onClick={() => (window.location.href = "/")}>
<span className="icon">🏠</span> Back to Home
</button>
) : (
<button className="btn action-btn" onClick={() => (window.location.href = "/signin")}>
<span className="icon">👉</span> Sign in
</button>
)}
</Only>
</div>
<Only when={!userService.isVisitorMode()}>
<ShortcutList />
+2 -5
View File
@@ -3,7 +3,6 @@ import * as utils from "../helpers/utils";
import { useAppSelector } from "../store";
import { locationService, memoService, userService } from "../services";
import useToggle from "../hooks/useToggle";
import Icon from "./Icon";
import Only from "./common/OnlyWhen";
import "../less/tag-list.less";
@@ -21,9 +20,7 @@ const TagList: React.FC<Props> = () => {
const [tags, setTags] = useState<Tag[]>([]);
useEffect(() => {
if (memos.length > 0) {
memoService.updateTagsState();
}
memoService.updateTagsState();
}, [memos]);
useEffect(() => {
@@ -119,7 +116,7 @@ const TagItemContainer: React.FC<TagItemContainerProps> = (props: TagItemContain
<div className="btns-container">
{hasSubTags ? (
<span className={`action-btn toggle-btn ${showSubTags ? "shown" : ""}`} onClick={handleToggleBtnClick}>
<Icon.ChevronRight className="icon-img" />
<i className="fa-solid fa-chevron-right icon-img"></i>
</span>
) : null}
</div>
+1 -2
View File
@@ -3,7 +3,6 @@ import * as utils from "../helpers/utils";
import userService from "../services/userService";
import { locationService } from "../services";
import { useAppSelector } from "../store";
import Icon from "./Icon";
import MenuBtnsPopup from "./MenuBtnsPopup";
import "../less/user-banner.less";
@@ -46,7 +45,7 @@ const UserBanner: React.FC<Props> = () => {
{!isVisitorMode && user?.role === "HOST" ? <span className="tag">MOD</span> : null}
</div>
<button className="action-btn menu-popup-btn" onClick={handlePopupBtnClick}>
<Icon.MoreHorizontal className="icon-img" />
<i className="fa-solid fa-ellipsis icon-img"></i>
</button>
<MenuBtnsPopup shownStatus={shouldShowPopupBtns} setShownStatus={setShouldShowPopupBtns} />
</div>
+2 -3
View File
@@ -1,7 +1,6 @@
import { useEffect, useState } from "react";
import { DAILY_TIMESTAMP } from "../../helpers/consts";
import "../../less/common/date-picker.less";
import Icon from "../Icon";
interface DatePickerProps {
className?: string;
@@ -56,13 +55,13 @@ const DatePicker: React.FC<DatePickerProps> = (props: DatePickerProps) => {
<div className={`date-picker-wrapper ${className}`}>
<div className="date-picker-header">
<span className="btn-text" onClick={() => handleChangeMonthBtnClick(-1)}>
<Icon.ChevronLeft className="icon-img" />
<i className="fa-solid fa-chevron-left icon-img"></i>
</span>
<span className="normal-text">
{firstDate.getFullYear()}/{firstDate.getMonth() + 1}
</span>
<span className="btn-text" onClick={() => handleChangeMonthBtnClick(1)}>
<Icon.ChevronRight className="icon-img" />
<i className="fa-solid fa-chevron-right icon-img"></i>
</span>
</div>
<div className="date-picker-day-container">
-40
View File
@@ -1,40 +0,0 @@
import { ReactNode, useEffect, useRef } from "react";
import useToggle from "../../hooks/useToggle";
import Icon from "../Icon";
import "../../less/common/dropdown.less";
interface DropdownProps {
children?: ReactNode;
className?: string;
}
const Dropdown: React.FC<DropdownProps> = (props: DropdownProps) => {
const { children, className } = props;
const [dropdownStatus, toggleDropdownStatus] = useToggle(false);
const dropdownWrapperRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (dropdownStatus) {
const handleClickOutside = (event: MouseEvent) => {
if (!dropdownWrapperRef.current?.contains(event.target as Node)) {
toggleDropdownStatus(false);
}
};
window.addEventListener("click", handleClickOutside, {
capture: true,
once: true,
});
}
}, [dropdownStatus]);
return (
<div ref={dropdownWrapperRef} className={`dropdown-wrapper ${className ?? ""}`} onClick={() => toggleDropdownStatus()}>
<span className="trigger-button">
<Icon.MoreHorizontal className="icon-img" />
</span>
<div className={`action-buttons-container ${dropdownStatus ? "" : "!hidden"}`}>{children}</div>
</div>
);
};
export default Dropdown;
+1 -2
View File
@@ -1,6 +1,5 @@
import { memo, useEffect, useRef } from "react";
import useToggle from "../../hooks/useToggle";
import Icon from "../Icon";
import "../../less/common/selector.less";
interface TVObject {
@@ -65,7 +64,7 @@ const Selector: React.FC<Props> = (props: Props) => {
<div className={`current-value-container ${showSelector ? "active" : ""}`} onClick={handleCurrentValueClick}>
<span className="value-text">{currentItem.text}</span>
<span className="arrow-text">
<Icon.ChevronDown className="icon-img" />
<i className="fa-solid fa-chevron-down fa-sm icon-img"></i>
</span>
</div>
+2 -14
View File
@@ -34,7 +34,7 @@ export function createUser(userCreate: UserCreate) {
return axios.post<ResponseObject<User>>("/api/user", userCreate);
}
export function getMyselfUser() {
export function getUser() {
return axios.get<ResponseObject<User>>("/api/user/me");
}
@@ -47,11 +47,7 @@ export function getUserById(id: number) {
}
export function patchUser(userPatch: UserPatch) {
return axios.patch<ResponseObject<User>>(`/api/user/${userPatch.id}`, userPatch);
}
export function deleteUser(userDelete: UserDelete) {
return axios.delete(`/api/user/${userDelete.id}`);
return axios.patch<ResponseObject<User>>("/api/user/me", userPatch);
}
export function getMemoList(memoFind?: MemoFind) {
@@ -109,18 +105,10 @@ export function deleteShortcutById(shortcutId: ShortcutId) {
return axios.delete(`/api/shortcut/${shortcutId}`);
}
export function getResourceList() {
return axios.get<ResponseObject<Resource[]>>("/api/resource");
}
export function uploadFile(formData: FormData) {
return axios.post<ResponseObject<Resource>>("/api/resource", formData);
}
export function deleteResourceById(id: ResourceId) {
return axios.delete(`/api/resource/${id}`);
}
export function getTagList(tagFind?: TagFind) {
const queryList = [];
if (tagFind?.creatorId) {
+1 -32
View File
@@ -1,6 +1,3 @@
import { escape } from "lodash-es";
import { IMAGE_URL_REG, LINK_URL_REG, MEMO_LINK_REG, TAG_REG } from "./consts";
const CODE_BLOCK_REG = /```([\s\S]*?)```/g;
const BOLD_TEXT_REG = /\*\*(.+?)\*\*/g;
const EM_TEXT_REG = /\*(.+?)\*/g;
@@ -31,32 +28,4 @@ const parseHtmlToRawText = (htmlStr: string): string => {
return text;
};
interface FormatterConfig {
inlineImage: boolean;
}
const defaultFormatterConfig: FormatterConfig = {
inlineImage: false,
};
const formatMemoContent = (content: string, addtionConfig?: Partial<FormatterConfig>) => {
const config = {
...defaultFormatterConfig,
...addtionConfig,
};
const tempElement = document.createElement("div");
tempElement.innerHTML = parseMarkedToHtml(escape(content));
let outputString = tempElement.innerHTML;
if (config.inlineImage) {
outputString = outputString.replace(IMAGE_URL_REG, "<img class='img' src='$1' />");
} else {
outputString = outputString.replace(IMAGE_URL_REG, "");
}
return outputString
.replace(MEMO_LINK_REG, "<span class='memo-link-text' data-value='$2'>$1</span>")
.replace(LINK_URL_REG, "<a class='link' target='_blank' rel='noreferrer' href='$2'>$1</a>")
.replace(TAG_REG, "<span class='tag-span'>#$1</span> ");
};
export { formatMemoContent, parseHtmlToRawText };
export { parseMarkedToHtml, parseHtmlToRawText };
+6 -3
View File
@@ -7,14 +7,17 @@
@apply w-128 max-w-full mb-8;
> .dialog-content-container {
@apply w-full flex flex-col justify-start items-start;
.flex(column, flex-start, flex-start);
@apply w-full overflow-y-auto;
> .tip-text-container {
@apply w-full h-32 flex flex-col justify-center items-center;
@apply w-full h-32;
.flex(column, center, center);
}
> .archived-memos-container {
@apply w-full flex flex-col justify-start items-start;
.flex(column, flex-start, flex-start);
@apply w-full;
}
}
}
-40
View File
@@ -1,40 +0,0 @@
@import "./mixin.less";
.dialog-wrapper {
@apply fixed top-0 left-0 flex flex-col justify-start items-center w-full h-full pt-16 z-100 overflow-x-hidden overflow-y-scroll bg-transparent transition-all;
.hide-scroll-bar();
&.showup {
background-color: rgba(0, 0, 0, 0.6);
}
&.showoff {
display: none;
}
> .dialog-container {
@apply flex flex-col justify-start items-start bg-white p-4 rounded-lg;
> .dialog-header-container {
@apply flex flex-row justify-between items-center w-full mb-4;
> .title-text {
> .icon-text {
@apply mr-2 text-base;
}
}
.btn {
@apply flex flex-col justify-center items-center w-6 h-6 rounded hover:bg-gray-100 hover:shadow;
}
}
> .dialog-content-container {
@apply flex flex-col justify-start items-start w-full;
}
> .dialog-footer-container {
@apply flex flex-row justify-end items-center w-full mt-4;
}
}
}
-27
View File
@@ -1,27 +0,0 @@
@import "./mixin.less";
.common-dialog {
> .dialog-container {
@apply w-80;
> .dialog-content-container {
@apply flex flex-col justify-start items-start;
> .btns-container {
@apply flex flex-row justify-end items-center w-full mt-4;
> .btn {
@apply text-sm py-1 px-3 mr-2 rounded-md hover:opacity-80;
&.confirm-btn {
@apply bg-red-100 border border-solid border-blue-600 text-blue-600;
&.warning {
@apply border-red-600 text-red-600;
}
}
}
}
}
}
}
-21
View File
@@ -1,21 +0,0 @@
@import "../mixin.less";
.dropdown-wrapper {
@apply relative flex flex-col justify-start items-start select-none;
> .trigger-button {
@apply flex flex-row justify-center items-center border p-1 rounded shadow text-gray-600 cursor-pointer hover:opacity-80;
> .icon-img {
@apply w-4 h-auto;
}
}
> .action-buttons-container {
@apply w-28 mt-1 absolute top-full right-0 flex flex-col justify-start items-start bg-white z-1 border p-1 rounded shadow;
> button {
@apply w-full text-left px-2 text-sm leading-7 rounded hover:bg-gray-100;
}
}
}
@@ -0,0 +1,36 @@
@import "./mixin.less";
.confirm-reset-openid-dialog {
> .dialog-container {
@apply w-80;
> .dialog-content-container {
.flex(column, flex-start, flex-start);
> .warn-text {
@apply pt-2;
}
> .btns-container {
.flex(row, flex-end, center);
@apply w-full mt-3;
> .btn {
@apply text-sm py-1 px-3 mr-2 rounded-md;
&:hover {
@apply opacity-80;
}
&.confirm-btn {
@apply bg-red-100 border border-solid border-red-600 text-red-600;
&.loading {
@apply opacity-80 cursor-wait;
}
}
}
}
}
}
}
+3 -7
View File
@@ -4,13 +4,13 @@
@apply p-0 sm:py-16;
> .dialog-container {
@apply w-full sm:w-112 max-w-full grow sm:grow-0 bg-white p-0 rounded-none sm:rounded-lg;
@apply w-112 max-w-full grow sm:grow-0 bg-white p-0 rounded-none sm:rounded-lg;
> .dialog-header-container {
@apply relative flex flex-row justify-between items-center w-full p-6 pb-0 mb-0;
> .title-text {
@apply px-2 py-1 -ml-2 cursor-pointer select-none rounded hover:bg-gray-100;
@apply cursor-pointer select-none rounded hover:bg-gray-100;
}
> .btns-container {
@@ -23,14 +23,10 @@
@apply w-full h-auto;
}
&.share {
&.share-btn {
@apply ~"p-0.5";
}
}
> .split-line {
@apply font-mono text-gray-300 mr-2;
}
}
> .date-picker {
+46
View File
@@ -0,0 +1,46 @@
@import "./mixin.less";
.dialog-wrapper {
.flex(column, flex-start, center);
@apply fixed top-0 left-0 w-full h-full pt-16 z-1000 overflow-x-hidden overflow-y-scroll bg-transparent transition-all;
.hide-scroll-bar();
&.showup {
background-color: rgba(0, 0, 0, 0.6);
}
&.showoff {
display: none;
}
> .dialog-container {
.flex(column, flex-start, flex-start);
@apply bg-white p-4 rounded-lg;
> .dialog-header-container {
.flex(row, space-between, center);
@apply w-full mb-4;
> .title-text {
> .icon-text {
@apply mr-2 text-base;
}
}
.btn {
.flex(column, center, center);
@apply w-6 h-6 rounded hover:bg-gray-100 hover:shadow;
}
}
> .dialog-content-container {
.flex(column, flex-start, flex-start);
@apply w-full;
}
> .dialog-footer-container {
.flex(row, flex-end, center);
@apply w-full mt-4;
}
}
}
+1 -1
View File
@@ -5,7 +5,7 @@
@apply w-auto h-full px-2 border-r rounded-l flex flex-row justify-center items-center text-xs font-bold text-gray-800 bg-gray-100;
> .icon-img {
@apply mr-1 w-4 h-4;
@apply mr-1;
}
}
-12
View File
@@ -18,18 +18,6 @@
@apply sticky top-0 w-full h-full flex flex-col justify-start items-start z-10;
background-color: #f6f5f4;
}
> .addtion-btn-container {
@apply fixed bottom-12 left-1/2 -translate-x-1/2;
> .btn {
@apply bg-blue-600 text-white px-4 py-2 rounded-3xl shadow-2xl hover:opacity-80;
> .icon {
@apply text-lg mr-1;
}
}
}
}
}
}
+5 -27
View File
@@ -6,35 +6,17 @@
> .dialog-container {
@apply w-full p-0 bg-transparent flex flex-col justify-start items-center;
> .card-header-container {
@apply z-10 w-128 max-w-full flex flex-row justify-start items-center mb-2;
> .visibility-selector-container {
@apply bg-white px-2 pl-3 py-1 rounded-lg flex flex-row justify-start items-center;
> .icon-img {
@apply mr-1 w-4 h-auto;
}
> .visibility-selector {
@apply w-32;
> .current-value-container {
@apply border-none;
}
}
}
}
> .memo-card-container {
@apply flex flex-col justify-start items-start relative w-128 max-w-full py-3 px-6 mb-3 rounded-lg bg-yellow-200;
.flex(column, flex-start, flex-start);
@apply relative w-128 max-w-full py-3 px-6 mb-3 rounded-lg bg-yellow-200;
> * {
z-index: 1;
}
> .header-container {
@apply flex flex-row justify-between items-center w-full h-auto pb-0 my-0;
.flex(row, space-between, center);
@apply w-full h-auto pb-0 my-0;
> .time-text {
@apply text-sm text-gray-500 font-mono;
@@ -45,11 +27,7 @@
> .btn {
.flex(row, center, center);
@apply w-6 h-6 p-1 ml-2 rounded text-gray-600 hover:bg-white;
}
> .split-line {
@apply font-mono text-gray-300 ml-2;
@apply w-6 h-6 ml-2 rounded text-gray-600 hover:bg-white;
}
}
}
-4
View File
@@ -7,10 +7,6 @@
@apply inline-block w-full h-auto mb-1 last:mb-0 text-base leading-7 whitespace-pre-wrap break-all;
}
.img {
@apply float-left max-w-full w-full;
}
.tag-span {
@apply inline-block w-auto font-mono text-blue-600;
}
+3 -11
View File
@@ -24,15 +24,7 @@
}
> .status-text {
@apply text-xs cursor-pointer ml-2 rounded border px-1;
&.public {
@apply border-green-600 text-green-600;
}
&.protected {
@apply border-gray-400 text-gray-400;
}
@apply text-xs cursor-pointer ml-2 rounded border border-green-600 px-1 text-green-600;
}
}
@@ -51,10 +43,10 @@
box-shadow: 0 0 8px 0 rgb(0 0 0 / 20%);
> .btns-container {
@apply w-full flex flex-row justify-around items-center border-b border-gray-100 p-1 mb-1;
@apply w-full flex flex-row justify-between items-center border-b border-gray-100 p-1 mb-1;
> .btn {
@apply relative w-6 h-6 p-1 text-gray-600;
@apply relative w-7 h-7 p-1 text-gray-600;
&:hover > .tip-text {
@apply block;
-2
View File
@@ -2,8 +2,6 @@
.preview-image-dialog {
@apply p-0;
z-index: 101;
background-color: rgba(0, 0, 0, 0.6);
> .dialog-container {
@apply flex flex-col justify-center items-center relative w-full h-full p-0;
-71
View File
@@ -1,71 +0,0 @@
@import "./mixin.less";
.resources-dialog {
@apply px-4;
> .dialog-container {
@apply w-128 max-w-full mb-8;
> .dialog-content-container {
@apply flex flex-col justify-start items-start w-full;
> .tip-text-container {
@apply w-full flex flex-row justify-start items-start border border-yellow-600 rounded px-2 py-1 mb-2 text-yellow-600 bg-yellow-50 text-sm;
}
> .upload-resource-container {
@apply mt-2 mb-4 w-full rounded flex flex-row justify-start items-center;
> .upload-resource-btn {
@apply px-3 py-1 rounded cursor-pointer flex flex-row justify-center items-center border border-dashed border-blue-600 text-blue-600 bg-blue-50 hover:opacity-80;
> .icon-img {
@apply w-4 h-auto mr-1;
}
}
}
> .loading-text-container {
@apply flex flex-col justify-center items-center w-full h-32;
}
> .resource-table-container {
@apply flex flex-col justify-start items-start w-full;
> .fields-container {
@apply px-2 py-2 w-full grid grid-cols-5 border-b;
> .field-text {
@apply font-mono text-gray-400;
}
}
> .tip-text {
@apply w-full text-center text-base my-6 mt-8;
}
> .resource-container {
@apply px-2 py-2 w-full grid grid-cols-5;
> .buttons-container {
@apply w-full flex flex-row justify-end items-center;
> .actions-dropdown {
.delete-btn {
@apply text-red-600;
}
}
}
}
.field-text {
@apply w-full truncate text-base pr-2 last:pr-0;
&.name-text {
@apply col-span-2;
}
}
}
}
}
}
+46 -24
View File
@@ -1,28 +1,24 @@
@import "./mixin.less";
.search-bar-container {
@apply relative w-auto;
&:hover,
&:active {
> .search-bar-inputer > .text-input {
@apply flex;
}
> .quickly-action-wrapper {
@apply flex;
}
}
@apply relative w-40;
> .search-bar-inputer {
@apply h-9 flex flex-row justify-start items-center w-full py-2 px-3 sm:px-4 rounded-full sm:rounded-lg bg-zinc-200;
.flex(row, flex-start, center);
@apply w-full py-2 px-4 rounded-lg flex flex-row justify-start items-center bg-zinc-200;
> .icon-img {
@apply w-4 h-auto opacity-30;
@apply mr-2 h-auto opacity-30;
}
> .text-input {
@apply hidden sm:flex ml-2 w-24 grow text-sm;
@apply grow text-sm;
}
&:hover {
+ .quickly-action-wrapper {
display: flex;
}
}
}
@@ -30,40 +26,66 @@
@apply hidden absolute top-9 -right-2 p-2 w-80 z-10;
> .quickly-action-container {
@apply flex flex-col justify-start items-start w-full bg-white px-4 py-3 rounded-lg;
.flex(column, flex-start, flex-start);
width: 100%;
background-color: white;
padding: 12px 16px;
border-radius: 8px;
box-shadow: 0 0 8px 0 rgb(0 0 0 / 20%);
> .title-text {
@apply text-gray-600 text-xs;
color: gray;
font-size: 12px;
}
> .types-container {
@apply flex flex-row justify-start items-start w-full text-xs mt-2;
.flex(row, flex-start, flex-start);
width: 100%;
font-size: 13px;
margin-top: 8px;
> .section-text {
@apply text-gray-600 mr-1 shrink-0 leading-6;
color: gray;
margin-right: 4px;
flex-shrink: 0;
line-height: 26px;
}
> .values-container {
@apply flex flex-row justify-start items-start flex-wrap select-none;
.flex(row, flex-start, flex-start);
flex-wrap: wrap;
user-select: none;
> div {
@apply flex flex-row justify-start items-center leading-6;
.flex(row, flex-start, center);
line-height: 26px;
.type-item {
@apply cursor-pointer px-1 rounded hover:bg-gray-100;
cursor: pointer;
padding: 0 4px;
border-radius: 6px;
&:hover {
background-color: @bg-whitegray;
}
&.selected {
@apply bg-green-600 text-white;
background-color: @text-green;
color: white;
}
}
.split-text {
@apply text-gray-400 mx-1;
color: lightgray;
margin: 0 2px;
}
}
}
}
}
&:hover {
display: flex;
}
}
}
+4 -14
View File
@@ -32,32 +32,22 @@
}
> .member-container {
@apply w-full grid grid-cols-4 border-b py-2;
@apply w-full grid grid-cols-5 border-b py-2;
> .field-text {
@apply text-base pl-2 mr-4 w-16 truncate;
@apply text-base pl-2 mr-4 w-16;
&.id-text {
@apply font-mono text-gray-600;
}
&.email-text {
@apply w-auto col-span-2;
@apply col-span-3;
}
}
> .buttons-container {
@apply col-span-1 flex flex-row justify-end items-center;
> .tip-text {
@apply text-gray-400;
}
> .actions-dropdown {
.delete {
@apply text-red-600;
}
}
@apply col-span-1;
}
}
}
+5 -5
View File
@@ -12,10 +12,10 @@
}
> .btn {
@apply flex flex-col justify-center items-center w-5 h-5 bg-gray-200 rounded ml-2 shadow hover:opacity-80;
@apply flex flex-col justify-center items-center w-5 p-1 h-5 bg-gray-200 rounded ml-2 shadow hover:opacity-80;
> .icon-img {
@apply w-4 h-4;
@apply opacity-60;
}
}
}
@@ -24,7 +24,7 @@
@apply flex flex-col justify-start items-start relative w-full h-auto flex-nowrap mb-2;
> .shortcut-container {
@apply relative flex flex-row justify-between items-center w-full h-10 py-0 px-4 mt-px first:mt-2 rounded-lg text-base cursor-pointer select-none shrink-0 hover:bg-white;
@apply flex flex-row justify-between items-center w-full h-10 py-0 px-4 mt-px first:mt-2 rounded-lg text-base cursor-pointer select-none shrink-0 hover:bg-white;
&:hover {
> .btns-container {
@@ -57,7 +57,7 @@
@apply flex flex-row justify-center items-center w-6 h-6 shrink-0;
&.toggle-btn {
@apply w-4 h-auto text-gray-600;
@apply opacity-60;
&:hover {
& + .action-btns-wrapper {
@@ -68,7 +68,7 @@
}
> .action-btns-wrapper {
@apply absolute top-6 right-0 flex-col justify-start items-start w-auto h-auto px-4 pt-3 hidden z-1;
@apply flex-col justify-start items-start absolute top-6 right-0 w-auto h-auto px-4 pt-3 hidden z-1;
> .action-btns-container {
@apply flex flex-col justify-start items-start w-24 h-auto p-1 whitespace-nowrap rounded-md bg-white;
+9 -1
View File
@@ -1,9 +1,10 @@
import { createRoot } from "react-dom/client";
import { Provider } from "react-redux";
import store from "./store";
import { updateStateWithLocation } from "./store/modules/location";
import App from "./App";
import "./helpers/polyfill";
import "./less/global.less";
import "./helpers/polyfill";
import "./css/index.css";
const container = document.getElementById("root");
@@ -13,3 +14,10 @@ root.render(
<App />
</Provider>
);
window.onload = () => {
store.dispatch(updateStateWithLocation());
window.onpopstate = () => {
store.dispatch(updateStateWithLocation());
};
};
-14
View File
@@ -12,7 +12,6 @@ import toastHelper from "../components/Toast";
import "../less/home.less";
function Home() {
const user = useAppSelector((state) => state.user.user);
const location = useAppSelector((state) => state.location);
const loadingState = useLoading();
@@ -54,19 +53,6 @@ function Home() {
<MemoFilter />
</div>
<MemoList />
<Only when={userService.isVisitorMode()}>
<div className="addtion-btn-container">
{user ? (
<button className="btn" onClick={() => (window.location.href = "/")}>
<span className="icon">🏠</span> Back to Home
</button>
) : (
<button className="btn" onClick={() => (window.location.href = "/signin")}>
<span className="icon">👉</span> Sign in
</button>
)}
</div>
</Only>
</main>
</div>
)}
+1 -5
View File
@@ -1,6 +1,6 @@
import { stringify } from "qs";
import store from "../store";
import { setQuery, setPathname, Query, updateStateWithLocation } from "../store/modules/location";
import { setQuery, setPathname, Query } from "../store/modules/location";
const updateLocationUrl = (method: "replace" | "push" = "replace") => {
const { query, pathname, hash } = store.getState().location;
@@ -23,10 +23,6 @@ const locationService = {
return store.getState().location;
},
updateStateWithLocation: () => {
store.dispatch(updateStateWithLocation());
},
setPathname: (pathname: string) => {
store.dispatch(setPathname(pathname));
updateLocationUrl();
-16
View File
@@ -1,19 +1,6 @@
import * as api from "../helpers/api";
const convertResponseModelResource = (resource: Resource): Resource => {
return {
...resource,
createdTs: resource.createdTs * 1000,
updatedTs: resource.updatedTs * 1000,
};
};
const resourceService = {
async getResourceList(): Promise<Resource[]> {
const { data } = (await api.getResourceList()).data;
const resourceList = data.map((m) => convertResponseModelResource(m));
return resourceList;
},
/**
* Upload resource file to server,
* @param file file
@@ -32,9 +19,6 @@ const resourceService = {
return data;
},
async deleteResourceById(id: ResourceId) {
return api.deleteResourceById(id);
},
};
export default resourceService;
+4 -10
View File
@@ -33,7 +33,7 @@ const userService = {
}
}
const { data: user } = (await api.getMyselfUser()).data;
const { data: user } = (await api.getUser()).data;
if (user) {
store.dispatch(setUser(convertResponseModelUser(user)));
}
@@ -53,7 +53,7 @@ const userService = {
},
doSignIn: async () => {
const { data: user } = (await api.getMyselfUser()).data;
const { data: user } = (await api.getUser()).data;
if (user) {
store.dispatch(setUser(convertResponseModelUser(user)));
} else {
@@ -78,14 +78,8 @@ const userService = {
patchUser: async (userPatch: UserPatch): Promise<void> => {
const { data } = (await api.patchUser(userPatch)).data;
if (userPatch.id === store.getState().user.user?.id) {
const user = convertResponseModelUser(data);
store.dispatch(patchUser(user));
}
},
deleteUser: async (userDelete: UserDelete) => {
await api.deleteUser(userDelete);
const user = convertResponseModelUser(data);
store.dispatch(patchUser(user));
},
};
-8
View File
@@ -64,20 +64,12 @@ const locationSlice = createSlice({
return getStateFromLocation();
},
setPathname: (state, action: PayloadAction<string>) => {
if (state.pathname === action.payload) {
return state;
}
return {
...state,
pathname: action.payload,
};
},
setQuery: (state, action: PayloadAction<Partial<Query>>) => {
if (JSON.stringify(action.payload) === state.query) {
return state;
}
return {
...state,
query: {
+1 -1
View File
@@ -1,6 +1,6 @@
type MemoId = number;
type Visibility = "PUBLIC" | "PROTECTED" | "PRIVATE";
type Visibility = "PUBLIC" | "PRIVATE";
interface Memo {
id: MemoId;
+1 -1
View File
@@ -1,7 +1,7 @@
type ResourceId = number;
interface Resource {
id: ResourceId;
id: string;
createdTs: TimeStamp;
updatedTs: TimeStamp;
-8
View File
@@ -22,15 +22,7 @@ interface UserCreate {
}
interface UserPatch {
id: UserId;
rowStatus?: RowStatus;
name?: string;
password?: string;
resetOpenId?: boolean;
}
interface UserDelete {
id: UserId;
}
+1 -8
View File
@@ -2028,7 +2028,7 @@ prettier@2.5.1:
resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.5.1.tgz#fff75fa9d519c54cf0fce328c1017d94546bc56a"
integrity sha512-vBZcPRUR5MZJwoyi3ZoyQlc1rXeEck8KgeC9AwwOn+exuxLxq5toTRDTSaVrXHxelDMHy9zlicw8u66yxoSUFg==
prop-types@^15.7.2, prop-types@^15.8.1:
prop-types@^15.8.1:
version "15.8.1"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
@@ -2072,13 +2072,6 @@ react-dom@^18.1.0:
loose-envify "^1.1.0"
scheduler "^0.23.0"
react-feather@^2.0.10:
version "2.0.10"
resolved "https://registry.yarnpkg.com/react-feather/-/react-feather-2.0.10.tgz#0e9abf05a66754f7b7bb71757ac4da7fb6be3b68"
integrity sha512-BLhukwJ+Z92Nmdcs+EMw6dy1Z/VLiJTzEQACDUEnWMClhYnFykJCGWQx+NmwP/qQHGX/5CzQ+TGi8ofg2+HzVQ==
dependencies:
prop-types "^15.7.2"
react-is@^16.13.1, react-is@^16.7.0:
version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"