Merge branch 'main' into lat-long-input

pull/5152/head
Ben Mitchinson 5 days ago committed by GitHub
commit 79b31ac634
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

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

@ -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
@ -24,12 +24,12 @@ require (
github.com/spf13/viper v1.20.1
github.com/stretchr/testify v1.10.0
github.com/usememos/gomark v0.0.0-20250925160223-606d7debad77
golang.org/x/crypto v0.41.0
golang.org/x/mod v0.27.0
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
)

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

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

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

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

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

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

@ -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",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.44.0",
"typescript-eslint": "^8.45.0",
"vite": "^7.1.5"
},
"pnpm": {

@ -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
@ -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.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.3)
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,8 +3362,8 @@ 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
@ -4808,14 +4808,14 @@ 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.3))(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.3)':
'@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.3)
'@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.3)
'@typescript-eslint/utils': 8.44.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.3)
'@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
@ -4825,41 +4825,41 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/parser@8.44.0(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)':
dependencies:
'@typescript-eslint/scope-manager': 8.44.0
'@typescript-eslint/types': 8.44.0
'@typescript-eslint/typescript-estree': 8.44.0(typescript@5.9.3)
'@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.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/project-service@8.44.0(typescript@5.9.3)':
'@typescript-eslint/project-service@8.45.0(typescript@5.9.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.44.0(typescript@5.9.3)
'@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.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.3)':
'@typescript-eslint/tsconfig-utils@8.45.0(typescript@5.9.3)':
dependencies:
typescript: 5.9.3
'@typescript-eslint/type-utils@8.44.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.3)':
'@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.3)
'@typescript-eslint/utils': 8.44.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.3)
'@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.3)
@ -4867,14 +4867,14 @@ snapshots:
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.3)':
'@typescript-eslint/typescript-estree@8.45.0(typescript@5.9.3)':
dependencies:
'@typescript-eslint/project-service': 8.44.0(typescript@5.9.3)
'@typescript-eslint/tsconfig-utils': 8.44.0(typescript@5.9.3)
'@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
@ -4885,20 +4885,20 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/utils@8.44.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)':
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.3)
'@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.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))':
@ -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
@ -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,12 +6957,12 @@ 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.3):
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.3))(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.3)
'@typescript-eslint/parser': 8.44.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.3)
'@typescript-eslint/typescript-estree': 8.44.0(typescript@5.9.3)
'@typescript-eslint/utils': 8.44.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.3)
'@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.3
transitivePeerDependencies:

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

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

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

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

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

@ -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 confirmDeleteMemo = async () => {
await memoStore.deleteMemo(memo.name);
toast.success(t("message.deleted-successfully"));
if (isInMemoDetailPage) {
navigateTo("/");
}
memoUpdatedCallback();
};
const handleRemoveCompletedTaskListItemsClick = () => {
setRemoveTasksDialogOpen(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 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>
);
});

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

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

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

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

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

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

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

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

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

Loading…
Cancel
Save