mirror of
https://github.com/usememos/memos.git
synced 2025-06-05 22:09:59 +02:00
Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0cb01b527 | ||
|
|
ef8981794e | ||
|
|
e52d77b2c4 | ||
|
|
1d2953b1b1 | ||
|
|
d702eaa625 | ||
|
|
50811c3064 | ||
|
|
99d9cc9168 | ||
|
|
119603da5d | ||
|
|
f6039f2eb9 | ||
|
|
65cc19c12e | ||
|
|
c07b4a57ca | ||
|
|
dca35bde87 | ||
|
|
9f25badde3 | ||
|
|
7efa749c66 | ||
|
|
72daa4e1d6 | ||
|
|
54702db9ba | ||
|
|
41ad084489 | ||
|
|
2fb171e069 | ||
|
|
201c0b020d | ||
|
|
b6f19ca093 | ||
|
|
68a77b6e1f | ||
|
|
e4a8a4d708 | ||
|
|
ab07c91d42 | ||
|
|
1838e616fd | ||
|
|
90d0ccc2e8 | ||
|
|
358a5c0ed9 | ||
|
|
40f39fd66c | ||
|
|
3b41976866 | ||
|
|
a23de50bb8 | ||
|
|
6596e6893e | ||
|
|
b6fe4d914e | ||
|
|
3c2cd43d28 | ||
|
|
2658b1fd09 | ||
|
|
b7df1f5bbf | ||
|
|
a0face6695 | ||
|
|
c177db69d5 | ||
|
|
b704c20809 | ||
|
|
6c17f94ef6 | ||
|
|
726285e634 | ||
|
|
bd6ab71d41 | ||
|
|
b67ed1ee13 | ||
|
|
55695f2189 | ||
|
|
e79d67d127 | ||
|
|
1d9ef9813a | ||
|
|
ef621a444f | ||
|
|
bd00fa798d | ||
|
|
a41745c9ae | ||
|
|
1eec474007 | ||
|
|
83e5278b51 | ||
|
|
a8751af6b5 | ||
|
|
6b24f52cd1 | ||
|
|
7ec22482c1 | ||
|
|
ee89dc00c0 | ||
|
|
bbd5fe4eb2 | ||
|
|
575a0610a3 | ||
|
|
b68cc08592 | ||
|
|
d51af7e98a | ||
|
|
334da5e903 | ||
|
|
35fed76d1a | ||
|
|
c77d49259a | ||
|
|
5520605ccc | ||
|
|
1dee8ae49f | ||
|
|
3fd4ee83ac | ||
|
|
5e978e2cfc | ||
|
|
37b7b983d2 | ||
|
|
c4278ef55a | ||
|
|
91220ea4a6 | ||
|
|
4bebbf3e1d | ||
|
|
5d8b8c37a5 | ||
|
|
564f20d13a | ||
|
|
c3adb1b152 | ||
|
|
688dc2304c |
@@ -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 }}
|
||||
|
||||
11
README.md
11
README.md
@@ -5,7 +5,7 @@
|
||||
<p align="center">
|
||||
<a href="https://github.com/usememos/memos/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/usememos/memos" /></a>
|
||||
<a href="https://hub.docker.com/r/neosmemo/memos"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/neosmemo/memos.svg" /></a>
|
||||
<img alt="Go report" src="https://goreportcard.com/badge/github.com/usememos/memos" />
|
||||
<a href="https://discord.gg/tfPJa4UmAv"><img alt="Discord" src="https://img.shields.io/badge/discord-chat-5865f2?logo=discord&logoColor=f5f5f5" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -13,7 +13,7 @@
|
||||
Discuss in <a href="https://t.me/+-_tNF1k70UU4ZTc9">Telegram</a> / <b><a href="https://discord.gg/tfPJa4UmAv">Discord 🏂</a></b>
|
||||
</p>
|
||||
|
||||

|
||||

|
||||
|
||||
## Features
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ type MemoCreate struct {
|
||||
}
|
||||
|
||||
type MemoPatch struct {
|
||||
ID int
|
||||
ID int `json:"-"`
|
||||
|
||||
// Standard fields
|
||||
CreatedTs *int64 `json:"createdTs"`
|
||||
|
||||
@@ -41,7 +41,7 @@ type ResourceFind struct {
|
||||
}
|
||||
|
||||
type ResourcePatch struct {
|
||||
ID int
|
||||
ID int `json:"-"`
|
||||
|
||||
// Standard fields
|
||||
UpdatedTs *int64
|
||||
|
||||
@@ -24,7 +24,7 @@ type ShortcutCreate struct {
|
||||
}
|
||||
|
||||
type ShortcutPatch struct {
|
||||
ID int
|
||||
ID int `json:"-"`
|
||||
|
||||
// Standard fields
|
||||
UpdatedTs *int64
|
||||
|
||||
@@ -14,4 +14,6 @@ type SystemStatus struct {
|
||||
AdditionalStyle string `json:"additionalStyle"`
|
||||
// Additional script.
|
||||
AdditionalScript string `json:"additionalScript"`
|
||||
// Customized server profile, including server name and external url.
|
||||
CustomizedProfile CustomizedProfile `json:"customizedProfile"`
|
||||
}
|
||||
|
||||
@@ -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
20
api/tag.go
Normal 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
|
||||
}
|
||||
@@ -69,7 +69,7 @@ func (create UserCreate) Validate() error {
|
||||
}
|
||||
|
||||
type UserPatch struct {
|
||||
ID int
|
||||
ID int `json:"-"`
|
||||
|
||||
// Standard fields
|
||||
UpdatedTs *int64
|
||||
|
||||
@@ -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
133
docs/deploy-with-render.md
Normal 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)
|
||||

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

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

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

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

|
||||
|
||||

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

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

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

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

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

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

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

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

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

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

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

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

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

|
||||
|
||||

|
||||
|
||||
## 🎉Celebrate!🎉
|
||||
|
||||
You did it! Enjoy using memos!
|
||||
|
||||
Want to learn more or need more guidance? Join the community on [telegram](https://t.me/+-_tNF1k70UU4ZTc9) and [discord](https://discord.gg/tfPJa4UmAv).
|
||||
7
go.mod
7
go.mod
@@ -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
6
go.sum
@@ -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=
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
106
server/tag.go
106
server/tag.go
@@ -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{}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
6
store/db/migration/prod/0.9/00__tag.sql
Normal file
6
store/db/migration/prod/0.9/00__tag.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
-- tag
|
||||
CREATE TABLE tag (
|
||||
name TEXT NOT NULL,
|
||||
creator_id INTEGER NOT NULL,
|
||||
UNIQUE(name, creator_id)
|
||||
);
|
||||
@@ -86,3 +86,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)
|
||||
);
|
||||
|
||||
32
store/db/seed/10006__tag.sql
Normal file
32
store/db/seed/10006__tag.sql
Normal 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
|
||||
);
|
||||
@@ -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
175
store/tag.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
)
|
||||
|
||||
type tagRaw struct {
|
||||
Name string
|
||||
CreatorID int
|
||||
}
|
||||
|
||||
func (raw *tagRaw) toTag() *api.Tag {
|
||||
return &api.Tag{
|
||||
Name: raw.Name,
|
||||
CreatorID: raw.CreatorID,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) UpsertTag(ctx context.Context, upsert *api.TagUpsert) (*api.Tag, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
tagRaw, err := upsertTag(ctx, tx, upsert)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tag := tagRaw.toTag()
|
||||
|
||||
return tag, nil
|
||||
}
|
||||
|
||||
func (s *Store) FindTagList(ctx context.Context, find *api.TagFind) ([]*api.Tag, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
tagRawList, err := findTagList(ctx, tx, find)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
list := []*api.Tag{}
|
||||
for _, raw := range tagRawList {
|
||||
list = append(list, raw.toTag())
|
||||
}
|
||||
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteTag(ctx context.Context, delete *api.TagDelete) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return FormatError(err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if err := deleteTag(ctx, tx, delete); err != nil {
|
||||
return FormatError(err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return FormatError(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func upsertTag(ctx context.Context, tx *sql.Tx, upsert *api.TagUpsert) (*tagRaw, error) {
|
||||
query := `
|
||||
INSERT INTO tag (
|
||||
name, creator_id
|
||||
)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(name, creator_id) DO UPDATE
|
||||
SET
|
||||
name = EXCLUDED.name
|
||||
RETURNING name, creator_id
|
||||
`
|
||||
var tagRaw tagRaw
|
||||
if err := tx.QueryRowContext(ctx, query, upsert.Name, upsert.CreatorID).Scan(
|
||||
&tagRaw.Name,
|
||||
&tagRaw.CreatorID,
|
||||
); err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
|
||||
return &tagRaw, nil
|
||||
}
|
||||
|
||||
func findTagList(ctx context.Context, tx *sql.Tx, find *api.TagFind) ([]*tagRaw, error) {
|
||||
where, args := []string{"creator_id = ?"}, []interface{}{find.CreatorID}
|
||||
|
||||
query := `
|
||||
SELECT
|
||||
name,
|
||||
creator_id
|
||||
FROM tag
|
||||
WHERE ` + strings.Join(where, " AND ")
|
||||
rows, err := tx.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
tagRawList := make([]*tagRaw, 0)
|
||||
for rows.Next() {
|
||||
var tagRaw tagRaw
|
||||
if err := rows.Scan(
|
||||
&tagRaw.Name,
|
||||
&tagRaw.CreatorID,
|
||||
); err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
|
||||
tagRawList = append(tagRawList, &tagRaw)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
|
||||
return tagRawList, nil
|
||||
}
|
||||
|
||||
func deleteTag(ctx context.Context, tx *sql.Tx, delete *api.TagDelete) error {
|
||||
where, args := []string{"name = ?", "creator_id = ?"}, []interface{}{delete.Name, delete.CreatorID}
|
||||
|
||||
stmt := `DELETE FROM tag WHERE ` + strings.Join(where, " AND ")
|
||||
result, err := tx.ExecContext(ctx, stmt, args...)
|
||||
if err != nil {
|
||||
return FormatError(err)
|
||||
}
|
||||
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return &common.Error{Code: common.NotFound, Err: fmt.Errorf("tag not found")}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func vacuumTag(ctx context.Context, tx *sql.Tx) error {
|
||||
stmt := `
|
||||
DELETE FROM
|
||||
tag
|
||||
WHERE
|
||||
creator_id NOT IN (
|
||||
SELECT
|
||||
id
|
||||
FROM
|
||||
user
|
||||
)`
|
||||
_, err := tx.ExecContext(ctx, stmt)
|
||||
if err != nil {
|
||||
return FormatError(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -2,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" />
|
||||
|
||||
@@ -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
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 |
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { Option, Select } from "@mui/joy";
|
||||
import { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { globalService, userService } from "../services";
|
||||
import { useAppSelector } from "../store";
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface Props {
|
||||
value: Appearance;
|
||||
onChange: (appearance: Appearance) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const appearanceList = ["system", "light", "dark"];
|
||||
|
||||
const AppearanceSelect = () => {
|
||||
const user = useAppSelector((state) => state.user.user);
|
||||
const appearance = useAppSelector((state) => state.global.appearance);
|
||||
const AppearanceSelect: FC<Props> = (props: Props) => {
|
||||
const { onChange, value, className } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getPrefixIcon = (apperance: Appearance) => {
|
||||
@@ -23,22 +27,19 @@ const AppearanceSelect = () => {
|
||||
};
|
||||
|
||||
const handleSelectChange = async (appearance: Appearance) => {
|
||||
if (user) {
|
||||
await userService.upsertUserSetting("appearance", appearance);
|
||||
}
|
||||
globalService.setAppearance(appearance);
|
||||
onChange(appearance);
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
className="!min-w-[10rem] w-auto text-sm"
|
||||
value={appearance}
|
||||
className={`!min-w-[10rem] w-auto whitespace-nowrap ${className ?? ""}`}
|
||||
value={value}
|
||||
onChange={(_, appearance) => {
|
||||
if (appearance) {
|
||||
handleSelectChange(appearance);
|
||||
}
|
||||
}}
|
||||
startDecorator={getPrefixIcon(appearance)}
|
||||
startDecorator={getPrefixIcon(value)}
|
||||
>
|
||||
{appearanceList.map((item) => (
|
||||
<Option key={item} value={item} className="whitespace-nowrap">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMemoStore } from "../store/module";
|
||||
import * as utils from "../helpers/utils";
|
||||
import useToggle from "../hooks/useToggle";
|
||||
import { memoService } from "../services";
|
||||
import toastHelper from "./Toast";
|
||||
import MemoContent from "./MemoContent";
|
||||
import MemoResources from "./MemoResources";
|
||||
@@ -14,13 +14,13 @@ interface Props {
|
||||
const ArchivedMemo: React.FC<Props> = (props: Props) => {
|
||||
const { memo } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const memoStore = useMemoStore();
|
||||
const [showConfirmDeleteBtn, toggleConfirmDeleteBtn] = useToggle(false);
|
||||
|
||||
const handleDeleteMemoClick = async () => {
|
||||
if (showConfirmDeleteBtn) {
|
||||
try {
|
||||
await memoService.deleteMemoById(memo.id);
|
||||
await memoStore.deleteMemoById(memo.id);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toastHelper.error(error.response.data.message);
|
||||
@@ -32,11 +32,11 @@ const ArchivedMemo: React.FC<Props> = (props: Props) => {
|
||||
|
||||
const handleRestoreMemoClick = async () => {
|
||||
try {
|
||||
await memoService.patchMemo({
|
||||
await memoStore.patchMemo({
|
||||
id: memo.id,
|
||||
rowStatus: "NORMAL",
|
||||
});
|
||||
await memoService.fetchMemos();
|
||||
await memoStore.fetchMemos();
|
||||
toastHelper.info(t("message.restored-successfully"));
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMemoStore } from "../store/module";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import { memoService } from "../services";
|
||||
import { useAppSelector } from "../store";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import toastHelper from "./Toast";
|
||||
@@ -14,12 +13,13 @@ type Props = DialogProps;
|
||||
const ArchivedMemoDialog: React.FC<Props> = (props: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { destroy } = props;
|
||||
const memos = useAppSelector((state) => state.memo.memos);
|
||||
const memoStore = useMemoStore();
|
||||
const memos = memoStore.state.memos;
|
||||
const loadingState = useLoading();
|
||||
const [archivedMemos, setArchivedMemos] = useState<Memo[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
memoService
|
||||
memoStore
|
||||
.fetchArchivedMemos()
|
||||
.then((result) => {
|
||||
setArchivedMemos(result);
|
||||
@@ -36,10 +36,7 @@ const ArchivedMemoDialog: React.FC<Props> = (props: Props) => {
|
||||
return (
|
||||
<>
|
||||
<div className="dialog-header-container">
|
||||
<p className="title-text">
|
||||
<span className="icon-text">🗂</span>
|
||||
{t("archived.archived-memos")}
|
||||
</p>
|
||||
<p className="title-text">{t("archived.archived-memos")}</p>
|
||||
<button className="btn close-btn" onClick={destroy}>
|
||||
<Icon.X className="icon-img" />
|
||||
</button>
|
||||
@@ -69,6 +66,7 @@ export default function showArchivedMemoDialog(): void {
|
||||
generateDialog(
|
||||
{
|
||||
className: "archived-memo-dialog",
|
||||
dialogName: "archived-memo-dialog",
|
||||
},
|
||||
ArchivedMemoDialog,
|
||||
{}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useUserStore } from "../store/module";
|
||||
import { validate, ValidatorConfig } from "../helpers/validator";
|
||||
import { userService } from "../services";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import toastHelper from "./Toast";
|
||||
@@ -20,6 +20,7 @@ interface Props extends DialogProps {
|
||||
const ChangeMemberPasswordDialog: React.FC<Props> = (props: Props) => {
|
||||
const { user: propsUser, destroy } = props;
|
||||
const { t } = useTranslation();
|
||||
const userStore = useUserStore();
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [newPasswordAgain, setNewPasswordAgain] = useState("");
|
||||
|
||||
@@ -60,7 +61,7 @@ const ChangeMemberPasswordDialog: React.FC<Props> = (props: Props) => {
|
||||
}
|
||||
|
||||
try {
|
||||
await userService.patchUser({
|
||||
await userStore.patchUser({
|
||||
id: propsUser.id,
|
||||
password: newPassword,
|
||||
});
|
||||
@@ -116,6 +117,7 @@ function showChangeMemberPasswordDialog(user: User) {
|
||||
generateDialog(
|
||||
{
|
||||
className: "change-member-password-dialog",
|
||||
dialogName: "change-member-password-dialog",
|
||||
},
|
||||
ChangeMemberPasswordDialog,
|
||||
{ user }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import dayjs from "dayjs";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { memoService } from "../services";
|
||||
import { useMemoStore } from "../store/module";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import toastHelper from "./Toast";
|
||||
@@ -14,11 +14,12 @@ interface Props extends DialogProps {
|
||||
const ChangeMemoCreatedTsDialog: React.FC<Props> = (props: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { destroy, memoId } = props;
|
||||
const memoStore = useMemoStore();
|
||||
const [createdAt, setCreatedAt] = useState("");
|
||||
const maxDatetimeValue = dayjs().format("YYYY-MM-DDTHH:mm");
|
||||
|
||||
useEffect(() => {
|
||||
memoService.getMemoById(memoId).then((memo) => {
|
||||
memoStore.getMemoById(memoId).then((memo) => {
|
||||
if (memo) {
|
||||
const datetime = dayjs(memo.createdTs).format("YYYY-MM-DDTHH:mm");
|
||||
setCreatedAt(datetime);
|
||||
@@ -48,7 +49,7 @@ const ChangeMemoCreatedTsDialog: React.FC<Props> = (props: Props) => {
|
||||
}
|
||||
|
||||
try {
|
||||
await memoService.patchMemo({
|
||||
await memoStore.patchMemo({
|
||||
id: memoId,
|
||||
createdTs,
|
||||
});
|
||||
@@ -96,6 +97,7 @@ function showChangeMemoCreatedTsDialog(memoId: MemoId) {
|
||||
generateDialog(
|
||||
{
|
||||
className: "change-memo-created-ts-dialog",
|
||||
dialogName: "change-memo-created-ts-dialog",
|
||||
},
|
||||
ChangeMemoCreatedTsDialog,
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useUserStore } from "../store/module";
|
||||
import { validate, ValidatorConfig } from "../helpers/validator";
|
||||
import { userService } from "../services";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import toastHelper from "./Toast";
|
||||
@@ -17,6 +17,7 @@ type Props = DialogProps;
|
||||
|
||||
const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const userStore = useUserStore();
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [newPasswordAgain, setNewPasswordAgain] = useState("");
|
||||
|
||||
@@ -57,8 +58,8 @@ const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const user = userService.getState().user as User;
|
||||
await userService.patchUser({
|
||||
const user = userStore.getState().user as User;
|
||||
await userStore.patchUser({
|
||||
id: user.id,
|
||||
password: newPassword,
|
||||
});
|
||||
@@ -114,6 +115,7 @@ function showChangePasswordDialog() {
|
||||
generateDialog(
|
||||
{
|
||||
className: "change-password-dialog",
|
||||
dialogName: "change-password-dialog",
|
||||
},
|
||||
ChangePasswordDialog
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { resourceService } from "../services";
|
||||
import { useResourceStore } from "../store/module";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import toastHelper from "./Toast";
|
||||
@@ -24,8 +24,9 @@ const validateFilename = (filename: string): boolean => {
|
||||
};
|
||||
|
||||
const ChangeResourceFilenameDialog: React.FC<Props> = (props: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { destroy, resourceId, resourceFilename } = props;
|
||||
const { t } = useTranslation();
|
||||
const resourceStore = useResourceStore();
|
||||
const [filename, setFilename] = useState<string>(resourceFilename);
|
||||
|
||||
const handleFilenameChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -47,7 +48,7 @@ const ChangeResourceFilenameDialog: React.FC<Props> = (props: Props) => {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await resourceService.patchResource({
|
||||
await resourceStore.patchResource({
|
||||
id: resourceId,
|
||||
filename: filename,
|
||||
});
|
||||
@@ -86,6 +87,7 @@ function showChangeResourceFilenameDialog(resourceId: ResourceId, resourceFilena
|
||||
generateDialog(
|
||||
{
|
||||
className: "change-resource-filename-dialog",
|
||||
dialogName: "change-resource-filename-dialog",
|
||||
},
|
||||
ChangeResourceFilenameDialog,
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import dayjs from "dayjs";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { memoService, shortcutService } from "../services";
|
||||
import { useShortcutStore, useTagStore } from "../store/module";
|
||||
import { filterConsts, getDefaultFilter, relationConsts } from "../helpers/filter";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import Icon from "./Icon";
|
||||
@@ -16,6 +16,7 @@ interface Props extends DialogProps {
|
||||
|
||||
const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
||||
const { destroy, shortcutId } = props;
|
||||
const shortcutStore = useShortcutStore();
|
||||
const [title, setTitle] = useState<string>("");
|
||||
const [filters, setFilters] = useState<Filter[]>([]);
|
||||
const requestState = useLoading(false);
|
||||
@@ -23,7 +24,7 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (shortcutId) {
|
||||
const shortcutTemp = shortcutService.getShortcutById(shortcutId);
|
||||
const shortcutTemp = shortcutStore.getShortcutById(shortcutId);
|
||||
if (shortcutTemp) {
|
||||
setTitle(shortcutTemp.title);
|
||||
const temp = JSON.parse(shortcutTemp.payload);
|
||||
@@ -52,13 +53,13 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
||||
}
|
||||
try {
|
||||
if (shortcutId) {
|
||||
await shortcutService.patchShortcut({
|
||||
await shortcutStore.patchShortcut({
|
||||
id: shortcutId,
|
||||
title,
|
||||
payload: JSON.stringify(filters),
|
||||
});
|
||||
} else {
|
||||
await shortcutService.createShortcut({
|
||||
await shortcutStore.createShortcut({
|
||||
title,
|
||||
payload: JSON.stringify(filters),
|
||||
});
|
||||
@@ -100,10 +101,7 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
||||
return (
|
||||
<>
|
||||
<div className="dialog-header-container">
|
||||
<p className="title-text">
|
||||
<span className="icon-text">🚀</span>
|
||||
{shortcutId ? t("shortcut-list.edit-shortcut") : t("shortcut-list.create-shortcut")}
|
||||
</p>
|
||||
<p className="title-text">{shortcutId ? t("shortcut-list.edit-shortcut") : t("shortcut-list.create-shortcut")}</p>
|
||||
<button className="btn close-btn" onClick={destroy}>
|
||||
<Icon.X />
|
||||
</button>
|
||||
@@ -161,11 +159,12 @@ interface MemoFilterInputerProps {
|
||||
const MemoFilterInputer: React.FC<MemoFilterInputerProps> = (props: MemoFilterInputerProps) => {
|
||||
const { index, filter, handleFilterChange, handleFilterRemove } = props;
|
||||
const { t } = useTranslation();
|
||||
const tagStore = useTagStore();
|
||||
const [value, setValue] = useState<string>(filter.value.value);
|
||||
|
||||
const tags = Array.from(memoService.getState().tags);
|
||||
const tags = Array.from(tagStore.getState().tags);
|
||||
const { type } = filter;
|
||||
|
||||
const typeDataSource = Object.values(filterConsts).map(({ text, value }) => ({ text: t(text), value }));
|
||||
const operatorDataSource = Object.values(filterConsts[type as FilterType].operators).map(({ text, value }) => ({ text: t(text), value }));
|
||||
|
||||
const valueDataSource =
|
||||
@@ -246,12 +245,7 @@ const MemoFilterInputer: React.FC<MemoFilterInputerProps> = (props: MemoFilterIn
|
||||
handleValueChanged={handleRelationChange}
|
||||
/>
|
||||
) : null}
|
||||
<Selector
|
||||
className="type-selector"
|
||||
dataSource={Object.values(filterConsts)}
|
||||
value={filter.type}
|
||||
handleValueChanged={handleTypeChange}
|
||||
/>
|
||||
<Selector className="type-selector" dataSource={typeDataSource} value={filter.type} handleValueChanged={handleTypeChange} />
|
||||
<Selector
|
||||
className="operator-selector"
|
||||
dataSource={operatorDataSource}
|
||||
@@ -290,6 +284,7 @@ export default function showCreateShortcutDialog(shortcutId?: ShortcutId): void
|
||||
generateDialog(
|
||||
{
|
||||
className: "create-shortcut-dialog",
|
||||
dialogName: "create-shortcut-dialog",
|
||||
},
|
||||
CreateShortcutDialog,
|
||||
{ shortcutId }
|
||||
|
||||
149
web/src/components/CreateTagDialog.tsx
Normal file
149
web/src/components/CreateTagDialog.tsx
Normal 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;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppSelector } from "../store";
|
||||
import { useMemoStore } from "../store/module";
|
||||
import toImage from "../labs/html2image";
|
||||
import useToggle from "../hooks/useToggle";
|
||||
import { DAILY_TIMESTAMP } from "../helpers/consts";
|
||||
@@ -21,7 +21,8 @@ const weekdayChineseStrArray = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
|
||||
|
||||
const DailyReviewDialog: React.FC<Props> = (props: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const memos = useAppSelector((state) => state.memo.memos);
|
||||
const memoStore = useMemoStore();
|
||||
const memos = memoStore.state.memos;
|
||||
const [currentDateStamp, setCurrentDateStamp] = useState(utils.getDateStampByDate(utils.getDateString(props.currentDateStamp)));
|
||||
const [showDatePicker, toggleShowDatePicker] = useToggle(false);
|
||||
const memosElRef = useRef<HTMLDivElement>(null);
|
||||
@@ -114,6 +115,7 @@ export default function showDailyReviewDialog(datestamp: DateStamp = Date.now())
|
||||
generateDialog(
|
||||
{
|
||||
className: "daily-review-dialog",
|
||||
dialogName: "daily-review-dialog",
|
||||
},
|
||||
DailyReviewDialog,
|
||||
{ currentDateStamp: datestamp }
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Provider } from "react-redux";
|
||||
import { ANIMATION_DURATION } from "../../helpers/consts";
|
||||
import store from "../../store";
|
||||
import "../../less/base-dialog.less";
|
||||
import { useDialogStore } from "../../store/module";
|
||||
import { CssVarsProvider } from "@mui/joy";
|
||||
import theme from "../../theme";
|
||||
import "../../less/base-dialog.less";
|
||||
|
||||
interface DialogConfig {
|
||||
className: string;
|
||||
dialogName: string;
|
||||
clickSpaceDestroy?: boolean;
|
||||
}
|
||||
|
||||
@@ -17,12 +19,18 @@ interface Props extends DialogConfig, DialogProps {
|
||||
}
|
||||
|
||||
const BaseDialog: React.FC<Props> = (props: Props) => {
|
||||
const { children, className, clickSpaceDestroy, destroy } = props;
|
||||
const { children, className, clickSpaceDestroy, dialogName, destroy } = props;
|
||||
const dialogStore = useDialogStore();
|
||||
const dialogContainerRef = useRef<HTMLDivElement>(null);
|
||||
const dialogIndex = dialogStore.state.dialogStack.findIndex((item) => item === dialogName);
|
||||
|
||||
useEffect(() => {
|
||||
dialogStore.pushDialogStack(dialogName);
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.code === "Escape") {
|
||||
destroy();
|
||||
if (dialogName === dialogStore.topDialogStack()) {
|
||||
destroy();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -30,9 +38,16 @@ const BaseDialog: React.FC<Props> = (props: Props) => {
|
||||
|
||||
return () => {
|
||||
document.body.removeEventListener("keydown", handleKeyDown);
|
||||
dialogStore.removeDialog(dialogName);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (dialogIndex > 0 && dialogContainerRef.current) {
|
||||
dialogContainerRef.current.style.marginTop = `${dialogIndex * 16}px`;
|
||||
}
|
||||
}, [dialogIndex]);
|
||||
|
||||
const handleSpaceClicked = () => {
|
||||
if (clickSpaceDestroy) {
|
||||
destroy();
|
||||
@@ -40,8 +55,8 @@ const BaseDialog: React.FC<Props> = (props: Props) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`dialog-wrapper ${className}`} onClick={handleSpaceClicked}>
|
||||
<div className="dialog-container" onClick={(e) => e.stopPropagation()}>
|
||||
<div className={`dialog-wrapper ${className}`} onMouseDown={handleSpaceClicked}>
|
||||
<div ref={dialogContainerRef} className="dialog-container" onMouseDown={(e) => e.stopPropagation()}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -72,6 +72,7 @@ interface CommonDialogProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
style?: DialogStyle;
|
||||
dialogName: string;
|
||||
closeBtnText?: string;
|
||||
confirmBtnText?: string;
|
||||
onClose?: () => void;
|
||||
@@ -82,6 +83,7 @@ export const showCommonDialog = (props: CommonDialogProps) => {
|
||||
generateDialog(
|
||||
{
|
||||
className: `common-dialog ${props?.className ?? ""}`,
|
||||
dialogName: `common-dialog ${props?.className ?? ""}`,
|
||||
},
|
||||
CommonDialog,
|
||||
props
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { forwardRef, ReactNode, useCallback, useEffect, useImperativeHandle, useRef } from "react";
|
||||
import useRefresh from "../../hooks/useRefresh";
|
||||
import "../../less/editor.less";
|
||||
|
||||
export interface EditorRefActions {
|
||||
@@ -10,6 +9,7 @@ export interface EditorRefActions {
|
||||
getContent: () => string;
|
||||
getSelectedContent: () => string;
|
||||
getCursorPosition: () => number;
|
||||
setCursorPosition: (startPos: number, endPos?: number) => void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -25,7 +25,6 @@ interface Props {
|
||||
const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<EditorRefActions>) {
|
||||
const { className, initialContent, placeholder, fullscreen, onPaste, onContentChange: handleContentChangeCallback } = props;
|
||||
const editorRef = useRef<HTMLTextAreaElement>(null);
|
||||
const refresh = useRefresh();
|
||||
|
||||
useEffect(() => {
|
||||
if (editorRef.current && initialContent) {
|
||||
@@ -36,10 +35,16 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
|
||||
|
||||
useEffect(() => {
|
||||
if (editorRef.current && !fullscreen) {
|
||||
updateEditorHeight();
|
||||
}
|
||||
}, [editorRef.current?.value, fullscreen]);
|
||||
|
||||
const updateEditorHeight = () => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.style.height = "auto";
|
||||
editorRef.current.style.height = (editorRef.current.scrollHeight ?? 0) + "px";
|
||||
}
|
||||
}, [editorRef.current?.value, fullscreen]);
|
||||
};
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
@@ -66,7 +71,7 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
|
||||
editorRef.current.focus();
|
||||
editorRef.current.selectionEnd = endPosition + prefix.length + content.length;
|
||||
handleContentChangeCallback(editorRef.current.value);
|
||||
refresh();
|
||||
updateEditorHeight();
|
||||
},
|
||||
removeText: (start: number, length: number) => {
|
||||
if (!editorRef.current) {
|
||||
@@ -79,14 +84,14 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
|
||||
editorRef.current.focus();
|
||||
editorRef.current.selectionEnd = start;
|
||||
handleContentChangeCallback(editorRef.current.value);
|
||||
refresh();
|
||||
updateEditorHeight();
|
||||
},
|
||||
setContent: (text: string) => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.value = text;
|
||||
editorRef.current.focus();
|
||||
handleContentChangeCallback(editorRef.current.value);
|
||||
refresh();
|
||||
updateEditorHeight();
|
||||
}
|
||||
},
|
||||
getContent: (): string => {
|
||||
@@ -100,13 +105,17 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
|
||||
const end = editorRef.current?.selectionEnd;
|
||||
return editorRef.current?.value.slice(start, end) ?? "";
|
||||
},
|
||||
setCursorPosition: (startPos: number, endPos?: number) => {
|
||||
const _endPos = isNaN(endPos as number) ? startPos : (endPos as number);
|
||||
editorRef.current?.setSelectionRange(startPos, _endPos);
|
||||
},
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleEditorInput = useCallback(() => {
|
||||
handleContentChangeCallback(editorRef.current?.value ?? "");
|
||||
refresh();
|
||||
updateEditorHeight();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
||||
37
web/src/components/LocaleSelect.tsx
Normal file
37
web/src/components/LocaleSelect.tsx
Normal 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;
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppSelector } from "../store";
|
||||
import { locationService, shortcutService } from "../services";
|
||||
import { useLocationStore, useShortcutStore } from "../store/module";
|
||||
import * as utils from "../helpers/utils";
|
||||
import { getTextWithMemoType } from "../helpers/filter";
|
||||
import Icon from "./Icon";
|
||||
@@ -8,19 +7,20 @@ import "../less/memo-filter.less";
|
||||
|
||||
const MemoFilter = () => {
|
||||
const { t } = useTranslation();
|
||||
useAppSelector((state) => state.shortcut.shortcuts);
|
||||
const query = useAppSelector((state) => state.location.query);
|
||||
const locationStore = useLocationStore();
|
||||
const shortcutStore = useShortcutStore();
|
||||
const query = locationStore.state.query;
|
||||
const { tag: tagQuery, duration, type: memoType, text: textQuery, shortcutId, visibility } = query;
|
||||
const shortcut = shortcutId ? shortcutService.getShortcutById(shortcutId) : null;
|
||||
const shortcut = shortcutId ? shortcutStore.getShortcutById(shortcutId) : null;
|
||||
const showFilter = Boolean(tagQuery || (duration && duration.from < duration.to) || memoType || textQuery || shortcut || visibility);
|
||||
|
||||
return (
|
||||
<div className={`filter-query-container ${showFilter ? "" : "!hidden"}`}>
|
||||
<span className="tip-text">{t("common.filter")}:</span>
|
||||
<span className="mx-2 text-gray-400">{t("common.filter")}:</span>
|
||||
<div
|
||||
className={"filter-item-container " + (shortcut ? "" : "!hidden")}
|
||||
onClick={() => {
|
||||
locationService.setMemoShortcut(undefined);
|
||||
locationStore.setMemoShortcut(undefined);
|
||||
}}
|
||||
>
|
||||
<Icon.Target className="icon-text" /> {shortcut?.title}
|
||||
@@ -28,7 +28,7 @@ const MemoFilter = () => {
|
||||
<div
|
||||
className={"filter-item-container " + (tagQuery ? "" : "!hidden")}
|
||||
onClick={() => {
|
||||
locationService.setTagQuery(undefined);
|
||||
locationStore.setTagQuery(undefined);
|
||||
}}
|
||||
>
|
||||
<Icon.Tag className="icon-text" /> {tagQuery}
|
||||
@@ -36,7 +36,7 @@ const MemoFilter = () => {
|
||||
<div
|
||||
className={"filter-item-container " + (memoType ? "" : "!hidden")}
|
||||
onClick={() => {
|
||||
locationService.setMemoTypeQuery(undefined);
|
||||
locationStore.setMemoTypeQuery(undefined);
|
||||
}}
|
||||
>
|
||||
<Icon.Box className="icon-text" /> {t(getTextWithMemoType(memoType as MemoSpecType))}
|
||||
@@ -44,7 +44,7 @@ const MemoFilter = () => {
|
||||
<div
|
||||
className={"filter-item-container " + (visibility ? "" : "!hidden")}
|
||||
onClick={() => {
|
||||
locationService.setMemoVisibilityQuery(undefined);
|
||||
locationStore.setMemoVisibilityQuery(undefined);
|
||||
}}
|
||||
>
|
||||
<Icon.Eye className="icon-text" /> {visibility}
|
||||
@@ -53,7 +53,7 @@ const MemoFilter = () => {
|
||||
<div
|
||||
className="filter-item-container"
|
||||
onClick={() => {
|
||||
locationService.setFromAndToQuery();
|
||||
locationStore.setFromAndToQuery();
|
||||
}}
|
||||
>
|
||||
<Icon.Calendar className="icon-text" /> {utils.getDateString(duration.from)} to {utils.getDateString(duration.to)}
|
||||
@@ -62,7 +62,7 @@ const MemoFilter = () => {
|
||||
<div
|
||||
className={"filter-item-container " + (textQuery ? "" : "!hidden")}
|
||||
onClick={() => {
|
||||
locationService.setTextQuery(undefined);
|
||||
locationStore.setTextQuery(undefined);
|
||||
}}
|
||||
>
|
||||
<Icon.Search className="icon-text" /> {textQuery}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { memoService, shortcutService } from "../services";
|
||||
import { DEFAULT_MEMO_LIMIT } from "../services/memoService";
|
||||
import { useAppSelector } from "../store";
|
||||
import { useLocationStore, useMemoStore, useShortcutStore, useUserStore } from "../store/module";
|
||||
import { TAG_REG, LINK_REG } from "../labs/marked/parser";
|
||||
import * as utils from "../helpers/utils";
|
||||
import { DEFAULT_MEMO_LIMIT } from "../helpers/consts";
|
||||
import { checkShouldShowMemoWithFilters } from "../helpers/filter";
|
||||
import toastHelper from "./Toast";
|
||||
import Memo from "./Memo";
|
||||
@@ -12,14 +11,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 {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { memoService, shortcutService } from "../services";
|
||||
import { useAppSelector } from "../store";
|
||||
import { useLocationStore, useMemoStore, useShortcutStore } from "../store/module";
|
||||
import Icon from "./Icon";
|
||||
import SearchBar from "./SearchBar";
|
||||
import { toggleSidebar } from "./Sidebar";
|
||||
@@ -9,8 +8,11 @@ import "../less/memos-header.less";
|
||||
let prevRequestTimestamp = Date.now();
|
||||
|
||||
const MemosHeader = () => {
|
||||
const query = useAppSelector((state) => state.location.query);
|
||||
const shortcuts = useAppSelector((state) => state.shortcut.shortcuts);
|
||||
const locationStore = useLocationStore();
|
||||
const memoStore = useMemoStore();
|
||||
const shortcutStore = useShortcutStore();
|
||||
const query = locationStore.state.query;
|
||||
const shortcuts = shortcutStore.state.shortcuts;
|
||||
const [titleText, setTitleText] = useState("MEMOS");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -19,7 +21,7 @@ const MemosHeader = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const shortcut = shortcutService.getShortcutById(query?.shortcutId);
|
||||
const shortcut = shortcutStore.getShortcutById(query?.shortcutId);
|
||||
if (shortcut) {
|
||||
setTitleText(shortcut.title);
|
||||
}
|
||||
@@ -29,14 +31,14 @@ const MemosHeader = () => {
|
||||
const now = Date.now();
|
||||
if (now - prevRequestTimestamp > 1 * 1000) {
|
||||
prevRequestTimestamp = now;
|
||||
memoService.fetchMemos().catch(() => {
|
||||
memoStore.fetchMemos().catch(() => {
|
||||
// do nth
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="section-header-container memos-header-container">
|
||||
<div className="memos-header-container">
|
||||
<div className="title-container">
|
||||
<div className="action-btn" onClick={() => toggleSidebar(true)}>
|
||||
<Icon.Menu className="icon-img" />
|
||||
|
||||
@@ -20,14 +20,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,
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
{}
|
||||
|
||||
@@ -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,
|
||||
{}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppSelector } from "../store";
|
||||
import { useUserStore } from "../store/module";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import MyAccountSection from "./Settings/MyAccountSection";
|
||||
@@ -20,7 +20,8 @@ interface State {
|
||||
const SettingDialog: React.FC<Props> = (props: Props) => {
|
||||
const { destroy } = props;
|
||||
const { t } = useTranslation();
|
||||
const user = useAppSelector((state) => state.user.user);
|
||||
const userStore = useUserStore();
|
||||
const user = userStore.state.user;
|
||||
const [state, setState] = useState<State>({
|
||||
selectedSection: "my-account",
|
||||
});
|
||||
@@ -66,7 +67,7 @@ const SettingDialog: React.FC<Props> = (props: Props) => {
|
||||
onClick={() => handleSectionSelectorItemClick("system")}
|
||||
className={`section-item ${state.selectedSection === "system" ? "selected" : ""}`}
|
||||
>
|
||||
<span className="icon-text">🧑🔧</span> {t("setting.system")}
|
||||
<span className="icon-text">🛠️</span> {t("setting.system")}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
@@ -91,6 +92,7 @@ export default function showSettingDialog(): void {
|
||||
generateDialog(
|
||||
{
|
||||
className: "setting-dialog",
|
||||
dialogName: "setting-dialog",
|
||||
},
|
||||
SettingDialog,
|
||||
{}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { userService } from "../../services";
|
||||
import { useAppSelector } from "../../store";
|
||||
import { useUserStore } from "../../store/module";
|
||||
import * as api from "../../helpers/api";
|
||||
import toastHelper from "../Toast";
|
||||
import Dropdown from "../common/Dropdown";
|
||||
@@ -16,7 +15,8 @@ interface State {
|
||||
|
||||
const PreferencesSection = () => {
|
||||
const { t } = useTranslation();
|
||||
const currentUser = useAppSelector((state) => state.user.user);
|
||||
const userStore = useUserStore();
|
||||
const currentUser = userStore.state.user;
|
||||
const [state, setState] = useState<State>({
|
||||
createUserUsername: "",
|
||||
createUserPassword: "",
|
||||
@@ -79,8 +79,9 @@ const PreferencesSection = () => {
|
||||
title: `Archive Member`,
|
||||
content: `❗️Are you sure to archive ${user.username}?`,
|
||||
style: "warning",
|
||||
dialogName: "archive-user-dialog",
|
||||
onConfirm: async () => {
|
||||
await userService.patchUser({
|
||||
await userStore.patchUser({
|
||||
id: user.id,
|
||||
rowStatus: "ARCHIVED",
|
||||
});
|
||||
@@ -90,7 +91,7 @@ const PreferencesSection = () => {
|
||||
};
|
||||
|
||||
const handleRestoreUserClick = async (user: User) => {
|
||||
await userService.patchUser({
|
||||
await userStore.patchUser({
|
||||
id: user.id,
|
||||
rowStatus: "NORMAL",
|
||||
});
|
||||
@@ -102,8 +103,9 @@ const PreferencesSection = () => {
|
||||
title: `Delete Member`,
|
||||
content: `Are you sure to delete ${user.username}? THIS ACTION IS IRREVERSIABLE.❗️`,
|
||||
style: "warning",
|
||||
dialogName: "delete-user-dialog",
|
||||
onConfirm: async () => {
|
||||
await userService.deleteUser({
|
||||
await userStore.deleteUser({
|
||||
id: user.id,
|
||||
});
|
||||
fetchUserList();
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppSelector } from "../../store";
|
||||
import { userService } from "../../services";
|
||||
import { useUserStore } from "../../store/module";
|
||||
import { showCommonDialog } from "../Dialog/CommonDialog";
|
||||
import showChangePasswordDialog from "../ChangePasswordDialog";
|
||||
import showUpdateAccountDialog from "../UpdateAccountDialog";
|
||||
@@ -8,7 +7,8 @@ import "../../less/settings/my-account-section.less";
|
||||
|
||||
const MyAccountSection = () => {
|
||||
const { t } = useTranslation();
|
||||
const user = useAppSelector((state) => state.user.user as User);
|
||||
const userStore = useUserStore();
|
||||
const user = userStore.state.user as User;
|
||||
const openAPIRoute = `${window.location.origin}/api/memo?openId=${user.openId}`;
|
||||
|
||||
const handleResetOpenIdBtnClick = async () => {
|
||||
@@ -16,8 +16,9 @@ const MyAccountSection = () => {
|
||||
title: "Reset Open API",
|
||||
content: "❗️The existing API will be invalidated and a new one will be generated, are you sure you want to reset?",
|
||||
style: "warning",
|
||||
dialogName: "reset-openid-dialog",
|
||||
onConfirm: async () => {
|
||||
await userService.patchUser({
|
||||
await userStore.patchUser({
|
||||
id: user.id,
|
||||
resetOpenId: true,
|
||||
});
|
||||
|
||||
@@ -1,46 +1,17 @@
|
||||
import { Select, Switch, Option } from "@mui/joy";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { globalService, userService } from "../../services";
|
||||
import { useAppSelector } from "../../store";
|
||||
import { useGlobalStore, useUserStore } from "../../store/module";
|
||||
import { VISIBILITY_SELECTOR_ITEMS, MEMO_DISPLAY_TS_OPTION_SELECTOR_ITEMS } from "../../helpers/consts";
|
||||
import Icon from "../Icon";
|
||||
import AppearanceSelect from "../AppearanceSelect";
|
||||
import LocaleSelect from "../LocaleSelect";
|
||||
import "../../less/settings/preferences-section.less";
|
||||
|
||||
const localeSelectorItems = [
|
||||
{
|
||||
text: "English",
|
||||
value: "en",
|
||||
},
|
||||
{
|
||||
text: "中文",
|
||||
value: "zh",
|
||||
},
|
||||
{
|
||||
text: "Tiếng Việt",
|
||||
value: "vi",
|
||||
},
|
||||
{
|
||||
text: "French",
|
||||
value: "fr",
|
||||
},
|
||||
{
|
||||
text: "Nederlands",
|
||||
value: "nl",
|
||||
},
|
||||
{
|
||||
text: "Svenska",
|
||||
value: "sv",
|
||||
},
|
||||
{
|
||||
text: "German",
|
||||
value: "de",
|
||||
},
|
||||
];
|
||||
|
||||
const PreferencesSection = () => {
|
||||
const { t } = useTranslation();
|
||||
const { setting, localSetting } = useAppSelector((state) => state.user.user as User);
|
||||
const globalStore = useGlobalStore();
|
||||
const userStore = useUserStore();
|
||||
const { appearance, locale } = globalStore.state;
|
||||
const { setting, localSetting } = userStore.state.user as User;
|
||||
const visibilitySelectorItems = VISIBILITY_SELECTOR_ITEMS.map((item) => {
|
||||
return {
|
||||
value: item.value,
|
||||
@@ -55,21 +26,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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
136
web/src/components/UpdateCustomizedProfileDialog.tsx
Normal file
136
web/src/components/UpdateCustomizedProfileDialog.tsx
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
17
web/src/labs/marked/parser/Br.ts
Normal file
17
web/src/labs/marked/parser/Br.ts
Normal 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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
25
web/src/labs/marked/parser/Heading.ts
Normal file
25
web/src/labs/marked/parser/Heading.ts
Normal 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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user