Compare commits

..

135 Commits

Author SHA1 Message Date
boojack eea826dd5c chore: release v0.2.2 2022-07-22 23:42:26 +08:00
boojack 3c5b0ea90a chore: update style 2022-07-22 23:31:25 +08:00
boojack 15e1037433 chore: create backup when migration 2022-07-22 23:21:12 +08:00
boojack 5da4c98f05 chore: update icon button styles 2022-07-19 21:46:38 +08:00
boojack a73ee7aefc chore: update readme (#124)
* chore: update readme

* chore: add space

* chore: add emoji
2022-07-18 23:16:39 +08:00
boojack 6c5bea9caf chore: update html2image 2022-07-17 10:42:35 +08:00
boojack 93ba2f4fab chore: fix icon style 2022-07-17 10:29:12 +08:00
boojack 9417797b99 chore: use fontawesome instead of material icons 2022-07-17 09:58:56 +08:00
boojack 167e5596f2 fix: generate html image in safari (#123) 2022-07-17 01:52:29 +08:00
boojack 2a1e34fe03 chore: update material icons 2022-07-16 11:51:03 +08:00
boojack 3de00cf4a8 chore: add dayjs to parse datetime 2022-07-16 11:50:40 +08:00
boojack 1d55545e30 chore: update github badge style 2022-07-16 09:52:57 +08:00
boojack 9b5a555d1f chore: release v0.2.1 (#120)
* chore: release `v0.2.1`

* chore: add tg group link
2022-07-15 22:49:22 +08:00
boojack 9c842d0a40 fix: remove axios withCredentials 2022-07-15 22:38:50 +08:00
boojack 0dc377550f chore: fix hover heatmap 2022-07-15 22:29:47 +08:00
boojack 8a91b0ad9d chore: add github badge 2022-07-15 22:17:11 +08:00
boojack 1b50ab5dca chore: use echo static middleware to serve dist 2022-07-15 21:25:29 +08:00
boojack 6053df050c chore: update create memo with visibility 2022-07-15 21:25:07 +08:00
boojack 3517c6181d chore: update vite 2022-07-14 19:33:20 +08:00
boojack 2e126c71f0 chore: update button elements 2022-07-10 12:02:36 +08:00
boojack 46d7ecca88 feat: use go embed 2022-07-10 09:02:56 +08:00
boojack 48d8c6ee0f chore: update lock file 2022-07-10 08:43:46 +08:00
boojack 5fd3cfdb61 chore: update user store 2022-07-10 08:36:10 +08:00
boojack 10d710cf03 chore: fix editor z-index 2022-07-10 08:35:36 +08:00
boojack 21702b615a chore: update seed data 2022-07-10 08:15:34 +08:00
boojack d75338b6e9 chore: fix z-index 2022-07-09 23:58:04 +08:00
boojack b85af714f5 feat: fullscreen editor 2022-07-09 23:16:20 +08:00
boojack a2b32e0b75 chore: update demo.webp 2022-07-09 21:04:53 +08:00
boojack 0505598509 fix: data desensitize 2022-07-09 21:01:09 +08:00
boojack 91d45d6d46 chore: release v0.2.0 (#114) 2022-07-09 14:09:40 +08:00
boojack de7058532a fix: schema migration for minor version 2022-07-09 13:34:14 +08:00
boojack 7c94db0ca0 chore: use flags instead of env vars 2022-07-09 12:57:08 +08:00
boojack 1d8603df2b chore: add latest docker tag (#113) 2022-07-09 12:31:56 +08:00
boojack 6a8c559e8c chore: update visitor view buttons 2022-07-09 12:00:26 +08:00
boojack 7418d2965d fix: visitor view in frontend 2022-07-09 08:32:46 +08:00
boojack ac560dfcf9 chore: update get user by id 2022-07-09 08:31:07 +08:00
boojack 1afc183458 feat: update memo visibility in frontend 2022-07-08 23:38:24 +08:00
boojack 697d01e306 feat: add visibility field to memo (#109)
* feat: add `visibility` field to memo

* chore: fix typo
2022-07-08 22:23:27 +08:00
boojack aed137472c fix: open id checking order 2022-07-08 22:17:17 +08:00
boojack bdc9632b5b chore: rename user role (#108)
* chore: rename user role to `host`

* chore: related frontend changes

* chore: fix migration file

* chore: use tricky sql
2022-07-08 22:16:18 +08:00
boojack 6f32643d7c refactor: visitor view (#107)
* refactor: update api

* refactor: visitor view

* chore: update seed data
2022-07-07 23:11:20 +08:00
boojack 346d219cd5 chore: reorder imports manually (#106)
* chore: reorder imports manually

* chore: remove unused less
2022-07-07 22:02:40 +08:00
Hyoban 6b5d5e757e feat: personal memos page (#105)
* feat: no need to log in to view memos

* chore: add a normal user to seed

* feat: page for other members

* fix: replace window.location

* fix: can not get username on home

* fix: check userID

* fix: can visit other user's page after login

* fix: do not redirect on wrong path

* fix: path error when clicked heatmap

* refactor: revise for review

* chore: remove unused import

* refactor: revise for review

* feat: update each user's route to /u/:userId.

* chore: eslint for import sort

* refactor: revise for review
2022-07-07 20:22:36 +08:00
Hyoban e202d7b8d6 fix: banner text click not work (#104)
* fix: banner text click not work

* fix: replenish duration

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

Co-authored-by: boojack <stevenlgtm@gmail.com>
2022-07-06 11:22:19 +08:00
boojack 5a20db0bed chore: fix missing extend style item 2022-07-05 22:54:22 +08:00
boojack 2136a954f5 chore: make editor sticky 2022-07-05 22:48:21 +08:00
boojack 0e8d3e6907 chore: fix memos amount 2022-07-05 22:09:11 +08:00
boojack 592e037f21 feat: use api with open_id instead of webhooks 2022-07-05 22:04:17 +08:00
boojack c6695121f0 chore: update github action 2022-07-04 22:37:31 +08:00
boojack 17a61bb65f choe: update shortcut actived style 2022-07-04 21:45:54 +08:00
boojack 49666ddaf3 fix: patch memo missing creator_id 2022-07-04 21:27:44 +08:00
boojack 29f73f0d25 chore: update tag list selector 2022-07-04 21:27:07 +08:00
boojack 3f3f6eaee8 fix: dockerfile 2022-07-03 22:45:10 +08:00
boojack f743532e57 feat: support multi platforms docker image (#103) 2022-07-03 21:52:44 +08:00
boojack 58f62f88a8 chore: add test github action (#102)
chore: test
2022-07-03 21:37:06 +08:00
boojack 3a837203a5 chore: update buildx version 2022-07-03 21:29:25 +08:00
boojack 1a86b3cb5a chore: update Dockerfile 2022-07-03 21:15:43 +08:00
boojack d211f0f474 chore: support multi-plantform for docker 2022-07-03 20:54:42 +08:00
boojack eb80bc7798 chore: update demo.png 2022-07-03 11:34:04 +08:00
boojack 3b0346d84c chore: update seed data 2022-07-03 11:25:06 +08:00
boojack 65ade1fc87 chore: update todo block 2022-07-03 11:24:57 +08:00
boojack 5dd6d505cc chore: use undefined instead of UNKNOWN_ID 2022-07-02 15:05:42 +08:00
boojack 2fe2b82809 chore: update seed data 2022-07-02 15:01:59 +08:00
boojack 06fc29aecd chore: rename delete to archive 2022-07-02 14:14:18 +08:00
boojack 536627007d feat: schema migration handler (#100)
* chore: update about site dialog

* feat: schema migration

* chore: lint with golangci
2022-07-02 10:47:16 +08:00
boojack 3c58953e56 chore: add version checker 2022-07-02 01:06:28 +08:00
boojack 0d317839d2 Merge branch 'main' of github.com:justmemos/memos 2022-07-02 00:58:11 +08:00
boojack fa9443f121 chore: update docker username 2022-07-02 00:58:03 +08:00
boojack a7425ac558 feat: toggle todo status by clicking (#99) 2022-07-02 00:56:25 +08:00
boojack 9611ff7386 chore: release v0.1.3 (#98)
* chore: update github action

* chore: release `v0.1.3`

* fix: create migration_history table

* fix: compare migration_history
2022-07-01 20:39:48 +08:00
boojack 87e6277977 fix: upsert migration history 2022-07-01 20:08:25 +08:00
boojack 0945b14deb chore: update signup page style 2022-07-01 19:53:31 +08:00
boojack 1b60180b79 chore: update setting dialog style 2022-07-01 19:32:42 +08:00
boojack bfc6e4dd0f chore: update seed data 2022-06-30 22:36:19 +08:00
boojack 57ce96d23e chore: fix expand style 2022-06-30 21:30:07 +08:00
boojack 64f67f4bda chore: update content parser (#97) 2022-06-30 21:19:50 +08:00
boojack 5b2e6a568f chore: get tags by openid (#95) 2022-06-28 22:32:10 +08:00
boojack 8cb9675965 chore: download image by one click (#94)
chore: download image by clicking
2022-06-28 21:56:06 +08:00
boojack 011fcc7dd4 chore: rename module 2022-06-27 22:09:06 +08:00
Hyoban a62c982a3d fix: cannot register in production (#91) 2022-06-27 19:11:56 +08:00
boojack 08210d55c3 chore: rename to DailyReviewDialog 2022-06-25 11:50:35 +08:00
boojack 62f0122cd5 chore: restore icon 2022-06-25 09:58:15 +08:00
boojack 8cb3994022 chore: update sharing image preview 2022-06-25 09:58:00 +08:00
boojack cad4db128b fix: mouse hover in heatmap 2022-06-25 09:57:31 +08:00
Steven 4871ebf24e chore: release v0.1.2 (#88)
* chore: release `v0.1.2`

* chore: update demo.png
2022-06-24 20:06:47 +08:00
boojack 929f621be4 chore: add image uploading status 2022-06-24 20:01:02 +08:00
boojack 2c8ff2794d chore: add ping button 2022-06-24 19:29:33 +08:00
Tiger d1a7527c0d fix: content link style (#86)
* Update memo-content.less

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

Co-authored-by: Steven <imrealleonardo@gmail.com>
2022-06-24 08:23:22 +08:00
boojack 3be5ea34a4 chore: update popup button styles 2022-06-22 19:52:06 +08:00
boojack 4ce728300b chore: data desensitize for owner 2022-06-22 19:16:31 +08:00
Steven 1999260f9d chore: expand/fold memo content button (#84)
* chore: toggle show all content button

* chore: update expand text

* chore: rename
2022-06-22 08:36:09 +08:00
boojack ceef257348 chore: get tags from exist memos 2022-06-21 23:29:07 +08:00
boojack babeb468c1 chore: update daily dialog style 2022-06-21 22:29:14 +08:00
boojack 85ce72282b fix: response type 2022-06-21 22:29:06 +08:00
Steven f80f0f2422 chore: use markdown image syntax (#83) 2022-06-21 22:14:52 +08:00
Steven 9f81362027 feat: add /api/tag (#82) 2022-06-21 21:58:33 +08:00
boojack cc54be0d1d chore: fix date selector style 2022-06-21 08:43:45 +08:00
Steven 40680a5e0f chore: update memo action buttons style (#80)
chore: update memo action btn style
2022-06-21 08:35:46 +08:00
Steven f849a94dc5 chore: show daily memos view in sidebar (#79) 2022-06-21 08:16:42 +08:00
STEVEN 50fee8b0f4 chore: release v0.1.1 (#77) 2022-06-19 11:46:34 +08:00
boojack efb3fad194 fix: hidden priority 2022-06-19 11:39:29 +08:00
STEVEN 1733ed670c fix: tag regex (#76)
* fix: tag regex

* fix: tag styles

* chore: remove usused codes
2022-06-19 11:34:49 +08:00
STEVEN cd7000da70 feat: responsive view (#75)
* chore: add license

* feat: mobile view
2022-06-19 11:32:49 +08:00
boojack b96d78ed19 fix: tag ending space 2022-06-19 10:42:07 +08:00
STEVEN 164873b344 chore: find memo by tag (#74) 2022-06-14 23:09:03 +08:00
boojack 8df0711f80 chore: update deploy guide in readme 2022-05-29 11:04:58 +08:00
boojack 183ce534b9 chore: add ON DELETE CASCADE 2022-05-28 07:43:07 +08:00
boojack cc2e5ab6fd chore: update router 2022-05-28 07:38:06 +08:00
STEVEN 5d6df87af0 fix: sort tags in creating shortcut (#69) 2022-05-24 18:07:46 +08:00
boojack 7b6be96eb3 chore: add patch memo by open api 2022-05-22 22:39:24 +08:00
STEVEN 668276f0df chore: release v0.1.0 (#65)
* chore: update version `v0.1.0`

* fix: initial memo filter
2022-05-22 18:11:27 +08:00
boojack d23262856e fix: initial memo filter 2022-05-22 17:40:32 +08:00
boojack 069c5d6a35 chore: fix upload image insert text 2022-05-22 17:10:46 +08:00
boojack 2e5b120986 chore: update store selector 2022-05-22 12:41:15 +08:00
boojack 948b53393a chore: add default ORDER BY 2022-05-22 12:35:57 +08:00
boojack b52c16c43f chore: use axios instead of fetch 2022-05-22 12:02:58 +08:00
boojack a580df5c9f chore: update redux store and service 2022-05-22 11:01:20 +08:00
boojack c3c2882dc5 chore: update server profile 2022-05-22 09:29:34 +08:00
boojack 0b3c77c79c chore: remove customize redux 2022-05-22 01:12:49 +08:00
boojack 8e01eb8702 feat: store/db module with sqlite 2022-05-22 00:59:22 +08:00
boojack c34cbb19bc fix: memo import 2022-05-21 12:42:28 +08:00
boojack 7b668f17f5 chore: rename repo 2022-05-21 12:33:18 +08:00
boojack c2e5a1a524 refactor: use redux 2022-05-21 12:21:06 +08:00
boojack 2e9152e223 fix: web build 2022-05-20 23:17:19 +08:00
boojack 9a3afc56e7 fix: shortcutId in filter 2022-05-20 23:10:47 +08:00
boojack 3fc907d446 chore: fix sign in style 2022-05-20 22:48:51 +08:00
boojack 7b8a825489 chore: add greeting banner 2022-05-20 22:48:36 +08:00
boojack 3fdc5bb2b1 chore: update demo user 2022-05-20 06:48:55 +08:00
boojack ae87ee6dd0 chore: update readme 2022-05-20 06:24:47 +08:00
STEVEN d6680a0bcc feat: seed data only in dev mode (#61) 2022-05-19 22:29:27 +08:00
boojack 304df8674a chore: fix build 2022-05-19 18:35:34 +08:00
boojack 6fe1db42b5 chore: update store types 2022-05-19 18:32:49 +08:00
boojack bc22f69ac5 refactor: raw struct for store 2022-05-19 18:32:04 +08:00
boojack 0b50122aac fix: version getter 2022-05-17 22:19:39 +08:00
STEVEN 5731fb8912 chore: release 0.0.1 (#57) 2022-05-17 21:59:12 +08:00
206 changed files with 5727 additions and 5147 deletions
+12 -6
View File
@@ -9,23 +9,29 @@ jobs:
build-and-push-dev-image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Login to Docker Hub
uses: docker/login-action@v1
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_NEOSMEMO_USERNAME }}
username: neosmemo
password: ${{ secrets.DOCKER_NEOSMEMO_TOKEN }}
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
uses: docker/setup-buildx-action@v2
with:
install: true
- name: Build and Push
id: docker_build
uses: docker/build-push-action@v2
uses: docker/build-push-action@v3
with:
context: ./
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ secrets.DOCKER_NEOSMEMO_USERNAME }}/memos:dev
tags: neosmemo/memos:dev
@@ -10,7 +10,10 @@ jobs:
build-and-push-release-image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Extract build args
# Extract version from branch name
@@ -19,20 +22,23 @@ jobs:
echo "VERSION=${GITHUB_REF_NAME#release/v}" >> $GITHUB_ENV
- name: Login to Docker Hub
uses: docker/login-action@v1
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_NEOSMEMO_USERNAME }}
username: neosmemo
password: ${{ secrets.DOCKER_NEOSMEMO_TOKEN }}
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
uses: docker/setup-buildx-action@v2
with:
install: true
- name: Build and Push
id: docker_build
uses: docker/build-push-action@v2
uses: docker/build-push-action@v3
with:
context: ./
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ secrets.DOCKER_NEOSMEMO_USERNAME }}/memos:${{ env.VERSION }}
tags: neosmemo/memos:latest, neosmemo/memos:${{ env.VERSION }}
@@ -0,0 +1,37 @@
name: build-and-push-test-image
on:
push:
branches:
- "test/*"
jobs:
build-and-push-dev-image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: neosmemo
password: ${{ secrets.DOCKER_NEOSMEMO_TOKEN }}
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
with:
install: true
- name: Build and Push
id: docker_build
uses: docker/build-push-action@v3
with:
context: ./
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: neosmemo/memos:test
+2 -20
View File
@@ -1,28 +1,10 @@
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Temp output
*.out
*.log
tmp
# Air (hot reload) generated
.air
# Frontend asset
dist
# Dev database
data
web/dist
# build folder
build
memos-build
.DS_Store
+5 -4
View File
@@ -1,5 +1,5 @@
# Build frontend dist.
FROM node:14.18.2-alpine3.14 AS frontend
FROM node:16.15.0-alpine AS frontend
WORKDIR /frontend-build
COPY ./web/ .
@@ -8,23 +8,24 @@ RUN yarn
RUN yarn build
# Build backend exec file.
FROM golang:1.16.12-alpine3.15 AS backend
FROM golang:1.18.3-alpine3.16 AS backend
WORKDIR /backend-build
RUN apk update
RUN apk --no-cache add gcc musl-dev
COPY . .
COPY --from=frontend /frontend-build/dist ./server/dist
RUN go build \
-o memos \
./bin/server/main.go
# Make workspace with above generated files.
FROM alpine:3.14.3 AS monolithic
FROM alpine:3.16.0 AS monolithic
WORKDIR /usr/local/memos
COPY --from=backend /backend-build/memos /usr/local/memos/
COPY --from=frontend /frontend-build/dist /usr/local/memos/web/dist
# Directory to store the data, which can be referenced as the mounting point.
RUN mkdir -p /var/opt/memos
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Memos
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+25 -20
View File
@@ -1,39 +1,44 @@
<h1 align="center">✍️ Memos</h1>
<p align="center">An open source, quickly self-hosted alternative to flomo</p>
<p align="center">An open source, self-hosted knowledge base that works with a SQLite db file.</p>
<p align="center">
<img alt="GitHub stars" src="https://img.shields.io/github/stars/justmemos/memos" />
<img alt="Docker pull" src="https://img.shields.io/docker/pulls/neosmemo/memos.svg" />
<img alt="Go report" src="https://goreportcard.com/badge/github.com/justmemos/memos" />
<img alt="GitHub stars" src="https://img.shields.io/github/stars/usememos/memos" />
<a href="https://hub.docker.com/r/neosmemo/memos"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/neosmemo/memos.svg" /></a>
<img alt="Go report" src="https://goreportcard.com/badge/github.com/usememos/memos" />
</p>
<p align="center">
<a href="https://memos.onrender.com/">Live Demo</a> •
<a href="https://github.com/justmemos/memos/discussions">Discussions</a>
<a href="https://t.me/+-_tNF1k70UU4ZTc9">Discuss in Telegram 👾</a>
</p>
![demo](https://raw.githubusercontent.com/justmemos/memos/main/resources/demo.png)
## 🎯 Intentions
- ✍️ Write down the light-card memos very easily;
- 🏗️ Build the fragmented knowledge management tool for yourself;
- 📒 For noting your 📅 daily/weekly plans, 💡 fantastic ideas, 📕 reading thoughts...
![demo](https://raw.githubusercontent.com/usememos/memos/main/resources/demo.webp)
## ✨ Features
- 🦄 Fully open source;
- 🤠 Great UI and never miss any detail;
- 🚀 Super quick self-hosted with `Docker` and `SQLite`;
- 📜 Writing in plain textarea without any burden,
- and support some useful markdown syntax 💪.
- 🌄 Share the memo in a pretty image or personal page like Twitter;
- 🚀 Fast self-hosting with `Docker`;
- 🤠 Pleasant UI and UX;
## ⚓️ Deploy with Docker
```docker
docker run --name memos --publish 5230:8080 --volume ~/.memos/:/var/opt/memos -e mode=prod neosmemo/memos:0.0.1
docker run \
--name memos \
--publish 5230:5230 \
--volume ~/.memos/:/var/opt/memos \
neosmemo/memos:latest \
--mode prod \
--port 5230
```
If the `~/.memos/` does not have a `memos_prod.db` file, then `memos` will auto generate it.
Memos should now 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.
⚠️ Please DO NOT use `dev` tag of docker image if you have no experience.
## 🏗 Development
@@ -45,7 +50,7 @@ Memos is built with a curated tech stack. It is optimized for developer experien
### Tech Stack
<img alt="tech stack" src="https://raw.githubusercontent.com/justmemos/memos/main/resources/tech-stack.png" width="360" />
<img alt="tech stack" src="https://raw.githubusercontent.com/usememos/memos/main/resources/tech-stack.png" width="360" />
### Prerequisites
@@ -58,7 +63,7 @@ Memos is built with a curated tech stack. It is optimized for developer experien
1. pull source code
```bash
git clone https://github.com/justmemos/memos
git clone https://github.com/usememos/memos
```
2. start backend using air(with live reload)
@@ -73,11 +78,11 @@ Memos is built with a curated tech stack. It is optimized for developer experien
cd web && yarn && yarn dev
```
Memos should now be running at https://localhost:3000 and change either frontend or backend code would trigger live reload.
Memos should now be running at [http://localhost:3000](http://localhost:3000) and change either frontend or backend code would trigger live reload.
## 🌟 Star history
[![Star History Chart](https://api.star-history.com/svg?repos=justmemos/memos&type=Date)](https://star-history.com/#justmemos/memos&Date)
[![Star History Chart](https://api.star-history.com/svg?repos=usememos/memos&type=Date)](https://star-history.com/#usememos/memos&Date)
---
+21
View File
@@ -0,0 +1,21 @@
package api
// RowStatus is the status for a row.
type RowStatus string
const (
// Normal is the status for a normal row.
Normal RowStatus = "NORMAL"
// Archived is the status for an archived row.
Archived RowStatus = "ARCHIVED"
)
func (e RowStatus) String() string {
switch e {
case Normal:
return "NORMAL"
case Archived:
return "ARCHIVED"
}
return ""
}
+1 -1
View File
@@ -1,6 +1,6 @@
package api
type Login struct {
type Signin struct {
Email string `json:"email"`
Password string `json:"password"`
}
+43 -10
View File
@@ -1,16 +1,38 @@
package api
// Visibility is the type of a visibility.
type Visibility string
const (
// Public is the PUBLIC visibility.
Public Visibility = "PUBLIC"
// Privite is the PRIVATE visibility.
Privite Visibility = "PRIVATE"
)
func (e Visibility) String() string {
switch e {
case Public:
return "PUBLIC"
case Privite:
return "PRIVATE"
}
return "PRIVATE"
}
type Memo struct {
ID int `json:"id"`
// Standard fields
CreatedTs int64 `json:"createdTs"`
UpdatedTs int64 `json:"updatedTs"`
RowStatus string `json:"rowStatus"`
RowStatus RowStatus `json:"rowStatus"`
CreatorID int `json:"creatorId"`
CreatedTs int64 `json:"createdTs"`
UpdatedTs int64 `json:"updatedTs"`
// Domain specific fields
Content string `json:"content"`
CreatorID int `json:"creatorId"`
Content string `json:"content"`
Visibility Visibility `json:"visibility"`
Pinned bool `json:"pinned"`
}
type MemoCreate struct {
@@ -20,25 +42,36 @@ type MemoCreate struct {
CreatedTs *int64 `json:"createdTs"`
// Domain specific fields
Content string `json:"content"`
Content string `json:"content"`
Visibility *Visibility `json:"visibility"`
}
type MemoPatch struct {
ID int
// Standard fields
RowStatus *string `json:"rowStatus"`
RowStatus *RowStatus `json:"rowStatus"`
// Domain specific fields
Content *string `json:"content"`
Content *string `json:"content"`
Visibility *Visibility `json:"visibility"`
}
type MemoFind struct {
ID *int `json:"id"`
// Standard fields
CreatorID *int `json:"creatorId"`
RowStatus *string `json:"rowStatus"`
RowStatus *RowStatus `json:"rowStatus"`
CreatorID *int `json:"creatorId"`
// Domain specific fields
Pinned *bool
ContentSearch *string
Visibility *Visibility
// Pagination
Limit int
Offset int
}
type MemoDelete struct {
+21
View File
@@ -0,0 +1,21 @@
package api
type MemoOrganizer struct {
ID int
// Domain specific fields
MemoID int
UserID int
Pinned bool
}
type MemoOrganizerFind struct {
MemoID int
UserID int
}
type MemoOrganizerUpsert struct {
MemoID int
UserID int
Pinned bool `json:"pinned"`
}
+5 -5
View File
@@ -4,10 +4,10 @@ type Shortcut struct {
ID int `json:"id"`
// Standard fields
CreatorID int
CreatedTs int64 `json:"createdTs"`
UpdatedTs int64 `json:"updatedTs"`
RowStatus string `json:"rowStatus"`
RowStatus RowStatus `json:"rowStatus"`
CreatorID int `json:"creatorId"`
CreatedTs int64 `json:"createdTs"`
UpdatedTs int64 `json:"updatedTs"`
// Domain specific fields
Title string `json:"title"`
@@ -27,7 +27,7 @@ type ShortcutPatch struct {
ID int
// Standard fields
RowStatus *string `json:"rowStatus"`
RowStatus *RowStatus `json:"rowStatus"`
// Domain specific fields
Title *string `json:"title"`
+3 -3
View File
@@ -1,8 +1,8 @@
package api
import "memos/common"
import "github.com/usememos/memos/server/profile"
type SystemStatus struct {
Owner *User `json:"owner"`
Profile *common.Profile `json:"profile"`
Host *User `json:"host"`
Profile *profile.Profile `json:"profile"`
}
+21 -4
View File
@@ -4,18 +4,29 @@ package api
type Role string
const (
// Owner is the OWNER role.
Owner Role = "OWNER"
// Host is the HOST role.
Host Role = "HOST"
// NormalUser is the USER role.
NormalUser Role = "USER"
)
func (e Role) String() string {
switch e {
case Host:
return "HOST"
case NormalUser:
return "USER"
}
return "USER"
}
type User struct {
ID int `json:"id"`
// Standard fields
CreatedTs int64 `json:"createdTs"`
UpdatedTs int64 `json:"updatedTs"`
RowStatus RowStatus `json:"rowStatus"`
CreatedTs int64 `json:"createdTs"`
UpdatedTs int64 `json:"updatedTs"`
// Domain specific fields
Email string `json:"email"`
@@ -38,6 +49,9 @@ type UserCreate struct {
type UserPatch struct {
ID int
// Standard fields
RowStatus *RowStatus `json:"rowStatus"`
// Domain specific fields
Email *string `json:"email"`
Name *string `json:"name"`
@@ -50,6 +64,9 @@ type UserPatch struct {
type UserFind struct {
ID *int `json:"id"`
// Standard fields
RowStatus *RowStatus `json:"rowStatus"`
// Domain specific fields
Email *string `json:"email"`
Role *Role
+32 -14
View File
@@ -4,42 +4,60 @@ import (
"fmt"
"os"
"memos/common"
"memos/server"
"memos/store"
"github.com/usememos/memos/server"
"github.com/usememos/memos/server/profile"
"github.com/usememos/memos/store"
DB "github.com/usememos/memos/store/db"
)
const (
greetingBanner = `
███╗ ███╗███████╗███╗ ███╗ ██████╗ ███████╗
████╗ ████║██╔════╝████╗ ████║██╔═══██╗██╔════╝
██╔████╔██║█████╗ ██╔████╔██║██║ ██║███████╗
██║╚██╔╝██║██╔══╝ ██║╚██╔╝██║██║ ██║╚════██║
██║ ╚═╝ ██║███████╗██║ ╚═╝ ██║╚██████╔╝███████║
╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝
`
)
type Main struct {
profile *common.Profile
profile *profile.Profile
}
func (m *Main) Run() error {
db := store.NewDB(m.profile)
db := DB.NewDB(m.profile)
if err := db.Open(); err != nil {
return fmt.Errorf("cannot open db: %w", err)
}
s := server.NewServer(m.profile)
storeInstance := store.New(db)
storeInstance := store.New(db.Db, m.profile)
s.Store = storeInstance
if err := s.Run(); err != nil {
return err
}
println(greetingBanner)
fmt.Printf("Version %s has started at :%d\n", m.profile.Version, m.profile.Port)
return nil
return s.Run()
}
func Execute() {
profile := common.GetProfile()
profile := profile.GetProfile()
m := Main{
profile: profile,
}
err := m.Run()
if err != nil {
fmt.Printf("%+v\n", err)
println("---")
println("profile")
println("mode:", profile.Mode)
println("port:", profile.Port)
println("dsn:", profile.DSN)
println("version:", profile.Version)
println("---")
if err := m.Run(); err != nil {
fmt.Printf("error: %+v\n", err)
os.Exit(1)
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
package main
import "memos/bin/server/cmd"
import "github.com/usememos/memos/bin/server/cmd"
func main() {
cmd.Execute()
+58 -1
View File
@@ -1,4 +1,61 @@
package common
import (
"strconv"
"strings"
)
// Version is the service current released version.
var Version = "0.0.1"
// Semantic versioning: https://semver.org/
var Version = "0.2.2"
// DevVersion is the service current development version.
var DevVersion = "0.2.2"
func GetCurrentVersion(mode string) string {
if mode == "dev" {
return DevVersion
}
return Version
}
func GetMinorVersion(version string) string {
versionList := strings.Split(version, ".")
if len(versionList) < 3 {
return ""
}
return versionList[0] + "." + versionList[1]
}
// convSemanticVersionToInt converts version string to int.
func convSemanticVersionToInt(version string) int {
versionList := strings.Split(version, ".")
if len(versionList) < 3 {
return 0
}
major, err := strconv.Atoi(versionList[0])
if err != nil {
return 0
}
minor, err := strconv.Atoi(versionList[1])
if err != nil {
return 0
}
patch, err := strconv.Atoi(versionList[2])
if err != nil {
return 0
}
return major*10000 + minor*100 + patch
}
// IsVersionGreaterThanOrEqualTo returns true if version is greater than or equal to target.
func IsVersionGreaterOrEqualThan(version, target string) bool {
return convSemanticVersionToInt(version) >= convSemanticVersionToInt(target)
}
// IsVersionGreaterThan returns true if version is greater than target.
func IsVersionGreaterThan(version, target string) bool {
return convSemanticVersionToInt(version) > convSemanticVersionToInt(target)
}
+3 -3
View File
@@ -1,4 +1,4 @@
module memos
module github.com/usememos/memos
go 1.17
@@ -12,7 +12,7 @@ require (
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.1 // indirect
golang.org/x/crypto v0.0.0-20210920023735-84f357641f63 // indirect
golang.org/x/crypto v0.0.0-20210920023735-84f357641f63
golang.org/x/net v0.0.0-20210917221730-978cfadd31cf // indirect
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b // indirect
golang.org/x/text v0.3.7 // indirect
@@ -26,7 +26,7 @@ require (
)
require (
github.com/gorilla/securecookie v1.1.1 // indirect
github.com/gorilla/securecookie v1.1.1
github.com/gorilla/sessions v1.2.1
github.com/labstack/echo-contrib v0.12.0
)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 924 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

+10
View File
@@ -0,0 +1,10 @@
# Usage: sh ./scripts/build.sh
set -e
cd "$(dirname "$0")/../"
echo "Start building..."
go build -o ./memos-build/memos ./bin/server/main.go
echo "Build finished"
+22 -21
View File
@@ -3,47 +3,49 @@ package server
import (
"encoding/json"
"fmt"
"memos/api"
"memos/common"
"net/http"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
"github.com/labstack/echo/v4"
"golang.org/x/crypto/bcrypt"
)
func (s *Server) registerAuthRoutes(g *echo.Group) {
g.POST("/auth/login", func(c echo.Context) error {
login := &api.Login{}
if err := json.NewDecoder(c.Request().Body).Decode(login); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted login request").SetInternal(err)
g.POST("/auth/signin", func(c echo.Context) error {
signin := &api.Signin{}
if err := json.NewDecoder(c.Request().Body).Decode(signin); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted signin request").SetInternal(err)
}
userFind := &api.UserFind{
Email: &login.Email,
Email: &signin.Email,
}
user, err := s.Store.FindUser(userFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user by email %s", login.Email)).SetInternal(err)
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user by email %s", signin.Email)).SetInternal(err)
}
if user == nil {
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("User not found with email %s", login.Email))
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("User not found with email %s", signin.Email))
} else if user.RowStatus == api.Archived {
return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("User has been archived with email %s", signin.Email))
}
// Compare the stored hashed password, with the hashed version of the password that was received.
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(login.Password)); err != nil {
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(signin.Password)); err != nil {
// If the two passwords don't match, return a 401 status.
return echo.NewHTTPError(http.StatusUnauthorized, "Incorrect password").SetInternal(err)
}
if err = setUserSession(c, user); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to set login session").SetInternal(err)
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to set signin session").SetInternal(err)
}
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)
}
return nil
})
@@ -58,17 +60,17 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
})
g.POST("/auth/signup", func(c echo.Context) error {
// Don't allow to signup by this api if site owner existed.
ownerUserType := api.Owner
ownerUserFind := api.UserFind{
Role: &ownerUserType,
// Don't allow to signup by this api if site host existed.
hostUserType := api.Host
hostUserFind := api.UserFind{
Role: &hostUserType,
}
ownerUser, err := s.Store.FindUser(&ownerUserFind)
hostUser, err := s.Store.FindUser(&hostUserFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find owner user").SetInternal(err)
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find host user").SetInternal(err)
}
if ownerUser != nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Site Owner existed, please contact the site owner to signin account firstly.").SetInternal(err)
if hostUser != nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Site Host existed, please contact the site host to signin account firstly.").SetInternal(err)
}
signup := &api.Signup{}
@@ -111,7 +113,6 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(user)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode created user response").SetInternal(err)
}
return nil
})
}
+30 -6
View File
@@ -2,11 +2,12 @@ package server
import (
"fmt"
"memos/api"
"memos/common"
"net/http"
"strconv"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
"github.com/gorilla/sessions"
"github.com/labstack/echo-contrib/session"
"github.com/labstack/echo/v4"
@@ -32,7 +33,6 @@ func setUserSession(c echo.Context, user *api.User) error {
if err != nil {
return fmt.Errorf("failed to set session, err: %w", err)
}
return nil
}
@@ -48,18 +48,40 @@ func removeUserSession(c echo.Context) error {
if err != nil {
return fmt.Errorf("failed to set session, err: %w", err)
}
return nil
}
// Use session to store user.id.
func BasicAuthMiddleware(s *Server, next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// Skips auth
if common.HasPrefixes(c.Path(), "/api/auth", "/api/ping", "/api/status") {
// Skip auth for some paths.
if common.HasPrefixes(c.Path(), "/api/auth", "/api/ping", "/api/status", "/api/user/:userId") {
return next(c)
}
// If there is openId in query string and related user is found, then skip auth.
openID := c.QueryParam("openId")
if openID != "" {
userFind := &api.UserFind{
OpenID: &openID,
}
user, err := s.Store.FindUser(userFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user by open_id").SetInternal(err)
}
if user != nil {
// Stores userID into context.
c.Set(getUserIDContextKey(), user.ID)
return next(c)
}
}
if common.HasPrefixes(c.Path(), "/api/memo", "/api/tag", "/api/shortcut") && c.Request().Method == http.MethodGet {
if _, err := strconv.Atoi(c.QueryParam("creatorId")); err == nil {
return next(c)
}
}
sess, err := session.Get("session", c)
if err != nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing session").SetInternal(err)
@@ -85,6 +107,8 @@ func BasicAuthMiddleware(s *Server, next echo.HandlerFunc) echo.HandlerFunc {
}
if user == nil {
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("Not found user ID: %d", userID))
} else if user.RowStatus == api.Archived {
return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("User has been archived with email %s", user.Email))
}
// Stores userID into context.
+13
View File
@@ -0,0 +1,13 @@
<!-- THIS FILE IS A PLACEHOLDER AND SHOULD NOT BE CHANGED -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Memos</title>
</head>
<body>
<p>No frontend embeded.</p>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
package server
import (
"embed"
"io/fs"
"net/http"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
//go:embed dist
var embeddedFiles embed.FS
func getFileSystem() http.FileSystem {
fs, err := fs.Sub(embeddedFiles, "dist")
if err != nil {
panic(err)
}
return http.FS(fs)
}
func embedFrontend(e *echo.Echo) {
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
HTML5: true,
Filesystem: getFileSystem(),
}))
}
+100 -10
View File
@@ -3,11 +3,12 @@ package server
import (
"encoding/json"
"fmt"
"memos/api"
"memos/common"
"net/http"
"strconv"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
"github.com/labstack/echo/v4"
)
@@ -21,6 +22,11 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo request").SetInternal(err)
}
if memoCreate.Visibility == nil || *memoCreate.Visibility == "" {
private := api.Privite
memoCreate.Visibility = &private
}
memo, err := s.Store.CreateMemo(memoCreate)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create memo").SetInternal(err)
@@ -30,7 +36,6 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(memo)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode memo response").SetInternal(err)
}
return nil
})
@@ -56,19 +61,50 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(memo)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode memo response").SetInternal(err)
}
return nil
})
g.GET("/memo", func(c echo.Context) error {
userID := c.Get(getUserIDContextKey()).(int)
memoFind := &api.MemoFind{
CreatorID: &userID,
memoFind := &api.MemoFind{}
if userID, err := strconv.Atoi(c.QueryParam("creatorId")); err == nil {
memoFind.CreatorID = &userID
} else {
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find memo")
}
memoFind.CreatorID = &userID
}
rowStatus := c.QueryParam("rowStatus")
// Only can get PUBLIC memos in visitor mode
_, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
publicVisibility := api.Public
memoFind.Visibility = &publicVisibility
}
rowStatus := api.RowStatus(c.QueryParam("rowStatus"))
if rowStatus != "" {
memoFind.RowStatus = &rowStatus
}
pinnedStr := c.QueryParam("pinned")
if pinnedStr != "" {
pinned := pinnedStr == "true"
memoFind.Pinned = &pinned
}
tag := c.QueryParam("tag")
if tag != "" {
contentSearch := "#" + tag + " "
memoFind.ContentSearch = &contentSearch
}
if limit, err := strconv.Atoi(c.QueryParam("limit")); err == nil {
memoFind.Limit = limit
}
if offset, err := strconv.Atoi(c.QueryParam("offset")); err == nil {
memoFind.Offset = offset
}
list, err := s.Store.FindMemoList(memoFind)
if err != nil {
@@ -79,7 +115,44 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(list)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode memo list response").SetInternal(err)
}
return nil
})
g.POST("/memo/:memoId/organizer", func(c echo.Context) error {
memoID, err := strconv.Atoi(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
}
userID := c.Get(getUserIDContextKey()).(int)
memoOrganizerUpsert := &api.MemoOrganizerUpsert{
MemoID: memoID,
UserID: userID,
}
if err := json.NewDecoder(c.Request().Body).Decode(memoOrganizerUpsert); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo organizer request").SetInternal(err)
}
err = s.Store.UpsertMemoOrganizer(memoOrganizerUpsert)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo organizer").SetInternal(err)
}
memo, err := s.Store.FindMemo(&api.MemoFind{
ID: &memoID,
})
if err != nil {
if common.ErrorCode(err) == common.NotFound {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo ID not found: %d", memoID)).SetInternal(err)
}
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find memo by ID: %v", memoID)).SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(memo)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode memo response").SetInternal(err)
}
return nil
})
@@ -105,7 +178,6 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(memo)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode memo response").SetInternal(err)
}
return nil
})
@@ -124,8 +196,26 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to delete memo ID: %v", memoID)).SetInternal(err)
}
c.JSON(http.StatusOK, true)
return c.JSON(http.StatusOK, true)
})
g.GET("/memo/amount", func(c echo.Context) error {
userID := c.Get(getUserIDContextKey()).(int)
normalRowStatus := api.Normal
memoFind := &api.MemoFind{
CreatorID: &userID,
RowStatus: &normalRowStatus,
}
memoList, err := s.Store.FindMemoList(memoFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo list").SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(len(memoList))); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode memo amount").SetInternal(err)
}
return nil
})
}
+24 -24
View File
@@ -1,18 +1,23 @@
package common
package profile
import (
"flag"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/usememos/memos/common"
)
// Profile is the configuration to start main server.
type Profile struct {
// Mode can be "prod" or "dev"
Mode string `json:"mode"`
// Port is the binding port for server
Port int `json:"port"`
// Data is the data directory
Data string `json:"data"`
// DSN points to where Memos stores its own data
DSN string `json:"dsn"`
// Version is the current version of server
@@ -33,42 +38,37 @@ func checkDSN(dataDir string) (string, error) {
dataDir = strings.TrimRight(dataDir, "/")
if _, err := os.Stat(dataDir); err != nil {
error := fmt.Errorf("unable to access -data %s, err %w", dataDir, err)
return "", error
return "", fmt.Errorf("unable to access data folder %s, err %w", dataDir, err)
}
return dataDir, nil
}
// GetDevProfile will return a profile for dev.
// GetDevProfile will return a profile for dev or prod.
func GetProfile() *Profile {
mode := os.Getenv("mode")
if mode != "dev" && mode != "prod" {
mode = "dev"
profile := Profile{}
flag.StringVar(&profile.Mode, "mode", "dev", "mode of server")
flag.IntVar(&profile.Port, "port", 8080, "port of server")
flag.StringVar(&profile.Data, "data", "", "data directory")
flag.Parse()
if profile.Mode != "dev" && profile.Mode != "prod" {
profile.Mode = "dev"
}
port, err := strconv.Atoi(os.Getenv("port"))
if err != nil {
port = 8080
if profile.Mode == "prod" && profile.Data == "" {
profile.Data = "/var/opt/memos"
}
data := ""
if mode == "prod" {
data = "/var/opt/memos"
}
dataDir, err := checkDSN(data)
dataDir, err := checkDSN(profile.Data)
if err != nil {
fmt.Printf("Failed to check dsn: %s, err: %+v\n", dataDir, err)
os.Exit(1)
}
dsn := fmt.Sprintf("%s/memos_%s.db", dataDir, mode)
profile.Data = dataDir
profile.DSN = fmt.Sprintf("%s/memos_%s.db", dataDir, profile.Mode)
profile.Version = common.GetCurrentVersion(profile.Mode)
return &Profile{
Mode: mode,
Port: port,
DSN: dsn,
Version: Version,
}
return &profile
}
+50 -5
View File
@@ -4,10 +4,11 @@ import (
"encoding/json"
"fmt"
"io/ioutil"
"memos/api"
"net/http"
"strconv"
"github.com/usememos/memos/api"
"github.com/labstack/echo/v4"
)
@@ -56,7 +57,6 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(resource)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode resource response").SetInternal(err)
}
return nil
})
@@ -74,6 +74,53 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(list)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode resource list response").SetInternal(err)
}
return nil
})
g.GET("/resource/:resourceId", func(c echo.Context) error {
resourceID, err := strconv.Atoi(c.Param("resourceId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
}
userID := c.Get(getUserIDContextKey()).(int)
resourceFind := &api.ResourceFind{
ID: &resourceID,
CreatorID: &userID,
}
resource, err := s.Store.FindResource(resourceFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource").SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(resource)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode resource response").SetInternal(err)
}
return nil
})
g.GET("/resource/:resourceId/blob", func(c echo.Context) error {
resourceID, err := strconv.Atoi(c.Param("resourceId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
}
userID := c.Get(getUserIDContextKey()).(int)
resourceFind := &api.ResourceFind{
ID: &resourceID,
CreatorID: &userID,
}
resource, err := s.Store.FindResource(resourceFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource").SetInternal(err)
}
c.Response().Writer.WriteHeader(http.StatusOK)
c.Response().Writer.Header().Set("Content-Type", resource.Type)
if _, err := c.Response().Writer.Write(resource.Blob); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to write resource blob").SetInternal(err)
}
return nil
})
@@ -91,8 +138,6 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete resource").SetInternal(err)
}
c.JSON(http.StatusOK, true)
return nil
return c.JSON(http.StatusOK, true)
})
}
+10 -13
View File
@@ -2,10 +2,11 @@ package server
import (
"fmt"
"memos/common"
"memos/store"
"time"
"github.com/usememos/memos/server/profile"
"github.com/usememos/memos/store"
"github.com/gorilla/securecookie"
"github.com/gorilla/sessions"
"github.com/labstack/echo-contrib/session"
@@ -16,16 +17,16 @@ import (
type Server struct {
e *echo.Echo
Profile *common.Profile
Profile *profile.Profile
Store *store.Store
}
func NewServer(profile *common.Profile) *Server {
func NewServer(profile *profile.Profile) *Server {
e := echo.New()
e.Debug = true
e.HideBanner = true
e.HidePort = false
e.HidePort = true
e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
Format: "${method} ${uri} ${status}\n",
@@ -37,15 +38,10 @@ func NewServer(profile *common.Profile) *Server {
Timeout: 30 * time.Second,
}))
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
Skipper: middleware.DefaultSkipper,
Root: "web/dist",
Browse: false,
HTML5: true,
}))
embedFrontend(e)
// In dev mode, set the const secret key to make login session persistence.
secret := []byte("justmemos")
// In dev mode, set the const secret key to make signin session persistence.
secret := []byte("usememos")
if profile.Mode == "prod" {
secret = securecookie.GenerateRandomKey(16)
}
@@ -70,6 +66,7 @@ func NewServer(profile *common.Profile) *Server {
s.registerMemoRoutes(apiGroup)
s.registerShortcutRoutes(apiGroup)
s.registerResourceRoutes(apiGroup)
s.registerTagRoutes(apiGroup)
return s
}
+15 -11
View File
@@ -3,10 +3,11 @@ package server
import (
"encoding/json"
"fmt"
"memos/api"
"net/http"
"strconv"
"github.com/usememos/memos/api"
"github.com/labstack/echo/v4"
)
@@ -29,7 +30,6 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(shortcut)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode shortcut response").SetInternal(err)
}
return nil
})
@@ -55,15 +55,23 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(shortcut)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode shortcut response").SetInternal(err)
}
return nil
})
g.GET("/shortcut", func(c echo.Context) error {
userID := c.Get(getUserIDContextKey()).(int)
shortcutFind := &api.ShortcutFind{
CreatorID: &userID,
shortcutFind := &api.ShortcutFind{}
if userID, err := strconv.Atoi(c.QueryParam("creatorId")); err == nil {
shortcutFind.CreatorID = &userID
} else {
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find shortcut")
}
shortcutFind.CreatorID = &userID
}
list, err := s.Store.FindShortcutList(shortcutFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch shortcut list").SetInternal(err)
@@ -73,7 +81,6 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(list)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode shortcut list response").SetInternal(err)
}
return nil
})
@@ -95,7 +102,6 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(shortcut)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode shortcut response").SetInternal(err)
}
return nil
})
@@ -112,8 +118,6 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete shortcut").SetInternal(err)
}
c.JSON(http.StatusOK, true)
return nil
return c.JSON(http.StatusOK, true)
})
}
+16 -10
View File
@@ -2,34 +2,41 @@ package server
import (
"encoding/json"
"memos/api"
"net/http"
"github.com/usememos/memos/api"
"github.com/labstack/echo/v4"
)
func (s *Server) registerSystemRoutes(g *echo.Group) {
g.GET("/ping", func(c echo.Context) error {
data := s.Profile
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(s.Profile)); err != nil {
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(data)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose system profile").SetInternal(err)
}
return nil
})
g.GET("/status", func(c echo.Context) error {
ownerUserType := api.Owner
ownerUserFind := api.UserFind{
Role: &ownerUserType,
hostUserType := api.Host
hostUserFind := api.UserFind{
Role: &hostUserType,
}
ownerUser, err := s.Store.FindUser(&ownerUserFind)
hostUser, err := s.Store.FindUser(&hostUserFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find owner user").SetInternal(err)
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find host user").SetInternal(err)
}
if hostUser != nil {
// data desensitize
hostUser.OpenID = ""
}
systemStatus := api.SystemStatus{
Owner: ownerUser,
Host: hostUser,
Profile: s.Profile,
}
@@ -37,7 +44,6 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(systemStatus)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode system status response").SetInternal(err)
}
return nil
})
}
+73
View File
@@ -0,0 +1,73 @@
package server
import (
"encoding/json"
"net/http"
"regexp"
"sort"
"strconv"
"github.com/usememos/memos/api"
"github.com/labstack/echo/v4"
)
func (s *Server) registerTagRoutes(g *echo.Group) {
g.GET("/tag", func(c echo.Context) error {
contentSearch := "#"
normalRowStatus := api.Normal
memoFind := api.MemoFind{
ContentSearch: &contentSearch,
RowStatus: &normalRowStatus,
}
if userID, err := strconv.Atoi(c.QueryParam("creatorId")); err == nil {
memoFind.CreatorID = &userID
} else {
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find tag")
}
memoFind.CreatorID = &userID
}
// Only can get PUBLIC memos in visitor mode
_, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
publicVisibility := api.Public
memoFind.Visibility = &publicVisibility
}
memoList, err := s.Store.FindMemoList(&memoFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo list").SetInternal(err)
}
tagMapSet := make(map[string]bool)
r, err := regexp.Compile("#(.+?) ")
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compile regexp").SetInternal(err)
}
for _, memo := range memoList {
for _, rawTag := range r.FindAllString(memo.Content, -1) {
tag := r.ReplaceAllString(rawTag, "$1")
tagMapSet[tag] = true
}
}
tagList := []string{}
for tag := range tagMapSet {
tagList = append(tagList, tag)
}
sort.Strings(tagList)
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(tagList)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode tags response").SetInternal(err)
}
return nil
})
}
+75 -17
View File
@@ -3,9 +3,11 @@ package server
import (
"encoding/json"
"fmt"
"memos/api"
"memos/common"
"net/http"
"strconv"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
"github.com/labstack/echo/v4"
"golang.org/x/crypto/bcrypt"
@@ -33,7 +35,6 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(user)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode user response").SetInternal(err)
}
return nil
})
@@ -47,7 +48,31 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(userList)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode user list response").SetInternal(err)
}
return nil
})
g.GET("/user/:id", func(c echo.Context) error {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted user id").SetInternal(err)
}
user, err := s.Store.FindUser(&api.UserFind{
ID: &id,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch user").SetInternal(err)
}
if user != nil {
// data desensitize
user.OpenID = ""
}
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)
}
return nil
})
@@ -71,7 +96,6 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(user)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode user response").SetInternal(err)
}
return nil
})
@@ -84,19 +108,6 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch user request").SetInternal(err)
}
if userPatch.Email != nil {
userFind := api.UserFind{
Email: userPatch.Email,
}
user, err := s.Store.FindUser(&userFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user by email %s", *userPatch.Email)).SetInternal(err)
}
if user != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("User with email %s existed", *userPatch.Email)).SetInternal(err)
}
}
if userPatch.Password != nil && *userPatch.Password != "" {
passwordHash, err := bcrypt.GenerateFromPassword([]byte(*userPatch.Password), bcrypt.DefaultCost)
if err != nil {
@@ -121,7 +132,54 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(user)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode user response").SetInternal(err)
}
return nil
})
g.PATCH("/user/:userId", func(c echo.Context) error {
currentUserID := c.Get(getUserIDContextKey()).(int)
currentUser, err := s.Store.FindUser(&api.UserFind{
ID: &currentUserID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
}
if currentUser == nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Current session user not found with ID: %d", currentUserID)).SetInternal(err)
} else if currentUser.Role != api.Host {
return echo.NewHTTPError(http.StatusForbidden, "Access forbidden for current session user").SetInternal(err)
}
userID, err := strconv.Atoi(c.Param("userId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("userId"))).SetInternal(err)
}
userPatch := &api.UserPatch{
ID: userID,
}
if err := json.NewDecoder(c.Request().Body).Decode(userPatch); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch user request").SetInternal(err)
}
if userPatch.Password != nil && *userPatch.Password != "" {
passwordHash, err := bcrypt.GenerateFromPassword([]byte(*userPatch.Password), bcrypt.DefaultCost)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate password hash").SetInternal(err)
}
passwordHashStr := string(passwordHash)
userPatch.PasswordHash = &passwordHashStr
}
user, err := s.Store.PatchUser(userPatch)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch user").SetInternal(err)
}
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)
}
return nil
})
}
+6 -133
View File
@@ -1,13 +1,12 @@
package server
import (
"encoding/json"
"fmt"
"io/ioutil"
"memos/api"
"net/http"
"strconv"
"github.com/usememos/memos/api"
"github.com/labstack/echo/v4"
)
@@ -16,133 +15,6 @@ func (s *Server) registerWebhookRoutes(g *echo.Group) {
return c.HTML(http.StatusOK, "<strong>Hello, World!</strong>")
})
g.POST("/:openId/memo", func(c echo.Context) error {
openID := c.Param("openId")
userFind := &api.UserFind{
OpenID: &openID,
}
user, err := s.Store.FindUser(userFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user by open_id").SetInternal(err)
}
if user == nil {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("User openId not found: %s", openID))
}
memoCreate := &api.MemoCreate{
CreatorID: user.ID,
}
if err := json.NewDecoder(c.Request().Body).Decode(memoCreate); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo request by open api").SetInternal(err)
}
memo, err := s.Store.CreateMemo(memoCreate)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create memo").SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(memo)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode memo response").SetInternal(err)
}
return nil
})
g.GET("/:openId/memo", func(c echo.Context) error {
openID := c.Param("openId")
userFind := &api.UserFind{
OpenID: &openID,
}
user, err := s.Store.FindUser(userFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user by open_id").SetInternal(err)
}
if user == nil {
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("Unauthorized: %s", openID))
}
memoFind := &api.MemoFind{
CreatorID: &user.ID,
}
rowStatus := c.QueryParam("rowStatus")
if rowStatus != "" {
memoFind.RowStatus = &rowStatus
}
list, err := s.Store.FindMemoList(memoFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch memo list").SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(list)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode memo list response").SetInternal(err)
}
return nil
})
g.POST("/:openId/resource", func(c echo.Context) error {
openID := c.Param("openId")
userFind := &api.UserFind{
OpenID: &openID,
}
user, err := s.Store.FindUser(userFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user by open_id").SetInternal(err)
}
if user == nil {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("User openId not found: %s", openID))
}
if err := c.Request().ParseMultipartForm(64 << 20); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Upload file overload max size").SetInternal(err)
}
file, err := c.FormFile("file")
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Upload file not found").SetInternal(err)
}
filename := file.Filename
filetype := file.Header.Get("Content-Type")
size := file.Size
src, err := file.Open()
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to open file").SetInternal(err)
}
defer src.Close()
fileBytes, err := ioutil.ReadAll(src)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to read file").SetInternal(err)
}
resourceCreate := &api.ResourceCreate{
Filename: filename,
Type: filetype,
Size: size,
Blob: fileBytes,
CreatorID: user.ID,
}
resource, err := s.Store.CreateResource(resourceCreate)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create resource").SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(resource)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode resource response").SetInternal(err)
}
return nil
})
g.GET("/r/:resourceId/:filename", func(c echo.Context) error {
resourceID, err := strconv.Atoi(c.Param("resourceId"))
if err != nil {
@@ -150,12 +22,10 @@ func (s *Server) registerWebhookRoutes(g *echo.Group) {
}
filename := c.Param("filename")
resourceFind := &api.ResourceFind{
ID: &resourceID,
Filename: &filename,
}
resource, err := s.Store.FindResource(resourceFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to fetch resource ID: %v", resourceID)).SetInternal(err)
@@ -163,7 +33,10 @@ func (s *Server) registerWebhookRoutes(g *echo.Group) {
c.Response().Writer.WriteHeader(http.StatusOK)
c.Response().Writer.Header().Set("Content-Type", resource.Type)
c.Response().Writer.Write(resource.Blob)
if _, err := c.Response().Writer.Write(resource.Blob); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to write response").SetInternal(err)
}
return nil
})
}
+248
View File
@@ -0,0 +1,248 @@
package db
import (
"database/sql"
"embed"
"errors"
"fmt"
"io/fs"
"io/ioutil"
"os"
"regexp"
"sort"
"time"
"github.com/usememos/memos/common"
"github.com/usememos/memos/server/profile"
_ "github.com/mattn/go-sqlite3"
)
//go:embed migration
var migrationFS embed.FS
//go:embed seed
var seedFS embed.FS
type DB struct {
// sqlite db connection instance
Db *sql.DB
profile *profile.Profile
}
// NewDB returns a new instance of DB associated with the given datasource name.
func NewDB(profile *profile.Profile) *DB {
db := &DB{
profile: profile,
}
return db
}
func (db *DB) Open() (err error) {
// Ensure a DSN is set before attempting to open the database.
if db.profile.DSN == "" {
return fmt.Errorf("dsn required")
}
// Connect to the database.
sqlDB, err := sql.Open("sqlite3", db.profile.DSN)
if err != nil {
return fmt.Errorf("failed to open db with dsn: %s, err: %w", db.profile.DSN, err)
}
db.Db = sqlDB
// If mode is dev, we should migrate and seed the database.
if db.profile.Mode == "dev" {
if err := db.applyLatestSchema(); err != nil {
return fmt.Errorf("failed to apply latest schema: %w", err)
}
if err := db.seed(); err != nil {
return fmt.Errorf("failed to seed: %w", err)
}
} else {
// If db file not exists, we should migrate the database.
if _, err := os.Stat(db.profile.DSN); errors.Is(err, os.ErrNotExist) {
err := db.applyLatestSchema()
if err != nil {
return fmt.Errorf("failed to apply latest schema: %w", err)
}
} else {
err := db.createMigrationHistoryTable()
if err != nil {
return fmt.Errorf("failed to create migration_history table: %w", err)
}
currentVersion := common.GetCurrentVersion(db.profile.Mode)
migrationHistory, err := findMigrationHistory(db.Db, &MigrationHistoryFind{})
if err != nil {
return err
}
if migrationHistory == nil {
migrationHistory, err = upsertMigrationHistory(db.Db, &MigrationHistoryUpsert{
Version: currentVersion,
})
if err != nil {
return err
}
}
if common.IsVersionGreaterThan(currentVersion, migrationHistory.Version) {
minorVersionList := getMinorVersionList()
// backup the raw database file before migration
rawBytes, err := ioutil.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 := ioutil.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 common.IsVersionGreaterThan(normalizedVersion, migrationHistory.Version) && common.IsVersionGreaterOrEqualThan(currentVersion, normalizedVersion) {
println("applying migration for", normalizedVersion)
if err := db.applyMigrationForMinorVersion(minorVersion); err != nil {
return fmt.Errorf("failed to apply minor version migration: %w", err)
}
}
}
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))
}
}
}
}
return err
}
const (
latestSchemaFileName = "LATEST__SCHEMA.sql"
)
func (db *DB) applyLatestSchema() error {
latestSchemaPath := fmt.Sprintf("%s/%s", "migration", latestSchemaFileName)
buf, err := migrationFS.ReadFile(latestSchemaPath)
if err != nil {
return fmt.Errorf("failed to read latest schema %q, error %w", latestSchemaPath, err)
}
stmt := string(buf)
if err := db.execute(stmt); err != nil {
return fmt.Errorf("migrate error: statement:%s err=%w", stmt, err)
}
return nil
}
func (db *DB) applyMigrationForMinorVersion(minorVersion string) error {
filenames, err := fs.Glob(migrationFS, fmt.Sprintf("%s/%s/*.sql", "migration", minorVersion))
if err != nil {
return err
}
sort.Strings(filenames)
migrationStmt := ""
// Loop over all migration files and execute them in order.
for _, filename := range filenames {
buf, err := migrationFS.ReadFile(filename)
if err != nil {
return fmt.Errorf("failed to read minor version migration file, filename=%s err=%w", filename, err)
}
stmt := string(buf)
migrationStmt += stmt
if err := db.execute(stmt); err != nil {
return fmt.Errorf("migrate error: statement:%s err=%w", stmt, err)
}
}
// upsert the newest version to migration_history
if _, err = upsertMigrationHistory(db.Db, &MigrationHistoryUpsert{
Version: minorVersion + ".0",
}); err != nil {
return err
}
return nil
}
func (db *DB) seed() error {
filenames, err := fs.Glob(seedFS, fmt.Sprintf("%s/*.sql", "seed"))
if err != nil {
return err
}
sort.Strings(filenames)
// Loop over all seed files and execute them in order.
for _, filename := range filenames {
buf, err := seedFS.ReadFile(filename)
if err != nil {
return fmt.Errorf("failed to read seed file, filename=%s err=%w", filename, err)
}
stmt := string(buf)
if err := db.execute(stmt); err != nil {
return fmt.Errorf("seed error: statement:%s err=%w", stmt, err)
}
}
return nil
}
// excecute runs a single SQL statement within a transaction.
func (db *DB) execute(stmt string) error {
tx, err := db.Db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(stmt); err != nil {
return err
}
return tx.Commit()
}
// minorDirRegexp is a regular expression for minor version directory.
var minorDirRegexp = regexp.MustCompile(`^migration/[0-9]+\.[0-9]+$`)
func getMinorVersionList() []string {
minorVersionList := []string{}
if err := fs.WalkDir(migrationFS, "migration", func(path string, file fs.DirEntry, err error) error {
if err != nil {
return err
}
if file.IsDir() && minorDirRegexp.MatchString(path) {
minorVersionList = append(minorVersionList, file.Name())
}
return nil
}); err != nil {
panic(err)
}
sort.Strings(minorVersionList)
return minorVersionList
}
// createMigrationHistoryTable creates the migration_history table if it doesn't exist.
func (db *DB) createMigrationHistoryTable() error {
if err := createTable(db.Db, `
CREATE TABLE IF NOT EXISTS migration_history (
version TEXT NOT NULL PRIMARY KEY,
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now'))
);
`); err != nil {
return err
}
return nil
}
+54
View File
@@ -0,0 +1,54 @@
-- change user role field from "OWNER"/"USER" to "HOST"/"USER".
PRAGMA foreign_keys = off;
DROP TABLE IF EXISTS _user_old;
ALTER TABLE
user RENAME TO _user_old;
CREATE TABLE user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
row_status TEXT NOT NULL CHECK (row_status IN ('NORMAL', 'ARCHIVED')) DEFAULT 'NORMAL',
email TEXT NOT NULL UNIQUE,
role TEXT NOT NULL CHECK (role IN ('HOST', 'USER')) DEFAULT 'USER',
name TEXT NOT NULL,
password_hash TEXT NOT NULL,
open_id TEXT NOT NULL UNIQUE
);
INSERT INTO user (
id, created_ts, updated_ts, row_status,
email, name, password_hash, open_id
)
SELECT
id,
created_ts,
updated_ts,
row_status,
email,
name,
password_hash,
open_id
FROM
_user_old;
UPDATE
user
SET
role = 'HOST'
WHERE
id IN (
SELECT
id
FROM
_user_old
WHERE
role = 'OWNER'
);
DROP TABLE IF EXISTS _user_old;
PRAGMA foreign_keys = on;
@@ -0,0 +1 @@
ALTER TABLE memo ADD visibility TEXT NOT NULL CHECK (visibility IN ('PUBLIC', 'PRIVATE')) DEFAULT 'PRIVATE';
@@ -1,10 +1,19 @@
-- drop all tables
DROP TABLE IF EXISTS `memo_organizer`;
DROP TABLE IF EXISTS `memo`;
DROP TABLE IF EXISTS `shortcut`;
DROP TABLE IF EXISTS `resource`;
DROP TABLE IF EXISTS `user`;
-- user
CREATE TABLE user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
-- allowed row status are 'NORMAL', 'ARCHIVED'.
row_status TEXT NOT NULL CHECK (row_status IN ('NORMAL', 'ARCHIVED')) DEFAULT 'NORMAL',
email TEXT NOT NULL UNIQUE,
role TEXT NOT NULL CHECK (role IN ('OWNER', 'USER')) DEFAULT 'USER',
role TEXT NOT NULL CHECK (role IN ('HOST', 'USER')) DEFAULT 'USER',
name TEXT NOT NULL,
password_hash TEXT NOT NULL,
open_id TEXT NOT NULL UNIQUE
@@ -33,16 +42,16 @@ CREATE TABLE memo (
creator_id INTEGER NOT NULL,
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
-- allowed row status are 'NORMAL', 'ARCHIVED', 'HIDDEN'.
row_status TEXT NOT NULL DEFAULT 'NORMAL',
row_status TEXT NOT NULL CHECK (row_status IN ('NORMAL', 'ARCHIVED')) DEFAULT 'NORMAL',
content TEXT NOT NULL DEFAULT '',
FOREIGN KEY(creator_id) REFERENCES users(id)
visibility TEXT NOT NULL CHECK (visibility IN ('PUBLIC', 'PRIVATE')) DEFAULT 'PRIVATE',
FOREIGN KEY(creator_id) REFERENCES user(id) ON DELETE CASCADE
);
INSERT INTO
sqlite_sequence (name, seq)
VALUES
('memo', 100);
('memo', 1000);
CREATE TRIGGER IF NOT EXISTS `trigger_update_memo_modification_time`
AFTER
@@ -56,23 +65,38 @@ WHERE
rowid = old.rowid;
END;
-- memo_organizer
CREATE TABLE memo_organizer (
id INTEGER PRIMARY KEY AUTOINCREMENT,
memo_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
pinned INTEGER NOT NULL CHECK (pinned IN (0, 1)) DEFAULT 0,
FOREIGN KEY(memo_id) REFERENCES memo(id) ON DELETE CASCADE,
FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE,
UNIQUE(memo_id, user_id)
);
INSERT INTO
sqlite_sequence (name, seq)
VALUES
('memo_organizer', 1000);
-- shortcut
CREATE TABLE shortcut (
id INTEGER PRIMARY KEY AUTOINCREMENT,
creator_id INTEGER NOT NULL,
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
row_status TEXT NOT NULL CHECK (row_status IN ('NORMAL', 'ARCHIVED')) DEFAULT 'NORMAL',
title TEXT NOT NULL DEFAULT '',
payload TEXT NOT NULL DEFAULT '{}',
-- allowed row status are 'NORMAL', 'ARCHIVED'.
row_status TEXT NOT NULL DEFAULT 'NORMAL',
FOREIGN KEY(creator_id) REFERENCES users(id)
FOREIGN KEY(creator_id) REFERENCES user(id) ON DELETE CASCADE
);
INSERT INTO
sqlite_sequence (name, seq)
VALUES
('shortcut', 100);
('shortcut', 10000);
CREATE TRIGGER IF NOT EXISTS `trigger_update_shortcut_modification_time`
AFTER
@@ -96,13 +120,13 @@ CREATE TABLE resource (
blob BLOB NOT NULL,
type TEXT NOT NULL DEFAULT '',
size INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(creator_id) REFERENCES users(id)
FOREIGN KEY(creator_id) REFERENCES user(id) ON DELETE CASCADE
);
INSERT INTO
sqlite_sequence (name, seq)
VALUES
('resource', 100);
('resource', 10000);
CREATE TRIGGER IF NOT EXISTS `trigger_update_resource_modification_time`
AFTER
+100
View File
@@ -0,0 +1,100 @@
package db
import (
"database/sql"
"strings"
)
type MigrationHistory struct {
Version string
CreatedTs int64
}
type MigrationHistoryUpsert struct {
Version string
}
type MigrationHistoryFind struct {
Version *string
}
func findMigrationHistoryList(db *sql.DB, find *MigrationHistoryFind) ([]*MigrationHistory, error) {
where, args := []string{"1 = 1"}, []interface{}{}
if v := find.Version; v != nil {
where, args = append(where, "version = ?"), append(args, *v)
}
rows, err := db.Query(`
SELECT
version,
created_ts
FROM
migration_history
WHERE `+strings.Join(where, " AND ")+`
ORDER BY created_ts DESC`,
args...,
)
if err != nil {
return nil, err
}
defer rows.Close()
migrationHistoryList := make([]*MigrationHistory, 0)
for rows.Next() {
var migrationHistory MigrationHistory
if err := rows.Scan(
&migrationHistory.Version,
&migrationHistory.CreatedTs,
); err != nil {
return nil, err
}
migrationHistoryList = append(migrationHistoryList, &migrationHistory)
}
return migrationHistoryList, nil
}
func findMigrationHistory(db *sql.DB, find *MigrationHistoryFind) (*MigrationHistory, error) {
list, err := findMigrationHistoryList(db, find)
if err != nil {
return nil, err
}
if len(list) == 0 {
return nil, nil
} else {
return list[0], nil
}
}
func upsertMigrationHistory(db *sql.DB, upsert *MigrationHistoryUpsert) (*MigrationHistory, error) {
row, err := db.Query(`
INSERT INTO migration_history (
version
)
VALUES (?)
ON CONFLICT(version) DO UPDATE
SET
version=EXCLUDED.version
RETURNING version, created_ts
`,
upsert.Version,
)
if err != nil {
return nil, err
}
defer row.Close()
row.Next()
var migrationHistory MigrationHistory
if err := row.Scan(
&migrationHistory.Version,
&migrationHistory.CreatedTs,
); err != nil {
return nil, err
}
return &migrationHistory, nil
}
@@ -1,3 +1,4 @@
DELETE FROM memo_organizer;
DELETE FROM resource;
DELETE FROM shortcut;
DELETE FROM memo;
+39
View File
@@ -0,0 +1,39 @@
INSERT INTO
user (
`id`,
`email`,
`role`,
`name`,
`open_id`,
`password_hash`
)
VALUES
(
101,
'demo@usememos.com',
'HOST',
'Demo Host',
'demo_open_id',
-- raw password: secret
'$2a$14$ajq8Q7fbtFRQvXpdCq7Jcuy.Rx1h/L4J60Otx.gyNLbAYctGMJ9tK'
);
INSERT INTO
user (
`id`,
`email`,
`role`,
`name`,
`open_id`,
`password_hash`
)
VALUES
(
102,
'jack@usememos.com',
'USER',
'Jack',
'jack_open_id',
-- raw password: secret
'$2a$14$ajq8Q7fbtFRQvXpdCq7Jcuy.Rx1h/L4J60Otx.gyNLbAYctGMJ9tK'
);
+78
View File
@@ -0,0 +1,78 @@
INSERT INTO
memo (
`id`,
`content`,
`creator_id`
)
VALUES
(
1001,
"#Hello 👋 Welcome to memos.
And here is old Jack's Page: [/u/102](/u/102)",
101
);
INSERT INTO
memo (
`id`,
`content`,
`creator_id`
)
VALUES
(
1002,
'#TODO
- [ ] Take more photos about **🌄 sunset**;
- [x] Clean the room;
- [x] Read *📖 The Little Prince*;
(👆 click to toggle status)',
101
);
INSERT INTO
memo (
`id`,
`content`,
`creator_id`,
`visibility`
)
VALUES
(
1003,
'好好学习,天天向上。🤜🤛',
101,
'PUBLIC'
);
INSERT INTO
memo (
`id`,
`content`,
`creator_id`
)
VALUES
(
1004,
'#TODO
- [x] Take more photos about **🌄 sunset**;
- [ ] Clean the classroom;
- [ ] Watch *👦 The Boys*;
(👆 click to toggle status)
',
102
);
INSERT INTO
memo (
`id`,
`content`,
`creator_id`,
`visibility`
)
VALUES
(
1005,
'三人行,必有我师焉!👨‍🏫',
102,
'PUBLIC'
);
+12
View File
@@ -0,0 +1,12 @@
INSERT INTO
memo_organizer (
`memo_id`,
`user_id`,
`pinned`
)
VALUES
(
1001,
101,
1
);
+66
View File
@@ -0,0 +1,66 @@
package db
import (
"database/sql"
"strings"
)
type Table struct {
Name string
SQL string
}
//lint:ignore U1000 Ignore unused function temporarily for debugging
func findTable(db *sql.DB, tableName string) (*Table, error) {
where, args := []string{"1 = 1"}, []interface{}{}
where, args = append(where, "type = ?"), append(args, "table")
where, args = append(where, "name = ?"), append(args, tableName)
rows, err := db.Query(`
SELECT
tbl_name,
sql
FROM sqlite_schema
WHERE `+strings.Join(where, " AND "),
args...,
)
if err != nil {
return nil, err
}
defer rows.Close()
tableList := make([]*Table, 0)
for rows.Next() {
var table Table
if err := rows.Scan(
&table.Name,
&table.SQL,
); err != nil {
return nil, err
}
tableList = append(tableList, &table)
}
if err := rows.Err(); err != nil {
return nil, err
}
if len(tableList) == 0 {
return nil, nil
} else {
return tableList[0], nil
}
}
func createTable(db *sql.DB, sql string) error {
result, err := db.Exec(sql)
if err != nil {
return err
}
_, err = result.RowsAffected()
return err
}
+19
View File
@@ -0,0 +1,19 @@
package store
import (
"database/sql"
"errors"
)
func FormatError(err error) error {
if err == nil {
return nil
}
switch err {
case sql.ErrNoRows:
return errors.New("data not found")
default:
return err
}
}
+157 -56
View File
@@ -1,14 +1,55 @@
package store
import (
"database/sql"
"fmt"
"memos/api"
"memos/common"
"strings"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
)
// memoRaw is the store model for an Memo.
// Fields have exactly the same meanings as Memo.
type memoRaw struct {
ID int
// Standard fields
RowStatus api.RowStatus
CreatorID int
CreatedTs int64
UpdatedTs int64
// Domain specific fields
Content string
Visibility api.Visibility
}
// toMemo creates an instance of Memo based on the memoRaw.
// This is intended to be called when we need to compose an Memo relationship.
func (raw *memoRaw) toMemo() *api.Memo {
return &api.Memo{
ID: raw.ID,
// Standard fields
RowStatus: raw.RowStatus,
CreatorID: raw.CreatorID,
CreatedTs: raw.CreatedTs,
UpdatedTs: raw.UpdatedTs,
// Domain specific fields
Content: raw.Content,
Visibility: raw.Visibility,
}
}
func (s *Store) CreateMemo(create *api.MemoCreate) (*api.Memo, error) {
memo, err := createMemo(s.db, create)
memoRaw, err := createMemoRaw(s.db, create)
if err != nil {
return nil, err
}
memo, err := s.composeMemo(memoRaw)
if err != nil {
return nil, err
}
@@ -17,7 +58,12 @@ func (s *Store) CreateMemo(create *api.MemoCreate) (*api.Memo, error) {
}
func (s *Store) PatchMemo(patch *api.MemoPatch) (*api.Memo, error) {
memo, err := patchMemo(s.db, patch)
memoRaw, err := patchMemoRaw(s.db, patch)
if err != nil {
return nil, err
}
memo, err := s.composeMemo(memoRaw)
if err != nil {
return nil, err
}
@@ -26,16 +72,26 @@ func (s *Store) PatchMemo(patch *api.MemoPatch) (*api.Memo, error) {
}
func (s *Store) FindMemoList(find *api.MemoFind) ([]*api.Memo, error) {
list, err := findMemoList(s.db, find)
memoRawList, err := findMemoRawList(s.db, find)
if err != nil {
return nil, err
}
list := []*api.Memo{}
for _, raw := range memoRawList {
memo, err := s.composeMemo(raw)
if err != nil {
return nil, err
}
list = append(list, memo)
}
return list, nil
}
func (s *Store) FindMemo(find *api.MemoFind) (*api.Memo, error) {
list, err := findMemoList(s.db, find)
list, err := findMemoRawList(s.db, find)
if err != nil {
return nil, err
}
@@ -44,7 +100,12 @@ func (s *Store) FindMemo(find *api.MemoFind) (*api.Memo, error) {
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")}
}
return list[0], nil
memo, err := s.composeMemo(list[0])
if err != nil {
return nil, err
}
return memo, nil
}
func (s *Store) DeleteMemo(delete *api.MemoDelete) error {
@@ -56,22 +117,25 @@ func (s *Store) DeleteMemo(delete *api.MemoDelete) error {
return nil
}
func createMemo(db *DB, create *api.MemoCreate) (*api.Memo, error) {
func createMemoRaw(db *sql.DB, create *api.MemoCreate) (*memoRaw, error) {
set := []string{"creator_id", "content"}
placeholder := []string{"?", "?"}
args := []interface{}{create.CreatorID, create.Content}
if v := create.Visibility; v != nil {
set, placeholder, args = append(set, "visibility"), append(placeholder, "?"), append(args, *v)
}
if v := create.CreatedTs; v != nil {
set, placeholder, args = append(set, "created_ts"), append(placeholder, "?"), append(args, *v)
}
row, err := db.Db.Query(`
INSERT INTO memo (
`+strings.Join(set, ", ")+`
)
VALUES (`+strings.Join(placeholder, ",")+`)
RETURNING id, creator_id, created_ts, updated_ts, content, row_status
`,
query := `
INSERT INTO memo (
` + strings.Join(set, ", ") + `
)
VALUES (` + strings.Join(placeholder, ",") + `)
RETURNING id, creator_id, created_ts, updated_ts, row_status, content, visibility`
row, err := db.Query(query,
args...,
)
if err != nil {
@@ -79,26 +143,24 @@ func createMemo(db *DB, create *api.MemoCreate) (*api.Memo, error) {
}
defer row.Close()
if !row.Next() {
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")}
}
var memo api.Memo
row.Next()
var memoRaw memoRaw
if err := row.Scan(
&memo.ID,
&memo.CreatorID,
&memo.CreatedTs,
&memo.UpdatedTs,
&memo.Content,
&memo.RowStatus,
&memoRaw.ID,
&memoRaw.CreatorID,
&memoRaw.CreatedTs,
&memoRaw.UpdatedTs,
&memoRaw.RowStatus,
&memoRaw.Content,
&memoRaw.Visibility,
); err != nil {
return nil, FormatError(err)
}
return &memo, nil
return &memoRaw, nil
}
func patchMemo(db *DB, patch *api.MemoPatch) (*api.Memo, error) {
func patchMemoRaw(db *sql.DB, patch *api.MemoPatch) (*memoRaw, error) {
set, args := []string{}, []interface{}{}
if v := patch.Content; v != nil {
@@ -107,39 +169,42 @@ func patchMemo(db *DB, patch *api.MemoPatch) (*api.Memo, error) {
if v := patch.RowStatus; v != nil {
set, args = append(set, "row_status = ?"), append(args, *v)
}
if v := patch.Visibility; v != nil {
set, args = append(set, "visibility = ?"), append(args, *v)
}
args = append(args, patch.ID)
row, err := db.Db.Query(`
row, err := db.Query(`
UPDATE memo
SET `+strings.Join(set, ", ")+`
WHERE id = ?
RETURNING id, created_ts, updated_ts, content, row_status
RETURNING id, creator_id, created_ts, updated_ts, row_status, content, visibility
`, args...)
if err != nil {
return nil, FormatError(err)
}
defer row.Close()
if !row.Next() {
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")}
}
row.Next()
var memo api.Memo
var memoRaw memoRaw
if err := row.Scan(
&memo.ID,
&memo.CreatedTs,
&memo.UpdatedTs,
&memo.Content,
&memo.RowStatus,
&memoRaw.ID,
&memoRaw.CreatorID,
&memoRaw.CreatedTs,
&memoRaw.UpdatedTs,
&memoRaw.RowStatus,
&memoRaw.Content,
&memoRaw.Visibility,
); err != nil {
return nil, FormatError(err)
}
return &memo, nil
return &memoRaw, nil
}
func findMemoList(db *DB, find *api.MemoFind) ([]*api.Memo, error) {
func findMemoRawList(db *sql.DB, find *api.MemoFind) ([]*memoRaw, error) {
where, args := []string{"1 = 1"}, []interface{}{}
if v := find.ID; v != nil {
@@ -151,17 +216,36 @@ func findMemoList(db *DB, find *api.MemoFind) ([]*api.Memo, error) {
if v := find.RowStatus; v != nil {
where, args = append(where, "row_status = ?"), append(args, *v)
}
if v := find.Pinned; v != nil {
where = append(where, "id in (SELECT memo_id FROM memo_organizer WHERE pinned = 1 AND user_id = memo.creator_id )")
}
if v := find.ContentSearch; v != nil {
where, args = append(where, "content LIKE ?"), append(args, "%"+*v+"%")
}
if v := find.Visibility; v != nil {
where, args = append(where, "visibility = ?"), append(args, *v)
}
rows, err := db.Db.Query(`
pagination := ""
if find.Limit > 0 {
pagination = fmt.Sprintf("%s LIMIT %d", pagination, find.Limit)
if find.Offset > 0 {
pagination = fmt.Sprintf("%s OFFSET %d", pagination, find.Offset)
}
}
rows, err := db.Query(`
SELECT
id,
creator_id,
created_ts,
updated_ts,
row_status,
content,
row_status
visibility
FROM memo
WHERE `+strings.Join(where, " AND "),
WHERE `+strings.Join(where, " AND ")+`
ORDER BY created_ts DESC`+pagination,
args...,
)
if err != nil {
@@ -169,32 +253,33 @@ func findMemoList(db *DB, find *api.MemoFind) ([]*api.Memo, error) {
}
defer rows.Close()
list := make([]*api.Memo, 0)
memoRawList := make([]*memoRaw, 0)
for rows.Next() {
var memo api.Memo
var memoRaw memoRaw
if err := rows.Scan(
&memo.ID,
&memo.CreatorID,
&memo.CreatedTs,
&memo.UpdatedTs,
&memo.Content,
&memo.RowStatus,
&memoRaw.ID,
&memoRaw.CreatorID,
&memoRaw.CreatedTs,
&memoRaw.UpdatedTs,
&memoRaw.RowStatus,
&memoRaw.Content,
&memoRaw.Visibility,
); err != nil {
return nil, FormatError(err)
}
list = append(list, &memo)
memoRawList = append(memoRawList, &memoRaw)
}
if err := rows.Err(); err != nil {
return nil, FormatError(err)
}
return list, nil
return memoRawList, nil
}
func deleteMemo(db *DB, delete *api.MemoDelete) error {
result, err := db.Db.Exec(`DELETE FROM memo WHERE id = ?`, delete.ID)
func deleteMemo(db *sql.DB, delete *api.MemoDelete) error {
result, err := db.Exec(`DELETE FROM memo WHERE id = ?`, delete.ID)
if err != nil {
return FormatError(err)
}
@@ -206,3 +291,19 @@ func deleteMemo(db *DB, delete *api.MemoDelete) error {
return nil
}
func (s *Store) composeMemo(raw *memoRaw) (*api.Memo, error) {
memo := raw.toMemo()
memoOrganizer, err := s.FindMemoOrganizer(&api.MemoOrganizerFind{
MemoID: memo.ID,
UserID: memo.CreatorID,
})
if err != nil && common.ErrorCode(err) != common.NotFound {
return nil, err
} else if memoOrganizer != nil {
memo.Pinned = memoOrganizer.Pinned
}
return memo, nil
}
+119
View File
@@ -0,0 +1,119 @@
package store
import (
"database/sql"
"fmt"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
)
// memoOrganizerRaw is the store model for an MemoOrganizer.
// Fields have exactly the same meanings as MemoOrganizer.
type memoOrganizerRaw struct {
ID int
// Domain specific fields
MemoID int
UserID int
Pinned bool
}
func (raw *memoOrganizerRaw) toMemoOrganizer() *api.MemoOrganizer {
return &api.MemoOrganizer{
ID: raw.ID,
MemoID: raw.MemoID,
UserID: raw.UserID,
Pinned: raw.Pinned,
}
}
func (s *Store) FindMemoOrganizer(find *api.MemoOrganizerFind) (*api.MemoOrganizer, error) {
memoOrganizerRaw, err := findMemoOrganizer(s.db, find)
if err != nil {
return nil, err
}
memoOrganizer := memoOrganizerRaw.toMemoOrganizer()
return memoOrganizer, nil
}
func (s *Store) UpsertMemoOrganizer(upsert *api.MemoOrganizerUpsert) error {
err := upsertMemoOrganizer(s.db, upsert)
if err != nil {
return err
}
return nil
}
func findMemoOrganizer(db *sql.DB, find *api.MemoOrganizerFind) (*memoOrganizerRaw, error) {
row, err := db.Query(`
SELECT
id,
memo_id,
user_id,
pinned
FROM memo_organizer
WHERE memo_id = ? AND user_id = ?
`, find.MemoID, find.UserID)
if err != nil {
return nil, FormatError(err)
}
defer row.Close()
if !row.Next() {
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")}
}
var memoOrganizerRaw memoOrganizerRaw
if err := row.Scan(
&memoOrganizerRaw.ID,
&memoOrganizerRaw.MemoID,
&memoOrganizerRaw.UserID,
&memoOrganizerRaw.Pinned,
); err != nil {
return nil, FormatError(err)
}
return &memoOrganizerRaw, nil
}
func upsertMemoOrganizer(db *sql.DB, upsert *api.MemoOrganizerUpsert) error {
row, err := db.Query(`
INSERT INTO memo_organizer (
memo_id,
user_id,
pinned
)
VALUES (?, ?, ?)
ON CONFLICT(memo_id, user_id) DO UPDATE
SET
pinned = EXCLUDED.pinned
RETURNING id, memo_id, user_id, pinned
`,
upsert.MemoID,
upsert.UserID,
upsert.Pinned,
)
if err != nil {
return FormatError(err)
}
defer row.Close()
row.Next()
var memoOrganizer api.MemoOrganizer
if err := row.Scan(
&memoOrganizer.ID,
&memoOrganizer.MemoID,
&memoOrganizer.UserID,
&memoOrganizer.Pinned,
); err != nil {
return FormatError(err)
}
return nil
}
-4
View File
@@ -1,4 +0,0 @@
DROP TABLE IF EXISTS `memo`;
DROP TABLE IF EXISTS `shortcut`;
DROP TABLE IF EXISTS `resource`;
DROP TABLE IF EXISTS `user`;
+80 -37
View File
@@ -1,28 +1,71 @@
package store
import (
"database/sql"
"fmt"
"memos/api"
"memos/common"
"strings"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
)
// resourceRaw is the store model for an Resource.
// Fields have exactly the same meanings as Resource.
type resourceRaw struct {
ID int
// Standard fields
CreatorID int
CreatedTs int64
UpdatedTs int64
// Domain specific fields
Filename string
Blob []byte
Type string
Size int64
}
func (raw *resourceRaw) toResource() *api.Resource {
return &api.Resource{
ID: raw.ID,
// Standard fields
CreatorID: raw.CreatorID,
CreatedTs: raw.CreatedTs,
UpdatedTs: raw.UpdatedTs,
// Domain specific fields
Filename: raw.Filename,
Blob: raw.Blob,
Type: raw.Type,
Size: raw.Size,
}
}
func (s *Store) CreateResource(create *api.ResourceCreate) (*api.Resource, error) {
resource, err := createResource(s.db, create)
resourceRaw, err := createResource(s.db, create)
if err != nil {
return nil, err
}
resource := resourceRaw.toResource()
return resource, nil
}
func (s *Store) FindResourceList(find *api.ResourceFind) ([]*api.Resource, error) {
list, err := findResourceList(s.db, find)
resourceRawList, err := findResourceList(s.db, find)
if err != nil {
return nil, err
}
return list, nil
resourceList := []*api.Resource{}
for _, raw := range resourceRawList {
resourceList = append(resourceList, raw.toResource())
}
return resourceList, nil
}
func (s *Store) FindResource(find *api.ResourceFind) (*api.Resource, error) {
@@ -35,7 +78,9 @@ func (s *Store) FindResource(find *api.ResourceFind) (*api.Resource, error) {
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")}
}
return list[0], nil
resource := list[0].toResource()
return resource, nil
}
func (s *Store) DeleteResource(delete *api.ResourceDelete) error {
@@ -47,8 +92,8 @@ func (s *Store) DeleteResource(delete *api.ResourceDelete) error {
return nil
}
func createResource(db *DB, create *api.ResourceCreate) (*api.Resource, error) {
row, err := db.Db.Query(`
func createResource(db *sql.DB, create *api.ResourceCreate) (*resourceRaw, error) {
row, err := db.Query(`
INSERT INTO resource (
filename,
blob,
@@ -70,27 +115,24 @@ func createResource(db *DB, create *api.ResourceCreate) (*api.Resource, error) {
}
defer row.Close()
if !row.Next() {
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")}
}
var resource api.Resource
row.Next()
var resourceRaw resourceRaw
if err := row.Scan(
&resource.ID,
&resource.Filename,
&resource.Blob,
&resource.Type,
&resource.Size,
&resource.CreatedTs,
&resource.UpdatedTs,
&resourceRaw.ID,
&resourceRaw.Filename,
&resourceRaw.Blob,
&resourceRaw.Type,
&resourceRaw.Size,
&resourceRaw.CreatedTs,
&resourceRaw.UpdatedTs,
); err != nil {
return nil, FormatError(err)
}
return &resource, nil
return &resourceRaw, nil
}
func findResourceList(db *DB, find *api.ResourceFind) ([]*api.Resource, error) {
func findResourceList(db *sql.DB, find *api.ResourceFind) ([]*resourceRaw, error) {
where, args := []string{"1 = 1"}, []interface{}{}
if v := find.ID; v != nil {
@@ -103,7 +145,7 @@ func findResourceList(db *DB, find *api.ResourceFind) ([]*api.Resource, error) {
where, args = append(where, "filename = ?"), append(args, *v)
}
rows, err := db.Db.Query(`
rows, err := db.Query(`
SELECT
id,
filename,
@@ -113,7 +155,8 @@ func findResourceList(db *DB, find *api.ResourceFind) ([]*api.Resource, error) {
created_ts,
updated_ts
FROM resource
WHERE `+strings.Join(where, " AND "),
WHERE `+strings.Join(where, " AND ")+`
ORDER BY created_ts DESC`,
args...,
)
if err != nil {
@@ -121,33 +164,33 @@ func findResourceList(db *DB, find *api.ResourceFind) ([]*api.Resource, error) {
}
defer rows.Close()
list := make([]*api.Resource, 0)
resourceRawList := make([]*resourceRaw, 0)
for rows.Next() {
var resource api.Resource
var resourceRaw resourceRaw
if err := rows.Scan(
&resource.ID,
&resource.Filename,
&resource.Blob,
&resource.Type,
&resource.Size,
&resource.CreatedTs,
&resource.UpdatedTs,
&resourceRaw.ID,
&resourceRaw.Filename,
&resourceRaw.Blob,
&resourceRaw.Type,
&resourceRaw.Size,
&resourceRaw.CreatedTs,
&resourceRaw.UpdatedTs,
); err != nil {
return nil, FormatError(err)
}
list = append(list, &resource)
resourceRawList = append(resourceRawList, &resourceRaw)
}
if err := rows.Err(); err != nil {
return nil, FormatError(err)
}
return list, nil
return resourceRawList, nil
}
func deleteResource(db *DB, delete *api.ResourceDelete) error {
result, err := db.Db.Exec(`DELETE FROM resource WHERE id = ?`, delete.ID)
func deleteResource(db *sql.DB, delete *api.ResourceDelete) error {
result, err := db.Exec(`DELETE FROM resource WHERE id = ?`, delete.ID)
if err != nil {
return FormatError(err)
}
-17
View File
@@ -1,17 +0,0 @@
INSERT INTO
user (
`id`,
`name`,
`email`,
`open_id`,
`password_hash`
)
VALUES
(
101,
'guest',
'guest@example.com',
'guest_open_id',
-- "secret"
'$2a$14$ajq8Q7fbtFRQvXpdCq7Jcuy.Rx1h/L4J60Otx.gyNLbAYctGMJ9tK'
);
-23
View File
@@ -1,23 +0,0 @@
INSERT INTO
memo (
`content`,
`creator_id`
)
VALUES
(
'#memos 👋 Welcome to memos',
101
);
INSERT INTO
memo (
`content`,
`creator_id`,
`row_status`
)
VALUES
(
'好好学习,天天向上。',
101,
'ARCHIVED'
);
+87 -44
View File
@@ -1,36 +1,77 @@
package store
import (
"database/sql"
"fmt"
"memos/api"
"memos/common"
"strings"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
)
// shortcutRaw is the store model for an Shortcut.
// Fields have exactly the same meanings as Shortcut.
type shortcutRaw struct {
ID int
// Standard fields
RowStatus api.RowStatus
CreatorID int
CreatedTs int64
UpdatedTs int64
// Domain specific fields
Title string
Payload string
}
func (raw *shortcutRaw) toShortcut() *api.Shortcut {
return &api.Shortcut{
ID: raw.ID,
RowStatus: raw.RowStatus,
CreatorID: raw.CreatorID,
CreatedTs: raw.CreatedTs,
UpdatedTs: raw.UpdatedTs,
Title: raw.Title,
Payload: raw.Payload,
}
}
func (s *Store) CreateShortcut(create *api.ShortcutCreate) (*api.Shortcut, error) {
shortcut, err := createShortcut(s.db, create)
shortcutRaw, err := createShortcut(s.db, create)
if err != nil {
return nil, err
}
shortcut := shortcutRaw.toShortcut()
return shortcut, nil
}
func (s *Store) PatchShortcut(patch *api.ShortcutPatch) (*api.Shortcut, error) {
shortcut, err := patchShortcut(s.db, patch)
shortcutRaw, err := patchShortcut(s.db, patch)
if err != nil {
return nil, err
}
shortcut := shortcutRaw.toShortcut()
return shortcut, nil
}
func (s *Store) FindShortcutList(find *api.ShortcutFind) ([]*api.Shortcut, error) {
list, err := findShortcutList(s.db, find)
shortcutRawList, err := findShortcutList(s.db, find)
if err != nil {
return nil, err
}
list := []*api.Shortcut{}
for _, raw := range shortcutRawList {
list = append(list, raw.toShortcut())
}
return list, nil
}
@@ -44,7 +85,9 @@ func (s *Store) FindShortcut(find *api.ShortcutFind) (*api.Shortcut, error) {
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")}
}
return list[0], nil
shortcut := list[0].toShortcut()
return shortcut, nil
}
func (s *Store) DeleteShortcut(delete *api.ShortcutDelete) error {
@@ -56,8 +99,8 @@ func (s *Store) DeleteShortcut(delete *api.ShortcutDelete) error {
return nil
}
func createShortcut(db *DB, create *api.ShortcutCreate) (*api.Shortcut, error) {
row, err := db.Db.Query(`
func createShortcut(db *sql.DB, create *api.ShortcutCreate) (*shortcutRaw, error) {
row, err := db.Query(`
INSERT INTO shortcut (
title,
payload,
@@ -70,30 +113,29 @@ func createShortcut(db *DB, create *api.ShortcutCreate) (*api.Shortcut, error) {
create.Payload,
create.CreatorID,
)
if err != nil {
return nil, FormatError(err)
}
defer row.Close()
row.Next()
var shortcut api.Shortcut
var shortcutRaw shortcutRaw
if err := row.Scan(
&shortcut.ID,
&shortcut.Title,
&shortcut.Payload,
&shortcut.CreatorID,
&shortcut.CreatedTs,
&shortcut.UpdatedTs,
&shortcut.RowStatus,
&shortcutRaw.ID,
&shortcutRaw.Title,
&shortcutRaw.Payload,
&shortcutRaw.CreatorID,
&shortcutRaw.CreatedTs,
&shortcutRaw.UpdatedTs,
&shortcutRaw.RowStatus,
); err != nil {
return nil, FormatError(err)
}
return &shortcut, nil
return &shortcutRaw, nil
}
func patchShortcut(db *DB, patch *api.ShortcutPatch) (*api.Shortcut, error) {
func patchShortcut(db *sql.DB, patch *api.ShortcutPatch) (*shortcutRaw, error) {
set, args := []string{}, []interface{}{}
if v := patch.Title; v != nil {
@@ -108,7 +150,7 @@ func patchShortcut(db *DB, patch *api.ShortcutPatch) (*api.Shortcut, error) {
args = append(args, patch.ID)
row, err := db.Db.Query(`
row, err := db.Query(`
UPDATE shortcut
SET `+strings.Join(set, ", ")+`
WHERE id = ?
@@ -123,22 +165,22 @@ func patchShortcut(db *DB, patch *api.ShortcutPatch) (*api.Shortcut, error) {
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")}
}
var shortcut api.Shortcut
var shortcutRaw shortcutRaw
if err := row.Scan(
&shortcut.ID,
&shortcut.Title,
&shortcut.Payload,
&shortcut.CreatedTs,
&shortcut.UpdatedTs,
&shortcut.RowStatus,
&shortcutRaw.ID,
&shortcutRaw.Title,
&shortcutRaw.Payload,
&shortcutRaw.CreatedTs,
&shortcutRaw.UpdatedTs,
&shortcutRaw.RowStatus,
); err != nil {
return nil, FormatError(err)
}
return &shortcut, nil
return &shortcutRaw, nil
}
func findShortcutList(db *DB, find *api.ShortcutFind) ([]*api.Shortcut, error) {
func findShortcutList(db *sql.DB, find *api.ShortcutFind) ([]*shortcutRaw, error) {
where, args := []string{"1 = 1"}, []interface{}{}
if v := find.ID; v != nil {
@@ -151,7 +193,7 @@ func findShortcutList(db *DB, find *api.ShortcutFind) ([]*api.Shortcut, error) {
where, args = append(where, "title = ?"), append(args, *v)
}
rows, err := db.Db.Query(`
rows, err := db.Query(`
SELECT
id,
title,
@@ -161,7 +203,8 @@ func findShortcutList(db *DB, find *api.ShortcutFind) ([]*api.Shortcut, error) {
updated_ts,
row_status
FROM shortcut
WHERE `+strings.Join(where, " AND "),
WHERE `+strings.Join(where, " AND ")+`
ORDER BY created_ts DESC`,
args...,
)
if err != nil {
@@ -169,33 +212,33 @@ func findShortcutList(db *DB, find *api.ShortcutFind) ([]*api.Shortcut, error) {
}
defer rows.Close()
list := make([]*api.Shortcut, 0)
shortcutRawList := make([]*shortcutRaw, 0)
for rows.Next() {
var shortcut api.Shortcut
var shortcutRaw shortcutRaw
if err := rows.Scan(
&shortcut.ID,
&shortcut.Title,
&shortcut.Payload,
&shortcut.CreatorID,
&shortcut.CreatedTs,
&shortcut.UpdatedTs,
&shortcut.RowStatus,
&shortcutRaw.ID,
&shortcutRaw.Title,
&shortcutRaw.Payload,
&shortcutRaw.CreatorID,
&shortcutRaw.CreatedTs,
&shortcutRaw.UpdatedTs,
&shortcutRaw.RowStatus,
); err != nil {
return nil, FormatError(err)
}
list = append(list, &shortcut)
shortcutRawList = append(shortcutRawList, &shortcutRaw)
}
if err := rows.Err(); err != nil {
return nil, FormatError(err)
}
return list, nil
return shortcutRawList, nil
}
func deleteShortcut(db *DB, delete *api.ShortcutDelete) error {
result, err := db.Db.Exec(`DELETE FROM shortcut WHERE id = ?`, delete.ID)
func deleteShortcut(db *sql.DB, delete *api.ShortcutDelete) error {
result, err := db.Exec(`DELETE FROM shortcut WHERE id = ?`, delete.ID)
if err != nil {
return FormatError(err)
}
-138
View File
@@ -1,138 +0,0 @@
package store
import (
"database/sql"
"embed"
"errors"
"fmt"
"io/fs"
"memos/common"
"os"
"sort"
_ "github.com/mattn/go-sqlite3"
)
//go:embed migration
var migrationFS embed.FS
//go:embed seed
var seedFS embed.FS
type DB struct {
Db *sql.DB
// datasource name
DSN string
// mode should be prod or dev
mode string
}
// NewDB returns a new instance of DB associated with the given datasource name.
func NewDB(profile *common.Profile) *DB {
db := &DB{
DSN: profile.DSN,
mode: profile.Mode,
}
return db
}
func (db *DB) Open() (err error) {
// Ensure a DSN is set before attempting to open the database.
if db.DSN == "" {
return fmt.Errorf("dsn required")
}
// Connect to the database.
sqlDB, err := sql.Open("sqlite3", db.DSN)
if err != nil {
return fmt.Errorf("failed to open db with dsn: %s, err: %w", db.DSN, err)
}
db.Db = sqlDB
// If db file not exists, we should migrate and seed the database.
if _, err := os.Stat(db.DSN); errors.Is(err, os.ErrNotExist) {
if err := db.migrate(); err != nil {
return fmt.Errorf("failed to migrate: %w", err)
}
if err := db.seed(); err != nil {
return fmt.Errorf("failed to seed: %w", err)
}
} else {
// If db file exists and mode is dev, we should migrate and seed the database.
if db.mode == "dev" {
if err := db.migrate(); err != nil {
return fmt.Errorf("failed to migrate: %w", err)
}
if err := db.seed(); err != nil {
return fmt.Errorf("failed to seed: %w", err)
}
}
}
return err
}
func (db *DB) migrate() error {
filenames, err := fs.Glob(migrationFS, fmt.Sprintf("%s/*.sql", "migration"))
if err != nil {
return err
}
sort.Strings(filenames)
// Loop over all migration files and execute them in order.
for _, filename := range filenames {
if err := db.executeFile(migrationFS, filename); err != nil {
return fmt.Errorf("migrate error: name=%q err=%w", filename, err)
}
}
return nil
}
func (db *DB) seed() error {
filenames, err := fs.Glob(seedFS, fmt.Sprintf("%s/*.sql", "seed"))
if err != nil {
return err
}
sort.Strings(filenames)
// Loop over all seed files and execute them in order.
for _, filename := range filenames {
if err := db.executeFile(seedFS, filename); err != nil {
return fmt.Errorf("seed error: name=%q err=%w", filename, err)
}
}
return nil
}
// executeFile runs a single seed file within a transaction.
func (db *DB) executeFile(FS embed.FS, name string) error {
tx, err := db.Db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
// Read and execute SQL file.
if buf, err := fs.ReadFile(FS, name); err != nil {
return err
} else if _, err := tx.Exec(string(buf)); err != nil {
return err
}
return tx.Commit()
}
func FormatError(err error) error {
if err == nil {
return nil
}
switch err {
case sql.ErrNoRows:
return errors.New("data not found")
default:
return err
}
}
+11 -3
View File
@@ -1,13 +1,21 @@
package store
import (
"database/sql"
"github.com/usememos/memos/server/profile"
)
// Store provides database access to all raw objects
type Store struct {
db *DB
db *sql.DB
profile *profile.Profile
}
// New creates a new instance of Store
func New(db *DB) *Store {
func New(db *sql.DB, profile *profile.Profile) *Store {
return &Store{
db: db,
db: db,
profile: profile,
}
}
+107 -52
View File
@@ -1,36 +1,81 @@
package store
import (
"database/sql"
"fmt"
"memos/api"
"memos/common"
"strings"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
)
// userRaw is the store model for an User.
// Fields have exactly the same meanings as User.
type userRaw struct {
ID int
// Standard fields
RowStatus api.RowStatus
CreatedTs int64
UpdatedTs int64
// Domain specific fields
Email string
Role api.Role
Name string
PasswordHash string
OpenID string
}
func (raw *userRaw) toUser() *api.User {
return &api.User{
ID: raw.ID,
RowStatus: raw.RowStatus,
CreatedTs: raw.CreatedTs,
UpdatedTs: raw.UpdatedTs,
Email: raw.Email,
Role: raw.Role,
Name: raw.Name,
PasswordHash: raw.PasswordHash,
OpenID: raw.OpenID,
}
}
func (s *Store) CreateUser(create *api.UserCreate) (*api.User, error) {
user, err := createUser(s.db, create)
userRaw, err := createUser(s.db, create)
if err != nil {
return nil, err
}
user := userRaw.toUser()
return user, nil
}
func (s *Store) PatchUser(patch *api.UserPatch) (*api.User, error) {
user, err := patchUser(s.db, patch)
userRaw, err := patchUser(s.db, patch)
if err != nil {
return nil, err
}
user := userRaw.toUser()
return user, nil
}
func (s *Store) FindUserList(find *api.UserFind) ([]*api.User, error) {
list, err := findUserList(s.db, find)
userRawList, err := findUserList(s.db, find)
if err != nil {
return nil, err
}
list := []*api.User{}
for _, raw := range userRawList {
list = append(list, raw.toUser())
}
return list, nil
}
@@ -46,11 +91,13 @@ func (s *Store) FindUser(find *api.UserFind) (*api.User, error) {
return nil, &common.Error{Code: common.Conflict, Err: fmt.Errorf("found %d users with filter %+v, expect 1. ", len(list), find)}
}
return list[0], nil
user := list[0].toUser()
return user, nil
}
func createUser(db *DB, create *api.UserCreate) (*api.User, error) {
row, err := db.Db.Query(`
func createUser(db *sql.DB, create *api.UserCreate) (*userRaw, error) {
row, err := db.Query(`
INSERT INTO user (
email,
role,
@@ -59,7 +106,7 @@ func createUser(db *DB, create *api.UserCreate) (*api.User, error) {
open_id
)
VALUES (?, ?, ?, ?, ?)
RETURNING id, email, role, name, password_hash, open_id, created_ts, updated_ts
RETURNING id, email, role, name, password_hash, open_id, created_ts, updated_ts, row_status
`,
create.Email,
create.Role,
@@ -73,46 +120,50 @@ func createUser(db *DB, create *api.UserCreate) (*api.User, error) {
defer row.Close()
row.Next()
var user api.User
var userRaw userRaw
if err := row.Scan(
&user.ID,
&user.Email,
&user.Role,
&user.Name,
&user.PasswordHash,
&user.OpenID,
&user.CreatedTs,
&user.UpdatedTs,
&userRaw.ID,
&userRaw.Email,
&userRaw.Role,
&userRaw.Name,
&userRaw.PasswordHash,
&userRaw.OpenID,
&userRaw.CreatedTs,
&userRaw.UpdatedTs,
&userRaw.RowStatus,
); err != nil {
return nil, FormatError(err)
}
return &user, nil
return &userRaw, nil
}
func patchUser(db *DB, patch *api.UserPatch) (*api.User, error) {
func patchUser(db *sql.DB, patch *api.UserPatch) (*userRaw, error) {
set, args := []string{}, []interface{}{}
if v := patch.RowStatus; v != nil {
set, args = append(set, "row_status = ?"), append(args, *v)
}
if v := patch.Email; v != nil {
set, args = append(set, "email = ?"), append(args, v)
set, args = append(set, "email = ?"), append(args, *v)
}
if v := patch.Name; v != nil {
set, args = append(set, "name = ?"), append(args, v)
set, args = append(set, "name = ?"), append(args, *v)
}
if v := patch.PasswordHash; v != nil {
set, args = append(set, "password_hash = ?"), append(args, v)
set, args = append(set, "password_hash = ?"), append(args, *v)
}
if v := patch.OpenID; v != nil {
set, args = append(set, "open_id = ?"), append(args, v)
set, args = append(set, "open_id = ?"), append(args, *v)
}
args = append(args, patch.ID)
row, err := db.Db.Query(`
row, err := db.Query(`
UPDATE user
SET `+strings.Join(set, ", ")+`
WHERE id = ?
RETURNING id, email, role, name, password_hash, open_id, created_ts, updated_ts
RETURNING id, email, role, name, password_hash, open_id, created_ts, updated_ts, row_status
`, args...)
if err != nil {
return nil, FormatError(err)
@@ -120,27 +171,28 @@ func patchUser(db *DB, patch *api.UserPatch) (*api.User, error) {
defer row.Close()
if row.Next() {
var user api.User
var userRaw userRaw
if err := row.Scan(
&user.ID,
&user.Email,
&user.Role,
&user.Name,
&user.PasswordHash,
&user.OpenID,
&user.CreatedTs,
&user.UpdatedTs,
&userRaw.ID,
&userRaw.Email,
&userRaw.Role,
&userRaw.Name,
&userRaw.PasswordHash,
&userRaw.OpenID,
&userRaw.CreatedTs,
&userRaw.UpdatedTs,
&userRaw.RowStatus,
); err != nil {
return nil, FormatError(err)
}
return &user, nil
return &userRaw, nil
}
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("user ID not found: %d", patch.ID)}
}
func findUserList(db *DB, find *api.UserFind) ([]*api.User, error) {
func findUserList(db *sql.DB, find *api.UserFind) ([]*userRaw, error) {
where, args := []string{"1 = 1"}, []interface{}{}
if v := find.ID; v != nil {
@@ -159,7 +211,7 @@ func findUserList(db *DB, find *api.UserFind) ([]*api.User, error) {
where, args = append(where, "open_id = ?"), append(args, *v)
}
rows, err := db.Db.Query(`
rows, err := db.Query(`
SELECT
id,
email,
@@ -168,9 +220,11 @@ func findUserList(db *DB, find *api.UserFind) ([]*api.User, error) {
password_hash,
open_id,
created_ts,
updated_ts
updated_ts,
row_status
FROM user
WHERE `+strings.Join(where, " AND "),
WHERE `+strings.Join(where, " AND ")+`
ORDER BY created_ts DESC`,
args...,
)
if err != nil {
@@ -178,29 +232,30 @@ func findUserList(db *DB, find *api.UserFind) ([]*api.User, error) {
}
defer rows.Close()
list := make([]*api.User, 0)
userRawList := make([]*userRaw, 0)
for rows.Next() {
var user api.User
var userRaw userRaw
if err := rows.Scan(
&user.ID,
&user.Email,
&user.Role,
&user.Name,
&user.PasswordHash,
&user.OpenID,
&user.CreatedTs,
&user.UpdatedTs,
&userRaw.ID,
&userRaw.Email,
&userRaw.Role,
&userRaw.Name,
&userRaw.PasswordHash,
&userRaw.OpenID,
&userRaw.CreatedTs,
&userRaw.UpdatedTs,
&userRaw.RowStatus,
); err != nil {
fmt.Println(err)
return nil, FormatError(err)
}
list = append(list, &user)
userRawList = append(userRawList, &userRaw)
}
if err := rows.Err(); err != nil {
return nil, FormatError(err)
}
return list, nil
return userRawList, nil
}
+6 -2
View File
@@ -23,7 +23,11 @@
],
"@typescript-eslint/no-empty-interface": ["off"],
"@typescript-eslint/no-explicit-any": ["off"],
"react/react-in-jsx-scope": "off",
"@typescript-eslint/no-namespace": "off"
"react/react-in-jsx-scope": "off"
},
"settings": {
"react": {
"version": "detect"
}
}
}
+2 -1
View File
@@ -2,10 +2,11 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/logo.svg" sizes="64x64" type="image/*" />
<link rel="icon" href="/favicon.svg" sizes="64x64" type="image/*" />
<meta name="theme-color" content="#f6f5f4" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no" />
<title>Memos</title>
<script src="https://kit.fontawesome.com/41e3aaa6af.js" crossorigin="anonymous"></script>
</head>
<body>
<div id="root"></div>
+14 -8
View File
@@ -1,6 +1,6 @@
{
"name": "memos",
"version": "2.0.6",
"version": "0.2.2",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@@ -8,17 +8,24 @@
"lint": "eslint --ext .js,.ts,.tsx, src"
},
"dependencies": {
"@reduxjs/toolkit": "^1.8.1",
"axios": "^0.27.2",
"dayjs": "^1.11.3",
"lodash-es": "^4.17.21",
"qs": "^6.11.0",
"react": "^18.1.0",
"react-dom": "^18.1.0"
"react-dom": "^18.1.0",
"react-redux": "^8.0.1"
},
"devDependencies": {
"@types/lodash-es": "^4.17.5",
"@types/react": "^18.0.8",
"@types/react-dom": "^18.0.3",
"@types/node": "^18.0.3",
"@types/qs": "^6.9.7",
"@types/react": "^18.0.9",
"@types/react-dom": "^18.0.4",
"@typescript-eslint/eslint-plugin": "^5.6.0",
"@typescript-eslint/parser": "^5.6.0",
"@vitejs/plugin-react": "^1.0.0",
"@vitejs/plugin-react": "^2.0.0",
"autoprefixer": "^10.4.2",
"eslint": "^8.4.1",
"eslint-config-prettier": "^8.3.0",
@@ -29,7 +36,6 @@
"prettier": "2.5.1",
"tailwindcss": "^3.0.18",
"typescript": "^4.3.2",
"vite": "^2.9.0"
},
"license": "MIT"
"vite": "^3.0.0"
}
}

Before

Width:  |  Height:  |  Size: 121 B

After

Width:  |  Height:  |  Size: 121 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48"><path d="M22.5 38V25.5H10V22.5H22.5V10H25.5V22.5H38V25.5H25.5V38Z"/></svg>

Before

Width:  |  Height:  |  Size: 137 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#37352f"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12l4.58-4.59z"/></svg>

Before

Width:  |  Height:  |  Size: 214 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#FFFFFF"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6-6-6z"/></svg>

Before

Width:  |  Height:  |  Size: 209 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#37352f"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6-6-6z"/></svg>

Before

Width:  |  Height:  |  Size: 209 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#37352f"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zM17.99 9l-1.41-1.42-6.59 6.59-2.58-2.57-1.42 1.41 4 3.99z"/></svg>

Before

Width:  |  Height:  |  Size: 308 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#37352f"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M19 5v14H5V5h14m0-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z"/></svg>

Before

Width:  |  Height:  |  Size: 249 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#37352f"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/></svg>

Before

Width:  |  Height:  |  Size: 268 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#FFFFFF"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M14.06 9.02l.92.92L5.92 19H5v-.92l9.06-9.06M17.66 3c-.25 0-.51.1-.7.29l-1.83 1.83 3.75 3.75 1.83-1.83c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.2-.2-.45-.29-.71-.29zm-3.6 3.19L3 17.25V21h3.75L17.81 9.94l-3.75-3.75z"/></svg>

Before

Width:  |  Height:  |  Size: 367 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#37352f"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M14.06 9.02l.92.92L5.92 19H5v-.92l9.06-9.06M17.66 3c-.25 0-.51.1-.7.29l-1.83 1.83 3.75 3.75 1.83-1.83c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.2-.2-.45-.29-.71-.29zm-3.6 3.19L3 17.25V21h3.75L17.81 9.94l-3.75-3.75z"/></svg>

Before

Width:  |  Height:  |  Size: 367 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M19 5v14H5V5h14m0-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-4.86 8.86l-3 3.87L9 13.14 6 17h12l-3.86-5.14z"/></svg>

Before

Width:  |  Height:  |  Size: 296 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#37352f"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z"/></svg>

Before

Width:  |  Height:  |  Size: 204 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#FFFFFF"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M6 10c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm12 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm-6 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/></svg>

Before

Width:  |  Height:  |  Size: 306 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#37352f"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M6 10c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm12 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm-6 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/></svg>

Before

Width:  |  Height:  |  Size: 306 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#37352f"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>

Before

Width:  |  Height:  |  Size: 393 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24px" viewBox="0 0 24 24" width="24px" fill="#37352f"><g><rect fill="none" height="24" width="24"/></g><g><path d="M16,5l-1.42,1.42l-1.59-1.59V16h-1.98V4.83L9.42,6.42L8,5l4-4L16,5z M20,10v11c0,1.1-0.9,2-2,2H6c-1.11,0-2-0.9-2-2V10 c0-1.11,0.89-2,2-2h3v2H6v11h12V10h-3V8h3C19.1,8,20,8.89,20,10z"/></g></svg>

Before

Width:  |  Height:  |  Size: 387 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><g><rect fill="none" height="24" width="24"/></g><g><path d="M20,10V8h-4V4h-2v4h-4V4H8v4H4v2h4v4H4v2h4v4h2v-4h4v4h2v-4h4v-2h-4v-4H20z M14,14h-4v-4h4V14z"/></g></svg>

Before

Width:  |  Height:  |  Size: 301 B

+2 -6
View File
@@ -1,12 +1,8 @@
import { useContext } from "react";
import appContext from "./stores/appContext";
import { appRouterSwitch } from "./routers";
import "./less/app.less";
import { useAppSelector } from "./store";
function App() {
const {
locationState: { pathname },
} = useContext(appContext);
const pathname = useAppSelector((state) => state.location.pathname);
return <>{appRouterSwitch(pathname)}</>;
}
+20 -12
View File
@@ -1,7 +1,8 @@
import { useEffect, useState } from "react";
import api from "../helpers/api";
import * as api from "../helpers/api";
import Only from "./common/OnlyWhen";
import { showDialog } from "./Dialog";
import GitHubBadge from "./GitHubBadge";
import "../less/about-site-dialog.less";
interface Props extends DialogProps {}
@@ -11,7 +12,10 @@ const AboutSiteDialog: React.FC<Props> = ({ destroy }: Props) => {
useEffect(() => {
try {
api.getSystemStatus().then(({ profile }) => {
api.getSystemStatus().then(({ data }) => {
const {
data: { profile },
} = data;
setProfile(profile);
});
} catch (error) {
@@ -33,22 +37,26 @@ const AboutSiteDialog: React.FC<Props> = ({ destroy }: Props) => {
<span className="icon-text">🤠</span>About <b>Memos</b>
</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<img className="icon-img" src="/icons/close.svg" />
<i className="fa-solid fa-xmark fa-lg"></i>
</button>
</div>
<div className="dialog-content-container">
<p>
Memos is an open source, quickly self-hosted alternative to <a href="https://flomoapp.com">flomo</a>.
Memos is an <i>open source</i>, <i>self-hosted</i> knowledge base that works with a SQLite db file.
</p>
<br />
<p>
<a href="https://github.com/justmemos/memos">🏗 Source code</a>, and built by <a href="https://github.com/boojack">Steven 🐯</a>.
</p>
<Only when={profile !== undefined}>
<p className="updated-time-text">
version: <span className="pre-text">{profile?.version}</span> 🎉
</p>
</Only>
<div className="addtion-info-container">
<GitHubBadge />
<Only when={profile !== undefined}>
<>
version:
<span className="pre-text">
{profile?.version}-{profile?.mode}
</span>
🎉
</>
</Only>
</div>
</div>
</>
);
@@ -1,5 +1,5 @@
import { IMAGE_URL_REG } from "../helpers/consts";
import utils from "../helpers/utils";
import * as utils from "../helpers/utils";
import useToggle from "../hooks/useToggle";
import { memoService } from "../services";
import Only from "./common/OnlyWhen";
@@ -9,25 +9,24 @@ import { formatMemoContent } from "./Memo";
import "../less/memo.less";
interface Props {
memo: Model.Memo;
handleDeletedMemoAction: (memoId: string) => void;
memo: Memo;
}
const DeletedMemo: React.FC<Props> = (props: Props) => {
const { memo: propsMemo, handleDeletedMemoAction } = props;
const memo: FormattedMemo = {
const ArchivedMemo: React.FC<Props> = (props: Props) => {
const { memo: propsMemo } = props;
const memo = {
...propsMemo,
createdAtStr: utils.getDateTimeString(propsMemo.createdAt),
deletedAtStr: utils.getDateTimeString(propsMemo.updatedAt ?? Date.now()),
createdAtStr: utils.getDateTimeString(propsMemo.createdTs),
archivedAtStr: utils.getDateTimeString(propsMemo.updatedTs ?? Date.now()),
};
const [showConfirmDeleteBtn, toggleConfirmDeleteBtn] = useToggle(false);
const imageUrls = Array.from(memo.content.match(IMAGE_URL_REG) ?? []);
const imageUrls = Array.from(memo.content.match(IMAGE_URL_REG) ?? []).map((s) => s.replace(IMAGE_URL_REG, "$1"));
const handleDeleteMemoClick = async () => {
if (showConfirmDeleteBtn) {
try {
await memoService.deleteMemoById(memo.id);
handleDeletedMemoAction(memo.id);
await memoService.fetchAllMemos();
} catch (error: any) {
toastHelper.error(error.message);
}
@@ -38,8 +37,11 @@ const DeletedMemo: React.FC<Props> = (props: Props) => {
const handleRestoreMemoClick = async () => {
try {
await memoService.restoreMemoById(memo.id);
handleDeletedMemoAction(memo.id);
await memoService.patchMemo({
id: memo.id,
rowStatus: "NORMAL",
});
await memoService.fetchAllMemos();
toastHelper.info("Restored successfully");
} catch (error: any) {
toastHelper.error(error.message);
@@ -53,9 +55,9 @@ const DeletedMemo: React.FC<Props> = (props: Props) => {
};
return (
<div className={`memo-wrapper deleted-memo ${"memos-" + memo.id}`} onMouseLeave={handleMouseLeaveMemoWrapper}>
<div className={`memo-wrapper archived-memo ${"memos-" + memo.id}`} onMouseLeave={handleMouseLeaveMemoWrapper}>
<div className="memo-top-wrapper">
<span className="time-text">Deleted at {memo.deletedAtStr}</span>
<span className="time-text">Archived at {memo.archivedAtStr}</span>
<div className="btns-container">
<span className="btn restore-btn" onClick={handleRestoreMemoClick}>
Restore
@@ -77,4 +79,4 @@ const DeletedMemo: React.FC<Props> = (props: Props) => {
);
};
export default DeletedMemo;
export default ArchivedMemo;
+73
View File
@@ -0,0 +1,73 @@
import { useEffect, useState } from "react";
import useLoading from "../hooks/useLoading";
import { memoService } from "../services";
import { useAppSelector } from "../store";
import { showDialog } from "./Dialog";
import toastHelper from "./Toast";
import ArchivedMemo from "./ArchivedMemo";
import "../less/archived-memo-dialog.less";
interface Props extends DialogProps {}
const ArchivedMemoDialog: React.FC<Props> = (props: Props) => {
const { destroy } = props;
const memos = useAppSelector((state) => state.memo.memos);
const loadingState = useLoading();
const [archivedMemos, setArchivedMemos] = useState<Memo[]>([]);
useEffect(() => {
memoService
.fetchArchivedMemos()
.then((result) => {
setArchivedMemos(result);
})
.catch((error) => {
toastHelper.error("Failed to fetch archived memos: ", error);
})
.finally(() => {
loadingState.setFinish();
});
}, [memos]);
return (
<>
<div className="dialog-header-container">
<p className="title-text">
<span className="icon-text">🗂</span>
Archived Memos
</p>
<button className="btn close-btn" onClick={destroy}>
<i className="fa-solid fa-xmark fa-lg icon-img"></i>
</button>
</div>
<div className="dialog-content-container">
{loadingState.isLoading ? (
<div className="tip-text-container">
<p className="tip-text">fetching data...</p>
</div>
) : archivedMemos.length === 0 ? (
<div className="tip-text-container">
<p className="tip-text">Here is No Zettels.</p>
</div>
) : (
<div className="archived-memos-container">
{archivedMemos.map((memo) => (
<ArchivedMemo key={`${memo.id}-${memo.updatedTs}`} memo={memo} />
))}
</div>
)}
</div>
</>
);
};
export default function showArchivedMemo(): void {
showDialog(
{
className: "archived-memo-dialog",
useAppContext: true,
},
ArchivedMemoDialog,
{}
);
}
+4 -2
View File
@@ -55,7 +55,9 @@ const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => {
}
try {
await userService.updatePassword(newPassword);
await userService.patchUser({
password: newPassword,
});
toastHelper.info("Password changed.");
handleCloseBtnClick();
} catch (error: any) {
@@ -68,7 +70,7 @@ const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => {
<div className="dialog-header-container">
<p className="title-text">Change Password</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<img className="icon-img" src="/icons/close.svg" />
<i className="fa-solid fa-xmark fa-lg"></i>
</button>
</div>
<div className="dialog-content-container">
@@ -1,8 +1,8 @@
import { useEffect } from "react";
import { showDialog } from "./Dialog";
import useLoading from "../hooks/useLoading";
import toastHelper from "./Toast";
import { userService } from "../services";
import useLoading from "../hooks/useLoading";
import { showDialog } from "./Dialog";
import toastHelper from "./Toast";
import "../less/confirm-reset-openid-dialog.less";
interface Props extends DialogProps {}
@@ -25,7 +25,9 @@ const ConfirmResetOpenIdDialog: React.FC<Props> = ({ destroy }: Props) => {
resetBtnClickLoadingState.setLoading();
try {
await userService.resetOpenId();
await userService.patchUser({
resetOpenId: true,
});
} catch (error) {
toastHelper.error("Request reset open API failed.");
return;
@@ -39,12 +41,12 @@ const ConfirmResetOpenIdDialog: React.FC<Props> = ({ destroy }: Props) => {
<div className="dialog-header-container">
<p className="title-text">Reset Open API</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<img className="icon-img" src="/icons/close.svg" />
<i className="fa-solid fa-xmark fa-lg"></i>
</button>
</div>
<div className="dialog-content-container">
<p className="warn-text">
The existing API will be invalidated and a new one will be generated, are you sure you want to reset?
The existing API will be invalidated and a new one will be generated, are you sure you want to reset?
</p>
<div className="btns-container">
<span className="btn cancel-btn" onClick={handleCloseBtnClick}>
+25 -20
View File
@@ -8,7 +8,7 @@ import Selector from "./common/Selector";
import "../less/create-shortcut-dialog.less";
interface Props extends DialogProps {
shortcutId?: string;
shortcutId?: ShortcutId;
}
const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
@@ -23,12 +23,14 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
}).length;
useEffect(() => {
const shortcutTemp = shortcutService.getShortcutById(shortcutId ?? "");
if (shortcutTemp) {
setTitle(shortcutTemp.title);
const temp = JSON.parse(shortcutTemp.payload);
if (Array.isArray(temp)) {
setFilters(temp);
if (shortcutId) {
const shortcutTemp = shortcutService.getShortcutById(shortcutId);
if (shortcutTemp) {
setTitle(shortcutTemp.title);
const temp = JSON.parse(shortcutTemp.payload);
if (Array.isArray(temp)) {
setFilters(temp);
}
}
}
}, [shortcutId]);
@@ -46,11 +48,16 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
try {
if (shortcutId) {
const editedShortcut = await shortcutService.updateShortcut(shortcutId, title, JSON.stringify(filters));
shortcutService.editShortcut(shortcutService.convertResponseModelShortcut(editedShortcut));
await shortcutService.patchShortcut({
id: shortcutId,
title,
payload: JSON.stringify(filters),
});
} else {
const shortcut = await shortcutService.createShortcut(title, JSON.stringify(filters));
shortcutService.pushShortcut(shortcutService.convertResponseModelShortcut(shortcut));
await shortcutService.createShortcut({
title,
payload: JSON.stringify(filters),
});
}
} catch (error: any) {
toastHelper.error(error.message);
@@ -93,7 +100,7 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
{shortcutId ? "Edit Shortcut" : "Create Shortcut"}
</p>
<button className="btn close-btn" onClick={destroy}>
<img className="icon-img" src="/icons/close.svg" />
<i className="fa-solid fa-xmark fa-lg"></i>
</button>
</div>
<div className="dialog-content-container">
@@ -145,6 +152,7 @@ interface MemoFilterInputerProps {
const FilterInputer: React.FC<MemoFilterInputerProps> = (props: MemoFilterInputerProps) => {
const { index, filter, handleFilterChange, handleFilterRemove } = props;
const tags = Array.from(memoService.getState().tags);
const { type } = filter;
const [inputElements, setInputElements] = useState<JSX.Element>(<></>);
@@ -178,12 +186,9 @@ const FilterInputer: React.FC<MemoFilterInputerProps> = (props: MemoFilterInpute
valueElement = (
<Selector
className="value-selector"
dataSource={memoService
.getState()
.tags.sort()
.map((t) => {
return { text: t, value: t };
})}
dataSource={tags.sort().map((t) => {
return { text: t, value: t };
})}
value={filter.value.value}
handleValueChanged={handleValueChange}
/>
@@ -291,14 +296,14 @@ const FilterInputer: React.FC<MemoFilterInputerProps> = (props: MemoFilterInpute
/>
{inputElements}
<img className="remove-btn" src="/icons/close.svg" onClick={handleRemoveBtnClick} />
<i className="fa-solid fa-xmark remove-btn" onClick={handleRemoveBtnClick}></i>
</div>
);
};
const MemoFilterInputer: React.FC<MemoFilterInputerProps> = memo(FilterInputer);
export default function showCreateShortcutDialog(shortcutId?: string): void {
export default function showCreateShortcutDialog(shortcutId?: ShortcutId): void {
showDialog(
{
className: "create-shortcut-dialog",
+10 -8
View File
@@ -1,25 +1,26 @@
import { IMAGE_URL_REG } from "../helpers/consts";
import utils from "../helpers/utils";
import { formatMemoContent } from "./Memo";
import * as utils from "../helpers/utils";
import Only from "./common/OnlyWhen";
import { formatMemoContent } from "./Memo";
import "../less/daily-memo.less";
interface DailyMemo extends FormattedMemo {
interface DailyMemo extends Memo {
createdAtStr: string;
timeStr: string;
}
interface Props {
memo: Model.Memo;
memo: Memo;
}
const DailyMemo: React.FC<Props> = (props: Props) => {
const { memo: propsMemo } = props;
const memo: DailyMemo = {
...propsMemo,
createdAtStr: utils.getDateTimeString(propsMemo.createdAt),
timeStr: utils.getTimeString(propsMemo.createdAt),
createdAtStr: utils.getDateTimeString(propsMemo.createdTs),
timeStr: utils.getTimeString(propsMemo.createdTs),
};
const imageUrls = Array.from(memo.content.match(IMAGE_URL_REG) ?? []);
const imageUrls = Array.from(memo.content.match(IMAGE_URL_REG) ?? []).map((s) => s.replace(IMAGE_URL_REG, "$1"));
return (
<div className="daily-memo-wrapper">
@@ -31,11 +32,12 @@ const DailyMemo: React.FC<Props> = (props: Props) => {
<Only when={imageUrls.length > 0}>
<div className="images-container">
{imageUrls.map((imgUrl, idx) => (
<img key={idx} crossOrigin="anonymous" src={imgUrl} decoding="async" />
<img key={idx} src={imgUrl} decoding="async" />
))}
</div>
</Only>
</div>
<div className="split-line"></div>
</div>
);
};
-135
View File
@@ -1,135 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { memoService } from "../services";
import toImage from "../labs/html2image";
import useToggle from "../hooks/useToggle";
import useLoading from "../hooks/useLoading";
import { DAILY_TIMESTAMP } from "../helpers/consts";
import utils from "../helpers/utils";
import { showDialog } from "./Dialog";
import showPreviewImageDialog from "./PreviewImageDialog";
import DailyMemo from "./DailyMemo";
import DatePicker from "./common/DatePicker";
import "../less/daily-memo-diary-dialog.less";
interface Props extends DialogProps {
currentDateStamp: DateStamp;
}
const monthChineseStrArray = ["一月", "二月", "三月", "四月", "五月", "六月", "七月", "八月", "九月", "十月", "十一月", "十二月"];
const weekdayChineseStrArray = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
const DailyMemoDiaryDialog: React.FC<Props> = (props: Props) => {
const loadingState = useLoading();
const [memos, setMemos] = useState<Model.Memo[]>([]);
const [currentDateStamp, setCurrentDateStamp] = useState(utils.getDateStampByDate(utils.getDateString(props.currentDateStamp)));
const [showDatePicker, toggleShowDatePicker] = useToggle(false);
const memosElRef = useRef<HTMLDivElement>(null);
const currentDate = new Date(currentDateStamp);
useEffect(() => {
const setDailyMemos = () => {
const dailyMemos = memoService
.getState()
.memos.filter(
(a) =>
utils.getTimeStampByDate(a.createdAt) >= currentDateStamp &&
utils.getTimeStampByDate(a.createdAt) < currentDateStamp + DAILY_TIMESTAMP
)
.sort((a, b) => utils.getTimeStampByDate(a.createdAt) - utils.getTimeStampByDate(b.createdAt));
setMemos(dailyMemos);
loadingState.setFinish();
};
setDailyMemos();
}, [currentDateStamp]);
const handleShareBtnClick = () => {
toggleShowDatePicker(false);
setTimeout(() => {
if (!memosElRef.current) {
return;
}
toImage(memosElRef.current, {
backgroundColor: "#ffffff",
pixelRatio: window.devicePixelRatio * 2,
})
.then((url) => {
showPreviewImageDialog(url);
})
.catch(() => {
// do nth
});
}, 0);
};
const handleDataPickerChange = (datestamp: DateStamp): void => {
setCurrentDateStamp(datestamp);
toggleShowDatePicker(false);
};
return (
<>
<div className="dialog-header-container">
<div className="header-wrapper">
<p className="title-text">Daily Memos</p>
<div className="btns-container">
<span className="btn-text" onClick={() => setCurrentDateStamp(currentDateStamp - DAILY_TIMESTAMP)}>
<img className="icon-img" src="/icons/arrow-left.svg" />
</span>
<span className="btn-text" onClick={() => setCurrentDateStamp(currentDateStamp + DAILY_TIMESTAMP)}>
<img className="icon-img" src="/icons/arrow-right.svg" />
</span>
<span className="btn-text share-btn" onClick={handleShareBtnClick}>
<img className="icon-img" src="/icons/share.svg" />
</span>
<span className="btn-text" onClick={() => props.destroy()}>
<img className="icon-img" src="/icons/close.svg" />
</span>
</div>
</div>
</div>
<div className="dialog-content-container" ref={memosElRef}>
<div className="date-card-container" onClick={() => toggleShowDatePicker()}>
<div className="year-text">{currentDate.getFullYear()}</div>
<div className="date-container">
<div className="month-text">{monthChineseStrArray[currentDate.getMonth()]}</div>
<div className="date-text">{currentDate.getDate()}</div>
<div className="day-text">{weekdayChineseStrArray[currentDate.getDay()]}</div>
</div>
</div>
<DatePicker
className={`date-picker ${showDatePicker ? "" : "hidden"}`}
datestamp={currentDateStamp}
handleDateStampChange={handleDataPickerChange}
/>
{loadingState.isLoading ? (
<div className="tip-container">
<p className="tip-text">Loading...</p>
</div>
) : memos.length === 0 ? (
<div className="tip-container">
<p className="tip-text">Oops, there is nothing.</p>
</div>
) : (
<div className="dailymemos-wrapper">
{memos.map((memo) => (
<DailyMemo key={`${memo.id}-${memo.updatedAt}`} memo={memo} />
))}
</div>
)}
</div>
</>
);
};
export default function showDailyMemoDiaryDialog(datestamp: DateStamp = Date.now()): void {
showDialog(
{
className: "daily-memo-diary-dialog",
},
DailyMemoDiaryDialog,
{ currentDateStamp: datestamp }
);
}
+119
View File
@@ -0,0 +1,119 @@
import { useRef, useState } from "react";
import { useAppSelector } from "../store";
import toImage from "../labs/html2image";
import useToggle from "../hooks/useToggle";
import { DAILY_TIMESTAMP } from "../helpers/consts";
import * as utils from "../helpers/utils";
import { showDialog } from "./Dialog";
import DatePicker from "./common/DatePicker";
import showPreviewImageDialog from "./PreviewImageDialog";
import DailyMemo from "./DailyMemo";
import "../less/daily-review-dialog.less";
interface Props extends DialogProps {
currentDateStamp: DateStamp;
}
const monthChineseStrArray = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sept", "Oct", "Nov", "Dev"];
const weekdayChineseStrArray = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const DailyReviewDialog: React.FC<Props> = (props: Props) => {
const memos = useAppSelector((state) => state.memo.memos);
const [currentDateStamp, setCurrentDateStamp] = useState(utils.getDateStampByDate(utils.getDateString(props.currentDateStamp)));
const [showDatePicker, toggleShowDatePicker] = useToggle(false);
const memosElRef = useRef<HTMLDivElement>(null);
const currentDate = new Date(currentDateStamp);
const dailyMemos = memos
.filter(
(m) =>
m.rowStatus === "NORMAL" &&
utils.getTimeStampByDate(m.createdTs) >= currentDateStamp &&
utils.getTimeStampByDate(m.createdTs) < currentDateStamp + DAILY_TIMESTAMP
)
.sort((a, b) => utils.getTimeStampByDate(a.createdTs) - utils.getTimeStampByDate(b.createdTs));
const handleShareBtnClick = () => {
if (!memosElRef.current) {
return;
}
toggleShowDatePicker(false);
toImage(memosElRef.current, {
backgroundColor: "#ffffff",
pixelRatio: window.devicePixelRatio * 2,
})
.then((url) => {
showPreviewImageDialog(url);
})
.catch(() => {
// do nth
});
};
const handleDataPickerChange = (datestamp: DateStamp): void => {
setCurrentDateStamp(datestamp);
toggleShowDatePicker(false);
};
return (
<>
<div className="dialog-header-container">
<p className="title-text" onClick={() => toggleShowDatePicker()}>
<span className="icon-text">📅</span> Daily Review
</p>
<div className="btns-container">
<button className="btn-text" onClick={() => setCurrentDateStamp(currentDateStamp - DAILY_TIMESTAMP)}>
<i className="fa-solid fa-chevron-left icon-img"></i>
</button>
<button className="btn-text" onClick={() => setCurrentDateStamp(currentDateStamp + DAILY_TIMESTAMP)}>
<i className="fa-solid fa-chevron-right icon-img"></i>
</button>
<button className="btn-text" onClick={handleShareBtnClick}>
<i className="fa-solid fa-share-nodes icon-img"></i>
</button>
<button className="btn-text" onClick={() => props.destroy()}>
<i className="fa-solid fa-xmark fa-lg icon-img"></i>
</button>
</div>
<DatePicker
className={`date-picker ${showDatePicker ? "" : "!hidden"}`}
datestamp={currentDateStamp}
handleDateStampChange={handleDataPickerChange}
/>
</div>
<div className="dialog-content-container" ref={memosElRef}>
<div className="date-card-container">
<div className="year-text">{currentDate.getFullYear()}</div>
<div className="date-container">
<div className="month-text">{monthChineseStrArray[currentDate.getMonth()]}</div>
<div className="date-text">{currentDate.getDate()}</div>
<div className="day-text">{weekdayChineseStrArray[currentDate.getDay()]}</div>
</div>
</div>
{dailyMemos.length === 0 ? (
<div className="tip-container">
<p className="tip-text">Oops, there is nothing.</p>
</div>
) : (
<div className="dailymemos-wrapper">
{dailyMemos.map((memo) => (
<DailyMemo key={`${memo.id}-${memo.updatedTs}`} memo={memo} />
))}
</div>
)}
</div>
</>
);
};
export default function showDailyReviewDialog(datestamp: DateStamp = Date.now()): void {
showDialog(
{
className: "daily-review-dialog",
useAppContext: true,
},
DailyReviewDialog,
{ currentDateStamp: datestamp }
);
}
+3 -8
View File
@@ -1,8 +1,7 @@
import { createRoot } from "react-dom/client";
import appContext from "../stores/appContext";
import Provider from "../labs/Provider";
import appStore from "../stores/appStore";
import { ANIMATION_DURATION } from "../helpers/consts";
import { Provider } from "react-redux";
import store from "../store";
import "../less/dialog.less";
interface DialogConfig {
@@ -69,11 +68,7 @@ export function showDialog<T extends DialogProps>(
);
if (config.useAppContext) {
Fragment = (
<Provider store={appStore} context={appContext}>
{Fragment}
</Provider>
);
Fragment = <Provider store={store}>{Fragment}</Provider>;
}
dialog.render(Fragment);
+4 -2
View File
@@ -15,6 +15,7 @@ interface EditorProps {
className: string;
initialContent: string;
placeholder: string;
fullscreen: boolean;
showConfirmBtn: boolean;
showCancelBtn: boolean;
tools?: ReactNode;
@@ -29,6 +30,7 @@ const Editor = forwardRef((props: EditorProps, ref: React.ForwardedRef<EditorRef
className,
initialContent,
placeholder,
fullscreen,
showConfirmBtn,
showCancelBtn,
onConfirmBtnClick: handleConfirmBtnClickCallback,
@@ -45,11 +47,11 @@ const Editor = forwardRef((props: EditorProps, ref: React.ForwardedRef<EditorRef
}, []);
useEffect(() => {
if (editorRef.current) {
if (editorRef.current && !fullscreen) {
editorRef.current.style.height = "auto";
editorRef.current.style.height = (editorRef.current.scrollHeight ?? 0) + "px";
}
}, [editorRef.current?.value]);
}, [editorRef.current?.value, fullscreen]);
useImperativeHandle(
ref,
+30
View File
@@ -0,0 +1,30 @@
import { useEffect, useState } from "react";
import * as api from "../helpers/api";
import "../less/github-badge.less";
interface Props {}
const GitHubBadge: React.FC<Props> = () => {
const [starCount, setStarCount] = useState(0);
useEffect(() => {
api.getRepoStarCount().then((data) => {
setStarCount(data);
});
}, []);
return (
<a className="github-badge-container" href="https://github.com/usememos/memos">
<div className="github-icon">
<i className="fa-brands fa-github fa-lg icon-img"></i>
Star
</div>
<div className="count-text">
{starCount || ""}
<span className="icon-text">🌟</span>
</div>
</a>
);
};
export default GitHubBadge;
+159 -111
View File
@@ -1,29 +1,67 @@
import { memo } from "react";
import { escape } from "lodash-es";
import { IMAGE_URL_REG, LINK_REG, MEMO_LINK_REG, TAG_REG } from "../helpers/consts";
import { parseMarkedToHtml, parseRawTextToHtml } from "../helpers/marked";
import utils from "../helpers/utils";
import useToggle from "../hooks/useToggle";
import { globalStateService, memoService } from "../services";
import { memo, useEffect, useRef, useState } from "react";
import { escape, indexOf } from "lodash-es";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { IMAGE_URL_REG, LINK_URL_REG, MEMO_LINK_REG, TAG_REG, UNKNOWN_ID } from "../helpers/consts";
import { DONE_BLOCK_REG, parseMarkedToHtml, TODO_BLOCK_REG } from "../helpers/marked";
import { editorStateService, locationService, memoService, userService } from "../services";
import Only from "./common/OnlyWhen";
import toastHelper from "./Toast";
import Image from "./Image";
import showMemoCardDialog from "./MemoCardDialog";
import showShareMemoImageDialog from "./ShareMemoImageDialog";
import toastHelper from "./Toast";
import "../less/memo.less";
dayjs.extend(relativeTime);
const MAX_MEMO_CONTAINER_HEIGHT = 384;
type ExpandButtonStatus = -1 | 0 | 1;
interface Props {
memo: Model.Memo;
memo: Memo;
}
interface State {
expandButtonStatus: ExpandButtonStatus;
}
export const getFormatedMemoCreatedAtStr = (createdTs: number): string => {
if (Date.now() - createdTs < 1000 * 60 * 60 * 24) {
return dayjs(createdTs).fromNow();
} else {
return dayjs(createdTs).format("YYYY/MM/DD HH:mm:ss");
}
};
const Memo: React.FC<Props> = (props: Props) => {
const { memo: propsMemo } = props;
const memo: FormattedMemo = {
...propsMemo,
createdAtStr: utils.getDateTimeString(propsMemo.createdAt),
};
const [showConfirmDeleteBtn, toggleConfirmDeleteBtn] = useToggle(false);
const imageUrls = Array.from(memo.content.match(IMAGE_URL_REG) ?? []);
const memo = props.memo;
const [state, setState] = useState<State>({
expandButtonStatus: -1,
});
const [createdAtStr, setCreatedAtStr] = useState<string>(getFormatedMemoCreatedAtStr(memo.createdTs));
const memoContainerRef = useRef<HTMLDivElement>(null);
const imageUrls = Array.from(memo.content.match(IMAGE_URL_REG) ?? []).map((s) => s.replace(IMAGE_URL_REG, "$1"));
const isVisitorMode = userService.isVisitorMode();
useEffect(() => {
if (!memoContainerRef) {
return;
}
if (Number(memoContainerRef.current?.clientHeight) > MAX_MEMO_CONTAINER_HEIGHT) {
setState({
...state,
expandButtonStatus: 0,
});
}
if (Date.now() - memo.createdTs < 1000 * 60 * 60 * 24) {
setInterval(() => {
setCreatedAtStr(dayjs(memo.createdTs).fromNow());
}, 1000 * 1);
}
}, []);
const handleShowMemoStoryDialog = () => {
showMemoCardDialog(memo);
@@ -31,18 +69,10 @@ const Memo: React.FC<Props> = (props: Props) => {
const handleTogglePinMemoBtnClick = async () => {
try {
if (memo.rowStatus === "ARCHIVED") {
if (memo.pinned) {
await memoService.unpinMemo(memo.id);
memoService.editMemo({
...memo,
rowStatus: "NORMAL",
});
} else {
await memoService.pinMemo(memo.id);
memoService.editMemo({
...memo,
rowStatus: "ARCHIVED",
});
}
} catch (error) {
// do nth
@@ -50,32 +80,25 @@ const Memo: React.FC<Props> = (props: Props) => {
};
const handleMarkMemoClick = () => {
globalStateService.setMarkMemoId(memo.id);
editorStateService.setMarkMemoWithId(memo.id);
};
const handleEditMemoClick = () => {
globalStateService.setEditMemoId(memo.id);
editorStateService.setEditMemoWithId(memo.id);
};
const handleDeleteMemoClick = async () => {
if (showConfirmDeleteBtn) {
try {
await memoService.hideMemoById(memo.id);
} catch (error: any) {
toastHelper.error(error.message);
}
if (globalStateService.getState().editMemoId === memo.id) {
globalStateService.setEditMemoId("");
}
} else {
toggleConfirmDeleteBtn();
const handleArchiveMemoClick = async () => {
try {
await memoService.patchMemo({
id: memo.id,
rowStatus: "ARCHIVED",
});
} catch (error: any) {
toastHelper.error(error.message);
}
};
const handleMouseLeaveMemoWrapper = () => {
if (showConfirmDeleteBtn) {
toggleConfirmDeleteBtn(false);
if (editorStateService.getState().editMemoId === memo.id) {
editorStateService.clearEditMemo();
}
};
@@ -88,7 +111,7 @@ const Memo: React.FC<Props> = (props: Props) => {
if (targetEl.className === "memo-link-text") {
const memoId = targetEl.dataset?.value;
const memoTemp = memoService.getMemoById(memoId ?? "");
const memoTemp = memoService.getMemoById(Number(memoId) ?? UNKNOWN_ID);
if (memoTemp) {
showMemoCardDialog(memoTemp);
@@ -96,53 +119,113 @@ const Memo: React.FC<Props> = (props: Props) => {
toastHelper.error("MEMO Not Found");
targetEl.classList.remove("memo-link-text");
}
} else if (targetEl.className === "todo-block") {
// do nth
} else if (targetEl.className === "tag-span") {
const tagName = targetEl.innerText.slice(1);
const currTagQuery = locationService.getState().query?.tag;
if (currTagQuery === tagName) {
locationService.setTagQuery(undefined);
} else {
locationService.setTagQuery(tagName);
}
} else if (targetEl.classList.contains("todo-block")) {
if (userService.isVisitorMode()) {
return;
}
const status = targetEl.dataset?.value;
const todoElementList = [...(memoContainerRef.current?.querySelectorAll(`span.todo-block[data-value=${status}]`) ?? [])];
for (const element of todoElementList) {
if (element === targetEl) {
const index = indexOf(todoElementList, element);
const tempList = memo.content.split(status === "DONE" ? DONE_BLOCK_REG : TODO_BLOCK_REG);
let finalContent = "";
for (let i = 0; i < tempList.length; i++) {
if (i === 0) {
finalContent += `${tempList[i]}`;
} else {
if (i === index + 1) {
finalContent += status === "DONE" ? "- [ ] " : "- [x] ";
} else {
finalContent += status === "DONE" ? "- [x] " : "- [ ] ";
}
finalContent += `${tempList[i]}`;
}
}
await memoService.patchMemo({
id: memo.id,
content: finalContent,
});
}
}
}
};
const handleExpandBtnClick = () => {
setState({
expandButtonStatus: Number(Boolean(!state.expandButtonStatus)) as ExpandButtonStatus,
});
};
return (
<div className={`memo-wrapper ${"memos-" + memo.id} ${memo.rowStatus}`} onMouseLeave={handleMouseLeaveMemoWrapper}>
<div className={`memo-wrapper ${"memos-" + memo.id} ${memo.pinned ? "pinned" : ""}`}>
<div className="memo-top-wrapper">
<span className="time-text" onClick={handleShowMemoStoryDialog}>
{memo.createdAtStr}
<Only when={memo.rowStatus === "ARCHIVED"}>
<span className="ml-2">PINNED</span>
<div className="status-text-container" onClick={handleShowMemoStoryDialog}>
<span className="time-text">{createdAtStr}</span>
<Only when={memo.pinned}>
<span className="status-text">PINNED</span>
</Only>
</span>
<div className="btns-container">
<Only when={memo.visibility === "PUBLIC" && !isVisitorMode}>
<span className="status-text">PUBLIC</span>
</Only>
</div>
<div className={`btns-container ${userService.isVisitorMode() ? "!hidden" : ""}`}>
<span className="btn more-action-btn">
<img className="icon-img" src="/icons/more.svg" />
<i className="fa-solid fa-ellipsis icon-img"></i>
</span>
<div className="more-action-btns-wrapper">
<div className="more-action-btns-container">
<span className="btn" onClick={handleShowMemoStoryDialog}>
View Story
</span>
<span className="btn" onClick={handleTogglePinMemoBtnClick}>
{memo.rowStatus === "NORMAL" ? "Pin" : "Unpin"}
</span>
<div className="btns-container">
<div className="btn" onClick={handleTogglePinMemoBtnClick}>
<i className={`fa-solid fa-thumbtack icon-img ${memo.pinned ? "" : "opacity-20"}`}></i>
<span className="tip-text">{memo.pinned ? "Unpin" : "Pin"}</span>
</div>
<div className="btn" onClick={handleEditMemoClick}>
<i className="fa-solid fa-pen-to-square icon-img"></i>
<span className="tip-text">Edit</span>
</div>
<div className="btn" onClick={handleGenMemoImageBtnClick}>
<i className="fa-solid fa-share-nodes icon-img"></i>
<span className="tip-text">Share</span>
</div>
</div>
<span className="btn" onClick={handleMarkMemoClick}>
Mark
</span>
<span className="btn" onClick={handleGenMemoImageBtnClick}>
Share
<span className="btn" onClick={handleShowMemoStoryDialog}>
View Story
</span>
<span className="btn" onClick={handleEditMemoClick}>
Edit
</span>
<span className={`btn delete-btn ${showConfirmDeleteBtn ? "final-confirm" : ""}`} onClick={handleDeleteMemoClick}>
{showConfirmDeleteBtn ? "Delete!" : "Delete"}
<span className="btn archive-btn" onClick={handleArchiveMemoClick}>
Archive
</span>
</div>
</div>
</div>
</div>
<div
className="memo-content-text"
ref={memoContainerRef}
className={`memo-content-text ${state.expandButtonStatus === 0 ? "expanded" : ""}`}
onClick={handleMemoContentClick}
dangerouslySetInnerHTML={{ __html: formatMemoContent(memo.content) }}
></div>
{state.expandButtonStatus !== -1 && (
<div className="expand-btn-container">
<span className={`btn ${state.expandButtonStatus === 0 ? "expand-btn" : "fold-btn"}`} onClick={handleExpandBtnClick}>
{state.expandButtonStatus === 0 ? "Expand" : "Fold"}
<i className="fa-solid fa-chevron-right icon-img"></i>
</span>
</div>
)}
<Only when={imageUrls.length > 0}>
<div className="images-wrapper">
{imageUrls.map((imgUrl, idx) => (
@@ -155,49 +238,14 @@ const Memo: React.FC<Props> = (props: Props) => {
};
export function formatMemoContent(content: string) {
content = escape(content);
content = parseRawTextToHtml(content)
.split("<br>")
.map((t) => {
return `<p>${t !== "" ? t : "<br>"}</p>`;
})
.join("");
const tempElement = document.createElement("div");
tempElement.innerHTML = parseMarkedToHtml(escape(content));
const { shouldUseMarkdownParser, shouldSplitMemoWord, shouldHideImageUrl } = globalStateService.getState();
if (shouldUseMarkdownParser) {
content = parseMarkedToHtml(content);
}
if (shouldHideImageUrl) {
content = content.replace(IMAGE_URL_REG, "");
}
content = content
.replace(TAG_REG, "<span class='tag-span'>#$1</span>")
.replace(LINK_REG, "<a class='link' target='_blank' rel='noreferrer' href='$1'>$1</a>")
.replace(MEMO_LINK_REG, "<span class='memo-link-text' data-value='$2'>$1</span>");
// Add space in english and chinese
if (shouldSplitMemoWord) {
content = content
.replace(/([\u4e00-\u9fa5])([A-Za-z0-9?.,;[\]]+)/g, "$1 $2")
.replace(/([A-Za-z0-9?.,;[\]]+)([\u4e00-\u9fa5])/g, "$1 $2");
}
const tempDivContainer = document.createElement("div");
tempDivContainer.innerHTML = content;
for (let i = 0; i < tempDivContainer.children.length; i++) {
const c = tempDivContainer.children[i];
if (c.tagName === "P" && c.textContent === "" && c.firstElementChild?.tagName !== "BR") {
c.remove();
i--;
continue;
}
}
return tempDivContainer.innerHTML;
return tempElement.innerHTML
.replace(IMAGE_URL_REG, "")
.replace(MEMO_LINK_REG, "<span class='memo-link-text' data-value='$2'>$1</span>")
.replace(LINK_URL_REG, "<a class='link' target='_blank' rel='noreferrer' href='$2'>$1</a>")
.replace(TAG_REG, "<span class='tag-span'>#$1</span> ");
}
export default memo(Memo);
+73 -35
View File
@@ -1,32 +1,31 @@
import { useState, useEffect, useCallback } from "react";
import { IMAGE_URL_REG, MEMO_LINK_REG } from "../helpers/consts";
import utils from "../helpers/utils";
import { globalStateService, memoService } from "../services";
import { editorStateService, memoService, userService } from "../services";
import { IMAGE_URL_REG, MEMO_LINK_REG, UNKNOWN_ID } from "../helpers/consts";
import * as utils from "../helpers/utils";
import { parseHtmlToRawText } from "../helpers/marked";
import { formatMemoContent } from "./Memo";
import Only from "./common/OnlyWhen";
import toastHelper from "./Toast";
import { showDialog } from "./Dialog";
import Only from "./common/OnlyWhen";
import Image from "./Image";
import { formatMemoContent } from "./Memo";
import "../less/memo-card-dialog.less";
import "../less/memo-content.less";
interface LinkedMemo extends FormattedMemo {
interface LinkedMemo extends Memo {
createdAtStr: string;
dateStr: string;
}
interface Props extends DialogProps {
memo: Model.Memo;
memo: Memo;
}
const MemoCardDialog: React.FC<Props> = (props: Props) => {
const [memo, setMemo] = useState<FormattedMemo>({
const [memo, setMemo] = useState<Memo>({
...props.memo,
createdAtStr: utils.getDateTimeString(props.memo.createdAt),
});
const [linkMemos, setLinkMemos] = useState<LinkedMemo[]>([]);
const [linkedMemos, setLinkedMemos] = useState<LinkedMemo[]>([]);
const imageUrls = Array.from(memo.content.match(IMAGE_URL_REG) ?? []);
const imageUrls = Array.from(memo.content.match(IMAGE_URL_REG) ?? []).map((s) => s.replace(IMAGE_URL_REG, "$1"));
useEffect(() => {
const fetchLinkedMemos = async () => {
@@ -35,13 +34,17 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
const matchedArr = [...memo.content.matchAll(MEMO_LINK_REG)];
for (const matchRes of matchedArr) {
if (matchRes && matchRes.length === 3) {
const id = matchRes[2];
const id = Number(matchRes[2]);
if (id === memo.id) {
continue;
}
const memoTemp = memoService.getMemoById(id);
if (memoTemp) {
linkMemos.push({
...memoTemp,
createdAtStr: utils.getDateTimeString(memoTemp.createdAt),
dateStr: utils.getDateString(memoTemp.createdAt),
createdAtStr: utils.getDateTimeString(memoTemp.createdTs),
dateStr: utils.getDateString(memoTemp.createdTs),
});
}
}
@@ -51,11 +54,12 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
const linkedMemos = await memoService.getLinkedMemos(memo.id);
setLinkedMemos(
linkedMemos
.sort((a, b) => utils.getTimeStampByDate(b.createdAt) - utils.getTimeStampByDate(a.createdAt))
.filter((m) => m.rowStatus === "NORMAL" && m.id !== memo.id)
.sort((a, b) => utils.getTimeStampByDate(b.createdTs) - utils.getTimeStampByDate(a.createdTs))
.map((m) => ({
...m,
createdAtStr: utils.getDateTimeString(m.createdAt),
dateStr: utils.getDateString(m.createdAt),
createdAtStr: utils.getDateTimeString(m.createdTs),
dateStr: utils.getDateString(m.createdTs),
}))
);
} catch (error) {
@@ -71,12 +75,12 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
if (targetEl.className === "memo-link-text") {
const nextMemoId = targetEl.dataset?.value;
const memoTemp = memoService.getMemoById(nextMemoId ?? "");
const memoTemp = memoService.getMemoById(Number(nextMemoId) ?? UNKNOWN_ID);
if (memoTemp) {
const nextMemo = {
...memoTemp,
createdAtStr: utils.getDateTimeString(memoTemp.createdAt),
createdAtStr: utils.getDateTimeString(memoTemp.createdTs),
};
setLinkMemos([]);
setLinkedMemos([]);
@@ -88,7 +92,7 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
}
}, []);
const handleLinkedMemoClick = useCallback((memo: FormattedMemo) => {
const handleLinkedMemoClick = useCallback((memo: Memo) => {
setLinkMemos([]);
setLinkedMemos([]);
setMemo(memo);
@@ -96,20 +100,54 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
const handleEditMemoBtnClick = useCallback(() => {
props.destroy();
globalStateService.setEditMemoId(memo.id);
editorStateService.setEditMemoWithId(memo.id);
}, [memo.id]);
const handlePinClick = async () => {
if (memo.pinned) {
await memoService.unpinMemo(memo.id);
} else {
await memoService.pinMemo(memo.id);
}
setMemo({
...memo,
pinned: !memo.pinned,
});
};
const handleVisibilityClick = async () => {
const visibility = memo.visibility === "PRIVATE" ? "PUBLIC" : "PRIVATE";
await memoService.patchMemo({
id: memo.id,
visibility: visibility,
});
setMemo({
...memo,
visibility: visibility,
});
};
return (
<>
<div className="memo-card-container">
<div className="header-container">
<p className="time-text">{memo.createdAtStr}</p>
<p className="time-text">{utils.getDateTimeString(memo.createdTs)}</p>
<div className="btns-container">
<button className="btn edit-btn" onClick={handleEditMemoBtnClick}>
<img className="icon-img" src="/icons/edit.svg" />
</button>
<Only when={!userService.isVisitorMode()}>
<>
<button className="btn" onClick={handlePinClick}>
<i className={`fa-solid fa-thumbtack icon-img ${memo.pinned ? "" : "opacity-20"}`}></i>
</button>
<button className="btn" onClick={handleVisibilityClick}>
<i className={`fa-solid fa-eye icon-img ${memo.visibility === "PUBLIC" ? "" : "opacity-20"}`}></i>
</button>
<button className="btn edit-btn" onClick={handleEditMemoBtnClick}>
<i className="fa-solid fa-pen-to-square icon-img"></i>
</button>
</>
</Only>
<button className="btn close-btn" onClick={props.destroy}>
<img className="icon-img" src="/icons/close.svg" />
<i className="fa-solid fa-xmark fa-lg icon-img"></i>
</button>
</div>
</div>
@@ -150,11 +188,11 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
{linkMemos.length > 0 ? (
<div className="linked-memos-wrapper">
<p className="normal-text">{linkMemos.length} related MEMO</p>
{linkMemos.map((m) => {
const rawtext = parseHtmlToRawText(formatMemoContent(m.content)).replaceAll("\n", " ");
{linkMemos.map((memo, index) => {
const rawtext = parseHtmlToRawText(formatMemoContent(memo.content)).replaceAll("\n", " ");
return (
<div className="linked-memo-container" key={m.id} onClick={() => handleLinkedMemoClick(m)}>
<span className="time-text">{m.dateStr} </span>
<div className="linked-memo-container" key={`${index}-${memo.id}`} onClick={() => handleLinkedMemoClick(memo)}>
<span className="time-text">{memo.dateStr} </span>
{rawtext}
</div>
);
@@ -164,11 +202,11 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
{linkedMemos.length > 0 ? (
<div className="linked-memos-wrapper">
<p className="normal-text">{linkedMemos.length} linked MEMO</p>
{linkedMemos.map((m) => {
const rawtext = parseHtmlToRawText(formatMemoContent(m.content)).replaceAll("\n", " ");
{linkedMemos.map((memo, index) => {
const rawtext = parseHtmlToRawText(formatMemoContent(memo.content)).replaceAll("\n", " ");
return (
<div className="linked-memo-container" key={m.id} onClick={() => handleLinkedMemoClick(m)}>
<span className="time-text">{m.dateStr} </span>
<div className="linked-memo-container" key={`${index}-${memo.id}`} onClick={() => handleLinkedMemoClick(memo)}>
<span className="time-text">{memo.dateStr} </span>
{rawtext}
</div>
);
@@ -179,7 +217,7 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
);
};
export default function showMemoCardDialog(memo: Model.Memo): void {
export default function showMemoCardDialog(memo: Memo): void {
showDialog(
{
className: "memo-card-dialog",
+110 -159
View File
@@ -1,88 +1,61 @@
import React, { useCallback, useContext, useEffect, useMemo, useRef } from "react";
import appContext from "../stores/appContext";
import { globalStateService, locationService, memoService, resourceService } from "../services";
import utils from "../helpers/utils";
import { storage } from "../helpers/storage";
import useToggle from "../hooks/useToggle";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { UNKNOWN_ID } from "../helpers/consts";
import { editorStateService, locationService, memoService, resourceService } from "../services";
import { useAppSelector } from "../store";
import * as storage from "../helpers/storage";
import toastHelper from "./Toast";
import Editor, { EditorRefActions } from "./Editor/Editor";
import "../less/memo-editor.less";
const getCursorPostion = (input: HTMLTextAreaElement) => {
const { offsetLeft: inputX, offsetTop: inputY, selectionEnd: selectionPoint } = input;
const div = document.createElement("div");
const copyStyle = window.getComputedStyle(input);
for (const item of copyStyle) {
div.style.setProperty(item, copyStyle.getPropertyValue(item));
}
div.style.position = "fixed";
div.style.visibility = "hidden";
div.style.whiteSpace = "pre-wrap";
const swap = ".";
const inputValue = input.tagName === "INPUT" ? input.value.replace(/ /g, swap) : input.value;
const textContent = inputValue.substring(0, selectionPoint || 0);
div.textContent = textContent;
if (input.tagName === "TEXTAREA") {
div.style.height = "auto";
}
const span = document.createElement("span");
span.textContent = inputValue.substring(selectionPoint || 0) || ".";
div.appendChild(span);
document.body.appendChild(div);
const { offsetLeft: spanX, offsetTop: spanY } = span;
document.body.removeChild(div);
return {
x: inputX + spanX,
y: inputY + spanY,
};
};
interface Props {}
interface State {
isUploadingResource: boolean;
fullscreen: boolean;
}
const MemoEditor: React.FC<Props> = () => {
const {
globalState,
memoState: { tags },
} = useContext(appContext);
const [isTagSeletorShown, toggleTagSeletor] = useToggle(false);
const editorState = useAppSelector((state) => state.editor);
const tags = useAppSelector((state) => state.memo.tags);
const [state, setState] = useState<State>({
isUploadingResource: false,
fullscreen: false,
});
const editorRef = useRef<EditorRefActions>(null);
const prevGlobalStateRef = useRef(globalState);
const prevGlobalStateRef = useRef(editorState);
const tagSeletorRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (globalState.markMemoId) {
if (editorState.markMemoId && editorState.markMemoId !== UNKNOWN_ID) {
const editorCurrentValue = editorRef.current?.getContent();
const memoLinkText = `${editorCurrentValue ? "\n" : ""}Mark: [@MEMO](${globalState.markMemoId})`;
const memoLinkText = `${editorCurrentValue ? "\n" : ""}Mark: @[MEMO](${editorState.markMemoId})`;
editorRef.current?.insertText(memoLinkText);
globalStateService.setMarkMemoId("");
editorStateService.clearMarkMemo();
}
if (globalState.editMemoId && globalState.editMemoId !== prevGlobalStateRef.current.editMemoId) {
const editMemo = memoService.getMemoById(globalState.editMemoId);
if (
editorState.editMemoId &&
editorState.editMemoId !== UNKNOWN_ID &&
editorState.editMemoId !== prevGlobalStateRef.current.editMemoId
) {
const editMemo = memoService.getMemoById(editorState.editMemoId ?? UNKNOWN_ID);
if (editMemo) {
editorRef.current?.setContent(editMemo.content ?? "");
editorRef.current?.focus();
}
}
prevGlobalStateRef.current = globalState;
}, [globalState.markMemoId, globalState.editMemoId]);
prevGlobalStateRef.current = editorState;
}, [editorState.markMemoId, editorState.editMemoId]);
useEffect(() => {
if (!editorRef.current) {
return;
}
const handlePasteEvent = async (event: ClipboardEvent) => {
if (event.clipboardData && event.clipboardData.files.length > 0) {
event.preventDefault();
const file = event.clipboardData.files[0];
const url = await handleUploadFile(file);
if (url) {
editorRef.current?.insertText(url);
editorRef.current?.insertText(`![](${url})`);
}
}
};
@@ -93,7 +66,7 @@ const MemoEditor: React.FC<Props> = () => {
const file = event.dataTransfer.files[0];
const url = await handleUploadFile(file);
if (url) {
editorRef.current?.insertText(url);
editorRef.current?.insertText(`![](${url})`);
}
}
};
@@ -108,10 +81,10 @@ const MemoEditor: React.FC<Props> = () => {
});
};
editorRef.current.element.addEventListener("paste", handlePasteEvent);
editorRef.current.element.addEventListener("drop", handleDropEvent);
editorRef.current.element.addEventListener("click", handleClickEvent);
editorRef.current.element.addEventListener("keydown", handleKeyDownEvent);
editorRef.current?.element.addEventListener("paste", handlePasteEvent);
editorRef.current?.element.addEventListener("drop", handleDropEvent);
editorRef.current?.element.addEventListener("click", handleClickEvent);
editorRef.current?.element.addEventListener("keydown", handleKeyDownEvent);
return () => {
editorRef.current?.element.removeEventListener("paste", handlePasteEvent);
@@ -121,116 +94,82 @@ const MemoEditor: React.FC<Props> = () => {
};
}, []);
const handleUploadFile = useCallback(async (file: File) => {
const { type } = file;
const handleUploadFile = useCallback(
async (file: File) => {
if (state.isUploadingResource) {
return;
}
if (!type.startsWith("image")) {
return;
}
setState({
...state,
isUploadingResource: true,
});
const { type } = file;
try {
const image = await resourceService.upload(file);
const url = `/h/r/${image.id}/${image.filename}`;
if (!type.startsWith("image")) {
return;
}
return url;
} catch (error: any) {
toastHelper.error(error);
}
}, []);
try {
const image = await resourceService.upload(file);
const url = `/h/r/${image.id}/${image.filename}`;
const handleSaveBtnClick = useCallback(async (content: string) => {
return url;
} catch (error: any) {
toastHelper.error(error);
} finally {
setState({
...state,
isUploadingResource: false,
});
}
},
[state]
);
const handleSaveBtnClick = async (content: string) => {
if (content === "") {
toastHelper.error("Content can't be empty");
return;
}
const { editMemoId } = globalStateService.getState();
try {
if (editMemoId) {
const prevMemo = memoService.getMemoById(editMemoId);
const { editMemoId } = editorStateService.getState();
if (editMemoId && editMemoId !== UNKNOWN_ID) {
const prevMemo = memoService.getMemoById(editMemoId ?? UNKNOWN_ID);
if (prevMemo && prevMemo.content !== content) {
const editedMemo = await memoService.updateMemo(prevMemo.id, content);
editedMemo.updatedAt = utils.getDateTimeString(Date.now());
memoService.editMemo(editedMemo);
await memoService.patchMemo({
id: prevMemo.id,
content,
});
}
globalStateService.setEditMemoId("");
editorStateService.clearEditMemo();
} else {
const newMemo = await memoService.createMemo(content);
memoService.pushMemo(newMemo);
await memoService.createMemo({
content,
});
locationService.clearQuery();
}
} catch (error: any) {
toastHelper.error(error.message);
}
setState({
...state,
fullscreen: false,
});
setEditorContentCache("");
}, []);
};
const handleCancelBtnClick = useCallback(() => {
globalStateService.setEditMemoId("");
editorStateService.clearEditMemo();
editorRef.current?.setContent("");
setEditorContentCache("");
}, []);
const handleContentChange = useCallback((content: string) => {
setEditorContentCache(content);
if (editorRef.current) {
const selectionStart = editorRef.current.element.selectionStart;
const prevString = content.slice(0, selectionStart);
const nextString = content.slice(selectionStart);
if (prevString.endsWith("#") && (nextString.startsWith(" ") || nextString === "")) {
toggleTagSeletor(true);
updateTagSelectorPopupPosition();
} else {
toggleTagSeletor(false);
}
editorRef.current?.focus();
}
}, []);
const handleTagTextBtnClick = useCallback(() => {
if (!editorRef.current) {
return;
}
const currentValue = editorRef.current.getContent();
const selectionStart = editorRef.current.element.selectionStart;
const prevString = currentValue.slice(0, selectionStart);
const nextString = currentValue.slice(selectionStart);
let nextValue = prevString + "# " + nextString;
let cursorIndex = prevString.length + 1;
if (prevString.endsWith("#") && nextString.startsWith(" ")) {
nextValue = prevString.slice(0, prevString.length - 1) + nextString.slice(1);
cursorIndex = prevString.length - 1;
}
editorRef.current.element.value = nextValue;
editorRef.current.element.setSelectionRange(cursorIndex, cursorIndex);
editorRef.current.focus();
handleContentChange(editorRef.current.element.value);
}, []);
const updateTagSelectorPopupPosition = useCallback(() => {
if (!editorRef.current || !tagSeletorRef.current) {
return;
}
const seletorPopupWidth = 128;
const editorWidth = editorRef.current.element.clientWidth;
const { x, y } = getCursorPostion(editorRef.current.element);
const left = x + seletorPopupWidth + 16 > editorWidth ? editorWidth + 20 - seletorPopupWidth : x + 2;
const top = y + 32 + 6;
tagSeletorRef.current.scroll(0, 0);
tagSeletorRef.current.style.left = `${left}px`;
tagSeletorRef.current.style.top = `${top}px`;
}, []);
const handleUploadFileBtnClick = useCallback(() => {
@@ -246,57 +185,69 @@ const MemoEditor: React.FC<Props> = () => {
const file = inputEl.files[0];
const url = await handleUploadFile(file);
if (url) {
editorRef.current?.insertText(url + " ");
editorRef.current?.insertText(`![](${url})`);
}
};
inputEl.click();
}, []);
const handleFullscreenBtnClick = () => {
setState({
...state,
fullscreen: !state.fullscreen,
});
};
const handleTagSeletorClick = useCallback((event: React.MouseEvent) => {
if (tagSeletorRef.current !== event.target && tagSeletorRef.current?.contains(event.target as Node)) {
editorRef.current?.insertText((event.target as HTMLElement).textContent + " " ?? "");
toggleTagSeletor(false);
editorRef.current?.insertText(`#${(event.target as HTMLElement).textContent} ` ?? "");
editorRef.current?.focus();
}
}, []);
const isEditing = Boolean(globalState.editMemoId);
const isEditing = Boolean(editorState.editMemoId && editorState.editMemoId !== UNKNOWN_ID);
const editorConfig = useMemo(
() => ({
className: "memo-editor",
initialContent: getEditorContentCache(),
placeholder: "Any thoughts...",
fullscreen: state.fullscreen,
showConfirmBtn: true,
showCancelBtn: isEditing,
onConfirmBtnClick: handleSaveBtnClick,
onCancelBtnClick: handleCancelBtnClick,
onContentChange: handleContentChange,
}),
[isEditing]
[isEditing, state.fullscreen]
);
return (
<div className={"memo-editor-container " + (isEditing ? "edit-ing" : "")}>
<div className={`memo-editor-container ${isEditing ? "edit-ing" : ""} ${state.fullscreen ? "fullscreen" : ""}`}>
<p className={"tip-text " + (isEditing ? "" : "hidden")}>Editting...</p>
<Editor
ref={editorRef}
{...editorConfig}
tools={
<>
<img className="action-btn file-upload" src="/icons/tag.svg" onClick={handleTagTextBtnClick} />
<img className="action-btn file-upload" src="/icons/image.svg" onClick={handleUploadFileBtnClick} />
<div className="action-btn tag-action">
<i className="fa-solid fa-hashtag icon-img"></i>
<div ref={tagSeletorRef} className="tag-list" onClick={handleTagSeletorClick}>
{tags.map((t) => {
return <span key={t}>{t}</span>;
})}
</div>
</div>
<button className="action-btn">
<i className="fa-solid fa-image icon-img" onClick={handleUploadFileBtnClick}></i>
<span className={`tip-text ${state.isUploadingResource ? "!block" : ""}`}>Uploading</span>
</button>
<button className="action-btn" onClick={handleFullscreenBtnClick}>
<i className={`fa-solid fa-${state.fullscreen ? "compress" : "expand"} icon-img`}></i>
</button>
</>
}
/>
<div
ref={tagSeletorRef}
className={`tag-list ${isTagSeletorShown && tags.length > 0 ? "" : "hidden"}`}
onClick={handleTagSeletorClick}
>
{tags.map((t) => {
return <span key={t}>{t}</span>;
})}
</div>
</div>
);
};
+13 -16
View File
@@ -1,36 +1,33 @@
import { useContext } from "react";
import appContext from "../stores/appContext";
import { useAppSelector } from "../store";
import { locationService, shortcutService } from "../services";
import utils from "../helpers/utils";
import * as utils from "../helpers/utils";
import { getTextWithMemoType } from "../helpers/filter";
import "../less/memo-filter.less";
interface FilterProps {}
const MemoFilter: React.FC<FilterProps> = () => {
const {
locationState: { query },
} = useContext(appContext);
const query = useAppSelector((state) => state.location.query);
useAppSelector((state) => state.shortcut.shortcuts);
const { tag: tagQuery, duration, type: memoType, text: textQuery, shortcutId } = query;
const queryFilter = shortcutService.getShortcutById(shortcutId);
const showFilter = Boolean(tagQuery || (duration && duration.from < duration.to) || memoType || textQuery || queryFilter);
const shortcut = shortcutId ? shortcutService.getShortcutById(shortcutId) : null;
const showFilter = Boolean(tagQuery || (duration && duration.from < duration.to) || memoType || textQuery || shortcut);
return (
<div className={`filter-query-container ${showFilter ? "" : "hidden"}`}>
<div className={`filter-query-container ${showFilter ? "" : "!hidden"}`}>
<span className="tip-text">Filter:</span>
<div
className={"filter-item-container " + (queryFilter ? "" : "hidden")}
className={"filter-item-container " + (shortcut ? "" : "hidden")}
onClick={() => {
locationService.setMemoShortcut("");
locationService.setMemoShortcut(undefined);
}}
>
<span className="icon-text">🎯</span> {queryFilter?.title}
<span className="icon-text">🎯</span> {shortcut?.title}
</div>
<div
className={"filter-item-container " + (tagQuery ? "" : "hidden")}
onClick={() => {
locationService.setTagQuery("");
locationService.setTagQuery(undefined);
}}
>
<span className="icon-text">🏷</span> {tagQuery}
@@ -38,7 +35,7 @@ const MemoFilter: React.FC<FilterProps> = () => {
<div
className={"filter-item-container " + (memoType ? "" : "hidden")}
onClick={() => {
locationService.setMemoTypeQuery("");
locationService.setMemoTypeQuery(undefined);
}}
>
<span className="icon-text">📦</span> {getTextWithMemoType(memoType as MemoSpecType)}
@@ -47,7 +44,7 @@ const MemoFilter: React.FC<FilterProps> = () => {
<div
className="filter-item-container"
onClick={() => {
locationService.setFromAndToQuery(0, 0);
locationService.setFromAndToQuery();
}}
>
<span className="icon-text">🗓</span> {utils.getDateString(duration.from)} to {utils.getDateString(duration.to)}
+22 -43
View File
@@ -1,34 +1,32 @@
import { useCallback, useContext, useEffect, useRef, useState } from "react";
import appContext from "../stores/appContext";
import { locationService, memoService, shortcutService } from "../services";
import { IMAGE_URL_REG, LINK_REG, MEMO_LINK_REG, TAG_REG } from "../helpers/consts";
import utils from "../helpers/utils";
import { useEffect, useRef, useState } from "react";
import { memoService, shortcutService } from "../services";
import { useAppSelector } from "../store";
import { IMAGE_URL_REG, LINK_URL_REG, MEMO_LINK_REG, TAG_REG } from "../helpers/consts";
import * as utils from "../helpers/utils";
import { checkShouldShowMemoWithFilters } from "../helpers/filter";
import Memo from "./Memo";
import toastHelper from "./Toast";
import Memo from "./Memo";
import "../less/memo-list.less";
interface Props {}
const MemoList: React.FC<Props> = () => {
const {
locationState: { query },
memoState: { memos },
} = useContext(appContext);
const query = useAppSelector((state) => state.location.query);
const memos = useAppSelector((state) => state.memo.memos);
const [isFetching, setFetchStatus] = useState(true);
const wrapperElement = useRef<HTMLDivElement>(null);
const { tag: tagQuery, duration, type: memoType, text: textQuery, shortcutId } = query;
const queryFilter = shortcutService.getShortcutById(shortcutId);
const showMemoFilter = Boolean(tagQuery || (duration && duration.from < duration.to) || memoType || textQuery || queryFilter);
const { tag: tagQuery, duration, type: memoType, text: textQuery, shortcutId } = query ?? {};
const shortcut = shortcutId ? shortcutService.getShortcutById(shortcutId) : null;
const showMemoFilter = Boolean(tagQuery || (duration && duration.from < duration.to) || memoType || textQuery || shortcut);
const shownMemos =
showMemoFilter || queryFilter
showMemoFilter || shortcut
? memos.filter((memo) => {
let shouldShow = true;
if (queryFilter) {
const filters = JSON.parse(queryFilter.payload) as Filter[];
if (shortcut) {
const filters = JSON.parse(shortcut.payload) as Filter[];
if (Array.isArray(filters)) {
shouldShow = checkShouldShowMemoWithFilters(memo, filters);
}
@@ -53,14 +51,14 @@ const MemoList: React.FC<Props> = () => {
if (
duration &&
duration.from < duration.to &&
(utils.getTimeStampByDate(memo.createdAt) < duration.from || utils.getTimeStampByDate(memo.createdAt) > duration.to)
(utils.getTimeStampByDate(memo.createdTs) < duration.from || utils.getTimeStampByDate(memo.createdTs) > duration.to)
) {
shouldShow = false;
}
if (memoType) {
if (memoType === "NOT_TAGGED" && memo.content.match(TAG_REG) !== null) {
shouldShow = false;
} else if (memoType === "LINKED" && memo.content.match(LINK_REG) === null) {
} else if (memoType === "LINKED" && memo.content.match(LINK_URL_REG) === null) {
shouldShow = false;
} else if (memoType === "IMAGED" && memo.content.match(IMAGE_URL_REG) === null) {
shouldShow = false;
@@ -76,9 +74,9 @@ const MemoList: React.FC<Props> = () => {
})
: memos;
const pinnedMemos = shownMemos.filter((m) => m.rowStatus === "ARCHIVED");
const unpinnedMemos = shownMemos.filter((m) => m.rowStatus === "NORMAL");
const sortedMemos = pinnedMemos.concat(unpinnedMemos);
const pinnedMemos = shownMemos.filter((m) => m.pinned);
const unpinnedMemos = shownMemos.filter((m) => !m.pinned);
const sortedMemos = pinnedMemos.concat(unpinnedMemos).filter((m) => m.rowStatus === "NORMAL");
useEffect(() => {
memoService
@@ -96,33 +94,14 @@ const MemoList: React.FC<Props> = () => {
wrapperElement.current?.scrollTo({ top: 0 });
}, [query]);
const handleMemoListClick = useCallback((event: React.MouseEvent) => {
const targetEl = event.target as HTMLElement;
if (targetEl.tagName === "SPAN" && targetEl.className === "tag-span") {
const tagName = targetEl.innerText.slice(1);
const currTagQuery = locationService.getState().query.tag;
if (currTagQuery === tagName) {
locationService.setTagQuery("");
} else {
locationService.setTagQuery(tagName);
}
}
}, []);
return (
<div className={`memo-list-container ${isFetching ? "" : "completed"}`} onClick={handleMemoListClick} ref={wrapperElement}>
<div className={`memo-list-container ${isFetching ? "" : "completed"}`} ref={wrapperElement}>
{sortedMemos.map((memo) => (
<Memo key={`${memo.id}-${memo.updatedAt}`} memo={memo} />
<Memo key={`${memo.id}-${memo.updatedTs}`} memo={memo} />
))}
<div className="status-text-container">
<p className="status-text">
{isFetching
? "Fetching data..."
: sortedMemos.length === 0
? "Oops, there is nothing"
: showMemoFilter
? ""
: "Fetching completed 🎉"}
{isFetching ? "Fetching data..." : sortedMemos.length === 0 ? "No memos 🌃" : showMemoFilter ? "" : "All memos are ready 🎉"}
</p>
</div>
</div>
-79
View File
@@ -1,79 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import useLoading from "../hooks/useLoading";
import { locationService, memoService } from "../services";
import { showDialog } from "./Dialog";
import toastHelper from "./Toast";
import DeletedMemo from "./DeletedMemo";
import "../less/memo-trash-dialog.less";
interface Props extends DialogProps {}
const MemoTrashDialog: React.FC<Props> = (props: Props) => {
const { destroy } = props;
const loadingState = useLoading();
const [deletedMemos, setDeletedMemos] = useState<Model.Memo[]>([]);
useEffect(() => {
memoService.fetchAllMemos();
memoService
.fetchDeletedMemos()
.then((result) => {
if (result !== false) {
setDeletedMemos(result);
}
})
.catch((error) => {
toastHelper.error("Failed to fetch deleted memos: ", error);
})
.finally(() => {
loadingState.setFinish();
});
locationService.clearQuery();
}, []);
const handleDeletedMemoAction = useCallback((memoId: string) => {
setDeletedMemos((deletedMemos) => deletedMemos.filter((memo) => memo.id !== memoId));
}, []);
return (
<>
<div className="dialog-header-container">
<p className="title-text">
<span className="icon-text">🗑</span>
Trash Bin
</p>
<button className="btn close-btn" onClick={destroy}>
<img className="icon-img" src="/icons/close.svg" />
</button>
</div>
<div className="dialog-content-container">
{loadingState.isLoading ? (
<div className="tip-text-container">
<p className="tip-text">fetching data...</p>
</div>
) : deletedMemos.length === 0 ? (
<div className="tip-text-container">
<p className="tip-text">Here is No Zettels.</p>
</div>
) : (
<div className="deleted-memos-container">
{deletedMemos.map((memo) => (
<DeletedMemo key={`${memo.id}-${memo.updatedAt}`} memo={memo} handleDeletedMemoAction={handleDeletedMemoAction} />
))}
</div>
)}
</div>
</>
);
};
export default function showMemoTrashDialog(): void {
showDialog(
{
className: "memo-trash-dialog",
useAppContext: true,
},
MemoTrashDialog,
{}
);
}
+22 -18
View File
@@ -1,7 +1,8 @@
import { useCallback, useContext, useEffect, useState } from "react";
import appContext from "../stores/appContext";
import SearchBar from "./SearchBar";
import { useCallback, useEffect, useState } from "react";
import { memoService, shortcutService } from "../services";
import { useAppSelector } from "../store";
import SearchBar from "./SearchBar";
import { toggleSiderbar } from "./Sidebar";
import "../less/memos-header.less";
let prevRequestTimestamp = Date.now();
@@ -9,25 +10,23 @@ let prevRequestTimestamp = Date.now();
interface Props {}
const MemosHeader: React.FC<Props> = () => {
const {
locationState: {
query: { shortcutId },
},
shortcutState: { shortcuts },
} = useContext(appContext);
const query = useAppSelector((state) => state.location.query);
const shortcuts = useAppSelector((state) => state.shortcut.shortcuts);
const [titleText, setTitleText] = useState("MEMOS");
useEffect(() => {
const query = shortcutService.getShortcutById(shortcutId);
if (query) {
setTitleText(query.title);
} else {
if (!query?.shortcutId) {
setTitleText("MEMOS");
return;
}
}, [shortcutId, shortcuts]);
const handleMemoTextClick = useCallback(() => {
const shortcut = shortcutService.getShortcutById(query?.shortcutId);
if (shortcut) {
setTitleText(shortcut.title);
}
}, [query, shortcuts]);
const handleTitleTextClick = useCallback(() => {
const now = Date.now();
if (now - prevRequestTimestamp > 10 * 1000) {
prevRequestTimestamp = now;
@@ -39,8 +38,13 @@ const MemosHeader: React.FC<Props> = () => {
return (
<div className="section-header-container memos-header-container">
<div className="title-text" onClick={handleMemoTextClick}>
<span className="normal-text">{titleText}</span>
<div className="title-container">
<div className="action-btn" onClick={toggleSiderbar}>
<i className="fa-solid fa-bars icon-img"></i>
</div>
<span className="title-text" onClick={handleTitleTextClick}>
{titleText}
</span>
</div>
<SearchBar />
</div>
+27 -3
View File
@@ -1,5 +1,8 @@
import { useEffect, useRef } from "react";
import * as api from "../helpers/api";
import { locationService, userService } from "../services";
import toastHelper from "./Toast";
import Only from "./common/OnlyWhen";
import showAboutSiteDialog from "./AboutSiteDialog";
import "../less/menu-btns-popup.less";
@@ -27,12 +30,28 @@ const MenuBtnsPopup: React.FC<Props> = (props: Props) => {
}
}, [shownStatus]);
const handlePingBtnClick = () => {
api
.getSystemStatus()
.then(({ data }) => {
const {
data: { profile },
} = data;
toastHelper.info(JSON.stringify(profile, null, 4));
})
.catch((error) => {
toastHelper.error("Failed to ping\n" + JSON.stringify(error, null, 4));
});
};
const handleAboutBtnClick = () => {
showAboutSiteDialog();
};
const handleSignOutBtnClick = async () => {
await userService.doSignOut();
userService.doSignOut().catch(() => {
// do nth
});
locationService.replaceHistory("/signin");
window.location.reload();
};
@@ -42,9 +61,14 @@ const MenuBtnsPopup: React.FC<Props> = (props: Props) => {
<button className="btn action-btn" onClick={handleAboutBtnClick}>
<span className="icon">🤠</span> About
</button>
<button className="btn action-btn" onClick={handleSignOutBtnClick}>
<span className="icon">👋</span> Sign out
<button className="btn action-btn" onClick={handlePingBtnClick}>
<span className="icon">🎯</span> Ping
</button>
<Only when={!userService.isVisitorMode()}>
<button className="btn action-btn" onClick={handleSignOutBtnClick}>
<span className="icon">👋</span> Sign out
</button>
</Only>
</div>
);
};
+15 -42
View File
@@ -1,6 +1,5 @@
import { useEffect, useRef, useState } from "react";
import utils from "../helpers/utils";
import { showDialog } from "./Dialog";
import * as utils from "../helpers/utils";
import "../less/preview-image-dialog.less";
interface Props extends DialogProps {
@@ -8,55 +7,29 @@ interface Props extends DialogProps {
}
const PreviewImageDialog: React.FC<Props> = ({ destroy, imgUrl }: Props) => {
const imgRef = useRef<HTMLImageElement>(null);
const [imgWidth, setImgWidth] = useState<number>(-1);
useEffect(() => {
utils.getImageSize(imgUrl).then(({ width }) => {
if (width !== 0) {
setImgWidth(80);
} else {
setImgWidth(0);
}
});
}, []);
const handleCloseBtnClick = () => {
destroy();
};
const handleDecreaseImageSize = () => {
if (imgWidth > 30) {
setImgWidth(imgWidth - 10);
}
};
const handleIncreaseImageSize = () => {
setImgWidth(imgWidth + 10);
const handleDownloadBtnClick = () => {
const a = document.createElement("a");
a.href = imgUrl;
a.download = `memos-${utils.getDateTimeString(Date.now())}.png`;
a.click();
};
return (
<>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<img className="icon-img" src="/icons/close.svg" />
</button>
<div className="img-container">
<img className={imgWidth <= 0 ? "hidden" : ""} ref={imgRef} width={imgWidth + "%"} src={imgUrl} />
<span className={"loading-text " + (imgWidth === -1 ? "" : "hidden")}>Loading image...</span>
<span className={"loading-text " + (imgWidth === 0 ? "" : "hidden")}>😟 Failed to load image</span>
<div className="btns-container">
<button className="btn" onClick={handleCloseBtnClick}>
<i className="fa-solid fa-xmark fa-lg icon-img"></i>
</button>
<button className="btn" onClick={handleDownloadBtnClick}>
<i className="fa-solid fa-download icon-img"></i>
</button>
</div>
<div className="action-btns-container">
<button className="btn" onClick={handleDecreaseImageSize}>
</button>
<button className="btn" onClick={handleIncreaseImageSize}>
</button>
<button className="btn" onClick={() => setImgWidth(80)}>
</button>
<div className="img-container">
<img src={imgUrl} />
</div>
</>
);
+6 -11
View File
@@ -1,22 +1,17 @@
import { useContext } from "react";
import appContext from "../stores/appContext";
import { locationService } from "../services";
import { useAppSelector } from "../store";
import { memoSpecialTypes } from "../helpers/filter";
import "../less/search-bar.less";
interface Props {}
const SearchBar: React.FC<Props> = () => {
const {
locationState: {
query: { type: memoType },
},
} = useContext(appContext);
const memoType = useAppSelector((state) => state.location.query?.type);
const handleMemoTypeItemClick = (type: MemoSpecType | "") => {
const { type: prevType } = locationService.getState().query;
const handleMemoTypeItemClick = (type: MemoSpecType | undefined) => {
const { type: prevType } = locationService.getState().query ?? {};
if (type === prevType) {
type = "";
type = undefined;
}
locationService.setMemoTypeQuery(type);
};
@@ -29,7 +24,7 @@ const SearchBar: React.FC<Props> = () => {
return (
<div className="search-bar-container">
<div className="search-bar-inputer">
<img className="icon-img" src="/icons/search.svg" />
<i className="fa-solid fa-magnifying-glass fa-sm icon-img"></i>
<input className="text-input" type="text" placeholder="" onChange={handleTextQueryInput} />
</div>
<div className="quickly-action-wrapper">
+27 -25
View File
@@ -1,5 +1,5 @@
import { useContext, useState } from "react";
import appContext from "../stores/appContext";
import { useState } from "react";
import { useAppSelector } from "../store";
import { showDialog } from "./Dialog";
import MyAccountSection from "./Settings/MyAccountSection";
import PreferencesSection from "./Settings/PreferencesSection";
@@ -15,10 +15,8 @@ interface State {
}
const SettingDialog: React.FC<Props> = (props: Props) => {
const {
userState: { user },
} = useContext(appContext);
const { destroy } = props;
const user = useAppSelector((state) => state.user.user);
const [state, setState] = useState<State>({
selectedSection: "my-account",
});
@@ -32,31 +30,35 @@ const SettingDialog: React.FC<Props> = (props: Props) => {
return (
<div className="dialog-content-container">
<button className="btn close-btn" onClick={destroy}>
<img className="icon-img" src="/icons/close.svg" />
<i className="fa-solid fa-xmark fa-lg icon-img"></i>
</button>
<div className="section-selector-container">
<span className="section-title">Basic</span>
<span
onClick={() => handleSectionSelectorItemClick("my-account")}
className={`section-item ${state.selectedSection === "my-account" ? "selected" : ""}`}
>
My account
</span>
<span
onClick={() => handleSectionSelectorItemClick("preferences")}
className={`section-item ${state.selectedSection === "preferences" ? "selected" : ""}`}
>
Preferences
</span>
{user?.role === "OWNER" ? (
<div className="section-items-container">
<span
onClick={() => handleSectionSelectorItemClick("my-account")}
className={`section-item ${state.selectedSection === "my-account" ? "selected" : ""}`}
>
<span className="icon-text">🤠</span> My account
</span>
<span
onClick={() => handleSectionSelectorItemClick("preferences")}
className={`section-item ${state.selectedSection === "preferences" ? "selected" : ""}`}
>
<span className="icon-text">🏟</span> Preferences
</span>
</div>
{user?.role === "HOST" ? (
<>
<span className="section-title">Admin</span>
<span
onClick={() => handleSectionSelectorItemClick("member")}
className={`section-item ${state.selectedSection === "member" ? "selected" : ""}`}
>
Member
</span>
<div className="section-items-container">
<span
onClick={() => handleSectionSelectorItemClick("member")}
className={`section-item ${state.selectedSection === "member" ? "selected" : ""}`}
>
<span className="icon-text">👤</span> Member
</span>
</div>
</>
) : null}
</div>

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