Compare commits
5
Commits
5ba558971c
...
8586bbafcc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8586bbafcc | ||
|
|
e05c4327d9 | ||
|
|
9f5b3b5593
|
||
|
|
e222a7dcd7
|
||
|
|
d9a2abb44f
|
@@ -1,67 +1,87 @@
|
||||
# Pargar
|
||||
<p align="center">
|
||||
<img src="frontend/public/logo.png" alt="Pargar mark" width="96" />
|
||||
</p>
|
||||
|
||||
A gated LMS for bootcamps: verified watch → quiz with hearts → skill-tree unlock → mentor chat, events, weekly league, monthly challenges, and certificates.
|
||||
<h1 align="center">Pargar</h1>
|
||||
|
||||
| Layer | Stack |
|
||||
|-------|--------|
|
||||
| API | Go 1.27+ · Postgres · Redis · S3/RustFS · WebSocket |
|
||||
| UI | Next.js 15 · React Query · Tailwind · Vazirmatn FD |
|
||||
| Edge | Traefik v3.7 (Compose and k3s) |
|
||||
<p align="center">
|
||||
<strong>A bootcamp learning path with no shortcuts.</strong><br />
|
||||
Verified watch, quizzes with hearts, a skill tree, and a mentor. The product UI is Persian (پرگار).
|
||||
</p>
|
||||
|
||||
## Quick start (local)
|
||||
<p align="center">
|
||||
<a href="https://github.com/Panahifarah/pargar/releases/tag/v0.1.3"><img src="https://img.shields.io/github/v/tag/Panahifarah/pargar?label=release&color=5B4CDB" alt="release" /></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-5B4CDB" alt="AGPL-3.0" /></a>
|
||||
</p>
|
||||
|
||||
Pargar is a gated LMS for a bootcamp. A student watches the lesson for real, passes that lesson's quiz, and only then opens the next branch of the skill tree. Alongside the path: mentor chat, events, a weekly league, a monthly challenge, and a certificate.
|
||||
|
||||
Public registration sits behind a phone whitelist. Invite links work on their own. Students cannot recover a password themselves; an admin sets a new one from that person's profile.
|
||||
|
||||
## The path
|
||||
|
||||
| Step | What happens |
|
||||
| --- | --- |
|
||||
| Verified watch | Watch time is checked with periodic heartbeats. Skipping ahead does not finish the lesson. |
|
||||
| Quiz with hearts | Each wrong answer spends a heart. An empty heart meter locks the account until staff reviews it. |
|
||||
| Skill tree | The next branch opens only after its parent is passed. Staff unlock the following lesson. |
|
||||
| Community | Mentor chat, events, a weekly league that resets on Monday, and a monthly challenge. |
|
||||
| Certificate | An unlisted digital link. A physical copy, when enabled, is offline payment only. |
|
||||
|
||||
Three roles: a **student** walks the path, a **mentor** sees curriculum and student learning, and an **admin** also manages users, invites, site settings, and physical certificate orders.
|
||||
|
||||
Product detail: [docs/product.md](docs/product.md)
|
||||
|
||||
## Run it locally
|
||||
|
||||
Docker is required. Copy `.env.example` and replace every `replace-with-…` value. The admin password is `ADMIN_PASSWORD`. The default username is `admin`.
|
||||
|
||||
```bash
|
||||
cp .env.example .env # fill in real secrets
|
||||
cp .env.example .env
|
||||
./scripts/compose-dev.sh up -d --build
|
||||
```
|
||||
|
||||
- App: **https://localhost** (Traefik default cert → one browser warning is expected)
|
||||
- Traefik dashboard: http://127.0.0.1:8088
|
||||
- Health: `curl -k https://localhost/api/health`
|
||||
|
||||
k3s:
|
||||
The app is at [https://localhost](https://localhost). Traefik uses its own certificate, so the browser shows one warning. API health:
|
||||
|
||||
```bash
|
||||
./scripts/k3s-load-images.sh
|
||||
./scripts/helm-k3s.sh
|
||||
curl -k https://pargar.local/api/health
|
||||
curl -k https://localhost/api/health
|
||||
```
|
||||
|
||||
Deployment details: [docs/deployment.md](docs/deployment.md)
|
||||
k3s and production: [docs/deployment.md](docs/deployment.md)
|
||||
|
||||
## Documentation
|
||||
## Stack
|
||||
|
||||
| Doc | Contents |
|
||||
|-----|----------|
|
||||
| [docs/README.md](docs/README.md) | Doc index and reading map |
|
||||
| [docs/architecture.md](docs/architecture.md) | System architecture and data flow |
|
||||
| [docs/product.md](docs/product.md) | Student / admin paths and features |
|
||||
| [docs/development.md](docs/development.md) | Development, tests, code conventions |
|
||||
| [docs/deployment.md](docs/deployment.md) | Compose · Helm/k3s · production |
|
||||
| [docs/security.md](docs/security.md) | Auth, secrets, rate limits, uploads |
|
||||
| [docs/operations.md](docs/operations.md) | Backup, metrics, troubleshooting |
|
||||
| [docs/api.md](docs/api.md) | API contract and key routes |
|
||||
| [docs/configuration.md](docs/configuration.md) | Environment variable reference |
|
||||
| Layer | Technology |
|
||||
| --- | --- |
|
||||
| API | Go, Postgres, Redis, S3/RustFS, WebSocket |
|
||||
| UI | Next.js 15, React, Tailwind |
|
||||
| Edge | Traefik, on Compose and Helm |
|
||||
|
||||
Domain summary for agents: [CONTEXT.md](CONTEXT.md)
|
||||
The browser talks to one origin. Traefik splits the UI from `/api`. Detail: [docs/architecture.md](docs/architecture.md)
|
||||
|
||||
## Repository layout
|
||||
## Repository
|
||||
|
||||
```text
|
||||
backend/ Go API, migrations, seed, media/sample.mp4
|
||||
frontend/ Next.js App Router
|
||||
deploy/ Compose + Traefik + Helm chart
|
||||
scripts/ compose / k3s / backup / regenerate-sample
|
||||
docs/ engineering documentation
|
||||
backend/ API, migrations, seed data
|
||||
frontend/ Next.js UI
|
||||
deploy/ Compose, Traefik, Helm chart
|
||||
scripts/ Local run, k3s, backup
|
||||
docs/ Engineering docs
|
||||
```
|
||||
|
||||
## Tests
|
||||
| Doc | Read it for |
|
||||
| --- | --- |
|
||||
| [Product](docs/product.md) | Student path, admin, invites, certificates |
|
||||
| [Architecture](docs/architecture.md) | Data flow and service boundaries |
|
||||
| [Development](docs/development.md) | Local workflow and tests |
|
||||
| [Deployment](docs/deployment.md) | Compose, Helm, production |
|
||||
| [Security](docs/security.md) | Sessions, secrets, rate limits, uploads |
|
||||
| [Operations](docs/operations.md) | Backup, metrics, troubleshooting |
|
||||
| [API](docs/api.md) | Route contract |
|
||||
| [Configuration](docs/configuration.md) | Environment variables |
|
||||
|
||||
```bash
|
||||
cd backend && go test ./internal/api/... ./internal/config/...
|
||||
cd frontend && bunx tsc --noEmit
|
||||
```
|
||||
Index: [docs/README.md](docs/README.md)
|
||||
|
||||
## License
|
||||
|
||||
This project is released under the [GNU Affero General Public License v3.0](LICENSE) (AGPL-3.0).
|
||||
Released under [AGPL-3.0](LICENSE).
|
||||
|
||||
+15
-15
@@ -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
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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, "بازیابی رمز عمومی غیرفعال است")
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -77,10 +77,12 @@ func SetupTracing(ctx context.Context, cfg Config) (func(context.Context) error,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Schemaless: a non-empty but different schema URL here (from an older
|
||||
// semconv pin) makes resource.Merge fail as soon as the SDK's
|
||||
// resource.Default() schema URL changes across otel upgrades.
|
||||
res, err := resource.Merge(
|
||||
resource.Default(),
|
||||
resource.NewWithAttributes(
|
||||
semconv.SchemaURL,
|
||||
resource.NewSchemaless(
|
||||
semconv.ServiceName(name),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Generated
+161
-190
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: "جلسات و کارگاهها" },
|
||||
|
||||
@@ -24,6 +24,7 @@ const PROTECTED_PREFIXES = [
|
||||
"/quiz",
|
||||
"/notifications",
|
||||
"/lockout",
|
||||
"/profile",
|
||||
];
|
||||
|
||||
function needsAuth(p: string) {
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user