Merge branch 'main' into main

Signed-off-by: Tiny-Paws <69763072+Tiny-Paws@users.noreply.github.com>
This commit is contained in:
Tiny-Paws 2025-10-14 21:11:18 +02:00 committed by GitHub
commit ace4e26ad4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
75 changed files with 2062 additions and 826 deletions

View File

@ -1,6 +1,17 @@
<!-- Premium Sponsors -->
<p align="center"><strong>Special thanks to our sponsor:</strong></p>
<div align="center">
<a href="https://go.warp.dev/memos" target="_blank" rel="noopener">
<img src="https://raw.githubusercontent.com/warpdotdev/brand-assets/main/Github/Sponsor/Warp-Github-LG-02.png" alt="Warp" height="256" />
</a>
<p>
<a href="https://go.warp.dev/memos" target="_blank" rel="noopener">Warp is built for coding with multiple AI agents</a>
</p>
</div>
# Memos
<img align="right" height="96px" src="https://www.usememos.com/logo-rounded.png" alt="Memos" />
<img align="right" height="96px" src="https://raw.githubusercontent.com/usememos/.github/refs/heads/main/assets/logo-rounded.png" alt="Memos" />
A modern, open-source, self-hosted knowledge management and note-taking platform designed for privacy-conscious users and organizations. Memos provides a lightweight yet powerful solution for capturing, organizing, and sharing thoughts with comprehensive Markdown support and cross-platform accessibility.
@ -17,7 +28,7 @@ A modern, open-source, self-hosted knowledge management and note-taking platform
</div>
![Memos Application Screenshot](https://www.usememos.com/demo.png)
![Memos Application Screenshot](https://raw.githubusercontent.com/usememos/.github/refs/heads/main/assets/demo.png)
<!-- Premium Sponsors -->
<!--
@ -99,6 +110,7 @@ Access Memos at `http://localhost:5230` and complete the initial setup.
Memos is made possible by the generous support of our sponsors. Their contributions help ensure the project's continued development, maintenance, and growth.
<a href="https://github.com/warpdev" target="_blank"><img src="https://avatars.githubusercontent.com/u/71840468?s=200&v=4" alt="warp" height="60" /></a>
<a href="https://github.com/yourselfhosted" target="_blank"><img src="https://avatars.githubusercontent.com/u/140182318?v=4" alt="yourselfhosted" height="60" /></a>
<a href="https://github.com/fixermark" target="_blank"><img src="https://avatars.githubusercontent.com/u/169982?v=4" alt="fixermark" height="60" /></a>
<a href="https://github.com/alik-agaev" target="_blank"><img src="https://avatars.githubusercontent.com/u/2662697?v=4" alt="alik-agaev" height="60" /></a>

34
go.mod
View File

@ -3,9 +3,9 @@ module github.com/usememos/memos
go 1.25
require (
github.com/aws/aws-sdk-go-v2 v1.38.3
github.com/aws/aws-sdk-go-v2/config v1.31.6
github.com/aws/aws-sdk-go-v2/credentials v1.18.10
github.com/aws/aws-sdk-go-v2 v1.39.2
github.com/aws/aws-sdk-go-v2/config v1.31.12
github.com/aws/aws-sdk-go-v2/credentials v1.18.16
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.19.4
github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3
github.com/go-sql-driver/mysql v1.9.3
@ -23,13 +23,13 @@ require (
github.com/spf13/cobra v1.10.1
github.com/spf13/viper v1.20.1
github.com/stretchr/testify v1.10.0
github.com/usememos/gomark v0.0.0-20250917125604-82623ecaf218
golang.org/x/crypto v0.41.0
golang.org/x/mod v0.27.0
github.com/usememos/gomark v0.0.0-20250925160223-606d7debad77
golang.org/x/crypto v0.42.0
golang.org/x/mod v0.28.0
golang.org/x/net v0.43.0
golang.org/x/oauth2 v0.30.0
google.golang.org/genproto/googleapis/api v0.0.0-20250826171959-ef028d996bc1
google.golang.org/grpc v1.75.0
google.golang.org/grpc v1.75.1
modernc.org/sqlite v1.38.2
)
@ -68,18 +68,18 @@ require (
require (
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.6 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.9 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.9 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.9 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.9 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.6 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.29.1 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.2 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.38.2 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.29.6 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.38.6 // indirect
github.com/aws/smithy-go v1.23.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/disintegration/imaging v1.6.2
@ -91,9 +91,9 @@ require (
github.com/soheilhy/cmux v0.1.5
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.28.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.29.0 // indirect
golang.org/x/time v0.12.0 // indirect
google.golang.org/protobuf v1.36.8
google.golang.org/protobuf v1.36.9
gopkg.in/yaml.v3 v3.0.1 // indirect
)

72
go.sum
View File

@ -28,22 +28,22 @@ github.com/aryann/difflib v0.0.0-20170710044230-e206f873d14a/go.mod h1:DAHtR1m6l
github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU=
github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g=
github.com/aws/aws-sdk-go-v2 v1.38.3 h1:B6cV4oxnMs45fql4yRH+/Po/YU+597zgWqvDpYMturk=
github.com/aws/aws-sdk-go-v2 v1.38.3/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY=
github.com/aws/aws-sdk-go-v2 v1.39.2 h1:EJLg8IdbzgeD7xgvZ+I8M1e0fL0ptn/M47lianzth0I=
github.com/aws/aws-sdk-go-v2 v1.39.2/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 h1:i8p8P4diljCr60PpJp6qZXNlgX4m2yQFpYk+9ZT+J4E=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1/go.mod h1:ddqbooRZYNoJ2dsTwOty16rM+/Aqmk/GOXrK8cg7V00=
github.com/aws/aws-sdk-go-v2/config v1.31.6 h1:a1t8fXY4GT4xjyJExz4knbuoxSCacB5hT/WgtfPyLjo=
github.com/aws/aws-sdk-go-v2/config v1.31.6/go.mod h1:5ByscNi7R+ztvOGzeUaIu49vkMk2soq5NaH5PYe33MQ=
github.com/aws/aws-sdk-go-v2/credentials v1.18.10 h1:xdJnXCouCx8Y0NncgoptztUocIYLKeQxrCgN6x9sdhg=
github.com/aws/aws-sdk-go-v2/credentials v1.18.10/go.mod h1:7tQk08ntj914F/5i9jC4+2HQTAuJirq7m1vZVIhEkWs=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.6 h1:wbjnrrMnKew78/juW7I2BtKQwa1qlf6EjQgS69uYY14=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.6/go.mod h1:AtiqqNrDioJXuUgz3+3T0mBWN7Hro2n9wll2zRUc0ww=
github.com/aws/aws-sdk-go-v2/config v1.31.12 h1:pYM1Qgy0dKZLHX2cXslNacbcEFMkDMl+Bcj5ROuS6p8=
github.com/aws/aws-sdk-go-v2/config v1.31.12/go.mod h1:/MM0dyD7KSDPR+39p9ZNVKaHDLb9qnfDurvVS2KAhN8=
github.com/aws/aws-sdk-go-v2/credentials v1.18.16 h1:4JHirI4zp958zC026Sm+V4pSDwW4pwLefKrc0bF2lwI=
github.com/aws/aws-sdk-go-v2/credentials v1.18.16/go.mod h1:qQMtGx9OSw7ty1yLclzLxXCRbrkjWAM7JnObZjmCB7I=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.9 h1:Mv4Bc0mWmv6oDuSWTKnk+wgeqPL5DRFu5bQL9BGPQ8Y=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.9/go.mod h1:IKlKfRppK2a1y0gy1yH6zD+yX5uplJ6UuPlgd48dJiQ=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.19.4 h1:BTl+TXrpnrpPWb/J3527GsJ/lMkn7z3GO12j6OlsbRg=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.19.4/go.mod h1:cG2tenc/fscpChiZE29a2crG9uo2t6nQGflFllFL8M8=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6 h1:uF68eJA6+S9iVr9WgX1NaRGyQ/6MdIyc4JNUo6TN1FA=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6/go.mod h1:qlPeVZCGPiobx8wb1ft0GHT5l+dc6ldnwInDFaMvC7Y=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6 h1:pa1DEC6JoI0zduhZePp3zmhWvk/xxm4NB8Hy/Tlsgos=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6/go.mod h1:gxEjPebnhWGJoaDdtDkA0JX46VRg1wcTHYe63OfX5pE=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.9 h1:se2vOWGD3dWQUtfn4wEjRQJb1HK1XsNIt825gskZ970=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.9/go.mod h1:hijCGH2VfbZQxqCDN7bwz/4dzxV+hkyhjawAtdPWKZA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.9 h1:6RBnKZLkJM4hQ+kN6E7yWFveOTg8NLPHAkqrs4ZPlTU=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.9/go.mod h1:V9rQKRmK7AWuEsOMnHzKj8WyrIir1yUJbZxDuZLFvXI=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.6 h1:R0tNFJqfjHL3900cqhXuwQ+1K4G0xc9Yf8EDbFXCKEw=
@ -52,18 +52,18 @@ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebP
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.6 h1:hncKj/4gR+TPauZgTAsxOxNcvBayhUlYZ6LO/BYiQ30=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.6/go.mod h1:OiIh45tp6HdJDDJGnja0mw8ihQGz3VGrUflLqSL0SmM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6 h1:LHS1YAIJXJ4K9zS+1d/xa9JAA9sL2QyXIQCQFQW/X08=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6/go.mod h1:c9PCiTEuh0wQID5/KqA32J+HAgZxN9tOGXKCiYJjTZI=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.9 h1:5r34CgVOD4WZudeEKZ9/iKpiT6cM1JyEROpXjOcdWv8=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.9/go.mod h1:dB12CEbNWPbzO2uC6QSWHteqOg4JfBVJOojbAoAUb5I=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.6 h1:nEXUSAwyUfLTgnc9cxlDWy637qsq4UWwp3sNAfl0Z3Y=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.6/go.mod h1:HGzIULx4Ge3Do2V0FaiYKcyKzOqwrhUZgCI77NisswQ=
github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3 h1:ETkfWcXP2KNPLecaDa++5bsQhCRa5M5sLUJa5DWYIIg=
github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3/go.mod h1:+/3ZTqoYb3Ur7DObD00tarKMLMuKg8iqz5CHEanqTnw=
github.com/aws/aws-sdk-go-v2/service/sso v1.29.1 h1:8OLZnVJPvjnrxEwHFg9hVUof/P4sibH+Ea4KKuqAGSg=
github.com/aws/aws-sdk-go-v2/service/sso v1.29.1/go.mod h1:27M3BpVi0C02UiQh1w9nsBEit6pLhlaH3NHna6WUbDE=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.2 h1:gKWSTnqudpo8dAxqBqZnDoDWCiEh/40FziUjr/mo6uA=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.2/go.mod h1:x7+rkNmRoEN1U13A6JE2fXne9EWyJy54o3n6d4mGaXQ=
github.com/aws/aws-sdk-go-v2/service/sts v1.38.2 h1:YZPjhyaGzhDQEvsffDEcpycq49nl7fiGcfJTIo8BszI=
github.com/aws/aws-sdk-go-v2/service/sts v1.38.2/go.mod h1:2dIN8qhQfv37BdUYGgEC8Q3tteM3zFxTI1MLO2O3J3c=
github.com/aws/aws-sdk-go-v2/service/sso v1.29.6 h1:A1oRkiSQOWstGh61y4Wc/yQ04sqrQZr1Si/oAXj20/s=
github.com/aws/aws-sdk-go-v2/service/sso v1.29.6/go.mod h1:5PfYspyCU5Vw1wNPsxi15LZovOnULudOQuVxphSflQA=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.1 h1:5fm5RTONng73/QA73LhCNR7UT9RpFH3hR6HWL6bIgVY=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.1/go.mod h1:xBEjWD13h+6nq+z4AkqSfSvqRKFgDIQeaMguAJndOWo=
github.com/aws/aws-sdk-go-v2/service/sts v1.38.6 h1:p3jIvqYwUZgu/XYeI48bJxOhvm47hZb5HUQ0tn6Q9kA=
github.com/aws/aws-sdk-go-v2/service/sts v1.38.6/go.mod h1:WtKK+ppze5yKPkZ0XwqIVWD4beCwv056ZbPQNoeHqM8=
github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE=
github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
@ -433,8 +433,8 @@ github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVM
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
github.com/usememos/gomark v0.0.0-20250917125604-82623ecaf218 h1:rkH9CKI0AzWOIkwzZOs0PYepWt9fJTZZ9c5W1lL/bMQ=
github.com/usememos/gomark v0.0.0-20250917125604-82623ecaf218/go.mod h1:7CZRoYFQyyljzplOTeyODFR26O+wr0BbnpTWVLGfKJA=
github.com/usememos/gomark v0.0.0-20250925160223-606d7debad77 h1:eDJ/NUqDIvQbyXwqiKaUC3Y4lvbauSME+KLmpaxRytk=
github.com/usememos/gomark v0.0.0-20250925160223-606d7debad77/go.mod h1:7CZRoYFQyyljzplOTeyODFR26O+wr0BbnpTWVLGfKJA=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
@ -482,8 +482,8 @@ golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20200331195152-e8c3332aa8e5/go.mod h1:4M0jN8W1tt0AVLNr8HDosyJCDCDuyL9N9+3m7wDWgKw=
@ -505,8 +505,8 @@ golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzB
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@ -542,8 +542,8 @@ golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -574,15 +574,15 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
@ -642,8 +642,8 @@ google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
google.golang.org/grpc v1.32.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI=
google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@ -654,8 +654,8 @@ google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@ -306,11 +306,9 @@ func (c *Cron) runScheduler() {
// startJob runs the given job in a new goroutine.
func (c *Cron) startJob(j Job) {
c.jobWaiter.Add(1)
go func() {
defer c.jobWaiter.Done()
c.jobWaiter.Go(func() {
j.Run()
}()
})
}
// now returns current time in c location.

View File

@ -326,4 +326,6 @@ message SpoilerNode {
message HTMLElementNode {
string tag_name = 1;
map<string, string> attributes = 2;
repeated Node children = 3;
bool is_self_closing = 4;
}

View File

@ -1,6 +1,6 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.8
// protoc-gen-go v1.36.9
// protoc (unknown)
// source: api/v1/activity_service.proto

View File

@ -1,6 +1,6 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.8
// protoc-gen-go v1.36.9
// protoc (unknown)
// source: api/v1/attachment_service.proto

View File

@ -1,6 +1,6 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.8
// protoc-gen-go v1.36.9
// protoc (unknown)
// source: api/v1/auth_service.proto

View File

@ -1,6 +1,6 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.8
// protoc-gen-go v1.36.9
// protoc (unknown)
// source: api/v1/common.proto

View File

@ -1,6 +1,6 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.8
// protoc-gen-go v1.36.9
// protoc (unknown)
// source: api/v1/idp_service.proto

View File

@ -1,6 +1,6 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.8
// protoc-gen-go v1.36.9
// protoc (unknown)
// source: api/v1/inbox_service.proto

View File

@ -1,6 +1,6 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.8
// protoc-gen-go v1.36.9
// protoc (unknown)
// source: api/v1/markdown_service.proto
@ -2634,6 +2634,8 @@ type HTMLElementNode struct {
state protoimpl.MessageState `protogen:"open.v1"`
TagName string `protobuf:"bytes,1,opt,name=tag_name,json=tagName,proto3" json:"tag_name,omitempty"`
Attributes map[string]string `protobuf:"bytes,2,rep,name=attributes,proto3" json:"attributes,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
Children []*Node `protobuf:"bytes,3,rep,name=children,proto3" json:"children,omitempty"`
IsSelfClosing bool `protobuf:"varint,4,opt,name=is_self_closing,json=isSelfClosing,proto3" json:"is_self_closing,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -2682,6 +2684,20 @@ func (x *HTMLElementNode) GetAttributes() map[string]string {
return nil
}
func (x *HTMLElementNode) GetChildren() []*Node {
if x != nil {
return x.Children
}
return nil
}
func (x *HTMLElementNode) GetIsSelfClosing() bool {
if x != nil {
return x.IsSelfClosing
}
return false
}
type TableNode_Row struct {
state protoimpl.MessageState `protogen:"open.v1"`
Cells []*Node `protobuf:"bytes,1,rep,name=cells,proto3" json:"cells,omitempty"`
@ -2874,12 +2890,14 @@ const file_api_v1_markdown_service_proto_rawDesc = "" +
"\rresource_name\x18\x01 \x01(\tR\fresourceName\x12\x16\n" +
"\x06params\x18\x02 \x01(\tR\x06params\"'\n" +
"\vSpoilerNode\x12\x18\n" +
"\acontent\x18\x01 \x01(\tR\acontent\"\xba\x01\n" +
"\acontent\x18\x01 \x01(\tR\acontent\"\x92\x02\n" +
"\x0fHTMLElementNode\x12\x19\n" +
"\btag_name\x18\x01 \x01(\tR\atagName\x12M\n" +
"\n" +
"attributes\x18\x02 \x03(\v2-.memos.api.v1.HTMLElementNode.AttributesEntryR\n" +
"attributes\x1a=\n" +
"attributes\x12.\n" +
"\bchildren\x18\x03 \x03(\v2\x12.memos.api.v1.NodeR\bchildren\x12&\n" +
"\x0fis_self_closing\x18\x04 \x01(\bR\risSelfClosing\x1a=\n" +
"\x0fAttributesEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01*\x83\x04\n" +
@ -3039,20 +3057,21 @@ var file_api_v1_markdown_service_proto_depIdxs = []int32{
10, // 46: memos.api.v1.ItalicNode.children:type_name -> memos.api.v1.Node
10, // 47: memos.api.v1.LinkNode.content:type_name -> memos.api.v1.Node
43, // 48: memos.api.v1.HTMLElementNode.attributes:type_name -> memos.api.v1.HTMLElementNode.AttributesEntry
10, // 49: memos.api.v1.TableNode.Row.cells:type_name -> memos.api.v1.Node
2, // 50: memos.api.v1.MarkdownService.ParseMarkdown:input_type -> memos.api.v1.ParseMarkdownRequest
4, // 51: memos.api.v1.MarkdownService.RestoreMarkdownNodes:input_type -> memos.api.v1.RestoreMarkdownNodesRequest
6, // 52: memos.api.v1.MarkdownService.StringifyMarkdownNodes:input_type -> memos.api.v1.StringifyMarkdownNodesRequest
8, // 53: memos.api.v1.MarkdownService.GetLinkMetadata:input_type -> memos.api.v1.GetLinkMetadataRequest
3, // 54: memos.api.v1.MarkdownService.ParseMarkdown:output_type -> memos.api.v1.ParseMarkdownResponse
5, // 55: memos.api.v1.MarkdownService.RestoreMarkdownNodes:output_type -> memos.api.v1.RestoreMarkdownNodesResponse
7, // 56: memos.api.v1.MarkdownService.StringifyMarkdownNodes:output_type -> memos.api.v1.StringifyMarkdownNodesResponse
9, // 57: memos.api.v1.MarkdownService.GetLinkMetadata:output_type -> memos.api.v1.LinkMetadata
54, // [54:58] is the sub-list for method output_type
50, // [50:54] is the sub-list for method input_type
50, // [50:50] is the sub-list for extension type_name
50, // [50:50] is the sub-list for extension extendee
0, // [0:50] is the sub-list for field type_name
10, // 49: memos.api.v1.HTMLElementNode.children:type_name -> memos.api.v1.Node
10, // 50: memos.api.v1.TableNode.Row.cells:type_name -> memos.api.v1.Node
2, // 51: memos.api.v1.MarkdownService.ParseMarkdown:input_type -> memos.api.v1.ParseMarkdownRequest
4, // 52: memos.api.v1.MarkdownService.RestoreMarkdownNodes:input_type -> memos.api.v1.RestoreMarkdownNodesRequest
6, // 53: memos.api.v1.MarkdownService.StringifyMarkdownNodes:input_type -> memos.api.v1.StringifyMarkdownNodesRequest
8, // 54: memos.api.v1.MarkdownService.GetLinkMetadata:input_type -> memos.api.v1.GetLinkMetadataRequest
3, // 55: memos.api.v1.MarkdownService.ParseMarkdown:output_type -> memos.api.v1.ParseMarkdownResponse
5, // 56: memos.api.v1.MarkdownService.RestoreMarkdownNodes:output_type -> memos.api.v1.RestoreMarkdownNodesResponse
7, // 57: memos.api.v1.MarkdownService.StringifyMarkdownNodes:output_type -> memos.api.v1.StringifyMarkdownNodesResponse
9, // 58: memos.api.v1.MarkdownService.GetLinkMetadata:output_type -> memos.api.v1.LinkMetadata
55, // [55:59] is the sub-list for method output_type
51, // [51:55] is the sub-list for method input_type
51, // [51:51] is the sub-list for extension type_name
51, // [51:51] is the sub-list for extension extendee
0, // [0:51] is the sub-list for field type_name
}
func init() { file_api_v1_markdown_service_proto_init() }

View File

@ -1,6 +1,6 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.8
// protoc-gen-go v1.36.9
// protoc (unknown)
// source: api/v1/memo_service.proto

View File

@ -1,6 +1,6 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.8
// protoc-gen-go v1.36.9
// protoc (unknown)
// source: api/v1/shortcut_service.proto

View File

@ -1,6 +1,6 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.8
// protoc-gen-go v1.36.9
// protoc (unknown)
// source: api/v1/user_service.proto

View File

@ -1,6 +1,6 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.8
// protoc-gen-go v1.36.9
// protoc (unknown)
// source: api/v1/workspace_service.proto

View File

@ -15,13 +15,19 @@ paths:
parameters:
- name: pageSize
in: query
description: "The maximum number of activities to return.\r\n The service may return fewer than this value.\r\n If unspecified, at most 100 activities will be returned.\r\n The maximum value is 1000; values above 1000 will be coerced to 1000."
description: |-
The maximum number of activities to return.
The service may return fewer than this value.
If unspecified, at most 100 activities will be returned.
The maximum value is 1000; values above 1000 will be coerced to 1000.
schema:
type: integer
format: int32
- name: pageToken
in: query
description: "A page token, received from a previous `ListActivities` call.\r\n Provide this to retrieve the subsequent page."
description: |-
A page token, received from a previous `ListActivities` call.
Provide this to retrieve the subsequent page.
schema:
type: string
responses:
@ -72,23 +78,35 @@ paths:
parameters:
- name: pageSize
in: query
description: "Optional. The maximum number of attachments to return.\r\n The service may return fewer than this value.\r\n If unspecified, at most 50 attachments will be returned.\r\n The maximum value is 1000; values above 1000 will be coerced to 1000."
description: |-
Optional. The maximum number of attachments to return.
The service may return fewer than this value.
If unspecified, at most 50 attachments will be returned.
The maximum value is 1000; values above 1000 will be coerced to 1000.
schema:
type: integer
format: int32
- name: pageToken
in: query
description: "Optional. A page token, received from a previous `ListAttachments` call.\r\n Provide this to retrieve the subsequent page."
description: |-
Optional. A page token, received from a previous `ListAttachments` call.
Provide this to retrieve the subsequent page.
schema:
type: string
- name: filter
in: query
description: "Optional. Filter to apply to the list results.\r\n Example: \"type=image/png\" or \"filename:*.jpg\"\r\n Supported operators: =, !=, <, <=, >, >=, :\r\n Supported fields: filename, type, size, create_time, memo"
description: |-
Optional. Filter to apply to the list results.
Example: "type=image/png" or "filename:*.jpg"
Supported operators: =, !=, <, <=, >, >=, :
Supported fields: filename, type, size, create_time, memo
schema:
type: string
- name: orderBy
in: query
description: "Optional. The order to sort results by.\r\n Example: \"create_time desc\" or \"filename asc\""
description: |-
Optional. The order to sort results by.
Example: "create_time desc" or "filename asc"
schema:
type: string
responses:
@ -112,7 +130,9 @@ paths:
parameters:
- name: attachmentId
in: query
description: "Optional. The attachment ID to use for this attachment.\r\n If empty, a unique ID will be generated."
description: |-
Optional. The attachment ID to use for this attachment.
If empty, a unique ID will be generated.
schema:
type: string
requestBody:
@ -223,7 +243,9 @@ paths:
post:
tags:
- AuthService
description: "CreateSession authenticates a user and creates a new session.\r\n Returns the authenticated user information upon successful authentication."
description: |-
CreateSession authenticates a user and creates a new session.
Returns the authenticated user information upon successful authentication.
operationId: AuthService_CreateSession
requestBody:
content:
@ -248,7 +270,9 @@ paths:
get:
tags:
- AuthService
description: "GetCurrentSession returns the current active session information.\r\n This method is idempotent and safe, suitable for checking current session state."
description: |-
GetCurrentSession returns the current active session information.
This method is idempotent and safe, suitable for checking current session state.
operationId: AuthService_GetCurrentSession
responses:
"200":
@ -266,7 +290,9 @@ paths:
delete:
tags:
- AuthService
description: "DeleteSession terminates the current user session.\r\n This is an idempotent operation that invalidates the user's authentication."
description: |-
DeleteSession terminates the current user session.
This is an idempotent operation that invalidates the user's authentication.
operationId: AuthService_DeleteSession
responses:
"200":
@ -305,7 +331,9 @@ paths:
parameters:
- name: identityProviderId
in: query
description: "Optional. The ID to use for the identity provider, which will become the final component of the resource name.\r\n If not provided, the system will generate one."
description: |-
Optional. The ID to use for the identity provider, which will become the final component of the resource name.
If not provided, the system will generate one.
schema:
type: string
requestBody:
@ -389,7 +417,9 @@ paths:
type: string
- name: updateMask
in: query
description: "Required. The update mask applies to the resource. Only the top level fields of\r\n IdentityProvider are supported."
description: |-
Required. The update mask applies to the resource. Only the top level fields of
IdentityProvider are supported.
schema:
type: string
format: field-mask
@ -481,7 +511,9 @@ paths:
get:
tags:
- MarkdownService
description: "GetLinkMetadata returns metadata for a given link.\r\n This is useful for generating link previews."
description: |-
GetLinkMetadata returns metadata for a given link.
This is useful for generating link previews.
operationId: MarkdownService_GetLinkMetadata
parameters:
- name: link
@ -506,7 +538,9 @@ paths:
post:
tags:
- MarkdownService
description: "ParseMarkdown parses the given markdown content and returns a list of nodes.\r\n This is a utility method that transforms markdown text into structured nodes."
description: |-
ParseMarkdown parses the given markdown content and returns a list of nodes.
This is a utility method that transforms markdown text into structured nodes.
operationId: MarkdownService_ParseMarkdown
requestBody:
content:
@ -531,7 +565,9 @@ paths:
post:
tags:
- MarkdownService
description: "RestoreMarkdownNodes restores the given nodes to markdown content.\r\n This is the inverse operation of ParseMarkdown."
description: |-
RestoreMarkdownNodes restores the given nodes to markdown content.
This is the inverse operation of ParseMarkdown.
operationId: MarkdownService_RestoreMarkdownNodes
requestBody:
content:
@ -556,7 +592,9 @@ paths:
post:
tags:
- MarkdownService
description: "StringifyMarkdownNodes stringify the given nodes to plain text content.\r\n This removes all markdown formatting and returns plain text."
description: |-
StringifyMarkdownNodes stringify the given nodes to plain text content.
This removes all markdown formatting and returns plain text.
operationId: MarkdownService_StringifyMarkdownNodes
requestBody:
content:
@ -586,18 +624,26 @@ paths:
parameters:
- name: pageSize
in: query
description: "Optional. The maximum number of memos to return.\r\n The service may return fewer than this value.\r\n If unspecified, at most 50 memos will be returned.\r\n The maximum value is 1000; values above 1000 will be coerced to 1000."
description: |-
Optional. The maximum number of memos to return.
The service may return fewer than this value.
If unspecified, at most 50 memos will be returned.
The maximum value is 1000; values above 1000 will be coerced to 1000.
schema:
type: integer
format: int32
- name: pageToken
in: query
description: "Optional. A page token, received from a previous `ListMemos` call.\r\n Provide this to retrieve the subsequent page."
description: |-
Optional. A page token, received from a previous `ListMemos` call.
Provide this to retrieve the subsequent page.
schema:
type: string
- name: state
in: query
description: "Optional. The state of the memos to list.\r\n Default to `NORMAL`. Set to `ARCHIVED` to list archived memos."
description: |-
Optional. The state of the memos to list.
Default to `NORMAL`. Set to `ARCHIVED` to list archived memos.
schema:
enum:
- STATE_UNSPECIFIED
@ -607,12 +653,18 @@ paths:
format: enum
- name: orderBy
in: query
description: "Optional. The order to sort results by.\r\n Default to \"display_time desc\".\r\n Example: \"display_time desc\" or \"create_time asc\""
description: |-
Optional. The order to sort results by.
Default to "display_time desc".
Example: "display_time desc" or "create_time asc"
schema:
type: string
- name: filter
in: query
description: "Optional. Filter to apply to the list results.\r\n Filter is a CEL expression to filter memos.\r\n Refer to `Shortcut.filter`."
description: |-
Optional. Filter to apply to the list results.
Filter is a CEL expression to filter memos.
Refer to `Shortcut.filter`.
schema:
type: string
- name: showDeleted
@ -641,7 +693,9 @@ paths:
parameters:
- name: memoId
in: query
description: "Optional. The memo ID to use for this memo.\r\n If empty, a unique ID will be generated."
description: |-
Optional. The memo ID to use for this memo.
If empty, a unique ID will be generated.
schema:
type: string
- name: validateOnly
@ -688,7 +742,9 @@ paths:
type: string
- name: readMask
in: query
description: "Optional. The fields to return in the response.\r\n If not specified, all fields are returned."
description: |-
Optional. The fields to return in the response.
If not specified, all fields are returned.
schema:
type: string
format: field-mask
@ -1140,18 +1196,28 @@ paths:
parameters:
- name: pageSize
in: query
description: "Optional. The maximum number of users to return.\r\n The service may return fewer than this value.\r\n If unspecified, at most 50 users will be returned.\r\n The maximum value is 1000; values above 1000 will be coerced to 1000."
description: |-
Optional. The maximum number of users to return.
The service may return fewer than this value.
If unspecified, at most 50 users will be returned.
The maximum value is 1000; values above 1000 will be coerced to 1000.
schema:
type: integer
format: int32
- name: pageToken
in: query
description: "Optional. A page token, received from a previous `ListUsers` call.\r\n Provide this to retrieve the subsequent page."
description: |-
Optional. A page token, received from a previous `ListUsers` call.
Provide this to retrieve the subsequent page.
schema:
type: string
- name: filter
in: query
description: "Optional. Filter to apply to the list results.\r\n Example: \"state=ACTIVE\" or \"role=USER\" or \"email:@example.com\"\r\n Supported operators: =, !=, <, <=, >, >=, :\r\n Supported fields: username, email, role, state, create_time, update_time"
description: |-
Optional. Filter to apply to the list results.
Example: "state=ACTIVE" or "role=USER" or "email:@example.com"
Supported operators: =, !=, <, <=, >, >=, :
Supported fields: username, email, role, state, create_time, update_time
schema:
type: string
- name: showDeleted
@ -1180,7 +1246,10 @@ paths:
parameters:
- name: userId
in: query
description: "Optional. The user ID to use for this user.\r\n If empty, a unique ID will be generated.\r\n Must match the pattern [a-z0-9-]+"
description: |-
Optional. The user ID to use for this user.
If empty, a unique ID will be generated.
Must match the pattern [a-z0-9-]+
schema:
type: string
- name: validateOnly
@ -1190,7 +1259,9 @@ paths:
type: boolean
- name: requestId
in: query
description: "Optional. An idempotency token that can be used to ensure that multiple\r\n requests to create a user have the same result."
description: |-
Optional. An idempotency token that can be used to ensure that multiple
requests to create a user have the same result.
schema:
type: string
requestBody:
@ -1227,7 +1298,9 @@ paths:
type: string
- name: readMask
in: query
description: "Optional. The fields to return in the response.\r\n If not specified, all fields are returned."
description: |-
Optional. The fields to return in the response.
If not specified, all fields are returned.
schema:
type: string
format: field-mask
@ -1454,23 +1527,35 @@ paths:
type: string
- name: pageSize
in: query
description: "Optional. The maximum number of inboxes to return.\r\n The service may return fewer than this value.\r\n If unspecified, at most 50 inboxes will be returned.\r\n The maximum value is 1000; values above 1000 will be coerced to 1000."
description: |-
Optional. The maximum number of inboxes to return.
The service may return fewer than this value.
If unspecified, at most 50 inboxes will be returned.
The maximum value is 1000; values above 1000 will be coerced to 1000.
schema:
type: integer
format: int32
- name: pageToken
in: query
description: "Optional. A page token, received from a previous `ListInboxes` call.\r\n Provide this to retrieve the subsequent page."
description: |-
Optional. A page token, received from a previous `ListInboxes` call.
Provide this to retrieve the subsequent page.
schema:
type: string
- name: filter
in: query
description: "Optional. Filter to apply to the list results.\r\n Example: \"status=UNREAD\" or \"type=MEMO_COMMENT\"\r\n Supported operators: =, !=\r\n Supported fields: status, type, sender, create_time"
description: |-
Optional. Filter to apply to the list results.
Example: "status=UNREAD" or "type=MEMO_COMMENT"
Supported operators: =, !=
Supported fields: status, type, sender, create_time
schema:
type: string
- name: orderBy
in: query
description: "Optional. The order to sort results by.\r\n Example: \"create_time desc\" or \"status asc\""
description: |-
Optional. The order to sort results by.
Example: "create_time desc" or "status asc"
schema:
type: string
responses:
@ -1556,13 +1641,19 @@ paths:
type: string
- name: pageSize
in: query
description: "Optional. The maximum number of settings to return.\r\n The service may return fewer than this value.\r\n If unspecified, at most 50 settings will be returned.\r\n The maximum value is 1000; values above 1000 will be coerced to 1000."
description: |-
Optional. The maximum number of settings to return.
The service may return fewer than this value.
If unspecified, at most 50 settings will be returned.
The maximum value is 1000; values above 1000 will be coerced to 1000.
schema:
type: integer
format: int32
- name: pageToken
in: query
description: "Optional. A page token, received from a previous `ListUserSettings` call.\r\n Provide this to retrieve the subsequent page."
description: |-
Optional. A page token, received from a previous `ListUserSettings` call.
Provide this to retrieve the subsequent page.
schema:
type: string
responses:
@ -2117,11 +2208,15 @@ components:
name:
readOnly: true
type: string
description: "The name of the activity.\r\n Format: activities/{id}"
description: |-
The name of the activity.
Format: activities/{id}
creator:
readOnly: true
type: string
description: "The name of the creator.\r\n Format: users/{user}"
description: |-
The name of the creator.
Format: users/{user}
type:
readOnly: true
enum:
@ -2156,10 +2251,14 @@ components:
properties:
memo:
type: string
description: "The memo name of comment.\r\n Format: memos/{memo}"
description: |-
The memo name of comment.
Format: memos/{memo}
relatedMemo:
type: string
description: "The name of related memo.\r\n Format: memos/{memo}"
description: |-
The name of related memo.
Format: memos/{memo}
description: ActivityMemoCommentPayload represents the payload of a memo comment activity.
ActivityPayload:
type: object
@ -2176,7 +2275,9 @@ components:
properties:
name:
type: string
description: "The name of the attachment.\r\n Format: attachments/{attachment}"
description: |-
The name of the attachment.
Format: attachments/{attachment}
createTime:
readOnly: true
type: string
@ -2202,7 +2303,9 @@ components:
description: Output only. The size of the attachment in bytes.
memo:
type: string
description: "Optional. The related memo. Refer to `Memo.name`.\r\n Format: memos/{memo}"
description: |-
Optional. The related memo. Refer to `Memo.name`.
Format: memos/{memo}
AutoLinkNode:
type: object
properties:
@ -2264,10 +2367,14 @@ components:
properties:
username:
type: string
description: "The username to sign in with.\r\n Required field for password-based authentication."
description: |-
The username to sign in with.
Required field for password-based authentication.
password:
type: string
description: "The password to sign in with.\r\n Required field for password-based authentication."
description: |-
The password to sign in with.
Required field for password-based authentication.
description: Nested message for password-based authentication credentials.
CreateSessionRequest_SSOCredentials:
required:
@ -2278,14 +2385,20 @@ components:
properties:
idpId:
type: integer
description: "The ID of the SSO provider.\r\n Required field to identify the SSO provider."
description: |-
The ID of the SSO provider.
Required field to identify the SSO provider.
format: int32
code:
type: string
description: "The authorization code from the SSO provider.\r\n Required field for completing the SSO flow."
description: |-
The authorization code from the SSO provider.
Required field for completing the SSO flow.
redirectUri:
type: string
description: "The redirect URI used in the SSO flow.\r\n Required field for security validation."
description: |-
The redirect URI used in the SSO flow.
Required field for security validation.
description: Nested message for SSO authentication credentials.
CreateSessionResponse:
type: object
@ -2296,7 +2409,9 @@ components:
description: The authenticated user information.
lastAccessedAt:
type: string
description: "Last time the session was accessed.\r\n Used for sliding expiration calculation (last_accessed_time + 2 weeks)."
description: |-
Last time the session was accessed.
Used for sliding expiration calculation (last_accessed_time + 2 weeks).
format: date-time
DeleteMemoTagRequest:
required:
@ -2306,7 +2421,9 @@ components:
properties:
parent:
type: string
description: "Required. The parent, who owns the tags.\r\n Format: memos/{memo}. Use \"memos/-\" to delete all tags."
description: |-
Required. The parent, who owns the tags.
Format: memos/{memo}. Use "memos/-" to delete all tags.
tag:
type: string
description: Required. The tag name to delete.
@ -2357,7 +2474,9 @@ components:
$ref: '#/components/schemas/User'
lastAccessedAt:
type: string
description: "Last time the session was accessed.\r\n Used for sliding expiration calculation (last_accessed_time + 2 weeks)."
description: |-
Last time the session was accessed.
Used for sliding expiration calculation (last_accessed_time + 2 weeks).
format: date-time
GoogleProtobufAny:
type: object
@ -2376,6 +2495,12 @@ components:
type: object
additionalProperties:
type: string
children:
type: array
items:
$ref: '#/components/schemas/Node'
isSelfClosing:
type: boolean
HeadingNode:
type: object
properties:
@ -2405,7 +2530,9 @@ components:
properties:
name:
type: string
description: "The resource name of the identity provider.\r\n Format: identityProviders/{idp}"
description: |-
The resource name of the identity provider.
Format: identityProviders/{idp}
type:
enum:
- TYPE_UNSPECIFIED
@ -2440,15 +2567,21 @@ components:
properties:
name:
type: string
description: "The resource name of the inbox.\r\n Format: inboxes/{inbox}"
description: |-
The resource name of the inbox.
Format: inboxes/{inbox}
sender:
readOnly: true
type: string
description: "The sender of the inbox notification.\r\n Format: users/{user}"
description: |-
The sender of the inbox notification.
Format: users/{user}
receiver:
readOnly: true
type: string
description: "The receiver of the inbox notification.\r\n Format: users/{user}"
description: |-
The receiver of the inbox notification.
Format: users/{user}
status:
enum:
- STATUS_UNSPECIFIED
@ -2518,7 +2651,10 @@ components:
description: The activities.
nextPageToken:
type: string
description: "A token to retrieve the next page of results.\r\n Pass this value in the page_token field in the subsequent call to `ListActivities`\r\n method to retrieve the next page of results."
description: |-
A token to retrieve the next page of results.
Pass this value in the page_token field in the subsequent call to `ListActivities`
method to retrieve the next page of results.
ListAllUserStatsResponse:
type: object
properties:
@ -2537,7 +2673,9 @@ components:
description: The list of attachments.
nextPageToken:
type: string
description: "A token that can be sent as `page_token` to retrieve the next page.\r\n If this field is omitted, there are no subsequent pages."
description: |-
A token that can be sent as `page_token` to retrieve the next page.
If this field is omitted, there are no subsequent pages.
totalSize:
type: integer
description: The total count of attachments (may be approximate).
@ -2560,7 +2698,9 @@ components:
description: The list of inboxes.
nextPageToken:
type: string
description: "A token that can be sent as `page_token` to retrieve the next page.\r\n If this field is omitted, there are no subsequent pages."
description: |-
A token that can be sent as `page_token` to retrieve the next page.
If this field is omitted, there are no subsequent pages.
totalSize:
type: integer
description: The total count of inboxes (may be approximate).
@ -2635,7 +2775,9 @@ components:
description: The list of memos.
nextPageToken:
type: string
description: "A token that can be sent as `page_token` to retrieve the next page.\r\n If this field is omitted, there are no subsequent pages."
description: |-
A token that can be sent as `page_token` to retrieve the next page.
If this field is omitted, there are no subsequent pages.
totalSize:
type: integer
description: The total count of memos (may be approximate).
@ -2699,7 +2841,9 @@ components:
description: The list of user settings.
nextPageToken:
type: string
description: "A token that can be sent as `page_token` to retrieve the next page.\r\n If this field is omitted, there are no subsequent pages."
description: |-
A token that can be sent as `page_token` to retrieve the next page.
If this field is omitted, there are no subsequent pages.
totalSize:
type: integer
description: The total count of settings (may be approximate).
@ -2723,7 +2867,9 @@ components:
description: The list of users.
nextPageToken:
type: string
description: "A token that can be sent as `page_token` to retrieve the next page.\r\n If this field is omitted, there are no subsequent pages."
description: |-
A token that can be sent as `page_token` to retrieve the next page.
If this field is omitted, there are no subsequent pages.
totalSize:
type: integer
description: The total count of users (may be approximate).
@ -2761,7 +2907,9 @@ components:
properties:
name:
type: string
description: "The resource name of the memo.\r\n Format: memos/{memo}, memo is the user defined id or uuid."
description: |-
The resource name of the memo.
Format: memos/{memo}, memo is the user defined id or uuid.
state:
enum:
- STATE_UNSPECIFIED
@ -2773,7 +2921,9 @@ components:
creator:
readOnly: true
type: string
description: "The name of the creator.\r\n Format: users/{user}"
description: |-
The name of the creator.
Format: users/{user}
createTime:
readOnly: true
type: string
@ -2839,7 +2989,9 @@ components:
parent:
readOnly: true
type: string
description: "Output only. The name of the parent memo.\r\n Format: memos/{memo}"
description: |-
Output only. The name of the parent memo.
Format: memos/{memo}
snippet:
readOnly: true
type: string
@ -2877,7 +3029,9 @@ components:
properties:
name:
type: string
description: "The resource name of the memo.\r\n Format: memos/{memo}"
description: |-
The resource name of the memo.
Format: memos/{memo}
snippet:
readOnly: true
type: string
@ -3063,14 +3217,21 @@ components:
name:
readOnly: true
type: string
description: "The resource name of the reaction.\r\n Format: reactions/{reaction}"
description: |-
The resource name of the reaction.
Format: reactions/{reaction}
creator:
readOnly: true
type: string
description: "The resource name of the creator.\r\n Format: users/{user}"
description: |-
The resource name of the creator.
Format: users/{user}
contentId:
type: string
description: "The resource name of the content.\r\n For memo reactions, this should be the memo's resource name.\r\n Format: memos/{memo}"
description: |-
The resource name of the content.
For memo reactions, this should be the memo's resource name.
Format: memos/{memo}
reactionType:
type: string
description: "Required. The type of reaction (e.g., \"\U0001F44D\", \"❤️\", \"\U0001F604\")."
@ -3097,7 +3258,9 @@ components:
properties:
parent:
type: string
description: "Required. The parent, who owns the tags.\r\n Format: memos/{memo}. Use \"memos/-\" to rename all tags."
description: |-
Required. The parent, who owns the tags.
Format: memos/{memo}. Use "memos/-" to rename all tags.
oldTag:
type: string
description: Required. The old tag name to rename.
@ -3128,7 +3291,9 @@ components:
properties:
name:
type: string
description: "Required. The resource name of the memo.\r\n Format: memos/{memo}"
description: |-
Required. The resource name of the memo.
Format: memos/{memo}
attachments:
type: array
items:
@ -3142,7 +3307,9 @@ components:
properties:
name:
type: string
description: "Required. The resource name of the memo.\r\n Format: memos/{memo}"
description: |-
Required. The resource name of the memo.
Format: memos/{memo}
relations:
type: array
items:
@ -3155,7 +3322,9 @@ components:
properties:
name:
type: string
description: "The resource name of the shortcut.\r\n Format: users/{user}/shortcuts/{shortcut}"
description: |-
The resource name of the shortcut.
Format: users/{user}/shortcuts/{shortcut}
title:
type: string
description: The title of the shortcut.
@ -3198,7 +3367,9 @@ components:
type: string
usePathStyle:
type: boolean
description: "S3 configuration for cloud storage backend.\r\n Reference: https://developers.cloudflare.com/r2/examples/aws/aws-sdk-go/"
description: |-
S3 configuration for cloud storage backend.
Reference: https://developers.cloudflare.com/r2/examples/aws/aws-sdk-go/
StrikethroughNode:
type: object
properties:
@ -3296,7 +3467,9 @@ components:
properties:
name:
type: string
description: "Required. The resource name of the memo.\r\n Format: memos/{memo}"
description: |-
Required. The resource name of the memo.
Format: memos/{memo}
reaction:
allOf:
- $ref: '#/components/schemas/Reaction'
@ -3310,7 +3483,9 @@ components:
properties:
name:
type: string
description: "The resource name of the user.\r\n Format: users/{user}"
description: |-
The resource name of the user.
Format: users/{user}
role:
enum:
- ROLE_UNSPECIFIED
@ -3362,7 +3537,9 @@ components:
properties:
name:
type: string
description: "The resource name of the access token.\r\n Format: users/{user}/accessTokens/{access_token}"
description: |-
The resource name of the access token.
Format: users/{user}/accessTokens/{access_token}
accessToken:
readOnly: true
type: string
@ -3385,7 +3562,9 @@ components:
properties:
name:
type: string
description: "The resource name of the session.\r\n Format: users/{user}/sessions/{session}"
description: |-
The resource name of the session.
Format: users/{user}/sessions/{session}
sessionId:
readOnly: true
type: string
@ -3398,7 +3577,9 @@ components:
lastAccessedTime:
readOnly: true
type: string
description: "The timestamp when the session was last accessed.\r\n Used for sliding expiration calculation (last_accessed_time + 2 weeks)."
description: |-
The timestamp when the session was last accessed.
Used for sliding expiration calculation (last_accessed_time + 2 weeks).
format: date-time
clientInfo:
readOnly: true
@ -3428,7 +3609,10 @@ components:
properties:
name:
type: string
description: "The name of the user setting.\r\n Format: users/{user}/settings/{setting}, {setting} is the key for the setting.\r\n For example, \"users/123/settings/GENERAL\" for general settings."
description: |-
The name of the user setting.
Format: users/{user}/settings/{setting}, {setting} is the key for the setting.
For example, "users/123/settings/GENERAL" for general settings.
generalSetting:
$ref: '#/components/schemas/UserSetting_GeneralSetting'
sessionsSetting:
@ -3458,7 +3642,10 @@ components:
description: The default visibility of the memo.
theme:
type: string
description: "The preferred theme of the user.\r\n This references a CSS file in the web/public/themes/ directory.\r\n If not set, the default theme will be used."
description: |-
The preferred theme of the user.
This references a CSS file in the web/public/themes/ directory.
If not set, the default theme will be used.
description: General user settings configuration.
UserSetting_SessionsSetting:
type: object
@ -3483,7 +3670,9 @@ components:
properties:
name:
type: string
description: "The resource name of the user whose stats these are.\r\n Format: users/{user}"
description: |-
The resource name of the user whose stats these are.
Format: users/{user}
memoDisplayTimestamps:
type: array
items:
@ -3531,7 +3720,9 @@ components:
properties:
name:
type: string
description: "The name of the webhook.\r\n Format: users/{user}/webhooks/{webhook}"
description: |-
The name of the webhook.
Format: users/{user}/webhooks/{webhook}
url:
type: string
description: The URL to send the webhook to.
@ -3554,7 +3745,9 @@ components:
properties:
owner:
type: string
description: "The name of instance owner.\r\n Format: users/{user}"
description: |-
The name of instance owner.
Format: users/{user}
version:
type: string
description: Version is the current version of instance.
@ -3570,7 +3763,9 @@ components:
properties:
name:
type: string
description: "The name of the workspace setting.\r\n Format: workspace/settings/{setting}"
description: |-
The name of the workspace setting.
Format: workspace/settings/{setting}
generalSetting:
$ref: '#/components/schemas/WorkspaceSetting_GeneralSetting'
storageSetting:
@ -3583,7 +3778,9 @@ components:
properties:
theme:
type: string
description: "theme is the name of the selected theme.\r\n This references a CSS file in the web/public/themes/ directory."
description: |-
theme is the name of the selected theme.
This references a CSS file in the web/public/themes/ directory.
disallowUserRegistration:
type: boolean
description: disallow_user_registration disallows user registration.
@ -3602,7 +3799,10 @@ components:
description: custom_profile is the custom profile.
weekStartDayOffset:
type: integer
description: "week_start_day_offset is the week start day offset from Sunday.\r\n 0: Sunday, 1: Monday, 2: Tuesday, 3: Wednesday, 4: Thursday, 5: Friday, 6: Saturday\r\n Default is Sunday."
description: |-
week_start_day_offset is the week start day offset from Sunday.
0: Sunday, 1: Monday, 2: Tuesday, 3: Wednesday, 4: Thursday, 5: Friday, 6: Saturday
Default is Sunday.
format: int32
disallowChangeUsername:
type: boolean
@ -3661,7 +3861,9 @@ components:
format: enum
filepathTemplate:
type: string
description: "The template of file path.\r\n e.g. assets/{timestamp}_{filename}"
description: |-
The template of file path.
e.g. assets/{timestamp}_{filename}
uploadSizeLimitMb:
type: string
description: The max upload size in megabytes.

View File

@ -1,6 +1,6 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.8
// protoc-gen-go v1.36.9
// protoc (unknown)
// source: store/activity.proto

View File

@ -1,6 +1,6 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.8
// protoc-gen-go v1.36.9
// protoc (unknown)
// source: store/attachment.proto

View File

@ -1,6 +1,6 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.8
// protoc-gen-go v1.36.9
// protoc (unknown)
// source: store/idp.proto

View File

@ -1,6 +1,6 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.8
// protoc-gen-go v1.36.9
// protoc (unknown)
// source: store/inbox.proto

View File

@ -1,6 +1,6 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.8
// protoc-gen-go v1.36.9
// protoc (unknown)
// source: store/memo.proto

View File

@ -1,6 +1,6 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.8
// protoc-gen-go v1.36.9
// protoc (unknown)
// source: store/user_setting.proto

View File

@ -1,6 +1,6 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.8
// protoc-gen-go v1.36.9
// protoc (unknown)
// source: store/workspace_setting.proto

View File

@ -53,6 +53,9 @@ func (s *APIV1Service) CreateAttachment(ctx context.Context, request *v1pb.Creat
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
if user == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
// Validate required fields
if request.Attachment == nil {
@ -124,6 +127,9 @@ func (s *APIV1Service) ListAttachments(ctx context.Context, request *v1pb.ListAt
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
if user == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
// Set default page size
pageSize := int(request.PageSize)
@ -364,6 +370,9 @@ func (s *APIV1Service) DeleteAttachment(ctx context.Context, request *v1pb.Delet
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
if user == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
attachment, err := s.Store.GetAttachment(ctx, &store.FindAttachment{
UID: &attachmentUID,
CreatorID: &user.ID,

View File

@ -128,7 +128,12 @@ func convertFromASTNode(rawNode ast.Node) *v1pb.Node {
case *ast.Spoiler:
node.Node = &v1pb.Node_SpoilerNode{SpoilerNode: &v1pb.SpoilerNode{Content: n.Content}}
case *ast.HTMLElement:
node.Node = &v1pb.Node_HtmlElementNode{HtmlElementNode: &v1pb.HTMLElementNode{TagName: n.TagName, Attributes: n.Attributes}}
node.Node = &v1pb.Node_HtmlElementNode{HtmlElementNode: &v1pb.HTMLElementNode{
TagName: n.TagName,
Attributes: n.Attributes,
Children: convertFromASTNodes(n.Children),
IsSelfClosing: n.IsSelfClosing,
}}
default:
node.Node = &v1pb.Node_TextNode{TextNode: &v1pb.TextNode{}}
}
@ -168,7 +173,7 @@ func convertListKindFromASTNode(node ast.ListKind) v1pb.ListNode_Kind {
return v1pb.ListNode_ORDERED
case ast.UnorderedList:
return v1pb.ListNode_UNORDERED
case ast.DescrpitionList:
case ast.DescriptionList:
return v1pb.ListNode_DESCRIPTION
default:
return v1pb.ListNode_KIND_UNSPECIFIED
@ -249,7 +254,16 @@ func convertToASTNode(node *v1pb.Node) ast.Node {
case *v1pb.Node_SpoilerNode:
return &ast.Spoiler{Content: n.SpoilerNode.Content}
case *v1pb.Node_HtmlElementNode:
return &ast.HTMLElement{TagName: n.HtmlElementNode.TagName, Attributes: n.HtmlElementNode.Attributes}
var children []ast.Node
if len(n.HtmlElementNode.Children) > 0 {
children = convertToASTNodes(n.HtmlElementNode.Children)
}
return &ast.HTMLElement{
TagName: n.HtmlElementNode.TagName,
Attributes: n.HtmlElementNode.Attributes,
Children: children,
IsSelfClosing: n.HtmlElementNode.IsSelfClosing,
}
default:
return &ast.Text{}
}
@ -286,9 +300,9 @@ func convertListKindToASTNode(kind v1pb.ListNode_Kind) ast.ListKind {
case v1pb.ListNode_UNORDERED:
return ast.UnorderedList
case v1pb.ListNode_DESCRIPTION:
return ast.DescrpitionList
return ast.DescriptionList
default:
// Default to description list.
return ast.DescrpitionList
return ast.DescriptionList
}
}

View File

@ -29,6 +29,9 @@ func (s *APIV1Service) CreateMemo(ctx context.Context, request *v1pb.CreateMemoR
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user")
}
if user == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
create := &store.Memo{
UID: shortuuid.New(),
@ -318,6 +321,9 @@ func (s *APIV1Service) UpdateMemo(ctx context.Context, request *v1pb.UpdateMemoR
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user")
}
if user == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
// Only the creator or admin can update the memo.
if memo.CreatorID != user.ID && !isSuperUser(user) {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
@ -453,6 +459,9 @@ func (s *APIV1Service) DeleteMemo(ctx context.Context, request *v1pb.DeleteMemoR
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user")
}
if user == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
// Only the creator or admin can update the memo.
if memo.CreatorID != user.ID && !isSuperUser(user) {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
@ -689,6 +698,9 @@ func (s *APIV1Service) RenameMemoTag(ctx context.Context, request *v1pb.RenameMe
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user")
}
if user == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
memoFind := &store.FindMemo{
CreatorID: &user.ID,
@ -739,6 +751,9 @@ func (s *APIV1Service) DeleteMemoTag(ctx context.Context, request *v1pb.DeleteMe
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user")
}
if user == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
memoFind := &store.FindMemo{
CreatorID: &user.ID,

View File

@ -37,6 +37,9 @@ func (s *APIV1Service) UpsertMemoReaction(ctx context.Context, request *v1pb.Ups
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user")
}
if user == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
reaction, err := s.Store.UpsertReaction(ctx, &store.Reaction{
CreatorID: user.ID,
ContentID: request.Reaction.ContentId,

View File

@ -36,6 +36,9 @@ func (s *APIV1Service) ListUsers(ctx context.Context, request *v1pb.ListUsersReq
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
}
if currentUser == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
if currentUser.Role != store.RoleHost && currentUser.Role != store.RoleAdmin {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
@ -322,6 +325,9 @@ func (s *APIV1Service) GetUserSetting(ctx context.Context, request *v1pb.GetUser
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
if currentUser == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
// Only allow user to get their own settings
if currentUser.ID != userID {
@ -356,6 +362,9 @@ func (s *APIV1Service) UpdateUserSetting(ctx context.Context, request *v1pb.Upda
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
if currentUser == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
// Only allow user to update their own settings
if currentUser.ID != userID {
@ -442,6 +451,9 @@ func (s *APIV1Service) ListUserSettings(ctx context.Context, request *v1pb.ListU
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
if currentUser == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
// Only allow user to list their own settings
if currentUser.ID != userID {
@ -500,7 +512,7 @@ func (s *APIV1Service) ListUserAccessTokens(ctx context.Context, request *v1pb.L
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
if currentUser == nil {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
if currentUser.ID != userID {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
@ -562,7 +574,7 @@ func (s *APIV1Service) CreateUserAccessToken(ctx context.Context, request *v1pb.
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
if currentUser == nil {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
if currentUser.ID != userID {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
@ -630,7 +642,7 @@ func (s *APIV1Service) DeleteUserAccessToken(ctx context.Context, request *v1pb.
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
if currentUser == nil {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
if currentUser.ID != userID {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
@ -673,7 +685,7 @@ func (s *APIV1Service) ListUserSessions(ctx context.Context, request *v1pb.ListU
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
if currentUser == nil {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
if currentUser.ID != userID {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
@ -736,7 +748,7 @@ func (s *APIV1Service) RevokeUserSession(ctx context.Context, request *v1pb.Revo
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
if currentUser == nil {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
if currentUser.ID != userID {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
@ -796,6 +808,9 @@ func (s *APIV1Service) ListUserWebhooks(ctx context.Context, request *v1pb.ListU
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
if currentUser == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
if currentUser.ID != userID && currentUser.Role != store.RoleHost && currentUser.Role != store.RoleAdmin {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
@ -825,6 +840,9 @@ func (s *APIV1Service) CreateUserWebhook(ctx context.Context, request *v1pb.Crea
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
if currentUser == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
if currentUser.ID != userID && currentUser.Role != store.RoleHost && currentUser.Role != store.RoleAdmin {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
@ -862,6 +880,9 @@ func (s *APIV1Service) UpdateUserWebhook(ctx context.Context, request *v1pb.Upda
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
if currentUser == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
if currentUser.ID != userID && currentUser.Role != store.RoleHost && currentUser.Role != store.RoleAdmin {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
@ -931,6 +952,9 @@ func (s *APIV1Service) DeleteUserWebhook(ctx context.Context, request *v1pb.Dele
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
if currentUser == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
if currentUser.ID != userID && currentUser.Role != store.RoleHost && currentUser.Role != store.RoleAdmin {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}

View File

@ -83,6 +83,9 @@ func (s *APIV1Service) UpdateWorkspaceSetting(ctx context.Context, request *v1pb
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
if user == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
if user.Role != store.RoleHost {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}

View File

@ -39,11 +39,11 @@
"katex": "^0.16.22",
"leaflet": "^1.9.4",
"lodash-es": "^4.17.21",
"lucide-react": "^0.486.0",
"lucide-react": "^0.544.0",
"mermaid": "^11.11.0",
"mime": "^4.1.0",
"mobx": "^6.13.7",
"mobx-react-lite": "^4.1.0",
"mobx-react-lite": "^4.1.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-force-graph-2d": "^1.29.0",
@ -82,9 +82,9 @@
"nice-grpc-web": "^3.3.8",
"prettier": "^3.6.2",
"terser": "^5.44.0",
"tw-animate-css": "^1.3.8",
"typescript": "^5.9.2",
"typescript-eslint": "^8.44.0",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.45.0",
"vite": "^7.1.5"
},
"pnpm": {
@ -92,4 +92,4 @@
"esbuild"
]
}
}
}

View File

@ -91,7 +91,7 @@ importers:
version: 11.11.1
i18next:
specifier: ^25.5.2
version: 25.5.2(typescript@5.9.2)
version: 25.5.2(typescript@5.9.3)
katex:
specifier: ^0.16.22
version: 0.16.22
@ -102,8 +102,8 @@ importers:
specifier: ^4.17.21
version: 4.17.21
lucide-react:
specifier: ^0.486.0
version: 0.486.0(react@18.3.1)
specifier: ^0.544.0
version: 0.544.0(react@18.3.1)
mermaid:
specifier: ^11.11.0
version: 11.11.0
@ -114,8 +114,8 @@ importers:
specifier: ^6.13.7
version: 6.13.7
mobx-react-lite:
specifier: ^4.1.0
version: 4.1.0(mobx@6.13.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
specifier: ^4.1.1
version: 4.1.1(mobx@6.13.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react:
specifier: ^18.3.1
version: 18.3.1
@ -130,7 +130,7 @@ importers:
version: 2.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-i18next:
specifier: ^15.7.3
version: 15.7.3(i18next@25.5.2(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.2)
version: 15.7.3(i18next@25.5.2(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)
react-leaflet:
specifier: ^4.2.1
version: 4.2.1(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@ -226,14 +226,14 @@ importers:
specifier: ^5.44.0
version: 5.44.0
tw-animate-css:
specifier: ^1.3.8
version: 1.3.8
specifier: ^1.4.0
version: 1.4.0
typescript:
specifier: ^5.9.2
version: 5.9.2
specifier: ^5.9.3
version: 5.9.3
typescript-eslint:
specifier: ^8.44.0
version: 8.44.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2)
specifier: ^8.45.0
version: 8.45.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.3)
vite:
specifier: ^7.1.5
version: 7.1.5(@types/node@24.5.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)
@ -1521,63 +1521,63 @@ packages:
'@types/uuid@10.0.0':
resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
'@typescript-eslint/eslint-plugin@8.44.0':
resolution: {integrity: sha512-EGDAOGX+uwwekcS0iyxVDmRV9HX6FLSM5kzrAToLTsr9OWCIKG/y3lQheCq18yZ5Xh78rRKJiEpP0ZaCs4ryOQ==}
'@typescript-eslint/eslint-plugin@8.45.0':
resolution: {integrity: sha512-HC3y9CVuevvWCl/oyZuI47dOeDF9ztdMEfMH8/DW/Mhwa9cCLnK1oD7JoTVGW/u7kFzNZUKUoyJEqkaJh5y3Wg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
'@typescript-eslint/parser': ^8.44.0
'@typescript-eslint/parser': ^8.45.0
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/parser@8.44.0':
resolution: {integrity: sha512-VGMpFQGUQWYT9LfnPcX8ouFojyrZ/2w3K5BucvxL/spdNehccKhB4jUyB1yBCXpr2XFm0jkECxgrpXBW2ipoAw==}
'@typescript-eslint/parser@8.45.0':
resolution: {integrity: sha512-TGf22kon8KW+DeKaUmOibKWktRY8b2NSAZNdtWh798COm1NWx8+xJ6iFBtk3IvLdv6+LGLJLRlyhrhEDZWargQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/project-service@8.44.0':
resolution: {integrity: sha512-ZeaGNraRsq10GuEohKTo4295Z/SuGcSq2LzfGlqiuEvfArzo/VRrT0ZaJsVPuKZ55lVbNk8U6FcL+ZMH8CoyVA==}
'@typescript-eslint/project-service@8.45.0':
resolution: {integrity: sha512-3pcVHwMG/iA8afdGLMuTibGR7pDsn9RjDev6CCB+naRsSYs2pns5QbinF4Xqw6YC/Sj3lMrm/Im0eMfaa61WUg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/scope-manager@8.44.0':
resolution: {integrity: sha512-87Jv3E+al8wpD+rIdVJm/ItDBe/Im09zXIjFoipOjr5gHUhJmTzfFLuTJ/nPTMc2Srsroy4IBXwcTCHyRR7KzA==}
'@typescript-eslint/scope-manager@8.45.0':
resolution: {integrity: sha512-clmm8XSNj/1dGvJeO6VGH7EUSeA0FMs+5au/u3lrA3KfG8iJ4u8ym9/j2tTEoacAffdW1TVUzXO30W1JTJS7dA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/tsconfig-utils@8.44.0':
resolution: {integrity: sha512-x5Y0+AuEPqAInc6yd0n5DAcvtoQ/vyaGwuX5HE9n6qAefk1GaedqrLQF8kQGylLUb9pnZyLf+iEiL9fr8APDtQ==}
'@typescript-eslint/tsconfig-utils@8.45.0':
resolution: {integrity: sha512-aFdr+c37sc+jqNMGhH+ajxPXwjv9UtFZk79k8pLoJ6p4y0snmYpPA52GuWHgt2ZF4gRRW6odsEj41uZLojDt5w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/type-utils@8.44.0':
resolution: {integrity: sha512-9cwsoSxJ8Sak67Be/hD2RNt/fsqmWnNE1iHohG8lxqLSNY8xNfyY7wloo5zpW3Nu9hxVgURevqfcH6vvKCt6yg==}
'@typescript-eslint/type-utils@8.45.0':
resolution: {integrity: sha512-bpjepLlHceKgyMEPglAeULX1vixJDgaKocp0RVJ5u4wLJIMNuKtUXIczpJCPcn2waII0yuvks/5m5/h3ZQKs0A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/types@8.44.0':
resolution: {integrity: sha512-ZSl2efn44VsYM0MfDQe68RKzBz75NPgLQXuGypmym6QVOWL5kegTZuZ02xRAT9T+onqvM6T8CdQk0OwYMB6ZvA==}
'@typescript-eslint/types@8.45.0':
resolution: {integrity: sha512-WugXLuOIq67BMgQInIxxnsSyRLFxdkJEJu8r4ngLR56q/4Q5LrbfkFRH27vMTjxEK8Pyz7QfzuZe/G15qQnVRA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/typescript-estree@8.44.0':
resolution: {integrity: sha512-lqNj6SgnGcQZwL4/SBJ3xdPEfcBuhCG8zdcwCPgYcmiPLgokiNDKlbPzCwEwu7m279J/lBYWtDYL+87OEfn8Jw==}
'@typescript-eslint/typescript-estree@8.45.0':
resolution: {integrity: sha512-GfE1NfVbLam6XQ0LcERKwdTTPlLvHvXXhOeUGC1OXi4eQBoyy1iVsW+uzJ/J9jtCz6/7GCQ9MtrQ0fml/jWCnA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/utils@8.44.0':
resolution: {integrity: sha512-nktOlVcg3ALo0mYlV+L7sWUD58KG4CMj1rb2HUVOO4aL3K/6wcD+NERqd0rrA5Vg06b42YhF6cFxeixsp9Riqg==}
'@typescript-eslint/utils@8.45.0':
resolution: {integrity: sha512-bxi1ht+tLYg4+XV2knz/F7RVhU0k6VrSMc9sb8DQ6fyCTrGQLHfo7lDtN0QJjZjKkLA2ThrKuCdHEvLReqtIGg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/visitor-keys@8.44.0':
resolution: {integrity: sha512-zaz9u8EJ4GBmnehlrpoKvj/E3dNbuQ7q0ucyZImm3cLqJ8INTc970B1qEqDX/Rzq65r3TvVTN7kHWPBoyW7DWw==}
'@typescript-eslint/visitor-keys@8.45.0':
resolution: {integrity: sha512-qsaFBA3e09MIDAGFUrTk+dzqtfv1XPVz8t8d1f0ybTzrCY7BKiMC5cjrl1O/P7UmHsNyW90EYSkU/ZWpmXelag==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@vitejs/plugin-react@4.7.0':
@ -2715,8 +2715,8 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lucide-react@0.486.0:
resolution: {integrity: sha512-xWop/wMsC1ikiEVLZrxXjPKw4vU/eAip33G2mZHgbWnr4Nr5Rt4Vx4s/q1D3B/rQVbxjOuqASkEZcUxDEKzecw==}
lucide-react@0.544.0:
resolution: {integrity: sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
@ -2774,8 +2774,8 @@ packages:
mlly@1.8.0:
resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==}
mobx-react-lite@4.1.0:
resolution: {integrity: sha512-QEP10dpHHBeQNv1pks3WnHRCem2Zp636lq54M2nKO2Sarr13pL4u6diQXf65yzXUn0mkk18SyIDCm9UOJYTi1w==}
mobx-react-lite@4.1.1:
resolution: {integrity: sha512-iUxiMpsvNraCKXU+yPotsOncNNmyeS2B5DKL+TL6Tar/xm+wwNJAubJmtRSeAoYawdZqwv8Z/+5nPRHeQxTiXg==}
peerDependencies:
mobx: ^6.9.0
react: ^16.8.0 || ^17 || ^18 || ^19
@ -3339,8 +3339,8 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
tw-animate-css@1.3.8:
resolution: {integrity: sha512-Qrk3PZ7l7wUcGYhwZloqfkWCmaXZAoqjkdbIDvzfGshwGtexa/DAs9koXxIkrpEasyevandomzCBAV1Yyop5rw==}
tw-animate-css@1.4.0:
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
@ -3362,15 +3362,15 @@ packages:
resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==}
engines: {node: '>= 0.4'}
typescript-eslint@8.44.0:
resolution: {integrity: sha512-ib7mCkYuIzYonCq9XWF5XNw+fkj2zg629PSa9KNIQ47RXFF763S5BIX4wqz1+FLPogTZoiw8KmCiRPRa8bL3qw==}
typescript-eslint@8.45.0:
resolution: {integrity: sha512-qzDmZw/Z5beNLUrXfd0HIW6MzIaAV5WNDxmMs9/3ojGOpYavofgNAAD/nC6tGV2PczIi0iw8vot2eAe/sBn7zg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
typescript@5.9.2:
resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
@ -4808,97 +4808,97 @@ snapshots:
'@types/uuid@10.0.0': {}
'@typescript-eslint/eslint-plugin@8.44.0(@typescript-eslint/parser@8.44.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2)':
'@typescript-eslint/eslint-plugin@8.45.0(@typescript-eslint/parser@8.45.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.3))(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.1
'@typescript-eslint/parser': 8.44.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2)
'@typescript-eslint/scope-manager': 8.44.0
'@typescript-eslint/type-utils': 8.44.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2)
'@typescript-eslint/utils': 8.44.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2)
'@typescript-eslint/visitor-keys': 8.44.0
'@typescript-eslint/parser': 8.45.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.45.0
'@typescript-eslint/type-utils': 8.45.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.3)
'@typescript-eslint/utils': 8.45.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.45.0
eslint: 9.35.0(jiti@2.5.1)
graphemer: 1.4.0
ignore: 7.0.5
natural-compare: 1.4.0
ts-api-utils: 2.1.0(typescript@5.9.2)
typescript: 5.9.2
ts-api-utils: 2.1.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/parser@8.44.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2)':
'@typescript-eslint/parser@8.45.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.44.0
'@typescript-eslint/types': 8.44.0
'@typescript-eslint/typescript-estree': 8.44.0(typescript@5.9.2)
'@typescript-eslint/visitor-keys': 8.44.0
'@typescript-eslint/scope-manager': 8.45.0
'@typescript-eslint/types': 8.45.0
'@typescript-eslint/typescript-estree': 8.45.0(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.45.0
debug: 4.4.3
eslint: 9.35.0(jiti@2.5.1)
typescript: 5.9.2
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/project-service@8.44.0(typescript@5.9.2)':
'@typescript-eslint/project-service@8.45.0(typescript@5.9.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.44.0(typescript@5.9.2)
'@typescript-eslint/types': 8.44.0
'@typescript-eslint/tsconfig-utils': 8.45.0(typescript@5.9.3)
'@typescript-eslint/types': 8.45.0
debug: 4.4.3
typescript: 5.9.2
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/scope-manager@8.44.0':
'@typescript-eslint/scope-manager@8.45.0':
dependencies:
'@typescript-eslint/types': 8.44.0
'@typescript-eslint/visitor-keys': 8.44.0
'@typescript-eslint/types': 8.45.0
'@typescript-eslint/visitor-keys': 8.45.0
'@typescript-eslint/tsconfig-utils@8.44.0(typescript@5.9.2)':
'@typescript-eslint/tsconfig-utils@8.45.0(typescript@5.9.3)':
dependencies:
typescript: 5.9.2
typescript: 5.9.3
'@typescript-eslint/type-utils@8.44.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2)':
'@typescript-eslint/type-utils@8.45.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/types': 8.44.0
'@typescript-eslint/typescript-estree': 8.44.0(typescript@5.9.2)
'@typescript-eslint/utils': 8.44.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2)
'@typescript-eslint/types': 8.45.0
'@typescript-eslint/typescript-estree': 8.45.0(typescript@5.9.3)
'@typescript-eslint/utils': 8.45.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.3)
debug: 4.4.3
eslint: 9.35.0(jiti@2.5.1)
ts-api-utils: 2.1.0(typescript@5.9.2)
typescript: 5.9.2
ts-api-utils: 2.1.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/types@8.44.0': {}
'@typescript-eslint/types@8.45.0': {}
'@typescript-eslint/typescript-estree@8.44.0(typescript@5.9.2)':
'@typescript-eslint/typescript-estree@8.45.0(typescript@5.9.3)':
dependencies:
'@typescript-eslint/project-service': 8.44.0(typescript@5.9.2)
'@typescript-eslint/tsconfig-utils': 8.44.0(typescript@5.9.2)
'@typescript-eslint/types': 8.44.0
'@typescript-eslint/visitor-keys': 8.44.0
'@typescript-eslint/project-service': 8.45.0(typescript@5.9.3)
'@typescript-eslint/tsconfig-utils': 8.45.0(typescript@5.9.3)
'@typescript-eslint/types': 8.45.0
'@typescript-eslint/visitor-keys': 8.45.0
debug: 4.4.3
fast-glob: 3.3.3
is-glob: 4.0.3
minimatch: 9.0.5
semver: 7.7.2
ts-api-utils: 2.1.0(typescript@5.9.2)
typescript: 5.9.2
ts-api-utils: 2.1.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/utils@8.44.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2)':
'@typescript-eslint/utils@8.45.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.0(eslint@9.35.0(jiti@2.5.1))
'@typescript-eslint/scope-manager': 8.44.0
'@typescript-eslint/types': 8.44.0
'@typescript-eslint/typescript-estree': 8.44.0(typescript@5.9.2)
'@typescript-eslint/scope-manager': 8.45.0
'@typescript-eslint/types': 8.45.0
'@typescript-eslint/typescript-estree': 8.45.0(typescript@5.9.3)
eslint: 9.35.0(jiti@2.5.1)
typescript: 5.9.2
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/visitor-keys@8.44.0':
'@typescript-eslint/visitor-keys@8.45.0':
dependencies:
'@typescript-eslint/types': 8.44.0
'@typescript-eslint/types': 8.45.0
eslint-visitor-keys: 4.2.1
'@vitejs/plugin-react@4.7.0(vite@7.1.5(@types/node@24.5.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0))':
@ -5903,11 +5903,11 @@ snapshots:
hyphenate-style-name@1.1.0: {}
i18next@25.5.2(typescript@5.9.2):
i18next@25.5.2(typescript@5.9.3):
dependencies:
'@babel/runtime': 7.28.4
optionalDependencies:
typescript: 5.9.2
typescript: 5.9.3
iconv-lite@0.6.3:
dependencies:
@ -6214,7 +6214,7 @@ snapshots:
dependencies:
yallist: 3.1.1
lucide-react@0.486.0(react@18.3.1):
lucide-react@0.544.0(react@18.3.1):
dependencies:
react: 18.3.1
@ -6285,7 +6285,7 @@ snapshots:
pkg-types: 1.3.1
ufo: 1.6.1
mobx-react-lite@4.1.0(mobx@6.13.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
mobx-react-lite@4.1.1(mobx@6.13.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
mobx: 6.13.7
react: 18.3.1
@ -6495,15 +6495,15 @@ snapshots:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-i18next@15.7.3(i18next@25.5.2(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.2):
react-i18next@15.7.3(i18next@25.5.2(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3):
dependencies:
'@babel/runtime': 7.28.4
html-parse-stringify: 3.0.1
i18next: 25.5.2(typescript@5.9.2)
i18next: 25.5.2(typescript@5.9.3)
react: 18.3.1
optionalDependencies:
react-dom: 18.3.1(react@18.3.1)
typescript: 5.9.2
typescript: 5.9.3
react-is@16.13.1: {}
@ -6906,9 +6906,9 @@ snapshots:
toggle-selection@1.0.6: {}
ts-api-utils@2.1.0(typescript@5.9.2):
ts-api-utils@2.1.0(typescript@5.9.3):
dependencies:
typescript: 5.9.2
typescript: 5.9.3
ts-dedent@2.2.0: {}
@ -6918,7 +6918,7 @@ snapshots:
tslib@2.8.1: {}
tw-animate-css@1.3.8: {}
tw-animate-css@1.4.0: {}
type-check@0.4.0:
dependencies:
@ -6957,18 +6957,18 @@ snapshots:
possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10
typescript-eslint@8.44.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2):
typescript-eslint@8.45.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.3):
dependencies:
'@typescript-eslint/eslint-plugin': 8.44.0(@typescript-eslint/parser@8.44.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2)
'@typescript-eslint/parser': 8.44.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2)
'@typescript-eslint/typescript-estree': 8.44.0(typescript@5.9.2)
'@typescript-eslint/utils': 8.44.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2)
'@typescript-eslint/eslint-plugin': 8.45.0(@typescript-eslint/parser@8.45.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.3))(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.3)
'@typescript-eslint/parser': 8.45.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.3)
'@typescript-eslint/typescript-estree': 8.45.0(typescript@5.9.3)
'@typescript-eslint/utils': 8.45.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.3)
eslint: 9.35.0(jiti@2.5.1)
typescript: 5.9.2
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
typescript@5.9.2: {}
typescript@5.9.3: {}
ufo@1.6.1: {}

View File

@ -1,178 +1,73 @@
import dayjs from "dayjs";
import { observer } from "mobx-react-lite";
import { memo, useMemo } from "react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { TooltipProvider } from "@/components/ui/tooltip";
import { workspaceStore } from "@/store";
import type { ActivityCalendarProps, CalendarDay } from "@/types/statistics";
import type { ActivityCalendarProps } from "@/types/statistics";
import { useTranslate } from "@/utils/i18n";
const getCellOpacity = (ratio: number): string => {
if (ratio === 0) return "";
if (ratio > 0.75) return "bg-destructive text-destructive-foreground";
if (ratio > 0.5) return "bg-destructive/70 text-destructive-foreground";
if (ratio > 0.25) return "bg-destructive/50 text-destructive-foreground";
return "bg-destructive/30 text-destructive-foreground";
};
const CalendarCell = memo(
({
dayInfo,
count,
maxCount,
isToday,
isSelected,
onClick,
tooltipText,
}: {
dayInfo: CalendarDay;
count: number;
maxCount: number;
isToday: boolean;
isSelected: boolean;
onClick?: () => void;
tooltipText: string;
}) => {
const cellContent = (
<div
className={cn(
"w-6 h-6 text-xs lg:text-[13px] flex justify-center items-center cursor-default",
"rounded-lg border-2 text-muted-foreground transition-all duration-200",
dayInfo.isCurrentMonth && getCellOpacity(count / maxCount),
dayInfo.isCurrentMonth && isToday && "border-border",
dayInfo.isCurrentMonth && isSelected && "font-medium border-border",
dayInfo.isCurrentMonth && !isToday && !isSelected && "border-transparent",
count > 0 && "cursor-pointer hover:scale-110",
)}
onClick={count > 0 ? onClick : undefined}
>
{dayInfo.day}
</div>
);
if (!dayInfo.isCurrentMonth) {
return (
<div
className={cn("w-6 h-6 text-xs lg:text-[13px] flex justify-center items-center cursor-default opacity-60 text-muted-foreground")}
>
{dayInfo.day}
</div>
);
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="shrink-0">{cellContent}</div>
</TooltipTrigger>
<TooltipContent>
<p>{tooltipText}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
},
);
import { CalendarCell } from "./CalendarCell";
import { useCalendarMatrix } from "./useCalendarMatrix";
export const ActivityCalendar = memo(
observer((props: ActivityCalendarProps) => {
const t = useTranslate();
const { month: monthStr, data, onClick } = props;
const { month, selectedDate, data, onClick } = props;
const weekStartDayOffset = workspaceStore.state.generalSetting.weekStartDayOffset;
const { days, weekDays, maxCount } = useMemo(() => {
const yearValue = dayjs(monthStr).toDate().getFullYear();
const monthValue = dayjs(monthStr).toDate().getMonth();
const dayInMonth = new Date(yearValue, monthValue + 1, 0).getDate();
const firstDay = (((new Date(yearValue, monthValue, 1).getDay() - weekStartDayOffset) % 7) + 7) % 7;
const lastDay = new Date(yearValue, monthValue, dayInMonth).getDay() - weekStartDayOffset;
const prevMonthDays = new Date(yearValue, monthValue, 0).getDate();
const WEEK_DAYS = [t("days.sun"), t("days.mon"), t("days.tue"), t("days.wed"), t("days.thu"), t("days.fri"), t("days.sat")];
const weekDaysOrdered = WEEK_DAYS.slice(weekStartDayOffset).concat(WEEK_DAYS.slice(0, weekStartDayOffset));
const daysArray: CalendarDay[] = [];
// Previous month's days
for (let i = firstDay - 1; i >= 0; i--) {
daysArray.push({ day: prevMonthDays - i, isCurrentMonth: false });
}
// Current month's days
for (let i = 1; i <= dayInMonth; i++) {
const date = dayjs(`${yearValue}-${monthValue + 1}-${i}`).format("YYYY-MM-DD");
daysArray.push({ day: i, isCurrentMonth: true, date });
}
// Next month's days
for (let i = 1; i < 7 - lastDay; i++) {
daysArray.push({ day: i, isCurrentMonth: false });
}
const maxCountValue = Math.max(...Object.values(data), 1);
return {
year: yearValue,
month: monthValue,
days: daysArray,
weekDays: weekDaysOrdered,
maxCount: maxCountValue,
};
}, [monthStr, data, weekStartDayOffset, t]);
const today = useMemo(() => dayjs().format("YYYY-MM-DD"), []);
const selectedDateFormatted = useMemo(() => dayjs(props.selectedDate).format("YYYY-MM-DD"), [props.selectedDate]);
const selectedDateFormatted = useMemo(() => dayjs(selectedDate).format("YYYY-MM-DD"), [selectedDate]);
const weekDaysRaw = useMemo(
() => [t("days.sun"), t("days.mon"), t("days.tue"), t("days.wed"), t("days.thu"), t("days.fri"), t("days.sat")],
[t],
);
const { weeks, weekDays, maxCount } = useCalendarMatrix({
month,
data,
weekDays: weekDaysRaw,
weekStartDayOffset,
today,
selectedDate: selectedDateFormatted,
});
return (
<div className={cn("w-full h-auto shrink-0 grid grid-cols-7 grid-flow-row gap-1")}>
{weekDays.map((day, index) => (
<div key={index} className={cn("w-6 h-5 text-xs flex justify-center items-center cursor-default opacity-60")}>
{day}
<TooltipProvider>
<div className="w-full flex flex-col gap-1">
<div className="grid grid-cols-7 gap-1 text-xs text-muted-foreground">
{weekDays.map((label, index) => (
<div key={index} className="flex h-5 items-center justify-center text-muted-foreground/80">
{label}
</div>
))}
</div>
))}
{days.map((dayInfo, index) => {
if (!dayInfo.isCurrentMonth) {
return (
<CalendarCell
key={`prev-next-${index}`}
dayInfo={dayInfo}
count={0}
maxCount={maxCount}
isToday={false}
isSelected={false}
tooltipText=""
/>
);
}
const date = dayInfo.date!;
const count = data[date] || 0;
const isToday = today === date;
const isSelected = selectedDateFormatted === date;
const tooltipText =
count === 0
? date
: t("memo.count-memos-in-date", {
count: count,
memos: count === 1 ? t("common.memo") : t("common.memos"),
date: date,
}).toLowerCase();
<div className="grid grid-cols-7 gap-1">
{weeks.map((week, weekIndex) =>
week.days.map((day, dayIndex) => {
const tooltipText =
day.count === 0
? day.date
: t("memo.count-memos-in-date", {
count: day.count,
memos: day.count === 1 ? t("common.memo") : t("common.memos"),
date: day.date,
}).toLowerCase();
return (
<CalendarCell
key={date}
dayInfo={dayInfo}
count={count}
maxCount={maxCount}
isToday={isToday}
isSelected={isSelected}
onClick={() => onClick?.(date)}
tooltipText={tooltipText}
/>
);
})}
</div>
return (
<CalendarCell
key={`${weekIndex}-${dayIndex}-${day.date}`}
day={day}
maxCount={maxCount}
tooltipText={tooltipText}
onClick={onClick}
/>
);
}),
)}
</div>
</div>
</TooltipProvider>
);
}),
);

View File

@ -0,0 +1,76 @@
import { memo } from "react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { CalendarDayCell } from "./types";
import { getCellIntensityClass } from "./utils";
interface CalendarCellProps {
day: CalendarDayCell;
maxCount: number;
tooltipText: string;
onClick?: (date: string) => void;
}
export const CalendarCell = memo((props: CalendarCellProps) => {
const { day, maxCount, tooltipText, onClick } = props;
const handleClick = () => {
if (day.count > 0 && onClick) {
onClick(day.date);
}
};
const baseClasses =
"w-full h-7 rounded-md border text-xs flex items-center justify-center text-center transition-transform duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 focus-visible:ring-offset-1 focus-visible:ring-offset-background select-none";
const isInteractive = Boolean(onClick && day.count > 0);
const ariaLabel = day.isSelected ? `${tooltipText} (selected)` : tooltipText;
if (!day.isCurrentMonth) {
return (
<div className={cn(baseClasses, "border-transparent text-muted-foreground/60 bg-transparent pointer-events-none opacity-80")}>
{day.label}
</div>
);
}
const intensityClass = getCellIntensityClass(day, maxCount);
const buttonClasses = cn(
baseClasses,
"border-transparent text-muted-foreground",
day.isToday && "border-border",
day.isSelected && "border-border font-medium",
day.isWeekend && "text-muted-foreground/80",
intensityClass,
isInteractive ? "cursor-pointer hover:scale-105" : "cursor-default",
);
const button = (
<button
type="button"
onClick={handleClick}
tabIndex={isInteractive ? 0 : -1}
aria-label={ariaLabel}
aria-current={day.isToday ? "date" : undefined}
aria-disabled={!isInteractive}
className={buttonClasses}
>
{day.label}
</button>
);
if (!tooltipText) {
return button;
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side="top">
<p>{tooltipText}</p>
</TooltipContent>
</Tooltip>
);
});
CalendarCell.displayName = "CalendarCell";

View File

@ -0,0 +1,19 @@
export interface CalendarDayCell {
date: string;
label: number;
count: number;
isCurrentMonth: boolean;
isToday: boolean;
isSelected: boolean;
isWeekend: boolean;
}
export interface CalendarDayRow {
days: CalendarDayCell[];
}
export interface CalendarMatrixResult {
weeks: CalendarDayRow[];
weekDays: string[];
maxCount: number;
}

View File

@ -0,0 +1,71 @@
import dayjs from "dayjs";
import { useMemo } from "react";
import type { CalendarDayCell, CalendarMatrixResult } from "./types";
interface UseCalendarMatrixParams {
month: string;
data: Record<string, number>;
weekDays: string[];
weekStartDayOffset: number;
today: string;
selectedDate: string;
}
export const useCalendarMatrix = ({
month,
data,
weekDays,
weekStartDayOffset,
today,
selectedDate,
}: UseCalendarMatrixParams): CalendarMatrixResult => {
return useMemo(() => {
const monthStart = dayjs(month).startOf("month");
const monthEnd = monthStart.endOf("month");
const monthKey = monthStart.format("YYYY-MM");
const orderedWeekDays = weekDays.slice(weekStartDayOffset).concat(weekDays.slice(0, weekStartDayOffset));
const startOffset = (monthStart.day() - weekStartDayOffset + 7) % 7;
const endOffset = (weekStartDayOffset + 6 - monthEnd.day() + 7) % 7;
const calendarStart = monthStart.subtract(startOffset, "day");
const calendarEnd = monthEnd.add(endOffset, "day");
const dayCount = calendarEnd.diff(calendarStart, "day") + 1;
const weeks: CalendarMatrixResult["weeks"] = [];
let maxCount = 0;
for (let index = 0; index < dayCount; index += 1) {
const current = calendarStart.add(index, "day");
const isoDate = current.format("YYYY-MM-DD");
const weekIndex = Math.floor(index / 7);
if (!weeks[weekIndex]) {
weeks[weekIndex] = { days: [] };
}
const isCurrentMonth = current.format("YYYY-MM") === monthKey;
const count = data[isoDate] ?? 0;
const dayCell: CalendarDayCell = {
date: isoDate,
label: current.date(),
count,
isCurrentMonth,
isToday: isoDate === today,
isSelected: isoDate === selectedDate,
isWeekend: [0, 6].includes(current.day()),
};
weeks[weekIndex].days.push(dayCell);
maxCount = Math.max(maxCount, count);
}
return {
weeks,
weekDays: orderedWeekDays,
maxCount: Math.max(maxCount, 1),
};
}, [month, data, weekDays, weekStartDayOffset, today, selectedDate]);
};

View File

@ -0,0 +1,13 @@
import type { CalendarDayCell } from "./types";
export const getCellIntensityClass = (day: CalendarDayCell, maxCount: number): string => {
if (!day.isCurrentMonth || day.count === 0 || maxCount <= 0) {
return "bg-transparent";
}
const ratio = day.count / maxCount;
if (ratio > 0.75) return "bg-primary text-primary-foreground border-primary";
if (ratio > 0.5) return "bg-primary/80 text-primary-foreground border-primary/90";
if (ratio > 0.25) return "bg-primary/60 text-primary-foreground border-primary/70";
return "bg-primary/40 text-primary";
};

View File

@ -0,0 +1,131 @@
# ConfirmDialog - Accessible Confirmation Dialog
## Overview
`ConfirmDialog` standardizes confirmation flows across the app. It replaces adhoc `window.confirm` usage with an accessible, themeable dialog that supports asynchronous operations.
## Key Features
### 1. Accessibility & UX
- Uses shared `Dialog` primitives (focus trap, ARIA roles)
- Blocks dismissal while async confirm is pending
- Clear separation of title (action) vs description (context)
### 2. Async-Aware
- Accepts sync or async `onConfirm`
- Auto-closes on resolve; remains open on error for retry / toast
### 3. Internationalization Ready
- All labels / text provided by caller through i18n hook
- Supports interpolation for dynamic context
### 4. Minimal Surface, Easy Extension
- Lightweight API (few required props)
- Style hook via `.container` class (SCSS module)
## Architecture
```
ConfirmDialog
├── State: loading (tracks pending confirm action)
├── Dialog primitives: Header (title + description), Footer (buttons)
└── External control: parent owns open state via onOpenChange
```
## Usage
```tsx
import { useTranslate } from "@/utils/i18n";
import ConfirmDialog from "@/components/ConfirmDialog";
const t = useTranslate();
<ConfirmDialog
open={open}
onOpenChange={setOpen}
title={t("memo.delete-confirm")}
description={t("memo.delete-confirm-description")}
confirmLabel={t("common.delete")}
cancelLabel={t("common.cancel")}
onConfirm={handleDelete}
confirmVariant="destructive"
/>;
```
## Props
| Prop | Type | Required | Acceptable Values |
|------|------|----------|------------------|
| `open` | `boolean` | Yes | `true` (visible) / `false` (hidden) |
| `onOpenChange` | `(open: boolean) => void` | Yes | Callback receiving next state; should update parent state |
| `title` | `React.ReactNode` | Yes | Short localized action summary (text / node) |
| `description` | `React.ReactNode` | No | Optional contextual message |
| `confirmLabel` | `string` | Yes | Non-empty localized action text (12 words) |
| `cancelLabel` | `string` | Yes | Localized cancel label |
| `onConfirm` | `() => void | Promise<void>` | Yes | Sync or async handler; resolve = close, reject = stay open |
| `confirmVariant` | `"default" | "destructive"` | No | Defaults to `"default"`; use `"destructive"` for irreversible actions |
## Benefits vs Previous Implementation
### Before (window.confirm / adhoc dialogs)
- Blocking native prompt, inconsistent styling
- No async progress handling
- No rich formatting
- Hard to localize consistently
### After (ConfirmDialog)
- Unified styling + accessibility semantics
- Async-safe with loading state shielding
- Plain description flexibility
- i18n-first via externalized labels
## Technical Implementation Details
### Async Handling
```tsx
const handleConfirm = async () => {
setLoading(true);
try {
await onConfirm(); // resolve -> close
onOpenChange(false);
} catch (e) {
console.error(e); // remain open for retry
} finally {
setLoading(false);
}
};
```
### Close Guard
```tsx
<Dialog open={open} onOpenChange={(next) => !loading && onOpenChange(next)} />
```
## Browser / Environment Support
- Works anywhere the existing `Dialog` primitives work (modern browsers)
- No ResizeObserver / layout dependencies
## Performance Considerations
1. Minimal renders: loading state toggles once per confirm attempt
2. No portal churn—relies on underlying dialog infra
## Future Enhancements
1. Severity icon / header accent
2. Auto-focus destructive button toggle
3. Secondary action (e.g. "Archive" vs "Delete")
4. Built-in retry / error slot
5. Optional checkbox confirmation ("I understand the consequences")
6. Motion/animation tokens integration
## Styling
The `ConfirmDialog.module.scss` file provides a `.container` hook. It currently only hosts a harmless custom property so the stylesheet is non-empty. Add real layout or variant tokens there instead of inline styles.
## Internationalization
All visible strings must come from the translation system. Use `useTranslate()` and pass localized values into props. Separate keys for title/description.
## Error Handling
Errors thrown in `onConfirm` are caught and logged. The dialog stays open so the caller can surface a toast or inline message and allow retry. (Consider routing serious errors to a higher-level handler.)
---
If you extend this component, update this README to keep usage discoverable.

View File

@ -0,0 +1,73 @@
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
export interface ConfirmDialogProps {
/** Whether the dialog is open */
open: boolean;
/** Open state change callback (closing disabled while loading) */
onOpenChange: (open: boolean) => void;
/** Title content (plain text or React nodes) */
title: React.ReactNode;
/** Optional description (plain text or React nodes) */
description?: React.ReactNode;
/** Confirm / primary action button label */
confirmLabel: string;
/** Cancel button label */
cancelLabel: string;
/** Async or sync confirm handler. Dialog auto-closes on resolve, stays open on reject */
onConfirm: () => void | Promise<void>;
/** Variant style of confirm button */
confirmVariant?: "default" | "destructive";
}
/**
* Accessible confirmation dialog.
* - Renders optional description content
* - Prevents closing while async confirm action is in-flight
* - Minimal opinionated styling; leverages existing UI primitives
*/
export default function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel,
cancelLabel,
onConfirm,
confirmVariant = "default",
}: ConfirmDialogProps) {
const [loading, setLoading] = React.useState(false);
const handleConfirm = async () => {
try {
setLoading(true);
await onConfirm();
onOpenChange(false);
} catch (e) {
// Intentionally swallow errors so user can retry; surface via caller's toast/logging
console.error("ConfirmDialog error for action:", title, e);
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={(o: boolean) => !loading && onOpenChange(o)}>
<DialogContent size="sm">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description ? <DialogDescription>{description}</DialogDescription> : null}
</DialogHeader>
<DialogFooter>
<Button variant="ghost" disabled={loading} onClick={() => onOpenChange(false)}>
{cancelLabel}
</Button>
<Button variant={confirmVariant} disabled={loading} onClick={handleConfirm} data-loading={loading}>
{confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -8,12 +8,13 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { userServiceClient } from "@/grpcweb";
import useCurrentUser from "@/hooks/useCurrentUser";
import useLoading from "@/hooks/useLoading";
import { UserAccessToken } from "@/types/proto/api/v1/user_service";
import { useTranslate } from "@/utils/i18n";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
onSuccess: (created: UserAccessToken) => void;
}
interface State {
@ -72,7 +73,7 @@ function CreateAccessTokenDialog({ open, onOpenChange, onSuccess }: Props) {
try {
requestState.setLoading();
await userServiceClient.createUserAccessToken({
const created = await userServiceClient.createUserAccessToken({
parent: currentUser.name,
accessToken: {
description: state.description,
@ -81,7 +82,7 @@ function CreateAccessTokenDialog({ open, onOpenChange, onSuccess }: Props) {
});
requestState.setFinish();
onSuccess();
onSuccess(created);
onOpenChange(false);
} catch (error: any) {
toast.error(error.details);

View File

@ -1,6 +1,8 @@
import { Edit3Icon, MoreVerticalIcon, TrashIcon, PlusIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useState } from "react";
import toast from "react-hot-toast";
import ConfirmDialog from "@/components/ConfirmDialog";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { shortcutServiceClient } from "@/grpcweb";
import useAsyncEffect from "@/hooks/useAsyncEffect";
@ -25,6 +27,7 @@ const ShortcutsSection = observer(() => {
const t = useTranslate();
const shortcuts = userStore.state.shortcuts;
const [isCreateShortcutDialogOpen, setIsCreateShortcutDialogOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Shortcut | undefined>();
const [editingShortcut, setEditingShortcut] = useState<Shortcut | undefined>();
useAsyncEffect(async () => {
@ -32,11 +35,15 @@ const ShortcutsSection = observer(() => {
}, []);
const handleDeleteShortcut = async (shortcut: Shortcut) => {
const confirmed = window.confirm("Are you sure you want to delete this shortcut?");
if (confirmed) {
await shortcutServiceClient.deleteShortcut({ name: shortcut.name });
await userStore.fetchUserSettings();
}
setDeleteTarget(shortcut);
};
const confirmDeleteShortcut = async () => {
if (!deleteTarget) return;
await shortcutServiceClient.deleteShortcut({ name: deleteTarget.name });
await userStore.fetchUserSettings();
toast.success(t("setting.shortcut.delete-success", { title: deleteTarget.title }));
setDeleteTarget(undefined);
};
const handleCreateShortcut = () => {
@ -113,6 +120,15 @@ const ShortcutsSection = observer(() => {
shortcut={editingShortcut}
onSuccess={handleShortcutDialogSuccess}
/>
<ConfirmDialog
open={!!deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(undefined)}
title={t("setting.shortcut.delete-confirm", { title: deleteTarget?.title ?? "" })}
confirmLabel={t("common.delete")}
cancelLabel={t("common.cancel")}
onConfirm={confirmDeleteShortcut}
confirmVariant="destructive"
/>
</div>
);
});

View File

@ -3,6 +3,7 @@ import { observer } from "mobx-react-lite";
import { useState } from "react";
import toast from "react-hot-toast";
import useLocalStorage from "react-use/lib/useLocalStorage";
import ConfirmDialog from "@/components/ConfirmDialog";
import { Switch } from "@/components/ui/switch";
import { memoServiceClient } from "@/grpcweb";
import { useDialog } from "@/hooks/useDialog";
@ -25,6 +26,7 @@ const TagsSection = observer((props: Props) => {
const [treeAutoExpand, setTreeAutoExpand] = useLocalStorage<boolean>("tag-tree-auto-expand", false);
const renameTagDialog = useDialog();
const [selectedTag, setSelectedTag] = useState<string>("");
const [deleteTagName, setDeleteTagName] = useState<string | undefined>(undefined);
const tags = Object.entries(userStore.state.tagCount)
.sort((a, b) => a[0].localeCompare(b[0]))
.sort((a, b) => b[1] - a[1]);
@ -52,14 +54,17 @@ const TagsSection = observer((props: Props) => {
};
const handleDeleteTag = async (tag: string) => {
const confirmed = window.confirm(t("tag.delete-confirm"));
if (confirmed) {
await memoServiceClient.deleteMemoTag({
parent: "memos/-",
tag: tag,
});
toast.success(t("message.deleted-successfully"));
}
setDeleteTagName(tag);
};
const confirmDeleteTag = async () => {
if (!deleteTagName) return;
await memoServiceClient.deleteMemoTag({
parent: "memos/-",
tag: deleteTagName,
});
toast.success(t("tag.delete-success"));
setDeleteTagName(undefined);
};
return (
@ -139,6 +144,15 @@ const TagsSection = observer((props: Props) => {
tag={selectedTag}
onSuccess={handleRenameSuccess}
/>
<ConfirmDialog
open={!!deleteTagName}
onOpenChange={(open) => !open && setDeleteTagName(undefined)}
title={t("tag.delete-confirm")}
confirmLabel={t("common.delete")}
cancelLabel={t("common.cancel")}
onConfirm={confirmDeleteTag}
confirmVariant="destructive"
/>
</div>
);
});

View File

@ -38,6 +38,16 @@ const LocationMarker = (props: MarkerProps) => {
map.locate();
}, []);
// Keep marker and map in sync with external position updates
useEffect(() => {
if (props.position) {
setPosition(props.position);
map.setView(props.position);
} else {
setPosition(undefined);
}
}, [props.position, map]);
return position === undefined ? null : <Marker position={position} icon={markerIcon}></Marker>;
};

View File

@ -0,0 +1,43 @@
import { MasonryItem } from "./MasonryItem";
import { MasonryColumnProps } from "./types";
/**
* Column component for masonry layout
*
* Responsibilities:
* - Render a single column in the masonry grid
* - Display prefix element in the first column (e.g., memo editor)
* - Render all assigned memo items in order
* - Pass render context to items (includes compact mode flag)
*/
export function MasonryColumn({
memoIndices,
memoList,
renderer,
renderContext,
onHeightChange,
isFirstColumn,
prefixElement,
prefixElementRef,
}: MasonryColumnProps) {
return (
<div className="min-w-0 mx-auto w-full max-w-2xl">
{/* Prefix element (like memo editor) goes in first column */}
{isFirstColumn && prefixElement && <div ref={prefixElementRef}>{prefixElement}</div>}
{/* Render all memos assigned to this column */}
{memoIndices?.map((memoIndex) => {
const memo = memoList[memoIndex];
return memo ? (
<MasonryItem
key={`${memo.name}-${memo.displayTime}`}
memo={memo}
renderer={renderer}
renderContext={renderContext}
onHeightChange={onHeightChange}
/>
) : null;
})}
</div>
);
}

View File

@ -0,0 +1,45 @@
import { useEffect, useRef } from "react";
import { MasonryItemProps } from "./types";
/**
* Individual item wrapper component for masonry layout
*
* Responsibilities:
* - Render the memo using the provided renderer with context
* - Measure its own height using ResizeObserver
* - Report height changes to parent for redistribution
*
* The ResizeObserver automatically tracks dynamic content changes such as:
* - Images loading
* - Expanded/collapsed text
* - Any other content size changes
*/
export function MasonryItem({ memo, renderer, renderContext, onHeightChange }: MasonryItemProps) {
const itemRef = useRef<HTMLDivElement>(null);
const resizeObserverRef = useRef<ResizeObserver | null>(null);
useEffect(() => {
if (!itemRef.current) return;
const measureHeight = () => {
if (itemRef.current) {
const height = itemRef.current.offsetHeight;
onHeightChange(memo.name, height);
}
};
// Initial measurement
measureHeight();
// Set up ResizeObserver to track dynamic content changes
resizeObserverRef.current = new ResizeObserver(measureHeight);
resizeObserverRef.current.observe(itemRef.current);
// Cleanup on unmount
return () => {
resizeObserverRef.current?.disconnect();
};
}, [memo.name, onHeightChange]);
return <div ref={itemRef}>{renderer(memo, renderContext)}</div>;
}

View File

@ -1,156 +1,42 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useMemo, useRef } from "react";
import { cn } from "@/lib/utils";
import { Memo } from "@/types/proto/api/v1/memo_service";
interface Props {
memoList: Memo[];
renderer: (memo: Memo) => JSX.Element;
prefixElement?: JSX.Element;
listMode?: boolean;
}
interface MemoItemProps {
memo: Memo;
renderer: (memo: Memo) => JSX.Element;
onHeightChange: (memoName: string, height: number) => void;
}
// Minimum width required to show more than one column
const MINIMUM_MEMO_VIEWPORT_WIDTH = 512;
const MemoItem = ({ memo, renderer, onHeightChange }: MemoItemProps) => {
const itemRef = useRef<HTMLDivElement>(null);
const resizeObserverRef = useRef<ResizeObserver | null>(null);
useEffect(() => {
if (!itemRef.current) return;
const measureHeight = () => {
if (itemRef.current) {
const height = itemRef.current.offsetHeight;
onHeightChange(memo.name, height);
}
};
measureHeight();
// Set up ResizeObserver to track dynamic content changes (images, expanded text, etc.)
resizeObserverRef.current = new ResizeObserver(measureHeight);
resizeObserverRef.current.observe(itemRef.current);
return () => {
resizeObserverRef.current?.disconnect();
};
}, [memo.name, onHeightChange]);
return <div ref={itemRef}>{renderer(memo)}</div>;
};
import { MasonryColumn } from "./MasonryColumn";
import { MasonryViewProps, MemoRenderContext } from "./types";
import { useMasonryLayout } from "./useMasonryLayout";
/**
* Algorithm to distribute memos into columns based on height for balanced layout
* Uses greedy approach: always place next memo in the shortest column
* Masonry layout component for displaying memos in a balanced, multi-column grid
*
* Features:
* - Responsive column count based on viewport width
* - Longest Processing-Time First (LPT) algorithm for optimal distribution
* - Pins editor and first memo to first column for stability
* - Debounced redistribution for performance
* - Automatic height tracking with ResizeObserver
* - Auto-enables compact mode in multi-column layouts
*
* The layout automatically adjusts to:
* - Window resizing
* - Content changes (images loading, text expansion)
* - Dynamic memo additions/removals
*
* Algorithm guarantee: Layout is never more than 34% longer than optimal (proven)
*/
const distributeMemosToColumns = (
memos: Memo[],
columns: number,
itemHeights: Map<string, number>,
prefixElementHeight: number = 0,
): { distribution: number[][]; columnHeights: number[] } => {
// List mode: all memos in single column
if (columns === 1) {
const totalHeight = memos.reduce((sum, memo) => sum + (itemHeights.get(memo.name) || 0), prefixElementHeight);
return {
distribution: [Array.from({ length: memos.length }, (_, i) => i)],
columnHeights: [totalHeight],
};
}
// Initialize columns and heights
const distribution: number[][] = Array.from({ length: columns }, () => []);
const columnHeights: number[] = Array(columns).fill(0);
// Add prefix element height to first column
if (prefixElementHeight > 0) {
columnHeights[0] = prefixElementHeight;
}
// Distribute each memo to the shortest column
memos.forEach((memo, index) => {
const height = itemHeights.get(memo.name) || 0;
// Find column with minimum height
const shortestColumnIndex = columnHeights.indexOf(Math.min(...columnHeights));
distribution[shortestColumnIndex].push(index);
columnHeights[shortestColumnIndex] += height;
});
return { distribution, columnHeights };
};
const MasonryView = (props: Props) => {
const [columns, setColumns] = useState(1);
const [itemHeights, setItemHeights] = useState<Map<string, number>>(new Map());
const [distribution, setDistribution] = useState<number[][]>([[]]);
const MasonryView = ({ memoList, renderer, prefixElement, listMode = false }: MasonryViewProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const prefixElementRef = useRef<HTMLDivElement>(null);
// Calculate optimal number of columns based on container width
const calculateColumns = useCallback(() => {
if (!containerRef.current || props.listMode) return 1;
const { columns, distribution, handleHeightChange } = useMasonryLayout(memoList, listMode, containerRef, prefixElementRef);
const containerWidth = containerRef.current.offsetWidth;
const scale = containerWidth / MINIMUM_MEMO_VIEWPORT_WIDTH;
return scale >= 2 ? Math.round(scale) : 1;
}, [props.listMode]);
// Recalculate memo distribution when layout changes
const redistributeMemos = useCallback(() => {
const prefixHeight = prefixElementRef.current?.offsetHeight || 0;
const { distribution: newDistribution } = distributeMemosToColumns(props.memoList, columns, itemHeights, prefixHeight);
setDistribution(newDistribution);
}, [props.memoList, columns, itemHeights]);
// Handle height changes from individual memo items
const handleHeightChange = useCallback(
(memoName: string, height: number) => {
setItemHeights((prevHeights) => {
const newItemHeights = new Map(prevHeights);
newItemHeights.set(memoName, height);
// Recalculate distribution with new heights
const prefixHeight = prefixElementRef.current?.offsetHeight || 0;
const { distribution: newDistribution } = distributeMemosToColumns(props.memoList, columns, newItemHeights, prefixHeight);
setDistribution(newDistribution);
return newItemHeights;
});
},
[props.memoList, columns],
// Create render context: automatically enable compact mode when multiple columns
const renderContext: MemoRenderContext = useMemo(
() => ({
compact: columns > 1,
columns,
}),
[columns],
);
// Handle window resize and calculate new column count
useEffect(() => {
const handleResize = () => {
if (!containerRef.current) return;
const newColumns = calculateColumns();
if (newColumns !== columns) {
setColumns(newColumns);
}
};
handleResize();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [calculateColumns, columns]);
// Redistribute memos when columns, memo list, or heights change
useEffect(() => {
redistributeMemos();
}, [redistributeMemos]);
return (
<div
ref={containerRef}
@ -160,22 +46,17 @@ const MasonryView = (props: Props) => {
}}
>
{Array.from({ length: columns }).map((_, columnIndex) => (
<div key={columnIndex} className="min-w-0 mx-auto w-full max-w-2xl">
{/* Prefix element (like memo editor) goes in first column */}
{props.prefixElement && columnIndex === 0 && <div ref={prefixElementRef}>{props.prefixElement}</div>}
{distribution[columnIndex]?.map((memoIndex) => {
const memo = props.memoList[memoIndex];
return memo ? (
<MemoItem
key={`${memo.name}-${memo.displayTime}`}
memo={memo}
renderer={props.renderer}
onHeightChange={handleHeightChange}
/>
) : null;
})}
</div>
<MasonryColumn
key={columnIndex}
memoIndices={distribution[columnIndex] || []}
memoList={memoList}
renderer={renderer}
renderContext={renderContext}
onHeightChange={handleHeightChange}
isFirstColumn={columnIndex === 0}
prefixElement={prefixElement}
prefixElementRef={prefixElementRef}
/>
))}
</div>
);

View File

@ -0,0 +1,11 @@
/**
* Minimum width required to show more than one column in masonry layout
* When viewport is narrower, layout falls back to single column
*/
export const MINIMUM_MEMO_VIEWPORT_WIDTH = 512;
/**
* Debounce delay for redistribution in milliseconds
* Balances responsiveness with performance by batching rapid height changes
*/
export const REDISTRIBUTION_DEBOUNCE_MS = 100;

View File

@ -0,0 +1,94 @@
import { Memo } from "@/types/proto/api/v1/memo_service";
import { DistributionResult } from "./types";
/**
* Distributes memos into columns using a height-aware greedy approach.
*
* Algorithm steps:
* 1. Pin editor and first memo to the first column (keep feed stable)
* 2. Place remaining memos into the currently shortest column
* 3. Break height ties by preferring the column with fewer items
*
* @param memos - Array of memos to distribute
* @param columns - Number of columns to distribute across
* @param itemHeights - Map of memo names to their measured heights
* @param prefixElementHeight - Height of prefix element (e.g., editor) in first column
* @returns Distribution result with memo indices per column and column heights
*/
export function distributeItemsToColumns(
memos: Memo[],
columns: number,
itemHeights: Map<string, number>,
prefixElementHeight: number = 0,
): DistributionResult {
// Single column mode: all memos in one column
if (columns === 1) {
const totalHeight = memos.reduce((sum, memo) => sum + (itemHeights.get(memo.name) || 0), prefixElementHeight);
return {
distribution: [Array.from({ length: memos.length }, (_, i) => i)],
columnHeights: [totalHeight],
};
}
// Initialize columns and their heights
const distribution: number[][] = Array.from({ length: columns }, () => []);
const columnHeights: number[] = Array(columns).fill(0);
const columnCounts: number[] = Array(columns).fill(0);
// Add prefix element height to first column
if (prefixElementHeight > 0) {
columnHeights[0] = prefixElementHeight;
}
let startIndex = 0;
// Pin the first memo to the first column to keep top-of-feed stable
if (memos.length > 0) {
const firstMemoHeight = itemHeights.get(memos[0].name) || 0;
distribution[0].push(0);
columnHeights[0] += firstMemoHeight;
columnCounts[0] += 1;
startIndex = 1;
}
for (let i = startIndex; i < memos.length; i++) {
const memo = memos[i];
const height = itemHeights.get(memo.name) || 0;
// Find column with minimum height
const shortestColumnIndex = findShortestColumnIndex(columnHeights, columnCounts);
distribution[shortestColumnIndex].push(i);
columnHeights[shortestColumnIndex] += height;
columnCounts[shortestColumnIndex] += 1;
}
return { distribution, columnHeights };
}
/**
* Finds the index of the column with the minimum height
* @param columnHeights - Array of column heights
* @param columnCounts - Array of items per column (for tie-breaking)
* @returns Index of the shortest column
*/
function findShortestColumnIndex(columnHeights: number[], columnCounts: number[]): number {
let minIndex = 0;
let minHeight = columnHeights[0];
for (let i = 1; i < columnHeights.length; i++) {
const currentHeight = columnHeights[i];
if (currentHeight < minHeight) {
minHeight = currentHeight;
minIndex = i;
continue;
}
// Tie-breaker: prefer column with fewer items to avoid stacking
if (currentHeight === minHeight && columnCounts[i] < columnCounts[minIndex]) {
minIndex = i;
}
}
return minIndex;
}

View File

@ -1,3 +1,25 @@
import MasonryView from "./MasonryView";
// Main component
export { default } from "./MasonryView";
export default MasonryView;
// Sub-components (exported for testing or advanced usage)
export { MasonryColumn } from "./MasonryColumn";
export { MasonryItem } from "./MasonryItem";
// Hooks
export { useMasonryLayout } from "./useMasonryLayout";
// Utilities
export { distributeItemsToColumns } from "./distributeItems";
// Types
export type {
MasonryViewProps,
MasonryItemProps,
MasonryColumnProps,
DistributionResult,
MemoWithHeight,
MemoRenderContext,
} from "./types";
// Constants
export { MINIMUM_MEMO_VIEWPORT_WIDTH, REDISTRIBUTION_DEBOUNCE_MS } from "./constants";

View File

@ -0,0 +1,81 @@
import { Memo } from "@/types/proto/api/v1/memo_service";
/**
* Render context passed to memo renderer
*/
export interface MemoRenderContext {
/** Whether to render in compact mode (automatically enabled for multi-column layouts) */
compact: boolean;
/** Current number of columns in the layout */
columns: number;
}
/**
* Props for the main MasonryView component
*/
export interface MasonryViewProps {
/** List of memos to display in masonry layout */
memoList: Memo[];
/** Render function for each memo. Second parameter provides layout context. */
renderer: (memo: Memo, context?: MemoRenderContext) => JSX.Element;
/** Optional element to display at the top of the first column (e.g., memo editor) */
prefixElement?: JSX.Element;
/** Force single column layout regardless of viewport width */
listMode?: boolean;
}
/**
* Props for individual MasonryItem component
*/
export interface MasonryItemProps {
/** The memo to render */
memo: Memo;
/** Render function for the memo */
renderer: (memo: Memo, context?: MemoRenderContext) => JSX.Element;
/** Render context for the memo */
renderContext: MemoRenderContext;
/** Callback when item height changes */
onHeightChange: (memoName: string, height: number) => void;
}
/**
* Props for MasonryColumn component
*/
export interface MasonryColumnProps {
/** Indices of memos in this column */
memoIndices: number[];
/** Full list of memos */
memoList: Memo[];
/** Render function for each memo */
renderer: (memo: Memo, context?: MemoRenderContext) => JSX.Element;
/** Render context for memos */
renderContext: MemoRenderContext;
/** Callback when item height changes */
onHeightChange: (memoName: string, height: number) => void;
/** Whether this is the first column (for prefix element) */
isFirstColumn: boolean;
/** Optional prefix element (only rendered in first column) */
prefixElement?: JSX.Element;
/** Ref for prefix element height measurement */
prefixElementRef?: React.RefObject<HTMLDivElement>;
}
/**
* Result of the distribution algorithm
*/
export interface DistributionResult {
/** Array of arrays, where each inner array contains memo indices for that column */
distribution: number[][];
/** Height of each column after distribution */
columnHeights: number[];
}
/**
* Memo item with measured height
*/
export interface MemoWithHeight {
/** Index of the memo in the original list */
index: number;
/** Measured height in pixels */
height: number;
}

View File

@ -0,0 +1,150 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Memo } from "@/types/proto/api/v1/memo_service";
import { MINIMUM_MEMO_VIEWPORT_WIDTH, REDISTRIBUTION_DEBOUNCE_MS } from "./constants";
import { distributeItemsToColumns } from "./distributeItems";
/**
* Custom hook for managing masonry layout state and logic
*
* Responsibilities:
* - Calculate optimal number of columns based on viewport width
* - Track item heights and trigger redistribution
* - Debounce redistribution to prevent excessive reflows
* - Handle window resize events
*
* @param memoList - Array of memos to layout
* @param listMode - Force single column mode
* @param containerRef - Reference to the container element
* @param prefixElementRef - Reference to the prefix element
* @returns Layout state and handlers
*/
export function useMasonryLayout(
memoList: Memo[],
listMode: boolean,
containerRef: React.RefObject<HTMLDivElement>,
prefixElementRef: React.RefObject<HTMLDivElement>,
) {
const [columns, setColumns] = useState(1);
const [itemHeights, setItemHeights] = useState<Map<string, number>>(new Map());
const [distribution, setDistribution] = useState<number[][]>([[]]);
const redistributionTimeoutRef = useRef<number | null>(null);
const itemHeightsRef = useRef<Map<string, number>>(itemHeights);
// Keep ref in sync with state
useEffect(() => {
itemHeightsRef.current = itemHeights;
}, [itemHeights]);
/**
* Calculate optimal number of columns based on container width
* Uses a scale factor to determine column count
*/
const calculateColumns = useCallback(() => {
if (!containerRef.current || listMode) return 1;
const containerWidth = containerRef.current.offsetWidth;
const scale = containerWidth / MINIMUM_MEMO_VIEWPORT_WIDTH;
return scale >= 2 ? Math.round(scale) : 1;
}, [containerRef, listMode]);
/**
* Recalculate memo distribution when layout changes
*/
const redistributeMemos = useCallback(() => {
const prefixHeight = prefixElementRef.current?.offsetHeight || 0;
setDistribution(() => {
const { distribution: newDistribution } = distributeItemsToColumns(memoList, columns, itemHeightsRef.current, prefixHeight);
return newDistribution;
});
}, [memoList, columns, prefixElementRef]);
/**
* Debounced redistribution to batch multiple height changes and prevent excessive reflows
*/
const debouncedRedistribute = useCallback(
(newItemHeights: Map<string, number>) => {
// Clear any pending redistribution
if (redistributionTimeoutRef.current) {
clearTimeout(redistributionTimeoutRef.current);
}
// Schedule new redistribution after debounce delay
redistributionTimeoutRef.current = window.setTimeout(() => {
const prefixHeight = prefixElementRef.current?.offsetHeight || 0;
setDistribution(() => {
const { distribution: newDistribution } = distributeItemsToColumns(memoList, columns, newItemHeights, prefixHeight);
return newDistribution;
});
}, REDISTRIBUTION_DEBOUNCE_MS);
},
[memoList, columns, prefixElementRef],
);
/**
* Handle height changes from individual memo items
*/
const handleHeightChange = useCallback(
(memoName: string, height: number) => {
setItemHeights((prevHeights) => {
const newItemHeights = new Map(prevHeights);
const previousHeight = prevHeights.get(memoName);
// Skip if height hasn't changed (avoid unnecessary updates)
if (previousHeight === height) {
return prevHeights;
}
newItemHeights.set(memoName, height);
// Use debounced redistribution to batch updates
debouncedRedistribute(newItemHeights);
return newItemHeights;
});
},
[debouncedRedistribute],
);
/**
* Handle window resize and calculate new column count
*/
useEffect(() => {
const handleResize = () => {
if (!containerRef.current) return;
const newColumns = calculateColumns();
if (newColumns !== columns) {
setColumns(newColumns);
}
};
handleResize();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [calculateColumns, columns, containerRef]);
/**
* Redistribute memos when columns or memo list change
*/
useEffect(() => {
redistributeMemos();
}, [columns, memoList, redistributeMemos]);
/**
* Cleanup timeout on unmount
*/
useEffect(() => {
return () => {
if (redistributionTimeoutRef.current) {
clearTimeout(redistributionTimeoutRef.current);
}
};
}, []);
return {
columns,
distribution,
handleHeightChange,
};
}

View File

@ -11,8 +11,10 @@ import {
SquareCheckIcon,
} from "lucide-react";
import { observer } from "mobx-react-lite";
import { useState } from "react";
import toast from "react-hot-toast";
import { useLocation } from "react-router-dom";
import ConfirmDialog from "@/components/ConfirmDialog";
import { markdownServiceClient } from "@/grpcweb";
import useNavigateTo from "@/hooks/useNavigateTo";
import { memoStore, userStore } from "@/store";
@ -49,6 +51,8 @@ const MemoActionMenu = observer((props: Props) => {
const t = useTranslate();
const location = useLocation();
const navigateTo = useNavigateTo();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [removeTasksDialogOpen, setRemoveTasksDialogOpen] = useState(false);
const hasCompletedTaskList = checkHasCompletedTaskList(memo);
const isInMemoDetailPage = location.pathname.startsWith(`/${memo.name}`);
const isComment = Boolean(memo.parent);
@ -101,7 +105,7 @@ const MemoActionMenu = observer((props: Props) => {
},
["state"],
);
toast(message);
toast.success(message);
} catch (error: any) {
toast.error(error.details);
console.error(error);
@ -123,48 +127,50 @@ const MemoActionMenu = observer((props: Props) => {
toast.success(t("message.succeed-copy-link"));
};
const handleDeleteMemoClick = async () => {
const confirmed = window.confirm(t("memo.delete-confirm"));
if (confirmed) {
await memoStore.deleteMemo(memo.name);
toast.success(t("message.deleted-successfully"));
if (isInMemoDetailPage) {
navigateTo("/");
}
memoUpdatedCallback();
}
const handleDeleteMemoClick = () => {
setDeleteDialogOpen(true);
};
const handleRemoveCompletedTaskListItemsClick = async () => {
const confirmed = window.confirm(t("memo.remove-completed-task-list-items-confirm"));
if (confirmed) {
const newNodes = JSON.parse(JSON.stringify(memo.nodes));
for (const node of newNodes) {
if (node.type === NodeType.LIST && node.listNode?.children?.length > 0) {
const children = node.listNode.children;
for (let i = 0; i < children.length; i++) {
if (children[i].type === NodeType.TASK_LIST_ITEM && children[i].taskListItemNode?.complete) {
// Remove completed taskList item and next line breaks
const confirmDeleteMemo = async () => {
await memoStore.deleteMemo(memo.name);
toast.success(t("message.deleted-successfully"));
if (isInMemoDetailPage) {
navigateTo("/");
}
memoUpdatedCallback();
};
const handleRemoveCompletedTaskListItemsClick = () => {
setRemoveTasksDialogOpen(true);
};
const confirmRemoveCompletedTaskListItems = async () => {
const newNodes = JSON.parse(JSON.stringify(memo.nodes));
for (const node of newNodes) {
if (node.type === NodeType.LIST && node.listNode?.children?.length > 0) {
const children = node.listNode.children;
for (let i = 0; i < children.length; i++) {
if (children[i].type === NodeType.TASK_LIST_ITEM && children[i].taskListItemNode?.complete) {
// Remove completed taskList item and next line breaks
children.splice(i, 1);
if (children[i]?.type === NodeType.LINE_BREAK) {
children.splice(i, 1);
if (children[i]?.type === NodeType.LINE_BREAK) {
children.splice(i, 1);
}
i--;
}
i--;
}
}
}
const { markdown } = await markdownServiceClient.restoreMarkdownNodes({ nodes: newNodes });
await memoStore.updateMemo(
{
name: memo.name,
content: markdown,
},
["content"],
);
toast.success(t("message.remove-completed-task-list-items-successfully"));
memoUpdatedCallback();
}
const { markdown } = await markdownServiceClient.restoreMarkdownNodes({ nodes: newNodes });
await memoStore.updateMemo(
{
name: memo.name,
content: markdown,
},
["content"],
);
toast.success(t("message.remove-completed-task-list-items-successfully"));
memoUpdatedCallback();
};
return (
@ -216,6 +222,27 @@ const MemoActionMenu = observer((props: Props) => {
</>
)}
</DropdownMenuContent>
{/* Delete confirmation dialog */}
<ConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
title={t("memo.delete-confirm")}
confirmLabel={t("common.delete")}
description={t("memo.delete-confirm-description")}
cancelLabel={t("common.cancel")}
onConfirm={confirmDeleteMemo}
confirmVariant="destructive"
/>
{/* Remove completed tasks confirmation */}
<ConfirmDialog
open={removeTasksDialogOpen}
onOpenChange={setRemoveTasksDialogOpen}
title={t("memo.remove-completed-task-list-items-confirm")}
confirmLabel={t("common.confirm")}
cancelLabel={t("common.cancel")}
onConfirm={confirmRemoveCompletedTaskListItems}
confirmVariant="destructive"
/>
</DropdownMenu>
);
});

View File

@ -1,12 +1,24 @@
import { createElement } from "react";
import { Node } from "@/types/proto/api/v1/markdown_service";
import Renderer from "./Renderer";
interface Props {
tagName: string;
attributes: { [key: string]: string };
children: Node[];
isSelfClosing: boolean;
}
const HTMLElement: React.FC<Props> = ({ tagName, attributes }: Props) => {
return createElement(tagName, attributes);
const HTMLElement: React.FC<Props> = ({ tagName, attributes, children, isSelfClosing }: Props) => {
if (isSelfClosing) {
return createElement(tagName, attributes);
}
return createElement(
tagName,
attributes,
children.map((child, index) => <Renderer key={`${child.type}-${index}`} index={String(index)} node={child} />),
);
};
export default HTMLElement;

View File

@ -95,6 +95,7 @@ const LocationSelector = (props: Props) => {
return;
}
// Sync lat/lng input values from position
const newLat = String(state.position.lat);
const newLng = String(state.position.lng);
if (state.latInput !== newLat || state.lngInput !== newLng) {

View File

@ -8,7 +8,7 @@ interface Props {
collapsed?: boolean;
}
const BrandBanner = observer((props: Props) => {
const MemosLogo = observer((props: Props) => {
const { collapsed } = props;
const workspaceGeneralSetting = workspaceStore.state.generalSetting;
const title = workspaceGeneralSetting.customProfile?.title || "Memos";
@ -24,4 +24,4 @@ const BrandBanner = observer((props: Props) => {
);
});
export default BrandBanner;
export default MemosLogo;

View File

@ -8,8 +8,8 @@ import { cn } from "@/lib/utils";
import { Routes } from "@/router";
import { userStore } from "@/store";
import { useTranslate } from "@/utils/i18n";
import BrandBanner from "./BrandBanner";
import UserBanner from "./UserBanner";
import MemosLogo from "./MemosLogo";
import UserMenu from "./UserMenu";
interface NavLinkItem {
id: string;
@ -67,7 +67,7 @@ const Navigation = observer((props: Props) => {
<header className={cn("w-full h-full overflow-auto flex flex-col justify-between items-start gap-4 hide-scrollbar", className)}>
<div className="w-full px-1 py-1 flex flex-col justify-start items-start space-y-2 overflow-auto overflow-x-hidden hide-scrollbar shrink">
<NavLink className="mb-3 cursor-default" to={currentUser ? Routes.ROOT : Routes.EXPLORE}>
<BrandBanner collapsed={collapsed} />
<MemosLogo collapsed={collapsed} />
</NavLink>
{navLinks.map((navLink) => (
<NavLink
@ -105,7 +105,7 @@ const Navigation = observer((props: Props) => {
</div>
{currentUser && (
<div className={cn("w-full flex flex-col justify-end", props.collapsed ? "items-center" : "items-start pl-3")}>
<UserBanner collapsed={collapsed} />
<UserMenu collapsed={collapsed} />
</div>
)}
</header>

View File

@ -12,11 +12,11 @@ import { State } from "@/types/proto/api/v1/common";
import { Memo } from "@/types/proto/api/v1/memo_service";
import { useTranslate } from "@/utils/i18n";
import Empty from "../Empty";
import MasonryView from "../MasonryView";
import MasonryView, { MemoRenderContext } from "../MasonryView";
import MemoEditor from "../MemoEditor";
interface Props {
renderer: (memo: Memo) => JSX.Element;
renderer: (memo: Memo, context?: MemoRenderContext) => JSX.Element;
listSort?: (list: Memo[]) => Memo[];
state?: State;
orderBy?: string;

View File

@ -2,6 +2,7 @@ import copy from "copy-to-clipboard";
import { ClipboardIcon, TrashIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import ConfirmDialog from "@/components/ConfirmDialog";
import { Button } from "@/components/ui/button";
import { userServiceClient } from "@/grpcweb";
import useCurrentUser from "@/hooks/useCurrentUser";
@ -20,6 +21,7 @@ const AccessTokenSection = () => {
const currentUser = useCurrentUser();
const [userAccessTokens, setUserAccessTokens] = useState<UserAccessToken[]>([]);
const createTokenDialog = useDialog();
const [deleteTarget, setDeleteTarget] = useState<UserAccessToken | undefined>(undefined);
useEffect(() => {
listAccessTokens(currentUser.name).then((accessTokens) => {
@ -27,9 +29,10 @@ const AccessTokenSection = () => {
});
}, []);
const handleCreateAccessTokenDialogConfirm = async () => {
const handleCreateAccessTokenDialogConfirm = async (created: UserAccessToken) => {
const accessTokens = await listAccessTokens(currentUser.name);
setUserAccessTokens(accessTokens);
toast.success(t("setting.access-token-section.create-dialog.access-token-created", { description: created.description }));
};
const handleCreateToken = () => {
@ -42,12 +45,17 @@ const AccessTokenSection = () => {
};
const handleDeleteAccessToken = async (userAccessToken: UserAccessToken) => {
const formatedAccessToken = getFormatedAccessToken(userAccessToken.accessToken);
const confirmed = window.confirm(t("setting.access-token-section.access-token-deletion", { accessToken: formatedAccessToken }));
if (confirmed) {
await userServiceClient.deleteUserAccessToken({ name: userAccessToken.name });
setUserAccessTokens(userAccessTokens.filter((token) => token.accessToken !== userAccessToken.accessToken));
}
setDeleteTarget(userAccessToken);
};
const confirmDeleteAccessToken = async () => {
if (!deleteTarget) return;
const { name: tokenName, description } = deleteTarget;
await userServiceClient.deleteUserAccessToken({ name: tokenName });
// Filter by stable resource name to avoid ambiguity with duplicate token strings
setUserAccessTokens((prev) => prev.filter((token) => token.name !== tokenName));
setDeleteTarget(undefined);
toast.success(t("setting.access-token-section.access-token-deleted", { description }));
};
const getFormatedAccessToken = (accessToken: string) => {
@ -134,6 +142,16 @@ const AccessTokenSection = () => {
onOpenChange={createTokenDialog.setOpen}
onSuccess={handleCreateAccessTokenDialogConfirm}
/>
<ConfirmDialog
open={!!deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(undefined)}
title={deleteTarget ? t("setting.access-token-section.access-token-deletion", { description: deleteTarget.description }) : ""}
description={t("setting.access-token-section.access-token-deletion-description")}
confirmLabel={t("common.delete")}
cancelLabel={t("common.cancel")}
onConfirm={confirmDeleteAccessToken}
confirmVariant="destructive"
/>
</div>
);
};

View File

@ -2,6 +2,8 @@ import { sortBy } from "lodash-es";
import { MoreVerticalIcon, PlusIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import React, { useEffect, useState } from "react";
import toast from "react-hot-toast";
import ConfirmDialog from "@/components/ConfirmDialog";
import { Button } from "@/components/ui/button";
import { userServiceClient } from "@/grpcweb";
import useCurrentUser from "@/hooks/useCurrentUser";
@ -21,6 +23,8 @@ const MemberSection = observer(() => {
const editDialog = useDialog();
const [editingUser, setEditingUser] = useState<User | undefined>();
const sortedUsers = sortBy(users, "id");
const [archiveTarget, setArchiveTarget] = useState<User | undefined>(undefined);
const [deleteTarget, setDeleteTarget] = useState<User | undefined>(undefined);
useEffect(() => {
fetchUsers();
@ -52,20 +56,26 @@ const MemberSection = observer(() => {
};
const handleArchiveUserClick = async (user: User) => {
const confirmed = window.confirm(t("setting.member-section.archive-warning", { username: user.displayName }));
if (confirmed) {
await userServiceClient.updateUser({
user: {
name: user.name,
state: State.ARCHIVED,
},
updateMask: ["state"],
});
fetchUsers();
}
setArchiveTarget(user);
};
const confirmArchiveUser = async () => {
if (!archiveTarget) return;
const username = archiveTarget.username;
await userServiceClient.updateUser({
user: {
name: archiveTarget.name,
state: State.ARCHIVED,
},
updateMask: ["state"],
});
setArchiveTarget(undefined);
toast.success(t("setting.member-section.archive-success", { username }));
await fetchUsers();
};
const handleRestoreUserClick = async (user: User) => {
const { username } = user;
await userServiceClient.updateUser({
user: {
name: user.name,
@ -73,15 +83,21 @@ const MemberSection = observer(() => {
},
updateMask: ["state"],
});
fetchUsers();
toast.success(t("setting.member-section.restore-success", { username }));
await fetchUsers();
};
const handleDeleteUserClick = async (user: User) => {
const confirmed = window.confirm(t("setting.member-section.delete-warning", { username: user.displayName }));
if (confirmed) {
await userStore.deleteUser(user.name);
fetchUsers();
}
setDeleteTarget(user);
};
const confirmDeleteUser = async () => {
if (!deleteTarget) return;
const { username, name } = deleteTarget;
await userStore.deleteUser(name);
setDeleteTarget(undefined);
toast.success(t("setting.member-section.delete-success", { username }));
await fetchUsers();
};
return (
@ -169,6 +185,28 @@ const MemberSection = observer(() => {
{/* Edit User Dialog */}
<CreateUserDialog open={editDialog.isOpen} onOpenChange={editDialog.setOpen} user={editingUser} onSuccess={fetchUsers} />
<ConfirmDialog
open={!!archiveTarget}
onOpenChange={(open) => !open && setArchiveTarget(undefined)}
title={archiveTarget ? t("setting.member-section.archive-warning", { username: archiveTarget.username }) : ""}
description={archiveTarget ? t("setting.member-section.archive-warning-description") : ""}
confirmLabel={t("common.confirm")}
cancelLabel={t("common.cancel")}
onConfirm={confirmArchiveUser}
confirmVariant="default"
/>
<ConfirmDialog
open={!!deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(undefined)}
title={deleteTarget ? t("setting.member-section.delete-warning", { username: deleteTarget.username }) : ""}
description={deleteTarget ? t("setting.member-section.delete-warning-description") : ""}
confirmLabel={t("common.delete")}
cancelLabel={t("common.cancel")}
onConfirm={confirmDeleteUser}
confirmVariant="destructive"
/>
</div>
);
});

View File

@ -1,6 +1,7 @@
import { MoreVerticalIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import ConfirmDialog from "@/components/ConfirmDialog";
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Separator } from "@/components/ui/separator";
@ -15,6 +16,7 @@ const SSOSection = () => {
const [identityProviderList, setIdentityProviderList] = useState<IdentityProvider[]>([]);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [editingIdentityProvider, setEditingIdentityProvider] = useState<IdentityProvider | undefined>();
const [deleteTarget, setDeleteTarget] = useState<IdentityProvider | undefined>(undefined);
useEffect(() => {
fetchIdentityProviderList();
@ -26,16 +28,19 @@ const SSOSection = () => {
};
const handleDeleteIdentityProvider = async (identityProvider: IdentityProvider) => {
const confirmed = window.confirm(t("setting.sso-section.confirm-delete", { name: identityProvider.title }));
if (confirmed) {
try {
await identityProviderServiceClient.deleteIdentityProvider({ name: identityProvider.name });
} catch (error: any) {
console.error(error);
toast.error(error.details);
}
await fetchIdentityProviderList();
setDeleteTarget(identityProvider);
};
const confirmDeleteIdentityProvider = async () => {
if (!deleteTarget) return;
try {
await identityProviderServiceClient.deleteIdentityProvider({ name: deleteTarget.name });
} catch (error: any) {
console.error(error);
toast.error(error.details);
}
await fetchIdentityProviderList();
setDeleteTarget(undefined);
};
const handleCreateIdentityProvider = () => {
@ -112,6 +117,16 @@ const SSOSection = () => {
identityProvider={editingIdentityProvider}
onSuccess={handleDialogSuccess}
/>
<ConfirmDialog
open={!!deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(undefined)}
title={deleteTarget ? t("setting.sso-section.confirm-delete", { name: deleteTarget.title }) : ""}
confirmLabel={t("common.delete")}
cancelLabel={t("common.cancel")}
onConfirm={confirmDeleteIdentityProvider}
confirmVariant="destructive"
/>
</div>
);
};

View File

@ -1,6 +1,7 @@
import { ClockIcon, MonitorIcon, SmartphoneIcon, TabletIcon, TrashIcon, WifiIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import ConfirmDialog from "@/components/ConfirmDialog";
import { Button } from "@/components/ui/button";
import { userServiceClient } from "@/grpcweb";
import useCurrentUser from "@/hooks/useCurrentUser";
@ -16,6 +17,7 @@ const UserSessionsSection = () => {
const t = useTranslate();
const currentUser = useCurrentUser();
const [userSessions, setUserSessions] = useState<UserSession[]>([]);
const [revokeTarget, setRevokeTarget] = useState<UserSession | undefined>(undefined);
useEffect(() => {
listUserSessions(currentUser.name).then((sessions) => {
@ -24,13 +26,15 @@ const UserSessionsSection = () => {
}, []);
const handleRevokeSession = async (userSession: UserSession) => {
const formattedSessionId = getFormattedSessionId(userSession.sessionId);
const confirmed = window.confirm(t("setting.user-sessions-section.session-revocation", { sessionId: formattedSessionId }));
if (confirmed) {
await userServiceClient.revokeUserSession({ name: userSession.name });
setUserSessions(userSessions.filter((session) => session.sessionId !== userSession.sessionId));
toast.success(t("setting.user-sessions-section.session-revoked"));
}
setRevokeTarget(userSession);
};
const confirmRevokeSession = async () => {
if (!revokeTarget) return;
await userServiceClient.revokeUserSession({ name: revokeTarget.name });
setUserSessions(userSessions.filter((session) => session.sessionId !== revokeTarget.sessionId));
toast.success(t("setting.user-sessions-section.session-revoked"));
setRevokeTarget(undefined);
};
const getFormattedSessionId = (sessionId: string) => {
@ -148,6 +152,22 @@ const UserSessionsSection = () => {
</div>
</div>
</div>
<ConfirmDialog
open={!!revokeTarget}
onOpenChange={(open) => !open && setRevokeTarget(undefined)}
title={
revokeTarget
? t("setting.user-sessions-section.session-revocation", {
sessionId: getFormattedSessionId(revokeTarget.sessionId),
})
: ""
}
description={revokeTarget ? t("setting.user-sessions-section.session-revocation-description") : ""}
confirmLabel={t("setting.user-sessions-section.revoke-session-button")}
cancelLabel={t("common.cancel")}
onConfirm={confirmRevokeSession}
confirmVariant="destructive"
/>
</div>
</div>
);

View File

@ -1,6 +1,8 @@
import { ExternalLinkIcon, TrashIcon } from "lucide-react";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { Link } from "react-router-dom";
import ConfirmDialog from "@/components/ConfirmDialog";
import { Button } from "@/components/ui/button";
import { userServiceClient } from "@/grpcweb";
import useCurrentUser from "@/hooks/useCurrentUser";
@ -13,6 +15,7 @@ const WebhookSection = () => {
const currentUser = useCurrentUser();
const [webhooks, setWebhooks] = useState<UserWebhook[]>([]);
const [isCreateWebhookDialogOpen, setIsCreateWebhookDialogOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<UserWebhook | undefined>(undefined);
const listWebhooks = async () => {
if (!currentUser) return [];
@ -30,16 +33,22 @@ const WebhookSection = () => {
const handleCreateWebhookDialogConfirm = async () => {
const webhooks = await listWebhooks();
const name = webhooks[webhooks.length - 1]?.displayName || "";
setWebhooks(webhooks);
setIsCreateWebhookDialogOpen(false);
toast.success(t("setting.webhook-section.create-dialog.create-webhook-success", { name }));
};
const handleDeleteWebhook = async (webhook: UserWebhook) => {
const confirmed = window.confirm(`Are you sure to delete webhook \`${webhook.displayName}\`? You cannot undo this action.`);
if (confirmed) {
await userServiceClient.deleteUserWebhook({ name: webhook.name });
setWebhooks(webhooks.filter((item) => item.name !== webhook.name));
}
setDeleteTarget(webhook);
};
const confirmDeleteWebhook = async () => {
if (!deleteTarget) return;
await userServiceClient.deleteUserWebhook({ name: deleteTarget.name });
setWebhooks(webhooks.filter((item) => item.name !== deleteTarget.name));
setDeleteTarget(undefined);
toast.success(t("setting.webhook-section.delete-dialog.delete-webhook-success", { name: deleteTarget.displayName }));
};
return (
@ -79,12 +88,7 @@ const WebhookSection = () => {
{webhook.url}
</td>
<td className="relative whitespace-nowrap px-3 py-2 text-right text-sm">
<Button
variant="ghost"
onClick={() => {
handleDeleteWebhook(webhook);
}}
>
<Button variant="ghost" onClick={() => handleDeleteWebhook(webhook)}>
<TrashIcon className="text-destructive w-4 h-auto" />
</Button>
</td>
@ -118,6 +122,16 @@ const WebhookSection = () => {
onOpenChange={setIsCreateWebhookDialogOpen}
onSuccess={handleCreateWebhookDialogConfirm}
/>
<ConfirmDialog
open={!!deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(undefined)}
title={t("setting.webhook-section.delete-dialog.delete-webhook-title", { name: deleteTarget?.displayName || "" })}
description={t("setting.webhook-section.delete-dialog.delete-webhook-description")}
confirmLabel={t("common.delete")}
cancelLabel={t("common.cancel")}
onConfirm={confirmDeleteWebhook}
confirmVariant="destructive"
/>
</div>
);
};

View File

@ -1,37 +1,55 @@
import { cloneElement, isValidElement } from "react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { StatCardProps } from "@/types/statistics";
export const StatCard = ({ icon, label, count, onClick, tooltip, className }: StatCardProps) => {
const content = (
<div
const iconNode = isValidElement(icon)
? cloneElement(icon, {
className: cn("h-3.5 w-3.5", icon.props.className),
})
: icon;
const countNode = (() => {
if (typeof count === "number" || typeof count === "string") {
return <span className="text-foreground/80">{count}</span>;
}
if (isValidElement(count)) {
return cloneElement(count, {
className: cn("text-foreground/80", count.props.className),
});
}
return <span className="text-foreground/80">{count}</span>;
})();
const button = (
<button
type="button"
onClick={onClick}
className={cn(
"w-auto border pl-1.5 pr-2 py-0.5 rounded-md flex justify-between items-center",
"cursor-pointer hover:bg-muted transition-colors",
"inline-flex items-center gap-1 rounded-md border border-border/40 bg-background/80 px-1 pr-2 py-0.5 text-sm leading-none text-muted-foreground transition-colors",
"hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring/70 focus-visible:ring-offset-1 focus-visible:ring-offset-background",
className,
)}
onClick={onClick}
>
<div className="w-auto flex justify-start items-center mr-1">
{icon}
<span className="block text-sm opacity-80">{label}</span>
</div>
<span className="text-sm truncate opacity-80">{count}</span>
</div>
<span className="flex h-5 w-5 items-center justify-center text-muted-foreground/80">{iconNode}</span>
<span className="truncate text-sm text-foreground/70">{label}</span>
<span className="ml-1 flex items-center text-sm">{countNode}</span>
</button>
);
if (tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
if (!tooltip) {
return button;
}
return content;
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};

View File

@ -46,10 +46,10 @@ const StatisticsView = observer(() => {
/>
</div>
<div className="pt-1 w-full flex flex-row justify-start items-center gap-1 flex-wrap">
<div className="pt-2 w-full flex flex-wrap items-center gap-2">
{isRootPath && hasPinnedMemos && (
<StatCard
icon={<BookmarkIcon className="w-4 h-auto mr-1 opacity-70" />}
icon={<BookmarkIcon className="opacity-70" />}
label={t("common.pinned")}
count={userStore.state.currentUserStats!.pinnedMemos.length}
onClick={() => handleFilterClick("pinned")}
@ -57,20 +57,14 @@ const StatisticsView = observer(() => {
)}
<StatCard
icon={<LinkIcon className="w-4 h-auto mr-1 opacity-70" />}
icon={<LinkIcon className="opacity-70" />}
label={t("memo.links")}
count={memoTypeStats.linkCount}
onClick={() => handleFilterClick("property.hasLink")}
/>
<StatCard
icon={
memoTypeStats.undoCount > 0 ? (
<ListTodoIcon className="w-4 h-auto mr-1 opacity-70" />
) : (
<CheckCircleIcon className="w-4 h-auto mr-1 opacity-70" />
)
}
icon={memoTypeStats.undoCount > 0 ? <ListTodoIcon className="opacity-70" /> : <CheckCircleIcon className="opacity-70" />}
label={t("memo.to-do")}
count={
memoTypeStats.undoCount > 0 ? (
@ -88,7 +82,7 @@ const StatisticsView = observer(() => {
/>
<StatCard
icon={<Code2Icon className="w-4 h-auto mr-1 opacity-70" />}
icon={<Code2Icon className="opacity-70" />}
label={t("memo.code")}
count={memoTypeStats.codeCount}
onClick={() => handleFilterClick("property.hasCode")}

View File

@ -12,7 +12,7 @@ interface Props {
collapsed?: boolean;
}
const UserBanner = (props: Props) => {
const UserMenu = (props: Props) => {
const { collapsed } = props;
const t = useTranslate();
const navigateTo = useNavigateTo();
@ -20,6 +20,21 @@ const UserBanner = (props: Props) => {
const handleSignOut = async () => {
await authServiceClient.deleteSession({});
// Clear user-specific localStorage items (e.g., drafts)
// Preserve app-wide settings like theme
const keysToPreserve = ["memos-theme", "tag-view-as-tree", "tag-tree-auto-expand", "viewStore"];
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && !keysToPreserve.includes(key)) {
keysToRemove.push(key);
}
}
keysToRemove.forEach((key) => localStorage.removeItem(key));
window.location.href = Routes.AUTH;
};
@ -65,4 +80,4 @@ const UserBanner = (props: Props) => {
);
};
export default UserBanner;
export default UserMenu;

View File

@ -143,7 +143,8 @@
},
"copy-link": "Copy Link",
"count-memos-in-date": "{{count}} {{memos}} in {{date}}",
"delete-confirm": "Are you sure you want to delete this memo? THIS ACTION IS IRREVERSIBLE",
"delete-confirm": "Are you sure you want to delete this memo?",
"delete-confirm-description": "This action is irreversible. Attachments, links, and references will also be removed.",
"direction": "Direction",
"direction-asc": "Ascending",
"direction-desc": "Descending",
@ -174,7 +175,7 @@
"archived-successfully": "Archived successfully",
"change-memo-created-time": "Change memo created time",
"copied": "Copied",
"deleted-successfully": "Deleted successfully",
"deleted-successfully": "Memo deleted successfully",
"description-is-required": "Description is required",
"failed-to-embed-memo": "Failed to embed memo",
"fill-all": "Please fill in all fields.",
@ -219,6 +220,8 @@
},
"delete-resource": "Delete Resource",
"delete-selected-resources": "Delete Selected Resources",
"delete-all-unused": "Delete all unused",
"delete-all-unused-confirm": "Are you sure you want to delete all unused resources? THIS ACTION IS IRREVERSIBLE",
"fetching-data": "Fetching data…",
"file-drag-drop-prompt": "Drag and drop your file here to upload file",
"linked-amount": "Linked amount",
@ -226,7 +229,7 @@
"no-resources": "No resources.",
"no-unused-resources": "No unused resources",
"reset-link": "Reset Link",
"reset-link-prompt": "Are you sure to reset the link? This will break all current link usages. THIS ACTION IS IRREVERSIBLE",
"reset-link-prompt": "Are you sure you want to reset the link? This will break all current link usages. THIS ACTION IS IRREVERSIBLE",
"reset-resource-link": "Reset Resource Link",
"unused-resources": "Unused resources"
},
@ -237,8 +240,11 @@
"setting": {
"access-token-section": {
"access-token-copied-to-clipboard": "Access token copied to clipboard",
"access-token-deletion": "Are you sure to delete access token {{accessToken}}? THIS ACTION IS IRREVERSIBLE.",
"access-token-deletion": "Are you sure you want to delete access token `{{description}}`?",
"access-token-deletion-description": "This action is irreversible. You will need to update any services using this token to use a new token.",
"access-token-deleted": "Access token `{{description}}` deleted",
"create-dialog": {
"access-token-created": "Access token `{{description}}` created",
"create-access-token": "Create Access Token",
"created-at": "Created At",
"description": "Description",
@ -262,9 +268,11 @@
"expires": "Expires",
"current": "Current",
"never": "Never",
"session-revocation": "Are you sure to revoke session {{sessionId}}? You will need to sign in again on that device.",
"session-revocation": "Are you sure you want to revoke session `{{sessionId}}`?",
"session-revocation-description": "You will need to sign in again on that device.",
"session-revoked": "Session revoked successfully",
"revoke-session": "Revoke session",
"revoke-session-button": "Revoke",
"cannot-revoke-current": "Cannot revoke current session",
"no-sessions": "No active sessions found"
},
@ -286,10 +294,15 @@
"member-section": {
"admin": "Admin",
"archive-member": "Archive member",
"archive-warning": "Are you sure to archive {{username}}?",
"archive-warning": "Are you sure you want to archive {{username}}?",
"archive-warning-description": "Archiving disables the account. You can restore or delete it later.",
"archive-success": "{{username}} archived successfully",
"restore-success": "{{username}} restored successfully",
"create-a-member": "Create a member",
"delete-member": "Delete Member",
"delete-warning": "Are you sure to delete {{username}}? THIS ACTION IS IRREVERSIBLE",
"delete-warning": "Are you sure you want to delete {{username}}?",
"delete-warning-description": "THIS ACTION IS IRREVERSIBLE",
"delete-success": "{{username}} deleted successfully",
"user": "User"
},
"memo-related": "Memo",
@ -309,12 +322,16 @@
"default-memo-visibility": "Default memo visibility",
"theme": "Theme"
},
"shortcut": {
"delete-confirm": "Are you sure you want to delete shortcut `{{title}}`?",
"delete-success": "Shortcut `{{title}}` deleted successfully"
},
"sso": "SSO",
"sso-section": {
"authorization-endpoint": "Authorization endpoint",
"client-id": "Client ID",
"client-secret": "Client secret",
"confirm-delete": "Are you sure to delete \"{{name}}\" SSO configuration? THIS ACTION IS IRREVERSIBLE",
"confirm-delete": "Are you sure you want to delete `{{name}}` SSO configuration? THIS ACTION IS IRREVERSIBLE",
"create-sso": "Create SSO",
"custom": "Custom",
"delete-sso": "Confirm delete",
@ -367,7 +384,7 @@
"url-prefix-placeholder": "Custom URL prefix, optional",
"url-suffix": "URL suffix",
"url-suffix-placeholder": "Custom URL suffix, optional",
"warning-text": "Are you sure to delete storage service \"{{name}}\"? THIS ACTION IS IRREVERSIBLE"
"warning-text": "Are you sure you want to delete storage service `{{name}}`? THIS ACTION IS IRREVERSIBLE"
},
"system": "System",
"system-section": {
@ -384,7 +401,7 @@
},
"disable-markdown-shortcuts-in-editor": "Disable Markdown shortcuts in editor",
"disable-password-login": "Disable password login",
"disable-password-login-final-warning": "Please type \"CONFIRM\" if you know what you are doing.",
"disable-password-login-final-warning": "Please type `CONFIRM` if you know what you are doing.",
"disable-password-login-warning": "This will disable password login for all users. It is not possible to log in without reverting this setting in the database if your configured identity providers fail. Youll also have to be extra careful when removing an identity provider",
"disable-public-memos": "Disable public memos",
"display-with-updated-time": "Display with updated time",
@ -403,11 +420,17 @@
"create-dialog": {
"an-easy-to-remember-name": "An easy-to-remember name",
"create-webhook": "Create webhook",
"create-webhook-success": "Webhook `{{name}}` created",
"edit-webhook": "Edit webhook",
"payload-url": "Payload URL",
"title": "Title",
"url-example-post-receive": "https://example.com/postreceive"
},
"delete-dialog": {
"delete-webhook-description": "This action is irreversible.",
"delete-webhook-title": "Are you sure you want to delete webhook `{{name}}`?",
"delete-webhook-success": "Webhook `{{name}}` deleted successfully"
},
"no-webhooks-found": "No webhooks found.",
"title": "Webhooks",
"url": "URL"
@ -427,8 +450,9 @@
"all-tags": "All Tags",
"create-tag": "Create Tag",
"create-tags-guide": "You can create tags by inputting `#tag`.",
"delete-confirm": "Are you sure to delete this tag? All related memos will be archived.",
"delete-confirm": "Are you sure you want to delete this tag? All related memos will be archived.",
"delete-tag": "Delete Tag",
"delete-success": "Tag deleted successfully",
"new-name": "New Name",
"no-tag-found": "No tag found",
"old-name": "Old Name",

View File

@ -12,7 +12,7 @@
"new-password": "新密碼",
"repeat-new-password": "再次輸入新密碼",
"sign-in-tip": "已經有帳戶了嗎?",
"sign-up-tip": "還沒有帳戶"
"sign-up-tip": "還沒有帳戶"
},
"common": {
"about": "關於",
@ -21,6 +21,7 @@
"archive": "封存",
"archived": "已封存",
"attachments": "附件",
"auto-expand": "自動展開",
"avatar": "頭像",
"basic": "基礎",
"beta": "測試版",
@ -114,7 +115,7 @@
"add-your-comment-here": "在這裡添加您的評論...",
"any-thoughts": "任何想法...",
"save": "儲存",
"no-changes-detected": "未檢測到變更"
"no-changes-detected": "未發現變更"
},
"filters": {
"has-code": "有程式碼",
@ -122,9 +123,9 @@
"has-task-list": "有待辦事項"
},
"inbox": {
"failed-to-load": "載入通知失敗",
"memo-comment": "{{user}} 對您的 {{memo}} 發表了評論。",
"version-update": "新版本 {{version}} 現已推出!"
"version-update": "新版本 {{version}} 現已推出!",
"failed-to-load": "載入失敗"
},
"markdown": {
"checkbox": "核取方塊",
@ -141,8 +142,9 @@
"write-a-comment": "寫下評論"
},
"copy-link": "複製連結",
"count-memos-in-date": "{{date}}的{{count}}條備忘錄",
"delete-confirm": "您確定要刪除此備忘錄?(此操作無法恢復)",
"count-memos-in-date": "{{date}} 的 {{count}} 條備忘錄",
"delete-confirm": "您確定要刪除此備忘錄嗎?",
"delete-confirm-description": "此操作無法恢復。附件、連結和引用將一併被移除。",
"direction": "排序",
"direction-asc": "升序",
"direction-desc": "降序",
@ -154,7 +156,7 @@
"no-memos": "無備忘錄",
"order-by": "排序",
"remove-completed-task-list-items": "移除已完成的待辦事項",
"remove-completed-task-list-items-confirm": "您確定要移除所有完成的待辦事項嗎?(此操作無法恢復)",
"remove-completed-task-list-items-confirm": "您確定要移除所有完成的待辦事項嗎?此操作無法恢復。",
"search-placeholder": "搜尋備忘錄",
"show-less": "顯示較少",
"show-more": "查看更多",
@ -173,12 +175,12 @@
"archived-successfully": "封存成功",
"change-memo-created-time": "變更備忘錄建立時間",
"copied": "已複製",
"deleted-successfully": "已成功刪除",
"deleted-successfully": "刪除成功",
"description-is-required": "說明必填",
"failed-to-embed-memo": "嵌入備忘錄失敗",
"fill-all": "請填寫所有欄位。",
"fill-all-required-fields": "請填寫所有必填欄位",
"maximum-upload-size-is": "最大允許檔案上傳大小 {{size}} MiB",
"maximum-upload-size-is": "最大允許上傳大小 {{size}} MiB",
"memo-not-found": "未找到備忘錄",
"new-password-not-match": "新密碼不一致。",
"no-data": "或許尋覓虛空,或者改換選擇之軌跡。",
@ -218,6 +220,8 @@
},
"delete-resource": "刪除檔案",
"delete-selected-resources": "刪除所選的檔案",
"delete-all-unused": "刪除所有未使用的檔案",
"delete-all-unused-confirm": "您確定要刪除所有未使用的檔案嗎?此操作無法恢復。",
"fetching-data": "抓取資料...",
"file-drag-drop-prompt": "將您的檔案拖曳到此處以上傳",
"linked-amount": "連結數量",
@ -225,7 +229,7 @@
"no-resources": "無檔案。",
"no-unused-resources": "無未使用的檔案",
"reset-link": "重設連結",
"reset-link-prompt": "您確定要重設連結嗎?這將導致當前使用的連結失效。(此操作無法恢復)",
"reset-link-prompt": "您確定要重設連結嗎?這將導致當前使用的連結失效。此操作無法恢復。",
"reset-resource-link": "重設檔案連結",
"unused-resources": "未使用的檔案"
},
@ -236,8 +240,11 @@
"setting": {
"access-token-section": {
"access-token-copied-to-clipboard": "存取令牌已複製到剪貼簿",
"access-token-deletion": "您確定要刪除存取令牌 {{accessToken}} 嗎?(此操作無法恢復)",
"access-token-deletion": "您確定要刪除存取令牌 `{{description}}` 嗎?",
"access-token-deletion-description": "此操作無法恢復。您需要更新所有使用此令牌的服務以使用新的令牌。",
"access-token-deleted": "存取令牌 `{{description}}` 已刪除",
"create-dialog": {
"access-token-created": "存取令牌 `{{description}}` 已建立",
"create-access-token": "建立存取令牌",
"created-at": "建立於",
"description": "說明",
@ -261,9 +268,11 @@
"expires": "過期時間",
"current": "當前裝置",
"never": "永不過期",
"session-revocation": "您確定要撤銷 {{device}} 的工作階段嗎?(此操作無法恢復)",
"session-revocation": "您確定要撤銷工作階段 `{{sessionId}}` 嗎?",
"session-revocation-description": "您需要在該裝置上重新登入。",
"session-revoked": "工作階段撤銷成功",
"revoke-session": "撤銷工作階段",
"revoke-session-button": "撤銷",
"cannot-revoke-current": "無法撤銷當前裝置的工作階段",
"no-sessions": "無工作階段"
},
@ -273,7 +282,7 @@
"export-memos": "導出備忘錄",
"nickname-note": "顯示於橫幅",
"openapi-reset": "重設 OpenAPI 密鑰Key",
"openapi-sample-post": "哈囉 來自 {{url}} 的 #memos",
"openapi-sample-post": "哈囉來自 {{url}} 的 #memos",
"openapi-title": "OpenAPI",
"reset-api": "重設 API",
"title": "帳號資訊",
@ -285,10 +294,15 @@
"member-section": {
"admin": "管理者",
"archive-member": "封存使用者",
"archive-warning": "您確定要封存 {{username}}?",
"archive-warning": "您確定要封存 {{username}} 嗎?",
"archive-warning-description": "封存會停用該帳戶。您可於日後恢復或刪除該帳戶。",
"archive-success": "{{username}} 封存成功",
"restore-success": "{{username}} 恢復成功",
"create-a-member": "新增使用者",
"delete-member": "刪除使用者",
"delete-warning": "您確定要刪除 {{username}}?(此操作無法恢復)",
"delete-warning": "您確定要刪除 {{username}} 嗎?",
"delete-warning-description": "此操作無法恢復。",
"delete-success": "{{username}} 刪除成功",
"user": "使用者"
},
"memo-related": "備忘錄",
@ -308,12 +322,16 @@
"default-memo-visibility": "備忘錄預設瀏覽權限",
"theme": "主題"
},
"shortcut": {
"delete-confirm": "您確定要刪除快捷篩選 `{{title}}` 嗎?",
"delete-success": "快捷篩選 `{{title}}` 刪除成功"
},
"sso": "SSO",
"sso-section": {
"authorization-endpoint": "驗證端點Authorization Endpoint",
"client-id": "客戶端 IDClient ID",
"client-secret": "客戶端金鑰Client Secret",
"confirm-delete": "您確定要刪除 \"{{name}}\" 的單點登錄 (SSO) 配置嗎?(此操作無法恢復)",
"confirm-delete": "您確定要刪除 `{{name}}` 的單點登錄 (SSO) 配置嗎?此操作無法恢復。",
"create-sso": "新增 SSO",
"custom": "自訂",
"delete-sso": "確認刪除",
@ -366,7 +384,7 @@
"url-prefix-placeholder": "自訂網址前綴(選填)",
"url-suffix": "網址後綴",
"url-suffix-placeholder": "自訂網址後綴(選填)",
"warning-text": "您確定要刪除存儲服務 \"{{name}}\" 嗎?(此操作無法恢復)"
"warning-text": "您確定要刪除存儲服務 `{{name}}` 嗎?此操作無法恢復。"
},
"system": "系統",
"system-section": {
@ -383,7 +401,7 @@
},
"disable-markdown-shortcuts-in-editor": "停用編輯器 Markdown 快捷鍵",
"disable-password-login": "停用密碼登入",
"disable-password-login-final-warning": "如果您知道自己在做什麼,請輸入「CONFIRM」。",
"disable-password-login-final-warning": "如果您知道自己在做什麼,請輸入 `CONFIRM`。",
"disable-password-login-warning": "所有使用者將無法使用密碼登入。如果設定的身份識別提供者失效,不在資料庫中恢復此設定將無法登入。刪除身分識別提供者時也要特別小心❗",
"disable-public-memos": "停用公共備忘錄",
"display-with-updated-time": "顯示更新時間",
@ -402,11 +420,17 @@
"create-dialog": {
"an-easy-to-remember-name": "一個容易記住的名稱",
"create-webhook": "建立 Webhook",
"create-webhook-success": "Webhook `{{name}}` 已建立",
"edit-webhook": "編輯 Webhook",
"payload-url": "URL",
"title": "標題",
"url-example-post-receive": "https://example.com/postreceive"
},
"delete-dialog": {
"delete-webhook-description": "此操作無法恢復。",
"delete-webhook-title": "您確定要刪除 webhook `{{name}}` 嗎?",
"delete-webhook-success": "Webhook `{{name}}` 刪除成功"
},
"no-webhooks-found": "尚未建立任何 Webhook。",
"title": "Webhook",
"url": "網址"
@ -428,13 +452,22 @@
"create-tags-guide": "您可以通過輸入`#標籤`來建立標籤。",
"delete-confirm": "您確定要刪除此標籤嗎?所有關聯的備忘錄將會被封存",
"delete-tag": "刪除標籤",
"no-tag-found": "未找到標籤",
"delete-success": "標籤刪除成功",
"new-name": "新標籤名稱",
"no-tag-found": "未找到標籤",
"old-name": "舊標籤名稱",
"rename-error-empty": "標籤名稱不能為空",
"rename-error-repeat": "標籤名稱已存在",
"rename-success": "重新命名標籤成功",
"rename-tag": "重新命名標籤",
"rename-tip": "您的標籤名稱將會被更新"
},
"tooltip": {
"link-memo": "連結備忘錄",
"markdown-menu": "Markdown",
"select-location": "位置",
"select-visibility": "瀏覽權限",
"tags": "標籤",
"upload-attachment": "上傳附件"
}
}

View File

@ -8,8 +8,12 @@ import "./index.css";
import router from "./router";
import { initialUserStore } from "./store/user";
import { initialWorkspaceStore } from "./store/workspace";
import { applyThemeEarly } from "./utils/theme";
import "leaflet/dist/leaflet.css";
// Apply theme early to prevent flash of wrong theme
applyThemeEarly();
const Main = observer(() => (
<>
<RouterProvider router={router} />

View File

@ -1,6 +1,7 @@
import dayjs from "dayjs";
import { observer } from "mobx-react-lite";
import { useMemo } from "react";
import { MemoRenderContext } from "@/components/MasonryView";
import MemoView from "@/components/MemoView";
import PagedMemoList from "@/components/PagedMemoList";
import useCurrentUser from "@/hooks/useCurrentUser";
@ -27,7 +28,9 @@ const Archived = observer(() => {
return (
<PagedMemoList
renderer={(memo: Memo) => <MemoView key={`${memo.name}-${memo.updateTime}`} memo={memo} showVisibility compact />}
renderer={(memo: Memo, context?: MemoRenderContext) => (
<MemoView key={`${memo.name}-${memo.updateTime}`} memo={memo} showVisibility compact={context?.compact} />
)}
listSort={(memos: Memo[]) =>
memos
.filter((memo) => memo.state === State.ARCHIVED)

View File

@ -1,15 +1,13 @@
import dayjs from "dayjs";
import { includes } from "lodash-es";
import { PaperclipIcon, SearchIcon, TrashIcon } from "lucide-react";
import { PaperclipIcon, SearchIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react";
import AttachmentIcon from "@/components/AttachmentIcon";
import Empty from "@/components/Empty";
import MobileHeader from "@/components/MobileHeader";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { attachmentServiceClient } from "@/grpcweb";
import useLoading from "@/hooks/useLoading";
import useResponsiveWidth from "@/hooks/useResponsiveWidth";
@ -56,16 +54,6 @@ const Attachments = observer(() => {
});
}, []);
const handleDeleteUnusedAttachments = async () => {
const confirmed = window.confirm("Are you sure to delete all unused attachments? This action cannot be undone.");
if (confirmed) {
for (const attachment of unusedAttachments) {
await attachmentServiceClient.deleteAttachment({ name: attachment.name });
}
setAttachments(attachments.filter((attachment) => attachment.memo));
}
};
return (
<section className="@container w-full max-w-5xl min-h-full flex flex-col justify-start items-center sm:pt-3 md:pt-6 pb-8">
{!md && <MobileHeader />}
@ -138,18 +126,6 @@ const Attachments = observer(() => {
<div className="w-full flex flex-row justify-start items-center gap-2">
<span className="text-muted-foreground">{t("resource.unused-resources")}</span>
<span className="text-muted-foreground opacity-80">({unusedAttachments.length})</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="sm" onClick={handleDeleteUnusedAttachments}>
<TrashIcon className="w-4 h-auto opacity-60" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Delete all</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
{unusedAttachments.map((attachment) => {
return (

View File

@ -1,5 +1,6 @@
import dayjs from "dayjs";
import { observer } from "mobx-react-lite";
import { MemoRenderContext } from "@/components/MasonryView";
import MemoView from "@/components/MemoView";
import MobileHeader from "@/components/MobileHeader";
import PagedMemoList from "@/components/PagedMemoList";
@ -16,7 +17,9 @@ const Explore = observer(() => {
{!md && <MobileHeader />}
<div className="w-full px-4 sm:px-6">
<PagedMemoList
renderer={(memo: Memo) => <MemoView key={`${memo.name}-${memo.updateTime}`} memo={memo} showCreator showVisibility compact />}
renderer={(memo: Memo, context?: MemoRenderContext) => (
<MemoView key={`${memo.name}-${memo.updateTime}`} memo={memo} showCreator showVisibility compact={context?.compact} />
)}
listSort={(memos: Memo[]) =>
memos
.filter((memo) => memo.state === State.NORMAL)

View File

@ -1,6 +1,7 @@
import dayjs from "dayjs";
import { observer } from "mobx-react-lite";
import { useMemo } from "react";
import { MemoRenderContext } from "@/components/MasonryView";
import MemoView from "@/components/MemoView";
import PagedMemoList from "@/components/PagedMemoList";
import useCurrentUser from "@/hooks/useCurrentUser";
@ -56,7 +57,9 @@ const Home = observer(() => {
return (
<div className="w-full min-h-full bg-background text-foreground">
<PagedMemoList
renderer={(memo: Memo) => <MemoView key={`${memo.name}-${memo.displayTime}`} memo={memo} showVisibility showPinned compact />}
renderer={(memo: Memo, context?: MemoRenderContext) => (
<MemoView key={`${memo.name}-${memo.displayTime}`} memo={memo} showVisibility showPinned compact={context?.compact} />
)}
listSort={(memos: Memo[]) =>
memos
.filter((memo) => memo.state === State.NORMAL)

View File

@ -5,6 +5,7 @@ import { observer } from "mobx-react-lite";
import { useEffect, useMemo, useState } from "react";
import { toast } from "react-hot-toast";
import { useParams } from "react-router-dom";
import { MemoRenderContext } from "@/components/MasonryView";
import MemoView from "@/components/MemoView";
import PagedMemoList from "@/components/PagedMemoList";
import UserAvatar from "@/components/UserAvatar";
@ -89,8 +90,8 @@ const UserProfile = observer(() => {
</div>
</div>
<PagedMemoList
renderer={(memo: Memo) => (
<MemoView key={`${memo.name}-${memo.displayTime}`} memo={memo} showVisibility showPinned compact />
renderer={(memo: Memo, context?: MemoRenderContext) => (
<MemoView key={`${memo.name}-${memo.displayTime}`} memo={memo} showVisibility showPinned compact={context?.compact} />
)}
listSort={(memos: Memo[]) =>
memos

View File

@ -503,6 +503,8 @@ export interface SpoilerNode {
export interface HTMLElementNode {
tagName: string;
attributes: { [key: string]: string };
children: Node[];
isSelfClosing: boolean;
}
export interface HTMLElementNode_AttributesEntry {
@ -3085,7 +3087,7 @@ export const SpoilerNode: MessageFns<SpoilerNode> = {
};
function createBaseHTMLElementNode(): HTMLElementNode {
return { tagName: "", attributes: {} };
return { tagName: "", attributes: {}, children: [], isSelfClosing: false };
}
export const HTMLElementNode: MessageFns<HTMLElementNode> = {
@ -3096,6 +3098,12 @@ export const HTMLElementNode: MessageFns<HTMLElementNode> = {
Object.entries(message.attributes).forEach(([key, value]) => {
HTMLElementNode_AttributesEntry.encode({ key: key as any, value }, writer.uint32(18).fork()).join();
});
for (const v of message.children) {
Node.encode(v!, writer.uint32(26).fork()).join();
}
if (message.isSelfClosing !== false) {
writer.uint32(32).bool(message.isSelfClosing);
}
return writer;
},
@ -3125,6 +3133,22 @@ export const HTMLElementNode: MessageFns<HTMLElementNode> = {
}
continue;
}
case 3: {
if (tag !== 26) {
break;
}
message.children.push(Node.decode(reader, reader.uint32()));
continue;
}
case 4: {
if (tag !== 32) {
break;
}
message.isSelfClosing = reader.bool();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
@ -3149,6 +3173,8 @@ export const HTMLElementNode: MessageFns<HTMLElementNode> = {
},
{},
);
message.children = object.children?.map((e) => Node.fromPartial(e)) || [];
message.isSelfClosing = object.isSelfClosing ?? false;
return message;
},
};

View File

@ -16,6 +16,43 @@ const validateTheme = (theme: string): ValidTheme => {
return VALID_THEMES.includes(theme as ValidTheme) ? (theme as ValidTheme) : "default";
};
/**
* Detects system theme preference
*/
export const getSystemTheme = (): "default" | "default-dark" => {
if (typeof window !== "undefined" && window.matchMedia) {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "default-dark" : "default";
}
return "default";
};
/**
* Gets the theme that should be applied on initial load
* Priority: stored user preference -> system preference -> default
*/
export const getInitialTheme = (): ValidTheme => {
// Try to get stored theme from localStorage (where user settings might be cached)
try {
const storedTheme = localStorage.getItem("memos-theme");
if (storedTheme && VALID_THEMES.includes(storedTheme as ValidTheme)) {
return storedTheme as ValidTheme;
}
} catch {
// localStorage might not be available
}
// Fall back to system preference
return getSystemTheme();
};
/**
* Applies the theme early to prevent flash of wrong theme
*/
export const applyThemeEarly = (): void => {
const theme = getInitialTheme();
loadTheme(theme);
};
export const loadTheme = (themeName: string): void => {
const validTheme = validateTheme(themeName);
@ -35,4 +72,11 @@ export const loadTheme = (themeName: string): void => {
// Set data attribute
document.documentElement.setAttribute("data-theme", validTheme);
// Store theme preference for future loads
try {
localStorage.setItem("memos-theme", validTheme);
} catch {
// localStorage might not be available
}
};