Compare commits
141 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 82f61f2a0e | |||
| c5368fe8d3 | |||
| 0aaf153717 | |||
| 942e1f887b | |||
| b8ab43aa25 | |||
| a5f3b051f2 | |||
| 4ba9767b94 | |||
| 12fda38520 | |||
| 9ed503fd6d | |||
| a8976de634 | |||
| f8855ddb56 | |||
| 288ecc617d | |||
| 14ec81b65c | |||
| fae0b64a08 | |||
| 677750ef51 | |||
| 4cfd000b92 | |||
| aacaf3f37c | |||
| ad4a79a510 | |||
| 219d2754a0 | |||
| 10430a66c3 | |||
| c167c21e4e | |||
| 40d25f7dca | |||
| 1441a1df1f | |||
| b19c3c6db3 | |||
| 8c146aed68 | |||
| 805122f45c | |||
| 7d5de1a07e | |||
| 4b860777cf | |||
| e29924c8a1 | |||
| 1847756ade | |||
| 771c56f485 | |||
| 0f057e81e9 | |||
| 529c9b34a7 | |||
| e2e8130f4c | |||
| 46c13a4b7f | |||
| 96798e10b4 | |||
| 0f8ce3dd16 | |||
| 491859bbf6 | |||
| f16123a624 | |||
| d50ad9433f | |||
| 92a8a4ac0c | |||
| 62f53888ba | |||
| 79180928d4 | |||
| e5550828a0 | |||
| 2e95f6824f | |||
| 5195012217 | |||
| a797280e3f | |||
| 293f88e40c | |||
| 861eeb7b0f | |||
| 24b21aa9d7 | |||
| 51eac649c5 | |||
| 7670c95360 | |||
| 65e9fdead1 | |||
| 2b2792de73 | |||
| c9bb2b785d | |||
| 64e5c343c5 | |||
| 9169b3f2cd | |||
| b6f7a85a2a | |||
| 3556ae4e65 | |||
| f888c62840 | |||
| c160bed403 | |||
| afc9709484 | |||
| 05b41804e3 | |||
| 2e2657b39d | |||
| 60ee602639 | |||
| cac04e4406 | |||
| 278b4d21b4 | |||
| 27fd1e2880 | |||
| fae9b3db46 | |||
| 49c7f49820 | |||
| ef8981794e | |||
| e52d77b2c4 | |||
| 1d2953b1b1 | |||
| d702eaa625 | |||
| 50811c3064 | |||
| 99d9cc9168 | |||
| 119603da5d | |||
| f6039f2eb9 | |||
| 65cc19c12e | |||
| c07b4a57ca | |||
| dca35bde87 | |||
| 9f25badde3 | |||
| 7efa749c66 | |||
| 72daa4e1d6 | |||
| 54702db9ba | |||
| 41ad084489 | |||
| 2fb171e069 | |||
| 201c0b020d | |||
| b6f19ca093 | |||
| 68a77b6e1f | |||
| e4a8a4d708 | |||
| ab07c91d42 | |||
| 1838e616fd | |||
| 90d0ccc2e8 | |||
| 358a5c0ed9 | |||
| 40f39fd66c | |||
| 3b41976866 | |||
| a23de50bb8 | |||
| 6596e6893e | |||
| b6fe4d914e | |||
| 3c2cd43d28 | |||
| 2658b1fd09 | |||
| b7df1f5bbf | |||
| a0face6695 | |||
| c177db69d5 | |||
| b704c20809 | |||
| 6c17f94ef6 | |||
| 726285e634 | |||
| bd6ab71d41 | |||
| b67ed1ee13 | |||
| 55695f2189 | |||
| e79d67d127 | |||
| 1d9ef9813a | |||
| ef621a444f | |||
| bd00fa798d | |||
| a41745c9ae | |||
| 1eec474007 | |||
| 83e5278b51 | |||
| a8751af6b5 | |||
| 6b24f52cd1 | |||
| 7ec22482c1 | |||
| ee89dc00c0 | |||
| bbd5fe4eb2 | |||
| 575a0610a3 | |||
| b68cc08592 | |||
| d51af7e98a | |||
| 334da5e903 | |||
| 35fed76d1a | |||
| c77d49259a | |||
| 5520605ccc | |||
| 1dee8ae49f | |||
| 3fd4ee83ac | |||
| 5e978e2cfc | |||
| 37b7b983d2 | |||
| c4278ef55a | |||
| 91220ea4a6 | |||
| 4bebbf3e1d | |||
| 5d8b8c37a5 | |||
| 564f20d13a | |||
| c3adb1b152 | |||
| 688dc2304c |
@@ -1,2 +1 @@
|
||||
web/node_modules
|
||||
web/yarn.lock
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
github: usememos
|
||||
ko_fi: stevenlgtm
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
name: Backend Test
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- "release/*.*.*"
|
||||
|
||||
jobs:
|
||||
go-static-checks:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.19
|
||||
check-latest: true
|
||||
cache: true
|
||||
- name: Verify go.mod is tidy
|
||||
run: |
|
||||
go mod tidy -go=1.19
|
||||
git diff --exit-code
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v3
|
||||
with:
|
||||
args: -v
|
||||
skip-cache: true
|
||||
|
||||
go-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.19
|
||||
check-latest: true
|
||||
cache: true
|
||||
- name: Run all tests
|
||||
run: go test -v ./... | tee test.log; exit ${PIPESTATUS[0]}
|
||||
- name: Pretty print tests running time
|
||||
run: grep --color=never -e '--- PASS:' -e '--- FAIL:' test.log | sed 's/[:()]//g' | awk '{print $2,$3,$4}' | sort -t' ' -nk3 -r | awk '{sum += $3; print $1,$2,$3,sum"s"}'
|
||||
@@ -1,12 +1,10 @@
|
||||
name: Test
|
||||
name: Frontend Test
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- "release/v*.*.*"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
- "release/*.*.*"
|
||||
|
||||
jobs:
|
||||
eslint-checks:
|
||||
@@ -53,36 +51,3 @@ jobs:
|
||||
- name: Run frontend build
|
||||
run: yarn build
|
||||
working-directory: web
|
||||
|
||||
go-static-checks:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.19
|
||||
check-latest: true
|
||||
cache: true
|
||||
- name: Verify go.mod is tidy
|
||||
run: |
|
||||
go mod tidy -go=1.19
|
||||
git diff --exit-code
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v3
|
||||
with:
|
||||
args: -v
|
||||
skip-cache: true
|
||||
|
||||
go-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.19
|
||||
check-latest: true
|
||||
cache: true
|
||||
- name: Run all tests
|
||||
run: go test -v ./... | tee test.log; exit ${PIPESTATUS[0]}
|
||||
- name: Pretty print tests running time
|
||||
run: grep --color=never -e '--- PASS:' -e '--- FAIL:' test.log | sed 's/[:()]//g' | awk '{print $2,$3,$4}' | sort -t' ' -nk3 -r | awk '{sum += $3; print $1,$2,$3,sum"s"}'
|
||||
@@ -0,0 +1,18 @@
|
||||
name: 'issue-translator'
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: usthe/issues-translate-action@v2.7
|
||||
with:
|
||||
IS_MODIFY_TITLE: false
|
||||
# not require, default false, . Decide whether to modify the issue title
|
||||
# if true, the robot account @Issues-translate-bot must have modification permissions, invite @Issues-translate-bot to your project or use your custom bot.
|
||||
CUSTOM_BOT_NOTE: Issue is not in English. It has been translated automatically.
|
||||
# not require. Customize the translation robot prefix message.
|
||||
+2
-4
@@ -4,15 +4,13 @@ WORKDIR /frontend-build
|
||||
|
||||
COPY ./web/ .
|
||||
|
||||
RUN yarn
|
||||
RUN yarn build
|
||||
RUN yarn && yarn build
|
||||
|
||||
# Build backend exec file.
|
||||
FROM golang:1.19.3-alpine3.16 AS backend
|
||||
WORKDIR /backend-build
|
||||
|
||||
RUN apk update
|
||||
RUN apk --no-cache add gcc musl-dev
|
||||
RUN apk update && apk add --no-cache gcc musl-dev
|
||||
|
||||
COPY . .
|
||||
COPY --from=frontend /frontend-build/dist ./server/dist
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<p align="center">
|
||||
<a href="https://github.com/usememos/memos/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/usememos/memos" /></a>
|
||||
<a href="https://hub.docker.com/r/neosmemo/memos"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/neosmemo/memos.svg" /></a>
|
||||
<img alt="Go report" src="https://goreportcard.com/badge/github.com/usememos/memos" />
|
||||
<a href="https://discord.gg/tfPJa4UmAv"><img alt="Discord" src="https://img.shields.io/badge/discord-chat-5865f2?logo=discord&logoColor=f5f5f5" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -13,7 +13,7 @@
|
||||
Discuss in <a href="https://t.me/+-_tNF1k70UU4ZTc9">Telegram</a> / <b><a href="https://discord.gg/tfPJa4UmAv">Discord 🏂</a></b>
|
||||
</p>
|
||||
|
||||

|
||||

|
||||
|
||||
## Features
|
||||
|
||||
@@ -31,6 +31,8 @@
|
||||
docker run -d --name memos -p 5230:5230 -v ~/.memos/:/var/opt/memos neosmemo/memos:latest
|
||||
```
|
||||
|
||||
> The `~/.memos/` will be used as the data directory in your machine. And `/var/opt/memos` is the directory of the volume in docker and should not be modified.
|
||||
|
||||
If the `~/.memos/` does not have a `memos_prod.db` file, then memos will auto generate it. Memos will be running at [http://localhost:5230](http://localhost:5230).
|
||||
|
||||
### Docker Compose
|
||||
@@ -43,11 +45,16 @@ If you want to upgrade the version of memos, use the following command.
|
||||
docker-compose down && docker image rm neosmemo/memos:latest && docker-compose up -d
|
||||
```
|
||||
|
||||
### Other guides
|
||||
|
||||
- [Deploy on render.com](./docs/deploy-with-render.md)
|
||||
- [Deploy on fly.io](https://github.com/hu3rror/memos-on-fly)
|
||||
|
||||
## Contribute
|
||||
|
||||
Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are greatly appreciated. 🥰
|
||||
|
||||
See more in [development guide](https://github.com/usememos/memos/tree/main/docs/development.md).
|
||||
See more in [development guide](./docs/development.md).
|
||||
|
||||
## Products made by Community
|
||||
|
||||
@@ -58,6 +65,10 @@ See more in [development guide](https://github.com/usememos/memos/tree/main/docs
|
||||
- [eallion/memos.top](https://github.com/eallion/memos.top) - A static page rendered with the Memos API
|
||||
- [eindex/logseq-memos-sync](https://github.com/EINDEX/logseq-memos-sync) - A Logseq plugin
|
||||
|
||||
## User stories
|
||||
|
||||
- [Memos - A Twitter Like Notes App You can Self Host](https://noted.lol/memos/)
|
||||
|
||||
### Join the community to build memos together!
|
||||
|
||||
<a href="https://github.com/usememos/memos/graphs/contributors">
|
||||
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
package api
|
||||
|
||||
import "github.com/usememos/memos/server/profile"
|
||||
|
||||
// ActivityType is the type for an activity.
|
||||
type ActivityType string
|
||||
|
||||
const (
|
||||
// User related.
|
||||
|
||||
// ActivityUserCreate is the type for creating users.
|
||||
ActivityUserCreate ActivityType = "user.create"
|
||||
// ActivityUserUpdate is the type for updating users.
|
||||
ActivityUserUpdate ActivityType = "user.update"
|
||||
// ActivityUserDelete is the type for deleting users.
|
||||
ActivityUserDelete ActivityType = "user.delete"
|
||||
// ActivityUserAuthSignIn is the type for user signin.
|
||||
ActivityUserAuthSignIn ActivityType = "user.auth.signin"
|
||||
// ActivityUserAuthSignUp is the type for user signup.
|
||||
ActivityUserAuthSignUp ActivityType = "user.auth.signup"
|
||||
// ActivityUserSettingUpdate is the type for updating user settings.
|
||||
ActivityUserSettingUpdate ActivityType = "user.setting.update"
|
||||
|
||||
// Memo related.
|
||||
|
||||
// ActivityMemoCreate is the type for creating memos.
|
||||
ActivityMemoCreate ActivityType = "memo.create"
|
||||
// ActivityMemoUpdate is the type for updating memos.
|
||||
ActivityMemoUpdate ActivityType = "memo.update"
|
||||
// ActivityMemoDelete is the type for deleting memos.
|
||||
ActivityMemoDelete ActivityType = "memo.delete"
|
||||
|
||||
// Shortcut related.
|
||||
|
||||
// ActivityShortcutCreate is the type for creating shortcuts.
|
||||
ActivityShortcutCreate ActivityType = "shortcut.create"
|
||||
// ActivityShortcutUpdate is the type for updating shortcuts.
|
||||
ActivityShortcutUpdate ActivityType = "shortcut.update"
|
||||
// ActivityShortcutDelete is the type for deleting shortcuts.
|
||||
ActivityShortcutDelete ActivityType = "shortcut.delete"
|
||||
|
||||
// Resource related.
|
||||
|
||||
// ActivityResourceCreate is the type for creating resources.
|
||||
ActivityResourceCreate ActivityType = "resource.create"
|
||||
// ActivityResourceDelete is the type for deleting resources.
|
||||
ActivityResourceDelete ActivityType = "resource.delete"
|
||||
|
||||
// Tag related.
|
||||
|
||||
// ActivityTagCreate is the type for creating tags.
|
||||
ActivityTagCreate ActivityType = "tag.create"
|
||||
// ActivityTagDelete is the type for deleting tags.
|
||||
ActivityTagDelete ActivityType = "tag.delete"
|
||||
|
||||
// Server related.
|
||||
|
||||
// ActivityServerStart is the type for starting server.
|
||||
ActivityServerStart ActivityType = "server.start"
|
||||
)
|
||||
|
||||
// ActivityLevel is the level of activities.
|
||||
type ActivityLevel string
|
||||
|
||||
const (
|
||||
// ActivityInfo is the INFO level of activities.
|
||||
ActivityInfo ActivityLevel = "INFO"
|
||||
// ActivityWarn is the WARN level of activities.
|
||||
ActivityWarn ActivityLevel = "WARN"
|
||||
// ActivityError is the ERROR level of activities.
|
||||
ActivityError ActivityLevel = "ERROR"
|
||||
)
|
||||
|
||||
type ActivityUserCreatePayload struct {
|
||||
UserID int `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
Role Role `json:"role"`
|
||||
}
|
||||
|
||||
type ActivityUserAuthSignInPayload struct {
|
||||
UserID int `json:"userId"`
|
||||
IP string `json:"ip"`
|
||||
}
|
||||
|
||||
type ActivityUserAuthSignUpPayload struct {
|
||||
Username string `json:"username"`
|
||||
IP string `json:"ip"`
|
||||
}
|
||||
|
||||
type ActivityMemoCreatePayload struct {
|
||||
Content string `json:"content"`
|
||||
Visibility string `json:"visibility"`
|
||||
}
|
||||
|
||||
type ActivityShortcutCreatePayload struct {
|
||||
Title string `json:"title"`
|
||||
Payload string `json:"payload"`
|
||||
}
|
||||
|
||||
type ActivityResourceCreatePayload struct {
|
||||
Filename string `json:"filename"`
|
||||
Type string `json:"type"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
type ActivityTagCreatePayload struct {
|
||||
TagName string `json:"tagName"`
|
||||
}
|
||||
|
||||
type ActivityServerStartPayload struct {
|
||||
ServerID string `json:"serverId"`
|
||||
Profile *profile.Profile `json:"profile"`
|
||||
}
|
||||
|
||||
type Activity struct {
|
||||
ID int `json:"id"`
|
||||
|
||||
// Standard fields
|
||||
CreatorID int `json:"creatorId"`
|
||||
CreatedTs int64 `json:"createdTs"`
|
||||
|
||||
// Domain specific fields
|
||||
Type ActivityType `json:"type"`
|
||||
Level ActivityLevel `json:"level"`
|
||||
Payload string `json:"payload"`
|
||||
}
|
||||
|
||||
// ActivityCreate is the API message for creating an activity.
|
||||
type ActivityCreate struct {
|
||||
// Standard fields
|
||||
CreatorID int
|
||||
|
||||
// Domain specific fields
|
||||
Type ActivityType `json:"type"`
|
||||
Level ActivityLevel
|
||||
Payload string `json:"payload"`
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
package api
|
||||
|
||||
// UnknownID is the ID for unknowns.
|
||||
const UnknownID = -1
|
||||
|
||||
// RowStatus is the status for a row.
|
||||
type RowStatus string
|
||||
|
||||
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
package api
|
||||
|
||||
type Signin struct {
|
||||
type SignIn struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type Signup struct {
|
||||
type SignUp struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Role Role `json:"role"`
|
||||
|
||||
+5
-5
@@ -46,7 +46,7 @@ type Memo struct {
|
||||
|
||||
type MemoCreate struct {
|
||||
// Standard fields
|
||||
CreatorID int
|
||||
CreatorID int `json:"-"`
|
||||
|
||||
// Domain specific fields
|
||||
Visibility Visibility `json:"visibility"`
|
||||
@@ -57,7 +57,7 @@ type MemoCreate struct {
|
||||
}
|
||||
|
||||
type MemoPatch struct {
|
||||
ID int
|
||||
ID int `json:"-"`
|
||||
|
||||
// Standard fields
|
||||
CreatedTs *int64 `json:"createdTs"`
|
||||
@@ -73,11 +73,11 @@ type MemoPatch struct {
|
||||
}
|
||||
|
||||
type MemoFind struct {
|
||||
ID *int `json:"id"`
|
||||
ID *int
|
||||
|
||||
// Standard fields
|
||||
RowStatus *RowStatus `json:"rowStatus"`
|
||||
CreatorID *int `json:"creatorId"`
|
||||
RowStatus *RowStatus
|
||||
CreatorID *int
|
||||
|
||||
// Domain specific fields
|
||||
Pinned *bool
|
||||
|
||||
@@ -9,17 +9,17 @@ type MemoOrganizer struct {
|
||||
Pinned bool
|
||||
}
|
||||
|
||||
type MemoOrganizerUpsert struct {
|
||||
MemoID int `json:"-"`
|
||||
UserID int `json:"-"`
|
||||
Pinned bool `json:"pinned"`
|
||||
}
|
||||
|
||||
type MemoOrganizerFind struct {
|
||||
MemoID int
|
||||
UserID int
|
||||
}
|
||||
|
||||
type MemoOrganizerUpsert struct {
|
||||
MemoID int
|
||||
UserID int
|
||||
Pinned bool `json:"pinned"`
|
||||
}
|
||||
|
||||
type MemoOrganizerDelete struct {
|
||||
MemoID *int
|
||||
UserID *int
|
||||
|
||||
@@ -8,7 +8,7 @@ type MemoResource struct {
|
||||
}
|
||||
|
||||
type MemoResourceUpsert struct {
|
||||
MemoID int
|
||||
MemoID int `json:"-"`
|
||||
ResourceID int
|
||||
UpdatedTs *int64
|
||||
}
|
||||
|
||||
+12
-10
@@ -9,10 +9,11 @@ type Resource struct {
|
||||
UpdatedTs int64 `json:"updatedTs"`
|
||||
|
||||
// Domain specific fields
|
||||
Filename string `json:"filename"`
|
||||
Blob []byte `json:"-"`
|
||||
Type string `json:"type"`
|
||||
Size int64 `json:"size"`
|
||||
Filename string `json:"filename"`
|
||||
Blob []byte `json:"-"`
|
||||
ExternalLink string `json:"externalLink"`
|
||||
Type string `json:"type"`
|
||||
Size int64 `json:"size"`
|
||||
|
||||
// Related fields
|
||||
LinkedMemoAmount int `json:"linkedMemoAmount"`
|
||||
@@ -20,13 +21,14 @@ type Resource struct {
|
||||
|
||||
type ResourceCreate struct {
|
||||
// Standard fields
|
||||
CreatorID int
|
||||
CreatorID int `json:"-"`
|
||||
|
||||
// Domain specific fields
|
||||
Filename string `json:"filename"`
|
||||
Blob []byte `json:"blob"`
|
||||
Type string `json:"type"`
|
||||
Size int64 `json:"size"`
|
||||
Filename string `json:"filename"`
|
||||
Blob []byte `json:"-"`
|
||||
ExternalLink string `json:"externalLink"`
|
||||
Type string `json:"-"`
|
||||
Size int64 `json:"-"`
|
||||
}
|
||||
|
||||
type ResourceFind struct {
|
||||
@@ -41,7 +43,7 @@ type ResourceFind struct {
|
||||
}
|
||||
|
||||
type ResourcePatch struct {
|
||||
ID int
|
||||
ID int `json:"-"`
|
||||
|
||||
// Standard fields
|
||||
UpdatedTs *int64
|
||||
|
||||
+2
-2
@@ -16,7 +16,7 @@ type Shortcut struct {
|
||||
|
||||
type ShortcutCreate struct {
|
||||
// Standard fields
|
||||
CreatorID int
|
||||
CreatorID int `json:"-"`
|
||||
|
||||
// Domain specific fields
|
||||
Title string `json:"title"`
|
||||
@@ -24,7 +24,7 @@ type ShortcutCreate struct {
|
||||
}
|
||||
|
||||
type ShortcutPatch struct {
|
||||
ID int
|
||||
ID int `json:"-"`
|
||||
|
||||
// Standard fields
|
||||
UpdatedTs *int64
|
||||
|
||||
@@ -14,4 +14,6 @@ type SystemStatus struct {
|
||||
AdditionalStyle string `json:"additionalStyle"`
|
||||
// Additional script.
|
||||
AdditionalScript string `json:"additionalScript"`
|
||||
// Customized server profile, including server name and external url.
|
||||
CustomizedProfile CustomizedProfile `json:"customizedProfile"`
|
||||
}
|
||||
|
||||
+54
-2
@@ -2,28 +2,59 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
type SystemSettingName string
|
||||
|
||||
const (
|
||||
// SystemSettingServerID is the key type of server id.
|
||||
SystemSettingServerID SystemSettingName = "serverId"
|
||||
// SystemSettingSecretSessionName is the key type of secret session name.
|
||||
SystemSettingSecretSessionName SystemSettingName = "secretSessionName"
|
||||
// SystemSettingAllowSignUpName is the key type of allow signup setting.
|
||||
SystemSettingAllowSignUpName SystemSettingName = "allowSignUp"
|
||||
// SystemSettingAdditionalStyleName is the key type of additional style.
|
||||
SystemSettingAdditionalStyleName SystemSettingName = "additionalStyle"
|
||||
// SystemSettingAdditionalScriptName is the key type of additional script.
|
||||
SystemSettingAdditionalScriptName SystemSettingName = "additionalScript"
|
||||
// SystemSettingCustomizedProfileName is the key type of customized server profile.
|
||||
SystemSettingCustomizedProfileName SystemSettingName = "customizedProfile"
|
||||
)
|
||||
|
||||
// CustomizedProfile is the struct definition for SystemSettingCustomizedProfileName system setting item.
|
||||
type CustomizedProfile struct {
|
||||
// Name is the server name, default is `memos`
|
||||
Name string `json:"name"`
|
||||
// LogoURL is the url of logo image.
|
||||
LogoURL string `json:"logoUrl"`
|
||||
// Description is the server description.
|
||||
Description string `json:"description"`
|
||||
// Locale is the server default locale.
|
||||
Locale string `json:"locale"`
|
||||
// Appearance is the server default appearance.
|
||||
Appearance string `json:"appearance"`
|
||||
// ExternalURL is the external url of server. e.g. https://usermemos.com
|
||||
ExternalURL string `json:"externalUrl"`
|
||||
}
|
||||
|
||||
func (key SystemSettingName) String() string {
|
||||
switch key {
|
||||
case SystemSettingServerID:
|
||||
return "serverId"
|
||||
case SystemSettingSecretSessionName:
|
||||
return "secretSessionName"
|
||||
case SystemSettingAllowSignUpName:
|
||||
return "allowSignUp"
|
||||
case SystemSettingAdditionalStyleName:
|
||||
return "additionalStyle"
|
||||
case SystemSettingAdditionalScriptName:
|
||||
return "additionalScript"
|
||||
case SystemSettingCustomizedProfileName:
|
||||
return "customizedProfile"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -34,7 +65,7 @@ var (
|
||||
|
||||
type SystemSetting struct {
|
||||
Name SystemSettingName
|
||||
// Value is a JSON string with basic value
|
||||
// Value is a JSON string with basic value.
|
||||
Value string
|
||||
Description string
|
||||
}
|
||||
@@ -46,7 +77,9 @@ type SystemSettingUpsert struct {
|
||||
}
|
||||
|
||||
func (upsert SystemSettingUpsert) Validate() error {
|
||||
if upsert.Name == SystemSettingAllowSignUpName {
|
||||
if upsert.Name == SystemSettingServerID {
|
||||
return errors.New("update server id is not allowed")
|
||||
} else if upsert.Name == SystemSettingAllowSignUpName {
|
||||
value := false
|
||||
err := json.Unmarshal([]byte(upsert.Value), &value)
|
||||
if err != nil {
|
||||
@@ -75,6 +108,25 @@ func (upsert SystemSettingUpsert) Validate() error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unmarshal system setting additional script value")
|
||||
}
|
||||
} else if upsert.Name == SystemSettingCustomizedProfileName {
|
||||
customizedProfile := CustomizedProfile{
|
||||
Name: "memos",
|
||||
LogoURL: "",
|
||||
Description: "",
|
||||
Locale: "en",
|
||||
Appearance: "system",
|
||||
ExternalURL: "",
|
||||
}
|
||||
err := json.Unmarshal([]byte(upsert.Value), &customizedProfile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unmarshal system setting customized profile value")
|
||||
}
|
||||
if !slices.Contains(UserSettingLocaleValue, customizedProfile.Locale) {
|
||||
return fmt.Errorf("invalid locale value")
|
||||
}
|
||||
if !slices.Contains(UserSettingAppearanceValue, customizedProfile.Appearance) {
|
||||
return fmt.Errorf("invalid appearance value")
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("invalid system setting name")
|
||||
}
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package api
|
||||
|
||||
type Tag struct {
|
||||
Name string
|
||||
CreatorID int
|
||||
}
|
||||
|
||||
type TagUpsert struct {
|
||||
Name string
|
||||
CreatorID int `json:"-"`
|
||||
}
|
||||
|
||||
type TagFind struct {
|
||||
CreatorID int
|
||||
}
|
||||
|
||||
type TagDelete struct {
|
||||
Name string
|
||||
CreatorID int
|
||||
}
|
||||
+42
-1
@@ -2,6 +2,8 @@ package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/usememos/memos/common"
|
||||
)
|
||||
|
||||
// Role is the type of a role.
|
||||
@@ -61,15 +63,29 @@ func (create UserCreate) Validate() error {
|
||||
if len(create.Username) < 4 {
|
||||
return fmt.Errorf("username is too short, minimum length is 4")
|
||||
}
|
||||
if len(create.Username) > 32 {
|
||||
return fmt.Errorf("username is too long, maximum length is 32")
|
||||
}
|
||||
if len(create.Password) < 4 {
|
||||
return fmt.Errorf("password is too short, minimum length is 4")
|
||||
}
|
||||
if len(create.Nickname) > 64 {
|
||||
return fmt.Errorf("nickname is too long, maximum length is 64")
|
||||
}
|
||||
if create.Email != "" {
|
||||
if len(create.Email) > 256 {
|
||||
return fmt.Errorf("email is too long, maximum length is 256")
|
||||
}
|
||||
if !common.ValidateEmail(create.Email) {
|
||||
return fmt.Errorf("invalid email format")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type UserPatch struct {
|
||||
ID int
|
||||
ID int `json:"-"`
|
||||
|
||||
// Standard fields
|
||||
UpdatedTs *int64
|
||||
@@ -85,6 +101,31 @@ type UserPatch struct {
|
||||
OpenID *string
|
||||
}
|
||||
|
||||
func (patch UserPatch) Validate() error {
|
||||
if patch.Username != nil && len(*patch.Username) < 4 {
|
||||
return fmt.Errorf("username is too short, minimum length is 4")
|
||||
}
|
||||
if patch.Username != nil && len(*patch.Username) > 32 {
|
||||
return fmt.Errorf("username is too long, maximum length is 32")
|
||||
}
|
||||
if patch.Password != nil && len(*patch.Password) < 4 {
|
||||
return fmt.Errorf("password is too short, minimum length is 4")
|
||||
}
|
||||
if patch.Nickname != nil && len(*patch.Nickname) > 64 {
|
||||
return fmt.Errorf("nickname is too long, maximum length is 64")
|
||||
}
|
||||
if patch.Email != nil {
|
||||
if len(*patch.Email) > 256 {
|
||||
return fmt.Errorf("email is too long, maximum length is 256")
|
||||
}
|
||||
if !common.ValidateEmail(*patch.Email) {
|
||||
return fmt.Errorf("invalid email format")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type UserFind struct {
|
||||
ID *int `json:"id"`
|
||||
|
||||
|
||||
+9
-39
@@ -3,6 +3,8 @@ package api
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
type UserSettingKey string
|
||||
@@ -34,7 +36,7 @@ func (key UserSettingKey) String() string {
|
||||
}
|
||||
|
||||
var (
|
||||
UserSettingLocaleValue = []string{"en", "zh", "vi", "fr", "nl", "sv", "de"}
|
||||
UserSettingLocaleValue = []string{"en", "zh", "vi", "fr", "nl", "sv", "de", "es", "uk", "ru", "it"}
|
||||
UserSettingAppearanceValue = []string{"system", "light", "dark"}
|
||||
UserSettingMemoVisibilityValue = []Visibility{Private, Protected, Public}
|
||||
UserSettingMemoDisplayTsOptionKeyValue = []string{"created_ts", "updated_ts"}
|
||||
@@ -48,7 +50,7 @@ type UserSetting struct {
|
||||
}
|
||||
|
||||
type UserSettingUpsert struct {
|
||||
UserID int
|
||||
UserID int `json:"-"`
|
||||
Key UserSettingKey `json:"key"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
@@ -60,32 +62,16 @@ func (upsert UserSettingUpsert) Validate() error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unmarshal user setting locale value")
|
||||
}
|
||||
|
||||
invalid := true
|
||||
for _, value := range UserSettingLocaleValue {
|
||||
if localeValue == value {
|
||||
invalid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if invalid {
|
||||
if !slices.Contains(UserSettingLocaleValue, localeValue) {
|
||||
return fmt.Errorf("invalid user setting locale value")
|
||||
}
|
||||
} else if upsert.Key == UserSettingAppearanceKey {
|
||||
appearanceValue := "light"
|
||||
appearanceValue := "system"
|
||||
err := json.Unmarshal([]byte(upsert.Value), &appearanceValue)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unmarshal user setting appearance value")
|
||||
}
|
||||
|
||||
invalid := true
|
||||
for _, value := range UserSettingAppearanceValue {
|
||||
if appearanceValue == value {
|
||||
invalid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if invalid {
|
||||
if !slices.Contains(UserSettingAppearanceValue, appearanceValue) {
|
||||
return fmt.Errorf("invalid user setting appearance value")
|
||||
}
|
||||
} else if upsert.Key == UserSettingMemoVisibilityKey {
|
||||
@@ -94,15 +80,7 @@ func (upsert UserSettingUpsert) Validate() error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unmarshal user setting memo visibility value")
|
||||
}
|
||||
|
||||
invalid := true
|
||||
for _, value := range UserSettingMemoVisibilityValue {
|
||||
if memoVisibilityValue == value {
|
||||
invalid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if invalid {
|
||||
if !slices.Contains(UserSettingMemoVisibilityValue, memoVisibilityValue) {
|
||||
return fmt.Errorf("invalid user setting memo visibility value")
|
||||
}
|
||||
} else if upsert.Key == UserSettingMemoDisplayTsOptionKey {
|
||||
@@ -111,15 +89,7 @@ func (upsert UserSettingUpsert) Validate() error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unmarshal user setting memo display ts option")
|
||||
}
|
||||
|
||||
invalid := true
|
||||
for _, value := range UserSettingMemoDisplayTsOptionKeyValue {
|
||||
if memoDisplayTsOption == value {
|
||||
invalid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if invalid {
|
||||
if !slices.Contains(UserSettingMemoDisplayTsOptionKeyValue, memoDisplayTsOption) {
|
||||
return fmt.Errorf("invalid user setting memo display ts option value")
|
||||
}
|
||||
} else {
|
||||
|
||||
+10
-36
@@ -4,16 +4,13 @@ import (
|
||||
"os"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
metric "github.com/usememos/memos/plugin/metrics"
|
||||
"github.com/usememos/memos/server"
|
||||
"github.com/usememos/memos/server/profile"
|
||||
"github.com/usememos/memos/store"
|
||||
|
||||
DB "github.com/usememos/memos/store/db"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -27,38 +24,12 @@ const (
|
||||
`
|
||||
)
|
||||
|
||||
func run(profile *profile.Profile) error {
|
||||
func run() error {
|
||||
ctx := context.Background()
|
||||
|
||||
db := DB.NewDB(profile)
|
||||
if err := db.Open(ctx); err != nil {
|
||||
return fmt.Errorf("cannot open db: %w", err)
|
||||
}
|
||||
|
||||
serverInstance := server.NewServer(profile)
|
||||
storeInstance := store.New(db.Db, profile)
|
||||
serverInstance.Store = storeInstance
|
||||
|
||||
metricCollector := server.NewMetricCollector(profile, storeInstance)
|
||||
// Disable metrics collector.
|
||||
metricCollector.Enabled = false
|
||||
serverInstance.Collector = &metricCollector
|
||||
|
||||
println(greetingBanner)
|
||||
fmt.Printf("Version %s has started at :%d\n", profile.Version, profile.Port)
|
||||
metricCollector.Collect(ctx, &metric.Metric{
|
||||
Name: "service started",
|
||||
})
|
||||
|
||||
return serverInstance.Run()
|
||||
}
|
||||
|
||||
func execute() error {
|
||||
profile, err := profile.GetProfile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
println("---")
|
||||
println("profile")
|
||||
println("mode:", profile.Mode)
|
||||
@@ -67,16 +38,19 @@ func execute() error {
|
||||
println("version:", profile.Version)
|
||||
println("---")
|
||||
|
||||
if err := run(profile); err != nil {
|
||||
fmt.Printf("error: %+v\n", err)
|
||||
return err
|
||||
serverInstance, err := server.NewServer(ctx, profile)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to start server")
|
||||
}
|
||||
|
||||
return nil
|
||||
println(greetingBanner)
|
||||
fmt.Printf("Version %s has started at :%d\n", profile.Version, profile.Port)
|
||||
return serverInstance.Run(ctx)
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := execute(); err != nil {
|
||||
if err := run(); err != nil {
|
||||
fmt.Printf("error: %+v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
# A Beginner's Guide to Deploying Memos on Render.com
|
||||
|
||||
written by [AJ](https://memos.ajstephens.website/) (also a noob)
|
||||
|
||||
<img height="64px" src="https://raw.githubusercontent.com/usememos/memos/main/resources/logo-full.webp" alt="✍️ memos" />
|
||||
|
||||
[Live Demo](https://demo.usememos.com) • [Official Website](https://usememos.com) • [Source Code](https://github.com/usememos/memos)
|
||||
|
||||
## Who is this guide for?
|
||||
|
||||
Someone who...
|
||||
|
||||
- doesn't have much experience with self hosting
|
||||
- has a minimal understanding of docker
|
||||
|
||||
Someone who wants...
|
||||
|
||||
- to use memos
|
||||
- to support the memos project
|
||||
- a cost effective and simple way to host it on the cloud with reliablity and persistance
|
||||
- to share memos with friends
|
||||
|
||||
## Requirements
|
||||
|
||||
- Can follow instructions
|
||||
- Have 7ish USD a month on a debit/credit card
|
||||
|
||||
## Guide
|
||||
|
||||
Create an account at [Render](https://dashboard.render.com/register)
|
||||

|
||||
|
||||
1. Go to your dashboard
|
||||
|
||||
[https://dashboard.render.com/](https://dashboard.render.com/)
|
||||
|
||||
2. Select New Web Service
|
||||
|
||||

|
||||
|
||||
3. Scroll down to "Public Git repository"
|
||||
|
||||
4. Paste in the link for the public git repository for memos (https://github.com/usememos/memos) and press continue
|
||||
|
||||

|
||||
|
||||
5. Render will pre-fill most of the fields but you will need to create a unique name for your web service
|
||||
|
||||
6. Adjust region if you want to
|
||||
|
||||
7. Don't touch the "branch", "root directory", and "environment" fields
|
||||
|
||||

|
||||
|
||||
8. Click "enter your payment information" and do so
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
9. Select the starter plan ($7 a month - a requirement for persistant data - render's free instances spin down when inactive and lose all data)
|
||||
|
||||
10. Click "Create Web Service"
|
||||
|
||||

|
||||
|
||||
11. Wait patiently while the _magic_ happens 🤷♂️
|
||||
|
||||

|
||||
|
||||
12. After some time (~ 6 min for me) the build will finish and you will see the web service is live
|
||||
|
||||

|
||||
|
||||
13. Now it's time to add the disk so your data won't dissappear when the webservice redeploys (redeploys happen automatically when the public repo is updated)
|
||||
|
||||
14. Select the "Disks" tab on the left menu and then click "Add Disk"
|
||||
|
||||

|
||||
|
||||
15. Name your disk (can be whatever)
|
||||
|
||||
16. Set the "Mount Path" to `/var/opt/memos`
|
||||
|
||||
17. Set the disk size (default is 10GB but 1GB is plenty and can be increased at any time)
|
||||
|
||||
18. Click "Save"
|
||||
|
||||

|
||||
|
||||
19. Wait...again...while the webservice redeploys with the persistant disk
|
||||
|
||||

|
||||
|
||||
20. aaaand....we're back online!
|
||||
|
||||

|
||||
|
||||
21. Time to test! We're going to make sure everything is working correctly.
|
||||
|
||||
22. Click the link in the top left, it should look like `https://the-name-you-chose.onrender.com` - this is your self hosted memos link!
|
||||
|
||||

|
||||
|
||||
23. Create a Username and Password (remember these) then click "Sign up as Host"
|
||||
|
||||

|
||||
|
||||
24. Create a test memo then click save
|
||||
|
||||

|
||||
|
||||
25. Sign out of your self-hosted memos
|
||||
|
||||

|
||||
|
||||
26. Return to your Render dashboard, click the "Manual Deploy" dropdown button and click "Deploy latest commit" and wait until the webservice is live again (This is to test that your data is persistant)
|
||||
|
||||

|
||||
|
||||
27. Once the webservice is live go back to your self-hosted memos page and sign in! (If your memos screen looks different then something went wrong)
|
||||
|
||||
28. Once you're logged in, verify your test memo is still there after the redeploy
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## 🎉Celebrate!🎉
|
||||
|
||||
You did it! Enjoy using memos!
|
||||
|
||||
Want to learn more or need more guidance? Join the community on [telegram](https://t.me/+-_tNF1k70UU4ZTc9) and [discord](https://discord.gg/tfPJa4UmAv).
|
||||
+1
-1
@@ -37,4 +37,4 @@ Memos is built with a curated tech stack. It is optimized for developer experien
|
||||
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.
|
||||
Memos should now be running at [http://localhost:3001](http://localhost:3001) and change either frontend or backend code would trigger live reload.
|
||||
|
||||
@@ -7,8 +7,8 @@ require github.com/mattn/go-sqlite3 v1.14.9
|
||||
require github.com/google/uuid v1.3.0
|
||||
|
||||
require (
|
||||
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa
|
||||
golang.org/x/net v0.0.0-20220728030405-41545e8bf201
|
||||
golang.org/x/crypto v0.1.0
|
||||
golang.org/x/net v0.1.0
|
||||
)
|
||||
|
||||
require github.com/labstack/echo/v4 v4.9.0
|
||||
@@ -16,10 +16,8 @@ require github.com/labstack/echo/v4 v4.9.0
|
||||
require (
|
||||
github.com/VictoriaMetrics/fastcache v1.10.0
|
||||
github.com/gorilla/feeds v1.1.1
|
||||
github.com/gorilla/securecookie v1.1.1
|
||||
github.com/gorilla/sessions v1.2.1
|
||||
github.com/labstack/echo-contrib v0.13.0
|
||||
github.com/stretchr/testify v1.8.1
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -29,6 +27,7 @@ require (
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/gorilla/context v1.1.1 // indirect
|
||||
github.com/gorilla/securecookie v1.1.1 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/labstack/gommon v0.3.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.12 // indirect
|
||||
@@ -38,11 +37,17 @@ require (
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.1 // indirect
|
||||
github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c // indirect
|
||||
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 // indirect
|
||||
golang.org/x/text v0.3.7 // indirect
|
||||
golang.org/x/sys v0.1.0 // indirect
|
||||
golang.org/x/text v0.4.0 // indirect
|
||||
golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
|
||||
)
|
||||
|
||||
require github.com/segmentio/analytics-go v3.1.0+incompatible
|
||||
require (
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/segmentio/analytics-go v3.1.0+incompatible
|
||||
github.com/stretchr/testify v1.7.0
|
||||
golang.org/x/exp v0.0.0-20230111222715-75897c7a292a
|
||||
golang.org/x/mod v0.6.0
|
||||
)
|
||||
|
||||
@@ -45,6 +45,8 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k
|
||||
github.com/mattn/go-sqlite3 v1.14.9 h1:10HX2Td0ocZpYEjhilsuo6WWtUqttj2Kb0KtD86/KYA=
|
||||
github.com/mattn/go-sqlite3 v1.14.9/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
@@ -54,38 +56,36 @@ github.com/segmentio/analytics-go v3.1.0+incompatible/go.mod h1:C7CYBtQWk4vRk2Ry
|
||||
github.com/segmentio/backo-go v1.0.1 h1:68RQccglxZeyURy93ASB/2kc9QudzgIDexJ927N++y4=
|
||||
github.com/segmentio/backo-go v1.0.1/go.mod h1:9/Rh6yILuLysoQnZ2oNooD2g7aBnvM7r/fNVxRNWfBc=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
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.2.1 h1:TVEnxayobAdVkhQfrfes2IzOB6o+z4roRkPF52WA1u4=
|
||||
github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c h1:3lbZUMbMiGUW/LMkfsEABsc5zNT9+b1CvsJx47JzJ8g=
|
||||
github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c/go.mod h1:UrdRz5enIKZ63MEE3IF9l2/ebyx59GyGgPi+tICQdmM=
|
||||
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/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/crypto v0.1.0 h1:MDRAIl0xIo9Io2xV565hzXHw3zVseKrJKodhohM5CjU=
|
||||
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
|
||||
golang.org/x/exp v0.0.0-20230111222715-75897c7a292a h1:/YWeLOBWYV5WAQORVPkZF3Pq9IppkcT72GKnWjNf5W8=
|
||||
golang.org/x/exp v0.0.0-20230111222715-75897c7a292a/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
|
||||
golang.org/x/mod v0.6.0 h1:b9gGHsz9/HhJ3HF5DHQytPpuwocVTChQJK3AvoLRD5I=
|
||||
golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI=
|
||||
golang.org/x/net v0.1.0 h1:hZ/3BUoy5aId7sCpA/Tc5lt8DkFgdVS2onTpJsZ/fl0=
|
||||
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/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/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/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg=
|
||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
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=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
package metric
|
||||
|
||||
// Metric is the API message for metric.
|
||||
type Metric struct {
|
||||
ID string
|
||||
Name string
|
||||
Labels map[string]string
|
||||
}
|
||||
|
||||
// Collector is the interface definition for metric collector.
|
||||
type Collector interface {
|
||||
Identify(id string) error
|
||||
Collect(metric *Metric) error
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
package metric
|
||||
|
||||
// Metric is the API message for metric.
|
||||
type Metric struct {
|
||||
Name string
|
||||
Labels map[string]string
|
||||
}
|
||||
@@ -3,15 +3,10 @@ package segment
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/segmentio/analytics-go"
|
||||
metric "github.com/usememos/memos/plugin/metrics"
|
||||
)
|
||||
|
||||
var (
|
||||
sessionUUID = uuid.NewString()
|
||||
)
|
||||
|
||||
// collector is the metrics collector https://segment.com/.
|
||||
type collector struct {
|
||||
client analytics.Client
|
||||
@@ -26,6 +21,14 @@ func NewCollector(key string) metric.Collector {
|
||||
}
|
||||
}
|
||||
|
||||
// Identify will identify the server caller.
|
||||
func (c *collector) Identify(id string) error {
|
||||
return c.client.Enqueue(analytics.Identify{
|
||||
UserId: id,
|
||||
Timestamp: time.Now().UTC(),
|
||||
})
|
||||
}
|
||||
|
||||
// Collect will exec all the segment collector.
|
||||
func (c *collector) Collect(metric *metric.Metric) error {
|
||||
properties := analytics.NewProperties()
|
||||
@@ -34,9 +37,9 @@ func (c *collector) Collect(metric *metric.Metric) error {
|
||||
}
|
||||
|
||||
return c.client.Enqueue(analytics.Track{
|
||||
Event: string(metric.Name),
|
||||
AnonymousId: sessionUUID,
|
||||
Properties: properties,
|
||||
Timestamp: time.Now().UTC(),
|
||||
UserId: metric.ID,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Event: metric.Name,
|
||||
Properties: properties,
|
||||
})
|
||||
}
|
||||
|
||||
+21
-47
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
var (
|
||||
userIDContextKey = "user-id"
|
||||
sessionName = "memos_session"
|
||||
)
|
||||
|
||||
func getUserIDContextKey() string {
|
||||
@@ -22,11 +23,12 @@ func getUserIDContextKey() string {
|
||||
}
|
||||
|
||||
func setUserSession(ctx echo.Context, user *api.User) error {
|
||||
sess, _ := session.Get("memos_session", ctx)
|
||||
sess, _ := session.Get(sessionName, ctx)
|
||||
sess.Options = &sessions.Options{
|
||||
Path: "/",
|
||||
MaxAge: 3600 * 24 * 30,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
}
|
||||
sess.Values[userIDContextKey] = user.ID
|
||||
err := sess.Save(ctx.Request(), ctx.Response())
|
||||
@@ -37,7 +39,7 @@ func setUserSession(ctx echo.Context, user *api.User) error {
|
||||
}
|
||||
|
||||
func removeUserSession(ctx echo.Context) error {
|
||||
sess, _ := session.Get("memos_session", ctx)
|
||||
sess, _ := session.Get(sessionName, ctx)
|
||||
sess.Options = &sessions.Options{
|
||||
Path: "/",
|
||||
MaxAge: 0,
|
||||
@@ -56,61 +58,33 @@ func aclMiddleware(s *Server, next echo.HandlerFunc) echo.HandlerFunc {
|
||||
ctx := c.Request().Context()
|
||||
path := c.Path()
|
||||
|
||||
// Skip auth.
|
||||
if common.HasPrefixes(path, "/api/auth") {
|
||||
if s.DefaultAuthSkipper(c) {
|
||||
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)
|
||||
sess, _ := session.Get(sessionName, c)
|
||||
userIDValue := sess.Values[userIDContextKey]
|
||||
if userIDValue != nil {
|
||||
userID, _ := strconv.Atoi(fmt.Sprintf("%v", userIDValue))
|
||||
userFind := &api.UserFind{
|
||||
ID: &userID,
|
||||
}
|
||||
user, err := s.Store.FindUser(ctx, userFind)
|
||||
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 {
|
||||
if user.RowStatus == api.Archived {
|
||||
return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("User has been archived with username %s", user.Username))
|
||||
}
|
||||
c.Set(getUserIDContextKey(), userID)
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
sess, _ := session.Get("memos_session", c)
|
||||
userIDValue := sess.Values[userIDContextKey]
|
||||
if userIDValue != nil {
|
||||
userID, _ := strconv.Atoi(fmt.Sprintf("%v", userIDValue))
|
||||
userFind := &api.UserFind{
|
||||
ID: &userID,
|
||||
}
|
||||
user, err := s.Store.FindUser(ctx, userFind)
|
||||
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 {
|
||||
if user.RowStatus == api.Archived {
|
||||
return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("User has been archived with username %s", user.Username))
|
||||
}
|
||||
c.Set(getUserIDContextKey(), userID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if common.HasPrefixes(path, "/api/ping", "/api/status", "/api/user/:id", "/api/memo/all", "/api/memo/:memoId", "/api/memo/amount") && c.Request().Method == http.MethodGet {
|
||||
if common.HasPrefixes(path, "/api/ping", "/api/status", "/api/user/:id", "/api/memo") && 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)
|
||||
}
|
||||
}
|
||||
|
||||
userID := c.Get(getUserIDContextKey())
|
||||
if userID == nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
|
||||
+71
-25
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
metric "github.com/usememos/memos/plugin/metrics"
|
||||
@@ -16,7 +17,7 @@ import (
|
||||
func (s *Server) registerAuthRoutes(g *echo.Group) {
|
||||
g.POST("/auth/signin", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
signin := &api.Signin{}
|
||||
signin := &api.SignIn{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(signin); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted signin request").SetInternal(err)
|
||||
}
|
||||
@@ -43,9 +44,9 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
|
||||
if err = setUserSession(c, user); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to set signin session").SetInternal(err)
|
||||
}
|
||||
s.Collector.Collect(ctx, &metric.Metric{
|
||||
Name: "user signed in",
|
||||
})
|
||||
if err := s.createUserAuthSignInActivity(c, user); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
|
||||
}
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(user)); err != nil {
|
||||
@@ -54,23 +55,9 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
|
||||
return nil
|
||||
})
|
||||
|
||||
g.POST("/auth/logout", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
err := removeUserSession(c)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to set logout session").SetInternal(err)
|
||||
}
|
||||
s.Collector.Collect(ctx, &metric.Metric{
|
||||
Name: "user logout",
|
||||
})
|
||||
|
||||
c.Response().WriteHeader(http.StatusOK)
|
||||
return nil
|
||||
})
|
||||
|
||||
g.POST("/auth/signup", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
signup := &api.Signup{}
|
||||
signup := &api.SignUp{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(signup); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted signup request").SetInternal(err)
|
||||
}
|
||||
@@ -84,7 +71,7 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find host user").SetInternal(err)
|
||||
}
|
||||
if signup.Role == api.Host && hostUser != nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Site Host existed, please contact the site host to signin account firstly.").SetInternal(err)
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Site Host existed, please contact the site host to signin account firstly").SetInternal(err)
|
||||
}
|
||||
|
||||
systemSettingAllowSignUpName := api.SystemSettingAllowSignUpName
|
||||
@@ -103,7 +90,7 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
|
||||
}
|
||||
}
|
||||
if !allowSignUpSettingValue && hostUser != nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Site Host existed, please contact the site host to signin account firstly.").SetInternal(err)
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Site Host existed, please contact the site host to signin account firstly").SetInternal(err)
|
||||
}
|
||||
|
||||
userCreate := &api.UserCreate{
|
||||
@@ -114,7 +101,7 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
|
||||
OpenID: common.GenUUID(),
|
||||
}
|
||||
if err := userCreate.Validate(); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid user create format.").SetInternal(err)
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid user create format").SetInternal(err)
|
||||
}
|
||||
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(signup.Password), bcrypt.DefaultCost)
|
||||
@@ -128,9 +115,9 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err)
|
||||
}
|
||||
s.Collector.Collect(ctx, &metric.Metric{
|
||||
Name: "user signed up",
|
||||
})
|
||||
if err := s.createUserAuthSignUpActivity(c, user); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
|
||||
}
|
||||
|
||||
err = setUserSession(c, user)
|
||||
if err != nil {
|
||||
@@ -143,4 +130,63 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
g.POST("/auth/signout", func(c echo.Context) error {
|
||||
err := removeUserSession(c)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to set sign out session").SetInternal(err)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, true)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) createUserAuthSignInActivity(c echo.Context, user *api.User) error {
|
||||
ctx := c.Request().Context()
|
||||
payload := api.ActivityUserAuthSignInPayload{
|
||||
UserID: user.ID,
|
||||
IP: echo.ExtractIPFromRealIPHeader()(c.Request()),
|
||||
}
|
||||
payloadStr, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to marshal activity payload")
|
||||
}
|
||||
activity, err := s.Store.CreateActivity(ctx, &api.ActivityCreate{
|
||||
CreatorID: user.ID,
|
||||
Type: api.ActivityUserAuthSignIn,
|
||||
Level: api.ActivityInfo,
|
||||
Payload: string(payloadStr),
|
||||
})
|
||||
if err != nil || activity == nil {
|
||||
return errors.Wrap(err, "failed to create activity")
|
||||
}
|
||||
s.Collector.Collect(ctx, &metric.Metric{
|
||||
Name: string(activity.Type),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Server) createUserAuthSignUpActivity(c echo.Context, user *api.User) error {
|
||||
ctx := c.Request().Context()
|
||||
payload := api.ActivityUserAuthSignUpPayload{
|
||||
Username: user.Username,
|
||||
IP: echo.ExtractIPFromRealIPHeader()(c.Request()),
|
||||
}
|
||||
payloadStr, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to marshal activity payload")
|
||||
}
|
||||
activity, err := s.Store.CreateActivity(ctx, &api.ActivityCreate{
|
||||
CreatorID: user.ID,
|
||||
Type: api.ActivityUserAuthSignUp,
|
||||
Level: api.ActivityInfo,
|
||||
Payload: string(payloadStr),
|
||||
})
|
||||
if err != nil || activity == nil {
|
||||
return errors.Wrap(err, "failed to create activity")
|
||||
}
|
||||
s.Collector.Collect(ctx, &metric.Metric{
|
||||
Name: string(activity.Type),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
+51
-5
@@ -1,11 +1,57 @@
|
||||
package server
|
||||
|
||||
func composeResponse(data interface{}) interface{} {
|
||||
type R struct {
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
return R{
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
)
|
||||
|
||||
type response struct {
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
|
||||
func composeResponse(data interface{}) response {
|
||||
return response{
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
func DefaultGetRequestSkipper(c echo.Context) bool {
|
||||
return c.Request().Method == http.MethodGet
|
||||
}
|
||||
|
||||
func DefaultAPIRequestSkipper(c echo.Context) bool {
|
||||
path := c.Path()
|
||||
return common.HasPrefixes(path, "/api")
|
||||
}
|
||||
|
||||
func (server *Server) DefaultAuthSkipper(c echo.Context) bool {
|
||||
ctx := c.Request().Context()
|
||||
path := c.Path()
|
||||
|
||||
// Skip auth.
|
||||
if common.HasPrefixes(path, "/api/auth") {
|
||||
return true
|
||||
}
|
||||
|
||||
// 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 := server.Store.FindUser(ctx, userFind)
|
||||
if err != nil && common.ErrorCode(err) != common.NotFound {
|
||||
return false
|
||||
}
|
||||
if user != nil {
|
||||
// Stores userID into context.
|
||||
c.Set(getUserIDContextKey(), user.ID)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -25,18 +25,19 @@ 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{
|
||||
Skipper: DefaultAPIRequestSkipper,
|
||||
HTML5: true,
|
||||
Filesystem: getFileSystem("dist"),
|
||||
}))
|
||||
|
||||
g := e.Group("assets")
|
||||
g.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
assetsGroup := e.Group("assets")
|
||||
assetsGroup.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{
|
||||
assetsGroup.Use(middleware.StaticWithConfig(middleware.StaticConfig{
|
||||
HTML5: true,
|
||||
Filesystem: getFileSystem("dist/assets"),
|
||||
}))
|
||||
|
||||
+1
-16
@@ -8,12 +8,10 @@ import (
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
getter "github.com/usememos/memos/plugin/http_getter"
|
||||
metric "github.com/usememos/memos/plugin/metrics"
|
||||
)
|
||||
|
||||
func (s *Server) registerGetterPublicRoutes(g *echo.Group) {
|
||||
func registerGetterPublicRoutes(g *echo.Group) {
|
||||
g.GET("/get/httpmeta", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
urlStr := c.QueryParam("url")
|
||||
if urlStr == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Missing website url")
|
||||
@@ -26,12 +24,6 @@ func (s *Server) registerGetterPublicRoutes(g *echo.Group) {
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusNotAcceptable, fmt.Sprintf("Failed to get website meta with url: %s", urlStr)).SetInternal(err)
|
||||
}
|
||||
s.Collector.Collect(ctx, &metric.Metric{
|
||||
Name: "getter used",
|
||||
Labels: map[string]string{
|
||||
"type": "httpmeta",
|
||||
},
|
||||
})
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(htmlMeta)); err != nil {
|
||||
@@ -41,7 +33,6 @@ func (s *Server) registerGetterPublicRoutes(g *echo.Group) {
|
||||
})
|
||||
|
||||
g.GET("/get/image", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
urlStr := c.QueryParam("url")
|
||||
if urlStr == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Missing image url")
|
||||
@@ -54,12 +45,6 @@ func (s *Server) registerGetterPublicRoutes(g *echo.Group) {
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Failed to get image url: %s", urlStr)).SetInternal(err)
|
||||
}
|
||||
s.Collector.Collect(ctx, &metric.Metric{
|
||||
Name: "getter used",
|
||||
Labels: map[string]string{
|
||||
"type": "image",
|
||||
},
|
||||
})
|
||||
|
||||
c.Response().Writer.WriteHeader(http.StatusOK)
|
||||
c.Response().Writer.Header().Set("Content-Type", image.Mediatype)
|
||||
|
||||
+225
-180
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
metric "github.com/usememos/memos/plugin/metrics"
|
||||
@@ -24,15 +25,10 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
|
||||
memoCreate := &api.MemoCreate{
|
||||
CreatorID: userID,
|
||||
}
|
||||
memoCreate := &api.MemoCreate{}
|
||||
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")
|
||||
}
|
||||
|
||||
if memoCreate.Visibility == "" {
|
||||
userSettingMemoVisibilityKey := api.UserSettingMemoVisibilityKey
|
||||
@@ -57,13 +53,14 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
|
||||
}
|
||||
}
|
||||
|
||||
memoCreate.CreatorID = userID
|
||||
memo, err := s.Store.CreateMemo(ctx, memoCreate)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create memo").SetInternal(err)
|
||||
}
|
||||
s.Collector.Collect(ctx, &metric.Metric{
|
||||
Name: "memo created",
|
||||
})
|
||||
if err := s.createMemoCreateActivity(c, memo); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
|
||||
}
|
||||
|
||||
for _, resourceID := range memoCreate.ResourceIDList {
|
||||
if _, err := s.Store.UpsertMemoResource(ctx, &api.MemoResourceUpsert{
|
||||
@@ -98,13 +95,15 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
|
||||
}
|
||||
|
||||
memoFind := &api.MemoFind{
|
||||
ID: &memoID,
|
||||
CreatorID: &userID,
|
||||
}
|
||||
if _, err := s.Store.FindMemo(ctx, memoFind); err != nil {
|
||||
memo, err := s.Store.FindMemo(ctx, &api.MemoFind{
|
||||
ID: &memoID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo").SetInternal(err)
|
||||
}
|
||||
if memo.CreatorID != userID {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||
}
|
||||
|
||||
currentTs := time.Now().Unix()
|
||||
memoPatch := &api.MemoPatch{
|
||||
@@ -115,7 +114,7 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch memo request").SetInternal(err)
|
||||
}
|
||||
|
||||
memo, err := s.Store.PatchMemo(ctx, memoPatch)
|
||||
memo, err = s.Store.PatchMemo(ctx, memoPatch)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch memo").SetInternal(err)
|
||||
}
|
||||
@@ -173,7 +172,7 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
|
||||
}
|
||||
tag := c.QueryParam("tag")
|
||||
if tag != "" {
|
||||
contentSearch := "#" + tag + " "
|
||||
contentSearch := "#" + tag
|
||||
memoFind.ContentSearch = &contentSearch
|
||||
}
|
||||
visibilityListStr := c.QueryParam("visibility")
|
||||
@@ -229,6 +228,148 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
|
||||
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.Private {
|
||||
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"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
|
||||
}
|
||||
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
memoOrganizerUpsert := &api.MemoOrganizerUpsert{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(memoOrganizerUpsert); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo organizer request").SetInternal(err)
|
||||
}
|
||||
memoOrganizerUpsert.MemoID = memoID
|
||||
memoOrganizerUpsert.UserID = userID
|
||||
|
||||
err = s.Store.UpsertMemoOrganizer(ctx, memoOrganizerUpsert)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo organizer").SetInternal(err)
|
||||
}
|
||||
|
||||
memo, err := s.Store.FindMemo(ctx, &api.MemoFind{
|
||||
ID: &memoID,
|
||||
})
|
||||
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.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)
|
||||
}
|
||||
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
memoResourceUpsert := &api.MemoResourceUpsert{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(memoResourceUpsert); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo resource request").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)
|
||||
}
|
||||
if resource == nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Resource not found").SetInternal(err)
|
||||
} else if resource.CreatorID != userID {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized to bind this resource").SetInternal(err)
|
||||
}
|
||||
|
||||
memoResourceUpsert.MemoID = memoID
|
||||
currentTs := time.Now().Unix()
|
||||
memoResourceUpsert.UpdatedTs = ¤tTs
|
||||
if _, err := s.Store.UpsertMemoResource(ctx, memoResourceUpsert); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo 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.GET("/memo/amount", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
normalRowStatus := api.Normal
|
||||
@@ -352,183 +493,26 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
|
||||
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.Private {
|
||||
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"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
|
||||
}
|
||||
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
memoOrganizerUpsert := &api.MemoOrganizerUpsert{
|
||||
MemoID: memoID,
|
||||
UserID: userID,
|
||||
}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(memoOrganizerUpsert); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo organizer request").SetInternal(err)
|
||||
}
|
||||
|
||||
err = s.Store.UpsertMemoOrganizer(ctx, memoOrganizerUpsert)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo organizer").SetInternal(err)
|
||||
}
|
||||
|
||||
memo, err := s.Store.FindMemo(ctx, &api.MemoFind{
|
||||
ID: &memoID,
|
||||
})
|
||||
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.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: ¤tTs,
|
||||
}
|
||||
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,
|
||||
CreatorID: &userID,
|
||||
}
|
||||
if _, err := s.Store.FindMemo(ctx, memoFind); err != nil {
|
||||
memo, err := s.Store.FindMemo(ctx, &api.MemoFind{
|
||||
ID: &memoID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo").SetInternal(err)
|
||||
}
|
||||
if memo.CreatorID != userID {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||
}
|
||||
|
||||
memoDelete := &api.MemoDelete{
|
||||
ID: memoID,
|
||||
@@ -542,4 +526,65 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
|
||||
|
||||
return c.JSON(http.StatusOK, true)
|
||||
})
|
||||
|
||||
g.DELETE("/memo/:memoId/resource/:resourceId", 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("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)
|
||||
}
|
||||
|
||||
memo, err := s.Store.FindMemo(ctx, &api.MemoFind{
|
||||
ID: &memoID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo").SetInternal(err)
|
||||
}
|
||||
if memo.CreatorID != userID {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) createMemoCreateActivity(c echo.Context, memo *api.Memo) error {
|
||||
ctx := c.Request().Context()
|
||||
payload := api.ActivityMemoCreatePayload{
|
||||
Content: memo.Content,
|
||||
Visibility: memo.Visibility.String(),
|
||||
}
|
||||
payloadStr, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to marshal activity payload")
|
||||
}
|
||||
activity, err := s.Store.CreateActivity(ctx, &api.ActivityCreate{
|
||||
CreatorID: memo.CreatorID,
|
||||
Type: api.ActivityMemoCreate,
|
||||
Level: api.ActivityInfo,
|
||||
Payload: string(payloadStr),
|
||||
})
|
||||
if err != nil || activity == nil {
|
||||
return errors.Wrap(err, "failed to create activity")
|
||||
}
|
||||
s.Collector.Collect(ctx, &metric.Metric{
|
||||
Name: string(activity.Type),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
+23
-16
@@ -8,29 +8,39 @@ import (
|
||||
"github.com/usememos/memos/plugin/metrics/segment"
|
||||
"github.com/usememos/memos/server/profile"
|
||||
"github.com/usememos/memos/server/version"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
// MetricCollector is the metric collector.
|
||||
type MetricCollector struct {
|
||||
Collector metric.Collector
|
||||
collector metric.Collector
|
||||
ID string
|
||||
Enabled bool
|
||||
Profile *profile.Profile
|
||||
Store *store.Store
|
||||
}
|
||||
|
||||
const (
|
||||
segmentMetricWriteKey = "fTn5BumOkj352n3TGw9tu0ARH2dOkcoQ"
|
||||
segmentMetricWriteKey = "NbPruMMmfqfD2AMCw3pkxZTsszVS3hKq"
|
||||
)
|
||||
|
||||
func NewMetricCollector(profile *profile.Profile, store *store.Store) MetricCollector {
|
||||
func (s *Server) registerMetricCollector() {
|
||||
c := segment.NewCollector(segmentMetricWriteKey)
|
||||
mc := &MetricCollector{
|
||||
collector: c,
|
||||
ID: s.ID,
|
||||
Enabled: false,
|
||||
Profile: s.Profile,
|
||||
}
|
||||
s.Collector = mc
|
||||
}
|
||||
|
||||
return MetricCollector{
|
||||
Collector: c,
|
||||
Enabled: true,
|
||||
Profile: profile,
|
||||
Store: store,
|
||||
func (mc *MetricCollector) Identify(_ context.Context) {
|
||||
if !mc.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
err := mc.collector.Identify(mc.ID)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to request segment, error: %+v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,16 +49,13 @@ func (mc *MetricCollector) Collect(_ context.Context, metric *metric.Metric) {
|
||||
return
|
||||
}
|
||||
|
||||
if mc.Profile.Mode == "dev" {
|
||||
return
|
||||
}
|
||||
|
||||
if metric.Labels == nil {
|
||||
metric.Labels = map[string]string{}
|
||||
}
|
||||
metric.Labels["mode"] = mc.Profile.Mode
|
||||
metric.Labels["version"] = version.GetCurrentVersion(mc.Profile.Mode)
|
||||
|
||||
err := mc.Collector.Collect(metric)
|
||||
metric.ID = mc.ID
|
||||
err := mc.collector.Collect(metric)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to request segment, error: %+v\n", err)
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ func checkDSN(dataDir string) (string, error) {
|
||||
func GetProfile() (*Profile, error) {
|
||||
profile := Profile{}
|
||||
flag.StringVar(&profile.Mode, "mode", "dev", "mode of server")
|
||||
flag.IntVar(&profile.Port, "port", 8080, "port of server")
|
||||
flag.IntVar(&profile.Port, "port", 8081, "port of server")
|
||||
flag.StringVar(&profile.Data, "data", "", "data directory")
|
||||
flag.Parse()
|
||||
|
||||
|
||||
+124
-58
@@ -1,14 +1,17 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
metric "github.com/usememos/memos/plugin/metrics"
|
||||
@@ -18,7 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
// The max file size is 32MB.
|
||||
maxFileSize = (32 * 8) << 20
|
||||
maxFileSize = 32 << 20
|
||||
)
|
||||
|
||||
func (s *Server) registerResourceRoutes(g *echo.Group) {
|
||||
@@ -29,6 +32,34 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
|
||||
resourceCreate := &api.ResourceCreate{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(resourceCreate); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post resource request").SetInternal(err)
|
||||
}
|
||||
|
||||
resourceCreate.CreatorID = userID
|
||||
resource, err := s.Store.CreateResource(ctx, resourceCreate)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create resource").SetInternal(err)
|
||||
}
|
||||
if err := s.createResourceCreateActivity(c, resource); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").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.POST("/resource/blob", 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")
|
||||
}
|
||||
|
||||
if err := c.Request().ParseMultipartForm(maxFileSize); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Upload file overload max size").SetInternal(err)
|
||||
}
|
||||
@@ -56,20 +87,19 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
|
||||
}
|
||||
|
||||
resourceCreate := &api.ResourceCreate{
|
||||
CreatorID: userID,
|
||||
Filename: filename,
|
||||
Type: filetype,
|
||||
Size: size,
|
||||
Blob: fileBytes,
|
||||
CreatorID: userID,
|
||||
}
|
||||
|
||||
resource, err := s.Store.CreateResource(ctx, resourceCreate)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create resource").SetInternal(err)
|
||||
}
|
||||
s.Collector.Collect(ctx, &metric.Metric{
|
||||
Name: "resource created",
|
||||
})
|
||||
if err := s.createResourceCreateActivity(c, resource); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
|
||||
}
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(resource)); err != nil {
|
||||
@@ -158,12 +188,57 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
|
||||
|
||||
c.Response().Writer.WriteHeader(http.StatusOK)
|
||||
c.Response().Writer.Header().Set("Content-Type", resource.Type)
|
||||
c.Response().Writer.Header().Set(echo.HeaderContentSecurityPolicy, "default-src 'self'")
|
||||
if _, err := c.Response().Writer.Write(resource.Blob); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to write resource blob").SetInternal(err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
g.PATCH("/resource/:resourceId", 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")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
resourceFind := &api.ResourceFind{
|
||||
ID: &resourceID,
|
||||
}
|
||||
resource, err := s.Store.FindResource(ctx, resourceFind)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find resource").SetInternal(err)
|
||||
}
|
||||
if resource.CreatorID != userID {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||
}
|
||||
|
||||
currentTs := time.Now().Unix()
|
||||
resourcePatch := &api.ResourcePatch{
|
||||
UpdatedTs: ¤tTs,
|
||||
}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(resourcePatch); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch resource request").SetInternal(err)
|
||||
}
|
||||
|
||||
resource.ID = resourceID
|
||||
resource, err = s.Store.PatchResource(ctx, resourcePatch)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch 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.DELETE("/resource/:resourceId", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
@@ -183,8 +258,8 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find resource").SetInternal(err)
|
||||
}
|
||||
if resource == nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "Not find resource").SetInternal(err)
|
||||
if resource.CreatorID != userID {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||
}
|
||||
|
||||
resourceDelete := &api.ResourceDelete{
|
||||
@@ -199,47 +274,6 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
|
||||
|
||||
return c.JSON(http.StatusOK, true)
|
||||
})
|
||||
|
||||
g.PATCH("/resource/:resourceId", 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")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
resourceFind := &api.ResourceFind{
|
||||
ID: &resourceID,
|
||||
CreatorID: &userID,
|
||||
}
|
||||
if _, err := s.Store.FindResource(ctx, resourceFind); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find resource").SetInternal(err)
|
||||
}
|
||||
|
||||
currentTs := time.Now().Unix()
|
||||
resourcePatch := &api.ResourcePatch{
|
||||
ID: resourceID,
|
||||
UpdatedTs: ¤tTs,
|
||||
}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(resourcePatch); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch resource request").SetInternal(err)
|
||||
}
|
||||
|
||||
resource, err := s.Store.PatchResource(ctx, resourcePatch)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch 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
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) registerResourcePublicRoutes(g *echo.Group) {
|
||||
@@ -249,8 +283,10 @@ func (s *Server) registerResourcePublicRoutes(g *echo.Group) {
|
||||
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"))
|
||||
filename, err := url.QueryUnescape(c.Param("filename"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("filename is invalid: %s", c.Param("filename"))).SetInternal(err)
|
||||
}
|
||||
resourceFind := &api.ResourceFind{
|
||||
ID: &resourceID,
|
||||
Filename: &filename,
|
||||
@@ -260,12 +296,42 @@ func (s *Server) registerResourcePublicRoutes(g *echo.Group) {
|
||||
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().Writer.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)
|
||||
resourceType := strings.ToLower(resource.Type)
|
||||
if strings.HasPrefix(resourceType, "text") || strings.HasPrefix(resourceType, "application") {
|
||||
resourceType = echo.MIMETextPlain
|
||||
}
|
||||
return nil
|
||||
c.Response().Writer.Header().Set(echo.HeaderCacheControl, "max-age=31536000, immutable")
|
||||
c.Response().Writer.Header().Set(echo.HeaderContentSecurityPolicy, "default-src 'self'")
|
||||
if strings.HasPrefix(resourceType, "video") || strings.HasPrefix(resourceType, "audio") {
|
||||
http.ServeContent(c.Response(), c.Request(), resource.Filename, time.Unix(resource.UpdatedTs, 0), bytes.NewReader(resource.Blob))
|
||||
return nil
|
||||
}
|
||||
return c.Stream(http.StatusOK, resourceType, bytes.NewReader(resource.Blob))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) createResourceCreateActivity(c echo.Context, resource *api.Resource) error {
|
||||
ctx := c.Request().Context()
|
||||
payload := api.ActivityResourceCreatePayload{
|
||||
Filename: resource.Filename,
|
||||
Type: resource.Type,
|
||||
Size: resource.Size,
|
||||
}
|
||||
payloadStr, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to marshal activity payload")
|
||||
}
|
||||
activity, err := s.Store.CreateActivity(ctx, &api.ActivityCreate{
|
||||
CreatorID: resource.CreatorID,
|
||||
Type: api.ActivityResourceCreate,
|
||||
Level: api.ActivityInfo,
|
||||
Payload: string(payloadStr),
|
||||
})
|
||||
if err != nil || activity == nil {
|
||||
return errors.Wrap(err, "failed to create activity")
|
||||
}
|
||||
s.Collector.Collect(ctx, &metric.Metric{
|
||||
Name: string(activity.Type),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
+87
-22
@@ -1,13 +1,18 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/usememos/memos/api"
|
||||
metric "github.com/usememos/memos/plugin/metrics"
|
||||
"github.com/usememos/memos/server/profile"
|
||||
"github.com/usememos/memos/store"
|
||||
"github.com/usememos/memos/store/db"
|
||||
|
||||
"github.com/gorilla/securecookie"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/labstack/echo-contrib/session"
|
||||
"github.com/labstack/echo/v4"
|
||||
@@ -17,46 +22,78 @@ import (
|
||||
type Server struct {
|
||||
e *echo.Echo
|
||||
|
||||
ID string
|
||||
Profile *profile.Profile
|
||||
Store *store.Store
|
||||
Collector *MetricCollector
|
||||
|
||||
Profile *profile.Profile
|
||||
|
||||
Store *store.Store
|
||||
}
|
||||
|
||||
func NewServer(profile *profile.Profile) *Server {
|
||||
func NewServer(ctx context.Context, profile *profile.Profile) (*Server, error) {
|
||||
e := echo.New()
|
||||
e.Debug = true
|
||||
e.HideBanner = true
|
||||
e.HidePort = true
|
||||
|
||||
s := &Server{
|
||||
e: e,
|
||||
Profile: profile,
|
||||
}
|
||||
|
||||
db := db.NewDB(profile)
|
||||
if err := db.Open(ctx); err != nil {
|
||||
return nil, errors.Wrap(err, "cannot open db")
|
||||
}
|
||||
|
||||
storeInstance := store.New(db.DBInstance, profile)
|
||||
s.Store = storeInstance
|
||||
|
||||
e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
|
||||
Format: `{"time":"${time_rfc3339}",` +
|
||||
`"method":"${method}","uri":"${uri}",` +
|
||||
`"status":${status},"error":"${error}"}` + "\n",
|
||||
}))
|
||||
|
||||
e.Use(middleware.Gzip())
|
||||
|
||||
e.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{
|
||||
Skipper: s.DefaultAuthSkipper,
|
||||
TokenLookup: "cookie:_csrf",
|
||||
}))
|
||||
|
||||
e.Use(middleware.CORS())
|
||||
|
||||
e.Use(middleware.SecureWithConfig(middleware.SecureConfig{
|
||||
Skipper: DefaultGetRequestSkipper,
|
||||
XSSProtection: "1; mode=block",
|
||||
ContentTypeNosniff: "nosniff",
|
||||
XFrameOptions: "SAMEORIGIN",
|
||||
HSTSPreloadEnabled: false,
|
||||
}))
|
||||
|
||||
e.Use(middleware.TimeoutWithConfig(middleware.TimeoutConfig{
|
||||
Skipper: middleware.DefaultSkipper,
|
||||
ErrorMessage: "Request timeout",
|
||||
Timeout: 30 * time.Second,
|
||||
}))
|
||||
|
||||
serverID, err := s.getSystemServerID(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.ID = serverID
|
||||
|
||||
secretSessionName := "usememos"
|
||||
if profile.Mode == "prod" {
|
||||
secretSessionName, err = s.getSystemSecretSessionName(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
e.Use(session.Middleware(sessions.NewCookieStore([]byte(secretSessionName))))
|
||||
|
||||
embedFrontend(e)
|
||||
|
||||
// In dev mode, set the const secret key to make signin session persistence.
|
||||
secret := []byte("usememos")
|
||||
if profile.Mode == "prod" {
|
||||
secret = securecookie.GenerateRandomKey(16)
|
||||
}
|
||||
e.Use(session.Middleware(sessions.NewCookieStore(secret)))
|
||||
|
||||
s := &Server{
|
||||
e: e,
|
||||
Profile: profile,
|
||||
}
|
||||
// Register MetricCollector to server.
|
||||
s.registerMetricCollector()
|
||||
|
||||
rootGroup := e.Group("")
|
||||
s.registerRSSRoutes(rootGroup)
|
||||
@@ -66,7 +103,7 @@ func NewServer(profile *profile.Profile) *Server {
|
||||
|
||||
publicGroup := e.Group("/o")
|
||||
s.registerResourcePublicRoutes(publicGroup)
|
||||
s.registerGetterPublicRoutes(publicGroup)
|
||||
registerGetterPublicRoutes(publicGroup)
|
||||
|
||||
apiGroup := e.Group("/api")
|
||||
apiGroup.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
@@ -80,9 +117,37 @@ func NewServer(profile *profile.Profile) *Server {
|
||||
s.registerResourceRoutes(apiGroup)
|
||||
s.registerTagRoutes(apiGroup)
|
||||
|
||||
return s
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (server *Server) Run() error {
|
||||
return server.e.Start(fmt.Sprintf(":%d", server.Profile.Port))
|
||||
func (s *Server) Run(ctx context.Context) error {
|
||||
if err := s.createServerStartActivity(ctx); err != nil {
|
||||
return errors.Wrap(err, "failed to create activity")
|
||||
}
|
||||
s.Collector.Identify(ctx)
|
||||
return s.e.Start(fmt.Sprintf(":%d", s.Profile.Port))
|
||||
}
|
||||
|
||||
func (s *Server) createServerStartActivity(ctx context.Context) error {
|
||||
payload := api.ActivityServerStartPayload{
|
||||
ServerID: s.ID,
|
||||
Profile: s.Profile,
|
||||
}
|
||||
payloadStr, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to marshal activity payload")
|
||||
}
|
||||
activity, err := s.Store.CreateActivity(ctx, &api.ActivityCreate{
|
||||
CreatorID: api.UnknownID,
|
||||
Type: api.ActivityServerStart,
|
||||
Level: api.ActivityInfo,
|
||||
Payload: string(payloadStr),
|
||||
})
|
||||
if err != nil || activity == nil {
|
||||
return errors.Wrap(err, "failed to create activity")
|
||||
}
|
||||
s.Collector.Collect(ctx, &metric.Metric{
|
||||
Name: string(activity.Type),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
+66
-20
@@ -7,9 +7,9 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
metric "github.com/usememos/memos/plugin/metrics"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
@@ -21,20 +21,19 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
shortcutCreate := &api.ShortcutCreate{
|
||||
CreatorID: userID,
|
||||
}
|
||||
shortcutCreate := &api.ShortcutCreate{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(shortcutCreate); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post shortcut request").SetInternal(err)
|
||||
}
|
||||
|
||||
shortcutCreate.CreatorID = userID
|
||||
shortcut, err := s.Store.CreateShortcut(ctx, shortcutCreate)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create shortcut").SetInternal(err)
|
||||
}
|
||||
s.Collector.Collect(ctx, &metric.Metric{
|
||||
Name: "shortcut created",
|
||||
})
|
||||
if err := s.createShortcutCreateActivity(c, shortcut); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
|
||||
}
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(shortcut)); err != nil {
|
||||
@@ -45,21 +44,36 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
|
||||
|
||||
g.PATCH("/shortcut/:shortcutId", 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")
|
||||
}
|
||||
shortcutID, err := strconv.Atoi(c.Param("shortcutId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("shortcutId"))).SetInternal(err)
|
||||
}
|
||||
|
||||
shortcutFind := &api.ShortcutFind{
|
||||
ID: &shortcutID,
|
||||
}
|
||||
shortcut, err := s.Store.FindShortcut(ctx, shortcutFind)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find shortcut").SetInternal(err)
|
||||
}
|
||||
if shortcut.CreatorID != userID {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||
}
|
||||
|
||||
currentTs := time.Now().Unix()
|
||||
shortcutPatch := &api.ShortcutPatch{
|
||||
ID: shortcutID,
|
||||
UpdatedTs: ¤tTs,
|
||||
}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(shortcutPatch); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch shortcut request").SetInternal(err)
|
||||
}
|
||||
|
||||
shortcut, err := s.Store.PatchShortcut(ctx, shortcutPatch)
|
||||
shortcutPatch.ID = shortcutID
|
||||
shortcut, err = s.Store.PatchShortcut(ctx, shortcutPatch)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch shortcut").SetInternal(err)
|
||||
}
|
||||
@@ -73,19 +87,14 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
|
||||
|
||||
g.GET("/shortcut", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
shortcutFind := &api.ShortcutFind{}
|
||||
|
||||
if userID, err := strconv.Atoi(c.QueryParam("creatorId")); err == nil {
|
||||
shortcutFind.CreatorID = &userID
|
||||
} else {
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find shortcut")
|
||||
}
|
||||
|
||||
shortcutFind.CreatorID = &userID
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find shortcut")
|
||||
}
|
||||
|
||||
shortcutFind := &api.ShortcutFind{
|
||||
CreatorID: &userID,
|
||||
}
|
||||
list, err := s.Store.FindShortcutList(ctx, shortcutFind)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch shortcut list").SetInternal(err)
|
||||
@@ -122,11 +131,26 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
|
||||
|
||||
g.DELETE("/shortcut/:shortcutId", 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")
|
||||
}
|
||||
shortcutID, err := strconv.Atoi(c.Param("shortcutId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("shortcutId"))).SetInternal(err)
|
||||
}
|
||||
|
||||
shortcutFind := &api.ShortcutFind{
|
||||
ID: &shortcutID,
|
||||
}
|
||||
shortcut, err := s.Store.FindShortcut(ctx, shortcutFind)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find shortcut").SetInternal(err)
|
||||
}
|
||||
if shortcut.CreatorID != userID {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||
}
|
||||
|
||||
shortcutDelete := &api.ShortcutDelete{
|
||||
ID: &shortcutID,
|
||||
}
|
||||
@@ -140,3 +164,25 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
|
||||
return c.JSON(http.StatusOK, true)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) createShortcutCreateActivity(c echo.Context, shortcut *api.Shortcut) error {
|
||||
ctx := c.Request().Context()
|
||||
payload := api.ActivityShortcutCreatePayload{
|
||||
Title: shortcut.Title,
|
||||
Payload: shortcut.Payload,
|
||||
}
|
||||
payloadStr, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to marshal activity payload")
|
||||
}
|
||||
activity, err := s.Store.CreateActivity(ctx, &api.ActivityCreate{
|
||||
CreatorID: shortcut.CreatorID,
|
||||
Type: api.ActivityShortcutCreate,
|
||||
Level: api.ActivityInfo,
|
||||
Payload: string(payloadStr),
|
||||
})
|
||||
if err != nil || activity == nil {
|
||||
return errors.Wrap(err, "failed to create activity")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
+78
-9
@@ -1,13 +1,14 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
metric "github.com/usememos/memos/plugin/metrics"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
@@ -37,6 +38,7 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
|
||||
if hostUser != nil {
|
||||
// data desensitize
|
||||
hostUser.OpenID = ""
|
||||
hostUser.Email = ""
|
||||
}
|
||||
|
||||
systemStatus := api.SystemStatus{
|
||||
@@ -46,6 +48,14 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
|
||||
AllowSignUp: false,
|
||||
AdditionalStyle: "",
|
||||
AdditionalScript: "",
|
||||
CustomizedProfile: api.CustomizedProfile{
|
||||
Name: "memos",
|
||||
LogoURL: "",
|
||||
Description: "",
|
||||
Locale: "en",
|
||||
Appearance: "system",
|
||||
ExternalURL: "",
|
||||
},
|
||||
}
|
||||
|
||||
systemSettingList, err := s.Store.FindSystemSettingList(ctx, &api.SystemSettingFind{})
|
||||
@@ -53,8 +63,12 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting list").SetInternal(err)
|
||||
}
|
||||
for _, systemSetting := range systemSettingList {
|
||||
if systemSetting.Name == api.SystemSettingServerID || systemSetting.Name == api.SystemSettingSecretSessionName {
|
||||
continue
|
||||
}
|
||||
|
||||
var value interface{}
|
||||
err = json.Unmarshal([]byte(systemSetting.Value), &value)
|
||||
err := json.Unmarshal([]byte(systemSetting.Value), &value)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal system setting").SetInternal(err)
|
||||
}
|
||||
@@ -65,6 +79,27 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
|
||||
systemStatus.AdditionalStyle = value.(string)
|
||||
} else if systemSetting.Name == api.SystemSettingAdditionalScriptName {
|
||||
systemStatus.AdditionalScript = value.(string)
|
||||
} else if systemSetting.Name == api.SystemSettingCustomizedProfileName {
|
||||
valueMap := value.(map[string]interface{})
|
||||
systemStatus.CustomizedProfile = api.CustomizedProfile{}
|
||||
if v := valueMap["name"]; v != nil {
|
||||
systemStatus.CustomizedProfile.Name = v.(string)
|
||||
}
|
||||
if v := valueMap["logoUrl"]; v != nil {
|
||||
systemStatus.CustomizedProfile.LogoURL = v.(string)
|
||||
}
|
||||
if v := valueMap["description"]; v != nil {
|
||||
systemStatus.CustomizedProfile.Description = v.(string)
|
||||
}
|
||||
if v := valueMap["locale"]; v != nil {
|
||||
systemStatus.CustomizedProfile.Locale = v.(string)
|
||||
}
|
||||
if v := valueMap["appearance"]; v != nil {
|
||||
systemStatus.CustomizedProfile.Appearance = v.(string)
|
||||
}
|
||||
if v := valueMap["externalUrl"]; v != nil {
|
||||
systemStatus.CustomizedProfile.ExternalURL = v.(string)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,9 +141,7 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
|
||||
}
|
||||
if user == nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "Current signin user not found")
|
||||
} else if user.Role != api.Host {
|
||||
if user == nil || user.Role != api.Host {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||
}
|
||||
|
||||
@@ -124,10 +157,6 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert system setting").SetInternal(err)
|
||||
}
|
||||
s.Collector.Collect(ctx, &metric.Metric{
|
||||
Name: "systemSetting updated",
|
||||
Labels: map[string]string{"field": string(systemSettingUpsert.Name)},
|
||||
})
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(systemSetting)); err != nil {
|
||||
@@ -172,3 +201,43 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) getSystemServerID(ctx context.Context) (string, error) {
|
||||
serverIDKey := api.SystemSettingServerID
|
||||
serverIDValue, err := s.Store.FindSystemSetting(ctx, &api.SystemSettingFind{
|
||||
Name: &serverIDKey,
|
||||
})
|
||||
if err != nil && common.ErrorCode(err) != common.NotFound {
|
||||
return "", err
|
||||
}
|
||||
if serverIDValue == nil || serverIDValue.Value == "" {
|
||||
serverIDValue, err = s.Store.UpsertSystemSetting(ctx, &api.SystemSettingUpsert{
|
||||
Name: serverIDKey,
|
||||
Value: uuid.NewString(),
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return serverIDValue.Value, nil
|
||||
}
|
||||
|
||||
func (s *Server) getSystemSecretSessionName(ctx context.Context) (string, error) {
|
||||
secretSessionNameKey := api.SystemSettingSecretSessionName
|
||||
secretSessionNameValue, err := s.Store.FindSystemSetting(ctx, &api.SystemSettingFind{
|
||||
Name: &secretSessionNameKey,
|
||||
})
|
||||
if err != nil && common.ErrorCode(err) != common.NotFound {
|
||||
return "", err
|
||||
}
|
||||
if secretSessionNameValue == nil || secretSessionNameValue.Value == "" {
|
||||
secretSessionNameValue, err = s.Store.UpsertSystemSetting(ctx, &api.SystemSettingUpsert{
|
||||
Name: secretSessionNameKey,
|
||||
Value: uuid.NewString(),
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return secretSessionNameValue.Value, nil
|
||||
}
|
||||
|
||||
+138
-27
@@ -2,55 +2,116 @@ package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
"golang.org/x/exp/slices"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
var tagRegexpList = []*regexp.Regexp{regexp.MustCompile(`^#([^\s#]+?) `), regexp.MustCompile(`[^\S]#([^\s#]+?) `), regexp.MustCompile(` #([^\s#]+?) `)}
|
||||
|
||||
func (s *Server) registerTagRoutes(g *echo.Group) {
|
||||
g.POST("/tag", 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")
|
||||
}
|
||||
|
||||
tagUpsert := &api.TagUpsert{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(tagUpsert); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post tag request").SetInternal(err)
|
||||
}
|
||||
if tagUpsert.Name == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Tag name shouldn't be empty")
|
||||
}
|
||||
|
||||
tagUpsert.CreatorID = userID
|
||||
tag, err := s.Store.UpsertTag(ctx, tagUpsert)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert tag").SetInternal(err)
|
||||
}
|
||||
if err := s.createTagCreateActivity(c, tag); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
|
||||
}
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(tag.Name)); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode tag response").SetInternal(err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
g.GET("/tag", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find tag")
|
||||
}
|
||||
|
||||
tagFind := &api.TagFind{
|
||||
CreatorID: userID,
|
||||
}
|
||||
tagList, err := s.Store.FindTagList(ctx, tagFind)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find tag list").SetInternal(err)
|
||||
}
|
||||
|
||||
tagNameList := []string{}
|
||||
for _, tag := range tagList {
|
||||
tagNameList = append(tagNameList, tag.Name)
|
||||
}
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(tagNameList)); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode tags response").SetInternal(err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
g.GET("/tag/suggestion", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Missing user session")
|
||||
}
|
||||
contentSearch := "#"
|
||||
normalRowStatus := api.Normal
|
||||
memoFind := api.MemoFind{
|
||||
CreatorID: &userID,
|
||||
ContentSearch: &contentSearch,
|
||||
RowStatus: &normalRowStatus,
|
||||
}
|
||||
|
||||
if userID, err := strconv.Atoi(c.QueryParam("creatorId")); err == nil {
|
||||
memoFind.CreatorID = &userID
|
||||
}
|
||||
|
||||
currentUserID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
if memoFind.CreatorID == nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find memo")
|
||||
}
|
||||
memoFind.VisibilityList = []api.Visibility{api.Public}
|
||||
} else {
|
||||
if memoFind.CreatorID == nil {
|
||||
memoFind.CreatorID = ¤tUserID
|
||||
} else {
|
||||
memoFind.VisibilityList = []api.Visibility{api.Public, api.Protected}
|
||||
}
|
||||
}
|
||||
|
||||
memoList, err := s.Store.FindMemoList(ctx, &memoFind)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo list").SetInternal(err)
|
||||
}
|
||||
|
||||
tagFind := &api.TagFind{
|
||||
CreatorID: userID,
|
||||
}
|
||||
existTagList, err := s.Store.FindTagList(ctx, tagFind)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find tag list").SetInternal(err)
|
||||
}
|
||||
tagNameList := []string{}
|
||||
for _, tag := range existTagList {
|
||||
tagNameList = append(tagNameList, tag.Name)
|
||||
}
|
||||
|
||||
tagMapSet := make(map[string]bool)
|
||||
for _, memo := range memoList {
|
||||
for _, tag := range findTagListFromMemoContent(memo.Content) {
|
||||
tagMapSet[tag] = true
|
||||
if !slices.Contains(tagNameList, tag) {
|
||||
tagMapSet[tag] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
tagList := []string{}
|
||||
@@ -65,15 +126,44 @@ func (s *Server) registerTagRoutes(g *echo.Group) {
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
g.DELETE("/tag/:tagName", 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")
|
||||
}
|
||||
|
||||
tagName, err := url.QueryUnescape(c.Param("tagName"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid tag name").SetInternal(err)
|
||||
} else if tagName == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Tag name cannot be empty")
|
||||
}
|
||||
|
||||
tagDelete := &api.TagDelete{
|
||||
Name: tagName,
|
||||
CreatorID: userID,
|
||||
}
|
||||
if err := s.Store.DeleteTag(ctx, tagDelete); err != nil {
|
||||
if common.ErrorCode(err) == common.NotFound {
|
||||
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Tag name not found: %s", tagName))
|
||||
}
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to delete tag name: %v", tagName)).SetInternal(err)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, true)
|
||||
})
|
||||
}
|
||||
|
||||
var tagRegexp = regexp.MustCompile(`#([^\s#]+)`)
|
||||
|
||||
func findTagListFromMemoContent(memoContent string) []string {
|
||||
tagMapSet := make(map[string]bool)
|
||||
for _, tagRegexp := range tagRegexpList {
|
||||
for _, rawTag := range tagRegexp.FindAllString(memoContent, -1) {
|
||||
tag := tagRegexp.ReplaceAllString(rawTag, "$1")
|
||||
tagMapSet[tag] = true
|
||||
}
|
||||
matches := tagRegexp.FindAllStringSubmatch(memoContent, -1)
|
||||
for _, v := range matches {
|
||||
tagName := v[1]
|
||||
tagMapSet[tagName] = true
|
||||
}
|
||||
|
||||
tagList := []string{}
|
||||
@@ -83,3 +173,24 @@ func findTagListFromMemoContent(memoContent string) []string {
|
||||
sort.Strings(tagList)
|
||||
return tagList
|
||||
}
|
||||
|
||||
func (s *Server) createTagCreateActivity(c echo.Context, tag *api.Tag) error {
|
||||
ctx := c.Request().Context()
|
||||
payload := api.ActivityTagCreatePayload{
|
||||
TagName: tag.Name,
|
||||
}
|
||||
payloadStr, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to marshal activity payload")
|
||||
}
|
||||
activity, err := s.Store.CreateActivity(ctx, &api.ActivityCreate{
|
||||
CreatorID: tag.CreatorID,
|
||||
Type: api.ActivityTagCreate,
|
||||
Level: api.ActivityInfo,
|
||||
Payload: string(payloadStr),
|
||||
})
|
||||
if err != nil || activity == nil {
|
||||
return errors.Wrap(err, "failed to create activity")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
+2
-2
@@ -31,11 +31,11 @@ func TestFindTagListFromMemoContent(t *testing.T) {
|
||||
},
|
||||
{
|
||||
memoContent: "#tag1 123123#tag2 \n#tag3 #tag4 ",
|
||||
want: []string{"tag1", "tag3", "tag4"},
|
||||
want: []string{"tag1", "tag2", "tag3", "tag4"},
|
||||
},
|
||||
{
|
||||
memoContent: "#tag1 http://123123.com?123123#tag2 \n#tag3 #tag4 http://123123.com?123123#tag2) ",
|
||||
want: []string{"tag1", "tag3", "tag4"},
|
||||
want: []string{"tag1", "tag2", "tag2)", "tag3", "tag4"},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
|
||||
+42
-12
@@ -7,6 +7,7 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
metric "github.com/usememos/memos/plugin/metrics"
|
||||
@@ -29,18 +30,20 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user by id").SetInternal(err)
|
||||
}
|
||||
if currentUser.Role != api.Host {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Only Host user can create member.")
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Only Host user can create member")
|
||||
}
|
||||
|
||||
userCreate := &api.UserCreate{
|
||||
OpenID: common.GenUUID(),
|
||||
}
|
||||
userCreate := &api.UserCreate{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(userCreate); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post user request").SetInternal(err)
|
||||
}
|
||||
if userCreate.Role == api.Host {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "Could not create host user")
|
||||
}
|
||||
userCreate.OpenID = common.GenUUID()
|
||||
|
||||
if err := userCreate.Validate(); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid user create format.").SetInternal(err)
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid user create format").SetInternal(err)
|
||||
}
|
||||
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(userCreate.Password), bcrypt.DefaultCost)
|
||||
@@ -53,9 +56,9 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err)
|
||||
}
|
||||
s.Collector.Collect(ctx, &metric.Metric{
|
||||
Name: "user created",
|
||||
})
|
||||
if err := s.createUserCreateActivity(c, user); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
|
||||
}
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(user)); err != nil {
|
||||
@@ -74,6 +77,7 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
|
||||
for _, user := range userList {
|
||||
// data desensitize
|
||||
user.OpenID = ""
|
||||
user.Email = ""
|
||||
}
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||
@@ -159,6 +163,7 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
|
||||
if user != nil {
|
||||
// data desensitize
|
||||
user.OpenID = ""
|
||||
user.Email = ""
|
||||
}
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||
@@ -192,15 +197,14 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
|
||||
|
||||
currentTs := time.Now().Unix()
|
||||
userPatch := &api.UserPatch{
|
||||
ID: userID,
|
||||
UpdatedTs: ¤tTs,
|
||||
}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(userPatch); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch user request").SetInternal(err)
|
||||
}
|
||||
|
||||
if userPatch.Email != nil && *userPatch.Email != "" && !common.ValidateEmail(*userPatch.Email) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid email format")
|
||||
userPatch.ID = userID
|
||||
if err := userPatch.Validate(); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid user patch format").SetInternal(err)
|
||||
}
|
||||
|
||||
if userPatch.Password != nil && *userPatch.Password != "" {
|
||||
@@ -274,3 +278,29 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
|
||||
return c.JSON(http.StatusOK, true)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) createUserCreateActivity(c echo.Context, user *api.User) error {
|
||||
ctx := c.Request().Context()
|
||||
payload := api.ActivityUserCreatePayload{
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
}
|
||||
payloadStr, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to marshal activity payload")
|
||||
}
|
||||
activity, err := s.Store.CreateActivity(ctx, &api.ActivityCreate{
|
||||
CreatorID: user.ID,
|
||||
Type: api.ActivityUserCreate,
|
||||
Level: api.ActivityInfo,
|
||||
Payload: string(payloadStr),
|
||||
})
|
||||
if err != nil || activity == nil {
|
||||
return errors.Wrap(err, "failed to create activity")
|
||||
}
|
||||
s.Collector.Collect(ctx, &metric.Metric{
|
||||
Name: string(activity.Type),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
+23
-29
@@ -1,16 +1,18 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/mod/semver"
|
||||
)
|
||||
|
||||
// Version is the service current released version.
|
||||
// Semantic versioning: https://semver.org/
|
||||
var Version = "0.8.2"
|
||||
var Version = "0.10.2"
|
||||
|
||||
// DevVersion is the service current development version.
|
||||
var DevVersion = "0.8.2"
|
||||
var DevVersion = "0.10.2"
|
||||
|
||||
func GetCurrentVersion(mode string) string {
|
||||
if mode == "dev" {
|
||||
@@ -29,39 +31,31 @@ func GetMinorVersion(version string) string {
|
||||
|
||||
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, ".")
|
||||
|
||||
if len(versionList) < 3 {
|
||||
return 0
|
||||
}
|
||||
major, err := strconv.Atoi(versionList[0])
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
minor, err := strconv.Atoi(versionList[1])
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
patch, err := strconv.Atoi(versionList[2])
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return major*10000 + minor*100 + patch
|
||||
}
|
||||
|
||||
// IsVersionGreaterThanOrEqualTo returns true if version is greater than or equal to target.
|
||||
func IsVersionGreaterOrEqualThan(version, target string) bool {
|
||||
return convSemanticVersionToInt(version) >= convSemanticVersionToInt(target)
|
||||
return semver.Compare(fmt.Sprintf("v%s", version), fmt.Sprintf("v%s", target)) > -1
|
||||
}
|
||||
|
||||
// IsVersionGreaterThan returns true if version is greater than target.
|
||||
func IsVersionGreaterThan(version, target string) bool {
|
||||
return convSemanticVersionToInt(version) > convSemanticVersionToInt(target)
|
||||
return semver.Compare(fmt.Sprintf("v%s", version), fmt.Sprintf("v%s", target)) > 0
|
||||
}
|
||||
|
||||
type SortVersion []string
|
||||
|
||||
func (s SortVersion) Len() int {
|
||||
return len(s)
|
||||
}
|
||||
|
||||
func (s SortVersion) Swap(i, j int) {
|
||||
s[i], s[j] = s[j], s[i]
|
||||
}
|
||||
|
||||
func (s SortVersion) Less(i, j int) bool {
|
||||
v1 := fmt.Sprintf("v%s", s[i])
|
||||
v2 := fmt.Sprintf("v%s", s[j])
|
||||
return semver.Compare(v1, v2) == -1
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestIsVersionGreaterOrEqualThan(t *testing.T) {
|
||||
tests := []struct {
|
||||
version string
|
||||
target string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
version: "0.9.1",
|
||||
target: "0.9.1",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
version: "0.10.0",
|
||||
target: "0.9.1",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
version: "0.9.0",
|
||||
target: "0.9.1",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
result := IsVersionGreaterOrEqualThan(test.version, test.target)
|
||||
if result != test.want {
|
||||
t.Errorf("got result %v, want %v.", result, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsVersionGreaterThan(t *testing.T) {
|
||||
tests := []struct {
|
||||
version string
|
||||
target string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
version: "0.9.1",
|
||||
target: "0.9.1",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
version: "0.10.0",
|
||||
target: "0.8.0",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
version: "0.8.0",
|
||||
target: "0.10.0",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
version: "0.9.0",
|
||||
target: "0.9.1",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
result := IsVersionGreaterThan(test.version, test.target)
|
||||
if result != test.want {
|
||||
t.Errorf("got result %v, want %v.", result, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
versionList []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
versionList: []string{"0.9.1", "0.10.0", "0.8.0"},
|
||||
want: []string{"0.8.0", "0.9.1", "0.10.0"},
|
||||
},
|
||||
{
|
||||
versionList: []string{"1.9.1", "0.9.1", "0.10.0", "0.8.0"},
|
||||
want: []string{"0.8.0", "0.9.1", "0.10.0", "1.9.1"},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
sort.Sort(SortVersion(test.versionList))
|
||||
assert.Equal(t, test.versionList, test.want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/usememos/memos/api"
|
||||
)
|
||||
|
||||
// activityRaw is the store model for an Activity.
|
||||
// Fields have exactly the same meanings as Activity.
|
||||
type activityRaw struct {
|
||||
ID int
|
||||
|
||||
// Standard fields
|
||||
CreatorID int
|
||||
CreatedTs int64
|
||||
|
||||
// Domain specific fields
|
||||
Type api.ActivityType
|
||||
Level api.ActivityLevel
|
||||
Payload string
|
||||
}
|
||||
|
||||
// toActivity creates an instance of Activity based on the ActivityRaw.
|
||||
func (raw *activityRaw) toActivity() *api.Activity {
|
||||
return &api.Activity{
|
||||
ID: raw.ID,
|
||||
|
||||
CreatorID: raw.CreatorID,
|
||||
CreatedTs: raw.CreatedTs,
|
||||
|
||||
Type: raw.Type,
|
||||
Level: raw.Level,
|
||||
Payload: raw.Payload,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateActivity creates an instance of Activity.
|
||||
func (s *Store) CreateActivity(ctx context.Context, create *api.ActivityCreate) (*api.Activity, error) {
|
||||
if s.profile.Mode != "dev" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
activityRaw, err := createActivity(ctx, tx, create)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
|
||||
activity := activityRaw.toActivity()
|
||||
return activity, nil
|
||||
}
|
||||
|
||||
// createActivity creates a new activity.
|
||||
func createActivity(ctx context.Context, tx *sql.Tx, create *api.ActivityCreate) (*activityRaw, error) {
|
||||
query := `
|
||||
INSERT INTO activity (
|
||||
creator_id,
|
||||
type,
|
||||
level,
|
||||
payload
|
||||
)
|
||||
VALUES (?, ?, ?, ?)
|
||||
RETURNING id, type, level, payload, creator_id, created_ts
|
||||
`
|
||||
var activityRaw activityRaw
|
||||
if err := tx.QueryRowContext(ctx, query, create.CreatorID, create.Type, create.Level, create.Payload).Scan(
|
||||
&activityRaw.ID,
|
||||
&activityRaw.Type,
|
||||
&activityRaw.Level,
|
||||
&activityRaw.Payload,
|
||||
&activityRaw.CreatedTs,
|
||||
&activityRaw.CreatedTs,
|
||||
); err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
|
||||
return &activityRaw, nil
|
||||
}
|
||||
+21
-13
@@ -24,8 +24,8 @@ var seedFS embed.FS
|
||||
|
||||
type DB struct {
|
||||
// sqlite db connection instance
|
||||
Db *sql.DB
|
||||
profile *profile.Profile
|
||||
DBInstance *sql.DB
|
||||
profile *profile.Profile
|
||||
}
|
||||
|
||||
// NewDB returns a new instance of DB associated with the given datasource name.
|
||||
@@ -43,11 +43,11 @@ func (db *DB) Open(ctx context.Context) (err error) {
|
||||
}
|
||||
|
||||
// Connect to the database without foreign_key.
|
||||
sqlDB, err := sql.Open("sqlite3", db.profile.DSN+"?_foreign_keys=0")
|
||||
sqliteDB, err := sql.Open("sqlite3", db.profile.DSN+"?cache=shared&_foreign_keys=0&_journal_mode=WAL")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open db with dsn: %s, err: %w", db.profile.DSN, err)
|
||||
}
|
||||
db.Db = sqlDB
|
||||
db.DBInstance = sqliteDB
|
||||
|
||||
if db.profile.Mode == "dev" {
|
||||
// In dev mode, we should migrate and seed the database.
|
||||
@@ -68,20 +68,28 @@ func (db *DB) Open(ctx context.Context) (err error) {
|
||||
}
|
||||
|
||||
currentVersion := version.GetCurrentVersion(db.profile.Mode)
|
||||
migrationHistory, err := db.FindMigrationHistory(ctx, &MigrationHistoryFind{})
|
||||
migrationHistoryList, err := db.FindMigrationHistoryList(ctx, &MigrationHistoryFind{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find migration history, err: %w", err)
|
||||
}
|
||||
if migrationHistory == nil {
|
||||
if _, err = db.UpsertMigrationHistory(ctx, &MigrationHistoryUpsert{
|
||||
if len(migrationHistoryList) == 0 {
|
||||
_, err := db.UpsertMigrationHistory(ctx, &MigrationHistoryUpsert{
|
||||
Version: currentVersion,
|
||||
}); err != nil {
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to upsert migration history, err: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if version.IsVersionGreaterThan(version.GetSchemaVersion(currentVersion), migrationHistory.Version) {
|
||||
migrationHistoryVersionList := []string{}
|
||||
for _, migrationHistory := range migrationHistoryList {
|
||||
migrationHistoryVersionList = append(migrationHistoryVersionList, migrationHistory.Version)
|
||||
}
|
||||
sort.Sort(version.SortVersion(migrationHistoryVersionList))
|
||||
latestMigrationHistoryVersion := migrationHistoryVersionList[len(migrationHistoryVersionList)-1]
|
||||
|
||||
if version.IsVersionGreaterThan(version.GetSchemaVersion(currentVersion), latestMigrationHistoryVersion) {
|
||||
minorVersionList := getMinorVersionList()
|
||||
|
||||
// backup the raw database file before migration
|
||||
@@ -98,7 +106,7 @@ func (db *DB) Open(ctx context.Context) (err error) {
|
||||
println("start migrate")
|
||||
for _, minorVersion := range minorVersionList {
|
||||
normalizedVersion := minorVersion + ".0"
|
||||
if version.IsVersionGreaterThan(normalizedVersion, migrationHistory.Version) && version.IsVersionGreaterOrEqualThan(currentVersion, normalizedVersion) {
|
||||
if version.IsVersionGreaterThan(normalizedVersion, latestMigrationHistoryVersion) && 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)
|
||||
@@ -156,7 +164,7 @@ func (db *DB) applyMigrationForMinorVersion(ctx context.Context, minorVersion st
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := db.Db.Begin()
|
||||
tx, err := db.DBInstance.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -197,7 +205,7 @@ func (db *DB) seed(ctx context.Context) error {
|
||||
|
||||
// execute runs a single SQL statement within a transaction.
|
||||
func (db *DB) execute(ctx context.Context, stmt string) error {
|
||||
tx, err := db.Db.Begin()
|
||||
tx, err := db.DBInstance.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -229,7 +237,7 @@ func getMinorVersionList() []string {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
sort.Strings(minorVersionList)
|
||||
sort.Sort(version.SortVersion(minorVersionList))
|
||||
|
||||
return minorVersionList
|
||||
}
|
||||
|
||||
@@ -86,3 +86,20 @@ CREATE TABLE memo_resource (
|
||||
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
UNIQUE(memo_id, resource_id)
|
||||
);
|
||||
|
||||
-- tag
|
||||
CREATE TABLE tag (
|
||||
name TEXT NOT NULL,
|
||||
creator_id INTEGER NOT NULL,
|
||||
UNIQUE(name, creator_id)
|
||||
);
|
||||
|
||||
-- activity
|
||||
CREATE TABLE activity (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
creator_id INTEGER NOT NULL,
|
||||
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
type TEXT NOT NULL DEFAULT '',
|
||||
level TEXT NOT NULL CHECK (level IN ('INFO', 'WARN', 'ERROR')) DEFAULT 'INFO',
|
||||
payload TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- activity
|
||||
CREATE TABLE activity (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
creator_id INTEGER NOT NULL,
|
||||
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
type TEXT NOT NULL DEFAULT '',
|
||||
level TEXT NOT NULL CHECK (level IN ('INFO', 'WARN', 'ERROR')) DEFAULT 'INFO',
|
||||
payload TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
-- tag
|
||||
CREATE TABLE tag (
|
||||
name TEXT NOT NULL,
|
||||
creator_id INTEGER NOT NULL,
|
||||
UNIQUE(name, creator_id)
|
||||
);
|
||||
@@ -86,3 +86,20 @@ CREATE TABLE memo_resource (
|
||||
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
UNIQUE(memo_id, resource_id)
|
||||
);
|
||||
|
||||
-- tag
|
||||
CREATE TABLE tag (
|
||||
name TEXT NOT NULL,
|
||||
creator_id INTEGER NOT NULL,
|
||||
UNIQUE(name, creator_id)
|
||||
);
|
||||
|
||||
-- activity
|
||||
CREATE TABLE activity (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
creator_id INTEGER NOT NULL,
|
||||
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
type TEXT NOT NULL DEFAULT '',
|
||||
level TEXT NOT NULL CHECK (level IN ('INFO', 'WARN', 'ERROR')) DEFAULT 'INFO',
|
||||
payload TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
@@ -19,8 +19,8 @@ type MigrationHistoryFind struct {
|
||||
Version *string
|
||||
}
|
||||
|
||||
func (db *DB) FindMigrationHistory(ctx context.Context, find *MigrationHistoryFind) (*MigrationHistory, error) {
|
||||
tx, err := db.Db.BeginTx(ctx, nil)
|
||||
func (db *DB) FindMigrationHistoryList(ctx context.Context, find *MigrationHistoryFind) ([]*MigrationHistory, error) {
|
||||
tx, err := db.DBInstance.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -31,16 +31,11 @@ func (db *DB) FindMigrationHistory(ctx context.Context, find *MigrationHistoryFi
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(list) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
migrationHistory := list[0]
|
||||
return migrationHistory, nil
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (db *DB) UpsertMigrationHistory(ctx context.Context, upsert *MigrationHistoryUpsert) (*MigrationHistory, error) {
|
||||
tx, err := db.Db.BeginTx(ctx, nil)
|
||||
tx, err := db.DBInstance.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
INSERT INTO
|
||||
tag (
|
||||
`name`,
|
||||
`creator_id`
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
'Hello',
|
||||
101
|
||||
);
|
||||
|
||||
INSERT INTO
|
||||
tag (
|
||||
`name`,
|
||||
`creator_id`
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
'TODO',
|
||||
101
|
||||
);
|
||||
|
||||
INSERT INTO
|
||||
tag (
|
||||
`name`,
|
||||
`creator_id`
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
'TODO',
|
||||
102
|
||||
);
|
||||
+19
-12
@@ -22,10 +22,11 @@ type resourceRaw struct {
|
||||
UpdatedTs int64
|
||||
|
||||
// Domain specific fields
|
||||
Filename string
|
||||
Blob []byte
|
||||
Type string
|
||||
Size int64
|
||||
Filename string
|
||||
Blob []byte
|
||||
ExternalLink string
|
||||
Type string
|
||||
Size int64
|
||||
}
|
||||
|
||||
func (raw *resourceRaw) toResource() *api.Resource {
|
||||
@@ -38,10 +39,11 @@ func (raw *resourceRaw) toResource() *api.Resource {
|
||||
UpdatedTs: raw.UpdatedTs,
|
||||
|
||||
// Domain specific fields
|
||||
Filename: raw.Filename,
|
||||
Blob: raw.Blob,
|
||||
Type: raw.Type,
|
||||
Size: raw.Size,
|
||||
Filename: raw.Filename,
|
||||
Blob: raw.Blob,
|
||||
ExternalLink: raw.ExternalLink,
|
||||
Type: raw.Type,
|
||||
Size: raw.Size,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,18 +217,20 @@ func createResource(ctx context.Context, tx *sql.Tx, create *api.ResourceCreate)
|
||||
INSERT INTO resource (
|
||||
filename,
|
||||
blob,
|
||||
external_link,
|
||||
type,
|
||||
size,
|
||||
creator_id
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
RETURNING id, filename, blob, type, size, creator_id, created_ts, updated_ts
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
RETURNING id, filename, blob, external_link, type, size, creator_id, created_ts, updated_ts
|
||||
`
|
||||
var resourceRaw resourceRaw
|
||||
if err := tx.QueryRowContext(ctx, query, create.Filename, create.Blob, create.Type, create.Size, create.CreatorID).Scan(
|
||||
if err := tx.QueryRowContext(ctx, query, create.Filename, create.Blob, create.ExternalLink, create.Type, create.Size, create.CreatorID).Scan(
|
||||
&resourceRaw.ID,
|
||||
&resourceRaw.Filename,
|
||||
&resourceRaw.Blob,
|
||||
&resourceRaw.ExternalLink,
|
||||
&resourceRaw.Type,
|
||||
&resourceRaw.Size,
|
||||
&resourceRaw.CreatorID,
|
||||
@@ -255,13 +259,14 @@ func patchResource(ctx context.Context, tx *sql.Tx, patch *api.ResourcePatch) (*
|
||||
UPDATE resource
|
||||
SET ` + strings.Join(set, ", ") + `
|
||||
WHERE id = ?
|
||||
RETURNING id, filename, blob, type, size, creator_id, created_ts, updated_ts
|
||||
RETURNING id, filename, blob, external_link, type, size, creator_id, created_ts, updated_ts
|
||||
`
|
||||
var resourceRaw resourceRaw
|
||||
if err := tx.QueryRowContext(ctx, query, args...).Scan(
|
||||
&resourceRaw.ID,
|
||||
&resourceRaw.Filename,
|
||||
&resourceRaw.Blob,
|
||||
&resourceRaw.ExternalLink,
|
||||
&resourceRaw.Type,
|
||||
&resourceRaw.Size,
|
||||
&resourceRaw.CreatorID,
|
||||
@@ -295,6 +300,7 @@ func findResourceList(ctx context.Context, tx *sql.Tx, find *api.ResourceFind) (
|
||||
id,
|
||||
filename,
|
||||
blob,
|
||||
external_link,
|
||||
type,
|
||||
size,
|
||||
creator_id,
|
||||
@@ -317,6 +323,7 @@ func findResourceList(ctx context.Context, tx *sql.Tx, find *api.ResourceFind) (
|
||||
&resourceRaw.ID,
|
||||
&resourceRaw.Filename,
|
||||
&resourceRaw.Blob,
|
||||
&resourceRaw.ExternalLink,
|
||||
&resourceRaw.Type,
|
||||
&resourceRaw.Size,
|
||||
&resourceRaw.CreatorID,
|
||||
|
||||
+2
-1
@@ -217,13 +217,14 @@ func patchShortcut(ctx context.Context, tx *sql.Tx, patch *api.ShortcutPatch) (*
|
||||
UPDATE shortcut
|
||||
SET ` + strings.Join(set, ", ") + `
|
||||
WHERE id = ?
|
||||
RETURNING id, title, payload, created_ts, updated_ts, row_status
|
||||
RETURNING id, title, payload, creator_id, created_ts, updated_ts, row_status
|
||||
`
|
||||
var shortcutRaw shortcutRaw
|
||||
if err := tx.QueryRowContext(ctx, query, args...).Scan(
|
||||
&shortcutRaw.ID,
|
||||
&shortcutRaw.Title,
|
||||
&shortcutRaw.Payload,
|
||||
&shortcutRaw.CreatorID,
|
||||
&shortcutRaw.CreatedTs,
|
||||
&shortcutRaw.UpdatedTs,
|
||||
&shortcutRaw.RowStatus,
|
||||
|
||||
@@ -67,6 +67,9 @@ func vacuum(ctx context.Context, tx *sql.Tx) error {
|
||||
return err
|
||||
}
|
||||
if err := vacuumMemoResource(ctx, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := vacuumTag(ctx, tx); err != nil {
|
||||
// Prevent revive warning.
|
||||
return err
|
||||
}
|
||||
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
)
|
||||
|
||||
type tagRaw struct {
|
||||
Name string
|
||||
CreatorID int
|
||||
}
|
||||
|
||||
func (raw *tagRaw) toTag() *api.Tag {
|
||||
return &api.Tag{
|
||||
Name: raw.Name,
|
||||
CreatorID: raw.CreatorID,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) UpsertTag(ctx context.Context, upsert *api.TagUpsert) (*api.Tag, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
tagRaw, err := upsertTag(ctx, tx, upsert)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tag := tagRaw.toTag()
|
||||
|
||||
return tag, nil
|
||||
}
|
||||
|
||||
func (s *Store) FindTagList(ctx context.Context, find *api.TagFind) ([]*api.Tag, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
tagRawList, err := findTagList(ctx, tx, find)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
list := []*api.Tag{}
|
||||
for _, raw := range tagRawList {
|
||||
list = append(list, raw.toTag())
|
||||
}
|
||||
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteTag(ctx context.Context, delete *api.TagDelete) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return FormatError(err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if err := deleteTag(ctx, tx, delete); err != nil {
|
||||
return FormatError(err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return FormatError(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func upsertTag(ctx context.Context, tx *sql.Tx, upsert *api.TagUpsert) (*tagRaw, error) {
|
||||
query := `
|
||||
INSERT INTO tag (
|
||||
name, creator_id
|
||||
)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(name, creator_id) DO UPDATE
|
||||
SET
|
||||
name = EXCLUDED.name
|
||||
RETURNING name, creator_id
|
||||
`
|
||||
var tagRaw tagRaw
|
||||
if err := tx.QueryRowContext(ctx, query, upsert.Name, upsert.CreatorID).Scan(
|
||||
&tagRaw.Name,
|
||||
&tagRaw.CreatorID,
|
||||
); err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
|
||||
return &tagRaw, nil
|
||||
}
|
||||
|
||||
func findTagList(ctx context.Context, tx *sql.Tx, find *api.TagFind) ([]*tagRaw, error) {
|
||||
where, args := []string{"creator_id = ?"}, []interface{}{find.CreatorID}
|
||||
|
||||
query := `
|
||||
SELECT
|
||||
name,
|
||||
creator_id
|
||||
FROM tag
|
||||
WHERE ` + strings.Join(where, " AND ")
|
||||
rows, err := tx.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
tagRawList := make([]*tagRaw, 0)
|
||||
for rows.Next() {
|
||||
var tagRaw tagRaw
|
||||
if err := rows.Scan(
|
||||
&tagRaw.Name,
|
||||
&tagRaw.CreatorID,
|
||||
); err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
|
||||
tagRawList = append(tagRawList, &tagRaw)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
|
||||
return tagRawList, nil
|
||||
}
|
||||
|
||||
func deleteTag(ctx context.Context, tx *sql.Tx, delete *api.TagDelete) error {
|
||||
where, args := []string{"name = ?", "creator_id = ?"}, []interface{}{delete.Name, delete.CreatorID}
|
||||
|
||||
stmt := `DELETE FROM tag WHERE ` + strings.Join(where, " AND ")
|
||||
result, err := tx.ExecContext(ctx, stmt, args...)
|
||||
if err != nil {
|
||||
return FormatError(err)
|
||||
}
|
||||
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return &common.Error{Code: common.NotFound, Err: fmt.Errorf("tag not found")}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func vacuumTag(ctx context.Context, tx *sql.Tx) error {
|
||||
stmt := `
|
||||
DELETE FROM
|
||||
tag
|
||||
WHERE
|
||||
creator_id NOT IN (
|
||||
SELECT
|
||||
id
|
||||
FROM
|
||||
user
|
||||
)`
|
||||
_, err := tx.ExecContext(ctx, stmt)
|
||||
if err != nil {
|
||||
return FormatError(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
+2
-2
@@ -2,8 +2,8 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/logo.webp" type="image/*" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f6f5f4" />
|
||||
<link rel="icon" href="/logo.png" type="image/*" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f4f4f5" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#27272a" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
+10
-6
@@ -3,14 +3,13 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"serve": "vite preview",
|
||||
"lint": "eslint --ext .js,.ts,.tsx, src",
|
||||
"test": "jest --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.10.5",
|
||||
"@emotion/styled": "^11.10.5",
|
||||
"@mui/joy": "^5.0.0-alpha.56",
|
||||
"@mui/joy": "^5.0.0-alpha.63",
|
||||
"@reduxjs/toolkit": "^1.8.1",
|
||||
"axios": "^0.27.2",
|
||||
"copy-to-clipboard": "^3.3.2",
|
||||
@@ -18,6 +17,7 @@
|
||||
"highlight.js": "^11.6.0",
|
||||
"i18next": "^21.9.2",
|
||||
"lodash-es": "^4.17.21",
|
||||
"qrcode.react": "^3.1.0",
|
||||
"qs": "^6.11.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
@@ -25,6 +25,7 @@
|
||||
"react-i18next": "^11.18.6",
|
||||
"react-redux": "^8.0.1",
|
||||
"react-router-dom": "^6.4.0",
|
||||
"semver": "^7.3.8",
|
||||
"tailwindcss": "^3.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -32,11 +33,13 @@
|
||||
"@types/lodash-es": "^4.17.5",
|
||||
"@types/node": "^18.0.3",
|
||||
"@types/qs": "^6.9.7",
|
||||
"@types/react": "^18.0.21",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"@types/react": "^18.0.26",
|
||||
"@types/react-dom": "^18.0.10",
|
||||
"@types/semver": "^7.3.13",
|
||||
"@typescript-eslint/eslint-plugin": "^5.6.0",
|
||||
"@typescript-eslint/parser": "^5.6.0",
|
||||
"@vitejs/plugin-react": "^2.0.0",
|
||||
"@vitejs/plugin-legacy": "^3.0.1",
|
||||
"@vitejs/plugin-react-swc": "^3.0.0",
|
||||
"autoprefixer": "^10.4.2",
|
||||
"eslint": "^8.4.1",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
@@ -47,8 +50,9 @@
|
||||
"lodash": "^4.17.21",
|
||||
"postcss": "^8.4.5",
|
||||
"prettier": "2.5.1",
|
||||
"terser": "^5.16.1",
|
||||
"ts-jest": "^29.0.3",
|
||||
"typescript": "^4.3.2",
|
||||
"vite": "^3.0.0"
|
||||
"vite": "^4.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 63 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 114 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 16 KiB |
@@ -4,14 +4,14 @@
|
||||
"description": "usememos/memos",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/logo.webp",
|
||||
"type": "image/webp",
|
||||
"src": "/logo.png",
|
||||
"type": "image/png",
|
||||
"sizes": "520x520"
|
||||
}
|
||||
],
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"theme_color": "#f6f5f4",
|
||||
"background_color": "#f6f5f4"
|
||||
"theme_color": "#f4f4f5",
|
||||
"background_color": "#f4f4f5"
|
||||
}
|
||||
|
||||
+28
-11
@@ -2,32 +2,44 @@ import { useColorScheme } from "@mui/joy";
|
||||
import { useEffect, Suspense } 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 { useLocationStore, useGlobalStore } from "./store/module";
|
||||
import * as storage from "./helpers/storage";
|
||||
import { getSystemColorScheme } from "./helpers/utils";
|
||||
import Loading from "./pages/Loading";
|
||||
|
||||
function App() {
|
||||
const App = () => {
|
||||
const { i18n } = useTranslation();
|
||||
const { appearance, locale, systemStatus } = useAppSelector((state) => state.global);
|
||||
const globalStore = useGlobalStore();
|
||||
const locationStore = useLocationStore();
|
||||
const { mode, setMode } = useColorScheme();
|
||||
const { appearance, locale, systemStatus } = globalStore.state;
|
||||
|
||||
useEffect(() => {
|
||||
locationService.updateStateWithLocation();
|
||||
locationStore.updateStateWithLocation();
|
||||
window.onpopstate = () => {
|
||||
locationService.updateStateWithLocation();
|
||||
locationStore.updateStateWithLocation();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => {
|
||||
if (globalService.getState().appearance === "system") {
|
||||
const darkMediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handleColorSchemeChange = (e: MediaQueryListEvent) => {
|
||||
if (globalStore.getState().appearance === "system") {
|
||||
const mode = e.matches ? "dark" : "light";
|
||||
setMode(mode);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
if (darkMediaQuery.addEventListener) {
|
||||
darkMediaQuery.addEventListener("change", handleColorSchemeChange);
|
||||
} else {
|
||||
darkMediaQuery.addListener(handleColorSchemeChange);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("failed to initial color scheme listener", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Inject additional style and script codes.
|
||||
@@ -36,13 +48,18 @@ function App() {
|
||||
const styleEl = document.createElement("style");
|
||||
styleEl.innerHTML = systemStatus.additionalStyle;
|
||||
styleEl.setAttribute("type", "text/css");
|
||||
document.head.appendChild(styleEl);
|
||||
document.body.insertAdjacentElement("beforeend", styleEl);
|
||||
}
|
||||
if (systemStatus.additionalScript) {
|
||||
const scriptEl = document.createElement("script");
|
||||
scriptEl.innerHTML = systemStatus.additionalScript;
|
||||
document.head.appendChild(scriptEl);
|
||||
}
|
||||
|
||||
// dynamic update metadata with customized profile.
|
||||
document.title = systemStatus.customizedProfile.name;
|
||||
const link = document.querySelector("link[rel~='icon']") as HTMLLinkElement;
|
||||
link.href = systemStatus.customizedProfile.logoUrl || "/logo.png";
|
||||
}, [systemStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -80,6 +97,6 @@ function App() {
|
||||
<RouterProvider router={router} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppSelector } from "../store";
|
||||
import { useGlobalStore } from "../store/module";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import GitHubBadge from "./GitHubBadge";
|
||||
@@ -9,7 +9,9 @@ type Props = DialogProps;
|
||||
|
||||
const AboutSiteDialog: React.FC<Props> = ({ destroy }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const profile = useAppSelector((state) => state.global.systemStatus.profile);
|
||||
const globalStore = useGlobalStore();
|
||||
const profile = globalStore.state.systemStatus.profile;
|
||||
const customizedProfile = globalStore.state.systemStatus.customizedProfile;
|
||||
|
||||
const handleCloseBtnClick = () => {
|
||||
destroy();
|
||||
@@ -19,36 +21,36 @@ const AboutSiteDialog: React.FC<Props> = ({ destroy }: Props) => {
|
||||
<>
|
||||
<div className="dialog-header-container">
|
||||
<p className="title-text flex items-center">
|
||||
<img className="w-7 h-auto mr-1" src="/logo.webp" alt="" />
|
||||
{t("common.about")} memos
|
||||
{t("common.about")} {customizedProfile.name}
|
||||
</p>
|
||||
<button className="btn close-btn" onClick={handleCloseBtnClick}>
|
||||
<Icon.X />
|
||||
</button>
|
||||
</div>
|
||||
<div className="dialog-content-container">
|
||||
<p>{t("slogan")}</p>
|
||||
<div className="border-t mt-1 pt-2 flex flex-row justify-start items-center">
|
||||
<span className=" text-gray-500 mr-2">Other projects:</span>
|
||||
<p className="text-sm">{customizedProfile.description || "No description"}</p>
|
||||
<div className="mt-4 flex flex-row text-sm justify-start items-center">
|
||||
<div className="flex flex-row justify-start items-center mr-2">
|
||||
Powered by
|
||||
<a href="https://usememos.com" className="flex flex-row justify-start items-center mr-1 hover:underline">
|
||||
<img className="w-6 h-auto" src="/logo.png" alt="" />
|
||||
memos
|
||||
</a>
|
||||
<span>v{profile.version}</span>
|
||||
</div>
|
||||
<GitHubBadge />
|
||||
</div>
|
||||
<div className="border-t mt-3 pt-2 text-sm flex flex-row justify-start items-center">
|
||||
<span className="text-gray-500 mr-2">Other projects:</span>
|
||||
<a href="https://github.com/boojack/sticky-notes" className="flex items-center underline text-blue-600 hover:opacity-80">
|
||||
<img
|
||||
className="w-5 h-auto mr-1"
|
||||
className="w-4 h-auto mr-1"
|
||||
src="https://raw.githubusercontent.com/boojack/sticky-notes/main/public/sticky-notes.ico"
|
||||
alt=""
|
||||
/>
|
||||
<span>Sticky notes</span>
|
||||
</a>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-row text-sm justify-start items-center">
|
||||
<GitHubBadge />
|
||||
<span className="ml-2">
|
||||
{t("common.version")}:
|
||||
<span className="font-mono">
|
||||
{profile.version}-{profile.mode}
|
||||
</span>
|
||||
🎉
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -58,6 +60,7 @@ export default function showAboutSiteDialog(): void {
|
||||
generateDialog(
|
||||
{
|
||||
className: "about-site-dialog",
|
||||
dialogName: "about-site-dialog",
|
||||
},
|
||||
AboutSiteDialog
|
||||
);
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { Option, Select } from "@mui/joy";
|
||||
import { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { globalService, userService } from "../services";
|
||||
import { useAppSelector } from "../store";
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface Props {
|
||||
value: Appearance;
|
||||
onChange: (appearance: Appearance) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const appearanceList = ["system", "light", "dark"];
|
||||
|
||||
const AppearanceSelect = () => {
|
||||
const user = useAppSelector((state) => state.user.user);
|
||||
const appearance = useAppSelector((state) => state.global.appearance);
|
||||
const AppearanceSelect: FC<Props> = (props: Props) => {
|
||||
const { onChange, value, className } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getPrefixIcon = (apperance: Appearance) => {
|
||||
@@ -23,22 +27,19 @@ const AppearanceSelect = () => {
|
||||
};
|
||||
|
||||
const handleSelectChange = async (appearance: Appearance) => {
|
||||
if (user) {
|
||||
await userService.upsertUserSetting("appearance", appearance);
|
||||
}
|
||||
globalService.setAppearance(appearance);
|
||||
onChange(appearance);
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
className="!min-w-[10rem] w-auto text-sm"
|
||||
value={appearance}
|
||||
className={`!min-w-[10rem] w-auto whitespace-nowrap ${className ?? ""}`}
|
||||
value={value}
|
||||
onChange={(_, appearance) => {
|
||||
if (appearance) {
|
||||
handleSelectChange(appearance);
|
||||
}
|
||||
}}
|
||||
startDecorator={getPrefixIcon(appearance)}
|
||||
startDecorator={getPrefixIcon(value)}
|
||||
>
|
||||
{appearanceList.map((item) => (
|
||||
<Option key={item} value={item} className="whitespace-nowrap">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMemoStore } from "../store/module";
|
||||
import * as utils from "../helpers/utils";
|
||||
import useToggle from "../hooks/useToggle";
|
||||
import { memoService } from "../services";
|
||||
import toastHelper from "./Toast";
|
||||
import MemoContent from "./MemoContent";
|
||||
import MemoResources from "./MemoResources";
|
||||
@@ -14,13 +14,13 @@ interface Props {
|
||||
const ArchivedMemo: React.FC<Props> = (props: Props) => {
|
||||
const { memo } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const memoStore = useMemoStore();
|
||||
const [showConfirmDeleteBtn, toggleConfirmDeleteBtn] = useToggle(false);
|
||||
|
||||
const handleDeleteMemoClick = async () => {
|
||||
if (showConfirmDeleteBtn) {
|
||||
try {
|
||||
await memoService.deleteMemoById(memo.id);
|
||||
await memoStore.deleteMemoById(memo.id);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toastHelper.error(error.response.data.message);
|
||||
@@ -32,11 +32,11 @@ const ArchivedMemo: React.FC<Props> = (props: Props) => {
|
||||
|
||||
const handleRestoreMemoClick = async () => {
|
||||
try {
|
||||
await memoService.patchMemo({
|
||||
await memoStore.patchMemo({
|
||||
id: memo.id,
|
||||
rowStatus: "NORMAL",
|
||||
});
|
||||
await memoService.fetchMemos();
|
||||
await memoStore.fetchMemos();
|
||||
toastHelper.info(t("message.restored-successfully"));
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMemoStore } from "../store/module";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import { memoService } from "../services";
|
||||
import { useAppSelector } from "../store";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import toastHelper from "./Toast";
|
||||
@@ -14,12 +13,13 @@ type Props = DialogProps;
|
||||
const ArchivedMemoDialog: React.FC<Props> = (props: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { destroy } = props;
|
||||
const memos = useAppSelector((state) => state.memo.memos);
|
||||
const memoStore = useMemoStore();
|
||||
const memos = memoStore.state.memos;
|
||||
const loadingState = useLoading();
|
||||
const [archivedMemos, setArchivedMemos] = useState<Memo[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
memoService
|
||||
memoStore
|
||||
.fetchArchivedMemos()
|
||||
.then((result) => {
|
||||
setArchivedMemos(result);
|
||||
@@ -36,10 +36,7 @@ const ArchivedMemoDialog: React.FC<Props> = (props: Props) => {
|
||||
return (
|
||||
<>
|
||||
<div className="dialog-header-container">
|
||||
<p className="title-text">
|
||||
<span className="icon-text">🗂</span>
|
||||
{t("archived.archived-memos")}
|
||||
</p>
|
||||
<p className="title-text">{t("archived.archived-memos")}</p>
|
||||
<button className="btn close-btn" onClick={destroy}>
|
||||
<Icon.X className="icon-img" />
|
||||
</button>
|
||||
@@ -69,6 +66,7 @@ export default function showArchivedMemoDialog(): void {
|
||||
generateDialog(
|
||||
{
|
||||
className: "archived-memo-dialog",
|
||||
dialogName: "archived-memo-dialog",
|
||||
},
|
||||
ArchivedMemoDialog,
|
||||
{}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useUserStore } from "../store/module";
|
||||
import { validate, ValidatorConfig } from "../helpers/validator";
|
||||
import { userService } from "../services";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import toastHelper from "./Toast";
|
||||
@@ -20,6 +20,7 @@ interface Props extends DialogProps {
|
||||
const ChangeMemberPasswordDialog: React.FC<Props> = (props: Props) => {
|
||||
const { user: propsUser, destroy } = props;
|
||||
const { t } = useTranslation();
|
||||
const userStore = useUserStore();
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [newPasswordAgain, setNewPasswordAgain] = useState("");
|
||||
|
||||
@@ -60,7 +61,7 @@ const ChangeMemberPasswordDialog: React.FC<Props> = (props: Props) => {
|
||||
}
|
||||
|
||||
try {
|
||||
await userService.patchUser({
|
||||
await userStore.patchUser({
|
||||
id: propsUser.id,
|
||||
password: newPassword,
|
||||
});
|
||||
@@ -116,6 +117,7 @@ function showChangeMemberPasswordDialog(user: User) {
|
||||
generateDialog(
|
||||
{
|
||||
className: "change-member-password-dialog",
|
||||
dialogName: "change-member-password-dialog",
|
||||
},
|
||||
ChangeMemberPasswordDialog,
|
||||
{ user }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import dayjs from "dayjs";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { memoService } from "../services";
|
||||
import { useMemoStore } from "../store/module";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import toastHelper from "./Toast";
|
||||
@@ -14,11 +14,12 @@ interface Props extends DialogProps {
|
||||
const ChangeMemoCreatedTsDialog: React.FC<Props> = (props: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { destroy, memoId } = props;
|
||||
const memoStore = useMemoStore();
|
||||
const [createdAt, setCreatedAt] = useState("");
|
||||
const maxDatetimeValue = dayjs().format("YYYY-MM-DDTHH:mm");
|
||||
|
||||
useEffect(() => {
|
||||
memoService.getMemoById(memoId).then((memo) => {
|
||||
memoStore.getMemoById(memoId).then((memo) => {
|
||||
if (memo) {
|
||||
const datetime = dayjs(memo.createdTs).format("YYYY-MM-DDTHH:mm");
|
||||
setCreatedAt(datetime);
|
||||
@@ -48,7 +49,7 @@ const ChangeMemoCreatedTsDialog: React.FC<Props> = (props: Props) => {
|
||||
}
|
||||
|
||||
try {
|
||||
await memoService.patchMemo({
|
||||
await memoStore.patchMemo({
|
||||
id: memoId,
|
||||
createdTs,
|
||||
});
|
||||
@@ -96,6 +97,7 @@ function showChangeMemoCreatedTsDialog(memoId: MemoId) {
|
||||
generateDialog(
|
||||
{
|
||||
className: "change-memo-created-ts-dialog",
|
||||
dialogName: "change-memo-created-ts-dialog",
|
||||
},
|
||||
ChangeMemoCreatedTsDialog,
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useUserStore } from "../store/module";
|
||||
import { validate, ValidatorConfig } from "../helpers/validator";
|
||||
import { userService } from "../services";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import toastHelper from "./Toast";
|
||||
@@ -17,6 +17,7 @@ type Props = DialogProps;
|
||||
|
||||
const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const userStore = useUserStore();
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [newPasswordAgain, setNewPasswordAgain] = useState("");
|
||||
|
||||
@@ -57,8 +58,8 @@ const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const user = userService.getState().user as User;
|
||||
await userService.patchUser({
|
||||
const user = userStore.getState().user as User;
|
||||
await userStore.patchUser({
|
||||
id: user.id,
|
||||
password: newPassword,
|
||||
});
|
||||
@@ -114,6 +115,7 @@ function showChangePasswordDialog() {
|
||||
generateDialog(
|
||||
{
|
||||
className: "change-password-dialog",
|
||||
dialogName: "change-password-dialog",
|
||||
},
|
||||
ChangePasswordDialog
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { resourceService } from "../services";
|
||||
import { useResourceStore } from "../store/module";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import toastHelper from "./Toast";
|
||||
@@ -24,8 +24,9 @@ const validateFilename = (filename: string): boolean => {
|
||||
};
|
||||
|
||||
const ChangeResourceFilenameDialog: React.FC<Props> = (props: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { destroy, resourceId, resourceFilename } = props;
|
||||
const { t } = useTranslation();
|
||||
const resourceStore = useResourceStore();
|
||||
const [filename, setFilename] = useState<string>(resourceFilename);
|
||||
|
||||
const handleFilenameChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -47,7 +48,7 @@ const ChangeResourceFilenameDialog: React.FC<Props> = (props: Props) => {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await resourceService.patchResource({
|
||||
await resourceStore.patchResource({
|
||||
id: resourceId,
|
||||
filename: filename,
|
||||
});
|
||||
@@ -86,6 +87,7 @@ function showChangeResourceFilenameDialog(resourceId: ResourceId, resourceFilena
|
||||
generateDialog(
|
||||
{
|
||||
className: "change-resource-filename-dialog",
|
||||
dialogName: "change-resource-filename-dialog",
|
||||
},
|
||||
ChangeResourceFilenameDialog,
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import dayjs from "dayjs";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { memoService, shortcutService } from "../services";
|
||||
import { useShortcutStore, useTagStore } from "../store/module";
|
||||
import { filterConsts, getDefaultFilter, relationConsts } from "../helpers/filter";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import Icon from "./Icon";
|
||||
@@ -16,6 +16,7 @@ interface Props extends DialogProps {
|
||||
|
||||
const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
||||
const { destroy, shortcutId } = props;
|
||||
const shortcutStore = useShortcutStore();
|
||||
const [title, setTitle] = useState<string>("");
|
||||
const [filters, setFilters] = useState<Filter[]>([]);
|
||||
const requestState = useLoading(false);
|
||||
@@ -23,7 +24,7 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (shortcutId) {
|
||||
const shortcutTemp = shortcutService.getShortcutById(shortcutId);
|
||||
const shortcutTemp = shortcutStore.getShortcutById(shortcutId);
|
||||
if (shortcutTemp) {
|
||||
setTitle(shortcutTemp.title);
|
||||
const temp = JSON.parse(shortcutTemp.payload);
|
||||
@@ -52,13 +53,13 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
||||
}
|
||||
try {
|
||||
if (shortcutId) {
|
||||
await shortcutService.patchShortcut({
|
||||
await shortcutStore.patchShortcut({
|
||||
id: shortcutId,
|
||||
title,
|
||||
payload: JSON.stringify(filters),
|
||||
});
|
||||
} else {
|
||||
await shortcutService.createShortcut({
|
||||
await shortcutStore.createShortcut({
|
||||
title,
|
||||
payload: JSON.stringify(filters),
|
||||
});
|
||||
@@ -100,10 +101,7 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
||||
return (
|
||||
<>
|
||||
<div className="dialog-header-container">
|
||||
<p className="title-text">
|
||||
<span className="icon-text">🚀</span>
|
||||
{shortcutId ? t("shortcut-list.edit-shortcut") : t("shortcut-list.create-shortcut")}
|
||||
</p>
|
||||
<p className="title-text">{shortcutId ? t("shortcut-list.edit-shortcut") : t("shortcut-list.create-shortcut")}</p>
|
||||
<button className="btn close-btn" onClick={destroy}>
|
||||
<Icon.X />
|
||||
</button>
|
||||
@@ -161,11 +159,12 @@ interface MemoFilterInputerProps {
|
||||
const MemoFilterInputer: React.FC<MemoFilterInputerProps> = (props: MemoFilterInputerProps) => {
|
||||
const { index, filter, handleFilterChange, handleFilterRemove } = props;
|
||||
const { t } = useTranslation();
|
||||
const tagStore = useTagStore();
|
||||
const [value, setValue] = useState<string>(filter.value.value);
|
||||
|
||||
const tags = Array.from(memoService.getState().tags);
|
||||
const tags = Array.from(tagStore.getState().tags);
|
||||
const { type } = filter;
|
||||
|
||||
const typeDataSource = Object.values(filterConsts).map(({ text, value }) => ({ text: t(text), value }));
|
||||
const operatorDataSource = Object.values(filterConsts[type as FilterType].operators).map(({ text, value }) => ({ text: t(text), value }));
|
||||
|
||||
const valueDataSource =
|
||||
@@ -246,12 +245,7 @@ const MemoFilterInputer: React.FC<MemoFilterInputerProps> = (props: MemoFilterIn
|
||||
handleValueChanged={handleRelationChange}
|
||||
/>
|
||||
) : null}
|
||||
<Selector
|
||||
className="type-selector"
|
||||
dataSource={Object.values(filterConsts)}
|
||||
value={filter.type}
|
||||
handleValueChanged={handleTypeChange}
|
||||
/>
|
||||
<Selector className="type-selector" dataSource={typeDataSource} value={filter.type} handleValueChanged={handleTypeChange} />
|
||||
<Selector
|
||||
className="operator-selector"
|
||||
dataSource={operatorDataSource}
|
||||
@@ -290,6 +284,7 @@ export default function showCreateShortcutDialog(shortcutId?: ShortcutId): void
|
||||
generateDialog(
|
||||
{
|
||||
className: "create-shortcut-dialog",
|
||||
dialogName: "create-shortcut-dialog",
|
||||
},
|
||||
CreateShortcutDialog,
|
||||
{ shortcutId }
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { Input } from "@mui/joy";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTagStore } from "../store/module";
|
||||
import { getTagSuggestionList } from "../helpers/api";
|
||||
import { matcher } from "../labs/marked/matcher";
|
||||
import Tag from "../labs/marked/parser/Tag";
|
||||
import Icon from "./Icon";
|
||||
import toastHelper from "./Toast";
|
||||
import { generateDialog } from "./Dialog";
|
||||
|
||||
type Props = DialogProps;
|
||||
|
||||
const validateTagName = (tagName: string): boolean => {
|
||||
const matchResult = matcher(`#${tagName}`, Tag.regexp);
|
||||
if (!matchResult || matchResult[1] !== tagName) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const CreateTagDialog: React.FC<Props> = (props: Props) => {
|
||||
const { destroy } = props;
|
||||
const tagStore = useTagStore();
|
||||
const [tagName, setTagName] = useState<string>("");
|
||||
const [suggestTagNameList, setSuggestTagNameList] = useState<string[]>([]);
|
||||
const [showTagSuggestions, setShowTagSuggestions] = useState<boolean>(false);
|
||||
const tagNameList = tagStore.state.tags;
|
||||
const shownSuggestTagNameList = suggestTagNameList.filter((tag) => !tagNameList.includes(tag));
|
||||
|
||||
useEffect(() => {
|
||||
getTagSuggestionList().then(({ data }) => {
|
||||
setSuggestTagNameList(data.data.filter((tag) => validateTagName(tag)));
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleTagNameInputKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (event.key === "Enter") {
|
||||
handleSaveBtnClick();
|
||||
}
|
||||
};
|
||||
|
||||
const handleTagNameChanged = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const tagName = event.target.value;
|
||||
setTagName(tagName.trim());
|
||||
};
|
||||
|
||||
const handleUpsertTag = async (tagName: string) => {
|
||||
await tagStore.upsertTag(tagName);
|
||||
};
|
||||
|
||||
const handleToggleShowSuggestionTags = () => {
|
||||
setShowTagSuggestions((state) => !state);
|
||||
};
|
||||
|
||||
const handleSaveBtnClick = async () => {
|
||||
if (!validateTagName(tagName)) {
|
||||
toastHelper.error("Invalid tag name");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await tagStore.upsertTag(tagName);
|
||||
setTagName("");
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toastHelper.error(error.response.data.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTag = async (tag: string) => {
|
||||
await tagStore.deleteTag(tag);
|
||||
};
|
||||
|
||||
const handleSaveSuggestTagList = async () => {
|
||||
for (const tagName of suggestTagNameList) {
|
||||
if (validateTagName(tagName)) {
|
||||
await tagStore.upsertTag(tagName);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dialog-header-container">
|
||||
<p className="title-text">Create Tag</p>
|
||||
<button className="btn close-btn" onClick={() => destroy()}>
|
||||
<Icon.X />
|
||||
</button>
|
||||
</div>
|
||||
<div className="dialog-content-container !w-80">
|
||||
<Input
|
||||
className="mb-2"
|
||||
size="md"
|
||||
placeholder="TAG_NAME"
|
||||
value={tagName}
|
||||
onChange={handleTagNameChanged}
|
||||
onKeyDown={handleTagNameInputKeyDown}
|
||||
fullWidth
|
||||
startDecorator={<Icon.Hash className="w-4 h-auto" />}
|
||||
endDecorator={<Icon.Check onClick={handleSaveBtnClick} className="w-4 h-auto cursor-pointer hover:opacity-80" />}
|
||||
/>
|
||||
{tagNameList.length > 0 && (
|
||||
<>
|
||||
<p className="w-full mt-2 mb-1 text-sm text-gray-400">All tags</p>
|
||||
<div className="w-full flex flex-row justify-start items-start flex-wrap">
|
||||
{tagNameList.map((tag) => (
|
||||
<span
|
||||
className="max-w-[120px] text-sm mr-2 mt-1 font-mono cursor-pointer truncate dark:text-gray-300 hover:opacity-60 hover:line-through"
|
||||
key={tag}
|
||||
onClick={() => handleDeleteTag(tag)}
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{shownSuggestTagNameList.length > 0 && (
|
||||
<>
|
||||
<div className="mt-4 mb-1 text-sm w-full flex flex-row justify-start items-center">
|
||||
<span className="text-gray-400">Tag suggestions</span>
|
||||
<button className="btn-normal ml-2 px-2 py-0 leading-6 font-mono" onClick={handleToggleShowSuggestionTags}>
|
||||
{showTagSuggestions ? "hide" : "show"}
|
||||
</button>
|
||||
</div>
|
||||
{showTagSuggestions && (
|
||||
<>
|
||||
<div className="w-full flex flex-row justify-start items-start flex-wrap">
|
||||
{shownSuggestTagNameList.map((tag) => (
|
||||
<span
|
||||
className="max-w-[120px] text-sm mr-2 mt-1 font-mono cursor-pointer truncate dark:text-gray-300 hover:opacity-60"
|
||||
key={tag}
|
||||
onClick={() => handleUpsertTag(tag)}
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<button className="btn-normal mt-2 px-2 py-0 leading-6 font-mono" onClick={handleSaveSuggestTagList}>
|
||||
Save all
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function showCreateTagDialog() {
|
||||
generateDialog(
|
||||
{
|
||||
className: "create-tag-dialog",
|
||||
dialogName: "create-tag-dialog",
|
||||
},
|
||||
CreateTagDialog
|
||||
);
|
||||
}
|
||||
|
||||
export default showCreateTagDialog;
|
||||
@@ -21,7 +21,7 @@ const DailyMemo: React.FC<Props> = (props: Props) => {
|
||||
</div>
|
||||
<div className="memo-container">
|
||||
<MemoContent content={memo.content} displayConfig={displayConfig} />
|
||||
<MemoResources resourceList={memo.resourceList} style="col" />
|
||||
<MemoResources resourceList={memo.resourceList} />
|
||||
</div>
|
||||
<div className="split-line"></div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppSelector } from "../store";
|
||||
import { useMemoStore } from "../store/module";
|
||||
import toImage from "../labs/html2image";
|
||||
import useToggle from "../hooks/useToggle";
|
||||
import { DAILY_TIMESTAMP } from "../helpers/consts";
|
||||
@@ -21,7 +21,8 @@ const weekdayChineseStrArray = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
|
||||
|
||||
const DailyReviewDialog: React.FC<Props> = (props: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const memos = useAppSelector((state) => state.memo.memos);
|
||||
const memoStore = useMemoStore();
|
||||
const memos = memoStore.state.memos;
|
||||
const [currentDateStamp, setCurrentDateStamp] = useState(utils.getDateStampByDate(utils.getDateString(props.currentDateStamp)));
|
||||
const [showDatePicker, toggleShowDatePicker] = useToggle(false);
|
||||
const memosElRef = useRef<HTMLDivElement>(null);
|
||||
@@ -114,6 +115,7 @@ export default function showDailyReviewDialog(datestamp: DateStamp = Date.now())
|
||||
generateDialog(
|
||||
{
|
||||
className: "daily-review-dialog",
|
||||
dialogName: "daily-review-dialog",
|
||||
},
|
||||
DailyReviewDialog,
|
||||
{ currentDateStamp: datestamp }
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Provider } from "react-redux";
|
||||
import { ANIMATION_DURATION } from "../../helpers/consts";
|
||||
import store from "../../store";
|
||||
import "../../less/base-dialog.less";
|
||||
import { useDialogStore } from "../../store/module";
|
||||
import { CssVarsProvider } from "@mui/joy";
|
||||
import theme from "../../theme";
|
||||
import "../../less/base-dialog.less";
|
||||
|
||||
interface DialogConfig {
|
||||
className: string;
|
||||
dialogName: string;
|
||||
className?: string;
|
||||
clickSpaceDestroy?: boolean;
|
||||
}
|
||||
|
||||
@@ -17,12 +19,18 @@ interface Props extends DialogConfig, DialogProps {
|
||||
}
|
||||
|
||||
const BaseDialog: React.FC<Props> = (props: Props) => {
|
||||
const { children, className, clickSpaceDestroy, destroy } = props;
|
||||
const { children, className, clickSpaceDestroy, dialogName, destroy } = props;
|
||||
const dialogStore = useDialogStore();
|
||||
const dialogContainerRef = useRef<HTMLDivElement>(null);
|
||||
const dialogIndex = dialogStore.state.dialogStack.findIndex((item) => item === dialogName);
|
||||
|
||||
useEffect(() => {
|
||||
dialogStore.pushDialogStack(dialogName);
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.code === "Escape") {
|
||||
destroy();
|
||||
if (dialogName === dialogStore.topDialogStack()) {
|
||||
destroy();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -30,9 +38,16 @@ const BaseDialog: React.FC<Props> = (props: Props) => {
|
||||
|
||||
return () => {
|
||||
document.body.removeEventListener("keydown", handleKeyDown);
|
||||
dialogStore.removeDialog(dialogName);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (dialogIndex > 0 && dialogContainerRef.current) {
|
||||
dialogContainerRef.current.style.marginTop = `${dialogIndex * 16}px`;
|
||||
}
|
||||
}, [dialogIndex]);
|
||||
|
||||
const handleSpaceClicked = () => {
|
||||
if (clickSpaceDestroy) {
|
||||
destroy();
|
||||
@@ -40,8 +55,8 @@ const BaseDialog: React.FC<Props> = (props: Props) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`dialog-wrapper ${className}`} onClick={handleSpaceClicked}>
|
||||
<div className="dialog-container" onClick={(e) => e.stopPropagation()}>
|
||||
<div className={`dialog-wrapper ${className ?? ""}`} onMouseDown={handleSpaceClicked}>
|
||||
<div ref={dialogContainerRef} className="dialog-container" onMouseDown={(e) => e.stopPropagation()}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -72,6 +72,7 @@ interface CommonDialogProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
style?: DialogStyle;
|
||||
dialogName: string;
|
||||
closeBtnText?: string;
|
||||
confirmBtnText?: string;
|
||||
onClose?: () => void;
|
||||
@@ -82,6 +83,7 @@ export const showCommonDialog = (props: CommonDialogProps) => {
|
||||
generateDialog(
|
||||
{
|
||||
className: `common-dialog ${props?.className ?? ""}`,
|
||||
dialogName: `common-dialog ${props?.className ?? ""}`,
|
||||
},
|
||||
CommonDialog,
|
||||
props
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { forwardRef, ReactNode, useCallback, useEffect, useImperativeHandle, useRef } from "react";
|
||||
import useRefresh from "../../hooks/useRefresh";
|
||||
import "../../less/editor.less";
|
||||
|
||||
export interface EditorRefActions {
|
||||
@@ -10,6 +9,7 @@ export interface EditorRefActions {
|
||||
getContent: () => string;
|
||||
getSelectedContent: () => string;
|
||||
getCursorPosition: () => number;
|
||||
setCursorPosition: (startPos: number, endPos?: number) => void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -25,7 +25,6 @@ interface Props {
|
||||
const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<EditorRefActions>) {
|
||||
const { className, initialContent, placeholder, fullscreen, onPaste, onContentChange: handleContentChangeCallback } = props;
|
||||
const editorRef = useRef<HTMLTextAreaElement>(null);
|
||||
const refresh = useRefresh();
|
||||
|
||||
useEffect(() => {
|
||||
if (editorRef.current && initialContent) {
|
||||
@@ -36,10 +35,16 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
|
||||
|
||||
useEffect(() => {
|
||||
if (editorRef.current && !fullscreen) {
|
||||
updateEditorHeight();
|
||||
}
|
||||
}, [editorRef.current?.value, fullscreen]);
|
||||
|
||||
const updateEditorHeight = () => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.style.height = "auto";
|
||||
editorRef.current.style.height = (editorRef.current.scrollHeight ?? 0) + "px";
|
||||
}
|
||||
}, [editorRef.current?.value, fullscreen]);
|
||||
};
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
@@ -66,7 +71,7 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
|
||||
editorRef.current.focus();
|
||||
editorRef.current.selectionEnd = endPosition + prefix.length + content.length;
|
||||
handleContentChangeCallback(editorRef.current.value);
|
||||
refresh();
|
||||
updateEditorHeight();
|
||||
},
|
||||
removeText: (start: number, length: number) => {
|
||||
if (!editorRef.current) {
|
||||
@@ -79,14 +84,14 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
|
||||
editorRef.current.focus();
|
||||
editorRef.current.selectionEnd = start;
|
||||
handleContentChangeCallback(editorRef.current.value);
|
||||
refresh();
|
||||
updateEditorHeight();
|
||||
},
|
||||
setContent: (text: string) => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.value = text;
|
||||
editorRef.current.focus();
|
||||
handleContentChangeCallback(editorRef.current.value);
|
||||
refresh();
|
||||
updateEditorHeight();
|
||||
}
|
||||
},
|
||||
getContent: (): string => {
|
||||
@@ -100,13 +105,17 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
|
||||
const end = editorRef.current?.selectionEnd;
|
||||
return editorRef.current?.value.slice(start, end) ?? "";
|
||||
},
|
||||
setCursorPosition: (startPos: number, endPos?: number) => {
|
||||
const _endPos = isNaN(endPos as number) ? startPos : (endPos as number);
|
||||
editorRef.current?.setSelectionRange(startPos, _endPos);
|
||||
},
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleEditorInput = useCallback(() => {
|
||||
handleContentChangeCallback(editorRef.current?.value ?? "");
|
||||
refresh();
|
||||
updateEditorHeight();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from "react";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import copy from "copy-to-clipboard";
|
||||
import toastHelper from "./Toast";
|
||||
|
||||
interface Props extends DialogProps {
|
||||
memoId: MemoId;
|
||||
}
|
||||
|
||||
const EmbedMemoDialog: React.FC<Props> = (props: Props) => {
|
||||
const { memoId, destroy } = props;
|
||||
|
||||
const memoEmbeddedCode = () => {
|
||||
return `<iframe style="width:100%;height:auto;min-width:256px;" src="${window.location.origin}/m/${memoId}/embed" frameBorder="0"></iframe>`;
|
||||
};
|
||||
|
||||
const handleCopyCode = () => {
|
||||
copy(memoEmbeddedCode());
|
||||
toastHelper.success("Succeed to copy code to clipboard.");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dialog-header-container">
|
||||
<p className="title-text">Embed Memo</p>
|
||||
<button className="btn close-btn" onClick={() => destroy()}>
|
||||
<Icon.X />
|
||||
</button>
|
||||
</div>
|
||||
<div className="dialog-content-container !w-80">
|
||||
<p className="text-base leading-6 mb-2">Copy and paste the below codes into your blog or website.</p>
|
||||
<pre className="w-full font-mono text-sm p-3 border rounded-lg">
|
||||
<code className="w-full break-all whitespace-pre-wrap">{memoEmbeddedCode()}</code>
|
||||
</pre>
|
||||
<p className="w-full text-sm leading-6 flex flex-row justify-between items-center mt-2">
|
||||
<span className="italic opacity-80">* Only the public memo supports.</span>
|
||||
<span className="btn-primary" onClick={handleCopyCode}>
|
||||
Copy
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function showEmbedMemoDialog(memoId: MemoId) {
|
||||
generateDialog(
|
||||
{
|
||||
className: "embed-memo-dialog",
|
||||
dialogName: "embed-memo-dialog",
|
||||
},
|
||||
EmbedMemoDialog,
|
||||
{
|
||||
memoId,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default showEmbedMemoDialog;
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Option, Select } from "@mui/joy";
|
||||
import { FC } from "react";
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface Props {
|
||||
value: Locale;
|
||||
onChange: (locale: Locale) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const LocaleSelect: FC<Props> = (props: Props) => {
|
||||
const { onChange, value, className } = props;
|
||||
|
||||
const handleSelectChange = async (locale: Locale) => {
|
||||
onChange(locale);
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
className={`!min-w-[10rem] w-auto whitespace-nowrap ${className ?? ""}`}
|
||||
startDecorator={<Icon.Globe className="w-4 h-auto" />}
|
||||
value={value}
|
||||
onChange={(_, value) => handleSelectChange(value as Locale)}
|
||||
>
|
||||
<Option value="en">English</Option>
|
||||
<Option value="zh">中文</Option>
|
||||
<Option value="vi">Tiếng Việt</Option>
|
||||
<Option value="fr">French</Option>
|
||||
<Option value="nl">Nederlands</Option>
|
||||
<Option value="sv">Svenska</Option>
|
||||
<Option value="de">German</Option>
|
||||
<Option value="es">Español</Option>
|
||||
<Option value="uk">Українська</Option>
|
||||
<Option value="ru">Русский</Option>
|
||||
<Option value="it">Italiano</Option>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
export default LocaleSelect;
|
||||
+30
-20
@@ -3,19 +3,19 @@ import dayjs from "dayjs";
|
||||
import { memo, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { editorStateService, locationService, memoService, userService } from "../services";
|
||||
import { useEditorStore, useLocationStore, useMemoStore, useUserStore } from "../store/module";
|
||||
import Icon from "./Icon";
|
||||
import toastHelper from "./Toast";
|
||||
import MemoContent from "./MemoContent";
|
||||
import MemoResources from "./MemoResources";
|
||||
import showShareMemo from "./ShareMemoDialog";
|
||||
import showPreviewImageDialog from "./PreviewImageDialog";
|
||||
import showEmbedMemoDialog from "./EmbedMemoDialog";
|
||||
import showChangeMemoCreatedTsDialog from "./ChangeMemoCreatedTsDialog";
|
||||
import "../less/memo.less";
|
||||
|
||||
interface Props {
|
||||
memo: Memo;
|
||||
highlightWord?: string;
|
||||
}
|
||||
|
||||
export const getFormatedMemoTimeStr = (time: number, locale = "en"): string => {
|
||||
@@ -27,12 +27,16 @@ export const getFormatedMemoTimeStr = (time: number, locale = "en"): string => {
|
||||
};
|
||||
|
||||
const Memo: React.FC<Props> = (props: Props) => {
|
||||
const { memo, highlightWord } = props;
|
||||
const { memo } = props;
|
||||
const { t, i18n } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const editorStore = useEditorStore();
|
||||
const locationStore = useLocationStore();
|
||||
const userStore = useUserStore();
|
||||
const memoStore = useMemoStore();
|
||||
const [displayTimeStr, setDisplayTimeStr] = useState<string>(getFormatedMemoTimeStr(memo.displayTs, i18n.language));
|
||||
const memoContainerRef = useRef<HTMLDivElement>(null);
|
||||
const isVisitorMode = userService.isVisitorMode();
|
||||
const isVisitorMode = userStore.isVisitorMode();
|
||||
|
||||
useEffect(() => {
|
||||
let intervalFlag: any = -1;
|
||||
@@ -51,6 +55,10 @@ const Memo: React.FC<Props> = (props: Props) => {
|
||||
navigate(`/m/${memo.id}`);
|
||||
};
|
||||
|
||||
const handleShowEmbedMemoDialog = () => {
|
||||
showEmbedMemoDialog(memo.id);
|
||||
};
|
||||
|
||||
const handleCopyContent = () => {
|
||||
copy(memo.content);
|
||||
toastHelper.success(t("message.succeed-copy-content"));
|
||||
@@ -59,9 +67,9 @@ const Memo: React.FC<Props> = (props: Props) => {
|
||||
const handleTogglePinMemoBtnClick = async () => {
|
||||
try {
|
||||
if (memo.pinned) {
|
||||
await memoService.unpinMemo(memo.id);
|
||||
await memoStore.unpinMemo(memo.id);
|
||||
} else {
|
||||
await memoService.pinMemo(memo.id);
|
||||
await memoStore.pinMemo(memo.id);
|
||||
}
|
||||
} catch (error) {
|
||||
// do nth
|
||||
@@ -69,12 +77,12 @@ const Memo: React.FC<Props> = (props: Props) => {
|
||||
};
|
||||
|
||||
const handleEditMemoClick = () => {
|
||||
editorStateService.setEditMemoWithId(memo.id);
|
||||
editorStore.setEditMemoWithId(memo.id);
|
||||
};
|
||||
|
||||
const handleArchiveMemoClick = async () => {
|
||||
try {
|
||||
await memoService.patchMemo({
|
||||
await memoStore.patchMemo({
|
||||
id: memo.id,
|
||||
rowStatus: "ARCHIVED",
|
||||
});
|
||||
@@ -83,8 +91,8 @@ const Memo: React.FC<Props> = (props: Props) => {
|
||||
toastHelper.error(error.response.data.message);
|
||||
}
|
||||
|
||||
if (editorStateService.getState().editMemoId === memo.id) {
|
||||
editorStateService.clearEditMemo();
|
||||
if (editorStore.getState().editMemoId === memo.id) {
|
||||
editorStore.clearEditMemo();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -97,14 +105,14 @@ const Memo: React.FC<Props> = (props: Props) => {
|
||||
|
||||
if (targetEl.className === "tag-span") {
|
||||
const tagName = targetEl.innerText.slice(1);
|
||||
const currTagQuery = locationService.getState().query?.tag;
|
||||
const currTagQuery = locationStore.getState().query?.tag;
|
||||
if (currTagQuery === tagName) {
|
||||
locationService.setTagQuery(undefined);
|
||||
locationStore.setTagQuery(undefined);
|
||||
} else {
|
||||
locationService.setTagQuery(tagName);
|
||||
locationStore.setTagQuery(tagName);
|
||||
}
|
||||
} else if (targetEl.classList.contains("todo-block")) {
|
||||
if (userService.isVisitorMode()) {
|
||||
if (userStore.isVisitorMode()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -128,7 +136,7 @@ const Memo: React.FC<Props> = (props: Props) => {
|
||||
finalContent += `${tempList[i]}`;
|
||||
}
|
||||
}
|
||||
await memoService.patchMemo({
|
||||
await memoStore.patchMemo({
|
||||
id: memo.id,
|
||||
content: finalContent,
|
||||
});
|
||||
@@ -151,7 +159,7 @@ const Memo: React.FC<Props> = (props: Props) => {
|
||||
return;
|
||||
}
|
||||
|
||||
editorStateService.setEditMemoWithId(memo.id);
|
||||
editorStore.setEditMemoWithId(memo.id);
|
||||
};
|
||||
|
||||
const handleMemoDisplayTimeClick = () => {
|
||||
@@ -159,11 +167,11 @@ const Memo: React.FC<Props> = (props: Props) => {
|
||||
};
|
||||
|
||||
const handleMemoVisibilityClick = (visibility: Visibility) => {
|
||||
const currVisibilityQuery = locationService.getState().query?.visibility;
|
||||
const currVisibilityQuery = locationStore.getState().query?.visibility;
|
||||
if (currVisibilityQuery === visibility) {
|
||||
locationService.setMemoVisibilityQuery(undefined);
|
||||
locationStore.setMemoVisibilityQuery(undefined);
|
||||
} else {
|
||||
locationService.setMemoVisibilityQuery(visibility);
|
||||
locationStore.setMemoVisibilityQuery(visibility);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -211,6 +219,9 @@ const Memo: React.FC<Props> = (props: Props) => {
|
||||
<span className="btn" onClick={handleViewMemoDetailPage}>
|
||||
{t("memo.view-detail")}
|
||||
</span>
|
||||
<span className="btn" onClick={handleShowEmbedMemoDialog}>
|
||||
Embed memo
|
||||
</span>
|
||||
<span className="btn archive-btn" onClick={handleArchiveMemoClick}>
|
||||
{t("common.archive")}
|
||||
</span>
|
||||
@@ -221,7 +232,6 @@ const Memo: React.FC<Props> = (props: Props) => {
|
||||
</div>
|
||||
<MemoContent
|
||||
content={memo.content}
|
||||
highlightWord={highlightWord}
|
||||
onMemoContentClick={handleMemoContentClick}
|
||||
onMemoContentDoubleClick={handleMemoContentDoubleClick}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useUserStore } from "../store/module";
|
||||
import { marked } from "../labs/marked";
|
||||
import { highlightWithWord } from "../labs/highlighter";
|
||||
import Icon from "./Icon";
|
||||
import { useAppSelector } from "../store";
|
||||
import "../less/memo-content.less";
|
||||
|
||||
export interface DisplayConfig {
|
||||
@@ -12,7 +11,6 @@ export interface DisplayConfig {
|
||||
|
||||
interface Props {
|
||||
content: string;
|
||||
highlightWord?: string;
|
||||
className?: string;
|
||||
displayConfig?: Partial<DisplayConfig>;
|
||||
onMemoContentClick?: (e: React.MouseEvent) => void;
|
||||
@@ -30,13 +28,14 @@ const defaultDisplayConfig: DisplayConfig = {
|
||||
};
|
||||
|
||||
const MemoContent: React.FC<Props> = (props: Props) => {
|
||||
const { className, content, highlightWord, onMemoContentClick, onMemoContentDoubleClick } = props;
|
||||
const { className, content, onMemoContentClick, onMemoContentDoubleClick } = props;
|
||||
const { t } = useTranslation();
|
||||
const userStore = useUserStore();
|
||||
const user = userStore.state.user;
|
||||
const foldedContent = useMemo(() => {
|
||||
const firstHorizontalRuleIndex = content.search(/^---$|^\*\*\*$|^___$/m);
|
||||
return firstHorizontalRuleIndex !== -1 ? content.slice(0, firstHorizontalRuleIndex) : content;
|
||||
}, [content]);
|
||||
const { t } = useTranslation();
|
||||
const user = useAppSelector((state) => state.user.user);
|
||||
|
||||
const [state, setState] = useState<State>({
|
||||
expandButtonStatus: -1,
|
||||
@@ -65,7 +64,7 @@ const MemoContent: React.FC<Props> = (props: Props) => {
|
||||
expandButtonStatus: -1,
|
||||
});
|
||||
}
|
||||
}, [user?.localSetting.enableFoldMemo]);
|
||||
}, [user?.localSetting.enableFoldMemo, content]);
|
||||
|
||||
const handleMemoContentClick = async (e: React.MouseEvent) => {
|
||||
if (onMemoContentClick) {
|
||||
@@ -84,6 +83,9 @@ const MemoContent: React.FC<Props> = (props: Props) => {
|
||||
setState({
|
||||
expandButtonStatus: Number(expandButtonStatus) as ExpandButtonStatus,
|
||||
});
|
||||
if (!expandButtonStatus) {
|
||||
memoContentContainerRef.current?.scrollIntoView();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -93,10 +95,9 @@ const MemoContent: React.FC<Props> = (props: Props) => {
|
||||
className={`memo-content-text ${state.expandButtonStatus === 0 ? "expanded" : ""}`}
|
||||
onClick={handleMemoContentClick}
|
||||
onDoubleClick={handleMemoContentDoubleClick}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlightWithWord(marked(state.expandButtonStatus === 0 ? foldedContent : content), highlightWord),
|
||||
}}
|
||||
></div>
|
||||
>
|
||||
{marked(state.expandButtonStatus === 0 ? foldedContent : content)}
|
||||
</div>
|
||||
{state.expandButtonStatus !== -1 && (
|
||||
<div className="expand-btn-container">
|
||||
<span className={`btn ${state.expandButtonStatus === 0 ? "expand-btn" : "fold-btn"}`} onClick={handleExpandBtnClick}>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { last, toLower } from "lodash";
|
||||
import { isNumber, last, toLower, uniq } from "lodash";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getMatchedNodes } from "../labs/marked";
|
||||
import { deleteMemoResource, upsertMemoResource } from "../helpers/api";
|
||||
import { TAB_SPACE_WIDTH, UNKNOWN_ID, VISIBILITY_SELECTOR_ITEMS } from "../helpers/consts";
|
||||
import { editorStateService, locationService, memoService, resourceService } from "../services";
|
||||
import { useAppSelector } from "../store";
|
||||
import { useEditorStore, useLocationStore, useMemoStore, useResourceStore, useTagStore, useUserStore } from "../store/module";
|
||||
import * as storage from "../helpers/storage";
|
||||
import Icon from "./Icon";
|
||||
import toastHelper from "./Toast";
|
||||
@@ -14,7 +14,9 @@ import ResourceIcon from "./ResourceIcon";
|
||||
import showResourcesSelectorDialog from "./ResourcesSelectorDialog";
|
||||
import "../less/memo-editor.less";
|
||||
|
||||
const listItemSymbolList = ["* ", "- ", "- [ ] ", "- [x] ", "- [X] "];
|
||||
const listItemSymbolList = ["- [ ] ", "- [x] ", "- [X] ", "* ", "- "];
|
||||
const emptyOlReg = /^(\d+)\. $/;
|
||||
const pairSymbols = ["[]", "()", '""', "''", "{}", "``", "”“", "‘‘", "【】", "()", "《》"];
|
||||
|
||||
const getEditorContentCache = (): string => {
|
||||
return storage.get(["editorContentCache"]).editorContentCache ?? "";
|
||||
@@ -35,24 +37,32 @@ const setEditingMemoVisibilityCache = (visibility: Visibility) => {
|
||||
interface State {
|
||||
fullscreen: boolean;
|
||||
isUploadingResource: boolean;
|
||||
shouldShowEmojiPicker: boolean;
|
||||
isRequesting: boolean;
|
||||
}
|
||||
|
||||
const MemoEditor = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const user = useAppSelector((state) => state.user.user as User);
|
||||
const setting = user.setting;
|
||||
const editorState = useAppSelector((state) => state.editor);
|
||||
const tags = useAppSelector((state) => state.memo.tags);
|
||||
const userStore = useUserStore();
|
||||
const editorStore = useEditorStore();
|
||||
const locationStore = useLocationStore();
|
||||
const memoStore = useMemoStore();
|
||||
const tagStore = useTagStore();
|
||||
const resourceStore = useResourceStore();
|
||||
|
||||
const [state, setState] = useState<State>({
|
||||
isUploadingResource: false,
|
||||
fullscreen: false,
|
||||
shouldShowEmojiPicker: false,
|
||||
isRequesting: false,
|
||||
});
|
||||
const [allowSave, setAllowSave] = useState<boolean>(false);
|
||||
const editorState = editorStore.state;
|
||||
const prevEditorStateRef = useRef(editorState);
|
||||
const editorRef = useRef<EditorRefActions>(null);
|
||||
const tagSelectorRef = useRef<HTMLDivElement>(null);
|
||||
const user = userStore.state.user as User;
|
||||
const setting = user.setting;
|
||||
const localSetting = user.localSetting;
|
||||
const tags = tagStore.state.tags;
|
||||
const memoVisibilityOptionSelectorItems = VISIBILITY_SELECTOR_ITEMS.map((item) => {
|
||||
return {
|
||||
value: item.value,
|
||||
@@ -63,22 +73,22 @@ const MemoEditor = () => {
|
||||
useEffect(() => {
|
||||
const { editingMemoIdCache, editingMemoVisibilityCache } = storage.get(["editingMemoIdCache", "editingMemoVisibilityCache"]);
|
||||
if (editingMemoIdCache) {
|
||||
editorStateService.setEditMemoWithId(editingMemoIdCache);
|
||||
editorStore.setEditMemoWithId(editingMemoIdCache);
|
||||
}
|
||||
if (editingMemoVisibilityCache) {
|
||||
editorStateService.setMemoVisibility(editingMemoVisibilityCache as "PUBLIC" | "PROTECTED" | "PRIVATE");
|
||||
editorStore.setMemoVisibility(editingMemoVisibilityCache as "PUBLIC" | "PROTECTED" | "PRIVATE");
|
||||
} else {
|
||||
editorStateService.setMemoVisibility(setting.memoVisibility);
|
||||
editorStore.setMemoVisibility(setting.memoVisibility);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (editorState.editMemoId) {
|
||||
memoService.getMemoById(editorState.editMemoId ?? UNKNOWN_ID).then((memo) => {
|
||||
memoStore.getMemoById(editorState.editMemoId ?? UNKNOWN_ID).then((memo) => {
|
||||
if (memo) {
|
||||
handleEditorFocus();
|
||||
editorStateService.setMemoVisibility(memo.visibility);
|
||||
editorStateService.setResourceList(memo.resourceList);
|
||||
editorStore.setMemoVisibility(memo.visibility);
|
||||
editorStore.setResourceList(memo.resourceList);
|
||||
editorRef.current?.setContent(memo.content ?? "");
|
||||
}
|
||||
});
|
||||
@@ -97,7 +107,9 @@ const MemoEditor = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
const isMetaKey = event.ctrlKey || event.metaKey;
|
||||
const isShiftKey = event.shiftKey;
|
||||
if (!isShiftKey && isMetaKey) {
|
||||
if (event.key === "Enter") {
|
||||
handleSaveBtnClick();
|
||||
return;
|
||||
@@ -117,67 +129,145 @@ const MemoEditor = () => {
|
||||
editorRef.current.insertText("", "`", "`");
|
||||
return;
|
||||
}
|
||||
if (event.key === "k") {
|
||||
event.preventDefault();
|
||||
const selectedContent = editorRef.current.getSelectedContent();
|
||||
editorRef.current.insertText("", "[", "](url)");
|
||||
if (selectedContent) {
|
||||
const startPos = editorRef.current.getCursorPosition() + 2;
|
||||
const endPos = startPos + 3;
|
||||
editorRef.current.setCursorPosition(startPos, endPos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
if (!isShiftKey && event.key === "Enter") {
|
||||
const cursorPosition = editorRef.current.getCursorPosition();
|
||||
const contentBeforeCursor = editorRef.current.getContent().slice(0, cursorPosition);
|
||||
const rowValue = last(contentBeforeCursor.split("\n"));
|
||||
if (rowValue) {
|
||||
if (listItemSymbolList.includes(rowValue)) {
|
||||
if (listItemSymbolList.includes(rowValue) || emptyOlReg.test(rowValue)) {
|
||||
event.preventDefault();
|
||||
editorRef.current.removeText(cursorPosition - rowValue.length, rowValue.length);
|
||||
} else {
|
||||
// unordered/todo list
|
||||
let matched = false;
|
||||
for (const listItemSymbol of listItemSymbolList) {
|
||||
if (rowValue.startsWith(listItemSymbol)) {
|
||||
event.preventDefault();
|
||||
editorRef.current.insertText("", `\n${listItemSymbol}`);
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!matched) {
|
||||
// ordered list
|
||||
const olMatchRes = /^(\d+)\. /.exec(rowValue);
|
||||
if (olMatchRes) {
|
||||
const order = parseInt(olMatchRes[1]);
|
||||
if (isNumber(order)) {
|
||||
event.preventDefault();
|
||||
editorRef.current.insertText("", `\n${order + 1}. `);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
if (!isShiftKey && event.key === "Escape") {
|
||||
if (state.fullscreen) {
|
||||
handleFullscreenBtnClick();
|
||||
} else if (editorState.editMemoId) {
|
||||
handleCancelEdit();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key === "Tab") {
|
||||
event.preventDefault();
|
||||
editorRef.current.insertText(" ".repeat(TAB_SPACE_WIDTH));
|
||||
return;
|
||||
const tabSpace = " ".repeat(TAB_SPACE_WIDTH);
|
||||
const cursorPosition = editorRef.current.getCursorPosition();
|
||||
const selectedContent = editorRef.current.getSelectedContent();
|
||||
if (isShiftKey) {
|
||||
const beforeContent = editorRef.current.getContent().slice(0, cursorPosition);
|
||||
for (let i = beforeContent.length - 1; i >= 0; i--) {
|
||||
if (beforeContent[i] !== "\n") {
|
||||
continue;
|
||||
}
|
||||
const rowStart = i + 1;
|
||||
const isTabSpace = beforeContent.substring(rowStart, i + TAB_SPACE_WIDTH + 1) === tabSpace;
|
||||
const isSpace = beforeContent.substring(rowStart, i + 2) === " ";
|
||||
if (!isTabSpace && !isSpace) {
|
||||
break;
|
||||
}
|
||||
const removeLength = isTabSpace ? TAB_SPACE_WIDTH : 1;
|
||||
editorRef.current.removeText(rowStart, removeLength);
|
||||
const startPos = cursorPosition - removeLength;
|
||||
let endPos = startPos;
|
||||
if (selectedContent) {
|
||||
endPos += selectedContent.length;
|
||||
}
|
||||
editorRef.current.setCursorPosition(startPos, endPos);
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
editorRef.current.insertText(tabSpace);
|
||||
if (selectedContent) {
|
||||
editorRef.current.setCursorPosition(cursorPosition + TAB_SPACE_WIDTH);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (localSetting.enablePowerfulEditor) {
|
||||
for (const symbol of pairSymbols) {
|
||||
if (event.key === symbol[0]) {
|
||||
event.preventDefault();
|
||||
editorRef.current.insertText("", symbol[0], symbol[1]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (event.key === "Backspace") {
|
||||
const cursor = editorRef.current.getCursorPosition();
|
||||
const content = editorRef.current.getContent();
|
||||
const deleteChar = content?.slice(cursor - 1, cursor);
|
||||
const nextChar = content?.slice(cursor, cursor + 1);
|
||||
if (pairSymbols.includes(`${deleteChar}${nextChar}`)) {
|
||||
event.preventDefault();
|
||||
editorRef.current.removeText(cursor - 1, 2);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const uploadMultiFiles = async (files: FileList) => {
|
||||
const uploadedResourceList: Resource[] = [];
|
||||
for (const file of files) {
|
||||
const resource = await handleUploadResource(file);
|
||||
if (resource) {
|
||||
uploadedResourceList.push(resource);
|
||||
if (editorState.editMemoId) {
|
||||
await upsertMemoResource(editorState.editMemoId, resource.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (uploadedResourceList.length > 0) {
|
||||
const resourceList = editorStore.getState().resourceList;
|
||||
editorStore.setResourceList([...resourceList, ...uploadedResourceList]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDropEvent = async (event: React.DragEvent) => {
|
||||
if (event.dataTransfer && event.dataTransfer.files.length > 0) {
|
||||
event.preventDefault();
|
||||
const resourceList: Resource[] = [];
|
||||
for (const file of event.dataTransfer.files) {
|
||||
const resource = await handleUploadResource(file);
|
||||
if (resource) {
|
||||
resourceList.push(resource);
|
||||
if (editorState.editMemoId) {
|
||||
await upsertMemoResource(editorState.editMemoId, resource.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
editorStateService.setResourceList([...editorState.resourceList, ...resourceList]);
|
||||
await uploadMultiFiles(event.dataTransfer.files);
|
||||
}
|
||||
};
|
||||
|
||||
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) {
|
||||
editorStateService.setResourceList([...editorState.resourceList, resource]);
|
||||
}
|
||||
await uploadMultiFiles(event.clipboardData.files);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -192,7 +282,7 @@ const MemoEditor = () => {
|
||||
let resource = undefined;
|
||||
|
||||
try {
|
||||
resource = await resourceService.upload(file);
|
||||
resource = await resourceStore.createResourceWithBlob(file);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toastHelper.error(error.response.data.message);
|
||||
@@ -207,39 +297,66 @@ const MemoEditor = () => {
|
||||
return resource;
|
||||
};
|
||||
|
||||
const scrollToEditingMemo = useCallback(() => {
|
||||
if (editorState.editMemoId) {
|
||||
const memoElements = document.getElementsByClassName(`memos-${editorState.editMemoId}`);
|
||||
if (memoElements.length !== 0) {
|
||||
memoElements[0].scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}
|
||||
}, [editorState.editMemoId]);
|
||||
|
||||
const handleSaveBtnClick = async () => {
|
||||
const content = editorRef.current?.getContent();
|
||||
if (!content) {
|
||||
toastHelper.error(t("editor.cant-empty"));
|
||||
if (state.isRequesting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState((state) => {
|
||||
return {
|
||||
...state,
|
||||
isRequesting: true,
|
||||
};
|
||||
});
|
||||
const content = editorRef.current?.getContent() ?? "";
|
||||
try {
|
||||
const { editMemoId } = editorStateService.getState();
|
||||
const { editMemoId } = editorStore.getState();
|
||||
if (editMemoId && editMemoId !== UNKNOWN_ID) {
|
||||
const prevMemo = await memoService.getMemoById(editMemoId ?? UNKNOWN_ID);
|
||||
const prevMemo = await memoStore.getMemoById(editMemoId ?? UNKNOWN_ID);
|
||||
|
||||
if (prevMemo) {
|
||||
await memoService.patchMemo({
|
||||
await memoStore.patchMemo({
|
||||
id: prevMemo.id,
|
||||
content,
|
||||
visibility: editorState.memoVisibility,
|
||||
resourceIdList: editorState.resourceList.map((resource) => resource.id),
|
||||
});
|
||||
}
|
||||
editorStateService.clearEditMemo();
|
||||
editorStore.clearEditMemo();
|
||||
} else {
|
||||
await memoService.createMemo({
|
||||
await memoStore.createMemo({
|
||||
content,
|
||||
visibility: editorState.memoVisibility,
|
||||
resourceIdList: editorState.resourceList.map((resource) => resource.id),
|
||||
});
|
||||
locationService.clearQuery();
|
||||
locationStore.clearQuery();
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toastHelper.error(error.response.data.message);
|
||||
}
|
||||
setState((state) => {
|
||||
return {
|
||||
...state,
|
||||
isRequesting: false,
|
||||
};
|
||||
});
|
||||
|
||||
// Upsert tag with the content.
|
||||
const matchedNodes = getMatchedNodes(content);
|
||||
const tagNameList = uniq(matchedNodes.filter((node) => node.parserName === "tag").map((node) => node.matchedContent.slice(1)));
|
||||
for (const tagName of tagNameList) {
|
||||
await tagStore.upsertTag(tagName);
|
||||
}
|
||||
|
||||
setState((state) => {
|
||||
return {
|
||||
@@ -247,20 +364,24 @@ const MemoEditor = () => {
|
||||
fullscreen: false,
|
||||
};
|
||||
});
|
||||
editorStateService.clearResourceList();
|
||||
editorStore.clearResourceList();
|
||||
setEditorContentCache("");
|
||||
storage.remove(["editingMemoVisibilityCache"]);
|
||||
editorRef.current?.setContent("");
|
||||
|
||||
scrollToEditingMemo();
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
if (editorState.editMemoId) {
|
||||
editorStateService.clearEditMemo();
|
||||
editorStateService.clearResourceList();
|
||||
editorStore.clearEditMemo();
|
||||
editorStore.clearResourceList();
|
||||
editorRef.current?.setContent("");
|
||||
setEditorContentCache("");
|
||||
storage.remove(["editingMemoVisibilityCache"]);
|
||||
}
|
||||
|
||||
scrollToEditingMemo();
|
||||
};
|
||||
|
||||
const handleContentChange = (content: string) => {
|
||||
@@ -320,7 +441,7 @@ const MemoEditor = () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
editorStateService.setResourceList([...editorState.resourceList, ...resourceList]);
|
||||
editorStore.setResourceList([...editorState.resourceList, ...resourceList]);
|
||||
document.body.removeChild(inputEl);
|
||||
};
|
||||
inputEl.click();
|
||||
@@ -343,7 +464,7 @@ const MemoEditor = () => {
|
||||
}, []);
|
||||
|
||||
const handleDeleteResource = async (resourceId: ResourceId) => {
|
||||
editorStateService.setResourceList(editorState.resourceList.filter((resource) => resource.id !== resourceId));
|
||||
editorStore.setResourceList(editorState.resourceList.filter((resource) => resource.id !== resourceId));
|
||||
if (editorState.editMemoId) {
|
||||
await deleteMemoResource(editorState.editMemoId, resourceId);
|
||||
}
|
||||
@@ -351,7 +472,7 @@ const MemoEditor = () => {
|
||||
|
||||
const handleMemoVisibilityOptionChanged = async (value: string) => {
|
||||
const visibilityValue = value as Visibility;
|
||||
editorStateService.setMemoVisibility(visibilityValue);
|
||||
editorStore.setMemoVisibility(visibilityValue);
|
||||
setEditingMemoVisibilityCache(visibilityValue);
|
||||
};
|
||||
|
||||
@@ -360,7 +481,7 @@ const MemoEditor = () => {
|
||||
};
|
||||
|
||||
const handleEditorBlur = () => {
|
||||
// do nth
|
||||
// do nothing
|
||||
};
|
||||
|
||||
const isEditing = Boolean(editorState.editMemoId && editorState.editMemoId !== UNKNOWN_ID);
|
||||
@@ -401,8 +522,8 @@ const MemoEditor = () => {
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className="tip-text" onClick={(e) => e.stopPropagation()}>
|
||||
{t("common.null")}
|
||||
<p className="tip-text italic" onClick={(e) => e.stopPropagation()}>
|
||||
No tags found
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -456,9 +577,13 @@ const MemoEditor = () => {
|
||||
<button className={`action-btn cancel-btn ${isEditing ? "" : "!hidden"}`} onClick={handleCancelEdit}>
|
||||
{t("editor.cancel-edit")}
|
||||
</button>
|
||||
<button className="action-btn confirm-btn" disabled={!allowSave || state.isUploadingResource} onClick={handleSaveBtnClick}>
|
||||
<button
|
||||
className="action-btn confirm-btn"
|
||||
disabled={!(allowSave || editorState.resourceList.length > 0) || state.isUploadingResource || state.isRequesting}
|
||||
onClick={handleSaveBtnClick}
|
||||
>
|
||||
{t("editor.save")}
|
||||
<img className="icon-img w-4 h-auto" src="/logo.webp" />
|
||||
<img className="icon-img w-4 h-auto" src="/logo.png" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppSelector } from "../store";
|
||||
import { locationService, shortcutService } from "../services";
|
||||
import { useLocationStore, useShortcutStore } from "../store/module";
|
||||
import * as utils from "../helpers/utils";
|
||||
import { getTextWithMemoType } from "../helpers/filter";
|
||||
import Icon from "./Icon";
|
||||
@@ -8,19 +7,20 @@ import "../less/memo-filter.less";
|
||||
|
||||
const MemoFilter = () => {
|
||||
const { t } = useTranslation();
|
||||
useAppSelector((state) => state.shortcut.shortcuts);
|
||||
const query = useAppSelector((state) => state.location.query);
|
||||
const locationStore = useLocationStore();
|
||||
const shortcutStore = useShortcutStore();
|
||||
const query = locationStore.state.query;
|
||||
const { tag: tagQuery, duration, type: memoType, text: textQuery, shortcutId, visibility } = query;
|
||||
const shortcut = shortcutId ? shortcutService.getShortcutById(shortcutId) : null;
|
||||
const shortcut = shortcutId ? shortcutStore.getShortcutById(shortcutId) : null;
|
||||
const showFilter = Boolean(tagQuery || (duration && duration.from < duration.to) || memoType || textQuery || shortcut || visibility);
|
||||
|
||||
return (
|
||||
<div className={`filter-query-container ${showFilter ? "" : "!hidden"}`}>
|
||||
<span className="tip-text">{t("common.filter")}:</span>
|
||||
<span className="mx-2 text-gray-400">{t("common.filter")}:</span>
|
||||
<div
|
||||
className={"filter-item-container " + (shortcut ? "" : "!hidden")}
|
||||
onClick={() => {
|
||||
locationService.setMemoShortcut(undefined);
|
||||
locationStore.setMemoShortcut(undefined);
|
||||
}}
|
||||
>
|
||||
<Icon.Target className="icon-text" /> {shortcut?.title}
|
||||
@@ -28,7 +28,7 @@ const MemoFilter = () => {
|
||||
<div
|
||||
className={"filter-item-container " + (tagQuery ? "" : "!hidden")}
|
||||
onClick={() => {
|
||||
locationService.setTagQuery(undefined);
|
||||
locationStore.setTagQuery(undefined);
|
||||
}}
|
||||
>
|
||||
<Icon.Tag className="icon-text" /> {tagQuery}
|
||||
@@ -36,7 +36,7 @@ const MemoFilter = () => {
|
||||
<div
|
||||
className={"filter-item-container " + (memoType ? "" : "!hidden")}
|
||||
onClick={() => {
|
||||
locationService.setMemoTypeQuery(undefined);
|
||||
locationStore.setMemoTypeQuery(undefined);
|
||||
}}
|
||||
>
|
||||
<Icon.Box className="icon-text" /> {t(getTextWithMemoType(memoType as MemoSpecType))}
|
||||
@@ -44,7 +44,7 @@ const MemoFilter = () => {
|
||||
<div
|
||||
className={"filter-item-container " + (visibility ? "" : "!hidden")}
|
||||
onClick={() => {
|
||||
locationService.setMemoVisibilityQuery(undefined);
|
||||
locationStore.setMemoVisibilityQuery(undefined);
|
||||
}}
|
||||
>
|
||||
<Icon.Eye className="icon-text" /> {visibility}
|
||||
@@ -53,7 +53,7 @@ const MemoFilter = () => {
|
||||
<div
|
||||
className="filter-item-container"
|
||||
onClick={() => {
|
||||
locationService.setFromAndToQuery();
|
||||
locationStore.setFromAndToQuery();
|
||||
}}
|
||||
>
|
||||
<Icon.Calendar className="icon-text" /> {utils.getDateString(duration.from)} to {utils.getDateString(duration.to)}
|
||||
@@ -62,7 +62,7 @@ const MemoFilter = () => {
|
||||
<div
|
||||
className={"filter-item-container " + (textQuery ? "" : "!hidden")}
|
||||
onClick={() => {
|
||||
locationService.setTextQuery(undefined);
|
||||
locationStore.setTextQuery(undefined);
|
||||
}}
|
||||
>
|
||||
<Icon.Search className="icon-text" /> {textQuery}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { memoService, shortcutService } from "../services";
|
||||
import { DEFAULT_MEMO_LIMIT } from "../services/memoService";
|
||||
import { useAppSelector } from "../store";
|
||||
import { useLocationStore, useMemoStore, useShortcutStore, useUserStore } from "../store/module";
|
||||
import { TAG_REG, LINK_REG } from "../labs/marked/parser";
|
||||
import * as utils from "../helpers/utils";
|
||||
import { DEFAULT_MEMO_LIMIT } from "../helpers/consts";
|
||||
import { checkShouldShowMemoWithFilters } from "../helpers/filter";
|
||||
import toastHelper from "./Toast";
|
||||
import Memo from "./Memo";
|
||||
@@ -12,14 +11,17 @@ import "../less/memo-list.less";
|
||||
|
||||
const MemoList = () => {
|
||||
const { t } = useTranslation();
|
||||
const query = useAppSelector((state) => state.location.query);
|
||||
const memoDisplayTsOption = useAppSelector((state) => state.user.user?.setting.memoDisplayTsOption);
|
||||
const { memos, isFetching } = useAppSelector((state) => state.memo);
|
||||
const userStore = useUserStore();
|
||||
const memoStore = useMemoStore();
|
||||
const shortcutStore = useShortcutStore();
|
||||
const locationStore = useLocationStore();
|
||||
const query = locationStore.state.query;
|
||||
const memoDisplayTsOption = userStore.state.user?.setting.memoDisplayTsOption;
|
||||
const { memos, isFetching } = memoStore.state;
|
||||
const [isComplete, setIsComplete] = useState<boolean>(false);
|
||||
const [highlightWord, setHighlightWord] = useState<string | undefined>("");
|
||||
|
||||
const { tag: tagQuery, duration, type: memoType, text: textQuery, shortcutId, visibility } = query ?? {};
|
||||
const shortcut = shortcutId ? shortcutService.getShortcutById(shortcutId) : null;
|
||||
const shortcut = shortcutId ? shortcutStore.getShortcutById(shortcutId) : null;
|
||||
const showMemoFilter = Boolean(tagQuery || (duration && duration.from < duration.to) || memoType || textQuery || shortcut || visibility);
|
||||
|
||||
const shownMemos =
|
||||
@@ -84,7 +86,7 @@ const MemoList = () => {
|
||||
const sortedMemos = pinnedMemos.concat(unpinnedMemos).filter((m) => m.rowStatus === "NORMAL");
|
||||
|
||||
useEffect(() => {
|
||||
memoService
|
||||
memoStore
|
||||
.fetchMemos()
|
||||
.then((fetchedMemos) => {
|
||||
if (fetchedMemos.length < DEFAULT_MEMO_LIMIT) {
|
||||
@@ -104,7 +106,6 @@ const MemoList = () => {
|
||||
if (pageWrapper) {
|
||||
pageWrapper.scrollTo(0, 0);
|
||||
}
|
||||
setHighlightWord(query?.text);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -118,7 +119,7 @@ const MemoList = () => {
|
||||
|
||||
const handleFetchMoreClick = async () => {
|
||||
try {
|
||||
const fetchedMemos = await memoService.fetchMemos(DEFAULT_MEMO_LIMIT, memos.length);
|
||||
const fetchedMemos = await memoStore.fetchMemos(DEFAULT_MEMO_LIMIT, memos.length);
|
||||
if (fetchedMemos.length < DEFAULT_MEMO_LIMIT) {
|
||||
setIsComplete(true);
|
||||
} else {
|
||||
@@ -133,7 +134,7 @@ const MemoList = () => {
|
||||
return (
|
||||
<div className="memo-list-container">
|
||||
{sortedMemos.map((memo) => (
|
||||
<Memo key={`${memo.id}-${memo.displayTs}`} memo={memo} highlightWord={highlightWord} />
|
||||
<Memo key={`${memo.id}-${memo.displayTs}`} memo={memo} />
|
||||
))}
|
||||
{isFetching ? (
|
||||
<div className="status-text-container fetching-tip">
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { getResourceUrl } from "../utils/resource";
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface Props {
|
||||
resource: Resource;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const MemoResource: React.FC<Props> = (props: Props) => {
|
||||
const { className, resource } = props;
|
||||
const resourceUrl = getResourceUrl(resource);
|
||||
|
||||
const handlePreviewBtnClick = () => {
|
||||
window.open(resourceUrl);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`w-auto flex flex-row justify-start items-center ${className}`}>
|
||||
{resource.type.startsWith("audio") ? (
|
||||
<>
|
||||
<audio className="h-8" src={resourceUrl} controls></audio>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon.FileText className="w-4 h-auto mr-1 text-gray-500" />
|
||||
<span className="text-gray-500 text-sm max-w-xs truncate font-mono cursor-pointer" onClick={handlePreviewBtnClick}>
|
||||
{resource.filename}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemoResource;
|
||||
@@ -1,69 +1,77 @@
|
||||
import Image from "./Image";
|
||||
import Icon from "./Icon";
|
||||
import { absolutifyLink } from "../helpers/utils";
|
||||
import { getResourceUrl } from "../utils/resource";
|
||||
import SquareDiv from "./common/SquareDiv";
|
||||
import showPreviewImageDialog from "./PreviewImageDialog";
|
||||
import MemoResource from "./MemoResource";
|
||||
import "../less/memo-resources.less";
|
||||
|
||||
interface Props {
|
||||
resourceList: Resource[];
|
||||
className?: string;
|
||||
style?: "row" | "col";
|
||||
}
|
||||
|
||||
const getDefaultProps = (): Props => {
|
||||
return {
|
||||
className: "",
|
||||
style: "row",
|
||||
resourceList: [],
|
||||
};
|
||||
};
|
||||
|
||||
const MemoResources: React.FC<Props> = (props: Props) => {
|
||||
const { className, style, resourceList } = {
|
||||
const { className, resourceList } = {
|
||||
...getDefaultProps(),
|
||||
...props,
|
||||
};
|
||||
const availableResourceList = resourceList.filter((resource) => resource.type.startsWith("image") || resource.type.startsWith("video"));
|
||||
const otherResourceList = resourceList.filter((resource) => !availableResourceList.includes(resource));
|
||||
|
||||
const handlePreviewBtnClick = (resource: Resource) => {
|
||||
const resourceUrl = `${window.location.origin}/o/r/${resource.id}/${resource.filename}`;
|
||||
window.open(resourceUrl);
|
||||
};
|
||||
|
||||
const imgUrls = availableResourceList
|
||||
.filter((resource) => resource.type.startsWith("image"))
|
||||
.map((resource) => {
|
||||
return `/o/r/${resource.id}/${resource.filename}`;
|
||||
return getResourceUrl(resource);
|
||||
});
|
||||
|
||||
const handleImageClick = (imgUrl: string) => {
|
||||
const index = imgUrls.findIndex((url) => url === imgUrl);
|
||||
showPreviewImageDialog(imgUrls, index);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`resource-wrapper ${className || ""}`}>
|
||||
{availableResourceList.length > 0 && (
|
||||
<div className={`images-wrapper ${style}`}>
|
||||
{availableResourceList.map((resource) => {
|
||||
const url = `/o/r/${resource.id}/${resource.filename}`;
|
||||
if (resource.type.startsWith("image")) {
|
||||
return (
|
||||
<Image className="memo-resource" key={resource.id} imgUrls={imgUrls} index={imgUrls.findIndex((item) => item === url)} />
|
||||
);
|
||||
} else if (resource.type.startsWith("video")) {
|
||||
return <video className="memo-resource" controls key={resource.id} src={url} />;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
<>
|
||||
<div className={`resource-wrapper ${className || ""}`}>
|
||||
{availableResourceList.length > 0 && (
|
||||
<div className="images-wrapper">
|
||||
{availableResourceList.map((resource) => {
|
||||
const url = getResourceUrl(resource);
|
||||
if (resource.type.startsWith("image")) {
|
||||
return (
|
||||
<SquareDiv key={resource.id} className="memo-resource">
|
||||
<img src={absolutifyLink(url)} onClick={() => handleImageClick(url)} decoding="async" loading="lazy" />
|
||||
</SquareDiv>
|
||||
);
|
||||
} else if (resource.type.startsWith("video")) {
|
||||
return (
|
||||
<SquareDiv key={resource.id} className="memo-resource">
|
||||
<video preload="metadata" controls key={resource.id}>
|
||||
<source src={absolutifyLink(url)} type={resource.type} />
|
||||
</video>
|
||||
</SquareDiv>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{otherResourceList.length > 0 && (
|
||||
<div className="w-full flex flex-row justify-start flex-wrap mt-2">
|
||||
{otherResourceList.map((resource) => {
|
||||
return <MemoResource key={resource.id} className="my-1 mr-2" resource={resource} />;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="other-resource-wrapper">
|
||||
{otherResourceList.map((resource) => {
|
||||
return (
|
||||
<div className="other-resource-container" key={resource.id} onClick={() => handlePreviewBtnClick(resource)}>
|
||||
<Icon.FileText className="icon-img" />
|
||||
<span className="name-text">{resource.filename}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { memoService, shortcutService } from "../services";
|
||||
import { useAppSelector } from "../store";
|
||||
import { useLocationStore, useMemoStore, useShortcutStore } from "../store/module";
|
||||
import Icon from "./Icon";
|
||||
import SearchBar from "./SearchBar";
|
||||
import { toggleSidebar } from "./Sidebar";
|
||||
@@ -9,8 +8,11 @@ import "../less/memos-header.less";
|
||||
let prevRequestTimestamp = Date.now();
|
||||
|
||||
const MemosHeader = () => {
|
||||
const query = useAppSelector((state) => state.location.query);
|
||||
const shortcuts = useAppSelector((state) => state.shortcut.shortcuts);
|
||||
const locationStore = useLocationStore();
|
||||
const memoStore = useMemoStore();
|
||||
const shortcutStore = useShortcutStore();
|
||||
const query = locationStore.state.query;
|
||||
const shortcuts = shortcutStore.state.shortcuts;
|
||||
const [titleText, setTitleText] = useState("MEMOS");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -19,7 +21,7 @@ const MemosHeader = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const shortcut = shortcutService.getShortcutById(query?.shortcutId);
|
||||
const shortcut = shortcutStore.getShortcutById(query?.shortcutId);
|
||||
if (shortcut) {
|
||||
setTitleText(shortcut.title);
|
||||
}
|
||||
@@ -29,14 +31,14 @@ const MemosHeader = () => {
|
||||
const now = Date.now();
|
||||
if (now - prevRequestTimestamp > 1 * 1000) {
|
||||
prevRequestTimestamp = now;
|
||||
memoService.fetchMemos().catch(() => {
|
||||
memoStore.fetchMemos().catch(() => {
|
||||
// do nth
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="section-header-container memos-header-container">
|
||||
<div className="memos-header-container">
|
||||
<div className="title-container">
|
||||
<div className="action-btn" onClick={() => toggleSidebar(true)}>
|
||||
<Icon.Menu className="icon-img" />
|
||||
|
||||
@@ -20,14 +20,18 @@ interface State {
|
||||
originY: number;
|
||||
}
|
||||
|
||||
const defaultState: State = {
|
||||
angle: 0,
|
||||
scale: 1,
|
||||
originX: -1,
|
||||
originY: -1,
|
||||
};
|
||||
|
||||
const PreviewImageDialog: React.FC<Props> = ({ destroy, imgUrls, initialIndex }: Props) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||
const [state, setState] = useState<State>({
|
||||
angle: 0,
|
||||
scale: 1,
|
||||
originX: -1,
|
||||
originY: -1,
|
||||
});
|
||||
const [state, setState] = useState<State>(defaultState);
|
||||
let startX = -1;
|
||||
let endX = -1;
|
||||
|
||||
const handleCloseBtnClick = () => {
|
||||
destroy();
|
||||
@@ -40,19 +44,63 @@ const PreviewImageDialog: React.FC<Props> = ({ destroy, imgUrls, initialIndex }:
|
||||
a.click();
|
||||
};
|
||||
|
||||
const handleTouchStart = (event: React.TouchEvent) => {
|
||||
if (event.touches.length > 1) {
|
||||
// two or more fingers, ignore
|
||||
return;
|
||||
}
|
||||
startX = event.touches[0].clientX;
|
||||
};
|
||||
|
||||
const handleTouchMove = (event: React.TouchEvent) => {
|
||||
if (event.touches.length > 1) {
|
||||
// two or more fingers, ignore
|
||||
return;
|
||||
}
|
||||
endX = event.touches[0].clientX;
|
||||
};
|
||||
|
||||
const handleTouchEnd = (event: React.TouchEvent) => {
|
||||
if (event.touches.length > 1) {
|
||||
// two or more fingers, ignore
|
||||
return;
|
||||
}
|
||||
if (startX > -1 && endX > -1) {
|
||||
const distance = startX - endX;
|
||||
if (distance > 50) {
|
||||
showNextImg();
|
||||
} else if (distance < -50) {
|
||||
showPrevImg();
|
||||
}
|
||||
}
|
||||
|
||||
endX = -1;
|
||||
startX = -1;
|
||||
};
|
||||
|
||||
const showPrevImg = () => {
|
||||
if (currentIndex > 0) {
|
||||
setState(defaultState);
|
||||
setCurrentIndex(currentIndex - 1);
|
||||
} else {
|
||||
destroy();
|
||||
}
|
||||
};
|
||||
|
||||
const showNextImg = () => {
|
||||
if (currentIndex < imgUrls.length - 1) {
|
||||
setState(defaultState);
|
||||
setCurrentIndex(currentIndex + 1);
|
||||
} else {
|
||||
destroy();
|
||||
}
|
||||
};
|
||||
|
||||
const handleImgContainerClick = (event: React.MouseEvent) => {
|
||||
if (event.clientX < window.innerWidth / 2) {
|
||||
if (currentIndex > 0) {
|
||||
setCurrentIndex(currentIndex - 1);
|
||||
} else {
|
||||
destroy();
|
||||
}
|
||||
showPrevImg();
|
||||
} else {
|
||||
if (currentIndex < imgUrls.length - 1) {
|
||||
setCurrentIndex(currentIndex + 1);
|
||||
} else {
|
||||
destroy();
|
||||
}
|
||||
showNextImg();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -107,6 +155,9 @@ const PreviewImageDialog: React.FC<Props> = ({ destroy, imgUrls, initialIndex }:
|
||||
<div className="img-container" onClick={handleImgContainerClick}>
|
||||
<img
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
src={imgUrls[currentIndex]}
|
||||
onWheel={handleImgContainerScroll}
|
||||
style={getImageComputedStyle()}
|
||||
@@ -120,6 +171,7 @@ export default function showPreviewImageDialog(imgUrls: string[] | string, initi
|
||||
generateDialog(
|
||||
{
|
||||
className: "preview-image-dialog",
|
||||
dialogName: "preview-image-dialog",
|
||||
},
|
||||
PreviewImageDialog,
|
||||
{
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Tooltip } from "@mui/joy";
|
||||
import { Button } from "@mui/joy";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import { resourceService } from "../services";
|
||||
import { useAppSelector } from "../store";
|
||||
import { useResourceStore } from "../store/module";
|
||||
import { getResourceUrl } from "../utils/resource";
|
||||
import Icon from "./Icon";
|
||||
import toastHelper from "./Toast";
|
||||
import Dropdown from "./common/Dropdown";
|
||||
@@ -16,21 +16,15 @@ import "../less/resources-dialog.less";
|
||||
|
||||
type Props = DialogProps;
|
||||
|
||||
interface State {
|
||||
isUploadingResource: boolean;
|
||||
}
|
||||
|
||||
const ResourcesDialog: React.FC<Props> = (props: Props) => {
|
||||
const { destroy } = props;
|
||||
const { t } = useTranslation();
|
||||
const loadingState = useLoading();
|
||||
const { resources } = useAppSelector((state) => state.resource);
|
||||
const [state, setState] = useState<State>({
|
||||
isUploadingResource: false,
|
||||
});
|
||||
const resourceStore = useResourceStore();
|
||||
const resources = resourceStore.state.resources;
|
||||
|
||||
useEffect(() => {
|
||||
resourceService
|
||||
resourceStore
|
||||
.fetchResourceList()
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
@@ -42,10 +36,6 @@ const ResourcesDialog: React.FC<Props> = (props: Props) => {
|
||||
}, []);
|
||||
|
||||
const handleUploadFileBtnClick = async () => {
|
||||
if (state.isUploadingResource) {
|
||||
return;
|
||||
}
|
||||
|
||||
const inputEl = document.createElement("input");
|
||||
inputEl.style.position = "fixed";
|
||||
inputEl.style.top = "-100vh";
|
||||
@@ -59,22 +49,12 @@ const ResourcesDialog: React.FC<Props> = (props: Props) => {
|
||||
return;
|
||||
}
|
||||
|
||||
setState({
|
||||
...state,
|
||||
isUploadingResource: true,
|
||||
});
|
||||
|
||||
for (const file of inputEl.files) {
|
||||
try {
|
||||
await resourceService.upload(file);
|
||||
await resourceStore.createResourceWithBlob(file);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toastHelper.error(error.response.data.message);
|
||||
} finally {
|
||||
setState({
|
||||
...state,
|
||||
isUploadingResource: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,10 +63,6 @@ const ResourcesDialog: React.FC<Props> = (props: Props) => {
|
||||
inputEl.click();
|
||||
};
|
||||
|
||||
const getResourceUrl = useCallback((resource: Resource) => {
|
||||
return `${window.location.origin}/o/r/${resource.id}/${resource.filename}`;
|
||||
}, []);
|
||||
|
||||
const handlePreviewBtnClick = (resource: Resource) => {
|
||||
const resourceUrl = getResourceUrl(resource);
|
||||
if (resource.type.startsWith("image")) {
|
||||
@@ -104,8 +80,9 @@ const ResourcesDialog: React.FC<Props> = (props: Props) => {
|
||||
};
|
||||
|
||||
const handleCopyResourceLinkBtnClick = (resource: Resource) => {
|
||||
copy(`${window.location.origin}/o/r/${resource.id}/${resource.filename}`);
|
||||
toastHelper.success("Succeed to copy resource link to clipboard");
|
||||
const url = getResourceUrl(resource);
|
||||
copy(url);
|
||||
toastHelper.success(t("message.succeed-copy-resource-link"));
|
||||
};
|
||||
|
||||
const handleDeleteUnusedResourcesBtnClick = () => {
|
||||
@@ -125,9 +102,10 @@ const ResourcesDialog: React.FC<Props> = (props: Props) => {
|
||||
title: t("resources.delete-resource"),
|
||||
content: warningText,
|
||||
style: "warning",
|
||||
dialogName: "delete-unused-resources",
|
||||
onConfirm: async () => {
|
||||
for (const resource of unusedResources) {
|
||||
await resourceService.deleteResourceById(resource.id);
|
||||
await resourceStore.deleteResourceById(resource.id);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -143,8 +121,9 @@ const ResourcesDialog: React.FC<Props> = (props: Props) => {
|
||||
title: t("resources.delete-resource"),
|
||||
content: warningText,
|
||||
style: "warning",
|
||||
dialogName: "delete-resource-dialog",
|
||||
onConfirm: async () => {
|
||||
await resourceService.deleteResourceById(resource.id);
|
||||
await resourceStore.deleteResourceById(resource.id);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -152,27 +131,22 @@ const ResourcesDialog: React.FC<Props> = (props: Props) => {
|
||||
return (
|
||||
<>
|
||||
<div className="dialog-header-container">
|
||||
<p className="title-text">
|
||||
<span className="icon-text">🌄</span>
|
||||
{t("sidebar.resources")}
|
||||
</p>
|
||||
<p className="title-text">{t("sidebar.resources")}</p>
|
||||
<button className="btn close-btn" onClick={destroy}>
|
||||
<Icon.X className="icon-img" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="dialog-content-container">
|
||||
<div className="action-buttons-container">
|
||||
<div className="buttons-wrapper">
|
||||
<div className="upload-resource-btn" onClick={() => handleUploadFileBtnClick()}>
|
||||
<Icon.File className="icon-img" />
|
||||
<span>{t("resources.upload")}</span>
|
||||
</div>
|
||||
<div className="w-full flex flex-row justify-between items-center">
|
||||
<div className="flex flex-row justify-start items-center space-x-2">
|
||||
<Button onClick={() => handleUploadFileBtnClick()} startDecorator={<Icon.Plus className="w-5 h-auto" />}>
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="buttons-wrapper">
|
||||
<div className="delete-unused-resource-btn" onClick={handleDeleteUnusedResourcesBtnClick}>
|
||||
<Icon.Trash2 className="icon-img" />
|
||||
<span>{t("resources.clear-unused-resources")}</span>
|
||||
</div>
|
||||
<div className="flex flex-row justify-end items-center">
|
||||
<Button color="danger" onClick={handleDeleteUnusedResourcesBtnClick} startDecorator={<Icon.Trash2 className="w-4 h-auto" />}>
|
||||
<span>{t("resources.clear")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{loadingState.isLoading ? (
|
||||
@@ -183,7 +157,7 @@ const ResourcesDialog: React.FC<Props> = (props: Props) => {
|
||||
<div className="resource-table-container">
|
||||
<div className="fields-container">
|
||||
<span className="field-text id-text">ID</span>
|
||||
<span className="field-text name-text">NAME</span>
|
||||
<span className="field-text name-text">{t("resources.name")}</span>
|
||||
<span></span>
|
||||
</div>
|
||||
{resources.length === 0 ? (
|
||||
@@ -192,9 +166,9 @@ const ResourcesDialog: React.FC<Props> = (props: Props) => {
|
||||
resources.map((resource) => (
|
||||
<div key={resource.id} className="resource-container">
|
||||
<span className="field-text id-text">{resource.id}</span>
|
||||
<Tooltip title={resource.filename}>
|
||||
<span className="field-text name-text">{resource.filename}</span>
|
||||
</Tooltip>
|
||||
<span className="field-text name-text" onClick={() => handleRenameBtnClick(resource)}>
|
||||
{resource.filename}
|
||||
</span>
|
||||
<div className="buttons-container">
|
||||
<Dropdown
|
||||
actionsClassName="!w-28"
|
||||
@@ -206,12 +180,6 @@ const ResourcesDialog: React.FC<Props> = (props: Props) => {
|
||||
>
|
||||
{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 dark:hover:bg-zinc-600"
|
||||
onClick={() => handleRenameBtnClick(resource)}
|
||||
>
|
||||
{t("resources.rename")}
|
||||
</button>
|
||||
<button
|
||||
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-600"
|
||||
onClick={() => handleCopyResourceLinkBtnClick(resource)}
|
||||
@@ -242,6 +210,7 @@ export default function showResourcesDialog() {
|
||||
generateDialog(
|
||||
{
|
||||
className: "resources-dialog",
|
||||
dialogName: "resources-dialog",
|
||||
},
|
||||
ResourcesDialog,
|
||||
{}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Checkbox, Tooltip } from "@mui/joy";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import { editorStateService, resourceService } from "../services";
|
||||
import { useAppSelector } from "../store";
|
||||
import { useEditorStore, useResourceStore } from "../store/module";
|
||||
import { getResourceUrl } from "../utils/resource";
|
||||
import Icon from "./Icon";
|
||||
import toastHelper from "./Toast";
|
||||
import { generateDialog } from "./Dialog";
|
||||
@@ -20,14 +20,15 @@ const ResourcesSelectorDialog: React.FC<Props> = (props: Props) => {
|
||||
const { destroy } = props;
|
||||
const { t } = useTranslation();
|
||||
const loadingState = useLoading();
|
||||
const { resources } = useAppSelector((state) => state.resource);
|
||||
const editorState = useAppSelector((state) => state.editor);
|
||||
const editorStore = useEditorStore();
|
||||
const resourceStore = useResourceStore();
|
||||
const resources = resourceStore.state.resources;
|
||||
const [state, setState] = useState<State>({
|
||||
checkedArray: [],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
resourceService
|
||||
resourceStore
|
||||
.fetchResourceList()
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
@@ -39,7 +40,7 @@ const ResourcesSelectorDialog: React.FC<Props> = (props: Props) => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const checkedResourceIdArray = editorState.resourceList.map((resource) => resource.id);
|
||||
const checkedResourceIdArray = editorStore.state.resourceList.map((resource) => resource.id);
|
||||
setState({
|
||||
checkedArray: resources.map((resource) => {
|
||||
return checkedResourceIdArray.includes(resource.id);
|
||||
@@ -47,10 +48,6 @@ const ResourcesSelectorDialog: React.FC<Props> = (props: Props) => {
|
||||
});
|
||||
}, [resources]);
|
||||
|
||||
const getResourceUrl = useCallback((resource: Resource) => {
|
||||
return `${window.location.origin}/o/r/${resource.id}/${resource.filename}`;
|
||||
}, []);
|
||||
|
||||
const handlePreviewBtnClick = (resource: Resource) => {
|
||||
const resourceUrl = getResourceUrl(resource);
|
||||
if (resource.type.startsWith("image")) {
|
||||
@@ -75,17 +72,14 @@ const ResourcesSelectorDialog: React.FC<Props> = (props: Props) => {
|
||||
const resourceList = resources.filter((_, index) => {
|
||||
return state.checkedArray[index];
|
||||
});
|
||||
editorStateService.setResourceList(resourceList);
|
||||
editorStore.setResourceList(resourceList);
|
||||
destroy();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dialog-header-container">
|
||||
<p className="title-text">
|
||||
<span className="icon-text">🌄</span>
|
||||
{t("sidebar.resources")}
|
||||
</p>
|
||||
<p className="title-text">{t("sidebar.resources")}</p>
|
||||
<button className="btn close-btn" onClick={destroy}>
|
||||
<Icon.X className="icon-img" />
|
||||
</button>
|
||||
@@ -99,7 +93,7 @@ const ResourcesSelectorDialog: React.FC<Props> = (props: Props) => {
|
||||
<div className="resource-table-container">
|
||||
<div className="fields-container">
|
||||
<span className="field-text id-text">ID</span>
|
||||
<span className="field-text name-text">NAME</span>
|
||||
<span className="field-text name-text">{t("resources.name")}</span>
|
||||
<span></span>
|
||||
</div>
|
||||
{resources.length === 0 ? (
|
||||
@@ -148,6 +142,7 @@ export default function showResourcesSelectorDialog() {
|
||||
generateDialog(
|
||||
{
|
||||
className: "resources-selector-dialog",
|
||||
dialogName: "resources-selector-dialog",
|
||||
},
|
||||
ResourcesSelectorDialog,
|
||||
{}
|
||||
|
||||
@@ -1,37 +1,77 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { locationService } from "../services";
|
||||
import { useAppSelector } from "../store";
|
||||
import useDebounce from "../hooks/useDebounce";
|
||||
import { useLocationStore, useDialogStore } from "../store/module";
|
||||
import { memoSpecialTypes } from "../helpers/filter";
|
||||
import Icon from "./Icon";
|
||||
import "../less/search-bar.less";
|
||||
|
||||
const SearchBar = () => {
|
||||
const { t } = useTranslation();
|
||||
const memoType = useAppSelector((state) => state.location.query?.type);
|
||||
const locationStore = useLocationStore();
|
||||
const dialogStore = useDialogStore();
|
||||
const memoType = locationStore.state.query.type;
|
||||
const [queryText, setQueryText] = useState("");
|
||||
const [isFocus, setIsFocus] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const text = locationService.getState().query.text;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!inputRef.current) {
|
||||
return;
|
||||
}
|
||||
if (dialogStore.getState().dialogStack.length) {
|
||||
return;
|
||||
}
|
||||
const isMetaKey = event.ctrlKey || event.metaKey;
|
||||
if (isMetaKey && event.key === "f") {
|
||||
event.preventDefault();
|
||||
inputRef.current.focus();
|
||||
return;
|
||||
}
|
||||
};
|
||||
document.body.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.body.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const text = locationStore.getState().query.text;
|
||||
setQueryText(text === undefined ? "" : text);
|
||||
}, [locationService.getState().query.text]);
|
||||
}, [locationStore.state.query.text]);
|
||||
|
||||
useDebounce(
|
||||
() => {
|
||||
locationStore.setTextQuery(queryText.length === 0 ? undefined : queryText);
|
||||
},
|
||||
200,
|
||||
[queryText]
|
||||
);
|
||||
|
||||
const handleMemoTypeItemClick = (type: MemoSpecType | undefined) => {
|
||||
const { type: prevType } = locationService.getState().query ?? {};
|
||||
const { type: prevType } = locationStore.getState().query ?? {};
|
||||
if (type === prevType) {
|
||||
type = undefined;
|
||||
}
|
||||
locationService.setMemoTypeQuery(type);
|
||||
locationStore.setMemoTypeQuery(type);
|
||||
};
|
||||
|
||||
const handleTextQueryInput = (event: React.FormEvent<HTMLInputElement>) => {
|
||||
const text = event.currentTarget.value;
|
||||
setQueryText(text);
|
||||
locationService.setTextQuery(text.length === 0 ? undefined : text);
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
setIsFocus(true);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsFocus(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="search-bar-container">
|
||||
<div className={`search-bar-container ${isFocus ? "is-focus" : ""}`}>
|
||||
<div className="search-bar-inputer">
|
||||
<Icon.Search className="icon-img" />
|
||||
<input
|
||||
@@ -39,8 +79,11 @@ const SearchBar = () => {
|
||||
autoComplete="new-password"
|
||||
type="text"
|
||||
placeholder=""
|
||||
ref={inputRef}
|
||||
value={queryText}
|
||||
onChange={handleTextQueryInput}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
</div>
|
||||
<div className="quickly-action-wrapper">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppSelector } from "../store";
|
||||
import { useUserStore } from "../store/module";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import MyAccountSection from "./Settings/MyAccountSection";
|
||||
@@ -20,7 +20,8 @@ interface State {
|
||||
const SettingDialog: React.FC<Props> = (props: Props) => {
|
||||
const { destroy } = props;
|
||||
const { t } = useTranslation();
|
||||
const user = useAppSelector((state) => state.user.user);
|
||||
const userStore = useUserStore();
|
||||
const user = userStore.state.user;
|
||||
const [state, setState] = useState<State>({
|
||||
selectedSection: "my-account",
|
||||
});
|
||||
@@ -66,7 +67,7 @@ const SettingDialog: React.FC<Props> = (props: Props) => {
|
||||
onClick={() => handleSectionSelectorItemClick("system")}
|
||||
className={`section-item ${state.selectedSection === "system" ? "selected" : ""}`}
|
||||
>
|
||||
<span className="icon-text">🧑🔧</span> {t("setting.system")}
|
||||
<span className="icon-text">🛠️</span> {t("setting.system")}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
@@ -91,6 +92,7 @@ export default function showSettingDialog(): void {
|
||||
generateDialog(
|
||||
{
|
||||
className: "setting-dialog",
|
||||
dialogName: "setting-dialog",
|
||||
},
|
||||
SettingDialog,
|
||||
{}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { userService } from "../../services";
|
||||
import { useAppSelector } from "../../store";
|
||||
import { useUserStore } from "../../store/module";
|
||||
import * as api from "../../helpers/api";
|
||||
import toastHelper from "../Toast";
|
||||
import Dropdown from "../common/Dropdown";
|
||||
@@ -16,7 +15,8 @@ interface State {
|
||||
|
||||
const PreferencesSection = () => {
|
||||
const { t } = useTranslation();
|
||||
const currentUser = useAppSelector((state) => state.user.user);
|
||||
const userStore = useUserStore();
|
||||
const currentUser = userStore.state.user;
|
||||
const [state, setState] = useState<State>({
|
||||
createUserUsername: "",
|
||||
createUserPassword: "",
|
||||
@@ -79,8 +79,9 @@ const PreferencesSection = () => {
|
||||
title: `Archive Member`,
|
||||
content: `❗️Are you sure to archive ${user.username}?`,
|
||||
style: "warning",
|
||||
dialogName: "archive-user-dialog",
|
||||
onConfirm: async () => {
|
||||
await userService.patchUser({
|
||||
await userStore.patchUser({
|
||||
id: user.id,
|
||||
rowStatus: "ARCHIVED",
|
||||
});
|
||||
@@ -90,7 +91,7 @@ const PreferencesSection = () => {
|
||||
};
|
||||
|
||||
const handleRestoreUserClick = async (user: User) => {
|
||||
await userService.patchUser({
|
||||
await userStore.patchUser({
|
||||
id: user.id,
|
||||
rowStatus: "NORMAL",
|
||||
});
|
||||
@@ -102,8 +103,9 @@ const PreferencesSection = () => {
|
||||
title: `Delete Member`,
|
||||
content: `Are you sure to delete ${user.username}? THIS ACTION IS IRREVERSIABLE.❗️`,
|
||||
style: "warning",
|
||||
dialogName: "delete-user-dialog",
|
||||
onConfirm: async () => {
|
||||
await userService.deleteUser({
|
||||
await userStore.deleteUser({
|
||||
id: user.id,
|
||||
});
|
||||
fetchUserList();
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppSelector } from "../../store";
|
||||
import { userService } from "../../services";
|
||||
import { useUserStore } from "../../store/module";
|
||||
import { showCommonDialog } from "../Dialog/CommonDialog";
|
||||
import showChangePasswordDialog from "../ChangePasswordDialog";
|
||||
import showUpdateAccountDialog from "../UpdateAccountDialog";
|
||||
@@ -8,7 +7,8 @@ import "../../less/settings/my-account-section.less";
|
||||
|
||||
const MyAccountSection = () => {
|
||||
const { t } = useTranslation();
|
||||
const user = useAppSelector((state) => state.user.user as User);
|
||||
const userStore = useUserStore();
|
||||
const user = userStore.state.user as User;
|
||||
const openAPIRoute = `${window.location.origin}/api/memo?openId=${user.openId}`;
|
||||
|
||||
const handleResetOpenIdBtnClick = async () => {
|
||||
@@ -16,8 +16,9 @@ const MyAccountSection = () => {
|
||||
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",
|
||||
dialogName: "reset-openid-dialog",
|
||||
onConfirm: async () => {
|
||||
await userService.patchUser({
|
||||
await userStore.patchUser({
|
||||
id: user.id,
|
||||
resetOpenId: true,
|
||||
});
|
||||
|
||||
@@ -1,46 +1,17 @@
|
||||
import { Select, Switch, Option } from "@mui/joy";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { globalService, userService } from "../../services";
|
||||
import { useAppSelector } from "../../store";
|
||||
import { useGlobalStore, useUserStore } from "../../store/module";
|
||||
import { VISIBILITY_SELECTOR_ITEMS, MEMO_DISPLAY_TS_OPTION_SELECTOR_ITEMS } from "../../helpers/consts";
|
||||
import Icon from "../Icon";
|
||||
import AppearanceSelect from "../AppearanceSelect";
|
||||
import LocaleSelect from "../LocaleSelect";
|
||||
import "../../less/settings/preferences-section.less";
|
||||
|
||||
const localeSelectorItems = [
|
||||
{
|
||||
text: "English",
|
||||
value: "en",
|
||||
},
|
||||
{
|
||||
text: "中文",
|
||||
value: "zh",
|
||||
},
|
||||
{
|
||||
text: "Tiếng Việt",
|
||||
value: "vi",
|
||||
},
|
||||
{
|
||||
text: "French",
|
||||
value: "fr",
|
||||
},
|
||||
{
|
||||
text: "Nederlands",
|
||||
value: "nl",
|
||||
},
|
||||
{
|
||||
text: "Svenska",
|
||||
value: "sv",
|
||||
},
|
||||
{
|
||||
text: "German",
|
||||
value: "de",
|
||||
},
|
||||
];
|
||||
|
||||
const PreferencesSection = () => {
|
||||
const { t } = useTranslation();
|
||||
const { setting, localSetting } = useAppSelector((state) => state.user.user as User);
|
||||
const globalStore = useGlobalStore();
|
||||
const userStore = useUserStore();
|
||||
const { appearance, locale } = globalStore.state;
|
||||
const { setting, localSetting } = userStore.state.user as User;
|
||||
const visibilitySelectorItems = VISIBILITY_SELECTOR_ITEMS.map((item) => {
|
||||
return {
|
||||
value: item.value,
|
||||
@@ -55,21 +26,30 @@ const PreferencesSection = () => {
|
||||
};
|
||||
});
|
||||
|
||||
const handleLocaleChanged = async (value: string) => {
|
||||
await userService.upsertUserSetting("locale", value);
|
||||
globalService.setLocale(value as Locale);
|
||||
const handleLocaleSelectChange = async (locale: Locale) => {
|
||||
await userStore.upsertUserSetting("locale", locale);
|
||||
globalStore.setLocale(locale);
|
||||
};
|
||||
|
||||
const handleAppearanceSelectChange = async (appearance: Appearance) => {
|
||||
await userStore.upsertUserSetting("appearance", appearance);
|
||||
globalStore.setAppearance(appearance);
|
||||
};
|
||||
|
||||
const handleDefaultMemoVisibilityChanged = async (value: string) => {
|
||||
await userService.upsertUserSetting("memoVisibility", value);
|
||||
await userStore.upsertUserSetting("memoVisibility", value);
|
||||
};
|
||||
|
||||
const handleMemoDisplayTsOptionChanged = async (value: string) => {
|
||||
await userService.upsertUserSetting("memoDisplayTsOption", value);
|
||||
await userStore.upsertUserSetting("memoDisplayTsOption", value);
|
||||
};
|
||||
|
||||
const handleIsFoldingEnabledChanged = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
userService.upsertLocalSetting("enableFoldMemo", event.target.checked);
|
||||
userStore.upsertLocalSetting({ ...localSetting, enableFoldMemo: event.target.checked });
|
||||
};
|
||||
|
||||
const handlePowerfulEditorEnabledChanged = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
userStore.upsertLocalSetting({ ...localSetting, enablePowerfulEditor: event.target.checked });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -77,26 +57,11 @@ const PreferencesSection = () => {
|
||||
<p className="title-text">{t("common.basic")}</p>
|
||||
<div className="form-label selector">
|
||||
<span className="normal-text">{t("common.language")}</span>
|
||||
<Select
|
||||
className="!min-w-[10rem] w-auto text-sm"
|
||||
value={setting.locale}
|
||||
onChange={(_, locale) => {
|
||||
if (locale) {
|
||||
handleLocaleChanged(locale);
|
||||
}
|
||||
}}
|
||||
startDecorator={<Icon.Globe className="w-4 h-auto" />}
|
||||
>
|
||||
{localeSelectorItems.map((item) => (
|
||||
<Option key={item.value} value={item.value} className="whitespace-nowrap">
|
||||
{item.text}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
<LocaleSelect value={locale} onChange={handleLocaleSelectChange} />
|
||||
</div>
|
||||
<div className="form-label selector">
|
||||
<span className="normal-text">Theme</span>
|
||||
<AppearanceSelect />
|
||||
<span className="normal-text">{t("setting.preference-section.theme")}</span>
|
||||
<AppearanceSelect value={appearance} onChange={handleAppearanceSelectChange} />
|
||||
</div>
|
||||
<p className="title-text">{t("setting.preference")}</p>
|
||||
<div className="form-label selector">
|
||||
@@ -139,6 +104,10 @@ const PreferencesSection = () => {
|
||||
<span className="normal-text">{t("setting.preference-section.enable-folding-memo")}</span>
|
||||
<Switch className="ml-2" checked={localSetting.enableFoldMemo} onChange={handleIsFoldingEnabledChanged} />
|
||||
</label>
|
||||
<label className="form-label selector">
|
||||
<span className="normal-text">{t("setting.preference-section.enable-powerful-editor")}</span>
|
||||
<Switch className="ml-2" checked={localSetting.enablePowerfulEditor} onChange={handlePowerfulEditorEnabledChanged} />
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Switch, Textarea } from "@mui/joy";
|
||||
import { useGlobalStore } from "../../store/module";
|
||||
import * as api from "../../helpers/api";
|
||||
import toastHelper from "../Toast";
|
||||
import "../../less/settings/system-section.less";
|
||||
import showUpdateCustomizedProfileDialog from "../UpdateCustomizedProfileDialog";
|
||||
import "@/less/settings/system-section.less";
|
||||
|
||||
interface State {
|
||||
dbSize: number;
|
||||
@@ -23,25 +25,28 @@ const formatBytes = (bytes: number) => {
|
||||
|
||||
const SystemSection = () => {
|
||||
const { t } = useTranslation();
|
||||
const globalStore = useGlobalStore();
|
||||
const systemStatus = globalStore.state.systemStatus;
|
||||
const [state, setState] = useState<State>({
|
||||
dbSize: 0,
|
||||
allowSignUp: false,
|
||||
additionalStyle: "",
|
||||
additionalScript: "",
|
||||
dbSize: systemStatus.dbSize,
|
||||
allowSignUp: systemStatus.allowSignUp,
|
||||
additionalStyle: systemStatus.additionalStyle,
|
||||
additionalScript: systemStatus.additionalScript,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
api.getSystemStatus().then(({ data }) => {
|
||||
const { data: status } = data;
|
||||
setState({
|
||||
dbSize: status.dbSize,
|
||||
allowSignUp: status.allowSignUp,
|
||||
additionalStyle: status.additionalStyle,
|
||||
additionalScript: status.additionalScript,
|
||||
});
|
||||
});
|
||||
globalStore.fetchSystemStatus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setState({
|
||||
dbSize: systemStatus.dbSize,
|
||||
allowSignUp: systemStatus.allowSignUp,
|
||||
additionalStyle: systemStatus.additionalStyle,
|
||||
additionalScript: systemStatus.additionalScript,
|
||||
});
|
||||
}, [systemStatus]);
|
||||
|
||||
const handleAllowSignUpChanged = async (value: boolean) => {
|
||||
setState({
|
||||
...state,
|
||||
@@ -60,21 +65,19 @@ const SystemSection = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateCustomizedProfileButtonClick = () => {
|
||||
showUpdateCustomizedProfileDialog();
|
||||
};
|
||||
|
||||
const handleVacuumBtnClick = async () => {
|
||||
try {
|
||||
await api.vacuumDatabase();
|
||||
const { data: status } = (await api.getSystemStatus()).data;
|
||||
setState({
|
||||
dbSize: status.dbSize,
|
||||
allowSignUp: status.allowSignUp,
|
||||
additionalStyle: status.additionalStyle,
|
||||
additionalScript: status.additionalScript,
|
||||
});
|
||||
await globalStore.fetchSystemStatus();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return;
|
||||
}
|
||||
toastHelper.success("Succeed to vacuum database");
|
||||
toastHelper.success(t("message.succeed-vacuum-database"));
|
||||
};
|
||||
|
||||
const handleSaveAdditionalStyle = async () => {
|
||||
@@ -87,7 +90,7 @@ const SystemSection = () => {
|
||||
console.error(error);
|
||||
return;
|
||||
}
|
||||
toastHelper.success("Succeed to update additional style");
|
||||
toastHelper.success(t("message.succeed-update-additional-style"));
|
||||
};
|
||||
|
||||
const handleAdditionalScriptChanged = (value: string) => {
|
||||
@@ -107,23 +110,29 @@ const SystemSection = () => {
|
||||
console.error(error);
|
||||
return;
|
||||
}
|
||||
toastHelper.success("Succeed to update additional script");
|
||||
toastHelper.success(t("message.succeed-update-additional-script"));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="section-container system-section-container">
|
||||
<p className="title-text">{t("common.basic")}</p>
|
||||
<label className="form-label">
|
||||
<div className="form-label">
|
||||
<div className="normal-text">
|
||||
{t("setting.system-section.server-name")}: <span className="font-mono font-bold">{systemStatus.customizedProfile.name}</span>
|
||||
</div>
|
||||
<Button onClick={handleUpdateCustomizedProfileButtonClick}>{t("common.edit")}</Button>
|
||||
</div>
|
||||
<div className="form-label">
|
||||
<span className="normal-text">
|
||||
{t("setting.system-section.database-file-size")}: <span className="font-mono font-medium">{formatBytes(state.dbSize)}</span>
|
||||
{t("setting.system-section.database-file-size")}: <span className="font-mono font-bold">{formatBytes(state.dbSize)}</span>
|
||||
</span>
|
||||
<Button onClick={handleVacuumBtnClick}>{t("common.vacuum")}</Button>
|
||||
</label>
|
||||
</div>
|
||||
<p className="title-text">{t("sidebar.setting")}</p>
|
||||
<label className="form-label">
|
||||
<div className="form-label">
|
||||
<span className="normal-text">{t("setting.system-section.allow-user-signup")}</span>
|
||||
<Switch checked={state.allowSignUp} onChange={(event) => handleAllowSignUpChanged(event.target.checked)} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-label">
|
||||
<span className="normal-text">{t("setting.system-section.additional-style")}</span>
|
||||
<Button onClick={handleSaveAdditionalStyle}>{t("common.save")}</Button>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Select, Option } from "@mui/joy";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { toLower } from "lodash";
|
||||
import toImage from "../labs/html2image";
|
||||
import { useGlobalStore, useMemoStore, useUserStore } from "../store/module";
|
||||
import { VISIBILITY_SELECTOR_ITEMS } from "../helpers/consts";
|
||||
import * as utils from "../helpers/utils";
|
||||
import { getMemoStats } from "../helpers/api";
|
||||
import { memoService, userService } from "../services";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
@@ -29,7 +30,11 @@ interface State {
|
||||
const ShareMemoDialog: React.FC<Props> = (props: Props) => {
|
||||
const { memo: propsMemo, destroy } = props;
|
||||
const { t } = useTranslation();
|
||||
const user = userService.getState().user as User;
|
||||
const userStore = useUserStore();
|
||||
const memoStore = useMemoStore();
|
||||
const globalStore = useGlobalStore();
|
||||
const user = userStore.state.user as User;
|
||||
const { systemStatus } = globalStore.state;
|
||||
const [state, setState] = useState<State>({
|
||||
memoAmount: 0,
|
||||
memoVisibility: propsMemo.visibility,
|
||||
@@ -67,7 +72,6 @@ const ShareMemoDialog: React.FC<Props> = (props: Props) => {
|
||||
if (!memoElRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
toImage(memoElRef.current, {
|
||||
pixelRatio: window.devicePixelRatio * 2,
|
||||
})
|
||||
@@ -97,7 +101,7 @@ const ShareMemoDialog: React.FC<Props> = (props: Props) => {
|
||||
|
||||
const handleCopyLinkBtnClick = () => {
|
||||
copy(`${window.location.origin}/m/${memo.id}`);
|
||||
toastHelper.success(t("message.succeed-copy-content"));
|
||||
toastHelper.success(t("message.succeed-copy-link"));
|
||||
};
|
||||
|
||||
const memoVisibilityOptionSelectorItems = VISIBILITY_SELECTOR_ITEMS.map((item) => {
|
||||
@@ -113,7 +117,7 @@ const ShareMemoDialog: React.FC<Props> = (props: Props) => {
|
||||
...state,
|
||||
memoVisibility: visibilityValue,
|
||||
});
|
||||
await memoService.patchMemo({
|
||||
await memoStore.patchMemo({
|
||||
id: memo.id,
|
||||
visibility: visibilityValue,
|
||||
});
|
||||
@@ -122,10 +126,7 @@ const ShareMemoDialog: React.FC<Props> = (props: Props) => {
|
||||
return (
|
||||
<>
|
||||
<div className="dialog-header-container">
|
||||
<p className="title-text">
|
||||
<span className="icon-text">🌄</span>
|
||||
{t("common.share")} Memo
|
||||
</p>
|
||||
<p className="title-text">{t("common.share")} Memo</p>
|
||||
<button className="btn close-btn" onClick={handleCloseBtnClick}>
|
||||
<Icon.X className="icon-img" />
|
||||
</button>
|
||||
@@ -136,16 +137,26 @@ const ShareMemoDialog: React.FC<Props> = (props: Props) => {
|
||||
<span className="time-text">{memo.createdAtStr}</span>
|
||||
<div className="memo-content-wrapper">
|
||||
<MemoContent content={memo.content} displayConfig={{ enableExpand: false }} />
|
||||
<MemoResources style="col" resourceList={memo.resourceList} />
|
||||
<MemoResources resourceList={memo.resourceList} />
|
||||
</div>
|
||||
<div className="watermark-container">
|
||||
<div className="logo-container">
|
||||
<img className="logo-img" src={`${systemStatus.customizedProfile.logoUrl || "/logo.png"}`} alt="" />
|
||||
</div>
|
||||
<div className="userinfo-container">
|
||||
<span className="name-text">{user.nickname || user.username}</span>
|
||||
<span className="usage-text">
|
||||
{state.memoAmount} MEMOS / {createdDays} DAYS
|
||||
</span>
|
||||
</div>
|
||||
<img className="logo-img" src="/logo.webp" alt="" />
|
||||
<QRCodeSVG
|
||||
value={`${window.location.origin}/m/${memo.id}`}
|
||||
size={64}
|
||||
bgColor={"#F3F4F6"}
|
||||
fgColor={"#4B5563"}
|
||||
level={"L"}
|
||||
includeMargin={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 py-3 w-full flex flex-row justify-between items-center">
|
||||
@@ -188,6 +199,7 @@ export default function showShareMemoDialog(memo: Memo): void {
|
||||
generateDialog(
|
||||
{
|
||||
className: "share-memo-dialog",
|
||||
dialogName: "share-memo-dialog",
|
||||
},
|
||||
ShareMemoDialog,
|
||||
{ memo }
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { locationService, shortcutService } from "../services";
|
||||
import { useAppSelector } from "../store";
|
||||
import { useLocationStore, useShortcutStore } from "../store/module";
|
||||
import * as utils from "../helpers/utils";
|
||||
import useToggle from "../hooks/useToggle";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
@@ -11,10 +10,12 @@ import showCreateShortcutDialog from "./CreateShortcutDialog";
|
||||
import "../less/shortcut-list.less";
|
||||
|
||||
const ShortcutList = () => {
|
||||
const query = useAppSelector((state) => state.location.query);
|
||||
const shortcuts = useAppSelector((state) => state.shortcut.shortcuts);
|
||||
const loadingState = useLoading();
|
||||
const { t } = useTranslation();
|
||||
const locationStore = useLocationStore();
|
||||
const shortcutStore = useShortcutStore();
|
||||
const query = locationStore.state.query;
|
||||
const shortcuts = shortcutStore.state.shortcuts;
|
||||
const loadingState = useLoading();
|
||||
|
||||
const pinnedShortcuts = shortcuts
|
||||
.filter((s) => s.rowStatus === "ARCHIVED")
|
||||
@@ -25,7 +26,7 @@ const ShortcutList = () => {
|
||||
const sortedShortcuts = pinnedShortcuts.concat(unpinnedShortcuts);
|
||||
|
||||
useEffect(() => {
|
||||
shortcutService
|
||||
shortcutStore
|
||||
.getMyAllShortcuts()
|
||||
.catch(() => {
|
||||
// do nth
|
||||
@@ -60,13 +61,15 @@ interface ShortcutContainerProps {
|
||||
const ShortcutContainer: React.FC<ShortcutContainerProps> = (props: ShortcutContainerProps) => {
|
||||
const { shortcut, isActive } = props;
|
||||
const { t } = useTranslation();
|
||||
const locationStore = useLocationStore();
|
||||
const shortcutStore = useShortcutStore();
|
||||
const [showConfirmDeleteBtn, toggleConfirmDeleteBtn] = useToggle(false);
|
||||
|
||||
const handleShortcutClick = () => {
|
||||
if (isActive) {
|
||||
locationService.setMemoShortcut(undefined);
|
||||
locationStore.setMemoShortcut(undefined);
|
||||
} else {
|
||||
locationService.setMemoShortcut(shortcut.id);
|
||||
locationStore.setMemoShortcut(shortcut.id);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -75,10 +78,10 @@ const ShortcutContainer: React.FC<ShortcutContainerProps> = (props: ShortcutCont
|
||||
|
||||
if (showConfirmDeleteBtn) {
|
||||
try {
|
||||
await shortcutService.deleteShortcutById(shortcut.id);
|
||||
if (locationService.getState().query?.shortcutId === shortcut.id) {
|
||||
await shortcutStore.deleteShortcutById(shortcut.id);
|
||||
if (locationStore.getState().query?.shortcutId === shortcut.id) {
|
||||
// need clear shortcut filter
|
||||
locationService.setMemoShortcut(undefined);
|
||||
locationStore.setMemoShortcut(undefined);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
@@ -102,7 +105,7 @@ const ShortcutContainer: React.FC<ShortcutContainerProps> = (props: ShortcutCont
|
||||
id: shortcut.id,
|
||||
rowStatus: shortcut.rowStatus === "ARCHIVED" ? "NORMAL" : "ARCHIVED",
|
||||
};
|
||||
await shortcutService.patchShortcut(shortcutPatch);
|
||||
await shortcutStore.patchShortcut(shortcutPatch);
|
||||
} catch (error) {
|
||||
// do nth
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user