2 Commits
Author SHA1 Message Date
ahp e222a7dcd7 fix: patch dependency advisories on the current release
Keep Next on the patched 15.5 line instead of the Next 16 Dependabot bump, and move OpenTelemetry and gRPC to the versions that close the open Go alerts.
2026-10-09 19:37:29 +03:30
ahp d9a2abb44f feat: release v0.1.3 chat, profiles, and media
Keep read receipts for the other person only, and let photos, voice, and files stay cached until they are actually replaced.
2026-10-09 19:05:59 +03:30
49 changed files with 2553 additions and 811 deletions
+15 -15
View File
@@ -12,10 +12,10 @@ require (
github.com/jackc/pgx/v5 v5.11.0
github.com/redis/go-redis/v9 v9.22.0
github.com/rs/cors v1.11.1
go.opentelemetry.io/otel v1.35.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0
go.opentelemetry.io/otel/sdk v1.35.0
go.opentelemetry.io/otel/trace v1.35.0
go.opentelemetry.io/otel v1.45.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0
go.opentelemetry.io/otel/sdk v1.45.0
go.opentelemetry.io/otel/trace v1.45.0
golang.org/x/crypto v0.57.0
)
@@ -34,26 +34,26 @@ require (
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.51.1 // indirect
github.com/aws/smithy-go v1.28.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/proto/otlp v1.5.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 // indirect
go.opentelemetry.io/otel/metric v1.45.0 // indirect
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect
google.golang.org/grpc v1.71.0 // indirect
google.golang.org/protobuf v1.36.5 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect
google.golang.org/grpc v1.83.2 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
+34 -32
View File
@@ -38,16 +38,16 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
@@ -60,8 +60,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 h1:e9Rjr40Z98/clHv5Yg79Is0NtosR5LXRvdr7o/6NwbA=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1/go.mod h1:tIxuGz/9mpox++sgp9fJjHO0+q1X9/UOWd798aAm22M=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -85,24 +85,24 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 h1:1fTNlAIJZGWLP5FVu0fikVry1IsiUnXjf7QFvoNN3Xw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0/go.mod h1:zjPK58DtkqQFn+YUMbx0M2XV3QgKU0gS9LeGohREyK4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 h1:xJ2qHD0C1BeYVTLLR9sX12+Qb95kfeD/byKj6Ky1pXg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0/go.mod h1:u5BF1xyjstDowA1R5QAO9JHzqK+ublenEW/dyqTjBVk=
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk=
go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w=
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4=
go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 h1:QRefszxJmfPdjXUUm3j6iDzY03mTPXMjqErFqQ67vUg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0/go.mod h1:Tiz03lTBVBrm7eWZBOidzEaYaJa8tjwGUGv6d8mlTyk=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 h1:QBajQ2SrwQijzHyZbQlPsuIzpl/ll8DY6wPWsajeGcI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0/go.mod h1:08ZQLjrPLQ6R4kAXvuOvODEer5Yh4CoFvll5qB2BCI8=
go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw=
go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA=
go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o=
go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA=
go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag=
go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc=
go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk=
go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@@ -117,14 +117,16 @@ golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a h1:nwKuGPlUAt+aR+pcrkfFRrTU1BVrSmYyYMxYbUIVHr0=
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a/go.mod h1:3kWAYMk1I75K4vykHtKt2ycnOgpA6974V7bREqbsenU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ=
google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg=
google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d h1:FarXi840EJWSHYTN3ERkADbPWjl307+FGrA22KAVjjc=
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d/go.mod h1:K/+WGbmBY7aNW1HDw1fJnKYo10i0DkAX6pows00dLig=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+16 -12
View File
@@ -22,6 +22,20 @@ func (s *Server) handleAdminStats(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"stats": stats})
}
func (s *Server) handleAdminGetUser(w http.ResponseWriter, r *http.Request) {
id := routeID(r, "id")
if id <= 0 {
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
return
}
u, err := s.store.GetUserByID(r.Context(), id)
if err != nil {
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"user": u})
}
func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
role := r.URL.Query().Get("role")
@@ -389,27 +403,17 @@ func (s *Server) handleAdminResetPassword(w http.ResponseWriter, r *http.Request
writeErr(w, http.StatusForbidden, "دسترسی کافی ندارید")
return
}
target, err := s.store.GetUserByID(r.Context(), id)
if err != nil {
if _, err := s.store.GetUserByID(r.Context(), id); err != nil {
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
return
}
var req struct {
SecurityAnswer string `json:"securityAnswer"`
NewPassword string `json:"newPassword"`
NewPassword string `json:"newPassword"`
}
if err := bodyJSON(r, &req); err != nil {
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
return
}
if target.SecurityAnswerHash == "" {
writeErr(w, http.StatusBadRequest, "برای این کاربر سوال امنیتی تعریف نشده است")
return
}
if !s.auth.CheckPassword(target.SecurityAnswerHash, strings.ToLower(strings.TrimSpace(req.SecurityAnswer))) {
writeErr(w, http.StatusForbidden, "جواب سوال امنیتی نادرست است")
return
}
if msg := validatePasswordOnly(req.NewPassword); msg != "" {
writeErr(w, http.StatusBadRequest, msg)
return
+1
View File
@@ -306,6 +306,7 @@ func (s *Server) handlePutAvatar(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusInternalServerError, "به‌روزرسانی آواتار ممکن نشد")
return
}
s.signUserMedia(user)
writeJSON(w, http.StatusOK, map[string]any{"user": user})
}
+3 -1
View File
@@ -22,7 +22,7 @@ import (
)
const (
captchaTTL = 5 * time.Minute
captchaTTL = 2 * time.Minute
captchaKeyPrefix = "pargar:captcha:"
captchaRateLimit int64 = 60
captchaCharset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
@@ -260,6 +260,7 @@ func (s *Server) handleCaptcha(w http.ResponseWriter, r *http.Request) {
resp := map[string]any{
"challengeId": "dev",
"imageBase64": img,
"expiresIn": int(captchaTTL.Seconds()),
}
if s.cfg.CaptchaExposeAnswer {
resp["answer"] = "A2B3"
@@ -297,6 +298,7 @@ func (s *Server) handleCaptcha(w http.ResponseWriter, r *http.Request) {
resp := map[string]any{
"challengeId": id,
"imageBase64": img,
"expiresIn": int(captchaTTL.Seconds()),
}
// Never expose plaintext answers in production. Local tests may opt in via
// CAPTCHA_EXPOSE_ANSWER=true combined with DEV_MODE=true.
+4
View File
@@ -30,6 +30,10 @@ func TestCaptchaImageChallenge(t *testing.T) {
if id == "" || img == "" || answer == "" {
t.Fatalf("captcha missing fields: %v", body)
}
exp, ok := body["expiresIn"].(float64)
if !ok || exp < 60 || exp > 180 {
t.Fatalf("expiresIn should be a short window: %v", body["expiresIn"])
}
if _, ok := body["question"]; ok {
t.Fatalf("legacy question field should be gone: %v", body)
}
+12 -2
View File
@@ -228,12 +228,22 @@ func (s *Server) handleRequestPhysicalCertificate(w http.ResponseWriter, r *http
func (s *Server) handleAdminListPhysicalOrders(w http.ResponseWriter, r *http.Request) {
status := r.URL.Query().Get("status")
orders, err := s.store.ListPhysicalOrders(r.Context(), status)
p := parsePageParams(r)
orders, total, err := s.store.ListPhysicalOrders(r.Context(), status, p.PageSize, p.Offset)
if err != nil {
writeErr(w, http.StatusInternalServerError, "بارگذاری سفارش‌ها ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"orders": orders, "physical": s.physicalSettings(r.Context())})
if orders == nil {
orders = []models.CertificatePhysicalOrder{}
}
writeJSON(w, http.StatusOK, map[string]any{
"orders": orders,
"total": total,
"page": p.Page,
"pageSize": p.PageSize,
"physical": s.physicalSettings(r.Context()),
})
}
func (s *Server) handleAdminUpdatePhysicalOrder(w http.ResponseWriter, r *http.Request) {
+89 -4
View File
@@ -37,6 +37,7 @@ func (s *Server) handleListMentors(w http.ResponseWriter, r *http.Request) {
if actor.Role == models.RoleStudent && m.Role != models.RoleMentor && m.Role != models.RoleAdmin {
continue
}
s.signUserMedia(&m)
safe = append(safe, map[string]any{
"id": m.ID, "name": m.Name, "email": m.Email, "role": m.Role,
"avatarVariant": m.AvatarVariant, "avatarPalette": m.AvatarPalette, "avatarPhoto": m.AvatarPhoto,
@@ -111,10 +112,16 @@ func (s *Server) handleConversations(w http.ResponseWriter, r *http.Request) {
unread++
}
}
photo := p.AvatarPhoto
if photo != "" {
tmp := &models.User{ID: p.ID, AvatarPhoto: photo}
s.signUserMedia(tmp)
photo = tmp.AvatarPhoto
}
conv := conversation{
Partner: map[string]any{
"id": p.ID, "name": p.Name, "email": p.Email, "role": p.Role,
"avatarVariant": p.AvatarVariant, "avatarPalette": p.AvatarPalette, "avatarPhoto": p.AvatarPhoto,
"avatarVariant": p.AvatarVariant, "avatarPalette": p.AvatarPalette, "avatarPhoto": photo,
"online": s.hub.Online(p.ID),
},
UnreadCount: unread,
@@ -137,6 +144,7 @@ func (s *Server) handleChatMessages(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusForbidden, "شما اجازهٔ گفتگو با این کاربر را ندارید")
return
}
_ = s.markIncomingChatRead(r.Context(), u, partner)
if raw := r.URL.Query().Get("around"); raw != "" {
aroundID, err := strconv.ParseInt(raw, 10, 64)
@@ -156,7 +164,6 @@ func (s *Server) handleChatMessages(w http.ResponseWriter, r *http.Request) {
return
}
_ = s.store.FillReactions(r.Context(), u.ID, msgs)
_ = s.store.MarkChatRead(r.Context(), u.ID, partner)
s.resignChatMessages(msgs)
pinned, _ := s.store.ListPinnedChatMessages(r.Context(), u.ID, partner)
if pinned == nil {
@@ -197,7 +204,6 @@ func (s *Server) handleChatMessages(w http.ResponseWriter, r *http.Request) {
}
}
_ = s.store.FillReactions(r.Context(), u.ID, msgs)
_ = s.store.MarkChatRead(r.Context(), u.ID, partner)
s.resignChatMessages(msgs)
pinned, _ := s.store.ListPinnedChatMessages(r.Context(), u.ID, partner)
if pinned == nil {
@@ -337,6 +343,73 @@ func (s *Server) handleChatReact(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "emoji": emoji, "reactions": reactions})
}
func (s *Server) handleChatEdit(w http.ResponseWriter, r *http.Request) {
u := currentUser(r)
partner := routeID(r, "partner")
if !s.canChatWith(r.Context(), u, partner) {
writeErr(w, http.StatusForbidden, "شما اجازهٔ گفتگو با این کاربر را ندارید")
return
}
msgID := routeID(r, "id")
var req struct {
Body string `json:"body"`
Attachment *models.Attachment `json:"attachment,omitempty"`
}
if err := bodyJSON(r, &req); err != nil {
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
return
}
req.Body = strings.TrimSpace(req.Body)
if len(req.Body) > 4000 {
writeErr(w, http.StatusBadRequest, "پیام باید بین ۱ تا ۴۰۰۰ نویسه باشد")
return
}
var (
msg *models.ChatMessage
err error
)
if req.Attachment != nil {
att := req.Attachment
att.Type = strings.ToLower(strings.TrimSpace(att.Type))
if att.Type != "image" && att.Type != "video" && att.Type != "audio" {
writeErr(w, http.StatusBadRequest, "فقط عکس، ویدیو یا صدا را می‌توان جایگزین کرد")
return
}
key := mediaKeyFromURL(att.URL)
if key == "" || !mediaKeyOwnedByUser(key, u.ID) {
writeErr(w, http.StatusBadRequest, "پیوست متعلق به شما نیست")
return
}
if q := strings.Index(att.URL, "?"); q >= 0 {
att.URL = att.URL[:q]
}
if att.Name == "" {
att.Name = "file"
}
msg, err = s.store.ReplaceChatAttachment(r.Context(), u.ID, partner, msgID, u.Role, req.Body, att.Type, att.URL, att.Name, att.Size)
} else {
if req.Body == "" {
writeErr(w, http.StatusBadRequest, "پیام باید بین ۱ تا ۴۰۰۰ نویسه باشد")
return
}
msg, err = s.store.EditChatMessage(r.Context(), u.ID, partner, msgID, u.Role, req.Body)
}
if errors.Is(err, store.ErrNotFound) {
writeErr(w, http.StatusNotFound, "پیام پیدا نشد")
return
}
if err != nil {
writeErr(w, http.StatusInternalServerError, "ویرایش پیام ممکن نشد")
return
}
s.resignChatMessage(msg)
event := map[string]any{"type": "chat_edit", "item": msg, "from": u.ID}
s.hub.Push(u.ID, event)
s.hub.Push(partner, event)
writeJSON(w, http.StatusOK, map[string]any{"message": msg})
}
func (s *Server) handleChatDelete(w http.ResponseWriter, r *http.Request) {
u := currentUser(r)
partner := routeID(r, "partner")
@@ -473,13 +546,25 @@ func (s *Server) handleChatRead(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusForbidden, "شما اجازهٔ گفتگو با این کاربر را ندارید")
return
}
if err := s.store.MarkChatRead(r.Context(), u.ID, partner); err != nil {
if err := s.markIncomingChatRead(r.Context(), u, partner); err != nil {
writeErr(w, http.StatusInternalServerError, "به‌روزرسانی وضعیت خوانده‌شدن ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) markIncomingChatRead(ctx context.Context, reader *models.User, partnerID int64) error {
if reader == nil {
return nil
}
n, err := s.store.MarkChatRead(ctx, reader.ID, partnerID, reader.Role)
if err != nil || n == 0 {
return err
}
s.hub.Push(partnerID, map[string]any{"type": "chat_read", "from": reader.ID})
return nil
}
func (s *Server) canChatWith(ctx context.Context, actor *models.User, partnerID int64) bool {
if actor == nil || partnerID <= 0 || actor.ID == partnerID {
return false
+2
View File
@@ -57,6 +57,7 @@ type chatExportMessage struct {
ReplyTo *int64 `json:"replyTo,omitempty"`
Pinned bool `json:"pinned"`
PinnedAt *time.Time `json:"pinnedAt,omitempty"`
EditedAt *time.Time `json:"editedAt,omitempty"`
Attachment *chatExportAttachment `json:"attachment,omitempty"`
}
@@ -183,6 +184,7 @@ func (s *Server) buildProjectChatExport(r *http.Request) (*chatExportPayload, []
ReplyTo: m.ReplyTo,
Pinned: m.PinnedAt != nil,
PinnedAt: m.PinnedAt,
EditedAt: m.EditedAt,
}
if m.Attachment != nil {
att := &chatExportAttachment{
+5 -5
View File
@@ -45,7 +45,7 @@ func TestUniquePhone(t *testing.T) {
}
}
func TestAdminResetPasswordRequiresSecurityAnswer(t *testing.T) {
func TestAdminResetPassword(t *testing.T) {
e := setup(t)
admin := login(t, e, bootstrapAdminEmail)
code, body := e.do(t, "POST", "/api/admin/users", admin, map[string]any{
@@ -59,14 +59,14 @@ func TestAdminResetPasswordRequiresSecurityAnswer(t *testing.T) {
uid := int64(body["user"].(map[string]any)["id"].(float64))
code, _ = e.do(t, "POST", "/api/admin/users/"+itoa(uid)+"/reset-password", admin, map[string]any{
"securityAnswer": "اشتباه", "newPassword": "newpass123",
"newPassword": "short",
})
if code != http.StatusForbidden {
t.Fatalf("wrong answer should 403, got %d", code)
if code != http.StatusBadRequest {
t.Fatalf("short password: %d", code)
}
code, body = e.do(t, "POST", "/api/admin/users/"+itoa(uid)+"/reset-password", admin, map[string]any{
"securityAnswer": "تهران", "newPassword": "newpass123",
"newPassword": "newpass123",
})
if code != http.StatusOK {
t.Fatalf("reset: %d %v", code, body)
+8 -1
View File
@@ -284,8 +284,15 @@ func (s *Server) handleAdminCreateInvite(w http.ResponseWriter, r *http.Request)
func (s *Server) handleAdminListInvites(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
status := strings.TrimSpace(r.URL.Query().Get("status"))
switch models.InviteStatus(status) {
case "", models.InviteActive, models.InvitePaused, models.InviteRevoked, models.InviteExhausted:
default:
writeErr(w, http.StatusBadRequest, "وضعیت نامعتبر است")
return
}
p := parsePageParams(r)
invites, total, err := s.store.ListRegistrationInvites(r.Context(), q, p.PageSize, p.Offset)
invites, total, err := s.store.ListRegistrationInvites(r.Context(), q, status, p.PageSize, p.Offset)
if err != nil {
writeErr(w, http.StatusInternalServerError, "بارگذاری لینک‌های عضویت ممکن نشد")
return
+1
View File
@@ -63,6 +63,7 @@ func (s *Server) handleWeeklyLeaderboard(w http.ResponseWriter, r *http.Request)
if err != nil {
continue
}
s.signUserMedia(usr)
entries = append(entries, lbEntry{
UserID: sc.userID, Name: usr.Name, Email: usr.Email, XP: sc.xp, Rank: i + 1,
AvatarVariant: usr.AvatarVariant, AvatarPalette: usr.AvatarPalette, AvatarPhoto: usr.AvatarPhoto,
+22 -3
View File
@@ -1,9 +1,11 @@
package api
import (
"errors"
"net/http"
"pargar/backend/internal/observability"
"pargar/backend/internal/store"
)
func (s *Server) handleLearningOverview(w http.ResponseWriter, r *http.Request) {
@@ -21,12 +23,25 @@ func (s *Server) handleLearningStudents(w http.ResponseWriter, r *http.Request)
ctx, span := observability.StartSpan(r.Context(), "admin.learning.students")
defer span.End()
q := r.URL.Query().Get("q")
rows, err := s.store.ListStudentLearning(ctx, q)
filter := r.URL.Query().Get("filter")
switch filter {
case "active", "locked":
default:
filter = ""
}
sort := r.URL.Query().Get("sort")
switch sort {
case "progress", "streak", "activity":
default:
sort = "xp"
}
p := parsePageParams(r)
rows, total, err := s.store.ListStudentLearning(ctx, q, filter, sort, p.PageSize, p.Offset)
if err != nil {
writeErr(w, http.StatusInternalServerError, "بارگذاری فهرست هنرجویان ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"students": rows})
writePage(w, rows, total, p)
}
func (s *Server) handleUserLearning(w http.ResponseWriter, r *http.Request) {
@@ -34,9 +49,13 @@ func (s *Server) handleUserLearning(w http.ResponseWriter, r *http.Request) {
defer span.End()
id := routeID(r, "id")
detail, err := s.store.UserLearningDetail(ctx, id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
return
}
if err != nil {
writeErr(w, http.StatusInternalServerError, "بارگذاری جزئیات یادگیری ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"learning": detail})
}
+17 -1
View File
@@ -13,12 +13,28 @@ import (
"pargar/backend/internal/models"
)
// mediaSignWindow keeps one signature for a stretch of time. The same object
// then keeps the same URL, so clients do not reload a photo that did not change.
const mediaSignWindow = 15 * time.Minute
func stableMediaExpiry(now time.Time, ttl, window time.Duration) int64 {
if window <= 0 {
window = mediaSignWindow
}
if ttl < window {
ttl = window
}
w := int64(window.Seconds())
slot := now.Unix() / w
return (slot+1)*w + int64(ttl.Seconds())
}
func (s *Server) signMediaURL(key string) string {
key = strings.TrimPrefix(strings.TrimSpace(key), "/")
if key == "" {
return ""
}
exp := time.Now().Add(s.cfg.MediaURLTTL).Unix()
exp := stableMediaExpiry(time.Now(), s.cfg.MediaURLTTL, mediaSignWindow)
sig := mediaHMAC(s.cfg.JWTSecret, key, exp)
return fmt.Sprintf("%s/media/%s?exp=%d&sig=%s",
s.cfg.PublicURL,
+18
View File
@@ -7,6 +7,24 @@ import (
"pargar/backend/internal/config"
)
func TestStableMediaExpiryHoldsInsideWindow(t *testing.T) {
start := time.Unix((1_700_000_000/900)*900, 0)
ttl := time.Hour
window := 15 * time.Minute
a := stableMediaExpiry(start, ttl, window)
b := stableMediaExpiry(start.Add(14*time.Minute), ttl, window)
if a != b {
t.Fatalf("same window should keep one expiry: %d vs %d", a, b)
}
c := stableMediaExpiry(start.Add(15*time.Minute), ttl, window)
if c == a {
t.Fatal("next window should mint a new expiry")
}
if c < a {
t.Fatal("expiry should move forward")
}
}
func TestMediaSignatureRoundTrip(t *testing.T) {
s := &Server{cfg: &config.Config{JWTSecret: "test-secret-at-least-32-chars-long!!", PublicURL: "http://localhost", MediaURLTTL: time.Hour}}
url := s.signMediaURL("videos/lesson-1.mp4")
+42
View File
@@ -0,0 +1,42 @@
package api
import (
"errors"
"net/http"
"pargar/backend/internal/store"
)
func (s *Server) handlePublicProfile(w http.ResponseWriter, r *http.Request) {
id := routeID(r, "id")
if id <= 0 {
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
return
}
u, err := s.store.GetUserByID(r.Context(), id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
return
}
writeErr(w, http.StatusInternalServerError, "بارگذاری پروفایل ممکن نشد")
return
}
s.signUserMedia(u)
writeJSON(w, http.StatusOK, map[string]any{
"user": map[string]any{
"id": u.ID,
"name": u.Name,
"username": u.Username,
"role": u.Role,
"xp": u.XP,
"hearts": u.Hearts,
"streakCurrent": u.StreakCurrent,
"streakLongest": u.StreakLongest,
"avatarVariant": u.AvatarVariant,
"avatarPalette": u.AvatarPalette,
"avatarPhoto": u.AvatarPhoto,
"isLocked": u.IsLocked,
},
})
}
+1 -1
View File
@@ -5,7 +5,7 @@ import (
)
// Public self-service password recovery is disabled for students.
// Staff reset passwords from the admin panel (requires the account security answer).
// Staff set a new password from the admin profile. Public recovery stays off.
func (s *Server) handleRecoveryChallenge(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusNotFound, "بازیابی رمز عمومی غیرفعال است")
+3
View File
@@ -56,7 +56,9 @@ func newRouter(s *Server) http.Handler {
mux.HandleFunc("GET /api/chats/conversations", s.requireAuth(s.handleConversations))
mux.HandleFunc("GET /api/chats/{partner}/messages", s.requireAuth(s.handleChatMessages))
mux.HandleFunc("POST /api/chats/{partner}/messages", s.requireAuth(s.handleChatSend))
mux.HandleFunc("PUT /api/chats/{partner}/messages/{id}", s.requireAuth(s.handleChatEdit))
mux.HandleFunc("DELETE /api/chats/{partner}/messages/{id}", s.requireAuth(s.handleChatDelete))
mux.HandleFunc("GET /api/users/{id}", s.requireAuth(s.handlePublicProfile))
mux.HandleFunc("POST /api/chats/{partner}/messages/{id}/reaction", s.requireAuth(s.handleChatReact))
mux.HandleFunc("POST /api/chats/{partner}/messages/{id}/pin", s.requireAuth(s.handleChatPin))
mux.HandleFunc("POST /api/chats/upload", s.requireAuth(s.perUserRateLimit(uploadRateLimit, "upload", s.handleChatUpload)))
@@ -89,6 +91,7 @@ func newRouter(s *Server) http.Handler {
mux.HandleFunc("GET /api/admin/users/{id}/learning", staff(s.handleUserLearning))
// Account administration is admin-only; mentors may unlock locked students only.
mux.HandleFunc("GET /api/admin/users", admin(s.handleAdminListUsers))
mux.HandleFunc("GET /api/admin/users/{id}", admin(s.handleAdminGetUser))
mux.HandleFunc("POST /api/admin/users", admin(s.handleAdminCreateUser))
mux.HandleFunc("POST /api/admin/users/{id}/lock", admin(s.handleAdminLockUser))
mux.HandleFunc("POST /api/admin/users/{id}/unlock", staff(s.handleAdminUnlockUser))
+330
View File
@@ -0,0 +1,330 @@
package api_test
import (
"strings"
"testing"
)
func TestChatEditOwnMessage(t *testing.T) {
e := setup(t)
student, studentID := register(t, e, "Chat Editor", "chat-editor@test.dev")
admin := login(t, e, bootstrapAdminEmail)
code, me := e.do(t, "GET", "/api/auth/me", admin, nil)
if code != 200 {
t.Fatalf("me: %d %v", code, me)
}
adminID := int64(me["user"].(map[string]any)["id"].(float64))
code, body := e.do(t, "POST", "/api/chats/"+itoa(adminID)+"/messages", student, map[string]any{"body": "سلام"})
if code != 201 {
t.Fatalf("send: %d %v", code, body)
}
msg := body["message"].(map[string]any)
msgID := int64(msg["id"].(float64))
code, body = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{"body": "سلام ویرایش"})
if code != 200 {
t.Fatalf("edit: %d %v", code, body)
}
edited := body["message"].(map[string]any)
if edited["body"] != "سلام ویرایش" || edited["editedAt"] == nil {
t.Fatalf("expected edited body, got %v", edited)
}
code, _ = e.do(t, "PUT", "/api/chats/"+itoa(studentID)+"/messages/"+itoa(msgID), admin, map[string]any{"body": "نه"})
if code != 404 {
t.Fatalf("partner edit should be rejected: %d", code)
}
code, _ = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{"body": " "})
if code != 400 {
t.Fatalf("empty edit: %d", code)
}
code, body = e.do(t, "GET", "/api/chats/"+itoa(adminID)+"/messages", student, nil)
if code != 200 {
t.Fatalf("list: %d %v", code, body)
}
msgs := body["messages"].([]any)
last := msgs[len(msgs)-1].(map[string]any)
if last["body"] != "سلام ویرایش" || last["editedAt"] == nil {
t.Fatalf("listed message not edited: %v", last)
}
}
func TestChatReplaceAttachment(t *testing.T) {
e := setup(t)
student, studentID := register(t, e, "Media Editor", "media-editor@test.dev")
admin := login(t, e, bootstrapAdminEmail)
code, me := e.do(t, "GET", "/api/auth/me", admin, nil)
if code != 200 {
t.Fatalf("me: %d %v", code, me)
}
adminID := int64(me["user"].(map[string]any)["id"].(float64))
png := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00}
code, up := uploadChatFile(t, e, student, "one.png", png)
if code != 201 {
t.Fatalf("upload: %d %v", code, up)
}
first := up["attachment"].(map[string]any)
code, body := e.do(t, "POST", "/api/chats/"+itoa(adminID)+"/messages", student, map[string]any{
"body": "",
"attachment": first,
})
if code != 201 {
t.Fatalf("send: %d %v", code, body)
}
msg := body["message"].(map[string]any)
msgID := int64(msg["id"].(float64))
oldPath := mediaPath(msg["attachment"].(map[string]any)["url"])
png[8] = 0x01
code, up = uploadChatFile(t, e, student, "two.png", png)
if code != 201 {
t.Fatalf("upload replacement: %d %v", code, up)
}
next := up["attachment"].(map[string]any)
code, body = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{
"body": "",
"attachment": next,
})
if code != 200 {
t.Fatalf("replace: %d %v", code, body)
}
edited := body["message"].(map[string]any)
newPath := mediaPath(edited["attachment"].(map[string]any)["url"])
if edited["editedAt"] == nil || newPath == "" || newPath == oldPath {
t.Fatalf("expected a replaced attachment, old %s new %v", oldPath, edited)
}
if edited["attachment"].(map[string]any)["type"] != "image" {
t.Fatalf("type: %v", edited["attachment"])
}
wrong := map[string]any{"type": "video", "url": next["url"], "name": "clip.mp4", "size": next["size"]}
code, _ = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{
"body": "",
"attachment": wrong,
})
if code != 404 {
t.Fatalf("different kind should miss: %d", code)
}
code, _ = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{
"body": "",
"attachment": map[string]any{"type": "file", "url": next["url"], "name": "x.pdf", "size": 1},
})
if code != 400 {
t.Fatalf("file replace: %d", code)
}
code, _ = e.do(t, "PUT", "/api/chats/"+itoa(studentID)+"/messages/"+itoa(msgID), admin, map[string]any{
"body": "",
"attachment": next,
})
if code != 400 {
t.Fatalf("partner replace: %d", code)
}
}
func TestChatReadMarksOnlyIncoming(t *testing.T) {
e := setup(t)
student, studentID := register(t, e, "Seen Student", "seen-student@test.dev")
admin := login(t, e, bootstrapAdminEmail)
code, me := e.do(t, "GET", "/api/auth/me", admin, nil)
if code != 200 {
t.Fatalf("me: %d %v", code, me)
}
adminID := int64(me["user"].(map[string]any)["id"].(float64))
code, body := e.do(t, "POST", "/api/chats/"+itoa(adminID)+"/messages", student, map[string]any{"body": "از هنرجو"})
if code != 201 {
t.Fatalf("student send: %d %v", code, body)
}
studentMsg := int64(body["message"].(map[string]any)["id"].(float64))
code, body = e.do(t, "GET", "/api/chats/"+itoa(adminID)+"/messages", student, nil)
if code != 200 {
t.Fatalf("student list: %d %v", code, body)
}
if chatMsg(t, body, studentMsg)["readAt"] != nil {
t.Fatalf("opening my own thread marked it seen: %v", chatMsg(t, body, studentMsg))
}
code, body = e.do(t, "GET", "/api/chats/"+itoa(studentID)+"/messages", admin, nil)
if code != 200 {
t.Fatalf("admin list: %d %v", code, body)
}
if chatMsg(t, body, studentMsg)["readAt"] == nil {
t.Fatalf("partner open should mark the incoming message seen")
}
code, body = e.do(t, "POST", "/api/chats/"+itoa(studentID)+"/messages", admin, map[string]any{"body": "از ادمین"})
if code != 201 {
t.Fatalf("admin send: %d %v", code, body)
}
adminMsg := int64(body["message"].(map[string]any)["id"].(float64))
code, body = e.do(t, "GET", "/api/chats/"+itoa(studentID)+"/messages", admin, nil)
if code != 200 {
t.Fatalf("admin list again: %d %v", code, body)
}
if chatMsg(t, body, adminMsg)["readAt"] != nil {
t.Fatalf("admin own message was marked seen: %v", chatMsg(t, body, adminMsg))
}
if chatMsg(t, body, studentMsg)["readAt"] == nil {
t.Fatalf("already seen incoming message lost its receipt")
}
}
func chatMsg(t *testing.T, body map[string]any, id int64) map[string]any {
t.Helper()
for _, raw := range body["messages"].([]any) {
msg := raw.(map[string]any)
if int64(msg["id"].(float64)) == id {
return msg
}
}
t.Fatalf("message %d missing in %v", id, body["messages"])
return nil
}
func mediaPath(raw any) string {
s, _ := raw.(string)
if i := strings.IndexByte(s, '?'); i >= 0 {
return s[:i]
}
return s
}
func TestLearningDetailMatchesActiveStudents(t *testing.T) {
e := setup(t)
admin := login(t, e, bootstrapAdminEmail)
code, body := e.do(t, "GET", "/api/admin/stats", admin, nil)
if code != 200 {
t.Fatalf("stats: %d %v", code, body)
}
if body["stats"].(map[string]any)["users"].(float64) != 0 {
t.Fatalf("overview should ignore staff, got %v", body["stats"])
}
_, uid := register(t, e, "Active Student", "active-student@test.dev")
code, body = e.do(t, "GET", "/api/admin/stats", admin, nil)
if code != 200 {
t.Fatalf("stats after student: %d %v", code, body)
}
if body["stats"].(map[string]any)["users"].(float64) != 1 {
t.Fatalf("expected 1 active student, got %v", body["stats"])
}
code, body = e.do(t, "GET", "/api/admin/learning/overview", admin, nil)
if code != 200 {
t.Fatalf("overview: %d %v", code, body)
}
if body["overview"].(map[string]any)["studentsActive"].(float64) != 1 {
t.Fatalf("learning active mismatch: %v", body["overview"])
}
code, body = e.do(t, "GET", "/api/admin/users/"+itoa(uid)+"/learning", admin, nil)
if code != 200 {
t.Fatalf("learning detail: %d %v", code, body)
}
user := body["learning"].(map[string]any)["user"].(map[string]any)
if user["name"] != "Active Student" {
t.Fatalf("detail user: %v", user)
}
code, body = e.do(t, "GET", "/api/admin/users/999999/learning", admin, nil)
if code != 404 {
t.Fatalf("missing user: %d %v", code, body)
}
code, body = e.do(t, "GET", "/api/admin/learning/students?page=1&pageSize=10", admin, nil)
if code != 200 {
t.Fatalf("students page: %d %v", code, body)
}
if body["total"].(float64) != 1 || len(body["items"].([]any)) != 1 {
t.Fatalf("expected one page row, got %v", body)
}
}
func TestInviteStatusFilter(t *testing.T) {
e := setup(t)
admin := login(t, e, bootstrapAdminEmail)
code, body := e.do(t, "POST", "/api/admin/invites", admin, map[string]any{"maxUses": 2, "label": "مهر"})
if code != 201 {
t.Fatalf("create invite: %d %v", code, body)
}
code, body = e.do(t, "GET", "/api/admin/invites?status=active", admin, nil)
if code != 200 {
t.Fatalf("active filter: %d %v", code, body)
}
if body["total"].(float64) < 1 {
t.Fatalf("expected active invites, got %v", body)
}
code, body = e.do(t, "GET", "/api/admin/invites?status=revoked", admin, nil)
if code != 200 {
t.Fatalf("revoked filter: %d %v", code, body)
}
if body["total"].(float64) != 0 {
t.Fatalf("expected no revoked invites, got %v", body)
}
code, body = e.do(t, "GET", "/api/admin/invites?status=nope", admin, nil)
if code != 400 {
t.Fatalf("bad status: %d %v", code, body)
}
}
func TestPublicProfileOmitsPrivateFields(t *testing.T) {
e := setup(t)
student, studentID := register(t, e, "Public Name", "public-name@test.dev")
admin := login(t, e, bootstrapAdminEmail)
code, me := e.do(t, "GET", "/api/auth/me", admin, nil)
if code != 200 {
t.Fatalf("me: %d %v", code, me)
}
adminID := int64(me["user"].(map[string]any)["id"].(float64))
code, body := e.do(t, "GET", "/api/users/"+itoa(adminID), student, nil)
if code != 200 {
t.Fatalf("profile: %d %v", code, body)
}
user := body["user"].(map[string]any)
if user["name"] == nil || user["name"] == "" {
t.Fatalf("missing name: %v", user)
}
if _, ok := user["email"]; ok {
t.Fatalf("email must not be public: %v", user)
}
if _, ok := user["phone"]; ok {
t.Fatalf("phone must not be public: %v", user)
}
code, adminView := e.do(t, "GET", "/api/admin/users/"+itoa(studentID), admin, nil)
if code != 200 {
t.Fatalf("admin user: %d %v", code, adminView)
}
managed := adminView["user"].(map[string]any)
if managed["email"] != "public-name@test.dev" {
t.Fatalf("admin should see email: %v", managed)
}
if _, ok := managed["passwordHash"]; ok {
t.Fatalf("password hash must stay hidden: %v", managed)
}
code, hidden := e.do(t, "GET", "/api/admin/users/"+itoa(adminID), student, nil)
if code != 403 {
t.Fatalf("student must not open admin user: %d %v", code, hidden)
}
code, body = e.do(t, "GET", "/api/users/"+itoa(studentID), admin, nil)
if code != 200 {
t.Fatalf("student profile: %d %v", code, body)
}
if body["user"].(map[string]any)["username"] != "public-name" {
t.Fatalf("username: %v", body["user"])
}
}
@@ -0,0 +1,3 @@
-- Own-message edits. Nullable so existing rows stay unedited.
ALTER TABLE chat_messages
ADD COLUMN IF NOT EXISTS edited_at TIMESTAMPTZ;
+1
View File
@@ -219,6 +219,7 @@ type ChatMessage struct {
ReplyTo *int64 `json:"replyTo,omitempty"`
PinnedAt *time.Time `json:"pinnedAt,omitempty"`
Pinned bool `json:"pinned"`
EditedAt *time.Time `json:"editedAt,omitempty"`
Attachment *Attachment `json:"attachment,omitempty"`
Reactions []ChatReaction `json:"reactions,omitempty"`
}
+15
View File
@@ -0,0 +1,15 @@
package storage
import "testing"
func TestVoiceNoteContentType(t *testing.T) {
if got := mimeType("chats/2-1.weba"); got != "audio/webm" {
t.Fatalf("weba: %s", got)
}
if got := mediaContentType("chats/2-1.weba", "application/octet-stream"); got != "audio/webm" {
t.Fatalf("generic store type must not hide the voice type: %s", got)
}
if got := mediaContentType("chats/2-1.bin", "application/pdf"); got != "application/pdf" {
t.Fatalf("unknown extension keeps the stored type: %s", got)
}
}
+33 -3
View File
@@ -243,10 +243,11 @@ func (s *S3) Serve(w http.ResponseWriter, r *http.Request, key string) error {
}
defer result.Body.Close()
ct := mimeType(key)
if result.ContentType != nil && *result.ContentType != "" {
ct = *result.ContentType
stored := ""
if result.ContentType != nil {
stored = *result.ContentType
}
ct := mediaContentType(key, stored)
w.Header().Set("Content-Type", ct)
setInlineMediaHeaders(w, ct)
w.Header().Set("Accept-Ranges", "bytes")
@@ -290,8 +291,20 @@ func mimeType(key string) string {
return "video/mp4"
case ".webm":
return "video/webm"
case ".weba":
return "audio/webm"
case ".mp3":
return "audio/mpeg"
case ".wav":
return "audio/wav"
case ".ogg", ".opus":
return "audio/ogg"
case ".m4a", ".aac":
return "audio/mp4"
case ".mov":
return "video/quicktime"
case ".m4v":
return "video/mp4"
case ".pdf":
return "application/pdf"
default:
@@ -299,6 +312,23 @@ func mimeType(key string) string {
}
}
// mediaContentType keeps a known extension ahead of a generic object-store type.
// Voice notes are stored as .weba; a default octet-stream plus nosniff stops playback.
func mediaContentType(key, stored string) string {
guessed := mimeType(key)
if guessed != "application/octet-stream" {
return guessed
}
stored = strings.TrimSpace(stored)
if i := strings.Index(stored, ";"); i >= 0 {
stored = strings.TrimSpace(stored[:i])
}
if stored != "" {
return stored
}
return guessed
}
// setInlineMediaHeaders discourages "Save as" download UX while keeping streaming/Range intact.
func setInlineMediaHeaders(w http.ResponseWriter, contentType string) {
if contentType != "" {
+6 -3
View File
@@ -146,7 +146,7 @@ func (s *Store) CreateRegistrationInvite(
return scanInvite(row)
}
func (s *Store) ListRegistrationInvites(ctx context.Context, query string, limit, offset int) ([]models.RegistrationInvite, int64, error) {
func (s *Store) ListRegistrationInvites(ctx context.Context, query, status string, limit, offset int) ([]models.RegistrationInvite, int64, error) {
if limit <= 0 {
limit = 10
}
@@ -157,18 +157,21 @@ func (s *Store) ListRegistrationInvites(ctx context.Context, query string, limit
offset = 0
}
query = strings.TrimSpace(query)
status = strings.TrimSpace(status)
var total int64
if err := s.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM registration_invites
WHERE ($1 = '' OR COALESCE(label, '') ILIKE '%'||$1||'%')`, query).Scan(&total); err != nil {
WHERE ($1 = '' OR COALESCE(label, '') ILIKE '%'||$1||'%')
AND ($2 = '' OR status = $2)`, query, status).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := s.pool.Query(ctx, `
SELECT `+inviteCols+`
FROM registration_invites
WHERE ($1 = '' OR COALESCE(label, '') ILIKE '%'||$1||'%')
AND ($2 = '' OR status = $2)
ORDER BY created_at DESC
LIMIT $2 OFFSET $3`, query, limit, offset)
LIMIT $3 OFFSET $4`, query, status, limit, offset)
if err != nil {
return nil, 0, err
}
+85 -58
View File
@@ -7,17 +7,17 @@ import (
// LearningOverview is cohort-level learning analytics for staff.
type LearningOverview struct {
StudentsActive int64 `json:"studentsActive"`
StudentsLocked int64 `json:"studentsLocked"`
StudentsTotal int64 `json:"studentsTotal"`
LessonsTotal int64 `json:"lessonsTotal"`
Completions int64 `json:"completions"`
Attempts7d int64 `json:"attempts7d"`
Passes7d int64 `json:"passes7d"`
PassRate7d float64 `json:"passRate7d"`
AvgWatchPct float64 `json:"avgWatchPct"`
XPAwarded7d int64 `json:"xpAwarded7d"`
StudentsStreaking int64 `json:"studentsStreaking"`
StudentsActive int64 `json:"studentsActive"`
StudentsLocked int64 `json:"studentsLocked"`
StudentsTotal int64 `json:"studentsTotal"`
LessonsTotal int64 `json:"lessonsTotal"`
Completions int64 `json:"completions"`
Attempts7d int64 `json:"attempts7d"`
Passes7d int64 `json:"passes7d"`
PassRate7d float64 `json:"passRate7d"`
AvgWatchPct float64 `json:"avgWatchPct"`
XPAwarded7d int64 `json:"xpAwarded7d"`
StudentsStreaking int64 `json:"studentsStreaking"`
}
func (s *Store) LearningOverview(ctx context.Context) (*LearningOverview, error) {
@@ -81,10 +81,40 @@ type StudentLearningRow struct {
LastActivityAt *time.Time `json:"lastActivityAt,omitempty"`
}
func (s *Store) ListStudentLearning(ctx context.Context, q string) ([]StudentLearningRow, error) {
func (s *Store) ListStudentLearning(ctx context.Context, q, filter, sort string, limit, offset int) ([]StudentLearningRow, int64, error) {
if limit <= 0 {
limit = 10
}
if limit > 100 {
limit = 100
}
if offset < 0 {
offset = 0
}
var lessonsTotal int64
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM lessons WHERE is_active`).Scan(&lessonsTotal); err != nil {
return nil, err
return nil, 0, err
}
where := `u.role = 'student'
AND ($1 = '' OR u.name ILIKE '%'||$1||'%' OR u.username ILIKE '%'||$1||'%' OR u.email ILIKE '%'||$1||'%')`
switch filter {
case "active":
where += ` AND u.is_active AND NOT u.is_locked`
case "locked":
where += ` AND u.is_locked`
}
order := `u.xp DESC, u.id ASC`
switch sort {
case "progress":
order = `COALESCE(p.passed, 0) DESC, u.id ASC`
case "streak":
order = `u.streak_current DESC, u.id ASC`
case "activity":
order = `u.last_activity_date DESC NULLS LAST, u.id ASC`
}
var total int64
if err := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users u WHERE `+where, q).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := s.pool.Query(ctx, `
SELECT u.id, u.name, u.username, u.email, u.xp, u.hearts, u.streak_current,
@@ -105,12 +135,11 @@ func (s *Store) ListStudentLearning(ctx context.Context, q string) ([]StudentLea
WHERE created_at >= now() - interval '7 days'
GROUP BY user_id
) a ON a.user_id = u.id
WHERE u.role = 'student'
AND ($1 = '' OR u.name ILIKE '%'||$1||'%' OR u.username ILIKE '%'||$1||'%' OR u.email ILIKE '%'||$1||'%')
ORDER BY u.xp DESC, u.id ASC
LIMIT 500`, q)
WHERE `+where+`
ORDER BY `+order+`
LIMIT $2 OFFSET $3`, q, limit, offset)
if err != nil {
return nil, err
return nil, 0, err
}
defer rows.Close()
out := make([]StudentLearningRow, 0)
@@ -122,7 +151,7 @@ func (s *Store) ListStudentLearning(ctx context.Context, q string) ([]StudentLea
&r.IsLocked, &r.IsActive, &r.LastActivityAt,
&r.LessonsPassed, &r.Attempts7d, &r.Passes7d,
); err != nil {
return nil, err
return nil, 0, err
}
if r.LessonsTotal > 0 {
r.ProgressPct = float64(r.LessonsPassed) / float64(r.LessonsTotal) * 100
@@ -132,21 +161,21 @@ func (s *Store) ListStudentLearning(ctx context.Context, q string) ([]StudentLea
}
out = append(out, r)
}
return out, rows.Err()
return out, total, rows.Err()
}
type LessonLearningDetail struct {
LessonID int64 `json:"lessonId"`
ChapterID int64 `json:"chapterId"`
ChapterTitle string `json:"chapterTitle"`
Title string `json:"title"`
SortOrder int `json:"sortOrder"`
WatchedPct float64 `json:"watchedPct"`
QuizUnlocked bool `json:"quizUnlocked"`
PassedQuiz bool `json:"passedQuiz"`
QuizCompletedAt *time.Time `json:"quizCompletedAt,omitempty"`
LastAttemptStatus string `json:"lastAttemptStatus,omitempty"`
LastAttemptScore float64 `json:"lastAttemptScore,omitempty"`
LessonID int64 `json:"lessonId"`
ChapterID int64 `json:"chapterId"`
ChapterTitle string `json:"chapterTitle"`
Title string `json:"title"`
SortOrder int `json:"sortOrder"`
WatchedPct float64 `json:"watchedPct"`
QuizUnlocked bool `json:"quizUnlocked"`
PassedQuiz bool `json:"passedQuiz"`
QuizCompletedAt *time.Time `json:"quizCompletedAt,omitempty"`
LastAttemptStatus string `json:"lastAttemptStatus,omitempty"`
LastAttemptScore float64 `json:"lastAttemptScore,omitempty"`
}
type AttemptLearningDetail struct {
@@ -178,11 +207,11 @@ type WatchSessionDetail struct {
}
type UserLearningDetail struct {
User StudentLearningRow `json:"user"`
Lessons []LessonLearningDetail `json:"lessons"`
Attempts []AttemptLearningDetail `json:"attempts"`
XPEvents []XPEventDetail `json:"xpEvents"`
Sessions []WatchSessionDetail `json:"sessions"`
User StudentLearningRow `json:"user"`
Lessons []LessonLearningDetail `json:"lessons"`
Attempts []AttemptLearningDetail `json:"attempts"`
XPEvents []XPEventDetail `json:"xpEvents"`
Sessions []WatchSessionDetail `json:"sessions"`
}
func (s *Store) UserLearningDetail(ctx context.Context, userID int64) (*UserLearningDetail, error) {
@@ -190,30 +219,28 @@ func (s *Store) UserLearningDetail(ctx context.Context, userID int64) (*UserLear
if err != nil {
return nil, err
}
list, err := s.ListStudentLearning(ctx, "")
if err != nil {
row := StudentLearningRow{
ID: u.ID, Name: u.Name, Username: u.Username, Email: u.Email,
XP: u.XP, Hearts: u.Hearts, StreakCurrent: u.StreakCurrent,
IsLocked: u.IsLocked, IsActive: u.IsActive, LastActivityAt: u.LastActivityDate,
}
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM lessons WHERE is_active`).Scan(&row.LessonsTotal); err != nil {
return nil, err
}
var row StudentLearningRow
found := false
for _, r := range list {
if r.ID == userID {
row = r
found = true
break
}
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM lesson_progress WHERE user_id=$1 AND passed_quiz`, userID).Scan(&row.LessonsPassed); err != nil {
return nil, err
}
if !found {
row = StudentLearningRow{
ID: u.ID, Name: u.Name, Username: u.Username, Email: u.Email,
XP: u.XP, Hearts: u.Hearts, StreakCurrent: u.StreakCurrent,
IsLocked: u.IsLocked, IsActive: u.IsActive, LastActivityAt: u.LastActivityDate,
}
_ = s.pool.QueryRow(ctx, `SELECT count(*) FROM lessons WHERE is_active`).Scan(&row.LessonsTotal)
_ = s.pool.QueryRow(ctx, `SELECT count(*) FROM lesson_progress WHERE user_id=$1 AND passed_quiz`, userID).Scan(&row.LessonsPassed)
if row.LessonsTotal > 0 {
row.ProgressPct = float64(row.LessonsPassed) / float64(row.LessonsTotal) * 100
}
if row.LessonsTotal > 0 {
row.ProgressPct = float64(row.LessonsPassed) / float64(row.LessonsTotal) * 100
}
if err := s.pool.QueryRow(ctx, `
SELECT COUNT(*), COUNT(*) FILTER (WHERE status='passed')
FROM quiz_attempts
WHERE user_id=$1 AND created_at >= now() - interval '7 days'`, userID).Scan(&row.Attempts7d, &row.Passes7d); err != nil {
return nil, err
}
if row.Attempts7d > 0 {
row.PassRate7d = float64(row.Passes7d) / float64(row.Attempts7d) * 100
}
detail := &UserLearningDetail{User: row, Lessons: []LessonLearningDetail{}, Attempts: []AttemptLearningDetail{}, XPEvents: []XPEventDetail{}, Sessions: []WatchSessionDetail{}}
@@ -221,7 +248,7 @@ func (s *Store) UserLearningDetail(ctx context.Context, userID int64) (*UserLear
lrows, err := s.pool.Query(ctx, `
SELECT l.id, l.chapter_id, c.title, l.title, l.sort_order,
COALESCE(p.watched_pct, 0), COALESCE(p.quiz_unlocked, false), COALESCE(p.passed_quiz, false), p.quiz_completed_at,
COALESCE(la.status, ''), COALESCE(la.score_pct, 0)
COALESCE(la.status::text, ''), COALESCE(la.score_pct, 0)
FROM lessons l
JOIN chapters c ON c.id = l.chapter_id
LEFT JOIN lesson_progress p ON p.lesson_id = l.id AND p.user_id = $1
+1 -1
View File
@@ -28,7 +28,7 @@ func TestLearningOverviewSmoke(t *testing.T) {
if o == nil {
t.Fatal("nil overview")
}
students, err := st.ListStudentLearning(ctx, "")
students, _, err := st.ListStudentLearning(ctx, "", "", "xp", 10, 0)
if err != nil {
t.Fatal(err)
}
+91 -26
View File
@@ -504,7 +504,26 @@ func (s *Store) GetPhysicalOrder(ctx context.Context, id int64) (*models.Certifi
return scanPhysicalOrder(row)
}
func (s *Store) ListPhysicalOrders(ctx context.Context, status string) ([]models.CertificatePhysicalOrder, error) {
func (s *Store) ListPhysicalOrders(ctx context.Context, status string, limit, offset int) ([]models.CertificatePhysicalOrder, int64, error) {
if limit <= 0 {
limit = 10
}
if limit > 100 {
limit = 100
}
if offset < 0 {
offset = 0
}
where := ""
args := []any{}
if status != "" {
where = ` WHERE o.status=$1`
args = append(args, status)
}
var total int64
if err := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM certificate_physical_orders o`+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
q := `
SELECT o.id, o.certificate_id, o.user_id, o.status, o.note,
o.recipient_name, o.phone, o.address, o.city, o.postal_code, o.tracking_code,
@@ -512,16 +531,11 @@ func (s *Store) ListPhysicalOrders(ctx context.Context, status string) ([]models
u.name, c.public_id
FROM certificate_physical_orders o
JOIN users u ON u.id = o.user_id
JOIN certificates c ON c.id = o.certificate_id`
args := []any{}
if status != "" {
q += ` WHERE o.status=$1`
args = append(args, status)
}
q += ` ORDER BY o.created_at DESC LIMIT 200`
JOIN certificates c ON c.id = o.certificate_id` + where + ` ORDER BY o.created_at DESC LIMIT $` + strconv.Itoa(len(args)+1) + ` OFFSET $` + strconv.Itoa(len(args)+2)
args = append(args, limit, offset)
rows, err := s.pool.Query(ctx, q, args...)
if err != nil {
return nil, err
return nil, 0, err
}
defer rows.Close()
out := make([]models.CertificatePhysicalOrder, 0)
@@ -530,11 +544,11 @@ func (s *Store) ListPhysicalOrders(ctx context.Context, status string) ([]models
if err := rows.Scan(&o.ID, &o.CertificateID, &o.UserID, &o.Status, &o.Note,
&o.RecipientName, &o.Phone, &o.Address, &o.City, &o.PostalCode, &o.TrackingCode,
&o.WindowEndsAt, &o.CreatedAt, &o.UpdatedAt, &o.UserName, &o.PublicID); err != nil {
return nil, err
return nil, 0, err
}
out = append(out, o)
}
return out, rows.Err()
return out, total, rows.Err()
}
func (s *Store) UpdatePhysicalOrder(ctx context.Context, id int64, status models.PhysicalOrderStatus, note, tracking string) (*models.CertificatePhysicalOrder, error) {
@@ -1069,7 +1083,7 @@ func (s *Store) AddChatMessage(ctx context.Context, userID, mentorID int64, send
INSERT INTO chat_messages (user_id, mentor_id, sender_role, body, reply_to, attachment_type, attachment_url, attachment_name, attachment_size)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at`,
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at`,
userID, mentorID, senderRole.String(), body, replyTo, attType, attURL, attName, attSize)
return scanMessage(row)
}
@@ -1094,7 +1108,7 @@ func (s *Store) ImportChatMessage(ctx context.Context, userID, mentorID int64, s
INSERT INTO chat_messages (user_id, mentor_id, sender_role, body, attachment_type, attachment_url, attachment_name, attachment_size, created_at, pinned_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at`,
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at`,
userID, mentorID, senderRole.String(), body, attType, attURL, attName, attSize, createdAt, pinnedAt)
return scanMessage(row)
}
@@ -1138,6 +1152,48 @@ func (s *Store) DeleteChatMessage(ctx context.Context, meID, partnerID, msgID in
return tag.RowsAffected() > 0, nil
}
// ReplaceChatAttachment swaps the photo, video, or voice on one of my messages and marks it edited.
// The new type must match the current attachment.
func (s *Store) ReplaceChatAttachment(ctx context.Context, meID, partnerID, msgID int64, meRole models.Role, body, attType, attURL, attName string, attSize int64) (*models.ChatMessage, error) {
row := s.pool.QueryRow(ctx, `
UPDATE chat_messages
SET body=$1, edited_at=now(),
attachment_type=$2, attachment_url=$3, attachment_name=$4, attachment_size=$5
WHERE id=$6
AND ((user_id=$7 AND mentor_id=$8) OR (user_id=$8 AND mentor_id=$7))
AND sender_role=$9
AND (user_id=$7 OR mentor_id=$7)
AND attachment_type=$2
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at`,
body, attType, attURL, attName, attSize, msgID, meID, partnerID, meRole.String())
m, err := scanMessage(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return m, err
}
// EditChatMessage replaces the body of one of my own messages in this conversation.
func (s *Store) EditChatMessage(ctx context.Context, meID, partnerID, msgID int64, meRole models.Role, body string) (*models.ChatMessage, error) {
row := s.pool.QueryRow(ctx, `
UPDATE chat_messages
SET body=$1, edited_at=now()
WHERE id=$2
AND ((user_id=$3 AND mentor_id=$4) OR (user_id=$4 AND mentor_id=$3))
AND sender_role=$5
AND (user_id=$3 OR mentor_id=$3)
AND ($1 <> '' OR attachment_url IS NOT NULL)
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at`,
body, msgID, meID, partnerID, meRole.String())
m, err := scanMessage(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return m, err
}
const maxPinnedMessages = 3
// TogglePinChatMessage pins or unpins a message. If already at the pin limit,
@@ -1160,7 +1216,7 @@ func (s *Store) TogglePinChatMessage(ctx context.Context, meID, partnerID, msgID
out := s.pool.QueryRow(ctx, `
UPDATE chat_messages SET pinned_at=NULL WHERE id=$1
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at`, msgID)
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at`, msgID)
return scanMessage(out)
}
var n int
@@ -1188,7 +1244,7 @@ func (s *Store) TogglePinChatMessage(ctx context.Context, meID, partnerID, msgID
out := s.pool.QueryRow(ctx, `
UPDATE chat_messages SET pinned_at=now() WHERE id=$1
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at`, msgID)
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at`, msgID)
return scanMessage(out)
}
@@ -1196,7 +1252,7 @@ func (s *Store) TogglePinChatMessage(ctx context.Context, meID, partnerID, msgID
func (s *Store) ListPinnedChatMessages(ctx context.Context, userID, mentorID int64) ([]models.ChatMessage, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at
FROM chat_messages
WHERE pinned_at IS NOT NULL
AND ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1))
@@ -1222,7 +1278,7 @@ func (s *Store) ListPinnedChatMessages(ctx context.Context, userID, mentorID int
func (s *Store) ListChatMessages(ctx context.Context, userID, mentorID, before, limit int64) ([]models.ChatMessage, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at
FROM chat_messages
WHERE ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1))
AND ($3 = 0 OR id < $3)
@@ -1270,7 +1326,7 @@ func (s *Store) ListChatMessagesAround(ctx context.Context, userID, partnerID, a
older, err := s.pool.Query(ctx, `
SELECT id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at
FROM chat_messages
WHERE ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1))
AND id <= $3
@@ -1302,7 +1358,7 @@ func (s *Store) ListChatMessagesAround(ctx context.Context, userID, partnerID, a
newer, err := s.pool.Query(ctx, `
SELECT id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at
FROM chat_messages
WHERE ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1))
AND id > $3
@@ -1345,7 +1401,7 @@ func scanMessage(row messageScanner) (*models.ChatMessage, error) {
var attSize *int64
if err := row.Scan(
&m.ID, &m.UserID, &m.MentorID, &m.SenderRole, &m.Body, &m.ReadAt, &m.CreatedAt, &m.ReplyTo,
&attType, &attURL, &attName, &attSize, &m.PinnedAt,
&attType, &attURL, &attName, &attSize, &m.PinnedAt, &m.EditedAt,
); err != nil {
return nil, err
}
@@ -1372,11 +1428,20 @@ func derefInt(p *int64) int64 {
return *p
}
func (s *Store) MarkChatRead(ctx context.Context, userID, mentorID int64) error {
_, err := s.pool.Exec(ctx, `
UPDATE chat_messages SET read_at=now() WHERE (user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1)`,
userID, mentorID)
return err
// MarkChatRead stamps messages the other person sent. Opening the thread must not
// mark my own messages as seen.
func (s *Store) MarkChatRead(ctx context.Context, meID, partnerID int64, meRole models.Role) (int64, error) {
tag, err := s.pool.Exec(ctx, `
UPDATE chat_messages
SET read_at=now()
WHERE read_at IS NULL
AND sender_role <> $3
AND ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1))`,
meID, partnerID, meRole.String())
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
}
// MessageInConversation reports whether a message belongs to a given pair.
@@ -1743,7 +1808,7 @@ func (s *Store) AllActiveUserIDs(ctx context.Context) ([]int64, error) {
func (s *Store) Stats(ctx context.Context) (map[string]any, error) {
var users, lessons, questions, events, completions int64
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM users`).Scan(&users); err != nil {
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM users WHERE role='student' AND is_active`).Scan(&users); err != nil {
return nil, err
}
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM lessons`).Scan(&lessons); err != nil {
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: pargar
description: Pargar gated LMS - Go API, Next.js frontend, Postgres, Redis, S3-compatible storage
type: application
version: 0.2.0
appVersion: "0.1.0"
version: 0.2.1
appVersion: "0.1.3"
keywords:
- lms
- pargar
+2 -2
View File
@@ -22,7 +22,7 @@ backend:
replicaCount: 2
image:
repository: backend
tag: "0.1.0"
tag: "0.1.3"
pullPolicy: IfNotPresent
existingSecret: pargar-app
env:
@@ -49,7 +49,7 @@ frontend:
replicaCount: 2
image:
repository: frontend
tag: "0.1.0"
tag: "0.1.3"
pullPolicy: IfNotPresent
resources:
requests:
+161 -190
View File
@@ -23,7 +23,7 @@
"emoji-datasource-apple": "^15.1.2",
"framer-motion": "^12.0.0",
"lucide-react": "^0.468.0",
"next": "^15.1.0",
"next": "15.5.27",
"next-themes": "^0.4.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -114,9 +114,9 @@
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz",
"integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz",
"integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==",
"cpu": [
"arm64"
],
@@ -132,13 +132,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.3.3"
"@img/sharp-libvips-darwin-arm64": "1.3.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz",
"integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz",
"integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==",
"cpu": [
"x64"
],
@@ -154,20 +154,20 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.3.3"
"@img/sharp-libvips-darwin-x64": "1.3.4"
}
},
"node_modules/@img/sharp-freebsd-wasm32": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz",
"integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz",
"integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==",
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"dependencies": {
"@img/sharp-wasm32": "0.35.4"
"@img/sharp-wasm32": "0.35.5"
},
"engines": {
"node": ">=20.9.0"
@@ -177,9 +177,9 @@
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz",
"integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz",
"integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==",
"cpu": [
"arm64"
],
@@ -193,9 +193,9 @@
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz",
"integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz",
"integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==",
"cpu": [
"x64"
],
@@ -209,9 +209,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz",
"integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz",
"integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==",
"cpu": [
"arm"
],
@@ -225,9 +225,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz",
"integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz",
"integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==",
"cpu": [
"arm64"
],
@@ -241,9 +241,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz",
"integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz",
"integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==",
"cpu": [
"ppc64"
],
@@ -257,9 +257,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz",
"integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz",
"integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==",
"cpu": [
"riscv64"
],
@@ -273,9 +273,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz",
"integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz",
"integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==",
"cpu": [
"s390x"
],
@@ -289,9 +289,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz",
"integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz",
"integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==",
"cpu": [
"x64"
],
@@ -305,9 +305,9 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz",
"integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz",
"integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==",
"cpu": [
"arm64"
],
@@ -321,9 +321,9 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz",
"integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz",
"integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==",
"cpu": [
"x64"
],
@@ -337,9 +337,9 @@
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz",
"integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz",
"integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==",
"cpu": [
"arm"
],
@@ -355,13 +355,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.3.3"
"@img/sharp-libvips-linux-arm": "1.3.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz",
"integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz",
"integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==",
"cpu": [
"arm64"
],
@@ -377,13 +377,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.3.3"
"@img/sharp-libvips-linux-arm64": "1.3.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz",
"integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz",
"integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==",
"cpu": [
"ppc64"
],
@@ -399,13 +399,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.3.3"
"@img/sharp-libvips-linux-ppc64": "1.3.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz",
"integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz",
"integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==",
"cpu": [
"riscv64"
],
@@ -421,13 +421,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.3.3"
"@img/sharp-libvips-linux-riscv64": "1.3.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz",
"integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz",
"integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==",
"cpu": [
"s390x"
],
@@ -443,13 +443,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.3.3"
"@img/sharp-libvips-linux-s390x": "1.3.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz",
"integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz",
"integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==",
"cpu": [
"x64"
],
@@ -465,13 +465,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.3.3"
"@img/sharp-libvips-linux-x64": "1.3.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz",
"integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz",
"integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==",
"cpu": [
"arm64"
],
@@ -487,13 +487,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3"
"@img/sharp-libvips-linuxmusl-arm64": "1.3.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz",
"integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz",
"integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==",
"cpu": [
"x64"
],
@@ -509,13 +509,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.3.3"
"@img/sharp-libvips-linuxmusl-x64": "1.3.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz",
"integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz",
"integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==",
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
@@ -529,16 +529,16 @@
}
},
"node_modules/@img/sharp-webcontainers-wasm32": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz",
"integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz",
"integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/sharp-wasm32": "0.35.4"
"@img/sharp-wasm32": "0.35.5"
},
"engines": {
"node": ">=20.9.0"
@@ -548,9 +548,9 @@
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz",
"integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz",
"integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==",
"cpu": [
"arm64"
],
@@ -567,9 +567,9 @@
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz",
"integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz",
"integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==",
"cpu": [
"ia32"
],
@@ -586,9 +586,9 @@
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz",
"integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz",
"integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==",
"cpu": [
"x64"
],
@@ -655,15 +655,15 @@
}
},
"node_modules/@next/env": {
"version": "15.5.25",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.25.tgz",
"integrity": "sha512-42h1lLr07vl4gawALP1hsgRZjHB1xYa58JfUfHwr0f7jG/zhPakh5GHkADHXOC9ZxUvlQFOPIrp7s6qX4DezPQ==",
"version": "15.5.27",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.27.tgz",
"integrity": "sha512-WC5hvVqiuFOcRyz6gKPeJkiVvSMf3scNeAV49CdWtVqxmUlzh8UKR6YHt9yiNkh8LngtzqSlVz07/Ma3ZAzchA==",
"license": "MIT"
},
"node_modules/@next/swc-darwin-arm64": {
"version": "15.5.25",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.25.tgz",
"integrity": "sha512-w+RR0v/QuApnWEjRGm1z6gcObKwGMb5YPA7V3bzBEVSBpMFUXprer0tS27UxjUcEnqbhL7Zuzohej79B6rYmBg==",
"version": "15.5.27",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.27.tgz",
"integrity": "sha512-fBA9QurmK4+uj4wNhmRTuO5zMVRaBHCvp6H93zTG/5i6Yi6TewbvbesXB8YQNj8T99Cwrow87hHiFV6wL/hyMA==",
"cpu": [
"arm64"
],
@@ -677,9 +677,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "15.5.25",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.25.tgz",
"integrity": "sha512-QiGGBUSakt8S1H4Lt9Ehsh6Ja87axiBnQQgysOObvCbI7iUfJnRGntF1P64S4/ijuHFnSB8KLsEddkY3nN26uw==",
"version": "15.5.27",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.27.tgz",
"integrity": "sha512-FGZwJwry8qZNHxD/nfwrCwCHHrqx6epqItkkxbYcBQEQ2uw5S43bY83fQB5zbAsZE7TELMjoYkbhsQ4oKM7wwg==",
"cpu": [
"x64"
],
@@ -693,9 +693,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "15.5.25",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.25.tgz",
"integrity": "sha512-ehLos/66zo0d/mJCU5u96a/VDcr01aaUrX0o/i16UdInxz8qPTCDSxGtjk/Lps1sIr1RJFdiX3hxc0fxJo+cPA==",
"version": "15.5.27",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.27.tgz",
"integrity": "sha512-GQulAaJYNF1RYuatlsw9/uzbKz4if2V7Ic9nVO+P0ljYPm4yBRTFIl6g5YkKTw40lMLbpy9edLoH44g3jSHMYA==",
"cpu": [
"arm64"
],
@@ -709,9 +709,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "15.5.25",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.25.tgz",
"integrity": "sha512-ZVMrqLiJ7DiChgmbkQwFtdhAnUkSH/4p7tB29QY+giATb0Q/XGHNRSKAb/B8XGDHRUaA67NepOW5W8u3ZRJBAA==",
"version": "15.5.27",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.27.tgz",
"integrity": "sha512-URFFsUmu8Pi0/1qdnUI7tx7keZw1Mt5zHfrIX1RfDG9Nih8DYxK54WZsDJUX2ZTlii1jgotL7qcbwKnSAES6xQ==",
"cpu": [
"arm64"
],
@@ -725,9 +725,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "15.5.25",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.25.tgz",
"integrity": "sha512-UOewtDGkTMJTiODrEdeLZ50yGb59xCZSriNpXkfPMxRRgwDkGc7i8mLWqV5076wEdb+Ca/XN7MhJyM3CupyNyQ==",
"version": "15.5.27",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.27.tgz",
"integrity": "sha512-aJp74z7uvU5jQhyH7lyk+4qSRcTdKbKT9NQE3jiO9kWYkoHZjKi0zzHDbCoZ+v2sJDEiJ/z58R0UlWMGWNF3HA==",
"cpu": [
"x64"
],
@@ -741,9 +741,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "15.5.25",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.25.tgz",
"integrity": "sha512-UBHwA8AhkCZgtRfU1aJpunuAJe/6gZv6jDESQe4p5MjTb5V0YEeJBWCdNqx15Vj3x+5jmauRfeMJSjfQj9HGFQ==",
"version": "15.5.27",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.27.tgz",
"integrity": "sha512-ILl2bQ8MbpvyMpY8AXljb3BXs0MJSC8HXZ6QV5AWpVS7ZELqNPtrHvhUk+SNarvjvICXX8bCS287TCi+gUFVYg==",
"cpu": [
"x64"
],
@@ -757,9 +757,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "15.5.25",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.25.tgz",
"integrity": "sha512-QcFcPRr16djk5IqK5+e8O80eZfgWzIvVBXfitIq0tQ/uc+eyfdoZ0NmKc0cnbIJyfVwREapKuG97YcxWA9gcpA==",
"version": "15.5.27",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.27.tgz",
"integrity": "sha512-1MB9Rh5y8TOPN2x7naNTJNkCZQav9c70AI7EBYXxsEewwOffC4DjjwBwKf20NBvcA+u+5jDv7nVmHxunlP2d7Q==",
"cpu": [
"arm64"
],
@@ -773,9 +773,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "15.5.25",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.25.tgz",
"integrity": "sha512-zREeykps3ndWr9egJgvJKqVkkDuaw6Zrrg23cYBos0ygydFkAWYU4+PaPVwXzP1eAYQJe53ShSK45iDM529BOg==",
"version": "15.5.27",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.27.tgz",
"integrity": "sha512-XtYgpGOswgnyBWXzOEG6q9RhvnSw0CEZ0MbWqCsl5PISgt9EzvJvNW5mU5n1PsphX/lnamzxjgQXH2GUJL8nqg==",
"cpu": [
"x64"
],
@@ -2291,12 +2291,12 @@
}
},
"node_modules/next": {
"version": "15.5.25",
"resolved": "https://registry.npmjs.org/next/-/next-15.5.25.tgz",
"integrity": "sha512-OMWNulIIqKM2ykvC2qMjIt0IoavB4UB2SCs4iXJ6z6847FvyH8jBmBWcvrF5iuhTu8Przh20Fo/aoszIdqx4PA==",
"version": "15.5.27",
"resolved": "https://registry.npmjs.org/next/-/next-15.5.27.tgz",
"integrity": "sha512-F82CrlPZ8GRxBH9RIIoR3qU1t5RT3dv1K8moe8SjlDEquiFipvEI13R1Om90TaUOEwShkWCMlg8+gvTNzfMhvg==",
"license": "MIT",
"dependencies": {
"@next/env": "15.5.25",
"@next/env": "15.5.27",
"@swc/helpers": "0.5.15",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
@@ -2309,14 +2309,14 @@
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "15.5.25",
"@next/swc-darwin-x64": "15.5.25",
"@next/swc-linux-arm64-gnu": "15.5.25",
"@next/swc-linux-arm64-musl": "15.5.25",
"@next/swc-linux-x64-gnu": "15.5.25",
"@next/swc-linux-x64-musl": "15.5.25",
"@next/swc-win32-arm64-msvc": "15.5.25",
"@next/swc-win32-x64-msvc": "15.5.25",
"@next/swc-darwin-arm64": "15.5.27",
"@next/swc-darwin-x64": "15.5.27",
"@next/swc-linux-arm64-gnu": "15.5.27",
"@next/swc-linux-arm64-musl": "15.5.27",
"@next/swc-linux-x64-gnu": "15.5.27",
"@next/swc-linux-x64-musl": "15.5.27",
"@next/swc-win32-arm64-msvc": "15.5.27",
"@next/swc-win32-x64-msvc": "15.5.27",
"sharp": "^0.34.3 || ^0.35.4"
},
"peerDependencies": {
@@ -2352,34 +2352,6 @@
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.6",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2390,7 +2362,6 @@
"version": "8.5.28",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
"integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -2555,9 +2526,9 @@
}
},
"node_modules/sharp": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
"integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz",
"integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
@@ -2572,31 +2543,31 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.35.4",
"@img/sharp-darwin-x64": "0.35.4",
"@img/sharp-freebsd-wasm32": "0.35.4",
"@img/sharp-libvips-darwin-arm64": "1.3.3",
"@img/sharp-libvips-darwin-x64": "1.3.3",
"@img/sharp-libvips-linux-arm": "1.3.3",
"@img/sharp-libvips-linux-arm64": "1.3.3",
"@img/sharp-libvips-linux-ppc64": "1.3.3",
"@img/sharp-libvips-linux-riscv64": "1.3.3",
"@img/sharp-libvips-linux-s390x": "1.3.3",
"@img/sharp-libvips-linux-x64": "1.3.3",
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3",
"@img/sharp-libvips-linuxmusl-x64": "1.3.3",
"@img/sharp-linux-arm": "0.35.4",
"@img/sharp-linux-arm64": "0.35.4",
"@img/sharp-linux-ppc64": "0.35.4",
"@img/sharp-linux-riscv64": "0.35.4",
"@img/sharp-linux-s390x": "0.35.4",
"@img/sharp-linux-x64": "0.35.4",
"@img/sharp-linuxmusl-arm64": "0.35.4",
"@img/sharp-linuxmusl-x64": "0.35.4",
"@img/sharp-webcontainers-wasm32": "0.35.4",
"@img/sharp-win32-arm64": "0.35.4",
"@img/sharp-win32-ia32": "0.35.4",
"@img/sharp-win32-x64": "0.35.4"
"@img/sharp-darwin-arm64": "0.35.5",
"@img/sharp-darwin-x64": "0.35.5",
"@img/sharp-freebsd-wasm32": "0.35.5",
"@img/sharp-libvips-darwin-arm64": "1.3.4",
"@img/sharp-libvips-darwin-x64": "1.3.4",
"@img/sharp-libvips-linux-arm": "1.3.4",
"@img/sharp-libvips-linux-arm64": "1.3.4",
"@img/sharp-libvips-linux-ppc64": "1.3.4",
"@img/sharp-libvips-linux-riscv64": "1.3.4",
"@img/sharp-libvips-linux-s390x": "1.3.4",
"@img/sharp-libvips-linux-x64": "1.3.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.3.4",
"@img/sharp-libvips-linuxmusl-x64": "1.3.4",
"@img/sharp-linux-arm": "0.35.5",
"@img/sharp-linux-arm64": "0.35.5",
"@img/sharp-linux-ppc64": "0.35.5",
"@img/sharp-linux-riscv64": "0.35.5",
"@img/sharp-linux-s390x": "0.35.5",
"@img/sharp-linux-x64": "0.35.5",
"@img/sharp-linuxmusl-arm64": "0.35.5",
"@img/sharp-linuxmusl-x64": "0.35.5",
"@img/sharp-webcontainers-wasm32": "0.35.5",
"@img/sharp-win32-arm64": "0.35.5",
"@img/sharp-win32-ia32": "0.35.5",
"@img/sharp-win32-x64": "0.35.5"
},
"peerDependenciesMeta": {
"@types/node": {
@@ -2615,9 +2586,9 @@
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
+6 -1
View File
@@ -27,7 +27,7 @@
"emoji-datasource-apple": "^15.1.2",
"framer-motion": "^12.0.0",
"lucide-react": "^0.468.0",
"next": "^15.1.0",
"next": "15.5.27",
"next-themes": "^0.4.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -44,5 +44,10 @@
"@types/react-dom": "^19.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.0"
},
"overrides": {
"postcss": "8.5.28",
"sharp": "0.35.5",
"source-map-js": "1.2.2"
}
}
+15 -77
View File
@@ -1,10 +1,10 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { CheckCircle2, Loader2, RefreshCw, ShieldCheck, Timer, Zap } from "lucide-react";
import { CheckCircle2, Loader2, ShieldCheck, Timer, Zap } from "lucide-react";
import { BrandMark } from "@/components/brand-mark";
import { useAuth } from "@/lib/auth-store";
import { http, toUserError } from "@/lib/api";
@@ -15,6 +15,7 @@ import { PasswordInput } from "@/components/ui/password-input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { CaptchaField, type CaptchaChallenge } from "@/components/captcha-field";
const perks = [
{ icon: Timer, text: "زمان تماشای راستی‌آزمایی‌شده — بدون میان‌بر" },
@@ -22,11 +23,6 @@ const perks = [
{ icon: CheckCircle2, text: "لیگ هفتگی، رویداد زنده و منتور یک‌به‌یک" },
];
type CaptchaChallenge = {
challengeId: string;
imageBase64: string;
};
export default function LoginPage() {
const router = useRouter();
const setSession = useAuth((s) => s.setSession);
@@ -34,7 +30,7 @@ export default function LoginPage() {
const [password, setPassword] = useState("");
const [captchaAnswer, setCaptchaAnswer] = useState("");
const [challenge, setChallenge] = useState<CaptchaChallenge | null>(null);
const [captchaLoading, setCaptchaLoading] = useState(true);
const [captchaKey, setCaptchaKey] = useState(0);
const [rememberMe, setRememberMe] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -44,29 +40,11 @@ export default function LoginPage() {
queryFn: () => http.get<{ enabled: boolean }>("/api/auth/register-status"),
});
const loadCaptcha = useCallback(async () => {
setCaptchaLoading(true);
setCaptchaAnswer("");
try {
const data = await http.get<CaptchaChallenge>("/api/auth/captcha");
setChallenge(data);
} catch (err) {
setChallenge(null);
setError(toUserError(err, "بارگذاری کد امنیتی ممکن نشد"));
} finally {
setCaptchaLoading(false);
}
}, []);
useEffect(() => {
void loadCaptcha();
}, [loadCaptcha]);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!challenge) {
setError("ابتدا کد امنیتی را بارگذاری کنید");
void loadCaptcha();
setCaptchaKey((k) => k + 1);
return;
}
setLoading(true);
@@ -83,7 +61,7 @@ export default function LoginPage() {
router.push("/cap");
} catch (err) {
setError(toUserError(err, "ورود ناموفق بود"));
void loadCaptcha();
setCaptchaKey((k) => k + 1);
} finally {
setLoading(false);
}
@@ -153,54 +131,14 @@ export default function LoginPage() {
placeholder="رمز عبور"
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label htmlFor="captcha">کد امنیتی</Label>
<button
type="button"
onClick={() => void loadCaptcha()}
disabled={captchaLoading}
className="inline-flex items-center gap-1 text-xs font-medium text-primary underline-offset-4 hover:underline disabled:opacity-50"
>
<RefreshCw className={`h-3.5 w-3.5 ${captchaLoading ? "animate-spin" : ""}`} />
تازه‌سازی
</button>
</div>
<div className="flex items-stretch gap-3">
<div
className="relative flex h-14 min-w-[9.5rem] shrink-0 items-center justify-center overflow-hidden rounded-xl border border-border bg-muted/60 shadow-inner"
aria-live="polite"
>
{captchaLoading || !challenge?.imageBase64 ? (
<span className="text-sm text-muted-foreground">…</span>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`data:image/png;base64,${challenge.imageBase64}`}
alt="کد امنیتی"
width={160}
height={52}
className="h-full w-full object-contain select-none"
draggable={false}
/>
)}
</div>
<Input
id="captcha"
type="text"
autoComplete="off"
autoCapitalize="characters"
spellCheck={false}
value={captchaAnswer}
onChange={(e) => setCaptchaAnswer(e.target.value.toUpperCase())}
required
dir="ltr"
className="h-14 text-center font-mono tracking-widest uppercase placeholder:text-center"
placeholder="کد تصویر"
maxLength={6}
/>
</div>
</div>
<CaptchaField
key={captchaKey}
id="captcha"
value={captchaAnswer}
onChange={setCaptchaAnswer}
onChallenge={setChallenge}
onError={setError}
/>
<label className="flex items-center justify-between gap-3 text-sm">
<span>مرا به یاد داشته باش</span>
<Switch
@@ -215,7 +153,7 @@ export default function LoginPage() {
variant="gradient"
className="h-12 w-full"
size="lg"
disabled={loading || captchaLoading || !challenge}
disabled={loading || !challenge}
>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Zap className="h-4 w-4" />} ورود
</Button>
+130
View File
@@ -0,0 +1,130 @@
"use client";
import type { ReactNode } from "react";
import dynamic from "next/dynamic";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { Flame, Heart, Sparkles, User as UserIcon } from "lucide-react";
import { http, toUserError } from "@/lib/api";
import { useAuth } from "@/lib/auth-store";
import type { PublicProfile } from "@/lib/types";
import { Panel } from "@/components/panel";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { UserAvatar, avatarPropsOf } from "@/components/ui/user-avatar";
import { formatFaNumber, formatStreakLabel } from "@/lib/utils";
const AdminProfileManage = dynamic(
() => import("@/components/admin/profile-manage").then((m) => m.AdminProfileManage),
{ ssr: false },
);
function roleLabel(role: string) {
if (role === "mentor") return "منتور";
if (role === "admin") return "ادمین";
return "هنرجو";
}
export default function PublicProfilePage() {
const params = useParams<{ id: string }>();
const id = Number(params.id);
const enabled = Number.isFinite(id) && id > 0;
const isAdmin = useAuth((s) => s.user?.role === "admin");
const profileQ = useQuery({
queryKey: ["public-profile", id],
queryFn: () => http.get<{ user: PublicProfile }>(`/api/users/${id}`),
enabled,
retry: 1,
});
if (!enabled) {
return (
<div className="mx-auto max-w-2xl py-16 text-center text-sm text-muted-foreground">
کاربر پیدا نشد.
</div>
);
}
if (profileQ.isPending) {
return (
<div className="mx-auto max-w-2xl space-y-4" aria-busy="true" aria-label="در حال بارگذاری پروفایل">
<Skeleton className="h-40 rounded-2xl" />
<div className="grid gap-3 sm:grid-cols-2">
{[0, 1, 2, 3].map((i) => (
<Skeleton key={i} className="h-20 rounded-2xl" />
))}
</div>
</div>
);
}
if (profileQ.isError || !profileQ.data?.user) {
return (
<div className="mx-auto max-w-2xl space-y-4 py-16 text-center">
<p className="text-sm font-bold text-destructive">
{toUserError(profileQ.error, "کاربر پیدا نشد")}
</p>
<Button asChild variant="outline">
<Link href="/unwrap">بازگشت</Link>
</Button>
</div>
);
}
const person = profileQ.data.user;
return (
<div className="mx-auto max-w-2xl space-y-6">
<Panel
title={person.name}
description={roleLabel(person.role)}
icon={<UserIcon className="h-5 w-5" />}
tint="bg-primary/10 text-primary"
>
<div className="flex flex-col items-center gap-4 sm:flex-row sm:items-start">
<UserAvatar name={person.name} className="h-20 w-20" {...avatarPropsOf(person)} />
<div className="min-w-0 flex-1 space-y-2 text-center sm:text-start">
<h1 className="text-2xl font-black tracking-tight">{person.name}</h1>
<p className="font-latin text-sm font-bold" dir="ltr">
@{person.username}
</p>
{person.isLocked && (
<p className="text-xs font-bold text-destructive">حساب قفل است</p>
)}
</div>
</div>
</Panel>
<div className="grid gap-3 sm:grid-cols-2">
<Stat icon={<Sparkles className="h-4 w-4 text-gold" />} label="امتیاز" value={formatFaNumber(person.xp)} />
<Stat icon={<Heart className="h-4 w-4 text-destructive" />} label="قلب‌ها" value={formatFaNumber(person.hearts)} />
<Stat
icon={<Flame className="h-4 w-4 text-accent" />}
label="زنجیرهٔ فعلی"
value={formatStreakLabel(person.streakCurrent)}
/>
<Stat
icon={<Flame className="h-4 w-4 text-muted-foreground" />}
label="بلندترین زنجیره"
value={formatStreakLabel(person.streakLongest)}
/>
</div>
{isAdmin && <AdminProfileManage userId={person.id} />}
</div>
);
}
function Stat({ icon, label, value }: { icon: ReactNode; label: string; value: string }) {
return (
<div className="flex items-center gap-3 rounded-2xl border border-border bg-card px-4 py-3">
{icon}
<div>
<p className="text-[11px] font-bold text-muted-foreground">{label}</p>
<p className="text-lg font-black tabular-nums">{value}</p>
</div>
</div>
);
}
+8 -65
View File
@@ -1,6 +1,7 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
@@ -16,7 +17,6 @@ import {
Globe,
GripVertical,
Heart,
KeyRound,
Layers,
Loader2,
Lock,
@@ -454,6 +454,10 @@ function UsersPanel() {
(currentUser?.role === "mentor" && u.role === "student");
return (
<div key={u.id} className="flex items-center gap-3 px-3 py-2.5">
<Link
href={`/profile/${u.id}`}
className="flex min-w-0 flex-1 items-center gap-3 rounded-md outline-none hover:text-primary focus-visible:ring-2 focus-visible:ring-ring"
>
<UserAvatar name={u.name} className="h-9 w-9" {...avatarPropsOf(u)} />
<div className="min-w-0 flex-1">
<p className="flex items-center gap-2 truncate text-sm font-medium">
@@ -475,6 +479,7 @@ function UsersPanel() {
{u.role === "admin" || u.role === "mentor" ? "∞" : formatFaNumber(u.hearts)}
</span>
</div>
</Link>
<div className="flex shrink-0 gap-1.5">
{canMutate && u.id !== currentUser?.id && (
u.isLocked ? (
@@ -515,7 +520,7 @@ function UsersPanel() {
);
}
function UserDialog({
export function UserDialog({
user,
onClose,
onSaved,
@@ -529,18 +534,13 @@ function UserDialog({
const [form, setForm] = useState<User>(user ?? ({} as User));
const [password, setPassword] = useState("");
const [securityAnswer, setSecurityAnswer] = useState("");
const [resetAnswer, setResetAnswer] = useState("");
const [resetPassword, setResetPassword] = useState("");
const [saving, setSaving] = useState(false);
const [resetting, setResetting] = useState(false);
useEffect(() => {
if (user) {
setForm(user);
setPassword("");
setSecurityAnswer("");
setResetAnswer("");
setResetPassword("");
}
}, [user]);
@@ -633,29 +633,6 @@ function UserDialog({
}
};
const doReset = async () => {
if (!form?.id) return;
const pwErr = validatePassword(resetPassword);
if (!resetAnswer.trim() || pwErr) {
toast.error(pwErr ?? "جواب امنیتی و رمز جدید (حداقل ۸ کاراکتر) لازم است");
return;
}
setResetting(true);
try {
await http.post(`/api/admin/users/${form.id}/reset-password`, {
securityAnswer: resetAnswer,
newPassword: resetPassword,
});
toast.success("رمز عبور ریست شد");
setResetAnswer("");
setResetPassword("");
} catch (e) {
toast.error(toUserError(e));
} finally {
setResetting(false);
}
};
return (
<Dialog open={!!user} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
@@ -664,7 +641,7 @@ function UserDialog({
<DialogDescription>
{isNew
? "حساب جدید با تلفن و سوال امنیتی. رمز حداقل ۸ کاراکتر."
: "اطلاعات کاربر را به‌روزرسانی کنید. ریست رمز فقط با جواب امنیتی."}
: "اطلاعات کاربر را به‌روزرسانی کنید."}
</DialogDescription>
</DialogHeader>
<div className="grid gap-3">
@@ -768,40 +745,6 @@ function UserDialog({
/>
</div>
)}
{!isNew && (
<div className="space-y-2 rounded-md border border-dashed border-border p-3">
<p className="text-sm font-medium">ریست رمز با سوال امنیتی</p>
{form.securityQuestion ? (
<p className="text-xs text-muted-foreground">سوال: {form.securityQuestion}</p>
) : (
<p className="text-xs text-destructive">سوال امنیتی تعریف نشده است.</p>
)}
<PasswordInput
value={resetAnswer}
onChange={(e) => setResetAnswer(e.target.value)}
placeholder="جواب امنیتی"
dir="rtl"
autoComplete="off"
/>
<PasswordInput
value={resetPassword}
onChange={(e) => setResetPassword(e.target.value)}
placeholder="رمز جدید (حداقل ۸ کاراکتر)"
minLength={8}
autoComplete="new-password"
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={resetting || !form.hasSecurityAnswer}
onClick={doReset}
>
{resetting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <KeyRound className="h-3.5 w-3.5" />}
ریست رمز
</Button>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>انصراف</Button>
@@ -68,16 +68,18 @@ export function InvitesPanel() {
const [editBusy, setEditBusy] = useState(false);
const [page, setPage] = useState(1);
const [q, setQ] = useState("");
const [status, setStatus] = useState<"" | RegistrationInvite["status"]>("");
useEffect(() => {
setPage(1);
}, [q]);
}, [q, status]);
const { data, isLoading } = useQuery({
queryKey: ["admin", "invites", q, page],
queryKey: ["admin", "invites", q, status, page],
queryFn: () => {
const params = new URLSearchParams(buildPageQuery(page));
if (q.trim()) params.set("q", q.trim());
if (status) params.set("status", status);
return http.get<Paginated<RegistrationInvite>>(`/api/admin/invites?${params.toString()}`);
},
});
@@ -296,14 +298,43 @@ export function InvitesPanel() {
</CardHeader>
<CardContent className="space-y-4">
<ListToolbar>
<div className="flex flex-wrap items-center gap-2">
{(
[
["", "همه"],
["active", "فعال"],
["paused", "متوقف"],
["exhausted", "تکمیل‌شده"],
["revoked", "لغو شده"],
] as const
).map(([value, label]) => (
<Button
key={value || "all"}
type="button"
size="sm"
variant={status === value ? "default" : "outline"}
onClick={() => setStatus(value)}
>
{label}
</Button>
))}
</div>
<Input
placeholder="جستجو با برچسب…"
value={q}
onChange={(e) => setQ(e.target.value)}
className="max-w-xs"
/>
{q && (
<Button type="button" size="sm" variant="ghost" onClick={() => setQ("")}>
{(q || status) && (
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => {
setQ("");
setStatus("");
}}
>
پاک کردن
</Button>
)}
@@ -315,7 +346,7 @@ export function InvitesPanel() {
)}
{!isLoading && invites.length === 0 && (
<p className="py-6 text-center text-sm text-muted-foreground">
{q.trim() ? "نتیجه‌ای یافت نشد." : "هنوز لینک عضویتی ساخته نشده است."}
{q.trim() || status ? "نتیجه‌ای یافت نشد." : "هنوز لینک عضویتی ساخته نشده است."}
</p>
)}
<div className="space-y-2">
@@ -1,7 +1,7 @@
"use client";
import { useMemo, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
Flame,
@@ -34,6 +34,12 @@ import {
} from "@/components/ui/dialog";
import { toast } from "@/components/providers";
import { cn, formatFaNumber } from "@/lib/utils";
import {
DEFAULT_PAGE_SIZE,
ListPagination,
buildPageQuery,
type Paginated,
} from "@/components/admin/list-pagination";
type SortKey = "xp" | "progress" | "activity" | "streak";
type FilterKey = "all" | "active" | "locked";
@@ -58,6 +64,11 @@ export function LearningPanel() {
const [sort, setSort] = useState<SortKey>("xp");
const [selectedId, setSelectedId] = useState<number | null>(null);
const [unlocking, setUnlocking] = useState(false);
const [page, setPage] = useState(1);
useEffect(() => {
setPage(1);
}, [q, filter, sort]);
const overviewQ = useQuery({
queryKey: ["admin", "learning", "overview"],
@@ -66,11 +77,17 @@ export function LearningPanel() {
});
const studentsQ = useQuery({
queryKey: ["admin", "learning", "students", q],
queryFn: () =>
http.get<{ students: StudentLearningRow[] }>(
`/api/admin/learning/students?q=${encodeURIComponent(q)}`
),
queryKey: ["admin", "learning", "students", q, filter, sort, page],
queryFn: () => {
const params = new URLSearchParams(buildPageQuery(page));
if (q.trim()) params.set("q", q.trim());
if (filter !== "all") params.set("filter", filter);
params.set("sort", sort);
return http.get<Paginated<StudentLearningRow>>(
`/api/admin/learning/students?${params.toString()}`,
);
},
placeholderData: keepPreviousData,
retry: 1,
});
@@ -98,27 +115,9 @@ export function LearningPanel() {
};
const overview = overviewQ.data?.overview;
const students = useMemo(() => {
let rows = [...(studentsQ.data?.students ?? [])];
if (filter === "active") rows = rows.filter((r) => r.isActive && !r.isLocked);
if (filter === "locked") rows = rows.filter((r) => r.isLocked);
rows.sort((a, b) => {
switch (sort) {
case "progress":
return b.progressPct - a.progressPct;
case "streak":
return b.streakCurrent - a.streakCurrent;
case "activity": {
const at = a.lastActivityAt ? new Date(a.lastActivityAt).getTime() : 0;
const bt = b.lastActivityAt ? new Date(b.lastActivityAt).getTime() : 0;
return bt - at;
}
default:
return b.xp - a.xp;
}
});
return rows;
}, [studentsQ.data?.students, filter, sort]);
const students = studentsQ.data?.items ?? [];
const studentsTotal = studentsQ.data?.total ?? 0;
const studentsPageSize = studentsQ.data?.pageSize ?? DEFAULT_PAGE_SIZE;
const cards = overview
? [
@@ -177,7 +176,7 @@ export function LearningPanel() {
<div>
<CardTitle className="text-base">جدول هنرجویان</CardTitle>
<p className="mt-1 text-xs text-muted-foreground">
{formatFaNumber(students.length)} نفر · برای جزئیات روی ردیف بزنید
{formatFaNumber(studentsTotal)} نفر · برای جزئیات روی ردیف بزنید
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
@@ -295,6 +294,12 @@ export function LearningPanel() {
<p className="py-10 text-center text-sm text-muted-foreground">هنرجویی یافت نشد.</p>
)}
</div>
<ListPagination
page={page}
pageSize={studentsPageSize}
total={studentsTotal}
onPageChange={setPage}
/>
</CardContent>
</Card>
@@ -305,9 +310,11 @@ export function LearningPanel() {
{detailQ.data?.learning.user.name ?? "جزئیات یادگیری"}
</DialogTitle>
<DialogDescription>
{detailQ.data
? `@${detailQ.data.learning.user.username} · ${pct(detailQ.data.learning.user.progressPct)} مسیر`
: "در حال بارگذاری…"}
{detailQ.isError
? toUserError(detailQ.error, "بارگذاری جزئیات ممکن نشد")
: detailQ.data
? `@${detailQ.data.learning.user.username} · ${pct(detailQ.data.learning.user.progressPct)} مسیر`
: "در حال بارگذاری…"}
</DialogDescription>
</DialogHeader>
<div className="space-y-5 px-6 py-5">
@@ -1,7 +1,7 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { Loader2 } from "lucide-react";
import { http, toUserError } from "@/lib/api";
import type { CertificatePhysicalOrder, PhysicalCertSettings, PhysicalOrderStatus } from "@/lib/types";
@@ -11,6 +11,11 @@ import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { toast } from "@/components/providers";
import { formatFaNumber } from "@/lib/utils";
import {
DEFAULT_PAGE_SIZE,
ListPagination,
buildPageQuery,
} from "@/components/admin/list-pagination";
const STATUS_LABEL: Record<PhysicalOrderStatus, string> = {
requested: "درخواست‌شده",
@@ -27,16 +32,29 @@ const NEXT: Partial<Record<PhysicalOrderStatus, PhysicalOrderStatus>> = {
export function PhysicalOrdersPanel() {
const qc = useQueryClient();
const [statusFilter, setStatusFilter] = useState("");
const [page, setPage] = useState(1);
const [notes, setNotes] = useState<Record<number, string>>({});
const [tracking, setTracking] = useState<Record<number, string>>({});
const [busy, setBusy] = useState<number | null>(null);
useEffect(() => {
setPage(1);
}, [statusFilter]);
const { data, isLoading } = useQuery({
queryKey: ["admin", "physical-orders", statusFilter],
queryFn: () =>
http.get<{ orders: CertificatePhysicalOrder[]; physical: PhysicalCertSettings }>(
`/api/admin/physical-orders?status=${encodeURIComponent(statusFilter)}`,
),
queryKey: ["admin", "physical-orders", statusFilter, page],
queryFn: () => {
const params = new URLSearchParams(buildPageQuery(page));
if (statusFilter) params.set("status", statusFilter);
return http.get<{
orders: CertificatePhysicalOrder[];
physical: PhysicalCertSettings;
total: number;
page: number;
pageSize: number;
}>(`/api/admin/physical-orders?${params.toString()}`);
},
placeholderData: keepPreviousData,
});
const update = async (id: number, status: PhysicalOrderStatus) => {
@@ -151,6 +169,12 @@ export function PhysicalOrdersPanel() {
{!isLoading && (data?.orders ?? []).length === 0 && (
<p className="py-8 text-center text-sm text-muted-foreground">سفارشی نیست.</p>
)}
<ListPagination
page={page}
pageSize={data?.pageSize ?? DEFAULT_PAGE_SIZE}
total={data?.total ?? 0}
onPageChange={setPage}
/>
</CardContent>
</Card>
);
@@ -0,0 +1,245 @@
"use client";
import { useState, type ReactNode } from "react";
import { useRouter } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { KeyRound, Loader2, Lock, Mail, Pencil, Phone, Shield, ShieldQuestion, Trash2, Unlock } from "lucide-react";
import { UserDialog } from "@/components/admin-studio";
import { useConfirm } from "@/components/admin/use-confirm";
import { Panel } from "@/components/panel";
import { toast } from "@/components/providers";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
import { Skeleton } from "@/components/ui/skeleton";
import { http, toUserError } from "@/lib/api";
import { useAuth } from "@/lib/auth-store";
import { validatePassword } from "@/lib/validation";
import { cn } from "@/lib/utils";
import type { User } from "@/lib/types";
export function AdminProfileManage({ userId }: { userId: number }) {
const me = useAuth((s) => s.user);
const router = useRouter();
const qc = useQueryClient();
const { confirm, dialog } = useConfirm();
const [busy, setBusy] = useState<"lock" | "delete" | "reset" | null>(null);
const [editing, setEditing] = useState<User | null>(null);
const [resetOpen, setResetOpen] = useState(false);
const [nextPassword, setNextPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const accountQ = useQuery({
queryKey: ["admin", "user", userId],
queryFn: () => http.get<{ user: User }>(`/api/admin/users/${userId}`),
retry: 1,
});
const refresh = () => {
qc.invalidateQueries({ queryKey: ["admin", "user", userId] });
qc.invalidateQueries({ queryKey: ["public-profile", userId] });
qc.invalidateQueries({ queryKey: ["admin", "users"] });
qc.invalidateQueries({ queryKey: ["admin", "stats"] });
};
const account = accountQ.data?.user;
const isSelf = account?.id === me?.id;
const act = async (kind: "lock" | "unlock") => {
setBusy("lock");
try {
await http.post(`/api/admin/users/${userId}/${kind}`);
toast.success(kind === "lock" ? "حساب قفل شد" : "حساب فعال شد");
refresh();
} catch (e) {
toast.error(toUserError(e));
} finally {
setBusy(null);
}
};
const closeReset = () => {
setResetOpen(false);
setNextPassword("");
setConfirmPassword("");
};
const resetPassword = async () => {
const pwErr = validatePassword(nextPassword, confirmPassword);
if (pwErr) {
toast.error(pwErr);
return;
}
setBusy("reset");
try {
await http.post(`/api/admin/users/${userId}/reset-password`, { newPassword: nextPassword });
toast.success("رمز عبور عوض شد");
closeReset();
} catch (e) {
toast.error(toUserError(e));
} finally {
setBusy(null);
}
};
const del = async () => {
if (!account) {
return;
}
if (
!(await confirm(
"حذف کاربر",
`حساب «${account.name}» (${account.email}) برای همیشه حذف شود؟ این عمل بازگشت‌ناپذیر است.`,
))
) {
return;
}
setBusy("delete");
try {
await http.del(`/api/admin/users/${account.id}`);
toast.success("کاربر حذف شد");
router.push("/admin?tab=users");
} catch (e) {
toast.error(toUserError(e));
} finally {
setBusy(null);
}
};
return (
<>
{dialog}
<Panel
title="مدیریت حساب"
description="فقط ادمین این بخش را می‌بیند."
icon={<Shield className="h-5 w-5" />}
tint="bg-destructive/10 text-destructive"
>
{accountQ.isPending && (
<div className="space-y-2" aria-busy="true">
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-9 w-full" />
</div>
)}
{accountQ.isError && (
<p className="text-sm font-bold text-destructive">{toUserError(accountQ.error, "حساب پیدا نشد")}</p>
)}
{account && (
<div className="space-y-4">
<dl className="grid gap-3 sm:grid-cols-2">
<AccountFact icon={<Mail className="h-3.5 w-3.5" />} label="ایمیل" value={account.email} ltr />
<AccountFact
icon={<Phone className="h-3.5 w-3.5" />}
label="تلفن"
value={account.phone || "ثبت نشده"}
ltr
/>
<AccountFact
className="sm:col-span-2"
icon={<ShieldQuestion className="h-3.5 w-3.5" />}
label="سوال امنیتی"
value={account.securityQuestion || "تعریف نشده"}
/>
</dl>
<div className="space-y-2 border-t border-border/70 pt-4">
{!isSelf &&
(account.isLocked ? (
<Button size="sm" variant="success" disabled={busy !== null} onClick={() => act("unlock")}>
{busy === "lock" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Unlock className="h-3.5 w-3.5" />}
فعال‌سازی
</Button>
) : (
<Button size="sm" variant="outline" disabled={busy !== null} onClick={() => act("lock")}>
{busy === "lock" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Lock className="h-3.5 w-3.5" />}
قفل
</Button>
))}
<div className={cn("grid gap-2", isSelf ? "sm:grid-cols-2" : "sm:grid-cols-3")}>
<Button size="sm" variant="outline" className="w-full" onClick={() => setEditing(account)}>
<Pencil className="h-3.5 w-3.5" /> ویرایش
</Button>
<Button size="sm" variant="outline" className="w-full" disabled={busy !== null} onClick={() => setResetOpen(true)}>
<KeyRound className="h-3.5 w-3.5" /> ریست رمز
</Button>
{!isSelf && (
<Button size="sm" variant="destructive" className="w-full" disabled={busy !== null} onClick={del}>
{busy === "delete" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Trash2 className="h-3.5 w-3.5" />}
حذف
</Button>
)}
</div>
</div>
</div>
)}
</Panel>
<UserDialog user={editing} onClose={() => setEditing(null)} onSaved={refresh} />
<Dialog open={resetOpen} onOpenChange={(open) => !open && closeReset()}>
<DialogContent>
<DialogHeader>
<DialogTitle>ریست رمز</DialogTitle>
<DialogDescription>رمز جدید همین حساب را بنویسید.</DialogDescription>
</DialogHeader>
<div className="grid gap-3">
<div className="space-y-1.5">
<Label htmlFor="reset-password">رمز جدید</Label>
<PasswordInput
id="reset-password"
value={nextPassword}
onChange={(e) => setNextPassword(e.target.value)}
autoComplete="new-password"
placeholder="حداقل ۸ کاراکتر"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="reset-password-confirm">تکرار رمز</Label>
<PasswordInput
id="reset-password-confirm"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
placeholder="همان رمز را دوباره بنویسید"
/>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={closeReset}>
انصراف
</Button>
<Button onClick={() => void resetPassword()} disabled={busy === "reset"}>
{busy === "reset" ? <Loader2 className="h-4 w-4 animate-spin" /> : <KeyRound className="h-4 w-4" />}
ذخیره رمز
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
function AccountFact({
icon,
label,
value,
ltr,
className,
}: {
icon: ReactNode;
label: string;
value: string;
ltr?: boolean;
className?: string;
}) {
return (
<div className={cn("rounded-xl border border-border/80 bg-muted/25 px-3.5 py-3", className)}>
<dt className="flex items-center gap-1.5 text-[11px] font-bold text-muted-foreground">
{icon}
{label}
</dt>
<dd className={cn("mt-1.5 text-sm font-semibold leading-relaxed [overflow-wrap:anywhere]", ltr && "font-latin")} dir={ltr ? "ltr" : undefined}>
{value}
</dd>
</div>
);
}
@@ -142,7 +142,7 @@ export function StatsPanel() {
const s = data?.stats;
const cards = [
{ label: "هنرجویان", value: s?.users ?? 0, icon: Users, klass: "text-primary bg-primary/10", hint: "حساب‌های فعال در سامانه" },
{ label: "هنرجویان", value: s?.users ?? 0, icon: Users, klass: "text-primary bg-primary/10", hint: "هنرجویان با حساب فعال" },
{ label: "درس‌ها", value: s?.lessons ?? 0, icon: BookOpen, klass: "text-secondary bg-secondary/10", hint: "درس‌های ثبت‌شده در درخت" },
{ label: "سوالات", value: s?.questions ?? 0, icon: Activity, klass: "text-accent bg-accent/10", hint: "بانک سوالات آزمون" },
{ label: "رویدادها", value: s?.events ?? 0, icon: CalendarPlus, klass: "text-gold bg-gold/10", hint: "جلسات و کارگاه‌ها" },
+1
View File
@@ -24,6 +24,7 @@ const PROTECTED_PREFIXES = [
"/quiz",
"/notifications",
"/lockout",
"/profile",
];
function needsAuth(p: string) {
+155
View File
@@ -0,0 +1,155 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { RefreshCw } from "lucide-react";
import { http, toUserError } from "@/lib/api";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export type CaptchaChallenge = {
challengeId: string;
imageBase64: string;
expiresIn?: number;
};
const FALLBACK_SECONDS = 120;
export function CaptchaField({
id,
value,
onChange,
onChallenge,
onError,
}: {
id: string;
value: string;
onChange: (value: string) => void;
onChallenge: (challenge: CaptchaChallenge | null) => void;
onError?: (message: string | null) => void;
}) {
const [challenge, setChallenge] = useState<CaptchaChallenge | null>(null);
const [loading, setLoading] = useState(true);
const [left, setLeft] = useState(FALLBACK_SECONDS);
const gen = useRef(0);
const rotating = useRef(false);
const onChallengeRef = useRef(onChallenge);
const onChangeRef = useRef(onChange);
const onErrorRef = useRef(onError);
onChallengeRef.current = onChallenge;
onChangeRef.current = onChange;
onErrorRef.current = onError;
const load = useCallback(async (silent: boolean) => {
if (silent && rotating.current) return;
if (silent) rotating.current = true;
const ticket = ++gen.current;
if (!silent) setLoading(true);
try {
const data = await http.get<CaptchaChallenge>("/api/auth/captcha");
if (ticket !== gen.current) return;
setChallenge(data);
onChallengeRef.current(data);
setLeft(Math.max(15, Math.round(data.expiresIn || FALLBACK_SECONDS)));
onChangeRef.current("");
onErrorRef.current?.(null);
} catch (err) {
if (ticket !== gen.current) return;
if (!silent) {
setChallenge(null);
onChallengeRef.current(null);
}
onErrorRef.current?.(toUserError(err, "بارگذاری کد امنیتی ممکن نشد"));
} finally {
rotating.current = false;
if (ticket === gen.current && !silent) setLoading(false);
}
}, []);
useEffect(() => {
void load(false);
}, [load]);
useEffect(() => {
if (!challenge) return;
const timer = window.setInterval(() => {
setLeft((n) => {
if (n <= 1) {
void load(true);
return n;
}
return n - 1;
});
}, 1000);
return () => window.clearInterval(timer);
}, [challenge, load]);
const minutes = String(Math.floor(left / 60)).padStart(1, "0");
const seconds = String(left % 60).padStart(2, "0");
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label htmlFor={id}>کد امنیتی</Label>
<button
type="button"
onClick={() => void load(false)}
disabled={loading}
className="inline-flex items-center gap-1 text-xs font-medium text-primary underline-offset-4 hover:underline disabled:opacity-50"
>
<RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />
تازه‌سازی
{challenge && (
<span className="tabular-nums text-muted-foreground" dir="ltr">
{minutes}:{seconds}
</span>
)}
</button>
</div>
<div className="flex items-stretch gap-3">
<div
className="relative flex h-14 min-w-[9.5rem] shrink-0 items-center justify-center overflow-hidden rounded-xl border border-border bg-muted/60 shadow-inner"
aria-live="polite"
>
{challenge?.imageBase64 ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`data:image/png;base64,${challenge.imageBase64}`}
alt="کد امنیتی"
width={160}
height={52}
className="h-full w-full object-contain select-none"
draggable={false}
/>
) : (
<span className="text-sm text-muted-foreground">{loading ? "…" : "—"}</span>
)}
{challenge && (
<span
aria-hidden
className="absolute inset-x-0 bottom-0 h-0.5 bg-primary/15"
>
<span
className="block h-full bg-primary transition-[width] duration-1000 ease-linear"
style={{ width: `${Math.min(100, (left / Math.max(challenge.expiresIn || FALLBACK_SECONDS, 1)) * 100)}%` }}
/>
</span>
)}
</div>
<Input
id={id}
type="text"
autoComplete="off"
autoCapitalize="characters"
spellCheck={false}
value={value}
onChange={(e) => onChange(e.target.value.toUpperCase())}
required
dir="ltr"
className="h-14 text-center font-mono tracking-widest uppercase placeholder:text-center"
placeholder="کد تصویر"
maxLength={6}
/>
</div>
</div>
);
}
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { Crown, Sparkles, Timer, Trophy } from "lucide-react";
import { http, toUserError } from "@/lib/api";
@@ -110,10 +111,11 @@ export function LeaderboardPanel() {
{rest.map((e) => {
const isMe = !!user && e.userId === user.id;
return (
<div
<Link
key={e.userId}
href={`/profile/${e.userId}`}
className={cn(
"flex items-center gap-3 rounded-xl border px-4 py-2.5",
"flex items-center gap-3 rounded-xl border px-4 py-2.5 transition-colors hover:bg-muted/40",
isMe ? "border-primary/50 bg-primary/5 ring-1 ring-primary/30" : "border-border bg-card"
)}
>
@@ -135,7 +137,7 @@ export function LeaderboardPanel() {
<span className="shrink-0 text-sm font-bold text-gold">
{formatFaNumber(Math.round(e.xp))} امتیاز
</span>
</div>
</Link>
);
})}
{rest.length === 0 && entries.length === 0 && (
@@ -197,9 +199,10 @@ function PodiumCard({
}) {
const isMe = myId != null && entry.userId === myId;
return (
<div
<Link
href={`/profile/${entry.userId}`}
className={cn(
"flex flex-col items-center rounded-2xl border-2 border-border bg-card px-3 py-4 text-center shadow-offset-sm",
"flex flex-col items-center rounded-2xl border-2 border-border bg-card px-3 py-4 text-center shadow-offset-sm transition-colors hover:bg-muted/40",
tall ? "pb-6 pt-5" : "pb-4",
isMe && "border-primary/50 ring-2 ring-primary/30",
className
@@ -225,7 +228,7 @@ function PodiumCard({
>
{entry.rank === 1 ? <Crown className="h-4 w-4" /> : formatFaNumber(entry.rank)}
</div>
</div>
</Link>
);
}
File diff suppressed because it is too large Load Diff
+15 -79
View File
@@ -1,9 +1,9 @@
"use client";
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { useState, type ReactNode } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowLeft, ArrowRight, Loader2, RefreshCw, UserPlus } from "lucide-react";
import { ArrowLeft, ArrowRight, Loader2, UserPlus } from "lucide-react";
import { useAuth } from "@/lib/auth-store";
import { http, toUserError } from "@/lib/api";
import type { User } from "@/lib/types";
@@ -12,6 +12,7 @@ import { Input } from "@/components/ui/input";
import { PasswordInput } from "@/components/ui/password-input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { CaptchaField, type CaptchaChallenge } from "@/components/captcha-field";
import {
validateEmail,
validateName,
@@ -30,11 +31,6 @@ const STEPS = [
{ id: 3, title: "گذرواژه", hint: "رمز و کد امنیتی" },
] as const;
type CaptchaChallenge = {
challengeId: string;
imageBase64: string;
};
type RegisterFormProps = {
endpoint: string;
submitLabel: string;
@@ -73,30 +69,10 @@ export function RegisterForm({ endpoint, submitLabel, description }: RegisterFor
const [securityAnswer, setSecurityAnswer] = useState("");
const [captchaAnswer, setCaptchaAnswer] = useState("");
const [challenge, setChallenge] = useState<CaptchaChallenge | null>(null);
const [captchaLoading, setCaptchaLoading] = useState(false);
const [captchaKey, setCaptchaKey] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadCaptcha = useCallback(async () => {
setCaptchaLoading(true);
setCaptchaAnswer("");
try {
const data = await http.get<CaptchaChallenge>("/api/auth/captcha");
setChallenge(data);
} catch (err) {
setChallenge(null);
setError(toUserError(err, "بارگذاری کد امنیتی ممکن نشد"));
} finally {
setCaptchaLoading(false);
}
}, []);
useEffect(() => {
if (step === 3) {
void loadCaptcha();
}
}, [step, loadCaptcha]);
const goNext = () => {
setError(null);
if (step === 1) {
@@ -152,7 +128,7 @@ export function RegisterForm({ endpoint, submitLabel, description }: RegisterFor
}
if (!challenge) {
setError("ابتدا کد امنیتی را بارگذاری کنید");
void loadCaptcha();
setCaptchaKey((k) => k + 1);
return;
}
setLoading(true);
@@ -176,7 +152,7 @@ export function RegisterForm({ endpoint, submitLabel, description }: RegisterFor
router.push("/cap");
} catch (err) {
setError(toUserError(err, "ثبت‌نام ناموفق بود"));
void loadCaptcha();
setCaptchaKey((k) => k + 1);
} finally {
setLoading(false);
}
@@ -322,54 +298,14 @@ export function RegisterForm({ endpoint, submitLabel, description }: RegisterFor
placeholder="همان رمز را دوباره وارد کنید"
/>
</Field>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label htmlFor="reg-captcha">کد امنیتی</Label>
<button
type="button"
onClick={() => void loadCaptcha()}
disabled={captchaLoading}
className="inline-flex items-center gap-1 text-xs font-medium text-primary underline-offset-4 hover:underline disabled:opacity-50"
>
<RefreshCw className={`h-3.5 w-3.5 ${captchaLoading ? "animate-spin" : ""}`} />
تازه‌سازی
</button>
</div>
<div className="flex items-stretch gap-3">
<div
className="relative flex h-14 min-w-[9.5rem] shrink-0 items-center justify-center overflow-hidden rounded-xl border border-border bg-muted/60 shadow-inner"
aria-live="polite"
>
{captchaLoading || !challenge?.imageBase64 ? (
<span className="text-sm text-muted-foreground">…</span>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`data:image/png;base64,${challenge.imageBase64}`}
alt="کد امنیتی"
width={160}
height={52}
className="h-full w-full object-contain select-none"
draggable={false}
/>
)}
</div>
<Input
id="reg-captcha"
type="text"
autoComplete="off"
autoCapitalize="characters"
spellCheck={false}
value={captchaAnswer}
onChange={(e) => setCaptchaAnswer(e.target.value.toUpperCase())}
required
dir="ltr"
className="h-14 text-center font-mono tracking-widest uppercase placeholder:text-center"
placeholder="کد تصویر"
maxLength={6}
/>
</div>
</div>
<CaptchaField
key={captchaKey}
id="reg-captcha"
value={captchaAnswer}
onChange={setCaptchaAnswer}
onChallenge={setChallenge}
onError={setError}
/>
</>
)}
@@ -400,7 +336,7 @@ export function RegisterForm({ endpoint, submitLabel, description }: RegisterFor
variant="gradient"
className="h-12 min-w-0 flex-1"
size="lg"
disabled={loading || captchaLoading || !challenge}
disabled={loading || !challenge}
>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <UserPlus className="h-4 w-4" />}
{submitLabel}
+46 -6
View File
@@ -1,8 +1,9 @@
"use client";
import { useEffect, useRef, useState } from "react";
import BoringAvatar from "boring-avatars";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";
import { mediaObjectKey } from "@/lib/media";
const AVATAR_COLORS = ["#7d6df2", "#0f9b8e", "#16a34a", "#d97706", "#9333ea", "#0891b2", "#65a30d", "#f59e0b"];
export { AVATAR_COLORS };
@@ -41,6 +42,40 @@ export function avatarPropsOf(u: AvatarOwner): { src: string | null; variant: Av
};
}
function useSettledPhoto(src?: string | null): string | null {
const [painted, setPainted] = useState<string | null>(null);
const paintedRef = useRef<string | null>(null);
useEffect(() => {
if (!src) {
paintedRef.current = null;
setPainted(null);
return;
}
if (src === paintedRef.current) return;
let cancel = false;
const img = new Image();
img.onload = () => {
if (cancel) return;
paintedRef.current = src;
setPainted(src);
};
img.onerror = () => {
if (cancel) return;
if (mediaObjectKey(src) !== mediaObjectKey(paintedRef.current)) {
paintedRef.current = null;
setPainted(null);
}
};
img.src = src;
return () => {
cancel = true;
};
}, [src]);
return painted;
}
export function UserAvatar({
name,
src,
@@ -54,12 +89,17 @@ export function UserAvatar({
palette?: string[];
className?: string;
}) {
if (src) {
const painted = useSettledPhoto(src);
if (painted) {
return (
<Avatar className={cn("ring-1 ring-black/5 dark:ring-white/10", className)}>
<AvatarImage src={src} alt={name} />
<AvatarFallback />
</Avatar>
<span
className={cn(
"relative inline-grid shrink-0 place-items-center overflow-hidden rounded-full bg-background ring-1 ring-black/5 dark:ring-white/10",
className
)}
>
<img src={painted} alt={name} className="h-full w-full object-cover" draggable={false} />
</span>
);
}
return (
+36
View File
@@ -0,0 +1,36 @@
"use client";
import { useEffect, useState } from "react";
import { mediaObjectKey } from "@/lib/media";
import { loadCachedMedia } from "@/lib/transfer";
/** Local bytes for an image or similar object. A replacement has a new key and loads again. Video is not cached. */
export function useCachedObjectUrl(url: string, cacheable = true): string {
const key = mediaObjectKey(url);
const [local, setLocal] = useState("");
useEffect(() => {
if (!cacheable || !url) {
setLocal("");
return;
}
let dead = false;
let objectUrl = "";
void loadCachedMedia(url)
.then((blob) => {
if (dead) return;
objectUrl = URL.createObjectURL(blob);
setLocal(objectUrl);
})
.catch(() => {
if (!dead) setLocal(url);
});
return () => {
dead = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [cacheable, key, url]);
if (!cacheable) return url;
return local;
}
+8
View File
@@ -0,0 +1,8 @@
/** Storage path of a signed /media URL, without the rotating signature. */
export function mediaObjectKey(src: string | null | undefined): string {
if (!src) return "";
const cut = src.indexOf("?");
const path = cut >= 0 ? src.slice(0, cut) : src;
const i = path.indexOf("/media/");
return i >= 0 ? path.slice(i) : path;
}
+179
View File
@@ -0,0 +1,179 @@
import { useAuth } from "./auth-store";
import { ApiError, refreshSession, throwFromResponse } from "./api";
import { mediaObjectKey } from "./media";
const mediaCacheName = "pargar-media-v1";
function apiBase(): string {
const fromEnv = process.env.NEXT_PUBLIC_API_URL;
if (fromEnv !== undefined && fromEnv !== "") return fromEnv.replace(/\/$/, "");
return "";
}
function requestId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID().replace(/-/g, "").slice(0, 16);
}
return Math.random().toString(16).slice(2, 18);
}
async function refreshOnce(): Promise<string | null> {
await refreshSession();
return useAuth.getState().accessToken;
}
type XHRResult = { status: number; body: string; blob: Blob | null };
function xhrSend(
method: "GET" | "POST",
url: string,
opts: {
token: string | null;
body?: FormData;
responseType: "json" | "blob";
onProgress?: (ratio: number) => void;
},
): Promise<XHRResult> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(method, url);
xhr.withCredentials = true;
xhr.setRequestHeader("X-Request-ID", requestId());
if (opts.token) xhr.setRequestHeader("Authorization", `Bearer ${opts.token}`);
xhr.responseType = opts.responseType === "blob" ? "blob" : "text";
const progress = (e: ProgressEvent) => {
if (!opts.onProgress) return;
if (e.lengthComputable && e.total > 0) opts.onProgress(Math.min(1, e.loaded / e.total));
};
if (method === "POST") xhr.upload.onprogress = progress;
else xhr.onprogress = progress;
xhr.onload = () => {
resolve({
status: xhr.status,
body: opts.responseType === "json" ? String(xhr.response ?? "") : "",
blob: opts.responseType === "blob" ? (xhr.response as Blob) : null,
});
};
xhr.onerror = () => reject(new ApiError("ارتباط قطع شد", 0));
xhr.send(opts.body ?? null);
});
}
/** Multipart upload that reports how full the transfer is. */
export async function apiFormProgress<T>(
path: string,
form: FormData,
onProgress?: (ratio: number) => void,
): Promise<T> {
const url = `${apiBase()}${path}`;
const send = (token: string | null) =>
xhrSend("POST", url, { token, body: form, responseType: "json", onProgress });
let res = await send(useAuth.getState().accessToken);
if (res.status === 401 && (useAuth.getState().refreshToken || useAuth.getState().user)) {
res = await send(await refreshOnce());
}
if (res.status < 200 || res.status >= 300) {
const fake = new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
await throwFromResponse(fake, `Upload failed (${res.status})`);
}
onProgress?.(1);
return JSON.parse(res.body) as T;
}
/** Download into memory and report fill ratio. Does not open a tab. */
export async function fetchBlob(
url: string,
onProgress?: (ratio: number) => void,
): Promise<Blob> {
const res = await xhrSend("GET", url, { token: null, responseType: "blob", onProgress });
if (res.status < 200 || res.status >= 300 || !res.blob) {
throw new ApiError("دانلود ممکن نشد", res.status);
}
onProgress?.(1);
return res.blob;
}
async function mediaCache(): Promise<Cache | null> {
if (typeof caches === "undefined") return null;
try {
return await caches.open(mediaCacheName);
} catch {
return null;
}
}
/** Bytes already saved for this object. A new photo, video, or voice is a new key. */
export async function readCachedMedia(url: string): Promise<Blob | null> {
try {
const key = mediaObjectKey(url);
if (!key) return null;
const cache = await mediaCache();
const hit = await cache?.match(key);
if (!hit) return null;
return hit.blob();
} catch {
return null;
}
}
async function writeCachedMedia(url: string, blob: Blob): Promise<void> {
try {
const key = mediaObjectKey(url);
if (!key) return;
const cache = await mediaCache();
if (!cache) return;
await cache.put(
key,
new Response(blob, { headers: { "Content-Type": blob.type || "application/octet-stream" } }),
);
} catch {
// A failed cache still leaves the file ready to save.
}
}
function triggerDownload(blob: Blob, filename: string) {
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = objectUrl;
a.download = filename || "file";
a.rel = "noopener";
document.body.appendChild(a);
a.click();
a.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1500);
}
/**
* Save a remote file after it has finished downloading.
* Image, audio, and other files are kept by object key, so a later save does not fetch again.
* Video is streamed and is not kept this way.
*/
export async function saveRemoteFile(
url: string,
filename: string,
onProgress?: (ratio: number) => void,
opts?: { cache?: boolean },
): Promise<void> {
const cache = opts?.cache !== false;
if (cache) {
const hit = await readCachedMedia(url);
if (hit) {
onProgress?.(1);
triggerDownload(hit, filename);
return;
}
}
const blob = await fetchBlob(url, onProgress);
if (cache) await writeCachedMedia(url, blob);
triggerDownload(blob, filename);
}
/** One network fetch per object. Later views reuse the saved bytes until the object is replaced. */
export async function loadCachedMedia(url: string): Promise<Blob> {
const hit = await readCachedMedia(url);
if (hit) return hit;
const blob = await fetchBlob(url);
await writeCachedMedia(url, blob);
return blob;
}
+16
View File
@@ -193,6 +193,7 @@ export interface ChatMessage {
replyTo?: number;
pinnedAt?: string;
pinned?: boolean;
editedAt?: string;
attachment?: ChatAttachment;
reactions?: ChatReaction[];
}
@@ -264,6 +265,21 @@ export interface MCQQuestion {
explanation: string;
}
export interface PublicProfile {
id: number;
name: string;
username: string;
role: Role;
xp: number;
hearts: number;
streakCurrent: number;
streakLongest: number;
avatarVariant?: User["avatarVariant"];
avatarPalette?: string;
avatarPhoto?: string;
isLocked: boolean;
}
export interface AdminStats {
users: number;
lessons: number;