Compare commits

...

100 Commits

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

* chore: add space

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

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

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

* chore: related frontend changes

* chore: fix migration file

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

* refactor: visitor view

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

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

* chore: add a normal user to seed

* feat: page for other members

* fix: replace window.location

* fix: can not get username on home

* fix: check userID

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

* fix: do not redirect on wrong path

* fix: path error when clicked heatmap

* refactor: revise for review

* chore: remove unused import

* refactor: revise for review

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

* chore: eslint for import sort

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

* fix: replenish duration

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

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

* feat: schema migration

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

* chore: release `v0.1.3`

* fix: create migration_history table

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

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

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

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

* chore: update expand text

* chore: rename
2022-06-22 08:36:09 +08:00
boojack ceef257348 chore: get tags from exist memos 2022-06-21 23:29:07 +08:00
boojack babeb468c1 chore: update daily dialog style 2022-06-21 22:29:14 +08:00
boojack 85ce72282b fix: response type 2022-06-21 22:29:06 +08:00
Steven f80f0f2422 chore: use markdown image syntax (#83) 2022-06-21 22:14:52 +08:00
Steven 9f81362027 feat: add /api/tag (#82) 2022-06-21 21:58:33 +08:00
boojack cc54be0d1d chore: fix date selector style 2022-06-21 08:43:45 +08:00
Steven 40680a5e0f chore: update memo action buttons style (#80)
chore: update memo action btn style
2022-06-21 08:35:46 +08:00
Steven f849a94dc5 chore: show daily memos view in sidebar (#79) 2022-06-21 08:16:42 +08:00
STEVEN 50fee8b0f4 chore: release v0.1.1 (#77) 2022-06-19 11:46:34 +08:00
158 changed files with 3332 additions and 2767 deletions
+12 -6
View File
@@ -9,23 +9,29 @@ jobs:
build-and-push-dev-image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Login to Docker Hub
uses: docker/login-action@v1
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_NEOSMEMO_USERNAME }}
username: neosmemo
password: ${{ secrets.DOCKER_NEOSMEMO_TOKEN }}
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
uses: docker/setup-buildx-action@v2
with:
install: true
- name: Build and Push
id: docker_build
uses: docker/build-push-action@v2
uses: docker/build-push-action@v3
with:
context: ./
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ secrets.DOCKER_NEOSMEMO_USERNAME }}/memos:dev
tags: neosmemo/memos:dev
@@ -10,7 +10,10 @@ jobs:
build-and-push-release-image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Extract build args
# Extract version from branch name
@@ -19,20 +22,23 @@ jobs:
echo "VERSION=${GITHUB_REF_NAME#release/v}" >> $GITHUB_ENV
- name: Login to Docker Hub
uses: docker/login-action@v1
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_NEOSMEMO_USERNAME }}
username: neosmemo
password: ${{ secrets.DOCKER_NEOSMEMO_TOKEN }}
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
uses: docker/setup-buildx-action@v2
with:
install: true
- name: Build and Push
id: docker_build
uses: docker/build-push-action@v2
uses: docker/build-push-action@v3
with:
context: ./
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ secrets.DOCKER_NEOSMEMO_USERNAME }}/memos:${{ env.VERSION }}
tags: neosmemo/memos:latest, neosmemo/memos:${{ env.VERSION }}
@@ -0,0 +1,37 @@
name: build-and-push-test-image
on:
push:
branches:
- "test/*"
jobs:
build-and-push-dev-image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: neosmemo
password: ${{ secrets.DOCKER_NEOSMEMO_TOKEN }}
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
with:
install: true
- name: Build and Push
id: docker_build
uses: docker/build-push-action@v3
with:
context: ./
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: neosmemo/memos:test
+2 -20
View File
@@ -1,28 +1,10 @@
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Temp output
*.out
*.log
tmp
# Air (hot reload) generated
.air
# Frontend asset
dist
# Dev database
data
web/dist
# build folder
build
memos-build
.DS_Store
+5 -4
View File
@@ -1,5 +1,5 @@
# Build frontend dist.
FROM node:14.18.2-alpine3.14 AS frontend
FROM node:16.15.0-alpine AS frontend
WORKDIR /frontend-build
COPY ./web/ .
@@ -8,23 +8,24 @@ RUN yarn
RUN yarn build
# Build backend exec file.
FROM golang:1.16.12-alpine3.15 AS backend
FROM golang:1.18.3-alpine3.16 AS backend
WORKDIR /backend-build
RUN apk update
RUN apk --no-cache add gcc musl-dev
COPY . .
COPY --from=frontend /frontend-build/dist ./server/dist
RUN go build \
-o memos \
./bin/server/main.go
# Make workspace with above generated files.
FROM alpine:3.14.3 AS monolithic
FROM alpine:3.16.0 AS monolithic
WORKDIR /usr/local/memos
COPY --from=backend /backend-build/memos /usr/local/memos/
COPY --from=frontend /frontend-build/dist /usr/local/memos/web/dist
# Directory to store the data, which can be referenced as the mounting point.
RUN mkdir -p /var/opt/memos
+17 -13
View File
@@ -4,38 +4,42 @@
<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://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://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;
- 👍 Write in the plain textarea without any burden;
- 🤠 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:5230 --volume ~/.memos/:/var/opt/memos -e mode=prod -e port=5230 neosmemo/memos:0.1.0
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.
⚠️ Please DO NOT use `dev` tag of docker image if you have no experience.
## 🏗 Development
Memos is built with a curated tech stack. It is optimized for developer experience and is very easy to start working on the code:
+1 -1
View File
@@ -1,6 +1,6 @@
package api
type Login struct {
type Signin struct {
Email string `json:"email"`
Password string `json:"password"`
}
+34 -6
View File
@@ -1,5 +1,25 @@
package api
// Visibility is the type of a visibility.
type Visibility string
const (
// Public is the PUBLIC visibility.
Public Visibility = "PUBLIC"
// Privite is the PRIVATE visibility.
Privite Visibility = "PRIVATE"
)
func (e Visibility) String() string {
switch e {
case Public:
return "PUBLIC"
case Privite:
return "PRIVATE"
}
return "PRIVATE"
}
type Memo struct {
ID int `json:"id"`
@@ -10,8 +30,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 +42,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 +53,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,8 +65,13 @@ type MemoFind struct {
CreatorID *int `json:"creatorId"`
// Domain specific fields
Pinned *bool
Tag *string
Pinned *bool
ContentSearch *string
Visibility *Visibility
// Pagination
Limit int
Offset int
}
type MemoDelete struct {
+2 -2
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"`
}
+4 -4
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"
}
+14 -10
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)
+1 -1
View File
@@ -1,6 +1,6 @@
package main
import "memos/bin/server/cmd"
import "github.com/usememos/memos/bin/server/cmd"
func main() {
cmd.Execute()
+58 -1
View File
@@ -1,4 +1,61 @@
package common
import (
"strconv"
"strings"
)
// Version is the service current released version.
var Version = "0.1.0"
// Semantic versioning: https://semver.org/
var Version = "0.2.2"
// DevVersion is the service current development version.
var DevVersion = "0.2.2"
func GetCurrentVersion(mode string) string {
if mode == "dev" {
return DevVersion
}
return Version
}
func GetMinorVersion(version string) string {
versionList := strings.Split(version, ".")
if len(versionList) < 3 {
return ""
}
return versionList[0] + "." + versionList[1]
}
// convSemanticVersionToInt converts version string to int.
func convSemanticVersionToInt(version string) int {
versionList := strings.Split(version, ".")
if len(versionList) < 3 {
return 0
}
major, err := strconv.Atoi(versionList[0])
if err != nil {
return 0
}
minor, err := strconv.Atoi(versionList[1])
if err != nil {
return 0
}
patch, err := strconv.Atoi(versionList[2])
if err != nil {
return 0
}
return major*10000 + minor*100 + patch
}
// IsVersionGreaterThanOrEqualTo returns true if version is greater than or equal to target.
func IsVersionGreaterOrEqualThan(version, target string) bool {
return convSemanticVersionToInt(version) >= convSemanticVersionToInt(target)
}
// IsVersionGreaterThan returns true if version is greater than target.
func IsVersionGreaterThan(version, target string) bool {
return convSemanticVersionToInt(version) > convSemanticVersionToInt(target)
}
+1 -1
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

+10
View File
@@ -0,0 +1,10 @@
# Usage: sh ./scripts/build.sh
set -e
cd "$(dirname "$0")/../"
echo "Start building..."
go build -o ./memos-build/memos ./bin/server/main.go
echo "Build finished"
+21 -22
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
})
}
+28 -6
View File
@@ -2,11 +2,12 @@ package server
import (
"fmt"
"memos/api"
"memos/common"
"net/http"
"strconv"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
"github.com/gorilla/sessions"
"github.com/labstack/echo-contrib/session"
"github.com/labstack/echo/v4"
@@ -32,7 +33,6 @@ func setUserSession(c echo.Context, user *api.User) error {
if err != nil {
return fmt.Errorf("failed to set session, err: %w", err)
}
return nil
}
@@ -48,18 +48,40 @@ func removeUserSession(c echo.Context) error {
if err != nil {
return fmt.Errorf("failed to set session, err: %w", err)
}
return nil
}
// Use session to store user.id.
func BasicAuthMiddleware(s *Server, next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// Skips auth
if common.HasPrefixes(c.Path(), "/api/auth", "/api/ping", "/api/status") {
// Skip auth for some paths.
if common.HasPrefixes(c.Path(), "/api/auth", "/api/ping", "/api/status", "/api/user/:userId") {
return next(c)
}
// If there is openId in query string and related user is found, then skip auth.
openID := c.QueryParam("openId")
if openID != "" {
userFind := &api.UserFind{
OpenID: &openID,
}
user, err := s.Store.FindUser(userFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user by open_id").SetInternal(err)
}
if user != nil {
// Stores userID into context.
c.Set(getUserIDContextKey(), user.ID)
return next(c)
}
}
if common.HasPrefixes(c.Path(), "/api/memo", "/api/tag", "/api/shortcut") && c.Request().Method == http.MethodGet {
if _, err := strconv.Atoi(c.QueryParam("creatorId")); err == nil {
return next(c)
}
}
sess, err := session.Get("session", c)
if err != nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing session").SetInternal(err)
+13
View File
@@ -0,0 +1,13 @@
<!-- THIS FILE IS A PLACEHOLDER AND SHOULD NOT BE CHANGED -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Memos</title>
</head>
<body>
<p>No frontend embeded.</p>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
package server
import (
"embed"
"io/fs"
"net/http"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
//go:embed dist
var embeddedFiles embed.FS
func getFileSystem() http.FileSystem {
fs, err := fs.Sub(embeddedFiles, "dist")
if err != nil {
panic(err)
}
return http.FS(fs)
}
func embedFrontend(e *echo.Echo) {
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
HTML5: true,
Filesystem: getFileSystem(),
}))
}
+53 -12
View File
@@ -3,11 +3,12 @@ package server
import (
"encoding/json"
"fmt"
"memos/api"
"memos/common"
"net/http"
"strconv"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
"github.com/labstack/echo/v4"
)
@@ -21,6 +22,11 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo request").SetInternal(err)
}
if memoCreate.Visibility == nil || *memoCreate.Visibility == "" {
private := api.Privite
memoCreate.Visibility = &private
}
memo, err := s.Store.CreateMemo(memoCreate)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create memo").SetInternal(err)
@@ -30,7 +36,6 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(memo)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode memo response").SetInternal(err)
}
return nil
})
@@ -56,14 +61,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
} else {
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find memo")
}
memoFind.CreatorID = &userID
}
// Only can get PUBLIC memos in visitor mode
_, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
publicVisibility := api.Public
memoFind.Visibility = &publicVisibility
}
rowStatus := api.RowStatus(c.QueryParam("rowStatus"))
@@ -77,7 +96,14 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
}
tag := c.QueryParam("tag")
if tag != "" {
memoFind.Tag = &tag
contentSearch := "#" + tag + " "
memoFind.ContentSearch = &contentSearch
}
if limit, err := strconv.Atoi(c.QueryParam("limit")); err == nil {
memoFind.Limit = limit
}
if offset, err := strconv.Atoi(c.QueryParam("offset")); err == nil {
memoFind.Offset = offset
}
list, err := s.Store.FindMemoList(memoFind)
@@ -89,7 +115,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
})
@@ -128,7 +153,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
})
@@ -154,7 +178,6 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(memo)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode memo response").SetInternal(err)
}
return nil
})
@@ -173,8 +196,26 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to delete memo ID: %v", memoID)).SetInternal(err)
}
c.JSON(http.StatusOK, true)
return c.JSON(http.StatusOK, true)
})
g.GET("/memo/amount", func(c echo.Context) error {
userID := c.Get(getUserIDContextKey()).(int)
normalRowStatus := api.Normal
memoFind := &api.MemoFind{
CreatorID: &userID,
RowStatus: &normalRowStatus,
}
memoList, err := s.Store.FindMemoList(memoFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo list").SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(len(memoList))); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode memo amount").SetInternal(err)
}
return nil
})
}
+22 -24
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
}
+50 -5
View File
@@ -4,10 +4,11 @@ import (
"encoding/json"
"fmt"
"io/ioutil"
"memos/api"
"net/http"
"strconv"
"github.com/usememos/memos/api"
"github.com/labstack/echo/v4"
)
@@ -56,7 +57,6 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(resource)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode resource response").SetInternal(err)
}
return nil
})
@@ -74,6 +74,53 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(list)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode resource list response").SetInternal(err)
}
return nil
})
g.GET("/resource/:resourceId", func(c echo.Context) error {
resourceID, err := strconv.Atoi(c.Param("resourceId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
}
userID := c.Get(getUserIDContextKey()).(int)
resourceFind := &api.ResourceFind{
ID: &resourceID,
CreatorID: &userID,
}
resource, err := s.Store.FindResource(resourceFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource").SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(resource)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode resource response").SetInternal(err)
}
return nil
})
g.GET("/resource/:resourceId/blob", func(c echo.Context) error {
resourceID, err := strconv.Atoi(c.Param("resourceId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
}
userID := c.Get(getUserIDContextKey()).(int)
resourceFind := &api.ResourceFind{
ID: &resourceID,
CreatorID: &userID,
}
resource, err := s.Store.FindResource(resourceFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource").SetInternal(err)
}
c.Response().Writer.WriteHeader(http.StatusOK)
c.Response().Writer.Header().Set("Content-Type", resource.Type)
if _, err := c.Response().Writer.Write(resource.Blob); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to write resource blob").SetInternal(err)
}
return nil
})
@@ -91,8 +138,6 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete resource").SetInternal(err)
}
c.JSON(http.StatusOK, true)
return nil
return c.JSON(http.StatusOK, true)
})
}
+6 -9
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"
@@ -37,14 +38,9 @@ func NewServer(profile *profile.Profile) *Server {
Timeout: 30 * time.Second,
}))
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
Skipper: middleware.DefaultSkipper,
Root: "web/dist",
Browse: false,
HTML5: true,
}))
embedFrontend(e)
// In dev mode, set the const secret key to make login session persistence.
// In dev mode, set the const secret key to make signin session persistence.
secret := []byte("usememos")
if profile.Mode == "prod" {
secret = securecookie.GenerateRandomKey(16)
@@ -70,6 +66,7 @@ func NewServer(profile *profile.Profile) *Server {
s.registerMemoRoutes(apiGroup)
s.registerShortcutRoutes(apiGroup)
s.registerResourceRoutes(apiGroup)
s.registerTagRoutes(apiGroup)
return s
}
+15 -11
View File
@@ -3,10 +3,11 @@ package server
import (
"encoding/json"
"fmt"
"memos/api"
"net/http"
"strconv"
"github.com/usememos/memos/api"
"github.com/labstack/echo/v4"
)
@@ -29,7 +30,6 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(shortcut)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode shortcut response").SetInternal(err)
}
return nil
})
@@ -55,15 +55,23 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(shortcut)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode shortcut response").SetInternal(err)
}
return nil
})
g.GET("/shortcut", func(c echo.Context) error {
userID := c.Get(getUserIDContextKey()).(int)
shortcutFind := &api.ShortcutFind{
CreatorID: &userID,
shortcutFind := &api.ShortcutFind{}
if userID, err := strconv.Atoi(c.QueryParam("creatorId")); err == nil {
shortcutFind.CreatorID = &userID
} else {
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find shortcut")
}
shortcutFind.CreatorID = &userID
}
list, err := s.Store.FindShortcutList(shortcutFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch shortcut list").SetInternal(err)
@@ -73,7 +81,6 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(list)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode shortcut list response").SetInternal(err)
}
return nil
})
@@ -95,7 +102,6 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(shortcut)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode shortcut response").SetInternal(err)
}
return nil
})
@@ -112,8 +118,6 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete shortcut").SetInternal(err)
}
c.JSON(http.StatusOK, true)
return nil
return c.JSON(http.StatusOK, true)
})
}
+16 -10
View File
@@ -2,34 +2,41 @@ package server
import (
"encoding/json"
"memos/api"
"net/http"
"github.com/usememos/memos/api"
"github.com/labstack/echo/v4"
)
func (s *Server) registerSystemRoutes(g *echo.Group) {
g.GET("/ping", func(c echo.Context) error {
data := s.Profile
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(s.Profile)); err != nil {
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(data)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose system profile").SetInternal(err)
}
return nil
})
g.GET("/status", func(c echo.Context) error {
ownerUserType := api.Owner
ownerUserFind := api.UserFind{
Role: &ownerUserType,
hostUserType := api.Host
hostUserFind := api.UserFind{
Role: &hostUserType,
}
ownerUser, err := s.Store.FindUser(&ownerUserFind)
hostUser, err := s.Store.FindUser(&hostUserFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find owner user").SetInternal(err)
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find host user").SetInternal(err)
}
if hostUser != nil {
// data desensitize
hostUser.OpenID = ""
}
systemStatus := api.SystemStatus{
Owner: ownerUser,
Host: hostUser,
Profile: s.Profile,
}
@@ -37,7 +44,6 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(systemStatus)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode system status response").SetInternal(err)
}
return nil
})
}
+73
View File
@@ -0,0 +1,73 @@
package server
import (
"encoding/json"
"net/http"
"regexp"
"sort"
"strconv"
"github.com/usememos/memos/api"
"github.com/labstack/echo/v4"
)
func (s *Server) registerTagRoutes(g *echo.Group) {
g.GET("/tag", func(c echo.Context) error {
contentSearch := "#"
normalRowStatus := api.Normal
memoFind := api.MemoFind{
ContentSearch: &contentSearch,
RowStatus: &normalRowStatus,
}
if userID, err := strconv.Atoi(c.QueryParam("creatorId")); err == nil {
memoFind.CreatorID = &userID
} else {
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find tag")
}
memoFind.CreatorID = &userID
}
// Only can get PUBLIC memos in visitor mode
_, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
publicVisibility := api.Public
memoFind.Visibility = &publicVisibility
}
memoList, err := s.Store.FindMemoList(&memoFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo list").SetInternal(err)
}
tagMapSet := make(map[string]bool)
r, err := regexp.Compile("#(.+?) ")
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compile regexp").SetInternal(err)
}
for _, memo := range memoList {
for _, rawTag := range r.FindAllString(memo.Content, -1) {
tag := r.ReplaceAllString(rawTag, "$1")
tagMapSet[tag] = true
}
}
tagList := []string{}
for tag := range tagMapSet {
tagList = append(tagList, tag)
}
sort.Strings(tagList)
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(tagList)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode tags response").SetInternal(err)
}
return nil
})
}
+28 -7
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
})
@@ -48,7 +48,31 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(userList)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode user list response").SetInternal(err)
}
return nil
})
g.GET("/user/:id", func(c echo.Context) error {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted user id").SetInternal(err)
}
user, err := s.Store.FindUser(&api.UserFind{
ID: &id,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch user").SetInternal(err)
}
if user != nil {
// data desensitize
user.OpenID = ""
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(user)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode user response").SetInternal(err)
}
return nil
})
@@ -72,7 +96,6 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(user)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode user response").SetInternal(err)
}
return nil
})
@@ -109,7 +132,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
})
@@ -123,7 +145,7 @@ 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)
}
@@ -158,7 +180,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
})
}
+6 -177
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,177 +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.PATCH("/:openId/memo/:memoId", 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))
}
memoID, err := strconv.Atoi(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("memoId is not a number: %s", c.Param("memoId"))).SetInternal(err)
}
memoPatch := &api.MemoPatch{
ID: memoID,
}
if err := json.NewDecoder(c.Request().Body).Decode(memoPatch); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch memo request by open api").SetInternal(err)
}
memo, err := s.Store.PatchMemo(memoPatch)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch 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.StatusNotFound, fmt.Sprintf("Not found user with openid: %s", openID))
}
memoFind := &api.MemoFind{
CreatorID: &user.ID,
}
rowStatus := api.RowStatus(c.QueryParam("rowStatus"))
if rowStatus != "" {
memoFind.RowStatus = &rowStatus
}
pinnedStr := c.QueryParam("pinned")
if pinnedStr != "" {
pinned := pinnedStr == "true"
memoFind.Pinned = &pinned
}
tag := c.QueryParam("tag")
if tag != "" {
memoFind.Tag = &tag
}
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 {
@@ -194,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)
@@ -207,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
})
}
+148 -67
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", "migration", latestSchemaFileName)
buf, err := migrationFS.ReadFile(latestSchemaPath)
if err != nil {
return fmt.Errorf("failed to read latest schema %q, error %w", latestSchemaPath, err)
}
stmt := string(buf)
if err := db.execute(stmt); err != nil {
return fmt.Errorf("migrate error: statement:%s err=%w", stmt, err)
}
return nil
}
func (db *DB) applyMigrationForMinorVersion(minorVersion string) error {
filenames, err := fs.Glob(migrationFS, fmt.Sprintf("%s/%s/*.sql", "migration", minorVersion))
if err != nil {
return err
}
sort.Strings(filenames)
migrationStmt := ""
// Loop over all migration files and execute them in order.
for _, filename := range filenames {
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/[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
+54
View File
@@ -0,0 +1,54 @@
-- change user role field from "OWNER"/"USER" to "HOST"/"USER".
PRAGMA foreign_keys = off;
DROP TABLE IF EXISTS _user_old;
ALTER TABLE
user RENAME TO _user_old;
CREATE TABLE user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
row_status TEXT NOT NULL CHECK (row_status IN ('NORMAL', 'ARCHIVED')) DEFAULT 'NORMAL',
email TEXT NOT NULL UNIQUE,
role TEXT NOT NULL CHECK (role IN ('HOST', 'USER')) DEFAULT 'USER',
name TEXT NOT NULL,
password_hash TEXT NOT NULL,
open_id TEXT NOT NULL UNIQUE
);
INSERT INTO user (
id, created_ts, updated_ts, row_status,
email, name, password_hash, open_id
)
SELECT
id,
created_ts,
updated_ts,
row_status,
email,
name,
password_hash,
open_id
FROM
_user_old;
UPDATE
user
SET
role = 'HOST'
WHERE
id IN (
SELECT
id
FROM
_user_old
WHERE
role = 'OWNER'
);
DROP TABLE IF EXISTS _user_old;
PRAGMA foreign_keys = on;
@@ -0,0 +1 @@
ALTER TABLE memo ADD visibility TEXT NOT NULL CHECK (visibility IN ('PUBLIC', 'PRIVATE')) DEFAULT 'PRIVATE';
-5
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`;
@@ -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 '',
visibility TEXT NOT NULL CHECK (visibility IN ('PUBLIC', 'PRIVATE')) DEFAULT 'PRIVATE',
FOREIGN KEY(creator_id) REFERENCES user(id) ON DELETE CASCADE
);
INSERT INTO
sqlite_sequence (name, seq)
VALUES
('memo', 100);
('memo', 1000);
CREATE TRIGGER IF NOT EXISTS `trigger_update_memo_modification_time`
AFTER
@@ -71,7 +79,7 @@ CREATE TABLE memo_organizer (
INSERT INTO
sqlite_sequence (name, seq)
VALUES
('memo_organizer', 100);
('memo_organizer', 1000);
-- shortcut
CREATE TABLE shortcut (
@@ -88,7 +96,7 @@ CREATE TABLE shortcut (
INSERT INTO
sqlite_sequence (name, seq)
VALUES
('shortcut', 100);
('shortcut', 10000);
CREATE TRIGGER IF NOT EXISTS `trigger_update_shortcut_modification_time`
AFTER
@@ -118,7 +126,7 @@ CREATE TABLE resource (
INSERT INTO
sqlite_sequence (name, seq)
VALUES
('resource', 100);
('resource', 10000);
CREATE TRIGGER IF NOT EXISTS `trigger_update_resource_modification_time`
AFTER
+55 -16
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
}
+22 -2
View File
@@ -11,9 +11,29 @@ 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'
);
+57 -4
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
);
@@ -19,7 +20,59 @@ INSERT INTO
)
VALUES
(
102,
'好好学习,天天向上。',
1002,
'#TODO
- [ ] Take more photos about **🌄 sunset**;
- [x] Clean the room;
- [x] Read *📖 The Little Prince*;
(👆 click to toggle status)',
101
);
INSERT INTO
memo (
`id`,
`content`,
`creator_id`,
`visibility`
)
VALUES
(
1003,
'好好学习,天天向上。🤜🤛',
101,
'PUBLIC'
);
INSERT INTO
memo (
`id`,
`content`,
`creator_id`
)
VALUES
(
1004,
'#TODO
- [x] Take more photos about **🌄 sunset**;
- [ ] Clean the classroom;
- [ ] Watch *👦 The Boys*;
(👆 click to toggle status)
',
102
);
INSERT INTO
memo (
`id`,
`content`,
`creator_id`,
`visibility`
)
VALUES
(
1005,
'三人行,必有我师焉!👨‍🏫',
102,
'PUBLIC'
);
+1 -1
View File
@@ -6,7 +6,7 @@ INSERT INTO
)
VALUES
(
102,
1001,
101,
1
);
+10 -9
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
}
+45 -22
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,8 +219,19 @@ 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.Tag; v != nil {
where, args = append(where, "content LIKE ?"), append(args, "%#"+*v+" %")
if v := find.ContentSearch; v != nil {
where, args = append(where, "content LIKE ?"), append(args, "%"+*v+"%")
}
if v := find.Visibility; v != nil {
where, args = append(where, "visibility = ?"), append(args, *v)
}
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(`
@@ -219,11 +240,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 {
@@ -239,8 +261,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)
}
+3 -2
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.
+3 -2
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.
+3 -2
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.
+2 -1
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
+10 -5
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.
@@ -105,7 +106,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 +130,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 +163,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 +181,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,7 +220,8 @@ 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`,
@@ -240,6 +244,7 @@ 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)
+5
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"
}
}
}
+2 -1
View File
@@ -2,10 +2,11 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/logo.svg" sizes="64x64" type="image/*" />
<link rel="icon" href="/favicon.svg" sizes="64x64" type="image/*" />
<meta name="theme-color" content="#f6f5f4" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no" />
<title>Memos</title>
<script src="https://kit.fontawesome.com/41e3aaa6af.js" crossorigin="anonymous"></script>
</head>
<body>
<div id="root"></div>
+7 -3
View File
@@ -1,6 +1,6 @@
{
"name": "memos",
"version": "0.1.0",
"version": "0.2.2",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@@ -10,18 +10,22 @@
"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-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,6 +36,6 @@
"prettier": "2.5.1",
"tailwindcss": "^3.0.18",
"typescript": "^4.3.2",
"vite": "^2.9.0"
"vite": "^3.0.0"
}
}

Before

Width:  |  Height:  |  Size: 121 B

After

Width:  |  Height:  |  Size: 121 B

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

Before

Width:  |  Height:  |  Size: 137 B

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

Before

Width:  |  Height:  |  Size: 214 B

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

Before

Width:  |  Height:  |  Size: 209 B

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

Before

Width:  |  Height:  |  Size: 209 B

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

Before

Width:  |  Height:  |  Size: 308 B

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

Before

Width:  |  Height:  |  Size: 249 B

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

Before

Width:  |  Height:  |  Size: 268 B

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

Before

Width:  |  Height:  |  Size: 367 B

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

Before

Width:  |  Height:  |  Size: 367 B

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

Before

Width:  |  Height:  |  Size: 296 B

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

Before

Width:  |  Height:  |  Size: 204 B

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

Before

Width:  |  Height:  |  Size: 306 B

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

Before

Width:  |  Height:  |  Size: 306 B

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

Before

Width:  |  Height:  |  Size: 393 B

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

Before

Width:  |  Height:  |  Size: 387 B

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

Before

Width:  |  Height:  |  Size: 301 B

-1
View File
@@ -1,6 +1,5 @@
import { appRouterSwitch } from "./routers";
import { useAppSelector } from "./store";
import "./less/app.less";
function App() {
const pathname = useAppSelector((state) => state.location.pathname);
+14 -9
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import * as api from "../helpers/api";
import Only from "./common/OnlyWhen";
import { showDialog } from "./Dialog";
import GitHubBadge from "./GitHubBadge";
import "../less/about-site-dialog.less";
interface Props extends DialogProps {}
@@ -36,7 +37,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" />
<i className="fa-solid fa-xmark fa-lg"></i>
</button>
</div>
<div className="dialog-content-container">
@@ -44,14 +45,18 @@ 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>
</>
);
@@ -10,24 +10,23 @@ 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;
+73
View File
@@ -0,0 +1,73 @@
import { useEffect, useState } from "react";
import useLoading from "../hooks/useLoading";
import { memoService } from "../services";
import { useAppSelector } from "../store";
import { showDialog } from "./Dialog";
import toastHelper from "./Toast";
import ArchivedMemo from "./ArchivedMemo";
import "../less/archived-memo-dialog.less";
interface Props extends DialogProps {}
const ArchivedMemoDialog: React.FC<Props> = (props: Props) => {
const { destroy } = props;
const memos = useAppSelector((state) => state.memo.memos);
const loadingState = useLoading();
const [archivedMemos, setArchivedMemos] = useState<Memo[]>([]);
useEffect(() => {
memoService
.fetchArchivedMemos()
.then((result) => {
setArchivedMemos(result);
})
.catch((error) => {
toastHelper.error("Failed to fetch archived memos: ", error);
})
.finally(() => {
loadingState.setFinish();
});
}, [memos]);
return (
<>
<div className="dialog-header-container">
<p className="title-text">
<span className="icon-text">🗂</span>
Archived Memos
</p>
<button className="btn close-btn" onClick={destroy}>
<i className="fa-solid fa-xmark fa-lg icon-img"></i>
</button>
</div>
<div className="dialog-content-container">
{loadingState.isLoading ? (
<div className="tip-text-container">
<p className="tip-text">fetching data...</p>
</div>
) : archivedMemos.length === 0 ? (
<div className="tip-text-container">
<p className="tip-text">Here is No Zettels.</p>
</div>
) : (
<div className="archived-memos-container">
{archivedMemos.map((memo) => (
<ArchivedMemo key={`${memo.id}-${memo.updatedTs}`} memo={memo} />
))}
</div>
)}
</div>
</>
);
};
export default function showArchivedMemo(): void {
showDialog(
{
className: "archived-memo-dialog",
useAppContext: true,
},
ArchivedMemoDialog,
{}
);
}
+1 -1
View File
@@ -70,7 +70,7 @@ const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => {
<div className="dialog-header-container">
<p className="title-text">Change Password</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<img className="icon-img" src="/icons/close.svg" />
<i className="fa-solid fa-xmark fa-lg"></i>
</button>
</div>
<div className="dialog-content-container">
@@ -1,8 +1,8 @@
import { useEffect } from "react";
import { showDialog } from "./Dialog";
import useLoading from "../hooks/useLoading";
import toastHelper from "./Toast";
import { userService } from "../services";
import useLoading from "../hooks/useLoading";
import { showDialog } from "./Dialog";
import toastHelper from "./Toast";
import "../less/confirm-reset-openid-dialog.less";
interface Props extends DialogProps {}
@@ -41,7 +41,7 @@ const ConfirmResetOpenIdDialog: React.FC<Props> = ({ destroy }: Props) => {
<div className="dialog-header-container">
<p className="title-text">Reset Open API</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<img className="icon-img" src="/icons/close.svg" />
<i className="fa-solid fa-xmark fa-lg"></i>
</button>
</div>
<div className="dialog-content-container">
+2 -2
View File
@@ -100,7 +100,7 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
{shortcutId ? "Edit Shortcut" : "Create Shortcut"}
</p>
<button className="btn close-btn" onClick={destroy}>
<img className="icon-img" src="/icons/close.svg" />
<i className="fa-solid fa-xmark fa-lg"></i>
</button>
</div>
<div className="dialog-content-container">
@@ -296,7 +296,7 @@ const FilterInputer: React.FC<MemoFilterInputerProps> = (props: MemoFilterInpute
/>
{inputElements}
<img className="remove-btn" src="/icons/close.svg" onClick={handleRemoveBtnClick} />
<i className="fa-solid fa-xmark remove-btn" onClick={handleRemoveBtnClick}></i>
</div>
);
};
+4 -3
View File
@@ -1,7 +1,7 @@
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 "./Memo";
import "../less/daily-memo.less";
interface DailyMemo extends Memo {
@@ -20,7 +20,7 @@ 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) ?? []);
const imageUrls = Array.from(memo.content.match(IMAGE_URL_REG) ?? []).map((s) => s.replace(IMAGE_URL_REG, "$1"));
return (
<div className="daily-memo-wrapper">
@@ -32,11 +32,12 @@ const DailyMemo: React.FC<Props> = (props: Props) => {
<Only when={imageUrls.length > 0}>
<div className="images-container">
{imageUrls.map((imgUrl, idx) => (
<img key={idx} crossOrigin="anonymous" src={imgUrl} decoding="async" />
<img key={idx} src={imgUrl} decoding="async" />
))}
</div>
</Only>
</div>
<div className="split-line"></div>
</div>
);
};
-135
View File
@@ -1,135 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { memoService } from "../services";
import toImage from "../labs/html2image";
import useToggle from "../hooks/useToggle";
import useLoading from "../hooks/useLoading";
import { DAILY_TIMESTAMP } from "../helpers/consts";
import * 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 }
);
}
+119
View File
@@ -0,0 +1,119 @@
import { useRef, useState } from "react";
import { useAppSelector } from "../store";
import toImage from "../labs/html2image";
import useToggle from "../hooks/useToggle";
import { DAILY_TIMESTAMP } from "../helpers/consts";
import * as utils from "../helpers/utils";
import { showDialog } from "./Dialog";
import DatePicker from "./common/DatePicker";
import showPreviewImageDialog from "./PreviewImageDialog";
import DailyMemo from "./DailyMemo";
import "../less/daily-review-dialog.less";
interface Props extends DialogProps {
currentDateStamp: DateStamp;
}
const monthChineseStrArray = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sept", "Oct", "Nov", "Dev"];
const weekdayChineseStrArray = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const DailyReviewDialog: React.FC<Props> = (props: Props) => {
const memos = useAppSelector((state) => state.memo.memos);
const [currentDateStamp, setCurrentDateStamp] = useState(utils.getDateStampByDate(utils.getDateString(props.currentDateStamp)));
const [showDatePicker, toggleShowDatePicker] = useToggle(false);
const memosElRef = useRef<HTMLDivElement>(null);
const currentDate = new Date(currentDateStamp);
const dailyMemos = memos
.filter(
(m) =>
m.rowStatus === "NORMAL" &&
utils.getTimeStampByDate(m.createdTs) >= currentDateStamp &&
utils.getTimeStampByDate(m.createdTs) < currentDateStamp + DAILY_TIMESTAMP
)
.sort((a, b) => utils.getTimeStampByDate(a.createdTs) - utils.getTimeStampByDate(b.createdTs));
const handleShareBtnClick = () => {
if (!memosElRef.current) {
return;
}
toggleShowDatePicker(false);
toImage(memosElRef.current, {
backgroundColor: "#ffffff",
pixelRatio: window.devicePixelRatio * 2,
})
.then((url) => {
showPreviewImageDialog(url);
})
.catch(() => {
// do nth
});
};
const handleDataPickerChange = (datestamp: DateStamp): void => {
setCurrentDateStamp(datestamp);
toggleShowDatePicker(false);
};
return (
<>
<div className="dialog-header-container">
<p className="title-text" onClick={() => toggleShowDatePicker()}>
<span className="icon-text">📅</span> Daily Review
</p>
<div className="btns-container">
<button className="btn-text" onClick={() => setCurrentDateStamp(currentDateStamp - DAILY_TIMESTAMP)}>
<i className="fa-solid fa-chevron-left icon-img"></i>
</button>
<button className="btn-text" onClick={() => setCurrentDateStamp(currentDateStamp + DAILY_TIMESTAMP)}>
<i className="fa-solid fa-chevron-right icon-img"></i>
</button>
<button className="btn-text" onClick={handleShareBtnClick}>
<i className="fa-solid fa-share-nodes icon-img"></i>
</button>
<button className="btn-text" onClick={() => props.destroy()}>
<i className="fa-solid fa-xmark fa-lg icon-img"></i>
</button>
</div>
<DatePicker
className={`date-picker ${showDatePicker ? "" : "!hidden"}`}
datestamp={currentDateStamp}
handleDateStampChange={handleDataPickerChange}
/>
</div>
<div className="dialog-content-container" ref={memosElRef}>
<div className="date-card-container">
<div className="year-text">{currentDate.getFullYear()}</div>
<div className="date-container">
<div className="month-text">{monthChineseStrArray[currentDate.getMonth()]}</div>
<div className="date-text">{currentDate.getDate()}</div>
<div className="day-text">{weekdayChineseStrArray[currentDate.getDay()]}</div>
</div>
</div>
{dailyMemos.length === 0 ? (
<div className="tip-container">
<p className="tip-text">Oops, there is nothing.</p>
</div>
) : (
<div className="dailymemos-wrapper">
{dailyMemos.map((memo) => (
<DailyMemo key={`${memo.id}-${memo.updatedTs}`} memo={memo} />
))}
</div>
)}
</div>
</>
);
};
export default function showDailyReviewDialog(datestamp: DateStamp = Date.now()): void {
showDialog(
{
className: "daily-review-dialog",
useAppContext: true,
},
DailyReviewDialog,
{ currentDateStamp: datestamp }
);
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { createRoot } from "react-dom/client";
import { ANIMATION_DURATION } from "../helpers/consts";
import { Provider } from "react-redux";
import store from "../store";
import { ANIMATION_DURATION } from "../helpers/consts";
import "../less/dialog.less";
interface DialogConfig {
+4 -2
View File
@@ -15,6 +15,7 @@ interface EditorProps {
className: string;
initialContent: string;
placeholder: string;
fullscreen: boolean;
showConfirmBtn: boolean;
showCancelBtn: boolean;
tools?: ReactNode;
@@ -29,6 +30,7 @@ const Editor = forwardRef((props: EditorProps, ref: React.ForwardedRef<EditorRef
className,
initialContent,
placeholder,
fullscreen,
showConfirmBtn,
showCancelBtn,
onConfirmBtnClick: handleConfirmBtnClickCallback,
@@ -45,11 +47,11 @@ const Editor = forwardRef((props: EditorProps, ref: React.ForwardedRef<EditorRef
}, []);
useEffect(() => {
if (editorRef.current) {
if (editorRef.current && !fullscreen) {
editorRef.current.style.height = "auto";
editorRef.current.style.height = (editorRef.current.scrollHeight ?? 0) + "px";
}
}, [editorRef.current?.value]);
}, [editorRef.current?.value, fullscreen]);
useImperativeHandle(
ref,
+30
View File
@@ -0,0 +1,30 @@
import { useEffect, useState } from "react";
import * as api from "../helpers/api";
import "../less/github-badge.less";
interface Props {}
const GitHubBadge: React.FC<Props> = () => {
const [starCount, setStarCount] = useState(0);
useEffect(() => {
api.getRepoStarCount().then((data) => {
setStarCount(data);
});
}, []);
return (
<a className="github-badge-container" href="https://github.com/usememos/memos">
<div className="github-icon">
<i className="fa-brands fa-github fa-lg icon-img"></i>
Star
</div>
<div className="count-text">
{starCount || ""}
<span className="icon-text">🌟</span>
</div>
</a>
);
};
export default GitHubBadge;
+152 -86
View File
@@ -1,29 +1,67 @@
import { memo } from "react";
import { escape } from "lodash-es";
import { IMAGE_URL_REG, LINK_REG, MEMO_LINK_REG, TAG_REG, 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 { escape, indexOf } from "lodash-es";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { IMAGE_URL_REG, LINK_URL_REG, MEMO_LINK_REG, TAG_REG, UNKNOWN_ID } from "../helpers/consts";
import { DONE_BLOCK_REG, parseMarkedToHtml, TODO_BLOCK_REG } from "../helpers/marked";
import { editorStateService, locationService, memoService, userService } from "../services";
import Only from "./common/OnlyWhen";
import toastHelper from "./Toast";
import Image from "./Image";
import showMemoCardDialog from "./MemoCardDialog";
import showShareMemoImageDialog from "./ShareMemoImageDialog";
import toastHelper from "./Toast";
import "../less/memo.less";
dayjs.extend(relativeTime);
const MAX_MEMO_CONTAINER_HEIGHT = 384;
type ExpandButtonStatus = -1 | 0 | 1;
interface Props {
memo: 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 +87,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 +119,113 @@ const Memo: React.FC<Props> = (props: Props) => {
toastHelper.error("MEMO Not Found");
targetEl.classList.remove("memo-link-text");
}
} else if (targetEl.className === "todo-block") {
// do nth
} else if (targetEl.className === "tag-span") {
const tagName = targetEl.innerText.slice(1);
const currTagQuery = locationService.getState().query?.tag;
if (currTagQuery === tagName) {
locationService.setTagQuery(undefined);
} else {
locationService.setTagQuery(tagName);
}
} else if (targetEl.classList.contains("todo-block")) {
if (userService.isVisitorMode()) {
return;
}
const status = targetEl.dataset?.value;
const todoElementList = [...(memoContainerRef.current?.querySelectorAll(`span.todo-block[data-value=${status}]`) ?? [])];
for (const element of todoElementList) {
if (element === targetEl) {
const index = indexOf(todoElementList, element);
const tempList = memo.content.split(status === "DONE" ? DONE_BLOCK_REG : TODO_BLOCK_REG);
let finalContent = "";
for (let i = 0; i < tempList.length; i++) {
if (i === 0) {
finalContent += `${tempList[i]}`;
} else {
if (i === index + 1) {
finalContent += status === "DONE" ? "- [ ] " : "- [x] ";
} else {
finalContent += status === "DONE" ? "- [x] " : "- [ ] ";
}
finalContent += `${tempList[i]}`;
}
}
await memoService.patchMemo({
id: memo.id,
content: finalContent,
});
}
}
}
};
const handleExpandBtnClick = () => {
setState({
expandButtonStatus: Number(Boolean(!state.expandButtonStatus)) as ExpandButtonStatus,
});
};
return (
<div className={`memo-wrapper ${"memos-" + memo.id} ${memo.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}
<div className="status-text-container" onClick={handleShowMemoStoryDialog}>
<span className="time-text">{createdAtStr}</span>
<Only when={memo.pinned}>
<span className="ml-2">PINNED</span>
<span className="status-text">PINNED</span>
</Only>
</span>
<div className="btns-container">
<Only when={memo.visibility === "PUBLIC" && !isVisitorMode}>
<span className="status-text">PUBLIC</span>
</Only>
</div>
<div className={`btns-container ${userService.isVisitorMode() ? "!hidden" : ""}`}>
<span className="btn more-action-btn">
<img className="icon-img" src="/icons/more.svg" />
<i className="fa-solid fa-ellipsis icon-img"></i>
</span>
<div className="more-action-btns-wrapper">
<div className="more-action-btns-container">
<span className="btn" onClick={handleShowMemoStoryDialog}>
View Story
</span>
<span className="btn" onClick={handleTogglePinMemoBtnClick}>
{memo.pinned ? "Unpin" : "Pin"}
</span>
<div className="btns-container">
<div className="btn" onClick={handleTogglePinMemoBtnClick}>
<i className={`fa-solid fa-thumbtack icon-img ${memo.pinned ? "" : "opacity-20"}`}></i>
<span className="tip-text">{memo.pinned ? "Unpin" : "Pin"}</span>
</div>
<div className="btn" onClick={handleEditMemoClick}>
<i className="fa-solid fa-pen-to-square icon-img"></i>
<span className="tip-text">Edit</span>
</div>
<div className="btn" onClick={handleGenMemoImageBtnClick}>
<i className="fa-solid fa-share-nodes icon-img"></i>
<span className="tip-text">Share</span>
</div>
</div>
<span className="btn" onClick={handleMarkMemoClick}>
Mark
</span>
<span className="btn" onClick={handleGenMemoImageBtnClick}>
Share
<span className="btn" onClick={handleShowMemoStoryDialog}>
View Story
</span>
<span className="btn" onClick={handleEditMemoClick}>
Edit
</span>
<span className={`btn delete-btn ${showConfirmDeleteBtn ? "final-confirm" : ""}`} onClick={handleDeleteMemoClick}>
{showConfirmDeleteBtn ? "Delete!" : "Delete"}
<span className="btn archive-btn" onClick={handleArchiveMemoClick}>
Archive
</span>
</div>
</div>
</div>
</div>
<div
className="memo-content-text"
ref={memoContainerRef}
className={`memo-content-text ${state.expandButtonStatus === 0 ? "expanded" : ""}`}
onClick={handleMemoContentClick}
dangerouslySetInnerHTML={{ __html: formatMemoContent(memo.content) }}
></div>
{state.expandButtonStatus !== -1 && (
<div className="expand-btn-container">
<span className={`btn ${state.expandButtonStatus === 0 ? "expand-btn" : "fold-btn"}`} onClick={handleExpandBtnClick}>
{state.expandButtonStatus === 0 ? "Expand" : "Fold"}
<i className="fa-solid fa-chevron-right icon-img"></i>
</span>
</div>
)}
<Only when={imageUrls.length > 0}>
<div className="images-wrapper">
{imageUrls.map((imgUrl, idx) => (
@@ -150,36 +238,14 @@ const Memo: React.FC<Props> = (props: Props) => {
};
export function formatMemoContent(content: string) {
content = escape(content);
content = parseRawTextToHtml(content)
.split("<br>")
.map((t) => {
return `<p>${t !== "" ? t : "<br>"}</p>`;
})
.join("");
const tempElement = document.createElement("div");
tempElement.innerHTML = parseMarkedToHtml(escape(content));
content = parseMarkedToHtml(content);
// 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
return tempElement.innerHTML
.replace(IMAGE_URL_REG, "")
.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>");
.replace(MEMO_LINK_REG, "<span class='memo-link-text' data-value='$2'>$1</span>")
.replace(LINK_URL_REG, "<a class='link' target='_blank' rel='noreferrer' href='$2'>$1</a>")
.replace(TAG_REG, "<span class='tag-span'>#$1</span> ");
}
export default memo(Memo);
+49 -11
View File
@@ -1,15 +1,14 @@
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 Only from "./common/OnlyWhen";
import toastHelper from "./Toast";
import { showDialog } from "./Dialog";
import Only from "./common/OnlyWhen";
import Image from "./Image";
import { formatMemoContent } from "./Memo";
import "../less/memo-card-dialog.less";
import "../less/memo-content.less";
interface LinkedMemo extends Memo {
createdAtStr: string;
@@ -26,7 +25,7 @@ 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"));
useEffect(() => {
const fetchLinkedMemos = async () => {
@@ -35,8 +34,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 +54,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,
@@ -99,17 +103,51 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
editorStateService.setEditMemoWithId(memo.id);
}, [memo.id]);
const handlePinClick = async () => {
if (memo.pinned) {
await memoService.unpinMemo(memo.id);
} else {
await memoService.pinMemo(memo.id);
}
setMemo({
...memo,
pinned: !memo.pinned,
});
};
const handleVisibilityClick = async () => {
const visibility = memo.visibility === "PRIVATE" ? "PUBLIC" : "PRIVATE";
await memoService.patchMemo({
id: memo.id,
visibility: visibility,
});
setMemo({
...memo,
visibility: visibility,
});
};
return (
<>
<div className="memo-card-container">
<div className="header-container">
<p className="time-text">{utils.getDateTimeString(memo.createdTs)}</p>
<div className="btns-container">
<button className="btn edit-btn" onClick={handleEditMemoBtnClick}>
<img className="icon-img" src="/icons/edit.svg" />
</button>
<Only when={!userService.isVisitorMode()}>
<>
<button className="btn" onClick={handlePinClick}>
<i className={`fa-solid fa-thumbtack icon-img ${memo.pinned ? "" : "opacity-20"}`}></i>
</button>
<button className="btn" onClick={handleVisibilityClick}>
<i className={`fa-solid fa-eye icon-img ${memo.visibility === "PUBLIC" ? "" : "opacity-20"}`}></i>
</button>
<button className="btn edit-btn" onClick={handleEditMemoBtnClick}>
<i className="fa-solid fa-pen-to-square icon-img"></i>
</button>
</>
</Only>
<button className="btn close-btn" onClick={props.destroy}>
<img className="icon-img" src="/icons/close.svg" />
<i className="fa-solid fa-xmark fa-lg icon-img"></i>
</button>
</div>
</div>
+82 -135
View File
@@ -1,51 +1,26 @@
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 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 +28,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 +49,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 +66,7 @@ const MemoEditor: React.FC<Props> = () => {
const file = event.dataTransfer.files[0];
const url = await handleUploadFile(file);
if (url) {
editorRef.current?.insertText(url);
editorRef.current?.insertText(`![](${url})`);
}
}
};
@@ -110,10 +81,10 @@ const MemoEditor: React.FC<Props> = () => {
});
};
editorRef.current.element.addEventListener("paste", handlePasteEvent);
editorRef.current.element.addEventListener("drop", handleDropEvent);
editorRef.current.element.addEventListener("click", handleClickEvent);
editorRef.current.element.addEventListener("keydown", handleKeyDownEvent);
editorRef.current?.element.addEventListener("paste", handlePasteEvent);
editorRef.current?.element.addEventListener("drop", handleDropEvent);
editorRef.current?.element.addEventListener("click", handleClickEvent);
editorRef.current?.element.addEventListener("keydown", handleKeyDownEvent);
return () => {
editorRef.current?.element.removeEventListener("paste", handlePasteEvent);
@@ -123,32 +94,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")) {
return;
}
return url;
} catch (error: any) {
toastHelper.error(error);
}
}, []);
try {
const image = await resourceService.upload(file);
const url = `/h/r/${image.id}/${image.filename}`;
const handleSaveBtnClick = useCallback(async (content: string) => {
return url;
} catch (error: any) {
toastHelper.error(error);
} finally {
setState({
...state,
isUploadingResource: false,
});
}
},
[state]
);
const handleSaveBtnClick = async (content: string) => {
if (content === "") {
toastHelper.error("Content can't be empty");
return;
}
const { editMemoId } = editorStateService.getState();
try {
const { editMemoId } = editorStateService.getState();
if (editMemoId && editMemoId !== UNKNOWN_ID) {
const prevMemo = memoService.getMemoById(editMemoId ?? UNKNOWN_ID);
@@ -169,8 +155,12 @@ const MemoEditor: React.FC<Props> = () => {
toastHelper.error(error.message);
}
setState({
...state,
fullscreen: false,
});
setEditorContentCache("");
}, []);
};
const handleCancelBtnClick = useCallback(() => {
editorStateService.clearEditMemo();
@@ -180,61 +170,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 +185,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 +212,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">
<i className="fa-solid fa-hashtag icon-img"></i>
<div ref={tagSeletorRef} className="tag-list" onClick={handleTagSeletorClick}>
{tags.map((t) => {
return <span key={t}>{t}</span>;
})}
</div>
</div>
<button className="action-btn">
<i className="fa-solid fa-image icon-img" onClick={handleUploadFileBtnClick}></i>
<span className={`tip-text ${state.isUploadingResource ? "!block" : ""}`}>Uploading</span>
</button>
<button className="action-btn" onClick={handleFullscreenBtnClick}>
<i className={`fa-solid fa-${state.fullscreen ? "compress" : "expand"} icon-img`}></i>
</button>
</>
}
/>
<div
ref={tagSeletorRef}
className={`tag-list ${isTagSeletorShown && tags.length > 0 ? "" : "!hidden"}`}
onClick={handleTagSeletorClick}
>
{tags.map((t) => {
return <span key={t}>{t}</span>;
})}
</div>
</div>
);
};
+7 -26
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;
@@ -94,33 +94,14 @@ const MemoList: React.FC<Props> = () => {
wrapperElement.current?.scrollTo({ top: 0 });
}, [query]);
const handleMemoListClick = useCallback((event: React.MouseEvent) => {
const targetEl = event.target as HTMLElement;
if (targetEl.tagName === "SPAN" && targetEl.className === "tag-span") {
const tagName = targetEl.innerText.slice(1);
const currTagQuery = locationService.getState().query?.tag;
if (currTagQuery === tagName) {
locationService.setTagQuery("");
} else {
locationService.setTagQuery(tagName);
}
}
}, []);
return (
<div className={`memo-list-container ${isFetching ? "" : "completed"}`} onClick={handleMemoListClick} ref={wrapperElement}>
<div className={`memo-list-container ${isFetching ? "" : "completed"}`} ref={wrapperElement}>
{sortedMemos.map((memo) => (
<Memo key={`${memo.id}-${memo.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>
-76
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,
{}
);
}
+1 -1
View File
@@ -40,7 +40,7 @@ const MemosHeader: React.FC<Props> = () => {
<div className="section-header-container memos-header-container">
<div className="title-container">
<div className="action-btn" onClick={toggleSiderbar}>
<img src="/icons/menu.svg" className="icon-img" alt="" />
<i className="fa-solid fa-bars icon-img"></i>
</div>
<span className="title-text" onClick={handleTitleTextClick}>
{titleText}
+24 -2
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>
);
};
+15 -42
View File
@@ -1,6 +1,5 @@
import { useEffect, useRef, useState } from "react";
import * as utils from "../helpers/utils";
import { showDialog } from "./Dialog";
import * as utils from "../helpers/utils";
import "../less/preview-image-dialog.less";
interface Props extends DialogProps {
@@ -8,55 +7,29 @@ interface Props extends DialogProps {
}
const PreviewImageDialog: React.FC<Props> = ({ destroy, imgUrl }: Props) => {
const imgRef = useRef<HTMLImageElement>(null);
const [imgWidth, setImgWidth] = useState<number>(-1);
useEffect(() => {
utils.getImageSize(imgUrl).then(({ width }) => {
if (width !== 0) {
setImgWidth(80);
} else {
setImgWidth(0);
}
});
}, []);
const handleCloseBtnClick = () => {
destroy();
};
const handleDecreaseImageSize = () => {
if (imgWidth > 30) {
setImgWidth(imgWidth - 10);
}
};
const handleIncreaseImageSize = () => {
setImgWidth(imgWidth + 10);
const handleDownloadBtnClick = () => {
const a = document.createElement("a");
a.href = imgUrl;
a.download = `memos-${utils.getDateTimeString(Date.now())}.png`;
a.click();
};
return (
<>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<img className="icon-img" src="/icons/close.svg" />
</button>
<div className="img-container">
<img className={imgWidth <= 0 ? "hidden" : ""} ref={imgRef} width={imgWidth + "%"} src={imgUrl} />
<span className={"loading-text " + (imgWidth === -1 ? "" : "hidden")}>Loading image...</span>
<span className={"loading-text " + (imgWidth === 0 ? "" : "hidden")}>😟 Failed to load image</span>
<div className="btns-container">
<button className="btn" onClick={handleCloseBtnClick}>
<i className="fa-solid fa-xmark fa-lg icon-img"></i>
</button>
<button className="btn" onClick={handleDownloadBtnClick}>
<i className="fa-solid fa-download icon-img"></i>
</button>
</div>
<div className="action-btns-container">
<button className="btn" onClick={handleDecreaseImageSize}>
</button>
<button className="btn" onClick={handleIncreaseImageSize}>
</button>
<button className="btn" onClick={() => setImgWidth(80)}>
</button>
<div className="img-container">
<img src={imgUrl} />
</div>
</>
);
+1 -1
View File
@@ -24,7 +24,7 @@ const SearchBar: React.FC<Props> = () => {
return (
<div className="search-bar-container">
<div className="search-bar-inputer">
<img className="icon-img" src="/icons/search.svg" />
<i className="fa-solid fa-magnifying-glass fa-sm icon-img"></i>
<input className="text-input" type="text" placeholder="" onChange={handleTextQueryInput} />
</div>
<div className="quickly-action-wrapper">
+5 -5
View File
@@ -30,7 +30,7 @@ const SettingDialog: React.FC<Props> = (props: Props) => {
return (
<div className="dialog-content-container">
<button className="btn close-btn" onClick={destroy}>
<img className="icon-img" src="/icons/close.svg" />
<i className="fa-solid fa-xmark fa-lg icon-img"></i>
</button>
<div className="section-selector-container">
<span className="section-title">Basic</span>
@@ -39,16 +39,16 @@ const SettingDialog: React.FC<Props> = (props: Props) => {
onClick={() => handleSectionSelectorItemClick("my-account")}
className={`section-item ${state.selectedSection === "my-account" ? "selected" : ""}`}
>
My account
<span className="icon-text">🤠</span> My account
</span>
<span
onClick={() => handleSectionSelectorItemClick("preferences")}
className={`section-item ${state.selectedSection === "preferences" ? "selected" : ""}`}
>
Preferences
<span className="icon-text">🏟</span> Preferences
</span>
</div>
{user?.role === "OWNER" ? (
{user?.role === "HOST" ? (
<>
<span className="section-title">Admin</span>
<div className="section-items-container">
@@ -56,7 +56,7 @@ const SettingDialog: React.FC<Props> = (props: Props) => {
onClick={() => handleSectionSelectorItemClick("member")}
className={`section-item ${state.selectedSection === "member" ? "selected" : ""}`}
>
Member
<span className="icon-text">👤</span> Member
</span>
</div>
</>
@@ -19,7 +19,7 @@ interface Props {}
const MyAccountSection: React.FC<Props> = () => {
const user = useAppSelector((state) => state.user.user as User);
const [username, setUsername] = useState<string>(user.name);
const openAPIRoute = `${window.location.origin}/h/${user.openId}/memo`;
const openAPIRoute = `${window.location.origin}/api/memo?openId=${user.openId}`;
const handleUsernameChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
const nextUsername = e.target.value as string;
@@ -71,7 +71,7 @@ const MyAccountSection: React.FC<Props> = () => {
<label className="form-label input-form-label username-label">
<span className="normal-text">Username:</span>
<input type="text" value={username} onChange={handleUsernameChanged} />
<div className={`btns-container ${username === user.name ? "hidden" : ""}`} onClick={handlePreventDefault}>
<div className={`btns-container ${username === user.name ? "!hidden" : ""}`} onClick={handlePreventDefault}>
<span className="btn confirm-btn" onClick={handleConfirmEditUsernameBtnClick}>
Save
</span>
@@ -100,7 +100,7 @@ const MyAccountSection: React.FC<Props> = () => {
</span>
<div className="usage-guide-container">
<p className="title-text">Usage guide:</p>
<pre>{`POST ${openAPIRoute}\nContent-type: application/json\n{\n "content": "Hello, #memos ${window.location.origin}"\n}`}</pre>
<pre>{`POST ${openAPIRoute}\nContent-type: application/json\n{\n "content": "Hello #memos from ${window.location.origin}"\n}`}</pre>
</div>
</div>
</>
+18 -18
View File
@@ -4,9 +4,9 @@ import toImage from "../labs/html2image";
import { ANIMATION_DURATION, IMAGE_URL_REG } from "../helpers/consts";
import * as utils from "../helpers/utils";
import { showDialog } from "./Dialog";
import { formatMemoContent } from "./Memo";
import Only from "./common/OnlyWhen";
import toastHelper from "./Toast";
import { formatMemoContent } from "./Memo";
import "../less/share-memo-image-dialog.less";
interface Props extends DialogProps {
@@ -20,10 +20,10 @@ const ShareMemoImageDialog: React.FC<Props> = (props: Props) => {
...propsMemo,
createdAtStr: utils.getDateTimeString(propsMemo.createdTs),
};
const memoImgUrls = 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 [shortcutImgUrl, setShortcutImgUrl] = useState("");
const [imgAmount, setImgAmount] = useState(memoImgUrls.length);
const [imgAmount, setImgAmount] = useState(imageUrls.length);
const memoElRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -55,43 +55,43 @@ const ShareMemoImageDialog: React.FC<Props> = (props: Props) => {
const handleImageOnLoad = (ev: React.SyntheticEvent<HTMLImageElement>) => {
if (ev.type === "error") {
toastHelper.error("有个图片加载失败了😟");
toastHelper.error("😟 Image load failed");
(ev.target as HTMLImageElement).remove();
}
setImgAmount(imgAmount - 1);
};
const handleDownloadBtnClick = () => {
const a = document.createElement("a");
a.href = shortcutImgUrl;
a.download = `memos-${utils.getDateTimeString(Date.now())}.png`;
a.click();
};
return (
<>
<div className="dialog-header-container">
<p className="title-text">
<span className="icon-text">🥰</span>Share Memo
<span className="icon-text">🌄</span>Share Memo
</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<img className="icon-img" src="/icons/close.svg" />
<i className="fa-solid fa-xmark fa-lg icon-img"></i>
</button>
</div>
<div className="dialog-content-container">
<div className={`tip-words-container ${shortcutImgUrl ? "finish" : "loading"}`}>
<p className="tip-text">{shortcutImgUrl ? "Right click or long press to save image 👇" : "Generating the screenshot..."}</p>
<p className="tip-text">{shortcutImgUrl ? "Click to save the image 👇" : "Generating the screenshot..."}</p>
</div>
<div className="memo-container" ref={memoElRef}>
<Only when={shortcutImgUrl !== ""}>
<img className="memo-shortcut-img" src={shortcutImgUrl} />
<img className="memo-shortcut-img" onClick={handleDownloadBtnClick} src={shortcutImgUrl} />
</Only>
<span className="time-text">{memo.createdAtStr}</span>
<div className="memo-content-text" dangerouslySetInnerHTML={{ __html: formatMemoContent(memo.content) }}></div>
<Only when={memoImgUrls.length > 0}>
<Only when={imageUrls.length > 0}>
<div className="images-container">
{memoImgUrls.map((imgUrl, idx) => (
<img
crossOrigin="anonymous"
decoding="async"
key={idx}
src={imgUrl}
onLoad={handleImageOnLoad}
onError={handleImageOnLoad}
/>
{imageUrls.map((imgUrl, idx) => (
<img decoding="async" key={idx} src={imgUrl} onLoad={handleImageOnLoad} onError={handleImageOnLoad} />
))}
</div>
</Only>
+5 -10
View File
@@ -1,7 +1,6 @@
import { useEffect } from "react";
import { locationService, shortcutService } from "../services";
import { useAppSelector } from "../store";
import { UNKNOWN_ID } from "../helpers/consts";
import * as utils from "../helpers/utils";
import useToggle from "../hooks/useToggle";
import useLoading from "../hooks/useLoading";
@@ -39,9 +38,9 @@ const ShortcutList: React.FC<Props> = () => {
<div className="shortcuts-wrapper">
<p className="title-text">
<span className="normal-text">Shortcuts</span>
<span className="btn" onClick={() => showCreateShortcutDialog()}>
<img src="/icons/add.svg" alt="add shortcut" />
</span>
<button className="btn" onClick={() => showCreateShortcutDialog()}>
<i className="fa-solid fa-plus icon-img fa-xs"></i>
</button>
</p>
<div className="shortcuts-container">
{sortedShortcuts.map((s) => {
@@ -63,11 +62,8 @@ const ShortcutContainer: React.FC<ShortcutContainerProps> = (props: ShortcutCont
const handleShortcutClick = () => {
if (isActive) {
locationService.setMemoShortcut(UNKNOWN_ID);
locationService.setMemoShortcut(undefined);
} else {
if (!["/"].includes(locationService.getState().pathname)) {
locationService.setPathname("/");
}
locationService.setMemoShortcut(shortcut.id);
}
};
@@ -113,12 +109,11 @@ const ShortcutContainer: React.FC<ShortcutContainerProps> = (props: ShortcutCont
<>
<div className={`shortcut-container ${isActive ? "active" : ""}`} onClick={handleShortcutClick}>
<div className="shortcut-text-container">
{/* <span className="icon-text">#</span> */}
<span className="shortcut-text">{shortcut.title}</span>
</div>
<div className="btns-container">
<span className="action-btn toggle-btn">
<img className="icon-img" src={`/icons/more${isActive ? "-white" : ""}.svg`} />
<i className="fa-solid fa-ellipsis fa-sm icon-img"></i>
</span>
<div className="action-btns-wrapper">
<div className="action-btns-container">
+30 -28
View File
@@ -1,8 +1,9 @@
import { userService } from "../services";
import { useAppSelector } from "../store";
import * as utils from "../helpers/utils";
import showDailyMemoDiaryDialog from "./DailyMemoDiaryDialog";
import Only from "./common/OnlyWhen";
import showDailyReviewDialog from "./DailyReviewDialog";
import showSettingDialog from "./SettingDialog";
import showMemoTrashDialog from "./MemoTrashDialog";
import showArchivedMemoDialog from "./ArchivedMemoDialog";
import UserBanner from "./UserBanner";
import UsageHeatMap from "./UsageHeatMap";
import ShortcutList from "./ShortcutList";
@@ -12,51 +13,52 @@ import "../less/siderbar.less";
interface Props {}
const Sidebar: React.FC<Props> = () => {
const { memos, tags } = useAppSelector((state) => state.memo);
const user = useAppSelector((state) => state.user.user);
const createdDays = user ? Math.ceil((Date.now() - utils.getTimeStampByDate(user.createdTs)) / 1000 / 3600 / 24) : 0;
const handleMyAccountBtnClick = () => {
showSettingDialog();
};
const handleMemosTrashBtnClick = () => {
showMemoTrashDialog();
const handleArchivedBtnClick = () => {
showArchivedMemoDialog();
};
return (
<aside className="sidebar-wrapper">
<div className="close-container">
<span className="action-btn" onClick={toggleSiderbar}>
<img src="/icons/close.svg" className="icon-img" alt="" />
<i className="fa-solid fa-xmark fa-lg icon-img"></i>
</span>
</div>
<UserBanner />
<div className="status-text-container">
<div className="status-text memos-text">
<span className="amount-text">{memos.length}</span>
<span className="type-text">MEMO</span>
</div>
<div className="status-text tags-text">
<span className="amount-text">{tags.length}</span>
<span className="type-text">TAG</span>
</div>
<div className="status-text duration-text" onClick={() => showDailyMemoDiaryDialog()}>
<span className="amount-text">{createdDays}</span>
<span className="type-text">DAY</span>
</div>
</div>
<UsageHeatMap />
<div className="action-btns-container">
<button className="btn action-btn" onClick={handleMyAccountBtnClick}>
<span className="icon">👤</span> Setting
<button className="btn action-btn" onClick={() => showDailyReviewDialog()}>
<span className="icon">📅</span> Daily Review
</button>
<button className="btn action-btn" onClick={handleMemosTrashBtnClick}>
<span className="icon">🗑</span> Recycle Bin
<Only when={!userService.isVisitorMode()}>
<button className="btn action-btn" onClick={handleMyAccountBtnClick}>
<span className="icon"></span> Setting
</button>
</Only>
<button className="btn action-btn" onClick={handleArchivedBtnClick}>
<span className="icon">🗂</span> Archived
</button>
<Only when={userService.isVisitorMode()}>
{user ? (
<button className="btn action-btn" onClick={() => (window.location.href = "/")}>
<span className="icon">🏠</span> Back to Home
</button>
) : (
<button className="btn action-btn" onClick={() => (window.location.href = "/signin")}>
<span className="icon">👉</span> Sign in
</button>
)}
</Only>
</div>
<ShortcutList />
<Only when={!userService.isVisitorMode()}>
<ShortcutList />
</Only>
<TagList />
</aside>
);
+6 -9
View File
@@ -1,9 +1,9 @@
import { useEffect, useState } from "react";
import * as utils from "../helpers/utils";
import { useAppSelector } from "../store";
import { locationService, memoService } from "../services";
import { locationService, memoService, userService } from "../services";
import useToggle from "../hooks/useToggle";
import Only from "./common/OnlyWhen";
import * as utils from "../helpers/utils";
import "../less/tag-list.less";
interface Tag {
@@ -71,7 +71,7 @@ const TagList: React.FC<Props> = () => {
{tags.map((t, idx) => (
<TagItemContainer key={t.text + "-" + idx} tag={t} tagQuery={query?.tag} />
))}
<Only when={tags.length < 5}>
<Only when={!userService.isVisitorMode() && tags.length < 5}>
<p className="tag-tip-container">
Enter <span className="code-text">#tag </span> to create a tag
</p>
@@ -94,12 +94,9 @@ const TagItemContainer: React.FC<TagItemContainerProps> = (props: TagItemContain
const handleTagClick = () => {
if (isActive) {
locationService.setTagQuery("");
locationService.setTagQuery(undefined);
} else {
utils.copyTextToClipboard(`#${tag.text} `);
if (!["/"].includes(locationService.getState().pathname)) {
locationService.setPathname("/");
}
locationService.setTagQuery(tag.text);
}
};
@@ -119,14 +116,14 @@ const TagItemContainer: React.FC<TagItemContainerProps> = (props: TagItemContain
<div className="btns-container">
{hasSubTags ? (
<span className={`action-btn toggle-btn ${showSubTags ? "shown" : ""}`} onClick={handleToggleBtnClick}>
<img className="icon-img" src="/icons/arrow-right.svg" />
<i className="fa-solid fa-chevron-right icon-img"></i>
</span>
) : null}
</div>
</div>
{hasSubTags ? (
<div className={`subtags-container ${showSubTags ? "" : "hidden"}`}>
<div className={`subtags-container ${showSubTags ? "" : "!hidden"}`}>
{tag.subTags.map((st, idx) => (
<TagItemContainer key={st.text + "-" + idx} tag={st} tagQuery={tagQuery} />
))}
+8 -19
View File
@@ -37,10 +37,8 @@ const UsageHeatMap: React.FC<Props> = () => {
const { memos } = useAppSelector((state) => state.memo);
const [allStat, setAllStat] = useState<DailyUsageStat[]>(getInitialUsageStat(usedDaysAmount, beginDayTimestemp));
const [popupStat, setPopupStat] = useState<DailyUsageStat | null>(null);
const [currentStat, setCurrentStat] = useState<DailyUsageStat | null>(null);
const containerElRef = useRef<HTMLDivElement>(null);
const popupRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const newStat: DailyUsageStat[] = getInitialUsageStat(usedDaysAmount, beginDayTimestemp);
@@ -54,18 +52,17 @@ const UsageHeatMap: React.FC<Props> = () => {
}, [memos]);
const handleUsageStatItemMouseEnter = useCallback((event: React.MouseEvent, item: DailyUsageStat) => {
setPopupStat(item);
if (!popupRef.current) {
return;
}
const targetEl = event.target as HTMLElement;
popupRef.current.style.left = targetEl.offsetLeft + "px";
popupRef.current.style.top = targetEl.offsetTop - 4 + "px";
const tempDiv = document.createElement("div");
tempDiv.className = "usage-detail-container pop-up";
const bounding = utils.getElementBounding(event.target as HTMLElement);
tempDiv.style.left = bounding.left + "px";
tempDiv.style.top = bounding.top - 2 + "px";
tempDiv.innerHTML = `${item.count} memos on <span className="date-text">${new Date(item.timestamp as number).toDateString()}</span>`;
document.body.appendChild(tempDiv);
}, []);
const handleUsageStatItemMouseLeave = useCallback(() => {
setPopupStat(null);
document.body.querySelectorAll("div.usage-detail-container.pop-up").forEach((node) => node.remove());
}, []);
const handleUsageStatItemClick = useCallback((item: DailyUsageStat) => {
@@ -73,9 +70,6 @@ const UsageHeatMap: React.FC<Props> = () => {
locationService.setFromAndToQuery();
setCurrentStat(null);
} else if (item.count > 0) {
if (!["/"].includes(locationService.getState().pathname)) {
locationService.setPathname("/");
}
locationService.setFromAndToQuery(item.timestamp, item.timestamp + DAILY_TIMESTAMP);
setCurrentStat(item);
}
@@ -92,11 +86,6 @@ const UsageHeatMap: React.FC<Props> = () => {
<span className="tip-text"></span>
<span className="tip-text">Sat</span>
</div>
<div ref={popupRef} className={"usage-detail-container pop-up " + (popupStat ? "" : "hidden")}>
{popupStat?.count} memos on <span className="date-text">{new Date(popupStat?.timestamp as number).toDateString()}</span>
</div>
<div className="usage-heat-map">
{allStat.map((v, i) => {
const count = v.count;
+46 -17
View File
@@ -1,40 +1,69 @@
import { useCallback, useState } from "react";
import { useAppSelector } from "../store";
import { useCallback, useEffect, useState } from "react";
import * as utils from "../helpers/utils";
import userService from "../services/userService";
import { locationService } from "../services";
import { useAppSelector } from "../store";
import MenuBtnsPopup from "./MenuBtnsPopup";
import "../less/user-banner.less";
interface Props {}
const UserBanner: React.FC<Props> = () => {
const user = useAppSelector((state) => state.user.user);
const { user, owner } = useAppSelector((state) => state.user);
const { memos, tags } = useAppSelector((state) => state.memo);
const [shouldShowPopupBtns, setShouldShowPopupBtns] = useState(false);
const [username, setUsername] = useState("Memos");
const [createdDays, setCreatedDays] = useState(0);
const isVisitorMode = userService.isVisitorMode();
const username = user ? user.name : "Memos";
useEffect(() => {
if (isVisitorMode) {
if (!owner) {
return;
}
setUsername(owner.name);
setCreatedDays(Math.ceil((Date.now() - utils.getTimeStampByDate(owner.createdTs)) / 1000 / 3600 / 24));
} else if (user) {
setUsername(user.name);
setCreatedDays(Math.ceil((Date.now() - utils.getTimeStampByDate(user.createdTs)) / 1000 / 3600 / 24));
}
}, [isVisitorMode, user, owner]);
const handleUsernameClick = useCallback(() => {
locationService.pushHistory("/");
locationService.clearQuery();
}, []);
const handlePopupBtnClick = () => {
const sidebarEl = document.querySelector(".sidebar-wrapper") as HTMLElement;
const popupEl = document.querySelector(".menu-btns-popup") as HTMLElement;
popupEl.style.top = 54 - sidebarEl.scrollTop + "px";
setShouldShowPopupBtns(true);
};
return (
<div className="user-banner-container">
<div className="username-container" onClick={handleUsernameClick}>
<span className="username-text">{username}</span>
{user?.role === "OWNER" ? <span className="tag">MOD</span> : null}
<>
<div className="user-banner-container">
<div className="username-container" onClick={handleUsernameClick}>
<span className="username-text">{username}</span>
{!isVisitorMode && user?.role === "HOST" ? <span className="tag">MOD</span> : null}
</div>
<button className="action-btn menu-popup-btn" onClick={handlePopupBtnClick}>
<i className="fa-solid fa-ellipsis icon-img"></i>
</button>
<MenuBtnsPopup shownStatus={shouldShowPopupBtns} setShownStatus={setShouldShowPopupBtns} />
</div>
<span className="action-btn menu-popup-btn" onClick={handlePopupBtnClick}>
<img src="/icons/more.svg" className="icon-img" />
</span>
<MenuBtnsPopup shownStatus={shouldShowPopupBtns} setShownStatus={setShouldShowPopupBtns} />
</div>
<div className="amount-text-container">
<div className="status-text memos-text">
<span className="amount-text">{memos.length}</span>
<span className="type-text">MEMO</span>
</div>
<div className="status-text tags-text">
<span className="amount-text">{tags.length}</span>
<span className="type-text">TAG</span>
</div>
<div className="status-text duration-text">
<span className="amount-text">{createdDays}</span>
<span className="type-text">DAY</span>
</div>
</div>
</>
);
};
+2 -2
View File
@@ -55,13 +55,13 @@ const DatePicker: React.FC<DatePickerProps> = (props: DatePickerProps) => {
<div className={`date-picker-wrapper ${className}`}>
<div className="date-picker-header">
<span className="btn-text" onClick={() => handleChangeMonthBtnClick(-1)}>
<img className="icon-img" src="/icons/arrow-left.svg" />
<i className="fa-solid fa-chevron-left icon-img"></i>
</span>
<span className="normal-text">
{firstDate.getFullYear()}/{firstDate.getMonth() + 1}
</span>
<span className="btn-text" onClick={() => handleChangeMonthBtnClick(1)}>
<img className="icon-img" src="/icons/arrow-right.svg" />
<i className="fa-solid fa-chevron-right icon-img"></i>
</span>
</div>
<div className="date-picker-day-container">
+1 -1
View File
@@ -64,7 +64,7 @@ const Selector: React.FC<Props> = (props: Props) => {
<div className={`current-value-container ${showSelector ? "active" : ""}`} onClick={handleCurrentValueClick}>
<span className="value-text">{currentItem.text}</span>
<span className="arrow-text">
<img className="icon-img" src="/icons/arrow-right.svg" />
<i className="fa-solid fa-chevron-down fa-sm icon-img"></i>
</span>
</div>
+39 -12
View File
@@ -1,7 +1,5 @@
import axios from "axios";
axios.defaults.withCredentials = true;
type ResponseObject<T> = {
data: T;
error?: string;
@@ -12,8 +10,8 @@ export function getSystemStatus() {
return axios.get<ResponseObject<SystemStatus>>("/api/status");
}
export function login(email: string, password: string) {
return axios.post<ResponseObject<User>>("/api/auth/login", {
export function signin(email: string, password: string) {
return axios.post<ResponseObject<User>>("/api/auth/signin", {
email,
password,
});
@@ -44,16 +42,23 @@ export function getUserList() {
return axios.get<ResponseObject<User[]>>("/api/user");
}
export function getUserById(id: number) {
return axios.get<ResponseObject<User>>(`/api/user/${id}`);
}
export function patchUser(userPatch: UserPatch) {
return axios.patch<ResponseObject<User>>("/api/user/me", userPatch);
}
export function getMyMemos() {
return axios.get<ResponseObject<Memo[]>>("/api/memo");
}
export function getMyArchivedMemos() {
return axios.get<ResponseObject<Memo[]>>("/api/memo?rowStatus=ARCHIVED");
export function getMemoList(memoFind?: MemoFind) {
const queryList = [];
if (memoFind?.creatorId) {
queryList.push(`creatorId=${memoFind.creatorId}`);
}
if (memoFind?.rowStatus) {
queryList.push(`rowStatus=${memoFind.rowStatus}`);
}
return axios.get<ResponseObject<Memo[]>>(`/api/memo?${queryList.join("&")}`);
}
export function createMemo(memoCreate: MemoCreate) {
@@ -80,8 +85,12 @@ export function deleteMemo(memoId: MemoId) {
return axios.delete(`/api/memo/${memoId}`);
}
export function getMyShortcuts() {
return axios.get<ResponseObject<Shortcut[]>>("/api/shortcut");
export function getShortcutList(shortcutFind?: ShortcutFind) {
const queryList = [];
if (shortcutFind?.creatorId) {
queryList.push(`creatorId=${shortcutFind.creatorId}`);
}
return axios.get<ResponseObject<Shortcut[]>>(`/api/shortcut?${queryList.join("&")}`);
}
export function createShortcut(shortcutCreate: ShortcutCreate) {
@@ -99,3 +108,21 @@ export function deleteShortcutById(shortcutId: ShortcutId) {
export function uploadFile(formData: FormData) {
return axios.post<ResponseObject<Resource>>("/api/resource", formData);
}
export function getTagList(tagFind?: TagFind) {
const queryList = [];
if (tagFind?.creatorId) {
queryList.push(`creatorId=${tagFind.creatorId}`);
}
return axios.get<ResponseObject<string[]>>(`/api/tag?${queryList.join("&")}`);
}
export async function getRepoStarCount() {
const { data } = await axios.get(`https://api.github.com/repos/usememos/memos`, {
headers: {
Accept: "application/vnd.github.v3.star+json",
Authorization: "",
},
});
return data.stargazers_count as number;
}
+6 -6
View File
@@ -11,13 +11,13 @@ export const TOAST_ANIMATION_DURATION = 400;
export const DAILY_TIMESTAMP = 3600 * 24 * 1000;
// tag regex
export const TAG_REG = /#(.+?) /g;
export const TAG_REG = /#(\S+?) /g;
// URL regex
export const LINK_REG = /(https?:\/\/[^\s<\\*>']+)/g;
// markdown image regex
export const IMAGE_URL_REG = /!\[.*?\]\((.+?)\)/g;
// image regex
export const IMAGE_URL_REG = /([^\s<\\*>']+\.(jpeg|jpg|gif|png|svg|webp))/g;
// markdown link regex
export const LINK_URL_REG = /\[(.*?)\]\((.+?)\)/g;
// linked memo regex
export const MEMO_LINK_REG = /\[@(.+?)\]\((.+?)\)/g;
export const MEMO_LINK_REG = /@\[(.+?)\]\((.+?)\)/g;

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