mirror of
https://github.com/usememos/memos.git
synced 2025-06-05 22:09:59 +02:00
Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd6e2337e6 | ||
|
|
66418d4210 | ||
|
|
ab8c7b9d8a | ||
|
|
387799b31c | ||
|
|
4a64a4dea8 | ||
|
|
964c58ac01 | ||
|
|
56716cdad4 | ||
|
|
a2ee750d1e | ||
|
|
3f0601f651 | ||
|
|
6f8e3432e9 | ||
|
|
b7ab6f8e7e | ||
|
|
36b92ad884 | ||
|
|
4d9857ce18 | ||
|
|
43b22ce55f | ||
|
|
147185309c | ||
|
|
f48226d4f2 | ||
|
|
e92407d9ec | ||
|
|
79bf365d78 | ||
|
|
492a1370ab | ||
|
|
e3ddf93c4d | ||
|
|
4a9314c476 | ||
|
|
4767ee3293 | ||
|
|
1ea74dfd0d | ||
|
|
53cf6ebb79 | ||
|
|
d1007950e0 | ||
|
|
331226ec68 | ||
|
|
a7374cf998 | ||
|
|
e3d76193b9 | ||
|
|
07f0c3f052 | ||
|
|
a467a7c173 | ||
|
|
14f9f29348 | ||
|
|
5451fd2d2c | ||
|
|
f092771ea1 | ||
|
|
7c6d7226f5 | ||
|
|
eaebc6dcef | ||
|
|
c5200ca31b | ||
|
|
1078132b12 | ||
|
|
6b058cd299 | ||
|
|
b8f24af5ae | ||
|
|
6384f5af74 | ||
|
|
52038d26d2 | ||
|
|
55f37664ef | ||
|
|
ab8e3473a1 | ||
|
|
eba23c4f6e | ||
|
|
00fe6d3862 | ||
|
|
3646b8f5dd | ||
|
|
d40639bf8e | ||
|
|
12b81781b9 | ||
|
|
b67a37453d | ||
|
|
b04e001db1 | ||
|
|
fbe7b604ef | ||
|
|
0402cb7b27 | ||
|
|
b72bfc9c24 | ||
|
|
40e92f9463 | ||
|
|
f883dd9c1d | ||
|
|
d8bf55efb2 | ||
|
|
f982e83d0a | ||
|
|
3472a6db26 | ||
|
|
c79e51a91b | ||
|
|
ce795a2a7d | ||
|
|
045819c312 | ||
|
|
2fa01886da | ||
|
|
dd7d322c47 | ||
|
|
dfe71f33c2 | ||
|
|
db1d223448 | ||
|
|
54271c1598 | ||
|
|
1ee8ebc9e1 | ||
|
|
6e5537d131 | ||
|
|
90c85103c3 | ||
|
|
2d5d734da4 | ||
|
|
e1e5121dd7 | ||
|
|
85db6721de |
97
.github/workflows/uffizzi-build.yml
vendored
Normal file
97
.github/workflows/uffizzi-build.yml
vendored
Normal file
@@ -0,0 +1,97 @@
|
||||
name: Build PR Image
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, closed]
|
||||
|
||||
jobs:
|
||||
build-memos:
|
||||
name: Build and push `Memos`
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
if: ${{ github.event.action != 'closed' }}
|
||||
steps:
|
||||
- name: Checkout git repo
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Generate UUID image name
|
||||
id: uuid
|
||||
run: echo "UUID_WORKER=$(uuidgen)" >> $GITHUB_ENV
|
||||
|
||||
- name: Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: registry.uffizzi.com/${{ env.UUID_WORKER }}
|
||||
tags: |
|
||||
type=raw,value=60d
|
||||
|
||||
- name: Build and Push Image to registry.uffizzi.com - Uffizzi's ephemeral Registry
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: ./
|
||||
file: Dockerfile
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha, mode=max
|
||||
|
||||
render-compose-file:
|
||||
name: Render Docker Compose File
|
||||
# Pass output of this workflow to another triggered by `workflow_run` event.
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build-memos
|
||||
outputs:
|
||||
compose-file-cache-key: ${{ steps.hash.outputs.hash }}
|
||||
steps:
|
||||
- name: Checkout git repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Render Compose File
|
||||
run: |
|
||||
MEMOS_IMAGE=${{ needs.build-memos.outputs.tags }}
|
||||
export MEMOS_IMAGE
|
||||
# Render simple template from environment variables.
|
||||
envsubst < docker-compose.uffizzi.yml > docker-compose.rendered.yml
|
||||
cat docker-compose.rendered.yml
|
||||
- name: Upload Rendered Compose File as Artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: preview-spec
|
||||
path: docker-compose.rendered.yml
|
||||
retention-days: 2
|
||||
- name: Serialize PR Event to File
|
||||
run: |
|
||||
cat << EOF > event.json
|
||||
${{ toJSON(github.event) }}
|
||||
|
||||
EOF
|
||||
- name: Upload PR Event as Artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: preview-spec
|
||||
path: event.json
|
||||
retention-days: 2
|
||||
|
||||
delete-preview:
|
||||
name: Call for Preview Deletion
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.action == 'closed' }}
|
||||
steps:
|
||||
# If this PR is closing, we will not render a compose file nor pass it to the next workflow.
|
||||
- name: Serialize PR Event to File
|
||||
run: |
|
||||
cat << EOF > event.json
|
||||
${{ toJSON(github.event) }}
|
||||
|
||||
EOF
|
||||
- name: Upload PR Event as Artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: preview-spec
|
||||
path: event.json
|
||||
retention-days: 2
|
||||
86
.github/workflows/uffizzi-preview.yml
vendored
Normal file
86
.github/workflows/uffizzi-preview.yml
vendored
Normal file
@@ -0,0 +1,86 @@
|
||||
name: Deploy Uffizzi Preview
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- "Build PR Image"
|
||||
types:
|
||||
- completed
|
||||
|
||||
|
||||
jobs:
|
||||
cache-compose-file:
|
||||
name: Cache Compose File
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
compose-file-cache-key: ${{ env.HASH }}
|
||||
pr-number: ${{ env.PR_NUMBER }}
|
||||
steps:
|
||||
- name: 'Download artifacts'
|
||||
# Fetch output (zip archive) from the workflow run that triggered this workflow.
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: context.payload.workflow_run.id,
|
||||
});
|
||||
let matchArtifact = allArtifacts.data.artifacts.filter((artifact) => {
|
||||
return artifact.name == "preview-spec"
|
||||
})[0];
|
||||
let download = await github.rest.actions.downloadArtifact({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
artifact_id: matchArtifact.id,
|
||||
archive_format: 'zip',
|
||||
});
|
||||
let fs = require('fs');
|
||||
fs.writeFileSync(`${process.env.GITHUB_WORKSPACE}/preview-spec.zip`, Buffer.from(download.data));
|
||||
|
||||
- name: 'Unzip artifact'
|
||||
run: unzip preview-spec.zip
|
||||
- name: Read Event into ENV
|
||||
run: |
|
||||
echo 'EVENT_JSON<<EOF' >> $GITHUB_ENV
|
||||
cat event.json >> $GITHUB_ENV
|
||||
echo 'EOF' >> $GITHUB_ENV
|
||||
|
||||
- name: Hash Rendered Compose File
|
||||
id: hash
|
||||
# If the previous workflow was triggered by a PR close event, we will not have a compose file artifact.
|
||||
if: ${{ fromJSON(env.EVENT_JSON).action != 'closed' }}
|
||||
run: echo "HASH=$(md5sum docker-compose.rendered.yml | awk '{ print $1 }')" >> $GITHUB_ENV
|
||||
- name: Cache Rendered Compose File
|
||||
if: ${{ fromJSON(env.EVENT_JSON).action != 'closed' }}
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: docker-compose.rendered.yml
|
||||
key: ${{ env.HASH }}
|
||||
|
||||
- name: Read PR Number From Event Object
|
||||
id: pr
|
||||
run: echo "PR_NUMBER=${{ fromJSON(env.EVENT_JSON).number }}" >> $GITHUB_ENV
|
||||
- name: DEBUG - Print Job Outputs
|
||||
if: ${{ runner.debug }}
|
||||
run: |
|
||||
echo "PR number: ${{ env.PR_NUMBER }}"
|
||||
echo "Compose file hash: ${{ env.HASH }}"
|
||||
cat event.json
|
||||
|
||||
deploy-uffizzi-preview:
|
||||
name: Use Remote Workflow to Preview on Uffizzi
|
||||
needs:
|
||||
- cache-compose-file
|
||||
uses: UffizziCloud/preview-action/.github/workflows/reusable.yaml@v2
|
||||
with:
|
||||
# If this workflow was triggered by a PR close event, cache-key will be an empty string
|
||||
# and this reusable workflow will delete the preview deployment.
|
||||
compose-file-cache-key: ${{ needs.cache-compose-file.outputs.compose-file-cache-key }}
|
||||
compose-file-cache-path: docker-compose.rendered.yml
|
||||
server: https://app.uffizzi.com
|
||||
pr-number: ${{ needs.cache-compose-file.outputs.pr-number }}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -15,4 +15,7 @@ build
|
||||
# Jetbrains
|
||||
.idea
|
||||
|
||||
# vscode
|
||||
.vscode
|
||||
|
||||
bin/air
|
||||
|
||||
20
README.md
20
README.md
@@ -1,6 +1,6 @@
|
||||
<p align="center"><a href="https://usememos.com"><img height="64px" src="https://raw.githubusercontent.com/usememos/memos/main/resources/logo-full.webp" alt="✍️ memos" /></a></p>
|
||||
|
||||
<p align="center">An open-source, self-hosted memo hub with knowledge management and collaboration.</p>
|
||||
<p align="center">An open-source, self-hosted memo hub with knowledge management and socialization.</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/usememos/memos/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/usememos/memos" /></a>
|
||||
@@ -19,8 +19,8 @@
|
||||
|
||||
- 🦄 Open source and free forever;
|
||||
- 🚀 Support for self-hosting with `Docker` in seconds;
|
||||
- 📜 Plain textarea first and support some useful markdown syntax;
|
||||
- 👥 Collaborate and share with your teammates;
|
||||
- 📜 Plain textarea first and support some useful Markdown syntax;
|
||||
- 👥 Set memo private or public to others;
|
||||
- 🧑💻 RESTful API for self-service.
|
||||
|
||||
## Deploy with Docker in seconds
|
||||
@@ -31,7 +31,7 @@
|
||||
docker run -d --name memos -p 5230:5230 -v ~/.memos/:/var/opt/memos neosmemo/memos:latest
|
||||
```
|
||||
|
||||
Memos should be running at [http://localhost:5230](http://localhost:5230). If the `~/.memos/` does not have a `memos_prod.db` file, then memos will auto generate it.
|
||||
If the `~/.memos/` does not have a `memos_prod.db` file, then memos will auto generate it. Memos will be running at [http://localhost:5230](http://localhost:5230).
|
||||
|
||||
### Docker Compose
|
||||
|
||||
@@ -45,7 +45,7 @@ docker-compose down && docker image rm neosmemo/memos:latest && docker-compose u
|
||||
|
||||
## Contribute
|
||||
|
||||
Contributions are what make the open source community such an amazing place to be learn, inspire, and create. Any contributions you make are greatly appreciated. 🥰
|
||||
Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are greatly appreciated. 🥰
|
||||
|
||||
See more in [development guide](https://github.com/usememos/memos/tree/main/docs/development.md).
|
||||
|
||||
@@ -53,8 +53,16 @@ See more in [development guide](https://github.com/usememos/memos/tree/main/docs
|
||||
|
||||
- [Moe Memos](https://memos.moe/) - Third party client for iOS and Android
|
||||
- [lmm214/memos-bber](https://github.com/lmm214/memos-bber) - Chrome extension
|
||||
- [Rabithua/memos_wmp](https://github.com/Rabithua/memos_wmp) - Wechat miniprogram
|
||||
- [Rabithua/memos_wmp](https://github.com/Rabithua/memos_wmp) - WeChat MiniProgram
|
||||
- [qazxcdswe123/telegramMemoBot](https://github.com/qazxcdswe123/telegramMemoBot) - Telegram bot
|
||||
- [eallion/memos.top](https://github.com/eallion/memos.top) - A static page rendered with the Memos API
|
||||
- [eindex/logseq-memos-sync](https://github.com/EINDEX/logseq-memos-sync) - A Logseq plugin
|
||||
|
||||
### Join the community to build memos together!
|
||||
|
||||
<a href="https://github.com/usememos/memos/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=usememos/memos" />
|
||||
</a>
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ const (
|
||||
Public Visibility = "PUBLIC"
|
||||
// Protected is the PROTECTED visibility.
|
||||
Protected Visibility = "PROTECTED"
|
||||
// Privite is the PRIVATE visibility.
|
||||
Privite Visibility = "PRIVATE"
|
||||
// Private is the PRIVATE visibility.
|
||||
Private Visibility = "PRIVATE"
|
||||
)
|
||||
|
||||
func (e Visibility) String() string {
|
||||
@@ -18,7 +18,7 @@ func (e Visibility) String() string {
|
||||
return "PUBLIC"
|
||||
case Protected:
|
||||
return "PROTECTED"
|
||||
case Privite:
|
||||
case Private:
|
||||
return "PRIVATE"
|
||||
}
|
||||
return "PRIVATE"
|
||||
|
||||
@@ -10,6 +10,8 @@ type UserSettingKey string
|
||||
const (
|
||||
// UserSettingLocaleKey is the key type for user locale.
|
||||
UserSettingLocaleKey UserSettingKey = "locale"
|
||||
// UserSettingAppearanceKey is the key type for user appearance.
|
||||
UserSettingAppearanceKey UserSettingKey = "appearance"
|
||||
// UserSettingMemoVisibilityKey is the key type for user preference memo default visibility.
|
||||
UserSettingMemoVisibilityKey UserSettingKey = "memoVisibility"
|
||||
// UserSettingMemoDisplayTsOptionKey is the key type for memo display ts option.
|
||||
@@ -21,6 +23,8 @@ func (key UserSettingKey) String() string {
|
||||
switch key {
|
||||
case UserSettingLocaleKey:
|
||||
return "locale"
|
||||
case UserSettingAppearanceKey:
|
||||
return "appearance"
|
||||
case UserSettingMemoVisibilityKey:
|
||||
return "memoVisibility"
|
||||
case UserSettingMemoDisplayTsOptionKey:
|
||||
@@ -30,9 +34,9 @@ func (key UserSettingKey) String() string {
|
||||
}
|
||||
|
||||
var (
|
||||
UserSettingLocaleValue = []string{"en", "zh", "vi", "fr"}
|
||||
UserSettingMemoVisibilityValue = []Visibility{Privite, Protected, Public}
|
||||
UserSettingEditorFontStyleValue = []string{"normal", "mono"}
|
||||
UserSettingLocaleValue = []string{"en", "zh", "vi", "fr", "nl", "sv", "de"}
|
||||
UserSettingAppearanceValue = []string{"system", "light", "dark"}
|
||||
UserSettingMemoVisibilityValue = []Visibility{Private, Protected, Public}
|
||||
UserSettingMemoDisplayTsOptionKeyValue = []string{"created_ts", "updated_ts"}
|
||||
)
|
||||
|
||||
@@ -67,8 +71,25 @@ func (upsert UserSettingUpsert) Validate() error {
|
||||
if invalid {
|
||||
return fmt.Errorf("invalid user setting locale value")
|
||||
}
|
||||
} else if upsert.Key == UserSettingAppearanceKey {
|
||||
appearanceValue := "light"
|
||||
err := json.Unmarshal([]byte(upsert.Value), &appearanceValue)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unmarshal user setting appearance value")
|
||||
}
|
||||
|
||||
invalid := true
|
||||
for _, value := range UserSettingAppearanceValue {
|
||||
if appearanceValue == value {
|
||||
invalid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if invalid {
|
||||
return fmt.Errorf("invalid user setting appearance value")
|
||||
}
|
||||
} else if upsert.Key == UserSettingMemoVisibilityKey {
|
||||
memoVisibilityValue := Privite
|
||||
memoVisibilityValue := Private
|
||||
err := json.Unmarshal([]byte(upsert.Value), &memoVisibilityValue)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unmarshal user setting memo visibility value")
|
||||
|
||||
@@ -40,6 +40,8 @@ func run(profile *profile.Profile) error {
|
||||
serverInstance.Store = storeInstance
|
||||
|
||||
metricCollector := server.NewMetricCollector(profile, storeInstance)
|
||||
// Disable metrics collector.
|
||||
metricCollector.Enabled = false
|
||||
serverInstance.Collector = &metricCollector
|
||||
|
||||
println(greetingBanner)
|
||||
|
||||
18
docker-compose.uffizzi.yml
Normal file
18
docker-compose.uffizzi.yml
Normal file
@@ -0,0 +1,18 @@
|
||||
version: "3.0"
|
||||
|
||||
# uffizzi integration
|
||||
x-uffizzi:
|
||||
ingress:
|
||||
service: memos
|
||||
port: 5230
|
||||
|
||||
services:
|
||||
memos:
|
||||
image: "${MEMOS_IMAGE}"
|
||||
volumes:
|
||||
- memos_volume:/var/opt/memos
|
||||
command: ["--mode", "dev"]
|
||||
|
||||
volumes:
|
||||
memos_volume:
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
@@ -45,7 +45,7 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
|
||||
}
|
||||
|
||||
if userMemoVisibilitySetting != nil {
|
||||
memoVisibility := api.Privite
|
||||
memoVisibility := api.Private
|
||||
err := json.Unmarshal([]byte(userMemoVisibilitySetting.Value), &memoVisibility)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal user setting value").SetInternal(err)
|
||||
@@ -53,7 +53,7 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
|
||||
memoCreate.Visibility = memoVisibility
|
||||
} else {
|
||||
// Private is the default memo visibility.
|
||||
memoCreate.Visibility = api.Privite
|
||||
memoCreate.Visibility = api.Private
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,10 +176,10 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
|
||||
contentSearch := "#" + tag + " "
|
||||
memoFind.ContentSearch = &contentSearch
|
||||
}
|
||||
visibilitListStr := c.QueryParam("visibility")
|
||||
if visibilitListStr != "" {
|
||||
visibilityListStr := c.QueryParam("visibility")
|
||||
if visibilityListStr != "" {
|
||||
visibilityList := []api.Visibility{}
|
||||
for _, visibility := range strings.Split(visibilitListStr, ",") {
|
||||
for _, visibility := range strings.Split(visibilityListStr, ",") {
|
||||
visibilityList = append(visibilityList, api.Visibility(visibility))
|
||||
}
|
||||
memoFind.VisibilityList = visibilityList
|
||||
@@ -271,7 +271,7 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
|
||||
if *memoFind.CreatorID != currentUserID {
|
||||
memoFind.VisibilityList = []api.Visibility{api.Public, api.Protected}
|
||||
} else {
|
||||
memoFind.VisibilityList = []api.Visibility{api.Public, api.Protected, api.Privite}
|
||||
memoFind.VisibilityList = []api.Visibility{api.Public, api.Protected, api.Private}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,10 +313,10 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
|
||||
contentSearch := "#" + tag + " "
|
||||
memoFind.ContentSearch = &contentSearch
|
||||
}
|
||||
visibilitListStr := c.QueryParam("visibility")
|
||||
if visibilitListStr != "" {
|
||||
visibilityListStr := c.QueryParam("visibility")
|
||||
if visibilityListStr != "" {
|
||||
visibilityList := []api.Visibility{}
|
||||
for _, visibility := range strings.Split(visibilitListStr, ",") {
|
||||
for _, visibility := range strings.Split(visibilityListStr, ",") {
|
||||
visibilityList = append(visibilityList, api.Visibility(visibility))
|
||||
}
|
||||
memoFind.VisibilityList = visibilityList
|
||||
@@ -372,7 +372,7 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
|
||||
}
|
||||
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if memo.Visibility == api.Privite {
|
||||
if memo.Visibility == api.Private {
|
||||
if !ok || memo.CreatorID != userID {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "this memo is private only")
|
||||
}
|
||||
|
||||
@@ -13,9 +13,10 @@ import (
|
||||
|
||||
// MetricCollector is the metric collector.
|
||||
type MetricCollector struct {
|
||||
collector metric.Collector
|
||||
profile *profile.Profile
|
||||
store *store.Store
|
||||
Collector metric.Collector
|
||||
Enabled bool
|
||||
Profile *profile.Profile
|
||||
Store *store.Store
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -26,23 +27,28 @@ func NewMetricCollector(profile *profile.Profile, store *store.Store) MetricColl
|
||||
c := segment.NewCollector(segmentMetricWriteKey)
|
||||
|
||||
return MetricCollector{
|
||||
collector: c,
|
||||
profile: profile,
|
||||
store: store,
|
||||
Collector: c,
|
||||
Enabled: true,
|
||||
Profile: profile,
|
||||
Store: store,
|
||||
}
|
||||
}
|
||||
|
||||
func (mc *MetricCollector) Collect(_ context.Context, metric *metric.Metric) {
|
||||
if mc.profile.Mode == "dev" {
|
||||
if !mc.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
if mc.Profile.Mode == "dev" {
|
||||
return
|
||||
}
|
||||
|
||||
if metric.Labels == nil {
|
||||
metric.Labels = map[string]string{}
|
||||
}
|
||||
metric.Labels["version"] = version.GetCurrentVersion(mc.profile.Mode)
|
||||
metric.Labels["version"] = version.GetCurrentVersion(mc.Profile.Mode)
|
||||
|
||||
err := mc.collector.Collect(metric)
|
||||
err := mc.Collector.Collect(metric)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to request segment, error: %+v\n", err)
|
||||
}
|
||||
|
||||
@@ -93,13 +93,13 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
|
||||
}
|
||||
|
||||
for _, resource := range list {
|
||||
memoResoureceList, err := s.Store.FindMemoResourceList(ctx, &api.MemoResourceFind{
|
||||
memoResourceList, err := s.Store.FindMemoResourceList(ctx, &api.MemoResourceFind{
|
||||
ResourceID: &resource.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo resource list").SetInternal(err)
|
||||
}
|
||||
resource.LinkedMemoAmount = len(memoResoureceList)
|
||||
resource.LinkedMemoAmount = len(memoResourceList)
|
||||
}
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||
|
||||
@@ -69,6 +69,7 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
|
||||
}
|
||||
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
// Get database size for host user.
|
||||
if ok {
|
||||
user, err := s.Store.FindUser(ctx, &api.UserFind{
|
||||
ID: &userID,
|
||||
@@ -148,4 +149,26 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
g.POST("/system/vacuum", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
user, err := s.Store.FindUser(ctx, &api.UserFind{
|
||||
ID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
|
||||
}
|
||||
if user == nil || user.Role != api.Host {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||
}
|
||||
if err := s.Store.Vacuum(ctx); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to vacuum database").SetInternal(err)
|
||||
}
|
||||
c.Response().WriteHeader(http.StatusOK)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -223,6 +223,14 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch user").SetInternal(err)
|
||||
}
|
||||
|
||||
userSettingList, err := s.Store.FindUserSettingList(ctx, &api.UserSettingFind{
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find userSettingList").SetInternal(err)
|
||||
}
|
||||
user.UserSettingList = userSettingList
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(user)); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode user response").SetInternal(err)
|
||||
|
||||
@@ -7,10 +7,10 @@ import (
|
||||
|
||||
// Version is the service current released version.
|
||||
// Semantic versioning: https://semver.org/
|
||||
var Version = "0.8.0"
|
||||
var Version = "0.8.2"
|
||||
|
||||
// DevVersion is the service current development version.
|
||||
var DevVersion = "0.8.0"
|
||||
var DevVersion = "0.8.2"
|
||||
|
||||
func GetCurrentVersion(mode string) string {
|
||||
if mode == "dev" {
|
||||
|
||||
@@ -49,8 +49,8 @@ func (db *DB) Open(ctx context.Context) (err error) {
|
||||
}
|
||||
db.Db = sqlDB
|
||||
|
||||
// If mode is dev, we should migrate and seed the database.
|
||||
if db.profile.Mode == "dev" {
|
||||
// In dev mode, we should migrate and seed the database.
|
||||
if _, err := os.Stat(db.profile.DSN); errors.Is(err, os.ErrNotExist) {
|
||||
if err := db.applyLatestSchema(ctx); err != nil {
|
||||
return fmt.Errorf("failed to apply latest schema: %w", err)
|
||||
@@ -65,51 +65,51 @@ func (db *DB) Open(ctx context.Context) (err error) {
|
||||
if err := db.applyLatestSchema(ctx); err != nil {
|
||||
return fmt.Errorf("failed to apply latest schema: %w", err)
|
||||
}
|
||||
} else {
|
||||
currentVersion := version.GetCurrentVersion(db.profile.Mode)
|
||||
migrationHistory, err := db.FindMigrationHistory(ctx, &MigrationHistoryFind{})
|
||||
}
|
||||
|
||||
currentVersion := version.GetCurrentVersion(db.profile.Mode)
|
||||
migrationHistory, err := db.FindMigrationHistory(ctx, &MigrationHistoryFind{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find migration history, err: %w", err)
|
||||
}
|
||||
if migrationHistory == nil {
|
||||
if _, err = db.UpsertMigrationHistory(ctx, &MigrationHistoryUpsert{
|
||||
Version: currentVersion,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to upsert migration history, err: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if version.IsVersionGreaterThan(version.GetSchemaVersion(currentVersion), migrationHistory.Version) {
|
||||
minorVersionList := getMinorVersionList()
|
||||
|
||||
// backup the raw database file before migration
|
||||
rawBytes, err := os.ReadFile(db.profile.DSN)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("failed to read raw database file, err: %w", err)
|
||||
}
|
||||
if migrationHistory == nil {
|
||||
migrationHistory, err = db.UpsertMigrationHistory(ctx, &MigrationHistoryUpsert{
|
||||
Version: currentVersion,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
backupDBFilePath := fmt.Sprintf("%s/memos_%s_%d_backup.db", db.profile.Data, db.profile.Version, time.Now().Unix())
|
||||
if err := os.WriteFile(backupDBFilePath, rawBytes, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write raw database file, err: %w", err)
|
||||
}
|
||||
println("succeed to copy a backup database file")
|
||||
|
||||
if version.IsVersionGreaterThan(version.GetSchemaVersion(currentVersion), migrationHistory.Version) {
|
||||
minorVersionList := getMinorVersionList()
|
||||
|
||||
// backup the raw database file before migration
|
||||
rawBytes, err := os.ReadFile(db.profile.DSN)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read raw database file, err: %w", err)
|
||||
}
|
||||
backupDBFilePath := fmt.Sprintf("%s/memos_%s_%d_backup.db", db.profile.Data, db.profile.Version, time.Now().Unix())
|
||||
if err := os.WriteFile(backupDBFilePath, rawBytes, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write raw database file, err: %w", err)
|
||||
}
|
||||
|
||||
println("succeed to copy a backup database file")
|
||||
println("start migrate")
|
||||
for _, minorVersion := range minorVersionList {
|
||||
normalizedVersion := minorVersion + ".0"
|
||||
if version.IsVersionGreaterThan(normalizedVersion, migrationHistory.Version) && version.IsVersionGreaterOrEqualThan(currentVersion, normalizedVersion) {
|
||||
println("applying migration for", normalizedVersion)
|
||||
if err := db.applyMigrationForMinorVersion(ctx, minorVersion); err != nil {
|
||||
return fmt.Errorf("failed to apply minor version migration: %w", err)
|
||||
}
|
||||
println("start migrate")
|
||||
for _, minorVersion := range minorVersionList {
|
||||
normalizedVersion := minorVersion + ".0"
|
||||
if version.IsVersionGreaterThan(normalizedVersion, migrationHistory.Version) && version.IsVersionGreaterOrEqualThan(currentVersion, normalizedVersion) {
|
||||
println("applying migration for", normalizedVersion)
|
||||
if err := db.applyMigrationForMinorVersion(ctx, minorVersion); err != nil {
|
||||
return fmt.Errorf("failed to apply minor version migration: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
println("end migrate")
|
||||
|
||||
println("end migrate")
|
||||
// remove the created backup db file after migrate succeed
|
||||
if err := os.Remove(backupDBFilePath); err != nil {
|
||||
println(fmt.Sprintf("Failed to remove temp database file, err %v", err))
|
||||
}
|
||||
// remove the created backup db file after migrate succeed
|
||||
if err := os.Remove(backupDBFilePath); err != nil {
|
||||
println(fmt.Sprintf("Failed to remove temp database file, err %v", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,7 +137,7 @@ func (db *DB) applyLatestSchema(ctx context.Context) error {
|
||||
func (db *DB) applyMigrationForMinorVersion(ctx context.Context, minorVersion string) error {
|
||||
filenames, err := fs.Glob(migrationFS, fmt.Sprintf("%s/%s/*.sql", "migration/prod", minorVersion))
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("failed to read ddl files, err: %w", err)
|
||||
}
|
||||
|
||||
sort.Strings(filenames)
|
||||
@@ -163,10 +163,11 @@ func (db *DB) applyMigrationForMinorVersion(ctx context.Context, minorVersion st
|
||||
defer tx.Rollback()
|
||||
|
||||
// upsert the newest version to migration_history
|
||||
version := minorVersion + ".0"
|
||||
if _, err = upsertMigrationHistory(ctx, tx, &MigrationHistoryUpsert{
|
||||
Version: minorVersion + ".0",
|
||||
Version: version,
|
||||
}); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("failed to upsert migration history with version: %s, err: %w", version, err)
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
@@ -175,7 +176,7 @@ func (db *DB) applyMigrationForMinorVersion(ctx context.Context, minorVersion st
|
||||
func (db *DB) seed(ctx context.Context) error {
|
||||
filenames, err := fs.Glob(seedFS, fmt.Sprintf("%s/*.sql", "seed"))
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("failed to read seed files, err: %w", err)
|
||||
}
|
||||
|
||||
sort.Strings(filenames)
|
||||
@@ -203,7 +204,7 @@ func (db *DB) execute(ctx context.Context, stmt string) error {
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx, stmt); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("failed to execute statement, err: %w", err)
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
|
||||
@@ -49,7 +49,7 @@ func (s *Store) Vacuum(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Exec vacuum records in a transcation.
|
||||
// Exec vacuum records in a transaction.
|
||||
func vacuum(ctx context.Context, tx *sql.Tx) error {
|
||||
if err := vacuumMemo(ctx, tx); err != nil {
|
||||
return err
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/logo.webp" type="image/*" />
|
||||
<meta name="theme-color" content="#f6f5f4" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f6f5f4" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#27272a" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<title>Memos</title>
|
||||
|
||||
@@ -10,12 +10,11 @@
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.10.5",
|
||||
"@emotion/styled": "^11.10.5",
|
||||
"@mui/joy": "^5.0.0-alpha.52",
|
||||
"@mui/joy": "^5.0.0-alpha.56",
|
||||
"@reduxjs/toolkit": "^1.8.1",
|
||||
"axios": "^0.27.2",
|
||||
"copy-to-clipboard": "^3.3.2",
|
||||
"dayjs": "^1.11.3",
|
||||
"emoji-picker-react": "^3.6.2",
|
||||
"highlight.js": "^11.6.0",
|
||||
"i18next": "^21.9.2",
|
||||
"lodash-es": "^4.17.21",
|
||||
@@ -25,7 +24,8 @@
|
||||
"react-feather": "^2.0.10",
|
||||
"react-i18next": "^11.18.6",
|
||||
"react-redux": "^8.0.1",
|
||||
"react-router-dom": "^6.4.0"
|
||||
"react-router-dom": "^6.4.0",
|
||||
"tailwindcss": "^3.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jest/globals": "^29.1.2",
|
||||
@@ -47,7 +47,6 @@
|
||||
"lodash": "^4.17.21",
|
||||
"postcss": "^8.4.5",
|
||||
"prettier": "2.5.1",
|
||||
"tailwindcss": "^3.0.18",
|
||||
"ts-jest": "^29.0.3",
|
||||
"typescript": "^4.3.2",
|
||||
"vite": "^3.0.0"
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { CssVarsProvider } from "@mui/joy/styles";
|
||||
import { useEffect } from "react";
|
||||
import { useColorScheme } from "@mui/joy";
|
||||
import { useEffect, Suspense } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RouterProvider } from "react-router-dom";
|
||||
import { locationService } from "./services";
|
||||
import { globalService, locationService } from "./services";
|
||||
import { useAppSelector } from "./store";
|
||||
import router from "./router";
|
||||
import * as storage from "./helpers/storage";
|
||||
import { getSystemColorScheme } from "./helpers/utils";
|
||||
import Loading from "./pages/Loading";
|
||||
|
||||
function App() {
|
||||
const { i18n } = useTranslation();
|
||||
const { locale, systemStatus } = useAppSelector((state) => state.global);
|
||||
const { appearance, locale, systemStatus } = useAppSelector((state) => state.global);
|
||||
const { mode, setMode } = useColorScheme();
|
||||
|
||||
useEffect(() => {
|
||||
locationService.updateStateWithLocation();
|
||||
@@ -18,6 +21,15 @@ function App() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => {
|
||||
if (globalService.getState().appearance === "system") {
|
||||
const mode = e.matches ? "dark" : "light";
|
||||
setMode(mode);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Inject additional style and script codes.
|
||||
useEffect(() => {
|
||||
if (systemStatus.additionalStyle) {
|
||||
@@ -34,16 +46,39 @@ function App() {
|
||||
}, [systemStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute("lang", locale);
|
||||
i18n.changeLanguage(locale);
|
||||
storage.set({
|
||||
locale: locale,
|
||||
});
|
||||
}, [locale]);
|
||||
|
||||
useEffect(() => {
|
||||
storage.set({
|
||||
appearance: appearance,
|
||||
});
|
||||
|
||||
let currentAppearance = appearance;
|
||||
if (appearance === "system") {
|
||||
currentAppearance = getSystemColorScheme();
|
||||
}
|
||||
|
||||
setMode(currentAppearance);
|
||||
}, [appearance]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
if (mode === "light") {
|
||||
root.classList.remove("dark");
|
||||
} else if (mode === "dark") {
|
||||
root.classList.add("dark");
|
||||
}
|
||||
}, [mode]);
|
||||
|
||||
return (
|
||||
<CssVarsProvider>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<RouterProvider router={router} />
|
||||
</CssVarsProvider>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,27 +18,36 @@ const AboutSiteDialog: React.FC<Props> = ({ destroy }: Props) => {
|
||||
return (
|
||||
<>
|
||||
<div className="dialog-header-container">
|
||||
<p className="title-text">
|
||||
<span className="icon-text">🤠</span>
|
||||
{t("common.about")}
|
||||
<p className="title-text flex items-center">
|
||||
<img className="w-7 h-auto mr-1" src="/logo.webp" alt="" />
|
||||
{t("common.about")} memos
|
||||
</p>
|
||||
<button className="btn close-btn" onClick={handleCloseBtnClick}>
|
||||
<Icon.X />
|
||||
</button>
|
||||
</div>
|
||||
<div className="dialog-content-container">
|
||||
<img className="logo-img" src="/logo-full.webp" alt="" />
|
||||
<p>{t("slogan")}</p>
|
||||
<br />
|
||||
<div className="addtion-info-container">
|
||||
<div className="border-t mt-1 pt-2 flex flex-row justify-start items-center">
|
||||
<span className=" text-gray-500 mr-2">Other projects:</span>
|
||||
<a href="https://github.com/boojack/sticky-notes" className="flex items-center underline text-blue-600 hover:opacity-80">
|
||||
<img
|
||||
className="w-5 h-auto mr-1"
|
||||
src="https://raw.githubusercontent.com/boojack/sticky-notes/main/public/sticky-notes.ico"
|
||||
alt=""
|
||||
/>
|
||||
<span>Sticky notes</span>
|
||||
</a>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-row text-sm justify-start items-center">
|
||||
<GitHubBadge />
|
||||
<>
|
||||
<span className="ml-2">
|
||||
{t("common.version")}:
|
||||
<span className="pre-text">
|
||||
<span className="font-mono">
|
||||
{profile.version}-{profile.mode}
|
||||
</span>
|
||||
🎉
|
||||
</>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
52
web/src/components/AppearanceSelect.tsx
Normal file
52
web/src/components/AppearanceSelect.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Option, Select } from "@mui/joy";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { globalService, userService } from "../services";
|
||||
import { useAppSelector } from "../store";
|
||||
import Icon from "./Icon";
|
||||
|
||||
const appearanceList = ["system", "light", "dark"];
|
||||
|
||||
const AppearanceSelect = () => {
|
||||
const user = useAppSelector((state) => state.user.user);
|
||||
const appearance = useAppSelector((state) => state.global.appearance);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getPrefixIcon = (apperance: Appearance) => {
|
||||
const className = "w-4 h-auto";
|
||||
if (apperance === "light") {
|
||||
return <Icon.Sun className={className} />;
|
||||
} else if (apperance === "dark") {
|
||||
return <Icon.Moon className={className} />;
|
||||
} else {
|
||||
return <Icon.Smile className={className} />;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectChange = async (appearance: Appearance) => {
|
||||
if (user) {
|
||||
await userService.upsertUserSetting("appearance", appearance);
|
||||
}
|
||||
globalService.setAppearance(appearance);
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
className="!min-w-[10rem] w-auto text-sm"
|
||||
value={appearance}
|
||||
onChange={(_, appearance) => {
|
||||
if (appearance) {
|
||||
handleSelectChange(appearance);
|
||||
}
|
||||
}}
|
||||
startDecorator={getPrefixIcon(appearance)}
|
||||
>
|
||||
{appearanceList.map((item) => (
|
||||
<Option key={item} value={item} className="whitespace-nowrap">
|
||||
{t(`setting.apperance-option.${item}`)}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppearanceSelect;
|
||||
@@ -51,7 +51,7 @@ const ArchivedMemo: React.FC<Props> = (props: Props) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`memo-wrapper archived-memo ${"memos-" + memo.id}`} onMouseLeave={handleMouseLeaveMemoWrapper}>
|
||||
<div className={`memo-wrapper archived ${"memos-" + memo.id}`} onMouseLeave={handleMouseLeaveMemoWrapper}>
|
||||
<div className="memo-top-wrapper">
|
||||
<span className="time-text">
|
||||
{t("common.archived-at")} {utils.getDateTimeString(memo.updatedTs)}
|
||||
|
||||
125
web/src/components/ChangeMemberPasswordDialog.tsx
Normal file
125
web/src/components/ChangeMemberPasswordDialog.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { validate, ValidatorConfig } from "../helpers/validator";
|
||||
import { userService } from "../services";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import toastHelper from "./Toast";
|
||||
|
||||
const validateConfig: ValidatorConfig = {
|
||||
minLength: 4,
|
||||
maxLength: 320,
|
||||
noSpace: true,
|
||||
noChinese: true,
|
||||
};
|
||||
|
||||
interface Props extends DialogProps {
|
||||
user: User;
|
||||
}
|
||||
|
||||
const ChangeMemberPasswordDialog: React.FC<Props> = (props: Props) => {
|
||||
const { user: propsUser, destroy } = props;
|
||||
const { t } = useTranslation();
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [newPasswordAgain, setNewPasswordAgain] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
// do nth
|
||||
}, []);
|
||||
|
||||
const handleCloseBtnClick = () => {
|
||||
destroy();
|
||||
};
|
||||
|
||||
const handleNewPasswordChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const text = e.target.value as string;
|
||||
setNewPassword(text);
|
||||
};
|
||||
|
||||
const handleNewPasswordAgainChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const text = e.target.value as string;
|
||||
setNewPasswordAgain(text);
|
||||
};
|
||||
|
||||
const handleSaveBtnClick = async () => {
|
||||
if (newPassword === "" || newPasswordAgain === "") {
|
||||
toastHelper.error(t("message.fill-all"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== newPasswordAgain) {
|
||||
toastHelper.error(t("message.new-password-not-match"));
|
||||
setNewPasswordAgain("");
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordValidResult = validate(newPassword, validateConfig);
|
||||
if (!passwordValidResult.result) {
|
||||
toastHelper.error(`${t("common.password")} ${t(passwordValidResult.reason as string)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await userService.patchUser({
|
||||
id: propsUser.id,
|
||||
password: newPassword,
|
||||
});
|
||||
toastHelper.info(t("message.password-changed"));
|
||||
handleCloseBtnClick();
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toastHelper.error(error.response.data.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dialog-header-container !w-64">
|
||||
<p className="title-text">
|
||||
{t("setting.account-section.change-password")} ({propsUser.username})
|
||||
</p>
|
||||
<button className="btn close-btn" onClick={handleCloseBtnClick}>
|
||||
<Icon.X />
|
||||
</button>
|
||||
</div>
|
||||
<div className="dialog-content-container">
|
||||
<p className="text-sm mb-1">{t("common.new-password")}</p>
|
||||
<input
|
||||
type="password"
|
||||
className="input-text"
|
||||
placeholder={t("common.repeat-new-password")}
|
||||
value={newPassword}
|
||||
onChange={handleNewPasswordChanged}
|
||||
/>
|
||||
<p className="text-sm mb-1 mt-2">{t("common.repeat-new-password")}</p>
|
||||
<input
|
||||
type="password"
|
||||
className="input-text"
|
||||
placeholder={t("common.repeat-new-password")}
|
||||
value={newPasswordAgain}
|
||||
onChange={handleNewPasswordAgainChanged}
|
||||
/>
|
||||
<div className="mt-4 w-full flex flex-row justify-end items-center space-x-2">
|
||||
<span className="btn-text" onClick={handleCloseBtnClick}>
|
||||
{t("common.cancel")}
|
||||
</span>
|
||||
<span className="btn-primary" onClick={handleSaveBtnClick}>
|
||||
{t("common.save")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function showChangeMemberPasswordDialog(user: User) {
|
||||
generateDialog(
|
||||
{
|
||||
className: "change-member-password-dialog",
|
||||
},
|
||||
ChangeMemberPasswordDialog,
|
||||
{ user }
|
||||
);
|
||||
}
|
||||
|
||||
export default showChangeMemberPasswordDialog;
|
||||
@@ -8,7 +8,7 @@ import toastHelper from "./Toast";
|
||||
|
||||
const validateConfig: ValidatorConfig = {
|
||||
minLength: 4,
|
||||
maxLength: 24,
|
||||
maxLength: 320,
|
||||
noSpace: true,
|
||||
noChinese: true,
|
||||
};
|
||||
@@ -52,7 +52,7 @@ const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => {
|
||||
|
||||
const passwordValidResult = validate(newPassword, validateConfig);
|
||||
if (!passwordValidResult.result) {
|
||||
toastHelper.error(`${t("common.password")} ${passwordValidResult.reason}`);
|
||||
toastHelper.error(`${t("common.password")} ${t(passwordValidResult.reason as string)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => {
|
||||
<p className="text-sm mb-1">{t("common.new-password")}</p>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
className="input-text"
|
||||
placeholder={t("common.repeat-new-password")}
|
||||
value={newPassword}
|
||||
@@ -90,6 +91,7 @@ const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => {
|
||||
<p className="text-sm mb-1 mt-2">{t("common.repeat-new-password")}</p>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
className="input-text"
|
||||
placeholder={t("common.repeat-new-password")}
|
||||
value={newPasswordAgain}
|
||||
|
||||
@@ -192,7 +192,7 @@ const MemoFilterInputer: React.FC<MemoFilterInputerProps> = (props: MemoFilterIn
|
||||
if (["AND", "OR"].includes(value)) {
|
||||
handleFilterChange(index, {
|
||||
...filter,
|
||||
relation: value as MemoFilterRalation,
|
||||
relation: value as MemoFilterRelation,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@ interface Props extends DialogProps {
|
||||
currentDateStamp: DateStamp;
|
||||
}
|
||||
|
||||
const monthChineseStrArray = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sept", "Oct", "Nov", "Dev"];
|
||||
const monthChineseStrArray = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sept", "Oct", "Nov", "Dec"];
|
||||
const weekdayChineseStrArray = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
|
||||
const DailyReviewDialog: React.FC<Props> = (props: Props) => {
|
||||
@@ -43,7 +43,6 @@ const DailyReviewDialog: React.FC<Props> = (props: Props) => {
|
||||
toggleShowDatePicker(false);
|
||||
|
||||
toImage(memosElRef.current, {
|
||||
backgroundColor: "#ffffff",
|
||||
pixelRatio: window.devicePixelRatio * 2,
|
||||
})
|
||||
.then((url) => {
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Provider } from "react-redux";
|
||||
import { ANIMATION_DURATION } from "../../helpers/consts";
|
||||
import store from "../../store";
|
||||
import "../../less/base-dialog.less";
|
||||
import { CssVarsProvider } from "@mui/joy";
|
||||
import theme from "../../theme";
|
||||
|
||||
interface DialogConfig {
|
||||
className: string;
|
||||
@@ -77,9 +79,11 @@ export function generateDialog<T extends DialogProps>(
|
||||
|
||||
const Fragment = (
|
||||
<Provider store={store}>
|
||||
<BaseDialog destroy={cbs.destroy} clickSpaceDestroy={true} {...config}>
|
||||
<DialogComponent {...dialogProps} />
|
||||
</BaseDialog>
|
||||
<CssVarsProvider theme={theme}>
|
||||
<BaseDialog destroy={cbs.destroy} clickSpaceDestroy={true} {...config}>
|
||||
<DialogComponent {...dialogProps} />
|
||||
</BaseDialog>
|
||||
</CssVarsProvider>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import "../../less/editor.less";
|
||||
export interface EditorRefActions {
|
||||
focus: FunctionType;
|
||||
insertText: (text: string, prefix?: string, suffix?: string) => void;
|
||||
removeText: (start: number, length: number) => void;
|
||||
setContent: (text: string) => void;
|
||||
getContent: () => string;
|
||||
getSelectedContent: () => string;
|
||||
@@ -67,6 +68,19 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
|
||||
handleContentChangeCallback(editorRef.current.value);
|
||||
refresh();
|
||||
},
|
||||
removeText: (start: number, length: number) => {
|
||||
if (!editorRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prevValue = editorRef.current.value;
|
||||
const value = prevValue.slice(0, start) + prevValue.slice(start + length);
|
||||
editorRef.current.value = value;
|
||||
editorRef.current.focus();
|
||||
editorRef.current.selectionEnd = start;
|
||||
handleContentChangeCallback(editorRef.current.value);
|
||||
refresh();
|
||||
},
|
||||
setContent: (text: string) => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.value = text;
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import Picker, { IEmojiPickerProps } from "emoji-picker-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface Props {
|
||||
shouldShow: boolean;
|
||||
onEmojiClick: IEmojiPickerProps["onEmojiClick"];
|
||||
onShouldShowEmojiPickerChange: (status: boolean) => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasShown: boolean;
|
||||
}
|
||||
|
||||
export const EmojiPicker: React.FC<Props> = (props: Props) => {
|
||||
const { shouldShow, onEmojiClick, onShouldShowEmojiPickerChange } = props;
|
||||
const [state, setState] = useState<State>({
|
||||
hasShown: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldShow) {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
const emojiWrapper = document.querySelector(".emoji-picker-react");
|
||||
const isContains = emojiWrapper?.contains(event.target as Node);
|
||||
if (!isContains) {
|
||||
onShouldShowEmojiPickerChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("click", handleClickOutside, {
|
||||
capture: true,
|
||||
once: true,
|
||||
});
|
||||
setState({
|
||||
hasShown: true,
|
||||
});
|
||||
}
|
||||
}, [shouldShow]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{state.hasShown && (
|
||||
<div className={`emoji-picker ${shouldShow ? "" : "hidden"}`}>
|
||||
<Picker onEmojiClick={onEmojiClick} disableSearchBar />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmojiPicker;
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import * as api from "../helpers/api";
|
||||
import Icon from "./Icon";
|
||||
import "../less/github-badge.less";
|
||||
|
||||
const GitHubBadge = () => {
|
||||
const [starCount, setStarCount] = useState(0);
|
||||
@@ -13,12 +12,15 @@ const GitHubBadge = () => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<a className="github-badge-container" href="https://github.com/usememos/memos">
|
||||
<div className="github-icon">
|
||||
<Icon.GitHub className="icon-img" />
|
||||
<a
|
||||
className="h-7 flex flex-row justify-start items-center border dark:border-zinc-600 rounded cursor-pointer hover:opacity-80"
|
||||
href="https://github.com/usememos/memos"
|
||||
>
|
||||
<div className="apply w-auto h-full px-2 flex flex-row justify-center items-center text-xs bg-gray-100 dark:bg-zinc-700">
|
||||
<Icon.GitHub className="mr-1 w-4 h-4" />
|
||||
Star
|
||||
</div>
|
||||
<div className="count-text">{starCount || ""}</div>
|
||||
<div className="w-auto h-full flex flex-row justify-center items-center px-3 text-xs font-bold">{starCount || ""}</div>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import { memo, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import "dayjs/locale/zh";
|
||||
import { editorStateService, locationService, memoService, userService } from "../services";
|
||||
import Icon from "./Icon";
|
||||
import toastHelper from "./Toast";
|
||||
import MemoContent from "./MemoContent";
|
||||
import MemoResources from "./MemoResources";
|
||||
import showShareMemoImageDialog from "./ShareMemoImageDialog";
|
||||
import showShareMemo from "./ShareMemoDialog";
|
||||
import showPreviewImageDialog from "./PreviewImageDialog";
|
||||
import showChangeMemoCreatedTsDialog from "./ChangeMemoCreatedTsDialog";
|
||||
import "../less/memo.less";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface Props {
|
||||
memo: Memo;
|
||||
highlightWord?: string;
|
||||
@@ -93,7 +89,7 @@ const Memo: React.FC<Props> = (props: Props) => {
|
||||
};
|
||||
|
||||
const handleGenMemoImageBtnClick = () => {
|
||||
showShareMemoImageDialog(memo);
|
||||
showShareMemo(memo);
|
||||
};
|
||||
|
||||
const handleMemoContentClick = async (e: React.MouseEvent) => {
|
||||
@@ -139,22 +135,9 @@ const Memo: React.FC<Props> = (props: Props) => {
|
||||
}
|
||||
}
|
||||
} else if (targetEl.tagName === "IMG") {
|
||||
const currImgUrl = targetEl.getAttribute("src");
|
||||
|
||||
if (currImgUrl) {
|
||||
// use regex to get all image urls from memo content
|
||||
const imageUrls =
|
||||
memo.content.match(/!\[.*?\]\((.*?)\)/g)?.map(
|
||||
(item) =>
|
||||
item
|
||||
.match(/\((.*?)\)/g)
|
||||
?.slice(-1)[0]
|
||||
.slice(1, -1) ?? ""
|
||||
) ?? [];
|
||||
showPreviewImageDialog(
|
||||
imageUrls,
|
||||
imageUrls.findIndex((item) => item === currImgUrl)
|
||||
);
|
||||
const imgUrl = targetEl.getAttribute("src");
|
||||
if (imgUrl) {
|
||||
showPreviewImageDialog([imgUrl], 0);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -162,9 +145,7 @@ const Memo: React.FC<Props> = (props: Props) => {
|
||||
const handleMemoContentDoubleClick = (e: React.MouseEvent) => {
|
||||
const targetEl = e.target as HTMLElement;
|
||||
|
||||
if (targetEl.className === "memo-link-text") {
|
||||
return;
|
||||
} else if (targetEl.className === "tag-span") {
|
||||
if (targetEl.className === "tag-span") {
|
||||
return;
|
||||
} else if (targetEl.classList.contains("todo-block")) {
|
||||
return;
|
||||
|
||||
@@ -3,8 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { marked } from "../labs/marked";
|
||||
import { highlightWithWord } from "../labs/highlighter";
|
||||
import Icon from "./Icon";
|
||||
import { SETTING_IS_FOLDING_ENABLED_KEY, IS_FOLDING_ENABLED_DEFAULT_VALUE } from "../helpers/consts";
|
||||
import useLocalStorage from "../hooks/useLocalStorage";
|
||||
import { useAppSelector } from "../store";
|
||||
import "../less/memo-content.less";
|
||||
|
||||
export interface DisplayConfig {
|
||||
@@ -37,7 +36,8 @@ const MemoContent: React.FC<Props> = (props: Props) => {
|
||||
return firstHorizontalRuleIndex !== -1 ? content.slice(0, firstHorizontalRuleIndex) : content;
|
||||
}, [content]);
|
||||
const { t } = useTranslation();
|
||||
const [isFoldingEnabled] = useLocalStorage(SETTING_IS_FOLDING_ENABLED_KEY, IS_FOLDING_ENABLED_DEFAULT_VALUE);
|
||||
const user = useAppSelector((state) => state.user.user);
|
||||
|
||||
const [state, setState] = useState<State>({
|
||||
expandButtonStatus: -1,
|
||||
});
|
||||
@@ -52,15 +52,20 @@ const MemoContent: React.FC<Props> = (props: Props) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (displayConfig.enableExpand && isFoldingEnabled) {
|
||||
if (displayConfig.enableExpand && user && user.localSetting.enableFoldMemo) {
|
||||
if (foldedContent.length !== content.length) {
|
||||
setState({
|
||||
...state,
|
||||
expandButtonStatus: 0,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setState({
|
||||
...state,
|
||||
expandButtonStatus: -1,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
}, [user?.localSetting.enableFoldMemo]);
|
||||
|
||||
const handleMemoContentClick = async (e: React.MouseEvent) => {
|
||||
if (onMemoContentClick) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { IEmojiData } from "emoji-picker-react";
|
||||
import { toLower } from "lodash";
|
||||
import { last, toLower } from "lodash";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { deleteMemoResource, upsertMemoResource } from "../helpers/api";
|
||||
@@ -11,10 +10,12 @@ import Icon from "./Icon";
|
||||
import toastHelper from "./Toast";
|
||||
import Selector from "./common/Selector";
|
||||
import Editor, { EditorRefActions } from "./Editor/Editor";
|
||||
import EmojiPicker from "./Editor/EmojiPicker";
|
||||
import ResourceIcon from "./ResourceIcon";
|
||||
import showResourcesSelectorDialog from "./ResourcesSelectorDialog";
|
||||
import "../less/memo-editor.less";
|
||||
|
||||
const listItemSymbolList = ["* ", "- ", "- [ ] ", "- [x] ", "- [X] "];
|
||||
|
||||
const getEditorContentCache = (): string => {
|
||||
return storage.get(["editorContentCache"]).editorContentCache ?? "";
|
||||
};
|
||||
@@ -34,7 +35,6 @@ const setEditingMemoVisibilityCache = (visibility: Visibility) => {
|
||||
interface State {
|
||||
fullscreen: boolean;
|
||||
isUploadingResource: boolean;
|
||||
resourceList: Resource[];
|
||||
shouldShowEmojiPicker: boolean;
|
||||
}
|
||||
|
||||
@@ -48,12 +48,11 @@ const MemoEditor = () => {
|
||||
isUploadingResource: false,
|
||||
fullscreen: false,
|
||||
shouldShowEmojiPicker: false,
|
||||
resourceList: [],
|
||||
});
|
||||
const [allowSave, setAllowSave] = useState<boolean>(false);
|
||||
const prevGlobalStateRef = useRef(editorState);
|
||||
const prevEditorStateRef = useRef(editorState);
|
||||
const editorRef = useRef<EditorRefActions>(null);
|
||||
const tagSeletorRef = useRef<HTMLDivElement>(null);
|
||||
const tagSelectorRef = useRef<HTMLDivElement>(null);
|
||||
const memoVisibilityOptionSelectorItems = VISIBILITY_SELECTOR_ITEMS.map((item) => {
|
||||
return {
|
||||
value: item.value,
|
||||
@@ -79,13 +78,8 @@ const MemoEditor = () => {
|
||||
if (memo) {
|
||||
handleEditorFocus();
|
||||
editorStateService.setMemoVisibility(memo.visibility);
|
||||
editorStateService.setResourceList(memo.resourceList);
|
||||
editorRef.current?.setContent(memo.content ?? "");
|
||||
setState((state) => {
|
||||
return {
|
||||
...state,
|
||||
resourceList: memo.resourceList,
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
storage.set({
|
||||
@@ -95,23 +89,14 @@ const MemoEditor = () => {
|
||||
storage.remove(["editingMemoIdCache"]);
|
||||
}
|
||||
|
||||
prevGlobalStateRef.current = editorState;
|
||||
prevEditorStateRef.current = editorState;
|
||||
}, [editorState.editMemoId]);
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
if (state.fullscreen) {
|
||||
handleFullscreenBtnClick();
|
||||
} else {
|
||||
handleCancelEdit();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key === "Tab") {
|
||||
event.preventDefault();
|
||||
editorRef.current?.insertText(" ".repeat(TAB_SPACE_WIDTH));
|
||||
if (!editorRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
if (event.key === "Enter") {
|
||||
handleSaveBtnClick();
|
||||
@@ -119,20 +104,53 @@ const MemoEditor = () => {
|
||||
}
|
||||
if (event.key === "b") {
|
||||
event.preventDefault();
|
||||
editorRef.current?.insertText("", "**", "**");
|
||||
editorRef.current.insertText("", "**", "**");
|
||||
return;
|
||||
}
|
||||
if (event.key === "i") {
|
||||
event.preventDefault();
|
||||
editorRef.current?.insertText("", "*", "*");
|
||||
editorRef.current.insertText("", "*", "*");
|
||||
return;
|
||||
}
|
||||
if (event.key === "e") {
|
||||
event.preventDefault();
|
||||
editorRef.current?.insertText("", "`", "`");
|
||||
editorRef.current.insertText("", "`", "`");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
const cursorPosition = editorRef.current.getCursorPosition();
|
||||
const contentBeforeCursor = editorRef.current.getContent().slice(0, cursorPosition);
|
||||
const rowValue = last(contentBeforeCursor.split("\n"));
|
||||
if (rowValue) {
|
||||
if (listItemSymbolList.includes(rowValue)) {
|
||||
event.preventDefault();
|
||||
editorRef.current.removeText(cursorPosition - rowValue.length, rowValue.length);
|
||||
} else {
|
||||
for (const listItemSymbol of listItemSymbolList) {
|
||||
if (rowValue.startsWith(listItemSymbol)) {
|
||||
event.preventDefault();
|
||||
editorRef.current.insertText("", `\n${listItemSymbol}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
if (state.fullscreen) {
|
||||
handleFullscreenBtnClick();
|
||||
} else if (editorState.editMemoId) {
|
||||
handleCancelEdit();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key === "Tab") {
|
||||
event.preventDefault();
|
||||
editorRef.current.insertText(" ".repeat(TAB_SPACE_WIDTH));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDropEvent = async (event: React.DragEvent) => {
|
||||
@@ -148,12 +166,7 @@ const MemoEditor = () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
setState((state) => {
|
||||
return {
|
||||
...state,
|
||||
resourceList: [...state.resourceList, ...resourceList],
|
||||
};
|
||||
});
|
||||
editorStateService.setResourceList([...editorState.resourceList, ...resourceList]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -163,12 +176,7 @@ const MemoEditor = () => {
|
||||
const file = event.clipboardData.files[0];
|
||||
const resource = await handleUploadResource(file);
|
||||
if (resource) {
|
||||
setState((state) => {
|
||||
return {
|
||||
...state,
|
||||
resourceList: [...state.resourceList, resource],
|
||||
};
|
||||
});
|
||||
editorStateService.setResourceList([...editorState.resourceList, resource]);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -216,7 +224,7 @@ const MemoEditor = () => {
|
||||
id: prevMemo.id,
|
||||
content,
|
||||
visibility: editorState.memoVisibility,
|
||||
resourceIdList: state.resourceList.map((resource) => resource.id),
|
||||
resourceIdList: editorState.resourceList.map((resource) => resource.id),
|
||||
});
|
||||
}
|
||||
editorStateService.clearEditMemo();
|
||||
@@ -224,7 +232,7 @@ const MemoEditor = () => {
|
||||
await memoService.createMemo({
|
||||
content,
|
||||
visibility: editorState.memoVisibility,
|
||||
resourceIdList: state.resourceList.map((resource) => resource.id),
|
||||
resourceIdList: editorState.resourceList.map((resource) => resource.id),
|
||||
});
|
||||
locationService.clearQuery();
|
||||
}
|
||||
@@ -237,23 +245,22 @@ const MemoEditor = () => {
|
||||
return {
|
||||
...state,
|
||||
fullscreen: false,
|
||||
resourceList: [],
|
||||
};
|
||||
});
|
||||
editorStateService.clearResourceList();
|
||||
setEditorContentCache("");
|
||||
storage.remove(["editingMemoVisibilityCache"]);
|
||||
editorRef.current?.setContent("");
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setState({
|
||||
...state,
|
||||
resourceList: [],
|
||||
});
|
||||
editorStateService.clearEditMemo();
|
||||
editorRef.current?.setContent("");
|
||||
setEditorContentCache("");
|
||||
storage.remove(["editingMemoVisibilityCache"]);
|
||||
if (editorState.editMemoId) {
|
||||
editorStateService.clearEditMemo();
|
||||
editorStateService.clearResourceList();
|
||||
editorRef.current?.setContent("");
|
||||
setEditorContentCache("");
|
||||
storage.remove(["editingMemoVisibilityCache"]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContentChange = (content: string) => {
|
||||
@@ -261,10 +268,6 @@ const MemoEditor = () => {
|
||||
setEditorContentCache(content);
|
||||
};
|
||||
|
||||
const handleEmojiPickerBtnClick = () => {
|
||||
handleChangeShouldShowEmojiPicker(!state.shouldShowEmojiPicker);
|
||||
};
|
||||
|
||||
const handleCheckBoxBtnClick = () => {
|
||||
if (!editorRef.current) {
|
||||
return;
|
||||
@@ -317,12 +320,7 @@ const MemoEditor = () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
setState((state) => {
|
||||
return {
|
||||
...state,
|
||||
resourceList: [...state.resourceList, ...resourceList],
|
||||
};
|
||||
});
|
||||
editorStateService.setResourceList([...editorState.resourceList, ...resourceList]);
|
||||
document.body.removeChild(inputEl);
|
||||
};
|
||||
inputEl.click();
|
||||
@@ -337,37 +335,15 @@ const MemoEditor = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleTagSeletorClick = useCallback((event: React.MouseEvent) => {
|
||||
if (tagSeletorRef.current !== event.target && tagSeletorRef.current?.contains(event.target as Node)) {
|
||||
const handleTagSelectorClick = useCallback((event: React.MouseEvent) => {
|
||||
if (tagSelectorRef.current !== event.target && tagSelectorRef.current?.contains(event.target as Node)) {
|
||||
editorRef.current?.insertText(`#${(event.target as HTMLElement).textContent} ` ?? "");
|
||||
handleEditorFocus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleChangeShouldShowEmojiPicker = (status: boolean) => {
|
||||
setState({
|
||||
...state,
|
||||
shouldShowEmojiPicker: status,
|
||||
});
|
||||
};
|
||||
|
||||
const handleEmojiClick = (_: any, emojiObject: IEmojiData) => {
|
||||
if (!editorRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
editorRef.current.insertText(`${emojiObject.emoji}`);
|
||||
handleChangeShouldShowEmojiPicker(false);
|
||||
};
|
||||
|
||||
const handleDeleteResource = async (resourceId: ResourceId) => {
|
||||
setState((state) => {
|
||||
return {
|
||||
...state,
|
||||
resourceList: state.resourceList.filter((resource) => resource.id !== resourceId),
|
||||
};
|
||||
});
|
||||
|
||||
editorStateService.setResourceList(editorState.resourceList.filter((resource) => resource.id !== resourceId));
|
||||
if (editorState.editMemoId) {
|
||||
await deleteMemoResource(editorState.editMemoId, resourceId);
|
||||
}
|
||||
@@ -415,7 +391,7 @@ const MemoEditor = () => {
|
||||
<div className="common-tools-container">
|
||||
<div className="action-btn tag-action">
|
||||
<Icon.Hash className="icon-img" />
|
||||
<div ref={tagSeletorRef} className="tag-list" onClick={handleTagSeletorClick}>
|
||||
<div ref={tagSelectorRef} className="tag-list" onClick={handleTagSelectorClick}>
|
||||
{tags.length > 0 ? (
|
||||
tags.map((tag) => {
|
||||
return (
|
||||
@@ -431,32 +407,34 @@ const MemoEditor = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button className="action-btn !hidden sm:!flex ">
|
||||
<Icon.Smile className="icon-img" onClick={handleEmojiPickerBtnClick} />
|
||||
</button>
|
||||
<button className="action-btn">
|
||||
<Icon.CheckSquare className="icon-img" onClick={handleCheckBoxBtnClick} />
|
||||
</button>
|
||||
<button className="action-btn">
|
||||
<Icon.Code className="icon-img" onClick={handleCodeBlockBtnClick} />
|
||||
</button>
|
||||
<button className="action-btn">
|
||||
<Icon.FileText className="icon-img" onClick={handleUploadFileBtnClick} />
|
||||
<div className="action-btn resource-btn">
|
||||
<Icon.FileText className="icon-img" />
|
||||
<span className={`tip-text ${state.isUploadingResource ? "!block" : ""}`}>Uploading</span>
|
||||
</button>
|
||||
<div className="resource-action-list">
|
||||
<div className="resource-action-item" onClick={handleUploadFileBtnClick}>
|
||||
<Icon.Upload className="icon-img" />
|
||||
<span>{t("editor.local")}</span>
|
||||
</div>
|
||||
<div className="resource-action-item" onClick={showResourcesSelectorDialog}>
|
||||
<Icon.Database className="icon-img" />
|
||||
<span>{t("editor.resources")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button className="action-btn" onClick={handleFullscreenBtnClick}>
|
||||
{state.fullscreen ? <Icon.Minimize className="icon-img" /> : <Icon.Maximize className="icon-img" />}
|
||||
</button>
|
||||
<EmojiPicker
|
||||
shouldShow={state.shouldShowEmojiPicker}
|
||||
onEmojiClick={handleEmojiClick}
|
||||
onShouldShowEmojiPickerChange={handleChangeShouldShowEmojiPicker}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{state.resourceList.length > 0 && (
|
||||
{editorState.resourceList && editorState.resourceList.length > 0 && (
|
||||
<div className="resource-list-wrapper">
|
||||
{state.resourceList.map((resource) => {
|
||||
{editorState.resourceList.map((resource) => {
|
||||
return (
|
||||
<div key={resource.id} className="resource-container">
|
||||
<ResourceIcon resourceType="resource.type" className="icon-img" />
|
||||
|
||||
@@ -24,7 +24,7 @@ const MemoResources: React.FC<Props> = (props: Props) => {
|
||||
const availableResourceList = resourceList.filter((resource) => resource.type.startsWith("image") || resource.type.startsWith("video"));
|
||||
const otherResourceList = resourceList.filter((resource) => !availableResourceList.includes(resource));
|
||||
|
||||
const handlPreviewBtnClick = (resource: Resource) => {
|
||||
const handlePreviewBtnClick = (resource: Resource) => {
|
||||
const resourceUrl = `${window.location.origin}/o/r/${resource.id}/${resource.filename}`;
|
||||
window.open(resourceUrl);
|
||||
};
|
||||
@@ -45,8 +45,10 @@ const MemoResources: React.FC<Props> = (props: Props) => {
|
||||
return (
|
||||
<Image className="memo-resource" key={resource.id} imgUrls={imgUrls} index={imgUrls.findIndex((item) => item === url)} />
|
||||
);
|
||||
} else {
|
||||
} else if (resource.type.startsWith("video")) {
|
||||
return <video className="memo-resource" controls key={resource.id} src={url} />;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
@@ -54,7 +56,7 @@ const MemoResources: React.FC<Props> = (props: Props) => {
|
||||
<div className="other-resource-wrapper">
|
||||
{otherResourceList.map((resource) => {
|
||||
return (
|
||||
<div className="other-resource-container" key={resource.id} onClick={() => handlPreviewBtnClick(resource)}>
|
||||
<div className="other-resource-container" key={resource.id} onClick={() => handlePreviewBtnClick(resource)}>
|
||||
<Icon.FileText className="icon-img" />
|
||||
<span className="name-text">{resource.filename}</span>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { memoService, shortcutService } from "../services";
|
||||
import { useAppSelector } from "../store";
|
||||
import Icon from "./Icon";
|
||||
import SearchBar from "./SearchBar";
|
||||
import { toggleSiderbar } from "./Sidebar";
|
||||
import { toggleSidebar } from "./Sidebar";
|
||||
import "../less/memos-header.less";
|
||||
|
||||
let prevRequestTimestamp = Date.now();
|
||||
@@ -38,7 +38,7 @@ const MemosHeader = () => {
|
||||
return (
|
||||
<div className="section-header-container memos-header-container">
|
||||
<div className="title-container">
|
||||
<div className="action-btn" onClick={() => toggleSiderbar(true)}>
|
||||
<div className="action-btn" onClick={() => toggleSidebar(true)}>
|
||||
<Icon.Menu className="icon-img" />
|
||||
</div>
|
||||
<span className="title-text" onClick={handleTitleTextClick}>
|
||||
|
||||
@@ -1,16 +1,33 @@
|
||||
import { useState } from "react";
|
||||
import React, { useState } from "react";
|
||||
import * as utils from "../helpers/utils";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import "../less/preview-image-dialog.less";
|
||||
|
||||
const MIN_SCALE = 0.5;
|
||||
const MAX_SCALE = 5;
|
||||
const SCALE_UNIT = 0.25;
|
||||
|
||||
interface Props extends DialogProps {
|
||||
imgUrls: string[];
|
||||
initialIndex: number;
|
||||
}
|
||||
|
||||
interface State {
|
||||
angle: number;
|
||||
scale: number;
|
||||
originX: number;
|
||||
originY: number;
|
||||
}
|
||||
|
||||
const PreviewImageDialog: React.FC<Props> = ({ destroy, imgUrls, initialIndex }: Props) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||
const [state, setState] = useState<State>({
|
||||
angle: 0,
|
||||
scale: 1,
|
||||
originX: -1,
|
||||
originY: -1,
|
||||
});
|
||||
|
||||
const handleCloseBtnClick = () => {
|
||||
destroy();
|
||||
@@ -39,6 +56,38 @@ const PreviewImageDialog: React.FC<Props> = ({ destroy, imgUrls, initialIndex }:
|
||||
}
|
||||
};
|
||||
|
||||
const handleImgRotate = (event: React.MouseEvent, angle: number) => {
|
||||
const curImgAngle = (state.angle + angle + 360) % 360;
|
||||
setState({
|
||||
...state,
|
||||
originX: -1,
|
||||
originY: -1,
|
||||
angle: curImgAngle,
|
||||
});
|
||||
};
|
||||
|
||||
const handleImgContainerScroll = (event: React.WheelEvent) => {
|
||||
const offsetX = event.nativeEvent.offsetX;
|
||||
const offsetY = event.nativeEvent.offsetY;
|
||||
const sign = event.deltaY < 0 ? 1 : -1;
|
||||
const curAngle = Math.max(MIN_SCALE, Math.min(MAX_SCALE, state.scale + sign * SCALE_UNIT));
|
||||
setState({
|
||||
...state,
|
||||
originX: offsetX,
|
||||
originY: offsetY,
|
||||
scale: curAngle,
|
||||
});
|
||||
};
|
||||
|
||||
const getImageComputedStyle = () => {
|
||||
return {
|
||||
transform: `scale(${state.scale}) rotate(${state.angle}deg)`,
|
||||
transformOrigin: `${state.originX === -1 ? "center" : `${state.originX}px`} ${
|
||||
state.originY === -1 ? "center" : `${state.originY}px`
|
||||
}`,
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="btns-container">
|
||||
@@ -48,9 +97,20 @@ const PreviewImageDialog: React.FC<Props> = ({ destroy, imgUrls, initialIndex }:
|
||||
<button className="btn" onClick={handleDownloadBtnClick}>
|
||||
<Icon.Download className="icon-img" />
|
||||
</button>
|
||||
<button className="btn" onClick={(e) => handleImgRotate(e, -90)}>
|
||||
<Icon.RotateCcw className="icon-img" />
|
||||
</button>
|
||||
<button className="btn" onClick={(e) => handleImgRotate(e, 90)}>
|
||||
<Icon.RotateCw className="icon-img" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="img-container" onClick={handleImgContainerClick}>
|
||||
<img onClick={(e) => e.stopPropagation()} src={imgUrls[currentIndex]} />
|
||||
<img
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
src={imgUrls[currentIndex]}
|
||||
onWheel={handleImgContainerScroll}
|
||||
style={getImageComputedStyle()}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Tooltip } from "@mui/joy";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as utils from "../helpers/utils";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import { resourceService } from "../services";
|
||||
import { useAppSelector } from "../store";
|
||||
@@ -83,15 +83,15 @@ const ResourcesDialog: React.FC<Props> = (props: Props) => {
|
||||
inputEl.click();
|
||||
};
|
||||
|
||||
const getResouceUrl = useCallback((resource: Resource) => {
|
||||
const getResourceUrl = useCallback((resource: Resource) => {
|
||||
return `${window.location.origin}/o/r/${resource.id}/${resource.filename}`;
|
||||
}, []);
|
||||
|
||||
const handlePreviewBtnClick = (resource: Resource) => {
|
||||
const resourceUrl = getResouceUrl(resource);
|
||||
const resourceUrl = getResourceUrl(resource);
|
||||
if (resource.type.startsWith("image")) {
|
||||
showPreviewImageDialog(
|
||||
resources.filter((r) => r.type.startsWith("image")).map((r) => getResouceUrl(r)),
|
||||
resources.filter((r) => r.type.startsWith("image")).map((r) => getResourceUrl(r)),
|
||||
resources.findIndex((r) => r.id === resource.id)
|
||||
);
|
||||
} else {
|
||||
@@ -149,20 +149,6 @@ const ResourcesDialog: React.FC<Props> = (props: Props) => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleResourceNameOrTypeMouseEnter = useCallback((event: React.MouseEvent, nameOrType: string) => {
|
||||
const tempDiv = document.createElement("div");
|
||||
tempDiv.className = "usage-detail-container pop-up";
|
||||
const bounding = utils.getElementBounding(event.target as HTMLElement);
|
||||
tempDiv.style.left = bounding.left + "px";
|
||||
tempDiv.style.top = bounding.top - 2 + "px";
|
||||
tempDiv.innerHTML = `<span>${nameOrType}</span>`;
|
||||
document.body.appendChild(tempDiv);
|
||||
}, []);
|
||||
|
||||
const handleResourceNameOrTypeMouseLeave = useCallback(() => {
|
||||
document.body.querySelectorAll("div.usage-detail-container.pop-up").forEach((node) => node.remove());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dialog-header-container">
|
||||
@@ -206,39 +192,34 @@ const ResourcesDialog: React.FC<Props> = (props: Props) => {
|
||||
resources.map((resource) => (
|
||||
<div key={resource.id} className="resource-container">
|
||||
<span className="field-text id-text">{resource.id}</span>
|
||||
<span className="field-text name-text">
|
||||
<span
|
||||
onMouseEnter={(e) => handleResourceNameOrTypeMouseEnter(e, resource.filename)}
|
||||
onMouseLeave={handleResourceNameOrTypeMouseLeave}
|
||||
>
|
||||
{resource.filename}
|
||||
</span>
|
||||
</span>
|
||||
<Tooltip title={resource.filename}>
|
||||
<span className="field-text name-text">{resource.filename}</span>
|
||||
</Tooltip>
|
||||
<div className="buttons-container">
|
||||
<Dropdown
|
||||
actionsClassName="!w-28"
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded hover:bg-gray-100"
|
||||
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-600"
|
||||
onClick={() => handlePreviewBtnClick(resource)}
|
||||
>
|
||||
{t("resources.preview")}
|
||||
</button>
|
||||
<button
|
||||
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded hover:bg-gray-100"
|
||||
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-600"
|
||||
onClick={() => handleRenameBtnClick(resource)}
|
||||
>
|
||||
{t("resources.rename")}
|
||||
</button>
|
||||
<button
|
||||
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded hover:bg-gray-100"
|
||||
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-600"
|
||||
onClick={() => handleCopyResourceLinkBtnClick(resource)}
|
||||
>
|
||||
{t("resources.copy-link")}
|
||||
</button>
|
||||
<button
|
||||
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded text-red-600 hover:bg-gray-100"
|
||||
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded text-red-600 hover:bg-gray-100 dark:hover:bg-zinc-600"
|
||||
onClick={() => handleDeleteResourceBtnClick(resource)}
|
||||
>
|
||||
{t("common.delete")}
|
||||
|
||||
155
web/src/components/ResourcesSelectorDialog.tsx
Normal file
155
web/src/components/ResourcesSelectorDialog.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { Checkbox, Tooltip } from "@mui/joy";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import { editorStateService, resourceService } from "../services";
|
||||
import { useAppSelector } from "../store";
|
||||
import Icon from "./Icon";
|
||||
import toastHelper from "./Toast";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import showPreviewImageDialog from "./PreviewImageDialog";
|
||||
import "../less/resources-selector-dialog.less";
|
||||
|
||||
type Props = DialogProps;
|
||||
|
||||
interface State {
|
||||
checkedArray: boolean[];
|
||||
}
|
||||
|
||||
const ResourcesSelectorDialog: React.FC<Props> = (props: Props) => {
|
||||
const { destroy } = props;
|
||||
const { t } = useTranslation();
|
||||
const loadingState = useLoading();
|
||||
const { resources } = useAppSelector((state) => state.resource);
|
||||
const editorState = useAppSelector((state) => state.editor);
|
||||
const [state, setState] = useState<State>({
|
||||
checkedArray: [],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
resourceService
|
||||
.fetchResourceList()
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
toastHelper.error(error.response.data.message);
|
||||
})
|
||||
.finally(() => {
|
||||
loadingState.setFinish();
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const checkedResourceIdArray = editorState.resourceList.map((resource) => resource.id);
|
||||
setState({
|
||||
checkedArray: resources.map((resource) => {
|
||||
return checkedResourceIdArray.includes(resource.id);
|
||||
}),
|
||||
});
|
||||
}, [resources]);
|
||||
|
||||
const getResourceUrl = useCallback((resource: Resource) => {
|
||||
return `${window.location.origin}/o/r/${resource.id}/${resource.filename}`;
|
||||
}, []);
|
||||
|
||||
const handlePreviewBtnClick = (resource: Resource) => {
|
||||
const resourceUrl = getResourceUrl(resource);
|
||||
if (resource.type.startsWith("image")) {
|
||||
showPreviewImageDialog(
|
||||
resources.filter((r) => r.type.startsWith("image")).map((r) => getResourceUrl(r)),
|
||||
resources.findIndex((r) => r.id === resource.id)
|
||||
);
|
||||
} else {
|
||||
window.open(resourceUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckboxChange = (index: number) => {
|
||||
const newCheckedArr = state.checkedArray;
|
||||
newCheckedArr[index] = !newCheckedArr[index];
|
||||
setState({
|
||||
checkedArray: newCheckedArr,
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirmBtnClick = () => {
|
||||
const resourceList = resources.filter((_, index) => {
|
||||
return state.checkedArray[index];
|
||||
});
|
||||
editorStateService.setResourceList(resourceList);
|
||||
destroy();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dialog-header-container">
|
||||
<p className="title-text">
|
||||
<span className="icon-text">🌄</span>
|
||||
{t("sidebar.resources")}
|
||||
</p>
|
||||
<button className="btn close-btn" onClick={destroy}>
|
||||
<Icon.X className="icon-img" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="dialog-content-container">
|
||||
{loadingState.isLoading ? (
|
||||
<div className="loading-text-container">
|
||||
<p className="tip-text">{t("resources.fetching-data")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="resource-table-container">
|
||||
<div className="fields-container">
|
||||
<span className="field-text id-text">ID</span>
|
||||
<span className="field-text name-text">NAME</span>
|
||||
<span></span>
|
||||
</div>
|
||||
{resources.length === 0 ? (
|
||||
<p className="tip-text">{t("resources.no-resources")}</p>
|
||||
) : (
|
||||
resources.map((resource, index) => (
|
||||
<div key={resource.id} className="resource-container">
|
||||
<span className="field-text id-text">{resource.id}</span>
|
||||
<Tooltip placement="top-start" title={resource.filename}>
|
||||
<span className="field-text name-text">{resource.filename}</span>
|
||||
</Tooltip>
|
||||
<div className="flex justify-end">
|
||||
<Icon.Eye
|
||||
className=" text-left text-sm leading-6 px-1 mr-2 cursor-pointer hover:opacity-80"
|
||||
onClick={() => handlePreviewBtnClick(resource)}
|
||||
>
|
||||
{t("resources.preview")}
|
||||
</Icon.Eye>
|
||||
<Checkbox checked={state.checkedArray[index]} onChange={() => handleCheckboxChange(index)} />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between w-full mt-2 px-2">
|
||||
<span className="text-sm font-mono text-gray-400 leading-8">
|
||||
{t("message.count-selected-resources")}: {state.checkedArray.filter((checked) => checked).length}
|
||||
</span>
|
||||
<div className="flex flex-row justify-start items-center">
|
||||
<div
|
||||
className="text-sm cursor-pointer px-3 py-1 rounded flex flex-row justify-center items-center border border-blue-600 text-blue-600 bg-blue-50 hover:opacity-80"
|
||||
onClick={handleConfirmBtnClick}
|
||||
>
|
||||
<Icon.PlusSquare className=" w-4 h-auto mr-1" />
|
||||
<span>{t("common.confirm")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default function showResourcesSelectorDialog() {
|
||||
generateDialog(
|
||||
{
|
||||
className: "resources-selector-dialog",
|
||||
},
|
||||
ResourcesSelectorDialog,
|
||||
{}
|
||||
);
|
||||
}
|
||||
@@ -34,7 +34,14 @@ const SearchBar = () => {
|
||||
<div className="search-bar-container">
|
||||
<div className="search-bar-inputer">
|
||||
<Icon.Search className="icon-img" />
|
||||
<input className="text-input" type="text" placeholder="" value={queryText} onChange={handleTextQueryInput} />
|
||||
<input
|
||||
className="text-input"
|
||||
autoComplete="new-password"
|
||||
type="text"
|
||||
placeholder=""
|
||||
value={queryText}
|
||||
onChange={handleTextQueryInput}
|
||||
/>
|
||||
</div>
|
||||
<div className="quickly-action-wrapper">
|
||||
<div className="quickly-action-container">
|
||||
|
||||
@@ -6,6 +6,7 @@ import * as api from "../../helpers/api";
|
||||
import toastHelper from "../Toast";
|
||||
import Dropdown from "../common/Dropdown";
|
||||
import { showCommonDialog } from "../Dialog/CommonDialog";
|
||||
import showChangeMemberPasswordDialog from "../ChangeMemberPasswordDialog";
|
||||
import "../../less/settings/member-section.less";
|
||||
|
||||
interface State {
|
||||
@@ -60,7 +61,6 @@ const PreferencesSection = () => {
|
||||
try {
|
||||
await api.createUser(userCreate);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toastHelper.error(error.response.data.message);
|
||||
}
|
||||
await fetchUserList();
|
||||
@@ -70,6 +70,10 @@ const PreferencesSection = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleChangePasswordClick = (user: User) => {
|
||||
showChangeMemberPasswordDialog(user);
|
||||
};
|
||||
|
||||
const handleArchiveUserClick = (user: User) => {
|
||||
showCommonDialog({
|
||||
title: `Archive Member`,
|
||||
@@ -113,17 +117,33 @@ const PreferencesSection = () => {
|
||||
<div className="create-member-container">
|
||||
<div className="input-form-container">
|
||||
<span className="field-text">{t("common.username")}</span>
|
||||
<input type="text" placeholder={t("common.username")} value={state.createUserUsername} onChange={handleUsernameInputChange} />
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="new-password"
|
||||
placeholder={t("common.username")}
|
||||
value={state.createUserUsername}
|
||||
onChange={handleUsernameInputChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="input-form-container">
|
||||
<span className="field-text">{t("common.password")}</span>
|
||||
<input type="text" placeholder={t("common.password")} value={state.createUserPassword} onChange={handlePasswordInputChange} />
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={t("common.password")}
|
||||
value={state.createUserPassword}
|
||||
onChange={handlePasswordInputChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="btns-container">
|
||||
<button onClick={handleCreateUserBtnClick}>{t("common.create")}</button>
|
||||
<button className="btn-normal" onClick={handleCreateUserBtnClick}>
|
||||
{t("common.create")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="title-text">{t("setting.member-list")}</p>
|
||||
<div className="w-full flex flex-row justify-between items-center">
|
||||
<div className="title-text">{t("setting.member-list")}</div>
|
||||
</div>
|
||||
<div className="member-container field-container">
|
||||
<span className="field-text">ID</span>
|
||||
<span className="field-text username-field">{t("common.username")}</span>
|
||||
@@ -138,12 +158,17 @@ const PreferencesSection = () => {
|
||||
<span className="tip-text">{t("common.yourself")}</span>
|
||||
) : (
|
||||
<Dropdown
|
||||
actionsClassName="!w-24"
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
className="w-full text-left text-sm whitespace-nowrap leading-6 py-1 px-3 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-600"
|
||||
onClick={() => handleChangePasswordClick(user)}
|
||||
>
|
||||
{t("setting.account-section.change-password")}
|
||||
</button>
|
||||
{user.rowStatus === "NORMAL" ? (
|
||||
<button
|
||||
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded hover:bg-gray-100"
|
||||
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-600"
|
||||
onClick={() => handleArchiveUserClick(user)}
|
||||
>
|
||||
{t("common.archive")}
|
||||
@@ -151,13 +176,13 @@ const PreferencesSection = () => {
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded hover:bg-gray-100"
|
||||
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-600"
|
||||
onClick={() => handleRestoreUserClick(user)}
|
||||
>
|
||||
{t("common.restore")}
|
||||
</button>
|
||||
<button
|
||||
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded text-red-600 hover:bg-gray-100"
|
||||
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded text-red-600 hover:bg-gray-100 dark:hover:bg-zinc-600"
|
||||
onClick={() => handleDeleteUserClick(user)}
|
||||
>
|
||||
{t("common.delete")}
|
||||
|
||||
@@ -36,10 +36,10 @@ const MyAccountSection = () => {
|
||||
<div className="flex flex-row justify-start items-center text-base text-gray-600">{user.email}</div>
|
||||
<div className="w-full flex flex-row justify-start items-center mt-2 space-x-2">
|
||||
<button className="btn-normal" onClick={showUpdateAccountDialog}>
|
||||
Update Information
|
||||
{t("setting.account-section.update-information")}
|
||||
</button>
|
||||
<button className="btn-normal" onClick={showChangePasswordDialog}>
|
||||
Change Password
|
||||
{t("setting.account-section.change-password")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { Select, Switch, Option } from "@mui/joy";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Switch from "@mui/joy/Switch";
|
||||
import { globalService, userService } from "../../services";
|
||||
import { useAppSelector } from "../../store";
|
||||
import {
|
||||
VISIBILITY_SELECTOR_ITEMS,
|
||||
MEMO_DISPLAY_TS_OPTION_SELECTOR_ITEMS,
|
||||
SETTING_IS_FOLDING_ENABLED_KEY,
|
||||
IS_FOLDING_ENABLED_DEFAULT_VALUE,
|
||||
} from "../../helpers/consts";
|
||||
import useLocalStorage from "../../hooks/useLocalStorage";
|
||||
import Selector from "../common/Selector";
|
||||
import { VISIBILITY_SELECTOR_ITEMS, MEMO_DISPLAY_TS_OPTION_SELECTOR_ITEMS } from "../../helpers/consts";
|
||||
import Icon from "../Icon";
|
||||
import AppearanceSelect from "../AppearanceSelect";
|
||||
import "../../less/settings/preferences-section.less";
|
||||
|
||||
const localeSelectorItems = [
|
||||
@@ -29,11 +24,23 @@ const localeSelectorItems = [
|
||||
text: "French",
|
||||
value: "fr",
|
||||
},
|
||||
{
|
||||
text: "Nederlands",
|
||||
value: "nl",
|
||||
},
|
||||
{
|
||||
text: "Svenska",
|
||||
value: "sv",
|
||||
},
|
||||
{
|
||||
text: "German",
|
||||
value: "de",
|
||||
},
|
||||
];
|
||||
|
||||
const PreferencesSection = () => {
|
||||
const { t } = useTranslation();
|
||||
const { setting } = useAppSelector((state) => state.user.user as User);
|
||||
const { setting, localSetting } = useAppSelector((state) => state.user.user as User);
|
||||
const visibilitySelectorItems = VISIBILITY_SELECTOR_ITEMS.map((item) => {
|
||||
return {
|
||||
value: item.value,
|
||||
@@ -48,8 +55,6 @@ const PreferencesSection = () => {
|
||||
};
|
||||
});
|
||||
|
||||
const [isFoldingEnabled, setIsFoldingEnabled] = useLocalStorage(SETTING_IS_FOLDING_ENABLED_KEY, IS_FOLDING_ENABLED_DEFAULT_VALUE);
|
||||
|
||||
const handleLocaleChanged = async (value: string) => {
|
||||
await userService.upsertUserSetting("locale", value);
|
||||
globalService.setLocale(value as Locale);
|
||||
@@ -64,38 +69,75 @@ const PreferencesSection = () => {
|
||||
};
|
||||
|
||||
const handleIsFoldingEnabledChanged = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setIsFoldingEnabled(event.target.checked);
|
||||
userService.upsertLocalSetting("enableFoldMemo", event.target.checked);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="section-container preferences-section-container">
|
||||
<p className="title-text">{t("common.basic")}</p>
|
||||
<label className="form-label selector">
|
||||
<div className="form-label selector">
|
||||
<span className="normal-text">{t("common.language")}</span>
|
||||
<Selector className="ml-2 w-32" value={setting.locale} dataSource={localeSelectorItems} handleValueChanged={handleLocaleChanged} />
|
||||
</label>
|
||||
<Select
|
||||
className="!min-w-[10rem] w-auto text-sm"
|
||||
value={setting.locale}
|
||||
onChange={(_, locale) => {
|
||||
if (locale) {
|
||||
handleLocaleChanged(locale);
|
||||
}
|
||||
}}
|
||||
startDecorator={<Icon.Globe className="w-4 h-auto" />}
|
||||
>
|
||||
{localeSelectorItems.map((item) => (
|
||||
<Option key={item.value} value={item.value} className="whitespace-nowrap">
|
||||
{item.text}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="form-label selector">
|
||||
<span className="normal-text">Theme</span>
|
||||
<AppearanceSelect />
|
||||
</div>
|
||||
<p className="title-text">{t("setting.preference")}</p>
|
||||
<label className="form-label selector">
|
||||
<div className="form-label selector">
|
||||
<span className="normal-text">{t("setting.preference-section.default-memo-visibility")}</span>
|
||||
<Selector
|
||||
className="ml-2 w-32"
|
||||
<Select
|
||||
className="!min-w-[10rem] w-auto text-sm"
|
||||
value={setting.memoVisibility}
|
||||
dataSource={visibilitySelectorItems}
|
||||
handleValueChanged={handleDefaultMemoVisibilityChanged}
|
||||
/>
|
||||
</label>
|
||||
onChange={(_, visibility) => {
|
||||
if (visibility) {
|
||||
handleDefaultMemoVisibilityChanged(visibility);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{visibilitySelectorItems.map((item) => (
|
||||
<Option key={item.value} value={item.value} className="whitespace-nowrap">
|
||||
{item.text}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<label className="form-label selector">
|
||||
<span className="normal-text">{t("setting.preference-section.default-memo-sort-option")}</span>
|
||||
<Selector
|
||||
className="ml-2 w-32"
|
||||
<Select
|
||||
className="!min-w-[10rem] w-auto text-sm"
|
||||
value={setting.memoDisplayTsOption}
|
||||
dataSource={memoDisplayTsOptionSelectorItems}
|
||||
handleValueChanged={handleMemoDisplayTsOptionChanged}
|
||||
/>
|
||||
onChange={(_, value) => {
|
||||
if (value) {
|
||||
handleMemoDisplayTsOptionChanged(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{memoDisplayTsOptionSelectorItems.map((item) => (
|
||||
<Option key={item.value} value={item.value} className="whitespace-nowrap">
|
||||
{item.text}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
<label className="form-label selector">
|
||||
<span className="normal-text">{t("setting.preference-section.enable-folding-memo")}</span>
|
||||
<Switch className="ml-2" checked={isFoldingEnabled} onChange={handleIsFoldingEnabledChanged} />
|
||||
<Switch className="ml-2" checked={localSetting.enableFoldMemo} onChange={handleIsFoldingEnabledChanged} />
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -60,6 +60,23 @@ const SystemSection = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleVacuumBtnClick = async () => {
|
||||
try {
|
||||
await api.vacuumDatabase();
|
||||
const { data: status } = (await api.getSystemStatus()).data;
|
||||
setState({
|
||||
dbSize: status.dbSize,
|
||||
allowSignUp: status.allowSignUp,
|
||||
additionalStyle: status.additionalStyle,
|
||||
additionalScript: status.additionalScript,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return;
|
||||
}
|
||||
toastHelper.success("Succeed to vacuum database");
|
||||
};
|
||||
|
||||
const handleSaveAdditionalStyle = async () => {
|
||||
try {
|
||||
await api.upsertSystemSetting({
|
||||
@@ -96,19 +113,20 @@ const SystemSection = () => {
|
||||
return (
|
||||
<div className="section-container system-section-container">
|
||||
<p className="title-text">{t("common.basic")}</p>
|
||||
<p className="text-value">
|
||||
{t("setting.system-section.database-file-size")}: <span className="font-mono font-medium">{formatBytes(state.dbSize)}</span>
|
||||
</p>
|
||||
<label className="form-label">
|
||||
<span className="normal-text">
|
||||
{t("setting.system-section.database-file-size")}: <span className="font-mono font-medium">{formatBytes(state.dbSize)}</span>
|
||||
</span>
|
||||
<Button onClick={handleVacuumBtnClick}>{t("common.vacuum")}</Button>
|
||||
</label>
|
||||
<p className="title-text">{t("sidebar.setting")}</p>
|
||||
<label className="form-label">
|
||||
<span className="normal-text">{t("setting.system-section.allow-user-signup")}</span>
|
||||
<Switch size="sm" checked={state.allowSignUp} onChange={(event) => handleAllowSignUpChanged(event.target.checked)} />
|
||||
<Switch checked={state.allowSignUp} onChange={(event) => handleAllowSignUpChanged(event.target.checked)} />
|
||||
</label>
|
||||
<div className="form-label">
|
||||
<span className="normal-text">{t("setting.system-section.additional-style")}</span>
|
||||
<Button size="sm" onClick={handleSaveAdditionalStyle}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
<Button onClick={handleSaveAdditionalStyle}>{t("common.save")}</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
className="w-full"
|
||||
@@ -117,25 +135,24 @@ const SystemSection = () => {
|
||||
fontSize: "14px",
|
||||
}}
|
||||
minRows={4}
|
||||
maxRows={10}
|
||||
maxRows={4}
|
||||
placeholder={t("setting.system-section.additional-style-placeholder")}
|
||||
value={state.additionalStyle}
|
||||
onChange={(event) => handleAdditionalStyleChanged(event.target.value)}
|
||||
/>
|
||||
<div className="form-label mt-2">
|
||||
<span className="normal-text">{t("setting.system-section.additional-script")}</span>
|
||||
<Button size="sm" onClick={handleSaveAdditionalScript}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
<Button onClick={handleSaveAdditionalScript}>{t("common.save")}</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
className="w-full"
|
||||
color="neutral"
|
||||
sx={{
|
||||
fontFamily: "monospace",
|
||||
fontSize: "14px",
|
||||
}}
|
||||
minRows={4}
|
||||
maxRows={10}
|
||||
maxRows={4}
|
||||
placeholder={t("setting.system-section.additional-script-placeholder")}
|
||||
value={state.additionalScript}
|
||||
onChange={(event) => handleAdditionalScriptChanged(event.target.value)}
|
||||
|
||||
195
web/src/components/ShareMemoDialog.tsx
Normal file
195
web/src/components/ShareMemoDialog.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import { Select, Option } from "@mui/joy";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { toLower } from "lodash";
|
||||
import toImage from "../labs/html2image";
|
||||
import { VISIBILITY_SELECTOR_ITEMS } from "../helpers/consts";
|
||||
import * as utils from "../helpers/utils";
|
||||
import { getMemoStats } from "../helpers/api";
|
||||
import { memoService, userService } from "../services";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import toastHelper from "./Toast";
|
||||
import MemoContent from "./MemoContent";
|
||||
import MemoResources from "./MemoResources";
|
||||
import "../less/share-memo-dialog.less";
|
||||
|
||||
interface Props extends DialogProps {
|
||||
memo: Memo;
|
||||
}
|
||||
|
||||
interface State {
|
||||
memoAmount: number;
|
||||
memoVisibility: string;
|
||||
generatedImgUrl: string;
|
||||
}
|
||||
|
||||
const ShareMemoDialog: React.FC<Props> = (props: Props) => {
|
||||
const { memo: propsMemo, destroy } = props;
|
||||
const { t } = useTranslation();
|
||||
const user = userService.getState().user as User;
|
||||
const [state, setState] = useState<State>({
|
||||
memoAmount: 0,
|
||||
memoVisibility: propsMemo.visibility,
|
||||
generatedImgUrl: "",
|
||||
});
|
||||
const loadingState = useLoading();
|
||||
const memoElRef = useRef<HTMLDivElement>(null);
|
||||
const memo = {
|
||||
...propsMemo,
|
||||
createdAtStr: utils.getDateTimeString(propsMemo.displayTs),
|
||||
};
|
||||
const createdDays = Math.ceil((Date.now() - utils.getTimeStampByDate(user.createdTs)) / 1000 / 3600 / 24);
|
||||
|
||||
useEffect(() => {
|
||||
getMemoStats(user.id)
|
||||
.then(({ data: { data } }) => {
|
||||
setState((state) => {
|
||||
return {
|
||||
...state,
|
||||
memoAmount: data.length,
|
||||
};
|
||||
});
|
||||
loadingState.setFinish();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (loadingState.isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!memoElRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
toImage(memoElRef.current, {
|
||||
pixelRatio: window.devicePixelRatio * 2,
|
||||
})
|
||||
.then((url) => {
|
||||
setState((state) => {
|
||||
return {
|
||||
...state,
|
||||
generatedImgUrl: url,
|
||||
};
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
}, [loadingState.isLoading]);
|
||||
|
||||
const handleCloseBtnClick = () => {
|
||||
destroy();
|
||||
};
|
||||
|
||||
const handleDownloadBtnClick = () => {
|
||||
const a = document.createElement("a");
|
||||
a.href = state.generatedImgUrl;
|
||||
a.download = `memos-${utils.getDateTimeString(Date.now())}.png`;
|
||||
a.click();
|
||||
};
|
||||
|
||||
const handleCopyLinkBtnClick = () => {
|
||||
copy(`${window.location.origin}/m/${memo.id}`);
|
||||
toastHelper.success(t("message.succeed-copy-content"));
|
||||
};
|
||||
|
||||
const memoVisibilityOptionSelectorItems = VISIBILITY_SELECTOR_ITEMS.map((item) => {
|
||||
return {
|
||||
value: item.value,
|
||||
text: t(`memo.visibility.${toLower(item.value)}`),
|
||||
};
|
||||
});
|
||||
|
||||
const handleMemoVisibilityOptionChanged = async (value: string) => {
|
||||
const visibilityValue = value as Visibility;
|
||||
setState({
|
||||
...state,
|
||||
memoVisibility: visibilityValue,
|
||||
});
|
||||
await memoService.patchMemo({
|
||||
id: memo.id,
|
||||
visibility: visibilityValue,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dialog-header-container">
|
||||
<p className="title-text">
|
||||
<span className="icon-text">🌄</span>
|
||||
{t("common.share")} Memo
|
||||
</p>
|
||||
<button className="btn close-btn" onClick={handleCloseBtnClick}>
|
||||
<Icon.X className="icon-img" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="dialog-content-container">
|
||||
<div className="memo-container" ref={memoElRef}>
|
||||
{state.generatedImgUrl !== "" && <img className="memo-shortcut-img" src={state.generatedImgUrl} />}
|
||||
<span className="time-text">{memo.createdAtStr}</span>
|
||||
<div className="memo-content-wrapper">
|
||||
<MemoContent content={memo.content} displayConfig={{ enableExpand: false }} />
|
||||
<MemoResources style="col" resourceList={memo.resourceList} />
|
||||
</div>
|
||||
<div className="watermark-container">
|
||||
<div className="userinfo-container">
|
||||
<span className="name-text">{user.nickname || user.username}</span>
|
||||
<span className="usage-text">
|
||||
{state.memoAmount} MEMOS / {createdDays} DAYS
|
||||
</span>
|
||||
</div>
|
||||
<img className="logo-img" src="/logo.webp" alt="" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 py-3 w-full flex flex-row justify-between items-center">
|
||||
<Select
|
||||
className="!min-w-[10rem] w-auto text-sm"
|
||||
value={state.memoVisibility}
|
||||
onChange={(_, visibility) => {
|
||||
if (visibility) {
|
||||
handleMemoVisibilityOptionChanged(visibility);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{memoVisibilityOptionSelectorItems.map((item) => (
|
||||
<Option key={item.value} value={item.value} className="whitespace-nowrap">
|
||||
{item.text}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
<div className="flex flex-row justify-end items-center">
|
||||
<button disabled={state.generatedImgUrl === ""} className="btn-normal mr-2" onClick={handleDownloadBtnClick}>
|
||||
{state.generatedImgUrl === "" ? (
|
||||
<Icon.Loader className="w-4 h-auto mr-1 animate-spin" />
|
||||
) : (
|
||||
<Icon.Download className="w-4 h-auto mr-1" />
|
||||
)}
|
||||
<span>{t("common.image")}</span>
|
||||
</button>
|
||||
<button className="btn-normal" onClick={handleCopyLinkBtnClick}>
|
||||
<Icon.Link className="w-4 h-auto mr-1" />
|
||||
<span>{t("common.link")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default function showShareMemoDialog(memo: Memo): void {
|
||||
generateDialog(
|
||||
{
|
||||
className: "share-memo-dialog",
|
||||
},
|
||||
ShareMemoDialog,
|
||||
{ memo }
|
||||
);
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { userService } from "../services";
|
||||
import toImage from "../labs/html2image";
|
||||
import { ANIMATION_DURATION } from "../helpers/consts";
|
||||
import * as utils from "../helpers/utils";
|
||||
import { getMemoStats } from "../helpers/api";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import MemoContent from "./MemoContent";
|
||||
import MemoResources from "./MemoResources";
|
||||
import "../less/share-memo-image-dialog.less";
|
||||
|
||||
interface Props extends DialogProps {
|
||||
memo: Memo;
|
||||
}
|
||||
|
||||
interface State {
|
||||
memoAmount: number;
|
||||
shortcutImgUrl: string;
|
||||
}
|
||||
|
||||
const ShareMemoImageDialog: React.FC<Props> = (props: Props) => {
|
||||
const { memo: propsMemo, destroy } = props;
|
||||
const { t } = useTranslation();
|
||||
const user = userService.getState().user as User;
|
||||
const [state, setState] = useState<State>({
|
||||
memoAmount: 0,
|
||||
shortcutImgUrl: "",
|
||||
});
|
||||
const loadingState = useLoading();
|
||||
const memoElRef = useRef<HTMLDivElement>(null);
|
||||
const memo = {
|
||||
...propsMemo,
|
||||
createdAtStr: utils.getDateTimeString(propsMemo.displayTs),
|
||||
};
|
||||
const createdDays = Math.ceil((Date.now() - utils.getTimeStampByDate(user.createdTs)) / 1000 / 3600 / 24);
|
||||
|
||||
useEffect(() => {
|
||||
getMemoStats(user.id)
|
||||
.then(({ data: { data } }) => {
|
||||
setState((state) => {
|
||||
return {
|
||||
...state,
|
||||
memoAmount: data.length,
|
||||
};
|
||||
});
|
||||
loadingState.setFinish();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (loadingState.isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (!memoElRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
toImage(memoElRef.current, {
|
||||
backgroundColor: "#eaeaea",
|
||||
pixelRatio: window.devicePixelRatio * 2,
|
||||
})
|
||||
.then((url) => {
|
||||
setState((state) => {
|
||||
return {
|
||||
...state,
|
||||
shortcutImgUrl: url,
|
||||
};
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
}, ANIMATION_DURATION);
|
||||
}, [loadingState.isLoading]);
|
||||
|
||||
const handleCloseBtnClick = () => {
|
||||
destroy();
|
||||
};
|
||||
|
||||
const handleDownloadBtnClick = () => {
|
||||
const a = document.createElement("a");
|
||||
a.href = state.shortcutImgUrl;
|
||||
a.download = `memos-${utils.getDateTimeString(Date.now())}.png`;
|
||||
a.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dialog-header-container">
|
||||
<p className="title-text">
|
||||
<span className="icon-text">🌄</span>
|
||||
{t("common.share")} Memo
|
||||
</p>
|
||||
<button className="btn close-btn" onClick={handleCloseBtnClick}>
|
||||
<Icon.X className="icon-img" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="dialog-content-container">
|
||||
<div className={`tip-words-container ${state.shortcutImgUrl ? "finish" : "loading"}`}>
|
||||
<p className="tip-text">
|
||||
{state.shortcutImgUrl ? t("message.click-to-save-the-image") + " 👇" : t("message.generating-the-screenshot")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="memo-container" ref={memoElRef}>
|
||||
{state.shortcutImgUrl !== "" && <img className="memo-shortcut-img" onClick={handleDownloadBtnClick} src={state.shortcutImgUrl} />}
|
||||
<span className="time-text">{memo.createdAtStr}</span>
|
||||
<div className="memo-content-wrapper">
|
||||
<MemoContent content={memo.content} displayConfig={{ enableExpand: false }} />
|
||||
<MemoResources style="col" resourceList={memo.resourceList} />
|
||||
</div>
|
||||
<div className="watermark-container">
|
||||
<div className="userinfo-container">
|
||||
<span className="name-text">{user.nickname || user.username}</span>
|
||||
<span className="usage-text">
|
||||
{createdDays} DAYS / {state.memoAmount} MEMOS
|
||||
</span>
|
||||
</div>
|
||||
<img className="logo-img" src="/logo.webp" alt="" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default function showShareMemoImageDialog(memo: Memo): void {
|
||||
generateDialog(
|
||||
{
|
||||
className: "share-memo-image-dialog",
|
||||
},
|
||||
ShareMemoImageDialog,
|
||||
{ memo }
|
||||
);
|
||||
}
|
||||
@@ -76,6 +76,10 @@ const ShortcutContainer: React.FC<ShortcutContainerProps> = (props: ShortcutCont
|
||||
if (showConfirmDeleteBtn) {
|
||||
try {
|
||||
await shortcutService.deleteShortcutById(shortcut.id);
|
||||
if (locationService.getState().query?.shortcutId === shortcut.id) {
|
||||
// need clear shortcut filter
|
||||
locationService.setMemoShortcut(undefined);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toastHelper.error(error.response.data.message);
|
||||
|
||||
@@ -17,7 +17,7 @@ const Sidebar = () => {
|
||||
const location = useAppSelector((state) => state.location);
|
||||
|
||||
useEffect(() => {
|
||||
toggleSiderbar(false);
|
||||
toggleSidebar(false);
|
||||
}, [location.query]);
|
||||
|
||||
const handleSettingBtnClick = () => {
|
||||
@@ -26,7 +26,7 @@ const Sidebar = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mask" onClick={() => toggleSiderbar(false)}></div>
|
||||
<div className="mask" onClick={() => toggleSidebar(false)}></div>
|
||||
<aside className="sidebar-wrapper">
|
||||
<UserBanner />
|
||||
<UsageHeatMap />
|
||||
@@ -52,7 +52,7 @@ const Sidebar = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const toggleSiderbar = (show?: boolean) => {
|
||||
export const toggleSidebar = (show?: boolean) => {
|
||||
const sidebarEl = document.body.querySelector(".sidebar-wrapper") as HTMLDivElement;
|
||||
const maskEl = document.body.querySelector(".mask") as HTMLDivElement;
|
||||
|
||||
|
||||
@@ -14,22 +14,22 @@ type ToastItemProps = {
|
||||
type: ToastType;
|
||||
content: string;
|
||||
duration: number;
|
||||
destory: FunctionType;
|
||||
destroy: FunctionType;
|
||||
};
|
||||
|
||||
const Toast: React.FC<ToastItemProps> = (props: ToastItemProps) => {
|
||||
const { destory, duration } = props;
|
||||
const { destroy, duration } = props;
|
||||
|
||||
useEffect(() => {
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
destory();
|
||||
destroy();
|
||||
}, duration);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="toast-container" onClick={destory}>
|
||||
<div className="toast-container" onClick={destroy}>
|
||||
<p className="content-text">{props.content}</p>
|
||||
</div>
|
||||
);
|
||||
@@ -57,8 +57,8 @@ const initialToastHelper = () => {
|
||||
shownToastContainers.push([toast, tempDiv]);
|
||||
|
||||
const cbs = {
|
||||
destory: () => {
|
||||
tempDiv.classList.add("destory");
|
||||
destroy: () => {
|
||||
tempDiv.classList.add("destroy");
|
||||
|
||||
setTimeout(() => {
|
||||
if (!tempDiv.parentElement) {
|
||||
@@ -77,7 +77,7 @@ const initialToastHelper = () => {
|
||||
},
|
||||
};
|
||||
|
||||
toast.render(<Toast {...config} destory={cbs.destory} />);
|
||||
toast.render(<Toast {...config} destroy={cbs.destroy} />);
|
||||
|
||||
setTimeout(() => {
|
||||
tempDiv.classList.add("showup");
|
||||
|
||||
@@ -6,6 +6,14 @@ import { userService } from "../services";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import toastHelper from "./Toast";
|
||||
import { validate, ValidatorConfig } from "../helpers/validator";
|
||||
|
||||
const validateConfig: ValidatorConfig = {
|
||||
minLength: 4,
|
||||
maxLength: 320,
|
||||
noSpace: true,
|
||||
noChinese: true,
|
||||
};
|
||||
|
||||
type Props = DialogProps;
|
||||
|
||||
@@ -63,6 +71,12 @@ const UpdateAccountDialog: React.FC<Props> = ({ destroy }: Props) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const usernameValidResult = validate(state.username, validateConfig);
|
||||
if (!usernameValidResult.result) {
|
||||
toastHelper.error(t("common.username") + ": " + t(usernameValidResult.reason as string));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const user = userService.getState().user as User;
|
||||
const userPatch: UserPatch = {
|
||||
|
||||
@@ -11,11 +11,11 @@ const tableConfig = {
|
||||
height: 7,
|
||||
};
|
||||
|
||||
const getInitialUsageStat = (usedDaysAmount: number, beginDayTimestemp: number): DailyUsageStat[] => {
|
||||
const getInitialUsageStat = (usedDaysAmount: number, beginDayTimestamp: number): DailyUsageStat[] => {
|
||||
const initialUsageStat: DailyUsageStat[] = [];
|
||||
for (let i = 1; i <= usedDaysAmount; i++) {
|
||||
initialUsageStat.push({
|
||||
timestamp: beginDayTimestemp + DAILY_TIMESTAMP * i,
|
||||
timestamp: beginDayTimestamp + DAILY_TIMESTAMP * i,
|
||||
count: 0,
|
||||
});
|
||||
}
|
||||
@@ -32,21 +32,25 @@ const UsageHeatMap = () => {
|
||||
const todayDay = new Date(todayTimeStamp).getDay() + 1;
|
||||
const nullCell = new Array(7 - todayDay).fill(0);
|
||||
const usedDaysAmount = (tableConfig.width - 1) * tableConfig.height + todayDay;
|
||||
const beginDayTimestemp = todayTimeStamp - usedDaysAmount * DAILY_TIMESTAMP;
|
||||
const beginDayTimestamp = todayTimeStamp - usedDaysAmount * DAILY_TIMESTAMP;
|
||||
|
||||
const { memos } = useAppSelector((state) => state.memo);
|
||||
const [allStat, setAllStat] = useState<DailyUsageStat[]>(getInitialUsageStat(usedDaysAmount, beginDayTimestemp));
|
||||
const [allStat, setAllStat] = useState<DailyUsageStat[]>(getInitialUsageStat(usedDaysAmount, beginDayTimestamp));
|
||||
const [currentStat, setCurrentStat] = useState<DailyUsageStat | null>(null);
|
||||
const containerElRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getMemoStats(userService.getCurrentUserId())
|
||||
.then(({ data: { data } }) => {
|
||||
const newStat: DailyUsageStat[] = getInitialUsageStat(usedDaysAmount, beginDayTimestemp);
|
||||
const newStat: DailyUsageStat[] = getInitialUsageStat(usedDaysAmount, beginDayTimestamp);
|
||||
for (const record of data) {
|
||||
const index = (utils.getDateStampByDate(record * 1000) - beginDayTimestemp) / (1000 * 3600 * 24) - 1;
|
||||
const index = (utils.getDateStampByDate(record * 1000) - beginDayTimestamp) / (1000 * 3600 * 24) - 1;
|
||||
if (index >= 0) {
|
||||
newStat[index].count += 1;
|
||||
// because of dailight savings, some days may be 23 hours long instead of 24 hours long
|
||||
// this causes the calculations to yield weird indices such as 40.93333333333
|
||||
// rounding them may not give you the exact day on the heat map, but it's not too bad
|
||||
const exactIndex = +index.toFixed(0);
|
||||
newStat[exactIndex].count += 1;
|
||||
}
|
||||
}
|
||||
setAllStat([...newStat]);
|
||||
@@ -64,6 +68,11 @@ const UsageHeatMap = () => {
|
||||
tempDiv.style.top = bounding.top - 2 + "px";
|
||||
tempDiv.innerHTML = `${item.count} memos on <span className="date-text">${new Date(item.timestamp as number).toDateString()}</span>`;
|
||||
document.body.appendChild(tempDiv);
|
||||
|
||||
if (tempDiv.offsetLeft - tempDiv.clientWidth / 2 < 0) {
|
||||
tempDiv.style.left = bounding.left + tempDiv.clientWidth * 0.4 + "px";
|
||||
tempDiv.className += " offset-left";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleUsageStatItemMouseLeave = useCallback(() => {
|
||||
|
||||
@@ -63,7 +63,6 @@ const UserBanner = () => {
|
||||
};
|
||||
|
||||
const handleSignOutBtnClick = async () => {
|
||||
userService.doSignOut().catch();
|
||||
navigate("/auth");
|
||||
};
|
||||
|
||||
@@ -75,20 +74,20 @@ const UserBanner = () => {
|
||||
{!isVisitorMode && user?.role === "HOST" ? <span className="tag">MOD</span> : null}
|
||||
</div>
|
||||
<Dropdown
|
||||
trigger={<Icon.MoreHorizontal className="ml-2 w-5 h-auto cursor-pointer" />}
|
||||
trigger={<Icon.MoreHorizontal className="ml-2 w-5 h-auto cursor-pointer dark:text-gray-200" />}
|
||||
actionsClassName="min-w-36"
|
||||
actions={
|
||||
<>
|
||||
{!userService.isVisitorMode() && (
|
||||
<>
|
||||
<button
|
||||
className="w-full px-3 whitespace-nowrap text-left leading-10 cursor-pointer rounded hover:bg-gray-100"
|
||||
className="w-full px-3 whitespace-nowrap text-left leading-10 cursor-pointer rounded dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-zinc-800"
|
||||
onClick={handleResourcesBtnClick}
|
||||
>
|
||||
<span className="mr-1">🌄</span> {t("sidebar.resources")}
|
||||
</button>
|
||||
<button
|
||||
className="w-full px-3 whitespace-nowrap text-left leading-10 cursor-pointer rounded hover:bg-gray-100"
|
||||
className="w-full px-3 whitespace-nowrap text-left leading-10 cursor-pointer rounded dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-zinc-800"
|
||||
onClick={handleArchivedBtnClick}
|
||||
>
|
||||
<span className="mr-1">🗂</span> {t("sidebar.archived")}
|
||||
@@ -96,14 +95,14 @@ const UserBanner = () => {
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className="w-full px-3 whitespace-nowrap text-left leading-10 cursor-pointer rounded hover:bg-gray-100"
|
||||
className="w-full px-3 whitespace-nowrap text-left leading-10 cursor-pointer rounded dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-zinc-800"
|
||||
onClick={handleAboutBtnClick}
|
||||
>
|
||||
<span className="mr-1">🤠</span> {t("common.about")}
|
||||
</button>
|
||||
{!userService.isVisitorMode() && (
|
||||
<button
|
||||
className="w-full px-3 whitespace-nowrap text-left leading-10 cursor-pointer rounded hover:bg-gray-100"
|
||||
className="w-full px-3 whitespace-nowrap text-left leading-10 cursor-pointer rounded dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-zinc-800"
|
||||
onClick={handleSignOutBtnClick}
|
||||
>
|
||||
<span className="mr-1">👋</span> {t("common.sign-out")}
|
||||
|
||||
@@ -6,7 +6,7 @@ import "../../less/common/date-picker.less";
|
||||
interface DatePickerProps {
|
||||
className?: string;
|
||||
datestamp: DateStamp;
|
||||
handleDateStampChange: (datastamp: DateStamp) => void;
|
||||
handleDateStampChange: (datestamp: DateStamp) => void;
|
||||
}
|
||||
|
||||
const DatePicker: React.FC<DatePickerProps> = (props: DatePickerProps) => {
|
||||
|
||||
@@ -7,10 +7,11 @@ interface Props {
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
actionsClassName?: string;
|
||||
positionClassName?: string;
|
||||
}
|
||||
|
||||
const Dropdown: React.FC<Props> = (props: Props) => {
|
||||
const { trigger, actions, className, actionsClassName } = props;
|
||||
const { trigger, actions, className, actionsClassName, positionClassName } = props;
|
||||
const [dropdownStatus, toggleDropdownStatus] = useToggle(false);
|
||||
const dropdownWrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -37,14 +38,14 @@ const Dropdown: React.FC<Props> = (props: Props) => {
|
||||
{trigger ? (
|
||||
trigger
|
||||
) : (
|
||||
<button className="flex flex-row justify-center items-center border p-1 rounded shadow text-gray-600 cursor-pointer hover:opacity-80">
|
||||
<button className="flex flex-row justify-center items-center border dark:border-zinc-700 p-1 rounded shadow text-gray-600 dark:text-gray-200 cursor-pointer hover:opacity-80">
|
||||
<Icon.MoreHorizontal className="w-4 h-auto" />
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
className={`w-auto mt-1 absolute top-full right-0 flex flex-col justify-start items-start bg-white z-1 border p-1 rounded-md shadow ${
|
||||
className={`w-auto absolute flex flex-col justify-start items-start bg-white dark:bg-zinc-700 z-10 p-1 rounded-md shadow ${
|
||||
actionsClassName ?? ""
|
||||
} ${dropdownStatus ? "" : "!hidden"}`}
|
||||
} ${dropdownStatus ? "" : "!hidden"} ${positionClassName ?? "top-full right-0 mt-1"}`}
|
||||
>
|
||||
{actions}
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,7 @@ const Selector: React.FC<Props> = (props: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const [showSelector, toggleSelectorStatus] = useToggle(false);
|
||||
|
||||
const seletorElRef = useRef<HTMLDivElement>(null);
|
||||
const selectorElRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
let currentItem = nullItem;
|
||||
for (const d of dataSource) {
|
||||
@@ -39,7 +39,7 @@ const Selector: React.FC<Props> = (props: Props) => {
|
||||
useEffect(() => {
|
||||
if (showSelector) {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (!seletorElRef.current?.contains(event.target as Node)) {
|
||||
if (!selectorElRef.current?.contains(event.target as Node)) {
|
||||
toggleSelectorStatus(false);
|
||||
}
|
||||
};
|
||||
@@ -63,7 +63,7 @@ const Selector: React.FC<Props> = (props: Props) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`selector-wrapper ${className ?? ""}`} ref={seletorElRef}>
|
||||
<div className={`selector-wrapper ${className ?? ""}`} ref={selectorElRef}>
|
||||
<div className={`current-value-container ${showSelector ? "active" : ""}`} onClick={handleCurrentValueClick}>
|
||||
<span className="value-text">{currentItem.text}</span>
|
||||
<span className="arrow-text">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
body,
|
||||
html {
|
||||
@apply text-base;
|
||||
html,
|
||||
body {
|
||||
@apply text-base dark:bg-zinc-800;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Noto Sans", "Noto Sans CJK SC", "Microsoft YaHei UI", "Microsoft YaHei",
|
||||
"WenQuanYi Micro Hei", sans-serif, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
|
||||
"Noto Color Emoji";
|
||||
@@ -3,33 +3,40 @@
|
||||
@tailwind utilities;
|
||||
|
||||
@layer utilities {
|
||||
.hide-scrollbar {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
/* Chrome, Safari and Opera */
|
||||
.hide-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hide-scrollbar {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
.word-break {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-normal {
|
||||
@apply select-none inline-flex border cursor-pointer px-3 text-sm leading-8 rounded-md hover:opacity-80 hover:shadow;
|
||||
}
|
||||
@layer components {
|
||||
.btn-normal {
|
||||
@apply select-none flex flex-row justify-center items-center border dark:border-zinc-700 cursor-pointer px-3 text-sm leading-8 rounded-md hover:opacity-80 hover:shadow disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:shadow-none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply btn-normal border-transparent bg-green-600 text-white;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply btn-normal border-transparent bg-green-600 text-white dark:border-transparent dark:text-gray-200;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply btn-normal border-red-600 bg-red-50 text-red-600;
|
||||
}
|
||||
.btn-danger {
|
||||
@apply btn-normal border-red-600 bg-red-50 text-red-600;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
@apply btn-normal text-gray-600 border-none hover:shadow-none;
|
||||
}
|
||||
.btn-text {
|
||||
@apply btn-normal text-gray-600 border-none dark:text-gray-200 hover:shadow-none;
|
||||
}
|
||||
|
||||
.input-text {
|
||||
@apply w-full px-3 py-2 leading-6 text-sm border rounded;
|
||||
.input-text {
|
||||
@apply w-full px-3 py-2 leading-6 text-sm dark:text-gray-200 rounded border focus:outline focus:outline-2 dark:border-zinc-700 dark:bg-zinc-800;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ export function upsertSystemSetting(systemSetting: SystemSetting) {
|
||||
return axios.post<ResponseObject<SystemSetting>>("/api/system/setting", systemSetting);
|
||||
}
|
||||
|
||||
export function vacuumDatabase() {
|
||||
return axios.post("/api/system/vacuum");
|
||||
}
|
||||
|
||||
export function signin(username: string, password: string) {
|
||||
return axios.post<ResponseObject<User>>("/api/auth/signin", {
|
||||
username,
|
||||
|
||||
@@ -8,17 +8,14 @@ export const ANIMATION_DURATION = 200;
|
||||
export const DAILY_TIMESTAMP = 3600 * 24 * 1000;
|
||||
|
||||
export const VISIBILITY_SELECTOR_ITEMS = [
|
||||
{ text: "PUBLIC", value: "PUBLIC" },
|
||||
{ text: "PROTECTED", value: "PROTECTED" },
|
||||
{ text: "PRIVATE", value: "PRIVATE" },
|
||||
{ text: "PROTECTED", value: "PROTECTED" },
|
||||
{ text: "PUBLIC", value: "PUBLIC" },
|
||||
];
|
||||
|
||||
export const MEMO_DISPLAY_TS_OPTION_SELECTOR_ITEMS = [
|
||||
{ text: "created_ts", value: "created_ts" },
|
||||
{ text: "created_ts", value: "updated_ts" },
|
||||
{ text: "updated_ts", value: "updated_ts" },
|
||||
];
|
||||
|
||||
export const IS_FOLDING_ENABLED_DEFAULT_VALUE = true;
|
||||
export const SETTING_IS_FOLDING_ENABLED_KEY = "setting_IS_FOLDING_ENABLED";
|
||||
|
||||
export const TAB_SPACE_WIDTH = 2;
|
||||
|
||||
@@ -156,7 +156,7 @@ export const checkShouldShowMemo = (memo: Memo, filter: Filter) => {
|
||||
if (type === "TAG") {
|
||||
let contained = true;
|
||||
const tagsSet = new Set<string>();
|
||||
for (const t of Array.from(memo.content.match(TAG_REG) ?? [])) {
|
||||
for (const t of Array.from(memo.content.match(new RegExp(TAG_REG, "g")) ?? [])) {
|
||||
const tag = t.replace(TAG_REG, "$1").trim();
|
||||
const items = tag.split("/");
|
||||
let temp = "";
|
||||
|
||||
@@ -10,6 +10,10 @@ interface StorageData {
|
||||
editingMemoVisibilityCache: Visibility;
|
||||
// locale
|
||||
locale: Locale;
|
||||
// appearance
|
||||
appearance: Appearance;
|
||||
// local setting
|
||||
localSetting: LocalSetting;
|
||||
// skipped version
|
||||
skippedVersion: string;
|
||||
}
|
||||
|
||||
@@ -134,3 +134,17 @@ export const parseHTMLToRawText = (htmlStr: string): string => {
|
||||
const text = tempEl.innerText;
|
||||
return text;
|
||||
};
|
||||
|
||||
export function absolutifyLink(rel: string): string {
|
||||
const anchor = document.createElement("a");
|
||||
anchor.setAttribute("href", rel);
|
||||
return anchor.href;
|
||||
}
|
||||
|
||||
export function getSystemColorScheme() {
|
||||
if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) {
|
||||
return "dark";
|
||||
} else {
|
||||
return "light";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Validator
|
||||
// * use for validating form data
|
||||
|
||||
const chineseReg = /[\u3000\u3400-\u4DBF\u4E00-\u9FFF]/;
|
||||
|
||||
export interface ValidatorConfig {
|
||||
@@ -18,7 +19,7 @@ export function validate(text: string, config: Partial<ValidatorConfig>): { resu
|
||||
if (text.length < config.minLength) {
|
||||
return {
|
||||
result: false,
|
||||
reason: "Too short",
|
||||
reason: "message.too-short",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -27,7 +28,7 @@ export function validate(text: string, config: Partial<ValidatorConfig>): { resu
|
||||
if (text.length > config.maxLength) {
|
||||
return {
|
||||
result: false,
|
||||
reason: "Too long",
|
||||
reason: "message.too-long",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -35,14 +36,14 @@ export function validate(text: string, config: Partial<ValidatorConfig>): { resu
|
||||
if (config.noSpace && text.includes(" ")) {
|
||||
return {
|
||||
result: false,
|
||||
reason: "Don't allow space",
|
||||
reason: "message.not-allow-space",
|
||||
};
|
||||
}
|
||||
|
||||
if (config.noChinese && chineseReg.test(text)) {
|
||||
return {
|
||||
result: false,
|
||||
reason: "Don't allow chinese",
|
||||
reason: "message.not-allow-chinese",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { useState } from "react";
|
||||
|
||||
const useLocalStorage = <T>(key: string, initialValue: T) => {
|
||||
const [storedValue, setStoredValue] = useState<T>(() => {
|
||||
try {
|
||||
const item = window.localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : initialValue;
|
||||
} catch (error) {
|
||||
return initialValue;
|
||||
}
|
||||
});
|
||||
|
||||
const setValue = (value: T | ((val: T) => T)) => {
|
||||
try {
|
||||
const valueToStore = value instanceof Function ? value(storedValue) : value;
|
||||
setStoredValue(valueToStore);
|
||||
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
return [storedValue, setValue] as const;
|
||||
};
|
||||
|
||||
export default useLocalStorage;
|
||||
@@ -1,13 +0,0 @@
|
||||
// A custom hook that builds on useLocation to parse
|
||||
|
||||
import React from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
// the query string for you.
|
||||
const useQuery = () => {
|
||||
const { search } = useLocation();
|
||||
|
||||
return React.useMemo(() => new URLSearchParams(search), [search]);
|
||||
};
|
||||
|
||||
export default useQuery;
|
||||
@@ -5,7 +5,7 @@ const useToggle = (initialState = false): [boolean, (nextState?: boolean) => voi
|
||||
// Initialize the state
|
||||
const [state, setState] = useState(initialState);
|
||||
|
||||
// Define and memorize toggler function in case we pass down the comopnent,
|
||||
// Define and memorize toggler function in case we pass down the component,
|
||||
// This function change the boolean value to it's opposite value
|
||||
const toggle = useCallback((nextState?: boolean) => {
|
||||
if (nextState !== undefined) {
|
||||
|
||||
@@ -4,6 +4,9 @@ import enLocale from "./locales/en.json";
|
||||
import zhLocale from "./locales/zh.json";
|
||||
import viLocale from "./locales/vi.json";
|
||||
import frLocale from "./locales/fr.json";
|
||||
import nlLocale from "./locales/nl.json";
|
||||
import svLocale from "./locales/sv.json";
|
||||
import deLocale from "./locales/de.json";
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
resources: {
|
||||
@@ -19,8 +22,17 @@ i18n.use(initReactI18next).init({
|
||||
fr: {
|
||||
translation: frLocale,
|
||||
},
|
||||
nl: {
|
||||
translation: nlLocale,
|
||||
},
|
||||
sv: {
|
||||
translation: svLocale,
|
||||
},
|
||||
de: {
|
||||
translation: deLocale,
|
||||
},
|
||||
},
|
||||
lng: "en",
|
||||
lng: "nl",
|
||||
fallbackLng: "en",
|
||||
});
|
||||
|
||||
|
||||
@@ -6,11 +6,20 @@ const applyStyles = async (sourceElement: HTMLElement, clonedElement: HTMLElemen
|
||||
}
|
||||
|
||||
if (sourceElement.tagName === "IMG") {
|
||||
const url = sourceElement.getAttribute("src") ?? "";
|
||||
let covertFailed = false;
|
||||
try {
|
||||
const url = await convertResourceToDataURL(sourceElement.getAttribute("src") ?? "");
|
||||
(clonedElement as HTMLImageElement).src = url;
|
||||
(clonedElement as HTMLImageElement).src = await convertResourceToDataURL(url);
|
||||
} catch (error) {
|
||||
// do nth
|
||||
covertFailed = true;
|
||||
}
|
||||
// NOTE: Get image blob from backend to avoid CORS error.
|
||||
if (covertFailed) {
|
||||
try {
|
||||
(clonedElement as HTMLImageElement).src = await convertResourceToDataURL(`/o/get/image?url=${url}`);
|
||||
} catch (error) {
|
||||
// do nth
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -60,8 +60,8 @@ console.log("hello world!")
|
||||
- [ ] finish my homework
|
||||
- [x] yahaha`,
|
||||
want: `<p>My task:</p>
|
||||
<p><span class='todo-block todo' data-value='TODO'></span>finish my homework</p>
|
||||
<p><span class='todo-block done' data-value='DONE'>✓</span>yahaha</p>`,
|
||||
<p class='li-container'><span class='todo-block todo' data-value='TODO'></span>finish my homework</p>
|
||||
<p class='li-container'><span class='todo-block done' data-value='DONE'>✓</span>yahaha</p>`,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -76,8 +76,8 @@ console.log("hello world!")
|
||||
* list 123
|
||||
1. 123123`,
|
||||
want: `<p>This is a list</p>
|
||||
<p><span class='ul-block'>•</span>list 123</p>
|
||||
<p><span class='ol-block'>1.</span>123123</p>`,
|
||||
<p class='li-container'><span class='ul-block'>•</span>list 123</p>
|
||||
<p class='li-container'><span class='ol-block'>1.</span>123123</p>`,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -157,43 +157,6 @@ console.log("hello world!")
|
||||
expect(unescape(marked(t.markdown))).toBe(t.want);
|
||||
}
|
||||
});
|
||||
test("parse table", () => {
|
||||
const tests = [
|
||||
{
|
||||
markdown: `text above the table
|
||||
| a | b | c |
|
||||
|---|---|---|
|
||||
| 1 | 2 | 3 |
|
||||
| 4 | 5 | 6 |
|
||||
text below the table
|
||||
`,
|
||||
want: `<p>text above the table</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>a</th><th>b</th><th>c</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>1</td><td>2</td><td>3</td></tr><tr><td>4</td><td>5</td><td>6</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>text below the table</p>
|
||||
`,
|
||||
},
|
||||
{
|
||||
markdown: `| a | b | c |
|
||||
| 1 | 2 | 3 |
|
||||
| 4 | 5 | 6 |`,
|
||||
want: `<p>| a | b | c |</p>
|
||||
<p>| 1 | 2 | 3 |</p>
|
||||
<p>| 4 | 5 | 6 |</p>`,
|
||||
},
|
||||
];
|
||||
for (const t of tests) {
|
||||
expect(unescape(marked(t.markdown))).toBe(t.want);
|
||||
}
|
||||
});
|
||||
test("parse full width space", () => {
|
||||
const tests = [
|
||||
{
|
||||
|
||||
@@ -12,7 +12,7 @@ const renderer = (rawStr: string): string => {
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "blockqoute",
|
||||
name: "blockquote",
|
||||
regex: BLOCKQUOTE_REG,
|
||||
renderer,
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ const renderer = (rawStr: string): string => {
|
||||
}
|
||||
|
||||
const parsedContent = marked(matchResult[1], [], inlineElementParserList);
|
||||
return `<p><span class='todo-block done' data-value='DONE'>✓</span>${parsedContent}</p>${matchResult[2]}`;
|
||||
return `<p class='li-container'><span class='todo-block done' data-value='DONE'>✓</span>${parsedContent}</p>${matchResult[2]}`;
|
||||
};
|
||||
|
||||
export default {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { escape } from "lodash-es";
|
||||
import { absolutifyLink } from "../../../helpers/utils";
|
||||
|
||||
export const IMAGE_REG = /!\[.*?\]\((.+?)\)/;
|
||||
|
||||
@@ -8,8 +9,8 @@ const renderer = (rawStr: string): string => {
|
||||
return rawStr;
|
||||
}
|
||||
|
||||
// NOTE: Get image blob from backend to avoid CORS.
|
||||
return `<img class='img' src='/o/get/image?url=${escape(matchResult[1])}' />`;
|
||||
const imageUrl = absolutifyLink(escape(matchResult[1]));
|
||||
return `<img class='img' src='${imageUrl}' />`;
|
||||
};
|
||||
|
||||
export default {
|
||||
|
||||
@@ -10,7 +10,7 @@ const renderer = (rawStr: string): string => {
|
||||
}
|
||||
|
||||
const parsedContent = marked(matchResult[2], [], inlineElementParserList);
|
||||
return `<p><span class='ol-block'>${matchResult[1]}.</span>${parsedContent}</p>${matchResult[3]}`;
|
||||
return `<p class='li-container'><span class='ol-block'>${matchResult[1]}.</span>${parsedContent}</p>${matchResult[3]}`;
|
||||
};
|
||||
|
||||
export default {
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
/**
|
||||
* Match markdown table
|
||||
* example:
|
||||
* | a | b | c |
|
||||
* |---|---|---|
|
||||
* | 1 | 2 | 3 |
|
||||
* | 4 | 5 | 6 |
|
||||
*/
|
||||
export const TABLE_REG = /^(\|.*\|)(?:(?:\n(?:\|-*)+\|))((?:\n\|.*\|)+)(\n?)/;
|
||||
|
||||
const renderer = (rawStr: string): string => {
|
||||
const matchResult = rawStr.match(TABLE_REG);
|
||||
if (!matchResult) {
|
||||
return rawStr;
|
||||
}
|
||||
const tableHeader = matchResult[1]
|
||||
.split("|")
|
||||
.filter((str) => str !== "")
|
||||
.map((str) => str.trim());
|
||||
const tableBody = matchResult[2]
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((str) =>
|
||||
str
|
||||
.split("|")
|
||||
.filter((str) => str !== "")
|
||||
.map((str) => str.trim())
|
||||
);
|
||||
return `<table>
|
||||
<thead>
|
||||
<tr>
|
||||
${tableHeader.map((str) => `<th>${str}</th>`).join("")}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${tableBody.map((row) => `<tr>${row.map((str) => `<td>${str}</td>`).join("")}</tr>`).join("")}
|
||||
</tbody>
|
||||
</table>${matchResult[3]}`;
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "table",
|
||||
regex: TABLE_REG,
|
||||
renderer,
|
||||
};
|
||||
@@ -11,7 +11,7 @@ const renderer = (rawStr: string): string => {
|
||||
}
|
||||
|
||||
const parsedContent = marked(matchResult[1], [], inlineElementParserList);
|
||||
return `<p><span class='todo-block todo' data-value='TODO'></span>${parsedContent}</p>${escape(matchResult[2])}`;
|
||||
return `<p class='li-container'><span class='todo-block todo' data-value='TODO'></span>${parsedContent}</p>${escape(matchResult[2])}`;
|
||||
};
|
||||
|
||||
export default {
|
||||
|
||||
@@ -11,7 +11,7 @@ const renderer = (rawStr: string): string => {
|
||||
}
|
||||
|
||||
const parsedContent = marked(matchResult[1], [], inlineElementParserList);
|
||||
return `<p><span class='ul-block'>•</span>${parsedContent}</p>${escape(matchResult[2])}`;
|
||||
return `<p class='li-container'><span class='ul-block'>•</span>${parsedContent}</p>${escape(matchResult[2])}`;
|
||||
};
|
||||
|
||||
export default {
|
||||
|
||||
@@ -12,7 +12,6 @@ import Emphasis from "./Emphasis";
|
||||
import PlainLink from "./PlainLink";
|
||||
import InlineCode from "./InlineCode";
|
||||
import PlainText from "./PlainText";
|
||||
import Table from "./Table";
|
||||
import BoldEmphasis from "./BoldEmphasis";
|
||||
import Blockquote from "./Blockquote";
|
||||
import HorizontalRules from "./HorizontalRules";
|
||||
@@ -24,20 +23,9 @@ export { DONE_LIST_REG } from "./DoneList";
|
||||
export { TAG_REG } from "./Tag";
|
||||
export { IMAGE_REG } from "./Image";
|
||||
export { LINK_REG } from "./Link";
|
||||
export { TABLE_REG } from "./Table";
|
||||
export { HORIZONTAL_RULES_REG } from "./HorizontalRules";
|
||||
|
||||
// The order determines the order of execution.
|
||||
export const blockElementParserList = [
|
||||
HorizontalRules,
|
||||
Table,
|
||||
CodeBlock,
|
||||
Blockquote,
|
||||
TodoList,
|
||||
DoneList,
|
||||
OrderedList,
|
||||
UnorderedList,
|
||||
Paragraph,
|
||||
];
|
||||
export const blockElementParserList = [HorizontalRules, CodeBlock, Blockquote, TodoList, DoneList, OrderedList, UnorderedList, Paragraph];
|
||||
export const inlineElementParserList = [Image, BoldEmphasis, Bold, Emphasis, Link, InlineCode, PlainLink, Strikethrough, Tag, PlainText];
|
||||
export const parserList = [...blockElementParserList, ...inlineElementParserList];
|
||||
|
||||
@@ -1,31 +1,13 @@
|
||||
.about-site-dialog {
|
||||
@apply px-4;
|
||||
|
||||
> .dialog-container {
|
||||
@apply w-112 max-w-full;
|
||||
|
||||
> .dialog-content-container {
|
||||
@apply flex flex-col justify-start items-start leading-relaxed;
|
||||
|
||||
> .logo-img {
|
||||
@apply h-16;
|
||||
}
|
||||
@apply flex flex-col justify-start items-start;
|
||||
|
||||
> p {
|
||||
@apply my-1;
|
||||
}
|
||||
|
||||
.pre-text {
|
||||
@apply font-mono mx-1;
|
||||
}
|
||||
|
||||
> .addtion-info-container {
|
||||
@apply flex flex-row text-sm justify-start items-center;
|
||||
|
||||
> .github-badge-container {
|
||||
@apply mr-4;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.page-wrapper.auth {
|
||||
@apply flex flex-row justify-center items-center w-full h-screen bg-white;
|
||||
@apply flex flex-row justify-center items-center w-full h-screen bg-white dark:bg-zinc-800;
|
||||
|
||||
> .page-container {
|
||||
@apply w-80 max-w-full h-full py-4 flex flex-col justify-start items-center;
|
||||
@@ -11,15 +11,19 @@
|
||||
@apply flex flex-col justify-start items-start w-full mb-4;
|
||||
|
||||
> .title-container {
|
||||
@apply w-full flex flex-row justify-between items-center;
|
||||
@apply w-full flex flex-row justify-start items-center;
|
||||
|
||||
> .logo-img {
|
||||
@apply h-20 w-auto;
|
||||
}
|
||||
|
||||
> .logo-text {
|
||||
@apply text-6xl tracking-wide text-black dark:text-gray-200;
|
||||
}
|
||||
}
|
||||
|
||||
> .slogan-text {
|
||||
@apply text-sm text-gray-700;
|
||||
@apply text-sm text-gray-700 dark:text-gray-300;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +37,7 @@
|
||||
@apply absolute top-3 left-3 px-1 leading-10 flex-shrink-0 text-base cursor-text text-gray-400 bg-transparent transition-all select-none;
|
||||
|
||||
&.not-null {
|
||||
@apply text-sm top-0 z-10 leading-4 bg-white rounded;
|
||||
@apply text-sm top-0 z-10 leading-4 bg-white dark:bg-zinc-800 rounded;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +45,7 @@
|
||||
@apply py-2;
|
||||
|
||||
> input {
|
||||
@apply w-full py-3 px-3 text-base shadow-inner rounded-lg border border-solid border-gray-400 hover:opacity-80;
|
||||
@apply w-full py-3 px-3 text-base rounded-lg;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,20 +58,8 @@
|
||||
> .action-btns-container {
|
||||
@apply flex flex-row justify-end items-center w-full mt-2;
|
||||
|
||||
> .btn {
|
||||
@apply flex flex-row justify-center items-center px-1 py-2 text-sm rounded hover:opacity-80;
|
||||
|
||||
&.signup-btn {
|
||||
@apply px-3;
|
||||
}
|
||||
|
||||
&.signin-btn {
|
||||
@apply bg-green-600 text-white px-3 shadow;
|
||||
}
|
||||
|
||||
&.requesting {
|
||||
@apply cursor-wait opacity-80;
|
||||
}
|
||||
> .requesting {
|
||||
@apply cursor-wait opacity-80;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,25 +71,5 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .footer-container {
|
||||
@apply w-full flex flex-col justify-start items-center;
|
||||
|
||||
> .language-container {
|
||||
@apply mt-2 w-full flex flex-row justify-center items-center text-sm text-gray-400;
|
||||
|
||||
> .locale-item {
|
||||
@apply px-2 cursor-pointer;
|
||||
|
||||
&.active {
|
||||
@apply text-blue-600 font-bold;
|
||||
}
|
||||
}
|
||||
|
||||
> .split-line {
|
||||
@apply font-mono text-gray-400;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.dialog-wrapper {
|
||||
@apply fixed top-0 left-0 flex flex-col justify-start items-center w-full h-full pt-16 pb-8 z-100 overflow-x-hidden overflow-y-scroll bg-transparent transition-all hide-scrollbar;
|
||||
@apply fixed top-0 left-0 flex flex-col justify-start items-center w-full h-full pt-16 pb-8 px-4 z-100 overflow-x-hidden overflow-y-scroll bg-transparent transition-all hide-scrollbar;
|
||||
|
||||
&.showup {
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
@@ -10,7 +10,7 @@
|
||||
}
|
||||
|
||||
> .dialog-container {
|
||||
@apply flex flex-col justify-start items-start bg-white p-4 rounded-lg;
|
||||
@apply max-w-full flex flex-col justify-start items-start bg-white dark:bg-zinc-800 dark:text-gray-200 p-4 rounded-lg;
|
||||
|
||||
> .dialog-header-container {
|
||||
@apply flex flex-row justify-between items-center w-full mb-4;
|
||||
@@ -22,7 +22,7 @@
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply flex flex-col justify-center items-center w-6 h-6 rounded hover:bg-gray-100 hover:shadow;
|
||||
@apply flex flex-col justify-center items-center w-6 h-6 rounded hover:bg-gray-100 dark:hover:bg-zinc-700 hover:shadow;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
7
web/src/less/code-highlight.less
Normal file
7
web/src/less/code-highlight.less
Normal file
@@ -0,0 +1,7 @@
|
||||
html.dark {
|
||||
@import (less) "highlight.js/styles/atom-one-dark.css";
|
||||
}
|
||||
|
||||
html:not(.dark) {
|
||||
@import (less) "highlight.js/styles/github.css";
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
@apply flex flex-row justify-end items-center w-full mt-4;
|
||||
|
||||
> .btn {
|
||||
@apply text-sm py-1 px-3 mr-2 rounded-md hover:opacity-80;
|
||||
@apply text-sm py-1 px-3 mr-2 rounded-md hover:opacity-80 cursor-pointer;
|
||||
|
||||
&.confirm-btn {
|
||||
@apply bg-red-100 border border-solid border-blue-600 text-blue-600;
|
||||
|
||||
@@ -22,10 +22,7 @@
|
||||
@apply flex flex-row justify-around items-center w-full;
|
||||
|
||||
> .day-item {
|
||||
@apply flex flex-col justify-center items-center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
user-select: none;
|
||||
@apply w-9 h-9 select-none flex flex-col justify-center items-center;
|
||||
color: gray;
|
||||
font-size: 13px;
|
||||
margin: 2px 0;
|
||||
@@ -33,21 +30,11 @@
|
||||
}
|
||||
|
||||
> .day-item {
|
||||
@apply flex flex-col justify-center items-center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
font-size: 14px;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
@apply w-9 h-9 rounded-full text-sm select-none cursor-pointer flex flex-col justify-center items-center hover:bg-gray-200 dark:hover:bg-zinc-600;
|
||||
margin: 2px;
|
||||
|
||||
&:hover {
|
||||
@apply bg-gray-100;
|
||||
}
|
||||
|
||||
&.current {
|
||||
@apply text-blue-600 bg-blue-100 text-base font-medium;
|
||||
@apply text-blue-600 !bg-blue-100 text-base font-medium;
|
||||
}
|
||||
|
||||
&.null {
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
@apply flex flex-col justify-start items-start relative h-8;
|
||||
|
||||
> .current-value-container {
|
||||
@apply flex flex-row justify-between items-center w-full h-full rounded px-2 pr-1 bg-white border cursor-pointer select-none;
|
||||
@apply flex flex-row justify-between items-center w-full h-full rounded px-2 pr-1 bg-white dark:bg-zinc-700 dark:border-zinc-600 border cursor-pointer select-none;
|
||||
|
||||
&:hover,
|
||||
&.active {
|
||||
@apply bg-gray-100;
|
||||
@apply bg-gray-100 dark:bg-zinc-700;
|
||||
}
|
||||
|
||||
> .value-text {
|
||||
@apply text-sm mr-0 truncate;
|
||||
@apply text-sm mr-0 truncate dark:text-gray-300;
|
||||
width: calc(100% - 20px);
|
||||
}
|
||||
|
||||
@@ -18,19 +18,19 @@
|
||||
@apply flex flex-row justify-center items-center w-4 shrink-0;
|
||||
|
||||
> .icon-img {
|
||||
@apply w-4 h-auto opacity-40;
|
||||
@apply w-4 h-auto opacity-40 dark:text-gray-300;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .items-wrapper {
|
||||
@apply flex flex-col justify-start items-start absolute top-full left-0 w-auto p-1 mt-1 -ml-2 bg-white rounded-md overflow-y-auto z-1 hide-scrollbar;
|
||||
@apply flex flex-col justify-start items-start absolute top-full left-0 w-auto p-1 mt-1 -ml-2 bg-white dark:bg-zinc-700 dark:border-zinc-600 rounded-md overflow-y-auto z-1 hide-scrollbar;
|
||||
min-width: calc(100% + 16px);
|
||||
max-height: 256px;
|
||||
box-shadow: 0 0 8px 0 rgb(0 0 0 / 20%);
|
||||
|
||||
> .item-container {
|
||||
@apply flex flex-col justify-start items-start w-full px-3 text-sm select-none leading-8 cursor-pointer rounded whitespace-nowrap hover:bg-gray-100;
|
||||
@apply flex flex-col justify-start items-start w-full px-3 text-sm select-none leading-8 cursor-pointer rounded whitespace-nowrap dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-zinc-600;
|
||||
|
||||
&.selected {
|
||||
@apply text-green-600;
|
||||
|
||||
@@ -8,22 +8,22 @@
|
||||
@apply flex flex-col justify-start items-start;
|
||||
|
||||
> .form-item-container {
|
||||
@apply w-full mt-2 py-1 flex flex-row justify-start items-start;
|
||||
@apply w-full mt-2 py-1 flex sm:flex-row flex-col justify-start items-start;
|
||||
|
||||
> .normal-text {
|
||||
@apply block flex-shrink-0 w-12 mr-3 text-right text-sm leading-8;
|
||||
@apply block flex-shrink-0 w-12 mr-3 sm:text-right text-left text-sm leading-8;
|
||||
color: gray;
|
||||
}
|
||||
|
||||
> .title-input {
|
||||
@apply w-full py-1 px-2 h-9 text-sm rounded border shadow-inner;
|
||||
@apply w-full py-1 px-2 h-9 text-sm rounded border dark:border-zinc-700 dark:bg-zinc-800 shadow-inner;
|
||||
}
|
||||
|
||||
> .filters-wrapper {
|
||||
@apply w-full flex flex-col justify-start items-start;
|
||||
|
||||
> .create-filter-btn {
|
||||
@apply text-sm py-1 px-2 rounded shadow flex flex-row justify-start items-center border cursor-pointer text-blue-500 hover:opacity-80;
|
||||
@apply text-sm py-1 px-2 rounded shadow flex flex-row sm:justify-start justify-center items-center border dark:border-zinc-700 cursor-pointer text-blue-500 hover:opacity-80 sm:min-w-0 min-w-full sm:mb-0 mb-1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,14 +56,16 @@
|
||||
}
|
||||
|
||||
.memo-filter-input-wrapper {
|
||||
@apply w-full mb-3 shrink-0 flex flex-row justify-start items-center;
|
||||
@apply w-full mb-3 shrink-0 flex flex-row sm:justify-start justify-center items-center sm:flex-nowrap flex-wrap sm:gap-0 gap-3;
|
||||
|
||||
> .selector-wrapper {
|
||||
@apply mr-1 h-9 grow-0 shrink-0;
|
||||
@apply mr-1 h-9 grow-0 shrink-0 sm:min-w-0 min-w-full;
|
||||
|
||||
&.relation-selector {
|
||||
@apply w-16;
|
||||
margin-left: -68px;
|
||||
@media only screen and (min-width: 640px) {
|
||||
margin-left: -68px;
|
||||
}
|
||||
}
|
||||
|
||||
&.type-selector {
|
||||
@@ -80,13 +82,17 @@
|
||||
}
|
||||
|
||||
> input.value-inputer {
|
||||
max-width: calc(100% - 152px);
|
||||
@apply h-9 px-2 shrink-0 grow mr-1 text-sm rounded border bg-transparent hover:bg-gray-50;
|
||||
@media only screen and (min-width: 640px) {
|
||||
max-width: calc(100% - 152px);
|
||||
}
|
||||
@apply h-9 px-2 shrink-0 grow mr-1 text-sm rounded border bg-transparent hover:bg-gray-50 sm:min-w-0 min-w-full;
|
||||
}
|
||||
|
||||
> input.datetime-selector {
|
||||
max-width: calc(100% - 152px);
|
||||
@apply h-9 px-2 shrink-0 grow mr-1 text-sm rounded border bg-transparent hover:bg-gray-50;
|
||||
@media only screen and (min-width: 640px) {
|
||||
max-width: calc(100% - 152px);
|
||||
}
|
||||
@apply h-9 px-2 shrink-0 grow mr-1 text-sm rounded border bg-transparent hover:bg-gray-50 sm:min-w-0 min-w-full;
|
||||
}
|
||||
|
||||
> .remove-btn {
|
||||
|
||||
@@ -8,18 +8,18 @@
|
||||
}
|
||||
|
||||
> .split-line {
|
||||
@apply h-full px-px bg-gray-50 absolute top-1 left-6 z-0 -ml-px;
|
||||
@apply h-full px-px bg-gray-50 dark:bg-zinc-600 absolute top-1 left-6 z-0 -ml-px;
|
||||
}
|
||||
|
||||
> .time-wrapper {
|
||||
@apply mt-px mr-4 w-12 h-7 shrink-0 text-xs leading-6 text-center font-mono rounded-lg bg-gray-100 border-2 border-white text-gray-600 z-10;
|
||||
@apply mt-px mr-4 w-12 h-7 shrink-0 text-xs leading-6 text-center font-mono rounded-lg bg-gray-100 dark:bg-zinc-600 border-2 border-white dark:border-zinc-800 text-gray-600 dark:text-gray-300 z-10;
|
||||
}
|
||||
|
||||
> .memo-container {
|
||||
@apply w-full overflow-x-hidden flex flex-col justify-start items-start;
|
||||
|
||||
.memo-content-text {
|
||||
margin-top: 3px;
|
||||
@apply mt-1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,20 +2,20 @@
|
||||
@apply p-0 sm:py-16;
|
||||
|
||||
> .dialog-container {
|
||||
@apply w-full sm:w-112 max-w-full grow sm:grow-0 bg-white p-0 rounded-none sm:rounded-lg;
|
||||
@apply w-full sm:w-112 max-w-full grow sm:grow-0 p-0 pb-4 rounded-none sm:rounded-lg;
|
||||
|
||||
> .dialog-header-container {
|
||||
@apply relative flex flex-row justify-between items-center w-full p-6 pb-0 mb-0;
|
||||
|
||||
> .title-text {
|
||||
@apply px-2 py-1 -ml-2 cursor-pointer select-none rounded hover:bg-gray-100;
|
||||
@apply px-2 py-1 -ml-2 cursor-pointer select-none rounded hover:bg-gray-100 dark:hover:bg-zinc-700;
|
||||
}
|
||||
|
||||
> .btns-container {
|
||||
@apply flex flex-row justify-start items-center;
|
||||
|
||||
> .btn-text {
|
||||
@apply w-6 h-6 mr-2 rounded cursor-pointer select-none text-gray-600 last:mr-0 hover:bg-gray-200 p-0.5;
|
||||
@apply w-6 h-6 mr-2 rounded cursor-pointer select-none last:mr-0 hover:bg-gray-200 dark:hover:bg-zinc-700 p-0.5;
|
||||
|
||||
> .icon-img {
|
||||
@apply w-full h-auto;
|
||||
@@ -28,19 +28,19 @@
|
||||
}
|
||||
|
||||
> .date-picker {
|
||||
@apply absolute top-12 left-4 mt-2 bg-white shadow z-20 mx-auto border border-gray-200 rounded-lg mb-6;
|
||||
@apply absolute top-12 left-4 mt-2 bg-white dark:bg-zinc-700 shadow z-20 mx-auto border dark:border-zinc-800 rounded-lg mb-6;
|
||||
}
|
||||
}
|
||||
|
||||
> .dialog-content-container {
|
||||
@apply w-full h-auto flex flex-col justify-start items-start p-6 pb-0;
|
||||
@apply w-full h-auto flex flex-col justify-start items-start p-6 pb-0 bg-white dark:bg-zinc-800;
|
||||
|
||||
> .date-card-container {
|
||||
@apply flex flex-col justify-center items-center m-auto pb-6 select-none;
|
||||
z-index: 1;
|
||||
|
||||
> .year-text {
|
||||
@apply m-auto font-bold text-gray-600 text-center leading-6 mb-2;
|
||||
@apply m-auto font-bold text-gray-600 dark:text-gray-300 text-center leading-6 mb-2;
|
||||
}
|
||||
|
||||
> .date-container {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
@apply flex flex-col justify-start items-start relative w-full h-auto bg-white;
|
||||
|
||||
> .common-editor-inputer {
|
||||
@apply w-full h-full ~"max-h-[300px]" my-1 text-base resize-none overflow-x-hidden overflow-y-auto bg-transparent outline-none whitespace-pre-wrap;
|
||||
@apply w-full h-full ~"max-h-[300px]" my-1 text-base resize-none overflow-x-hidden overflow-y-auto bg-transparent outline-none whitespace-pre-wrap break-all;
|
||||
|
||||
&::placeholder {
|
||||
padding-left: 2px;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.page-wrapper.explore {
|
||||
@apply relative top-0 w-full h-screen overflow-y-auto overflow-x-hidden;
|
||||
@apply relative top-0 w-full h-screen overflow-y-auto overflow-x-hidden dark:bg-zinc-800;
|
||||
background-color: #f6f5f4;
|
||||
|
||||
> .page-container {
|
||||
@@ -16,17 +16,13 @@
|
||||
}
|
||||
|
||||
> .title-text {
|
||||
@apply text-xl sm:text-3xl font-mono text-gray-700;
|
||||
@apply text-xl sm:text-4xl font-mono text-gray-700 dark:text-gray-200;
|
||||
}
|
||||
}
|
||||
|
||||
> .action-button-container {
|
||||
> .btn {
|
||||
@apply block text-gray-600 font-mono text-base py-1 border px-3 leading-8 rounded-xl hover:opacity-80 hover:underline;
|
||||
|
||||
> .icon {
|
||||
@apply text-lg;
|
||||
}
|
||||
> .link-btn {
|
||||
@apply block text-gray-600 dark:text-gray-200 dark:border-gray-600 font-mono text-base py-1 border px-3 leading-8 rounded-xl hover:opacity-80;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,7 +31,7 @@
|
||||
@apply relative flex-grow max-w-2xl w-full min-h-full flex flex-col justify-start items-start px-4 sm:pr-6;
|
||||
|
||||
> .memo-container {
|
||||
@apply flex flex-col justify-start items-start w-full p-4 mt-2 bg-white rounded-lg border border-white hover:border-gray-200;
|
||||
@apply flex flex-col justify-start items-start w-full p-4 mt-2 bg-white dark:bg-zinc-700 rounded-lg border border-white dark:border-zinc-800 hover:border-gray-200 dark:hover:border-zinc-600;
|
||||
|
||||
> .memo-header {
|
||||
@apply mb-2 w-full flex flex-row justify-start items-center text-sm text-gray-400;
|
||||
@@ -44,14 +40,6 @@
|
||||
@apply ml-2 hover:text-green-600 hover:underline;
|
||||
}
|
||||
}
|
||||
|
||||
> .memo-content {
|
||||
@apply cursor-default;
|
||||
|
||||
> * {
|
||||
@apply cursor-default;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
.github-badge-container {
|
||||
@apply h-7 flex flex-row justify-start items-center border rounded cursor-pointer hover:opacity-80;
|
||||
|
||||
> .github-icon {
|
||||
@apply w-auto h-full px-2 border-r rounded-l flex flex-row justify-center items-center text-xs text-gray-800 bg-gray-100;
|
||||
|
||||
> .icon-img {
|
||||
@apply mr-1 w-4 h-4;
|
||||
}
|
||||
}
|
||||
|
||||
> .count-text {
|
||||
@apply w-auto h-full flex flex-row justify-center items-center px-3 text-xs font-bold text-gray-800;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
.page-wrapper.home {
|
||||
@apply relative top-0 w-full h-screen overflow-y-auto overflow-x-hidden;
|
||||
@apply relative top-0 w-full h-screen overflow-y-auto overflow-x-hidden dark:bg-zinc-800;
|
||||
background-color: #f6f5f4;
|
||||
|
||||
> .banner-wrapper {
|
||||
@@ -17,15 +17,15 @@
|
||||
@apply relative flex-grow max-w-2xl w-full min-h-full flex flex-col justify-start items-start px-4 sm:pr-6;
|
||||
|
||||
> .memos-editor-wrapper {
|
||||
@apply sticky top-0 w-full h-full flex flex-col justify-start items-start z-10;
|
||||
@apply sticky top-0 w-full h-full flex flex-col justify-start items-start z-10 dark:bg-zinc-800;
|
||||
background-color: #f6f5f4;
|
||||
}
|
||||
|
||||
> .addtion-btn-container {
|
||||
> .addition-btn-container {
|
||||
@apply fixed bottom-12 left-1/2 -translate-x-1/2;
|
||||
|
||||
> .btn {
|
||||
@apply bg-blue-600 text-white px-4 py-2 rounded-3xl shadow-2xl hover:opacity-80;
|
||||
@apply bg-blue-600 dark:bg-blue-800 text-white dark:text-gray-200 px-4 py-2 rounded-3xl shadow-2xl hover:opacity-80;
|
||||
|
||||
> .icon {
|
||||
@apply text-lg mr-1;
|
||||
|
||||
@@ -1,28 +1,29 @@
|
||||
.memo-content-wrapper {
|
||||
@apply w-full flex flex-col justify-start items-start;
|
||||
@apply w-full flex flex-col justify-start items-start text-gray-800 dark:text-gray-200;
|
||||
|
||||
> .memo-content-text {
|
||||
@apply w-full break-words text-base leading-7;
|
||||
@apply w-full max-w-full word-break text-base leading-6;
|
||||
|
||||
> p {
|
||||
@apply w-full h-auto mb-1 last:mb-0 text-base leading-6 whitespace-pre-wrap break-words;
|
||||
@apply w-full h-auto mb-1 last:mb-0 text-base;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
> .li-container {
|
||||
@apply w-full flex flex-row flex-nowrap;
|
||||
}
|
||||
|
||||
.img {
|
||||
@apply block max-w-full rounded cursor-pointer hover:shadow;
|
||||
}
|
||||
|
||||
.tag-span {
|
||||
@apply inline-block w-auto font-mono text-blue-600 cursor-pointer;
|
||||
}
|
||||
|
||||
.memo-link-text {
|
||||
@apply inline-block text-blue-600 cursor-pointer font-bold border-none no-underline hover:opacity-80;
|
||||
@apply inline-block w-auto font-mono text-blue-600 dark:text-blue-400 cursor-pointer;
|
||||
}
|
||||
|
||||
.link {
|
||||
@apply text-blue-600 cursor-pointer underline break-all hover:opacity-80 decoration-1;
|
||||
@apply text-blue-600 dark:text-blue-400 cursor-pointer underline break-all hover:opacity-80 decoration-1;
|
||||
|
||||
code {
|
||||
@apply underline decoration-1;
|
||||
}
|
||||
@@ -31,31 +32,23 @@
|
||||
.ol-block,
|
||||
.ul-block,
|
||||
.todo-block {
|
||||
@apply inline-block box-border text-right w-8 mr-px font-mono select-none whitespace-nowrap;
|
||||
@apply shrink-0 inline-block box-border text-right w-8 mr-px font-mono text-sm leading-6 select-none whitespace-nowrap;
|
||||
}
|
||||
|
||||
.ol-block {
|
||||
@apply opacity-80 mt-px;
|
||||
}
|
||||
|
||||
.ul-block {
|
||||
@apply text-center;
|
||||
@apply text-center mt-px;
|
||||
}
|
||||
|
||||
.todo-block {
|
||||
@apply w-4 h-4 leading-4 border rounded box-border text-lg cursor-pointer shadow-inner hover:opacity-80;
|
||||
transform: translateY(2px);
|
||||
margin-left: 6px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
li {
|
||||
list-style-type: none;
|
||||
|
||||
&::before {
|
||||
@apply font-bold mr-1;
|
||||
content: "•";
|
||||
}
|
||||
@apply w-4 h-4 leading-4 mx-2 mt-1 border rounded box-border text-lg cursor-pointer shadow-inner hover:opacity-80;
|
||||
}
|
||||
|
||||
pre {
|
||||
@apply w-full my-1 p-3 rounded bg-gray-100 whitespace-pre-wrap;
|
||||
@apply w-full my-1 p-3 rounded bg-gray-100 dark:bg-zinc-600 whitespace-pre-wrap;
|
||||
|
||||
code {
|
||||
@apply block;
|
||||
@@ -63,7 +56,7 @@
|
||||
}
|
||||
|
||||
code {
|
||||
@apply bg-gray-100 px-1 rounded text-sm font-mono leading-6 inline-block;
|
||||
@apply break-all bg-gray-100 dark:bg-zinc-600 px-1 rounded text-sm font-mono leading-6 inline-block;
|
||||
}
|
||||
|
||||
table {
|
||||
@@ -79,11 +72,11 @@
|
||||
}
|
||||
|
||||
blockquote {
|
||||
@apply border-l-4 pl-2 text-gray-400;
|
||||
@apply border-l-4 pl-2 text-gray-400 dark:text-gray-300;
|
||||
}
|
||||
|
||||
hr {
|
||||
@apply my-1;
|
||||
@apply my-1 dark:border-zinc-600;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +84,7 @@
|
||||
@apply w-full relative flex flex-row justify-start items-center;
|
||||
|
||||
> .btn {
|
||||
@apply flex flex-row justify-start items-center pl-2 pr-1 py-1 my-1 text-xs rounded-lg border bg-gray-100 border-gray-200 opacity-80 shadow hover:opacity-60;
|
||||
@apply flex flex-row justify-start items-center pl-2 pr-1 py-1 my-1 text-xs rounded-lg border bg-gray-100 dark:bg-zinc-600 border-gray-200 dark:border-zinc-600 opacity-80 shadow hover:opacity-60 cursor-pointer;
|
||||
|
||||
&.expand-btn {
|
||||
@apply mt-2;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
.page-wrapper.memo-detail {
|
||||
@apply relative top-0 w-full h-screen overflow-y-auto overflow-x-hidden;
|
||||
@apply relative top-0 w-full h-screen overflow-y-auto overflow-x-hidden dark:bg-zinc-800;
|
||||
background-color: #f6f5f4;
|
||||
|
||||
> .page-container {
|
||||
@apply relative w-full min-h-screen mx-auto flex flex-col justify-start items-center pb-8;
|
||||
|
||||
> .page-header {
|
||||
@apply sticky top-0 z-10 max-w-2xl w-full min-h-full flex flex-row justify-between items-center px-4 pt-6 mb-2;
|
||||
@apply sticky top-0 z-10 max-w-2xl w-full min-h-full flex flex-row justify-between items-center px-4 pt-6 mb-2 dark:bg-zinc-800;
|
||||
background-color: #f6f5f4;
|
||||
|
||||
> .title-container {
|
||||
@@ -16,6 +16,10 @@
|
||||
@apply h-12 sm:h-14 w-auto mr-1;
|
||||
}
|
||||
|
||||
> .logo-text {
|
||||
@apply text-4xl tracking-wide text-black dark:text-white;
|
||||
}
|
||||
|
||||
> .title-text {
|
||||
@apply text-xl sm:text-3xl font-mono text-gray-700;
|
||||
}
|
||||
@@ -23,7 +27,7 @@
|
||||
|
||||
> .action-button-container {
|
||||
> .btn {
|
||||
@apply block text-gray-600 font-mono text-base py-1 border px-3 leading-8 rounded-xl hover:opacity-80 hover:underline;
|
||||
@apply block text-gray-600 dark:text-gray-300 font-mono text-base py-1 border px-3 leading-8 rounded-xl hover:opacity-80 hover:underline;
|
||||
|
||||
> .icon {
|
||||
@apply text-lg;
|
||||
@@ -36,7 +40,7 @@
|
||||
@apply relative flex-grow max-w-2xl w-full min-h-full flex flex-col justify-start items-start px-4;
|
||||
|
||||
> .memo-container {
|
||||
@apply flex flex-col justify-start items-start w-full p-4 pt-3 mt-2 bg-white rounded-lg border border-white hover:border-gray-200;
|
||||
@apply flex flex-col justify-start items-start w-full p-4 mt-2 bg-white dark:bg-zinc-700 rounded-lg border border-white dark:border-zinc-800 hover:border-gray-200 dark:hover:border-zinc-700;
|
||||
|
||||
> .memo-header {
|
||||
@apply mb-2 w-full flex flex-row justify-between items-center;
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
.memo-editor-container {
|
||||
@apply transition-all relative w-full flex flex-col justify-start items-start bg-white px-4 rounded-lg border-2 border-gray-200;
|
||||
@apply relative w-full flex flex-col justify-start items-start bg-white dark:bg-zinc-700 px-4 rounded-lg border-2 border-gray-200 dark:border-zinc-600;
|
||||
|
||||
&.fullscreen {
|
||||
@apply fixed w-full h-full top-0 left-0 z-1000 border-none rounded-none sm:p-8;
|
||||
background-color: #f6f5f4;
|
||||
@apply transition-all fixed w-full h-full top-0 left-0 z-1000 border-none rounded-none sm:p-8 dark:bg-zinc-800;
|
||||
|
||||
> .memo-editor {
|
||||
@apply p-4 mb-4 rounded-lg border shadow-lg flex flex-col flex-grow justify-start items-start relative w-full h-full bg-white;
|
||||
@apply p-4 mb-4 rounded-lg border shadow-lg flex flex-col flex-grow justify-start items-start relative w-full h-full bg-white dark:bg-zinc-700 dark:border-zinc-600;
|
||||
|
||||
> .common-editor-inputer {
|
||||
@apply flex-grow w-full !h-full max-h-full;
|
||||
@@ -18,9 +17,8 @@
|
||||
top: unset !important;
|
||||
}
|
||||
|
||||
.emoji-picker-react {
|
||||
@apply !bottom-8;
|
||||
top: unset !important;
|
||||
.items-wrapper {
|
||||
@apply mb-1 bottom-full top-auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +27,7 @@
|
||||
}
|
||||
|
||||
> .memo-editor {
|
||||
@apply mt-4 flex flex-col justify-start items-start relative w-full h-auto bg-white;
|
||||
@apply mt-4 flex flex-col justify-start items-start relative w-full h-auto bg-inherit dark:text-gray-200;
|
||||
}
|
||||
|
||||
> .common-tools-wrapper {
|
||||
@@ -39,7 +37,7 @@
|
||||
@apply flex flex-row justify-start items-center;
|
||||
|
||||
> .action-btn {
|
||||
@apply flex flex-row justify-center items-center p-1 w-auto h-auto mr-1 select-none rounded cursor-pointer opacity-60 hover:opacity-90 hover:bg-gray-300 hover:shadow;
|
||||
@apply flex flex-row justify-center items-center p-1 w-auto h-auto mr-1 select-none rounded cursor-pointer dark:text-gray-200 opacity-60 hover:opacity-90 hover:bg-gray-300 dark:hover:bg-zinc-800 hover:shadow;
|
||||
|
||||
&.tag-action {
|
||||
@apply relative;
|
||||
@@ -63,20 +61,34 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.resource-btn {
|
||||
@apply relative;
|
||||
|
||||
&:hover {
|
||||
> .resource-action-list {
|
||||
@apply flex;
|
||||
}
|
||||
}
|
||||
|
||||
> .resource-action-list {
|
||||
@apply hidden flex-col justify-start items-start absolute top-6 left-0 mt-1 p-1 z-1 rounded w-auto overflow-auto font-mono shadow bg-zinc-200 dark:bg-zinc-600;
|
||||
|
||||
> .resource-action-item {
|
||||
@apply w-full flex text-black dark:text-gray-300 cursor-pointer rounded text-sm leading-6 px-2 truncate hover:bg-zinc-300 dark:hover:bg-zinc-700 shrink-0;
|
||||
|
||||
> .icon-img {
|
||||
@apply w-4 mr-1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .icon-img {
|
||||
@apply w-5 h-5 mx-auto flex flex-row justify-center items-center;
|
||||
}
|
||||
|
||||
> .tip-text {
|
||||
@apply hidden ml-1 text-xs leading-5 text-gray-700 border border-gray-300 rounded-xl px-2;
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-picker-react {
|
||||
@apply absolute shadow left-6 top-8;
|
||||
|
||||
li.emoji::before {
|
||||
@apply hidden;
|
||||
@apply hidden ml-1 text-xs leading-5 text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-zinc-500 rounded-xl px-2;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,7 +98,7 @@
|
||||
@apply w-full flex flex-row justify-start flex-wrap;
|
||||
|
||||
> .resource-container {
|
||||
@apply mt-1 mr-1 flex flex-row justify-start items-center flex-nowrap bg-gray-100 px-2 py-1 rounded cursor-pointer hover:bg-gray-200;
|
||||
@apply max-w-full mt-1 mr-1 flex flex-row justify-start items-center flex-nowrap bg-gray-100 px-2 py-1 rounded cursor-pointer hover:bg-gray-200;
|
||||
|
||||
> .icon-img {
|
||||
@apply w-4 h-auto mr-1 text-gray-500;
|
||||
@@ -103,7 +115,7 @@
|
||||
}
|
||||
|
||||
> .editor-footer-container {
|
||||
@apply w-full flex flex-row justify-between items-center border-t border-t-gray-100 py-3 mt-2;
|
||||
@apply w-full flex flex-row justify-between items-center border-t border-t-gray-100 dark:border-t-zinc-500 py-3 mt-2;
|
||||
|
||||
> .visibility-selector {
|
||||
@apply h-8;
|
||||
@@ -121,7 +133,7 @@
|
||||
@apply grow-0 shrink-0 flex flex-row justify-end items-center;
|
||||
|
||||
> .cancel-btn {
|
||||
@apply mr-4 text-sm text-gray-600 hover:opacity-80;
|
||||
@apply mr-4 text-sm text-gray-500 hover:opacity-80 dark:text-gray-300;
|
||||
}
|
||||
|
||||
> .confirm-btn {
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
.filter-query-container {
|
||||
@apply flex flex-row justify-start items-start w-full flex-wrap p-2 pb-1 text-sm font-mono leading-7;
|
||||
@apply flex flex-row justify-start items-start w-full flex-wrap p-2 pb-1 text-sm font-mono leading-7 dark:text-gray-300;
|
||||
|
||||
> .tip-text {
|
||||
@apply mr-2;
|
||||
}
|
||||
|
||||
> .filter-item-container {
|
||||
@apply flex flex-row justify-start items-center px-2 mr-2 cursor-pointer bg-gray-200 rounded whitespace-nowrap truncate hover:line-through;
|
||||
@apply flex flex-row justify-start items-center px-2 mr-2 cursor-pointer dark:text-gray-300 bg-gray-200 dark:bg-zinc-700 rounded whitespace-nowrap truncate hover:line-through;
|
||||
max-width: 256px;
|
||||
|
||||
> .icon-text {
|
||||
@apply w-4 h-auto mr-1 text-gray-500;
|
||||
@apply w-4 h-auto mr-1 text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
.memo-wrapper {
|
||||
@apply relative flex flex-col justify-start items-start w-full p-4 pt-3 mt-2 bg-white rounded-lg border border-white hover:border-gray-200;
|
||||
|
||||
&.archived-memo {
|
||||
@apply border-gray-200;
|
||||
}
|
||||
@apply relative flex flex-col justify-start items-start w-full p-4 pt-3 mt-2 bg-white dark:bg-zinc-700 rounded-lg border border-white dark:border-zinc-600 hover:border-gray-200 dark:hover:border-zinc-600;
|
||||
|
||||
&.pinned {
|
||||
@apply border-gray-200 border-2;
|
||||
@apply border-gray-200 border-2 dark:border-zinc-600;
|
||||
}
|
||||
|
||||
&.archived {
|
||||
@apply border-gray-200 dark:border-zinc-600;
|
||||
}
|
||||
|
||||
> .corner-container {
|
||||
@@ -54,14 +54,14 @@
|
||||
@apply hidden flex-col justify-start items-center absolute top-2 -right-4 flex-nowrap hover:flex p-3;
|
||||
|
||||
> .more-action-btns-container {
|
||||
@apply w-28 h-auto p-1 z-1 whitespace-nowrap rounded-lg bg-white;
|
||||
@apply w-28 h-auto p-1 z-1 whitespace-nowrap rounded-lg bg-white dark:bg-zinc-700;
|
||||
box-shadow: 0 0 8px 0 rgb(0 0 0 / 20%);
|
||||
|
||||
> .btns-container {
|
||||
@apply w-full flex flex-row justify-around items-center border-b border-gray-100 p-1 mb-1;
|
||||
@apply w-full flex flex-row justify-around items-center border-b border-gray-100 dark:border-zinc-600 p-1 mb-1;
|
||||
|
||||
> .btn {
|
||||
@apply relative w-6 h-6 p-1 text-gray-600 cursor-pointer select-none;
|
||||
@apply relative w-6 h-6 p-1 text-gray-600 dark:text-gray-300 cursor-pointer select-none;
|
||||
|
||||
&:hover > .tip-text {
|
||||
@apply block;
|
||||
@@ -78,7 +78,7 @@
|
||||
}
|
||||
|
||||
> .btn {
|
||||
@apply w-full text-sm leading-6 py-1 px-3 rounded justify-start cursor-pointer;
|
||||
@apply w-full text-sm leading-6 py-1 px-3 rounded justify-start cursor-pointer dark:text-gray-300;
|
||||
|
||||
&.archive-btn {
|
||||
@apply text-orange-600;
|
||||
@@ -88,13 +88,13 @@
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply flex flex-row justify-center items-center px-2 leading-6 text-sm rounded hover:bg-gray-200;
|
||||
@apply flex flex-row justify-center items-center px-2 leading-6 text-sm rounded hover:bg-gray-200 dark:hover:bg-zinc-600;
|
||||
|
||||
&.more-action-btn {
|
||||
@apply w-8 -mr-2 opacity-60 cursor-default hover:bg-transparent;
|
||||
|
||||
> .icon-img {
|
||||
@apply w-4 h-auto;
|
||||
@apply w-4 h-auto dark:text-gray-300;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@@ -118,30 +118,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .expand-btn-container {
|
||||
@apply w-full relative flex flex-row justify-start items-center;
|
||||
|
||||
> .btn {
|
||||
@apply flex flex-row justify-start items-center pl-2 pr-1 py-1 my-1 text-xs rounded-lg border bg-gray-100 border-gray-200 opacity-80 shadow hover:opacity-60;
|
||||
|
||||
&.expand-btn {
|
||||
@apply mt-2;
|
||||
|
||||
> .icon-img {
|
||||
@apply rotate-90;
|
||||
}
|
||||
}
|
||||
|
||||
&.fold-btn {
|
||||
> .icon-img {
|
||||
@apply -rotate-90;
|
||||
}
|
||||
}
|
||||
|
||||
> .icon-img {
|
||||
@apply w-4 h-auto ml-1 transition-all;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.section-header-container,
|
||||
.memos-header-container {
|
||||
@apply sticky top-4 flex flex-row justify-between items-center w-full h-10 flex-nowrap mt-4 mb-2 shrink-0 z-1;
|
||||
@apply sticky top-4 flex flex-row justify-between items-center w-full h-10 flex-nowrap mt-4 mb-2 shrink-0 z-10;
|
||||
|
||||
> .title-container {
|
||||
@apply flex flex-row justify-start items-center mr-2 shrink-0 overflow-hidden;
|
||||
@@ -9,12 +9,12 @@
|
||||
@apply flex sm:hidden flex-row justify-center items-center w-6 h-6 mr-1 shrink-0 bg-transparent;
|
||||
|
||||
> .icon-img {
|
||||
@apply w-5 h-auto;
|
||||
@apply w-5 h-auto dark:text-gray-200;
|
||||
}
|
||||
}
|
||||
|
||||
> .title-text {
|
||||
@apply font-bold text-lg leading-10 mr-2 text-ellipsis shrink-0 cursor-pointer overflow-hidden text-gray-700;
|
||||
@apply font-bold text-lg leading-10 mr-2 text-ellipsis shrink-0 cursor-pointer overflow-hidden text-gray-700 dark:text-gray-200;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
@apply fixed top-8 right-8 flex flex-col justify-start items-center;
|
||||
|
||||
> .btn {
|
||||
@apply mb-3 last:mb-0 w-8 h-8 p-1 cursor-pointer rounded opacity-90 bg-gray-300 z-10 shadow-md hover:opacity-70;
|
||||
@apply mb-3 last:mb-0 w-8 h-8 p-1 cursor-pointer rounded opacity-90 bg-gray-300 dark:bg-zinc-600 z-10 shadow-md hover:opacity-70;
|
||||
|
||||
> .icon-img {
|
||||
@apply w-6 h-auto;
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
@apply text-sm cursor-pointer px-3 py-1 rounded flex flex-row justify-center items-center border border-red-600 text-red-600 bg-red-100 hover:opacity-80;
|
||||
|
||||
> .icon-img {
|
||||
@apply w-4 h-auto mr-1;
|
||||
@apply w-4 h-auto mr-1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@
|
||||
@apply flex flex-col justify-start items-start w-full;
|
||||
|
||||
> .fields-container {
|
||||
@apply px-2 py-2 w-full grid grid-cols-7 border-b;
|
||||
@apply px-2 py-2 w-full grid grid-cols-7 border-b dark:border-b-zinc-600;
|
||||
|
||||
> .field-text {
|
||||
@apply font-mono text-gray-400;
|
||||
|
||||
51
web/src/less/resources-selector-dialog.less
Normal file
51
web/src/less/resources-selector-dialog.less
Normal file
@@ -0,0 +1,51 @@
|
||||
.resources-selector-dialog {
|
||||
@apply px-4;
|
||||
|
||||
> .dialog-container {
|
||||
@apply w-112 max-w-full mb-8;
|
||||
|
||||
> .dialog-content-container {
|
||||
@apply flex flex-col justify-start items-start w-full;
|
||||
|
||||
> .loading-text-container {
|
||||
@apply flex flex-col justify-center items-center w-full h-32;
|
||||
}
|
||||
|
||||
> .resource-table-container {
|
||||
@apply flex flex-col justify-start items-start w-full;
|
||||
|
||||
> .fields-container {
|
||||
@apply px-2 py-2 w-full grid grid-cols-7 border-b dark:border-b-gray-500;
|
||||
|
||||
> .field-text {
|
||||
@apply font-mono text-gray-400;
|
||||
}
|
||||
}
|
||||
|
||||
> .tip-text {
|
||||
@apply w-full text-center text-base my-6 mt-8;
|
||||
}
|
||||
|
||||
> .resource-container {
|
||||
@apply px-2 py-2 w-full grid grid-cols-7 dark:bg-zinc-700;
|
||||
|
||||
> .buttons-container {
|
||||
@apply w-full flex flex-row justify-end items-center;
|
||||
}
|
||||
}
|
||||
|
||||
.field-text {
|
||||
@apply w-full truncate text-base pr-2 last:pr-0;
|
||||
|
||||
&.id-text {
|
||||
@apply col-span-2;
|
||||
}
|
||||
|
||||
&.name-text {
|
||||
@apply col-span-4;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user