Compare commits

...

145 Commits

Author SHA1 Message Date
boojack
9c084b247f chore: release v0.3.1 2022-08-07 01:58:00 +08:00
boojack
c991a48df6 chore: add upload resource button 2022-08-07 01:56:10 +08:00
boojack
fd44255668 chore: use dropdown in member section 2022-08-07 01:35:20 +08:00
boojack
84564891be feat: add view resource dialog 2022-08-07 01:30:48 +08:00
boojack
8c8bb9e59f chore: update search bar styles 2022-07-31 09:10:30 +08:00
boojack
99df4acfe9 chore: use cors middleware 2022-07-30 14:52:37 +08:00
boojack
47ffd99c3b chore: fix typo 2022-07-30 07:47:18 +08:00
boojack
c703f281d9 chore: update feather icon 2022-07-30 00:29:20 +08:00
boojack
e179c65e52 chore: fix typo 2022-07-29 21:41:56 +08:00
boojack
29da70be56 chore: update readme 2022-07-29 21:39:52 +08:00
boojack
a9dce26099 chore: remove build dev image 2022-07-29 21:26:08 +08:00
boojack
2c27f5d425 chore: release v0.3.0 (#136) 2022-07-29 21:09:48 +08:00
boojack
df7b4d54c6 chore: show inline image in daily review dialog (#135) 2022-07-29 20:11:14 +08:00
boojack
9994b1fabc chore: update member setting styles 2022-07-29 19:52:16 +08:00
boojack
2d093d5be0 chore: update daily review dialog style 2022-07-28 23:38:09 +08:00
boojack
12b373701b chore: fix shortcut list buttons style 2022-07-28 21:08:18 +08:00
boojack
2b8078a19b chore: add CommonDialog 2022-07-28 20:19:14 +08:00
boojack
5617118fa8 fix: acl middleware 2022-07-28 20:09:25 +08:00
boojack
fa93d0fd6e chore: update visibility selector style 2022-07-27 20:02:00 +08:00
boojack
dc436490f8 chore: update build.sh 2022-07-27 19:47:13 +08:00
boojack
d83f204d8c chore: update acl middleware 2022-07-27 19:45:37 +08:00
boojack
873973a088 chore: update favicon 2022-07-26 22:40:29 +08:00
boojack
d371cfd78d chore: update member list action buttons 2022-07-26 22:36:24 +08:00
boojack
7b1bad5b29 feat: update delete user api 2022-07-26 22:32:26 +08:00
boojack
0c2adfa1d2 feat: add delete user api 2022-07-26 21:41:20 +08:00
boojack
07d9649b22 chore: add visibility selector 2022-07-26 21:24:52 +08:00
boojack
b7339e00ba feat: update finding memo with visibility 2022-07-26 21:12:20 +08:00
boojack
58e68f8f80 chore: update signin button in visitor mode 2022-07-25 21:50:25 +08:00
boojack
cfa4151cff chore: update migration folder 2022-07-25 21:17:46 +08:00
boojack
3d33b5d564 chore: update memo visibility field (#132)
chore: update `memo` visibility field in schema
2022-07-24 21:01:56 +08:00
boojack
b516a8561f chore: update readme 2022-07-24 13:37:07 +08:00
boojack
38383a426f chore: update error message (#129) 2022-07-24 00:29:19 +08:00
boojack
7e34de23f1 chore: update live demo link 2022-07-23 19:47:14 +08:00
boojack
f02ec375a6 chore: release v0.2.2 (#127) 2022-07-22 23:51:58 +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
187 changed files with 4710 additions and 3319 deletions

View File

@@ -1,31 +0,0 @@
name: build-and-push-dev-image
on:
push:
branches:
- "main"
jobs:
build-and-push-dev-image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Login to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_NEOSMEMO_USERNAME }}
password: ${{ secrets.DOCKER_NEOSMEMO_TOKEN }}
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
- name: Build and Push
id: docker_build
uses: docker/build-push-action@v2
with:
context: ./
file: ./Dockerfile
push: true
tags: ${{ secrets.DOCKER_NEOSMEMO_USERNAME }}/memos:dev

View File

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

View File

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

22
.gitignore vendored
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

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
LICENSE Normal file
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.

View File

@@ -3,37 +3,40 @@
<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/usememos/memos" />
<img alt="Docker pull" src="https://img.shields.io/docker/pulls/neosmemo/memos.svg" />
<a href="https://github.com/usememos/memos/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/usememos/memos" /></a>
<a href="https://hub.docker.com/r/neosmemo/memos"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/neosmemo/memos.svg" /></a>
<img alt="Go report" src="https://goreportcard.com/badge/github.com/usememos/memos" />
</p>
<p align="center">
<a href="https://memos.onrender.com/">Live Demo</a> •
<a href="https://github.com/usememos/memos/discussions">Discussions</a>
<a href="https://demo.usememos.com/">Live Demo</a> •
<a href="https://t.me/+-_tNF1k70UU4ZTc9">Discuss in Telegram 👾</a>
</p>
![demo](https://raw.githubusercontent.com/usememos/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
```
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.
Memos should be running at [http://localhost:5230](http://localhost:5230). If the `~/.memos/` does not have a `memos_prod.db` file, then memos will auto generate it.
## 🏗 Development
@@ -49,8 +52,9 @@ Memos is built with a curated tech stack. It is optimized for developer experien
### Prerequisites
- [Go](https://golang.org/doc/install) (1.16 or later)
- [Go](https://golang.org/doc/install)
- [Air](https://github.com/cosmtrek/air#installation) for backend live reload
- [Node.js](https://nodejs.org/)
- [yarn](https://yarnpkg.com/getting-started/install)
### Steps
@@ -75,10 +79,10 @@ Memos is built with a curated tech stack. It is optimized for developer experien
Memos should now be running at [http://localhost:3000](http://localhost:3000) and change either frontend or backend code would trigger live reload.
### Contributing
Contributions are what make the open source community such an amazing place to be learn, inspire, and create. Any contributions you make are greatly appreciated. 🥰
## 🌟 Star history
[![Star History Chart](https://api.star-history.com/svg?repos=usememos/memos&type=Date)](https://star-history.com/#usememos/memos&Date)
---
Just enjoy it.

View File

@@ -1,6 +1,10 @@
package api
type Login struct {
var (
UNKNOWN_ID = 0
)
type Signin struct {
Email string `json:"email"`
Password string `json:"password"`
}

View File

@@ -1,5 +1,29 @@
package api
// Visibility is the type of a visibility.
type Visibility string
const (
// Public is the PUBLIC visibility.
Public Visibility = "PUBLIC"
// Protected is the PROTECTED visibility.
Protected Visibility = "PROTECTED"
// Privite is the PRIVATE visibility.
Privite Visibility = "PRIVATE"
)
func (e Visibility) String() string {
switch e {
case Public:
return "PUBLIC"
case Protected:
return "PROTECTED"
case Privite:
return "PRIVATE"
}
return "PRIVATE"
}
type Memo struct {
ID int `json:"id"`
@@ -10,8 +34,9 @@ type Memo struct {
UpdatedTs int64 `json:"updatedTs"`
// Domain specific fields
Content string `json:"content"`
Pinned bool `json:"pinned"`
Content string `json:"content"`
Visibility Visibility `json:"visibility"`
Pinned bool `json:"pinned"`
}
type MemoCreate struct {
@@ -21,7 +46,8 @@ 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 {
@@ -31,7 +57,8 @@ type MemoPatch struct {
RowStatus *RowStatus `json:"rowStatus"`
// Domain specific fields
Content *string `json:"content"`
Content *string `json:"content"`
Visibility *Visibility `json:"visibility"`
}
type MemoFind struct {
@@ -42,7 +69,13 @@ type MemoFind struct {
CreatorID *int `json:"creatorId"`
// Domain specific fields
Pinned *bool
Pinned *bool
ContentSearch *string
VisibilityList []Visibility
// Pagination
Limit int
Offset int
}
type MemoDelete struct {

View File

@@ -38,4 +38,7 @@ type ResourceFind struct {
type ResourceDelete struct {
ID int
// Standard fields
CreatorID int
}

View File

@@ -1,8 +1,8 @@
package api
import "memos/server/profile"
import "github.com/usememos/memos/server/profile"
type SystemStatus struct {
Owner *User `json:"owner"`
Host *User `json:"host"`
Profile *profile.Profile `json:"profile"`
}

View File

@@ -4,16 +4,16 @@ 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 Owner:
return "OWNER"
case Host:
return "HOST"
case NormalUser:
return "USER"
}
@@ -73,3 +73,7 @@ type UserFind struct {
Name *string `json:"name"`
OpenID *string
}
type UserDelete struct {
ID int
}

View File

@@ -4,10 +4,10 @@ import (
"fmt"
"os"
"memos/server"
"memos/server/profile"
"memos/store"
DB "memos/store/db"
"github.com/usememos/memos/server"
"github.com/usememos/memos/server/profile"
"github.com/usememos/memos/store"
DB "github.com/usememos/memos/store/db"
)
const (
@@ -36,11 +36,10 @@ func (m *Main) Run() error {
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() {
@@ -49,8 +48,13 @@ func Execute() {
profile: profile,
}
println(greetingBanner)
fmt.Printf("Version %s has started at :%d\n", profile.Version, profile.Port)
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)

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

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.3.1"
// DevVersion is the service current development version.
var DevVersion = "0.3.1"
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)
}

2
go.mod
View File

@@ -1,4 +1,4 @@
module memos
module github.com/usememos/memos
go 1.17

Binary file not shown.

Before

Width:  |  Height:  |  Size: 924 KiB

BIN
resources/demo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

13
scripts/build.sh Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/bash
# Usage: ./scripts/build.sh
set -e
cd "$(dirname "$0")/../"
echo "Start building..."
go build -o ./memos-build/memos ./bin/server/main.go
echo "Build finished"

116
server/acl.go Normal file
View File

@@ -0,0 +1,116 @@
package server
import (
"fmt"
"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"
)
var (
userIDContextKey = "user-id"
)
func getUserIDContextKey() string {
return userIDContextKey
}
func setUserSession(ctx echo.Context, user *api.User) error {
sess, _ := session.Get("session", ctx)
sess.Options = &sessions.Options{
Path: "/",
MaxAge: 1000 * 3600 * 24 * 30,
HttpOnly: true,
}
sess.Values[userIDContextKey] = user.ID
err := sess.Save(ctx.Request(), ctx.Response())
if err != nil {
return fmt.Errorf("failed to set session, err: %w", err)
}
return nil
}
func removeUserSession(ctx echo.Context) error {
sess, _ := session.Get("session", ctx)
sess.Options = &sessions.Options{
Path: "/",
MaxAge: 0,
HttpOnly: true,
}
sess.Values[userIDContextKey] = nil
err := sess.Save(ctx.Request(), ctx.Response())
if err != nil {
return fmt.Errorf("failed to set session, err: %w", err)
}
return nil
}
func aclMiddleware(s *Server, next echo.HandlerFunc) echo.HandlerFunc {
return func(ctx echo.Context) error {
// Skip auth.
if common.HasPrefixes(ctx.Path(), "/api/auth") {
return next(ctx)
}
if common.HasPrefixes(ctx.Path(), "/api/ping", "/api/status", "/api/user/:id") && ctx.Request().Method == http.MethodGet {
return next(ctx)
}
// If there is openId in query string and related user is found, then skip auth.
openID := ctx.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.
ctx.Set(getUserIDContextKey(), user.ID)
return next(ctx)
}
}
{
sess, _ := session.Get("session", ctx)
userIDValue := sess.Values[userIDContextKey]
if userIDValue != nil {
userID, _ := strconv.Atoi(fmt.Sprintf("%v", userIDValue))
userFind := &api.UserFind{
ID: &userID,
}
user, err := s.Store.FindUser(userFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user by ID: %d", userID)).SetInternal(err)
}
if user != nil {
if user.RowStatus == api.Archived {
return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("User has been archived with email %s", user.Email))
}
ctx.Set(getUserIDContextKey(), userID)
}
}
}
if common.HasPrefixes(ctx.Path(), "/api/memo", "/api/tag", "/api/shortcut") && ctx.Request().Method == http.MethodGet {
if _, err := strconv.Atoi(ctx.QueryParam("creatorId")); err == nil {
return next(ctx)
}
}
userID := ctx.Get(getUserIDContextKey())
if userID == nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
return next(ctx)
}
}

View File

@@ -3,49 +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", login.Email))
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
})
@@ -60,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{}
@@ -113,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
})
}

View File

@@ -1,97 +0,0 @@
package server
import (
"fmt"
"memos/api"
"memos/common"
"net/http"
"strconv"
"github.com/gorilla/sessions"
"github.com/labstack/echo-contrib/session"
"github.com/labstack/echo/v4"
)
var (
userIDContextKey = "user-id"
)
func getUserIDContextKey() string {
return userIDContextKey
}
func setUserSession(c echo.Context, user *api.User) error {
sess, _ := session.Get("session", c)
sess.Options = &sessions.Options{
Path: "/",
MaxAge: 1000 * 3600 * 24 * 30,
HttpOnly: true,
}
sess.Values[userIDContextKey] = user.ID
err := sess.Save(c.Request(), c.Response())
if err != nil {
return fmt.Errorf("failed to set session, err: %w", err)
}
return nil
}
func removeUserSession(c echo.Context) error {
sess, _ := session.Get("session", c)
sess.Options = &sessions.Options{
Path: "/",
MaxAge: 0,
HttpOnly: true,
}
sess.Values[userIDContextKey] = nil
err := sess.Save(c.Request(), c.Response())
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") {
return next(c)
}
sess, err := session.Get("session", c)
if err != nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing session").SetInternal(err)
}
userIDValue := sess.Values[userIDContextKey]
if userIDValue == nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing userID in session")
}
userID, err := strconv.Atoi(fmt.Sprintf("%v", userIDValue))
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to malformatted user id in the session.").SetInternal(err)
}
// Even if there is no error, we still need to make sure the user still exists.
userFind := &api.UserFind{
ID: &userID,
}
user, err := s.Store.FindUser(userFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user by ID: %d", userID)).SetInternal(err)
}
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.
c.Set(getUserIDContextKey(), userID)
return next(c)
}
}

13
server/dist/index.html vendored Normal file
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
server/embed_frontend.go Normal file
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(),
}))
}

View File

@@ -3,17 +3,22 @@ package server
import (
"encoding/json"
"fmt"
"memos/api"
"memos/common"
"net/http"
"strconv"
"strings"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
"github.com/labstack/echo/v4"
)
func (s *Server) registerMemoRoutes(g *echo.Group) {
g.POST("/memo", func(c echo.Context) error {
userID := c.Get(getUserIDContextKey()).(int)
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
memoCreate := &api.MemoCreate{
CreatorID: userID,
}
@@ -21,6 +26,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 +40,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,14 +65,28 @@ 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
}
currentUserID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
if memoFind.CreatorID == nil {
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find memo")
}
memoFind.VisibilityList = []api.Visibility{api.Public}
} else {
if memoFind.CreatorID == nil {
memoFind.CreatorID = &currentUserID
} else {
memoFind.VisibilityList = []api.Visibility{api.Public, api.Protected}
}
}
rowStatus := api.RowStatus(c.QueryParam("rowStatus"))
@@ -75,6 +98,25 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
pinned := pinnedStr == "true"
memoFind.Pinned = &pinned
}
tag := c.QueryParam("tag")
if tag != "" {
contentSearch := "#" + tag + " "
memoFind.ContentSearch = &contentSearch
}
visibilitListStr := c.QueryParam("visibility")
if visibilitListStr != "" {
visibilityList := []api.Visibility{}
for _, visibility := range strings.Split(visibilitListStr, ",") {
visibilityList = append(visibilityList, api.Visibility(visibility))
}
memoFind.VisibilityList = visibilityList
}
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 {
@@ -85,7 +127,6 @@ 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
})
@@ -95,7 +136,10 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
}
userID := c.Get(getUserIDContextKey()).(int)
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
memoOrganizerUpsert := &api.MemoOrganizerUpsert{
MemoID: memoID,
UserID: userID,
@@ -124,7 +168,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
})
@@ -150,7 +193,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
})
@@ -163,14 +205,33 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
memoDelete := &api.MemoDelete{
ID: memoID,
}
err = s.Store.DeleteMemo(memoDelete)
if err != nil {
if err := s.Store.DeleteMemo(memoDelete); err != nil {
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, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
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
})
}

View File

@@ -1,12 +1,13 @@
package profile
import (
"flag"
"fmt"
"memos/common"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/usememos/memos/common"
)
// Profile is the configuration to start main server.
@@ -15,6 +16,8 @@ type Profile struct {
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
@@ -35,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: common.Version,
}
return &profile
}

View File

@@ -4,16 +4,20 @@ import (
"encoding/json"
"fmt"
"io/ioutil"
"memos/api"
"net/http"
"strconv"
"github.com/usememos/memos/api"
"github.com/labstack/echo/v4"
)
func (s *Server) registerResourceRoutes(g *echo.Group) {
g.POST("/resource", func(c echo.Context) error {
userID := c.Get(getUserIDContextKey()).(int)
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
err := c.Request().ParseMultipartForm(64 << 20)
if err != nil {
@@ -56,12 +60,14 @@ 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
})
g.GET("/resource", func(c echo.Context) error {
userID := c.Get(getUserIDContextKey()).(int)
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
resourceFind := &api.ResourceFind{
CreatorID: &userID,
}
@@ -74,25 +80,82 @@ 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, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
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, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
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
})
g.DELETE("/resource/:resourceId", func(c echo.Context) error {
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
resourceID, err := strconv.Atoi(c.Param("resourceId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
}
resourceDelete := &api.ResourceDelete{
ID: resourceID,
ID: resourceID,
CreatorID: userID,
}
if err := s.Store.DeleteResource(resourceDelete); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete resource").SetInternal(err)
}
c.JSON(http.StatusOK, true)
return nil
return c.JSON(http.StatusOK, true)
})
}

View File

@@ -2,10 +2,11 @@ package server
import (
"fmt"
"memos/server/profile"
"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"
@@ -31,20 +32,17 @@ func NewServer(profile *profile.Profile) *Server {
Format: "${method} ${uri} ${status}\n",
}))
e.Use(middleware.CORS())
e.Use(middleware.TimeoutWithConfig(middleware.TimeoutConfig{
Skipper: middleware.DefaultSkipper,
ErrorMessage: "Request timeout",
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.
// In dev mode, set the const secret key to make signin session persistence.
secret := []byte("usememos")
if profile.Mode == "prod" {
secret = securecookie.GenerateRandomKey(16)
@@ -62,7 +60,7 @@ func NewServer(profile *profile.Profile) *Server {
apiGroup := e.Group("/api")
apiGroup.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return BasicAuthMiddleware(s, next)
return aclMiddleware(s, next)
})
s.registerSystemRoutes(apiGroup)
s.registerAuthRoutes(apiGroup)
@@ -70,6 +68,7 @@ func NewServer(profile *profile.Profile) *Server {
s.registerMemoRoutes(apiGroup)
s.registerShortcutRoutes(apiGroup)
s.registerResourceRoutes(apiGroup)
s.registerTagRoutes(apiGroup)
return s
}

View File

@@ -3,16 +3,20 @@ package server
import (
"encoding/json"
"fmt"
"memos/api"
"net/http"
"strconv"
"github.com/usememos/memos/api"
"github.com/labstack/echo/v4"
)
func (s *Server) registerShortcutRoutes(g *echo.Group) {
g.POST("/shortcut", func(c echo.Context) error {
userID := c.Get(getUserIDContextKey()).(int)
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
shortcutCreate := &api.ShortcutCreate{
CreatorID: userID,
}
@@ -29,7 +33,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 +58,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 +84,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 +105,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 +121,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)
})
}

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
server/tag.go Normal file
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
}
currentUserID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
if memoFind.CreatorID == nil {
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find memo")
}
memoFind.VisibilityList = []api.Visibility{api.Public}
} else {
if memoFind.CreatorID == nil {
memoFind.CreatorID = &currentUserID
} else {
memoFind.VisibilityList = []api.Visibility{api.Public, api.Protected}
}
}
memoList, err := s.Store.FindMemoList(&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
})
}

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"
"golang.org/x/crypto/bcrypt"
)
@@ -34,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
})
@@ -44,22 +44,50 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch user list").SetInternal(err)
}
for _, user := range userList {
// data desensitize
user.OpenID = ""
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
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
})
// GET /api/user/me is used to check if the user is logged in.
g.GET("/user/me", func(c echo.Context) error {
userSessionID := c.Get(getUserIDContextKey())
if userSessionID == nil {
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
}
userID := userSessionID.(int)
userFind := &api.UserFind{
ID: &userID,
}
@@ -72,12 +100,30 @@ 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/me", func(c echo.Context) error {
userID := c.Get(getUserIDContextKey()).(int)
g.PATCH("/user/:id", func(c echo.Context) error {
userID, err := strconv.Atoi(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("id"))).SetInternal(err)
}
currentUserID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
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 && currentUserID != userID {
return echo.NewHTTPError(http.StatusForbidden, "Access forbidden for current session user").SetInternal(err)
}
userPatch := &api.UserPatch{
ID: userID,
}
@@ -109,12 +155,14 @@ 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)
g.DELETE("/user/:id", func(c echo.Context) error {
currentUserID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
currentUser, err := s.Store.FindUser(&api.UserFind{
ID: &currentUserID,
})
@@ -123,42 +171,22 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
}
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.Owner {
} 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"))
userID, err := strconv.Atoi(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("userId"))).SetInternal(err)
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("id"))).SetInternal(err)
}
userPatch := &api.UserPatch{
userDelete := &api.UserDelete{
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 err := s.Store.DeleteUser(userDelete); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete user").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
return c.JSON(http.StatusOK, true)
})
}

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 := api.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
})
}

View File

@@ -6,10 +6,14 @@ import (
"errors"
"fmt"
"io/fs"
"memos/common"
"memos/server/profile"
"io/ioutil"
"os"
"regexp"
"sort"
"time"
"github.com/usememos/memos/common"
"github.com/usememos/memos/server/profile"
_ "github.com/mattn/go-sqlite3"
)
@@ -22,54 +26,96 @@ var seedFS embed.FS
type DB struct {
// sqlite db connection instance
Db *sql.DB
// datasource name
DSN string
// mode should be prod or dev
mode string
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{
DSN: profile.DSN,
mode: profile.Mode,
profile: profile,
}
return db
}
func (db *DB) Open() (err error) {
// Ensure a DSN is set before attempting to open the database.
if db.DSN == "" {
if db.profile.DSN == "" {
return fmt.Errorf("dsn required")
}
// Connect to the database.
sqlDB, err := sql.Open("sqlite3", db.DSN)
sqlDB, err := sql.Open("sqlite3", db.profile.DSN)
if err != nil {
return fmt.Errorf("failed to open db with dsn: %s, err: %w", db.DSN, err)
return fmt.Errorf("failed to open db with dsn: %s, err: %w", db.profile.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 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 mode is dev, then seed the database.
if db.mode == "dev" {
if err := db.seed(); err != nil {
return fmt.Errorf("failed to seed: %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 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)
}
if err := db.seed(); err != nil {
return fmt.Errorf("failed to seed: %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))
}
}
}
}
@@ -77,25 +123,52 @@ func (db *DB) Open() (err error) {
return err
}
func (db *DB) migrate() error {
err := db.compareMigrationHistory()
if err != nil {
return fmt.Errorf("failed to compare migration history, err=%w", err)
}
const (
latestSchemaFileName = "LATEST__SCHEMA.sql"
)
filenames, err := fs.Glob(migrationFS, fmt.Sprintf("%s/*.sql", "migration"))
func (db *DB) applyLatestSchema() error {
latestSchemaPath := fmt.Sprintf("%s/%s/%s", "migration", db.profile.Mode, 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/prod", minorVersion))
if err != nil {
return err
}
sort.Strings(filenames)
migrationStmt := ""
// 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)
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
}
@@ -109,58 +182,66 @@ func (db *DB) seed() error {
// 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)
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
}
// executeFile runs a single seed file within a transaction.
func (db *DB) executeFile(FS embed.FS, name string) error {
// 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()
// 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 {
if _, err := tx.Exec(stmt); err != nil {
return err
}
return tx.Commit()
}
// compareMigrationHistory compares migration history data
func (db *DB) compareMigrationHistory() error {
table, err := findTable(db, "migration_history")
if err != nil {
return err
}
if table == nil {
createTable(db, `
CREATE TABLE migration_history (
version TEXT NOT NULL PRIMARY KEY,
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now'))
);
`)
}
// minorDirRegexp is a regular expression for minor version directory.
var minorDirRegexp = regexp.MustCompile(`^migration/prod/[0-9]+\.[0-9]+$`)
migrationHistoryList, err := findMigrationHistoryList(db)
if err != nil {
return err
}
func getMinorVersionList() []string {
minorVersionList := []string{}
if len(migrationHistoryList) == 0 {
createMigrationHistory(db, common.Version)
} else {
migrationHistory := migrationHistoryList[0]
if migrationHistory.Version != common.Version {
createMigrationHistory(db, common.Version)
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

View File

@@ -1,5 +0,0 @@
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`;

View File

@@ -1,3 +1,10 @@
-- 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,
@@ -6,7 +13,7 @@ CREATE TABLE user (
-- 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
@@ -37,13 +44,14 @@ CREATE TABLE memo (
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
row_status TEXT NOT NULL CHECK (row_status IN ('NORMAL', 'ARCHIVED')) DEFAULT 'NORMAL',
content TEXT NOT NULL DEFAULT '',
FOREIGN KEY(creator_id) REFERENCES user(id)
visibility TEXT NOT NULL CHECK (visibility IN ('PUBLIC', 'PROTECTED', '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
@@ -63,15 +71,15 @@ CREATE TABLE memo_organizer (
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),
FOREIGN KEY(user_id) REFERENCES user(id),
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', 100);
('memo_organizer', 1000);
-- shortcut
CREATE TABLE shortcut (
@@ -82,13 +90,13 @@ CREATE TABLE shortcut (
row_status TEXT NOT NULL CHECK (row_status IN ('NORMAL', 'ARCHIVED')) DEFAULT 'NORMAL',
title TEXT NOT NULL DEFAULT '',
payload TEXT NOT NULL DEFAULT '{}',
FOREIGN KEY(creator_id) REFERENCES user(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
@@ -112,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 user(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

View File

@@ -0,0 +1,53 @@
-- 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;

View File

@@ -0,0 +1 @@
ALTER TABLE memo ADD visibility TEXT NOT NULL CHECK (visibility IN ('PUBLIC', 'PRIVATE')) DEFAULT 'PRIVATE';

View File

@@ -0,0 +1,37 @@
-- change memo visibility field from "PRIVATE"/"PUBLIC" to "PRIVATE"/"PROTECTED"/"PUBLIC".
PRAGMA foreign_keys = off;
DROP TABLE IF EXISTS _memo_old;
ALTER TABLE memo RENAME TO _memo_old;
CREATE TABLE memo (
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',
content TEXT NOT NULL DEFAULT '',
visibility TEXT NOT NULL CHECK (visibility IN ('PUBLIC', 'PROTECTED', 'PRIVATE')) DEFAULT 'PRIVATE',
FOREIGN KEY(creator_id) REFERENCES user(id) ON DELETE CASCADE
);
INSERT INTO memo (
id, creator_id, created_ts, updated_ts,
row_status, content, visibility
)
SELECT
id,
creator_id,
created_ts,
updated_ts,
row_status,
content,
visibility
FROM
_memo_old;
DROP TABLE IF EXISTS _memo_old;
PRAGMA foreign_keys = on;

View File

@@ -0,0 +1,141 @@
-- 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 ('HOST', 'USER')) DEFAULT 'USER',
name TEXT NOT NULL,
password_hash TEXT NOT NULL,
open_id TEXT NOT NULL UNIQUE
);
INSERT INTO
sqlite_sequence (name, seq)
VALUES
('user', 100);
CREATE TRIGGER IF NOT EXISTS `trigger_update_user_modification_time`
AFTER
UPDATE
ON `user` FOR EACH ROW BEGIN
UPDATE
`user`
SET
updated_ts = (strftime('%s', 'now'))
WHERE
rowid = old.rowid;
END;
-- memo
CREATE TABLE memo (
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',
content TEXT NOT NULL DEFAULT '',
visibility TEXT NOT NULL CHECK (visibility IN ('PUBLIC', 'PROTECTED', 'PRIVATE')) DEFAULT 'PRIVATE',
FOREIGN KEY(creator_id) REFERENCES user(id) ON DELETE CASCADE
);
INSERT INTO
sqlite_sequence (name, seq)
VALUES
('memo', 1000);
CREATE TRIGGER IF NOT EXISTS `trigger_update_memo_modification_time`
AFTER
UPDATE
ON `memo` FOR EACH ROW BEGIN
UPDATE
`memo`
SET
updated_ts = (strftime('%s', 'now'))
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 '{}',
FOREIGN KEY(creator_id) REFERENCES user(id) ON DELETE CASCADE
);
INSERT INTO
sqlite_sequence (name, seq)
VALUES
('shortcut', 10000);
CREATE TRIGGER IF NOT EXISTS `trigger_update_shortcut_modification_time`
AFTER
UPDATE
ON `shortcut` FOR EACH ROW BEGIN
UPDATE
`shortcut`
SET
updated_ts = (strftime('%s', 'now'))
WHERE
rowid = old.rowid;
END;
-- resource
CREATE TABLE resource (
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')),
filename TEXT NOT NULL DEFAULT '',
blob BLOB NOT NULL,
type TEXT NOT NULL DEFAULT '',
size INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(creator_id) REFERENCES user(id) ON DELETE CASCADE
);
INSERT INTO
sqlite_sequence (name, seq)
VALUES
('resource', 10000);
CREATE TRIGGER IF NOT EXISTS `trigger_update_resource_modification_time`
AFTER
UPDATE
ON `resource` FOR EACH ROW BEGIN
UPDATE
`resource`
SET
updated_ts = (strftime('%s', 'now'))
WHERE
rowid = old.rowid;
END;

View File

@@ -1,23 +1,40 @@
package db
import (
"fmt"
"database/sql"
"strings"
)
type MigrationHistory struct {
CreatedTs int64
Version string
CreatedTs int64
}
func findMigrationHistoryList(db *DB) ([]*MigrationHistory, error) {
rows, err := db.Db.Query(`
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
ORDER BY created_ts DESC
`)
WHERE `+strings.Join(where, " AND ")+`
ORDER BY created_ts DESC`,
args...,
)
if err != nil {
return nil, err
}
@@ -39,23 +56,45 @@ func findMigrationHistoryList(db *DB) ([]*MigrationHistory, error) {
return migrationHistoryList, nil
}
func createMigrationHistory(db *DB, version string) error {
result, err := db.Db.Exec(`
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
`,
version,
upsert.Version,
)
if err != nil {
return err
return nil, err
}
defer row.Close()
row.Next()
var migrationHistory MigrationHistory
if err := row.Scan(
&migrationHistory.Version,
&migrationHistory.CreatedTs,
); err != nil {
return nil, err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("failed to create migration history with %s", version)
}
return nil
return &migrationHistory, nil
}

View File

@@ -11,9 +11,51 @@ VALUES
(
101,
'demo@usememos.com',
'OWNER',
'Demo Owner',
'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'
);
INSERT INTO
user (
`id`,
`row_status`,
`email`,
`role`,
`name`,
`open_id`,
`password_hash`
)
VALUES
(
103,
'ARCHIVED',
'bob@usememos.com',
'USER',
'Bob',
'bob_open_id',
-- raw password: secret
'$2a$14$ajq8Q7fbtFRQvXpdCq7Jcuy.Rx1h/L4J60Otx.gyNLbAYctGMJ9tK'
);

View File

@@ -6,8 +6,9 @@ INSERT INTO
)
VALUES
(
101,
'#memos 👋 Welcome to memos',
1001,
"#Hello 👋 Welcome to memos.
And here is old Jack's Page: [/u/102](/u/102)",
101
);
@@ -15,11 +16,67 @@ INSERT INTO
memo (
`id`,
`content`,
`creator_id`
`creator_id`,
`visibility`
)
VALUES
(
102,
'好好学习,天天向上。',
101
1002,
'#TODO
- [ ] Take more photos about **🌄 sunset**;
- [x] Clean the room;
- [x] Read *📖 The Little Prince*;
(👆 click to toggle status)',
101,
'PROTECTED'
);
INSERT INTO
memo (
`id`,
`content`,
`creator_id`,
`visibility`
)
VALUES
(
1003,
'好好学习,天天向上。🤜🤛',
101,
'PUBLIC'
);
INSERT INTO
memo (
`id`,
`content`,
`creator_id`,
`visibility`
)
VALUES
(
1004,
'#TODO
- [x] Take more photos about **🌄 sunset**;
- [ ] Clean the classroom;
- [ ] Watch *👦 The Boys*;
(👆 click to toggle status)
',
102,
'PROTECTED'
);
INSERT INTO
memo (
`id`,
`content`,
`creator_id`,
`visibility`
)
VALUES
(
1005,
'三人行,必有我师焉!👨‍🏫',
102,
'PUBLIC'
);

View File

@@ -6,7 +6,7 @@ INSERT INTO
)
VALUES
(
102,
1001,
101,
1
);

View File

@@ -1,7 +1,7 @@
package db
import (
"fmt"
"database/sql"
"strings"
)
@@ -10,13 +10,14 @@ type Table struct {
SQL string
}
func findTable(db *DB, tableName string) (*Table, error) {
//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.Db.Query(`
rows, err := db.Query(`
SELECT
tbl_name,
sql
@@ -53,13 +54,13 @@ func findTable(db *DB, tableName string) (*Table, error) {
}
}
func createTable(db *DB, sql string) error {
result, err := db.Db.Exec(sql)
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("failed to create table with %s", sql)
func createTable(db *sql.DB, sql string) error {
result, err := db.Exec(sql)
if err != nil {
return err
}
_, err = result.RowsAffected()
return err
}

View File

@@ -3,9 +3,10 @@ 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.
@@ -20,7 +21,8 @@ type memoRaw struct {
UpdatedTs int64
// Domain specific fields
Content string
Content string
Visibility api.Visibility
}
// toMemo creates an instance of Memo based on the memoRaw.
@@ -36,7 +38,8 @@ func (raw *memoRaw) toMemo() *api.Memo {
UpdatedTs: raw.UpdatedTs,
// Domain specific fields
Content: raw.Content,
Content: raw.Content,
Visibility: raw.Visibility,
}
}
@@ -119,17 +122,20 @@ func createMemoRaw(db *sql.DB, create *api.MemoCreate) (*memoRaw, error) {
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.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 {
@@ -144,8 +150,9 @@ func createMemoRaw(db *sql.DB, create *api.MemoCreate) (*memoRaw, error) {
&memoRaw.CreatorID,
&memoRaw.CreatedTs,
&memoRaw.UpdatedTs,
&memoRaw.Content,
&memoRaw.RowStatus,
&memoRaw.Content,
&memoRaw.Visibility,
); err != nil {
return nil, FormatError(err)
}
@@ -162,6 +169,9 @@ func patchMemoRaw(db *sql.DB, patch *api.MemoPatch) (*memoRaw, 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)
@@ -169,24 +179,24 @@ func patchMemoRaw(db *sql.DB, patch *api.MemoPatch) (*memoRaw, error) {
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 memoRaw memoRaw
if err := row.Scan(
&memoRaw.ID,
&memoRaw.CreatorID,
&memoRaw.CreatedTs,
&memoRaw.UpdatedTs,
&memoRaw.Content,
&memoRaw.RowStatus,
&memoRaw.Content,
&memoRaw.Visibility,
); err != nil {
return nil, FormatError(err)
}
@@ -209,6 +219,25 @@ func findMemoRawList(db *sql.DB, find *api.MemoFind) ([]*memoRaw, error) {
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.VisibilityList; len(v) != 0 {
list := []string{}
for _, visibility := range v {
list = append(list, fmt.Sprintf("$%d", len(args)+1))
args = append(args, visibility)
}
where = append(where, fmt.Sprintf("visibility in (%s)", strings.Join(list, ",")))
}
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
@@ -216,11 +245,12 @@ func findMemoRawList(db *sql.DB, find *api.MemoFind) ([]*memoRaw, error) {
creator_id,
created_ts,
updated_ts,
row_status,
content,
row_status
visibility
FROM memo
WHERE `+strings.Join(where, " AND ")+`
ORDER BY created_ts DESC`,
ORDER BY created_ts DESC`+pagination,
args...,
)
if err != nil {
@@ -236,8 +266,9 @@ func findMemoRawList(db *sql.DB, find *api.MemoFind) ([]*memoRaw, error) {
&memoRaw.CreatorID,
&memoRaw.CreatedTs,
&memoRaw.UpdatedTs,
&memoRaw.Content,
&memoRaw.RowStatus,
&memoRaw.Content,
&memoRaw.Visibility,
); err != nil {
return nil, FormatError(err)
}
@@ -253,7 +284,10 @@ func findMemoRawList(db *sql.DB, find *api.MemoFind) ([]*memoRaw, error) {
}
func deleteMemo(db *sql.DB, delete *api.MemoDelete) error {
result, err := db.Exec(`DELETE FROM memo WHERE id = ?`, delete.ID)
result, err := db.Exec(`
PRAGMA foreign_keys = ON;
DELETE FROM memo WHERE id = ?
`, delete.ID)
if err != nil {
return FormatError(err)
}

View File

@@ -3,8 +3,9 @@ package store
import (
"database/sql"
"fmt"
"memos/api"
"memos/common"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
)
// memoOrganizerRaw is the store model for an MemoOrganizer.

View File

@@ -3,9 +3,10 @@ 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.
@@ -101,7 +102,7 @@ func createResource(db *sql.DB, create *api.ResourceCreate) (*resourceRaw, error
creator_id
)
VALUES (?, ?, ?, ?, ?)
RETURNING id, filename, blob, type, size, created_ts, updated_ts
RETURNING id, filename, blob, type, size, creator_id, created_ts, updated_ts
`,
create.Filename,
create.Blob,
@@ -122,6 +123,7 @@ func createResource(db *sql.DB, create *api.ResourceCreate) (*resourceRaw, error
&resourceRaw.Blob,
&resourceRaw.Type,
&resourceRaw.Size,
&resourceRaw.CreatorID,
&resourceRaw.CreatedTs,
&resourceRaw.UpdatedTs,
); err != nil {
@@ -151,6 +153,7 @@ func findResourceList(db *sql.DB, find *api.ResourceFind) ([]*resourceRaw, error
blob,
type,
size,
creator_id,
created_ts,
updated_ts
FROM resource
@@ -172,6 +175,7 @@ func findResourceList(db *sql.DB, find *api.ResourceFind) ([]*resourceRaw, error
&resourceRaw.Blob,
&resourceRaw.Type,
&resourceRaw.Size,
&resourceRaw.CreatorID,
&resourceRaw.CreatedTs,
&resourceRaw.UpdatedTs,
); err != nil {
@@ -189,7 +193,10 @@ func findResourceList(db *sql.DB, find *api.ResourceFind) ([]*resourceRaw, error
}
func deleteResource(db *sql.DB, delete *api.ResourceDelete) error {
result, err := db.Exec(`DELETE FROM resource WHERE id = ?`, delete.ID)
result, err := db.Exec(`
PRAGMA foreign_keys = ON;
DELETE FROM resource WHERE id = ? AND creator_id = ?
`, delete.ID, delete.CreatorID)
if err != nil {
return FormatError(err)
}

View File

@@ -3,9 +3,10 @@ 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.
@@ -237,7 +238,10 @@ func findShortcutList(db *sql.DB, find *api.ShortcutFind) ([]*shortcutRaw, error
}
func deleteShortcut(db *sql.DB, delete *api.ShortcutDelete) error {
result, err := db.Exec(`DELETE FROM shortcut WHERE id = ?`, delete.ID)
result, err := db.Exec(`
PRAGMA foreign_keys = ON;
DELETE FROM shortcut WHERE id = ?
`, delete.ID)
if err != nil {
return FormatError(err)
}

View File

@@ -2,7 +2,8 @@ package store
import (
"database/sql"
"memos/server/profile"
"github.com/usememos/memos/server/profile"
)
// Store provides database access to all raw objects

View File

@@ -3,9 +3,10 @@ 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.
@@ -95,6 +96,15 @@ func (s *Store) FindUser(find *api.UserFind) (*api.User, error) {
return user, nil
}
func (s *Store) DeleteUser(delete *api.UserDelete) error {
err := deleteUser(s.db, delete)
if err != nil {
return FormatError(err)
}
return nil
}
func createUser(db *sql.DB, create *api.UserCreate) (*userRaw, error) {
row, err := db.Query(`
INSERT INTO user (
@@ -105,7 +115,7 @@ func createUser(db *sql.DB, create *api.UserCreate) (*userRaw, 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,
@@ -129,6 +139,7 @@ func createUser(db *sql.DB, create *api.UserCreate) (*userRaw, error) {
&userRaw.OpenID,
&userRaw.CreatedTs,
&userRaw.UpdatedTs,
&userRaw.RowStatus,
); err != nil {
return nil, FormatError(err)
}
@@ -161,7 +172,7 @@ func patchUser(db *sql.DB, patch *api.UserPatch) (*userRaw, error) {
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)
@@ -179,6 +190,7 @@ func patchUser(db *sql.DB, patch *api.UserPatch) (*userRaw, error) {
&userRaw.OpenID,
&userRaw.CreatedTs,
&userRaw.UpdatedTs,
&userRaw.RowStatus,
); err != nil {
return nil, FormatError(err)
}
@@ -217,10 +229,11 @@ func findUserList(db *sql.DB, find *api.UserFind) ([]*userRaw, error) {
password_hash,
open_id,
created_ts,
updated_ts
updated_ts,
row_status
FROM user
WHERE `+strings.Join(where, " AND ")+`
ORDER BY created_ts DESC`,
ORDER BY created_ts DESC, row_status DESC`,
args...,
)
if err != nil {
@@ -240,8 +253,8 @@ func findUserList(db *sql.DB, find *api.UserFind) ([]*userRaw, error) {
&userRaw.OpenID,
&userRaw.CreatedTs,
&userRaw.UpdatedTs,
&userRaw.RowStatus,
); err != nil {
fmt.Println(err)
return nil, FormatError(err)
}
@@ -254,3 +267,20 @@ func findUserList(db *sql.DB, find *api.UserFind) ([]*userRaw, error) {
return userRawList, nil
}
func deleteUser(db *sql.DB, delete *api.UserDelete) error {
result, err := db.Exec(`
PRAGMA foreign_keys = ON;
DELETE FROM user WHERE id = ?
`, delete.ID)
if err != nil {
return FormatError(err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return &common.Error{Code: common.NotFound, Err: fmt.Errorf("user ID not found: %d", delete.ID)}
}
return nil
}

View File

@@ -24,5 +24,10 @@
"@typescript-eslint/no-empty-interface": ["off"],
"@typescript-eslint/no-explicit-any": ["off"],
"react/react-in-jsx-scope": "off"
},
"settings": {
"react": {
"version": "detect"
}
}
}

View File

@@ -2,9 +2,9 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/logo.svg" sizes="64x64" type="image/*" />
<link rel="icon" href="/logo.png" 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" />
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
<title>Memos</title>
</head>
<body>

View File

@@ -1,6 +1,6 @@
{
"name": "memos",
"version": "0.0.1",
"version": "0.3.1",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@@ -10,18 +10,23 @@
"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-feather": "^2.0.10",
"react-redux": "^8.0.1"
},
"devDependencies": {
"@types/lodash-es": "^4.17.5",
"@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",
@@ -32,7 +37,6 @@
"prettier": "2.5.1",
"tailwindcss": "^3.0.18",
"typescript": "^4.3.2",
"vite": "^2.9.0"
},
"license": "MIT"
"vite": "^3.0.0"
}
}

View File

Before

Width:  |  Height:  |  Size: 121 B

After

Width:  |  Height:  |  Size: 121 B

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

BIN
web/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -1,11 +1,21 @@
import { useEffect, useState } from "react";
import { appRouterSwitch } from "./routers";
import { locationService } from "./services";
import { useAppSelector } from "./store";
import "./less/app.less";
function App() {
const pathname = useAppSelector((state) => state.location.pathname);
const [isLoading, setLoading] = useState(true);
return <>{appRouterSwitch(pathname)}</>;
useEffect(() => {
locationService.updateStateWithLocation();
window.onpopstate = () => {
locationService.updateStateWithLocation();
};
setLoading(false);
}, []);
return <>{isLoading ? null : appRouterSwitch(pathname)}</>;
}
export default App;

View File

@@ -1,7 +1,9 @@
import { useEffect, useState } from "react";
import * as api from "../helpers/api";
import Only from "./common/OnlyWhen";
import { showDialog } from "./Dialog";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import GitHubBadge from "./GitHubBadge";
import "../less/about-site-dialog.less";
interface Props extends DialogProps {}
@@ -36,7 +38,7 @@ 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" />
<Icon.X />
</button>
</div>
<div className="dialog-content-container">
@@ -44,21 +46,25 @@ const AboutSiteDialog: React.FC<Props> = ({ destroy }: Props) => {
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/usememos/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>
</>
);
};
export default function showAboutSiteDialog(): void {
showDialog(
generateDialog(
{
className: "about-site-dialog",
},

View File

@@ -2,32 +2,31 @@ import { IMAGE_URL_REG } from "../helpers/consts";
import * as utils from "../helpers/utils";
import useToggle from "../hooks/useToggle";
import { memoService } from "../services";
import { formatMemoContent } from "../helpers/marked";
import Only from "./common/OnlyWhen";
import Image from "./Image";
import toastHelper from "./Toast";
import { formatMemoContent } from "./Memo";
import "../less/memo.less";
interface Props {
memo: Memo;
handleDeletedMemoAction: (memoId: MemoId) => void;
}
const DeletedMemo: React.FC<Props> = (props: Props) => {
const { memo: propsMemo, handleDeletedMemoAction } = props;
const ArchivedMemo: React.FC<Props> = (props: Props) => {
const { memo: propsMemo } = props;
const memo = {
...propsMemo,
createdAtStr: utils.getDateTimeString(propsMemo.createdTs),
deletedAtStr: utils.getDateTimeString(propsMemo.updatedTs ?? Date.now()),
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);
}
@@ -42,7 +41,7 @@ const DeletedMemo: React.FC<Props> = (props: Props) => {
id: memo.id,
rowStatus: "NORMAL",
});
handleDeletedMemoAction(memo.id);
await memoService.fetchAllMemos();
toastHelper.info("Restored successfully");
} catch (error: any) {
toastHelper.error(error.message);
@@ -56,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
@@ -80,4 +79,4 @@ const DeletedMemo: React.FC<Props> = (props: Props) => {
);
};
export default DeletedMemo;
export default ArchivedMemo;

View File

@@ -0,0 +1,74 @@
import { useEffect, useState } from "react";
import useLoading from "../hooks/useLoading";
import { memoService } from "../services";
import { useAppSelector } from "../store";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import toastHelper from "./Toast";
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}>
<Icon.X className="icon-img" />
</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">No archived memos.</p>
</div>
) : (
<div className="archived-memos-container">
{archivedMemos.map((memo) => (
<ArchivedMemo key={`${memo.id}-${memo.updatedTs}`} memo={memo} />
))}
</div>
)}
</div>
</>
);
};
export default function showArchivedMemoDialog(): void {
generateDialog(
{
className: "archived-memo-dialog",
useAppContext: true,
},
ArchivedMemoDialog,
{}
);
}

View File

@@ -1,7 +1,8 @@
import { useEffect, useState } from "react";
import { validate, ValidatorConfig } from "../helpers/validator";
import { userService } from "../services";
import { showDialog } from "./Dialog";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import toastHelper from "./Toast";
import "../less/change-password-dialog.less";
@@ -55,7 +56,9 @@ const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => {
}
try {
const user = userService.getState().user as User;
await userService.patchUser({
id: user.id,
password: newPassword,
});
toastHelper.info("Password changed.");
@@ -70,7 +73,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" />
<Icon.X />
</button>
</div>
<div className="dialog-content-container">
@@ -94,7 +97,7 @@ const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => {
};
function showChangePasswordDialog() {
showDialog(
generateDialog(
{
className: "change-password-dialog",
},

View File

@@ -1,73 +0,0 @@
import { useEffect } from "react";
import { showDialog } from "./Dialog";
import useLoading from "../hooks/useLoading";
import toastHelper from "./Toast";
import { userService } from "../services";
import "../less/confirm-reset-openid-dialog.less";
interface Props extends DialogProps {}
const ConfirmResetOpenIdDialog: React.FC<Props> = ({ destroy }: Props) => {
const resetBtnClickLoadingState = useLoading(false);
useEffect(() => {
// do nth
}, []);
const handleCloseBtnClick = () => {
destroy();
};
const handleConfirmBtnClick = async () => {
if (resetBtnClickLoadingState.isLoading) {
return;
}
resetBtnClickLoadingState.setLoading();
try {
await userService.patchUser({
resetOpenId: true,
});
} catch (error) {
toastHelper.error("Request reset open API failed.");
return;
}
toastHelper.success("Reset open API succeeded.");
handleCloseBtnClick();
};
return (
<>
<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" />
</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?
</p>
<div className="btns-container">
<span className="btn cancel-btn" onClick={handleCloseBtnClick}>
Cancel
</span>
<span className={`btn confirm-btn ${resetBtnClickLoadingState.isLoading ? "loading" : ""}`} onClick={handleConfirmBtnClick}>
Reset!
</span>
</div>
</div>
</>
);
};
function showConfirmResetOpenIdDialog() {
showDialog(
{
className: "confirm-reset-openid-dialog",
},
ConfirmResetOpenIdDialog
);
}
export default showConfirmResetOpenIdDialog;

View File

@@ -2,7 +2,8 @@ import { memo, useCallback, useEffect, useState } from "react";
import { memoService, shortcutService } from "../services";
import { checkShouldShowMemoWithFilters, filterConsts, getDefaultFilter, relationConsts } from "../helpers/filter";
import useLoading from "../hooks/useLoading";
import { showDialog } from "./Dialog";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import toastHelper from "./Toast";
import Selector from "./common/Selector";
import "../less/create-shortcut-dialog.less";
@@ -100,7 +101,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" />
<Icon.X />
</button>
</div>
<div className="dialog-content-container">
@@ -152,6 +153,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>(<></>);
@@ -185,12 +187,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}
/>
@@ -298,7 +297,7 @@ const FilterInputer: React.FC<MemoFilterInputerProps> = (props: MemoFilterInpute
/>
{inputElements}
<img className="remove-btn" src="/icons/close.svg" onClick={handleRemoveBtnClick} />
<Icon.X className="remove-btn" onClick={handleRemoveBtnClick} />
</div>
);
};
@@ -306,7 +305,7 @@ const FilterInputer: React.FC<MemoFilterInputerProps> = (props: MemoFilterInpute
const MemoFilterInputer: React.FC<MemoFilterInputerProps> = memo(FilterInputer);
export default function showCreateShortcutDialog(shortcutId?: ShortcutId): void {
showDialog(
generateDialog(
{
className: "create-shortcut-dialog",
},

View File

@@ -1,7 +1,5 @@
import { IMAGE_URL_REG } from "../helpers/consts";
import * as utils from "../helpers/utils";
import { formatMemoContent } from "./Memo";
import Only from "./common/OnlyWhen";
import { formatMemoContent } from "../helpers/marked";
import "../less/daily-memo.less";
interface DailyMemo extends Memo {
@@ -20,7 +18,6 @@ const DailyMemo: React.FC<Props> = (props: Props) => {
createdAtStr: utils.getDateTimeString(propsMemo.createdTs),
timeStr: utils.getTimeString(propsMemo.createdTs),
};
const imageUrls = Array.from(memo.content.match(IMAGE_URL_REG) ?? []);
return (
<div className="daily-memo-wrapper">
@@ -28,15 +25,16 @@ const DailyMemo: React.FC<Props> = (props: Props) => {
<span className="normal-text">{memo.timeStr}</span>
</div>
<div className="memo-content-container">
<div className="memo-content-text" dangerouslySetInnerHTML={{ __html: formatMemoContent(memo.content) }}></div>
<Only when={imageUrls.length > 0}>
<div className="images-container">
{imageUrls.map((imgUrl, idx) => (
<img key={idx} crossOrigin="anonymous" src={imgUrl} decoding="async" />
))}
</div>
</Only>
<div
className="memo-content-text"
dangerouslySetInnerHTML={{
__html: formatMemoContent(memo.content, {
inlineImage: true,
}),
}}
></div>
</div>
<div className="split-line"></div>
</div>
);
};

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 * as 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<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.createdTs) >= currentDateStamp &&
utils.getTimeStampByDate(a.createdTs) < currentDateStamp + DAILY_TIMESTAMP
)
.sort((a, b) => utils.getTimeStampByDate(a.createdTs) - utils.getTimeStampByDate(b.createdTs));
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.updatedTs}`} memo={memo} />
))}
</div>
)}
</div>
</>
);
};
export default function showDailyMemoDiaryDialog(datestamp: DateStamp = Date.now()): void {
showDialog(
{
className: "daily-memo-diary-dialog",
},
DailyMemoDiaryDialog,
{ currentDateStamp: datestamp }
);
}

View File

@@ -0,0 +1,121 @@
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 Icon from "./Icon";
import { generateDialog } 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)}>
<Icon.ChevronLeft className="icon-img" />
</button>
<button className="btn-text" onClick={() => setCurrentDateStamp(currentDateStamp + DAILY_TIMESTAMP)}>
<Icon.ChevronRight className="icon-img" />
</button>
<button className="btn-text share" onClick={handleShareBtnClick}>
<Icon.Share className="icon-img" />
</button>
<span className="split-line">/</span>
<button className="btn-text" onClick={() => props.destroy()}>
<Icon.X className="icon-img" />
</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 {
generateDialog(
{
className: "daily-review-dialog",
useAppContext: true,
},
DailyReviewDialog,
{ currentDateStamp: datestamp }
);
}

View File

@@ -1,8 +1,8 @@
import { createRoot } from "react-dom/client";
import { Provider } from "react-redux";
import store from "../store";
import { ANIMATION_DURATION } from "../helpers/consts";
import "../less/dialog.less";
import { ANIMATION_DURATION } from "../../helpers/consts";
import store from "../../store";
import "../../less/base-dialog.less";
interface DialogConfig {
className: string;
@@ -32,7 +32,7 @@ const BaseDialog: React.FC<Props> = (props: Props) => {
);
};
export function showDialog<T extends DialogProps>(
export function generateDialog<T extends DialogProps>(
config: DialogConfig,
DialogComponent: React.FC<T>,
props?: Omit<T, "destroy">

View File

@@ -0,0 +1,85 @@
import Icon from "../Icon";
import { generateDialog } from "./BaseDialog";
import "../../less/common-dialog.less";
type DialogStyle = "info" | "warning";
interface Props extends DialogProps {
title: string;
content: string;
style?: DialogStyle;
closeBtnText?: string;
confirmBtnText?: string;
onClose?: () => void;
onConfirm?: () => void;
}
const defaultProps = {
title: "",
content: "",
style: "info",
closeBtnText: "Close",
confirmBtnText: "Confirm",
onClose: () => null,
onConfirm: () => null,
};
const CommonDialog: React.FC<Props> = (props: Props) => {
const { title, content, destroy, closeBtnText, confirmBtnText, onClose, onConfirm, style } = {
...defaultProps,
...props,
};
const handleCloseBtnClick = () => {
onClose();
destroy();
};
const handleConfirmBtnClick = async () => {
onConfirm();
destroy();
};
return (
<>
<div className="dialog-header-container">
<p className="title-text">{title}</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<Icon.X />
</button>
</div>
<div className="dialog-content-container">
<p className="content-text">{content}</p>
<div className="btns-container">
<span className="btn cancel-btn" onClick={handleCloseBtnClick}>
{closeBtnText}
</span>
<span className={`btn confirm-btn ${style}`} onClick={handleConfirmBtnClick}>
{confirmBtnText}
</span>
</div>
</div>
</>
);
};
interface CommonDialogProps {
title: string;
content: string;
className?: string;
style?: DialogStyle;
closeBtnText?: string;
confirmBtnText?: string;
onClose?: () => void;
onConfirm?: () => void;
}
export const showCommonDialog = (props: CommonDialogProps) => {
generateDialog(
{
className: `common-dialog ${props?.className ?? ""}`,
},
CommonDialog,
props
);
};

View File

@@ -0,0 +1 @@
export { generateDialog } from "./BaseDialog";

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,

View File

@@ -0,0 +1,31 @@
import { useEffect, useState } from "react";
import * as api from "../helpers/api";
import Icon from "./Icon";
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">
<Icon.GitHub className="icon-img" />
Star
</div>
<div className="count-text">
{starCount || ""}
<span className="icon-text">🌟</span>
</div>
</a>
);
};
export default GitHubBadge;

View File

@@ -0,0 +1,3 @@
import * as Icon from "react-feather";
export default Icon;

View File

@@ -1,29 +1,68 @@
import { memo } from "react";
import { escape } from "lodash-es";
import { IMAGE_URL_REG, LINK_REG, MEMO_LINK_REG, TAG_REG, UNKNOWN_ID } from "../helpers/consts";
import { parseMarkedToHtml, parseRawTextToHtml } from "../helpers/marked";
import * as utils from "../helpers/utils";
import useToggle from "../hooks/useToggle";
import { editorStateService, memoService } from "../services";
import { memo, useEffect, useRef, useState } from "react";
import { indexOf } from "lodash-es";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { IMAGE_URL_REG, UNKNOWN_ID } from "../helpers/consts";
import { DONE_BLOCK_REG, formatMemoContent, TODO_BLOCK_REG } from "../helpers/marked";
import { editorStateService, locationService, memoService, userService } from "../services";
import Icon from "./Icon";
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: 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 = {
...propsMemo,
createdAtStr: utils.getDateTimeString(propsMemo.createdTs),
};
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);
@@ -49,28 +88,18 @@ const Memo: React.FC<Props> = (props: Props) => {
editorStateService.setEditMemoWithId(memo.id);
};
const handleDeleteMemoClick = async () => {
if (showConfirmDeleteBtn) {
try {
await memoService.patchMemo({
id: memo.id,
rowStatus: "ARCHIVED",
});
} catch (error: any) {
toastHelper.error(error.message);
}
if (editorStateService.getState().editMemoId === memo.id) {
editorStateService.clearEditMemo();
}
} 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();
}
};
@@ -91,53 +120,110 @@ 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.pinned ? "pinned" : ""}`} 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.pinned}>
<span className="ml-2">PINNED</span>
<div className="status-text-container" onClick={handleShowMemoStoryDialog}>
<span className="time-text">{createdAtStr}</span>
<Only when={memo.visibility !== "PRIVATE" && !isVisitorMode}>
<span className={`status-text ${memo.visibility.toLocaleLowerCase()}`}>{memo.visibility}</span>
</Only>
</span>
<div className="btns-container">
</div>
<div className={`btns-container ${userService.isVisitorMode() ? "!hidden" : ""}`}>
<span className="btn more-action-btn">
<img className="icon-img" src="/icons/more.svg" />
<Icon.MoreHorizontal className="icon-img" />
</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.pinned ? "Unpin" : "Pin"}
</span>
<div className="btns-container">
<div className="btn" onClick={handleTogglePinMemoBtnClick}>
<Icon.MapPin className={`icon-img ${memo.pinned ? "" : "opacity-20"}`} />
<span className="tip-text">{memo.pinned ? "Unpin" : "Pin"}</span>
</div>
<div className="btn" onClick={handleEditMemoClick}>
<Icon.Edit3 className="icon-img" />
<span className="tip-text">Edit</span>
</div>
<div className="btn" onClick={handleGenMemoImageBtnClick}>
<Icon.Share className="icon-img" />
<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"}
<Icon.ChevronRight className="icon-img" />
</span>
</div>
)}
<Only when={imageUrls.length > 0}>
<div className="images-wrapper">
{imageUrls.map((imgUrl, idx) => (
@@ -149,40 +235,4 @@ 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("");
content = parseMarkedToHtml(content);
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
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;
}
export default memo(Memo);

View File

@@ -1,15 +1,15 @@
import { useState, useEffect, useCallback } from "react";
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 { editorStateService, memoService } from "../services";
import { parseHtmlToRawText } from "../helpers/marked";
import { formatMemoContent } from "./Memo";
import toastHelper from "./Toast";
import { showDialog } from "./Dialog";
import { formatMemoContent, parseHtmlToRawText } from "../helpers/marked";
import Only from "./common/OnlyWhen";
import toastHelper from "./Toast";
import { generateDialog } from "./Dialog";
import Image from "./Image";
import "../less/memo-card-dialog.less";
import "../less/memo-content.less";
import Selector from "./common/Selector";
import Icon from "./Icon";
interface LinkedMemo extends Memo {
createdAtStr: string;
@@ -26,7 +26,12 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
});
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"));
const visibilityList = [
{ text: "PUBLIC", value: "PUBLIC" },
{ text: "PROTECTED", value: "PROTECTED" },
{ text: "PRIVATE", value: "PRIVATE" },
];
useEffect(() => {
const fetchLinkedMemos = async () => {
@@ -35,8 +40,12 @@ 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 memoTemp = memoService.getMemoById(Number(id));
const id = Number(matchRes[2]);
if (id === memo.id) {
continue;
}
const memoTemp = memoService.getMemoById(id);
if (memoTemp) {
linkMemos.push({
...memoTemp,
@@ -51,6 +60,7 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
const linkedMemos = await memoService.getLinkedMemos(memo.id);
setLinkedMemos(
linkedMemos
.filter((m) => m.rowStatus === "NORMAL" && m.id !== memo.id)
.sort((a, b) => utils.getTimeStampByDate(b.createdTs) - utils.getTimeStampByDate(a.createdTs))
.map((m) => ({
...m,
@@ -94,22 +104,55 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
setMemo(memo);
}, []);
const handleEditMemoBtnClick = useCallback(() => {
const handleEditMemoBtnClick = () => {
props.destroy();
editorStateService.setEditMemoWithId(memo.id);
}, [memo.id]);
};
const handleVisibilitySelectorChange = async (visibility: Visibility) => {
if (memo.visibility === visibility) {
return;
}
await memoService.patchMemo({
id: memo.id,
visibility: visibility,
});
setMemo({
...memo,
visibility: visibility,
});
};
return (
<>
<Only when={!userService.isVisitorMode()}>
<div className="card-header-container">
<div className="visibility-selector-container">
<Icon.Eye className="icon-img" />
<Selector
className="visibility-selector"
dataSource={visibilityList}
value={memo.visibility}
handleValueChanged={(value) => handleVisibilitySelectorChange(value as Visibility)}
/>
</div>
</div>
</Only>
<div className="memo-card-container">
<div className="header-container">
<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 edit-btn" onClick={handleEditMemoBtnClick}>
<Icon.Edit3 className="icon-img" />
</button>
<span className="split-line">/</span>
</>
</Only>
<button className="btn close-btn" onClick={props.destroy}>
<img className="icon-img" src="/icons/close.svg" />
<Icon.X className="icon-img" />
</button>
</div>
</div>
@@ -150,11 +193,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 +207,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>
);
@@ -180,7 +223,7 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
};
export default function showMemoCardDialog(memo: Memo): void {
showDialog(
generateDialog(
{
className: "memo-card-dialog",
},

View File

@@ -1,51 +1,27 @@
import React, { useCallback, useEffect, useMemo, useRef } from "react";
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 { UNKNOWN_ID } from "../helpers/consts";
import * as storage from "../helpers/storage";
import useToggle from "../hooks/useToggle";
import Icon from "./Icon";
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 editorState = useAppSelector((state) => state.editor);
const tags = useAppSelector((state) => state.memo.tags);
const [isTagSeletorShown, toggleTagSeletor] = useToggle(false);
const [state, setState] = useState<State>({
isUploadingResource: false,
fullscreen: false,
});
const editorRef = useRef<EditorRefActions>(null);
const prevGlobalStateRef = useRef(editorState);
const tagSeletorRef = useRef<HTMLDivElement>(null);
@@ -53,7 +29,7 @@ const MemoEditor: React.FC<Props> = () => {
useEffect(() => {
if (editorState.markMemoId && editorState.markMemoId !== UNKNOWN_ID) {
const editorCurrentValue = editorRef.current?.getContent();
const memoLinkText = `${editorCurrentValue ? "\n" : ""}Mark: [@MEMO](${editorState.markMemoId})`;
const memoLinkText = `${editorCurrentValue ? "\n" : ""}Mark: @[MEMO](${editorState.markMemoId})`;
editorRef.current?.insertText(memoLinkText);
editorStateService.clearMarkMemo();
}
@@ -74,17 +50,13 @@ const MemoEditor: React.FC<Props> = () => {
}, [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})`);
}
}
};
@@ -95,7 +67,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})`);
}
}
};
@@ -110,10 +82,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);
@@ -123,32 +95,47 @@ 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")) {
toastHelper.error("Only image file supported.");
return;
}
return url;
} catch (error: any) {
toastHelper.error(error);
}
}, []);
try {
const image = await resourceService.upload(file);
const url = `/h/r/${image.id}/${image.filename}`;
return url;
} catch (error: any) {
toastHelper.error("Failed to upload image\n" + JSON.stringify(error, null, 4));
} finally {
setState({
...state,
isUploadingResource: false,
});
}
},
[state]
);
const handleSaveBtnClick = useCallback(async (content: string) => {
const handleSaveBtnClick = async (content: string) => {
if (content === "") {
toastHelper.error("Content can't be empty");
return;
}
const { editMemoId } = editorStateService.getState();
try {
const { editMemoId } = editorStateService.getState();
if (editMemoId && editMemoId !== UNKNOWN_ID) {
const prevMemo = memoService.getMemoById(editMemoId ?? UNKNOWN_ID);
@@ -169,8 +156,12 @@ const MemoEditor: React.FC<Props> = () => {
toastHelper.error(error.message);
}
setState({
...state,
fullscreen: false,
});
setEditorContentCache("");
}, []);
};
const handleCancelBtnClick = useCallback(() => {
editorStateService.clearEditMemo();
@@ -180,61 +171,6 @@ const MemoEditor: React.FC<Props> = () => {
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(() => {
@@ -250,16 +186,23 @@ 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();
}
}, []);
@@ -270,37 +213,42 @@ const MemoEditor: React.FC<Props> = () => {
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">
<Icon.Hash className="icon-img" />
<div ref={tagSeletorRef} className="tag-list" onClick={handleTagSeletorClick}>
{tags.map((t) => {
return <span key={t}>{t}</span>;
})}
</div>
</div>
<button className="action-btn">
<Icon.Image className="icon-img" onClick={handleUploadFileBtnClick} />
<span className={`tip-text ${state.isUploadingResource ? "!block" : ""}`}>Uploading</span>
</button>
<button className="action-btn" onClick={handleFullscreenBtnClick}>
{state.fullscreen ? <Icon.Minimize className="icon-img" /> : <Icon.Maximize className="icon-img" />}
</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>
);
};

View File

@@ -8,12 +8,13 @@ interface FilterProps {}
const MemoFilter: React.FC<FilterProps> = () => {
const query = useAppSelector((state) => state.location.query);
const { tag: tagQuery, duration, type: memoType, text: textQuery, shortcutId } = query ?? {};
useAppSelector((state) => state.shortcut.shortcuts);
const { tag: tagQuery, duration, type: memoType, text: textQuery, shortcutId } = query;
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 " + (shortcut ? "" : "hidden")}

View File

@@ -1,11 +1,11 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { locationService, memoService, shortcutService } from "../services";
import { useEffect, useRef, useState } from "react";
import { memoService, shortcutService } from "../services";
import { useAppSelector } from "../store";
import { IMAGE_URL_REG, LINK_REG, MEMO_LINK_REG, TAG_REG } from "../helpers/consts";
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 {}
@@ -58,7 +58,7 @@ const MemoList: React.FC<Props> = () => {
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;
@@ -83,7 +83,6 @@ const MemoList: React.FC<Props> = () => {
.fetchAllMemos()
.then(() => {
setFetchStatus(false);
memoService.updateTagsState();
})
.catch(() => {
toastHelper.error("😭 Fetching failed, please try again later.");
@@ -94,33 +93,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.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>

View File

@@ -1,76 +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<Memo[]>([]);
useEffect(() => {
memoService
.fetchDeletedMemos()
.then((result) => {
setDeletedMemos(result);
})
.catch((error) => {
toastHelper.error("Failed to fetch deleted memos: ", error);
})
.finally(() => {
loadingState.setFinish();
});
locationService.clearQuery();
}, []);
const handleDeletedMemoAction = useCallback((memoId: MemoId) => {
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.updatedTs}`} memo={memo} handleDeletedMemoAction={handleDeletedMemoAction} />
))}
</div>
)}
</div>
</>
);
};
export default function showMemoTrashDialog(): void {
showDialog(
{
className: "memo-trash-dialog",
useAppContext: true,
},
MemoTrashDialog,
{}
);
}

View File

@@ -1,7 +1,9 @@
import { useCallback, useEffect, useState } from "react";
import { useAppSelector } from "../store";
import SearchBar from "./SearchBar";
import { memoService, shortcutService } from "../services";
import { useAppSelector } from "../store";
import Icon from "./Icon";
import SearchBar from "./SearchBar";
import { toggleSiderbar } from "./Sidebar";
import "../less/memos-header.less";
let prevRequestTimestamp = Date.now();
@@ -25,7 +27,7 @@ const MemosHeader: React.FC<Props> = () => {
}
}, [query, shortcuts]);
const handleMemoTextClick = useCallback(() => {
const handleTitleTextClick = useCallback(() => {
const now = Date.now();
if (now - prevRequestTimestamp > 10 * 1000) {
prevRequestTimestamp = now;
@@ -37,8 +39,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}>
<Icon.Menu className="icon-img" />
</div>
<span className="title-text" onClick={handleTitleTextClick}>
{titleText}
</span>
</div>
<SearchBar />
</div>

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,6 +30,20 @@ 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();
};
@@ -44,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>
);
};

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
import * as utils from "../helpers/utils";
import { showDialog } from "./Dialog";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import "../less/preview-image-dialog.less";
interface Props extends DialogProps {
@@ -8,62 +8,36 @@ 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}>
<Icon.X className="icon-img" />
</button>
<button className="btn" onClick={handleDownloadBtnClick}>
<Icon.Download className="icon-img" />
</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>
</>
);
};
export default function showPreviewImageDialog(imgUrl: string): void {
showDialog(
generateDialog(
{
className: "preview-image-dialog",
},

View File

@@ -0,0 +1,163 @@
import { useEffect, useState } from "react";
import * as utils from "../helpers/utils";
import useLoading from "../hooks/useLoading";
import { resourceService } from "../services";
import Dropdown from "./common/Dropdown";
import { generateDialog } from "./Dialog";
import { showCommonDialog } from "./Dialog/CommonDialog";
import toastHelper from "./Toast";
import Icon from "./Icon";
import "../less/resources-dialog.less";
interface Props extends DialogProps {}
interface State {
resources: Resource[];
isUploadingResource: boolean;
}
const ResourcesDialog: React.FC<Props> = (props: Props) => {
const { destroy } = props;
const loadingState = useLoading();
const [state, setState] = useState<State>({
resources: [],
isUploadingResource: false,
});
useEffect(() => {
fetchResources()
.catch((error) => {
toastHelper.error("Failed to fetch archived memos: ", error);
})
.finally(() => {
loadingState.setFinish();
});
}, []);
const fetchResources = async () => {
const data = await resourceService.getResourceList();
setState({
...state,
resources: data,
});
};
const handleUploadFileBtnClick = async () => {
if (state.isUploadingResource) {
return;
}
const inputEl = document.createElement("input");
inputEl.type = "file";
inputEl.multiple = false;
inputEl.accept = "image/png, image/gif, image/jpeg";
inputEl.onchange = async () => {
if (!inputEl.files || inputEl.files.length === 0) {
return;
}
setState({
...state,
isUploadingResource: true,
});
const file = inputEl.files[0];
try {
await resourceService.upload(file);
} catch (error: any) {
toastHelper.error("Failed to upload resource\n" + JSON.stringify(error, null, 4));
} finally {
setState({
...state,
isUploadingResource: false,
});
await fetchResources();
}
};
inputEl.click();
};
const handleCopyResourceLinkBtnClick = (resource: Resource) => {
utils.copyTextToClipboard(`${window.location.origin}/h/r/${resource.id}/${resource.filename}`);
toastHelper.success("Succeed to copy resource link to clipboard");
};
const handleDeleteResourceBtnClick = (resource: Resource) => {
showCommonDialog({
title: `Delete Resource`,
content: `Are you sure to delete this resource? THIS ACTION IS IRREVERSIABLE.❗️`,
style: "warning",
onConfirm: async () => {
await resourceService.deleteResourceById(resource.id);
await fetchResources();
},
});
};
return (
<>
<div className="dialog-header-container">
<p className="title-text">
<span className="icon-text">🌄</span>
Resources
</p>
<button className="btn close-btn" onClick={destroy}>
<Icon.X className="icon-img" />
</button>
</div>
<div className="dialog-content-container">
<div className="tip-text-container">(👨💻WIP) View your static resources in memos. e.g. images</div>
<div className="upload-resource-container" onClick={() => handleUploadFileBtnClick()}>
<div className="upload-resource-btn">
<Icon.File className="icon-img" />
<span>Upload</span>
</div>
</div>
{loadingState.isLoading ? (
<div className="loading-text-container">
<p className="tip-text">fetching data...</p>
</div>
) : (
<div className="resource-table-container">
<div className="fields-container">
<span className="field-text">ID</span>
<span className="field-text name-text">NAME</span>
<span className="field-text">TYPE</span>
<span></span>
</div>
{state.resources.length === 0 ? (
<p className="tip-text">No resource.</p>
) : (
state.resources.map((resource) => (
<div key={resource.id} className="resource-container">
<span className="field-text">{resource.id}</span>
<span className="field-text name-text">{resource.filename}</span>
<span className="field-text">{resource.type}</span>
<div className="buttons-container">
<Dropdown className="actions-dropdown">
<button onClick={() => handleCopyResourceLinkBtnClick(resource)}>Copy Link</button>
<button className="delete-btn" onClick={() => handleDeleteResourceBtnClick(resource)}>
Delete
</button>
</Dropdown>
</div>
</div>
))
)}
</div>
)}
</div>
</>
);
};
export default function showResourcesDialog() {
generateDialog(
{
className: "resources-dialog",
useAppContext: true,
},
ResourcesDialog,
{}
);
}

View File

@@ -2,6 +2,7 @@ import { locationService } from "../services";
import { useAppSelector } from "../store";
import { memoSpecialTypes } from "../helpers/filter";
import "../less/search-bar.less";
import Icon from "./Icon";
interface Props {}
@@ -24,7 +25,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" />
<Icon.Search className="icon-img" />
<input className="text-input" type="text" placeholder="" onChange={handleTextQueryInput} />
</div>
<div className="quickly-action-wrapper">

View File

@@ -1,10 +1,11 @@
import { useState } from "react";
import { useAppSelector } from "../store";
import { showDialog } from "./Dialog";
import { generateDialog } from "./Dialog";
import MyAccountSection from "./Settings/MyAccountSection";
import PreferencesSection from "./Settings/PreferencesSection";
import MemberSection from "./Settings/MemberSection";
import "../less/setting-dialog.less";
import Icon from "./Icon";
interface Props extends DialogProps {}
@@ -30,31 +31,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" />
<Icon.X className="icon-img" />
</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>
@@ -72,7 +77,7 @@ const SettingDialog: React.FC<Props> = (props: Props) => {
};
export default function showSettingDialog(): void {
showDialog(
generateDialog(
{
className: "setting-dialog",
useAppContext: true,

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