Compare commits

...

141 Commits

Author SHA1 Message Date
boojack 82f61f2a0e chore: upgrade version to 0.10.2 (#983) 2023-01-21 08:56:26 +08:00
boojack c5368fe8d3 chore: update resource dialog style (#982) 2023-01-21 08:46:49 +08:00
Wujiao233 0aaf153717 fix: video and audio can't play on safari (#980)
* fix: video can't play on safari

* fix: audio can't play on safari
2023-01-20 16:52:38 +08:00
Stephen Zhou 942e1f887b feat: scrool to memo after editing (#907) 2023-01-19 20:57:45 +08:00
Wujiao233 b8ab43aa25 feat: support swipe to switch img on touchscreen (#970)
* feat: support swipe to switch img on touchscreen

* fix: fix two or more fingers touch

* fix lint
2023-01-19 20:57:03 +08:00
Wujiao233 a5f3b051f2 fix: round corner issue in resource blocks (#979) 2023-01-19 17:59:37 +08:00
boojack 4ba9767b94 fix: use input instead of textfield (#973) 2023-01-19 09:16:22 +08:00
Zeng1998 12fda38520 feat: add customized logo in share dialog (#969)
* feat: add qrcode in share dialog

* update: change the color

* feat: add customized logo in share dialog

* update: import order

Co-authored-by: boojack <stevenlgtm@gmail.com>
2023-01-18 10:52:25 +00:00
Zeng1998 9ed503fd6d feat: add qrcode in share dialog (#964)
* feat: add qrcode in share dialog

* update: change the color

* update: import order
2023-01-18 18:49:48 +08:00
Viet-Anh, Nguyen a8976de634 feat: update Vietnamese translation (#965)
refactor: Update Vietnamese translation
2023-01-18 09:01:53 +08:00
Zeng1998 f8855ddb56 feat: support empty content memo (#963)
feat: support empty-text memo
2023-01-17 20:56:57 +08:00
Jasper Platenburg 288ecc617d feat: issue translator workflow (#956)
Create issue-translator.yml
2023-01-15 21:33:19 +08:00
Ángel Fernández Sánchez 14ec81b65c feat: update Spanish translation (#954) 2023-01-15 08:35:20 +08:00
boojack fae0b64a08 fix: delete tag api (#950)
* fix: delete tag api

* chore: update
2023-01-14 12:08:31 +08:00
boojack 677750ef51 chore: upgrade version to 0.10.1 (#949) 2023-01-14 08:00:07 +08:00
boojack 4cfd000b92 feat: support audio player (#948) 2023-01-14 07:41:17 +08:00
boojack aacaf3f37c chore: remove sponsor with open collective (#947)
chore: update github sponsors
2023-01-14 06:56:17 +08:00
boojack ad4a79a510 chore: update GitHub sponsor (#946)
chore: update github sponsor
2023-01-14 06:51:21 +08:00
boojack 219d2754a0 chore: remove existed tags in suggestion (#944) 2023-01-13 22:37:30 +08:00
boojack 10430a66c3 chore: debounce search text input (#943)
* chore: debounce search text input

* chore: update
2023-01-13 22:33:52 +08:00
Jasper Platenburg c167c21e4e chore: added translation for copy memo link (#942) 2023-01-13 21:22:20 +08:00
boojack 40d25f7dca fix: skip api error for static middleware (#941) 2023-01-13 07:06:15 +08:00
boojack 1441a1df1f chore: update funding with open collective (#940) 2023-01-12 23:48:51 +08:00
boojack b19c3c6db3 feat: update renderer in list (#935) 2023-01-12 08:52:57 +08:00
boojack 8c146aed68 feat: update memo resources style (#933)
* feat: update memo resources style

* chore: update
2023-01-12 00:00:44 +08:00
boojack 805122f45c chore: add User stories section to readme (#932) 2023-01-11 09:21:52 +08:00
sfan5 7d5de1a07e feat: update German translation (#926) 2023-01-08 23:41:48 +08:00
boojack 4b860777cf fix: tag generate in code block (#925) 2023-01-08 13:49:26 +08:00
boojack e29924c8a1 fix: codeblock renderer (#924) 2023-01-08 11:24:28 +08:00
boojack 1847756ade chore: remove escape (#918) 2023-01-07 14:52:47 +08:00
boojack 771c56f485 chore: fix renderer (#917) 2023-01-07 14:07:17 +08:00
boojack 0f057e81e9 fix: version compare (#916)
* fix: version compare

* chore: update
2023-01-07 13:58:42 +08:00
Stephen Zhou 529c9b34a7 fix: missing creator id in shortcut cache (#915)
fix: missing creatot id in shortcut cache
2023-01-07 12:45:55 +08:00
boojack e2e8130f4c fix: sort version (#914) 2023-01-07 11:49:58 +08:00
boojack 46c13a4b7f chore: add skipper for secure (#913) 2023-01-07 10:51:34 +08:00
boojack 96798e10b4 feat: support embed memo with iframe (#912) 2023-01-07 01:56:02 +08:00
boojack 0f8ce3dd16 refactor: return jsx element instead of string in marked (#910)
* refactor: return jsx element instead of string in marked

* chore: update
2023-01-07 00:13:49 +08:00
boojack 491859bbf6 chore: update activity metrics (#908) 2023-01-05 20:56:50 +08:00
boojack f16123a624 chore: update memo create activity (#903) 2023-01-03 23:49:11 +08:00
boojack d50ad9433f feat: persistent session name (#902)
* feat: persistent session name

* chore: update
2023-01-03 23:05:42 +08:00
Zeng1998 92a8a4ac0c feat: support code copy (#901)
* feat: support code copy

* update
2023-01-03 23:05:00 +08:00
helaxious 62f53888ba feat: update docker command description (#893) (#900)
* Add a line on README to help prevent a mistake (#893)

* Update README.md

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

Co-authored-by: boojack <stevenlgtm@gmail.com>
2023-01-03 22:40:53 +08:00
boojack 79180928d4 chore: update server activity (#898) 2023-01-03 20:05:37 +08:00
boojack e5550828a0 chore: update activity payload (#891) 2023-01-02 23:18:12 +08:00
Vincenzo Cardone 2e95f6824f feat: add Italian Translation (#890) 2023-01-02 09:41:39 +08:00
boojack 5195012217 feat: add activity table (#888)
feat: introduce activity
2023-01-01 23:55:02 +08:00
boojack a797280e3f chore: update middleware skipper (#887)
* chore: update middleware skipper

* chore: update
2023-01-01 23:26:21 +08:00
boojack 293f88e40c chore: update GitHub action name (#886)
* chore: update github actions name

* chore: update
2023-01-01 22:01:16 +08:00
boojack 861eeb7b0f chore: add skipper in CSRF (#885) 2023-01-01 21:32:17 +08:00
boojack 24b21aa9d7 chore: update version to 0.9.1 (#882) 2022-12-31 15:40:53 +08:00
boojack 51eac649c5 chore: update create tag dialog (#881) 2022-12-31 15:13:25 +08:00
boojack 7670c95360 chore: fix XSS in renderer (#880) 2022-12-31 11:52:57 +08:00
Ivan 65e9fdead1 feat: add russian locale (#879) 2022-12-31 09:02:14 +08:00
Zeng1998 2b2792de73 fix: logic of email validation (#877)
* fix: fix logic of email validation

* update
2022-12-30 13:10:52 +08:00
boojack c9bb2b785d chore: fix CSRF (#876) 2022-12-30 00:17:19 +08:00
boojack 64e5c343c5 chore: fix XSS in renderer (#875)
chore: fix xss in renderer
2022-12-29 23:27:56 +08:00
boojack 9169b3f2cd chore: update tip text for empty tag list (#872) 2022-12-29 09:14:24 +08:00
boojack b6f7a85a2a fix: reload page when sign out (#871) 2022-12-28 20:58:59 +08:00
boojack 3556ae4e65 fix: access control (#870) 2022-12-28 20:22:52 +08:00
boojack f888c62840 chore: update userinfo validator (#868)
* chore: update userinfo validator

* chore: update actions

* chore: update
2022-12-27 21:51:43 +08:00
Taras c160bed403 fead: add ukrainian locale (#864) 2022-12-26 19:10:47 +08:00
boojack afc9709484 chore: update dev config (#857) 2022-12-25 10:39:45 +08:00
boojack 05b41804e3 chore: hide host user email (#856) 2022-12-25 10:28:51 +08:00
Zeng1998 2e2657b39d feat: add setting for power editor (#851) 2022-12-24 16:18:13 +08:00
Zeng1998 60ee602639 feat: enable word break (#849)
* feat: enable word break

* Update web/src/less/editor.less

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

* Update web/src/less/memo-content.less

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

Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-12-24 14:50:27 +08:00
Zeng1998 cac04e4406 feat: enable word break (#849)
* feat: enable word break

* Update web/src/less/editor.less

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

* Update web/src/less/memo-content.less

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

Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-12-24 14:50:10 +08:00
M. Gschwandtner 278b4d21b4 fix: prioritize user css by moving it to the body end (#847)
Co-authored-by: M. Gschwandtner <84477901+OnlyPain-ctrl@users.noreply.github.com>
2022-12-24 09:35:30 +08:00
boojack 27fd1e2880 chore: remove secure flag (#848) 2022-12-24 08:31:37 +08:00
boojack fae9b3db46 chore: revert add linux/arm/v7 to platforms (#843)
Revert "chore: add `linux/arm/v7` to platforms (#842)"

This reverts commit 49c7f49820.
2022-12-23 22:39:04 +08:00
boojack 49c7f49820 chore: add linux/arm/v7 to platforms (#842) 2022-12-23 22:25:24 +08:00
boojack ef8981794e chore: restore dockerfile (#841) 2022-12-23 22:12:41 +08:00
boojack e52d77b2c4 chore: restore lockfile (#840) 2022-12-23 22:08:54 +08:00
boojack 1d2953b1b1 chore: downgrade joy-ui version (#839) 2022-12-23 21:53:13 +08:00
boojack d702eaa625 chore: update dockerfile (#838) 2022-12-23 20:48:51 +08:00
boojack 50811c3064 chore: update yarn.lock (#837) 2022-12-23 20:33:53 +08:00
boojack 99d9cc9168 fix: set csp header only for resource (#836) 2022-12-23 20:02:42 +08:00
boojack 119603da5d chore: upgrade version to 0.9.0 (#835) 2022-12-23 19:49:55 +08:00
boojack f6039f2eb9 chore: update dialog title (#834) 2022-12-23 19:49:44 +08:00
boojack 65cc19c12e chore: add escape to prevent XSS (#833) 2022-12-23 19:17:33 +08:00
boojack c07b4a57ca feat: add secure middleware (#832) 2022-12-23 18:58:55 +08:00
boojack dca35bde87 fix: disable decode patch id (#831) 2022-12-23 18:38:24 +08:00
boojack 9f25badde3 chore: update logo format to png (#830) 2022-12-23 00:21:53 +08:00
boojack 7efa749c66 feat: customize system profile (#828) 2022-12-22 19:48:44 +08:00
boojack 72daa4e1d6 feat: support heading syntax (#827) 2022-12-22 19:48:19 +08:00
ChasLui 54702db9ba feat: prevent page jitter caused by the presence of scroll bars (#808) 2022-12-22 17:46:09 +08:00
Zeng1998 41ad084489 fix: fix css of input placeholder in auth page (#824) 2022-12-22 17:45:12 +08:00
boojack 2fb171e069 chore: update create tag dialog style (#818)
* chore: update create tag dialog

* chore: update
2022-12-22 09:29:22 +08:00
boojack 201c0b020d chore: update seed data for tag (#817)
* chore: update seed data

* chore: add `_journal_mode` for SQLite

* chore: update create tag dialog
2022-12-22 08:34:05 +08:00
boojack b6f19ca093 feat: upsert tag based content (#816)
* feat: upsert tag based content

* chore: update
2022-12-22 00:35:47 +08:00
boojack 68a77b6e1f feat: create tag dialog (#814) 2022-12-21 23:59:03 +08:00
boojack e4a8a4d708 feat: tag table (#811)
* feat: tag table

* chore: update

* chore: update
2022-12-21 19:22:32 +08:00
boojack ab07c91d42 feat: update marked (#810) 2022-12-21 18:36:26 +08:00
ChasLui 1838e616fd feat: show active panel when searchBar is in focus (#806)
* feat: Show active panel when searchBar is in focus

* refactor: rename
2022-12-21 18:36:08 +08:00
M. Gschwandtner 90d0ccc2e8 feat: add arm/v7 to buildx platforms (#802)
added arm/v7 to buildx platforms

Co-authored-by: M. Gschwandtner <84477901+OnlyPain-ctrl@users.noreply.github.com>
2022-12-21 09:08:08 +08:00
ChasLui 358a5c0ed9 feat: press cmd+f to focus on the search bar (#800) 2022-12-21 00:46:22 +08:00
ChasLui 40f39fd66c feat: use shift+tab to unindent (#799)
* feat: Use shift+tab to unindent, just like vscode

* fix: shit+tab return
2022-12-20 23:03:25 +08:00
Jasper Platenburg 3b41976866 feat: implement plurals for stats (#783)
* implement plurals for stats

* renamed variables

* modified according to 18n guide
2022-12-20 21:29:10 +08:00
PublicHer0 a23de50bb8 feat: update spanish locale (#786)
* Adding spanish version

* update spanish locale

Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-12-20 13:18:21 +00:00
Jasper Platenburg 6596e6893e feat: implement translation for days (#784)
implement trranslation for weekdays

Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-12-20 13:10:25 +00:00
ChasLui b6fe4d914e fix: incorrect cursor when text is selected in range (#797) 2022-12-20 20:51:32 +08:00
ChasLui 3c2cd43d28 fix: shortcuts should exclude the shift judgment (#796)
* fix: Shortcuts should exclude the shift judgment

* fix: eslint
2022-12-20 19:47:35 +08:00
ChasLui 2658b1fd09 feat: support command + k shortcuts insert []() (#793)
* feat: support `command + k` shortcuts insert []()

* fix: eslint

* fix: clear code

* fix: eslint

* feat: insert [](url)

* refactor: rename param

* fix: eslint
2022-12-20 18:51:22 +08:00
Zeng1998 b7df1f5bbf feat: matching punctuation (#791) 2022-12-20 18:10:02 +08:00
ChasLui a0face6695 feat: update i18n (#790)
* feat: tag type i18n

* feat: custom server dialog i18n

* feat: i18n resources name

* feat: i18n toast

* fix: eslint

* eslint: fix

* fix: eslint

* fix: eslint
2022-12-20 17:47:02 +08:00
boojack c177db69d5 chore: update tag regexp (#785) 2022-12-20 09:44:41 +08:00
boojack b704c20809 chore: return raw text for html (#782) 2022-12-19 18:45:17 +08:00
boojack 6c17f94ef6 fix: max open conns for SQLite (#781) 2022-12-19 18:28:15 +08:00
lujiefsi 726285e634 chore: restrict the html file (#749)
* restrict the html file

* replace spaces with table

* remove space
2022-12-19 18:26:50 +08:00
Zeng1998 bd6ab71d41 chore: remove unused state (#780) 2022-12-19 18:03:39 +08:00
boojack b67ed1ee13 feat: customize system profile (#774)
* feat: system setting for customized profile

* chore: update

* feat: update frontend

* chore: update
2022-12-18 21:18:30 +08:00
Zeng1998 55695f2189 feat: esc key to exit multiple dialogs (#692)
* fix: `esc` key to exit multiple dialogs

* update

* update

* update

* Update web/src/components/Dialog/BaseDialog.tsx

Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-12-18 10:09:12 +00:00
boojack e79d67d127 chore: update readme with deploy guides (#771) 2022-12-18 17:59:29 +08:00
ajstephens1995 1d9ef9813a docs: guide to deploy memos with render for beginners (#769) 2022-12-18 16:35:09 +08:00
boojack ef621a444f refactor: introducing use{Module}Store instead of service (#768)
* refactor: introducing `useEditorStore`

* refactor: update

* chore: update
2022-12-18 15:25:18 +08:00
boojack bd00fa798d chore: simplify ordered list in editor (#767)
chore: simplify editor
2022-12-18 12:44:46 +08:00
Zeng1998 a41745c9ae feat: editor enhancement for order list (#763) 2022-12-18 11:02:42 +08:00
M. Gschwandtner 1eec474007 fix: heatmap popup showing after logging out (#761)
* fix for heatmap popup showing after logging out

* moved node.remove to component unmount

* Update web/src/components/UsageHeatMap.tsx

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

Co-authored-by: M. Gschwandtner <84477901+OnlyPain-ctrl@users.noreply.github.com>
Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-12-17 03:10:06 +00:00
Stephen Zhou 83e5278b51 fix: dialog close when draging from in to out (#760) 2022-12-17 10:53:55 +08:00
boojack a8751af6b5 fix: memo list padding bottom (#759) 2022-12-17 10:19:15 +08:00
boojack 6b24f52cd1 fix: watermark container width (#758) 2022-12-17 10:15:54 +08:00
boojack 7ec22482c1 chore: upgrade version to 0.8.3 (#755) 2022-12-16 23:12:07 +08:00
boojack ee89dc00c0 chore: update list style (#754) 2022-12-16 23:09:01 +08:00
boojack bbd5fe4eb2 feat: remove sticky style for memo editor (#752) 2022-12-16 22:41:12 +08:00
boojack 575a0610a3 chore: revert "feat: add visibility field to resource (#743)" (#751)
Revert "feat: add `visibility` field to resource (#743)"

This reverts commit b68cc08592.
2022-12-16 22:20:17 +08:00
boojack b68cc08592 feat: add visibility field to resource (#743) 2022-12-15 21:15:16 +08:00
Stephen Zhou d51af7e98a fix: hr in edited content do not trigger folding (#748) 2022-12-15 18:38:35 +08:00
M. Gschwandtner 334da5e903 fix: add a span as wrapper to fix whitespace (#747) 2022-12-15 08:45:20 +08:00
Jasper Platenburg 35fed76d1a feat: add 'theme' to translation (#746) 2022-12-15 08:43:32 +08:00
boojack c77d49259a chore: update light bg color (#744) 2022-12-13 23:34:38 +08:00
PublicHer0 5520605ccc feat: add spanish locale (#741) 2022-12-13 09:12:22 +08:00
Zeng1998 1dee8ae49f fix: url resource filename decode (#738)
* fix: url resource filename decode

* update

* update

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

Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-12-12 20:00:21 +08:00
Zeng1998 3fd4ee83ac fix: checklist auto continuation (#737) 2022-12-12 18:30:04 +08:00
boojack 5e978e2cfc chore: update color scheme listener (#735) 2022-12-11 23:18:25 +08:00
boojack 37b7b983d2 chore: add vite plugin legacy (#734) 2022-12-11 22:15:52 +08:00
Zeng1998 c4278ef55a fix: fix order in resource dialog (#733) 2022-12-11 21:36:17 +08:00
Zeng1998 91220ea4a6 fix: reset image state in gallery (#730) 2022-12-11 20:14:55 +08:00
Zeng1998 4bebbf3e1d feat: enable paste multiple resources (#729)
* feat: enable paste multiple resources

* update

* update
2022-12-11 19:17:39 +08:00
boojack 5d8b8c37a5 chore: upgrade vite (#728)
* chore: upgrade vite

* Revert "chore: remove lazy import component (#724)"

This reverts commit 688dc2304c.
2022-12-11 18:38:29 +08:00
boojack 564f20d13a chore: remove ESC to close edit (#726) 2022-12-11 14:18:04 +08:00
boojack c3adb1b152 fix: set resource list in memo editor (#725) 2022-12-11 14:04:22 +08:00
boojack 688dc2304c chore: remove lazy import component (#724) 2022-12-10 19:39:43 +08:00
232 changed files with 7870 additions and 3978 deletions
-1
View File
@@ -1,2 +1 @@
web/node_modules
web/yarn.lock
+1
View File
@@ -1 +1,2 @@
github: usememos
ko_fi: stevenlgtm
+41
View File
@@ -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"}'
+18
View File
@@ -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
View File
@@ -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
+14 -3
View File
@@ -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>
![demo](https://raw.githubusercontent.com/usememos/memos/main/resources/demo.webp)
![demo](./resources/demo.webp)
## 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
View File
@@ -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"`
}
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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
+6 -6
View File
@@ -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
+1 -1
View File
@@ -8,7 +8,7 @@ type MemoResource struct {
}
type MemoResourceUpsert struct {
MemoID int
MemoID int `json:"-"`
ResourceID int
UpdatedTs *int64
}
+12 -10
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
}
+133
View File
@@ -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)
![ss1](https://i.imgur.com/l3K7aqC.png)
1. Go to your dashboard
[https://dashboard.render.com/](https://dashboard.render.com/)
2. Select New Web Service
![ss2](https://i.imgur.com/IIDdK2y.png)
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
![ss3](https://i.imgur.com/OXoCWoJ.png)
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
![ss4](https://i.imgur.com/v7Sw3fp.png)
8. Click "enter your payment information" and do so
![ss5](https://i.imgur.com/paKcQFl.png)
![ss6](https://i.imgur.com/JdcO1HC.png)
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"
![ss7](https://i.imgur.com/MHe45J4.png)
11. Wait patiently while the _magic_ happens 🤷‍♂️
![ss8](https://i.imgur.com/h1PXHHJ.png)
12. After some time (~ 6 min for me) the build will finish and you will see the web service is live
![ss9](https://i.imgur.com/msapkRw.png)
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"
![ss10](https://i.imgur.com/rGeI0bv.png)
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"
![ss11](https://i.imgur.com/Jbg7O6q.png)
19. Wait...again...while the webservice redeploys with the persistant disk
![ss12](https://i.imgur.com/pTzpE34.png)
20. aaaand....we're back online!
![ss13](https://i.imgur.com/qdsFfSa.png)
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!
![ss14](https://i.imgur.com/cgzFSIn.png)
23. Create a Username and Password (remember these) then click "Sign up as Host"
![ss15](https://i.imgur.com/kuRStAj.png)
24. Create a test memo then click save
![ss16](https://i.imgur.com/Eh2AB44.png)
25. Sign out of your self-hosted memos
![ss17](https://i.imgur.com/0mMb88G.png)
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)
![ss18](https://i.imgur.com/w1N7VTb.png)
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
![ss19](https://i.imgur.com/dTcEQZS.png)
![ss20](https://i.imgur.com/VE2lu8H.png)
## 🎉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
View File
@@ -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.
+13 -8
View File
@@ -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
)
+16 -16
View File
@@ -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=
+8
View File
@@ -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
}
-7
View File
@@ -1,7 +0,0 @@
package metric
// Metric is the API message for metric.
type Metric struct {
Name string
Labels map[string]string
}
+12 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+4 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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 = &currentTs
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: &currentTs,
}
if err := json.NewDecoder(c.Request().Body).Decode(memoResourceUpsert); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo resource request").SetInternal(err)
}
if _, err := s.Store.UpsertMemoResource(ctx, memoResourceUpsert); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo resource").SetInternal(err)
}
resourceFind := &api.ResourceFind{
ID: &memoResourceUpsert.ResourceID,
}
resource, err := s.Store.FindResource(ctx, resourceFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource").SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(resource)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode resource response").SetInternal(err)
}
return nil
})
g.GET("/memo/:memoId/resource", func(c echo.Context) error {
ctx := c.Request().Context()
memoID, err := strconv.Atoi(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
}
resourceFind := &api.ResourceFind{
MemoID: &memoID,
}
resourceList, err := s.Store.FindResourceList(ctx, resourceFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource list").SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(resourceList)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode resource list response").SetInternal(err)
}
return nil
})
g.DELETE("/memo/:memoId/resource/:resourceId", func(c echo.Context) error {
ctx := c.Request().Context()
memoID, err := strconv.Atoi(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Memo ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
}
resourceID, err := strconv.Atoi(c.Param("resourceId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Resource ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
}
memoResourceDelete := &api.MemoResourceDelete{
MemoID: &memoID,
ResourceID: &resourceID,
}
if err := s.Store.DeleteMemoResource(ctx, memoResourceDelete); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource list").SetInternal(err)
}
return c.JSON(http.StatusOK, true)
})
g.DELETE("/memo/:memoId", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
memoID, err := strconv.Atoi(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
}
memoFind := &api.MemoFind{
ID: &memoID,
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
View File
@@ -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)
}
+1 -1
View File
@@ -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
View File
@@ -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: &currentTs,
}
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: &currentTs,
}
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
View File
@@ -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
View File
@@ -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: &currentTs,
}
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
View File
@@ -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
View File
@@ -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 = &currentUserID
} 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
View File
@@ -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
View File
@@ -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: &currentTs,
}
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
View File
@@ -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
}
+93
View File
@@ -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)
}
}
+89
View File
@@ -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
View File
@@ -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
}
+17
View File
@@ -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 '{}'
);
+6
View File
@@ -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 '{}'
);
+4 -9
View File
@@ -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
}
+32
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 -4
View File
@@ -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
View File
@@ -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;
+21 -18
View File
@@ -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
);
+13 -12
View File
@@ -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">
+5 -5
View File
@@ -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);
+6 -8
View File
@@ -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,
{
+5 -3
View File
@@ -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,
{
+11 -16
View File
@@ -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 }
+162
View File
@@ -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;
+1 -1
View File
@@ -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>
+4 -2
View File
@@ -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 }
+22 -7
View File
@@ -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
+16 -7
View File
@@ -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 (
+60
View File
@@ -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;
+40
View File
@@ -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
View File
@@ -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}
/>
+12 -11
View File
@@ -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}>
+186 -61
View File
@@ -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>
+12 -12
View File
@@ -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}
+13 -12
View File
@@ -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">
+37
View File
@@ -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;
+44 -36
View File
@@ -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>
</>
);
};
+9 -7
View File
@@ -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" />
+68 -16
View File
@@ -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,
{
+30 -61
View File
@@ -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,
{}
+12 -17
View File
@@ -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,
{}
+53 -10
View File
@@ -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">
+5 -3
View File
@@ -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>
);
};
+38 -29
View File
@@ -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>
+23 -11
View File
@@ -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 }
+15 -12
View File
@@ -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