Compare commits

...

72 Commits

Author SHA1 Message Date
boojack
dd6e2337e6 chore: update version to 0.8.2 (#722) 2022-12-10 13:23:38 +08:00
boojack
66418d4210 feat: get image only when cors error (#721) 2022-12-10 13:20:48 +08:00
boojack
ab8c7b9d8a fix: auto complete in memo editor (#720) 2022-12-10 12:44:45 +08:00
M. Gschwandtner
387799b31c fix: added dark theme bg color to buttons (#719) 2022-12-10 12:14:02 +08:00
boojack
4a64a4dea8 fix: html lang attr (#718) 2022-12-10 10:42:10 +08:00
M. Gschwandtner
964c58ac01 feat: responsive layout for create shortcut dialog (#717) 2022-12-10 10:17:47 +08:00
boojack
56716cdad4 fix: break word (#708)
* fix: break word

* chore: update
2022-12-09 08:31:45 +08:00
Shruti Chaturvedi
a2ee750d1e fix: bump Version of reusable.yaml (#707)
Bump version

Bump up the version for reusable.yaml to v2
2022-12-09 00:31:00 +08:00
Shruti Chaturvedi
3f0601f651 feat: add Uffizzi Integration (#655)
* Integrate Uffizzi

* Update docker-compose.uffizzi.yml

Start memos in dev mode

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

Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-12-08 23:45:23 +08:00
Zeng1998
6f8e3432e9 fix: correct priority of keys in editor (#703) 2022-12-08 18:46:43 +08:00
Stephen Zhou
b7ab6f8e7e fix: code highlight in dark mode (#702) 2022-12-08 18:30:46 +08:00
Zeng1998
36b92ad884 feat: auto continuation list in editor (#689)
* feat: auto continuation list in editor

* update

* update
2022-12-08 10:01:01 +08:00
apixandru
4d9857ce18 fix: update UsageHeatMap.tsx to account for daylight savings (#696) 2022-12-08 08:24:36 +08:00
boojack
43b22ce55f chore: fix typo (#695) 2022-12-07 22:49:05 +08:00
Zeng1998
147185309c feat: vacuum database in setting (#694)
* feat: vacuum database in setting

* update

* update

* update

* update
2022-12-07 22:45:47 +08:00
Maurice Bauer
f48226d4f2 chore: update German translation (#691)
Switched to plural to make the difference between TAG(S) and TAG(E) visible...
Refers to #545
2022-12-07 20:18:35 +08:00
Zeng1998
e92407d9ec feat: image preview enhancement (#682) 2022-12-06 09:38:01 +08:00
Jasper Platenburg
79bf365d78 Dutch locale (#687) 2022-12-06 08:11:21 +08:00
Maurice Bauer
492a1370ab feat: add German i18n item (#686) 2022-12-06 07:42:17 +08:00
boojack
e3ddf93c4d chore: update demo image (#672) 2022-12-04 20:43:46 +08:00
boojack
4a9314c476 chore: rename enableFoldMemo (#671)
* chore: rename `enableFoldMemo`

* chore: update
2022-12-04 15:34:03 +08:00
boojack
4767ee3293 feat: support follow system appearance (#670) 2022-12-04 12:23:29 +08:00
boojack
1ea74dfd0d chore: remove table syntax (#669) 2022-12-04 10:52:11 +08:00
Andreas Backström
53cf6ebb79 feat: add swedish/svenska translation (#668)
Add swedish / svenska translation
2022-12-03 21:38:25 +08:00
boojack
d1007950e0 chore: remove emoji picker (#667) 2022-12-03 15:28:37 +08:00
Zeng1998
331226ec68 chore: fix some typos of README (#666) 2022-12-03 15:01:27 +08:00
boojack
a7374cf998 fix: generate sharing memo image (#663) 2022-12-03 09:41:52 +08:00
boojack
e3d76193b9 chore: update global css (#658) 2022-12-02 22:00:03 +08:00
boojack
07f0c3f052 chore: update global css (#657) 2022-12-02 21:34:43 +08:00
boojack
a467a7c173 feat: upgrade dev version to 0.8.1 (#656)
* feat: upgrade version to `0.8.1`

* chore: update
2022-12-02 21:09:11 +08:00
boojack
14f9f29348 chore: update user setting appearance (#654) 2022-12-02 20:00:34 +08:00
EINDEX
5451fd2d2c feat: add a product of logseq plugin (#652) 2022-12-02 19:46:39 +08:00
hoi-lau
f092771ea1 fix: resource-container overflow (#649) 2022-12-02 19:45:22 +08:00
boojack
7c6d7226f5 feat: update appearance selector (#645) 2022-12-01 20:57:19 +08:00
Stephen Zhou
eaebc6dcef fix: apperance can not auto switch (#644) 2022-12-01 19:39:58 +08:00
boojack
c5200ca31b feat: dark mode for dialogs (#643) 2022-11-30 20:34:16 +08:00
Tiefseemonster
1078132b12 fix: member menu dropdown position (#639)
* fix: member menu dropdown position

* chore: cleanup

* chore: cleanup
2022-11-30 20:18:39 +08:00
Stephen Zhou
6b058cd299 feat: save folding option with localstorage (#641)
* fix: change folding option need reload

* fix: floding option undefied
2022-11-30 19:13:55 +08:00
Wence
b8f24af5ae feat: dynamic lazy loading route with simple loading page (#632)
* feat: dynamic loading route with simple loading page

* fix: lint fix

* Update web/src/less/loading.less

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

Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-11-29 22:13:22 +00:00
boojack
6384f5af74 feat: dark mode for main pages (#637)
* feat: update dark mode styles for auth and explore page

* feat: dark mode for home page
2022-11-29 21:44:52 +08:00
Zeng1998
52038d26d2 chore: update i18n for validator message (#636) 2022-11-29 21:35:40 +08:00
boojack
55f37664ef chore: add theme file for joyui (#635) 2022-11-29 20:15:16 +08:00
Zeng1998
ab8e3473a1 feat: support resources reuse (#620)
* feat: support resource reuse

* update

* update

* update

* update
2022-11-29 19:07:20 +08:00
Zeng1998
eba23c4f6e fix: add validation for user information update (#633) 2022-11-29 19:03:29 +08:00
Zeng1998
00fe6d3862 chore: add joyui tooltip for resources dialog (#630) 2022-11-29 09:36:48 +08:00
Charles Chin
3646b8f5dd docs: update readme (#628) 2022-11-29 06:34:44 +08:00
boojack
d40639bf8e chore: update readme me with contributors graph (#626) 2022-11-28 22:48:02 +08:00
Zeng1998
12b81781b9 feat: share memo dialog (#618)
* feat: new share dialog

* update

* update

* update

* update
2022-11-28 21:20:35 +08:00
Zeng1998
b67a37453d feat: member management enhancement (#617)
* feat: member management enhancement

* update

* update

* update

* update
2022-11-28 19:59:11 +08:00
boojack
b04e001db1 fix: image url host missing (#623) 2022-11-28 19:52:03 +08:00
Stephen Zhou
fbe7b604ef feat: dark mode support for memo detail (#604)
* feat: dark mode support for memo detail

* chore: update

* chore: update

* chore: update
2022-11-28 19:40:08 +08:00
Zeng1998
0402cb7b27 fix: no user settings returns when patch user (#622) 2022-11-28 19:38:44 +08:00
Tiefseemonster
b72bfc9c24 fix: selector dropdown position in fullscreen mod (#619) 2022-11-28 19:36:42 +08:00
Zeng1998
40e92f9463 fix: change password max length validation (#616) 2022-11-28 19:33:14 +08:00
Zeng1998
f883dd9c1d feat: create user repeat password (#614)
* feat: create user repeat password

* update
2022-11-28 19:32:53 +08:00
Wujiao233
d8bf55efb2 fix: shoutcut tag filter handle mutiple tags (#608)
* fix: shoutcut tag filter handle mutiple tags

* not edit parser
2022-11-28 19:32:01 +08:00
Wujiao233
f982e83d0a fix: clear shortcut filter when delete this shortcut (#611) 2022-11-28 06:14:25 +08:00
Jasper Platenburg
3472a6db26 fix: password field visible (#609) 2022-11-27 21:53:10 +08:00
boojack
c79e51a91b chore: update readme (#606) 2022-11-27 16:04:35 +08:00
boojack
ce795a2a7d chore: show content image (#602) 2022-11-27 09:01:19 +08:00
boojack
045819c312 fix: initial database schema (#601) 2022-11-27 08:52:43 +08:00
Tiefseemonster
2fa01886da fix: tooltip overlaps a window border (#599) 2022-11-27 08:48:21 +08:00
Tiefseemonster
dd7d322c47 chore: add .vscode to gitignore (#596) 2022-11-27 07:56:19 +08:00
Tiefseemonster
dfe71f33c2 fix: search bar dropdown disappearing (#593) 2022-11-26 23:13:02 +08:00
boojack
db1d223448 fix: apperance select (#585) 2022-11-26 17:45:16 +08:00
Zeng1998
54271c1598 chore: fix some typos (#587) 2022-11-26 06:23:29 +00:00
Zeng1998
1ee8ebc9e1 fix: collapse btn cursor style (#586) 2022-11-26 05:12:37 +00:00
Stephen Zhou
6e5537d131 feat: dark mode support for explore page (#584)
* feat: dark mode support for auth page

* chore: update

* feat: dark mode support for explore page (#583)

* fix: avoid white text

* fix: import order
2022-11-26 12:19:00 +08:00
Stephen Zhou
90c85103c3 feat: dark mode support for auth page (#569)
* feat: dark mode support for auth page

* chore: update
2022-11-26 11:20:22 +08:00
Zeng1998
2d5d734da4 chore: update i18n for account settings (#582) 2022-11-26 02:23:57 +00:00
Zeng1998
e1e5121dd7 fix: get markdown image from backend (#581) 2022-11-26 10:20:49 +08:00
boojack
85db6721de chore: disable metrics collector (#580) 2022-11-26 09:23:38 +08:00
137 changed files with 2718 additions and 1231 deletions

97
.github/workflows/uffizzi-build.yml vendored Normal file
View File

@@ -0,0 +1,97 @@
name: Build PR Image
on:
pull_request:
types: [opened, synchronize, reopened, closed]
jobs:
build-memos:
name: Build and push `Memos`
runs-on: ubuntu-latest
outputs:
tags: ${{ steps.meta.outputs.tags }}
if: ${{ github.event.action != 'closed' }}
steps:
- name: Checkout git repo
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Generate UUID image name
id: uuid
run: echo "UUID_WORKER=$(uuidgen)" >> $GITHUB_ENV
- name: Docker metadata
id: meta
uses: docker/metadata-action@v4
with:
images: registry.uffizzi.com/${{ env.UUID_WORKER }}
tags: |
type=raw,value=60d
- name: Build and Push Image to registry.uffizzi.com - Uffizzi's ephemeral Registry
uses: docker/build-push-action@v3
with:
context: ./
file: Dockerfile
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
push: true
cache-from: type=gha
cache-to: type=gha, mode=max
render-compose-file:
name: Render Docker Compose File
# Pass output of this workflow to another triggered by `workflow_run` event.
runs-on: ubuntu-latest
needs:
- build-memos
outputs:
compose-file-cache-key: ${{ steps.hash.outputs.hash }}
steps:
- name: Checkout git repo
uses: actions/checkout@v3
- name: Render Compose File
run: |
MEMOS_IMAGE=${{ needs.build-memos.outputs.tags }}
export MEMOS_IMAGE
# Render simple template from environment variables.
envsubst < docker-compose.uffizzi.yml > docker-compose.rendered.yml
cat docker-compose.rendered.yml
- name: Upload Rendered Compose File as Artifact
uses: actions/upload-artifact@v3
with:
name: preview-spec
path: docker-compose.rendered.yml
retention-days: 2
- name: Serialize PR Event to File
run: |
cat << EOF > event.json
${{ toJSON(github.event) }}
EOF
- name: Upload PR Event as Artifact
uses: actions/upload-artifact@v3
with:
name: preview-spec
path: event.json
retention-days: 2
delete-preview:
name: Call for Preview Deletion
runs-on: ubuntu-latest
if: ${{ github.event.action == 'closed' }}
steps:
# If this PR is closing, we will not render a compose file nor pass it to the next workflow.
- name: Serialize PR Event to File
run: |
cat << EOF > event.json
${{ toJSON(github.event) }}
EOF
- name: Upload PR Event as Artifact
uses: actions/upload-artifact@v3
with:
name: preview-spec
path: event.json
retention-days: 2

86
.github/workflows/uffizzi-preview.yml vendored Normal file
View File

@@ -0,0 +1,86 @@
name: Deploy Uffizzi Preview
on:
workflow_run:
workflows:
- "Build PR Image"
types:
- completed
jobs:
cache-compose-file:
name: Cache Compose File
runs-on: ubuntu-latest
outputs:
compose-file-cache-key: ${{ env.HASH }}
pr-number: ${{ env.PR_NUMBER }}
steps:
- name: 'Download artifacts'
# Fetch output (zip archive) from the workflow run that triggered this workflow.
uses: actions/github-script@v6
with:
script: |
let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: context.payload.workflow_run.id,
});
let matchArtifact = allArtifacts.data.artifacts.filter((artifact) => {
return artifact.name == "preview-spec"
})[0];
let download = await github.rest.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: matchArtifact.id,
archive_format: 'zip',
});
let fs = require('fs');
fs.writeFileSync(`${process.env.GITHUB_WORKSPACE}/preview-spec.zip`, Buffer.from(download.data));
- name: 'Unzip artifact'
run: unzip preview-spec.zip
- name: Read Event into ENV
run: |
echo 'EVENT_JSON<<EOF' >> $GITHUB_ENV
cat event.json >> $GITHUB_ENV
echo 'EOF' >> $GITHUB_ENV
- name: Hash Rendered Compose File
id: hash
# If the previous workflow was triggered by a PR close event, we will not have a compose file artifact.
if: ${{ fromJSON(env.EVENT_JSON).action != 'closed' }}
run: echo "HASH=$(md5sum docker-compose.rendered.yml | awk '{ print $1 }')" >> $GITHUB_ENV
- name: Cache Rendered Compose File
if: ${{ fromJSON(env.EVENT_JSON).action != 'closed' }}
uses: actions/cache@v3
with:
path: docker-compose.rendered.yml
key: ${{ env.HASH }}
- name: Read PR Number From Event Object
id: pr
run: echo "PR_NUMBER=${{ fromJSON(env.EVENT_JSON).number }}" >> $GITHUB_ENV
- name: DEBUG - Print Job Outputs
if: ${{ runner.debug }}
run: |
echo "PR number: ${{ env.PR_NUMBER }}"
echo "Compose file hash: ${{ env.HASH }}"
cat event.json
deploy-uffizzi-preview:
name: Use Remote Workflow to Preview on Uffizzi
needs:
- cache-compose-file
uses: UffizziCloud/preview-action/.github/workflows/reusable.yaml@v2
with:
# If this workflow was triggered by a PR close event, cache-key will be an empty string
# and this reusable workflow will delete the preview deployment.
compose-file-cache-key: ${{ needs.cache-compose-file.outputs.compose-file-cache-key }}
compose-file-cache-path: docker-compose.rendered.yml
server: https://app.uffizzi.com
pr-number: ${{ needs.cache-compose-file.outputs.pr-number }}
permissions:
contents: read
pull-requests: write
id-token: write

3
.gitignore vendored
View File

@@ -15,4 +15,7 @@ build
# Jetbrains
.idea
# vscode
.vscode
bin/air

View File

@@ -1,6 +1,6 @@
<p align="center"><a href="https://usememos.com"><img height="64px" src="https://raw.githubusercontent.com/usememos/memos/main/resources/logo-full.webp" alt="✍️ memos" /></a></p>
<p align="center">An open-source, self-hosted memo hub with knowledge management and collaboration.</p>
<p align="center">An open-source, self-hosted memo hub with knowledge management and socialization.</p>
<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>
@@ -19,8 +19,8 @@
- 🦄 Open source and free forever;
- 🚀 Support for self-hosting with `Docker` in seconds;
- 📜 Plain textarea first and support some useful markdown syntax;
- 👥 Collaborate and share with your teammates;
- 📜 Plain textarea first and support some useful Markdown syntax;
- 👥 Set memo private or public to others;
- 🧑‍💻 RESTful API for self-service.
## Deploy with Docker in seconds
@@ -31,7 +31,7 @@
docker run -d --name memos -p 5230:5230 -v ~/.memos/:/var/opt/memos neosmemo/memos:latest
```
Memos should be running at [http://localhost:5230](http://localhost:5230). If the `~/.memos/` does not have a `memos_prod.db` file, then memos will auto generate it.
If the `~/.memos/` does not have a `memos_prod.db` file, then memos will auto generate it. Memos will be running at [http://localhost:5230](http://localhost:5230).
### Docker Compose
@@ -45,7 +45,7 @@ docker-compose down && docker image rm neosmemo/memos:latest && docker-compose u
## Contribute
Contributions are what make the open source community such an amazing place to be learn, inspire, and create. Any contributions you make are greatly appreciated. 🥰
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).
@@ -53,8 +53,16 @@ See more in [development guide](https://github.com/usememos/memos/tree/main/docs
- [Moe Memos](https://memos.moe/) - Third party client for iOS and Android
- [lmm214/memos-bber](https://github.com/lmm214/memos-bber) - Chrome extension
- [Rabithua/memos_wmp](https://github.com/Rabithua/memos_wmp) - Wechat miniprogram
- [Rabithua/memos_wmp](https://github.com/Rabithua/memos_wmp) - WeChat MiniProgram
- [qazxcdswe123/telegramMemoBot](https://github.com/qazxcdswe123/telegramMemoBot) - Telegram bot
- [eallion/memos.top](https://github.com/eallion/memos.top) - A static page rendered with the Memos API
- [eindex/logseq-memos-sync](https://github.com/EINDEX/logseq-memos-sync) - A Logseq plugin
### Join the community to build memos together!
<a href="https://github.com/usememos/memos/graphs/contributors">
<img src="https://contrib.rocks/image?repo=usememos/memos" />
</a>
## License

View File

@@ -8,8 +8,8 @@ const (
Public Visibility = "PUBLIC"
// Protected is the PROTECTED visibility.
Protected Visibility = "PROTECTED"
// Privite is the PRIVATE visibility.
Privite Visibility = "PRIVATE"
// Private is the PRIVATE visibility.
Private Visibility = "PRIVATE"
)
func (e Visibility) String() string {
@@ -18,7 +18,7 @@ func (e Visibility) String() string {
return "PUBLIC"
case Protected:
return "PROTECTED"
case Privite:
case Private:
return "PRIVATE"
}
return "PRIVATE"

View File

@@ -10,6 +10,8 @@ type UserSettingKey string
const (
// UserSettingLocaleKey is the key type for user locale.
UserSettingLocaleKey UserSettingKey = "locale"
// UserSettingAppearanceKey is the key type for user appearance.
UserSettingAppearanceKey UserSettingKey = "appearance"
// UserSettingMemoVisibilityKey is the key type for user preference memo default visibility.
UserSettingMemoVisibilityKey UserSettingKey = "memoVisibility"
// UserSettingMemoDisplayTsOptionKey is the key type for memo display ts option.
@@ -21,6 +23,8 @@ func (key UserSettingKey) String() string {
switch key {
case UserSettingLocaleKey:
return "locale"
case UserSettingAppearanceKey:
return "appearance"
case UserSettingMemoVisibilityKey:
return "memoVisibility"
case UserSettingMemoDisplayTsOptionKey:
@@ -30,9 +34,9 @@ func (key UserSettingKey) String() string {
}
var (
UserSettingLocaleValue = []string{"en", "zh", "vi", "fr"}
UserSettingMemoVisibilityValue = []Visibility{Privite, Protected, Public}
UserSettingEditorFontStyleValue = []string{"normal", "mono"}
UserSettingLocaleValue = []string{"en", "zh", "vi", "fr", "nl", "sv", "de"}
UserSettingAppearanceValue = []string{"system", "light", "dark"}
UserSettingMemoVisibilityValue = []Visibility{Private, Protected, Public}
UserSettingMemoDisplayTsOptionKeyValue = []string{"created_ts", "updated_ts"}
)
@@ -67,8 +71,25 @@ func (upsert UserSettingUpsert) Validate() error {
if invalid {
return fmt.Errorf("invalid user setting locale value")
}
} else if upsert.Key == UserSettingAppearanceKey {
appearanceValue := "light"
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 {
return fmt.Errorf("invalid user setting appearance value")
}
} else if upsert.Key == UserSettingMemoVisibilityKey {
memoVisibilityValue := Privite
memoVisibilityValue := Private
err := json.Unmarshal([]byte(upsert.Value), &memoVisibilityValue)
if err != nil {
return fmt.Errorf("failed to unmarshal user setting memo visibility value")

View File

@@ -40,6 +40,8 @@ func run(profile *profile.Profile) error {
serverInstance.Store = storeInstance
metricCollector := server.NewMetricCollector(profile, storeInstance)
// Disable metrics collector.
metricCollector.Enabled = false
serverInstance.Collector = &metricCollector
println(greetingBanner)

View File

@@ -0,0 +1,18 @@
version: "3.0"
# uffizzi integration
x-uffizzi:
ingress:
service: memos
port: 5230
services:
memos:
image: "${MEMOS_IMAGE}"
volumes:
- memos_volume:/var/opt/memos
command: ["--mode", "dev"]
volumes:
memos_volume:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -45,7 +45,7 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
}
if userMemoVisibilitySetting != nil {
memoVisibility := api.Privite
memoVisibility := api.Private
err := json.Unmarshal([]byte(userMemoVisibilitySetting.Value), &memoVisibility)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal user setting value").SetInternal(err)
@@ -53,7 +53,7 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
memoCreate.Visibility = memoVisibility
} else {
// Private is the default memo visibility.
memoCreate.Visibility = api.Privite
memoCreate.Visibility = api.Private
}
}
@@ -176,10 +176,10 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
contentSearch := "#" + tag + " "
memoFind.ContentSearch = &contentSearch
}
visibilitListStr := c.QueryParam("visibility")
if visibilitListStr != "" {
visibilityListStr := c.QueryParam("visibility")
if visibilityListStr != "" {
visibilityList := []api.Visibility{}
for _, visibility := range strings.Split(visibilitListStr, ",") {
for _, visibility := range strings.Split(visibilityListStr, ",") {
visibilityList = append(visibilityList, api.Visibility(visibility))
}
memoFind.VisibilityList = visibilityList
@@ -271,7 +271,7 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
if *memoFind.CreatorID != currentUserID {
memoFind.VisibilityList = []api.Visibility{api.Public, api.Protected}
} else {
memoFind.VisibilityList = []api.Visibility{api.Public, api.Protected, api.Privite}
memoFind.VisibilityList = []api.Visibility{api.Public, api.Protected, api.Private}
}
}
@@ -313,10 +313,10 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
contentSearch := "#" + tag + " "
memoFind.ContentSearch = &contentSearch
}
visibilitListStr := c.QueryParam("visibility")
if visibilitListStr != "" {
visibilityListStr := c.QueryParam("visibility")
if visibilityListStr != "" {
visibilityList := []api.Visibility{}
for _, visibility := range strings.Split(visibilitListStr, ",") {
for _, visibility := range strings.Split(visibilityListStr, ",") {
visibilityList = append(visibilityList, api.Visibility(visibility))
}
memoFind.VisibilityList = visibilityList
@@ -372,7 +372,7 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
}
userID, ok := c.Get(getUserIDContextKey()).(int)
if memo.Visibility == api.Privite {
if memo.Visibility == api.Private {
if !ok || memo.CreatorID != userID {
return echo.NewHTTPError(http.StatusForbidden, "this memo is private only")
}

View File

@@ -13,9 +13,10 @@ import (
// MetricCollector is the metric collector.
type MetricCollector struct {
collector metric.Collector
profile *profile.Profile
store *store.Store
Collector metric.Collector
Enabled bool
Profile *profile.Profile
Store *store.Store
}
const (
@@ -26,23 +27,28 @@ func NewMetricCollector(profile *profile.Profile, store *store.Store) MetricColl
c := segment.NewCollector(segmentMetricWriteKey)
return MetricCollector{
collector: c,
profile: profile,
store: store,
Collector: c,
Enabled: true,
Profile: profile,
Store: store,
}
}
func (mc *MetricCollector) Collect(_ context.Context, metric *metric.Metric) {
if mc.profile.Mode == "dev" {
if !mc.Enabled {
return
}
if mc.Profile.Mode == "dev" {
return
}
if metric.Labels == nil {
metric.Labels = map[string]string{}
}
metric.Labels["version"] = version.GetCurrentVersion(mc.profile.Mode)
metric.Labels["version"] = version.GetCurrentVersion(mc.Profile.Mode)
err := mc.collector.Collect(metric)
err := mc.Collector.Collect(metric)
if err != nil {
fmt.Printf("Failed to request segment, error: %+v\n", err)
}

View File

@@ -93,13 +93,13 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
}
for _, resource := range list {
memoResoureceList, err := s.Store.FindMemoResourceList(ctx, &api.MemoResourceFind{
memoResourceList, err := s.Store.FindMemoResourceList(ctx, &api.MemoResourceFind{
ResourceID: &resource.ID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo resource list").SetInternal(err)
}
resource.LinkedMemoAmount = len(memoResoureceList)
resource.LinkedMemoAmount = len(memoResourceList)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)

View File

@@ -69,6 +69,7 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
}
userID, ok := c.Get(getUserIDContextKey()).(int)
// Get database size for host user.
if ok {
user, err := s.Store.FindUser(ctx, &api.UserFind{
ID: &userID,
@@ -148,4 +149,26 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
}
return nil
})
g.POST("/system/vacuum", 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")
}
user, err := s.Store.FindUser(ctx, &api.UserFind{
ID: &userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
}
if user == nil || user.Role != api.Host {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
if err := s.Store.Vacuum(ctx); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to vacuum database").SetInternal(err)
}
c.Response().WriteHeader(http.StatusOK)
return nil
})
}

View File

@@ -223,6 +223,14 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch user").SetInternal(err)
}
userSettingList, err := s.Store.FindUserSettingList(ctx, &api.UserSettingFind{
UserID: userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find userSettingList").SetInternal(err)
}
user.UserSettingList = userSettingList
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(user)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode user response").SetInternal(err)

View File

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

View File

@@ -49,8 +49,8 @@ func (db *DB) Open(ctx context.Context) (err error) {
}
db.Db = sqlDB
// If mode is dev, we should migrate and seed the database.
if db.profile.Mode == "dev" {
// In dev mode, we should migrate and seed the database.
if _, err := os.Stat(db.profile.DSN); errors.Is(err, os.ErrNotExist) {
if err := db.applyLatestSchema(ctx); err != nil {
return fmt.Errorf("failed to apply latest schema: %w", err)
@@ -65,51 +65,51 @@ func (db *DB) Open(ctx context.Context) (err error) {
if err := db.applyLatestSchema(ctx); err != nil {
return fmt.Errorf("failed to apply latest schema: %w", err)
}
} else {
currentVersion := version.GetCurrentVersion(db.profile.Mode)
migrationHistory, err := db.FindMigrationHistory(ctx, &MigrationHistoryFind{})
}
currentVersion := version.GetCurrentVersion(db.profile.Mode)
migrationHistory, err := db.FindMigrationHistory(ctx, &MigrationHistoryFind{})
if err != nil {
return fmt.Errorf("failed to find migration history, err: %w", err)
}
if migrationHistory == nil {
if _, err = db.UpsertMigrationHistory(ctx, &MigrationHistoryUpsert{
Version: currentVersion,
}); err != nil {
return fmt.Errorf("failed to upsert migration history, err: %w", err)
}
return nil
}
if version.IsVersionGreaterThan(version.GetSchemaVersion(currentVersion), migrationHistory.Version) {
minorVersionList := getMinorVersionList()
// backup the raw database file before migration
rawBytes, err := os.ReadFile(db.profile.DSN)
if err != nil {
return err
return fmt.Errorf("failed to read raw database file, err: %w", err)
}
if migrationHistory == nil {
migrationHistory, err = db.UpsertMigrationHistory(ctx, &MigrationHistoryUpsert{
Version: currentVersion,
})
if err != nil {
return err
}
backupDBFilePath := fmt.Sprintf("%s/memos_%s_%d_backup.db", db.profile.Data, db.profile.Version, time.Now().Unix())
if err := os.WriteFile(backupDBFilePath, rawBytes, 0644); err != nil {
return fmt.Errorf("failed to write raw database file, err: %w", err)
}
println("succeed to copy a backup database file")
if version.IsVersionGreaterThan(version.GetSchemaVersion(currentVersion), migrationHistory.Version) {
minorVersionList := getMinorVersionList()
// backup the raw database file before migration
rawBytes, err := os.ReadFile(db.profile.DSN)
if err != nil {
return fmt.Errorf("failed to read raw database file, err: %w", err)
}
backupDBFilePath := fmt.Sprintf("%s/memos_%s_%d_backup.db", db.profile.Data, db.profile.Version, time.Now().Unix())
if err := os.WriteFile(backupDBFilePath, rawBytes, 0644); err != nil {
return fmt.Errorf("failed to write raw database file, err: %w", err)
}
println("succeed to copy a backup database file")
println("start migrate")
for _, minorVersion := range minorVersionList {
normalizedVersion := minorVersion + ".0"
if version.IsVersionGreaterThan(normalizedVersion, migrationHistory.Version) && version.IsVersionGreaterOrEqualThan(currentVersion, normalizedVersion) {
println("applying migration for", normalizedVersion)
if err := db.applyMigrationForMinorVersion(ctx, minorVersion); err != nil {
return fmt.Errorf("failed to apply minor version migration: %w", err)
}
println("start migrate")
for _, minorVersion := range minorVersionList {
normalizedVersion := minorVersion + ".0"
if version.IsVersionGreaterThan(normalizedVersion, migrationHistory.Version) && version.IsVersionGreaterOrEqualThan(currentVersion, normalizedVersion) {
println("applying migration for", normalizedVersion)
if err := db.applyMigrationForMinorVersion(ctx, minorVersion); err != nil {
return fmt.Errorf("failed to apply minor version migration: %w", err)
}
}
}
println("end migrate")
println("end migrate")
// remove the created backup db file after migrate succeed
if err := os.Remove(backupDBFilePath); err != nil {
println(fmt.Sprintf("Failed to remove temp database file, err %v", err))
}
// remove the created backup db file after migrate succeed
if err := os.Remove(backupDBFilePath); err != nil {
println(fmt.Sprintf("Failed to remove temp database file, err %v", err))
}
}
}
@@ -137,7 +137,7 @@ func (db *DB) applyLatestSchema(ctx context.Context) error {
func (db *DB) applyMigrationForMinorVersion(ctx context.Context, minorVersion string) error {
filenames, err := fs.Glob(migrationFS, fmt.Sprintf("%s/%s/*.sql", "migration/prod", minorVersion))
if err != nil {
return err
return fmt.Errorf("failed to read ddl files, err: %w", err)
}
sort.Strings(filenames)
@@ -163,10 +163,11 @@ func (db *DB) applyMigrationForMinorVersion(ctx context.Context, minorVersion st
defer tx.Rollback()
// upsert the newest version to migration_history
version := minorVersion + ".0"
if _, err = upsertMigrationHistory(ctx, tx, &MigrationHistoryUpsert{
Version: minorVersion + ".0",
Version: version,
}); err != nil {
return err
return fmt.Errorf("failed to upsert migration history with version: %s, err: %w", version, err)
}
return tx.Commit()
@@ -175,7 +176,7 @@ func (db *DB) applyMigrationForMinorVersion(ctx context.Context, minorVersion st
func (db *DB) seed(ctx context.Context) error {
filenames, err := fs.Glob(seedFS, fmt.Sprintf("%s/*.sql", "seed"))
if err != nil {
return err
return fmt.Errorf("failed to read seed files, err: %w", err)
}
sort.Strings(filenames)
@@ -203,7 +204,7 @@ func (db *DB) execute(ctx context.Context, stmt string) error {
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, stmt); err != nil {
return err
return fmt.Errorf("failed to execute statement, err: %w", err)
}
return tx.Commit()

View File

@@ -49,7 +49,7 @@ func (s *Store) Vacuum(ctx context.Context) error {
return nil
}
// Exec vacuum records in a transcation.
// Exec vacuum records in a transaction.
func vacuum(ctx context.Context, tx *sql.Tx) error {
if err := vacuumMemo(ctx, tx); err != nil {
return err

View File

@@ -3,7 +3,8 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/logo.webp" type="image/*" />
<meta name="theme-color" content="#f6f5f4" />
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f6f5f4" />
<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" />
<title>Memos</title>

View File

@@ -10,12 +10,11 @@
"dependencies": {
"@emotion/react": "^11.10.5",
"@emotion/styled": "^11.10.5",
"@mui/joy": "^5.0.0-alpha.52",
"@mui/joy": "^5.0.0-alpha.56",
"@reduxjs/toolkit": "^1.8.1",
"axios": "^0.27.2",
"copy-to-clipboard": "^3.3.2",
"dayjs": "^1.11.3",
"emoji-picker-react": "^3.6.2",
"highlight.js": "^11.6.0",
"i18next": "^21.9.2",
"lodash-es": "^4.17.21",
@@ -25,7 +24,8 @@
"react-feather": "^2.0.10",
"react-i18next": "^11.18.6",
"react-redux": "^8.0.1",
"react-router-dom": "^6.4.0"
"react-router-dom": "^6.4.0",
"tailwindcss": "^3.2.4"
},
"devDependencies": {
"@jest/globals": "^29.1.2",
@@ -47,7 +47,6 @@
"lodash": "^4.17.21",
"postcss": "^8.4.5",
"prettier": "2.5.1",
"tailwindcss": "^3.0.18",
"ts-jest": "^29.0.3",
"typescript": "^4.3.2",
"vite": "^3.0.0"

View File

@@ -1,15 +1,18 @@
import { CssVarsProvider } from "@mui/joy/styles";
import { useEffect } from "react";
import { useColorScheme } from "@mui/joy";
import { useEffect, Suspense } from "react";
import { useTranslation } from "react-i18next";
import { RouterProvider } from "react-router-dom";
import { locationService } from "./services";
import { globalService, locationService } from "./services";
import { useAppSelector } from "./store";
import router from "./router";
import * as storage from "./helpers/storage";
import { getSystemColorScheme } from "./helpers/utils";
import Loading from "./pages/Loading";
function App() {
const { i18n } = useTranslation();
const { locale, systemStatus } = useAppSelector((state) => state.global);
const { appearance, locale, systemStatus } = useAppSelector((state) => state.global);
const { mode, setMode } = useColorScheme();
useEffect(() => {
locationService.updateStateWithLocation();
@@ -18,6 +21,15 @@ function App() {
};
}, []);
useEffect(() => {
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => {
if (globalService.getState().appearance === "system") {
const mode = e.matches ? "dark" : "light";
setMode(mode);
}
});
}, []);
// Inject additional style and script codes.
useEffect(() => {
if (systemStatus.additionalStyle) {
@@ -34,16 +46,39 @@ function App() {
}, [systemStatus]);
useEffect(() => {
document.documentElement.setAttribute("lang", locale);
i18n.changeLanguage(locale);
storage.set({
locale: locale,
});
}, [locale]);
useEffect(() => {
storage.set({
appearance: appearance,
});
let currentAppearance = appearance;
if (appearance === "system") {
currentAppearance = getSystemColorScheme();
}
setMode(currentAppearance);
}, [appearance]);
useEffect(() => {
const root = document.documentElement;
if (mode === "light") {
root.classList.remove("dark");
} else if (mode === "dark") {
root.classList.add("dark");
}
}, [mode]);
return (
<CssVarsProvider>
<Suspense fallback={<Loading />}>
<RouterProvider router={router} />
</CssVarsProvider>
</Suspense>
);
}

View File

@@ -18,27 +18,36 @@ const AboutSiteDialog: React.FC<Props> = ({ destroy }: Props) => {
return (
<>
<div className="dialog-header-container">
<p className="title-text">
<span className="icon-text">🤠</span>
{t("common.about")}
<p className="title-text flex items-center">
<img className="w-7 h-auto mr-1" src="/logo.webp" alt="" />
{t("common.about")} memos
</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<Icon.X />
</button>
</div>
<div className="dialog-content-container">
<img className="logo-img" src="/logo-full.webp" alt="" />
<p>{t("slogan")}</p>
<br />
<div className="addtion-info-container">
<div className="border-t mt-1 pt-2 flex flex-row justify-start items-center">
<span className=" text-gray-500 mr-2">Other projects:</span>
<a href="https://github.com/boojack/sticky-notes" className="flex items-center underline text-blue-600 hover:opacity-80">
<img
className="w-5 h-auto mr-1"
src="https://raw.githubusercontent.com/boojack/sticky-notes/main/public/sticky-notes.ico"
alt=""
/>
<span>Sticky notes</span>
</a>
</div>
<div className="mt-4 flex flex-row text-sm justify-start items-center">
<GitHubBadge />
<>
<span className="ml-2">
{t("common.version")}:
<span className="pre-text">
<span className="font-mono">
{profile.version}-{profile.mode}
</span>
🎉
</>
</span>
</div>
</div>
</>

View File

@@ -0,0 +1,52 @@
import { Option, Select } from "@mui/joy";
import { useTranslation } from "react-i18next";
import { globalService, userService } from "../services";
import { useAppSelector } from "../store";
import Icon from "./Icon";
const appearanceList = ["system", "light", "dark"];
const AppearanceSelect = () => {
const user = useAppSelector((state) => state.user.user);
const appearance = useAppSelector((state) => state.global.appearance);
const { t } = useTranslation();
const getPrefixIcon = (apperance: Appearance) => {
const className = "w-4 h-auto";
if (apperance === "light") {
return <Icon.Sun className={className} />;
} else if (apperance === "dark") {
return <Icon.Moon className={className} />;
} else {
return <Icon.Smile className={className} />;
}
};
const handleSelectChange = async (appearance: Appearance) => {
if (user) {
await userService.upsertUserSetting("appearance", appearance);
}
globalService.setAppearance(appearance);
};
return (
<Select
className="!min-w-[10rem] w-auto text-sm"
value={appearance}
onChange={(_, appearance) => {
if (appearance) {
handleSelectChange(appearance);
}
}}
startDecorator={getPrefixIcon(appearance)}
>
{appearanceList.map((item) => (
<Option key={item} value={item} className="whitespace-nowrap">
{t(`setting.apperance-option.${item}`)}
</Option>
))}
</Select>
);
};
export default AppearanceSelect;

View File

@@ -51,7 +51,7 @@ const ArchivedMemo: React.FC<Props> = (props: Props) => {
};
return (
<div className={`memo-wrapper archived-memo ${"memos-" + memo.id}`} onMouseLeave={handleMouseLeaveMemoWrapper}>
<div className={`memo-wrapper archived ${"memos-" + memo.id}`} onMouseLeave={handleMouseLeaveMemoWrapper}>
<div className="memo-top-wrapper">
<span className="time-text">
{t("common.archived-at")} {utils.getDateTimeString(memo.updatedTs)}

View File

@@ -0,0 +1,125 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { validate, ValidatorConfig } from "../helpers/validator";
import { userService } from "../services";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import toastHelper from "./Toast";
const validateConfig: ValidatorConfig = {
minLength: 4,
maxLength: 320,
noSpace: true,
noChinese: true,
};
interface Props extends DialogProps {
user: User;
}
const ChangeMemberPasswordDialog: React.FC<Props> = (props: Props) => {
const { user: propsUser, destroy } = props;
const { t } = useTranslation();
const [newPassword, setNewPassword] = useState("");
const [newPasswordAgain, setNewPasswordAgain] = useState("");
useEffect(() => {
// do nth
}, []);
const handleCloseBtnClick = () => {
destroy();
};
const handleNewPasswordChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value as string;
setNewPassword(text);
};
const handleNewPasswordAgainChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value as string;
setNewPasswordAgain(text);
};
const handleSaveBtnClick = async () => {
if (newPassword === "" || newPasswordAgain === "") {
toastHelper.error(t("message.fill-all"));
return;
}
if (newPassword !== newPasswordAgain) {
toastHelper.error(t("message.new-password-not-match"));
setNewPasswordAgain("");
return;
}
const passwordValidResult = validate(newPassword, validateConfig);
if (!passwordValidResult.result) {
toastHelper.error(`${t("common.password")} ${t(passwordValidResult.reason as string)}`);
return;
}
try {
await userService.patchUser({
id: propsUser.id,
password: newPassword,
});
toastHelper.info(t("message.password-changed"));
handleCloseBtnClick();
} catch (error: any) {
console.error(error);
toastHelper.error(error.response.data.message);
}
};
return (
<>
<div className="dialog-header-container !w-64">
<p className="title-text">
{t("setting.account-section.change-password")} ({propsUser.username})
</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<Icon.X />
</button>
</div>
<div className="dialog-content-container">
<p className="text-sm mb-1">{t("common.new-password")}</p>
<input
type="password"
className="input-text"
placeholder={t("common.repeat-new-password")}
value={newPassword}
onChange={handleNewPasswordChanged}
/>
<p className="text-sm mb-1 mt-2">{t("common.repeat-new-password")}</p>
<input
type="password"
className="input-text"
placeholder={t("common.repeat-new-password")}
value={newPasswordAgain}
onChange={handleNewPasswordAgainChanged}
/>
<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 showChangeMemberPasswordDialog(user: User) {
generateDialog(
{
className: "change-member-password-dialog",
},
ChangeMemberPasswordDialog,
{ user }
);
}
export default showChangeMemberPasswordDialog;

View File

@@ -8,7 +8,7 @@ import toastHelper from "./Toast";
const validateConfig: ValidatorConfig = {
minLength: 4,
maxLength: 24,
maxLength: 320,
noSpace: true,
noChinese: true,
};
@@ -52,7 +52,7 @@ const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => {
const passwordValidResult = validate(newPassword, validateConfig);
if (!passwordValidResult.result) {
toastHelper.error(`${t("common.password")} ${passwordValidResult.reason}`);
toastHelper.error(`${t("common.password")} ${t(passwordValidResult.reason as string)}`);
return;
}
@@ -82,6 +82,7 @@ const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => {
<p className="text-sm mb-1">{t("common.new-password")}</p>
<input
type="password"
autoComplete="new-password"
className="input-text"
placeholder={t("common.repeat-new-password")}
value={newPassword}
@@ -90,6 +91,7 @@ const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => {
<p className="text-sm mb-1 mt-2">{t("common.repeat-new-password")}</p>
<input
type="password"
autoComplete="new-password"
className="input-text"
placeholder={t("common.repeat-new-password")}
value={newPasswordAgain}

View File

@@ -192,7 +192,7 @@ const MemoFilterInputer: React.FC<MemoFilterInputerProps> = (props: MemoFilterIn
if (["AND", "OR"].includes(value)) {
handleFilterChange(index, {
...filter,
relation: value as MemoFilterRalation,
relation: value as MemoFilterRelation,
});
}
};

View File

@@ -16,7 +16,7 @@ interface Props extends DialogProps {
currentDateStamp: DateStamp;
}
const monthChineseStrArray = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sept", "Oct", "Nov", "Dev"];
const monthChineseStrArray = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sept", "Oct", "Nov", "Dec"];
const weekdayChineseStrArray = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const DailyReviewDialog: React.FC<Props> = (props: Props) => {
@@ -43,7 +43,6 @@ const DailyReviewDialog: React.FC<Props> = (props: Props) => {
toggleShowDatePicker(false);
toImage(memosElRef.current, {
backgroundColor: "#ffffff",
pixelRatio: window.devicePixelRatio * 2,
})
.then((url) => {

View File

@@ -4,6 +4,8 @@ import { Provider } from "react-redux";
import { ANIMATION_DURATION } from "../../helpers/consts";
import store from "../../store";
import "../../less/base-dialog.less";
import { CssVarsProvider } from "@mui/joy";
import theme from "../../theme";
interface DialogConfig {
className: string;
@@ -77,9 +79,11 @@ export function generateDialog<T extends DialogProps>(
const Fragment = (
<Provider store={store}>
<BaseDialog destroy={cbs.destroy} clickSpaceDestroy={true} {...config}>
<DialogComponent {...dialogProps} />
</BaseDialog>
<CssVarsProvider theme={theme}>
<BaseDialog destroy={cbs.destroy} clickSpaceDestroy={true} {...config}>
<DialogComponent {...dialogProps} />
</BaseDialog>
</CssVarsProvider>
</Provider>
);

View File

@@ -5,6 +5,7 @@ import "../../less/editor.less";
export interface EditorRefActions {
focus: FunctionType;
insertText: (text: string, prefix?: string, suffix?: string) => void;
removeText: (start: number, length: number) => void;
setContent: (text: string) => void;
getContent: () => string;
getSelectedContent: () => string;
@@ -67,6 +68,19 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
handleContentChangeCallback(editorRef.current.value);
refresh();
},
removeText: (start: number, length: number) => {
if (!editorRef.current) {
return;
}
const prevValue = editorRef.current.value;
const value = prevValue.slice(0, start) + prevValue.slice(start + length);
editorRef.current.value = value;
editorRef.current.focus();
editorRef.current.selectionEnd = start;
handleContentChangeCallback(editorRef.current.value);
refresh();
},
setContent: (text: string) => {
if (editorRef.current) {
editorRef.current.value = text;

View File

@@ -1,52 +0,0 @@
import Picker, { IEmojiPickerProps } from "emoji-picker-react";
import { useEffect, useState } from "react";
interface Props {
shouldShow: boolean;
onEmojiClick: IEmojiPickerProps["onEmojiClick"];
onShouldShowEmojiPickerChange: (status: boolean) => void;
}
interface State {
hasShown: boolean;
}
export const EmojiPicker: React.FC<Props> = (props: Props) => {
const { shouldShow, onEmojiClick, onShouldShowEmojiPickerChange } = props;
const [state, setState] = useState<State>({
hasShown: false,
});
useEffect(() => {
if (shouldShow) {
const handleClickOutside = (event: MouseEvent) => {
event.stopPropagation();
const emojiWrapper = document.querySelector(".emoji-picker-react");
const isContains = emojiWrapper?.contains(event.target as Node);
if (!isContains) {
onShouldShowEmojiPickerChange(false);
}
};
window.addEventListener("click", handleClickOutside, {
capture: true,
once: true,
});
setState({
hasShown: true,
});
}
}, [shouldShow]);
return (
<>
{state.hasShown && (
<div className={`emoji-picker ${shouldShow ? "" : "hidden"}`}>
<Picker onEmojiClick={onEmojiClick} disableSearchBar />
</div>
)}
</>
);
};
export default EmojiPicker;

View File

@@ -1,7 +1,6 @@
import { useEffect, useState } from "react";
import * as api from "../helpers/api";
import Icon from "./Icon";
import "../less/github-badge.less";
const GitHubBadge = () => {
const [starCount, setStarCount] = useState(0);
@@ -13,12 +12,15 @@ const GitHubBadge = () => {
}, []);
return (
<a className="github-badge-container" href="https://github.com/usememos/memos">
<div className="github-icon">
<Icon.GitHub className="icon-img" />
<a
className="h-7 flex flex-row justify-start items-center border dark:border-zinc-600 rounded cursor-pointer hover:opacity-80"
href="https://github.com/usememos/memos"
>
<div className="apply w-auto h-full px-2 flex flex-row justify-center items-center text-xs bg-gray-100 dark:bg-zinc-700">
<Icon.GitHub className="mr-1 w-4 h-4" />
Star
</div>
<div className="count-text">{starCount || ""}</div>
<div className="w-auto h-full flex flex-row justify-center items-center px-3 text-xs font-bold">{starCount || ""}</div>
</a>
);
};

View File

@@ -1,22 +1,18 @@
import copy from "copy-to-clipboard";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { memo, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import "dayjs/locale/zh";
import { editorStateService, locationService, memoService, userService } from "../services";
import Icon from "./Icon";
import toastHelper from "./Toast";
import MemoContent from "./MemoContent";
import MemoResources from "./MemoResources";
import showShareMemoImageDialog from "./ShareMemoImageDialog";
import showShareMemo from "./ShareMemoDialog";
import showPreviewImageDialog from "./PreviewImageDialog";
import showChangeMemoCreatedTsDialog from "./ChangeMemoCreatedTsDialog";
import "../less/memo.less";
dayjs.extend(relativeTime);
interface Props {
memo: Memo;
highlightWord?: string;
@@ -93,7 +89,7 @@ const Memo: React.FC<Props> = (props: Props) => {
};
const handleGenMemoImageBtnClick = () => {
showShareMemoImageDialog(memo);
showShareMemo(memo);
};
const handleMemoContentClick = async (e: React.MouseEvent) => {
@@ -139,22 +135,9 @@ const Memo: React.FC<Props> = (props: Props) => {
}
}
} else if (targetEl.tagName === "IMG") {
const currImgUrl = targetEl.getAttribute("src");
if (currImgUrl) {
// use regex to get all image urls from memo content
const imageUrls =
memo.content.match(/!\[.*?\]\((.*?)\)/g)?.map(
(item) =>
item
.match(/\((.*?)\)/g)
?.slice(-1)[0]
.slice(1, -1) ?? ""
) ?? [];
showPreviewImageDialog(
imageUrls,
imageUrls.findIndex((item) => item === currImgUrl)
);
const imgUrl = targetEl.getAttribute("src");
if (imgUrl) {
showPreviewImageDialog([imgUrl], 0);
}
}
};
@@ -162,9 +145,7 @@ const Memo: React.FC<Props> = (props: Props) => {
const handleMemoContentDoubleClick = (e: React.MouseEvent) => {
const targetEl = e.target as HTMLElement;
if (targetEl.className === "memo-link-text") {
return;
} else if (targetEl.className === "tag-span") {
if (targetEl.className === "tag-span") {
return;
} else if (targetEl.classList.contains("todo-block")) {
return;

View File

@@ -3,8 +3,7 @@ import { useTranslation } from "react-i18next";
import { marked } from "../labs/marked";
import { highlightWithWord } from "../labs/highlighter";
import Icon from "./Icon";
import { SETTING_IS_FOLDING_ENABLED_KEY, IS_FOLDING_ENABLED_DEFAULT_VALUE } from "../helpers/consts";
import useLocalStorage from "../hooks/useLocalStorage";
import { useAppSelector } from "../store";
import "../less/memo-content.less";
export interface DisplayConfig {
@@ -37,7 +36,8 @@ const MemoContent: React.FC<Props> = (props: Props) => {
return firstHorizontalRuleIndex !== -1 ? content.slice(0, firstHorizontalRuleIndex) : content;
}, [content]);
const { t } = useTranslation();
const [isFoldingEnabled] = useLocalStorage(SETTING_IS_FOLDING_ENABLED_KEY, IS_FOLDING_ENABLED_DEFAULT_VALUE);
const user = useAppSelector((state) => state.user.user);
const [state, setState] = useState<State>({
expandButtonStatus: -1,
});
@@ -52,15 +52,20 @@ const MemoContent: React.FC<Props> = (props: Props) => {
return;
}
if (displayConfig.enableExpand && isFoldingEnabled) {
if (displayConfig.enableExpand && user && user.localSetting.enableFoldMemo) {
if (foldedContent.length !== content.length) {
setState({
...state,
expandButtonStatus: 0,
});
}
} else {
setState({
...state,
expandButtonStatus: -1,
});
}
}, []);
}, [user?.localSetting.enableFoldMemo]);
const handleMemoContentClick = async (e: React.MouseEvent) => {
if (onMemoContentClick) {

View File

@@ -1,5 +1,4 @@
import { IEmojiData } from "emoji-picker-react";
import { toLower } from "lodash";
import { last, toLower } from "lodash";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { deleteMemoResource, upsertMemoResource } from "../helpers/api";
@@ -11,10 +10,12 @@ import Icon from "./Icon";
import toastHelper from "./Toast";
import Selector from "./common/Selector";
import Editor, { EditorRefActions } from "./Editor/Editor";
import EmojiPicker from "./Editor/EmojiPicker";
import ResourceIcon from "./ResourceIcon";
import showResourcesSelectorDialog from "./ResourcesSelectorDialog";
import "../less/memo-editor.less";
const listItemSymbolList = ["* ", "- ", "- [ ] ", "- [x] ", "- [X] "];
const getEditorContentCache = (): string => {
return storage.get(["editorContentCache"]).editorContentCache ?? "";
};
@@ -34,7 +35,6 @@ const setEditingMemoVisibilityCache = (visibility: Visibility) => {
interface State {
fullscreen: boolean;
isUploadingResource: boolean;
resourceList: Resource[];
shouldShowEmojiPicker: boolean;
}
@@ -48,12 +48,11 @@ const MemoEditor = () => {
isUploadingResource: false,
fullscreen: false,
shouldShowEmojiPicker: false,
resourceList: [],
});
const [allowSave, setAllowSave] = useState<boolean>(false);
const prevGlobalStateRef = useRef(editorState);
const prevEditorStateRef = useRef(editorState);
const editorRef = useRef<EditorRefActions>(null);
const tagSeletorRef = useRef<HTMLDivElement>(null);
const tagSelectorRef = useRef<HTMLDivElement>(null);
const memoVisibilityOptionSelectorItems = VISIBILITY_SELECTOR_ITEMS.map((item) => {
return {
value: item.value,
@@ -79,13 +78,8 @@ const MemoEditor = () => {
if (memo) {
handleEditorFocus();
editorStateService.setMemoVisibility(memo.visibility);
editorStateService.setResourceList(memo.resourceList);
editorRef.current?.setContent(memo.content ?? "");
setState((state) => {
return {
...state,
resourceList: memo.resourceList,
};
});
}
});
storage.set({
@@ -95,23 +89,14 @@ const MemoEditor = () => {
storage.remove(["editingMemoIdCache"]);
}
prevGlobalStateRef.current = editorState;
prevEditorStateRef.current = editorState;
}, [editorState.editMemoId]);
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === "Escape") {
if (state.fullscreen) {
handleFullscreenBtnClick();
} else {
handleCancelEdit();
}
return;
}
if (event.key === "Tab") {
event.preventDefault();
editorRef.current?.insertText(" ".repeat(TAB_SPACE_WIDTH));
if (!editorRef.current) {
return;
}
if (event.ctrlKey || event.metaKey) {
if (event.key === "Enter") {
handleSaveBtnClick();
@@ -119,20 +104,53 @@ const MemoEditor = () => {
}
if (event.key === "b") {
event.preventDefault();
editorRef.current?.insertText("", "**", "**");
editorRef.current.insertText("", "**", "**");
return;
}
if (event.key === "i") {
event.preventDefault();
editorRef.current?.insertText("", "*", "*");
editorRef.current.insertText("", "*", "*");
return;
}
if (event.key === "e") {
event.preventDefault();
editorRef.current?.insertText("", "`", "`");
editorRef.current.insertText("", "`", "`");
return;
}
}
if (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)) {
event.preventDefault();
editorRef.current.removeText(cursorPosition - rowValue.length, rowValue.length);
} else {
for (const listItemSymbol of listItemSymbolList) {
if (rowValue.startsWith(listItemSymbol)) {
event.preventDefault();
editorRef.current.insertText("", `\n${listItemSymbol}`);
}
}
}
}
return;
}
if (event.key === "Escape") {
if (state.fullscreen) {
handleFullscreenBtnClick();
} else if (editorState.editMemoId) {
handleCancelEdit();
}
return;
}
if (event.key === "Tab") {
event.preventDefault();
editorRef.current.insertText(" ".repeat(TAB_SPACE_WIDTH));
return;
}
};
const handleDropEvent = async (event: React.DragEvent) => {
@@ -148,12 +166,7 @@ const MemoEditor = () => {
}
}
}
setState((state) => {
return {
...state,
resourceList: [...state.resourceList, ...resourceList],
};
});
editorStateService.setResourceList([...editorState.resourceList, ...resourceList]);
}
};
@@ -163,12 +176,7 @@ const MemoEditor = () => {
const file = event.clipboardData.files[0];
const resource = await handleUploadResource(file);
if (resource) {
setState((state) => {
return {
...state,
resourceList: [...state.resourceList, resource],
};
});
editorStateService.setResourceList([...editorState.resourceList, resource]);
}
}
};
@@ -216,7 +224,7 @@ const MemoEditor = () => {
id: prevMemo.id,
content,
visibility: editorState.memoVisibility,
resourceIdList: state.resourceList.map((resource) => resource.id),
resourceIdList: editorState.resourceList.map((resource) => resource.id),
});
}
editorStateService.clearEditMemo();
@@ -224,7 +232,7 @@ const MemoEditor = () => {
await memoService.createMemo({
content,
visibility: editorState.memoVisibility,
resourceIdList: state.resourceList.map((resource) => resource.id),
resourceIdList: editorState.resourceList.map((resource) => resource.id),
});
locationService.clearQuery();
}
@@ -237,23 +245,22 @@ const MemoEditor = () => {
return {
...state,
fullscreen: false,
resourceList: [],
};
});
editorStateService.clearResourceList();
setEditorContentCache("");
storage.remove(["editingMemoVisibilityCache"]);
editorRef.current?.setContent("");
};
const handleCancelEdit = () => {
setState({
...state,
resourceList: [],
});
editorStateService.clearEditMemo();
editorRef.current?.setContent("");
setEditorContentCache("");
storage.remove(["editingMemoVisibilityCache"]);
if (editorState.editMemoId) {
editorStateService.clearEditMemo();
editorStateService.clearResourceList();
editorRef.current?.setContent("");
setEditorContentCache("");
storage.remove(["editingMemoVisibilityCache"]);
}
};
const handleContentChange = (content: string) => {
@@ -261,10 +268,6 @@ const MemoEditor = () => {
setEditorContentCache(content);
};
const handleEmojiPickerBtnClick = () => {
handleChangeShouldShowEmojiPicker(!state.shouldShowEmojiPicker);
};
const handleCheckBoxBtnClick = () => {
if (!editorRef.current) {
return;
@@ -317,12 +320,7 @@ const MemoEditor = () => {
}
}
}
setState((state) => {
return {
...state,
resourceList: [...state.resourceList, ...resourceList],
};
});
editorStateService.setResourceList([...editorState.resourceList, ...resourceList]);
document.body.removeChild(inputEl);
};
inputEl.click();
@@ -337,37 +335,15 @@ const MemoEditor = () => {
});
};
const handleTagSeletorClick = useCallback((event: React.MouseEvent) => {
if (tagSeletorRef.current !== event.target && tagSeletorRef.current?.contains(event.target as Node)) {
const handleTagSelectorClick = useCallback((event: React.MouseEvent) => {
if (tagSelectorRef.current !== event.target && tagSelectorRef.current?.contains(event.target as Node)) {
editorRef.current?.insertText(`#${(event.target as HTMLElement).textContent} ` ?? "");
handleEditorFocus();
}
}, []);
const handleChangeShouldShowEmojiPicker = (status: boolean) => {
setState({
...state,
shouldShowEmojiPicker: status,
});
};
const handleEmojiClick = (_: any, emojiObject: IEmojiData) => {
if (!editorRef.current) {
return;
}
editorRef.current.insertText(`${emojiObject.emoji}`);
handleChangeShouldShowEmojiPicker(false);
};
const handleDeleteResource = async (resourceId: ResourceId) => {
setState((state) => {
return {
...state,
resourceList: state.resourceList.filter((resource) => resource.id !== resourceId),
};
});
editorStateService.setResourceList(editorState.resourceList.filter((resource) => resource.id !== resourceId));
if (editorState.editMemoId) {
await deleteMemoResource(editorState.editMemoId, resourceId);
}
@@ -415,7 +391,7 @@ const MemoEditor = () => {
<div className="common-tools-container">
<div className="action-btn tag-action">
<Icon.Hash className="icon-img" />
<div ref={tagSeletorRef} className="tag-list" onClick={handleTagSeletorClick}>
<div ref={tagSelectorRef} className="tag-list" onClick={handleTagSelectorClick}>
{tags.length > 0 ? (
tags.map((tag) => {
return (
@@ -431,32 +407,34 @@ const MemoEditor = () => {
)}
</div>
</div>
<button className="action-btn !hidden sm:!flex ">
<Icon.Smile className="icon-img" onClick={handleEmojiPickerBtnClick} />
</button>
<button className="action-btn">
<Icon.CheckSquare className="icon-img" onClick={handleCheckBoxBtnClick} />
</button>
<button className="action-btn">
<Icon.Code className="icon-img" onClick={handleCodeBlockBtnClick} />
</button>
<button className="action-btn">
<Icon.FileText className="icon-img" onClick={handleUploadFileBtnClick} />
<div className="action-btn resource-btn">
<Icon.FileText className="icon-img" />
<span className={`tip-text ${state.isUploadingResource ? "!block" : ""}`}>Uploading</span>
</button>
<div className="resource-action-list">
<div className="resource-action-item" onClick={handleUploadFileBtnClick}>
<Icon.Upload className="icon-img" />
<span>{t("editor.local")}</span>
</div>
<div className="resource-action-item" onClick={showResourcesSelectorDialog}>
<Icon.Database className="icon-img" />
<span>{t("editor.resources")}</span>
</div>
</div>
</div>
<button className="action-btn" onClick={handleFullscreenBtnClick}>
{state.fullscreen ? <Icon.Minimize className="icon-img" /> : <Icon.Maximize className="icon-img" />}
</button>
<EmojiPicker
shouldShow={state.shouldShowEmojiPicker}
onEmojiClick={handleEmojiClick}
onShouldShowEmojiPickerChange={handleChangeShouldShowEmojiPicker}
/>
</div>
</div>
{state.resourceList.length > 0 && (
{editorState.resourceList && editorState.resourceList.length > 0 && (
<div className="resource-list-wrapper">
{state.resourceList.map((resource) => {
{editorState.resourceList.map((resource) => {
return (
<div key={resource.id} className="resource-container">
<ResourceIcon resourceType="resource.type" className="icon-img" />

View File

@@ -24,7 +24,7 @@ const MemoResources: React.FC<Props> = (props: Props) => {
const availableResourceList = resourceList.filter((resource) => resource.type.startsWith("image") || resource.type.startsWith("video"));
const otherResourceList = resourceList.filter((resource) => !availableResourceList.includes(resource));
const handlPreviewBtnClick = (resource: Resource) => {
const handlePreviewBtnClick = (resource: Resource) => {
const resourceUrl = `${window.location.origin}/o/r/${resource.id}/${resource.filename}`;
window.open(resourceUrl);
};
@@ -45,8 +45,10 @@ const MemoResources: React.FC<Props> = (props: Props) => {
return (
<Image className="memo-resource" key={resource.id} imgUrls={imgUrls} index={imgUrls.findIndex((item) => item === url)} />
);
} else {
} else if (resource.type.startsWith("video")) {
return <video className="memo-resource" controls key={resource.id} src={url} />;
} else {
return null;
}
})}
</div>
@@ -54,7 +56,7 @@ const MemoResources: React.FC<Props> = (props: Props) => {
<div className="other-resource-wrapper">
{otherResourceList.map((resource) => {
return (
<div className="other-resource-container" key={resource.id} onClick={() => handlPreviewBtnClick(resource)}>
<div className="other-resource-container" key={resource.id} onClick={() => handlePreviewBtnClick(resource)}>
<Icon.FileText className="icon-img" />
<span className="name-text">{resource.filename}</span>
</div>

View File

@@ -3,7 +3,7 @@ import { memoService, shortcutService } from "../services";
import { useAppSelector } from "../store";
import Icon from "./Icon";
import SearchBar from "./SearchBar";
import { toggleSiderbar } from "./Sidebar";
import { toggleSidebar } from "./Sidebar";
import "../less/memos-header.less";
let prevRequestTimestamp = Date.now();
@@ -38,7 +38,7 @@ const MemosHeader = () => {
return (
<div className="section-header-container memos-header-container">
<div className="title-container">
<div className="action-btn" onClick={() => toggleSiderbar(true)}>
<div className="action-btn" onClick={() => toggleSidebar(true)}>
<Icon.Menu className="icon-img" />
</div>
<span className="title-text" onClick={handleTitleTextClick}>

View File

@@ -1,16 +1,33 @@
import { useState } from "react";
import React, { useState } from "react";
import * as utils from "../helpers/utils";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import "../less/preview-image-dialog.less";
const MIN_SCALE = 0.5;
const MAX_SCALE = 5;
const SCALE_UNIT = 0.25;
interface Props extends DialogProps {
imgUrls: string[];
initialIndex: number;
}
interface State {
angle: number;
scale: number;
originX: number;
originY: number;
}
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 handleCloseBtnClick = () => {
destroy();
@@ -39,6 +56,38 @@ const PreviewImageDialog: React.FC<Props> = ({ destroy, imgUrls, initialIndex }:
}
};
const handleImgRotate = (event: React.MouseEvent, angle: number) => {
const curImgAngle = (state.angle + angle + 360) % 360;
setState({
...state,
originX: -1,
originY: -1,
angle: curImgAngle,
});
};
const handleImgContainerScroll = (event: React.WheelEvent) => {
const offsetX = event.nativeEvent.offsetX;
const offsetY = event.nativeEvent.offsetY;
const sign = event.deltaY < 0 ? 1 : -1;
const curAngle = Math.max(MIN_SCALE, Math.min(MAX_SCALE, state.scale + sign * SCALE_UNIT));
setState({
...state,
originX: offsetX,
originY: offsetY,
scale: curAngle,
});
};
const getImageComputedStyle = () => {
return {
transform: `scale(${state.scale}) rotate(${state.angle}deg)`,
transformOrigin: `${state.originX === -1 ? "center" : `${state.originX}px`} ${
state.originY === -1 ? "center" : `${state.originY}px`
}`,
};
};
return (
<>
<div className="btns-container">
@@ -48,9 +97,20 @@ const PreviewImageDialog: React.FC<Props> = ({ destroy, imgUrls, initialIndex }:
<button className="btn" onClick={handleDownloadBtnClick}>
<Icon.Download className="icon-img" />
</button>
<button className="btn" onClick={(e) => handleImgRotate(e, -90)}>
<Icon.RotateCcw className="icon-img" />
</button>
<button className="btn" onClick={(e) => handleImgRotate(e, 90)}>
<Icon.RotateCw className="icon-img" />
</button>
</div>
<div className="img-container" onClick={handleImgContainerClick}>
<img onClick={(e) => e.stopPropagation()} src={imgUrls[currentIndex]} />
<img
onClick={(e) => e.stopPropagation()}
src={imgUrls[currentIndex]}
onWheel={handleImgContainerScroll}
style={getImageComputedStyle()}
/>
</div>
</>
);

View File

@@ -1,7 +1,7 @@
import { Tooltip } from "@mui/joy";
import copy from "copy-to-clipboard";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import * as utils from "../helpers/utils";
import useLoading from "../hooks/useLoading";
import { resourceService } from "../services";
import { useAppSelector } from "../store";
@@ -83,15 +83,15 @@ const ResourcesDialog: React.FC<Props> = (props: Props) => {
inputEl.click();
};
const getResouceUrl = useCallback((resource: Resource) => {
const getResourceUrl = useCallback((resource: Resource) => {
return `${window.location.origin}/o/r/${resource.id}/${resource.filename}`;
}, []);
const handlePreviewBtnClick = (resource: Resource) => {
const resourceUrl = getResouceUrl(resource);
const resourceUrl = getResourceUrl(resource);
if (resource.type.startsWith("image")) {
showPreviewImageDialog(
resources.filter((r) => r.type.startsWith("image")).map((r) => getResouceUrl(r)),
resources.filter((r) => r.type.startsWith("image")).map((r) => getResourceUrl(r)),
resources.findIndex((r) => r.id === resource.id)
);
} else {
@@ -149,20 +149,6 @@ const ResourcesDialog: React.FC<Props> = (props: Props) => {
});
};
const handleResourceNameOrTypeMouseEnter = useCallback((event: React.MouseEvent, nameOrType: string) => {
const tempDiv = document.createElement("div");
tempDiv.className = "usage-detail-container pop-up";
const bounding = utils.getElementBounding(event.target as HTMLElement);
tempDiv.style.left = bounding.left + "px";
tempDiv.style.top = bounding.top - 2 + "px";
tempDiv.innerHTML = `<span>${nameOrType}</span>`;
document.body.appendChild(tempDiv);
}, []);
const handleResourceNameOrTypeMouseLeave = useCallback(() => {
document.body.querySelectorAll("div.usage-detail-container.pop-up").forEach((node) => node.remove());
}, []);
return (
<>
<div className="dialog-header-container">
@@ -206,39 +192,34 @@ const ResourcesDialog: React.FC<Props> = (props: Props) => {
resources.map((resource) => (
<div key={resource.id} className="resource-container">
<span className="field-text id-text">{resource.id}</span>
<span className="field-text name-text">
<span
onMouseEnter={(e) => handleResourceNameOrTypeMouseEnter(e, resource.filename)}
onMouseLeave={handleResourceNameOrTypeMouseLeave}
>
{resource.filename}
</span>
</span>
<Tooltip title={resource.filename}>
<span className="field-text name-text">{resource.filename}</span>
</Tooltip>
<div className="buttons-container">
<Dropdown
actionsClassName="!w-28"
actions={
<>
<button
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded hover:bg-gray-100"
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-600"
onClick={() => handlePreviewBtnClick(resource)}
>
{t("resources.preview")}
</button>
<button
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded hover:bg-gray-100"
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-600"
onClick={() => handleRenameBtnClick(resource)}
>
{t("resources.rename")}
</button>
<button
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded hover:bg-gray-100"
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-600"
onClick={() => handleCopyResourceLinkBtnClick(resource)}
>
{t("resources.copy-link")}
</button>
<button
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded text-red-600 hover:bg-gray-100"
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded text-red-600 hover:bg-gray-100 dark:hover:bg-zinc-600"
onClick={() => handleDeleteResourceBtnClick(resource)}
>
{t("common.delete")}

View File

@@ -0,0 +1,155 @@
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 Icon from "./Icon";
import toastHelper from "./Toast";
import { generateDialog } from "./Dialog";
import showPreviewImageDialog from "./PreviewImageDialog";
import "../less/resources-selector-dialog.less";
type Props = DialogProps;
interface State {
checkedArray: boolean[];
}
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 [state, setState] = useState<State>({
checkedArray: [],
});
useEffect(() => {
resourceService
.fetchResourceList()
.catch((error) => {
console.error(error);
toastHelper.error(error.response.data.message);
})
.finally(() => {
loadingState.setFinish();
});
}, []);
useEffect(() => {
const checkedResourceIdArray = editorState.resourceList.map((resource) => resource.id);
setState({
checkedArray: resources.map((resource) => {
return checkedResourceIdArray.includes(resource.id);
}),
});
}, [resources]);
const getResourceUrl = useCallback((resource: Resource) => {
return `${window.location.origin}/o/r/${resource.id}/${resource.filename}`;
}, []);
const handlePreviewBtnClick = (resource: Resource) => {
const resourceUrl = getResourceUrl(resource);
if (resource.type.startsWith("image")) {
showPreviewImageDialog(
resources.filter((r) => r.type.startsWith("image")).map((r) => getResourceUrl(r)),
resources.findIndex((r) => r.id === resource.id)
);
} else {
window.open(resourceUrl);
}
};
const handleCheckboxChange = (index: number) => {
const newCheckedArr = state.checkedArray;
newCheckedArr[index] = !newCheckedArr[index];
setState({
checkedArray: newCheckedArr,
});
};
const handleConfirmBtnClick = () => {
const resourceList = resources.filter((_, index) => {
return state.checkedArray[index];
});
editorStateService.setResourceList(resourceList);
destroy();
};
return (
<>
<div className="dialog-header-container">
<p className="title-text">
<span className="icon-text">🌄</span>
{t("sidebar.resources")}
</p>
<button className="btn close-btn" onClick={destroy}>
<Icon.X className="icon-img" />
</button>
</div>
<div className="dialog-content-container">
{loadingState.isLoading ? (
<div className="loading-text-container">
<p className="tip-text">{t("resources.fetching-data")}</p>
</div>
) : (
<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></span>
</div>
{resources.length === 0 ? (
<p className="tip-text">{t("resources.no-resources")}</p>
) : (
resources.map((resource, index) => (
<div key={resource.id} className="resource-container">
<span className="field-text id-text">{resource.id}</span>
<Tooltip placement="top-start" title={resource.filename}>
<span className="field-text name-text">{resource.filename}</span>
</Tooltip>
<div className="flex justify-end">
<Icon.Eye
className=" text-left text-sm leading-6 px-1 mr-2 cursor-pointer hover:opacity-80"
onClick={() => handlePreviewBtnClick(resource)}
>
{t("resources.preview")}
</Icon.Eye>
<Checkbox checked={state.checkedArray[index]} onChange={() => handleCheckboxChange(index)} />
</div>
</div>
))
)}
</div>
)}
<div className="flex justify-between w-full mt-2 px-2">
<span className="text-sm font-mono text-gray-400 leading-8">
{t("message.count-selected-resources")}: {state.checkedArray.filter((checked) => checked).length}
</span>
<div className="flex flex-row justify-start items-center">
<div
className="text-sm cursor-pointer px-3 py-1 rounded flex flex-row justify-center items-center border border-blue-600 text-blue-600 bg-blue-50 hover:opacity-80"
onClick={handleConfirmBtnClick}
>
<Icon.PlusSquare className=" w-4 h-auto mr-1" />
<span>{t("common.confirm")}</span>
</div>
</div>
</div>
</div>
</>
);
};
export default function showResourcesSelectorDialog() {
generateDialog(
{
className: "resources-selector-dialog",
},
ResourcesSelectorDialog,
{}
);
}

View File

@@ -34,7 +34,14 @@ const SearchBar = () => {
<div className="search-bar-container">
<div className="search-bar-inputer">
<Icon.Search className="icon-img" />
<input className="text-input" type="text" placeholder="" value={queryText} onChange={handleTextQueryInput} />
<input
className="text-input"
autoComplete="new-password"
type="text"
placeholder=""
value={queryText}
onChange={handleTextQueryInput}
/>
</div>
<div className="quickly-action-wrapper">
<div className="quickly-action-container">

View File

@@ -6,6 +6,7 @@ import * as api from "../../helpers/api";
import toastHelper from "../Toast";
import Dropdown from "../common/Dropdown";
import { showCommonDialog } from "../Dialog/CommonDialog";
import showChangeMemberPasswordDialog from "../ChangeMemberPasswordDialog";
import "../../less/settings/member-section.less";
interface State {
@@ -60,7 +61,6 @@ const PreferencesSection = () => {
try {
await api.createUser(userCreate);
} catch (error: any) {
console.error(error);
toastHelper.error(error.response.data.message);
}
await fetchUserList();
@@ -70,6 +70,10 @@ const PreferencesSection = () => {
});
};
const handleChangePasswordClick = (user: User) => {
showChangeMemberPasswordDialog(user);
};
const handleArchiveUserClick = (user: User) => {
showCommonDialog({
title: `Archive Member`,
@@ -113,17 +117,33 @@ const PreferencesSection = () => {
<div className="create-member-container">
<div className="input-form-container">
<span className="field-text">{t("common.username")}</span>
<input type="text" placeholder={t("common.username")} value={state.createUserUsername} onChange={handleUsernameInputChange} />
<input
type="text"
autoComplete="new-password"
placeholder={t("common.username")}
value={state.createUserUsername}
onChange={handleUsernameInputChange}
/>
</div>
<div className="input-form-container">
<span className="field-text">{t("common.password")}</span>
<input type="text" placeholder={t("common.password")} value={state.createUserPassword} onChange={handlePasswordInputChange} />
<input
type="password"
autoComplete="new-password"
placeholder={t("common.password")}
value={state.createUserPassword}
onChange={handlePasswordInputChange}
/>
</div>
<div className="btns-container">
<button onClick={handleCreateUserBtnClick}>{t("common.create")}</button>
<button className="btn-normal" onClick={handleCreateUserBtnClick}>
{t("common.create")}
</button>
</div>
</div>
<p className="title-text">{t("setting.member-list")}</p>
<div className="w-full flex flex-row justify-between items-center">
<div className="title-text">{t("setting.member-list")}</div>
</div>
<div className="member-container field-container">
<span className="field-text">ID</span>
<span className="field-text username-field">{t("common.username")}</span>
@@ -138,12 +158,17 @@ const PreferencesSection = () => {
<span className="tip-text">{t("common.yourself")}</span>
) : (
<Dropdown
actionsClassName="!w-24"
actions={
<>
<button
className="w-full text-left text-sm whitespace-nowrap leading-6 py-1 px-3 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-600"
onClick={() => handleChangePasswordClick(user)}
>
{t("setting.account-section.change-password")}
</button>
{user.rowStatus === "NORMAL" ? (
<button
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded hover:bg-gray-100"
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-600"
onClick={() => handleArchiveUserClick(user)}
>
{t("common.archive")}
@@ -151,13 +176,13 @@ const PreferencesSection = () => {
) : (
<>
<button
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded hover:bg-gray-100"
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-600"
onClick={() => handleRestoreUserClick(user)}
>
{t("common.restore")}
</button>
<button
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded text-red-600 hover:bg-gray-100"
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded text-red-600 hover:bg-gray-100 dark:hover:bg-zinc-600"
onClick={() => handleDeleteUserClick(user)}
>
{t("common.delete")}

View File

@@ -36,10 +36,10 @@ const MyAccountSection = () => {
<div className="flex flex-row justify-start items-center text-base text-gray-600">{user.email}</div>
<div className="w-full flex flex-row justify-start items-center mt-2 space-x-2">
<button className="btn-normal" onClick={showUpdateAccountDialog}>
Update Information
{t("setting.account-section.update-information")}
</button>
<button className="btn-normal" onClick={showChangePasswordDialog}>
Change Password
{t("setting.account-section.change-password")}
</button>
</div>
</div>

View File

@@ -1,15 +1,10 @@
import { Select, Switch, Option } from "@mui/joy";
import { useTranslation } from "react-i18next";
import Switch from "@mui/joy/Switch";
import { globalService, userService } from "../../services";
import { useAppSelector } from "../../store";
import {
VISIBILITY_SELECTOR_ITEMS,
MEMO_DISPLAY_TS_OPTION_SELECTOR_ITEMS,
SETTING_IS_FOLDING_ENABLED_KEY,
IS_FOLDING_ENABLED_DEFAULT_VALUE,
} from "../../helpers/consts";
import useLocalStorage from "../../hooks/useLocalStorage";
import Selector from "../common/Selector";
import { VISIBILITY_SELECTOR_ITEMS, MEMO_DISPLAY_TS_OPTION_SELECTOR_ITEMS } from "../../helpers/consts";
import Icon from "../Icon";
import AppearanceSelect from "../AppearanceSelect";
import "../../less/settings/preferences-section.less";
const localeSelectorItems = [
@@ -29,11 +24,23 @@ const localeSelectorItems = [
text: "French",
value: "fr",
},
{
text: "Nederlands",
value: "nl",
},
{
text: "Svenska",
value: "sv",
},
{
text: "German",
value: "de",
},
];
const PreferencesSection = () => {
const { t } = useTranslation();
const { setting } = useAppSelector((state) => state.user.user as User);
const { setting, localSetting } = useAppSelector((state) => state.user.user as User);
const visibilitySelectorItems = VISIBILITY_SELECTOR_ITEMS.map((item) => {
return {
value: item.value,
@@ -48,8 +55,6 @@ const PreferencesSection = () => {
};
});
const [isFoldingEnabled, setIsFoldingEnabled] = useLocalStorage(SETTING_IS_FOLDING_ENABLED_KEY, IS_FOLDING_ENABLED_DEFAULT_VALUE);
const handleLocaleChanged = async (value: string) => {
await userService.upsertUserSetting("locale", value);
globalService.setLocale(value as Locale);
@@ -64,38 +69,75 @@ const PreferencesSection = () => {
};
const handleIsFoldingEnabledChanged = (event: React.ChangeEvent<HTMLInputElement>) => {
setIsFoldingEnabled(event.target.checked);
userService.upsertLocalSetting("enableFoldMemo", event.target.checked);
};
return (
<div className="section-container preferences-section-container">
<p className="title-text">{t("common.basic")}</p>
<label className="form-label selector">
<div className="form-label selector">
<span className="normal-text">{t("common.language")}</span>
<Selector className="ml-2 w-32" value={setting.locale} dataSource={localeSelectorItems} handleValueChanged={handleLocaleChanged} />
</label>
<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>
</div>
<div className="form-label selector">
<span className="normal-text">Theme</span>
<AppearanceSelect />
</div>
<p className="title-text">{t("setting.preference")}</p>
<label className="form-label selector">
<div className="form-label selector">
<span className="normal-text">{t("setting.preference-section.default-memo-visibility")}</span>
<Selector
className="ml-2 w-32"
<Select
className="!min-w-[10rem] w-auto text-sm"
value={setting.memoVisibility}
dataSource={visibilitySelectorItems}
handleValueChanged={handleDefaultMemoVisibilityChanged}
/>
</label>
onChange={(_, visibility) => {
if (visibility) {
handleDefaultMemoVisibilityChanged(visibility);
}
}}
>
{visibilitySelectorItems.map((item) => (
<Option key={item.value} value={item.value} className="whitespace-nowrap">
{item.text}
</Option>
))}
</Select>
</div>
<label className="form-label selector">
<span className="normal-text">{t("setting.preference-section.default-memo-sort-option")}</span>
<Selector
className="ml-2 w-32"
<Select
className="!min-w-[10rem] w-auto text-sm"
value={setting.memoDisplayTsOption}
dataSource={memoDisplayTsOptionSelectorItems}
handleValueChanged={handleMemoDisplayTsOptionChanged}
/>
onChange={(_, value) => {
if (value) {
handleMemoDisplayTsOptionChanged(value);
}
}}
>
{memoDisplayTsOptionSelectorItems.map((item) => (
<Option key={item.value} value={item.value} className="whitespace-nowrap">
{item.text}
</Option>
))}
</Select>
</label>
<label className="form-label selector">
<span className="normal-text">{t("setting.preference-section.enable-folding-memo")}</span>
<Switch className="ml-2" checked={isFoldingEnabled} onChange={handleIsFoldingEnabledChanged} />
<Switch className="ml-2" checked={localSetting.enableFoldMemo} onChange={handleIsFoldingEnabledChanged} />
</label>
</div>
);

View File

@@ -60,6 +60,23 @@ const SystemSection = () => {
});
};
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,
});
} catch (error) {
console.error(error);
return;
}
toastHelper.success("Succeed to vacuum database");
};
const handleSaveAdditionalStyle = async () => {
try {
await api.upsertSystemSetting({
@@ -96,19 +113,20 @@ const SystemSection = () => {
return (
<div className="section-container system-section-container">
<p className="title-text">{t("common.basic")}</p>
<p className="text-value">
{t("setting.system-section.database-file-size")}: <span className="font-mono font-medium">{formatBytes(state.dbSize)}</span>
</p>
<label className="form-label">
<span className="normal-text">
{t("setting.system-section.database-file-size")}: <span className="font-mono font-medium">{formatBytes(state.dbSize)}</span>
</span>
<Button onClick={handleVacuumBtnClick}>{t("common.vacuum")}</Button>
</label>
<p className="title-text">{t("sidebar.setting")}</p>
<label className="form-label">
<span className="normal-text">{t("setting.system-section.allow-user-signup")}</span>
<Switch size="sm" checked={state.allowSignUp} onChange={(event) => handleAllowSignUpChanged(event.target.checked)} />
<Switch checked={state.allowSignUp} onChange={(event) => handleAllowSignUpChanged(event.target.checked)} />
</label>
<div className="form-label">
<span className="normal-text">{t("setting.system-section.additional-style")}</span>
<Button size="sm" onClick={handleSaveAdditionalStyle}>
{t("common.save")}
</Button>
<Button onClick={handleSaveAdditionalStyle}>{t("common.save")}</Button>
</div>
<Textarea
className="w-full"
@@ -117,25 +135,24 @@ const SystemSection = () => {
fontSize: "14px",
}}
minRows={4}
maxRows={10}
maxRows={4}
placeholder={t("setting.system-section.additional-style-placeholder")}
value={state.additionalStyle}
onChange={(event) => handleAdditionalStyleChanged(event.target.value)}
/>
<div className="form-label mt-2">
<span className="normal-text">{t("setting.system-section.additional-script")}</span>
<Button size="sm" onClick={handleSaveAdditionalScript}>
{t("common.save")}
</Button>
<Button onClick={handleSaveAdditionalScript}>{t("common.save")}</Button>
</div>
<Textarea
className="w-full"
color="neutral"
sx={{
fontFamily: "monospace",
fontSize: "14px",
}}
minRows={4}
maxRows={10}
maxRows={4}
placeholder={t("setting.system-section.additional-script-placeholder")}
value={state.additionalScript}
onChange={(event) => handleAdditionalScriptChanged(event.target.value)}

View File

@@ -0,0 +1,195 @@
import { Select, Option } from "@mui/joy";
import React, { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import copy from "copy-to-clipboard";
import { toLower } from "lodash";
import toImage from "../labs/html2image";
import { 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";
import toastHelper from "./Toast";
import MemoContent from "./MemoContent";
import MemoResources from "./MemoResources";
import "../less/share-memo-dialog.less";
interface Props extends DialogProps {
memo: Memo;
}
interface State {
memoAmount: number;
memoVisibility: string;
generatedImgUrl: string;
}
const ShareMemoDialog: React.FC<Props> = (props: Props) => {
const { memo: propsMemo, destroy } = props;
const { t } = useTranslation();
const user = userService.getState().user as User;
const [state, setState] = useState<State>({
memoAmount: 0,
memoVisibility: propsMemo.visibility,
generatedImgUrl: "",
});
const loadingState = useLoading();
const memoElRef = useRef<HTMLDivElement>(null);
const memo = {
...propsMemo,
createdAtStr: utils.getDateTimeString(propsMemo.displayTs),
};
const createdDays = Math.ceil((Date.now() - utils.getTimeStampByDate(user.createdTs)) / 1000 / 3600 / 24);
useEffect(() => {
getMemoStats(user.id)
.then(({ data: { data } }) => {
setState((state) => {
return {
...state,
memoAmount: data.length,
};
});
loadingState.setFinish();
})
.catch((error) => {
console.error(error);
});
}, []);
useEffect(() => {
if (loadingState.isLoading) {
return;
}
if (!memoElRef.current) {
return;
}
toImage(memoElRef.current, {
pixelRatio: window.devicePixelRatio * 2,
})
.then((url) => {
setState((state) => {
return {
...state,
generatedImgUrl: url,
};
});
})
.catch((err) => {
console.error(err);
});
}, [loadingState.isLoading]);
const handleCloseBtnClick = () => {
destroy();
};
const handleDownloadBtnClick = () => {
const a = document.createElement("a");
a.href = state.generatedImgUrl;
a.download = `memos-${utils.getDateTimeString(Date.now())}.png`;
a.click();
};
const handleCopyLinkBtnClick = () => {
copy(`${window.location.origin}/m/${memo.id}`);
toastHelper.success(t("message.succeed-copy-content"));
};
const memoVisibilityOptionSelectorItems = VISIBILITY_SELECTOR_ITEMS.map((item) => {
return {
value: item.value,
text: t(`memo.visibility.${toLower(item.value)}`),
};
});
const handleMemoVisibilityOptionChanged = async (value: string) => {
const visibilityValue = value as Visibility;
setState({
...state,
memoVisibility: visibilityValue,
});
await memoService.patchMemo({
id: memo.id,
visibility: visibilityValue,
});
};
return (
<>
<div className="dialog-header-container">
<p className="title-text">
<span className="icon-text">🌄</span>
{t("common.share")} Memo
</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<Icon.X className="icon-img" />
</button>
</div>
<div className="dialog-content-container">
<div className="memo-container" ref={memoElRef}>
{state.generatedImgUrl !== "" && <img className="memo-shortcut-img" src={state.generatedImgUrl} />}
<span className="time-text">{memo.createdAtStr}</span>
<div className="memo-content-wrapper">
<MemoContent content={memo.content} displayConfig={{ enableExpand: false }} />
<MemoResources style="col" resourceList={memo.resourceList} />
</div>
<div className="watermark-container">
<div className="userinfo-container">
<span className="name-text">{user.nickname || user.username}</span>
<span className="usage-text">
{state.memoAmount} MEMOS / {createdDays} DAYS
</span>
</div>
<img className="logo-img" src="/logo.webp" alt="" />
</div>
</div>
<div className="px-4 py-3 w-full flex flex-row justify-between items-center">
<Select
className="!min-w-[10rem] w-auto text-sm"
value={state.memoVisibility}
onChange={(_, visibility) => {
if (visibility) {
handleMemoVisibilityOptionChanged(visibility);
}
}}
>
{memoVisibilityOptionSelectorItems.map((item) => (
<Option key={item.value} value={item.value} className="whitespace-nowrap">
{item.text}
</Option>
))}
</Select>
<div className="flex flex-row justify-end items-center">
<button disabled={state.generatedImgUrl === ""} className="btn-normal mr-2" onClick={handleDownloadBtnClick}>
{state.generatedImgUrl === "" ? (
<Icon.Loader className="w-4 h-auto mr-1 animate-spin" />
) : (
<Icon.Download className="w-4 h-auto mr-1" />
)}
<span>{t("common.image")}</span>
</button>
<button className="btn-normal" onClick={handleCopyLinkBtnClick}>
<Icon.Link className="w-4 h-auto mr-1" />
<span>{t("common.link")}</span>
</button>
</div>
</div>
</div>
</>
);
};
export default function showShareMemoDialog(memo: Memo): void {
generateDialog(
{
className: "share-memo-dialog",
},
ShareMemoDialog,
{ memo }
);
}

View File

@@ -1,142 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { userService } from "../services";
import toImage from "../labs/html2image";
import { ANIMATION_DURATION } from "../helpers/consts";
import * as utils from "../helpers/utils";
import { getMemoStats } from "../helpers/api";
import useLoading from "../hooks/useLoading";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import MemoContent from "./MemoContent";
import MemoResources from "./MemoResources";
import "../less/share-memo-image-dialog.less";
interface Props extends DialogProps {
memo: Memo;
}
interface State {
memoAmount: number;
shortcutImgUrl: string;
}
const ShareMemoImageDialog: React.FC<Props> = (props: Props) => {
const { memo: propsMemo, destroy } = props;
const { t } = useTranslation();
const user = userService.getState().user as User;
const [state, setState] = useState<State>({
memoAmount: 0,
shortcutImgUrl: "",
});
const loadingState = useLoading();
const memoElRef = useRef<HTMLDivElement>(null);
const memo = {
...propsMemo,
createdAtStr: utils.getDateTimeString(propsMemo.displayTs),
};
const createdDays = Math.ceil((Date.now() - utils.getTimeStampByDate(user.createdTs)) / 1000 / 3600 / 24);
useEffect(() => {
getMemoStats(user.id)
.then(({ data: { data } }) => {
setState((state) => {
return {
...state,
memoAmount: data.length,
};
});
loadingState.setFinish();
})
.catch((error) => {
console.error(error);
});
}, []);
useEffect(() => {
if (loadingState.isLoading) {
return;
}
setTimeout(() => {
if (!memoElRef.current) {
return;
}
toImage(memoElRef.current, {
backgroundColor: "#eaeaea",
pixelRatio: window.devicePixelRatio * 2,
})
.then((url) => {
setState((state) => {
return {
...state,
shortcutImgUrl: url,
};
});
})
.catch((err) => {
console.error(err);
});
}, ANIMATION_DURATION);
}, [loadingState.isLoading]);
const handleCloseBtnClick = () => {
destroy();
};
const handleDownloadBtnClick = () => {
const a = document.createElement("a");
a.href = state.shortcutImgUrl;
a.download = `memos-${utils.getDateTimeString(Date.now())}.png`;
a.click();
};
return (
<>
<div className="dialog-header-container">
<p className="title-text">
<span className="icon-text">🌄</span>
{t("common.share")} Memo
</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<Icon.X className="icon-img" />
</button>
</div>
<div className="dialog-content-container">
<div className={`tip-words-container ${state.shortcutImgUrl ? "finish" : "loading"}`}>
<p className="tip-text">
{state.shortcutImgUrl ? t("message.click-to-save-the-image") + " 👇" : t("message.generating-the-screenshot")}
</p>
</div>
<div className="memo-container" ref={memoElRef}>
{state.shortcutImgUrl !== "" && <img className="memo-shortcut-img" onClick={handleDownloadBtnClick} src={state.shortcutImgUrl} />}
<span className="time-text">{memo.createdAtStr}</span>
<div className="memo-content-wrapper">
<MemoContent content={memo.content} displayConfig={{ enableExpand: false }} />
<MemoResources style="col" resourceList={memo.resourceList} />
</div>
<div className="watermark-container">
<div className="userinfo-container">
<span className="name-text">{user.nickname || user.username}</span>
<span className="usage-text">
{createdDays} DAYS / {state.memoAmount} MEMOS
</span>
</div>
<img className="logo-img" src="/logo.webp" alt="" />
</div>
</div>
</div>
</>
);
};
export default function showShareMemoImageDialog(memo: Memo): void {
generateDialog(
{
className: "share-memo-image-dialog",
},
ShareMemoImageDialog,
{ memo }
);
}

View File

@@ -76,6 +76,10 @@ const ShortcutContainer: React.FC<ShortcutContainerProps> = (props: ShortcutCont
if (showConfirmDeleteBtn) {
try {
await shortcutService.deleteShortcutById(shortcut.id);
if (locationService.getState().query?.shortcutId === shortcut.id) {
// need clear shortcut filter
locationService.setMemoShortcut(undefined);
}
} catch (error: any) {
console.error(error);
toastHelper.error(error.response.data.message);

View File

@@ -17,7 +17,7 @@ const Sidebar = () => {
const location = useAppSelector((state) => state.location);
useEffect(() => {
toggleSiderbar(false);
toggleSidebar(false);
}, [location.query]);
const handleSettingBtnClick = () => {
@@ -26,7 +26,7 @@ const Sidebar = () => {
return (
<>
<div className="mask" onClick={() => toggleSiderbar(false)}></div>
<div className="mask" onClick={() => toggleSidebar(false)}></div>
<aside className="sidebar-wrapper">
<UserBanner />
<UsageHeatMap />
@@ -52,7 +52,7 @@ const Sidebar = () => {
);
};
export const toggleSiderbar = (show?: boolean) => {
export const toggleSidebar = (show?: boolean) => {
const sidebarEl = document.body.querySelector(".sidebar-wrapper") as HTMLDivElement;
const maskEl = document.body.querySelector(".mask") as HTMLDivElement;

View File

@@ -14,22 +14,22 @@ type ToastItemProps = {
type: ToastType;
content: string;
duration: number;
destory: FunctionType;
destroy: FunctionType;
};
const Toast: React.FC<ToastItemProps> = (props: ToastItemProps) => {
const { destory, duration } = props;
const { destroy, duration } = props;
useEffect(() => {
if (duration > 0) {
setTimeout(() => {
destory();
destroy();
}, duration);
}
}, []);
return (
<div className="toast-container" onClick={destory}>
<div className="toast-container" onClick={destroy}>
<p className="content-text">{props.content}</p>
</div>
);
@@ -57,8 +57,8 @@ const initialToastHelper = () => {
shownToastContainers.push([toast, tempDiv]);
const cbs = {
destory: () => {
tempDiv.classList.add("destory");
destroy: () => {
tempDiv.classList.add("destroy");
setTimeout(() => {
if (!tempDiv.parentElement) {
@@ -77,7 +77,7 @@ const initialToastHelper = () => {
},
};
toast.render(<Toast {...config} destory={cbs.destory} />);
toast.render(<Toast {...config} destroy={cbs.destroy} />);
setTimeout(() => {
tempDiv.classList.add("showup");

View File

@@ -6,6 +6,14 @@ import { userService } from "../services";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import toastHelper from "./Toast";
import { validate, ValidatorConfig } from "../helpers/validator";
const validateConfig: ValidatorConfig = {
minLength: 4,
maxLength: 320,
noSpace: true,
noChinese: true,
};
type Props = DialogProps;
@@ -63,6 +71,12 @@ const UpdateAccountDialog: React.FC<Props> = ({ destroy }: Props) => {
return;
}
const usernameValidResult = validate(state.username, validateConfig);
if (!usernameValidResult.result) {
toastHelper.error(t("common.username") + ": " + t(usernameValidResult.reason as string));
return;
}
try {
const user = userService.getState().user as User;
const userPatch: UserPatch = {

View File

@@ -11,11 +11,11 @@ const tableConfig = {
height: 7,
};
const getInitialUsageStat = (usedDaysAmount: number, beginDayTimestemp: number): DailyUsageStat[] => {
const getInitialUsageStat = (usedDaysAmount: number, beginDayTimestamp: number): DailyUsageStat[] => {
const initialUsageStat: DailyUsageStat[] = [];
for (let i = 1; i <= usedDaysAmount; i++) {
initialUsageStat.push({
timestamp: beginDayTimestemp + DAILY_TIMESTAMP * i,
timestamp: beginDayTimestamp + DAILY_TIMESTAMP * i,
count: 0,
});
}
@@ -32,21 +32,25 @@ const UsageHeatMap = () => {
const todayDay = new Date(todayTimeStamp).getDay() + 1;
const nullCell = new Array(7 - todayDay).fill(0);
const usedDaysAmount = (tableConfig.width - 1) * tableConfig.height + todayDay;
const beginDayTimestemp = todayTimeStamp - usedDaysAmount * DAILY_TIMESTAMP;
const beginDayTimestamp = todayTimeStamp - usedDaysAmount * DAILY_TIMESTAMP;
const { memos } = useAppSelector((state) => state.memo);
const [allStat, setAllStat] = useState<DailyUsageStat[]>(getInitialUsageStat(usedDaysAmount, beginDayTimestemp));
const [allStat, setAllStat] = useState<DailyUsageStat[]>(getInitialUsageStat(usedDaysAmount, beginDayTimestamp));
const [currentStat, setCurrentStat] = useState<DailyUsageStat | null>(null);
const containerElRef = useRef<HTMLDivElement>(null);
useEffect(() => {
getMemoStats(userService.getCurrentUserId())
.then(({ data: { data } }) => {
const newStat: DailyUsageStat[] = getInitialUsageStat(usedDaysAmount, beginDayTimestemp);
const newStat: DailyUsageStat[] = getInitialUsageStat(usedDaysAmount, beginDayTimestamp);
for (const record of data) {
const index = (utils.getDateStampByDate(record * 1000) - beginDayTimestemp) / (1000 * 3600 * 24) - 1;
const index = (utils.getDateStampByDate(record * 1000) - beginDayTimestamp) / (1000 * 3600 * 24) - 1;
if (index >= 0) {
newStat[index].count += 1;
// because of dailight savings, some days may be 23 hours long instead of 24 hours long
// this causes the calculations to yield weird indices such as 40.93333333333
// rounding them may not give you the exact day on the heat map, but it's not too bad
const exactIndex = +index.toFixed(0);
newStat[exactIndex].count += 1;
}
}
setAllStat([...newStat]);
@@ -64,6 +68,11 @@ const UsageHeatMap = () => {
tempDiv.style.top = bounding.top - 2 + "px";
tempDiv.innerHTML = `${item.count} memos on <span className="date-text">${new Date(item.timestamp as number).toDateString()}</span>`;
document.body.appendChild(tempDiv);
if (tempDiv.offsetLeft - tempDiv.clientWidth / 2 < 0) {
tempDiv.style.left = bounding.left + tempDiv.clientWidth * 0.4 + "px";
tempDiv.className += " offset-left";
}
}, []);
const handleUsageStatItemMouseLeave = useCallback(() => {

View File

@@ -63,7 +63,6 @@ const UserBanner = () => {
};
const handleSignOutBtnClick = async () => {
userService.doSignOut().catch();
navigate("/auth");
};
@@ -75,20 +74,20 @@ const UserBanner = () => {
{!isVisitorMode && user?.role === "HOST" ? <span className="tag">MOD</span> : null}
</div>
<Dropdown
trigger={<Icon.MoreHorizontal className="ml-2 w-5 h-auto cursor-pointer" />}
trigger={<Icon.MoreHorizontal className="ml-2 w-5 h-auto cursor-pointer dark:text-gray-200" />}
actionsClassName="min-w-36"
actions={
<>
{!userService.isVisitorMode() && (
<>
<button
className="w-full px-3 whitespace-nowrap text-left leading-10 cursor-pointer rounded hover:bg-gray-100"
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={handleResourcesBtnClick}
>
<span className="mr-1">🌄</span> {t("sidebar.resources")}
</button>
<button
className="w-full px-3 whitespace-nowrap text-left leading-10 cursor-pointer rounded hover:bg-gray-100"
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={handleArchivedBtnClick}
>
<span className="mr-1">🗂</span> {t("sidebar.archived")}
@@ -96,14 +95,14 @@ const UserBanner = () => {
</>
)}
<button
className="w-full px-3 whitespace-nowrap text-left leading-10 cursor-pointer rounded hover:bg-gray-100"
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={handleAboutBtnClick}
>
<span className="mr-1">🤠</span> {t("common.about")}
</button>
{!userService.isVisitorMode() && (
<button
className="w-full px-3 whitespace-nowrap text-left leading-10 cursor-pointer rounded hover:bg-gray-100"
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}
>
<span className="mr-1">👋</span> {t("common.sign-out")}

View File

@@ -6,7 +6,7 @@ import "../../less/common/date-picker.less";
interface DatePickerProps {
className?: string;
datestamp: DateStamp;
handleDateStampChange: (datastamp: DateStamp) => void;
handleDateStampChange: (datestamp: DateStamp) => void;
}
const DatePicker: React.FC<DatePickerProps> = (props: DatePickerProps) => {

View File

@@ -7,10 +7,11 @@ interface Props {
actions?: ReactNode;
className?: string;
actionsClassName?: string;
positionClassName?: string;
}
const Dropdown: React.FC<Props> = (props: Props) => {
const { trigger, actions, className, actionsClassName } = props;
const { trigger, actions, className, actionsClassName, positionClassName } = props;
const [dropdownStatus, toggleDropdownStatus] = useToggle(false);
const dropdownWrapperRef = useRef<HTMLDivElement>(null);
@@ -37,14 +38,14 @@ const Dropdown: React.FC<Props> = (props: Props) => {
{trigger ? (
trigger
) : (
<button className="flex flex-row justify-center items-center border p-1 rounded shadow text-gray-600 cursor-pointer hover:opacity-80">
<button className="flex flex-row justify-center items-center border dark:border-zinc-700 p-1 rounded shadow text-gray-600 dark:text-gray-200 cursor-pointer hover:opacity-80">
<Icon.MoreHorizontal className="w-4 h-auto" />
</button>
)}
<div
className={`w-auto mt-1 absolute top-full right-0 flex flex-col justify-start items-start bg-white z-1 border p-1 rounded-md shadow ${
className={`w-auto absolute flex flex-col justify-start items-start bg-white dark:bg-zinc-700 z-10 p-1 rounded-md shadow ${
actionsClassName ?? ""
} ${dropdownStatus ? "" : "!hidden"}`}
} ${dropdownStatus ? "" : "!hidden"} ${positionClassName ?? "top-full right-0 mt-1"}`}
>
{actions}
</div>

View File

@@ -26,7 +26,7 @@ const Selector: React.FC<Props> = (props: Props) => {
const { t } = useTranslation();
const [showSelector, toggleSelectorStatus] = useToggle(false);
const seletorElRef = useRef<HTMLDivElement>(null);
const selectorElRef = useRef<HTMLDivElement>(null);
let currentItem = nullItem;
for (const d of dataSource) {
@@ -39,7 +39,7 @@ const Selector: React.FC<Props> = (props: Props) => {
useEffect(() => {
if (showSelector) {
const handleClickOutside = (event: MouseEvent) => {
if (!seletorElRef.current?.contains(event.target as Node)) {
if (!selectorElRef.current?.contains(event.target as Node)) {
toggleSelectorStatus(false);
}
};
@@ -63,7 +63,7 @@ const Selector: React.FC<Props> = (props: Props) => {
};
return (
<div className={`selector-wrapper ${className ?? ""}`} ref={seletorElRef}>
<div className={`selector-wrapper ${className ?? ""}`} ref={selectorElRef}>
<div className={`current-value-container ${showSelector ? "active" : ""}`} onClick={handleCurrentValueClick}>
<span className="value-text">{currentItem.text}</span>
<span className="arrow-text">

View File

@@ -1,6 +1,6 @@
body,
html {
@apply text-base;
html,
body {
@apply text-base dark:bg-zinc-800;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Noto Sans", "Noto Sans CJK SC", "Microsoft YaHei UI", "Microsoft YaHei",
"WenQuanYi Micro Hei", sans-serif, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
"Noto Color Emoji";

View File

@@ -3,33 +3,40 @@
@tailwind utilities;
@layer utilities {
.hide-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
/* Chrome, Safari and Opera */
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
.hide-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
.word-break {
overflow-wrap: anywhere;
word-break: normal;
}
}
.btn-normal {
@apply select-none inline-flex border cursor-pointer px-3 text-sm leading-8 rounded-md hover:opacity-80 hover:shadow;
}
@layer components {
.btn-normal {
@apply select-none flex flex-row justify-center items-center border dark:border-zinc-700 cursor-pointer px-3 text-sm leading-8 rounded-md hover:opacity-80 hover:shadow disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:shadow-none;
}
.btn-primary {
@apply btn-normal border-transparent bg-green-600 text-white;
}
.btn-primary {
@apply btn-normal border-transparent bg-green-600 text-white dark:border-transparent dark:text-gray-200;
}
.btn-danger {
@apply btn-normal border-red-600 bg-red-50 text-red-600;
}
.btn-danger {
@apply btn-normal border-red-600 bg-red-50 text-red-600;
}
.btn-text {
@apply btn-normal text-gray-600 border-none hover:shadow-none;
}
.btn-text {
@apply btn-normal text-gray-600 border-none dark:text-gray-200 hover:shadow-none;
}
.input-text {
@apply w-full px-3 py-2 leading-6 text-sm border rounded;
.input-text {
@apply w-full px-3 py-2 leading-6 text-sm dark:text-gray-200 rounded border focus:outline focus:outline-2 dark:border-zinc-700 dark:bg-zinc-800;
}
}

View File

@@ -14,6 +14,10 @@ export function upsertSystemSetting(systemSetting: SystemSetting) {
return axios.post<ResponseObject<SystemSetting>>("/api/system/setting", systemSetting);
}
export function vacuumDatabase() {
return axios.post("/api/system/vacuum");
}
export function signin(username: string, password: string) {
return axios.post<ResponseObject<User>>("/api/auth/signin", {
username,

View File

@@ -8,17 +8,14 @@ export const ANIMATION_DURATION = 200;
export const DAILY_TIMESTAMP = 3600 * 24 * 1000;
export const VISIBILITY_SELECTOR_ITEMS = [
{ text: "PUBLIC", value: "PUBLIC" },
{ text: "PROTECTED", value: "PROTECTED" },
{ text: "PRIVATE", value: "PRIVATE" },
{ text: "PROTECTED", value: "PROTECTED" },
{ text: "PUBLIC", value: "PUBLIC" },
];
export const MEMO_DISPLAY_TS_OPTION_SELECTOR_ITEMS = [
{ text: "created_ts", value: "created_ts" },
{ text: "created_ts", value: "updated_ts" },
{ text: "updated_ts", value: "updated_ts" },
];
export const IS_FOLDING_ENABLED_DEFAULT_VALUE = true;
export const SETTING_IS_FOLDING_ENABLED_KEY = "setting_IS_FOLDING_ENABLED";
export const TAB_SPACE_WIDTH = 2;

View File

@@ -156,7 +156,7 @@ export const checkShouldShowMemo = (memo: Memo, filter: Filter) => {
if (type === "TAG") {
let contained = true;
const tagsSet = new Set<string>();
for (const t of Array.from(memo.content.match(TAG_REG) ?? [])) {
for (const t of Array.from(memo.content.match(new RegExp(TAG_REG, "g")) ?? [])) {
const tag = t.replace(TAG_REG, "$1").trim();
const items = tag.split("/");
let temp = "";

View File

@@ -10,6 +10,10 @@ interface StorageData {
editingMemoVisibilityCache: Visibility;
// locale
locale: Locale;
// appearance
appearance: Appearance;
// local setting
localSetting: LocalSetting;
// skipped version
skippedVersion: string;
}

View File

@@ -134,3 +134,17 @@ export const parseHTMLToRawText = (htmlStr: string): string => {
const text = tempEl.innerText;
return text;
};
export function absolutifyLink(rel: string): string {
const anchor = document.createElement("a");
anchor.setAttribute("href", rel);
return anchor.href;
}
export function getSystemColorScheme() {
if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) {
return "dark";
} else {
return "light";
}
}

View File

@@ -1,5 +1,6 @@
// Validator
// * use for validating form data
const chineseReg = /[\u3000\u3400-\u4DBF\u4E00-\u9FFF]/;
export interface ValidatorConfig {
@@ -18,7 +19,7 @@ export function validate(text: string, config: Partial<ValidatorConfig>): { resu
if (text.length < config.minLength) {
return {
result: false,
reason: "Too short",
reason: "message.too-short",
};
}
}
@@ -27,7 +28,7 @@ export function validate(text: string, config: Partial<ValidatorConfig>): { resu
if (text.length > config.maxLength) {
return {
result: false,
reason: "Too long",
reason: "message.too-long",
};
}
}
@@ -35,14 +36,14 @@ export function validate(text: string, config: Partial<ValidatorConfig>): { resu
if (config.noSpace && text.includes(" ")) {
return {
result: false,
reason: "Don't allow space",
reason: "message.not-allow-space",
};
}
if (config.noChinese && chineseReg.test(text)) {
return {
result: false,
reason: "Don't allow chinese",
reason: "message.not-allow-chinese",
};
}

View File

@@ -1,26 +0,0 @@
import { useState } from "react";
const useLocalStorage = <T>(key: string, initialValue: T) => {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
return initialValue;
}
});
const setValue = (value: T | ((val: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.log(error);
}
};
return [storedValue, setValue] as const;
};
export default useLocalStorage;

View File

@@ -1,13 +0,0 @@
// A custom hook that builds on useLocation to parse
import React from "react";
import { useLocation } from "react-router-dom";
// the query string for you.
const useQuery = () => {
const { search } = useLocation();
return React.useMemo(() => new URLSearchParams(search), [search]);
};
export default useQuery;

View File

@@ -5,7 +5,7 @@ const useToggle = (initialState = false): [boolean, (nextState?: boolean) => voi
// Initialize the state
const [state, setState] = useState(initialState);
// Define and memorize toggler function in case we pass down the comopnent,
// Define and memorize toggler function in case we pass down the component,
// This function change the boolean value to it's opposite value
const toggle = useCallback((nextState?: boolean) => {
if (nextState !== undefined) {

View File

@@ -4,6 +4,9 @@ import enLocale from "./locales/en.json";
import zhLocale from "./locales/zh.json";
import viLocale from "./locales/vi.json";
import frLocale from "./locales/fr.json";
import nlLocale from "./locales/nl.json";
import svLocale from "./locales/sv.json";
import deLocale from "./locales/de.json";
i18n.use(initReactI18next).init({
resources: {
@@ -19,8 +22,17 @@ i18n.use(initReactI18next).init({
fr: {
translation: frLocale,
},
nl: {
translation: nlLocale,
},
sv: {
translation: svLocale,
},
de: {
translation: deLocale,
},
},
lng: "en",
lng: "nl",
fallbackLng: "en",
});

View File

@@ -6,11 +6,20 @@ const applyStyles = async (sourceElement: HTMLElement, clonedElement: HTMLElemen
}
if (sourceElement.tagName === "IMG") {
const url = sourceElement.getAttribute("src") ?? "";
let covertFailed = false;
try {
const url = await convertResourceToDataURL(sourceElement.getAttribute("src") ?? "");
(clonedElement as HTMLImageElement).src = url;
(clonedElement as HTMLImageElement).src = await convertResourceToDataURL(url);
} catch (error) {
// do nth
covertFailed = true;
}
// NOTE: Get image blob from backend to avoid CORS error.
if (covertFailed) {
try {
(clonedElement as HTMLImageElement).src = await convertResourceToDataURL(`/o/get/image?url=${url}`);
} catch (error) {
// do nth
}
}
}

View File

@@ -60,8 +60,8 @@ console.log("hello world!")
- [ ] finish my homework
- [x] yahaha`,
want: `<p>My task:</p>
<p><span class='todo-block todo' data-value='TODO'></span>finish my homework</p>
<p><span class='todo-block done' data-value='DONE'>✓</span>yahaha</p>`,
<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>`,
},
];
@@ -76,8 +76,8 @@ console.log("hello world!")
* list 123
1. 123123`,
want: `<p>This is a list</p>
<p><span class='ul-block'>•</span>list 123</p>
<p><span class='ol-block'>1.</span>123123</p>`,
<p class='li-container'><span class='ul-block'>•</span>list 123</p>
<p class='li-container'><span class='ol-block'>1.</span>123123</p>`,
},
];
@@ -157,43 +157,6 @@ console.log("hello world!")
expect(unescape(marked(t.markdown))).toBe(t.want);
}
});
test("parse table", () => {
const tests = [
{
markdown: `text above the table
| a | b | c |
|---|---|---|
| 1 | 2 | 3 |
| 4 | 5 | 6 |
text below the table
`,
want: `<p>text above the table</p>
<table>
<thead>
<tr>
<th>a</th><th>b</th><th>c</th>
</tr>
</thead>
<tbody>
<tr><td>1</td><td>2</td><td>3</td></tr><tr><td>4</td><td>5</td><td>6</td></tr>
</tbody>
</table>
<p>text below the table</p>
`,
},
{
markdown: `| a | b | c |
| 1 | 2 | 3 |
| 4 | 5 | 6 |`,
want: `<p>| a | b | c |</p>
<p>| 1 | 2 | 3 |</p>
<p>| 4 | 5 | 6 |</p>`,
},
];
for (const t of tests) {
expect(unescape(marked(t.markdown))).toBe(t.want);
}
});
test("parse full width space", () => {
const tests = [
{

View File

@@ -12,7 +12,7 @@ const renderer = (rawStr: string): string => {
};
export default {
name: "blockqoute",
name: "blockquote",
regex: BLOCKQUOTE_REG,
renderer,
};

View File

@@ -10,7 +10,7 @@ const renderer = (rawStr: string): string => {
}
const parsedContent = marked(matchResult[1], [], inlineElementParserList);
return `<p><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>${parsedContent}</p>${matchResult[2]}`;
};
export default {

View File

@@ -1,4 +1,5 @@
import { escape } from "lodash-es";
import { absolutifyLink } from "../../../helpers/utils";
export const IMAGE_REG = /!\[.*?\]\((.+?)\)/;
@@ -8,8 +9,8 @@ const renderer = (rawStr: string): string => {
return rawStr;
}
// NOTE: Get image blob from backend to avoid CORS.
return `<img class='img' src='/o/get/image?url=${escape(matchResult[1])}' />`;
const imageUrl = absolutifyLink(escape(matchResult[1]));
return `<img class='img' src='${imageUrl}' />`;
};
export default {

View File

@@ -10,7 +10,7 @@ const renderer = (rawStr: string): string => {
}
const parsedContent = marked(matchResult[2], [], inlineElementParserList);
return `<p><span class='ol-block'>${matchResult[1]}.</span>${parsedContent}</p>${matchResult[3]}`;
return `<p class='li-container'><span class='ol-block'>${matchResult[1]}.</span>${parsedContent}</p>${matchResult[3]}`;
};
export default {

View File

@@ -1,45 +0,0 @@
/**
* Match markdown table
* example:
* | a | b | c |
* |---|---|---|
* | 1 | 2 | 3 |
* | 4 | 5 | 6 |
*/
export const TABLE_REG = /^(\|.*\|)(?:(?:\n(?:\|-*)+\|))((?:\n\|.*\|)+)(\n?)/;
const renderer = (rawStr: string): string => {
const matchResult = rawStr.match(TABLE_REG);
if (!matchResult) {
return rawStr;
}
const tableHeader = matchResult[1]
.split("|")
.filter((str) => str !== "")
.map((str) => str.trim());
const tableBody = matchResult[2]
.trim()
.split("\n")
.map((str) =>
str
.split("|")
.filter((str) => str !== "")
.map((str) => str.trim())
);
return `<table>
<thead>
<tr>
${tableHeader.map((str) => `<th>${str}</th>`).join("")}
</tr>
</thead>
<tbody>
${tableBody.map((row) => `<tr>${row.map((str) => `<td>${str}</td>`).join("")}</tr>`).join("")}
</tbody>
</table>${matchResult[3]}`;
};
export default {
name: "table",
regex: TABLE_REG,
renderer,
};

View File

@@ -11,7 +11,7 @@ const renderer = (rawStr: string): string => {
}
const parsedContent = marked(matchResult[1], [], inlineElementParserList);
return `<p><span class='todo-block todo' data-value='TODO'></span>${parsedContent}</p>${escape(matchResult[2])}`;
return `<p class='li-container'><span class='todo-block todo' data-value='TODO'></span>${parsedContent}</p>${escape(matchResult[2])}`;
};
export default {

View File

@@ -11,7 +11,7 @@ const renderer = (rawStr: string): string => {
}
const parsedContent = marked(matchResult[1], [], inlineElementParserList);
return `<p><span class='ul-block'>•</span>${parsedContent}</p>${escape(matchResult[2])}`;
return `<p class='li-container'><span class='ul-block'>•</span>${parsedContent}</p>${escape(matchResult[2])}`;
};
export default {

View File

@@ -12,7 +12,6 @@ import Emphasis from "./Emphasis";
import PlainLink from "./PlainLink";
import InlineCode from "./InlineCode";
import PlainText from "./PlainText";
import Table from "./Table";
import BoldEmphasis from "./BoldEmphasis";
import Blockquote from "./Blockquote";
import HorizontalRules from "./HorizontalRules";
@@ -24,20 +23,9 @@ export { DONE_LIST_REG } from "./DoneList";
export { TAG_REG } from "./Tag";
export { IMAGE_REG } from "./Image";
export { LINK_REG } from "./Link";
export { TABLE_REG } from "./Table";
export { HORIZONTAL_RULES_REG } from "./HorizontalRules";
// The order determines the order of execution.
export const blockElementParserList = [
HorizontalRules,
Table,
CodeBlock,
Blockquote,
TodoList,
DoneList,
OrderedList,
UnorderedList,
Paragraph,
];
export const blockElementParserList = [HorizontalRules, CodeBlock, Blockquote, TodoList, DoneList, OrderedList, UnorderedList, Paragraph];
export const inlineElementParserList = [Image, BoldEmphasis, Bold, Emphasis, Link, InlineCode, PlainLink, Strikethrough, Tag, PlainText];
export const parserList = [...blockElementParserList, ...inlineElementParserList];

View File

@@ -1,31 +1,13 @@
.about-site-dialog {
@apply px-4;
> .dialog-container {
@apply w-112 max-w-full;
> .dialog-content-container {
@apply flex flex-col justify-start items-start leading-relaxed;
> .logo-img {
@apply h-16;
}
@apply flex flex-col justify-start items-start;
> p {
@apply my-1;
}
.pre-text {
@apply font-mono mx-1;
}
> .addtion-info-container {
@apply flex flex-row text-sm justify-start items-center;
> .github-badge-container {
@apply mr-4;
}
}
}
}
}

View File

@@ -1,5 +1,5 @@
.page-wrapper.auth {
@apply flex flex-row justify-center items-center w-full h-screen bg-white;
@apply flex flex-row justify-center items-center w-full h-screen bg-white dark:bg-zinc-800;
> .page-container {
@apply w-80 max-w-full h-full py-4 flex flex-col justify-start items-center;
@@ -11,15 +11,19 @@
@apply flex flex-col justify-start items-start w-full mb-4;
> .title-container {
@apply w-full flex flex-row justify-between items-center;
@apply w-full flex flex-row justify-start items-center;
> .logo-img {
@apply h-20 w-auto;
}
> .logo-text {
@apply text-6xl tracking-wide text-black dark:text-gray-200;
}
}
> .slogan-text {
@apply text-sm text-gray-700;
@apply text-sm text-gray-700 dark:text-gray-300;
}
}
@@ -33,7 +37,7 @@
@apply absolute top-3 left-3 px-1 leading-10 flex-shrink-0 text-base cursor-text text-gray-400 bg-transparent transition-all select-none;
&.not-null {
@apply text-sm top-0 z-10 leading-4 bg-white rounded;
@apply text-sm top-0 z-10 leading-4 bg-white dark:bg-zinc-800 rounded;
}
}
@@ -41,7 +45,7 @@
@apply py-2;
> input {
@apply w-full py-3 px-3 text-base shadow-inner rounded-lg border border-solid border-gray-400 hover:opacity-80;
@apply w-full py-3 px-3 text-base rounded-lg;
}
}
}
@@ -54,20 +58,8 @@
> .action-btns-container {
@apply flex flex-row justify-end items-center w-full mt-2;
> .btn {
@apply flex flex-row justify-center items-center px-1 py-2 text-sm rounded hover:opacity-80;
&.signup-btn {
@apply px-3;
}
&.signin-btn {
@apply bg-green-600 text-white px-3 shadow;
}
&.requesting {
@apply cursor-wait opacity-80;
}
> .requesting {
@apply cursor-wait opacity-80;
}
}
@@ -79,25 +71,5 @@
}
}
}
> .footer-container {
@apply w-full flex flex-col justify-start items-center;
> .language-container {
@apply mt-2 w-full flex flex-row justify-center items-center text-sm text-gray-400;
> .locale-item {
@apply px-2 cursor-pointer;
&.active {
@apply text-blue-600 font-bold;
}
}
> .split-line {
@apply font-mono text-gray-400;
}
}
}
}
}

View File

@@ -1,5 +1,5 @@
.dialog-wrapper {
@apply fixed top-0 left-0 flex flex-col justify-start items-center w-full h-full pt-16 pb-8 z-100 overflow-x-hidden overflow-y-scroll bg-transparent transition-all hide-scrollbar;
@apply fixed top-0 left-0 flex flex-col justify-start items-center w-full h-full pt-16 pb-8 px-4 z-100 overflow-x-hidden overflow-y-scroll bg-transparent transition-all hide-scrollbar;
&.showup {
background-color: rgba(0, 0, 0, 0.6);
@@ -10,7 +10,7 @@
}
> .dialog-container {
@apply flex flex-col justify-start items-start bg-white p-4 rounded-lg;
@apply max-w-full flex flex-col justify-start items-start bg-white dark:bg-zinc-800 dark:text-gray-200 p-4 rounded-lg;
> .dialog-header-container {
@apply flex flex-row justify-between items-center w-full mb-4;
@@ -22,7 +22,7 @@
}
.btn {
@apply flex flex-col justify-center items-center w-6 h-6 rounded hover:bg-gray-100 hover:shadow;
@apply flex flex-col justify-center items-center w-6 h-6 rounded hover:bg-gray-100 dark:hover:bg-zinc-700 hover:shadow;
}
}

View File

@@ -0,0 +1,7 @@
html.dark {
@import (less) "highlight.js/styles/atom-one-dark.css";
}
html:not(.dark) {
@import (less) "highlight.js/styles/github.css";
}

View File

@@ -9,7 +9,7 @@
@apply flex flex-row justify-end items-center w-full mt-4;
> .btn {
@apply text-sm py-1 px-3 mr-2 rounded-md hover:opacity-80;
@apply text-sm py-1 px-3 mr-2 rounded-md hover:opacity-80 cursor-pointer;
&.confirm-btn {
@apply bg-red-100 border border-solid border-blue-600 text-blue-600;

View File

@@ -22,10 +22,7 @@
@apply flex flex-row justify-around items-center w-full;
> .day-item {
@apply flex flex-col justify-center items-center;
width: 36px;
height: 36px;
user-select: none;
@apply w-9 h-9 select-none flex flex-col justify-center items-center;
color: gray;
font-size: 13px;
margin: 2px 0;
@@ -33,21 +30,11 @@
}
> .day-item {
@apply flex flex-col justify-center items-center;
width: 36px;
height: 36px;
border-radius: 50%;
font-size: 14px;
user-select: none;
cursor: pointer;
@apply w-9 h-9 rounded-full text-sm select-none cursor-pointer flex flex-col justify-center items-center hover:bg-gray-200 dark:hover:bg-zinc-600;
margin: 2px;
&:hover {
@apply bg-gray-100;
}
&.current {
@apply text-blue-600 bg-blue-100 text-base font-medium;
@apply text-blue-600 !bg-blue-100 text-base font-medium;
}
&.null {

View File

@@ -2,15 +2,15 @@
@apply flex flex-col justify-start items-start relative h-8;
> .current-value-container {
@apply flex flex-row justify-between items-center w-full h-full rounded px-2 pr-1 bg-white border cursor-pointer select-none;
@apply flex flex-row justify-between items-center w-full h-full rounded px-2 pr-1 bg-white dark:bg-zinc-700 dark:border-zinc-600 border cursor-pointer select-none;
&:hover,
&.active {
@apply bg-gray-100;
@apply bg-gray-100 dark:bg-zinc-700;
}
> .value-text {
@apply text-sm mr-0 truncate;
@apply text-sm mr-0 truncate dark:text-gray-300;
width: calc(100% - 20px);
}
@@ -18,19 +18,19 @@
@apply flex flex-row justify-center items-center w-4 shrink-0;
> .icon-img {
@apply w-4 h-auto opacity-40;
@apply w-4 h-auto opacity-40 dark:text-gray-300;
}
}
}
> .items-wrapper {
@apply flex flex-col justify-start items-start absolute top-full left-0 w-auto p-1 mt-1 -ml-2 bg-white rounded-md overflow-y-auto z-1 hide-scrollbar;
@apply flex flex-col justify-start items-start absolute top-full left-0 w-auto p-1 mt-1 -ml-2 bg-white dark:bg-zinc-700 dark:border-zinc-600 rounded-md overflow-y-auto z-1 hide-scrollbar;
min-width: calc(100% + 16px);
max-height: 256px;
box-shadow: 0 0 8px 0 rgb(0 0 0 / 20%);
> .item-container {
@apply flex flex-col justify-start items-start w-full px-3 text-sm select-none leading-8 cursor-pointer rounded whitespace-nowrap hover:bg-gray-100;
@apply flex flex-col justify-start items-start w-full px-3 text-sm select-none leading-8 cursor-pointer rounded whitespace-nowrap dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-zinc-600;
&.selected {
@apply text-green-600;

View File

@@ -8,22 +8,22 @@
@apply flex flex-col justify-start items-start;
> .form-item-container {
@apply w-full mt-2 py-1 flex flex-row justify-start items-start;
@apply w-full mt-2 py-1 flex sm:flex-row flex-col justify-start items-start;
> .normal-text {
@apply block flex-shrink-0 w-12 mr-3 text-right text-sm leading-8;
@apply block flex-shrink-0 w-12 mr-3 sm:text-right text-left text-sm leading-8;
color: gray;
}
> .title-input {
@apply w-full py-1 px-2 h-9 text-sm rounded border shadow-inner;
@apply w-full py-1 px-2 h-9 text-sm rounded border dark:border-zinc-700 dark:bg-zinc-800 shadow-inner;
}
> .filters-wrapper {
@apply w-full flex flex-col justify-start items-start;
> .create-filter-btn {
@apply text-sm py-1 px-2 rounded shadow flex flex-row justify-start items-center border cursor-pointer text-blue-500 hover:opacity-80;
@apply text-sm py-1 px-2 rounded shadow flex flex-row sm:justify-start justify-center items-center border dark:border-zinc-700 cursor-pointer text-blue-500 hover:opacity-80 sm:min-w-0 min-w-full sm:mb-0 mb-1;
}
}
}
@@ -56,14 +56,16 @@
}
.memo-filter-input-wrapper {
@apply w-full mb-3 shrink-0 flex flex-row justify-start items-center;
@apply w-full mb-3 shrink-0 flex flex-row sm:justify-start justify-center items-center sm:flex-nowrap flex-wrap sm:gap-0 gap-3;
> .selector-wrapper {
@apply mr-1 h-9 grow-0 shrink-0;
@apply mr-1 h-9 grow-0 shrink-0 sm:min-w-0 min-w-full;
&.relation-selector {
@apply w-16;
margin-left: -68px;
@media only screen and (min-width: 640px) {
margin-left: -68px;
}
}
&.type-selector {
@@ -80,13 +82,17 @@
}
> input.value-inputer {
max-width: calc(100% - 152px);
@apply h-9 px-2 shrink-0 grow mr-1 text-sm rounded border bg-transparent hover:bg-gray-50;
@media only screen and (min-width: 640px) {
max-width: calc(100% - 152px);
}
@apply h-9 px-2 shrink-0 grow mr-1 text-sm rounded border bg-transparent hover:bg-gray-50 sm:min-w-0 min-w-full;
}
> input.datetime-selector {
max-width: calc(100% - 152px);
@apply h-9 px-2 shrink-0 grow mr-1 text-sm rounded border bg-transparent hover:bg-gray-50;
@media only screen and (min-width: 640px) {
max-width: calc(100% - 152px);
}
@apply h-9 px-2 shrink-0 grow mr-1 text-sm rounded border bg-transparent hover:bg-gray-50 sm:min-w-0 min-w-full;
}
> .remove-btn {

View File

@@ -8,18 +8,18 @@
}
> .split-line {
@apply h-full px-px bg-gray-50 absolute top-1 left-6 z-0 -ml-px;
@apply h-full px-px bg-gray-50 dark:bg-zinc-600 absolute top-1 left-6 z-0 -ml-px;
}
> .time-wrapper {
@apply mt-px mr-4 w-12 h-7 shrink-0 text-xs leading-6 text-center font-mono rounded-lg bg-gray-100 border-2 border-white text-gray-600 z-10;
@apply mt-px mr-4 w-12 h-7 shrink-0 text-xs leading-6 text-center font-mono rounded-lg bg-gray-100 dark:bg-zinc-600 border-2 border-white dark:border-zinc-800 text-gray-600 dark:text-gray-300 z-10;
}
> .memo-container {
@apply w-full overflow-x-hidden flex flex-col justify-start items-start;
.memo-content-text {
margin-top: 3px;
@apply mt-1;
}
}
}

View File

@@ -2,20 +2,20 @@
@apply p-0 sm:py-16;
> .dialog-container {
@apply w-full sm:w-112 max-w-full grow sm:grow-0 bg-white p-0 rounded-none sm:rounded-lg;
@apply w-full sm:w-112 max-w-full grow sm:grow-0 p-0 pb-4 rounded-none sm:rounded-lg;
> .dialog-header-container {
@apply relative flex flex-row justify-between items-center w-full p-6 pb-0 mb-0;
> .title-text {
@apply px-2 py-1 -ml-2 cursor-pointer select-none rounded hover:bg-gray-100;
@apply px-2 py-1 -ml-2 cursor-pointer select-none rounded hover:bg-gray-100 dark:hover:bg-zinc-700;
}
> .btns-container {
@apply flex flex-row justify-start items-center;
> .btn-text {
@apply w-6 h-6 mr-2 rounded cursor-pointer select-none text-gray-600 last:mr-0 hover:bg-gray-200 p-0.5;
@apply w-6 h-6 mr-2 rounded cursor-pointer select-none last:mr-0 hover:bg-gray-200 dark:hover:bg-zinc-700 p-0.5;
> .icon-img {
@apply w-full h-auto;
@@ -28,19 +28,19 @@
}
> .date-picker {
@apply absolute top-12 left-4 mt-2 bg-white shadow z-20 mx-auto border border-gray-200 rounded-lg mb-6;
@apply absolute top-12 left-4 mt-2 bg-white dark:bg-zinc-700 shadow z-20 mx-auto border dark:border-zinc-800 rounded-lg mb-6;
}
}
> .dialog-content-container {
@apply w-full h-auto flex flex-col justify-start items-start p-6 pb-0;
@apply w-full h-auto flex flex-col justify-start items-start p-6 pb-0 bg-white dark:bg-zinc-800;
> .date-card-container {
@apply flex flex-col justify-center items-center m-auto pb-6 select-none;
z-index: 1;
> .year-text {
@apply m-auto font-bold text-gray-600 text-center leading-6 mb-2;
@apply m-auto font-bold text-gray-600 dark:text-gray-300 text-center leading-6 mb-2;
}
> .date-container {

View File

@@ -2,7 +2,7 @@
@apply flex flex-col justify-start items-start relative w-full h-auto bg-white;
> .common-editor-inputer {
@apply w-full h-full ~"max-h-[300px]" my-1 text-base resize-none overflow-x-hidden overflow-y-auto bg-transparent outline-none whitespace-pre-wrap;
@apply w-full h-full ~"max-h-[300px]" my-1 text-base resize-none overflow-x-hidden overflow-y-auto bg-transparent outline-none whitespace-pre-wrap break-all;
&::placeholder {
padding-left: 2px;

View File

@@ -1,5 +1,5 @@
.page-wrapper.explore {
@apply relative top-0 w-full h-screen overflow-y-auto overflow-x-hidden;
@apply relative top-0 w-full h-screen overflow-y-auto overflow-x-hidden dark:bg-zinc-800;
background-color: #f6f5f4;
> .page-container {
@@ -16,17 +16,13 @@
}
> .title-text {
@apply text-xl sm:text-3xl font-mono text-gray-700;
@apply text-xl sm:text-4xl font-mono text-gray-700 dark:text-gray-200;
}
}
> .action-button-container {
> .btn {
@apply block text-gray-600 font-mono text-base py-1 border px-3 leading-8 rounded-xl hover:opacity-80 hover:underline;
> .icon {
@apply text-lg;
}
> .link-btn {
@apply block text-gray-600 dark:text-gray-200 dark:border-gray-600 font-mono text-base py-1 border px-3 leading-8 rounded-xl hover:opacity-80;
}
}
}
@@ -35,7 +31,7 @@
@apply relative flex-grow max-w-2xl w-full min-h-full flex flex-col justify-start items-start px-4 sm:pr-6;
> .memo-container {
@apply flex flex-col justify-start items-start w-full p-4 mt-2 bg-white rounded-lg border border-white hover:border-gray-200;
@apply flex flex-col justify-start items-start w-full p-4 mt-2 bg-white dark:bg-zinc-700 rounded-lg border border-white dark:border-zinc-800 hover:border-gray-200 dark:hover:border-zinc-600;
> .memo-header {
@apply mb-2 w-full flex flex-row justify-start items-center text-sm text-gray-400;
@@ -44,14 +40,6 @@
@apply ml-2 hover:text-green-600 hover:underline;
}
}
> .memo-content {
@apply cursor-default;
> * {
@apply cursor-default;
}
}
}
}
}

View File

@@ -1,15 +0,0 @@
.github-badge-container {
@apply h-7 flex flex-row justify-start items-center border rounded cursor-pointer hover:opacity-80;
> .github-icon {
@apply w-auto h-full px-2 border-r rounded-l flex flex-row justify-center items-center text-xs text-gray-800 bg-gray-100;
> .icon-img {
@apply mr-1 w-4 h-4;
}
}
> .count-text {
@apply w-auto h-full flex flex-row justify-center items-center px-3 text-xs font-bold text-gray-800;
}
}

View File

@@ -1,5 +1,5 @@
.page-wrapper.home {
@apply relative top-0 w-full h-screen overflow-y-auto overflow-x-hidden;
@apply relative top-0 w-full h-screen overflow-y-auto overflow-x-hidden dark:bg-zinc-800;
background-color: #f6f5f4;
> .banner-wrapper {
@@ -17,15 +17,15 @@
@apply relative flex-grow max-w-2xl w-full min-h-full flex flex-col justify-start items-start px-4 sm:pr-6;
> .memos-editor-wrapper {
@apply sticky top-0 w-full h-full flex flex-col justify-start items-start z-10;
@apply sticky top-0 w-full h-full flex flex-col justify-start items-start z-10 dark:bg-zinc-800;
background-color: #f6f5f4;
}
> .addtion-btn-container {
> .addition-btn-container {
@apply fixed bottom-12 left-1/2 -translate-x-1/2;
> .btn {
@apply bg-blue-600 text-white px-4 py-2 rounded-3xl shadow-2xl hover:opacity-80;
@apply bg-blue-600 dark:bg-blue-800 text-white dark:text-gray-200 px-4 py-2 rounded-3xl shadow-2xl hover:opacity-80;
> .icon {
@apply text-lg mr-1;

View File

@@ -1,28 +1,29 @@
.memo-content-wrapper {
@apply w-full flex flex-col justify-start items-start;
@apply w-full flex flex-col justify-start items-start text-gray-800 dark:text-gray-200;
> .memo-content-text {
@apply w-full break-words text-base leading-7;
@apply w-full max-w-full word-break text-base leading-6;
> p {
@apply w-full h-auto mb-1 last:mb-0 text-base leading-6 whitespace-pre-wrap break-words;
@apply w-full h-auto mb-1 last:mb-0 text-base;
min-height: 24px;
}
> .li-container {
@apply w-full flex flex-row flex-nowrap;
}
.img {
@apply block max-w-full rounded cursor-pointer hover:shadow;
}
.tag-span {
@apply inline-block w-auto font-mono text-blue-600 cursor-pointer;
}
.memo-link-text {
@apply inline-block text-blue-600 cursor-pointer font-bold border-none no-underline hover:opacity-80;
@apply inline-block w-auto font-mono text-blue-600 dark:text-blue-400 cursor-pointer;
}
.link {
@apply text-blue-600 cursor-pointer underline break-all hover:opacity-80 decoration-1;
@apply text-blue-600 dark:text-blue-400 cursor-pointer underline break-all hover:opacity-80 decoration-1;
code {
@apply underline decoration-1;
}
@@ -31,31 +32,23 @@
.ol-block,
.ul-block,
.todo-block {
@apply inline-block box-border text-right w-8 mr-px font-mono select-none whitespace-nowrap;
@apply shrink-0 inline-block box-border text-right w-8 mr-px font-mono text-sm leading-6 select-none whitespace-nowrap;
}
.ol-block {
@apply opacity-80 mt-px;
}
.ul-block {
@apply text-center;
@apply text-center mt-px;
}
.todo-block {
@apply w-4 h-4 leading-4 border rounded box-border text-lg cursor-pointer shadow-inner hover:opacity-80;
transform: translateY(2px);
margin-left: 6px;
margin-right: 6px;
}
li {
list-style-type: none;
&::before {
@apply font-bold mr-1;
content: "•";
}
@apply w-4 h-4 leading-4 mx-2 mt-1 border rounded box-border text-lg cursor-pointer shadow-inner hover:opacity-80;
}
pre {
@apply w-full my-1 p-3 rounded bg-gray-100 whitespace-pre-wrap;
@apply w-full my-1 p-3 rounded bg-gray-100 dark:bg-zinc-600 whitespace-pre-wrap;
code {
@apply block;
@@ -63,7 +56,7 @@
}
code {
@apply bg-gray-100 px-1 rounded text-sm font-mono leading-6 inline-block;
@apply break-all bg-gray-100 dark:bg-zinc-600 px-1 rounded text-sm font-mono leading-6 inline-block;
}
table {
@@ -79,11 +72,11 @@
}
blockquote {
@apply border-l-4 pl-2 text-gray-400;
@apply border-l-4 pl-2 text-gray-400 dark:text-gray-300;
}
hr {
@apply my-1;
@apply my-1 dark:border-zinc-600;
}
}
@@ -91,7 +84,7 @@
@apply w-full relative flex flex-row justify-start items-center;
> .btn {
@apply flex flex-row justify-start items-center pl-2 pr-1 py-1 my-1 text-xs rounded-lg border bg-gray-100 border-gray-200 opacity-80 shadow hover:opacity-60;
@apply flex flex-row justify-start items-center pl-2 pr-1 py-1 my-1 text-xs rounded-lg border bg-gray-100 dark:bg-zinc-600 border-gray-200 dark:border-zinc-600 opacity-80 shadow hover:opacity-60 cursor-pointer;
&.expand-btn {
@apply mt-2;

View File

@@ -1,12 +1,12 @@
.page-wrapper.memo-detail {
@apply relative top-0 w-full h-screen overflow-y-auto overflow-x-hidden;
@apply relative top-0 w-full h-screen overflow-y-auto overflow-x-hidden dark:bg-zinc-800;
background-color: #f6f5f4;
> .page-container {
@apply relative w-full min-h-screen mx-auto flex flex-col justify-start items-center pb-8;
> .page-header {
@apply sticky top-0 z-10 max-w-2xl w-full min-h-full flex flex-row justify-between items-center px-4 pt-6 mb-2;
@apply sticky top-0 z-10 max-w-2xl w-full min-h-full flex flex-row justify-between items-center px-4 pt-6 mb-2 dark:bg-zinc-800;
background-color: #f6f5f4;
> .title-container {
@@ -16,6 +16,10 @@
@apply h-12 sm:h-14 w-auto mr-1;
}
> .logo-text {
@apply text-4xl tracking-wide text-black dark:text-white;
}
> .title-text {
@apply text-xl sm:text-3xl font-mono text-gray-700;
}
@@ -23,7 +27,7 @@
> .action-button-container {
> .btn {
@apply block text-gray-600 font-mono text-base py-1 border px-3 leading-8 rounded-xl hover:opacity-80 hover:underline;
@apply block text-gray-600 dark:text-gray-300 font-mono text-base py-1 border px-3 leading-8 rounded-xl hover:opacity-80 hover:underline;
> .icon {
@apply text-lg;
@@ -36,7 +40,7 @@
@apply relative flex-grow max-w-2xl w-full min-h-full flex flex-col justify-start items-start px-4;
> .memo-container {
@apply flex flex-col justify-start items-start w-full p-4 pt-3 mt-2 bg-white rounded-lg border border-white hover:border-gray-200;
@apply flex flex-col justify-start items-start w-full p-4 mt-2 bg-white dark:bg-zinc-700 rounded-lg border border-white dark:border-zinc-800 hover:border-gray-200 dark:hover:border-zinc-700;
> .memo-header {
@apply mb-2 w-full flex flex-row justify-between items-center;

View File

@@ -1,12 +1,11 @@
.memo-editor-container {
@apply transition-all relative w-full flex flex-col justify-start items-start bg-white px-4 rounded-lg border-2 border-gray-200;
@apply relative w-full flex flex-col justify-start items-start bg-white dark:bg-zinc-700 px-4 rounded-lg border-2 border-gray-200 dark:border-zinc-600;
&.fullscreen {
@apply fixed w-full h-full top-0 left-0 z-1000 border-none rounded-none sm:p-8;
background-color: #f6f5f4;
@apply transition-all fixed w-full h-full top-0 left-0 z-1000 border-none rounded-none sm:p-8 dark:bg-zinc-800;
> .memo-editor {
@apply p-4 mb-4 rounded-lg border shadow-lg flex flex-col flex-grow justify-start items-start relative w-full h-full bg-white;
@apply p-4 mb-4 rounded-lg border shadow-lg flex flex-col flex-grow justify-start items-start relative w-full h-full bg-white dark:bg-zinc-700 dark:border-zinc-600;
> .common-editor-inputer {
@apply flex-grow w-full !h-full max-h-full;
@@ -18,9 +17,8 @@
top: unset !important;
}
.emoji-picker-react {
@apply !bottom-8;
top: unset !important;
.items-wrapper {
@apply mb-1 bottom-full top-auto;
}
}
@@ -29,7 +27,7 @@
}
> .memo-editor {
@apply mt-4 flex flex-col justify-start items-start relative w-full h-auto bg-white;
@apply mt-4 flex flex-col justify-start items-start relative w-full h-auto bg-inherit dark:text-gray-200;
}
> .common-tools-wrapper {
@@ -39,7 +37,7 @@
@apply flex flex-row justify-start items-center;
> .action-btn {
@apply flex flex-row justify-center items-center p-1 w-auto h-auto mr-1 select-none rounded cursor-pointer opacity-60 hover:opacity-90 hover:bg-gray-300 hover:shadow;
@apply flex flex-row justify-center items-center p-1 w-auto h-auto mr-1 select-none rounded cursor-pointer dark:text-gray-200 opacity-60 hover:opacity-90 hover:bg-gray-300 dark:hover:bg-zinc-800 hover:shadow;
&.tag-action {
@apply relative;
@@ -63,20 +61,34 @@
}
}
&.resource-btn {
@apply relative;
&:hover {
> .resource-action-list {
@apply flex;
}
}
> .resource-action-list {
@apply hidden flex-col justify-start items-start absolute top-6 left-0 mt-1 p-1 z-1 rounded w-auto overflow-auto font-mono shadow bg-zinc-200 dark:bg-zinc-600;
> .resource-action-item {
@apply w-full flex text-black dark:text-gray-300 cursor-pointer rounded text-sm leading-6 px-2 truncate hover:bg-zinc-300 dark:hover:bg-zinc-700 shrink-0;
> .icon-img {
@apply w-4 mr-1;
}
}
}
}
> .icon-img {
@apply w-5 h-5 mx-auto flex flex-row justify-center items-center;
}
> .tip-text {
@apply hidden ml-1 text-xs leading-5 text-gray-700 border border-gray-300 rounded-xl px-2;
}
}
.emoji-picker-react {
@apply absolute shadow left-6 top-8;
li.emoji::before {
@apply hidden;
@apply hidden ml-1 text-xs leading-5 text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-zinc-500 rounded-xl px-2;
}
}
}
@@ -86,7 +98,7 @@
@apply w-full flex flex-row justify-start flex-wrap;
> .resource-container {
@apply mt-1 mr-1 flex flex-row justify-start items-center flex-nowrap bg-gray-100 px-2 py-1 rounded cursor-pointer hover:bg-gray-200;
@apply max-w-full mt-1 mr-1 flex flex-row justify-start items-center flex-nowrap bg-gray-100 px-2 py-1 rounded cursor-pointer hover:bg-gray-200;
> .icon-img {
@apply w-4 h-auto mr-1 text-gray-500;
@@ -103,7 +115,7 @@
}
> .editor-footer-container {
@apply w-full flex flex-row justify-between items-center border-t border-t-gray-100 py-3 mt-2;
@apply w-full flex flex-row justify-between items-center border-t border-t-gray-100 dark:border-t-zinc-500 py-3 mt-2;
> .visibility-selector {
@apply h-8;
@@ -121,7 +133,7 @@
@apply grow-0 shrink-0 flex flex-row justify-end items-center;
> .cancel-btn {
@apply mr-4 text-sm text-gray-600 hover:opacity-80;
@apply mr-4 text-sm text-gray-500 hover:opacity-80 dark:text-gray-300;
}
> .confirm-btn {

View File

@@ -1,16 +1,16 @@
.filter-query-container {
@apply flex flex-row justify-start items-start w-full flex-wrap p-2 pb-1 text-sm font-mono leading-7;
@apply flex flex-row justify-start items-start w-full flex-wrap p-2 pb-1 text-sm font-mono leading-7 dark:text-gray-300;
> .tip-text {
@apply mr-2;
}
> .filter-item-container {
@apply flex flex-row justify-start items-center px-2 mr-2 cursor-pointer bg-gray-200 rounded whitespace-nowrap truncate hover:line-through;
@apply flex flex-row justify-start items-center px-2 mr-2 cursor-pointer dark:text-gray-300 bg-gray-200 dark:bg-zinc-700 rounded whitespace-nowrap truncate hover:line-through;
max-width: 256px;
> .icon-text {
@apply w-4 h-auto mr-1 text-gray-500;
@apply w-4 h-auto mr-1 text-gray-500 dark:text-gray-400;
}
}
}

View File

@@ -1,12 +1,12 @@
.memo-wrapper {
@apply relative flex flex-col justify-start items-start w-full p-4 pt-3 mt-2 bg-white rounded-lg border border-white hover:border-gray-200;
&.archived-memo {
@apply border-gray-200;
}
@apply relative flex flex-col justify-start items-start w-full p-4 pt-3 mt-2 bg-white dark:bg-zinc-700 rounded-lg border border-white dark:border-zinc-600 hover:border-gray-200 dark:hover:border-zinc-600;
&.pinned {
@apply border-gray-200 border-2;
@apply border-gray-200 border-2 dark:border-zinc-600;
}
&.archived {
@apply border-gray-200 dark:border-zinc-600;
}
> .corner-container {
@@ -54,14 +54,14 @@
@apply hidden flex-col justify-start items-center absolute top-2 -right-4 flex-nowrap hover:flex p-3;
> .more-action-btns-container {
@apply w-28 h-auto p-1 z-1 whitespace-nowrap rounded-lg bg-white;
@apply w-28 h-auto p-1 z-1 whitespace-nowrap rounded-lg bg-white dark:bg-zinc-700;
box-shadow: 0 0 8px 0 rgb(0 0 0 / 20%);
> .btns-container {
@apply w-full flex flex-row justify-around items-center border-b border-gray-100 p-1 mb-1;
@apply w-full flex flex-row justify-around items-center border-b border-gray-100 dark:border-zinc-600 p-1 mb-1;
> .btn {
@apply relative w-6 h-6 p-1 text-gray-600 cursor-pointer select-none;
@apply relative w-6 h-6 p-1 text-gray-600 dark:text-gray-300 cursor-pointer select-none;
&:hover > .tip-text {
@apply block;
@@ -78,7 +78,7 @@
}
> .btn {
@apply w-full text-sm leading-6 py-1 px-3 rounded justify-start cursor-pointer;
@apply w-full text-sm leading-6 py-1 px-3 rounded justify-start cursor-pointer dark:text-gray-300;
&.archive-btn {
@apply text-orange-600;
@@ -88,13 +88,13 @@
}
.btn {
@apply flex flex-row justify-center items-center px-2 leading-6 text-sm rounded hover:bg-gray-200;
@apply flex flex-row justify-center items-center px-2 leading-6 text-sm rounded hover:bg-gray-200 dark:hover:bg-zinc-600;
&.more-action-btn {
@apply w-8 -mr-2 opacity-60 cursor-default hover:bg-transparent;
> .icon-img {
@apply w-4 h-auto;
@apply w-4 h-auto dark:text-gray-300;
}
&:hover {
@@ -118,30 +118,4 @@
}
}
}
> .expand-btn-container {
@apply w-full relative flex flex-row justify-start items-center;
> .btn {
@apply flex flex-row justify-start items-center pl-2 pr-1 py-1 my-1 text-xs rounded-lg border bg-gray-100 border-gray-200 opacity-80 shadow hover:opacity-60;
&.expand-btn {
@apply mt-2;
> .icon-img {
@apply rotate-90;
}
}
&.fold-btn {
> .icon-img {
@apply -rotate-90;
}
}
> .icon-img {
@apply w-4 h-auto ml-1 transition-all;
}
}
}
}

View File

@@ -1,6 +1,6 @@
.section-header-container,
.memos-header-container {
@apply sticky top-4 flex flex-row justify-between items-center w-full h-10 flex-nowrap mt-4 mb-2 shrink-0 z-1;
@apply sticky top-4 flex flex-row justify-between items-center w-full h-10 flex-nowrap mt-4 mb-2 shrink-0 z-10;
> .title-container {
@apply flex flex-row justify-start items-center mr-2 shrink-0 overflow-hidden;
@@ -9,12 +9,12 @@
@apply flex sm:hidden flex-row justify-center items-center w-6 h-6 mr-1 shrink-0 bg-transparent;
> .icon-img {
@apply w-5 h-auto;
@apply w-5 h-auto dark:text-gray-200;
}
}
> .title-text {
@apply font-bold text-lg leading-10 mr-2 text-ellipsis shrink-0 cursor-pointer overflow-hidden text-gray-700;
@apply font-bold text-lg leading-10 mr-2 text-ellipsis shrink-0 cursor-pointer overflow-hidden text-gray-700 dark:text-gray-200;
}
}

View File

@@ -11,7 +11,7 @@
@apply fixed top-8 right-8 flex flex-col justify-start items-center;
> .btn {
@apply mb-3 last:mb-0 w-8 h-8 p-1 cursor-pointer rounded opacity-90 bg-gray-300 z-10 shadow-md hover:opacity-70;
@apply mb-3 last:mb-0 w-8 h-8 p-1 cursor-pointer rounded opacity-90 bg-gray-300 dark:bg-zinc-600 z-10 shadow-md hover:opacity-70;
> .icon-img {
@apply w-6 h-auto;

View File

@@ -25,7 +25,7 @@
@apply text-sm cursor-pointer px-3 py-1 rounded flex flex-row justify-center items-center border border-red-600 text-red-600 bg-red-100 hover:opacity-80;
> .icon-img {
@apply w-4 h-auto mr-1;
@apply w-4 h-auto mr-1;
}
}
}
@@ -39,7 +39,7 @@
@apply flex flex-col justify-start items-start w-full;
> .fields-container {
@apply px-2 py-2 w-full grid grid-cols-7 border-b;
@apply px-2 py-2 w-full grid grid-cols-7 border-b dark:border-b-zinc-600;
> .field-text {
@apply font-mono text-gray-400;

View File

@@ -0,0 +1,51 @@
.resources-selector-dialog {
@apply px-4;
> .dialog-container {
@apply w-112 max-w-full mb-8;
> .dialog-content-container {
@apply flex flex-col justify-start items-start w-full;
> .loading-text-container {
@apply flex flex-col justify-center items-center w-full h-32;
}
> .resource-table-container {
@apply flex flex-col justify-start items-start w-full;
> .fields-container {
@apply px-2 py-2 w-full grid grid-cols-7 border-b dark:border-b-gray-500;
> .field-text {
@apply font-mono text-gray-400;
}
}
> .tip-text {
@apply w-full text-center text-base my-6 mt-8;
}
> .resource-container {
@apply px-2 py-2 w-full grid grid-cols-7 dark:bg-zinc-700;
> .buttons-container {
@apply w-full flex flex-row justify-end items-center;
}
}
.field-text {
@apply w-full truncate text-base pr-2 last:pr-0;
&.id-text {
@apply col-span-2;
}
&.name-text {
@apply col-span-4;
}
}
}
}
}
}

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