Compare commits

...

94 Commits

Author SHA1 Message Date
steven 4477f47c25 chore: release version 0.5.0 2022-10-03 18:49:11 +08:00
steven 4a2fe3aaae chore: update user banner style 2022-10-03 18:48:44 +08:00
steven 0f65b8bdd3 fix: re-generate table columns foreign keys 2022-10-03 18:47:31 +08:00
steven 778282ae29 chore: update resource dialog 2022-10-03 09:39:59 +08:00
steven 85dc29bfb9 feat: add linkedMemoAmount to resource 2022-10-03 09:39:49 +08:00
steven 05e46ee4a8 fix: emoji picker style 2022-10-03 09:11:07 +08:00
steven 6a3b052fa2 chore: don't clean data in dev mode 2022-10-03 08:49:20 +08:00
boojack 222c792539 fix: tag regexp (#253) 2022-10-02 23:12:06 +08:00
boojack 51fb8ddb07 feat: simple markdown parser (#252)
* feat: simple markdown parser

* chore: rename test file name

* feat: add plain text link parser

* chore: update style
2022-10-02 22:49:30 +08:00
steven 8e63b8f289 chore: update interval flag type 2022-10-01 23:01:32 +08:00
steven 17c35be426 chore: add --passWithNoTests to jest 2022-10-01 22:59:00 +08:00
steven dfd461518f chore: add jest github action 2022-10-01 22:57:32 +08:00
steven df5818cdc5 chore: add jest 2022-10-01 22:56:20 +08:00
steven 5894104524 chore: update inline image 2022-10-01 20:00:45 +08:00
steven 09732df0f5 chore: regenerate yarn.lock 2022-10-01 19:59:15 +08:00
steven ae8f292306 feat: create memo with resourceIdList 2022-10-01 10:57:14 +08:00
steven 9f8c0ce567 fix: raw data cache 2022-10-01 10:37:02 +08:00
steven c7cf35c7de feat: update memo resource component 2022-10-01 10:02:16 +08:00
steven e5c9d8604d feat: compose memo resource list 2022-10-01 09:49:59 +08:00
steven b2c22977c1 feat: update memo editor with uploading resources 2022-10-01 00:10:31 +08:00
steven c0edb72b3d chore: update react version 2022-10-01 00:07:06 +08:00
steven 33d31b7dca fix: delete memo resource 2022-10-01 00:06:56 +08:00
steven 4c465bef2d feat: add memo resource apis 2022-09-30 22:58:59 +08:00
steven 3bde9543f3 chore: update dev version v0.5.0 2022-09-30 22:58:12 +08:00
steven cff0e86989 feat: add memo_resource model 2022-09-30 20:20:00 +08:00
boojack 65f7aa7914 fix: emoji picker position in fullscreen (#251) 2022-09-30 18:40:46 +08:00
boojack 06f5a5788e fix: mobile editor float style (#247) 2022-09-27 22:58:03 +08:00
boojack 52c8ac2ad3 chore: update emoji picker toggle logic (#244) 2022-09-26 22:33:41 +08:00
Steven 0c80654cc2 chore: update dropdown action button style 2022-09-26 22:06:06 +08:00
Steven a2180f177f chore: remove vite-plugin-pwa 2022-09-26 21:40:37 +08:00
Steven b992d07f3e chore: update memo detail header 2022-09-24 10:05:51 +08:00
Steven dc3052e225 chore: release v0.4.5 2022-09-24 09:41:14 +08:00
Steven 9b2e57cee5 fix: api access checks 2022-09-24 09:34:01 +08:00
Steven 77a3513a6b chore: update memo detail page 2022-09-24 09:31:20 +08:00
Steven 0dd2337663 chore: update router 2022-09-21 21:30:33 +08:00
Steven d316c04837 feat: update dev latest schema 2022-09-21 19:34:54 +08:00
Steven 63468dbaf3 chore: update memo detail access handler 2022-09-21 19:31:02 +08:00
f97 2acd5d4af2 chore: support html form (#236) 2022-09-21 14:12:16 +08:00
Steven 6c1cc1d283 chore: use conditional rendering instead of OnlyWhen 2022-09-20 23:30:25 +08:00
Steven 15cfc9e1f5 chore: add memo detail page 2022-09-20 22:55:24 +08:00
Steven 004713d4cd chore: update dropdown component 2022-09-20 21:11:33 +08:00
f97 7a6eb53e0f feat: float mobile editor (#234)
* feat: float mobile editor

* fix: fix pr comment

* lint: fix golangci-lint
2022-09-20 20:42:14 +08:00
boojack 02c26d5bb4 chore: update i18n for memo visibility (#233) 2022-09-20 08:34:49 +08:00
Steven c60c58ed69 chore: fix memo content click handler 2022-09-19 23:01:14 +08:00
Steven 366afdd1e4 feat: use i18next 2022-09-19 22:27:50 +08:00
Steven 307483e499 feat: use react-router 2022-09-19 21:53:27 +08:00
Steven 4608894e56 fix: add _foreign_keys to sqlite dsn 2022-09-18 22:48:26 +08:00
boojack a1066322c8 chore: add vite-plugin-pwa (#230) 2022-09-18 11:58:03 +08:00
f97 050a2d39fa chore: fix i18n vietnamese typos and update MemoCardDialog (#228) 2022-09-18 09:17:46 +08:00
Steven 13aa61bbc0 chore: update i18n for change password dialog 2022-09-17 13:33:40 +08:00
boojack f4d0e8c948 chore: hide the searchbar of emoji picker (#222)
chore: hide emoji picker searchbar
2022-09-17 09:23:53 +08:00
f97 e7db587a9e feat: add emoji picker in editor (#221)
* feat: add vietnamese

* feat: add emoji picker in editor

* fix failing checks

* move emoji button before upload button

* move script to body index.html

* Update web/index.html

Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-09-17 00:42:52 +08:00
Steven 059080f194 chore: fix docker tag 2022-09-16 23:34:57 +08:00
Steven 78c159cd59 chore: fix build tag 2022-09-16 23:15:16 +08:00
Steven 721aa3c907 chore: update v0.4.4 2022-09-16 23:04:40 +08:00
Steven 77178afad5 chore: add no data tip 2022-09-16 22:55:39 +08:00
Steven fd7b8c3293 chore: add copy non-private memo link 2022-09-16 22:49:11 +08:00
Steven 660908e436 chore: add react-router 2022-09-16 22:48:41 +08:00
Steven 8d694f7732 chore: remove language beta badge 2022-09-16 21:47:00 +08:00
f97 bdfa9f7a56 chore: update i18n for UserBanner (#219)
* feat: add vietnamese

* chore: update i18n

* Update web/src/locales/zh.json

Co-authored-by: boojack <stevenlgtm@gmail.com>

Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-09-16 21:48:18 +08:00
Steven 19ef97a7ec chore: update dev version 2022-09-16 21:45:56 +08:00
Steven 2c17ea703d chore: fix manifest.json image 2022-09-16 21:33:31 +08:00
Steven 1591fdf61c chore: update i18n structures 2022-09-16 21:25:39 +08:00
f97 811f3340e9 feat: add vietnamese (#218) 2022-09-16 21:09:59 +08:00
ChasLui 7e8d1128f8 chore: update i18n (#215) 2022-09-15 06:53:51 +08:00
Steven 54db6eda04 chore: update common locales 2022-09-14 19:35:45 +08:00
boojack c5b26e3310 chore: fix copy to clipboard (#214) 2022-09-14 19:24:13 +08:00
ChasLui 7079faf2b9 chore: update i18n (#212)
* feat: 增加部分 i18n

* feat: 增加部分 i18n

* feat: 增加部分 i18n
2022-09-14 19:14:45 +08:00
Steven 707d1a96eb chore: move version pkg to server/version 2022-09-12 17:25:34 +08:00
boojack 76801dfa4f chore: vacuum db file after deleting resource (#210) 2022-09-10 23:43:19 +08:00
Steven 6e4577f721 feat: add MemoContent component 2022-09-10 21:22:26 +08:00
winwin2011 7b0987610c feat: i18n for dialogs (#203)
* feat: i18n in dialog

* fix: use common translation
2022-09-10 09:30:10 +08:00
boojack 50fa560d4a chore: update username validate config (#209) 2022-09-09 22:30:24 +08:00
Steven b8a7df21f2 chore: release v0.4.3 2022-09-09 20:00:04 +08:00
boojack d1a4348048 chore: support double-click to edit memo (#207) 2022-09-09 19:42:04 +08:00
Steven 020b211660 chore: update explore page style 2022-09-09 19:41:12 +08:00
Steven 4657e58b52 chore: update loading section 2022-09-09 08:22:49 +08:00
Steven 22971c3a93 chore: support upload more file type 2022-09-09 08:10:39 +08:00
Steven 5fa9aa3c22 chore: add memo resources component 2022-09-09 07:53:02 +08:00
Steven fbce43870f chore: update resource base url 2022-09-09 07:40:21 +08:00
Steven b1e6956441 chore: add cache for resource 2022-09-09 00:50:58 +08:00
Steven ad462cec29 chore: update explore style 2022-09-09 00:37:31 +08:00
Steven a0a42285d0 chore: update docs 2022-09-09 00:32:01 +08:00
boojack b0b2776d03 chore: update button entries (#206) 2022-09-09 00:24:41 +08:00
boojack e9ac6affef feat: add explore page (#205) 2022-09-09 00:06:05 +08:00
winwin2011 5eea1339c9 fix: upload resouces double time (#204) 2022-09-08 00:13:21 +08:00
Steven f303dc21a0 chore: add get all memo api 2022-09-05 21:14:17 +08:00
Steven d68891d91d chore: fix tag regex 2022-09-05 20:15:34 +08:00
winwin2011 987bb80770 fix: heatmap popover blink (#195) 2022-09-04 22:34:24 +08:00
Steven b884327a53 chore: update eslint rules 2022-09-04 06:48:19 +08:00
Steven 4743818fe7 chore: update not found handler in deleting 2022-09-03 18:54:22 +08:00
Steven 43575e6f54 chore: update readme 2022-09-03 18:46:16 +08:00
Steven 89f9dc5640 chore: update resources 2022-09-03 18:41:54 +08:00
Steven f7aca99d52 fix: upload image in iOS safari 2022-09-03 00:10:50 +08:00
151 changed files with 5841 additions and 2298 deletions
@@ -4,7 +4,7 @@ on:
push:
branches:
# Run on pushing branches like `release/1.0.0`
- "release/v*.*.*"
- "release/*.*.*"
jobs:
build-and-push-release-image:
@@ -17,9 +17,9 @@ jobs:
- name: Extract build args
# Extract version from branch name
# Example: branch name `release/v1.0.0` sets up env.VERSION=1.0.0
# Example: branch name `release/1.0.0` sets up env.VERSION=1.0.0
run: |
echo "VERSION=${GITHUB_REF_NAME#release/v}" >> $GITHUB_ENV
echo "VERSION=${GITHUB_REF_NAME#release/}" >> $GITHUB_ENV
- name: Login to Docker Hub
uses: docker/login-action@v2
+15
View File
@@ -43,6 +43,21 @@ jobs:
run: yarn lint
working-directory: web
jest-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: "16"
cache: yarn
cache-dependency-path: "web/yarn.lock"
- run: yarn
working-directory: web
- name: Run jest
run: yarn test
working-directory: web
frontend-build:
runs-on: ubuntu-latest
steps:
+6 -55
View File
@@ -26,73 +26,24 @@
## Deploy with Docker
#### Docker Run
### Docker Run
```docker
docker run -d --name memos -p 5230:5230 -v ~/.memos/:/var/opt/memos neosmemo/memos:latest
```
#### Docker Compose
```
version: "3.0"
services:
memos:
image: neosmemo/memos:latest
container_name: memos
volumes:
- ~/.memos/:/var/opt/memos
ports:
- 5230: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.
## Development
### Docker Compose
Memos is built with a curated tech stack. It is optimized for developer experience and is very easy to start working on the code:
See more in the example [`docker-compose.yaml`](./docker-compose.yaml) file.
1. It has no external dependency.
2. It requires zero config.
3. 1 command to start backend and 1 command to start frontend, both with live reload support.
### Tech Stack
<img alt="tech stack" src="https://raw.githubusercontent.com/usememos/memos/main/resources/tech-stack.png" width="360" />
### Prerequisites
- [Go](https://golang.org/doc/install)
- [Air](https://github.com/cosmtrek/air#installation) for backend live reload
- [Node.js](https://nodejs.org/)
- [yarn](https://yarnpkg.com/getting-started/install)
### Steps
1. pull source code
```bash
git clone https://github.com/usememos/memos
```
2. start backend using air(with live reload)
```bash
air -c scripts/.air.toml
```
3. start frontend dev server
```bash
cd web && yarn && yarn dev
```
Memos should now be running at [http://localhost:3000](http://localhost:3000) and change either frontend or backend code would trigger live reload.
### Contributing
## 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. 🥰
Gets more about [development guide](https://github.com/usememos/memos/tree/main/docs/development.md).
## Star history
[![Star History Chart](https://api.star-history.com/svg?repos=usememos/memos&type=Date)](https://star-history.com/#usememos/memos&Date)
+7
View File
@@ -37,6 +37,10 @@ type Memo struct {
Content string `json:"content"`
Visibility Visibility `json:"visibility"`
Pinned bool `json:"pinned"`
// Related fields
Creator *User `json:"creator"`
ResourceList []*Resource `json:"resourceList"`
}
type MemoCreate struct {
@@ -46,6 +50,9 @@ type MemoCreate struct {
// Domain specific fields
Visibility Visibility `json:"visibility"`
Content string `json:"content"`
// Related fields
ResourceIDList []int `json:"resourceIdList"`
}
type MemoPatch struct {
+24
View File
@@ -0,0 +1,24 @@
package api
type MemoResource struct {
MemoID int
ResourceID int
CreatedTs int64
UpdatedTs int64
}
type MemoResourceUpsert struct {
MemoID int
ResourceID int
UpdatedTs *int64
}
type MemoResourceFind struct {
MemoID *int
ResourceID *int
}
type MemoResourceDelete struct {
MemoID int
ResourceID *int
}
+4
View File
@@ -13,6 +13,9 @@ type Resource struct {
Blob []byte `json:"-"`
Type string `json:"type"`
Size int64 `json:"size"`
// Related fields
LinkedMemoAmount int `json:"linkedMemoAmount"`
}
type ResourceCreate struct {
@@ -34,6 +37,7 @@ type ResourceFind struct {
// Domain specific fields
Filename *string `json:"filename"`
MemoID *int
}
type ResourceDelete struct {
+25 -3
View File
@@ -14,6 +14,8 @@ const (
UserSettingMemoVisibilityKey UserSettingKey = "memoVisibility"
// UserSettingEditorFontStyleKey is the key type for editor font style.
UserSettingEditorFontStyleKey UserSettingKey = "editorFontStyle"
// UserSettingEditorFontStyleKey is the key type for mobile editor style.
UserSettingMobileEditorStyleKey UserSettingKey = "mobileEditorStyle"
)
// String returns the string format of UserSettingKey type.
@@ -25,14 +27,17 @@ func (key UserSettingKey) String() string {
return "memoVisibility"
case UserSettingEditorFontStyleKey:
return "editorFontFamily"
case UserSettingMobileEditorStyleKey:
return "mobileEditorStyle"
}
return ""
}
var (
UserSettingLocaleValue = []string{"en", "zh"}
UserSettingMemoVisibilityValue = []Visibility{Privite, Protected, Public}
UserSettingEditorFontStyleValue = []string{"normal", "mono"}
UserSettingLocaleValue = []string{"en", "zh", "vi"}
UserSettingMemoVisibilityValue = []Visibility{Privite, Protected, Public}
UserSettingEditorFontStyleValue = []string{"normal", "mono"}
UserSettingMobileEditorStyleValue = []string{"normal", "float"}
)
type UserSetting struct {
@@ -100,6 +105,23 @@ func (upsert UserSettingUpsert) Validate() error {
if invalid {
return fmt.Errorf("invalid user setting editor font style value")
}
} else if upsert.Key == UserSettingMobileEditorStyleKey {
mobileEditorStyleValue := "normal"
err := json.Unmarshal([]byte(upsert.Value), &mobileEditorStyleValue)
if err != nil {
return fmt.Errorf("failed to unmarshal user setting mobile editor style")
}
invalid := true
for _, value := range UserSettingMobileEditorStyleValue {
if mobileEditorStyleValue == value {
invalid = false
break
}
}
if invalid {
return fmt.Errorf("invalid user setting mobile editor style value")
}
} else {
return fmt.Errorf("invalid user setting key")
}
-63
View File
@@ -1,63 +0,0 @@
package cmd
import (
"context"
"fmt"
"github.com/usememos/memos/server"
"github.com/usememos/memos/server/profile"
"github.com/usememos/memos/store"
DB "github.com/usememos/memos/store/db"
)
const (
greetingBanner = `
███╗ ███╗███████╗███╗ ███╗ ██████╗ ███████╗
████╗ ████║██╔════╝████╗ ████║██╔═══██╗██╔════╝
██╔████╔██║█████╗ ██╔████╔██║██║ ██║███████╗
██║╚██╔╝██║██╔══╝ ██║╚██╔╝██║██║ ██║╚════██║
██║ ╚═╝ ██║███████╗██║ ╚═╝ ██║╚██████╔╝███████║
╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝
`
)
func Run(profile *profile.Profile) error {
ctx := context.Background()
db := DB.NewDB(profile)
if err := db.Open(ctx); err != nil {
return fmt.Errorf("cannot open db: %w", err)
}
s := server.NewServer(profile)
storeInstance := store.New(db.Db, profile)
s.Store = storeInstance
println(greetingBanner)
fmt.Printf("Version %s has started at :%d\n", profile.Version, profile.Port)
return s.Run()
}
func Execute() error {
profile, err := profile.GetProfile()
if err != nil {
return err
}
println("---")
println("profile")
println("mode:", profile.Mode)
println("port:", profile.Port)
println("dsn:", profile.DSN)
println("version:", profile.Version)
println("---")
if err := Run(profile); err != nil {
fmt.Printf("error: %+v\n", err)
return err
}
return nil
}
+61 -2
View File
@@ -5,11 +5,70 @@ import (
_ "github.com/mattn/go-sqlite3"
"github.com/usememos/memos/bin/server/cmd"
"context"
"fmt"
"github.com/usememos/memos/server"
"github.com/usememos/memos/server/profile"
"github.com/usememos/memos/store"
DB "github.com/usememos/memos/store/db"
)
const (
greetingBanner = `
███╗ ███╗███████╗███╗ ███╗ ██████╗ ███████╗
████╗ ████║██╔════╝████╗ ████║██╔═══██╗██╔════╝
██╔████╔██║█████╗ ██╔████╔██║██║ ██║███████╗
██║╚██╔╝██║██╔══╝ ██║╚██╔╝██║██║ ██║╚════██║
██║ ╚═╝ ██║███████╗██║ ╚═╝ ██║╚██████╔╝███████║
╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝
`
)
func run(profile *profile.Profile) error {
ctx := context.Background()
db := DB.NewDB(profile)
if err := db.Open(ctx); err != nil {
return fmt.Errorf("cannot open db: %w", err)
}
s := server.NewServer(profile)
storeInstance := store.New(db.Db, profile)
s.Store = storeInstance
println(greetingBanner)
fmt.Printf("Version %s has started at :%d\n", profile.Version, profile.Port)
return s.Run()
}
func execute() error {
profile, err := profile.GetProfile()
if err != nil {
return err
}
println("---")
println("profile")
println("mode:", profile.Mode)
println("port:", profile.Port)
println("dsn:", profile.DSN)
println("version:", profile.Version)
println("---")
if err := run(profile); err != nil {
fmt.Printf("error: %+v\n", err)
return err
}
return nil
}
func main() {
if err := cmd.Execute(); err != nil {
if err := execute(); err != nil {
os.Exit(1)
}
}
@@ -7,4 +7,3 @@ services:
- ~/.memos/:/var/opt/memos
ports:
- 5230:5230
restart: unless-stopped
+40
View File
@@ -0,0 +1,40 @@
# Development
Memos is built with a curated tech stack. It is optimized for developer experience and is very easy to start working on the code:
1. It has no external dependency.
2. It requires zero config.
3. 1 command to start backend and 1 command to start frontend, both with live reload support.
## Tech Stack
![tech-stack](https://raw.githubusercontent.com/usememos/memos/main/resources/tech-stack.png)
## Prerequisites
- [Go](https://golang.org/doc/install)
- [Air](https://github.com/cosmtrek/air#installation) for backend live reload
- [Node.js](https://nodejs.org/)
- [yarn](https://yarnpkg.com/getting-started/install)
## Steps
1. pull source code
```bash
git clone https://github.com/usememos/memos
```
2. start backend using air(with live reload)
```bash
air -c scripts/.air.toml
```
3. start frontend dev server
```bash
cd web && yarn && yarn dev
```
Memos should now be running at [http://localhost:3000](http://localhost:3000) and change either frontend or backend code would trigger live reload.
+7 -7
View File
@@ -8,20 +8,20 @@ require github.com/google/uuid v1.3.0
require (
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/mattn/go-colorable v0.1.11 // indirect
github.com/mattn/go-colorable v0.1.12 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.1 // indirect
golang.org/x/crypto v0.0.0-20210920023735-84f357641f63
golang.org/x/net v0.0.0-20210917221730-978cfadd31cf // indirect
golang.org/x/sys v0.0.0-20220405052023-b1e9470b6e64 // indirect
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa
golang.org/x/net v0.0.0-20220728030405-41545e8bf201 // indirect
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 // indirect
golang.org/x/text v0.3.7 // indirect
golang.org/x/time v0.0.0-20201208040808-7e3f01d25324 // indirect
golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 // indirect
)
require (
github.com/gorilla/context v1.1.1 // indirect
github.com/labstack/echo/v4 v4.6.3
github.com/labstack/echo/v4 v4.9.0
github.com/labstack/gommon v0.3.1 // indirect
)
@@ -29,7 +29,7 @@ require (
github.com/VictoriaMetrics/fastcache v1.10.0
github.com/gorilla/securecookie v1.1.1
github.com/gorilla/sessions v1.2.1
github.com/labstack/echo-contrib v0.12.0
github.com/labstack/echo-contrib v0.13.0
)
require (
+46 -24
View File
@@ -52,7 +52,7 @@ github.com/appleboy/gofight/v2 v2.1.2/go.mod h1:frW+U1QZEdDgixycTj4CygQ48yLTUhpl
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/casbin/casbin/v2 v2.40.6/go.mod h1:sEL80qBYTbd+BPeL4iyvwYzFT3qwLaESq5aFKVLbLfA=
github.com/casbin/casbin/v2 v2.51.1/go.mod h1:vByNa/Fchek0KZUgG5wEsl7iFsiviAYKRtgrQfcJqHg=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
@@ -63,7 +63,11 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI=
github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -77,6 +81,7 @@ github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.m
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0=
github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
@@ -90,9 +95,11 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
@@ -139,6 +146,8 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
@@ -178,6 +187,7 @@ github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
@@ -193,21 +203,16 @@ github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfn
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo-contrib v0.12.0 h1:NPr1ez+XUa5s/4LujEon+32Bxg5DO6EKSW/va06pmLc=
github.com/labstack/echo-contrib v0.12.0/go.mod h1:kR62TbwsBgmpV2HVab5iQRsQtLuhPyGqCBee88XRc4M=
github.com/labstack/echo/v4 v4.6.1/go.mod h1:RnjgMWNDB9g/HucVWhQYNQP9PvbYf6adqftqryo7s9k=
github.com/labstack/echo/v4 v4.6.3 h1:VhPuIZYxsbPmo4m9KAkMU/el2442eB7EBFFhNTTT9ac=
github.com/labstack/echo/v4 v4.6.3/go.mod h1:Hk5OiHj0kDqmFq7aHe7eDqI7CUhuCrfpupQtLGGLm7A=
github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k=
github.com/labstack/echo-contrib v0.13.0 h1:bzSG0SpuZZd7BmJLvsWtPfU23W0Enh3K0tok3aENVKA=
github.com/labstack/echo-contrib v0.13.0/go.mod h1:IF9+MJu22ADOZEHD+bAV67XMIO3vNXUy7Naz/ABPHEs=
github.com/labstack/echo/v4 v4.7.2/go.mod h1:xkCDAdFCIf8jsFQ5NnbK7oqaF/yU1A1X20Ltm0OvSks=
github.com/labstack/echo/v4 v4.9.0 h1:wPOF1CE6gvt/kmbMR4dGzWvHMPT+sAEUJOwOTtvITVY=
github.com/labstack/echo/v4 v4.9.0/go.mod h1:xkCDAdFCIf8jsFQ5NnbK7oqaF/yU1A1X20Ltm0OvSks=
github.com/labstack/gommon v0.3.1 h1:OomWaJXm7xR6L1HmEtGyQf26TEn7V6X88mktX9kee9o=
github.com/labstack/gommon v0.3.1/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.11 h1:nQ+aFkoE2TMGc0b68U2OKSexC+eq46+XwZzWXHRmPYs=
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-sqlite3 v1.14.9 h1:10HX2Td0ocZpYEjhilsuo6WWtUqttj2Kb0KtD86/KYA=
@@ -217,6 +222,7 @@ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJ
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
@@ -230,7 +236,7 @@ github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7J
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
github.com/openzipkin/zipkin-go v0.3.0/go.mod h1:4c3sLeE8xjNqehmF5RpAFLPLJxXscc0R4l6Zg0P1tTQ=
github.com/openzipkin/zipkin-go v0.4.0/go.mod h1:4c3sLeE8xjNqehmF5RpAFLPLJxXscc0R4l6Zg0P1tTQ=
github.com/pierrec/lz4 v2.6.1+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -241,6 +247,8 @@ github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXP
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY=
github.com/prometheus/client_golang v1.12.2/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
@@ -249,10 +257,13 @@ github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y8
github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc=
github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls=
github.com/prometheus/common v0.37.0/go.mod h1:phzohg0JFMnBEFGxTDbfu3QyL5GI8gTQJFhYO5B3mfA=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4=
github.com/rabbitmq/amqp091-go v1.1.0/go.mod h1:ogQDLSOACsLPsIq0NpbtiifNZi2YOz0VTJ0kHRghqbM=
github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
@@ -277,7 +288,6 @@ github.com/uber/jaeger-lib v2.4.1+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
github.com/valyala/fasttemplate v1.2.1 h1:TVEnxayobAdVkhQfrfes2IzOB6o+z4roRkPF52WA1u4=
github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
@@ -302,8 +312,9 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210920023735-84f357641f63 h1:kETrAMYZq6WVGPa8IIixL0CaEcIUNi+1WX7grUoi3y8=
golang.org/x/crypto v0.0.0-20210920023735-84f357641f63/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa h1:zuSxTR4o9y82ebqCUJYNGJbGPo6sKVl54f/TVDObg1c=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@@ -372,15 +383,20 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210913180222-943fd674d43e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210917221730-978cfadd31cf h1:R150MpwJIv1MpS0N/pc+NhTM8ajzvlmxlY5OYsrevXQ=
golang.org/x/net v0.0.0-20210917221730-978cfadd31cf/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220728030405-41545e8bf201 h1:bvOltf3SADAfG05iRml8lAB3qjoEX5RCyN4K6G5v3N0=
golang.org/x/net v0.0.0-20220728030405-41545e8bf201/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -391,12 +407,12 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -405,7 +421,6 @@ golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -415,7 +430,6 @@ golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -433,17 +447,22 @@ golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210910150752-751e447fb3d0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220405052023-b1e9470b6e64 h1:D1v9ucDTYBtbz5vNuBbAhIMAGhQhJ6Ym5ah3maMVNX4=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220405052023-b1e9470b6e64/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 h1:WIoqL4EROvwiPdUtaip4VcDdpZ4kha7wBWZrbVKCIZg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -456,8 +475,9 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20201208040808-7e3f01d25324 h1:Hir2P/De0WpUhtrKGGjvSb2YxUgyZ7EFOSLIcSSpiwE=
golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 h1:ftMN5LMiBFjbzleLqtoBZk7KdJwhuybIU+FckUHgoyQ=
golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -576,6 +596,7 @@ google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM
google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
google.golang.org/grpc v1.41.0/go.mod h1:U3l9uK9J0sini8mHphKoXyaqDA/8VyGnDee1zzIUK6k=
google.golang.org/grpc v1.48.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@@ -589,6 +610,7 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-10
View File
@@ -1,10 +0,0 @@
version: "3.2"
services:
portainer:
image: neosmemo/memos:latest
container_name: memos
ports:
- "5230:5230"
volumes:
- ~/.memos/:/var/opt/memos
command: --mode=prod --port=5230
Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 63 KiB

+21 -15
View File
@@ -64,20 +64,22 @@ func aclMiddleware(s *Server, next echo.HandlerFunc) echo.HandlerFunc {
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(ctx, 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 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(ctx, userFind)
if err != nil && common.ErrorCode(err) != common.NotFound {
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)
}
}
}
@@ -90,7 +92,7 @@ func aclMiddleware(s *Server, next echo.HandlerFunc) echo.HandlerFunc {
ID: &userID,
}
user, err := s.Store.FindUser(ctx, userFind)
if err != nil {
if err != nil && common.ErrorCode(err) != common.NotFound {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user by ID: %d", userID)).SetInternal(err)
}
if user != nil {
@@ -102,6 +104,10 @@ func aclMiddleware(s *Server, next echo.HandlerFunc) echo.HandlerFunc {
}
}
if common.HasPrefixes(path, "/api/memo/all", "/api/memo/:memoId") && c.Request().Method == http.MethodGet {
return next(c)
}
if common.HasPrefixes(path, "/api/memo", "/api/tag", "/api/shortcut") && c.Request().Method == http.MethodGet {
if _, err := strconv.Atoi(c.QueryParam("creatorId")); err == nil {
return next(c)
+2 -2
View File
@@ -24,7 +24,7 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
Email: &signin.Email,
}
user, err := s.Store.FindUser(ctx, userFind)
if err != nil {
if err != nil && common.ErrorCode(err) != common.NotFound {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user by email %s", signin.Email)).SetInternal(err)
}
if user == nil {
@@ -68,7 +68,7 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
Role: &hostUserType,
}
hostUser, err := s.Store.FindUser(ctx, &hostUserFind)
if err != nil {
if err != nil && common.ErrorCode(err) != common.NotFound {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find host user").SetInternal(err)
}
if hostUser != nil {
+17 -3
View File
@@ -12,8 +12,8 @@ import (
//go:embed dist
var embeddedFiles embed.FS
func getFileSystem() http.FileSystem {
fs, err := fs.Sub(embeddedFiles, "dist")
func getFileSystem(path string) http.FileSystem {
fs, err := fs.Sub(embeddedFiles, path)
if err != nil {
panic(err)
}
@@ -22,8 +22,22 @@ func getFileSystem() http.FileSystem {
}
func embedFrontend(e *echo.Echo) {
// Use echo static middleware to serve the built dist folder
// refer: https://github.com/labstack/echo/blob/master/middleware/static.go
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
HTML5: true,
Filesystem: getFileSystem(),
Filesystem: getFileSystem("dist"),
}))
g := e.Group("assets")
g.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
c.Response().Header().Set(echo.HeaderCacheControl, "max-age=31536000, immutable")
return next(c)
}
})
g.Use(middleware.StaticWithConfig(middleware.StaticConfig{
HTML5: true,
Filesystem: getFileSystem("dist/assets"),
}))
}
+210 -25
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"strconv"
"strings"
"time"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
@@ -20,6 +21,7 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
memoCreate := &api.MemoCreate{
CreatorID: userID,
// Private is the default memo visibility.
@@ -28,7 +30,6 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
if err := json.NewDecoder(c.Request().Body).Decode(memoCreate); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo request").SetInternal(err)
}
if memoCreate.Content == "" {
return echo.NewHTTPError(http.StatusBadRequest, "Memo content shouldn't be empty")
}
@@ -55,6 +56,20 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create memo").SetInternal(err)
}
for _, resourceID := range memoCreate.ResourceIDList {
if _, err := s.Store.UpsertMemoResource(ctx, &api.MemoResourceUpsert{
MemoID: memo.ID,
ResourceID: resourceID,
}); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo resource").SetInternal(err)
}
}
memo, err = s.Store.ComposeMemo(ctx, memo)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo").SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(memo)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode memo response").SetInternal(err)
@@ -64,11 +79,24 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
g.PATCH("/memo/:memoId", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
memoID, err := strconv.Atoi(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
}
memoFind := &api.MemoFind{
ID: &memoID,
CreatorID: &userID,
}
if _, err := s.Store.FindMemo(ctx, memoFind); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo").SetInternal(err)
}
memoPatch := &api.MemoPatch{
ID: memoID,
}
@@ -91,7 +119,6 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
g.GET("/memo", func(c echo.Context) error {
ctx := c.Request().Context()
memoFind := &api.MemoFind{}
if userID, err := strconv.Atoi(c.QueryParam("creatorId")); err == nil {
memoFind.CreatorID = &userID
}
@@ -151,6 +178,95 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
return nil
})
g.GET("/memo/all", func(c echo.Context) error {
ctx := c.Request().Context()
memoFind := &api.MemoFind{}
_, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
memoFind.VisibilityList = []api.Visibility{api.Public}
} else {
memoFind.VisibilityList = []api.Visibility{api.Public, api.Protected}
}
pinnedStr := c.QueryParam("pinned")
if pinnedStr != "" {
pinned := pinnedStr == "true"
memoFind.Pinned = &pinned
}
tag := c.QueryParam("tag")
if tag != "" {
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
}
if offset, err := strconv.Atoi(c.QueryParam("offset")); err == nil {
memoFind.Offset = offset
}
// Only fetch normal status memos.
normalStatus := api.Normal
memoFind.RowStatus = &normalStatus
list, err := s.Store.FindMemoList(ctx, memoFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch all memo list").SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(list)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode all memo list response").SetInternal(err)
}
return nil
})
g.GET("/memo/:memoId", func(c echo.Context) error {
ctx := c.Request().Context()
memoID, err := strconv.Atoi(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
}
memoFind := &api.MemoFind{
ID: &memoID,
}
memo, err := s.Store.FindMemo(ctx, memoFind)
if err != nil {
if common.ErrorCode(err) == common.NotFound {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo ID not found: %d", memoID)).SetInternal(err)
}
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find memo by ID: %v", memoID)).SetInternal(err)
}
userID, ok := c.Get(getUserIDContextKey()).(int)
if memo.Visibility == api.Privite {
if !ok || memo.CreatorID != userID {
return echo.NewHTTPError(http.StatusForbidden, "this memo is private only")
}
} else if memo.Visibility == api.Protected {
if !ok {
return echo.NewHTTPError(http.StatusForbidden, "this memo is protected, missing user in session")
}
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(memo)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode memo response").SetInternal(err)
}
return nil
})
g.POST("/memo/:memoId/organizer", func(c echo.Context) error {
ctx := c.Request().Context()
memoID, err := strconv.Atoi(c.Param("memoId"))
@@ -193,43 +309,112 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
return nil
})
g.GET("/memo/:memoId", func(c echo.Context) error {
g.POST("/memo/:memoId/resource", func(c echo.Context) error {
ctx := c.Request().Context()
memoID, err := strconv.Atoi(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
}
currentTs := time.Now().Unix()
memoResourceUpsert := &api.MemoResourceUpsert{
MemoID: memoID,
UpdatedTs: &currentTs,
}
if err := json.NewDecoder(c.Request().Body).Decode(memoResourceUpsert); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo resource request").SetInternal(err)
}
if _, err := s.Store.UpsertMemoResource(ctx, memoResourceUpsert); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo resource").SetInternal(err)
}
resourceFind := &api.ResourceFind{
ID: &memoResourceUpsert.ResourceID,
}
resource, err := s.Store.FindResource(ctx, resourceFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource").SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(resource)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode resource response").SetInternal(err)
}
return nil
})
g.GET("/memo/:memoId/resource", func(c echo.Context) error {
ctx := c.Request().Context()
memoID, err := strconv.Atoi(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
}
resourceFind := &api.ResourceFind{
MemoID: &memoID,
}
resourceList, err := s.Store.FindResourceList(ctx, resourceFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource list").SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(resourceList)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode resource list response").SetInternal(err)
}
return nil
})
g.DELETE("/memo/:memoId/resource/:resourceId", func(c echo.Context) error {
ctx := c.Request().Context()
memoID, err := strconv.Atoi(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Memo ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
}
resourceID, err := strconv.Atoi(c.Param("resourceId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Resource ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
}
memoResourceDelete := &api.MemoResourceDelete{
MemoID: memoID,
ResourceID: &resourceID,
}
if err := s.Store.DeleteMemoResource(ctx, memoResourceDelete); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource list").SetInternal(err)
}
return c.JSON(http.StatusOK, true)
})
g.DELETE("/memo/:memoId", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
memoID, err := strconv.Atoi(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
}
memoFind := &api.MemoFind{
ID: &memoID,
ID: &memoID,
CreatorID: &userID,
}
memo, err := s.Store.FindMemo(ctx, memoFind)
if err != nil {
if common.ErrorCode(err) == common.NotFound {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo ID not found: %d", memoID)).SetInternal(err)
}
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find memo by ID: %v", memoID)).SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(memo)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode memo response").SetInternal(err)
}
return nil
})
g.DELETE("/memo/:memoId", func(c echo.Context) error {
ctx := c.Request().Context()
memoID, err := strconv.Atoi(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
if _, err := s.Store.FindMemo(ctx, memoFind); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo").SetInternal(err)
}
memoDelete := &api.MemoDelete{
ID: memoID,
}
if err := s.Store.DeleteMemo(ctx, memoDelete); err != nil {
if common.ErrorCode(err) == common.NotFound {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo ID not found: %d", memoID))
}
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to delete memo ID: %v", memoID)).SetInternal(err)
}
+2 -2
View File
@@ -7,7 +7,7 @@ import (
"path/filepath"
"strings"
"github.com/usememos/memos/common"
"github.com/usememos/memos/server/version"
)
// Profile is the configuration to start main server.
@@ -68,7 +68,7 @@ func GetProfile() (*Profile, error) {
profile.Data = dataDir
profile.DSN = fmt.Sprintf("%s/memos_%s.db", dataDir, profile.Mode)
profile.Version = common.GetCurrentVersion(profile.Mode)
profile.Version = version.GetCurrentVersion(profile.Mode)
return &profile, nil
}
+44
View File
@@ -3,11 +3,13 @@ package server
import (
"encoding/json"
"fmt"
"html"
"io"
"net/http"
"strconv"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
"github.com/labstack/echo/v4"
)
@@ -78,6 +80,16 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource list").SetInternal(err)
}
for _, resource := range list {
memoResoureceList, err := s.Store.FindMemoResourceList(ctx, &api.MemoResourceFind{
ResourceID: &resource.ID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo resource list").SetInternal(err)
}
resource.LinkedMemoAmount = len(memoResoureceList)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(list)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode resource list response").SetInternal(err)
@@ -158,9 +170,41 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
CreatorID: userID,
}
if err := s.Store.DeleteResource(ctx, resourceDelete); err != nil {
if common.ErrorCode(err) == common.NotFound {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Resource ID not found: %d", resourceID))
}
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete resource").SetInternal(err)
}
return c.JSON(http.StatusOK, true)
})
}
func (s *Server) registerResourcePublicRoutes(g *echo.Group) {
g.GET("/r/:resourceId/:filename", func(c echo.Context) error {
ctx := c.Request().Context()
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)
}
filename := html.UnescapeString(c.Param("filename"))
resourceFind := &api.ResourceFind{
ID: &resourceID,
Filename: &filename,
}
resource, err := s.Store.FindResource(ctx, resourceFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to fetch resource ID: %v", resourceID)).SetInternal(err)
}
c.Response().Writer.WriteHeader(http.StatusOK)
c.Response().Writer.Header().Set("Content-Type", resource.Type)
c.Response().Header().Set(echo.HeaderCacheControl, "max-age=31536000, immutable")
if _, err := c.Response().Writer.Write(resource.Blob); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to write response").SetInternal(err)
}
return nil
})
}
+4 -2
View File
@@ -56,9 +56,11 @@ func NewServer(profile *profile.Profile) *Server {
Profile: profile,
}
// Webhooks api skips auth checker.
webhookGroup := e.Group("/h")
s.registerWebhookRoutes(webhookGroup)
s.registerResourcePublicRoutes(webhookGroup)
publicGroup := e.Group("/o")
s.registerResourcePublicRoutes(publicGroup)
apiGroup := e.Group("/api")
apiGroup.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
+4
View File
@@ -7,6 +7,7 @@ import (
"strconv"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
"github.com/labstack/echo/v4"
)
@@ -123,6 +124,9 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
ID: shortcutID,
}
if err := s.Store.DeleteShortcut(ctx, shortcutDelete); err != nil {
if common.ErrorCode(err) == common.NotFound {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Shortcut ID not found: %d", shortcutID))
}
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete shortcut").SetInternal(err)
}
+2 -1
View File
@@ -5,6 +5,7 @@ import (
"net/http"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
"github.com/labstack/echo/v4"
)
@@ -27,7 +28,7 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
Role: &hostUserType,
}
hostUser, err := s.Store.FindUser(ctx, &hostUserFind)
if err != nil {
if err != nil && common.ErrorCode(err) != common.NotFound {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find host user").SetInternal(err)
}
+4 -6
View File
@@ -12,6 +12,8 @@ import (
"github.com/labstack/echo/v4"
)
var tagRegexp = regexp.MustCompile(`[^\s]?#([^\s#]+?) `)
func (s *Server) registerTagRoutes(g *echo.Group) {
g.GET("/tag", func(c echo.Context) error {
ctx := c.Request().Context()
@@ -47,13 +49,9 @@ func (s *Server) registerTagRoutes(g *echo.Group) {
tagMapSet := make(map[string]bool)
r := regexp.MustCompile(`#([^\s#]+?) `)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compile regexp").SetInternal(err)
}
for _, memo := range memoList {
for _, rawTag := range r.FindAllString(memo.Content, -1) {
tag := r.ReplaceAllString(rawTag, "$1")
for _, rawTag := range tagRegexp.FindAllString(memo.Content, -1) {
tag := tagRegexp.ReplaceAllString(rawTag, "$1")
tagMapSet[tag] = true
}
}
+3
View File
@@ -250,6 +250,9 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
ID: userID,
}
if err := s.Store.DeleteUser(ctx, userDelete); err != nil {
if common.ErrorCode(err) == common.NotFound {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("User ID not found: %d", userID))
}
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete user").SetInternal(err)
}
@@ -1,4 +1,4 @@
package common
package version
import (
"strconv"
@@ -7,10 +7,10 @@ import (
// Version is the service current released version.
// Semantic versioning: https://semver.org/
var Version = "0.4.2"
var Version = "0.5.0"
// DevVersion is the service current development version.
var DevVersion = "0.4.2"
var DevVersion = "0.5.0"
func GetCurrentVersion(mode string) string {
if mode == "dev" {
@@ -27,6 +27,12 @@ func GetMinorVersion(version string) string {
return versionList[0] + "." + versionList[1]
}
func GetSchemaVersion(version string) string {
minorVersion := GetMinorVersion(version)
return minorVersion + ".0"
}
// convSemanticVersionToInt converts version string to int.
func convSemanticVersionToInt(version string) int {
versionList := strings.Split(version, ".")
-44
View File
@@ -1,44 +0,0 @@
package server
import (
"fmt"
"html"
"net/http"
"strconv"
"github.com/usememos/memos/api"
"github.com/labstack/echo/v4"
)
func (s *Server) registerWebhookRoutes(g *echo.Group) {
g.GET("/test", func(c echo.Context) error {
return c.HTML(http.StatusOK, "<strong>Hello, World!</strong>")
})
g.GET("/r/:resourceId/:filename", func(c echo.Context) error {
ctx := c.Request().Context()
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)
}
filename := html.UnescapeString(c.Param("filename"))
resourceFind := &api.ResourceFind{
ID: &resourceID,
Filename: &filename,
}
resource, err := s.Store.FindResource(ctx, resourceFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to fetch resource ID: %v", resourceID)).SetInternal(err)
}
c.Response().Writer.WriteHeader(http.StatusOK)
c.Response().Writer.Header().Set("Content-Type", resource.Type)
if _, err := c.Response().Writer.Write(resource.Blob); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to write response").SetInternal(err)
}
return nil
})
}
+28 -16
View File
@@ -12,8 +12,8 @@ import (
"sort"
"time"
"github.com/usememos/memos/common"
"github.com/usememos/memos/server/profile"
"github.com/usememos/memos/server/version"
)
//go:embed migration
@@ -42,35 +42,35 @@ func (db *DB) Open(ctx context.Context) (err error) {
return fmt.Errorf("dsn required")
}
// Connect to the database.
sqlDB, err := sql.Open("sqlite3", db.profile.DSN)
// Connect to the database without foreign_keys config.
tempDB, err := sql.Open("sqlite3", db.profile.DSN)
if err != nil {
return fmt.Errorf("failed to open db with dsn: %s, err: %w", db.profile.DSN, err)
}
db.Db = sqlDB
db.Db = tempDB
// If mode is dev, we should migrate and seed the database.
if db.profile.Mode == "dev" {
if err := db.applyLatestSchema(ctx); err != nil {
return fmt.Errorf("failed to apply latest schema: %w", err)
}
if err := db.seed(ctx); err != nil {
return fmt.Errorf("failed to seed: %w", err)
if _, err := os.Stat(db.profile.DSN); errors.Is(err, os.ErrNotExist) {
if err := db.applyLatestSchema(ctx); err != nil {
return fmt.Errorf("failed to apply latest schema: %w", err)
}
if err := db.seed(ctx); err != nil {
return fmt.Errorf("failed to seed: %w", err)
}
}
} else {
// If db file not exists, we should migrate the database.
if _, err := os.Stat(db.profile.DSN); errors.Is(err, os.ErrNotExist) {
err := db.applyLatestSchema(ctx)
if err != nil {
if err := db.applyLatestSchema(ctx); err != nil {
return fmt.Errorf("failed to apply latest schema: %w", err)
}
} else {
err := db.createMigrationHistoryTable(ctx)
if err != nil {
if err := db.createMigrationHistoryTable(ctx); err != nil {
return fmt.Errorf("failed to create migration_history table: %w", err)
}
currentVersion := common.GetCurrentVersion(db.profile.Mode)
currentVersion := version.GetCurrentVersion(db.profile.Mode)
migrationHistory, err := db.FindMigrationHistory(ctx, &MigrationHistoryFind{})
if err != nil {
return err
@@ -84,7 +84,7 @@ func (db *DB) Open(ctx context.Context) (err error) {
}
}
if common.IsVersionGreaterThan(currentVersion, migrationHistory.Version) {
if version.IsVersionGreaterThan(version.GetSchemaVersion(currentVersion), migrationHistory.Version) {
minorVersionList := getMinorVersionList()
// backup the raw database file before migration
@@ -101,7 +101,7 @@ func (db *DB) Open(ctx context.Context) (err error) {
println("start migrate")
for _, minorVersion := range minorVersionList {
normalizedVersion := minorVersion + ".0"
if common.IsVersionGreaterThan(normalizedVersion, migrationHistory.Version) && common.IsVersionGreaterOrEqualThan(currentVersion, normalizedVersion) {
if version.IsVersionGreaterThan(normalizedVersion, migrationHistory.Version) && version.IsVersionGreaterOrEqualThan(currentVersion, normalizedVersion) {
println("applying migration for", normalizedVersion)
if err := db.applyMigrationForMinorVersion(ctx, minorVersion); err != nil {
return fmt.Errorf("failed to apply minor version migration: %w", err)
@@ -118,6 +118,18 @@ func (db *DB) Open(ctx context.Context) (err error) {
}
}
if err := tempDB.Close(); err != nil {
return fmt.Errorf("failed to close temp db without foreign_keys, err: %w", err)
}
// Connect to the database with foreign_keys config.
sqlDB, err := sql.Open("sqlite3", db.profile.DSN+"?_foreign_keys=1")
if err != nil {
return fmt.Errorf("failed to open db with dsn: %s, err: %w", db.profile.DSN, err)
}
db.Db = sqlDB
return err
}
+24 -4
View File
@@ -1,4 +1,6 @@
-- drop all tables
DROP TABLE IF EXISTS `system_setting`;
DROP TABLE IF EXISTS `memo_resource`;
DROP TABLE IF EXISTS `memo_organizer`;
DROP TABLE IF EXISTS `memo`;
DROP TABLE IF EXISTS `shortcut`;
@@ -11,7 +13,6 @@ 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',
@@ -118,7 +119,8 @@ CREATE TABLE resource (
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,
blob BLOB DEFAULT NULL,
external_link TEXT NOT NULL DEFAULT '',
type TEXT NOT NULL DEFAULT '',
size INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(creator_id) REFERENCES user(id) ON DELETE CASCADE
@@ -146,7 +148,25 @@ CREATE TABLE user_setting (
user_id INTEGER NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE
FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE,
UNIQUE(user_id, key)
);
CREATE UNIQUE INDEX user_setting_key_user_id_index ON user_setting(key, user_id);
-- memo_resource
CREATE TABLE memo_resource (
memo_id INTEGER NOT NULL,
resource_id INTEGER NOT NULL,
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
FOREIGN KEY(memo_id) REFERENCES memo(id) ON DELETE CASCADE,
FOREIGN KEY(resource_id) REFERENCES resource(id) ON DELETE CASCADE,
UNIQUE(memo_id, resource_id)
);
-- system_setting
CREATE TABLE system_setting (
name TEXT NOT NULL,
value TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
UNIQUE(name)
);
@@ -1 +1 @@
ALTER TABLE memo ADD visibility TEXT NOT NULL CHECK (visibility IN ('PUBLIC', 'PRIVATE')) DEFAULT 'PRIVATE';
ALTER TABLE memo ADD COLUMN visibility TEXT NOT NULL CHECK (visibility IN ('PUBLIC', 'PRIVATE')) DEFAULT 'PRIVATE';
@@ -0,0 +1,201 @@
DROP TABLE IF EXISTS _user_old;
ALTER TABLE user RENAME TO _user_old;
-- 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')),
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 user (
id, created_ts, updated_ts, row_status, email, role, name, password_hash, open_id
)
SELECT
id, created_ts, updated_ts, row_status, email, role, name, password_hash, open_id
FROM
_user_old;
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;
DROP TABLE IF EXISTS _user_old;
DROP TABLE IF EXISTS _memo_old;
ALTER TABLE memo RENAME TO _memo_old;
-- 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 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;
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;
DROP TABLE IF EXISTS _memo_old;
DROP TABLE IF EXISTS _memo_organizer_old;
ALTER TABLE memo_organizer RENAME TO _memo_organizer_old;
-- 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 memo_organizer (
id, memo_id, user_id, pinned
)
SELECT
id, memo_id, user_id, pinned
FROM
_memo_organizer_old;
DROP TABLE IF EXISTS _memo_organizer_old;
DROP TABLE IF EXISTS _shortcut_old;
ALTER TABLE shortcut RENAME TO _shortcut_old;
-- 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 shortcut (
id, creator_id, created_ts, updated_ts, row_status, title, payload
)
SELECT
id, creator_id, created_ts, updated_ts, row_status, title, payload
FROM
_shortcut_old;
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;
DROP TABLE IF EXISTS _shortcut_old;
DROP TABLE IF EXISTS _resource_old;
ALTER TABLE resource RENAME TO _resource_old;
-- 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 DEFAULT NULL,
type TEXT NOT NULL DEFAULT '',
size INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(creator_id) REFERENCES user(id) ON DELETE CASCADE
);
INSERT INTO resource (
id, creator_id, created_ts, updated_ts, filename, blob, type, size
)
SELECT
id, creator_id, created_ts, updated_ts, filename, blob, type, size
FROM
_resource_old;
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;
DROP TABLE IF EXISTS _resource_old;
DROP TABLE IF EXISTS _user_setting_old;
ALTER TABLE user_setting RENAME TO _user_setting_old;
-- user_setting
CREATE TABLE user_setting (
user_id INTEGER NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE,
UNIQUE(user_id, key)
);
INSERT INTO user_setting (
user_id, key, value
)
SELECT
user_id, key, value
FROM
_user_setting_old;
DROP TABLE IF EXISTS _user_setting_old;
@@ -0,0 +1,10 @@
-- memo_resource
CREATE TABLE memo_resource (
memo_id INTEGER NOT NULL,
resource_id INTEGER NOT NULL,
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
FOREIGN KEY(memo_id) REFERENCES memo(id) ON DELETE CASCADE,
FOREIGN KEY(resource_id) REFERENCES resource(id) ON DELETE CASCADE,
UNIQUE(memo_id, resource_id)
);
@@ -0,0 +1,7 @@
-- system_setting
CREATE TABLE system_setting (
name TEXT NOT NULL,
value TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
UNIQUE(name)
);
@@ -0,0 +1 @@
ALTER TABLE resource ADD COLUMN external_link TEXT NOT NULL DEFAULT '';
+24 -4
View File
@@ -1,4 +1,6 @@
-- drop all tables
DROP TABLE IF EXISTS `system_setting`;
DROP TABLE IF EXISTS `memo_resource`;
DROP TABLE IF EXISTS `memo_organizer`;
DROP TABLE IF EXISTS `memo`;
DROP TABLE IF EXISTS `shortcut`;
@@ -11,7 +13,6 @@ 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',
@@ -118,7 +119,8 @@ CREATE TABLE resource (
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,
blob BLOB DEFAULT NULL,
external_link TEXT NOT NULL DEFAULT '',
type TEXT NOT NULL DEFAULT '',
size INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(creator_id) REFERENCES user(id) ON DELETE CASCADE
@@ -146,7 +148,25 @@ CREATE TABLE user_setting (
user_id INTEGER NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE
FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE,
UNIQUE(user_id, key)
);
CREATE UNIQUE INDEX user_setting_key_user_id_index ON user_setting(key, user_id);
-- memo_resource
CREATE TABLE memo_resource (
memo_id INTEGER NOT NULL,
resource_id INTEGER NOT NULL,
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
FOREIGN KEY(memo_id) REFERENCES memo(id) ON DELETE CASCADE,
FOREIGN KEY(resource_id) REFERENCES resource(id) ON DELETE CASCADE,
UNIQUE(memo_id, resource_id)
);
-- system_setting
CREATE TABLE system_setting (
name TEXT NOT NULL,
value TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
UNIQUE(name)
);
+32 -18
View File
@@ -44,9 +44,7 @@ func (raw *memoRaw) toMemo() *api.Memo {
}
}
func (s *Store) composeMemo(ctx context.Context, raw *memoRaw) (*api.Memo, error) {
memo := raw.toMemo()
func (s *Store) ComposeMemo(ctx context.Context, memo *api.Memo) (*api.Memo, error) {
memoOrganizer, err := s.FindMemoOrganizer(ctx, &api.MemoOrganizerFind{
MemoID: memo.ID,
UserID: memo.CreatorID,
@@ -57,6 +55,13 @@ func (s *Store) composeMemo(ctx context.Context, raw *memoRaw) (*api.Memo, error
memo.Pinned = memoOrganizer.Pinned
}
if err = s.ComposeMemoCreator(ctx, memo); err != nil {
return nil, err
}
if err = s.ComposeMemoResourceList(ctx, memo); err != nil {
return nil, err
}
return memo, nil
}
@@ -76,12 +81,12 @@ func (s *Store) CreateMemo(ctx context.Context, create *api.MemoCreate) (*api.Me
return nil, FormatError(err)
}
memo, err := s.composeMemo(ctx, memoRaw)
if err != nil {
if err := s.cache.UpsertCache(api.MemoCache, memoRaw.ID, memoRaw); err != nil {
return nil, err
}
if err := s.cache.UpsertCache(api.MemoCache, memo.ID, memo); err != nil {
memo, err := s.ComposeMemo(ctx, memoRaw.toMemo())
if err != nil {
return nil, err
}
@@ -104,12 +109,12 @@ func (s *Store) PatchMemo(ctx context.Context, patch *api.MemoPatch) (*api.Memo,
return nil, FormatError(err)
}
memo, err := s.composeMemo(ctx, memoRaw)
if err != nil {
if err := s.cache.UpsertCache(api.MemoCache, memoRaw.ID, memoRaw); err != nil {
return nil, err
}
if err := s.cache.UpsertCache(api.MemoCache, memo.ID, memo); err != nil {
memo, err := s.ComposeMemo(ctx, memoRaw.toMemo())
if err != nil {
return nil, err
}
@@ -130,7 +135,7 @@ func (s *Store) FindMemoList(ctx context.Context, find *api.MemoFind) ([]*api.Me
list := []*api.Memo{}
for _, raw := range memoRawList {
memo, err := s.composeMemo(ctx, raw)
memo, err := s.ComposeMemo(ctx, raw.toMemo())
if err != nil {
return nil, err
}
@@ -143,12 +148,16 @@ func (s *Store) FindMemoList(ctx context.Context, find *api.MemoFind) ([]*api.Me
func (s *Store) FindMemo(ctx context.Context, find *api.MemoFind) (*api.Memo, error) {
if find.ID != nil {
memo := &api.Memo{}
has, err := s.cache.FindCache(api.MemoCache, *find.ID, memo)
memoRaw := &memoRaw{}
has, err := s.cache.FindCache(api.MemoCache, *find.ID, memoRaw)
if err != nil {
return nil, err
}
if has {
memo, err := s.ComposeMemo(ctx, memoRaw.toMemo())
if err != nil {
return nil, err
}
return memo, nil
}
}
@@ -168,12 +177,13 @@ func (s *Store) FindMemo(ctx context.Context, find *api.MemoFind) (*api.Memo, er
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")}
}
memo, err := s.composeMemo(ctx, list[0])
if err != nil {
memoRaw := list[0]
if err := s.cache.UpsertCache(api.MemoCache, memoRaw.ID, memoRaw); err != nil {
return nil, err
}
if err := s.cache.UpsertCache(api.MemoCache, memo.ID, memo); err != nil {
memo, err := s.ComposeMemo(ctx, memoRaw.toMemo())
if err != nil {
return nil, err
}
@@ -281,7 +291,7 @@ func findMemoRawList(ctx context.Context, tx *sql.Tx, find *api.MemoFind) ([]*me
where, args = append(where, "row_status = ?"), append(args, *v)
}
if v := find.Pinned; v != nil {
where = append(where, "id in (SELECT memo_id FROM memo_organizer WHERE pinned = 1 AND user_id = memo.creator_id )")
where = append(where, "id in (SELECT memo_id FROM memo_organizer WHERE pinned = 1 AND user_id = memo.creator_id)")
}
if v := find.ContentSearch; v != nil {
where, args = append(where, "content LIKE ?"), append(args, "%"+*v+"%")
@@ -348,13 +358,17 @@ func findMemoRawList(ctx context.Context, tx *sql.Tx, find *api.MemoFind) ([]*me
}
func deleteMemo(ctx context.Context, tx *sql.Tx, delete *api.MemoDelete) error {
_, err := tx.ExecContext(ctx, `
PRAGMA foreign_keys = ON;
result, err := tx.ExecContext(ctx, `
DELETE FROM memo 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("memo ID not found: %d", delete.ID)}
}
return nil
}
+188
View File
@@ -0,0 +1,188 @@
package store
import (
"context"
"database/sql"
"fmt"
"strings"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
)
// memoResourceRaw is the store model for an MemoResource.
// Fields have exactly the same meanings as MemoResource.
type memoResourceRaw struct {
MemoID int
ResourceID int
CreatedTs int64
UpdatedTs int64
}
func (raw *memoResourceRaw) toMemoResource() *api.MemoResource {
return &api.MemoResource{
MemoID: raw.MemoID,
ResourceID: raw.ResourceID,
CreatedTs: raw.CreatedTs,
UpdatedTs: raw.UpdatedTs,
}
}
func (s *Store) FindMemoResourceList(ctx context.Context, find *api.MemoResourceFind) ([]*api.MemoResource, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
memoResourceRawList, err := findMemoResourceList(ctx, tx, find)
if err != nil {
return nil, err
}
list := []*api.MemoResource{}
for _, raw := range memoResourceRawList {
memoResource := raw.toMemoResource()
list = append(list, memoResource)
}
return list, nil
}
func (s *Store) UpsertMemoResource(ctx context.Context, upsert *api.MemoResourceUpsert) (*api.MemoResource, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
memoResourceRaw, err := upsertMemoResource(ctx, tx, upsert)
if err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, FormatError(err)
}
return memoResourceRaw.toMemoResource(), nil
}
func (s *Store) DeleteMemoResource(ctx context.Context, delete *api.MemoResourceDelete) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return FormatError(err)
}
defer tx.Rollback()
if err := deleteMemoResource(ctx, tx, delete); err != nil {
return FormatError(err)
}
if err := tx.Commit(); err != nil {
return FormatError(err)
}
return nil
}
func findMemoResourceList(ctx context.Context, tx *sql.Tx, find *api.MemoResourceFind) ([]*memoResourceRaw, error) {
where, args := []string{"1 = 1"}, []interface{}{}
if v := find.MemoID; v != nil {
where, args = append(where, "memo_id = ?"), append(args, *v)
}
if v := find.ResourceID; v != nil {
where, args = append(where, "resource_id = ?"), append(args, *v)
}
query := `
SELECT
memo_id,
resource_id,
created_ts,
updated_ts
FROM memo_resource
WHERE ` + strings.Join(where, " AND ") + `
ORDER BY updated_ts DESC
`
rows, err := tx.QueryContext(ctx, query, args...)
if err != nil {
return nil, FormatError(err)
}
defer rows.Close()
memoResourceRawList := make([]*memoResourceRaw, 0)
for rows.Next() {
var memoResourceRaw memoResourceRaw
if err := rows.Scan(
&memoResourceRaw.MemoID,
&memoResourceRaw.ResourceID,
&memoResourceRaw.CreatedTs,
&memoResourceRaw.UpdatedTs,
); err != nil {
return nil, FormatError(err)
}
memoResourceRawList = append(memoResourceRawList, &memoResourceRaw)
}
if err := rows.Err(); err != nil {
return nil, err
}
return memoResourceRawList, nil
}
func upsertMemoResource(ctx context.Context, tx *sql.Tx, upsert *api.MemoResourceUpsert) (*memoResourceRaw, error) {
set := []string{"memo_id", "resource_id"}
args := []interface{}{upsert.MemoID, upsert.ResourceID}
placeholder := []string{"?", "?"}
if v := upsert.UpdatedTs; v != nil {
set, args, placeholder = append(set, "updated_ts"), append(args, v), append(placeholder, "?")
}
query := `
INSERT INTO memo_resource (
` + strings.Join(set, ", ") + `
)
VALUES (` + strings.Join(placeholder, ",") + `)
ON CONFLICT(memo_id, resource_id) DO UPDATE
SET
updated_ts = EXCLUDED.updated_ts
RETURNING memo_id, resource_id, created_ts, updated_ts
`
var memoResourceRaw memoResourceRaw
if err := tx.QueryRowContext(ctx, query, args...).Scan(
&memoResourceRaw.MemoID,
&memoResourceRaw.ResourceID,
&memoResourceRaw.CreatedTs,
&memoResourceRaw.UpdatedTs,
); err != nil {
return nil, FormatError(err)
}
return &memoResourceRaw, nil
}
func deleteMemoResource(ctx context.Context, tx *sql.Tx, delete *api.MemoResourceDelete) error {
where, args := []string{"memo_id = ?"}, []interface{}{delete.MemoID}
if v := delete.ResourceID; v != nil {
where, args = append(where, "resource_id = ?"), append(args, *v)
}
result, err := tx.ExecContext(ctx, `
DELETE FROM memo_resource WHERE `+strings.Join(where, " AND "), args...)
if err != nil {
return FormatError(err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return &common.Error{Code: common.NotFound, Err: fmt.Errorf("memo resource not found")}
}
return nil
}
+38 -11
View File
@@ -44,6 +44,19 @@ func (raw *resourceRaw) toResource() *api.Resource {
}
}
func (s *Store) ComposeMemoResourceList(ctx context.Context, memo *api.Memo) error {
resourceList, err := s.FindResourceList(ctx, &api.ResourceFind{
MemoID: &memo.ID,
})
if err != nil {
return err
}
memo.ResourceList = resourceList
return nil
}
func (s *Store) CreateResource(ctx context.Context, create *api.ResourceCreate) (*api.Resource, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
@@ -60,12 +73,12 @@ func (s *Store) CreateResource(ctx context.Context, create *api.ResourceCreate)
return nil, FormatError(err)
}
resource := resourceRaw.toResource()
if err := s.cache.UpsertCache(api.ResourceCache, resource.ID, resource); err != nil {
if err := s.cache.UpsertCache(api.ResourceCache, resourceRaw.ID, resourceRaw); err != nil {
return nil, err
}
resource := resourceRaw.toResource()
return resource, nil
}
@@ -91,13 +104,13 @@ func (s *Store) FindResourceList(ctx context.Context, find *api.ResourceFind) ([
func (s *Store) FindResource(ctx context.Context, find *api.ResourceFind) (*api.Resource, error) {
if find.ID != nil {
resource := &api.Resource{}
has, err := s.cache.FindCache(api.ResourceCache, *find.ID, resource)
resourceRaw := &resourceRaw{}
has, err := s.cache.FindCache(api.ResourceCache, *find.ID, resourceRaw)
if err != nil {
return nil, err
}
if has {
return resource, nil
return resourceRaw.toResource(), nil
}
}
@@ -116,12 +129,14 @@ func (s *Store) FindResource(ctx context.Context, find *api.ResourceFind) (*api.
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")}
}
resource := list[0].toResource()
resourceRaw := list[0]
if err := s.cache.UpsertCache(api.ResourceCache, resource.ID, resource); err != nil {
if err := s.cache.UpsertCache(api.ResourceCache, resourceRaw.ID, resourceRaw); err != nil {
return nil, err
}
resource := resourceRaw.toResource()
return resource, nil
}
@@ -141,6 +156,11 @@ func (s *Store) DeleteResource(ctx context.Context, delete *api.ResourceDelete)
return FormatError(err)
}
// Vacuum sqlite database file size after deleting resource.
if _, err := s.db.Exec("VACUUM;"); err != nil {
return err
}
s.cache.DeleteCache(api.ResourceCache, delete.ID)
return nil
@@ -187,6 +207,9 @@ func findResourceList(ctx context.Context, tx *sql.Tx, find *api.ResourceFind) (
if v := find.Filename; v != nil {
where, args = append(where, "filename = ?"), append(args, *v)
}
if v := find.MemoID; v != nil {
where, args = append(where, "id in (SELECT resource_id FROM memo_resource WHERE memo_id = ?)"), append(args, *v)
}
query := `
SELECT
@@ -200,7 +223,7 @@ func findResourceList(ctx context.Context, tx *sql.Tx, find *api.ResourceFind) (
updated_ts
FROM resource
WHERE ` + strings.Join(where, " AND ") + `
ORDER BY created_ts DESC
ORDER BY id DESC
`
rows, err := tx.QueryContext(ctx, query, args...)
if err != nil {
@@ -235,13 +258,17 @@ func findResourceList(ctx context.Context, tx *sql.Tx, find *api.ResourceFind) (
}
func deleteResource(ctx context.Context, tx *sql.Tx, delete *api.ResourceDelete) error {
_, err := tx.ExecContext(ctx, `
PRAGMA foreign_keys = ON;
result, err := tx.ExecContext(ctx, `
DELETE FROM resource WHERE id = ? AND creator_id = ?
`, delete.ID, delete.CreatorID)
if err != nil {
return FormatError(err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return &common.Error{Code: common.NotFound, Err: fmt.Errorf("resource ID not found: %d", delete.ID)}
}
return nil
}
+19 -13
View File
@@ -56,12 +56,12 @@ func (s *Store) CreateShortcut(ctx context.Context, create *api.ShortcutCreate)
return nil, FormatError(err)
}
shortcut := shortcutRaw.toShortcut()
if err := s.cache.UpsertCache(api.ShortcutCache, shortcut.ID, shortcut); err != nil {
if err := s.cache.UpsertCache(api.ShortcutCache, shortcutRaw.ID, shortcutRaw); err != nil {
return nil, err
}
shortcut := shortcutRaw.toShortcut()
return shortcut, nil
}
@@ -81,12 +81,12 @@ func (s *Store) PatchShortcut(ctx context.Context, patch *api.ShortcutPatch) (*a
return nil, FormatError(err)
}
shortcut := shortcutRaw.toShortcut()
if err := s.cache.UpsertCache(api.ShortcutCache, shortcut.ID, shortcut); err != nil {
if err := s.cache.UpsertCache(api.ShortcutCache, shortcutRaw.ID, shortcutRaw); err != nil {
return nil, err
}
shortcut := shortcutRaw.toShortcut()
return shortcut, nil
}
@@ -112,13 +112,13 @@ func (s *Store) FindShortcutList(ctx context.Context, find *api.ShortcutFind) ([
func (s *Store) FindShortcut(ctx context.Context, find *api.ShortcutFind) (*api.Shortcut, error) {
if find.ID != nil {
shortcut := &api.Shortcut{}
has, err := s.cache.FindCache(api.ShortcutCache, *find.ID, shortcut)
shortcutRaw := &shortcutRaw{}
has, err := s.cache.FindCache(api.ShortcutCache, *find.ID, shortcutRaw)
if err != nil {
return nil, err
}
if has {
return shortcut, nil
return shortcutRaw.toShortcut(), nil
}
}
@@ -137,12 +137,14 @@ func (s *Store) FindShortcut(ctx context.Context, find *api.ShortcutFind) (*api.
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")}
}
shortcut := list[0].toShortcut()
shortcutRaw := list[0]
if err := s.cache.UpsertCache(api.ShortcutCache, shortcut.ID, shortcut); err != nil {
if err := s.cache.UpsertCache(api.ShortcutCache, shortcutRaw.ID, shortcutRaw); err != nil {
return nil, err
}
shortcut := shortcutRaw.toShortcut()
return shortcut, nil
}
@@ -287,13 +289,17 @@ func findShortcutList(ctx context.Context, tx *sql.Tx, find *api.ShortcutFind) (
}
func deleteShortcut(ctx context.Context, tx *sql.Tx, delete *api.ShortcutDelete) error {
_, err := tx.ExecContext(ctx, `
PRAGMA foreign_keys = ON;
result, err := tx.ExecContext(ctx, `
DELETE FROM shortcut 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("shortcut ID not found: %d", delete.ID)}
}
return nil
}
+38 -16
View File
@@ -44,6 +44,21 @@ func (raw *userRaw) toUser() *api.User {
}
}
func (s *Store) ComposeMemoCreator(ctx context.Context, memo *api.Memo) error {
user, err := s.FindUser(ctx, &api.UserFind{
ID: &memo.CreatorID,
})
if err != nil {
return err
}
user.OpenID = ""
user.UserSettingList = nil
memo.Creator = user
return nil
}
func (s *Store) CreateUser(ctx context.Context, create *api.UserCreate) (*api.User, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
@@ -60,12 +75,12 @@ func (s *Store) CreateUser(ctx context.Context, create *api.UserCreate) (*api.Us
return nil, FormatError(err)
}
user := userRaw.toUser()
if err := s.cache.UpsertCache(api.UserCache, user.ID, user); err != nil {
if err := s.cache.UpsertCache(api.UserCache, userRaw.ID, userRaw); err != nil {
return nil, err
}
user := userRaw.toUser()
return user, nil
}
@@ -85,12 +100,12 @@ func (s *Store) PatchUser(ctx context.Context, patch *api.UserPatch) (*api.User,
return nil, FormatError(err)
}
user := userRaw.toUser()
if err := s.cache.UpsertCache(api.UserCache, user.ID, user); err != nil {
if err := s.cache.UpsertCache(api.UserCache, userRaw.ID, userRaw); err != nil {
return nil, err
}
user := userRaw.toUser()
return user, nil
}
@@ -116,13 +131,13 @@ func (s *Store) FindUserList(ctx context.Context, find *api.UserFind) ([]*api.Us
func (s *Store) FindUser(ctx context.Context, find *api.UserFind) (*api.User, error) {
if find.ID != nil {
user := &api.User{}
has, err := s.cache.FindCache(api.UserCache, *find.ID, user)
userRaw := &userRaw{}
has, err := s.cache.FindCache(api.UserCache, *find.ID, userRaw)
if err != nil {
return nil, err
}
if has {
return user, nil
return userRaw.toUser(), nil
}
}
@@ -138,17 +153,19 @@ func (s *Store) FindUser(ctx context.Context, find *api.UserFind) (*api.User, er
}
if len(list) == 0 {
return nil, nil
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found user with filter %+v", find)}
} else if len(list) > 1 {
return nil, &common.Error{Code: common.Conflict, Err: fmt.Errorf("found %d users with filter %+v, expect 1. ", len(list), find)}
return nil, &common.Error{Code: common.Conflict, Err: fmt.Errorf("found %d users with filter %+v, expect 1", len(list), find)}
}
user := list[0].toUser()
userRaw := list[0]
if err := s.cache.UpsertCache(api.UserCache, user.ID, user); err != nil {
if err := s.cache.UpsertCache(api.UserCache, userRaw.ID, userRaw); err != nil {
return nil, err
}
user := userRaw.toUser()
return user, nil
}
@@ -336,12 +353,17 @@ func findUserList(ctx context.Context, tx *sql.Tx, find *api.UserFind) ([]*userR
}
func deleteUser(ctx context.Context, tx *sql.Tx, delete *api.UserDelete) error {
if _, err := tx.ExecContext(ctx, `
PRAGMA foreign_keys = ON;
result, err := tx.ExecContext(ctx, `
DELETE FROM user WHERE id = ?
`, delete.ID); err != nil {
`, 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
}
-1
View File
@@ -21,7 +21,6 @@
"endOfLine": "auto"
}
],
"@typescript-eslint/no-empty-interface": ["off"],
"@typescript-eslint/no-explicit-any": ["off"],
"react/react-in-jsx-scope": "off"
},
+1
View File
@@ -12,6 +12,7 @@
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script>
var global = global || window;
window.addEventListener("load", () => {
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js");
+6
View File
@@ -0,0 +1,6 @@
/* eslint-disable no-undef */
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
};
+15 -7
View File
@@ -1,29 +1,35 @@
{
"name": "memos",
"version": "0.4.2",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"serve": "vite preview",
"lint": "eslint --ext .js,.ts,.tsx, src"
"lint": "eslint --ext .js,.ts,.tsx, src",
"test": "jest --passWithNoTests"
},
"dependencies": {
"@reduxjs/toolkit": "^1.8.1",
"axios": "^0.27.2",
"copy-to-clipboard": "^3.3.2",
"dayjs": "^1.11.3",
"emoji-picker-react": "^3.6.2",
"i18next": "^21.9.2",
"lodash-es": "^4.17.21",
"qs": "^6.11.0",
"react": "^18.1.0",
"react-dom": "^18.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-feather": "^2.0.10",
"react-redux": "^8.0.1"
"react-i18next": "^11.18.6",
"react-redux": "^8.0.1",
"react-router-dom": "^6.4.0"
},
"devDependencies": {
"@jest/globals": "^29.1.2",
"@types/lodash-es": "^4.17.5",
"@types/node": "^18.0.3",
"@types/qs": "^6.9.7",
"@types/react": "^18.0.9",
"@types/react-dom": "^18.0.4",
"@types/react": "^18.0.21",
"@types/react-dom": "^18.0.6",
"@typescript-eslint/eslint-plugin": "^5.6.0",
"@typescript-eslint/parser": "^5.6.0",
"@vitejs/plugin-react": "^2.0.0",
@@ -32,10 +38,12 @@
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-react": "^7.27.1",
"jest": "^29.1.2",
"less": "^4.1.1",
"postcss": "^8.4.5",
"prettier": "2.5.1",
"tailwindcss": "^3.0.18",
"ts-jest": "^29.0.3",
"typescript": "^4.3.2",
"vite": "^3.0.0"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 63 KiB

+5 -5
View File
@@ -1,17 +1,17 @@
{
"short_name": "Memos",
"name": "Memos",
"description": "usememos/memos",
"icons": [
{
"src": "/logo.webp",
"type": "image/*",
"sizes": "200x200"
"type": "image/webp",
"sizes": "520x520"
}
],
"start_url": "/",
"background_color": "#f6f5f4",
"display": "standalone",
"scope": "/",
"display": "standalone",
"theme_color": "#f6f5f4",
"description": "usememos/memos"
"background_color": "#f6f5f4"
}
+9 -18
View File
@@ -1,41 +1,32 @@
import { useEffect, useState } from "react";
import useI18n from "./hooks/useI18n";
import { appRouterSwitch } from "./routers";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { RouterProvider } from "react-router-dom";
import { globalService, locationService } from "./services";
import { useAppSelector } from "./store";
import router from "./router";
import * as storage from "./helpers/storage";
function App() {
const { setLocale } = useI18n();
const user = useAppSelector((state) => state.user.user);
const { i18n } = useTranslation();
const global = useAppSelector((state) => state.global);
const pathname = useAppSelector((state) => state.location.pathname);
const [isLoading, setLoading] = useState(true);
useEffect(() => {
locationService.updateStateWithLocation();
window.onpopstate = () => {
locationService.updateStateWithLocation();
};
globalService.initialState().then(() => {
setLoading(false);
});
globalService.initialState();
}, []);
useEffect(() => {
if (user?.setting.locale) {
globalService.setLocale(user.setting.locale);
}
}, [user?.setting.locale]);
useEffect(() => {
setLocale(global.locale);
i18n.changeLanguage(global.locale);
storage.set({
locale: global.locale,
});
}, [global.locale]);
return <>{isLoading ? null : appRouterSwitch(pathname)}</>;
return <RouterProvider router={router} />;
}
export default App;
+5 -6
View File
@@ -1,16 +1,15 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import * as api from "../helpers/api";
import useI18n from "../hooks/useI18n";
import Only from "./common/OnlyWhen";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import GitHubBadge from "./GitHubBadge";
import "../less/about-site-dialog.less";
interface Props extends DialogProps {}
type Props = DialogProps;
const AboutSiteDialog: React.FC<Props> = ({ destroy }: Props) => {
const { t } = useI18n();
const { t } = useTranslation();
const [profile, setProfile] = useState<Profile>();
useEffect(() => {
@@ -50,7 +49,7 @@ const AboutSiteDialog: React.FC<Props> = ({ destroy }: Props) => {
<br />
<div className="addtion-info-container">
<GitHubBadge />
<Only when={profile !== undefined}>
{profile !== undefined && (
<>
{t("common.version")}:
<span className="pre-text">
@@ -58,7 +57,7 @@ const AboutSiteDialog: React.FC<Props> = ({ destroy }: Props) => {
</span>
🎉
</>
</Only>
)}
</div>
</div>
</>
+13 -19
View File
@@ -1,12 +1,10 @@
import { IMAGE_URL_REG } from "../helpers/marked";
import { useTranslation } from "react-i18next";
import * as utils from "../helpers/utils";
import useI18n from "../hooks/useI18n";
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 MemoContent from "./MemoContent";
import MemoResources from "./MemoResources";
import "../less/memo.less";
interface Props {
@@ -20,15 +18,15 @@ const ArchivedMemo: React.FC<Props> = (props: Props) => {
createdAtStr: utils.getDateTimeString(propsMemo.createdTs),
archivedAtStr: utils.getDateTimeString(propsMemo.updatedTs ?? Date.now()),
};
const { t } = useI18n();
const { t } = useTranslation();
const [showConfirmDeleteBtn, toggleConfirmDeleteBtn] = useToggle(false);
const imageUrls = Array.from(memo.content.match(IMAGE_URL_REG) ?? []).map((s) => s.replace(IMAGE_URL_REG, "$1"));
const handleDeleteMemoClick = async () => {
if (showConfirmDeleteBtn) {
try {
await memoService.deleteMemoById(memo.id);
await memoService.fetchAllMemos();
await memoService.fetchMemos();
} catch (error: any) {
console.error(error);
toastHelper.error(error.response.data.message);
@@ -44,8 +42,8 @@ const ArchivedMemo: React.FC<Props> = (props: Props) => {
id: memo.id,
rowStatus: "NORMAL",
});
await memoService.fetchAllMemos();
toastHelper.info("Restored successfully");
await memoService.fetchMemos();
toastHelper.info(t("message.restored-successfully"));
} catch (error: any) {
console.error(error);
toastHelper.error(error.response.data.message);
@@ -61,7 +59,9 @@ const ArchivedMemo: React.FC<Props> = (props: Props) => {
return (
<div className={`memo-wrapper archived-memo ${"memos-" + memo.id}`} onMouseLeave={handleMouseLeaveMemoWrapper}>
<div className="memo-top-wrapper">
<span className="time-text">Archived at {memo.archivedAtStr}</span>
<span className="time-text">
{t("common.archived-at")} {memo.archivedAtStr}
</span>
<div className="btns-container">
<span className="btn restore-btn" onClick={handleRestoreMemoClick}>
{t("common.restore")}
@@ -72,14 +72,8 @@ const ArchivedMemo: React.FC<Props> = (props: Props) => {
</span>
</div>
</div>
<div className="memo-content-text" dangerouslySetInnerHTML={{ __html: formatMemoContent(memo.content) }}></div>
<Only when={imageUrls.length > 0}>
<div className="images-wrapper">
{imageUrls.map((imgUrl, idx) => (
<Image className="memo-img" key={idx} imgUrl={imgUrl} />
))}
</div>
</Only>
<MemoContent content={memo.content} />
<MemoResources memo={memo} />
</div>
);
};
+6 -4
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import useLoading from "../hooks/useLoading";
import { memoService } from "../services";
import { useAppSelector } from "../store";
@@ -8,9 +9,10 @@ import toastHelper from "./Toast";
import ArchivedMemo from "./ArchivedMemo";
import "../less/archived-memo-dialog.less";
interface Props extends DialogProps {}
type Props = DialogProps;
const ArchivedMemoDialog: React.FC<Props> = (props: Props) => {
const { t } = useTranslation();
const { destroy } = props;
const memos = useAppSelector((state) => state.memo.memos);
const loadingState = useLoading();
@@ -36,7 +38,7 @@ const ArchivedMemoDialog: React.FC<Props> = (props: Props) => {
<div className="dialog-header-container">
<p className="title-text">
<span className="icon-text">🗂</span>
Archived Memos
{t("archived.archived-memos")}
</p>
<button className="btn close-btn" onClick={destroy}>
<Icon.X className="icon-img" />
@@ -45,11 +47,11 @@ const ArchivedMemoDialog: React.FC<Props> = (props: Props) => {
<div className="dialog-content-container">
{loadingState.isLoading ? (
<div className="tip-text-container">
<p className="tip-text">fetching data...</p>
<p className="tip-text">{t("archived.fetching-data")}</p>
</div>
) : archivedMemos.length === 0 ? (
<div className="tip-text-container">
<p className="tip-text">No archived memos.</p>
<p className="tip-text">{t("archived.no-archived-memos")}</p>
</div>
) : (
<div className="archived-memos-container">
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import dayjs from "dayjs";
import useI18n from "../hooks/useI18n";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { memoService } from "../services";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
@@ -12,7 +12,7 @@ interface Props extends DialogProps {
}
const ChangeMemoCreatedTsDialog: React.FC<Props> = (props: Props) => {
const { t } = useI18n();
const { t } = useTranslation();
const { destroy, memoId } = props;
const [createdAt, setCreatedAt] = useState("");
const maxDatetimeValue = dayjs().format("YYYY-MM-DDTHH:mm");
@@ -23,7 +23,7 @@ const ChangeMemoCreatedTsDialog: React.FC<Props> = (props: Props) => {
const datetime = dayjs(memo.createdTs).format("YYYY-MM-DDTHH:mm");
setCreatedAt(datetime);
} else {
toastHelper.error("Memo not found.");
toastHelper.error(t("message.memo-not-found"));
destroy();
}
}, []);
@@ -42,7 +42,7 @@ const ChangeMemoCreatedTsDialog: React.FC<Props> = (props: Props) => {
const createdTs = dayjs(createdAt).unix();
if (createdTs > nowTs) {
toastHelper.error("Invalid created datetime.");
toastHelper.error(t("message.invalid-created-datetime"));
return;
}
@@ -51,7 +51,7 @@ const ChangeMemoCreatedTsDialog: React.FC<Props> = (props: Props) => {
id: memoId,
createdTs,
});
toastHelper.info("Memo created datetime changed.");
toastHelper.info(t("message.memo-updated-datetime"));
handleCloseBtnClick();
} catch (error: any) {
console.error(error);
@@ -62,7 +62,7 @@ const ChangeMemoCreatedTsDialog: React.FC<Props> = (props: Props) => {
return (
<>
<div className="dialog-header-container">
<p className="title-text">Change memo created time</p>
<p className="title-text">{t("message.change-memo-created-time")}</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<Icon.X />
</button>
+15 -10
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { validate, ValidatorConfig } from "../helpers/validator";
import useI18n from "../hooks/useI18n";
import { userService } from "../services";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
@@ -14,10 +14,10 @@ const validateConfig: ValidatorConfig = {
noChinese: true,
};
interface Props extends DialogProps {}
type Props = DialogProps;
const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => {
const { t } = useI18n();
const { t } = useTranslation();
const [newPassword, setNewPassword] = useState("");
const [newPasswordAgain, setNewPasswordAgain] = useState("");
@@ -41,19 +41,19 @@ const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => {
const handleSaveBtnClick = async () => {
if (newPassword === "" || newPasswordAgain === "") {
toastHelper.error("Please fill in all fields.");
toastHelper.error(t("message.fill-all"));
return;
}
if (newPassword !== newPasswordAgain) {
toastHelper.error("New passwords do not match.");
toastHelper.error(t("message.new-password-not-match"));
setNewPasswordAgain("");
return;
}
const passwordValidResult = validate(newPassword, validateConfig);
if (!passwordValidResult.result) {
toastHelper.error("Password " + passwordValidResult.reason);
toastHelper.error(`${t("common.password")} ${passwordValidResult.reason}`);
return;
}
@@ -63,7 +63,7 @@ const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => {
id: user.id,
password: newPassword,
});
toastHelper.info("Password changed.");
toastHelper.info(t("message.password-changed"));
handleCloseBtnClick();
} catch (error: any) {
console.error(error);
@@ -74,17 +74,22 @@ const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => {
return (
<>
<div className="dialog-header-container">
<p className="title-text">Change Password</p>
<p className="title-text">{t("setting.account-section.change-password")}</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<Icon.X />
</button>
</div>
<div className="dialog-content-container">
<label className="form-label input-form-label">
<input type="password" placeholder="New passworld" value={newPassword} onChange={handleNewPasswordChanged} />
<input type="password" placeholder={t("common.new-password")} value={newPassword} onChange={handleNewPasswordChanged} />
</label>
<label className="form-label input-form-label">
<input type="password" placeholder="Repeat the new password" value={newPasswordAgain} onChange={handleNewPasswordAgainChanged} />
<input
type="password"
placeholder={t("common.repeat-new-password")}
value={newPasswordAgain}
onChange={handleNewPasswordAgainChanged}
/>
</label>
<div className="btns-container">
<span className="btn cancel-btn" onClick={handleCloseBtnClick}>
+17 -10
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { memoService, shortcutService } from "../services";
import { checkShouldShowMemoWithFilters, filterConsts, getDefaultFilter, relationConsts } from "../helpers/filter";
import useLoading from "../hooks/useLoading";
@@ -14,10 +15,10 @@ interface Props extends DialogProps {
const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
const { destroy, shortcutId } = props;
const [title, setTitle] = useState<string>("");
const [filters, setFilters] = useState<Filter[]>([]);
const requestState = useLoading(false);
const { t } = useTranslation();
const shownMemoLength = memoService.getState().memos.filter((memo) => {
return checkShouldShowMemoWithFilters(memo, filters);
@@ -43,7 +44,7 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
const handleSaveBtnClick = async () => {
if (!title) {
toastHelper.error("Title is required");
toastHelper.error(t("shortcut-list.title-required"));
return;
}
@@ -71,7 +72,7 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
if (filters.length > 0) {
const lastFilter = filters[filters.length - 1];
if (lastFilter.value.value === "") {
toastHelper.info("Please fill in previous filter value");
toastHelper.info(t("shortcut-list.fill-previous"));
return;
}
}
@@ -99,7 +100,7 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
<div className="dialog-header-container">
<p className="title-text">
<span className="icon-text">🚀</span>
{shortcutId ? "Edit Shortcut" : "Create Shortcut"}
{shortcutId ? t("shortcut-list.edit-shortcut") : t("shortcut-list.create-shortcut")}
</p>
<button className="btn close-btn" onClick={destroy}>
<Icon.X />
@@ -107,11 +108,17 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
</div>
<div className="dialog-content-container">
<div className="form-item-container input-form-container">
<span className="normal-text">Title</span>
<input className="title-input" type="text" placeholder="shortcut title" value={title} onChange={handleTitleInputChange} />
<span className="normal-text">{t("common.title")}</span>
<input
className="title-input"
type="text"
placeholder={t("shortcut-list.shortcut-title")}
value={title}
onChange={handleTitleInputChange}
/>
</div>
<div className="form-item-container filter-form-container">
<span className="normal-text">Filters</span>
<span className="normal-text">{t("common.filter")}</span>
<div className="filters-wrapper">
{filters.map((f, index) => {
return (
@@ -125,7 +132,7 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
);
})}
<div className="create-filter-btn" onClick={handleAddFilterBenClick}>
New Filter
{t("shortcut-list.new-filter")}
</div>
</div>
</div>
@@ -134,10 +141,10 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
<div></div>
<div className="btns-container">
<span className={`tip-text ${filters.length === 0 && "hidden"}`}>
<strong>{shownMemoLength}</strong> eligible memo
<strong>{shownMemoLength}</strong> {t("shortcut-list.eligible-memo")}
</span>
<button className={`btn save-btn ${requestState.isLoading ? "requesting" : ""}`} onClick={handleSaveBtnClick}>
Save
{t("common.save")}
</button>
</div>
</div>
+8 -10
View File
@@ -1,5 +1,6 @@
import * as utils from "../helpers/utils";
import { formatMemoContent } from "../helpers/marked";
import MemoContent, { DisplayConfig } from "./MemoContent";
import MemoResources from "./MemoResources";
import "../less/daily-memo.less";
interface DailyMemo extends Memo {
@@ -18,21 +19,18 @@ const DailyMemo: React.FC<Props> = (props: Props) => {
createdAtStr: utils.getDateTimeString(propsMemo.createdTs),
timeStr: utils.getTimeString(propsMemo.createdTs),
};
const displayConfig: DisplayConfig = {
enableExpand: false,
};
return (
<div className="daily-memo-wrapper">
<div className="time-wrapper">
<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-container">
<MemoContent content={memo.content} displayConfig={displayConfig} />
<MemoResources memo={memo} />
</div>
<div className="split-line"></div>
</div>
+3 -3
View File
@@ -1,7 +1,7 @@
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAppSelector } from "../store";
import toImage from "../labs/html2image";
import useI18n from "../hooks/useI18n";
import useToggle from "../hooks/useToggle";
import { DAILY_TIMESTAMP } from "../helpers/consts";
import * as utils from "../helpers/utils";
@@ -20,7 +20,7 @@ const monthChineseStrArray = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "
const weekdayChineseStrArray = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const DailyReviewDialog: React.FC<Props> = (props: Props) => {
const { t } = useI18n();
const { t } = useTranslation();
const memos = useAppSelector((state) => state.memo.memos);
const [currentDateStamp, setCurrentDateStamp] = useState(utils.getDateStampByDate(utils.getDateString(props.currentDateStamp)));
const [showDatePicker, toggleShowDatePicker] = useToggle(false);
@@ -97,7 +97,7 @@ const DailyReviewDialog: React.FC<Props> = (props: Props) => {
</div>
{dailyMemos.length === 0 ? (
<div className="tip-container">
<p className="tip-text">Oops, there is nothing.</p>
<p className="tip-text">{t("daily-review.oops-nothing")}</p>
</div>
) : (
<div className="dailymemos-wrapper">
+5 -50
View File
@@ -1,11 +1,8 @@
import { forwardRef, ReactNode, useCallback, useEffect, useImperativeHandle, useRef } from "react";
import useI18n from "../../hooks/useI18n";
import useRefresh from "../../hooks/useRefresh";
import Only from "../common/OnlyWhen";
import "../../less/editor.less";
export interface EditorRefActions {
element: HTMLTextAreaElement;
focus: FunctionType;
insertText: (text: string) => void;
setContent: (text: string) => void;
@@ -13,29 +10,19 @@ export interface EditorRefActions {
getCursorPosition: () => number;
}
interface EditorProps {
interface Props {
className: string;
initialContent: string;
placeholder: string;
fullscreen: boolean;
showConfirmBtn: boolean;
tools?: ReactNode;
onConfirmBtnClick: (content: string) => void;
onPaste: (event: React.ClipboardEvent) => void;
onContentChange: (content: string) => void;
}
// eslint-disable-next-line react/display-name
const Editor = forwardRef((props: EditorProps, ref: React.ForwardedRef<EditorRefActions>) => {
const {
className,
initialContent,
placeholder,
fullscreen,
showConfirmBtn,
onConfirmBtnClick: handleConfirmBtnClickCallback,
onContentChange: handleContentChangeCallback,
} = props;
const { t } = useI18n();
const Editor = forwardRef((props: Props, ref: React.ForwardedRef<EditorRefActions>) => {
const { className, initialContent, placeholder, fullscreen, onPaste, onContentChange: handleContentChangeCallback } = props;
const editorRef = useRef<HTMLTextAreaElement>(null);
const refresh = useRefresh();
@@ -55,7 +42,6 @@ const Editor = forwardRef((props: EditorProps, ref: React.ForwardedRef<EditorRef
useImperativeHandle(
ref,
() => ({
element: editorRef.current as HTMLTextAreaElement,
focus: () => {
editorRef.current?.focus();
},
@@ -95,25 +81,6 @@ const Editor = forwardRef((props: EditorProps, ref: React.ForwardedRef<EditorRef
refresh();
}, []);
const handleEditorKeyDown = useCallback((event: React.KeyboardEvent<HTMLTextAreaElement>) => {
event.stopPropagation();
if (event.code === "Enter") {
if (event.metaKey || event.ctrlKey) {
handleCommonConfirmBtnClick();
}
}
}, []);
const handleCommonConfirmBtnClick = useCallback(() => {
if (!editorRef.current) {
return;
}
handleConfirmBtnClickCallback(editorRef.current.value);
editorRef.current.value = "";
}, []);
return (
<div className={"common-editor-wrapper " + className}>
<textarea
@@ -121,21 +88,9 @@ const Editor = forwardRef((props: EditorProps, ref: React.ForwardedRef<EditorRef
rows={1}
placeholder={placeholder}
ref={editorRef}
onPaste={onPaste}
onInput={handleEditorInput}
onKeyDown={handleEditorKeyDown}
></textarea>
<div className="common-tools-wrapper">
<div className="common-tools-container">
<Only when={props.tools !== undefined}>{props.tools}</Only>
</div>
<div className="btns-container">
<Only when={showConfirmBtn}>
<button className="action-btn confirm-btn" disabled={editorRef.current?.value === ""} onClick={handleCommonConfirmBtnClick}>
{t("editor.save")} <span className="icon-text"></span>
</button>
</Only>
</div>
</div>
</div>
);
});
+38
View File
@@ -0,0 +1,38 @@
import Picker, { IEmojiPickerProps } from "emoji-picker-react";
import { useEffect } from "react";
interface Props {
shouldShow: boolean;
onEmojiClick: IEmojiPickerProps["onEmojiClick"];
onShouldShowEmojiPickerChange: (status: boolean) => void;
}
export const EmojiPicker: React.FC<Props> = (props: Props) => {
const { shouldShow, onEmojiClick, onShouldShowEmojiPickerChange } = props;
useEffect(() => {
if (shouldShow) {
const handleClickOutside = (event: MouseEvent) => {
event.stopPropagation();
const emojiWrapper = document.querySelector(".emoji-picker-react");
const isContains = emojiWrapper?.contains(event.target as Node);
if (!isContains) {
onShouldShowEmojiPickerChange(false);
}
};
window.addEventListener("click", handleClickOutside, {
capture: true,
once: true,
});
}
}, [shouldShow]);
return (
<div className={`emoji-picker ${shouldShow ? "" : "hidden"}`}>
<Picker onEmojiClick={onEmojiClick} disableSearchBar />
</div>
);
};
export default EmojiPicker;
+1 -3
View File
@@ -3,9 +3,7 @@ import * as api from "../helpers/api";
import Icon from "./Icon";
import "../less/github-badge.less";
interface Props {}
const GitHubBadge: React.FC<Props> = () => {
const GitHubBadge = () => {
const [starCount, setStarCount] = useState(0);
useEffect(() => {
+76 -99
View File
@@ -1,34 +1,26 @@
import { memo, useEffect, useRef, useState } from "react";
import { indexOf } from "lodash-es";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { indexOf } from "lodash-es";
import { memo, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import "dayjs/locale/zh";
import useI18n from "../hooks/useI18n";
import { UNKNOWN_ID } from "../helpers/consts";
import { DONE_BLOCK_REG, formatMemoContent, IMAGE_URL_REG, 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";
import MemoContent from "./MemoContent";
import MemoResources from "./MemoResources";
import showMemoCardDialog from "./MemoCardDialog";
import showShareMemoImageDialog from "./ShareMemoImageDialog";
import "../less/memo.less";
dayjs.extend(relativeTime);
const MAX_MEMO_CONTAINER_HEIGHT = 384;
type ExpandButtonStatus = -1 | 0 | 1;
interface Props {
memo: Memo;
}
interface State {
expandButtonStatus: ExpandButtonStatus;
}
export const getFormatedMemoCreatedAtStr = (createdTs: number, locale = "en"): string => {
if (Date.now() - createdTs < 1000 * 60 * 60 * 24) {
return dayjs(createdTs).locale(locale).fromNow();
@@ -39,44 +31,37 @@ export const getFormatedMemoCreatedAtStr = (createdTs: number, locale = "en"): s
const Memo: React.FC<Props> = (props: Props) => {
const memo = props.memo;
const { t, locale } = useI18n();
const [state, setState] = useState<State>({
expandButtonStatus: -1,
});
const [createdAtStr, setCreatedAtStr] = useState<string>(getFormatedMemoCreatedAtStr(memo.createdTs, locale));
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const [createdAtStr, setCreatedAtStr] = useState<string>(getFormatedMemoCreatedAtStr(memo.createdTs, i18n.language));
const memoContainerRef = useRef<HTMLDivElement>(null);
const memoContentContainerRef = useRef<HTMLDivElement>(null);
const imageUrls = Array.from(memo.content.match(IMAGE_URL_REG) ?? []).map((s) => s.replace(IMAGE_URL_REG, "$1"));
const isVisitorMode = userService.isVisitorMode();
useEffect(() => {
if (!memoContentContainerRef) {
return;
}
if (Number(memoContentContainerRef.current?.clientHeight) > MAX_MEMO_CONTAINER_HEIGHT) {
setState({
...state,
expandButtonStatus: 0,
});
}
let intervalFlag = -1;
let intervalFlag: any = -1;
if (Date.now() - memo.createdTs < 1000 * 60 * 60 * 24) {
intervalFlag = setInterval(() => {
setCreatedAtStr(getFormatedMemoCreatedAtStr(memo.createdTs, locale));
setCreatedAtStr(getFormatedMemoCreatedAtStr(memo.createdTs, i18n.language));
}, 1000 * 1);
}
return () => {
clearInterval(intervalFlag);
};
}, [locale]);
}, [i18n.language]);
const handleShowMemoStoryDialog = () => {
if (isVisitorMode) {
return;
}
showMemoCardDialog(memo);
};
const handleViewMemoDetailPage = () => {
navigate(`/m/${memo.id}`);
};
const handleTogglePinMemoBtnClick = async () => {
try {
if (memo.pinned) {
@@ -127,7 +112,7 @@ const Memo: React.FC<Props> = (props: Props) => {
if (memoTemp) {
showMemoCardDialog(memoTemp);
} else {
toastHelper.error("Memo Not Found");
toastHelper.error(t("message.memo-not-found"));
targetEl.classList.remove("memo-link-text");
}
} else if (targetEl.className === "tag-span") {
@@ -144,11 +129,11 @@ const Memo: React.FC<Props> = (props: Props) => {
}
const status = targetEl.dataset?.value;
const todoElementList = [...(memoContentContainerRef.current?.querySelectorAll(`span.todo-block[data-value=${status}]`) ?? [])];
const todoElementList = [...(memoContainerRef.current?.querySelectorAll(`span.todo-block[data-value=${status}]`) ?? [])];
for (const element of todoElementList) {
if (element === targetEl) {
const index = indexOf(todoElementList, element);
const tempList = memo.content.split(status === "DONE" ? DONE_BLOCK_REG : TODO_BLOCK_REG);
const tempList = memo.content.split(status === "DONE" ? /- \[x\] / : /- \[ \] /);
let finalContent = "";
for (let i = 0; i < tempList.length; i++) {
@@ -172,80 +157,72 @@ const Memo: React.FC<Props> = (props: Props) => {
}
};
const handleExpandBtnClick = () => {
const expandButtonStatus = Boolean(!state.expandButtonStatus);
if (!expandButtonStatus) {
memoContainerRef.current?.scrollIntoView();
const handleMemoContentDoubleClick = (e: React.MouseEvent) => {
const targetEl = e.target as HTMLElement;
if (targetEl.className === "memo-link-text") {
return;
} else if (targetEl.className === "tag-span") {
return;
} else if (targetEl.classList.contains("todo-block")) {
return;
}
setState({
expandButtonStatus: Number(expandButtonStatus) as ExpandButtonStatus,
});
editorStateService.setEditMemoWithId(memo.id);
};
return (
<div className={`memo-wrapper ${"memos-" + memo.id} ${memo.pinned ? "pinned" : ""}`} ref={memoContainerRef}>
<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>
</div>
<div className={`btns-container ${userService.isVisitorMode() ? "!hidden" : ""}`}>
<span className="btn more-action-btn">
<Icon.MoreHorizontal className="icon-img" />
<div className="status-text-container">
<span className="time-text" onClick={handleShowMemoStoryDialog}>
{createdAtStr}
</span>
<div className="more-action-btns-wrapper">
<div className="more-action-btns-container">
<div className="btns-container">
<div className="btn" onClick={handleTogglePinMemoBtnClick}>
<Icon.Flag className={`icon-img ${memo.pinned ? "" : "opacity-20"}`} />
<span className="tip-text">{memo.pinned ? t("common.unpin") : t("common.pin")}</span>
</div>
<div className="btn" onClick={handleEditMemoClick}>
<Icon.Edit3 className="icon-img" />
<span className="tip-text">{t("common.edit")}</span>
</div>
<div className="btn" onClick={handleGenMemoImageBtnClick}>
<Icon.Share className="icon-img" />
<span className="tip-text">{t("common.share")}</span>
{memo.visibility !== "PRIVATE" && !isVisitorMode && (
<span className={`status-text ${memo.visibility.toLocaleLowerCase()}`}>{memo.visibility}</span>
)}
</div>
{!isVisitorMode && (
<div className="btns-container">
<span className="btn more-action-btn">
<Icon.MoreHorizontal className="icon-img" />
</span>
<div className="more-action-btns-wrapper">
<div className="more-action-btns-container">
<div className="btns-container">
<div className="btn" onClick={handleTogglePinMemoBtnClick}>
<Icon.Flag className={`icon-img ${memo.pinned ? "" : "opacity-20"}`} />
<span className="tip-text">{memo.pinned ? t("common.unpin") : t("common.pin")}</span>
</div>
<div className="btn" onClick={handleEditMemoClick}>
<Icon.Edit3 className="icon-img" />
<span className="tip-text">{t("common.edit")}</span>
</div>
<div className="btn" onClick={handleGenMemoImageBtnClick}>
<Icon.Share className="icon-img" />
<span className="tip-text">{t("common.share")}</span>
</div>
</div>
<span className="btn" onClick={handleMarkMemoClick}>
{t("common.mark")}
</span>
<span className="btn" onClick={handleViewMemoDetailPage}>
{t("memo.view-detail")}
</span>
<span className="btn archive-btn" onClick={handleArchiveMemoClick}>
{t("common.archive")}
</span>
</div>
<span className="btn" onClick={handleMarkMemoClick}>
{t("common.mark")}
</span>
<span className="btn" onClick={handleShowMemoStoryDialog}>
{t("memo.view-story")}
</span>
<span className="btn archive-btn" onClick={handleArchiveMemoClick}>
{t("common.archive")}
</span>
</div>
</div>
</div>
)}
</div>
<div
ref={memoContentContainerRef}
className={`memo-content-text ${state.expandButtonStatus === 0 ? "expanded" : ""}`}
onClick={handleMemoContentClick}
dangerouslySetInnerHTML={{ __html: formatMemoContent(memo.content) }}
></div>
{state.expandButtonStatus !== -1 && (
<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" />
</span>
</div>
)}
<Only when={imageUrls.length > 0}>
<div className="images-wrapper">
{imageUrls.map((imgUrl, idx) => (
<Image className="memo-img" key={idx} imgUrl={imgUrl} />
))}
</div>
</Only>
<MemoContent
content={memo.content}
onMemoContentClick={handleMemoContentClick}
onMemoContentDoubleClick={handleMemoContentDoubleClick}
/>
<MemoResources memo={memo} />
</div>
);
};
+36 -25
View File
@@ -1,15 +1,18 @@
import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { editorStateService, memoService, userService } from "../services";
import { useAppSelector } from "../store";
import { UNKNOWN_ID, VISIBILITY_SELECTOR_ITEMS } from "../helpers/consts";
import * as utils from "../helpers/utils";
import { formatMemoContent, IMAGE_URL_REG, MEMO_LINK_REG, parseHtmlToRawText } from "../helpers/marked";
import Only from "./common/OnlyWhen";
import { parseHTMLToRawText } from "../helpers/utils";
import { marked } from "../labs/marked";
import { MARK_REG } from "../labs/marked/parser";
import toastHelper from "./Toast";
import { generateDialog } from "./Dialog";
import Image from "./Image";
import Icon from "./Icon";
import Selector from "./common/Selector";
import MemoContent from "./MemoContent";
import MemoResources from "./MemoResources";
import showChangeMemoCreatedTsDialog from "./ChangeMemoCreatedTsDialog";
import "../less/memo-card-dialog.less";
@@ -23,19 +26,26 @@ interface Props extends DialogProps {
}
const MemoCardDialog: React.FC<Props> = (props: Props) => {
const { t } = useTranslation();
const memos = useAppSelector((state) => state.memo.memos);
const [memo, setMemo] = useState<Memo>({
...props.memo,
});
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 isVisitorMode = userService.isVisitorMode();
const visibilitySelectorItems = VISIBILITY_SELECTOR_ITEMS.map((item) => {
return {
value: item.value,
text: t(`memo.visibility.${item.text.toLowerCase()}`),
};
});
useEffect(() => {
const fetchLinkedMemos = async () => {
try {
const linkMemos: LinkedMemo[] = [];
const matchedArr = [...memo.content.matchAll(MEMO_LINK_REG)];
const matchedArr = [...memo.content.matchAll(MARK_REG)];
for (const matchRes of matchedArr) {
if (matchRes && matchRes.length === 3) {
const id = Number(matchRes[2]);
@@ -76,6 +86,10 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
}, [memos, memo.id]);
const handleMemoCreatedAtClick = () => {
if (isVisitorMode) {
return;
}
showChangeMemoCreatedTsDialog(memo.id);
};
@@ -95,7 +109,7 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
setLinkedMemos([]);
setMemo(nextMemo);
} else {
toastHelper.error("Memo Not Found");
toastHelper.error(t("message.memo-not-found"));
targetEl.classList.remove("memo-link-text");
}
}
@@ -107,6 +121,10 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
setMemo(memo);
}, []);
const handleGotoMemoLinkBtnClick = () => {
window.open(`/m/${memo.id}`);
};
const handleEditMemoBtnClick = () => {
props.destroy();
editorStateService.setEditMemoWithId(memo.id);
@@ -129,51 +147,44 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
return (
<>
<Only when={!userService.isVisitorMode()}>
{!isVisitorMode && (
<div className="card-header-container">
<div className="visibility-selector-container">
<Icon.Eye className="icon-img" />
<Selector
className="visibility-selector"
dataSource={VISIBILITY_SELECTOR_ITEMS}
dataSource={visibilitySelectorItems}
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" onClick={handleMemoCreatedAtClick}>
{utils.getDateTimeString(memo.createdTs)}
</p>
<div className="btns-container">
<Only when={!userService.isVisitorMode()}>
{!isVisitorMode && (
<>
<button className="btn edit-btn" onClick={handleGotoMemoLinkBtnClick}>
<Icon.ExternalLink className="icon-img" />
</button>
<button className="btn edit-btn" onClick={handleEditMemoBtnClick}>
<Icon.Edit3 className="icon-img" />
</button>
<span className="split-line">/</span>
</>
</Only>
)}
<button className="btn close-btn" onClick={props.destroy}>
<Icon.X className="icon-img" />
</button>
</div>
</div>
<div className="memo-container">
<div
className="memo-content-text"
onClick={handleMemoContentClick}
dangerouslySetInnerHTML={{ __html: formatMemoContent(memo.content) }}
></div>
<Only when={imageUrls.length > 0}>
<div className="images-wrapper">
{imageUrls.map((imgUrl, idx) => (
<Image className="memo-img" key={idx} imgUrl={imgUrl} />
))}
</div>
</Only>
<MemoContent displayConfig={{ enableExpand: false }} content={memo.content} onMemoContentClick={handleMemoContentClick} />
<MemoResources memo={memo} />
</div>
<div className="layer-container"></div>
{linkMemos.map((_, idx) => {
@@ -199,7 +210,7 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
<div className="linked-memos-wrapper">
<p className="normal-text">{linkMemos.length} related MEMO</p>
{linkMemos.map((memo, index) => {
const rawtext = parseHtmlToRawText(formatMemoContent(memo.content)).replaceAll("\n", " ");
const rawtext = parseHTMLToRawText(marked(memo.content)).replaceAll("\n", " ");
return (
<div className="linked-memo-container" key={`${index}-${memo.id}`} onClick={() => handleLinkedMemoClick(memo)}>
<span className="time-text">{memo.dateStr} </span>
@@ -213,7 +224,7 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
<div className="linked-memos-wrapper">
<p className="normal-text">{linkedMemos.length} linked MEMO</p>
{linkedMemos.map((memo, index) => {
const rawtext = parseHtmlToRawText(formatMemoContent(memo.content)).replaceAll("\n", " ");
const rawtext = parseHTMLToRawText(marked(memo.content)).replaceAll("\n", " ");
return (
<div className="linked-memo-container" key={`${index}-${memo.id}`} onClick={() => handleLinkedMemoClick(memo)}>
<span className="time-text">{memo.dateStr} </span>
+96
View File
@@ -0,0 +1,96 @@
import { useEffect, useRef, useState } from "react";
import { marked } from "../labs/marked";
import Icon from "./Icon";
import "../less/memo-content.less";
export interface DisplayConfig {
enableExpand: boolean;
}
interface Props {
content: string;
className?: string;
displayConfig?: Partial<DisplayConfig>;
onMemoContentClick?: (e: React.MouseEvent) => void;
onMemoContentDoubleClick?: (e: React.MouseEvent) => void;
}
type ExpandButtonStatus = -1 | 0 | 1;
interface State {
expandButtonStatus: ExpandButtonStatus;
}
const defaultDisplayConfig: DisplayConfig = {
enableExpand: true,
};
const MAX_MEMO_CONTAINER_HEIGHT = 384;
const MemoContent: React.FC<Props> = (props: Props) => {
const { className, content, onMemoContentClick, onMemoContentDoubleClick } = props;
const [state, setState] = useState<State>({
expandButtonStatus: -1,
});
const memoContentContainerRef = useRef<HTMLDivElement>(null);
const displayConfig = {
...defaultDisplayConfig,
...props.displayConfig,
};
useEffect(() => {
if (!memoContentContainerRef) {
return;
}
if (displayConfig.enableExpand) {
if (Number(memoContentContainerRef.current?.clientHeight) > MAX_MEMO_CONTAINER_HEIGHT) {
setState({
...state,
expandButtonStatus: 0,
});
}
}
}, []);
const handleMemoContentClick = async (e: React.MouseEvent) => {
if (onMemoContentClick) {
onMemoContentClick(e);
}
};
const handleMemoContentDoubleClick = async (e: React.MouseEvent) => {
if (onMemoContentDoubleClick) {
onMemoContentDoubleClick(e);
}
};
const handleExpandBtnClick = () => {
const expandButtonStatus = Boolean(!state.expandButtonStatus);
setState({
expandButtonStatus: Number(expandButtonStatus) as ExpandButtonStatus,
});
};
return (
<div className={`memo-content-wrapper ${className || ""}`}>
<div
ref={memoContentContainerRef}
className={`memo-content-text ${state.expandButtonStatus === 0 ? "expanded" : ""}`}
onClick={handleMemoContentClick}
onDoubleClick={handleMemoContentDoubleClick}
dangerouslySetInnerHTML={{ __html: marked(content) }}
></div>
{state.expandButtonStatus !== -1 && (
<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" />
</span>
</div>
)}
</div>
);
};
export default MemoContent;
+192 -153
View File
@@ -1,34 +1,51 @@
import { IEmojiData } from "emoji-picker-react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { deleteMemoResource, upsertMemoResource } from "../helpers/api";
import { UNKNOWN_ID } from "../helpers/consts";
import { editorStateService, locationService, memoService, resourceService } from "../services";
import useI18n from "../hooks/useI18n";
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 EmojiPicker from "./Editor/EmojiPicker";
import "../less/memo-editor.less";
interface Props {}
const getEditorContentCache = (): string => {
return storage.get(["editorContentCache"]).editorContentCache ?? "";
};
const setEditorContentCache = (content: string) => {
storage.set({
editorContentCache: content,
});
};
interface State {
isUploadingResource: boolean;
fullscreen: boolean;
isUploadingResource: boolean;
shouldShowEmojiPicker: boolean;
resourceList: Resource[];
}
const MemoEditor: React.FC<Props> = () => {
const { t, locale } = useI18n();
const MemoEditor: React.FC = () => {
const { t, i18n } = useTranslation();
const user = useAppSelector((state) => state.user.user);
const editorState = useAppSelector((state) => state.editor);
const tags = useAppSelector((state) => state.memo.tags);
const [state, setState] = useState<State>({
isUploadingResource: false,
fullscreen: false,
shouldShowEmojiPicker: false,
resourceList: [],
});
const editorRef = useRef<EditorRefActions>(null);
const [allowSave, setAllowSave] = useState<boolean>(false);
const prevGlobalStateRef = useRef(editorState);
const editorRef = useRef<EditorRefActions>(null);
const tagSeletorRef = useRef<HTMLDivElement>(null);
const editorFontStyle = user?.setting.editorFontStyle || "normal";
const mobileEditorStyle = user?.setting.mobileEditorStyle || "normal";
useEffect(() => {
if (editorState.markMemoId && editorState.markMemoId !== UNKNOWN_ID) {
@@ -37,105 +54,68 @@ const MemoEditor: React.FC<Props> = () => {
editorRef.current?.insertText(memoLinkText);
editorStateService.clearMarkMemo();
}
}, [editorState.markMemoId]);
useEffect(() => {
if (
editorState.editMemoId &&
editorState.editMemoId !== UNKNOWN_ID &&
editorState.editMemoId !== prevGlobalStateRef.current.editMemoId
) {
const editMemo = memoService.getMemoById(editorState.editMemoId ?? UNKNOWN_ID);
if (editMemo) {
editorRef.current?.setContent(editMemo.content ?? "");
const memo = memoService.getMemoById(editorState.editMemoId ?? UNKNOWN_ID);
if (memo) {
setState({
...state,
resourceList: memo.resourceList,
});
editorRef.current?.setContent(memo.content ?? "");
editorRef.current?.focus();
}
}
prevGlobalStateRef.current = editorState;
}, [editorState.markMemoId, editorState.editMemoId]);
}, [state, editorState.editMemoId]);
useEffect(() => {
const handlePasteEvent = async (event: ClipboardEvent) => {
if (event.clipboardData && event.clipboardData.files.length > 0) {
event.preventDefault();
const file = event.clipboardData.files[0];
const url = await handleUploadFile(file);
if (url) {
editorRef.current?.insertText(`![](${url})`);
}
}
};
const handleDropEvent = async (event: DragEvent) => {
if (event.dataTransfer && event.dataTransfer.files.length > 0) {
event.preventDefault();
const file = event.dataTransfer.files[0];
const url = await handleUploadFile(file);
if (url) {
editorRef.current?.insertText(`![](${url})`);
}
}
};
const handleClickEvent = () => {
handleContentChange(editorRef.current?.element.value ?? "");
};
const handleKeyDownEvent = () => {
setTimeout(() => {
handleContentChange(editorRef.current?.element.value ?? "");
});
};
editorRef.current?.element.addEventListener("paste", handlePasteEvent);
editorRef.current?.element.addEventListener("drop", handleDropEvent);
editorRef.current?.element.addEventListener("click", handleClickEvent);
editorRef.current?.element.addEventListener("keydown", handleKeyDownEvent);
return () => {
editorRef.current?.element.removeEventListener("paste", handlePasteEvent);
editorRef.current?.element.removeEventListener("drop", handleDropEvent);
editorRef.current?.element.removeEventListener("click", handleClickEvent);
editorRef.current?.element.removeEventListener("keydown", handleKeyDownEvent);
};
}, []);
const handleUploadFile = useCallback(
async (file: File) => {
if (state.isUploadingResource) {
return;
}
setState({
...state,
isUploadingResource: true,
});
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) {
console.error(error);
toastHelper.error(error.response.data.message);
} finally {
const handlePasteEvent = async (event: React.ClipboardEvent) => {
if (event.clipboardData && event.clipboardData.files.length > 0) {
event.preventDefault();
const file = event.clipboardData.files[0];
const resource = await handleUploadResource(file);
if (resource) {
setState({
...state,
isUploadingResource: false,
resourceList: [...state.resourceList, resource],
});
}
},
[state]
);
}
};
const handleSaveBtnClick = async (content: string) => {
if (content === "") {
toastHelper.error("Content can't be empty");
const handleUploadResource = async (file: File) => {
setState({
...state,
isUploadingResource: true,
});
let resource = undefined;
try {
resource = await resourceService.upload(file);
} catch (error: any) {
console.error(error);
toastHelper.error(error.response.data.message);
}
setState({
...state,
isUploadingResource: false,
});
return resource;
};
const handleSaveBtnClick = async () => {
const content = editorRef.current?.getContent();
if (!content) {
toastHelper.error(t("editor.cant-empty"));
return;
}
@@ -144,7 +124,7 @@ const MemoEditor: React.FC<Props> = () => {
if (editMemoId && editMemoId !== UNKNOWN_ID) {
const prevMemo = memoService.getMemoById(editMemoId ?? UNKNOWN_ID);
if (prevMemo && prevMemo.content !== content) {
if (prevMemo) {
await memoService.patchMemo({
id: prevMemo.id,
content,
@@ -154,6 +134,7 @@ const MemoEditor: React.FC<Props> = () => {
} else {
await memoService.createMemo({
content,
resourceIdList: state.resourceList.map((resource) => resource.id),
});
locationService.clearQuery();
}
@@ -165,19 +146,30 @@ const MemoEditor: React.FC<Props> = () => {
setState({
...state,
fullscreen: false,
resourceList: [],
});
setEditorContentCache("");
editorRef.current?.setContent("");
};
const handleCancelEditingBtnClick = useCallback(() => {
const handleCancelEditing = () => {
setState({
...state,
resourceList: [],
});
editorStateService.clearEditMemo();
editorRef.current?.setContent("");
setEditorContentCache("");
}, []);
};
const handleContentChange = useCallback((content: string) => {
const handleContentChange = (content: string) => {
setAllowSave(content !== "");
setEditorContentCache(content);
}, []);
};
const handleEmojiPickerBtnClick = () => {
handleChangeShouldShowEmojiPicker(!state.shouldShowEmojiPicker);
};
const handleCheckBoxBtnClick = () => {
if (!editorRef.current) {
@@ -207,7 +199,7 @@ const MemoEditor: React.FC<Props> = () => {
}
};
const handleUploadFileBtnClick = useCallback(() => {
const handleUploadFileBtnClick = () => {
const inputEl = document.createElement("input");
inputEl.style.position = "fixed";
inputEl.style.top = "-100vh";
@@ -215,22 +207,30 @@ const MemoEditor: React.FC<Props> = () => {
document.body.appendChild(inputEl);
inputEl.type = "file";
inputEl.multiple = true;
inputEl.accept = "image/*";
inputEl.accept = "*";
inputEl.onchange = async () => {
if (!inputEl.files || inputEl.files.length === 0) {
return;
}
const resourceList: Resource[] = [];
for (const file of inputEl.files) {
const url = await handleUploadFile(file);
if (url) {
editorRef.current?.insertText(`![](${url})`);
const resource = await handleUploadResource(file);
if (resource) {
resourceList.push(resource);
if (editorState.editMemoId) {
await upsertMemoResource(editorState.editMemoId, resource.id);
}
}
}
setState({
...state,
resourceList: [...state.resourceList, ...resourceList],
});
document.body.removeChild(inputEl);
};
inputEl.click();
document.body.removeChild(inputEl);
}, []);
};
const handleFullscreenBtnClick = () => {
setState({
@@ -246,6 +246,33 @@ const MemoEditor: React.FC<Props> = () => {
}
}, []);
const handleChangeShouldShowEmojiPicker = (status: boolean) => {
setState({
...state,
shouldShowEmojiPicker: status,
});
};
const handleEmojiClick = (_: any, emojiObject: IEmojiData) => {
if (!editorRef.current) {
return;
}
editorRef.current.insertText(`${emojiObject.emoji}`);
handleChangeShouldShowEmojiPicker(false);
};
const handleDeleteResource = async (resourceId: ResourceId) => {
setState({
...state,
resourceList: state.resourceList.filter((resource) => resource.id !== resourceId),
});
if (editorState.editMemoId) {
await deleteMemoResource(editorState.editMemoId, resourceId);
}
};
const isEditing = Boolean(editorState.editMemoId && editorState.editMemoId !== UNKNOWN_ID);
const editorConfig = useMemo(
@@ -254,72 +281,84 @@ const MemoEditor: React.FC<Props> = () => {
initialContent: getEditorContentCache(),
placeholder: t("editor.placeholder"),
fullscreen: state.fullscreen,
showConfirmBtn: true,
onConfirmBtnClick: handleSaveBtnClick,
onContentChange: handleContentChange,
}),
[isEditing, state.fullscreen, locale, editorFontStyle]
[state.fullscreen, i18n.language, editorFontStyle]
);
return (
<div className={`memo-editor-container ${isEditing ? "edit-ing" : ""} ${state.fullscreen ? "fullscreen" : ""}`}>
<div className={`memo-editor-container ${mobileEditorStyle} ${isEditing ? "edit-ing" : ""} ${state.fullscreen ? "fullscreen" : ""}`}>
<div className={`tip-container ${isEditing ? "" : "!hidden"}`}>
<span className="tip-text">{t("editor.editing")}</span>
<button className="cancel-btn" onClick={handleCancelEditingBtnClick}>
<button className="cancel-btn" onClick={handleCancelEditing}>
{t("common.cancel")}
</button>
</div>
<Editor
ref={editorRef}
{...editorConfig}
tools={
<>
<div className="action-btn tag-action">
<Icon.Hash className="icon-img" />
<div ref={tagSeletorRef} className="tag-list" onClick={handleTagSeletorClick}>
{tags.length > 0 ? (
tags.map((tag) => {
return (
<span className="item-container" key={tag}>
{tag}
</span>
);
})
) : (
<p className="tip-text" onClick={(e) => e.stopPropagation()}>
{t("common.null")}
</p>
)}
</div>
<Editor ref={editorRef} {...editorConfig} onPaste={handlePasteEvent} />
<div className="common-tools-wrapper">
<div className="common-tools-container">
<div className="action-btn tag-action">
<Icon.Hash className="icon-img" />
<div ref={tagSeletorRef} className="tag-list" onClick={handleTagSeletorClick}>
{tags.length > 0 ? (
tags.map((tag) => {
return (
<span className="item-container" key={tag}>
{tag}
</span>
);
})
) : (
<p className="tip-text" onClick={(e) => e.stopPropagation()}>
{t("common.null")}
</p>
)}
</div>
<button className="action-btn">
<Icon.CheckSquare className="icon-img" onClick={handleCheckBoxBtnClick} />
</button>
<button className="action-btn">
<Icon.Code className="icon-img" onClick={handleCodeBlockBtnClick} />
</button>
<button className="action-btn">
<Icon.Image className="icon-img" onClick={handleUploadFileBtnClick} />
<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" />}
</button>
</>
}
/>
</div>
<button className="action-btn">
<Icon.Smile className="icon-img" onClick={handleEmojiPickerBtnClick} />
</button>
<button className="action-btn">
<Icon.CheckSquare className="icon-img" onClick={handleCheckBoxBtnClick} />
</button>
<button className="action-btn">
<Icon.Code className="icon-img" onClick={handleCodeBlockBtnClick} />
</button>
<button className="action-btn">
<Icon.FileText className="icon-img" onClick={handleUploadFileBtnClick} />
<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" />}
</button>
<EmojiPicker
shouldShow={state.shouldShowEmojiPicker}
onEmojiClick={handleEmojiClick}
onShouldShowEmojiPickerChange={handleChangeShouldShowEmojiPicker}
/>
</div>
<div className="btns-container">
<button className="action-btn confirm-btn" disabled={!allowSave} onClick={handleSaveBtnClick}>
{t("editor.save")}
<img className="icon-img" src="/logo.webp" />
</button>
</div>
</div>
{state.resourceList.length > 0 && (
<div className="resource-list-wrapper">
{state.resourceList.map((resource) => {
return (
<div key={resource.id} className="resource-container">
{resource.type.includes("image") ? <Icon.Image className="icon-img" /> : <Icon.FileText className="icon-img" />}
<span className="name-text">{resource.filename}</span>
<Icon.X className="close-icon" onClick={() => handleDeleteResource(resource.id)} />
</div>
);
})}
</div>
)}
</div>
);
};
function getEditorContentCache(): string {
return storage.get(["editorContentCache"]).editorContentCache ?? "";
}
function setEditorContentCache(content: string) {
storage.set({
editorContentCache: content,
});
}
export default MemoEditor;
+4 -5
View File
@@ -1,21 +1,20 @@
import { useTranslation } from "react-i18next";
import { useAppSelector } from "../store";
import { locationService, shortcutService } from "../services";
import * as utils from "../helpers/utils";
import { getTextWithMemoType } from "../helpers/filter";
import "../less/memo-filter.less";
interface FilterProps {}
const MemoFilter: React.FC<FilterProps> = () => {
const MemoFilter = () => {
const { t } = useTranslation();
const query = useAppSelector((state) => state.location.query);
useAppSelector((state) => state.shortcut.shortcuts);
const { tag: tagQuery, duration, type: memoType, text: textQuery, shortcutId } = query;
const shortcut = shortcutId ? shortcutService.getShortcutById(shortcutId) : null;
const showFilter = Boolean(tagQuery || (duration && duration.from < duration.to) || memoType || textQuery || shortcut);
return (
<div className={`filter-query-container ${showFilter ? "" : "!hidden"}`}>
<span className="tip-text">Filter:</span>
<span className="tip-text">{t("common.filter")}:</span>
<div
className={"filter-item-container " + (shortcut ? "" : "hidden")}
onClick={() => {
+15 -23
View File
@@ -1,19 +1,16 @@
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { memoService, shortcutService } from "../services";
import useI18n from "../hooks/useI18n";
import { useAppSelector } from "../store";
import { IMAGE_URL_REG, LINK_URL_REG, MEMO_LINK_REG, TAG_REG } from "../helpers/marked";
import { TAG_REG, LINK_REG } from "../labs/marked/parser";
import * as utils from "../helpers/utils";
import { checkShouldShowMemoWithFilters } from "../helpers/filter";
import toastHelper from "./Toast";
import Only from "./common/OnlyWhen";
import Memo from "./Memo";
import "../less/memo-list.less";
interface Props {}
const MemoList: React.FC<Props> = () => {
const { t } = useI18n();
const MemoList = () => {
const { t } = useTranslation();
const query = useAppSelector((state) => state.location.query);
const { memos, isFetching } = useAppSelector((state) => state.memo);
const wrapperElement = useRef<HTMLDivElement>(null);
@@ -60,11 +57,7 @@ const MemoList: React.FC<Props> = () => {
if (memoType) {
if (memoType === "NOT_TAGGED" && memo.content.match(TAG_REG) !== null) {
shouldShow = false;
} else if (memoType === "LINKED" && memo.content.match(LINK_URL_REG) === null) {
shouldShow = false;
} else if (memoType === "IMAGED" && memo.content.match(IMAGE_URL_REG) === null) {
shouldShow = false;
} else if (memoType === "CONNECTED" && memo.content.match(MEMO_LINK_REG) === null) {
} else if (memoType === "LINKED" && memo.content.match(LINK_REG) === null) {
shouldShow = false;
}
}
@@ -82,7 +75,7 @@ const MemoList: React.FC<Props> = () => {
useEffect(() => {
memoService
.fetchAllMemos()
.fetchMemos()
.then(() => {
// do nth
})
@@ -99,20 +92,19 @@ const MemoList: React.FC<Props> = () => {
}, [query]);
return (
<div className={`memo-list-container ${isFetching ? "" : "completed"}`} ref={wrapperElement}>
<Only when={isFetching}>
<div className="status-text-container fetching-tip">
<p className="status-text">{t("memo-list.fetching-data")}</p>
</div>
</Only>
<div className="memo-list-container" ref={wrapperElement}>
{sortedMemos.map((memo) => (
<Memo key={`${memo.id}-${memo.createdTs}-${memo.updatedTs}`} memo={memo} />
))}
<Only when={!isFetching}>
<div className="status-text-container">
<p className="status-text">{sortedMemos.length === 0 ? "no memos 🌃" : showMemoFilter ? "" : "all memos are ready 🎉"}</p>
{isFetching ? (
<div className="status-text-container fetching-tip">
<p className="status-text">{t("memo-list.fetching-data")}</p>
</div>
</Only>
) : (
<div className="status-text-container">
<p className="status-text">{sortedMemos.length === 0 ? t("message.no-memos") : showMemoFilter ? "" : t("message.memos-ready")}</p>
</div>
)}
</div>
);
};
+42
View File
@@ -0,0 +1,42 @@
import Image from "./Image";
import Icon from "./Icon";
import "../less/memo-resources.less";
interface Props {
memo: Memo;
}
const MemoResources: React.FC<Props> = (props: Props) => {
const { memo } = props;
const imageList = memo.resourceList.filter((resource) => resource.type.includes("image"));
const otherResourceList = memo.resourceList.filter((resource) => !resource.type.includes("image"));
const handlPreviewBtnClick = (resource: Resource) => {
const resourceUrl = `${window.location.origin}/o/r/${resource.id}/${resource.filename}`;
window.open(resourceUrl);
};
return (
<div className="resource-wrapper">
{imageList.length > 0 && (
<div className="images-wrapper">
{imageList.map((resource) => (
<Image className="memo-img" key={resource.id} imgUrl={`/o/r/${resource.id}/${resource.filename}`} />
))}
</div>
)}
<div className="other-resource-wrapper">
{otherResourceList.map((resource) => {
return (
<div className="other-resource-container" key={resource.id} onClick={() => handlPreviewBtnClick(resource)}>
<Icon.FileText className="icon-img" />
<span className="name-text">{resource.filename}</span>
</div>
);
})}
</div>
</div>
);
};
export default MemoResources;
+2 -4
View File
@@ -8,9 +8,7 @@ import "../less/memos-header.less";
let prevRequestTimestamp = Date.now();
interface Props {}
const MemosHeader: React.FC<Props> = () => {
const MemosHeader = () => {
const query = useAppSelector((state) => state.location.query);
const shortcuts = useAppSelector((state) => state.shortcut.shortcuts);
const [titleText, setTitleText] = useState("MEMOS");
@@ -31,7 +29,7 @@ const MemosHeader: React.FC<Props> = () => {
const now = Date.now();
if (now - prevRequestTimestamp > 1 * 1000) {
prevRequestTimestamp = now;
memoService.fetchAllMemos().catch(() => {
memoService.fetchMemos().catch(() => {
// do nth
});
}
-83
View File
@@ -1,83 +0,0 @@
import { useEffect, useRef } from "react";
import * as api from "../helpers/api";
import { locationService, userService } from "../services";
import useI18n from "../hooks/useI18n";
import toastHelper from "./Toast";
import Only from "./common/OnlyWhen";
import showAboutSiteDialog from "./AboutSiteDialog";
import "../less/menu-btns-popup.less";
interface Props {
shownStatus: boolean;
setShownStatus: (status: boolean) => void;
}
const MenuBtnsPopup: React.FC<Props> = (props: Props) => {
const { shownStatus, setShownStatus } = props;
const { t } = useI18n();
const popupElRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (shownStatus) {
const handleClickOutside = (event: MouseEvent) => {
if (!popupElRef.current?.contains(event.target as Node)) {
event.stopPropagation();
}
setShownStatus(false);
};
window.addEventListener("click", handleClickOutside, {
capture: true,
once: true,
});
}
}, [shownStatus]);
const handlePingBtnClick = () => {
api
.getSystemStatus()
.then(({ data }) => {
const {
data: { profile },
} = data;
toastHelper.info(JSON.stringify(profile, null, 4));
})
.catch((error) => {
console.error(error);
toastHelper.error(error.response.data.message);
});
};
const handleAboutBtnClick = () => {
showAboutSiteDialog();
};
const handleSignOutBtnClick = async () => {
userService
.doSignOut()
.then(() => {
locationService.replaceHistory("/auth");
window.location.reload();
})
.catch(() => {
// do nth
});
};
return (
<div className={`menu-btns-popup ${shownStatus ? "" : "hidden"}`} ref={popupElRef}>
<button className="btn action-btn" onClick={handleAboutBtnClick}>
<span className="icon">🤠</span> {t("common.about")}
</button>
<button className="btn action-btn" onClick={handlePingBtnClick}>
<span className="icon">🎯</span> Ping
</button>
<Only when={!userService.isVisitorMode()}>
<button className="btn action-btn" onClick={handleSignOutBtnClick}>
<span className="icon">👋</span> {t("common.sign-out")}
</button>
</Only>
</div>
);
};
export default MenuBtnsPopup;
+47 -21
View File
@@ -1,6 +1,6 @@
import copy from "copy-to-clipboard";
import { useEffect, useState } from "react";
import * as utils from "../helpers/utils";
import useI18n from "../hooks/useI18n";
import { useTranslation } from "react-i18next";
import useLoading from "../hooks/useLoading";
import { resourceService } from "../services";
import Dropdown from "./common/Dropdown";
@@ -11,7 +11,7 @@ import Icon from "./Icon";
import toastHelper from "./Toast";
import "../less/resources-dialog.less";
interface Props extends DialogProps {}
type Props = DialogProps;
interface State {
resources: Resource[];
@@ -20,7 +20,7 @@ interface State {
const ResourcesDialog: React.FC<Props> = (props: Props) => {
const { destroy } = props;
const { t } = useI18n();
const { t } = useTranslation();
const loadingState = useLoading();
const [state, setState] = useState<State>({
resources: [],
@@ -58,7 +58,7 @@ const ResourcesDialog: React.FC<Props> = (props: Props) => {
document.body.appendChild(inputEl);
inputEl.type = "file";
inputEl.multiple = true;
inputEl.accept = "image/*";
inputEl.accept = "*";
inputEl.onchange = async () => {
if (!inputEl.files || inputEl.files.length === 0) {
return;
@@ -70,7 +70,6 @@ const ResourcesDialog: React.FC<Props> = (props: Props) => {
});
for (const file of inputEl.files) {
await resourceService.upload(file);
try {
await resourceService.upload(file);
} catch (error: any) {
@@ -84,25 +83,35 @@ const ResourcesDialog: React.FC<Props> = (props: Props) => {
}
}
document.body.removeChild(inputEl);
await fetchResources();
};
inputEl.click();
document.body.removeChild(inputEl);
};
const handlPreviewBtnClick = (resource: Resource) => {
showPreviewImageDialog(`${window.location.origin}/h/r/${resource.id}/${resource.filename}`);
const resourceUrl = `${window.location.origin}/o/r/${resource.id}/${resource.filename}`;
if (resource.type.startsWith("image")) {
showPreviewImageDialog(resourceUrl);
} else {
window.open(resourceUrl);
}
};
const handleCopyResourceLinkBtnClick = (resource: Resource) => {
utils.copyTextToClipboard(`${window.location.origin}/h/r/${resource.id}/${resource.filename}`);
copy(`${window.location.origin}/o/r/${resource.id}/${resource.filename}`);
toastHelper.success("Succeed to copy resource link to clipboard");
};
const handleDeleteResourceBtnClick = (resource: Resource) => {
let warningText = "Are you sure to delete this resource? THIS ACTION IS IRREVERSIABLE.❗️";
if (resource.linkedMemoAmount > 0) {
warningText = warningText + `\nLinked memo amount: ${resource.linkedMemoAmount}`;
}
showCommonDialog({
title: `Delete Resource`,
content: `Are you sure to delete this resource? THIS ACTION IS IRREVERSIABLE.❗️`,
content: warningText,
style: "warning",
onConfirm: async () => {
await resourceService.deleteResourceById(resource.id);
@@ -123,16 +132,15 @@ const ResourcesDialog: React.FC<Props> = (props: Props) => {
</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>
<span>{t("resources.upload")}</span>
</div>
</div>
{loadingState.isLoading ? (
<div className="loading-text-container">
<p className="tip-text">fetching data...</p>
<p className="tip-text">{t("resources.fetching-data")}</p>
</div>
) : (
<div className="resource-table-container">
@@ -143,7 +151,7 @@ const ResourcesDialog: React.FC<Props> = (props: Props) => {
<span></span>
</div>
{state.resources.length === 0 ? (
<p className="tip-text">No resource.</p>
<p className="tip-text">{t("resources.no-resources")}</p>
) : (
state.resources.map((resource) => (
<div key={resource.id} className="resource-container">
@@ -151,13 +159,31 @@ const ResourcesDialog: React.FC<Props> = (props: Props) => {
<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={() => handlPreviewBtnClick(resource)}>Preview</button>
<button onClick={() => handleCopyResourceLinkBtnClick(resource)}>Copy Link</button>
<button className="delete-btn" onClick={() => handleDeleteResourceBtnClick(resource)}>
Delete
</button>
</Dropdown>
<Dropdown
actionsClassName="!w-28"
actions={
<>
<button
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded hover:bg-gray-100"
onClick={() => handlPreviewBtnClick(resource)}
>
{t("resources.preview")}
</button>
<button
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded hover:bg-gray-100"
onClick={() => handleCopyResourceLinkBtnClick(resource)}
>
{t("resources.copy-link")}
</button>
<button
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded text-red-600 hover:bg-gray-100"
onClick={() => handleDeleteResourceBtnClick(resource)}
>
{t("common.delete")}
</button>
</>
}
/>
</div>
</div>
))
+5 -5
View File
@@ -1,12 +1,12 @@
import { useTranslation } from "react-i18next";
import { locationService } from "../services";
import { useAppSelector } from "../store";
import { memoSpecialTypes } from "../helpers/filter";
import Icon from "./Icon";
import "../less/search-bar.less";
interface Props {}
const SearchBar: React.FC<Props> = () => {
const SearchBar = () => {
const { t } = useTranslation();
const memoType = useAppSelector((state) => state.location.query?.type);
const handleMemoTypeItemClick = (type: MemoSpecType | undefined) => {
@@ -30,9 +30,9 @@ const SearchBar: React.FC<Props> = () => {
</div>
<div className="quickly-action-wrapper">
<div className="quickly-action-container">
<p className="title-text">QUICKLY FILTER</p>
<p className="title-text">{t("search.quickly-filter").toUpperCase()}</p>
<div className="section-container types-container">
<span className="section-text">Type:</span>
<span className="section-text">{t("common.type").toUpperCase()}:</span>
<div className="values-container">
{memoSpecialTypes.map((t, idx) => {
return (
+3 -3
View File
@@ -1,6 +1,6 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useAppSelector } from "../store";
import useI18n from "../hooks/useI18n";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import MyAccountSection from "./Settings/MyAccountSection";
@@ -8,7 +8,7 @@ import PreferencesSection from "./Settings/PreferencesSection";
import MemberSection from "./Settings/MemberSection";
import "../less/setting-dialog.less";
interface Props extends DialogProps {}
type Props = DialogProps;
type SettingSection = "my-account" | "preferences" | "member";
@@ -18,7 +18,7 @@ interface State {
const SettingDialog: React.FC<Props> = (props: Props) => {
const { destroy } = props;
const { t } = useI18n();
const { t } = useTranslation();
const user = useAppSelector((state) => state.user.user);
const [state, setState] = useState<State>({
selectedSection: "my-account",
+36 -20
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from "react";
import { isEmpty } from "lodash-es";
import useI18n from "../../hooks/useI18n";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { userService } from "../../services";
import { useAppSelector } from "../../store";
import * as api from "../../helpers/api";
@@ -9,15 +9,13 @@ import Dropdown from "../common/Dropdown";
import { showCommonDialog } from "../Dialog/CommonDialog";
import "../../less/settings/member-section.less";
interface Props {}
interface State {
createUserEmail: string;
createUserPassword: string;
}
const PreferencesSection: React.FC<Props> = () => {
const { t } = useI18n();
const PreferencesSection = () => {
const { t } = useTranslation();
const currentUser = useAppSelector((state) => state.user.user);
const [state, setState] = useState<State>({
createUserEmail: "",
@@ -50,7 +48,7 @@ const PreferencesSection: React.FC<Props> = () => {
const handleCreateUserBtnClick = async () => {
if (isEmpty(state.createUserEmail) || isEmpty(state.createUserPassword)) {
toastHelper.error("Please fill out this form");
toastHelper.error(t("message.fill-form"));
return;
}
@@ -127,10 +125,10 @@ const PreferencesSection: React.FC<Props> = () => {
<button onClick={handleCreateUserBtnClick}>{t("common.create")}</button>
</div>
</div>
<p className="title-text">Member list</p>
<p className="title-text">{t("setting.member-list")}</p>
<div className="member-container field-container">
<span className="field-text">ID</span>
<span className="field-text">EMAIL</span>
<span className="field-text">{t("common.email")}</span>
<span></span>
</div>
{userList.map((user) => (
@@ -139,20 +137,38 @@ const PreferencesSection: React.FC<Props> = () => {
<span className="field-text email-text">{user.email}</span>
<div className="buttons-container">
{currentUser?.id === user.id ? (
<span className="tip-text">Yourself</span>
<span className="tip-text">{t("common.yourself")}</span>
) : (
<Dropdown className="actions-dropdown">
{user.rowStatus === "NORMAL" ? (
<button onClick={() => handleArchiveUserClick(user)}>{t("common.archive")}</button>
) : (
<Dropdown
actionsClassName="!w-24"
actions={
<>
<button onClick={() => handleRestoreUserClick(user)}>{t("common.restore")}</button>
<button className="delete" onClick={() => handleDeleteUserClick(user)}>
{t("common.delete")}
</button>
{user.rowStatus === "NORMAL" ? (
<button
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded hover:bg-gray-100"
onClick={() => handleArchiveUserClick(user)}
>
{t("common.archive")}
</button>
) : (
<>
<button
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded hover:bg-gray-100"
onClick={() => handleRestoreUserClick(user)}
>
{t("common.restore")}
</button>
<button
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded text-red-600 hover:bg-gray-100"
onClick={() => handleDeleteUserClick(user)}
>
{t("common.delete")}
</button>
</>
)}
</>
)}
</Dropdown>
}
/>
)}
</div>
</div>
@@ -1,5 +1,5 @@
import { useState } from "react";
import useI18n from "../../hooks/useI18n";
import { useTranslation } from "react-i18next";
import { useAppSelector } from "../../store";
import { userService } from "../../services";
import { validate, ValidatorConfig } from "../../helpers/validator";
@@ -9,16 +9,14 @@ import showChangePasswordDialog from "../ChangePasswordDialog";
import "../../less/settings/my-account-section.less";
const validateConfig: ValidatorConfig = {
minLength: 4,
minLength: 1,
maxLength: 24,
noSpace: true,
noChinese: true,
noChinese: false,
};
interface Props {}
const MyAccountSection: React.FC<Props> = () => {
const { t } = useI18n();
const MyAccountSection = () => {
const { t, i18n } = useTranslation();
const user = useAppSelector((state) => state.user.user as User);
const [username, setUsername] = useState<string>(user.name);
const openAPIRoute = `${window.location.origin}/api/memo?openId=${user.openId}`;
@@ -35,7 +33,7 @@ const MyAccountSection: React.FC<Props> = () => {
const usernameValidResult = validate(username, validateConfig);
if (!usernameValidResult.result) {
toastHelper.error("Username " + usernameValidResult.reason);
toastHelper.error(t("common.username") + i18n.language === "zh" ? "" : " " + usernameValidResult.reason);
return;
}
@@ -44,7 +42,7 @@ const MyAccountSection: React.FC<Props> = () => {
id: user.id,
name: username,
});
toastHelper.info("Username changed");
toastHelper.info(t("common.username") + i18n.language === "zh" ? "" : " " + t("common.changed"));
} catch (error: any) {
console.error(error);
toastHelper.error(error.response.data.message);
@@ -60,6 +58,12 @@ const MyAccountSection: React.FC<Props> = () => {
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",
onConfirm: async () => {
await userService.patchUser({
id: user.id,
resetOpenId: true,
});
},
});
};
@@ -1,13 +1,10 @@
import { useTranslation } from "react-i18next";
import { globalService, userService } from "../../services";
import { useAppSelector } from "../../store";
import { VISIBILITY_SELECTOR_ITEMS } from "../../helpers/consts";
import useI18n from "../../hooks/useI18n";
import Selector from "../common/Selector";
import BetaBadge from "../BetaBadge";
import "../../less/settings/preferences-section.less";
interface Props {}
const localeSelectorItems = [
{
text: "English",
@@ -17,6 +14,10 @@ const localeSelectorItems = [
text: "中文",
value: "zh",
},
{
text: "Tiếng Việt",
value: "vi",
},
];
const editorFontStyleSelectorItems = [
@@ -30,9 +31,26 @@ const editorFontStyleSelectorItems = [
},
];
const PreferencesSection: React.FC<Props> = () => {
const { t } = useI18n();
const mobileEditorStyleSelectorItems = [
{
text: "Normal",
value: "normal",
},
{
text: "Float",
value: "float",
},
];
const PreferencesSection = () => {
const { t } = useTranslation();
const { setting } = useAppSelector((state) => state.user.user as User);
const visibilitySelectorItems = VISIBILITY_SELECTOR_ITEMS.map((item) => {
return {
value: item.value,
text: t(`memo.visibility.${item.text.toLowerCase()}`),
};
});
const handleLocaleChanged = async (value: string) => {
await userService.upsertUserSetting("locale", value);
@@ -47,14 +65,15 @@ const PreferencesSection: React.FC<Props> = () => {
await userService.upsertUserSetting("editorFontStyle", value);
};
const handleMobileEditorStyleChanged = async (value: string) => {
await userService.upsertUserSetting("mobileEditorStyle", value);
};
return (
<div className="section-container preferences-section-container">
<p className="title-text">{t("common.basic")}</p>
<label className="form-label selector">
<span className="normal-text">
{t("common.language")}
<BetaBadge className="ml-2" />
</span>
<span className="normal-text">{t("common.language")}</span>
<Selector className="ml-2 w-32" value={setting.locale} dataSource={localeSelectorItems} handleValueChanged={handleLocaleChanged} />
</label>
<p className="title-text">{t("setting.preference")}</p>
@@ -63,7 +82,7 @@ const PreferencesSection: React.FC<Props> = () => {
<Selector
className="ml-2 w-32"
value={setting.memoVisibility}
dataSource={VISIBILITY_SELECTOR_ITEMS}
dataSource={visibilitySelectorItems}
handleValueChanged={handleDefaultMemoVisibilityChanged}
/>
</label>
@@ -76,6 +95,15 @@ const PreferencesSection: React.FC<Props> = () => {
handleValueChanged={handleEditorFontStyleChanged}
/>
</label>
<label className="form-label selector">
<span className="normal-text">{t("setting.preference-section.mobile-editor-style")}</span>
<Selector
className="ml-2 w-32"
value={setting.mobileEditorStyle}
dataSource={mobileEditorStyleSelectorItems}
handleValueChanged={handleMobileEditorStyleChanged}
/>
</label>
</div>
);
};
+9 -35
View File
@@ -1,14 +1,12 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { userService } from "../services";
import toImage from "../labs/html2image";
import { ANIMATION_DURATION } from "../helpers/consts";
import useI18n from "../hooks/useI18n";
import * as utils from "../helpers/utils";
import { formatMemoContent, IMAGE_URL_REG } from "../helpers/marked";
import Only from "./common/OnlyWhen";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import toastHelper from "./Toast";
import MemoContent from "./MemoContent";
import "../less/share-memo-image-dialog.less";
interface Props extends DialogProps {
@@ -17,23 +15,16 @@ interface Props extends DialogProps {
const ShareMemoImageDialog: React.FC<Props> = (props: Props) => {
const { memo: propsMemo, destroy } = props;
const { t } = useI18n();
const { t } = useTranslation();
const { user: userinfo } = userService.getState();
const [shortcutImgUrl, setShortcutImgUrl] = useState("");
const memo = {
...propsMemo,
createdAtStr: utils.getDateTimeString(propsMemo.createdTs),
};
const imageUrls = Array.from(memo.content.match(IMAGE_URL_REG) ?? []).map((s) => s.replace(IMAGE_URL_REG, "$1"));
const [shortcutImgUrl, setShortcutImgUrl] = useState("");
const [imgAmount, setImgAmount] = useState(imageUrls.length);
const memoElRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (imgAmount > 0) {
return;
}
setTimeout(() => {
if (!memoElRef.current) {
return;
@@ -46,24 +37,16 @@ const ShareMemoImageDialog: React.FC<Props> = (props: Props) => {
.then((url) => {
setShortcutImgUrl(url);
})
.catch(() => {
// do nth
.catch((err) => {
console.error(err);
});
}, ANIMATION_DURATION);
}, [imgAmount]);
}, []);
const handleCloseBtnClick = () => {
destroy();
};
const handleImageOnLoad = (event: React.SyntheticEvent<HTMLImageElement>) => {
if (event.type === "error") {
toastHelper.error("Image load failed");
(event.target as HTMLImageElement).remove();
}
setImgAmount(imgAmount - 1);
};
const handleDownloadBtnClick = () => {
const a = document.createElement("a");
a.href = shortcutImgUrl;
@@ -87,18 +70,9 @@ const ShareMemoImageDialog: React.FC<Props> = (props: Props) => {
<p className="tip-text">{shortcutImgUrl ? "Click to save the image 👇" : "Generating the screenshot..."}</p>
</div>
<div className="memo-container" ref={memoElRef}>
<Only when={shortcutImgUrl !== ""}>
<img className="memo-shortcut-img" onClick={handleDownloadBtnClick} src={shortcutImgUrl} />
</Only>
{shortcutImgUrl !== "" && <img className="memo-shortcut-img" onClick={handleDownloadBtnClick} src={shortcutImgUrl} />}
<span className="time-text">{memo.createdAtStr}</span>
<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 decoding="async" key={idx} src={imgUrl} onLoad={handleImageOnLoad} onError={handleImageOnLoad} />
))}
</div>
</Only>
<MemoContent className="memo-content-wrapper" content={memo.content} displayConfig={{ enableExpand: false }} />
<div className="watermark-container">
<span className="normal-text">
<span className="icon-text"></span> by <span className="name-text">{userinfo?.name}</span>
+5 -6
View File
@@ -1,7 +1,7 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { locationService, shortcutService } from "../services";
import { useAppSelector } from "../store";
import useI18n from "../hooks/useI18n";
import * as utils from "../helpers/utils";
import useToggle from "../hooks/useToggle";
import useLoading from "../hooks/useLoading";
@@ -10,12 +10,11 @@ import toastHelper from "./Toast";
import showCreateShortcutDialog from "./CreateShortcutDialog";
import "../less/shortcut-list.less";
interface Props {}
const ShortcutList: React.FC<Props> = () => {
const ShortcutList = () => {
const query = useAppSelector((state) => state.location.query);
const shortcuts = useAppSelector((state) => state.shortcut.shortcuts);
const loadingState = useLoading();
const { t } = useTranslation();
const pinnedShortcuts = shortcuts
.filter((s) => s.rowStatus === "ARCHIVED")
@@ -39,7 +38,7 @@ const ShortcutList: React.FC<Props> = () => {
return (
<div className="shortcuts-wrapper">
<p className="title-text">
<span className="normal-text">Shortcuts</span>
<span className="normal-text">{t("common.shortcuts")}</span>
<button className="btn" onClick={() => showCreateShortcutDialog()}>
<Icon.Plus className="icon-img" />
</button>
@@ -60,7 +59,7 @@ interface ShortcutContainerProps {
const ShortcutContainer: React.FC<ShortcutContainerProps> = (props: ShortcutContainerProps) => {
const { shortcut, isActive } = props;
const { t } = useI18n();
const { t } = useTranslation();
const [showConfirmDeleteBtn, toggleConfirmDeleteBtn] = useToggle(false);
const handleShortcutClick = () => {
+16 -29
View File
@@ -1,31 +1,21 @@
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { userService } from "../services";
import useI18n from "../hooks/useI18n";
import Icon from "./Icon";
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";
import TagList from "./TagList";
import "../less/siderbar.less";
interface Props {}
const Sidebar = () => {
const { t } = useTranslation();
const Sidebar: React.FC<Props> = () => {
const { t } = useI18n();
const handleMyAccountBtnClick = () => {
const handleSettingBtnClick = () => {
showSettingDialog();
};
const handleResourcesBtnClick = () => {
showResourcesDialog();
};
const handleArchivedBtnClick = () => {
showArchivedMemoDialog();
};
return (
<aside className="sidebar-wrapper">
@@ -40,21 +30,18 @@ const Sidebar: React.FC<Props> = () => {
<button className="btn action-btn" onClick={() => showDailyReviewDialog()}>
<span className="icon">📅</span> {t("sidebar.daily-review")}
</button>
<Only when={!userService.isVisitorMode()}>
<button className="btn action-btn" onClick={handleResourcesBtnClick}>
<span className="icon">🌄</span> {t("sidebar.resources")}
</button>
<button className="btn action-btn" onClick={handleMyAccountBtnClick}>
<span className="icon"></span> {t("sidebar.setting")}
</button>
<button className="btn action-btn" onClick={handleArchivedBtnClick}>
<span className="icon">🗂</span> {t("sidebar.archived")}
</button>
</Only>
{!userService.isVisitorMode() && (
<>
<Link to="/explore" className="btn action-btn">
<span className="icon">🏂</span> {t("common.explore")}
</Link>
<button className="btn action-btn" onClick={handleSettingBtnClick}>
<span className="icon"></span> {t("sidebar.setting")}
</button>
</>
)}
</div>
<Only when={!userService.isVisitorMode()}>
<ShortcutList />
</Only>
{!userService.isVisitorMode() && <ShortcutList />}
<TagList />
</aside>
);
+8 -12
View File
@@ -1,11 +1,9 @@
import { useEffect, useState } from "react";
import * as utils from "../helpers/utils";
import { useTranslation } from "react-i18next";
import { useAppSelector } from "../store";
import { locationService, memoService, userService } from "../services";
import useI18n from "../hooks/useI18n";
import useToggle from "../hooks/useToggle";
import Icon from "./Icon";
import Only from "./common/OnlyWhen";
import "../less/tag-list.less";
interface Tag {
@@ -14,10 +12,8 @@ interface Tag {
subTags: Tag[];
}
interface Props {}
const TagList: React.FC<Props> = () => {
const { t } = useI18n();
const TagList = () => {
const { t } = useTranslation();
const { memos, tags: tagsText } = useAppSelector((state) => state.memo);
const query = useAppSelector((state) => state.location.query);
const [tags, setTags] = useState<Tag[]>([]);
@@ -33,10 +29,12 @@ const TagList: React.FC<Props> = () => {
const root: KVObject<any> = {
subTags: [],
};
for (const tag of sortedTags) {
const subtags = tag.split("/");
let tempObj = root;
let tagText = "";
for (let i = 0; i < subtags.length; i++) {
const key = subtags[i];
if (i === 0) {
@@ -66,19 +64,18 @@ const TagList: React.FC<Props> = () => {
tempObj = obj;
}
}
setTags(root.subTags as Tag[]);
}, [tagsText]);
return (
<div className="tags-wrapper">
<p className="title-text">Tags</p>
<p className="title-text">{t("common.tags")}</p>
<div className="tags-container">
{tags.map((t, idx) => (
<TagItemContainer key={t.text + "-" + idx} tag={t} tagQuery={query?.tag} />
))}
<Only when={!userService.isVisitorMode() && tags.length === 0}>
<p className="tip-text">{t("tag-list.tip-text")}</p>
</Only>
{!userService.isVisitorMode() && tags.length === 0 && <p className="tip-text">{t("tag-list.tip-text")}</p>}
</div>
</div>
);
@@ -99,7 +96,6 @@ const TagItemContainer: React.FC<TagItemContainerProps> = (props: TagItemContain
if (isActive) {
locationService.setTagQuery(undefined);
} else {
utils.copyTextToClipboard(`#${tag.text} `);
locationService.setTagQuery(tag.text);
}
};
+42 -37
View File
@@ -1,6 +1,5 @@
import { useEffect } from "react";
import { createRoot, Root } from "react-dom/client";
import { TOAST_ANIMATION_DURATION } from "../helpers/consts";
import "../less/toast.less";
type ToastType = "normal" | "success" | "info" | "error";
@@ -36,42 +35,26 @@ const Toast: React.FC<ToastItemProps> = (props: ToastItemProps) => {
);
};
class ToastHelper {
private shownToastAmount = 0;
private toastWrapper: HTMLDivElement;
private shownToastContainers: [Root, HTMLDivElement][] = [];
// toast animation duration.
const TOAST_ANIMATION_DURATION = 400;
constructor() {
const wrapperClassName = "toast-list-container";
const tempDiv = document.createElement("div");
tempDiv.className = wrapperClassName;
document.body.appendChild(tempDiv);
this.toastWrapper = tempDiv;
}
const initialToastHelper = () => {
const shownToastContainers: [Root, HTMLDivElement][] = [];
let shownToastAmount = 0;
public info = (content: string, duration = 3000) => {
return this.showToast({ type: "normal", content, duration });
};
const wrapperClassName = "toast-list-container";
const tempDiv = document.createElement("div");
tempDiv.className = wrapperClassName;
document.body.appendChild(tempDiv);
const toastWrapper = tempDiv;
public success = (content: string, duration = 3000) => {
return this.showToast({ type: "success", content, duration });
};
public error = (content: string, duration = 3000) => {
return this.showToast({ type: "error", content, duration });
};
private showToast = (config: ToastConfig) => {
const showToast = (config: ToastConfig) => {
const tempDiv = document.createElement("div");
const toast = createRoot(tempDiv);
tempDiv.className = `toast-wrapper ${config.type}`;
this.toastWrapper.appendChild(tempDiv);
this.shownToastAmount++;
this.shownToastContainers.push([toast, tempDiv]);
setTimeout(() => {
tempDiv.classList.add("showup");
}, 0);
toastWrapper.appendChild(tempDiv);
shownToastAmount++;
shownToastContainers.push([toast, tempDiv]);
const cbs = {
destory: () => {
@@ -82,13 +65,13 @@ class ToastHelper {
return;
}
this.shownToastAmount--;
if (this.shownToastAmount === 0) {
for (const [root, tempDiv] of this.shownToastContainers) {
shownToastAmount--;
if (shownToastAmount === 0) {
for (const [root, tempDiv] of shownToastContainers) {
root.unmount();
tempDiv.remove();
}
this.shownToastContainers.splice(0, this.shownToastContainers.length);
shownToastContainers.splice(0, shownToastContainers.length);
}
}, TOAST_ANIMATION_DURATION);
},
@@ -96,10 +79,32 @@ class ToastHelper {
toast.render(<Toast {...config} destory={cbs.destory} />);
setTimeout(() => {
tempDiv.classList.add("showup");
}, 10);
return cbs;
};
}
const toastHelper = new ToastHelper();
const info = (content: string, duration = 3000) => {
return showToast({ type: "normal", content, duration });
};
const success = (content: string, duration = 3000) => {
return showToast({ type: "success", content, duration });
};
const error = (content: string, duration = -1) => {
return showToast({ type: "error", content, duration });
};
return {
info,
success,
error,
};
};
const toastHelper = initialToastHelper();
export default toastHelper;
+13 -9
View File
@@ -26,9 +26,7 @@ interface DailyUsageStat {
count: number;
}
interface Props {}
const UsageHeatMap: React.FC<Props> = () => {
const UsageHeatMap = () => {
const todayTimeStamp = utils.getDateStampByDate(Date.now());
const todayDay = new Date(todayTimeStamp).getDay() + 1;
const nullCell = new Array(7 - todayDay).fill(0);
@@ -101,19 +99,25 @@ const UsageHeatMap: React.FC<Props> = () => {
: "stat-day-L4-bg";
return (
<span
className={`stat-container ${colorLevel} ${currentStat === v ? "current" : ""} ${
todayTimeStamp === v.timestamp ? "today" : ""
}`}
<div
className="stat-wrapper"
key={i}
onMouseEnter={(e) => handleUsageStatItemMouseEnter(e, v)}
onMouseLeave={handleUsageStatItemMouseLeave}
onClick={() => handleUsageStatItemClick(v)}
></span>
>
<span
className={`stat-container ${colorLevel} ${currentStat === v ? "current" : ""} ${
todayTimeStamp === v.timestamp ? "today" : ""
}`}
></span>
</div>
);
})}
{nullCell.map((_, i) => (
<span className="stat-container null" key={i}></span>
<div className="stat-wrapper" key={i}>
<span className="stat-container null"></span>
</div>
))}
</div>
</div>
+71 -14
View File
@@ -1,18 +1,22 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
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 Dropdown from "./common/Dropdown";
import showResourcesDialog from "./ResourcesDialog";
import showArchivedMemoDialog from "./ArchivedMemoDialog";
import showAboutSiteDialog from "./AboutSiteDialog";
import "../less/user-banner.less";
interface Props {}
const UserBanner: React.FC<Props> = () => {
const UserBanner = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { user, owner } = useAppSelector((state) => state.user);
const { memos, tags } = useAppSelector((state) => state.memo);
const [shouldShowPopupBtns, setShouldShowPopupBtns] = useState(false);
const [username, setUsername] = useState("Memos");
const [createdDays, setCreatedDays] = useState(0);
const isVisitorMode = userService.isVisitorMode();
@@ -34,8 +38,27 @@ const UserBanner: React.FC<Props> = () => {
locationService.clearQuery();
}, []);
const handlePopupBtnClick = () => {
setShouldShowPopupBtns(true);
const handleResourcesBtnClick = () => {
showResourcesDialog();
};
const handleArchivedBtnClick = () => {
showArchivedMemoDialog();
};
const handleAboutBtnClick = () => {
showAboutSiteDialog();
};
const handleSignOutBtnClick = async () => {
userService
.doSignOut()
.then(() => {
navigate("/auth");
})
.catch(() => {
// do nth
});
};
return (
@@ -45,23 +68,57 @@ const UserBanner: React.FC<Props> = () => {
<span className="username-text">{username}</span>
{!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" />
</button>
<MenuBtnsPopup shownStatus={shouldShowPopupBtns} setShownStatus={setShouldShowPopupBtns} />
<Dropdown
trigger={<Icon.MoreHorizontal className="ml-2 w-5 h-auto cursor-pointer" />}
actionsClassName="!w-36"
actions={
<>
{!userService.isVisitorMode() && (
<>
<button
className="w-full px-3 whitespace-nowrap text-left leading-10 cursor-pointer rounded hover:bg-gray-100"
onClick={handleResourcesBtnClick}
>
<span className="mr-1">🌄</span> {t("sidebar.resources")}
</button>
<button
className="w-full px-3 whitespace-nowrap text-left leading-10 cursor-pointer rounded hover:bg-gray-100"
onClick={handleArchivedBtnClick}
>
<span className="mr-1">🗂</span> {t("sidebar.archived")}
</button>
</>
)}
<button
className="w-full px-3 whitespace-nowrap text-left leading-10 cursor-pointer rounded hover:bg-gray-100"
onClick={handleAboutBtnClick}
>
<span className="mr-1">🤠</span> {t("common.about")}
</button>
{!userService.isVisitorMode() && (
<button
className="w-full px-3 whitespace-nowrap text-left leading-10 cursor-pointer rounded hover:bg-gray-100"
onClick={handleSignOutBtnClick}
>
<span className="mr-1">👋</span> {t("common.sign-out")}
</button>
)}
</>
}
/>
</div>
<div className="amount-text-container">
<div className="status-text memos-text">
<span className="amount-text">{memos.length}</span>
<span className="type-text">MEMO</span>
<span className="type-text">{t("amount-text.memo")}</span>
</div>
<div className="status-text tags-text">
<span className="amount-text">{tags.length}</span>
<span className="type-text">TAG</span>
<span className="type-text">{t("amount-text.tag")}</span>
</div>
<div className="status-text duration-text">
<span className="amount-text">{createdDays}</span>
<span className="type-text">DAY</span>
<span className="type-text">{t("amount-text.day")}</span>
</div>
</div>
</>
+25 -10
View File
@@ -1,15 +1,16 @@
import { ReactNode, useEffect, useRef } from "react";
import useToggle from "../../hooks/useToggle";
import Icon from "../Icon";
import "../../less/common/dropdown.less";
interface DropdownProps {
children?: ReactNode;
interface Props {
trigger?: ReactNode;
actions?: ReactNode;
className?: string;
actionsClassName?: string;
}
const Dropdown: React.FC<DropdownProps> = (props: DropdownProps) => {
const { children, className } = props;
const Dropdown: React.FC<Props> = (props: Props) => {
const { trigger, actions, className, actionsClassName } = props;
const [dropdownStatus, toggleDropdownStatus] = useToggle(false);
const dropdownWrapperRef = useRef<HTMLDivElement>(null);
@@ -28,11 +29,25 @@ const Dropdown: React.FC<DropdownProps> = (props: DropdownProps) => {
}, [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
ref={dropdownWrapperRef}
className={`relative flex flex-col justify-start items-start select-none ${className ?? ""}`}
onClick={() => toggleDropdownStatus()}
>
{trigger ? (
trigger
) : (
<button className="flex flex-row justify-center items-center border p-1 rounded shadow text-gray-600 cursor-pointer hover:opacity-80">
<Icon.MoreHorizontal className="w-4 h-auto" />
</button>
)}
<div
className={`w-auto mt-1 absolute top-full right-0 flex flex-col justify-start items-start bg-white z-1 border p-1 rounded-md shadow ${
actionsClassName ?? ""
} ${dropdownStatus ? "" : "!hidden"}`}
>
{actions}
</div>
</div>
);
};
-15
View File
@@ -1,15 +0,0 @@
import { ReactNode } from "react";
interface OnlyWhenProps {
children: ReactNode;
when: boolean;
}
const OnlyWhen: React.FC<OnlyWhenProps> = (props: OnlyWhenProps) => {
const { children, when } = props;
return when ? <>{children}</> : null;
};
const Only = OnlyWhen;
export default Only;
+2 -2
View File
@@ -1,5 +1,5 @@
import { memo, useEffect, useRef } from "react";
import useI18n from "../../hooks/useI18n";
import { useTranslation } from "react-i18next";
import useToggle from "../../hooks/useToggle";
import Icon from "../Icon";
import "../../less/common/selector.less";
@@ -23,7 +23,7 @@ const nullItem = {
const Selector: React.FC<Props> = (props: Props) => {
const { className, dataSource, handleValueChanged, value } = props;
const { t } = useI18n();
const { t } = useTranslation();
const [showSelector, toggleSelectorStatus] = useToggle(false);
const seletorElRef = useRef<HTMLDivElement>(null);
+22
View File
@@ -58,6 +58,10 @@ export function deleteUser(userDelete: UserDelete) {
return axios.delete(`/api/user/${userDelete.id}`);
}
export function getAllMemos() {
return axios.get<ResponseObject<Memo[]>>("/api/memo/all");
}
export function getMemoList(memoFind?: MemoFind) {
const queryList = [];
if (memoFind?.creatorId) {
@@ -69,6 +73,10 @@ export function getMemoList(memoFind?: MemoFind) {
return axios.get<ResponseObject<Memo[]>>(`/api/memo?${queryList.join("&")}`);
}
export function getMemoById(id: MemoId) {
return axios.get<ResponseObject<Memo>>(`/api/memo/${id}`);
}
export function createMemo(memoCreate: MemoCreate) {
return axios.post<ResponseObject<Memo>>("/api/memo", memoCreate);
}
@@ -125,6 +133,20 @@ export function deleteResourceById(id: ResourceId) {
return axios.delete(`/api/resource/${id}`);
}
export function getMemoResourceList(memoId: MemoId) {
return axios.get<ResponseObject<Resource[]>>(`/api/memo/${memoId}/resource`);
}
export function upsertMemoResource(memoId: MemoId, resourceId: ResourceId) {
return axios.post<ResponseObject<Resource>>(`/api/memo/${memoId}/resource`, {
resourceId,
});
}
export function deleteMemoResource(memoId: MemoId, resourceId: ResourceId) {
return axios.delete(`/api/memo/${memoId}/resource/${resourceId}`);
}
export function getTagList(tagFind?: TagFind) {
const queryList = [];
if (tagFind?.creatorId) {
-3
View File
@@ -4,9 +4,6 @@ export const UNKNOWN_ID = -1;
// default animation duration
export const ANIMATION_DURATION = 200;
// toast animation duration
export const TOAST_ANIMATION_DURATION = 400;
// millisecond in a day
export const DAILY_TIMESTAMP = 3600 * 24 * 1000;
+2 -14
View File
@@ -1,4 +1,4 @@
import { IMAGE_URL_REG, LINK_URL_REG, MEMO_LINK_REG, TAG_REG } from "./marked";
import { TAG_REG, LINK_REG } from "../labs/marked/parser";
export const relationConsts = [
{ text: "And", value: "AND" },
@@ -34,10 +34,6 @@ export const filterConsts = {
},
],
values: [
{
text: "Connected",
value: "CONNECTED",
},
{
text: "No tags",
value: "NOT_TAGGED",
@@ -46,10 +42,6 @@ export const filterConsts = {
text: "Has links",
value: "LINKED",
},
{
text: "Has images",
value: "IMAGED",
},
],
},
TEXT: {
@@ -142,11 +134,7 @@ export const checkShouldShowMemo = (memo: Memo, filter: Filter) => {
let matched = false;
if (value === "NOT_TAGGED" && memo.content.match(TAG_REG) === null) {
matched = true;
} else if (value === "LINKED" && memo.content.match(LINK_URL_REG) !== null) {
matched = true;
} else if (value === "IMAGED" && memo.content.match(IMAGE_URL_REG) !== null) {
matched = true;
} else if (value === "CONNECTED" && memo.content.match(MEMO_LINK_REG) !== null) {
} else if (value === "LINKED" && memo.content.match(LINK_REG) !== null) {
matched = true;
}
if (operator === "IS_NOT") {
-69
View File
@@ -1,69 +0,0 @@
import { escape } from "lodash-es";
const CODE_BLOCK_REG = /```([\s\S]*?)```\n?/g;
const BOLD_TEXT_REG = /\*\*(.+?)\*\*/g;
const EM_TEXT_REG = /\*(.+?)\*/g;
const DOT_LI_REG = /[*-] /g;
const NUM_LI_REG = /(\d+)\. /g;
export const TODO_BLOCK_REG = /- \[ \] /g;
export const DONE_BLOCK_REG = /- \[x\] /g;
// tag regex
export const TAG_REG = /#([^\s#]+?) /g;
// markdown image regex
export const IMAGE_URL_REG = /!\[.*?\]\((.+?)\)\n?/g;
// markdown link regex
export const LINK_URL_REG = /\[(.*?)\]\((.+?)\)/g;
// linked memo regex
export const MEMO_LINK_REG = /@\[(.+?)\]\((.+?)\)/g;
const parseMarkedToHtml = (markedStr: string): string => {
const htmlText = markedStr
.replace(/([\u4e00-\u9fa5])([A-Za-z0-9?.,;[\]]+)/g, "$1 $2")
.replace(/([A-Za-z0-9?.,;[\]]+)([\u4e00-\u9fa5])/g, "$1 $2")
.replace(CODE_BLOCK_REG, "<pre lang=''>$1</pre>")
.replace(TODO_BLOCK_REG, "<span class='todo-block todo' data-value='TODO'></span>")
.replace(DONE_BLOCK_REG, "<span class='todo-block done' data-value='DONE'>✓</span>")
.replace(DOT_LI_REG, "<span class='counter-block'>•</span>")
.replace(NUM_LI_REG, "<span class='counter-block'>$1.</span>")
.replace(BOLD_TEXT_REG, "<strong>$1</strong>")
.replace(EM_TEXT_REG, "<em>$1</em>");
return htmlText;
};
const parseHtmlToRawText = (htmlStr: string): string => {
const tempEl = document.createElement("div");
tempEl.className = "memo-content-text";
tempEl.innerHTML = htmlStr;
const text = tempEl.innerText;
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 };
+13 -109
View File
@@ -1,4 +1,8 @@
import { assign } from "lodash-es";
import { assign, isNull, isUndefined } from "lodash-es";
export const isNullorUndefined = (value: any) => {
return isNull(value) || isUndefined(value);
};
export function getNowTimeStamp(): number {
return Date.now();
@@ -76,114 +80,6 @@ export function getDateTimeString(t: Date | number | string): string {
return `${year}/${monthStr}/${dateStr} ${hoursStr}:${minsStr}:${secsStr}`;
}
export function dedupe<T>(data: T[]): T[] {
return Array.from(new Set(data));
}
export function dedupeObjectWithId<T extends { id: string | number }>(data: T[]): T[] {
const idSet = new Set<string | number>();
const result = [];
for (const d of data) {
if (!idSet.has(d.id)) {
idSet.add(d.id);
result.push(d);
}
}
return result;
}
export function debounce(fn: FunctionType, delay: number) {
let timer: number | null = null;
return () => {
if (timer) {
clearTimeout(timer);
timer = setTimeout(fn, delay);
} else {
timer = setTimeout(fn, delay);
}
};
}
export function throttle(fn: FunctionType, delay: number) {
let valid = true;
return () => {
if (!valid) {
return false;
}
valid = false;
setTimeout(() => {
fn();
valid = true;
}, delay);
};
}
export function filterObjectNullKeys(object: KVObject): KVObject {
if (!object) {
return {};
}
const finalObject: KVObject = {};
const keys = Object.keys(object).sort();
for (const key of keys) {
const val = object[key];
if (typeof val === "object") {
const temp = filterObjectNullKeys(JSON.parse(JSON.stringify(val)));
if (temp && Object.keys(temp).length > 0) {
finalObject[key] = temp;
}
} else {
if (val) {
finalObject[key] = val;
}
}
}
return finalObject;
}
export async function copyTextToClipboard(text: string) {
if (navigator.clipboard && navigator.clipboard.writeText) {
try {
await navigator.clipboard.writeText(text);
} catch (error: unknown) {
console.warn("Copy to clipboard failed.", error);
}
} else {
console.warn("Copy to clipboard failed, methods not supports.");
}
}
export function getImageSize(src: string): Promise<{ width: number; height: number }> {
return new Promise((resolve) => {
const imgEl = new Image();
imgEl.onload = () => {
const { width, height } = imgEl;
if (width > 0 && height > 0) {
resolve({ width, height });
} else {
resolve({ width: 0, height: 0 });
}
};
imgEl.onerror = () => {
resolve({ width: 0, height: 0 });
};
imgEl.className = "hidden";
imgEl.src = src;
document.body.appendChild(imgEl);
imgEl.remove();
});
}
export const getElementBounding = (element: HTMLElement, relativeEl?: HTMLElement) => {
const scrollTop = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop;
const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft || document.body.scrollLeft;
@@ -232,3 +128,11 @@ export const getElementBounding = (element: HTMLElement, relativeEl?: HTMLElemen
left: elementRect.left + scrollLeft,
});
};
export const parseHTMLToRawText = (htmlStr: string): string => {
const tempEl = document.createElement("div");
tempEl.className = "memo-content-text";
tempEl.innerHTML = htmlStr;
const text = tempEl.innerText;
return text;
};
-3
View File
@@ -1,3 +0,0 @@
import useI18n from "../labs/i18n/useI18n";
export default useI18n;
+13
View File
@@ -0,0 +1,13 @@
// A custom hook that builds on useLocation to parse
import React from "react";
import { useLocation } from "react-router-dom";
// the query string for you.
const useQuery = () => {
const { search } = useLocation();
return React.useMemo(() => new URLSearchParams(search), [search]);
};
export default useQuery;
+23
View File
@@ -0,0 +1,23 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import enLocale from "./locales/en.json";
import zhLocale from "./locales/zh.json";
import viLocale from "./locales/vi.json";
i18n.use(initReactI18next).init({
resources: {
en: {
translation: enLocale,
},
zh: {
translation: zhLocale,
},
vi: {
translation: viLocale,
},
},
lng: "en",
fallbackLng: "en",
});
export default i18n;
-27
View File
@@ -1,27 +0,0 @@
import { createContext, useEffect, useState } from "react";
import i18nStore from "./i18nStore";
interface Props {
children: React.ReactElement;
}
const i18nContext = createContext(i18nStore.getState());
const I18nProvider: React.FC<Props> = (props: Props) => {
const { children } = props;
const [i18nState, setI18nState] = useState(i18nStore.getState());
useEffect(() => {
const unsubscribe = i18nStore.subscribe((ns) => {
setI18nState(ns);
});
return () => {
unsubscribe();
};
}, []);
return <i18nContext.Provider value={i18nState}>{children}</i18nContext.Provider>;
};
export default I18nProvider;
-52
View File
@@ -1,52 +0,0 @@
type I18nState = Readonly<{
locale: string;
}>;
type Listener = (ns: I18nState, ps?: I18nState) => void;
const createI18nStore = (preloadedState: I18nState) => {
const listeners: Listener[] = [];
let currentState = preloadedState;
const getState = () => {
return currentState;
};
const setState = (state: Partial<I18nState>) => {
const nextState = {
...currentState,
...state,
};
const prevState = currentState;
currentState = nextState;
for (const cb of listeners) {
cb(currentState, prevState);
}
};
const subscribe = (listener: Listener) => {
let isSubscribed = true;
listeners.push(listener);
const unsubscribe = () => {
if (!isSubscribed) {
return;
}
const index = listeners.indexOf(listener);
listeners.splice(index, 1);
isSubscribed = false;
};
return unsubscribe;
};
return {
getState,
setState,
subscribe,
};
};
export default createI18nStore;
-9
View File
@@ -1,9 +0,0 @@
import createI18nStore from "./createI18nStore";
const defaultI18nState = {
locale: "en",
};
const i18nStore = createI18nStore(defaultI18nState);
export default i18nStore;
-55
View File
@@ -1,55 +0,0 @@
import { useEffect, useState } from "react";
import i18nStore from "./i18nStore";
import enLocale from "../../locales/en.json";
import zhLocale from "../../locales/zh.json";
const resources: Record<string, any> = {
en: enLocale,
zh: zhLocale,
};
const useI18n = () => {
const [{ locale }, setState] = useState(i18nStore.getState());
useEffect(() => {
const unsubscribe = i18nStore.subscribe((ns) => {
setState(ns);
});
return () => {
unsubscribe();
};
}, []);
const translate = (key: string) => {
const keys = key.split(".");
let value = resources[locale];
for (const k of keys) {
if (value) {
value = value[k];
} else {
return key;
}
}
if (value) {
return value;
} else {
return key;
}
};
const setLocale = (locale: Locale) => {
i18nStore.setState({
locale,
});
};
return {
t: translate,
locale,
setLocale,
};
};
export default useI18n;
+18
View File
@@ -0,0 +1,18 @@
import { parserList } from "./parser";
export const marked = (markdownStr: string, parsers = parserList) => {
for (const parser of parsers) {
const startIndex = markdownStr.search(parser.regex);
const matchedLength = parser.match(markdownStr);
if (startIndex > -1 && matchedLength > 0) {
const prefixStr = markdownStr.slice(0, startIndex);
const matchedStr = markdownStr.slice(startIndex, startIndex + matchedLength);
const suffixStr = markdownStr.slice(startIndex + matchedLength);
markdownStr = marked(prefixStr, parsers) + parser.renderer(matchedStr) + marked(suffixStr, parsers);
break;
}
}
return markdownStr;
};
+89
View File
@@ -0,0 +1,89 @@
import { describe, expect, test } from "@jest/globals";
import { marked } from ".";
describe("test marked parser", () => {
test("parse code block", () => {
const tests = [
{
markdown: `\`\`\`
hello world!
\`\`\``,
want: `<pre lang=''>
hello world!
</pre>`,
},
{
markdown: `test code block
\`\`\`js
console.log("hello world!")
\`\`\``,
want: `<p>test code block</p>
<p></p>
<pre lang='js'>
console.log("hello world!")
</pre>`,
},
];
for (const t of tests) {
expect(marked(t.markdown)).toBe(t.want);
}
});
test("parse todo list block", () => {
const tests = [
{
markdown: `My task:
- [ ] finish my homework
- [x] yahaha`,
want: `<p>My task:</p>
<p><span class='todo-block todo' data-value='TODO'></span>finish my homework</p>
<p><span class='todo-block done' data-value='DONE'>✓</span>yahaha</p>`,
},
];
for (const t of tests) {
expect(marked(t.markdown)).toBe(t.want);
}
});
test("parse list block", () => {
const tests = [
{
markdown: `This is a list
* list 123
1. 123123`,
want: `<p>This is a list</p>
<p><span class='ul-block'>•</span>list 123</p>
<p><span class='ol-block'>1.</span>123123</p>`,
},
];
for (const t of tests) {
expect(marked(t.markdown)).toBe(t.want);
}
});
test("parse inline element", () => {
const tests = [
{
markdown: `Link: [baidu](https://baidu.com)`,
want: `<p>Link: <a class='link' target='_blank' rel='noreferrer' href='https://baidu.com'>baidu</a></p>`,
},
];
for (const t of tests) {
expect(marked(t.markdown)).toBe(t.want);
}
});
test("parse plain link", () => {
const tests = [
{
markdown: `Link:https://baidu.com`,
want: `<p>Link:<a class='link' target='_blank' rel='noreferrer' href='https://baidu.com'>https://baidu.com</a></p>`,
},
];
for (const t of tests) {
expect(marked(t.markdown)).toBe(t.want);
}
});
});
+23
View File
@@ -0,0 +1,23 @@
export const BOLD_REG = /\*\*([\S ]+?)\*\*/;
const match = (rawStr: string): number => {
const matchResult = rawStr.match(BOLD_REG);
if (!matchResult) {
return 0;
}
const matchStr = matchResult[0];
return matchStr.length;
};
const renderer = (rawStr: string): string => {
const parsedStr = rawStr.replace(BOLD_REG, "<strong>$1</strong>");
return parsedStr;
};
export default {
name: "bold",
regex: BOLD_REG,
match,
renderer,
};
+23
View File
@@ -0,0 +1,23 @@
export const CODE_BLOCK_REG = /^```(\S*?)\s([\s\S]*?)```(\n?)/;
const match = (rawStr: string): number => {
const matchResult = rawStr.match(CODE_BLOCK_REG);
if (!matchResult) {
return 0;
}
const matchStr = matchResult[0];
return matchStr.length;
};
const renderer = (rawStr: string): string => {
const parsedStr = rawStr.replace(CODE_BLOCK_REG, "<pre lang='$1'>\n$2</pre>$3");
return parsedStr;
};
export default {
name: "code block",
regex: CODE_BLOCK_REG,
match,
renderer,
};

Some files were not shown because too many files have changed in this diff Show More