Compare commits

...

72 Commits

Author SHA1 Message Date
boojack
c0cb01b527 chore: add linux/arm/v7 to platforms (#842) 2022-12-23 22:27:22 +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
161 changed files with 3738 additions and 1681 deletions

View File

@@ -39,6 +39,6 @@ jobs:
with:
context: ./
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true
tags: neosmemo/memos:latest, neosmemo/memos:${{ env.VERSION }}

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
@@ -43,11 +43,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

View File

@@ -57,7 +57,7 @@ type MemoCreate struct {
}
type MemoPatch struct {
ID int
ID int `json:"-"`
// Standard fields
CreatedTs *int64 `json:"createdTs"`

View File

@@ -41,7 +41,7 @@ type ResourceFind struct {
}
type ResourcePatch struct {
ID int
ID int `json:"-"`
// Standard fields
UpdatedTs *int64

View File

@@ -24,7 +24,7 @@ type ShortcutCreate struct {
}
type ShortcutPatch struct {
ID int
ID int `json:"-"`
// Standard fields
UpdatedTs *int64

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"`
}

View File

@@ -3,6 +3,8 @@ package api
import (
"encoding/json"
"fmt"
"golang.org/x/exp/slices"
)
type SystemSettingName string
@@ -14,8 +16,26 @@ const (
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 SystemSettingAllowSignUpName:
@@ -24,6 +44,8 @@ func (key SystemSettingName) String() string {
return "additionalStyle"
case SystemSettingAdditionalScriptName:
return "additionalScript"
case SystemSettingCustomizedProfileName:
return "customizedProfile"
}
return ""
}
@@ -75,6 +97,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
api/tag.go Normal file
View File

@@ -0,0 +1,20 @@
package api
type Tag struct {
Name string
CreatorID int
}
type TagUpsert struct {
Name string
CreatorID int
}
type TagFind struct {
CreatorID int
}
type TagDelete struct {
Name string
CreatorID int
}

View File

@@ -69,7 +69,7 @@ func (create UserCreate) Validate() error {
}
type UserPatch struct {
ID int
ID int `json:"-"`
// Standard fields
UpdatedTs *int64

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"}
UserSettingAppearanceValue = []string{"system", "light", "dark"}
UserSettingMemoVisibilityValue = []Visibility{Private, Protected, Public}
UserSettingMemoDisplayTsOptionKeyValue = []string{"created_ts", "updated_ts"}
@@ -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 {

133
docs/deploy-with-render.md Normal file
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).

7
go.mod
View File

@@ -38,11 +38,14 @@ 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/sys v0.1.0 // indirect
golang.org/x/text v0.3.7 // 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
)
require github.com/segmentio/analytics-go v3.1.0+incompatible
require (
github.com/segmentio/analytics-go v3.1.0+incompatible
golang.org/x/exp v0.0.0-20221217163422-3c43f8badb15
)

6
go.sum
View File

@@ -70,14 +70,16 @@ github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c h1:3lbZUMbMiGUW/LMkfsEAB
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/exp v0.0.0-20221217163422-3c43f8badb15 h1:5oN1Pz/eDhCpbMbLstvIPa0b/BEQo6g6nwV3pLjfM6w=
golang.org/x/exp v0.0.0-20221217163422-3c43f8badb15/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
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/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/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.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 h1:ftMN5LMiBFjbzleLqtoBZk7KdJwhuybIU+FckUHgoyQ=

View File

@@ -27,6 +27,7 @@ func setUserSession(ctx echo.Context, user *api.User) error {
Path: "/",
MaxAge: 3600 * 24 * 30,
HttpOnly: true,
Secure: true,
}
sess.Values[userIDContextKey] = user.ID
err := sess.Save(ctx.Request(), ctx.Response())

View File

@@ -3,9 +3,9 @@ package server
import (
"encoding/json"
"fmt"
"html"
"io"
"net/http"
"net/url"
"strconv"
"time"
@@ -164,42 +164,6 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
return nil
})
g.DELETE("/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)
}
resource, err := s.Store.FindResource(ctx, &api.ResourceFind{
ID: &resourceID,
CreatorID: &userID,
})
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)
}
resourceDelete := &api.ResourceDelete{
ID: resourceID,
}
if err := s.Store.DeleteResource(ctx, resourceDelete); err != nil {
if common.ErrorCode(err) == common.NotFound {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Resource ID not found: %d", resourceID))
}
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete resource").SetInternal(err)
}
return c.JSON(http.StatusOK, true)
})
g.PATCH("/resource/:resourceId", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
@@ -240,6 +204,42 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
}
return nil
})
g.DELETE("/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)
}
resource, err := s.Store.FindResource(ctx, &api.ResourceFind{
ID: &resourceID,
CreatorID: &userID,
})
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)
}
resourceDelete := &api.ResourceDelete{
ID: resourceID,
}
if err := s.Store.DeleteResource(ctx, resourceDelete); err != nil {
if common.ErrorCode(err) == common.NotFound {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Resource ID not found: %d", resourceID))
}
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete resource").SetInternal(err)
}
return c.JSON(http.StatusOK, true)
})
}
func (s *Server) registerResourcePublicRoutes(g *echo.Group) {
@@ -249,8 +249,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,9 +262,10 @@ 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.WriteHeader(http.StatusOK)
c.Response().Writer.Header().Set(echo.HeaderCacheControl, "max-age=31536000, immutable")
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 response").SetInternal(err)
}

View File

@@ -46,6 +46,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{})
@@ -54,7 +62,7 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
}
for _, systemSetting := range systemSettingList {
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 +73,16 @@ 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{
Name: valueMap["name"].(string),
LogoURL: valueMap["logoUrl"].(string),
Description: valueMap["description"].(string),
Locale: valueMap["locale"].(string),
Appearance: valueMap["appearance"].(string),
ExternalURL: valueMap["externalUrl"].(string),
}
}
}

View File

@@ -2,20 +2,85 @@ package server
import (
"encoding/json"
"fmt"
"net/http"
"regexp"
"sort"
"strconv"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
metric "github.com/usememos/memos/plugin/metrics"
"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{
CreatorID: userID,
}
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")
}
tag, err := s.Store.UpsertTag(ctx, tagUpsert)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert tag").SetInternal(err)
}
s.Collector.Collect(ctx, &metric.Metric{
Name: "tag created",
})
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()
tagFind := &api.TagFind{}
if userID, err := strconv.Atoi(c.QueryParam("creatorId")); err == nil {
tagFind.CreatorID = userID
}
if tagFind.CreatorID == 0 {
currentUserID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find tag")
}
tagFind.CreatorID = currentUserID
}
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()
contentSearch := "#"
normalRowStatus := api.Normal
@@ -65,15 +130,42 @@ 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 := c.Param("tagName")
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{}

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 {

View File

@@ -7,10 +7,10 @@ import (
// Version is the service current released version.
// Semantic versioning: https://semver.org/
var Version = "0.8.2"
var Version = "0.9.0"
// DevVersion is the service current development version.
var DevVersion = "0.8.2"
var DevVersion = "0.9.0"
func GetCurrentVersion(mode string) string {
if mode == "dev" {

View File

@@ -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.Db = sqliteDB
if db.profile.Mode == "dev" {
// In dev mode, we should migrate and seed the database.

View File

@@ -86,3 +86,10 @@ 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)
);

View File

@@ -0,0 +1,6 @@
-- tag
CREATE TABLE tag (
name TEXT NOT NULL,
creator_id INTEGER NOT NULL,
UNIQUE(name, creator_id)
);

View File

@@ -86,3 +86,10 @@ 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)
);

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

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
store/tag.go Normal file
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
}

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

View File

@@ -3,7 +3,6 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"serve": "vite preview",
"lint": "eslint --ext .js,.ts,.tsx, src",
"test": "jest --passWithNoTests"
},
@@ -36,7 +35,8 @@
"@types/react-dom": "^18.0.6",
"@typescript-eslint/eslint-plugin": "^5.6.0",
"@typescript-eslint/parser": "^5.6.0",
"@vitejs/plugin-react": "^2.0.0",
"@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 +47,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

BIN
web/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

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

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.
@@ -43,6 +55,11 @@ function App() {
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;

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,8 @@ 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 handleCloseBtnClick = () => {
destroy();
@@ -19,7 +20,7 @@ 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="" />
<img className="w-7 h-auto mr-1" src="/logo.png" alt="" />
{t("common.about")} memos
</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
@@ -58,6 +59,7 @@ export default function showAboutSiteDialog(): void {
generateDialog(
{
className: "about-site-dialog",
dialogName: "about-site-dialog",
},
AboutSiteDialog
);

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">

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

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

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

View File

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

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

View File

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

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 }

View File

@@ -0,0 +1,149 @@
import { TextField } from "@mui/joy";
import React, { useEffect, useState } from "react";
import { useTagStore } from "../store/module";
import { getTagSuggestionList } from "../helpers/api";
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 = Tag.matcher(`#${tagName}`);
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 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 handleUpsertSuggestTag = async (tagName: string) => {
await tagStore.upsertTag(tagName);
};
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">
<TextField
className="mb-2"
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 && (
<>
<p className="w-full mt-2 mb-1 text-sm text-gray-400">Tag suggestions</p>
<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={() => handleUpsertSuggestTag(tag)}
>
#{tag}
</span>
))}
</div>
<button
className="mt-2 text-sm border px-2 leading-6 rounded cursor-pointer dark:border-gray-400 dark:text-gray-300 hover:opacity-80 hover:shadow"
onClick={handleSaveSuggestTagList}
>
Save all
</button>
</>
)}
</div>
</>
);
};
function showCreateTagDialog() {
generateDialog(
{
className: "create-tag-dialog",
dialogName: "create-tag-dialog",
},
CreateTagDialog
);
}
export default showCreateTagDialog;

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 }

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;
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>

View File

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

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 (

View File

@@ -0,0 +1,37 @@
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>
</Select>
);
};
export default LocaleSelect;

View File

@@ -3,7 +3,7 @@ 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";
@@ -30,9 +30,13 @@ const Memo: React.FC<Props> = (props: Props) => {
const { memo, highlightWord } = 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;
@@ -59,9 +63,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 +73,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 +87,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 +101,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 +132,7 @@ const Memo: React.FC<Props> = (props: Props) => {
finalContent += `${tempList[i]}`;
}
}
await memoService.patchMemo({
await memoStore.patchMemo({
id: memo.id,
content: finalContent,
});
@@ -151,7 +155,7 @@ const Memo: React.FC<Props> = (props: Props) => {
return;
}
editorStateService.setEditMemoWithId(memo.id);
editorStore.setEditMemoWithId(memo.id);
};
const handleMemoDisplayTimeClick = () => {
@@ -159,11 +163,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);
}
};

View File

@@ -1,9 +1,9 @@
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 {
@@ -36,7 +36,8 @@ const MemoContent: React.FC<Props> = (props: Props) => {
return firstHorizontalRuleIndex !== -1 ? content.slice(0, firstHorizontalRuleIndex) : content;
}, [content]);
const { t } = useTranslation();
const user = useAppSelector((state) => state.user.user);
const userStore = useUserStore();
const user = userStore.state.user;
const [state, setState] = useState<State>({
expandButtonStatus: -1,
@@ -65,7 +66,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 +85,9 @@ const MemoContent: React.FC<Props> = (props: Props) => {
setState({
expandButtonStatus: Number(expandButtonStatus) as ExpandButtonStatus,
});
if (!expandButtonStatus) {
memoContentContainerRef.current?.scrollIntoView();
}
};
return (

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,29 @@ const setEditingMemoVisibilityCache = (visibility: Visibility) => {
interface State {
fullscreen: boolean;
isUploadingResource: boolean;
shouldShowEmojiPicker: 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,
});
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 tags = tagStore.state.tags;
const memoVisibilityOptionSelectorItems = VISIBILITY_SELECTOR_ITEMS.map((item) => {
return {
value: item.value,
@@ -63,22 +70,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 +104,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 +126,144 @@ 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));
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;
}
}
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 +278,7 @@ const MemoEditor = () => {
let resource = undefined;
try {
resource = await resourceService.upload(file);
resource = await resourceStore.upload(file);
} catch (error: any) {
console.error(error);
toastHelper.error(error.response.data.message);
@@ -215,39 +301,46 @@ const MemoEditor = () => {
}
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);
}
// 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 {
...state,
fullscreen: false,
};
});
editorStateService.clearResourceList();
editorStore.clearResourceList();
setEditorContentCache("");
storage.remove(["editingMemoVisibilityCache"]);
editorRef.current?.setContent("");
@@ -255,8 +348,8 @@ const MemoEditor = () => {
const handleCancelEdit = () => {
if (editorState.editMemoId) {
editorStateService.clearEditMemo();
editorStateService.clearResourceList();
editorStore.clearEditMemo();
editorStore.clearResourceList();
editorRef.current?.setContent("");
setEditorContentCache("");
storage.remove(["editingMemoVisibilityCache"]);
@@ -320,7 +413,7 @@ const MemoEditor = () => {
}
}
}
editorStateService.setResourceList([...editorState.resourceList, ...resourceList]);
editorStore.setResourceList([...editorState.resourceList, ...resourceList]);
document.body.removeChild(inputEl);
};
inputEl.click();
@@ -343,7 +436,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 +444,7 @@ const MemoEditor = () => {
const handleMemoVisibilityOptionChanged = async (value: string) => {
const visibilityValue = value as Visibility;
editorStateService.setMemoVisibility(visibilityValue);
editorStore.setMemoVisibility(visibilityValue);
setEditingMemoVisibilityCache(visibilityValue);
};
@@ -360,7 +453,7 @@ const MemoEditor = () => {
};
const handleEditorBlur = () => {
// do nth
// do nothing
};
const isEditing = Boolean(editorState.editMemoId && editorState.editMemoId !== UNKNOWN_ID);
@@ -458,7 +551,7 @@ const MemoEditor = () => {
</button>
<button className="action-btn confirm-btn" disabled={!allowSave || state.isUploadingResource} 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>

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}

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,18 @@ 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 +87,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) {
@@ -118,7 +121,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 {

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

View File

@@ -20,14 +20,16 @@ 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);
const handleCloseBtnClick = () => {
destroy();
@@ -43,12 +45,14 @@ const PreviewImageDialog: React.FC<Props> = ({ destroy, imgUrls, initialIndex }:
const handleImgContainerClick = (event: React.MouseEvent) => {
if (event.clientX < window.innerWidth / 2) {
if (currentIndex > 0) {
setState(defaultState);
setCurrentIndex(currentIndex - 1);
} else {
destroy();
}
} else {
if (currentIndex < imgUrls.length - 1) {
setState(defaultState);
setCurrentIndex(currentIndex + 1);
} else {
destroy();
@@ -120,6 +124,7 @@ export default function showPreviewImageDialog(imgUrls: string[] | string, initi
generateDialog(
{
className: "preview-image-dialog",
dialogName: "preview-image-dialog",
},
PreviewImageDialog,
{

View File

@@ -3,8 +3,7 @@ import copy from "copy-to-clipboard";
import { useCallback, useEffect, useState } 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 Icon from "./Icon";
import toastHelper from "./Toast";
import Dropdown from "./common/Dropdown";
@@ -24,13 +23,14 @@ const ResourcesDialog: React.FC<Props> = (props: Props) => {
const { destroy } = props;
const { t } = useTranslation();
const loadingState = useLoading();
const { resources } = useAppSelector((state) => state.resource);
const resourceStore = useResourceStore();
const resources = resourceStore.state.resources;
const [state, setState] = useState<State>({
isUploadingResource: false,
});
useEffect(() => {
resourceService
resourceStore
.fetchResourceList()
.catch((error) => {
console.error(error);
@@ -66,7 +66,7 @@ const ResourcesDialog: React.FC<Props> = (props: Props) => {
for (const file of inputEl.files) {
try {
await resourceService.upload(file);
await resourceStore.upload(file);
} catch (error: any) {
console.error(error);
toastHelper.error(error.response.data.message);
@@ -105,7 +105,7 @@ 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");
toastHelper.success(t("message.succeed-copy-resource-link"));
};
const handleDeleteUnusedResourcesBtnClick = () => {
@@ -125,9 +125,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 +144,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,10 +154,7 @@ 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>
@@ -183,7 +182,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 ? (
@@ -242,6 +241,7 @@ export default function showResourcesDialog() {
generateDialog(
{
className: "resources-dialog",
dialogName: "resources-dialog",
},
ResourcesDialog,
{}

View File

@@ -2,8 +2,7 @@ import { Checkbox, Tooltip } from "@mui/joy";
import { useCallback, 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 Icon from "./Icon";
import toastHelper from "./Toast";
import { generateDialog } from "./Dialog";
@@ -20,14 +19,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 +39,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);
@@ -75,17 +75,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 +96,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 +145,7 @@ export default function showResourcesSelectorDialog() {
generateDialog(
{
className: "resources-selector-dialog",
dialogName: "resources-selector-dialog",
},
ResourcesSelectorDialog,
{}

View File

@@ -1,37 +1,69 @@
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 { 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 inputRef = useRef<HTMLInputElement>(null);
const [isFocus, setIsFocus] = useState(false);
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]);
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);
locationStore.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 +71,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">

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

View File

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

View File

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

View File

@@ -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,26 @@ 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("enableFoldMemo", event.target.checked);
};
return (
@@ -77,26 +53,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">

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>

View File

@@ -4,10 +4,10 @@ import { useTranslation } from "react-i18next";
import copy from "copy-to-clipboard";
import { toLower } from "lodash";
import toImage from "../labs/html2image";
import { 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 +29,9 @@ 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 user = userStore.state.user as User;
const [state, setState] = useState<State>({
memoAmount: 0,
memoVisibility: propsMemo.visibility,
@@ -113,7 +115,7 @@ const ShareMemoDialog: React.FC<Props> = (props: Props) => {
...state,
memoVisibility: visibilityValue,
});
await memoService.patchMemo({
await memoStore.patchMemo({
id: memo.id,
visibility: visibilityValue,
});
@@ -122,10 +124,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>
@@ -145,7 +144,7 @@ const ShareMemoDialog: React.FC<Props> = (props: Props) => {
{state.memoAmount} MEMOS / {createdDays} DAYS
</span>
</div>
<img className="logo-img" src="/logo.webp" alt="" />
<img className="logo-img" src="/logo.png" alt="" />
</div>
</div>
<div className="px-4 py-3 w-full flex flex-row justify-between items-center">
@@ -188,6 +187,7 @@ export default function showShareMemoDialog(memo: Memo): void {
generateDialog(
{
className: "share-memo-dialog",
dialogName: "share-memo-dialog",
},
ShareMemoDialog,
{ memo }

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
}

View File

@@ -2,8 +2,7 @@ import { isUndefined } from "lodash-es";
import { useEffect } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { userService } from "../services";
import { useAppSelector } from "../store";
import { useLocationStore, useUserStore } from "../store/module";
import showDailyReviewDialog from "./DailyReviewDialog";
import showSettingDialog from "./SettingDialog";
import UserBanner from "./UserBanner";
@@ -14,11 +13,13 @@ import "../less/siderbar.less";
const Sidebar = () => {
const { t } = useTranslation();
const location = useAppSelector((state) => state.location);
const userStore = useUserStore();
const locationStore = useLocationStore();
const query = locationStore.state.query;
useEffect(() => {
toggleSidebar(false);
}, [location.query]);
}, [query]);
const handleSettingBtnClick = () => {
showSettingDialog();
@@ -34,7 +35,7 @@ const Sidebar = () => {
<button className="btn action-btn" onClick={() => showDailyReviewDialog()}>
<span className="icon">📅</span> {t("sidebar.daily-review")}
</button>
{!userService.isVisitorMode() && (
{!userStore.isVisitorMode() && (
<>
<Link to="/explore" className="btn action-btn">
<span className="icon">🏂</span> {t("common.explore")}
@@ -45,8 +46,12 @@ const Sidebar = () => {
</>
)}
</div>
{!userService.isVisitorMode() && <ShortcutList />}
<TagList />
{!userStore.isVisitorMode() && (
<>
<ShortcutList />
<TagList />
</>
)}
</aside>
</>
);

View File

@@ -1,9 +1,9 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAppSelector } from "../store";
import { locationService, memoService, userService } from "../services";
import { useLocationStore, useTagStore } from "../store/module";
import useToggle from "../hooks/useToggle";
import Icon from "./Icon";
import showCreateTagDialog from "./CreateTagDialog";
import "../less/tag-list.less";
interface Tag {
@@ -14,15 +14,15 @@ interface Tag {
const TagList = () => {
const { t } = useTranslation();
const { memos, tags: tagsText } = useAppSelector((state) => state.memo);
const query = useAppSelector((state) => state.location.query);
const locationStore = useLocationStore();
const tagStore = useTagStore();
const tagsText = tagStore.state.tags;
const query = locationStore.state.query;
const [tags, setTags] = useState<Tag[]>([]);
useEffect(() => {
if (memos.length > 0) {
memoService.updateTagsState();
}
}, [memos]);
tagStore.fetchTags();
}, []);
useEffect(() => {
const sortedTags = Array.from(tagsText).sort();
@@ -70,12 +70,20 @@ const TagList = () => {
return (
<div className="tags-wrapper">
<p className="title-text">{t("common.tags")}</p>
<div className="w-full flex flex-row justify-start items-center px-4 mb-1">
<span className="text-sm leading-6 font-mono text-gray-400">{t("common.tags")}</span>
<button
onClick={() => showCreateTagDialog()}
className="flex flex-col justify-center items-center w-5 h-5 bg-gray-200 dark:bg-zinc-700 rounded ml-2 hover:shadow"
>
<Icon.Plus className="w-4 h-4 text-gray-400" />
</button>
</div>
<div className="tags-container">
{tags.map((t, idx) => (
<TagItemContainer key={t.text + "-" + idx} tag={t} tagQuery={query?.tag} />
))}
{!userService.isVisitorMode() && tags.length === 0 && <p className="tip-text">{t("tag-list.tip-text")}</p>}
{tags.length <= 3 && <p className="tip-text">{t("tag-list.tip-text")}</p>}
</div>
</div>
);
@@ -87,6 +95,7 @@ interface TagItemContainerProps {
}
const TagItemContainer: React.FC<TagItemContainerProps> = (props: TagItemContainerProps) => {
const locationStore = useLocationStore();
const { tag, tagQuery } = props;
const isActive = tagQuery === tag.text;
const hasSubTags = tag.subTags.length > 0;
@@ -94,9 +103,9 @@ const TagItemContainer: React.FC<TagItemContainerProps> = (props: TagItemContain
const handleTagClick = () => {
if (isActive) {
locationService.setTagQuery(undefined);
locationStore.setTagQuery(undefined);
} else {
locationService.setTagQuery(tag.text);
locationStore.setTagQuery(tag.text);
}
};

View File

@@ -1,12 +1,11 @@
import { isEqual } from "lodash";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAppSelector } from "../store";
import { userService } from "../services";
import { useUserStore } from "../store/module";
import { validate, ValidatorConfig } from "../helpers/validator";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import toastHelper from "./Toast";
import { validate, ValidatorConfig } from "../helpers/validator";
const validateConfig: ValidatorConfig = {
minLength: 4,
@@ -25,7 +24,8 @@ interface State {
const UpdateAccountDialog: React.FC<Props> = ({ destroy }: Props) => {
const { t } = useTranslation();
const user = useAppSelector((state) => state.user.user as User);
const userStore = useUserStore();
const user = userStore.state.user as User;
const [state, setState] = useState<State>({
username: user.username,
nickname: user.nickname,
@@ -78,7 +78,7 @@ const UpdateAccountDialog: React.FC<Props> = ({ destroy }: Props) => {
}
try {
const user = userService.getState().user as User;
const user = userStore.getState().user as User;
const userPatch: UserPatch = {
id: user.id,
};
@@ -91,8 +91,8 @@ const UpdateAccountDialog: React.FC<Props> = ({ destroy }: Props) => {
if (!isEqual(user.email, state.email)) {
userPatch.email = state.email;
}
await userService.patchUser(userPatch);
toastHelper.info("Update succeed");
await userStore.patchUser(userPatch);
toastHelper.info(t("message.update-succeed"));
handleCloseBtnClick();
} catch (error: any) {
console.error(error);
@@ -139,6 +139,7 @@ function showUpdateAccountDialog() {
generateDialog(
{
className: "update-account-dialog",
dialogName: "update-account-dialog",
},
UpdateAccountDialog
);

View File

@@ -0,0 +1,136 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useGlobalStore } from "../store/module";
import * as api from "../helpers/api";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import toastHelper from "./Toast";
import LocaleSelect from "./LocaleSelect";
import AppearanceSelect from "./AppearanceSelect";
type Props = DialogProps;
const UpdateCustomizedProfileDialog: React.FC<Props> = ({ destroy }: Props) => {
const { t } = useTranslation();
const globalStore = useGlobalStore();
const [state, setState] = useState<CustomizedProfile>(globalStore.state.systemStatus.customizedProfile);
useEffect(() => {
// do nth
}, []);
const handleCloseBtnClick = () => {
destroy();
};
const handleNameChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
setState((state) => {
return {
...state,
name: e.target.value as string,
};
});
};
const handleLogoUrlChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
setState((state) => {
return {
...state,
logoUrl: e.target.value as string,
};
});
};
const handleDescriptionChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
setState((state) => {
return {
...state,
description: e.target.value as string,
};
});
};
const handleLocaleSelectChange = (locale: Locale) => {
setState((state) => {
return {
...state,
locale: locale,
};
});
};
const handleAppearanceSelectChange = (appearance: Appearance) => {
setState((state) => {
return {
...state,
appearance: appearance,
};
});
};
const handleSaveBtnClick = async () => {
if (state.name === "" || state.logoUrl === "") {
toastHelper.error(t("message.fill-all"));
return;
}
try {
await api.upsertSystemSetting({
name: "customizedProfile",
value: JSON.stringify(state),
});
await globalStore.fetchSystemStatus();
} catch (error) {
console.error(error);
return;
}
toastHelper.success(t("message.succeed-update-customized-profile"));
destroy();
};
return (
<>
<div className="dialog-header-container">
<p className="title-text">{t("setting.system-section.customize-server.title")}</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<Icon.X />
</button>
</div>
<div className="dialog-content-container !w-80">
<p className="text-sm mb-1">
{t("setting.system-section.server-name")}
<span className="text-sm text-gray-400 ml-1">({t("setting.system-section.customize-server.default")})</span>
</p>
<input type="text" className="input-text" value={state.name} onChange={handleNameChanged} />
<p className="text-sm mb-1 mt-2">{t("setting.system-section.customize-server.icon-url")}</p>
<input type="text" className="input-text" value={state.logoUrl} onChange={handleLogoUrlChanged} />
<p className="text-sm mb-1 mt-2">Description</p>
<input type="text" className="input-text" value={state.description} onChange={handleDescriptionChanged} />
<p className="text-sm mb-1 mt-2">Server locale</p>
<LocaleSelect className="w-full" value={state.locale} onChange={handleLocaleSelectChange} />
<p className="text-sm mb-1 mt-2">Server appearance</p>
<AppearanceSelect className="w-full" value={state.appearance} onChange={handleAppearanceSelectChange} />
<div className="mt-4 w-full flex flex-row justify-end items-center space-x-2">
<span className="btn-text" onClick={handleCloseBtnClick}>
{t("common.cancel")}
</span>
<span className="btn-primary" onClick={handleSaveBtnClick}>
{t("common.save")}
</span>
</div>
</div>
</>
);
};
function showUpdateCustomizedProfileDialog() {
generateDialog(
{
className: "update-customized-profile-dialog",
dialogName: "update-customized-profile-dialog",
},
UpdateCustomizedProfileDialog
);
}
export default showUpdateCustomizedProfileDialog;

View File

@@ -1,9 +1,9 @@
import { useEffect, useState } from "react";
import { useAppSelector } from "../store";
import * as api from "../helpers/api";
import * as storage from "../helpers/storage";
import Icon from "./Icon";
import "../less/about-site-dialog.less";
import { useGlobalStore } from "../store/module";
interface State {
latestVersion: string;
@@ -11,7 +11,8 @@ interface State {
}
const UpdateVersionBanner: React.FC = () => {
const profile = useAppSelector((state) => state.global.systemStatus.profile);
const globalStore = useGlobalStore();
const profile = globalStore.state.systemStatus.profile;
const [state, setState] = useState<State>({
latestVersion: "",
show: false,

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useAppSelector } from "../store";
import { locationService, userService } from "../services";
import { useLocationStore, useMemoStore, useUserStore } from "../store/module";
import { useTranslation } from "react-i18next";
import { getMemoStats } from "../helpers/api";
import { DAILY_TIMESTAMP } from "../helpers/consts";
import * as utils from "../helpers/utils";
@@ -28,19 +28,22 @@ interface DailyUsageStat {
}
const UsageHeatMap = () => {
const { t } = useTranslation();
const locationStore = useLocationStore();
const userStore = useUserStore();
const memoStore = useMemoStore();
const todayTimeStamp = utils.getDateStampByDate(Date.now());
const todayDay = new Date(todayTimeStamp).getDay() + 1;
const nullCell = new Array(7 - todayDay).fill(0);
const usedDaysAmount = (tableConfig.width - 1) * tableConfig.height + todayDay;
const beginDayTimestamp = todayTimeStamp - usedDaysAmount * DAILY_TIMESTAMP;
const { memos } = useAppSelector((state) => state.memo);
const memos = memoStore.state.memos;
const [allStat, setAllStat] = useState<DailyUsageStat[]>(getInitialUsageStat(usedDaysAmount, beginDayTimestamp));
const [currentStat, setCurrentStat] = useState<DailyUsageStat | null>(null);
const containerElRef = useRef<HTMLDivElement>(null);
useEffect(() => {
getMemoStats(userService.getCurrentUserId())
getMemoStats(userStore.getCurrentUserId())
.then(({ data: { data } }) => {
const newStat: DailyUsageStat[] = getInitialUsageStat(usedDaysAmount, beginDayTimestamp);
for (const record of data) {
@@ -58,6 +61,10 @@ const UsageHeatMap = () => {
.catch((error) => {
console.error(error);
});
return () => {
handleUsageStatItemMouseLeave();
};
}, [memos.length]);
const handleUsageStatItemMouseEnter = useCallback((event: React.MouseEvent, item: DailyUsageStat) => {
@@ -80,11 +87,11 @@ const UsageHeatMap = () => {
}, []);
const handleUsageStatItemClick = useCallback((item: DailyUsageStat) => {
if (locationService.getState().query?.duration?.from === item.timestamp) {
locationService.setFromAndToQuery();
if (locationStore.getState().query?.duration?.from === item.timestamp) {
locationStore.setFromAndToQuery();
setCurrentStat(null);
} else if (item.count > 0) {
locationService.setFromAndToQuery(item.timestamp, item.timestamp + DAILY_TIMESTAMP);
locationStore.setFromAndToQuery(item.timestamp, item.timestamp + DAILY_TIMESTAMP);
setCurrentStat(item);
}
}, []);
@@ -92,13 +99,13 @@ const UsageHeatMap = () => {
return (
<div className="usage-heat-map-wrapper" ref={containerElRef}>
<div className="day-tip-text-container">
<span className="tip-text">Sun</span>
<span className="tip-text">{t("days.sun")}</span>
<span className="tip-text"></span>
<span className="tip-text">Tue</span>
<span className="tip-text">{t("days.tue")}</span>
<span className="tip-text"></span>
<span className="tip-text">Thu</span>
<span className="tip-text">{t("days.thu")}</span>
<span className="tip-text"></span>
<span className="tip-text">Sat</span>
<span className="tip-text">{t("days.sat")}</span>
</div>
<div className="usage-heat-map">
{allStat.map((v, i) => {

View File

@@ -1,11 +1,9 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useLocationStore, useMemoStore, useTagStore, useUserStore } from "../store/module";
import { getMemoStats } from "../helpers/api";
import * as utils from "../helpers/utils";
import userService from "../services/userService";
import { locationService } from "../services";
import { useAppSelector } from "../store";
import Icon from "./Icon";
import Dropdown from "./common/Dropdown";
import showResourcesDialog from "./ResourcesDialog";
@@ -16,12 +14,17 @@ import "../less/user-banner.less";
const UserBanner = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { user, owner } = useAppSelector((state) => state.user);
const { memos, tags } = useAppSelector((state) => state.memo);
const locationStore = useLocationStore();
const userStore = useUserStore();
const memoStore = useMemoStore();
const tagStore = useTagStore();
const { user, owner } = userStore.state;
const { memos } = memoStore.state;
const tags = tagStore.state.tags;
const [username, setUsername] = useState("Memos");
const [memoAmount, setMemoAmount] = useState(0);
const [createdDays, setCreatedDays] = useState(0);
const isVisitorMode = userService.isVisitorMode();
const isVisitorMode = userStore.isVisitorMode();
useEffect(() => {
if (isVisitorMode) {
@@ -37,7 +40,7 @@ const UserBanner = () => {
}, [isVisitorMode, user, owner]);
useEffect(() => {
getMemoStats(userService.getCurrentUserId())
getMemoStats(userStore.getCurrentUserId())
.then(({ data: { data } }) => {
setMemoAmount(data.length);
})
@@ -47,7 +50,7 @@ const UserBanner = () => {
}, [memos]);
const handleUsernameClick = useCallback(() => {
locationService.clearQuery();
locationStore.clearQuery();
}, []);
const handleResourcesBtnClick = () => {
@@ -78,7 +81,7 @@ const UserBanner = () => {
actionsClassName="min-w-36"
actions={
<>
{!userService.isVisitorMode() && (
{!userStore.isVisitorMode() && (
<>
<button
className="w-full px-3 whitespace-nowrap text-left leading-10 cursor-pointer rounded dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-zinc-800"
@@ -100,7 +103,7 @@ const UserBanner = () => {
>
<span className="mr-1">🤠</span> {t("common.about")}
</button>
{!userService.isVisitorMode() && (
{!userStore.isVisitorMode() && (
<button
className="w-full px-3 whitespace-nowrap text-left leading-10 cursor-pointer rounded dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-zinc-800"
onClick={handleSignOutBtnClick}
@@ -115,15 +118,15 @@ const UserBanner = () => {
<div className="amount-text-container">
<div className="status-text memos-text">
<span className="amount-text">{memoAmount}</span>
<span className="type-text">{t("amount-text.memo")}</span>
<span className="type-text">{t("amount-text.memo", { count: memoAmount })}</span>
</div>
<div className="status-text tags-text">
<span className="amount-text">{tags.length}</span>
<span className="type-text">{t("amount-text.tag")}</span>
<span className="type-text">{t("amount-text.tag", { count: tags.length })}</span>
</div>
<div className="status-text duration-text">
<span className="amount-text">{createdDays}</span>
<span className="type-text">{t("amount-text.day")}</span>
<span className="type-text">{t("amount-text.day", { count: createdDays })}</span>
</div>
</div>
</>

View File

@@ -17,7 +17,7 @@ interface Props {
}
const nullItem = {
text: "Select",
text: "common.select",
value: "",
};
@@ -28,7 +28,7 @@ const Selector: React.FC<Props> = (props: Props) => {
const selectorElRef = useRef<HTMLDivElement>(null);
let currentItem = nullItem;
let currentItem = { text: t(nullItem.text), value: nullItem.value };
for (const d of dataSource) {
if (d.value === value) {
currentItem = d;

View File

@@ -17,6 +17,11 @@
overflow-wrap: anywhere;
word-break: normal;
}
@media screen and (min-width: 1024px) {
.ml-calc {
margin-left: calc(100vw - 100%);
}
}
}
@layer components {

View File

@@ -187,6 +187,20 @@ export function getTagList(tagFind?: TagFind) {
return axios.get<ResponseObject<string[]>>(`/api/tag?${queryList.join("&")}`);
}
export function getTagSuggestionList() {
return axios.get<ResponseObject<string[]>>(`/api/tag/suggestion`);
}
export function upsertTag(tagName: string) {
return axios.post<ResponseObject<string>>(`/api/tag`, {
name: tagName,
});
}
export function deleteTag(tagName: string) {
return axios.delete<ResponseObject<string>>(`/api/tag/${tagName}`);
}
export async function getRepoStarCount() {
const { data } = await axios.get(`https://api.github.com/repos/usememos/memos`, {
headers: {

View File

@@ -18,4 +18,8 @@ export const MEMO_DISPLAY_TS_OPTION_SELECTOR_ITEMS = [
{ text: "updated_ts", value: "updated_ts" },
];
// space width for tab action in editor
export const TAB_SPACE_WIDTH = 2;
// default fetch memo amount
export const DEFAULT_MEMO_LIMIT = 30;

View File

@@ -8,7 +8,7 @@ export const relationConsts = [
export const filterConsts = {
TAG: {
text: "Tag",
text: "filter.type.tag",
value: "TAG",
operators: [
{
@@ -22,7 +22,7 @@ export const filterConsts = {
],
},
TYPE: {
text: "Type",
text: "filter.type.type",
value: "TYPE",
operators: [
{
@@ -46,7 +46,7 @@ export const filterConsts = {
],
},
TEXT: {
text: "Text",
text: "filter.type.text",
value: "TEXT",
operators: [
{
@@ -60,7 +60,7 @@ export const filterConsts = {
],
},
DISPLAY_TIME: {
text: "Display Time",
text: "filter.type.display-time",
value: "DISPLAY_TIME",
operators: [
{
@@ -74,7 +74,7 @@ export const filterConsts = {
],
},
VISIBILITY: {
text: "Visibility",
text: "filter.type.visibility",
value: "VISIBILITY",
operators: [
{

View File

@@ -1,15 +0,0 @@
import { useCallback, useState } from "react";
const useRefresh = () => {
const [, setBoolean] = useState<boolean>(false);
const refresh = useCallback(() => {
setBoolean((ps) => {
return !ps;
});
}, []);
return refresh;
};
export default useRefresh;

View File

@@ -7,6 +7,7 @@ import frLocale from "./locales/fr.json";
import nlLocale from "./locales/nl.json";
import svLocale from "./locales/sv.json";
import deLocale from "./locales/de.json";
import esLocale from "./locales/es.json";
i18n.use(initReactI18next).init({
resources: {
@@ -31,6 +32,9 @@ i18n.use(initReactI18next).init({
de: {
translation: deLocale,
},
es: {
translation: esLocale,
},
},
lng: "nl",
fallbackLng: "en",

View File

@@ -1,6 +1,4 @@
const escapeRegExp = (str: string): string => {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
};
import { escape } from "lodash";
const walkthroughNodeWithKeyword = (node: HTMLElement, keyword: string) => {
if (node.nodeType === 3) {
@@ -19,8 +17,8 @@ export const highlightWithWord = (html: string, keyword?: string): string => {
if (!keyword) {
return html;
}
keyword = escapeRegExp(keyword);
keyword = escape(keyword);
const wrap = document.createElement("div");
wrap.innerHTML = html;
wrap.innerHTML = escape(html);
return walkthroughNodeWithKeyword(wrap, keyword);
};

View File

@@ -1,25 +1,22 @@
import { blockElementParserList, inlineElementParserList } from "./parser";
const match = (rawStr: string, regex: RegExp): number => {
const matchResult = rawStr.match(regex);
if (!matchResult) {
return 0;
}
const matchStr = matchResult[0];
return matchStr.length;
};
export const marked = (markdownStr: string, blockParsers = blockElementParserList, inlineParsers = inlineElementParserList): string => {
for (const parser of blockParsers) {
const startIndex = markdownStr.search(parser.regex);
const matchedLength = match(markdownStr, parser.regex);
const matchResult = parser.matcher(markdownStr);
if (!matchResult) {
continue;
}
const matchedStr = matchResult[0];
const retainContent = markdownStr.slice(matchedStr.length);
if (startIndex > -1 && matchedLength > 0) {
const prefixStr = markdownStr.slice(0, startIndex);
const matchedStr = markdownStr.slice(startIndex, startIndex + matchedLength);
const suffixStr = markdownStr.slice(startIndex + matchedLength);
return marked(prefixStr, blockParsers, inlineParsers) + parser.renderer(matchedStr) + marked(suffixStr, blockParsers, inlineParsers);
if (parser.name === "br") {
return parser.renderer(matchedStr) + marked(retainContent, blockParsers, inlineParsers);
} else {
if (retainContent === "") {
return parser.renderer(matchedStr);
} else if (retainContent.startsWith("\n")) {
return parser.renderer(matchedStr) + marked(retainContent.slice(1), blockParsers, inlineParsers);
}
}
}
@@ -27,28 +24,104 @@ export const marked = (markdownStr: string, blockParsers = blockElementParserLis
let matchedIndex = -1;
for (const parser of inlineParsers) {
const matchResult = parser.matcher(markdownStr);
if (!matchResult) {
continue;
}
if (parser.name === "plain text" && matchedInlineParser !== undefined) {
continue;
}
const startIndex = markdownStr.search(parser.regex);
const matchedLength = match(markdownStr, parser.regex);
if (startIndex > -1 && matchedLength > 0) {
if (!matchedInlineParser || matchedIndex > startIndex) {
matchedIndex = startIndex;
matchedInlineParser = parser;
}
const startIndex = matchResult.index as number;
if (matchedInlineParser === undefined || matchedIndex > startIndex) {
matchedInlineParser = parser;
matchedIndex = startIndex;
}
}
if (matchedInlineParser) {
const matchedLength = match(markdownStr, matchedInlineParser.regex);
const prefixStr = markdownStr.slice(0, matchedIndex);
const matchedStr = markdownStr.slice(matchedIndex, matchedIndex + matchedLength);
const suffixStr = markdownStr.slice(matchedIndex + matchedLength);
return prefixStr + matchedInlineParser.renderer(matchedStr) + marked(suffixStr, [], inlineParsers);
const matchResult = matchedInlineParser.matcher(markdownStr);
if (matchResult) {
const matchedStr = matchResult[0];
const matchedLength = matchedStr.length;
const prefixStr = markdownStr.slice(0, matchedIndex);
const suffixStr = markdownStr.slice(matchedIndex + matchedLength);
return prefixStr + matchedInlineParser.renderer(matchedStr) + marked(suffixStr, [], inlineParsers);
}
}
return markdownStr;
};
interface MatchedNode {
parserName: string;
matchedContent: string;
}
export const getMatchedNodes = (markdownStr: string): MatchedNode[] => {
const matchedNodeList: MatchedNode[] = [];
const walkthough = (markdownStr: string, blockParsers = blockElementParserList, inlineParsers = inlineElementParserList): string => {
for (const parser of blockParsers) {
const matchResult = parser.matcher(markdownStr);
if (!matchResult) {
continue;
}
const matchedStr = matchResult[0];
const retainContent = markdownStr.slice(matchedStr.length);
matchedNodeList.push({
parserName: parser.name,
matchedContent: matchedStr,
});
if (parser.name === "br") {
return walkthough(retainContent, blockParsers, inlineParsers);
} else {
if (retainContent.startsWith("\n")) {
return walkthough(retainContent.slice(1), blockParsers, inlineParsers);
}
}
}
let matchedInlineParser = undefined;
let matchedIndex = -1;
for (const parser of inlineParsers) {
const matchResult = parser.matcher(markdownStr);
if (!matchResult) {
continue;
}
if (parser.name === "plain text" && matchedInlineParser !== undefined) {
continue;
}
const startIndex = matchResult.index as number;
if (matchedInlineParser === undefined || matchedIndex > startIndex) {
matchedInlineParser = parser;
matchedIndex = startIndex;
}
}
if (matchedInlineParser) {
const matchResult = matchedInlineParser.matcher(markdownStr);
if (matchResult) {
const matchedStr = matchResult[0];
const matchedLength = matchedStr.length;
const suffixStr = markdownStr.slice(matchedIndex + matchedLength);
matchedNodeList.push({
parserName: matchedInlineParser.name,
matchedContent: matchedStr,
});
return walkthough(suffixStr, [], inlineParsers);
}
}
return markdownStr;
};
walkthough(markdownStr);
return matchedNodeList;
};

View File

@@ -7,20 +7,13 @@ describe("test marked parser", () => {
test("horizontal rule", () => {
const tests = [
{
markdown: `To create a horizontal rule, use three or more asterisks (***), dashes (---), or underscores (___) on a line by themselves.
---
markdown: `---
This is some text after the horizontal rule.
___
This is some text after the horizontal rule.
***
This is some text after the horizontal rule.`,
want: `<p>To create a horizontal rule, use three or more asterisks (<em>*</em>), dashes (---), or underscores (___) on a line by themselves.</p>
<hr>
<p>This is some text after the horizontal rule.</p>
<hr>
<p>This is some text after the horizontal rule.</p>
<hr>
<p>This is some text after the horizontal rule.</p>`,
want: `<hr><p>This is some text after the horizontal rule.</p><hr><p>This is some text after the horizontal rule.</p><hr><p>This is some text after the horizontal rule.</p>`,
},
];
for (const t of tests) {
@@ -42,9 +35,7 @@ hello world!
\`\`\`js
console.log("hello world!")
\`\`\``,
want: `<p>test code block</p>
<p></p>
<pre><code class="language-js"><span class="hljs-variable language_">console</span>.<span class="hljs-title function_">log</span>(<span class="hljs-string">"hello world!"</span>)
want: `<p>test code block</p><br><pre><code class="language-js"><span class="hljs-variable language_">console</span>.<span class="hljs-title function_">log</span>(<span class="hljs-string">"hello world!"</span>)
</code></pre>`,
},
];
@@ -59,9 +50,7 @@ console.log("hello world!")
markdown: `My task:
- [ ] finish my homework
- [x] yahaha`,
want: `<p>My task:</p>
<p class='li-container'><span class='todo-block todo' data-value='TODO'></span>finish my homework</p>
<p class='li-container'><span class='todo-block done' data-value='DONE'>✓</span>yahaha</p>`,
want: `<p>My task:</p><p class='li-container'><span class='todo-block todo' data-value='TODO'></span><span>finish my homework</span></p><p class='li-container'><span class='todo-block done' data-value='DONE'>✓</span><span>yahaha</span></p>`,
},
];
@@ -75,9 +64,7 @@ console.log("hello world!")
markdown: `This is a list
* list 123
1. 123123`,
want: `<p>This is a list</p>
<p class='li-container'><span class='ul-block'>•</span>list 123</p>
<p class='li-container'><span class='ol-block'>1.</span>123123</p>`,
want: `<p>This is a list</p><p class='li-container'><span class='ul-block'>•</span><span>list 123</span></p><p class='li-container'><span class='ol-block'>1.</span><span>123123</span></p>`,
},
];
@@ -88,8 +75,8 @@ console.log("hello world!")
test("parse inline element", () => {
const tests = [
{
markdown: `Link: [baidu](https://baidu.com)`,
want: `<p>Link: <a class='link' target='_blank' rel='noreferrer' href='https://baidu.com'>baidu</a></p>`,
markdown: `Link: [baidu](https://baidu.com#1231)`,
want: `<p>Link: <a class='link' target='_blank' rel='noreferrer' href='https://baidu.com#1231'>baidu</a></p>`,
},
];
@@ -112,8 +99,8 @@ console.log("hello world!")
test("parse plain link", () => {
const tests = [
{
markdown: `Link:https://baidu.com`,
want: `<p>Link:<a class='link' target='_blank' rel='noreferrer' href='https://baidu.com'>https://baidu.com</a></p>`,
markdown: `Link:https://baidu.com#1231`,
want: `<p>Link:<a class='link' target='_blank' rel='noreferrer' href='https://baidu.com#1231'>https://baidu.com#1231</a></p>`,
},
];
@@ -162,8 +149,22 @@ console.log("hello world!")
{
markdown: `  line1
  line2`,
want: `<p>  line1</p>
<p>  line2</p>`,
want: `<p>  line1</p><p>  line2</p>`,
},
];
for (const t of tests) {
expect(unescape(marked(t.markdown))).toBe(t.want);
}
});
test("parse heading", () => {
const tests = [
{
markdown: `# 123 `,
want: `<h1>123 </h1>`,
},
{
markdown: `## 123 `,
want: `<h2>123 </h2>`,
},
];
for (const t of tests) {

View File

@@ -1,18 +1,24 @@
import { escape } from "lodash";
export const BLOCKQUOTE_REG = /^>\s+(.+)(\n?)/;
export const BLOCKQUOTE_REG = /^> ([^\n]+)/;
const matcher = (rawStr: string) => {
const matchResult = rawStr.match(BLOCKQUOTE_REG);
return matchResult;
};
const renderer = (rawStr: string): string => {
const matchResult = rawStr.match(BLOCKQUOTE_REG);
const matchResult = matcher(rawStr);
if (!matchResult) {
return rawStr;
}
return `<blockquote>${escape(matchResult[1])}</blockquote>${matchResult[2]}`;
return `<blockquote>${escape(matchResult[1])}</blockquote>`;
};
export default {
name: "blockquote",
regex: BLOCKQUOTE_REG,
matcher,
renderer,
};

View File

@@ -1,20 +1,27 @@
import { escape } from "lodash";
import { marked } from "..";
import Link from "./Link";
export const BOLD_REG = /\*\*(.+?)\*\*/;
const renderer = (rawStr: string): string => {
const matcher = (rawStr: string) => {
const matchResult = rawStr.match(BOLD_REG);
return matchResult;
};
const renderer = (rawStr: string): string => {
const matchResult = matcher(rawStr);
if (!matchResult) {
return rawStr;
}
const parsedContent = marked(matchResult[1], [], [Link]);
const parsedContent = marked(escape(matchResult[1]), [], [Link]);
return `<strong>${parsedContent}</strong>`;
};
export default {
name: "bold",
regex: BOLD_REG,
matcher,
renderer,
};

View File

@@ -1,20 +1,27 @@
import { escape } from "lodash";
import { marked } from "..";
import Link from "./Link";
export const BOLD_EMPHASIS_REG = /\*\*\*(.+?)\*\*\*/;
const renderer = (rawStr: string): string => {
const matcher = (rawStr: string) => {
const matchResult = rawStr.match(BOLD_EMPHASIS_REG);
return matchResult;
};
const renderer = (rawStr: string): string => {
const matchResult = matcher(rawStr);
if (!matchResult) {
return rawStr;
}
const parsedContent = marked(matchResult[1], [], [Link]);
const parsedContent = marked(escape(matchResult[1]), [], [Link]);
return `<strong><em>${parsedContent}</em></strong>`;
};
export default {
name: "bold emphasis",
regex: BOLD_EMPHASIS_REG,
matcher,
renderer,
};

View File

@@ -0,0 +1,17 @@
export const BR_REG = /^(\n+)/;
const matcher = (rawStr: string) => {
const matchResult = rawStr.match(BR_REG);
return matchResult;
};
const renderer = (rawStr: string): string => {
return rawStr.replaceAll("\n", "<br>");
};
export default {
name: "br",
regex: BR_REG,
matcher,
renderer,
};

View File

@@ -1,10 +1,15 @@
import { escape } from "lodash-es";
import hljs from "highlight.js";
export const CODE_BLOCK_REG = /^```(\S*?)\s([\s\S]*?)```(\n?)/;
export const CODE_BLOCK_REG = /^```(\S*?)\s([\s\S]*?)```/;
const matcher = (rawStr: string) => {
const matchResult = rawStr.match(CODE_BLOCK_REG);
return matchResult;
};
const renderer = (rawStr: string): string => {
const matchResult = rawStr.match(CODE_BLOCK_REG);
const matchResult = matcher(rawStr);
if (!matchResult) {
return rawStr;
}
@@ -21,11 +26,12 @@ const renderer = (rawStr: string): string => {
// do nth
}
return `<pre><code class="language-${language}">${highlightedCode}</code></pre>${matchResult[3]}`;
return `<pre><code class="language-${language}">${highlightedCode}</code></pre>`;
};
export default {
name: "code block",
regex: CODE_BLOCK_REG,
matcher,
renderer,
};

View File

@@ -1,20 +1,26 @@
import { inlineElementParserList } from ".";
import { marked } from "..";
export const DONE_LIST_REG = /^- \[[xX]\] (.+)(\n?)/;
export const DONE_LIST_REG = /^- \[[xX]\] ([^\n]+)/;
const matcher = (rawStr: string) => {
const matchResult = rawStr.match(DONE_LIST_REG);
return matchResult;
};
const renderer = (rawStr: string): string => {
const matchResult = rawStr.match(DONE_LIST_REG);
const matchResult = matcher(rawStr);
if (!matchResult) {
return rawStr;
}
const parsedContent = marked(matchResult[1], [], inlineElementParserList);
return `<p class='li-container'><span class='todo-block done' data-value='DONE'>✓</span>${parsedContent}</p>${matchResult[2]}`;
return `<p class='li-container'><span class='todo-block done' data-value='DONE'>✓</span><span>${parsedContent}</span></p>`;
};
export default {
name: "done list",
regex: DONE_LIST_REG,
matcher,
renderer,
};

View File

@@ -1,20 +1,27 @@
import { escape } from "lodash";
import { marked } from "..";
import Link from "./Link";
export const EMPHASIS_REG = /\*(.+?)\*/;
const renderer = (rawStr: string): string => {
const matcher = (rawStr: string) => {
const matchResult = rawStr.match(EMPHASIS_REG);
return matchResult;
};
const renderer = (rawStr: string): string => {
const matchResult = matcher(rawStr);
if (!matchResult) {
return rawStr;
}
const parsedContent = marked(matchResult[1], [], [Link]);
const parsedContent = marked(escape(matchResult[1]), [], [Link]);
return `<em>${parsedContent}</em>`;
};
export default {
name: "emphasis",
regex: EMPHASIS_REG,
matcher,
renderer,
};

View File

@@ -0,0 +1,25 @@
import { escape } from "lodash";
export const HEADING_REG = /^(#+) ([^\n]+)/;
const matcher = (rawStr: string) => {
const matchResult = rawStr.match(HEADING_REG);
return matchResult;
};
const renderer = (rawStr: string): string => {
const matchResult = matcher(rawStr);
if (!matchResult) {
return rawStr;
}
const level = matchResult[1].length;
return `<h${level}>${escape(matchResult[2])}</h${level}>`;
};
export default {
name: "heading",
regex: HEADING_REG,
matcher,
renderer,
};

View File

@@ -1,15 +1,18 @@
export const HORIZONTAL_RULES_REG = /^---\n|^\*\*\*\n|^___\n/;
export const HORIZONTAL_RULES_REG = /^_{3}|^-{3}|^\*{3}/;
export const renderer = (rawStr: string): string => {
const matcher = (rawStr: string) => {
const matchResult = rawStr.match(HORIZONTAL_RULES_REG);
if (!matchResult) {
return rawStr;
}
return `<hr>\n`;
return matchResult;
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const renderer = (rawStr: string): string => {
return `<hr>`;
};
export default {
name: "horizontal rules",
regex: HORIZONTAL_RULES_REG,
matcher,
renderer,
};

View File

@@ -3,8 +3,13 @@ import { absolutifyLink } from "../../../helpers/utils";
export const IMAGE_REG = /!\[.*?\]\((.+?)\)/;
const renderer = (rawStr: string): string => {
const matcher = (rawStr: string) => {
const matchResult = rawStr.match(IMAGE_REG);
return matchResult;
};
const renderer = (rawStr: string): string => {
const matchResult = matcher(rawStr);
if (!matchResult) {
return rawStr;
}
@@ -16,5 +21,6 @@ const renderer = (rawStr: string): string => {
export default {
name: "image",
regex: IMAGE_REG,
matcher,
renderer,
};

View File

@@ -2,8 +2,13 @@ import { escape } from "lodash-es";
export const INLINE_CODE_REG = /`(.+?)`/;
const renderer = (rawStr: string): string => {
const matcher = (rawStr: string) => {
const matchResult = rawStr.match(INLINE_CODE_REG);
return matchResult;
};
const renderer = (rawStr: string): string => {
const matchResult = matcher(rawStr);
if (!matchResult) {
return rawStr;
}
@@ -14,5 +19,6 @@ const renderer = (rawStr: string): string => {
export default {
name: "inline code",
regex: INLINE_CODE_REG,
matcher,
renderer,
};

View File

@@ -7,17 +7,23 @@ import BoldEmphasis from "./BoldEmphasis";
export const LINK_REG = /\[(.*?)\]\((.+?)\)+/;
const renderer = (rawStr: string): string => {
const matcher = (rawStr: string) => {
const matchResult = rawStr.match(LINK_REG);
return matchResult;
};
const renderer = (rawStr: string): string => {
const matchResult = matcher(rawStr);
if (!matchResult) {
return rawStr;
}
const parsedContent = marked(matchResult[1], [], [InlineCode, BoldEmphasis, Emphasis, Bold]);
const parsedContent = marked(escape(matchResult[1]), [], [InlineCode, BoldEmphasis, Emphasis, Bold]);
return `<a class='link' target='_blank' rel='noreferrer' href='${escape(matchResult[2])}'>${parsedContent}</a>`;
};
export default {
name: "link",
regex: LINK_REG,
matcher,
renderer,
};

View File

@@ -1,20 +1,26 @@
import { inlineElementParserList } from ".";
import { marked } from "..";
export const ORDERED_LIST_REG = /^(\d+)\. (.+)(\n?)/;
export const ORDERED_LIST_REG = /^(\d+)\. (.+)/;
const matcher = (rawStr: string) => {
const matchResult = rawStr.match(ORDERED_LIST_REG);
return matchResult;
};
const renderer = (rawStr: string): string => {
const matchResult = rawStr.match(ORDERED_LIST_REG);
const matchResult = matcher(rawStr);
if (!matchResult) {
return rawStr;
}
const parsedContent = marked(matchResult[2], [], inlineElementParserList);
return `<p class='li-container'><span class='ol-block'>${matchResult[1]}.</span>${parsedContent}</p>${matchResult[3]}`;
return `<p class='li-container'><span class='ol-block'>${matchResult[1]}.</span><span>${parsedContent}</span></p>`;
};
export default {
name: "ordered list",
regex: ORDERED_LIST_REG,
matcher,
renderer,
};

View File

@@ -1,20 +1,21 @@
import { inlineElementParserList } from ".";
import { marked } from "..";
export const PARAGRAPH_REG = /^(.*)(\n?)/;
export const PARAGRAPH_REG = /^([^\n]+)/;
const matcher = (rawStr: string) => {
const matchResult = rawStr.match(PARAGRAPH_REG);
return matchResult;
};
const renderer = (rawStr: string): string => {
const matchResult = rawStr.match(PARAGRAPH_REG);
if (!matchResult) {
return rawStr;
}
const parsedContent = marked(matchResult[1], [], inlineElementParserList);
return `<p>${parsedContent}</p>${matchResult[2]}`;
const parsedContent = marked(rawStr, [], inlineElementParserList);
return `<p>${parsedContent}</p>`;
};
export default {
name: "paragraph",
regex: PARAGRAPH_REG,
matcher,
renderer,
};

View File

@@ -2,8 +2,13 @@ import { escape } from "lodash-es";
export const PLAIN_LINK_REG = /(https?:\/\/[^ ]+)/;
const renderer = (rawStr: string): string => {
const matcher = (rawStr: string) => {
const matchResult = rawStr.match(PLAIN_LINK_REG);
return matchResult;
};
const renderer = (rawStr: string): string => {
const matchResult = matcher(rawStr);
if (!matchResult) {
return rawStr;
}
@@ -14,5 +19,6 @@ const renderer = (rawStr: string): string => {
export default {
name: "plain link",
regex: PLAIN_LINK_REG,
matcher,
renderer,
};

View File

@@ -2,8 +2,13 @@ import { escape } from "lodash-es";
export const PLAIN_TEXT_REG = /(.+)/;
const renderer = (rawStr: string): string => {
const matcher = (rawStr: string) => {
const matchResult = rawStr.match(PLAIN_TEXT_REG);
return matchResult;
};
const renderer = (rawStr: string): string => {
const matchResult = matcher(rawStr);
if (!matchResult) {
return rawStr;
}
@@ -14,5 +19,6 @@ const renderer = (rawStr: string): string => {
export default {
name: "plain text",
regex: PLAIN_TEXT_REG,
matcher,
renderer,
};

View File

@@ -2,8 +2,13 @@ import { marked } from "..";
export const STRIKETHROUGH_REG = /~~(.+?)~~/;
const renderer = (rawStr: string): string => {
const matcher = (rawStr: string) => {
const matchResult = rawStr.match(STRIKETHROUGH_REG);
return matchResult;
};
const renderer = (rawStr: string): string => {
const matchResult = matcher(rawStr);
if (!matchResult) {
return rawStr;
}
@@ -15,5 +20,6 @@ const renderer = (rawStr: string): string => {
export default {
name: "Strikethrough",
regex: STRIKETHROUGH_REG,
matcher,
renderer,
};

View File

@@ -1,18 +1,27 @@
import { escape } from "lodash-es";
export const TAG_REG = /#([^\s#]+?) /;
export const TAG_REG = /#([^\s#]+)/;
export const matcher = (rawStr: string) => {
const matchResult = rawStr.match(TAG_REG);
if (matchResult) {
return matchResult;
}
return null;
};
const renderer = (rawStr: string): string => {
const matchResult = rawStr.match(TAG_REG);
const matchResult = matcher(rawStr);
if (!matchResult) {
return rawStr;
}
return `<span class='tag-span'>#${escape(matchResult[1])}</span> `;
return `<span class='tag-span'>#${escape(matchResult[1])}</span>`;
};
export default {
name: "tag",
regex: TAG_REG,
matcher,
renderer,
};

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