12 Commits
Author SHA1 Message Date
ahp 3bf6dae724 feat: manual cert gating, chat layout overhaul, and video player UX fixes
- Certificate: Hardcode approval gate — completely removed the auto-issue path;
  `certRequiresApproval()` now strictly returns `true`, ignoring DB configuration.
- Certificate: Quiz completion now queues a pending record and triggers a
  "Pending approval" notification instead of falsely confirming issuance.
- Certificate: Public endpoints now enforce a guard on `status=issued`; frontend
  strips the auto-issue side effect and claim button (issuance restricted to admins).
- Chats tab: Refactor layout into a full-width centered banner above two
  equal-height side-by-side panels (`grid-cols-1 lg:grid-cols-2`), utilizing
  `max-h` and `overflow-y-auto` to eliminate whitespace bleed.
- Video player: Fix settings flyout prematurely closing on mouse movement (removed
  `setSettingsOpen` from `poke`); add a click-outside listener via `mousedown`.
- Video player: Deduplicate controls — the speed pill now directly cycles playback
  speed, leaving the gear icon as the single entry point for settings (eliminating
  overlapping popovers).
- Video player: Implement full-bleed theater mode using the `-mx-[50vw] w-screen`
  technique, keeping the progress strip constrained to `max-w-5xl` below.
2026-10-18 12:09:30 +03:30
ahp 6228e66d2c feat: production-grade video player — ambient mode, speed controls, hls.js, double-tap, settings flyout
- Ambient canvas (32×18, ~8fps rAF, blur(55px)) behind video; toggleable, persisted
  - Speed controls 0.5×–2× via settings flyout; capRate replaces lockRate, anti-cheat
  preserved
  - hls.js dynamic import for .m3u8 URLs; Safari native HLS fallback; MP4 unchanged
  - Double-tap left=seek back 10s, right=blocked; single-tap toggles play on touch
  - Keyboard: Space/K play-pause, J/← back, L/→ blocked, ↑↓ volume, M mute, F fullscreen
  - Settings flyout with speed picker + ambient toggle (animated, framer-motion)
  - Theater mode toggle, fullscreen API, volume slider with localStorage persistence
  - Buffer visualization behind progress bar, hover timestamp tooltip, playhead thumb
  - All anti-cheat intact: maxTrustedPosRef, PiP block, visibility pause, heartbeat system
2026-10-18 11:10:11 +03:30
ahpandClaude Sonnet 4.6 089c114019 feat: mobile-first responsive overhaul — safe areas, touch targets, bottom sheet, bottom nav
Viewport:
- Add `viewport` export with `viewport-fit=cover` (enables CSS safe-area-inset-*
  on bezel-less iOS/Android) and `interactive-widget=resizes-content` (prevents
  layout jumps from virtual keyboard open/close)

iOS zoom prevention:
- input.tsx, textarea.tsx: change text-sm (14px) → text-base sm:text-sm (16px)
  on mobile; iOS Safari auto-zooms any input with font-size < 16px

Touch targets:
- button.tsx: default h-10 → h-11 (44px), sm h-8 → h-9 (36px), icon h-10→h-11
  matching the 44×44px iOS HIG and WCAG 2.5.5 minimum
- Add touch-manipulation to buttons, inputs, textarea to eliminate 300ms tap delay

Dialog → mobile bottom sheet:
- On mobile (< 640px): anchored to bottom with full width, rounded top corners,
  slide-in-from-bottom / slide-out-to-bottom animation, drag handle affordance,
  max-h-[90svh] + overflow-y-auto to prevent viewport overflow
- Safe area padding: pb-[max(1.25rem,env(safe-area-inset-bottom))]
- On desktop (sm+): unchanged centered modal with zoom animation
- Close button gets a 32×32 tap area on mobile

Mobile navigation:
- New bottom-nav.tsx: persistent 3-tab bottom bar (مسیر/مرکز/لیگ) shown below
  md: breakpoint, hidden on desktop; 56px height + safe-area-inset-bottom;
  active tab gets heavier stroke and scale
- Only shown for authenticated non-admin users
- app-shell.tsx: injects BottomNav; main content gets dynamic padding-bottom to
  clear the bar (56px + safe area) on mobile, reverts to py-10 on md:
- Footer: safe-area-inset-bottom padding so content clears iPhone home indicator

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-18 10:51:37 +03:30
ahpandClaude Sonnet 4.6 602d3f2f4d feat: remove glassmorphism from notification toast; improve video player UX
Task 4 — Event Registration Confirmation (glassmorphism removal):
- Replace translucent gradient + backdrop-blur-md toast in use-realtime.tsx
  with a solid bg-card container, full-opacity border, and a colored
  left accent bar — high contrast, readable in all themes, no blur

Task 5 — Video player UX (within anti-cheat constraints):
- Keyboard shortcuts: Space (play/pause), M (mute), ← (seek back 10s),
  F (fullscreen) — active globally, disabled when an input is focused
- Fullscreen button added to the control bar (F key shortcut)
- Volume slider (0–100%) replaces mute-only toggle; volume is persisted
  to localStorage across sessions under key `pargar:volume`
- Buffer range visualized as a translucent white bar behind the playback
  progress track so users see how much is buffered
- Exponential backoff auto-retry on video error: 1s, 2s, 4s … 30s cap;
  retry count shown in error message; manual retry clears the timer
- Speed controls (0.75x–2x) are intentionally absent: the verified-watch
  anti-cheat locks playbackRate=1; changing it would break the heartbeat
  accumulation that gates quiz unlock and certification
- HLS/ABR requires backend FFmpeg transcoding; the player is structured
  so swapping src=mp4 → src=.m3u8 + hls.js is a one-file change when
  the backend pipeline is ready

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-18 10:40:13 +03:30
ahpandClaude Sonnet 4.6 40fa0b8581 fix: quality audit — memory leaks, dead prose classes, stale state scope
- safe-markdown: add `cancelled` flag to async useEffect; no more
  setState after unmount when component is destroyed mid-import
- rich-editor: same cleanup for preview tab effect; also remove dead
  `prose prose-sm max-w-none` classes (Tailwind Typography not installed)
  and replace with explicit arbitrary-selector markdown styling that
  actually renders using existing design tokens
- profile-certificate-slot: drop dead `ORDER_LABEL`, `order` variable,
  and `physicalOrder` from `MineCertificateResponse`; physical orders
  are disabled — these were leftover references causing lint noise
- learning-panel: change `issuingCert: boolean` → `issuingCertFor:
  number | null` so only the targeted user's button shows a spinner;
  a boolean shared across all users caused the wrong row to animate
  when the admin switched selected users mid-request

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-18 10:29:58 +03:30
ahpandClaude Sonnet 4.6 b4a7e7e1d3 fix: resolve TipTap v3 type errors in RichEditor
- Cast editor.storage to any for tiptap-markdown's getMarkdown (untyped extension storage)
- Replace setContent(value, false) with setContent(value, { emitUpdate: false }) for TipTap v3 API

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-18 10:18:51 +03:30
ahp 03610bc3d3 feat: implement 8 features across full stack
- Leaderboard: rank-based percentile tiers (champion/gold/silver/bronze),
    deterministic tie-breaking (xp DESC, user_id ASC)
  - Mentor chat: md: breakpoints, safe-area-inset-bottom padding
  - Learning progress: server-side progressPct, curriculum expansion safety
  - Physical orders: hard-disabled with 410 Gone
  - Certificate approval: pending/issued flow, admin issue/revoke endpoints,
    certificate_requires_approval setting (migration 0031)
  - Draft/publish: is_published on lessons and chapters, admin publish UI
    (migration 0030)
  - Video panel: static thumbnails (no preload), client-side pagination
  - Rich editor: TipTap + tiptap-markdown for lesson descriptions,
    marked + DOMPurify for SafeMarkdown rendering
2026-10-18 10:14:22 +03:30
ahp 75e2892e36 fix: release v0.2.1 lesson deletes and large uploads
- Raise Traefik read timeout to 1h (compose dev/prod + k3s HelmChartConfig)
  so admin video uploads up to 200MB no longer 502 mid-upload.
- Migration 0029: cascade watch/quiz history on lesson delete and unlink
  dependent lessons, so removing a lesson or chapter stops 500ing.
- Add the Apollo cluster Helm overlay (values-apollo.yaml) for the deployed
  environment.
- k3s Traefik chart config also enables access logs, cross-namespace
  IngressRoutes, and fixes the Prometheus scrape port.

Bump appVersion/product.go/values tags to 0.2.1; chart version 0.2.3.
2026-10-16 09:44:23 +03:30
ahp 22d92c133d feat: release v0.2.0 profiles, chat, freeze, and bots
Ship the product pass on the v0.1.3 schema with additive migrations, a replica migration lock, and bot accounts kept out of people lists.
2026-10-10 16:42:35 +03:30
ahp 8586bbafcc Merge branch 'hotfix/v0.1.3-otel-schema'
Fix OTel schema URL conflict that crashed the v0.1.3 backend at startup
when an OTLP endpoint is configured (resource.Merge merged
resource.Default() schema 1.43.0 with semconv/v1.24.0's schema URL).
Use resource.NewSchemaless for the service.name attribute.
2026-10-09 20:58:22 +03:30
ahp e05c4327d9 fix(observability): use schemaless resource attrs to avoid schema URL conflict
SetupTracing merged resource.Default() (schema 1.43.0 under otel 1.45.0)
with semconv/v1.24.0's schema URL, so resource.Merge errored with
'conflicting Schema URL' and the backend exited at startup whenever an
OTLP endpoint was configured. resource.NewSchemaless keeps service.name
without pinning a schema, surviving otel major bumps.
2026-10-09 20:35:24 +03:30
ahp 9f5b3b5593 docs: rewrite the README as a first look at Pargar
Lead with the mark and the learning path so the repository explains the product before the engineering map.
2026-10-09 19:47:18 +03:30
111 changed files with 13172 additions and 2232 deletions
+63 -43
View File
@@ -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 | <p align="center">
|-------|--------| <strong>A bootcamp learning path with no shortcuts.</strong><br />
| API | Go 1.27+ · Postgres · Redis · S3/RustFS · WebSocket | Verified watch, quizzes with hearts, a skill tree, and a mentor. The product UI is Persian (پرگار).
| UI | Next.js 15 · React Query · Tailwind · Vazirmatn FD | </p>
| Edge | Traefik v3.7 (Compose and k3s) |
## Quick start (local) <p align="center">
<a href="https://github.com/Panahifarah/pargar/releases/tag/v0.2.1"><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 ```bash
cp .env.example .env # fill in real secrets cp .env.example .env
./scripts/compose-dev.sh up -d --build ./scripts/compose-dev.sh up -d --build
``` ```
- App: **https://localhost** (Traefik default cert → one browser warning is expected) The app is at [https://localhost](https://localhost). Traefik uses its own certificate, so the browser shows one warning. API health:
- Traefik dashboard: http://127.0.0.1:8088
- Health: `curl -k https://localhost/api/health`
k3s:
```bash ```bash
./scripts/k3s-load-images.sh curl -k https://localhost/api/health
./scripts/helm-k3s.sh
curl -k https://pargar.local/api/health
``` ```
Deployment details: [docs/deployment.md](docs/deployment.md) k3s and production: [docs/deployment.md](docs/deployment.md)
## Documentation ## Stack
| Doc | Contents | | Layer | Technology |
|-----|----------| | --- | --- |
| [docs/README.md](docs/README.md) | Doc index and reading map | | API | Go, Postgres, Redis, S3/RustFS, WebSocket |
| [docs/architecture.md](docs/architecture.md) | System architecture and data flow | | UI | Next.js 15, React, Tailwind |
| [docs/product.md](docs/product.md) | Student / admin paths and features | | Edge | Traefik, on Compose and Helm |
| [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 |
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 ```text
backend/ Go API, migrations, seed, media/sample.mp4 backend/ API, migrations, seed data
frontend/ Next.js App Router frontend/ Next.js UI
deploy/ Compose + Traefik + Helm chart deploy/ Compose, Traefik, Helm chart
scripts/ compose / k3s / backup / regenerate-sample scripts/ Local run, k3s, backup
docs/ engineering documentation 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 Index: [docs/README.md](docs/README.md)
cd backend && go test ./internal/api/... ./internal/config/...
cd frontend && bunx tsc --noEmit
```
## License ## License
This project is released under the [GNU Affero General Public License v3.0](LICENSE) (AGPL-3.0). Released under [AGPL-3.0](LICENSE).
+172
View File
@@ -0,0 +1,172 @@
package api
import (
"errors"
"net/http"
"strings"
"time"
"unicode/utf8"
"pargar/backend/internal/store"
)
const msgUsernameCooldown = "سقف سه تغییر نام کاربری پر شده است. تا پایان مهلت نمی‌توانید دوباره نام کاربری را عوض کنید."
func (s *Server) handleUpdateUsername(w http.ResponseWriter, r *http.Request) {
me := currentUser(r)
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := bodyJSON(r, &req); err != nil {
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
return
}
if strings.TrimSpace(req.Password) != "" {
writeErr(w, http.StatusBadRequest, "تغییر گذرواژه از این مسیر ممکن نیست")
return
}
username := strings.ToLower(strings.TrimSpace(req.Username))
if username == "" {
writeErr(w, http.StatusBadRequest, "شناسه کاربری الزامی است")
return
}
if !validUsername(username) {
writeErr(w, http.StatusBadRequest, msgUsernameInvalid)
return
}
user, err := s.store.ChangeUsername(r.Context(), me.ID, username, time.Now())
if err != nil {
var cool *store.UsernameCooldownError
if errors.As(err, &cool) {
writeJSON(w, http.StatusTooManyRequests, map[string]any{
"error": msgUsernameCooldown,
"code": "username_cooldown",
"requestId": requestIDFromWriter(w),
"usernameCooldownUntil": cool.Until,
"usernameChangesRemaining": 0,
})
return
}
if errors.Is(err, store.ErrUsernameTaken) {
writeErr(w, http.StatusConflict, "این شناسه قبلاً گرفته شده است")
return
}
writeErr(w, http.StatusInternalServerError, "تغییر نام کاربری ممکن نشد")
return
}
s.signUserMedia(user)
writeJSON(w, http.StatusOK, map[string]any{
"user": user,
"usernameChangesRemaining": user.UsernameChangesRemaining,
"usernameCooldownUntil": user.UsernameCooldownUntil,
})
}
func (s *Server) handleUpdateAccount(w http.ResponseWriter, r *http.Request) {
me := currentUser(r)
var req struct {
CurrentPassword string `json:"currentPassword"`
Password string `json:"password"`
Phone *string `json:"phone"`
Name *string `json:"name"`
Email *string `json:"email"`
Role *string `json:"role"`
Username *string `json:"username"`
}
if err := bodyJSON(r, &req); err != nil {
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
return
}
if req.Role != nil && strings.TrimSpace(*req.Role) != me.Role.String() {
writeErr(w, http.StatusForbidden, "نقش از این مسیر تغییر نمی‌کند")
return
}
if req.Username != nil {
next := strings.ToLower(strings.TrimSpace(*req.Username))
if next != "" && next != strings.ToLower(me.Username) {
writeErr(w, http.StatusBadRequest, "نام کاربری از این مسیر تغییر نمی‌کند")
return
}
}
hash := ""
changingPassword := strings.TrimSpace(req.Password) != ""
if changingPassword {
if !s.auth.CheckPassword(me.PasswordHash, req.CurrentPassword) {
writeErr(w, http.StatusForbidden, "گذرواژه فعلی نادرست است")
return
}
if utf8.RuneCountInString(req.Password) < minPasswordLen {
writeErr(w, http.StatusBadRequest, msgPasswordShort)
return
}
if len(req.Password) > maxPasswordLen {
writeErr(w, http.StatusBadRequest, msgPasswordLong)
return
}
var err error
hash, err = s.auth.HashPassword(req.Password)
if err != nil {
writeErr(w, http.StatusInternalServerError, "تغییر گذرواژه ممکن نشد")
return
}
}
name := ""
if req.Name != nil {
name = strings.TrimSpace(*req.Name)
if msg := validatePersonName(name); msg != "" {
writeErr(w, http.StatusBadRequest, msg)
return
}
}
email := ""
if req.Email != nil {
email = strings.ToLower(strings.TrimSpace(*req.Email))
if !validEmail(email) {
writeErr(w, http.StatusBadRequest, msgEmailInvalid)
return
}
if !strings.EqualFold(email, me.Email) {
if _, err := s.store.GetUserByEmail(r.Context(), email); err == nil {
writeErr(w, http.StatusConflict, "این ایمیل قبلاً ثبت شده است")
return
} else if !errors.Is(err, store.ErrNotFound) {
writeErr(w, http.StatusInternalServerError, "ذخیره حساب ممکن نشد")
return
}
}
}
phone := me.Phone
if req.Phone != nil {
phone = normalizePhone(*req.Phone)
if !validPhone(phone) {
writeErr(w, http.StatusBadRequest, msgPhoneInvalid)
return
}
if phone != "" && phone != me.Phone {
if _, err := s.store.GetUserByPhone(r.Context(), phone); err == nil {
writeErr(w, http.StatusConflict, "این شماره تلفن قبلاً ثبت شده است")
return
} else if !errors.Is(err, store.ErrNotFound) {
writeErr(w, http.StatusInternalServerError, "ذخیره حساب ممکن نشد")
return
}
}
}
user, err := s.store.UpdateUser(r.Context(), me.ID, name, email, "", hash, phone, "", "", me.Role, me.Hearts, me.XP)
if err != nil {
if store.IsUniqueViolation(err) {
writeErr(w, http.StatusConflict, "این مقدار قبلاً ثبت شده است")
return
}
writeErr(w, http.StatusInternalServerError, "ذخیره حساب ممکن نشد")
return
}
s.signUserMedia(user)
writeJSON(w, http.StatusOK, map[string]any{"user": user})
}
+105
View File
@@ -0,0 +1,105 @@
package api
import (
"context"
"errors"
"net/http"
"strings"
"pargar/backend/internal/models"
"pargar/backend/internal/observability"
"pargar/backend/internal/store"
)
const (
msgAccountClosedLogin = "حساب بسته شده است و ورود ممکن نیست"
msgFreezeAlready = "حساب همین حالا فریز است"
msgAccountClosed = "حساب بسته شده است"
msgUnfreezeClosed = "مهلت فریز تمام شده و لغو فریز ممکن نیست"
msgUnfreezeNone = "حساب فریز نیست"
msgDeleteNotFrozen = "حذف دائمی فقط برای حساب فریز یا بسته ممکن است"
)
// settleAccount writes closed_at when a freeze has passed the 30-day window.
func (s *Server) settleAccount(ctx context.Context, u *models.User) *models.User {
if u == nil || u.ClosedAt != nil || u.FrozenAt == nil || !u.IsClosed {
return u
}
next, err := s.store.MarkAccountClosed(ctx, u.ID)
if err != nil || next == nil {
return u
}
return next
}
func (s *Server) handleFreezeAccount(w http.ResponseWriter, r *http.Request) {
me := s.settleAccount(r.Context(), currentUser(r))
var req struct {
CurrentPassword string `json:"currentPassword"`
}
if err := bodyJSON(r, &req); err != nil {
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
return
}
if strings.TrimSpace(req.CurrentPassword) == "" {
writeErr(w, http.StatusBadRequest, "گذرواژه فعلی الزامی است")
return
}
if !s.auth.CheckPassword(me.PasswordHash, req.CurrentPassword) {
writeErr(w, http.StatusForbidden, "گذرواژه فعلی نادرست است")
return
}
if me.IsClosed {
writeErr(w, http.StatusConflict, msgAccountClosed)
return
}
if me.IsFrozen {
writeErr(w, http.StatusConflict, msgFreezeAlready)
return
}
user, err := s.store.FreezeUser(r.Context(), me.ID)
if err != nil {
if errors.Is(err, store.ErrAccountClosed) {
writeErr(w, http.StatusConflict, msgAccountClosed)
return
}
if errors.Is(err, store.ErrAccountFrozen) {
writeErr(w, http.StatusConflict, msgFreezeAlready)
return
}
writeErr(w, http.StatusInternalServerError, "فریز حساب ممکن نشد")
return
}
observability.Activity("account.frozen", "user_id", user.ID)
s.signUserMedia(user)
writeJSON(w, http.StatusOK, map[string]any{"user": user})
}
func (s *Server) handleUnfreezeAccount(w http.ResponseWriter, r *http.Request) {
me := s.settleAccount(r.Context(), currentUser(r))
if me.IsClosed {
writeErr(w, http.StatusConflict, msgUnfreezeClosed)
return
}
if !me.IsFrozen {
writeErr(w, http.StatusConflict, msgUnfreezeNone)
return
}
user, err := s.store.UnfreezeUser(r.Context(), me.ID)
if err != nil {
if errors.Is(err, store.ErrAccountClosed) {
_, _ = s.store.MarkAccountClosed(r.Context(), me.ID)
writeErr(w, http.StatusConflict, msgUnfreezeClosed)
return
}
if errors.Is(err, store.ErrAccountNotFrozen) {
writeErr(w, http.StatusConflict, msgUnfreezeNone)
return
}
writeErr(w, http.StatusInternalServerError, "لغو فریز ممکن نشد")
return
}
observability.Activity("account.unfrozen", "user_id", user.ID)
s.signUserMedia(user)
writeJSON(w, http.StatusOK, map[string]any{"user": user})
}
+102
View File
@@ -0,0 +1,102 @@
package api_test
import (
"context"
"net/http"
"strings"
"testing"
"time"
)
func TestAccountFreeze(t *testing.T) {
e := setup(t)
token, uid := register(t, e, "Freeze", "freeze@test.dev")
code, missing := e.do(t, "POST", "/api/me/freeze", token, map[string]any{})
missingMsg, _ := missing["error"].(string)
if code == http.StatusOK || !strings.Contains(missingMsg, "گذرواژه") {
t.Fatalf("freeze without password: %d %q", code, missingMsg)
}
code, wrong := e.do(t, "POST", "/api/me/freeze", token, map[string]any{"currentPassword": "not-the-password"})
wrongMsg, _ := wrong["error"].(string)
if code != http.StatusForbidden || !strings.Contains(wrongMsg, "گذرواژه") || !strings.Contains(wrongMsg, "نادرست") {
t.Fatalf("freeze with wrong password: %d %q", code, wrongMsg)
}
var untouched *time.Time
if err := e.pool.QueryRow(context.Background(), `SELECT frozen_at FROM users WHERE id=$1`, uid).Scan(&untouched); err != nil {
t.Fatal(err)
}
if untouched != nil {
t.Fatal("failed freeze wrote frozen_at")
}
code, body := e.do(t, "POST", "/api/me/freeze", token, map[string]any{"currentPassword": "password123"})
if code != http.StatusOK {
t.Fatalf("freeze: %d %v", code, body["error"])
}
user := body["user"].(map[string]any)
frozenAt, _ := user["frozenAt"].(string)
if frozenAt == "" || user["isFrozen"] != true || user["isClosed"] == true {
t.Fatalf("freeze state: frozenAt=%v isFrozen=%v isClosed=%v", user["frozenAt"], user["isFrozen"], user["isClosed"])
}
if user["isLocked"] == true {
t.Fatal("freeze reused the admin lock flag")
}
var stored *time.Time
if err := e.pool.QueryRow(context.Background(), `SELECT frozen_at FROM users WHERE id=$1`, uid).Scan(&stored); err != nil {
t.Fatal(err)
}
if stored == nil || stored.IsZero() {
t.Fatal("frozen_at was not stored")
}
code, profile := e.do(t, "GET", "/api/users/"+itoa(uid), token, nil)
if code != http.StatusOK {
t.Fatalf("profile: %d %v", code, profile["error"])
}
pub := profile["user"].(map[string]any)
if pub["isFrozen"] != true {
t.Fatalf("public profile missing freeze flag: %v", pub["isFrozen"])
}
if _, ok := pub["email"]; ok {
t.Fatalf("public profile leaked email: %v", pub["email"])
}
_ = login(t, e, "freeze@test.dev")
code, body = e.do(t, "POST", "/api/me/unfreeze", token, map[string]any{})
if code != http.StatusOK {
t.Fatalf("unfreeze: %d %v", code, body["error"])
}
cleared := body["user"].(map[string]any)
if cleared["isFrozen"] == true || cleared["frozenAt"] != nil {
t.Fatalf("unfreeze left a timestamp: %v", cleared["frozenAt"])
}
var after *time.Time
if err := e.pool.QueryRow(context.Background(), `SELECT frozen_at FROM users WHERE id=$1`, uid).Scan(&after); err != nil {
t.Fatal(err)
}
if after != nil {
t.Fatalf("frozen_at still set: %s", after.Format(time.RFC3339))
}
if _, err := e.pool.Exec(context.Background(), `UPDATE users SET closed_at = now() WHERE id=$1`, uid); err != nil {
t.Fatal(err)
}
code, denied := e.do(t, "POST", "/api/auth/login", "", loginBody(t, e, "freeze@test.dev", "password123"))
errMsg, _ := denied["error"].(string)
if code != http.StatusForbidden || !strings.Contains(errMsg, "بسته") || !strings.Contains(errMsg, "ورود") {
t.Fatalf("closed login: %d %q", code, errMsg)
}
code, undo := e.do(t, "POST", "/api/me/unfreeze", token, map[string]any{})
undoMsg, _ := undo["error"].(string)
if code != http.StatusConflict || !strings.Contains(undoMsg, "لغو فریز") {
t.Fatalf("unfreeze after close: %d %q", code, undoMsg)
}
code, forbidden := e.do(t, "DELETE", "/api/admin/users/"+itoa(uid), token, nil)
if code != http.StatusForbidden {
t.Fatalf("student permanent delete: %d %v", code, forbidden["error"])
}
}
@@ -0,0 +1,186 @@
package api_test
import (
"context"
"net/http"
"strings"
"testing"
)
func TestAccountUsernameQuota(t *testing.T) {
e := setup(t)
token, uid := register(t, e, "Quota", "quota@test.dev")
code, body := e.do(t, "PUT", "/api/me/username", token, map[string]any{"username": "quota"})
if code != http.StatusOK {
t.Fatalf("same username: %d %v", code, body["error"])
}
if body["usernameChangesRemaining"].(float64) != 3 {
t.Fatalf("same username consumed a slot: %v", body["usernameChangesRemaining"])
}
for i, next := range []string{"quotaone", "quotatwo", "quotathree"} {
code, body = e.do(t, "PUT", "/api/me/username", token, map[string]any{"username": next})
if code != http.StatusOK {
t.Fatalf("change %d: %d %v", i+1, code, body["error"])
}
user := body["user"].(map[string]any)
if user["username"] != next {
t.Fatalf("username: %v", user["username"])
}
wantRemaining := float64(2 - i)
if body["usernameChangesRemaining"].(float64) != wantRemaining {
t.Fatalf("remaining after change %d: %v", i+1, body["usernameChangesRemaining"])
}
}
if body["usernameCooldownUntil"] == nil || body["usernameCooldownUntil"] == "" {
t.Fatalf("third change should start cooldown: %v", body["usernameCooldownUntil"])
}
code, blocked := e.do(t, "PUT", "/api/me/username", token, map[string]any{"username": "quotafour"})
errMsg, _ := blocked["error"].(string)
if code != http.StatusTooManyRequests || !strings.Contains(errMsg, "نام کاربری") || !strings.Contains(errMsg, "مهلت") {
t.Fatalf("fourth change: %d %q", code, errMsg)
}
code, me := e.do(t, "GET", "/api/auth/me", token, nil)
if code != http.StatusOK {
t.Fatalf("me: %d", code)
}
if me["user"].(map[string]any)["username"] != "quotathree" {
t.Fatalf("username changed during cooldown: %v", me["user"].(map[string]any)["username"])
}
if _, err := e.pool.Exec(context.Background(), `UPDATE users SET username_cooldown_until = now() - interval '1 second' WHERE id=$1`, uid); err != nil {
t.Fatal(err)
}
code, body = e.do(t, "PUT", "/api/me/username", token, map[string]any{"username": "quotafour"})
if code != http.StatusOK {
t.Fatalf("after cooldown: %d %v", code, body["error"])
}
if body["user"].(map[string]any)["username"] != "quotafour" {
t.Fatalf("username after cooldown: %v", body["user"])
}
if body["usernameChangesRemaining"].(float64) != 2 {
t.Fatalf("allowance should reset then consume one: %v", body["usernameChangesRemaining"])
}
code, denied := e.do(t, "PUT", "/api/me/account", token, map[string]any{"password": "replacement-pass"})
if code != http.StatusForbidden {
t.Fatalf("password change without current password: %d %v", code, denied["error"])
}
_ = login(t, e, "quota@test.dev")
code, rejected := e.do(t, "PUT", "/api/me/username", token, map[string]any{
"username": "quotafive",
"password": "should-be-ignored",
})
if code != http.StatusBadRequest {
t.Fatalf("username endpoint accepted a password: %d %v", code, rejected["error"])
}
code, me = e.do(t, "GET", "/api/auth/me", token, nil)
if code != http.StatusOK || me["user"].(map[string]any)["username"] != "quotafour" {
t.Fatalf("username changed via password field: %d %v", code, me["user"])
}
}
func TestAccountIdentityWithoutPassword(t *testing.T) {
e := setup(t)
token, _ := register(t, e, "Identity", "identity@test.dev")
code, me := e.do(t, "GET", "/api/auth/me", token, nil)
if code != http.StatusOK {
t.Fatalf("me: %d", code)
}
before := me["user"].(map[string]any)
username, _ := before["username"].(string)
role, _ := before["role"].(string)
code, body := e.do(t, "PUT", "/api/me/account", token, map[string]any{
"name": "هویت تازه",
"email": "identity-new@test.dev",
"phone": "09120001122",
})
if code != http.StatusOK {
t.Fatalf("identity save: %d %v", code, body["error"])
}
user := body["user"].(map[string]any)
if user["name"] != "هویت تازه" || user["email"] != "identity-new@test.dev" || user["phone"] != "09120001122" {
t.Fatalf("saved identity: name=%v email=%v phone=%v", user["name"], user["email"], user["phone"])
}
if user["username"] != username || user["role"] != role {
t.Fatalf("username or role changed: username=%v role=%v", user["username"], user["role"])
}
if user["securityQuestion"] != before["securityQuestion"] {
t.Fatalf("security question changed: %v", user["securityQuestion"])
}
_ = login(t, e, "identity-new@test.dev")
code, same := e.do(t, "PUT", "/api/me/account", token, map[string]any{
"name": "هویت تازه",
"email": "identity-new@test.dev",
"phone": "09120001122",
})
if code != http.StatusOK {
t.Fatalf("same identity: %d %v", code, same["error"])
}
_, _ = register(t, e, "Other", "taken-mail@test.dev")
code, dup := e.do(t, "PUT", "/api/me/account", token, map[string]any{"email": "taken-mail@test.dev"})
if code != http.StatusConflict {
t.Fatalf("duplicate email: %d %v", code, dup["error"])
}
code, bad := e.do(t, "PUT", "/api/me/account", token, map[string]any{"email": "not-an-email"})
if code != http.StatusBadRequest {
t.Fatalf("invalid email: %d %v", code, bad["error"])
}
code, phoneBad := e.do(t, "PUT", "/api/me/account", token, map[string]any{"phone": "123"})
if code != http.StatusBadRequest {
t.Fatalf("invalid phone: %d %v", code, phoneBad["error"])
}
code, roleBody := e.do(t, "PUT", "/api/me/account", token, map[string]any{"role": "admin", "name": "نباید ذخیره شود"})
if code != http.StatusForbidden {
t.Fatalf("role change: %d %v", code, roleBody["error"])
}
code, userBody := e.do(t, "PUT", "/api/me/account", token, map[string]any{"username": "hijackname"})
if code != http.StatusBadRequest {
t.Fatalf("username via account: %d %v", code, userBody["error"])
}
code, me = e.do(t, "GET", "/api/auth/me", token, nil)
if code != http.StatusOK {
t.Fatalf("me after rejects: %d", code)
}
after := me["user"].(map[string]any)
if after["role"] != role || after["username"] != username || after["name"] != "هویت تازه" || after["email"] != "identity-new@test.dev" {
t.Fatalf("account mutated by rejected fields: %v", after)
}
code, denied := e.do(t, "PUT", "/api/me/account", token, map[string]any{"password": "replacement-pass"})
if code != http.StatusForbidden {
t.Fatalf("password without current: %d %v", code, denied["error"])
}
_ = login(t, e, "identity-new@test.dev")
code, changed := e.do(t, "PUT", "/api/me/account", token, map[string]any{
"currentPassword": "password123",
"password": "replacement-pass",
})
if code != http.StatusOK {
t.Fatalf("password with current: %d %v", code, changed["error"])
}
kept := changed["user"].(map[string]any)
if kept["name"] != "هویت تازه" || kept["email"] != "identity-new@test.dev" || kept["phone"] != "09120001122" || kept["role"] != role {
t.Fatalf("password save changed identity: %v", kept)
}
code, session := e.do(t, "POST", "/api/auth/login", "", loginBody(t, e, "identity-new@test.dev", "replacement-pass"))
if code != http.StatusOK || session["accessToken"] == nil {
t.Fatalf("login after password change: %d %v", code, session["error"])
}
code, old := e.do(t, "POST", "/api/auth/login", "", loginBody(t, e, "identity-new@test.dev", "password123"))
if code == http.StatusOK {
t.Fatalf("previous password still works: %d", code)
}
_ = old["error"]
}
+43 -55
View File
@@ -33,6 +33,7 @@ func (s *Server) handleAdminGetUser(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد") writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
return return
} }
u = s.settleAccount(r.Context(), u)
writeJSON(w, http.StatusOK, map[string]any{"user": u}) writeJSON(w, http.StatusOK, map[string]any{"user": u})
} }
@@ -442,6 +443,16 @@ func (s *Server) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusForbidden, "دسترسی کافی ندارید") writeErr(w, http.StatusForbidden, "دسترسی کافی ندارید")
return return
} }
target, err := s.store.GetUserByID(r.Context(), id)
if err != nil {
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
return
}
target = s.settleAccount(r.Context(), target)
if !target.IsFrozen && !target.IsClosed {
writeErr(w, http.StatusConflict, msgDeleteNotFrozen)
return
}
if err := s.store.DeleteUser(r.Context(), id); err != nil { if err := s.store.DeleteUser(r.Context(), id); err != nil {
writeErr(w, http.StatusInternalServerError, "حذف کاربر ممکن نشد") writeErr(w, http.StatusInternalServerError, "حذف کاربر ممکن نشد")
return return
@@ -564,6 +575,38 @@ func (s *Server) handleAdminDeleteLesson(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, map[string]any{"ok": true}) writeJSON(w, http.StatusOK, map[string]any{"ok": true})
} }
func (s *Server) handleAdminPublishLesson(w http.ResponseWriter, r *http.Request) {
id := routeID(r, "id")
var body struct {
Publish bool `json:"publish"`
}
if err := bodyJSON(r, &body); err != nil {
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
return
}
if err := s.store.PublishLesson(r.Context(), id, body.Publish); err != nil {
writeErr(w, http.StatusInternalServerError, "به‌روزرسانی وضعیت درس ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) handleAdminPublishChapter(w http.ResponseWriter, r *http.Request) {
id := routeID(r, "id")
var body struct {
Publish bool `json:"publish"`
}
if err := bodyJSON(r, &body); err != nil {
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
return
}
if err := s.store.PublishChapter(r.Context(), id, body.Publish); err != nil {
writeErr(w, http.StatusInternalServerError, "به‌روزرسانی وضعیت فصل ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// ---- questions ---- // ---- questions ----
func (s *Server) handleAdminListQuestions(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAdminListQuestions(w http.ResponseWriter, r *http.Request) {
@@ -837,58 +880,3 @@ func validateAdminEvent(e *models.Event, create bool) string {
_ = create _ = create
return "" return ""
} }
// ---- announcements ----
func (s *Server) handleAdminAnnounce(w http.ResponseWriter, r *http.Request) {
var req struct {
Title string `json:"title"`
Body string `json:"body"`
Route string `json:"route"`
Category string `json:"category"`
}
if err := bodyJSON(r, &req); err != nil {
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
return
}
req.Title = strings.TrimSpace(req.Title)
req.Body = strings.TrimSpace(req.Body)
req.Route = strings.TrimSpace(req.Route)
if utf8.RuneCountInString(req.Title) < 3 {
writeErr(w, http.StatusBadRequest, "عنوان اطلاعیه خیلی کوتاه است")
return
}
if utf8.RuneCountInString(req.Title) > 200 {
writeErr(w, http.StatusBadRequest, "عنوان اطلاعیه خیلی طولانی است")
return
}
if utf8.RuneCountInString(req.Body) < 8 {
writeErr(w, http.StatusBadRequest, "متن اطلاعیه خیلی کوتاه است")
return
}
if utf8.RuneCountInString(req.Body) > 500 {
writeErr(w, http.StatusBadRequest, "متن اطلاعیه خیلی طولانی است")
return
}
if req.Category == "" {
req.Category = "event"
}
switch req.Category {
case "event", "progress", "gamification", "mentor":
default:
writeErr(w, http.StatusBadRequest, "دستهٔ اطلاعیه نامعتبر است")
return
}
userIDs, err := s.store.AllActiveUserIDs(r.Context())
if err != nil {
writeErr(w, http.StatusInternalServerError, "بارگذاری کاربران ممکن نشد")
return
}
go func() {
for _, uid := range userIDs {
_ = s.notify.Notify(context.Background(), uid, req.Category, "announcement",
req.Title, req.Body, req.Route, nil)
}
}()
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "recipients": len(userIDs)})
}
+13
View File
@@ -175,6 +175,12 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusForbidden, "حساب غیرفعال است") writeErr(w, http.StatusForbidden, "حساب غیرفعال است")
return return
} }
user = s.settleAccount(r.Context(), user)
if user.IsClosed {
observability.Logins.WithLabelValues("closed").Inc()
writeErr(w, http.StatusForbidden, msgAccountClosedLogin)
return
}
refreshTTL := s.auth.refreshTTL(req.RememberMe) refreshTTL := s.auth.refreshTTL(req.RememberMe)
access, refresh, err := s.auth.IssueForUserTTL(r.Context(), user, refreshTTL) access, refresh, err := s.auth.IssueForUserTTL(r.Context(), user, refreshTTL)
if err != nil { if err != nil {
@@ -222,6 +228,12 @@ func (s *Server) handleRefresh(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusUnauthorized, "حساب غیرفعال است") writeErr(w, http.StatusUnauthorized, "حساب غیرفعال است")
return return
} }
user = s.settleAccount(r.Context(), user)
if user.IsClosed {
s.clearSessionCookies(w)
writeErr(w, http.StatusForbidden, msgAccountClosedLogin)
return
}
// Preserve remember-me vs short session when rotating the refresh token. // Preserve remember-me vs short session when rotating the refresh token.
ttl := s.cfg.RefreshTokenTTLSession ttl := s.cfg.RefreshTokenTTLSession
if lifetime >= 48*time.Hour { if lifetime >= 48*time.Hour {
@@ -266,6 +278,7 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
user = hearts user = hearts
} }
user = s.settleAccount(r.Context(), user)
s.signUserMedia(user) s.signUserMedia(user)
writeJSON(w, http.StatusOK, map[string]any{"user": user}) writeJSON(w, http.StatusOK, map[string]any{"user": user})
} }
+75 -86
View File
@@ -7,7 +7,6 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"time"
"pargar/backend/internal/models" "pargar/backend/internal/models"
"pargar/backend/internal/observability" "pargar/backend/internal/observability"
@@ -22,7 +21,24 @@ func newPublicID() (string, error) {
return hex.EncodeToString(b), nil return hex.EncodeToString(b), nil
} }
func (s *Server) certEnabled(ctx context.Context) bool {
if v, err := s.store.GetSetting(ctx, "certificate_enabled"); err == nil {
return v == "true" || v == "1"
}
return true
}
// certRequiresApproval always returns true — certificates are never auto-issued.
// The DB setting is kept for observability but cannot override the hard gate.
func (s *Server) certRequiresApproval(_ context.Context) bool { return true }
// issueCertificateIfEligible creates a pending certificate row when the user has
// completed the curriculum. It never issues (publishes) the certificate directly;
// that step requires explicit admin action via handleAdminIssueCertificate.
func (s *Server) issueCertificateIfEligible(ctx context.Context, user *models.User) (*models.Certificate, bool, error) { func (s *Server) issueCertificateIfEligible(ctx context.Context, user *models.User) (*models.Certificate, bool, error) {
if !s.certEnabled(ctx) {
return nil, false, nil
}
ok, err := s.store.HasCompletedCurriculum(ctx, user.ID) ok, err := s.store.HasCompletedCurriculum(ctx, user.ID)
if err != nil { if err != nil {
return nil, false, err return nil, false, err
@@ -37,15 +53,12 @@ func (s *Server) issueCertificateIfEligible(ctx context.Context, user *models.Us
} else if err != store.ErrNotFound { } else if err != store.ErrNotFound {
return nil, false, err return nil, false, err
} }
publicID, err := newPublicID() // Always create a pending row — never auto-issue.
cert, err := s.store.CreatePendingCertificate(ctx, user.ID, user.Name)
if err != nil { if err != nil {
return nil, false, err return nil, false, err
} }
cert, err := s.store.CreateCertificate(ctx, user.ID, publicID, user.Name) return cert, cert.Status == "pending", nil
if err != nil {
return nil, false, err
}
return cert, true, nil
} }
func (s *Server) handleIssueCertificate(w http.ResponseWriter, r *http.Request) { func (s *Server) handleIssueCertificate(w http.ResponseWriter, r *http.Request) {
@@ -101,7 +114,7 @@ func (s *Server) handlePublicCertificate(w http.ResponseWriter, r *http.Request)
return return
} }
cert, err := s.store.GetCertificateByPublicID(r.Context(), publicID) cert, err := s.store.GetCertificateByPublicID(r.Context(), publicID)
if err != nil || cert.RevokedAt != nil { if err != nil || cert.RevokedAt != nil || cert.Status != "issued" {
writeErr(w, http.StatusNotFound, "گواهینامه پیدا نشد") writeErr(w, http.StatusNotFound, "گواهینامه پیدا نشد")
return return
} }
@@ -140,90 +153,66 @@ func (s *Server) physicalSettings(ctx context.Context) map[string]any {
} }
func (s *Server) handleRequestPhysicalCertificate(w http.ResponseWriter, r *http.Request) { func (s *Server) handleRequestPhysicalCertificate(w http.ResponseWriter, r *http.Request) {
u := currentUser(r) writeErr(w, http.StatusGone, "سفارش فیزیکی موقتاً غیرفعال است")
settings := s.physicalSettings(r.Context())
if enabled, _ := settings["enabled"].(bool); !enabled {
writeErr(w, http.StatusBadRequest, "درخواست نسخه فیزیکی فعلاً فعال نیست")
return
} }
cert, err := s.store.GetCertificateByUser(r.Context(), u.ID)
if err != nil || cert.RevokedAt != nil { func (s *Server) handleAdminIssueCertificate(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusConflict, "ابتدا گواهینامه دیجیتال را دریافت کنید") userID := routeID(r, "id")
return u, err := s.store.GetUserByID(r.Context(), userID)
}
days, _ := settings["windowDays"].(int)
if days <= 0 {
days = 30
}
windowEnds := cert.IssuedAt.Add(time.Duration(days) * 24 * time.Hour)
if time.Now().After(windowEnds) {
writeErr(w, http.StatusConflict, "مهلت درخواست نسخه فیزیکی به پایان رسیده است")
return
}
if existing, err := s.store.GetLatestPhysicalOrder(r.Context(), u.ID); err == nil {
if existing.Status != models.PhysicalCancelled {
writeErr(w, http.StatusConflict, "سفارش فیزیکی فعال دارید")
return
}
}
var req struct {
RecipientName string `json:"recipientName"`
Phone string `json:"phone"`
Address string `json:"address"`
City string `json:"city"`
PostalCode string `json:"postalCode"`
}
if err := bodyJSON(r, &req); err != nil {
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
return
}
ship := models.CertificatePhysicalOrder{
RecipientName: strings.TrimSpace(req.RecipientName),
Phone: normalizePhone(req.Phone),
Address: strings.TrimSpace(req.Address),
City: strings.TrimSpace(req.City),
PostalCode: strings.TrimSpace(req.PostalCode),
}
if ship.RecipientName == "" {
ship.RecipientName = u.Name
}
if ship.Phone == "" {
ship.Phone = normalizePhone(u.Phone)
}
if ship.Phone == "" || !validPhone(ship.Phone) {
writeErr(w, http.StatusBadRequest, msgPhoneInvalid)
return
}
if ship.Address == "" || ship.City == "" {
writeErr(w, http.StatusBadRequest, "آدرس و شهر برای ارسال الزامی است")
return
}
if len(ship.Address) > 500 || len(ship.City) > 80 || len(ship.RecipientName) > 120 {
writeErr(w, http.StatusBadRequest, "اطلاعات ارسال بیش از حد طولانی است")
return
}
order, err := s.store.CreatePhysicalOrder(r.Context(), cert.ID, u.ID, windowEnds, ship)
if err != nil { if err != nil {
writeErr(w, http.StatusInternalServerError, "ثبت درخواست ممکن نشد") writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
return
}
existing, err := s.store.GetCertificateByUser(r.Context(), userID)
if err != nil && err != store.ErrNotFound {
writeInternalErr(w, "cert.admin.issue", err, "بارگذاری گواهینامه ممکن نشد")
return
}
if existing != nil && existing.RevokedAt == nil && existing.Status == "issued" {
writeErr(w, http.StatusConflict, "گواهینامه قبلاً صادر شده است")
return
}
publicID, err := newPublicID()
if err != nil {
writeInternalErr(w, "cert.admin.pubid", err, "تولید شناسه ممکن نشد")
return
}
var cert *models.Certificate
if existing != nil {
cert, err = s.store.IssuePendingCertificate(r.Context(), userID, publicID, u.Name)
} else {
cert, err = s.store.CreateCertificate(r.Context(), userID, publicID, u.Name)
}
if err != nil {
writeInternalErr(w, "cert.admin.create", err, "صدور گواهینامه ممکن نشد")
return return
} }
staff, _ := s.store.ListMentors(r.Context())
go func() { go func() {
for _, m := range staff { _ = s.notify.Notify(context.Background(), userID, "progress", "certificate_issued",
if m.Role != models.RoleAdmin { "گواهینامه صادر شد", "گواهینامه پرگار شما صادر شد. برای مشاهده به صفحه گواهینامه مراجعه کنید.", "/cap",
continue map[string]any{"publicId": cert.PublicID})
}
_ = s.notify.Notify(context.Background(), m.ID, "progress", "physical_order",
"سفارش گواهینامه فیزیکی", u.Name+" درخواست نسخه فیزیکی ثبت کرد.", "/admin",
map[string]any{"orderId": order.ID, "userId": u.ID})
}
}() }()
observability.Activity("certificate.physical_requested", "user_id", u.ID, "order_id", order.ID) observability.Activity("certificate.admin_issued", "user_id", userID, "actor_id", currentUser(r).ID)
writeJSON(w, http.StatusCreated, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{"certificate": cert})
"order": order, }
"message": "پس از واریز با ادمین هماهنگ کنید",
"physical": settings, func (s *Server) handleAdminRevokeCertificate(w http.ResponseWriter, r *http.Request) {
}) userID := routeID(r, "id")
if err := s.store.RevokeCertificate(r.Context(), userID); err != nil {
writeInternalErr(w, "cert.admin.revoke", err, "لغو گواهینامه ممکن نشد")
return
}
observability.Activity("certificate.admin_revoked", "user_id", userID, "actor_id", currentUser(r).ID)
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) handleAdminListPendingCertificates(w http.ResponseWriter, r *http.Request) {
certs, err := s.store.ListCertificatesByStatus(r.Context(), "pending")
if err != nil {
writeInternalErr(w, "cert.admin.pending", err, "بارگذاری گواهینامه‌های در انتظار ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"certificates": certs})
} }
func (s *Server) handleAdminListPhysicalOrders(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAdminListPhysicalOrders(w http.ResponseWriter, r *http.Request) {
+270 -73
View File
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"path/filepath" "path/filepath"
"sort"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -18,38 +19,59 @@ const maxUploadSize = 25 << 20 // 25 MB
func (s *Server) handleListMentors(w http.ResponseWriter, r *http.Request) { func (s *Server) handleListMentors(w http.ResponseWriter, r *http.Request) {
actor := currentUser(r) actor := currentUser(r)
var mentors []models.User page := parsePageParams(r)
var err error if page.PageSize < 20 {
if actor.Role == models.RoleMentor || actor.Role == models.RoleAdmin { page.PageSize = 20
mentors, _, err = s.store.ListUsers(r.Context(), "", "", 100, 0) page.Offset = (page.Page - 1) * page.PageSize
} else {
mentors, err = s.store.ListMentors(r.Context())
} }
roles := []string{models.RoleMentor.String(), models.RoleAdmin.String()}
if actor.Role == models.RoleMentor || actor.Role == models.RoleAdmin {
roles = []string{models.RoleStudent.String(), models.RoleMentor.String(), models.RoleAdmin.String()}
}
people, total, err := s.store.ListActiveByRoles(r.Context(), roles, page.PageSize, page.Offset)
if err != nil { if err != nil {
writeErr(w, http.StatusInternalServerError, "بارگذاری منتورها ممکن نشد") writeErr(w, http.StatusInternalServerError, "بارگذاری افراد ممکن نشد")
return return
} }
safe := make([]map[string]any, 0, len(mentors)) ids := make([]int64, 0, len(people))
for _, m := range mentors { for _, m := range people {
if m.ID == actor.ID || !m.IsActive { if m.ID != actor.ID {
continue ids = append(ids, m.ID)
} }
if actor.Role == models.RoleStudent && m.Role != models.RoleMentor && m.Role != models.RoleAdmin { }
seen, _ := s.store.LastSeenByIDs(r.Context(), ids)
safe := make([]map[string]any, 0, len(people))
for _, m := range people {
if m.ID == actor.ID {
continue continue
} }
s.signUserMedia(&m) s.signUserMedia(&m)
safe = append(safe, map[string]any{ item := map[string]any{
"id": m.ID, "name": m.Name, "email": m.Email, "role": m.Role, "id": m.ID, "name": m.Name, "email": m.Email, "role": m.Role,
"avatarVariant": m.AvatarVariant, "avatarPalette": m.AvatarPalette, "avatarPhoto": m.AvatarPhoto, "avatarVariant": m.AvatarVariant, "avatarPalette": m.AvatarPalette, "avatarPhoto": m.AvatarPhoto,
}) "online": s.hub.Online(m.ID),
} }
writeJSON(w, http.StatusOK, map[string]any{"mentors": safe}) if ts, ok := seen[m.ID]; ok && !ts.IsZero() {
item["lastSeen"] = ts.UTC()
}
safe = append(safe, item)
}
writeJSON(w, http.StatusOK, map[string]any{
"mentors": safe,
"total": total,
"page": page.Page,
"pageSize": page.PageSize,
})
} }
type conversation struct { type conversation struct {
Partner map[string]any `json:"partner"` Partner map[string]any `json:"partner"`
LastMessage *models.ChatMessage `json:"lastMessage,omitempty"` LastMessage *models.ChatMessage `json:"lastMessage,omitempty"`
UnreadCount int `json:"unreadCount"` UnreadCount int `json:"unreadCount"`
MessageCount int `json:"messageCount"`
PinnedRank *int `json:"pinnedRank"`
Muted bool `json:"muted"`
Archived bool `json:"archived"`
} }
func (s *Server) handleConversations(w http.ResponseWriter, r *http.Request) { func (s *Server) handleConversations(w http.ResponseWriter, r *http.Request) {
@@ -94,11 +116,23 @@ func (s *Server) handleConversations(w http.ResponseWriter, r *http.Request) {
return return
} }
showArchived := r.URL.Query().Get("archived") == "1"
partnerIDs := make([]int64, 0, len(partners))
for _, p := range partners {
if p.ID != u.ID {
partnerIDs = append(partnerIDs, p.ID)
}
}
seen, _ := s.store.LastSeenByIDs(r.Context(), partnerIDs)
out := make([]conversation, 0, len(partners)) out := make([]conversation, 0, len(partners))
for _, p := range partners { for _, p := range partners {
if !s.canChatWith(r.Context(), u, p.ID) { if !s.canChatWith(r.Context(), u, p.ID) {
continue continue
} }
pref := s.store.ChatPref(r.Context(), u.ID, p.ID)
if pref.Archived != showArchived {
continue
}
msgs, err := s.store.ListChatMessages(r.Context(), u.ID, p.ID, 0, 100) msgs, err := s.store.ListChatMessages(r.Context(), u.ID, p.ID, 0, 100)
if err != nil { if err != nil {
continue continue
@@ -118,23 +152,95 @@ func (s *Server) handleConversations(w http.ResponseWriter, r *http.Request) {
s.signUserMedia(tmp) s.signUserMedia(tmp)
photo = tmp.AvatarPhoto photo = tmp.AvatarPhoto
} }
conv := conversation{ name := p.Name
Partner: map[string]any{ saved := p.ID == u.ID
"id": p.ID, "name": p.Name, "email": p.Email, "role": p.Role, if saved {
name = "پیام‌های ذخیره‌شده"
}
partner := map[string]any{
"id": p.ID, "name": name, "email": p.Email, "role": p.Role,
"avatarVariant": p.AvatarVariant, "avatarPalette": p.AvatarPalette, "avatarPhoto": photo, "avatarVariant": p.AvatarVariant, "avatarPalette": p.AvatarPalette, "avatarPhoto": photo,
"online": s.hub.Online(p.ID), "online": !saved && s.hub.Online(p.ID), "saved": saved,
}, }
if !saved {
if ts, ok := seen[p.ID]; ok && !ts.IsZero() {
partner["lastSeen"] = ts.UTC()
}
}
conv := conversation{
Partner: partner,
UnreadCount: unread, UnreadCount: unread,
MessageCount: len(msgs),
PinnedRank: pref.PinnedRank,
Muted: pref.Muted,
Archived: pref.Archived,
} }
if last != nil { if last != nil {
conv.LastMessage = last conv.LastMessage = last
} }
out = append(out, conv) out = append(out, conv)
} }
if !showArchived {
hasSelf := false
for _, c := range out {
if id, _ := c.Partner["id"].(int64); id == u.ID {
hasSelf = true
break
}
}
if !hasSelf {
out = append(out, conversation{
Partner: map[string]any{
"id": u.ID, "name": "پیام‌های ذخیره‌شده", "email": u.Email, "role": u.Role,
"avatarVariant": u.AvatarVariant, "avatarPalette": u.AvatarPalette, "avatarPhoto": u.AvatarPhoto,
"online": false, "saved": true,
},
})
}
}
if out == nil { if out == nil {
out = []conversation{} out = []conversation{}
} }
writeJSON(w, http.StatusOK, map[string]any{"conversations": out}) sort.SliceStable(out, func(i, j int) bool {
pi, pj := out[i].PinnedRank, out[j].PinnedRank
if (pi != nil) != (pj != nil) {
return pi != nil
}
if pi != nil && pj != nil && *pi != *pj {
return *pi < *pj
}
ti := time.Time{}
tj := time.Time{}
if out[i].LastMessage != nil {
ti = out[i].LastMessage.CreatedAt
}
if out[j].LastMessage != nil {
tj = out[j].LastMessage.CreatedAt
}
return ti.After(tj)
})
total := len(out)
slice := out
page := pageParams{Page: 1, PageSize: total}
if r.URL.Query().Get("page") != "" || r.URL.Query().Get("pageSize") != "" {
page = parsePageParams(r)
start := page.Offset
if start > total {
start = total
}
end := start + page.PageSize
if end > total {
end = total
}
slice = out[start:end]
}
writeJSON(w, http.StatusOK, map[string]any{
"conversations": slice,
"total": total,
"page": page.Page,
"pageSize": page.PageSize,
"mutedAll": s.store.ChatMutedAll(r.Context(), u.ID),
})
} }
func (s *Server) handleChatMessages(w http.ResponseWriter, r *http.Request) { func (s *Server) handleChatMessages(w http.ResponseWriter, r *http.Request) {
@@ -227,40 +333,19 @@ func (s *Server) handleChatSend(w http.ResponseWriter, r *http.Request) {
Body string `json:"body"` Body string `json:"body"`
ReplyTo *int64 `json:"replyTo"` ReplyTo *int64 `json:"replyTo"`
Attachment *models.Attachment `json:"attachment,omitempty"` Attachment *models.Attachment `json:"attachment,omitempty"`
Attachments []models.Attachment `json:"attachments,omitempty"`
} }
if err := bodyJSON(r, &req); err != nil { if err := bodyJSON(r, &req); err != nil {
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
return return
} }
req.Body = strings.TrimSpace(req.Body) req.Body = strings.TrimSpace(req.Body)
if att := req.Attachment; att != nil { atts, attErr := prepareChatAttachments(u.ID, req.Attachment, req.Attachments)
if att.Size > maxUploadSize { if attErr != "" {
writeErr(w, http.StatusBadRequest, "حجم پیوست بیش از حد مجاز است") writeErr(w, http.StatusBadRequest, attErr)
return return
} }
att.Type = strings.ToLower(att.Type) if req.Body == "" && len(atts) == 0 {
if att.Type != "image" && att.Type != "video" && att.Type != "audio" && att.Type != "file" {
writeErr(w, http.StatusBadRequest, "نوع پیوست پشتیبانی نمی‌شود")
return
}
if att.URL == "" || !strings.Contains(att.URL, "/media/") {
writeErr(w, http.StatusBadRequest, "نشانی پیوست نامعتبر است")
return
}
key := mediaKeyFromURL(att.URL)
if !mediaKeyOwnedByUser(key, u.ID) {
writeErr(w, http.StatusBadRequest, "پیوست متعلق به شما نیست")
return
}
// Persist without signature query — re-signed when messages are returned.
if q := strings.Index(att.URL, "?"); q >= 0 {
att.URL = att.URL[:q]
}
if att.Name == "" {
att.Name = "file"
}
}
if req.Body == "" && req.Attachment == nil {
writeErr(w, http.StatusBadRequest, "پیام باید شامل متن یا فایل باشد") writeErr(w, http.StatusBadRequest, "پیام باید شامل متن یا فایل باشد")
return return
} }
@@ -281,7 +366,7 @@ func (s *Server) handleChatSend(w http.ResponseWriter, r *http.Request) {
mentorID = u.ID mentorID = u.ID
} }
msg, err := s.store.AddChatMessage(r.Context(), studentID, mentorID, u.Role, req.Body, req.ReplyTo, req.Attachment) msg, err := s.store.AddChatMessage(r.Context(), studentID, mentorID, u.Role, req.Body, req.ReplyTo, atts)
if err != nil { if err != nil {
writeErr(w, http.StatusInternalServerError, "ارسال پیام ممکن نشد") writeErr(w, http.StatusInternalServerError, "ارسال پیام ممکن نشد")
return return
@@ -291,6 +376,7 @@ func (s *Server) handleChatSend(w http.ResponseWriter, r *http.Request) {
// live push chat_message to both participants // live push chat_message to both participants
s.hub.Push(u.ID, map[string]any{"type": "chat_message", "item": msg, "from": u.ID}) s.hub.Push(u.ID, map[string]any{"type": "chat_message", "item": msg, "from": u.ID})
s.hub.Push(partnerID, map[string]any{"type": "chat_message", "item": msg, "from": u.ID}) s.hub.Push(partnerID, map[string]any{"type": "chat_message", "item": msg, "from": u.ID})
s.noteBotReply(r, partnerID, req.Body)
// notify the recipient // notify the recipient
if u.Role == models.RoleMentor || u.Role == models.RoleAdmin { if u.Role == models.RoleMentor || u.Role == models.RoleAdmin {
@@ -354,6 +440,12 @@ func (s *Server) handleChatEdit(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
Body string `json:"body"` Body string `json:"body"`
Attachment *models.Attachment `json:"attachment,omitempty"` Attachment *models.Attachment `json:"attachment,omitempty"`
AddAttachments []models.Attachment `json:"addAttachments,omitempty"`
RemoveIndexes []int `json:"removeIndexes,omitempty"`
Replacements []struct {
Index int `json:"index"`
Attachment models.Attachment `json:"attachment"`
} `json:"replacements,omitempty"`
} }
if err := bodyJSON(r, &req); err != nil { if err := bodyJSON(r, &req); err != nil {
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
@@ -365,36 +457,73 @@ func (s *Server) handleChatEdit(w http.ResponseWriter, r *http.Request) {
return return
} }
var ( current, err := s.store.GetOwnChatMessage(r.Context(), u.ID, partner, msgID, u.Role)
msg *models.ChatMessage if errors.Is(err, store.ErrNotFound) {
err error writeErr(w, http.StatusNotFound, "پیام پیدا نشد")
) return
}
if err != nil {
writeErr(w, http.StatusInternalServerError, "ویرایش پیام ممکن نشد")
return
}
existing := storedAttachments(current)
drop := map[int]struct{}{}
for _, index := range req.RemoveIndexes {
if index < 0 || index >= len(existing) {
writeErr(w, http.StatusBadRequest, "پیوست انتخاب‌شده پیدا نشد")
return
}
drop[index] = struct{}{}
}
for _, rep := range req.Replacements {
if _, removed := drop[rep.Index]; removed {
continue
}
if rep.Index < 0 || rep.Index >= len(existing) {
writeErr(w, http.StatusBadRequest, "پیوست انتخاب‌شده پیدا نشد")
return
}
prepared, attErr := prepareChatAttachments(u.ID, &rep.Attachment, nil)
if attErr != "" {
writeErr(w, http.StatusBadRequest, attErr)
return
}
if prepared[0].Type != existing[rep.Index].Type {
writeErr(w, http.StatusBadRequest, "فایل جدید باید از همان نوع باشد")
return
}
existing[rep.Index] = prepared[0]
}
toAdd := req.AddAttachments
if req.Attachment != nil { if req.Attachment != nil {
att := req.Attachment toAdd = append(toAdd, *req.Attachment)
att.Type = strings.ToLower(strings.TrimSpace(att.Type)) }
if att.Type != "image" && att.Type != "video" && att.Type != "audio" { added, attErr := prepareChatAttachments(u.ID, nil, toAdd)
writeErr(w, http.StatusBadRequest, "فقط عکس، ویدیو یا صدا را می‌توان جایگزین کرد") if attErr != "" {
writeErr(w, http.StatusBadRequest, attErr)
return return
} }
key := mediaKeyFromURL(att.URL)
if key == "" || !mediaKeyOwnedByUser(key, u.ID) { final := make([]models.Attachment, 0, len(existing)+len(added))
writeErr(w, http.StatusBadRequest, "پیوست متعلق به شما نیست") for i, att := range existing {
if _, removed := drop[i]; removed {
continue
}
final = append(final, att)
}
final = append(final, added...)
if len(final) > maxChatAttachments {
writeErr(w, http.StatusBadRequest, "در هر پیام حداکثر ۱۰ پیوست می‌توان فرستاد")
return return
} }
if q := strings.Index(att.URL, "?"); q >= 0 { if req.Body == "" && len(final) == 0 {
att.URL = att.URL[:q] writeErr(w, http.StatusBadRequest, "پیام باید متن یا پیوست داشته باشد")
}
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 return
} }
msg, err = s.store.EditChatMessage(r.Context(), u.ID, partner, msgID, u.Role, req.Body)
} msg, err := s.store.SaveEditedChatMessage(r.Context(), u.ID, partner, msgID, u.Role, req.Body, final)
if errors.Is(err, store.ErrNotFound) { if errors.Is(err, store.ErrNotFound) {
writeErr(w, http.StatusNotFound, "پیام پیدا نشد") writeErr(w, http.StatusNotFound, "پیام پیدا نشد")
return return
@@ -566,9 +695,12 @@ func (s *Server) markIncomingChatRead(ctx context.Context, reader *models.User,
} }
func (s *Server) canChatWith(ctx context.Context, actor *models.User, partnerID int64) bool { func (s *Server) canChatWith(ctx context.Context, actor *models.User, partnerID int64) bool {
if actor == nil || partnerID <= 0 || actor.ID == partnerID { if actor == nil || partnerID <= 0 {
return false return false
} }
if actor.ID == partnerID {
return true
}
partner, err := s.store.GetUserByID(ctx, partnerID) partner, err := s.store.GetUserByID(ctx, partnerID)
if err != nil || !partner.IsActive { if err != nil || !partner.IsActive {
return false return false
@@ -586,11 +718,76 @@ func (s *Server) resignChatMessages(msgs []models.ChatMessage) {
} }
func (s *Server) resignChatMessage(msg *models.ChatMessage) { func (s *Server) resignChatMessage(msg *models.ChatMessage) {
if msg == nil || msg.Attachment == nil || msg.Attachment.URL == "" { if msg == nil {
return return
} }
for i := range msg.Attachments {
if msg.Attachments[i].URL != "" {
msg.Attachments[i].URL = s.signExistingMediaURL(msg.Attachments[i].URL)
}
}
if len(msg.Attachments) > 0 {
first := msg.Attachments[0]
msg.Attachment = &first
return
}
if msg.Attachment != nil && msg.Attachment.URL != "" {
msg.Attachment.URL = s.signExistingMediaURL(msg.Attachment.URL) msg.Attachment.URL = s.signExistingMediaURL(msg.Attachment.URL)
} }
}
func storedAttachments(m *models.ChatMessage) []models.Attachment {
if m == nil {
return nil
}
if len(m.Attachments) > 0 {
out := make([]models.Attachment, len(m.Attachments))
copy(out, m.Attachments)
return out
}
if m.Attachment != nil && m.Attachment.URL != "" {
return []models.Attachment{*m.Attachment}
}
return nil
}
const maxChatAttachments = 10
func prepareChatAttachments(ownerID int64, single *models.Attachment, many []models.Attachment) ([]models.Attachment, string) {
atts := many
if len(atts) == 0 && single != nil {
atts = []models.Attachment{*single}
}
if len(atts) > maxChatAttachments {
return nil, "در هر پیام حداکثر ۱۰ پیوست می‌توان فرستاد"
}
out := make([]models.Attachment, 0, len(atts))
for i := range atts {
att := atts[i]
if att.Size > maxUploadSize {
return nil, "حجم پیوست بیش از حد مجاز است"
}
att.Type = strings.ToLower(strings.TrimSpace(att.Type))
if att.Type != "image" && att.Type != "video" && att.Type != "audio" && att.Type != "file" {
return nil, "نوع پیوست پشتیبانی نمی‌شود"
}
if att.URL == "" || !strings.Contains(att.URL, "/media/") {
return nil, "نشانی پیوست نامعتبر است"
}
key := mediaKeyFromURL(att.URL)
if !mediaKeyOwnedByUser(key, ownerID) {
return nil, "پیوست متعلق به شما نیست"
}
if q := strings.Index(att.URL, "?"); q >= 0 {
att.URL = att.URL[:q]
}
if strings.TrimSpace(att.Name) == "" {
att.Name = "file"
}
out = append(out, att)
}
return out, ""
}
func itoa(v int64) string { func itoa(v int64) string {
return strconvFormatInt(v) return strconvFormatInt(v)
+35 -13
View File
@@ -13,6 +13,7 @@ import (
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"sync/atomic"
"time" "time"
"pargar/backend/internal/models" "pargar/backend/internal/models"
@@ -59,6 +60,7 @@ type chatExportMessage struct {
PinnedAt *time.Time `json:"pinnedAt,omitempty"` PinnedAt *time.Time `json:"pinnedAt,omitempty"`
EditedAt *time.Time `json:"editedAt,omitempty"` EditedAt *time.Time `json:"editedAt,omitempty"`
Attachment *chatExportAttachment `json:"attachment,omitempty"` Attachment *chatExportAttachment `json:"attachment,omitempty"`
Attachments []chatExportAttachment `json:"attachments,omitempty"`
} }
type chatExportConversation struct { type chatExportConversation struct {
@@ -186,20 +188,25 @@ func (s *Server) buildProjectChatExport(r *http.Request) (*chatExportPayload, []
PinnedAt: m.PinnedAt, PinnedAt: m.PinnedAt,
EditedAt: m.EditedAt, EditedAt: m.EditedAt,
} }
if m.Attachment != nil { list := m.Attachments
att := &chatExportAttachment{ if len(list) == 0 && m.Attachment != nil {
Type: m.Attachment.Type, list = []models.Attachment{*m.Attachment}
Name: m.Attachment.Name,
Size: m.Attachment.Size,
} }
key := mediaKeyFromURL(m.Attachment.URL) exported := make([]chatExportAttachment, 0, len(list))
for i, src := range list {
att := chatExportAttachment{Type: src.Type, Name: src.Name, Size: src.Size}
key := mediaKeyFromURL(src.URL)
if key == "" { if key == "" {
att.Missing = true att.Missing = true
} else { } else {
label := safeExportFilename(src.Name)
if len(list) > 1 {
label = fmt.Sprintf("%d-%s", i+1, label)
}
rel := filepath.ToSlash(filepath.Join( rel := filepath.ToSlash(filepath.Join(
"attachments", "attachments",
convKey, convKey,
fmt.Sprintf("%d-%s", m.ID, safeExportFilename(m.Attachment.Name)), fmt.Sprintf("%d-%s", m.ID, label),
)) ))
rc, _, _, err := s.storage.Open(key) rc, _, _, err := s.storage.Open(key)
if err != nil { if err != nil {
@@ -210,7 +217,14 @@ func (s *Server) buildProjectChatExport(r *http.Request) (*chatExportPayload, []
files = append(files, zipAttachmentFile{Path: rel, Key: key}) files = append(files, zipAttachmentFile{Path: rel, Key: key})
} }
} }
item.Attachment = att exported = append(exported, att)
}
if len(exported) > 0 {
first := exported[0]
item.Attachment = &first
if len(exported) > 1 {
item.Attachments = exported
}
} }
batch = append(batch, item) batch = append(batch, item)
} }
@@ -373,11 +387,17 @@ func (s *Server) handleAdminChatExportRestore(w http.ResponseWriter, r *http.Req
if created.IsZero() { if created.IsZero() {
created = time.Now().UTC() created = time.Now().UTC()
} }
var att *models.Attachment srcs := m.Attachments
if m.Attachment != nil { if len(srcs) == 0 && m.Attachment != nil {
att = s.materializeRestoredAttachment(studentID, m.Attachment, zipFiles) srcs = []chatExportAttachment{*m.Attachment}
} }
if _, err := s.store.ImportChatMessage(r.Context(), studentID, mentorID, role, m.Body, att, created, m.Pinned); err != nil { atts := make([]models.Attachment, 0, len(srcs))
for i := range srcs {
if got := s.materializeRestoredAttachment(studentID, &srcs[i], zipFiles); got != nil {
atts = append(atts, *got)
}
}
if _, err := s.store.ImportChatMessage(r.Context(), studentID, mentorID, role, m.Body, atts, created, m.Pinned); err != nil {
continue continue
} }
imported++ imported++
@@ -459,6 +479,8 @@ type bytesFile struct {
func (b bytesFile) Close() error { return nil } func (b bytesFile) Close() error { return nil }
var restoreMediaSeq atomic.Uint64
func (s *Server) materializeRestoredAttachment(ownerID int64, src *chatExportAttachment, zipFiles map[string][]byte) *models.Attachment { func (s *Server) materializeRestoredAttachment(ownerID int64, src *chatExportAttachment, zipFiles map[string][]byte) *models.Attachment {
if src == nil { if src == nil {
return nil return nil
@@ -476,7 +498,7 @@ func (s *Server) materializeRestoredAttachment(ownerID int64, src *chatExportAtt
if src.Path != "" && zipFiles != nil { if src.Path != "" && zipFiles != nil {
path := filepath.ToSlash(src.Path) path := filepath.ToSlash(src.Path)
if raw, ok := zipFiles[path]; ok && len(raw) > 0 { if raw, ok := zipFiles[path]; ok && len(raw) > 0 {
key := fmt.Sprintf("chats/%d-%d%s", ownerID, time.Now().UnixNano(), filepath.Ext(name)) key := fmt.Sprintf("chats/%d-%d-%d%s", ownerID, time.Now().UnixNano(), restoreMediaSeq.Add(1), filepath.Ext(name))
url, err := s.storage.Save(key, bytesFile{Reader: bytes.NewReader(raw)}, int64(len(raw))) url, err := s.storage.Save(key, bytesFile{Reader: bytes.NewReader(raw)}, int64(len(raw)))
if err == nil { if err == nil {
return &models.Attachment{ return &models.Attachment{
+1 -55
View File
@@ -1,65 +1,11 @@
package api package api
import ( import (
"context"
"net/http" "net/http"
"strings"
"pargar/backend/internal/models"
) )
func (s *Server) handleCommunity(w http.ResponseWriter, r *http.Request) { func (s *Server) handleCommunity(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
donationEnabled := s.cfg.DonationEnabled
if v, err := s.store.GetSetting(ctx, "donation_enabled"); err == nil {
donationEnabled = v == "true" || v == "1"
}
note := s.cfg.DonationNote
if v, err := s.store.GetSetting(ctx, "donation_note"); err == nil && strings.TrimSpace(v) != "" {
note = strings.TrimSpace(v)
}
if strings.TrimSpace(note) == "" {
note = "اگر مایلید از مسیر حمایت کنید، وارد شوید و در گفتگو با تیم هماهنگ کنید — جزئیات فقط خصوصی رد و بدل می‌شود."
}
var contactID any
if u, err := s.Authenticate(r); err == nil && u != nil {
if id := s.donationContactID(ctx, u); id > 0 {
contactID = id
}
}
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"donation": map[string]any{ "socials": s.loadSocials(r.Context()),
"enabled": donationEnabled,
"note": note,
"mode": "chat",
"contactId": contactID,
"draft": "سلام؛ دربارهٔ حمایت مالی / دونیت هماهنگ کنیم.",
},
}) })
} }
func (s *Server) donationContactID(ctx context.Context, actor *models.User) int64 {
staff, err := s.store.ListMentors(ctx)
if err != nil {
return 0
}
var adminID, mentorID int64
for _, m := range staff {
if m.ID == actor.ID {
continue
}
if m.Role == models.RoleAdmin && adminID == 0 {
adminID = m.ID
}
if m.Role == models.RoleMentor && mentorID == 0 {
mentorID = m.ID
}
}
if adminID != 0 {
return adminID
}
return mentorID
}
+6 -9
View File
@@ -11,18 +11,15 @@ func TestCommunityEndpointPublic(t *testing.T) {
if code != http.StatusOK { if code != http.StatusOK {
t.Fatalf("community: %d %v", code, body) t.Fatalf("community: %d %v", code, body)
} }
don, ok := body["donation"].(map[string]any) socials, ok := body["socials"].([]any)
if !ok { if !ok {
t.Fatalf("missing donation: %v", body) t.Fatalf("missing socials: %v", body)
} }
if _, ok := don["enabled"]; !ok { if socials == nil {
t.Fatalf("missing donation.enabled: %v", don) t.Fatal("socials must be a list")
} }
if don["mode"] != "chat" { if _, hasDonation := body["donation"]; hasDonation {
t.Fatalf("donation.mode want chat, got %v", don["mode"]) t.Fatal("donation must not be exposed")
}
if _, hasCard := don["cardNumber"]; hasCard {
t.Fatal("public card number must not be exposed")
} }
// Telegram channel surface was removed from the product. // Telegram channel surface was removed from the product.
if _, hasChannel := body["channel"]; hasChannel { if _, hasChannel := body["channel"]; hasChannel {
+38 -23
View File
@@ -3,6 +3,7 @@ package api_test
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"strings"
"testing" "testing"
) )
@@ -189,26 +190,39 @@ func TestQuizFailLocksAccountAndMentorUnlocks(t *testing.T) {
token, uid := register(t, e, "Struggle", "struggle@test.dev") token, uid := register(t, e, "Struggle", "struggle@test.dev")
watchToUnlock(t, e, token, 1) watchToUnlock(t, e, token, 1)
adminEarly := login(t, e, bootstrapAdminEmail)
code, _ := e.do(t, "PUT", "/api/admin/users/"+itoa(uid), adminEarly, map[string]any{"hearts": 3})
if code != http.StatusOK {
t.Fatalf("set hearts: %d", code)
}
// all-wrong answers burn 3 hearts -> account locked // all-wrong answers spend the starting hearts. Zero does not lock by default.
code, result := e.do(t, "POST", "/api/lessons/1/quiz/submit", token, map[string]any{"answers": []int{0, 0, 0}}) var result map[string]any
code, result = e.do(t, "POST", "/api/lessons/1/quiz/submit", token, map[string]any{"answers": []int{0, 0, 0}})
if code != http.StatusOK { if code != http.StatusOK {
t.Fatalf("submit: %d", code) t.Fatalf("submit: %d", code)
} }
if result["heartsLeft"].(float64) != 0 { if result["heartsLeft"].(float64) != 0 {
t.Fatalf("expected 0 hearts") t.Fatalf("expected 0 hearts")
} }
if result["locked"] != true { if result["locked"] == true {
t.Fatalf("expected locked") t.Fatalf("empty hearts must not lock by default")
} }
// locked users can no longer send heartbeats (423)
code, _ = e.do(t, "POST", "/api/lessons/1/heartbeat", token, map[string]any{"position": 45, "delta": 1, "seq": 9}) code, _ = e.do(t, "POST", "/api/lessons/1/heartbeat", token, map[string]any{"position": 45, "delta": 1, "seq": 9})
if code != http.StatusLocked { if code == http.StatusLocked {
t.Fatalf("expected 423 while locked, got %d", code) t.Fatalf("heartbeat should stay open while waiting for a heart")
}
code, _ = e.do(t, "POST", "/api/lessons/1/quiz/submit", token, map[string]any{"answers": []int{0, 0, 0}})
if code != http.StatusForbidden {
t.Fatalf("expected quiz blocked at 0 hearts, got %d", code)
} }
// locked users may request staff review (does not unlock); only staff unlocks // A manual lock still accepts a review request. Empty hearts alone do not.
code, _ = e.do(t, "POST", "/api/admin/users/"+itoa(uid)+"/lock", adminEarly, nil)
if code != http.StatusOK {
t.Fatalf("manual lock: %d", code)
}
code, body := e.do(t, "POST", "/api/me/request-unlock", token, map[string]any{"note": "لطفاً بررسی کنید"}) code, body := e.do(t, "POST", "/api/me/request-unlock", token, map[string]any{"note": "لطفاً بررسی کنید"})
if code != http.StatusCreated && code != http.StatusOK { if code != http.StatusCreated && code != http.StatusOK {
t.Fatalf("unlock request: %d %v", code, body) t.Fatalf("unlock request: %d %v", code, body)
@@ -248,8 +262,8 @@ func TestQuizFailLocksAccountAndMentorUnlocks(t *testing.T) {
if code != http.StatusOK || u["isLocked"] != false { if code != http.StatusOK || u["isLocked"] != false {
t.Fatalf("user should be unlocked: %v", u["isLocked"]) t.Fatalf("user should be unlocked: %v", u["isLocked"])
} }
if u["hearts"].(float64) != 3 { if u["hearts"].(float64) != 5 {
t.Fatalf("hearts should be restored to 3, got %v", u["hearts"]) t.Fatalf("hearts should be restored to 5, got %v", u["hearts"])
} }
} }
@@ -264,12 +278,12 @@ func TestAdminHeartOverflowLocksAndCapsAccount(t *testing.T) {
} }
uid := int64(body["items"].([]any)[0].(map[string]any)["id"].(float64)) uid := int64(body["items"].([]any)[0].(map[string]any)["id"].(float64))
code, body = e.do(t, "PUT", "/api/admin/users/"+itoa(uid), admin, map[string]any{"hearts": 4}) code, body = e.do(t, "PUT", "/api/admin/users/"+itoa(uid), admin, map[string]any{"hearts": 9})
if code != http.StatusOK { if code != http.StatusOK {
t.Fatalf("update user: %d", code) t.Fatalf("update user: %d", code)
} }
user := body["user"].(map[string]any) user := body["user"].(map[string]any)
if user["hearts"].(float64) != 3 || user["isLocked"] != true { if user["hearts"].(float64) != 5 || user["isLocked"] != true {
t.Fatalf("overflow must cap hearts and lock account: %v", user) t.Fatalf("overflow must cap hearts and lock account: %v", user)
} }
@@ -278,7 +292,7 @@ func TestAdminHeartOverflowLocksAndCapsAccount(t *testing.T) {
t.Fatalf("me: %d", code) t.Fatalf("me: %d", code)
} }
user = body["user"].(map[string]any) user = body["user"].(map[string]any)
if user["hearts"].(float64) != 3 || user["isLocked"] != true { if user["hearts"].(float64) != 5 || user["isLocked"] != true {
t.Fatalf("persisted overflow guard failed: %v", user) t.Fatalf("persisted overflow guard failed: %v", user)
} }
} }
@@ -382,12 +396,14 @@ func TestChatRecipientPermissions(t *testing.T) {
} }
mentorID := int64(body["user"].(map[string]any)["id"].(float64)) mentorID := int64(body["user"].(map[string]any)["id"].(float64))
for _, target := range []int64{studentID, otherStudentID} { code, _ = e.do(t, "POST", "/api/chats/"+itoa(studentID)+"/messages", studentToken, map[string]any{"body": "saved"})
code, _ = e.do(t, "POST", "/api/chats/"+itoa(target)+"/messages", studentToken, map[string]any{"body": "not allowed"}) if code != http.StatusCreated {
t.Fatalf("saved messages: %d", code)
}
code, _ = e.do(t, "POST", "/api/chats/"+itoa(otherStudentID)+"/messages", studentToken, map[string]any{"body": "not allowed"})
if code != http.StatusForbidden { if code != http.StatusForbidden {
t.Fatalf("student→student must be forbidden, got %d", code) t.Fatalf("student→student must be forbidden, got %d", code)
} }
}
code, _ = e.do(t, "POST", "/api/chats/"+itoa(mentorID)+"/messages", studentToken, map[string]any{"body": "ok"}) code, _ = e.do(t, "POST", "/api/chats/"+itoa(mentorID)+"/messages", studentToken, map[string]any{"body": "ok"})
if code != http.StatusCreated { if code != http.StatusCreated {
t.Fatalf("student→mentor should work: %d", code) t.Fatalf("student→mentor should work: %d", code)
@@ -566,8 +582,7 @@ func TestAdminSettingsAndSponsors(t *testing.T) {
code, body := e.do(t, "PUT", "/api/admin/settings", admin, map[string]any{ code, body := e.do(t, "PUT", "/api/admin/settings", admin, map[string]any{
"settings": map[string]any{ "settings": map[string]any{
"donation_enabled": "true", "social_links": `[{"name":"تلگرام","url":"https://t.me/pargar"}]`,
"donation_note": "یادداشت تست",
"sponsors": `[{"name":"حامی یک","url":"https://example.com","blurb":"حمایت"}]`, "sponsors": `[{"name":"حامی یک","url":"https://example.com","blurb":"حمایت"}]`,
}, },
}) })
@@ -575,8 +590,8 @@ func TestAdminSettingsAndSponsors(t *testing.T) {
t.Fatalf("put settings: %d %v", code, body) t.Fatalf("put settings: %d %v", code, body)
} }
settings := body["settings"].(map[string]any) settings := body["settings"].(map[string]any)
if settings["donation_note"] != "یادداشت تست" { if !strings.Contains(settings["social_links"].(string), "تلگرام") {
t.Fatalf("donation note not saved: %v", settings["donation_note"]) t.Fatalf("social links not saved: %v", settings["social_links"])
} }
code, body = e.do(t, "GET", "/api/sponsors", "", nil) code, body = e.do(t, "GET", "/api/sponsors", "", nil)
@@ -595,8 +610,8 @@ func TestAdminSettingsAndSponsors(t *testing.T) {
if _, ok := body["channel"]; ok { if _, ok := body["channel"]; ok {
t.Fatalf("community must not expose telegram channel: %v", body["channel"]) t.Fatalf("community must not expose telegram channel: %v", body["channel"])
} }
donation := body["donation"].(map[string]any) socials, _ := body["socials"].([]any)
if donation["note"] != "یادداشت تست" { if len(socials) != 1 || socials[0].(map[string]any)["name"] != "تلگرام" {
t.Fatalf("community donation note: %v", donation["note"]) t.Fatalf("community socials: %v", body["socials"])
} }
} }
+62 -12
View File
@@ -3,6 +3,7 @@ package api
import ( import (
"context" "context"
"net/http" "net/http"
"sort"
"strconv" "strconv"
"time" "time"
@@ -15,21 +16,40 @@ type lbEntry struct {
Email string `json:"email"` Email string `json:"email"`
XP float64 `json:"xp"` XP float64 `json:"xp"`
Rank int `json:"rank"` Rank int `json:"rank"`
Tier string `json:"tier"`
AvatarVariant string `json:"avatarVariant"` AvatarVariant string `json:"avatarVariant"`
AvatarPalette string `json:"avatarPalette"` AvatarPalette string `json:"avatarPalette"`
AvatarPhoto string `json:"avatarPhoto,omitempty"` AvatarPhoto string `json:"avatarPhoto,omitempty"`
} }
// tierFromRank assigns a league tier based on rank relative to total participants.
// Thresholds: rank 1 = champion, top 10% = gold, top 30% = silver, rest = bronze.
func tierFromRank(rank, total int) string {
if rank <= 0 || total <= 0 {
return "bronze"
}
if rank == 1 {
return "champion"
}
if rank*10 <= total {
return "gold"
}
if rank*10 <= total*3 {
return "silver"
}
return "bronze"
}
func (s *Server) handleWeeklyLeaderboard(w http.ResponseWriter, r *http.Request) { func (s *Server) handleWeeklyLeaderboard(w http.ResponseWriter, r *http.Request) {
u := currentUser(r) u := currentUser(r)
key := "lb:" + service.ISOWeek(time.Now()) key := "lb:" + service.ISOWeek(time.Now())
entries := make([]lbEntry, 0, 20)
type scored struct { type scored struct {
userID int64 userID int64
xp float64 xp float64
} }
var scoredList []scored var scoredList []scored
var totalParticipants int64
if s.redis != nil { if s.redis != nil {
z, err := s.redis.ZRevRangeWithScores(context.Background(), key, 0, 19).Result() z, err := s.redis.ZRevRangeWithScores(context.Background(), key, 0, 19).Result()
@@ -39,12 +59,16 @@ func (s *Server) handleWeeklyLeaderboard(w http.ResponseWriter, r *http.Request)
scoredList = append(scoredList, scored{userID: id, xp: member.Score}) scoredList = append(scoredList, scored{userID: id, xp: member.Score})
} }
} }
if count, err := s.redis.ZCard(context.Background(), key).Result(); err == nil {
totalParticipants = count
}
} }
// fallback to DB if redis empty (e.g. dev bootstrap) // Fallback to DB if Redis is empty (e.g. dev bootstrap).
if len(scoredList) == 0 { if len(scoredList) == 0 {
rows, err := s.store.Pool().Query(context.Background(), ` rows, err := s.store.Pool().Query(context.Background(),
SELECT user_id, xp FROM weekly_leaderboard WHERE week=$1 ORDER BY xp DESC LIMIT 20`, `SELECT user_id, xp FROM weekly_leaderboard WHERE week=$1
ORDER BY xp DESC, user_id ASC LIMIT 20`,
service.ISOWeek(time.Now())) service.ISOWeek(time.Now()))
if err == nil { if err == nil {
for rows.Next() { for rows.Next() {
@@ -56,30 +80,50 @@ func (s *Server) handleWeeklyLeaderboard(w http.ResponseWriter, r *http.Request)
} }
rows.Close() rows.Close()
} }
if err := s.store.Pool().QueryRow(context.Background(),
`SELECT COUNT(*) FROM weekly_leaderboard WHERE week=$1`,
service.ISOWeek(time.Now())).Scan(&totalParticipants); err != nil {
totalParticipants = int64(len(scoredList))
}
} }
// Deterministic tie-breaking: XP descending, then user_id ascending.
sort.SliceStable(scoredList, func(i, j int) bool {
if scoredList[i].xp != scoredList[j].xp {
return scoredList[i].xp > scoredList[j].xp
}
return scoredList[i].userID < scoredList[j].userID
})
entries := make([]lbEntry, 0, len(scoredList))
for i, sc := range scoredList { for i, sc := range scoredList {
usr, err := s.store.GetUserByID(context.Background(), sc.userID) usr, err := s.store.GetUserByID(context.Background(), sc.userID)
if err != nil { if err != nil {
continue continue
} }
s.signUserMedia(usr) s.signUserMedia(usr)
rank := i + 1
entries = append(entries, lbEntry{ entries = append(entries, lbEntry{
UserID: sc.userID, Name: usr.Name, Email: usr.Email, XP: sc.xp, Rank: i + 1, UserID: sc.userID,
AvatarVariant: usr.AvatarVariant, AvatarPalette: usr.AvatarPalette, AvatarPhoto: usr.AvatarPhoto, Name: usr.Name,
Email: usr.Email,
XP: sc.xp,
Rank: rank,
Tier: tierFromRank(rank, int(totalParticipants)),
AvatarVariant: usr.AvatarVariant,
AvatarPalette: usr.AvatarPalette,
AvatarPhoto: usr.AvatarPhoto,
}) })
} }
// my position // Current user's position (may be outside top 20).
myRank := -1 myRank := -1
var myXP float64 var myXP float64
if s.redis != nil { if s.redis != nil {
rank, err := s.redis.ZRevRank(context.Background(), key, strconv.FormatInt(u.ID, 10)).Result() if rank, err := s.redis.ZRevRank(context.Background(), key, strconv.FormatInt(u.ID, 10)).Result(); err == nil {
if err == nil {
myRank = int(rank) + 1 myRank = int(rank) + 1
} }
score, err := s.redis.ZScore(context.Background(), key, strconv.FormatInt(u.ID, 10)).Result() if score, err := s.redis.ZScore(context.Background(), key, strconv.FormatInt(u.ID, 10)).Result(); err == nil {
if err == nil {
myXP = score myXP = score
} }
} }
@@ -90,6 +134,12 @@ func (s *Server) handleWeeklyLeaderboard(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"week": service.ISOWeek(time.Now()), "week": service.ISOWeek(time.Now()),
"entries": entries, "entries": entries,
"me": map[string]any{"rank": myRank, "xp": myXP, "streak": u.StreakCurrent}, "totalParticipants": totalParticipants,
"me": map[string]any{
"rank": myRank,
"xp": myXP,
"streak": u.StreakCurrent,
"tier": tierFromRank(myRank, int(totalParticipants)),
},
}) })
} }
+31
View File
@@ -0,0 +1,31 @@
package api
import "testing"
func TestTierFromRank(t *testing.T) {
cases := []struct {
rank, total int
want string
}{
{1, 1, "champion"},
{1, 100, "champion"},
{1, 0, "bronze"}, // zero total → bronze (guard)
{0, 100, "bronze"}, // zero rank → bronze (guard)
{10, 100, "gold"}, // exactly 10%
{11, 100, "silver"}, // 11%
{30, 100, "silver"}, // exactly 30%
{31, 100, "bronze"}, // 31%
{5, 50, "gold"}, // 10%
{15, 50, "silver"}, // exactly 30%
{16, 50, "bronze"}, // 32% → bronze
{50, 100, "bronze"},
{2, 10, "silver"}, // 20% → silver
{1, 10, "champion"},
}
for _, c := range cases {
got := tierFromRank(c.rank, c.total)
if got != c.want {
t.Errorf("tierFromRank(%d, %d) = %q; want %q", c.rank, c.total, got, c.want)
}
}
}
+604
View File
@@ -0,0 +1,604 @@
package api
import (
"archive/zip"
"context"
"encoding/json"
"fmt"
"html"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"pargar/backend/internal/models"
"pargar/backend/internal/observability"
)
const (
meExportHTMLBase int64 = 8 * 1024
meExportHTMLPerMessage int64 = 512
)
func (s *Server) handleMeExport(w http.ResponseWriter, r *http.Request) {
u := currentUser(r)
if u == nil {
writeErr(w, http.StatusUnauthorized, "ابتدا وارد شوید")
return
}
partnerID, ok := meExportChatID(r)
if !ok {
writeErr(w, http.StatusBadRequest, "گفتگو را مشخص کنید")
return
}
if !s.canChatWith(r.Context(), u, partnerID) {
writeErr(w, http.StatusNotFound, "گفتگو پیدا نشد")
return
}
if r.URL.Query().Get("meta") == "1" {
n, last, err := s.estimateMeExportBytes(r.Context(), u.ID, partnerID)
if err != nil {
writeErr(w, http.StatusInternalServerError, "برآورد حجم خروجی ممکن نشد")
return
}
payload := map[string]any{"bytes": n, "chat": partnerID}
if last != nil && !last.IsZero() {
payload["lastAt"] = last.UTC()
}
writeJSON(w, http.StatusOK, payload)
return
}
if err := s.writeMeChatHTMLZip(w, r, u, partnerID); err != nil {
observability.L().Error("me.export_zip", "error", err, "user_id", u.ID, "chat", partnerID)
}
}
func meExportChatID(r *http.Request) (int64, bool) {
raw := strings.TrimSpace(r.URL.Query().Get("chat"))
if raw == "" {
return 0, false
}
id, err := strconv.ParseInt(raw, 10, 64)
if err != nil || id <= 0 {
return 0, false
}
return id, true
}
func (s *Server) estimateMeExportBytes(ctx context.Context, userID, partnerID int64) (int64, *time.Time, error) {
var messages, media int64
var last *time.Time
err := s.store.Pool().QueryRow(ctx, `
SELECT
count(*)::bigint,
COALESCE(SUM(
CASE
WHEN attachments IS NOT NULL
AND jsonb_typeof(attachments) = 'array'
AND jsonb_array_length(attachments) > 0
THEN COALESCE((
SELECT SUM(GREATEST(COALESCE((elem->>'size')::bigint, 0), 0))
FROM jsonb_array_elements(attachments) AS elem
), 0)
ELSE GREATEST(COALESCE(attachment_size, 0), 0)
END
), 0)::bigint,
MAX(created_at)
FROM chat_messages
WHERE (user_id = $1 AND mentor_id = $2) OR (user_id = $2 AND mentor_id = $1)`, userID, partnerID).Scan(&messages, &media, &last)
if err != nil {
return 0, nil, err
}
return media + meExportHTMLBase + messages*meExportHTMLPerMessage, last, nil
}
type meExportMessage struct {
ID int64
UserID int64
MentorID int64
SenderRole string
Body string
CreatedAt time.Time
StudentName string
MentorName string
Atts []models.Attachment
}
type meExportFile struct {
Path string
Key string
}
type mePlaced struct {
msg meExportMessage
rel []string
}
func (s *Server) writeMeChatHTMLZip(w http.ResponseWriter, r *http.Request, u *models.User, partnerID int64) error {
title := "پیام‌های ذخیره‌شده"
if partnerID != u.ID {
partner, err := s.store.GetUserByID(r.Context(), partnerID)
if err != nil {
writeErr(w, http.StatusNotFound, "گفتگو پیدا نشد")
return err
}
title = strings.TrimSpace(partner.Name)
if title == "" {
title = "گفتگو"
}
}
rows, err := s.store.Pool().Query(r.Context(), `
SELECT m.id, m.user_id, m.mentor_id, m.sender_role, m.body, m.created_at,
m.attachment_type, m.attachment_url, m.attachment_name, m.attachment_size, m.attachments,
COALESCE(su.name, ''), COALESCE(mu.name, '')
FROM chat_messages m
LEFT JOIN users su ON su.id = m.user_id
LEFT JOIN users mu ON mu.id = m.mentor_id
WHERE (m.user_id = $1 AND m.mentor_id = $2) OR (m.user_id = $2 AND m.mentor_id = $1)
ORDER BY m.created_at ASC, m.id ASC`, u.ID, partnerID)
if err != nil {
writeErr(w, http.StatusInternalServerError, "ساخت خروجی گفتگوها ممکن نشد")
return err
}
defer rows.Close()
var messages []meExportMessage
for rows.Next() {
var m meExportMessage
var attType, attURL, attName *string
var attSize *int64
var rawAtts []byte
if err := rows.Scan(
&m.ID, &m.UserID, &m.MentorID, &m.SenderRole, &m.Body, &m.CreatedAt,
&attType, &attURL, &attName, &attSize, &rawAtts,
&m.StudentName, &m.MentorName,
); err != nil {
continue
}
m.Atts = decodeExportAttachments(rawAtts, attType, attURL, attName, attSize)
messages = append(messages, m)
}
if err := rows.Err(); err != nil {
writeErr(w, http.StatusInternalServerError, "ساخت خروجی گفتگوها ممکن نشد")
return err
}
placed := make([]mePlaced, 0, len(messages))
var files []meExportFile
seen := map[string]bool{}
for _, m := range messages {
item := mePlaced{msg: m, rel: make([]string, len(m.Atts))}
for i, att := range m.Atts {
key := mediaKeyFromURL(att.URL)
if key == "" || strings.Contains(key, "..") || !exportKeyAllowed(key, u.ID, m.UserID, m.MentorID) || !s.storage.Exists(key) {
continue
}
label := safeExportFilename(att.Name)
rel := filepath.ToSlash(filepath.Join("media", fmt.Sprintf("%d-%d-%s", m.ID, i+1, label)))
if seen[rel] {
rel = filepath.ToSlash(filepath.Join("media", fmt.Sprintf("%d-%d-b-%s", m.ID, i+1, label)))
}
seen[rel] = true
item.rel[i] = rel
files = append(files, meExportFile{Path: rel, Key: key})
}
placed = append(placed, item)
}
doc := renderMeExportHTML(u, title, placed)
tmp, err := os.CreateTemp("", "pargar-chats-*.zip")
if err != nil {
writeErr(w, http.StatusInternalServerError, "ساخت خروجی گفتگوها ممکن نشد")
return err
}
tmpName := tmp.Name()
defer func() {
_ = tmp.Close()
_ = os.Remove(tmpName)
}()
zw := zip.NewWriter(tmp)
hw, err := zw.CreateHeader(&zip.FileHeader{
Name: "index.html",
Method: zip.Deflate,
Modified: time.Now().UTC(),
})
if err != nil {
writeErr(w, http.StatusInternalServerError, "ساخت خروجی گفتگوها ممکن نشد")
return err
}
if _, err := io.WriteString(hw, doc); err != nil {
writeErr(w, http.StatusInternalServerError, "ساخت خروجی گفتگوها ممکن نشد")
return err
}
for _, f := range files {
rc, _, _, err := s.storage.Open(f.Key)
if err != nil {
continue
}
aw, err := zw.CreateHeader(&zip.FileHeader{
Name: f.Path,
Method: zip.Deflate,
Modified: time.Now().UTC(),
})
if err != nil {
_ = rc.Close()
writeErr(w, http.StatusInternalServerError, "ساخت خروجی گفتگوها ممکن نشد")
return err
}
_, copyErr := io.Copy(aw, rc)
_ = rc.Close()
if copyErr != nil {
writeErr(w, http.StatusInternalServerError, "ساخت خروجی گفتگوها ممکن نشد")
return copyErr
}
}
if err := zw.Close(); err != nil {
writeErr(w, http.StatusInternalServerError, "ساخت خروجی گفتگوها ممکن نشد")
return err
}
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
writeErr(w, http.StatusInternalServerError, "ساخت خروجی گفتگوها ممکن نشد")
return err
}
info, err := tmp.Stat()
if err != nil {
writeErr(w, http.StatusInternalServerError, "ساخت خروجی گفتگوها ممکن نشد")
return err
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="pargar-chat-%d.zip"`, partnerID))
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Cache-Control", "private, no-store")
w.Header().Set("Content-Length", strconv.FormatInt(info.Size(), 10))
w.WriteHeader(http.StatusOK)
_, err = io.Copy(w, tmp)
return err
}
func exportKeyAllowed(key string, me, studentID, mentorID int64) bool {
return mediaKeyOwnedByUser(key, me) || mediaKeyOwnedByUser(key, studentID) || mediaKeyOwnedByUser(key, mentorID)
}
func decodeExportAttachments(raw []byte, attType, attURL, attName *string, attSize *int64) []models.Attachment {
if len(raw) > 0 && string(raw) != "null" && string(raw) != "[]" {
var list []models.Attachment
if err := json.Unmarshal(raw, &list); err == nil && len(list) > 0 {
return list
}
}
if attURL != nil && *attURL != "" {
size := int64(0)
if attSize != nil {
size = *attSize
}
typ, name := "", ""
if attType != nil {
typ = *attType
}
if attName != nil {
name = *attName
}
return []models.Attachment{{Type: typ, URL: *attURL, Name: name, Size: size}}
}
return nil
}
func renderMeExportHTML(owner *models.User, title string, messages []mePlaced) string {
var b strings.Builder
b.WriteString(`<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>`)
b.WriteString(html.EscapeString(title))
b.WriteString(`</title>
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; }
body {
margin: 0;
background: #eceef8;
color: #2a2745;
font-family: Vazirmatn, "IRANSans", Tahoma, sans-serif;
}
.sheet {
max-width: 440px;
margin: 24px auto 40px;
overflow: hidden;
background: #f7f7fc;
border: 1px solid rgba(255,255,255,.75);
border-radius: 26px;
box-shadow: 0 24px 70px -18px rgba(42,39,69,.35);
}
header.bar {
position: relative;
padding: 16px 16px 12px;
background: rgba(255,255,255,.78);
border-bottom: 1px solid #e3e5f2;
}
header.bar::before {
content: "";
position: absolute;
inset: 0 0 auto 0;
height: 3px;
background: linear-gradient(to left, #6d63f0, #ec6fa9, #f2a437);
}
header.bar h1 { margin: 0; font-size: .95rem; font-weight: 800; }
header.bar p { margin: 3px 0 0; color: #6b6c8a; font-size: .75rem; }
.log { min-height: 240px; padding: 12px 12px 18px; }
.day { display: flex; justify-content: center; margin: 10px 0 8px; }
.day span {
border: 1px solid #d9dbe9;
background: rgba(255,255,255,.9);
color: #6b6c8a;
border-radius: 999px;
padding: 3px 10px;
font-size: 10px;
font-weight: 700;
}
article.msg { display: flex; margin: 0 0 6px; }
article.msg.mine { justify-content: flex-start; }
article.msg.theirs { justify-content: flex-end; }
.stack { display: flex; flex-direction: column; max-width: min(78%, 20rem); }
article.mine .stack { align-items: flex-start; }
article.theirs .stack { align-items: flex-end; }
.who { margin: 0 6px 3px; font-size: 11px; font-weight: 800; color: #6b6c8a; }
.bubble {
width: fit-content;
max-width: 100%;
padding: 8px 14px;
font-size: .875rem;
line-height: 1.65;
}
article.mine .bubble {
background: #6d63f0;
color: #fff;
border-radius: 18px 6px 18px 18px;
box-shadow: 0 1px 1px rgba(0,0,0,.12);
}
article.theirs .bubble {
background: #fff;
color: #2a2745;
border: 1px solid #d9dbe9;
border-radius: 6px 18px 18px 18px;
box-shadow: 0 1px 1px rgba(0,0,0,.06);
}
.body {
margin: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
unicode-bidi: plaintext;
}
time { display: block; margin-top: 4px; font-size: 10px; line-height: 1; opacity: .78; }
article.mine time { text-align: right; }
article.theirs time { text-align: left; }
img, video {
display: block;
max-width: min(100%, 240px);
height: auto;
border-radius: 12px;
margin-top: 6px;
}
audio { display: block; width: min(100%, 240px); margin-top: 6px; }
.file, .missing { margin: 6px 0 0; font-size: .82rem; }
article.mine a { color: #fff; }
article.theirs a { color: #4c45c6; }
.missing { opacity: .85; }
.empty { margin: 28px 8px; text-align: center; color: #6b6c8a; font-size: .875rem; }
</style>
</head>
<body>
<main class="sheet">
<header class="bar">
<h1>`)
b.WriteString(html.EscapeString(title))
b.WriteString(`</h1>
<p>خروجی پرگار · `)
b.WriteString(html.EscapeString(formatJalaliStamp(time.Now())))
b.WriteString(`</p>
</header>
<div class="log">
`)
if len(messages) == 0 {
b.WriteString(`<p class="empty">هنوز پیامی در این گفتگو نیست.</p>`)
}
var hasPrev bool
var prevMine bool
var prevAt time.Time
for _, item := range messages {
mine := meExportIsMine(owner.ID, item.msg)
if !hasPrev || !sameExportDay(prevAt, item.msg.CreatedAt) {
b.WriteString(`<div class="day"><span>`)
b.WriteString(html.EscapeString(formatJalaliDay(item.msg.CreatedAt)))
b.WriteString(`</span></div>`)
}
showName := !hasPrev || prevMine != mine || !sameExportDay(prevAt, item.msg.CreatedAt)
cls := "theirs"
if mine {
cls = "mine"
}
b.WriteString(`<article class="msg ` + cls + `"><div class="stack">`)
if showName {
b.WriteString(`<p class="who">`)
b.WriteString(html.EscapeString(meExportSenderName(owner, item.msg)))
b.WriteString(`</p>`)
}
b.WriteString(`<div class="bubble">`)
if strings.TrimSpace(item.msg.Body) != "" {
b.WriteString(`<p class="body">` + escapeExportBody(item.msg.Body) + `</p>`)
}
for i, att := range item.msg.Atts {
rel := ""
if i < len(item.rel) {
rel = item.rel[i]
}
b.WriteString(renderExportAttachment(att, rel))
}
b.WriteString(`<time datetime="` + html.EscapeString(item.msg.CreatedAt.UTC().Format(time.RFC3339)) + `">`)
b.WriteString(html.EscapeString(formatJalaliStamp(item.msg.CreatedAt)))
b.WriteString(`</time></div></div></article>`)
hasPrev = true
prevMine = mine
prevAt = item.msg.CreatedAt
}
b.WriteString(`</div>
</main>
</body>
</html>
`)
return b.String()
}
func meExportSenderName(me *models.User, m meExportMessage) string {
if meExportIsMine(me.ID, m) {
name := strings.TrimSpace(me.Name)
if name == "" {
return "شما"
}
return name
}
name := m.StudentName
if me.ID == m.UserID {
name = m.MentorName
}
name = strings.TrimSpace(name)
if name == "" {
return "گفتگو"
}
return name
}
func sameExportDay(a, b time.Time) bool {
al := a.In(tehranLocation())
bl := b.In(tehranLocation())
return al.Year() == bl.Year() && al.Month() == bl.Month() && al.Day() == bl.Day()
}
func formatJalaliDay(t time.Time) string {
if t.IsZero() {
return ""
}
local := t.In(tehranLocation())
_, jm, jd := gregorianToJalali(local.Year(), int(local.Month()), local.Day())
month := "ماه"
if jm >= 1 && jm <= len(jalaliMonthNames) {
month = jalaliMonthNames[jm-1]
}
return persianDigits(jd) + " " + month
}
func meExportIsMine(meID int64, m meExportMessage) bool {
if m.UserID == m.MentorID {
return true
}
role := models.Role(m.SenderRole)
if meID == m.UserID && role == models.RoleStudent {
return true
}
if meID == m.MentorID && (role == models.RoleMentor || role == models.RoleAdmin) {
return true
}
return false
}
func escapeExportBody(s string) string {
return html.EscapeString(s)
}
func renderExportAttachment(att models.Attachment, rel string) string {
name := strings.TrimSpace(att.Name)
if name == "" {
name = "فایل"
}
safe := html.EscapeString(name)
if rel == "" {
return `<p class="missing">فایل همراه موجود نیست: ` + safe + `</p>`
}
src := html.EscapeString(rel)
switch strings.ToLower(strings.TrimSpace(att.Type)) {
case "image":
return `<img src="` + src + `" alt="` + safe + `">`
case "audio":
return `<audio controls preload="metadata" src="` + src + `"></audio>`
case "video":
return `<video controls preload="metadata" src="` + src + `"></video>`
default:
return `<p class="file"><a href="` + src + `">` + safe + `</a></p>`
}
}
func formatJalaliStamp(t time.Time) string {
if t.IsZero() {
return ""
}
local := t.In(tehranLocation())
_, jm, jd := gregorianToJalali(local.Year(), int(local.Month()), local.Day())
month := "ماه"
if jm >= 1 && jm <= len(jalaliMonthNames) {
month = jalaliMonthNames[jm-1]
}
return persianDigits(jd) + " " + month + " · " + persianDigits2(local.Hour()) + ":" + persianDigits2(local.Minute())
}
func tehranLocation() *time.Location {
loc, err := time.LoadLocation("Asia/Tehran")
if err != nil {
return time.FixedZone("IRST", 3*3600+30*60)
}
return loc
}
var jalaliMonthNames = []string{
"فروردین", "اردیبهشت", "خرداد", "تیر", "مرداد", "شهریور",
"مهر", "آبان", "آذر", "دی", "بهمن", "اسفند",
}
func persianDigits(n int) string {
if n < 0 {
n = 0
}
return strings.Map(func(r rune) rune {
if r >= '0' && r <= '9' {
return '۰' + (r - '0')
}
return r
}, strconv.Itoa(n))
}
func persianDigits2(n int) string {
if n < 0 {
n = 0
}
return persianDigits(n/10) + persianDigits(n%10)
}
func gregorianToJalali(gy, gm, gd int) (jy, jm, jd int) {
gdm := [12]int{0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334}
gy2 := gy
if gm > 2 {
gy2 = gy + 1
}
days := 355666 + 365*gy + (gy2+3)/4 - (gy2+99)/100 + (gy2+399)/400 + gd + gdm[gm-1]
jy = -1595 + 33*(days/12053)
days %= 12053
jy += 4 * (days / 1461)
days %= 1461
if days > 365 {
jy += (days - 1) / 365
days = (days - 1) % 365
}
if days < 186 {
jm = 1 + days/31
jd = 1 + days%31
return jy, jm, jd
}
jm = 7 + (days-186)/30
jd = 1 + (days-186)%30
return jy, jm, jd
}
+254
View File
@@ -0,0 +1,254 @@
package api_test
import (
"archive/zip"
"bytes"
"context"
"encoding/base64"
"io"
"net/http"
"strings"
"testing"
)
func TestMeChatHTMLZipExport(t *testing.T) {
e := setup(t)
student, studentID := register(t, e, "Export Student", "me-export@test.dev")
other, otherID := register(t, e, "Other Student", "me-export-other@test.dev")
admin := login(t, e, bootstrapAdminEmail)
code, body := e.do(t, "POST", "/api/admin/users", admin, map[string]any{
"name": "Mentor Export", "email": "mentor-export@test.dev", "username": "mentorexport",
"password": "password123", "role": "mentor",
"securityQuestion": "نام حیوان خانگی؟", "securityAnswer": "rex",
})
if code != http.StatusCreated {
t.Fatalf("create mentor: %d %v", code, body)
}
mentorID := int64(body["user"].(map[string]any)["id"].(float64))
mentor := login(t, e, "mentor-export@test.dev")
png, err := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==")
if err != nil || len(png) < 8 {
t.Fatalf("png: %v len=%d", err, len(png))
}
wav := make([]byte, 44)
copy(wav, []byte("RIFF"))
copy(wav[8:], []byte("WAVE"))
note := []byte("note-bytes")
code, upImg := uploadChatFile(t, e, student, "shot.png", png)
if code != http.StatusCreated {
t.Fatalf("png upload: %d %v", code, upImg)
}
code, upNote := uploadChatFile(t, e, student, "notes.txt", note)
if code != http.StatusCreated {
t.Fatalf("txt upload: %d %v", code, upNote)
}
code, upVoice := uploadChatFile(t, e, student, "voice-note.wav", wav)
if code != http.StatusCreated {
t.Fatalf("wav upload: %d %v", code, upVoice)
}
code, sent := e.do(t, "POST", "/api/chats/"+itoa(mentorID)+"/messages", student, map[string]any{
"body": "<b>راز</b>\nخط دوم",
"attachments": []any{upImg["attachment"], upNote["attachment"]},
})
if code != http.StatusCreated {
t.Fatalf("send: %d %v", code, sent)
}
msgID := int64(sent["message"].(map[string]any)["id"].(float64))
if _, err := e.pool.Exec(context.Background(), `UPDATE chat_messages SET created_at = $1 WHERE id = $2`, "2026-03-21 12:00:00+00", msgID); err != nil {
t.Fatal(err)
}
code, voice := e.do(t, "POST", "/api/chats/"+itoa(mentorID)+"/messages", student, map[string]any{
"body": "صدا",
"attachment": upVoice["attachment"],
})
if code != http.StatusCreated {
t.Fatalf("voice: %d %v", code, voice)
}
if _, err := e.pool.Exec(context.Background(), `
INSERT INTO chat_messages (user_id, mentor_id, sender_role, body, attachment_type, attachment_url, attachment_name, attachment_size)
VALUES ($1, $2, 'student', 'گمشده', 'audio', 'http://localhost:8080/media/chats/999-missing.wav', 'gone.wav', 5000)`,
studentID, mentorID); err != nil {
t.Fatal(err)
}
code, saved := e.do(t, "POST", "/api/chats/"+itoa(studentID)+"/messages", student, map[string]any{"body": "یادداشت خصوصی"})
if code != http.StatusCreated {
t.Fatalf("saved: %d %v", code, saved)
}
code, reply := e.do(t, "POST", "/api/chats/"+itoa(studentID)+"/messages", mentor, map[string]any{"body": "از منتور"})
if code != http.StatusCreated {
t.Fatalf("reply: %d %v", code, reply)
}
code, _ = e.do(t, "GET", "/api/me/export?meta=1", student, nil)
if code != http.StatusBadRequest {
t.Fatalf("meta without chat: %d", code)
}
code, _, raw := e.doRaw(t, "GET", "/api/me/export", student, nil)
if code != http.StatusBadRequest || (len(raw) >= 2 && raw[0] == 'P' && raw[1] == 'K') {
t.Fatalf("export without chat should not be a zip: %d", code)
}
code, meta := e.do(t, "GET", "/api/me/export?meta=1&chat="+itoa(mentorID), student, nil)
if code != http.StatusOK {
t.Fatalf("meta: %d %v", code, meta)
}
approx := int64(meta["bytes"].(float64))
floor := int64(len(png) + len(wav) + len(note) + 5000)
if approx < floor {
t.Fatalf("estimate %d < media floor %d", approx, floor)
}
if int64(meta["chat"].(float64)) != mentorID {
t.Fatalf("meta chat: %v", meta["chat"])
}
code, savedMeta := e.do(t, "GET", "/api/me/export?meta=1&chat="+itoa(studentID), student, nil)
if code != http.StatusOK || int64(savedMeta["bytes"].(float64)) >= approx {
t.Fatalf("saved meta should be only that chat: %d %v", code, savedMeta)
}
code, ct, raw := e.doRaw(t, "GET", "/api/me/export?chat="+itoa(mentorID), student, nil)
if code != http.StatusOK {
t.Fatalf("zip: %d %s", code, ct)
}
if ct != "application/zip" || len(raw) < 2 || raw[0] != 'P' || raw[1] != 'K' {
t.Fatalf("not a zip: %s %d", ct, len(raw))
}
zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
if err != nil {
t.Fatal(err)
}
var htmlDoc string
var sawImage, sawAudio, sawNote bool
for _, f := range zr.File {
if strings.Contains(f.Name, "gone.wav") || strings.Contains(f.Name, "missing") {
t.Fatalf("missing media was packed: %s", f.Name)
}
rc, err := f.Open()
if err != nil {
t.Fatal(err)
}
blob, _ := io.ReadAll(rc)
_ = rc.Close()
switch {
case f.Name == "index.html":
htmlDoc = string(blob)
case strings.HasSuffix(f.Name, ".png"):
sawImage = bytes.Equal(blob, png)
case strings.HasSuffix(f.Name, ".wav"):
sawAudio = bytes.Equal(blob, wav)
case strings.HasSuffix(f.Name, ".txt"):
sawNote = bytes.Equal(blob, note)
}
}
if htmlDoc == "" || !sawImage || !sawAudio || !sawNote {
t.Fatalf("zip parts image=%v audio=%v note=%v html=%d", sawImage, sawAudio, sawNote, len(htmlDoc))
}
if !strings.Contains(htmlDoc, `lang="fa"`) || !strings.Contains(htmlDoc, `dir="rtl"`) {
t.Fatal("html is not a persian rtl document")
}
if strings.Contains(htmlDoc, "<b>راز") || !strings.Contains(htmlDoc, "&lt;b&gt;راز&lt;/b&gt;") {
t.Fatal("user text was not escaped")
}
if !strings.Contains(htmlDoc, "&lt;b&gt;راز&lt;/b&gt;\nخط دوم") {
t.Fatal("newline was not kept in the bubble")
}
if !strings.Contains(htmlDoc, "white-space: pre-wrap") {
t.Fatal("bubble text does not keep whitespace")
}
if !strings.Contains(htmlDoc, `class="msg mine"`) || !strings.Contains(htmlDoc, `class="msg theirs"`) {
t.Fatal("own and partner bubbles are missing")
}
if !strings.Contains(htmlDoc, "#6d63f0") {
t.Fatal("own bubble is not the chat primary color")
}
if !strings.Contains(htmlDoc, ">Mentor Export</h1>") || !strings.Contains(htmlDoc, "Export Student") || !strings.Contains(htmlDoc, "از منتور") {
t.Fatal("single conversation header or sender names missing")
}
if strings.Contains(htmlDoc, "یادداشت خصوصی") || strings.Contains(htmlDoc, "گفتگوهای پرگار") {
t.Fatal("export included another conversation")
}
if !strings.Contains(htmlDoc, "<img ") || !strings.Contains(htmlDoc, `src="media/`) {
t.Fatal("image is not inlined with a relative src")
}
if !strings.Contains(htmlDoc, "<audio controls") {
t.Fatal("voice has no native audio control")
}
if !strings.Contains(htmlDoc, "<a href=\"media/") {
t.Fatal("other file is not a relative link")
}
if !strings.Contains(htmlDoc, "۱ فروردین · ۱۵:۳۰") {
t.Fatal("jalali stamp missing")
}
if !strings.Contains(htmlDoc, "فایل همراه موجود نیست") || !strings.Contains(htmlDoc, "gone.wav") {
t.Fatal("missing file was not reported in the transcript")
}
code, ct, savedRaw := e.doRaw(t, "GET", "/api/me/export?chat="+itoa(studentID), student, nil)
if code != http.StatusOK || ct != "application/zip" || len(savedRaw) < 2 || savedRaw[0] != 'P' {
t.Fatalf("saved zip: %d %s", code, ct)
}
savedZip, err := zip.NewReader(bytes.NewReader(savedRaw), int64(len(savedRaw)))
if err != nil {
t.Fatal(err)
}
var savedHTML string
for _, f := range savedZip.File {
if strings.HasSuffix(f.Name, ".png") || strings.HasSuffix(f.Name, ".wav") || strings.HasSuffix(f.Name, ".txt") {
t.Fatalf("saved chat packed another conversation's media: %s", f.Name)
}
if f.Name != "index.html" {
continue
}
rc, err := f.Open()
if err != nil {
t.Fatal(err)
}
blob, _ := io.ReadAll(rc)
_ = rc.Close()
savedHTML = string(blob)
}
if !strings.Contains(savedHTML, "پیام‌های ذخیره‌شده") || !strings.Contains(savedHTML, "یادداشت خصوصی") {
t.Fatal("saved chat export is missing its own transcript")
}
if strings.Contains(savedHTML, "راز") || strings.Contains(savedHTML, "از منتور") {
t.Fatal("saved chat export included the mentor thread")
}
code, _, _ = e.doRaw(t, "GET", "/api/me/export?chat="+itoa(studentID), other, nil)
if code != http.StatusNotFound {
t.Fatalf("other user chat: %d", code)
}
code, _, otherRaw := e.doRaw(t, "GET", "/api/me/export?chat="+itoa(otherID), other, nil)
if code != http.StatusOK || len(otherRaw) < 2 || otherRaw[0] != 'P' {
t.Fatalf("other user own export failed: %d", code)
}
otherZip, err := zip.NewReader(bytes.NewReader(otherRaw), int64(len(otherRaw)))
if err != nil {
t.Fatal(err)
}
for _, f := range otherZip.File {
rc, err := f.Open()
if err != nil {
t.Fatal(err)
}
blob, _ := io.ReadAll(rc)
_ = rc.Close()
if bytes.Contains(blob, []byte("راز")) || bytes.Contains(blob, []byte("یادداشت خصوصی")) {
t.Fatalf("other user export leaked %s", f.Name)
}
}
code, mentorMeta := e.do(t, "GET", "/api/me/export?meta=1&chat="+itoa(studentID), mentor, nil)
if code != http.StatusOK || int64(mentorMeta["bytes"].(float64)) < floor {
t.Fatalf("mentor meta: %d %v", code, mentorMeta)
}
code, _ = e.do(t, "GET", "/api/me/export?chat=999999", student, nil)
if code != http.StatusNotFound {
t.Fatalf("missing partner: %d", code)
}
code, _, _ = e.doRaw(t, "GET", "/api/me/export?chat="+itoa(mentorID), "", nil)
if code != http.StatusUnauthorized {
t.Fatalf("anon: %d", code)
}
}
@@ -0,0 +1,27 @@
package api
import (
"testing"
"time"
)
func TestGregorianToJalaliKnownDates(t *testing.T) {
cases := []struct {
y, m, d int
jy, jm, jd int
}{
{2026, 3, 21, 1405, 1, 1},
{2026, 10, 10, 1405, 7, 18},
{2024, 3, 20, 1403, 1, 1},
}
for _, c := range cases {
jy, jm, jd := gregorianToJalali(c.y, c.m, c.d)
if jy != c.jy || jm != c.jm || jd != c.jd {
t.Fatalf("%04d-%02d-%02d: got %d-%d-%d want %d-%d-%d", c.y, c.m, c.d, jy, jm, jd, c.jy, c.jm, c.jd)
}
}
stamp := formatJalaliStamp(time.Date(2026, 3, 21, 12, 0, 0, 0, time.UTC))
if stamp != "۱ فروردین · ۱۵:۳۰" {
t.Fatalf("stamp: %q", stamp)
}
}
+801
View File
@@ -0,0 +1,801 @@
package api
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"runtime/debug"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/jackc/pgx/v5"
"pargar/backend/internal/models"
"pargar/backend/internal/store"
)
var announcementCategories = map[string]bool{
"event": true, "progress": true, "gamification": true, "mentor": true,
"urgent": true, "curriculum": true, "community": true, "system": true,
}
func (s *Server) handleAdminAnnounce(w http.ResponseWriter, r *http.Request) {
s.createAnnouncement(w, r)
}
func (s *Server) handleAdminListAnnouncements(w http.ResponseWriter, r *http.Request) {
list, err := s.store.ListAnnouncements(r.Context())
if err != nil {
writeErr(w, http.StatusInternalServerError, "بارگذاری اطلاعیه‌ها ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"announcements": announceViews(list)})
}
func (s *Server) handleAdminUpdateAnnouncement(w http.ResponseWriter, r *http.Request) {
id := routeID(r, "id")
a, ok := s.readAnnouncement(w, r)
if !ok {
return
}
a.ID = id
if err := s.store.UpdateAnnouncement(r.Context(), a); err != nil {
if errors.Is(err, store.ErrNotFound) {
writeErr(w, http.StatusNotFound, "اطلاعیه پیدا نشد")
return
}
writeErr(w, http.StatusInternalServerError, "ویرایش اطلاعیه ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) handleAdminDeleteAnnouncement(w http.ResponseWriter, r *http.Request) {
id := routeID(r, "id")
if err := s.store.DeleteAnnouncement(r.Context(), id); err != nil {
if errors.Is(err, store.ErrNotFound) {
writeErr(w, http.StatusNotFound, "اطلاعیه پیدا نشد")
return
}
writeErr(w, http.StatusInternalServerError, "حذف اطلاعیه ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) handlePinnedAnnouncement(w http.ResponseWriter, r *http.Request) {
list, err := s.store.RecentAnnouncements(r.Context(), 8)
var pinned any
if err == nil {
for _, a := range list {
if a.Pinned {
pinned = announceViews([]store.Announcement{a})[0]
break
}
}
}
if pinned == nil {
if a, perr := s.store.PinnedAnnouncement(r.Context()); perr == nil {
pinned = announceViews([]store.Announcement{*a})[0]
}
}
views := []map[string]any{}
if err == nil {
views = announceViews(list)
}
writeJSON(w, http.StatusOK, map[string]any{"announcement": pinned, "announcements": views})
}
func (s *Server) createAnnouncement(w http.ResponseWriter, r *http.Request) {
a, ok := s.readAnnouncement(w, r)
if !ok {
return
}
a.CreatedBy = currentUser(r).ID
if err := s.store.CreateAnnouncement(r.Context(), &a); err != nil {
writeErr(w, http.StatusInternalServerError, "ثبت اطلاعیه ممکن نشد")
return
}
ids, err := s.store.AllActiveUserIDs(r.Context())
if err != nil {
writeErr(w, http.StatusInternalServerError, "بارگذاری کاربران ممکن نشد")
return
}
go func() {
for _, uid := range ids {
n := &models.Notification{
UserID: uid, Category: a.Category, Type: "announcement",
Title: a.Title, Body: a.Body, Route: a.Route,
}
if err := s.store.CreateNotification(context.Background(), n); err == nil {
_ = s.store.AttachAnnouncement(context.Background(), n.ID, a.ID)
}
}
}()
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "id": a.ID, "recipients": len(ids)})
}
func (s *Server) readAnnouncement(w http.ResponseWriter, r *http.Request) (store.Announcement, bool) {
var req struct {
Title string `json:"title"`
Body string `json:"body"`
Route string `json:"route"`
Category string `json:"category"`
Pinned bool `json:"pinned"`
}
if err := bodyJSON(r, &req); err != nil {
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
return store.Announcement{}, false
}
req.Title = strings.TrimSpace(req.Title)
req.Body = strings.TrimSpace(req.Body)
req.Route = strings.TrimSpace(req.Route)
if req.Category == "" {
req.Category = "event"
}
if !announcementCategories[req.Category] {
writeErr(w, http.StatusBadRequest, "دستهٔ اطلاعیه نامعتبر است")
return store.Announcement{}, false
}
if utf8.RuneCountInString(req.Title) < 3 || utf8.RuneCountInString(req.Title) > 200 {
writeErr(w, http.StatusBadRequest, "عنوان اطلاعیه نامعتبر است")
return store.Announcement{}, false
}
if utf8.RuneCountInString(req.Body) < 8 || utf8.RuneCountInString(req.Body) > 4000 {
writeErr(w, http.StatusBadRequest, "متن اطلاعیه نامعتبر است")
return store.Announcement{}, false
}
if req.Route != "" && !strings.HasPrefix(req.Route, "/") {
writeErr(w, http.StatusBadRequest, "مسیر باید با / شروع شود")
return store.Announcement{}, false
}
return store.Announcement{
Title: req.Title, Body: req.Body, Category: req.Category, Route: req.Route, Pinned: req.Pinned,
}, true
}
func announceViews(list []store.Announcement) []map[string]any {
if list == nil {
return []map[string]any{}
}
out := make([]map[string]any, 0, len(list))
for _, a := range list {
out = append(out, map[string]any{
"id": a.ID, "title": a.Title, "body": a.Body, "category": a.Category,
"route": a.Route, "pinned": a.Pinned, "createdAt": a.CreatedAt, "updatedAt": a.UpdatedAt,
})
}
return out
}
func (s *Server) handleChatPrefs(w http.ResponseWriter, r *http.Request) {
me := currentUser(r)
partner := routeID(r, "partner")
if !s.canChatWith(r.Context(), me, partner) {
writeErr(w, http.StatusForbidden, "شما اجازهٔ گفتگو با این کاربر را ندارید")
return
}
var req struct {
PinnedRank *int `json:"pinnedRank"`
Muted bool `json:"muted"`
Archived bool `json:"archived"`
}
if err := bodyJSON(r, &req); err != nil {
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
return
}
if err := s.store.UpsertChatPref(r.Context(), me.ID, partner, req.PinnedRank, req.Muted, req.Archived); err != nil {
writeErr(w, http.StatusInternalServerError, "ذخیره تنظیم گفتگو ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) handleChatMuteAll(w http.ResponseWriter, r *http.Request) {
var req struct {
Muted bool `json:"muted"`
}
if err := bodyJSON(r, &req); err != nil {
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
return
}
if err := s.store.SetChatMutedAll(r.Context(), currentUser(r).ID, req.Muted); err != nil {
writeErr(w, http.StatusInternalServerError, "ذخیره ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "muted": req.Muted})
}
func (s *Server) handleChatPeek(w http.ResponseWriter, r *http.Request) {
me := currentUser(r)
partner := routeID(r, "partner")
if !s.canChatWith(r.Context(), me, partner) {
writeErr(w, http.StatusForbidden, "شما اجازهٔ گفتگو با این کاربر را ندارید")
return
}
msgs, err := s.store.ListChatMessages(r.Context(), me.ID, partner, 0, 8)
if err != nil {
writeErr(w, http.StatusInternalServerError, "پیش‌نمایش ممکن نشد")
return
}
if len(msgs) > 8 {
msgs = msgs[len(msgs)-8:]
}
writeJSON(w, http.StatusOK, map[string]any{"messages": msgs})
}
func (s *Server) handleChatUnread(w http.ResponseWriter, r *http.Request) {
me := currentUser(r)
partner := routeID(r, "partner")
if err := s.store.MarkChatUnread(r.Context(), me.ID, partner, me.Role.String()); err != nil {
writeErr(w, http.StatusInternalServerError, "بازگشت به نخوانده ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) handleChatDeleteBoth(w http.ResponseWriter, r *http.Request) {
me := currentUser(r)
partner := routeID(r, "partner")
if r.URL.Query().Get("confirm") != "both" {
writeErr(w, http.StatusBadRequest, "برای پاک کردن دو طرفه confirm=both لازم است")
return
}
if !s.canChatWith(r.Context(), me, partner) && me.Role != models.RoleAdmin {
writeErr(w, http.StatusForbidden, "اجازه ندارید")
return
}
if err := s.store.DeleteChatBoth(r.Context(), me.ID, partner); err != nil {
writeErr(w, http.StatusInternalServerError, "حذف گفتگو ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) handleChatBroadcast(w http.ResponseWriter, r *http.Request) {
me := currentUser(r)
if !me.Role.IsStaff() {
writeErr(w, http.StatusForbidden, "فقط مسئول دوره می‌تواند پیام همگانی بفرستد")
return
}
var req struct {
Body string `json:"body"`
UserIDs []int64 `json:"userIds"`
All bool `json:"all"`
}
if err := bodyJSON(r, &req); err != nil || strings.TrimSpace(req.Body) == "" {
writeErr(w, http.StatusBadRequest, "متن پیام لازم است")
return
}
ids := req.UserIDs
if req.All {
var err error
ids, err = s.store.AllActiveUserIDs(r.Context())
if err != nil {
writeErr(w, http.StatusInternalServerError, "بارگذاری کاربران ممکن نشد")
return
}
}
sent := 0
for _, id := range ids {
if id == me.ID || !s.canChatWith(r.Context(), me, id) {
continue
}
studentID, mentorID := id, me.ID
if me.Role == models.RoleStudent {
studentID, mentorID = me.ID, id
}
if _, err := s.store.AddChatMessage(r.Context(), studentID, mentorID, me.Role, strings.TrimSpace(req.Body), nil, nil); err == nil {
sent++
}
}
writeJSON(w, http.StatusOK, map[string]any{"sent": sent})
}
func (s *Server) handleSaveMessage(w http.ResponseWriter, r *http.Request) {
var req struct {
Body string `json:"body"`
AttachmentURL string `json:"attachmentUrl"`
AttachmentType string `json:"attachmentType"`
AttachmentName string `json:"attachmentName"`
}
if err := bodyJSON(r, &req); err != nil {
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
return
}
if strings.TrimSpace(req.Body) == "" && req.AttachmentURL == "" {
writeErr(w, http.StatusBadRequest, "پیامی برای ذخیره نیست")
return
}
me := currentUser(r)
var att *models.Attachment
if req.AttachmentURL != "" {
url := req.AttachmentURL
if q := strings.Index(url, "?"); q >= 0 {
url = url[:q]
}
att = &models.Attachment{Type: req.AttachmentType, URL: url, Name: req.AttachmentName}
}
var saved []models.Attachment
if att != nil {
saved = []models.Attachment{*att}
}
if _, err := s.store.AddChatMessage(r.Context(), me.ID, me.ID, me.Role, strings.TrimSpace(req.Body), nil, saved); err != nil {
writeErr(w, http.StatusInternalServerError, "ذخیره ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) handleListSaved(w http.ResponseWriter, r *http.Request) {
list, err := s.store.ListSaved(r.Context(), currentUser(r).ID)
if err != nil {
writeErr(w, http.StatusInternalServerError, "بارگذاری ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"messages": list})
}
func (s *Server) handleGetMeProfile(w http.ResponseWriter, r *http.Request) {
p, err := s.store.GetProfileExtras(r.Context(), currentUser(r).ID)
if err != nil {
writeErr(w, http.StatusInternalServerError, "بارگذاری پروفایل ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"profile": s.profileView(p)})
}
func (s *Server) handlePutMeProfile(w http.ResponseWriter, r *http.Request) {
var req struct {
Banner *string `json:"banner"`
Public *bool `json:"public"`
ShowActivity *bool `json:"showActivity"`
ShowStats *bool `json:"showStats"`
ShowPath *bool `json:"showPath"`
ShowBadges *bool `json:"showBadges"`
SocialLinks json.RawMessage `json:"socialLinks"`
Theme *string `json:"theme"`
}
if err := bodyJSON(r, &req); err != nil {
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
return
}
cur, err := s.store.GetProfileExtras(r.Context(), currentUser(r).ID)
if err != nil {
writeErr(w, http.StatusInternalServerError, "بارگذاری پروفایل ممکن نشد")
return
}
if req.Banner != nil {
cur.Banner = strings.TrimSpace(*req.Banner)
}
if req.Public != nil {
cur.Public = *req.Public
}
if req.ShowActivity != nil {
cur.ShowActivity = *req.ShowActivity
}
if req.ShowStats != nil {
cur.ShowStats = *req.ShowStats
}
if req.ShowPath != nil {
cur.ShowPath = *req.ShowPath
}
if req.ShowBadges != nil {
cur.ShowBadges = *req.ShowBadges
}
if req.Theme != nil {
cur.Theme = strings.TrimSpace(*req.Theme)
}
if len(req.SocialLinks) > 0 {
var links []map[string]string
if err := json.Unmarshal(req.SocialLinks, &links); err != nil || len(links) > 5 {
writeErr(w, http.StatusBadRequest, "حداکثر ۵ لینک شخصی مجاز است")
return
}
cur.SocialLinks = req.SocialLinks
}
if i := strings.Index(cur.Banner, "?"); i >= 0 {
cur.Banner = cur.Banner[:i]
}
if err := s.store.SaveProfileExtras(r.Context(), currentUser(r).ID, cur); err != nil {
writeErr(w, http.StatusInternalServerError, "ذخیره پروفایل ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"profile": s.profileView(cur)})
}
func (s *Server) profileView(p store.ProfileExtras) map[string]any {
links := json.RawMessage(p.SocialLinks)
if len(links) == 0 {
links = json.RawMessage(`[]`)
}
banner := p.Banner
if i := strings.Index(banner, "?"); i >= 0 {
banner = banner[:i]
}
return map[string]any{
"banner": s.signExistingMediaURL(banner), "public": p.Public, "showActivity": p.ShowActivity,
"showStats": p.ShowStats, "showPath": p.ShowPath, "showBadges": p.ShowBadges,
"socialLinks": json.RawMessage(links), "theme": p.Theme,
}
}
func (s *Server) handleMeAchievements(w http.ResponseWriter, r *http.Request) {
u := currentUser(r)
writeJSON(w, http.StatusOK, map[string]any{"achievements": s.achievementsFor(r.Context(), u)})
}
func (s *Server) achievementsFor(ctx context.Context, u *models.User) []map[string]any {
passed := 0
_ = s.store.Pool().QueryRow(ctx, `SELECT count(*) FROM lesson_progress WHERE user_id=$1 AND passed_quiz`, u.ID).Scan(&passed)
certs := 0
_ = s.store.Pool().QueryRow(ctx, `SELECT count(*) FROM certificates WHERE user_id=$1 AND revoked_at IS NULL`, u.ID).Scan(&certs)
items := []struct {
id, title string
on bool
}{
{"first_pass", "اولین قبولی", passed > 0},
{"chapter_done", "اتمام فصل", s.finishedChapter(ctx, u.ID)},
{"streak_7", "استمرار هفت‌روزه", u.StreakCurrent >= 7 || u.StreakLongest >= 7},
{"course_done", "پایان دوره", certs > 0},
}
out := make([]map[string]any, 0, len(items))
for _, it := range items {
out = append(out, map[string]any{"id": it.id, "title": it.title, "unlocked": it.on})
}
return out
}
func (s *Server) finishedChapter(ctx context.Context, userID int64) bool {
var n int
err := s.store.Pool().QueryRow(ctx, `
SELECT count(*) FROM chapters c
WHERE EXISTS (SELECT 1 FROM lessons l WHERE l.chapter_id=c.id)
AND NOT EXISTS (
SELECT 1 FROM lessons l
LEFT JOIN lesson_progress p ON p.lesson_id=l.id AND p.user_id=$1
WHERE l.chapter_id=c.id AND COALESCE(p.passed_quiz, false)=false
)`, userID).Scan(&n)
return err == nil && n > 0
}
func (s *Server) handleMeActivity(w http.ResponseWriter, r *http.Request) {
rows, err := s.store.Pool().Query(r.Context(), `
SELECT day::text, count(*) FROM (
SELECT created_at::date AS day FROM quiz_attempts WHERE user_id=$1
UNION ALL
SELECT created_at::date FROM chat_messages WHERE user_id=$1 OR mentor_id=$1
) d
WHERE day >= current_date - 120
GROUP BY day ORDER BY day`, currentUser(r).ID)
if err != nil {
writeErr(w, http.StatusInternalServerError, "بارگذاری فعالیت ممکن نشد")
return
}
defer rows.Close()
var days []map[string]any
for rows.Next() {
var day string
var n int
if err := rows.Scan(&day, &n); err != nil {
continue
}
days = append(days, map[string]any{"day": day, "count": n})
}
if days == nil {
days = []map[string]any{}
}
week, month := 0, 0
_ = s.store.Pool().QueryRow(r.Context(), `
SELECT
count(*) FILTER (WHERE day >= current_date - 6),
count(*) FILTER (WHERE day >= current_date - 29)
FROM (
SELECT DISTINCT created_at::date AS day FROM quiz_attempts WHERE user_id=$1
UNION
SELECT DISTINCT created_at::date FROM chat_messages WHERE user_id=$1 OR mentor_id=$1
) d`, currentUser(r).ID).Scan(&week, &month)
writeJSON(w, http.StatusOK, map[string]any{"days": days, "weekDays": week, "monthDays": month})
}
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
if utf8.RuneCountInString(q) < 2 {
writeJSON(w, http.StatusOK, map[string]any{"results": []any{}})
return
}
me := currentUser(r)
like := "%" + q + "%"
var results []map[string]any
rows, err := s.store.Pool().Query(r.Context(), `
SELECT id, title FROM lessons WHERE title ILIKE $1 ORDER BY id LIMIT 8`, like)
if err == nil {
for rows.Next() {
var id int64
var title string
if rows.Scan(&id, &title) == nil {
results = append(results, map[string]any{"kind": "lesson", "title": title, "href": "/player/" + strconv.FormatInt(id, 10)})
}
}
rows.Close()
}
rows, err = s.store.Pool().Query(r.Context(), `
SELECT id, title FROM events WHERE title ILIKE $1 ORDER BY starts_at DESC LIMIT 8`, like)
if err == nil {
for rows.Next() {
var id int64
var title string
if rows.Scan(&id, &title) == nil {
results = append(results, map[string]any{"kind": "event", "title": title, "href": "/unwrap"})
}
}
rows.Close()
}
if me.Role.IsStaff() {
people, _, _ := s.store.ListUsers(r.Context(), q, "", 8, 0)
for _, p := range people {
results = append(results, map[string]any{"kind": "person", "title": p.Name, "href": "/profile/" + strconv.FormatInt(p.ID, 10)})
}
} else {
people, _, _ := s.store.ListActiveByRoles(r.Context(), []string{"mentor", "admin"}, 40, 0)
n := 0
for _, p := range people {
if n >= 8 || !strings.Contains(strings.ToLower(p.Name), strings.ToLower(q)) {
continue
}
results = append(results, map[string]any{"kind": "person", "title": p.Name, "href": "/profile/" + strconv.FormatInt(p.ID, 10)})
n++
}
}
pages := []struct{ title, href string }{
{"درخت مهارت", "/cap"}, {"اعلان‌ها", "/notifications"}, {"لیگ", "/leaderboard"},
{"تنظیمات", "/settings"}, {"پروفایل", "/profile"},
}
for _, p := range pages {
if strings.Contains(p.title, q) {
results = append(results, map[string]any{"kind": "page", "title": p.title, "href": p.href})
}
}
if results == nil {
results = []map[string]any{}
}
writeJSON(w, http.StatusOK, map[string]any{"results": results})
}
func (s *Server) handleMeta(w http.ResponseWriter, r *http.Request) {
env := "production"
if s.cfg.DevMode {
env = "development"
}
version, built := appBuild()
writeJSON(w, http.StatusOK, map[string]any{
"version": version,
"built": built,
"env": env,
"health": "ok",
})
}
func appBuild() (version, built string) {
version = "0.2.1"
built = "dev"
info, ok := debug.ReadBuildInfo()
if !ok {
return version, built
}
for _, setting := range info.Settings {
if setting.Key == "vcs.time" && len(setting.Value) >= 10 {
built = setting.Value[:10]
}
if setting.Key == "vcs.revision" && len(setting.Value) >= 7 {
version = "0.2.1+" + setting.Value[:7]
}
}
return version, built
}
func (s *Server) handleAdminListBots(w http.ResponseWriter, r *http.Request) {
p := parsePageParams(r)
p.PageSize = 8
p.Offset = (p.Page - 1) * p.PageSize
var total int64
if err := s.store.Pool().QueryRow(r.Context(), `SELECT count(*) FROM bot_tokens`).Scan(&total); err != nil {
writeErr(w, http.StatusInternalServerError, "بارگذاری کلیدها ممکن نشد")
return
}
rows, err := s.store.Pool().Query(r.Context(), `
SELECT bt.id, bt.bot_user_id, COALESCE(u.name, ''), bt.created_at, bt.revoked_at IS NULL, bt.request_count
FROM bot_tokens bt
JOIN users u ON u.id = bt.bot_user_id
ORDER BY bt.id DESC
LIMIT $1 OFFSET $2`, p.PageSize, p.Offset)
if err != nil {
writeErr(w, http.StatusInternalServerError, "بارگذاری کلیدها ممکن نشد")
return
}
defer rows.Close()
items := make([]map[string]any, 0)
for rows.Next() {
var id, botID, requestCount int64
var name string
var createdAt time.Time
var active bool
if rows.Scan(&id, &botID, &name, &createdAt, &active, &requestCount) != nil {
continue
}
items = append(items, map[string]any{
"id": id,
"botId": botID,
"name": name,
"createdAt": createdAt,
"active": active,
"requestCount": requestCount,
})
}
writePage(w, items, total, p)
}
func (s *Server) handleAdminSetBotActive(w http.ResponseWriter, r *http.Request) {
id := routeID(r, "id")
if id == 0 {
writeErr(w, http.StatusBadRequest, "شناسه نامعتبر است")
return
}
var req struct {
Active bool `json:"active"`
}
if err := bodyJSON(r, &req); err != nil {
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
return
}
var err error
var updated int64
if req.Active {
tag, execErr := s.store.Pool().Exec(r.Context(), `UPDATE bot_tokens SET revoked_at=NULL WHERE id=$1`, id)
err = execErr
updated = tag.RowsAffected()
} else {
tag, execErr := s.store.Pool().Exec(r.Context(), `UPDATE bot_tokens SET revoked_at=COALESCE(revoked_at, now()) WHERE id=$1`, id)
err = execErr
updated = tag.RowsAffected()
}
if err != nil {
writeErr(w, http.StatusInternalServerError, "به‌روزرسانی ممکن نشد")
return
}
if updated == 0 {
writeErr(w, http.StatusNotFound, "کلید پیدا نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "active": req.Active})
}
func (s *Server) handleCreateBot(w http.ResponseWriter, r *http.Request) {
me := currentUser(r)
var req struct {
Name string `json:"name"`
}
_ = bodyJSON(r, &req)
name := strings.TrimSpace(req.Name)
if name == "" {
name = "ربات پرگار"
}
raw := make([]byte, 24)
_, _ = rand.Read(raw)
token := hex.EncodeToString(raw)
sum := sha256.Sum256([]byte(token))
hash := hex.EncodeToString(sum[:])
email := "bot-" + token[:8] + "@bots.pargar.local"
bot, err := s.store.CreateUser(r.Context(), name, email, "bot"+token[:6], hash, "", "", "", models.RoleMentor)
if err != nil {
writeErr(w, http.StatusInternalServerError, "ساخت ربات ممکن نشد")
return
}
if _, err := s.store.Pool().Exec(r.Context(), `
INSERT INTO bot_tokens (bot_user_id, owner_id, token_hash) VALUES ($1,$2,$3)`, bot.ID, me.ID, hash); err != nil {
writeErr(w, http.StatusInternalServerError, "ثبت توکن ممکن نشد")
return
}
writeJSON(w, http.StatusCreated, map[string]any{"botId": bot.ID, "token": token})
}
func (s *Server) botFromRequest(r *http.Request) (int64, int64, bool) {
token := strings.TrimSpace(r.Header.Get("X-Bot-Token"))
if token == "" {
return 0, 0, false
}
sum := sha256.Sum256([]byte(token))
hash := hex.EncodeToString(sum[:])
var botID, ownerID int64
var active bool
err := s.store.Pool().QueryRow(r.Context(), `
SELECT bot_user_id, owner_id, revoked_at IS NULL FROM bot_tokens WHERE token_hash=$1`, hash).Scan(&botID, &ownerID, &active)
if err != nil || !active {
return 0, 0, false
}
_, _ = s.store.Pool().Exec(r.Context(), `
UPDATE bot_tokens SET request_count = request_count + 1 WHERE token_hash=$1 AND revoked_at IS NULL`, hash)
return botID, ownerID, true
}
func (s *Server) handleBotSend(w http.ResponseWriter, r *http.Request) {
botID, _, ok := s.botFromRequest(r)
if !ok {
writeErr(w, http.StatusUnauthorized, "توکن ربات نامعتبر است")
return
}
var req struct {
UserID int64 `json:"userId"`
Text string `json:"text"`
Buttons []any `json:"buttons"`
}
if err := bodyJSON(r, &req); err != nil || req.UserID == 0 || strings.TrimSpace(req.Text) == "" {
writeErr(w, http.StatusBadRequest, "userId و text لازم است")
return
}
buttons, _ := json.Marshal(req.Buttons)
var id int64
err := s.store.Pool().QueryRow(r.Context(), `
INSERT INTO chat_messages (user_id, mentor_id, sender_role, body, buttons)
VALUES ($1,$2,'mentor',$3,$4) RETURNING id`,
req.UserID, botID, strings.TrimSpace(req.Text), buttons).Scan(&id)
if err != nil {
writeErr(w, http.StatusInternalServerError, "ارسال ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"messageId": id})
}
func (s *Server) handleBotUpdates(w http.ResponseWriter, r *http.Request) {
botID, _, ok := s.botFromRequest(r)
if !ok {
writeErr(w, http.StatusUnauthorized, "توکن ربات نامعتبر است")
return
}
after, _ := strconv.ParseInt(r.URL.Query().Get("offset"), 10, 64)
rows, err := s.store.Pool().Query(r.Context(), `
SELECT id, payload, created_at FROM bot_updates WHERE bot_user_id=$1 AND id>$2 ORDER BY id LIMIT 50`, botID, after)
if err != nil {
writeErr(w, http.StatusInternalServerError, "بارگذاری ممکن نشد")
return
}
defer rows.Close()
var updates []map[string]any
for rows.Next() {
var id int64
var payload json.RawMessage
var at time.Time
if rows.Scan(&id, &payload, &at) == nil {
updates = append(updates, map[string]any{"id": id, "payload": json.RawMessage(payload), "createdAt": at})
}
}
if updates == nil {
updates = []map[string]any{}
}
writeJSON(w, http.StatusOK, map[string]any{"updates": updates})
}
func (s *Server) recordBotReply(ctx context.Context, from *models.User, partnerID int64, body string) {
var ownerID int64
err := s.store.Pool().QueryRow(ctx, `SELECT owner_id FROM bot_tokens WHERE bot_user_id=$1`, partnerID).Scan(&ownerID)
if err != nil {
return
}
payload, _ := json.Marshal(map[string]any{"from": from.ID, "name": from.Name, "text": body})
_, _ = s.store.Pool().Exec(ctx, `INSERT INTO bot_updates (bot_user_id, payload) VALUES ($1,$2)`, partnerID, payload)
_ = ownerID
}
func (s *Server) noteBotReply(r *http.Request, partner int64, body string) {
s.recordBotReply(r.Context(), currentUser(r), partner, body)
}
// silence unused import if pgx is only used by callers
var _ = pgx.ErrNoRows
+458
View File
@@ -0,0 +1,458 @@
package api_test
import (
"context"
"encoding/json"
"net/http"
"strings"
"testing"
"time"
)
func TestAnnouncementRecordEditDeleteAndPin(t *testing.T) {
e := setup(t)
admin := login(t, e, bootstrapAdminEmail)
student, _ := register(t, e, "Ann", "ann@test.dev")
code, created := e.do(t, "POST", "/api/admin/announce", admin, map[string]any{
"title": "اطلاعیه تازه", "body": "متن **مارک‌داون** برای همه.", "category": "urgent", "route": "", "pinned": true,
})
if code != http.StatusOK {
t.Fatalf("create: %d %v", code, created)
}
id := int64(created["id"].(float64))
code, list := e.do(t, "GET", "/api/admin/announcements", admin, nil)
if code != http.StatusOK || len(list["announcements"].([]any)) == 0 {
t.Fatalf("list: %d %v", code, list)
}
code, pin := e.do(t, "GET", "/api/announcements/pinned", student, nil)
if code != http.StatusOK {
t.Fatalf("pin: %d %v", code, pin)
}
item := pin["announcement"].(map[string]any)
if item["title"] != "اطلاعیه تازه" {
t.Fatalf("pinned title: %v", item)
}
code, second := e.do(t, "POST", "/api/admin/announce", admin, map[string]any{
"title": "اطلاعیه دوم", "body": "متن دوم برای اسلاید.", "category": "event", "route": "", "pinned": false,
})
if code != http.StatusOK {
t.Fatalf("second: %d %v", code, second)
}
code, slidesRes := e.do(t, "GET", "/api/announcements/pinned", student, nil)
slides, _ := slidesRes["announcements"].([]any)
if code != http.StatusOK || len(slides) < 2 {
t.Fatalf("slides: %d %v", code, slidesRes["announcements"])
}
code, _ = e.do(t, "PUT", "/api/admin/announcements/"+itoa(id), admin, map[string]any{
"title": "اطلاعیه ویرایش‌شده", "body": "متن ویرایش‌شده برای رونوشت.", "category": "curriculum", "route": "/cap", "pinned": true,
})
if code != http.StatusOK {
t.Fatalf("update: %d", code)
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
code, pin = e.do(t, "GET", "/api/announcements/pinned", student, nil)
if code == http.StatusOK {
if got, _ := pin["announcement"].(map[string]any); got != nil && got["title"] == "اطلاعیه ویرایش‌شده" {
break
}
}
time.Sleep(50 * time.Millisecond)
}
code, _ = e.do(t, "DELETE", "/api/admin/announcements/"+itoa(id), admin, nil)
if code != http.StatusOK {
t.Fatalf("delete: %d", code)
}
}
func TestHeartsRegenerateAndDoNotLock(t *testing.T) {
e := setup(t)
token, uid := register(t, e, "Heart", "heart@test.dev")
admin := login(t, e, bootstrapAdminEmail)
code, _ := e.do(t, "PUT", "/api/admin/users/"+itoa(uid), admin, map[string]any{"hearts": 0})
if code != http.StatusOK {
t.Fatalf("set hearts: %d", code)
}
if _, err := e.pool.Exec(context.Background(), `UPDATE users SET hearts_updated_at = now() - interval '5 hours' WHERE id=$1`, uid); err != nil {
t.Fatal(err)
}
code, me := e.do(t, "GET", "/api/auth/me", token, nil)
if code != http.StatusOK {
t.Fatalf("me: %d", code)
}
user := me["user"].(map[string]any)
if user["hearts"].(float64) < 1 {
t.Fatalf("expected a returned heart, got %v", user["hearts"])
}
if user["isLocked"] == true {
t.Fatal("zero hearts must not lock by default")
}
code, _ = e.do(t, "PUT", "/api/admin/settings", admin, map[string]any{
"settings": map[string]string{"hearts_max": "5", "hearts_regen_minutes": "240", "hearts_lock_on_empty": "false"},
})
if code != http.StatusOK {
t.Fatalf("settings: %d", code)
}
}
func TestProfilePrivateByDefault(t *testing.T) {
e := setup(t)
viewer, _ := register(t, e, "Viewer", "viewer@test.dev")
_, other := register(t, e, "Hidden", "hidden@test.dev")
code, body := e.do(t, "GET", "/api/users/"+itoa(other), viewer, nil)
if code != http.StatusOK {
t.Fatalf("profile: %d %v", code, body)
}
user := body["user"].(map[string]any)
if user["private"] != true {
t.Fatalf("expected private profile, got %v", user)
}
if _, ok := user["xp"]; ok {
t.Fatalf("private profile leaked xp: %v", user)
}
}
func TestMentorDirectoryUsesRealRoles(t *testing.T) {
e := setup(t)
admin := login(t, e, bootstrapAdminEmail)
_, uid := register(t, e, "StudentRow", "row@test.dev")
code, body := e.do(t, "GET", "/api/mentors?page=1&pageSize=50", admin, nil)
if code != http.StatusOK {
t.Fatalf("mentors: %d %v", code, body)
}
found := false
for _, raw := range body["mentors"].([]any) {
row := raw.(map[string]any)
if int64(row["id"].(float64)) == uid {
found = true
if row["role"] != "student" {
t.Fatalf("role: %v", row["role"])
}
}
}
if !found {
t.Fatal("student missing from staff directory")
}
}
func TestSavedMessageBecomesSelfChat(t *testing.T) {
e := setup(t)
token, uid := register(t, e, "Saver", "saver@test.dev")
code, body := e.do(t, "POST", "/api/me/saved", token, map[string]any{"body": "برای بعد"})
if code != http.StatusOK {
t.Fatalf("save: %d %v", code, body)
}
code, body = e.do(t, "GET", "/api/chats/"+itoa(uid)+"/messages", token, nil)
if code != http.StatusOK {
t.Fatalf("self chat: %d %v", code, body)
}
msgs, _ := body["messages"].([]any)
if len(msgs) == 0 {
t.Fatal("saved text missing from self chat")
}
last := msgs[len(msgs)-1].(map[string]any)
if last["body"] != "برای بعد" {
t.Fatalf("body: %v", last["body"])
}
}
func TestBroadcastCanTargetSomePeople(t *testing.T) {
e := setup(t)
admin := login(t, e, bootstrapAdminEmail)
student, sid := register(t, e, "One", "one@test.dev")
code, me := e.do(t, "GET", "/api/auth/me", admin, nil)
if code != http.StatusOK {
t.Fatal(code)
}
adminID := int64(me["user"].(map[string]any)["id"].(float64))
code, body := e.do(t, "POST", "/api/chats/broadcast", admin, map[string]any{
"body": "فقط برای تو", "userIds": []int64{sid},
})
if code != http.StatusOK || body["sent"].(float64) != 1 {
t.Fatalf("broadcast: %d %v", code, body)
}
code, thread := e.do(t, "GET", "/api/chats/"+itoa(adminID)+"/messages", student, nil)
if code != http.StatusOK {
t.Fatalf("thread: %d %v", code, thread)
}
found := false
for _, raw := range thread["messages"].([]any) {
if raw.(map[string]any)["body"] == "فقط برای تو" {
found = true
}
}
if !found {
t.Fatal("targeted broadcast missing")
}
}
func TestChatPinAndBotReply(t *testing.T) {
e := setup(t)
admin := login(t, e, bootstrapAdminEmail)
student, sid := register(t, e, "Chatty", "chatty@test.dev")
code, _ := e.do(t, "PUT", "/api/chats/"+itoa(sid)+"/prefs", admin, map[string]any{
"pinnedRank": 1, "muted": true, "archived": false,
})
if code != http.StatusOK {
t.Fatalf("prefs: %d", code)
}
code, conv := e.do(t, "GET", "/api/chats/conversations?page=1&pageSize=20", admin, nil)
if code != http.StatusOK {
t.Fatalf("conversations: %d %v", code, conv)
}
code, bot := e.do(t, "POST", "/api/admin/bots", admin, map[string]any{"name": "ربات آزمون"})
if code != http.StatusCreated {
t.Fatalf("bot: %d %v", code, bot)
}
token := bot["token"].(string)
botID := int64(bot["botId"].(float64))
code, sent := e.doHeaders(t, "POST", "/api/bot/send", "", map[string]string{"X-Bot-Token": token}, map[string]any{
"userId": sid, "text": "سلام از ربات", "buttons": []any{map[string]string{"text": "باشه"}},
})
if code != http.StatusOK {
t.Fatalf("bot send: %d %v", code, sent)
}
code, _ = e.do(t, "POST", "/api/chats/"+itoa(botID)+"/messages", student, map[string]any{"body": "جواب هنرجو"})
if code != http.StatusOK && code != http.StatusCreated {
t.Fatalf("reply: %d", code)
}
code, updates := e.doHeaders(t, "GET", "/api/bot/updates", "", map[string]string{"X-Bot-Token": token}, nil)
if code != http.StatusOK || len(updates["updates"].([]any)) == 0 {
t.Fatalf("updates: %d %v", code, updates)
}
}
func TestLastSeenAndPinnedReorder(t *testing.T) {
e := setup(t)
admin := login(t, e, bootstrapAdminEmail)
_, alpha := register(t, e, "آلفا", "alpha-pin@test.dev")
_, beta := register(t, e, "بتا", "beta-pin@test.dev")
for _, id := range []int64{alpha, beta} {
code, body := e.do(t, "POST", "/api/chats/"+itoa(id)+"/messages", admin, map[string]any{"body": "سلام"})
if code != http.StatusCreated && code != http.StatusOK {
t.Fatalf("message %d: %d %v", id, code, body)
}
}
code, _ := e.do(t, "PUT", "/api/chats/"+itoa(alpha)+"/prefs", admin, map[string]any{
"pinnedRank": 0, "muted": false, "archived": false,
})
if code != http.StatusOK {
t.Fatalf("pin alpha: %d", code)
}
code, _ = e.do(t, "PUT", "/api/chats/"+itoa(beta)+"/prefs", admin, map[string]any{
"pinnedRank": 1, "muted": false, "archived": false,
})
if code != http.StatusOK {
t.Fatalf("pin beta: %d", code)
}
code, _ = e.do(t, "PUT", "/api/chats/"+itoa(alpha)+"/prefs", admin, map[string]any{
"pinnedRank": 1, "muted": false, "archived": false,
})
if code != http.StatusOK {
t.Fatalf("swap alpha: %d", code)
}
code, _ = e.do(t, "PUT", "/api/chats/"+itoa(beta)+"/prefs", admin, map[string]any{
"pinnedRank": 0, "muted": false, "archived": false,
})
if code != http.StatusOK {
t.Fatalf("swap beta: %d", code)
}
code, conv := e.do(t, "GET", "/api/chats/conversations", admin, nil)
if code != http.StatusOK {
t.Fatalf("conversations: %d %v", code, conv)
}
var pinned []float64
for _, raw := range conv["conversations"].([]any) {
row := raw.(map[string]any)
partner := row["partner"].(map[string]any)
id := partner["id"].(float64)
if id != float64(alpha) && id != float64(beta) {
continue
}
rank, ok := row["pinnedRank"].(float64)
if !ok {
t.Fatalf("missing pinnedRank for %v", id)
}
pinned = append(pinned, id)
if id == float64(beta) && rank != 0 {
t.Fatalf("beta rank: %v", rank)
}
if id == float64(alpha) && rank != 1 {
t.Fatalf("alpha rank: %v", rank)
}
}
if len(pinned) != 2 || pinned[0] != float64(beta) {
t.Fatalf("pin order: %v", pinned)
}
seenAt := time.Now().Add(-2 * time.Hour).UTC()
if _, err := e.pool.Exec(context.Background(), `UPDATE users SET last_seen_at=$2 WHERE id=$1`, alpha, seenAt); err != nil {
t.Fatalf("last seen: %v", err)
}
code, people := e.do(t, "GET", "/api/mentors?page=1&pageSize=50", admin, nil)
if code != http.StatusOK {
t.Fatalf("mentors: %d %v", code, people)
}
var found bool
for _, raw := range people["mentors"].([]any) {
person := raw.(map[string]any)
if person["id"].(float64) != float64(alpha) {
continue
}
found = true
stamp, _ := person["lastSeen"].(string)
if stamp == "" {
t.Fatalf("lastSeen missing: %v", person)
}
parsed, err := time.Parse(time.RFC3339, stamp)
if err != nil {
parsed, err = time.Parse(time.RFC3339Nano, stamp)
}
if err != nil || parsed.Sub(seenAt).Abs() > time.Minute {
t.Fatalf("lastSeen %q: %v", stamp, err)
}
}
if !found {
t.Fatal("alpha missing from mentors")
}
}
func TestBotAPIKeys(t *testing.T) {
e := setup(t)
admin := login(t, e, bootstrapAdminEmail)
_, sid := register(t, e, "BotPeer", "bot-peer@test.dev")
code, created := e.do(t, "POST", "/api/admin/bots", admin, map[string]any{"name": "تست"})
if code != http.StatusCreated {
t.Fatalf("create: %d", code)
}
secret, _ := created["token"].(string)
if len(secret) < 32 {
t.Fatal("create did not return a secret")
}
if created["botId"] == nil {
t.Fatal("botId missing")
}
botUserID := created["botId"].(float64)
code, people := e.do(t, "GET", "/api/mentors?page=1&pageSize=50", admin, nil)
if code != http.StatusOK {
t.Fatalf("mentors: %d %v", code, people["error"])
}
for _, raw := range people["mentors"].([]any) {
person := raw.(map[string]any)
if person["id"].(float64) == botUserID {
t.Fatal("bot appeared in the mentor directory")
}
}
code, list := e.do(t, "GET", "/api/admin/bots?page=1&pageSize=8", admin, nil)
if code != http.StatusOK {
t.Fatalf("list: %d", code)
}
if list["pageSize"] != float64(8) {
t.Fatalf("pageSize: %v", list["pageSize"])
}
assertBotListHidesSecret(t, list, secret)
item := findBotItem(t, list, "تست")
createdAt, _ := item["createdAt"].(string)
if item["active"] != true || item["requestCount"] != float64(0) || createdAt == "" {
t.Fatalf("row shape: active=%v count=%v hasCreated=%v", item["active"], item["requestCount"], createdAt != "")
}
id := int64(item["id"].(float64))
code, _ = e.doHeaders(t, "POST", "/api/bot/send", "", map[string]string{"X-Bot-Token": "not-a-real-token"}, map[string]any{
"userId": sid, "text": "نباید بشمرد",
})
if code != http.StatusUnauthorized {
t.Fatalf("bad token: %d", code)
}
code, sent := e.doHeaders(t, "POST", "/api/bot/send", "", map[string]string{"X-Bot-Token": secret}, map[string]any{
"userId": sid, "text": "سلام از ربات", "buttons": []any{map[string]string{"text": "باشه"}},
})
if code != http.StatusOK {
t.Fatalf("send: %d %v", code, sent["error"])
}
code, list = e.do(t, "GET", "/api/admin/bots?page=1&pageSize=8", admin, nil)
if code != http.StatusOK {
t.Fatalf("list after send: %d", code)
}
assertBotListHidesSecret(t, list, secret)
item = findBotItem(t, list, "تست")
if item["requestCount"] != float64(1) {
t.Fatalf("requestCount after send: %v", item["requestCount"])
}
code, updates := e.doHeaders(t, "GET", "/api/bot/updates", "", map[string]string{"X-Bot-Token": secret}, nil)
if code != http.StatusOK {
t.Fatalf("updates: %d %v", code, updates["error"])
}
code, list = e.do(t, "GET", "/api/admin/bots?page=1&pageSize=8", admin, nil)
item = findBotItem(t, list, "تست")
if item["requestCount"] != float64(2) {
t.Fatalf("requestCount after updates: %v", item["requestCount"])
}
code, stopped := e.do(t, "PUT", "/api/admin/bots/"+itoa(id), admin, map[string]any{"active": false})
if code != http.StatusOK || stopped["active"] != false {
t.Fatalf("stop: %d %v", code, stopped["error"])
}
code, _ = e.doHeaders(t, "POST", "/api/bot/send", "", map[string]string{"X-Bot-Token": secret}, map[string]any{
"userId": sid, "text": "بعد از توقف",
})
if code != http.StatusUnauthorized {
t.Fatalf("stopped send: %d", code)
}
code, _ = e.doHeaders(t, "GET", "/api/bot/updates", "", map[string]string{"X-Bot-Token": secret}, nil)
if code != http.StatusUnauthorized {
t.Fatalf("stopped updates: %d", code)
}
code, list = e.do(t, "GET", "/api/admin/bots?page=1&pageSize=8", admin, nil)
assertBotListHidesSecret(t, list, secret)
item = findBotItem(t, list, "تست")
if item["active"] != false || item["requestCount"] != float64(2) {
t.Fatalf("after stop: active=%v count=%v", item["active"], item["requestCount"])
}
}
func findBotItem(t *testing.T, list map[string]any, name string) map[string]any {
t.Helper()
items, _ := list["items"].([]any)
for _, raw := range items {
row, _ := raw.(map[string]any)
if row["name"] == name {
return row
}
}
t.Fatalf("missing bot %s", name)
return nil
}
func assertBotListHidesSecret(t *testing.T, list map[string]any, secret string) {
t.Helper()
raw, err := json.Marshal(list)
if err != nil {
t.Fatal(err)
}
if secret != "" && strings.Contains(string(raw), secret) {
t.Fatal("list included the bot secret")
}
var walk func(any)
walk = func(v any) {
switch x := v.(type) {
case map[string]any:
for k, child := range x {
switch k {
case "token", "tokenHash", "token_hash", "secret":
t.Fatalf("unexpected field %s", k)
}
walk(child)
}
case []any:
for _, child := range x {
walk(child)
}
}
}
walk(list)
}
+37 -12
View File
@@ -1,6 +1,7 @@
package api package api
import ( import (
"encoding/json"
"errors" "errors"
"net/http" "net/http"
@@ -22,21 +23,45 @@ func (s *Server) handlePublicProfile(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusInternalServerError, "بارگذاری پروفایل ممکن نشد") writeErr(w, http.StatusInternalServerError, "بارگذاری پروفایل ممکن نشد")
return return
} }
u = s.settleAccount(r.Context(), u)
s.signUserMedia(u) s.signUserMedia(u)
me := currentUser(r)
extras, err := s.store.GetProfileExtras(r.Context(), u.ID)
if err != nil {
extras = store.ProfileExtras{}
}
full := me.ID == u.ID || me.Role.IsStaff()
if !full && !extras.Public {
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"user": map[string]any{ "user": map[string]any{
"id": u.ID, "id": u.ID, "name": u.Name, "role": u.Role,
"name": u.Name, "avatarVariant": u.AvatarVariant, "avatarPalette": u.AvatarPalette, "avatarPhoto": u.AvatarPhoto,
"username": u.Username, "private": true, "isFrozen": u.IsFrozen, "isClosed": u.IsClosed,
"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,
}, },
}) })
return
}
view := map[string]any{
"id": u.ID, "name": u.Name, "username": u.Username, "role": u.Role,
"avatarVariant": u.AvatarVariant, "avatarPalette": u.AvatarPalette, "avatarPhoto": u.AvatarPhoto,
"private": false, "public": extras.Public,
"createdAt": u.CreatedAt,
"isFrozen": u.IsFrozen, "isClosed": u.IsClosed,
}
if full || extras.ShowStats {
view["xp"] = u.XP
view["hearts"] = u.Hearts
view["streakCurrent"] = u.StreakCurrent
view["streakLongest"] = u.StreakLongest
view["isLocked"] = u.IsLocked
}
if full || extras.Public {
view["banner"] = s.signExistingMediaURL(extras.Banner)
links := extras.SocialLinks
if len(links) == 0 {
links = []byte(`[]`)
}
view["socialLinks"] = json.RawMessage(links)
}
writeJSON(w, http.StatusOK, map[string]any{"user": view})
} }
+8 -7
View File
@@ -77,6 +77,10 @@ func (s *Server) handleQuizSubmit(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusForbidden, service.ErrQuizNotUnlocked.Error()) writeErr(w, http.StatusForbidden, service.ErrQuizNotUnlocked.Error())
return return
} }
if !pg.PassedQuiz && !u.Role.IsStaff() && u.Hearts <= 0 {
writeErr(w, http.StatusForbidden, "جان‌ها تمام شده است. تا بازگشت یک جان صبر کنید.")
return
}
var req quizSubmitRequest var req quizSubmitRequest
if err := bodyJSON(r, &req); err != nil { if err := bodyJSON(r, &req); err != nil {
@@ -154,16 +158,13 @@ func (s *Server) handleQuizSubmit(w http.ResponseWriter, r *http.Request) {
return return
} }
if cert, created, cerr := s.issueCertificateIfEligible(r.Context(), activeUser); cerr == nil && created && cert != nil { if cert, created, cerr := s.issueCertificateIfEligible(r.Context(), activeUser); cerr == nil && created && cert != nil {
observability.Activity("certificate.issued", "user_id", u.ID, "public_id", cert.PublicID) observability.Activity("certificate.pending", "user_id", u.ID)
go func() { _ = s.notify.Notify(r.Context(), u.ID, "progress", "certificate_pending",
_ = s.notify.Notify(context.Background(), u.ID, "progress", "certificate_issued", "درخواست گواهینامه ثبت شد", "دوره را با موفقیت به پایان رساندید. درخواست گواهینامه شما ثبت شد و پس از تأیید مدیریت صادر خواهد شد.", "/profile", nil)
"گواهینامه آماده است", "دوره را به پایان رساندید. گواهینامه دیجیتال شما صادر شد.", "/c/"+cert.PublicID, nil) _ = cert // pending; no publicId yet
}()
} }
go func() {
s.notifyQuizPassed(u.ID, lesson.Title, id, activeUser.StreakCurrent) s.notifyQuizPassed(u.ID, lesson.Title, id, activeUser.StreakCurrent)
_ = s.notifyUnlockedLessons(u.ID, id) _ = s.notifyUnlockedLessons(u.ID, id)
}()
} }
} else if pg.PassedQuiz { } else if pg.PassedQuiz {
// Already cleared — practice attempts do not burn hearts. // Already cleared — practice attempts do not burn hearts.
+33
View File
@@ -101,6 +101,9 @@ func newRouter(s *Server) http.Handler {
mux.HandleFunc("POST /api/admin/users/{id}/reset-password", admin(s.handleAdminResetPassword)) mux.HandleFunc("POST /api/admin/users/{id}/reset-password", admin(s.handleAdminResetPassword))
mux.HandleFunc("GET /api/admin/physical-orders", admin(s.handleAdminListPhysicalOrders)) mux.HandleFunc("GET /api/admin/physical-orders", admin(s.handleAdminListPhysicalOrders))
mux.HandleFunc("PUT /api/admin/physical-orders/{id}", admin(s.handleAdminUpdatePhysicalOrder)) mux.HandleFunc("PUT /api/admin/physical-orders/{id}", admin(s.handleAdminUpdatePhysicalOrder))
mux.HandleFunc("GET /api/admin/certificates/pending", staff(s.handleAdminListPendingCertificates))
mux.HandleFunc("POST /api/admin/users/{id}/certificate/issue", staff(s.handleAdminIssueCertificate))
mux.HandleFunc("POST /api/admin/users/{id}/certificate/revoke", staff(s.handleAdminRevokeCertificate))
mux.HandleFunc("GET /api/admin/settings", admin(s.handleAdminGetSettings)) mux.HandleFunc("GET /api/admin/settings", admin(s.handleAdminGetSettings))
mux.HandleFunc("PUT /api/admin/settings", admin(s.handleAdminPutSettings)) mux.HandleFunc("PUT /api/admin/settings", admin(s.handleAdminPutSettings))
mux.HandleFunc("GET /api/admin/chat-export", admin(s.handleAdminChatExport)) mux.HandleFunc("GET /api/admin/chat-export", admin(s.handleAdminChatExport))
@@ -127,11 +130,13 @@ func newRouter(s *Server) http.Handler {
mux.HandleFunc("POST /api/admin/chapters", staff(s.handleAdminCreateChapter)) mux.HandleFunc("POST /api/admin/chapters", staff(s.handleAdminCreateChapter))
mux.HandleFunc("PUT /api/admin/chapters/{id}", staff(s.handleAdminUpdateChapter)) mux.HandleFunc("PUT /api/admin/chapters/{id}", staff(s.handleAdminUpdateChapter))
mux.HandleFunc("DELETE /api/admin/chapters/{id}", staff(s.handleAdminDeleteChapter)) mux.HandleFunc("DELETE /api/admin/chapters/{id}", staff(s.handleAdminDeleteChapter))
mux.HandleFunc("POST /api/admin/chapters/{id}/publish", staff(s.handleAdminPublishChapter))
mux.HandleFunc("GET /api/admin/lessons", staff(s.handleAdminListLessons)) mux.HandleFunc("GET /api/admin/lessons", staff(s.handleAdminListLessons))
mux.HandleFunc("POST /api/admin/lessons", staff(s.handleAdminCreateLesson)) mux.HandleFunc("POST /api/admin/lessons", staff(s.handleAdminCreateLesson))
mux.HandleFunc("PUT /api/admin/lessons/{id}", staff(s.handleAdminUpdateLesson)) mux.HandleFunc("PUT /api/admin/lessons/{id}", staff(s.handleAdminUpdateLesson))
mux.HandleFunc("DELETE /api/admin/lessons/{id}", staff(s.handleAdminDeleteLesson)) mux.HandleFunc("DELETE /api/admin/lessons/{id}", staff(s.handleAdminDeleteLesson))
mux.HandleFunc("POST /api/admin/lessons/{id}/publish", staff(s.handleAdminPublishLesson))
mux.HandleFunc("GET /api/admin/lessons/{id}/questions", staff(s.handleAdminListQuestions)) mux.HandleFunc("GET /api/admin/lessons/{id}/questions", staff(s.handleAdminListQuestions))
mux.HandleFunc("POST /api/admin/lessons/{id}/questions", staff(s.handleAdminCreateQuestion)) mux.HandleFunc("POST /api/admin/lessons/{id}/questions", staff(s.handleAdminCreateQuestion))
mux.HandleFunc("PUT /api/admin/questions/{id}", staff(s.handleAdminUpdateQuestion)) mux.HandleFunc("PUT /api/admin/questions/{id}", staff(s.handleAdminUpdateQuestion))
@@ -152,6 +157,34 @@ func newRouter(s *Server) http.Handler {
mux.HandleFunc("DELETE /api/admin/challenges/{id}", admin(s.handleAdminDeleteChallenge)) mux.HandleFunc("DELETE /api/admin/challenges/{id}", admin(s.handleAdminDeleteChallenge))
mux.HandleFunc("POST /api/admin/announce", admin(s.handleAdminAnnounce)) mux.HandleFunc("POST /api/admin/announce", admin(s.handleAdminAnnounce))
mux.HandleFunc("GET /api/admin/announcements", admin(s.handleAdminListAnnouncements))
mux.HandleFunc("PUT /api/admin/announcements/{id}", admin(s.handleAdminUpdateAnnouncement))
mux.HandleFunc("DELETE /api/admin/announcements/{id}", admin(s.handleAdminDeleteAnnouncement))
mux.HandleFunc("GET /api/admin/bots", admin(s.handleAdminListBots))
mux.HandleFunc("POST /api/admin/bots", admin(s.handleCreateBot))
mux.HandleFunc("PUT /api/admin/bots/{id}", admin(s.handleAdminSetBotActive))
mux.HandleFunc("GET /api/announcements/pinned", s.requireAuth(s.handlePinnedAnnouncement))
mux.HandleFunc("PUT /api/chats/{partner}/prefs", s.requireAuth(s.handleChatPrefs))
mux.HandleFunc("POST /api/chats/mute-all", s.requireAuth(s.handleChatMuteAll))
mux.HandleFunc("GET /api/chats/{partner}/peek", s.requireAuth(s.handleChatPeek))
mux.HandleFunc("POST /api/chats/{partner}/unread", s.requireAuth(s.handleChatUnread))
mux.HandleFunc("DELETE /api/chats/{partner}", s.requireAuth(s.handleChatDeleteBoth))
mux.HandleFunc("POST /api/chats/broadcast", s.requireAuth(s.handleChatBroadcast))
mux.HandleFunc("GET /api/me/saved", s.requireAuth(s.handleListSaved))
mux.HandleFunc("POST /api/me/saved", s.requireAuth(s.handleSaveMessage))
mux.HandleFunc("GET /api/me/profile", s.requireAuth(s.handleGetMeProfile))
mux.HandleFunc("PUT /api/me/profile", s.requireAuth(s.handlePutMeProfile))
mux.HandleFunc("PUT /api/me/account", s.requireAuth(s.handleUpdateAccount))
mux.HandleFunc("POST /api/me/freeze", s.requireAuth(s.handleFreezeAccount))
mux.HandleFunc("POST /api/me/unfreeze", s.requireAuth(s.handleUnfreezeAccount))
mux.HandleFunc("PUT /api/me/username", s.requireAuth(s.handleUpdateUsername))
mux.HandleFunc("GET /api/me/achievements", s.requireAuth(s.handleMeAchievements))
mux.HandleFunc("GET /api/me/activity", s.requireAuth(s.handleMeActivity))
mux.HandleFunc("GET /api/me/export", s.requireAuth(s.handleMeExport))
mux.HandleFunc("GET /api/search", s.requireAuth(s.handleSearch))
mux.HandleFunc("GET /api/meta", s.handleMeta)
mux.HandleFunc("POST /api/bot/send", s.handleBotSend)
mux.HandleFunc("GET /api/bot/updates", s.handleBotUpdates)
return mux return mux
} }
+7
View File
@@ -1,10 +1,12 @@
package api package api
import ( import (
"context"
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"time"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
@@ -31,6 +33,11 @@ type Server struct {
func NewServer(cfg *config.Config, st *store.Store, rdb *redis.Client, stg storage.Storage) *Server { func NewServer(cfg *config.Config, st *store.Store, rdb *redis.Client, stg storage.Storage) *Server {
hub := NewHub(rdb) hub := NewHub(rdb)
hub.SetSeen(func(userID int64) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_ = st.TouchLastSeen(ctx, userID)
})
notifier := service.NewNotifier(st, hub) notifier := service.NewNotifier(st, hub)
trustedProxyConfig = cfg trustedProxyConfig = cfg
return &Server{ return &Server{
+85 -14
View File
@@ -9,6 +9,7 @@ import (
"unicode/utf8" "unicode/utf8"
"pargar/backend/internal/observability" "pargar/backend/internal/observability"
"pargar/backend/internal/store"
) )
func (s *Server) handleGetSponsors(w http.ResponseWriter, r *http.Request) { func (s *Server) handleGetSponsors(w http.ResponseWriter, r *http.Request) {
@@ -41,12 +42,66 @@ func (s *Server) loadSponsors(ctx context.Context) []map[string]string {
return out return out
} }
func (s *Server) loadSocials(ctx context.Context) []map[string]string {
raw, err := s.store.GetSetting(ctx, "social_links")
if err != nil || strings.TrimSpace(raw) == "" {
return []map[string]string{}
}
var list []map[string]string
if err := json.Unmarshal([]byte(raw), &list); err != nil {
return []map[string]string{}
}
out := make([]map[string]string, 0, len(list))
for _, item := range list {
name := strings.TrimSpace(item["name"])
url := strings.TrimSpace(item["url"])
if name == "" || url == "" {
continue
}
out = append(out, map[string]string{"name": name, "url": url})
}
return out
}
func validateSocialLinks(v string) string {
if v == "" {
return ""
}
var tmp []map[string]string
if err := json.Unmarshal([]byte(v), &tmp); err != nil {
return "فرمت شبکه‌ها نامعتبر است"
}
if len(tmp) > 8 {
return "حداکثر ۸ شبکه مجاز است"
}
for _, item := range tmp {
name := strings.TrimSpace(item["name"])
url := strings.TrimSpace(item["url"])
if name == "" || url == "" {
return "نام و لینک شبکه الزامی است"
}
if utf8.RuneCountInString(name) > 40 {
return "نام شبکه خیلی طولانی است"
}
if !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "http://") {
return "لینک شبکه باید با http شروع شود"
}
if utf8.RuneCountInString(url) > 500 {
return "لینک شبکه خیلی طولانی است"
}
}
return ""
}
func (s *Server) handleAdminGetSettings(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAdminGetSettings(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
keys := []string{ keys := []string{
"donation_enabled", "donation_note",
"physical_cert_enabled", "physical_cert_price_irr", "physical_cert_window_days", "physical_cert_enabled", "physical_cert_price_irr", "physical_cert_window_days",
"sponsors", "sponsors",
"social_links",
"hearts_max",
"hearts_regen_minutes",
"hearts_lock_on_empty",
"registration_enabled", "registration_enabled",
"registration_require_whitelist", "registration_require_whitelist",
} }
@@ -58,15 +113,17 @@ func (s *Server) handleAdminGetSettings(w http.ResponseWriter, r *http.Request)
out[k] = "" out[k] = ""
} }
} }
if strings.TrimSpace(out["donation_note"]) == "" { if strings.TrimSpace(out["social_links"]) == "" {
out["donation_note"] = s.cfg.DonationNote out["social_links"] = "[]"
} }
if strings.TrimSpace(out["donation_enabled"]) == "" { if strings.TrimSpace(out["hearts_max"]) == "" {
if s.cfg.DonationEnabled { out["hearts_max"] = "5"
out["donation_enabled"] = "true"
} else {
out["donation_enabled"] = "false"
} }
if strings.TrimSpace(out["hearts_regen_minutes"]) == "" {
out["hearts_regen_minutes"] = "240"
}
if strings.TrimSpace(out["hearts_lock_on_empty"]) == "" {
out["hearts_lock_on_empty"] = "false"
} }
if strings.TrimSpace(out["physical_cert_enabled"]) == "" { if strings.TrimSpace(out["physical_cert_enabled"]) == "" {
if s.cfg.PhysicalCertEnabled { if s.cfg.PhysicalCertEnabled {
@@ -99,14 +156,16 @@ func (s *Server) handleAdminPutSettings(w http.ResponseWriter, r *http.Request)
return return
} }
allowed := map[string]bool{ allowed := map[string]bool{
"donation_enabled": true,
"donation_note": true,
"physical_cert_enabled": true, "physical_cert_enabled": true,
"physical_cert_price_irr": true, "physical_cert_price_irr": true,
"physical_cert_window_days": true, "physical_cert_window_days": true,
"sponsors": true, "sponsors": true,
"registration_enabled": true, "registration_enabled": true,
"registration_require_whitelist": true, "registration_require_whitelist": true,
"social_links": true,
"hearts_max": true,
"hearts_regen_minutes": true,
"hearts_lock_on_empty": true,
} }
for k, v := range req.Settings { for k, v := range req.Settings {
if !allowed[k] { if !allowed[k] {
@@ -114,7 +173,7 @@ func (s *Server) handleAdminPutSettings(w http.ResponseWriter, r *http.Request)
} }
v = strings.TrimSpace(v) v = strings.TrimSpace(v)
switch k { switch k {
case "donation_enabled", "physical_cert_enabled", "registration_enabled", "registration_require_whitelist": case "physical_cert_enabled", "registration_enabled", "registration_require_whitelist", "hearts_lock_on_empty":
if v == "1" || strings.EqualFold(v, "true") || v == "yes" { if v == "1" || strings.EqualFold(v, "true") || v == "yes" {
v = "true" v = "true"
} else { } else {
@@ -162,9 +221,21 @@ func (s *Server) handleAdminPutSettings(w http.ResponseWriter, r *http.Request)
return return
} }
} }
case "donation_note": case "social_links":
if utf8.RuneCountInString(v) > 2000 { if msg := validateSocialLinks(v); msg != "" {
writeErr(w, http.StatusBadRequest, "یادداشت دونیت خیلی طولانی است") writeErr(w, http.StatusBadRequest, msg)
return
}
case "hearts_max":
n, err := strconv.Atoi(v)
if err != nil || n < 1 || n > store.MaxHearts {
writeErr(w, http.StatusBadRequest, "سقف قلب باید بین ۱ تا ۵ باشد")
return
}
case "hearts_regen_minutes":
n, err := strconv.Atoi(v)
if err != nil || n < 5 || n > 24*60 {
writeErr(w, http.StatusBadRequest, "فاصله بازگشت قلب باید بین ۵ تا ۱۴۴۰ دقیقه باشد")
return return
} }
} }
+166 -15
View File
@@ -80,41 +80,88 @@ func TestChatReplaceAttachment(t *testing.T) {
msgID := int64(msg["id"].(float64)) msgID := int64(msg["id"].(float64))
oldPath := mediaPath(msg["attachment"].(map[string]any)["url"]) oldPath := mediaPath(msg["attachment"].(map[string]any)["url"])
code, body = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{
"body": "کپشن صدا",
})
if code != 200 {
t.Fatalf("caption: %d %v", code, body)
}
captioned := body["message"].(map[string]any)
if captioned["body"] != "کپشن صدا" || captioned["editedAt"] == nil {
t.Fatalf("caption edit: %v", captioned)
}
if mediaPath(captioned["attachment"].(map[string]any)["url"]) != oldPath {
t.Fatalf("caption edit replaced the file: %v", captioned)
}
code, body = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{
"body": "",
})
if code != 200 {
t.Fatalf("clear caption: %d %v", code, body)
}
cleared := body["message"].(map[string]any)
if cleared["body"] != "" || mediaPath(cleared["attachment"].(map[string]any)["url"]) != oldPath {
t.Fatalf("empty caption should keep the file: %v", cleared)
}
png[8] = 0x01 png[8] = 0x01
code, up = uploadChatFile(t, e, student, "two.png", png) code, up = uploadChatFile(t, e, student, "two.png", png)
if code != 201 {
t.Fatalf("upload extra: %d %v", code, up)
}
extra := up["attachment"].(map[string]any)
code, body = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{
"body": "کپشن صدا",
"attachment": extra,
})
if code != 200 {
t.Fatalf("append: %d %v", code, body)
}
appended := body["message"].(map[string]any)
appendedAtts := appended["attachments"].([]any)
if len(appendedAtts) != 2 || mediaPath(appendedAtts[0].(map[string]any)["url"]) != oldPath {
t.Fatalf("append wiped the original: %v", appended)
}
if mediaPath(appendedAtts[1].(map[string]any)["url"]) == "" || mediaPath(appendedAtts[1].(map[string]any)["url"]) == oldPath {
t.Fatalf("appended file: %v", appendedAtts)
}
png[8] = 0x02
code, up = uploadChatFile(t, e, student, "three.png", png)
if code != 201 { if code != 201 {
t.Fatalf("upload replacement: %d %v", code, up) t.Fatalf("upload replacement: %d %v", code, up)
} }
next := up["attachment"].(map[string]any) next := up["attachment"].(map[string]any)
code, body = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{ code, body = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{
"body": "", "body": "کپشن صدا",
"attachment": next, "replacements": []any{map[string]any{"index": 0, "attachment": next}},
}) })
if code != 200 { if code != 200 {
t.Fatalf("replace: %d %v", code, body) t.Fatalf("replace: %d %v", code, body)
} }
edited := body["message"].(map[string]any) edited := body["message"].(map[string]any)
newPath := mediaPath(edited["attachment"].(map[string]any)["url"]) editedAtts := edited["attachments"].([]any)
if edited["editedAt"] == nil || newPath == "" || newPath == oldPath { newPath := mediaPath(editedAtts[0].(map[string]any)["url"])
t.Fatalf("expected a replaced attachment, old %s new %v", oldPath, edited) if edited["editedAt"] == nil || len(editedAtts) != 2 || newPath == "" || newPath == oldPath {
t.Fatalf("expected only the first file to be replaced, got %v", edited)
} }
if edited["attachment"].(map[string]any)["type"] != "image" { if mediaPath(editedAtts[1].(map[string]any)["url"]) != mediaPath(extra["url"]) {
t.Fatalf("type: %v", edited["attachment"]) t.Fatalf("replace dropped the added file: %v", editedAtts)
} }
wrong := map[string]any{"type": "video", "url": next["url"], "name": "clip.mp4", "size": next["size"]} 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{ code, _ = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{
"body": "", "body": "کپشن صدا",
"attachment": wrong, "replacements": []any{map[string]any{"index": 0, "attachment": wrong}},
}) })
if code != 404 { if code != 400 {
t.Fatalf("different kind should miss: %d", code) t.Fatalf("different kind should be rejected: %d", code)
} }
code, _ = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{ code, _ = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{
"body": "", "body": "",
"attachment": map[string]any{"type": "file", "url": next["url"], "name": "x.pdf", "size": 1}, "replacements": []any{map[string]any{"index": 0, "attachment": map[string]any{"type": "file", "url": next["url"], "name": "x.pdf", "size": 1}}},
}) })
if code != 400 { if code != 400 {
t.Fatalf("file replace: %d", code) t.Fatalf("file replace: %d", code)
@@ -124,8 +171,8 @@ func TestChatReplaceAttachment(t *testing.T) {
"body": "", "body": "",
"attachment": next, "attachment": next,
}) })
if code != 400 { if code != 404 {
t.Fatalf("partner replace: %d", code) t.Fatalf("partner edit should be rejected: %d", code)
} }
} }
@@ -179,6 +226,110 @@ func TestChatReadMarksOnlyIncoming(t *testing.T) {
} }
} }
func TestChatSendMultipleAttachments(t *testing.T) {
e := setup(t)
student, _ := register(t, e, "Album Student", "album-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))
png := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00}
code, up1 := uploadChatFile(t, e, student, "one.png", png)
if code != 201 {
t.Fatalf("upload one: %d %v", code, up1)
}
png[8] = 0x01
code, up2 := uploadChatFile(t, e, student, "two.png", png)
if code != 201 {
t.Fatalf("upload two: %d %v", code, up2)
}
a := up1["attachment"].(map[string]any)
b := up2["attachment"].(map[string]any)
code, body := e.do(t, "POST", "/api/chats/"+itoa(adminID)+"/messages", student, map[string]any{
"body": "کپشن مشترک",
"attachments": []any{a, b},
})
if code != 201 {
t.Fatalf("send album: %d %v", code, body)
}
msg := body["message"].(map[string]any)
if msg["body"] != "کپشن مشترک" {
t.Fatalf("caption: %v", msg["body"])
}
atts := msg["attachments"].([]any)
if len(atts) != 2 {
t.Fatalf("attachments: %v", msg["attachments"])
}
if mediaPath(atts[0].(map[string]any)["url"]) == "" || mediaPath(atts[0].(map[string]any)["url"]) == mediaPath(atts[1].(map[string]any)["url"]) {
t.Fatalf("photo urls: %v", atts)
}
if mediaPath(msg["attachment"].(map[string]any)["url"]) != mediaPath(atts[0].(map[string]any)["url"]) {
t.Fatalf("first attachment should mirror the album: %v", msg["attachment"])
}
msgID := int64(msg["id"].(float64))
code, body = e.do(t, "GET", "/api/chats/"+itoa(adminID)+"/messages", student, nil)
if code != 200 {
t.Fatalf("list: %d %v", code, body)
}
listed := chatMsg(t, body, msgID)
if listed["readAt"] != nil {
t.Fatalf("own album was marked seen: %v", listed)
}
listedAtts := listed["attachments"].([]any)
if len(listedAtts) != 2 || listed["body"] != "کپشن مشترک" {
t.Fatalf("listed album: %v", listed)
}
code, body = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{
"body": "کپشن ویرایش‌شده",
})
if code != 200 {
t.Fatalf("album caption: %d %v", code, body)
}
albumEdit := body["message"].(map[string]any)
albumAtts := albumEdit["attachments"].([]any)
if albumEdit["body"] != "کپشن ویرایش‌شده" || len(albumAtts) != 2 {
t.Fatalf("album caption edit: %v", albumEdit)
}
if mediaPath(albumAtts[0].(map[string]any)["url"]) != mediaPath(atts[0].(map[string]any)["url"]) ||
mediaPath(albumAtts[1].(map[string]any)["url"]) != mediaPath(atts[1].(map[string]any)["url"]) {
t.Fatalf("album caption edit changed photos: %v", albumAtts)
}
code, up3 := uploadChatFile(t, e, student, "notes.txt", []byte("hello"))
if code != 201 {
t.Fatalf("upload txt: %d %v", code, up3)
}
code, up4 := uploadChatFile(t, e, student, "more.txt", []byte("world"))
if code != 201 {
t.Fatalf("upload txt 2: %d %v", code, up4)
}
code, body = e.do(t, "POST", "/api/chats/"+itoa(adminID)+"/messages", student, map[string]any{
"attachments": []any{up3["attachment"], up4["attachment"]},
})
if code != 201 {
t.Fatalf("send files: %d %v", code, body)
}
files := body["message"].(map[string]any)["attachments"].([]any)
if len(files) != 2 || files[0].(map[string]any)["type"] != "file" || files[1].(map[string]any)["name"] != "more.txt" {
t.Fatalf("files: %v", files)
}
many := make([]any, 0, 11)
for i := 0; i < 11; i++ {
many = append(many, a)
}
code, _ = e.do(t, "POST", "/api/chats/"+itoa(adminID)+"/messages", student, map[string]any{"attachments": many})
if code != 400 {
t.Fatalf("too many: %d", code)
}
}
func chatMsg(t *testing.T, body map[string]any, id int64) map[string]any { func chatMsg(t *testing.T, body map[string]any, id int64) map[string]any {
t.Helper() t.Helper()
for _, raw := range body["messages"].([]any) { for _, raw := range body["messages"].([]any) {
+21 -1
View File
@@ -57,6 +57,23 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
} }
} }
// Compute progress aggregates using only active+published lessons as the denominator so
// that frontend and backend always agree on the percentage.
totalActive, completedActive := 0, 0
for _, l := range lessons {
if !l.IsActive || !l.IsPublished {
continue
}
totalActive++
if p, ok := progress[l.ID]; ok && p.PassedQuiz {
completedActive++
}
}
var progressPct float64
if totalActive > 0 {
progressPct = float64(completedActive) / float64(totalActive) * 100
}
out := make([]chapterView, 0, len(chapters)) out := make([]chapterView, 0, len(chapters))
for _, ch := range chapters { for _, ch := range chapters {
cv := chapterView{Chapter: ch, Lessons: []lessonView{}} cv := chapterView{Chapter: ch, Lessons: []lessonView{}}
@@ -85,6 +102,9 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"chapters": out, "chapters": out,
"totalLessons": totalActive,
"completedLessons": completedActive,
"progressPct": progressPct,
"me": map[string]any{ "me": map[string]any{
"xp": u.XP, "xp": u.XP,
"streak": u.StreakCurrent, "streak": u.StreakCurrent,
@@ -244,7 +264,7 @@ func (s *Server) allowLessonAccess(w http.ResponseWriter, r *http.Request, lesso
writeErrCode(w, http.StatusLocked, CodeLocked, service.ErrLocked.Error()) writeErrCode(w, http.StatusLocked, CodeLocked, service.ErrLocked.Error())
return false return false
} }
if !lesson.IsActive && !u.Role.IsStaff() { if (!lesson.IsActive || !lesson.IsPublished) && !u.Role.IsStaff() {
writeErrCode(w, http.StatusNotFound, CodeNotFound, "درس پیدا نشد") writeErrCode(w, http.StatusNotFound, CodeNotFound, "درس پیدا نشد")
return false return false
} }
+28
View File
@@ -19,6 +19,7 @@ type Hub struct {
mu sync.RWMutex mu sync.RWMutex
conns map[int64]map[*Client]struct{} conns map[int64]map[*Client]struct{}
rdb *redis.Client rdb *redis.Client
onSeen func(userID int64)
} }
type Client struct { type Client struct {
@@ -84,6 +85,26 @@ func (h *Hub) Online(userID int64) bool {
return len(h.conns[userID]) > 0 return len(h.conns[userID]) > 0
} }
// SetSeen records a callback invoked when a user connects, stays connected, or disconnects.
func (h *Hub) SetSeen(fn func(userID int64)) {
h.mu.Lock()
h.onSeen = fn
h.mu.Unlock()
}
func (h *Hub) noteSeen(userID int64) {
if userID == 0 {
return
}
h.mu.RLock()
fn := h.onSeen
h.mu.RUnlock()
if fn == nil {
return
}
go fn(userID)
}
// Register adds a new client connection for a user. // Register adds a new client connection for a user.
func (h *Hub) Register(userID int64, conn *websocket.Conn) *Client { func (h *Hub) Register(userID int64, conn *websocket.Conn) *Client {
c := &Client{hub: h, conn: conn, user: userID, send: make(chan []byte, 16)} c := &Client{hub: h, conn: conn, user: userID, send: make(chan []byte, 16)}
@@ -93,23 +114,29 @@ func (h *Hub) Register(userID int64, conn *websocket.Conn) *Client {
} }
h.conns[userID][c] = struct{}{} h.conns[userID][c] = struct{}{}
h.mu.Unlock() h.mu.Unlock()
h.noteSeen(userID)
go c.writePump() go c.writePump()
go c.readPump() go c.readPump()
return c return c
} }
func (h *Hub) Unregister(c *Client) { func (h *Hub) Unregister(c *Client) {
offline := false
h.mu.Lock() h.mu.Lock()
if set, ok := h.conns[c.user]; ok { if set, ok := h.conns[c.user]; ok {
if _, ok := set[c]; ok { if _, ok := set[c]; ok {
delete(set, c) delete(set, c)
if len(set) == 0 { if len(set) == 0 {
delete(h.conns, c.user) delete(h.conns, c.user)
offline = true
} }
} }
} }
h.mu.Unlock() h.mu.Unlock()
close(c.send) close(c.send)
if offline {
h.noteSeen(c.user)
}
} }
// close unregisters the client and closes its connection exactly once, // close unregisters the client and closes its connection exactly once,
@@ -148,6 +175,7 @@ func (c *Client) writePump() {
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return return
} }
c.hub.noteSeen(c.user)
} }
} }
} }
+17
View File
@@ -11,9 +11,26 @@ import (
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
) )
// migrateLockID is held for the whole migration pass so two replicas cannot
// apply the same file at once. The lock lives on one pooled connection and
// is released when that session ends.
const migrateLockID int64 = 764727013
// Migrate runs all *.sql files in the migrations fs in lexicographic order. // Migrate runs all *.sql files in the migrations fs in lexicographic order.
// Tracks applied migrations in schema_migrations. // Tracks applied migrations in schema_migrations.
func Migrate(ctx context.Context, pool *pgxpool.Pool, migrations fs.FS) error { func Migrate(ctx context.Context, pool *pgxpool.Pool, migrations fs.FS) error {
conn, err := pool.Acquire(ctx)
if err != nil {
return fmt.Errorf("migrate lock: %w", err)
}
defer conn.Release()
if _, err := conn.Exec(ctx, `SELECT pg_advisory_lock($1)`, migrateLockID); err != nil {
return fmt.Errorf("migrate lock: %w", err)
}
defer func() {
_, _ = conn.Exec(context.Background(), `SELECT pg_advisory_unlock($1)`, migrateLockID)
}()
if _, err := pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (version TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now())`); err != nil { if _, err := pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (version TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now())`); err != nil {
return fmt.Errorf("create migrations table: %w", err) return fmt.Errorf("create migrations table: %w", err)
} }
+192
View File
@@ -0,0 +1,192 @@
package db
import (
"context"
"fmt"
"io/fs"
"os"
"strings"
"sync"
"testing"
"testing/fstest"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestMigrateFromV013Schema(t *testing.T) {
user := os.Getenv("POSTGRES_USER")
password := os.Getenv("POSTGRES_PASSWORD")
if user == "" || password == "" {
t.Skip("postgres env not set")
}
host := "127.0.0.1"
port := "5432"
adminURL := fmt.Sprintf("postgres://%s:%s@%s:%s/postgres?sslmode=disable", user, password, host, port)
ctx := context.Background()
admin, err := pgxpool.New(ctx, adminURL)
if err != nil {
t.Skipf("postgres unavailable: %v", err)
}
defer admin.Close()
if err := admin.Ping(ctx); err != nil {
t.Skipf("postgres unavailable: %v", err)
}
const name = "pargar_upgrade_test"
_, _ = admin.Exec(ctx, `DROP DATABASE IF EXISTS `+name+` WITH (FORCE)`)
if _, err := admin.Exec(ctx, `CREATE DATABASE `+name); err != nil {
t.Fatalf("create database: %v", err)
}
t.Cleanup(func() {
_, _ = admin.Exec(context.Background(), `DROP DATABASE IF EXISTS `+name+` WITH (FORCE)`)
})
url := fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable", user, password, host, port, name)
pool, err := Connect(ctx, url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
sub, err := fs.Sub(MigrationFiles, "migrations")
if err != nil {
t.Fatal(err)
}
legacy := fstest.MapFS{}
entries, err := fs.ReadDir(sub, ".")
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") || entry.Name() >= "0023" {
continue
}
body, err := fs.ReadFile(sub, entry.Name())
if err != nil {
t.Fatal(err)
}
legacy[entry.Name()] = &fstest.MapFile{Data: body}
}
if len(legacy) == 0 {
t.Fatal("no v0.1.3 migrations")
}
if err := Migrate(ctx, pool, legacy); err != nil {
t.Fatalf("migrate through 0022: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO users (name, email, username, password_hash, role, hearts)
VALUES ('legacy', 'legacy@example.com', 'legacy', 'x', 'student', 3)`); err != nil {
t.Fatalf("seed hearts=3: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO users (name, email, username, password_hash, role, hearts)
VALUES ('over', 'over@example.com', 'over', 'x', 'student', 5)`); err == nil {
t.Fatal("hearts=5 was accepted on the v0.1.3 constraint")
}
if err := Migrate(ctx, pool, sub); err != nil {
t.Fatalf("upgrade 0023-0028: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO users (name, email, username, password_hash, role, hearts)
VALUES ('over', 'over@example.com', 'over', 'x', 'student', 5)`); err != nil {
t.Fatalf("hearts=5 after upgrade: %v", err)
}
var hearts int
var locked bool
if err := pool.QueryRow(ctx, `SELECT hearts, is_locked FROM users WHERE email='legacy@example.com'`).Scan(&hearts, &locked); err != nil {
t.Fatal(err)
}
if hearts != 3 || locked {
t.Fatalf("legacy account changed: hearts=%d locked=%v", hearts, locked)
}
var constraint string
if err := pool.QueryRow(ctx, `SELECT pg_get_constraintdef(oid) FROM pg_constraint WHERE conname='users_hearts_max'`).Scan(&constraint); err != nil {
t.Fatal(err)
}
if !strings.Contains(constraint, "5") {
t.Fatalf("hearts constraint: %s", constraint)
}
var labels string
if err := pool.QueryRow(ctx, `
SELECT string_agg(e.enumlabel, ',' ORDER BY e.enumsortorder)
FROM pg_enum e
JOIN pg_type t ON t.oid = e.enumtypid
WHERE t.typname = 'notif_category'`).Scan(&labels); err != nil {
t.Fatal(err)
}
for _, want := range []string{"urgent", "curriculum", "community", "system"} {
if !strings.Contains(labels, want) {
t.Fatalf("enum %s missing from %s", want, labels)
}
}
var cols int
if err := pool.QueryRow(ctx, `
SELECT count(*) FROM information_schema.columns
WHERE table_name='users' AND column_name IN ('last_seen_at','username_change_count','frozen_at','closed_at')`).Scan(&cols); err != nil {
t.Fatal(err)
}
if cols != 4 {
t.Fatalf("user columns: %d", cols)
}
var attachments bool
if err := pool.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name='chat_messages' AND column_name='attachments'
)`).Scan(&attachments); err != nil {
t.Fatal(err)
}
if !attachments {
t.Fatal("chat attachments column missing")
}
var botCols int
if err := pool.QueryRow(ctx, `
SELECT count(*) FROM information_schema.columns
WHERE table_name='bot_tokens' AND column_name IN ('request_count','revoked_at')`).Scan(&botCols); err != nil {
t.Fatal(err)
}
if botCols != 2 {
t.Fatalf("bot token columns: %d", botCols)
}
var before int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM schema_migrations`).Scan(&before); err != nil {
t.Fatal(err)
}
if err := Migrate(ctx, pool, sub); err != nil {
t.Fatalf("second migrate: %v", err)
}
var after int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM schema_migrations`).Scan(&after); err != nil {
t.Fatal(err)
}
if before != after {
t.Fatalf("repeat migrate wrote rows: %d -> %d", before, after)
}
lockCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
var wg sync.WaitGroup
errCh := make(chan error, 2)
for range 2 {
wg.Add(1)
go func() {
defer wg.Done()
errCh <- Migrate(lockCtx, pool, sub)
}()
}
wg.Wait()
close(errCh)
for err := range errCh {
if err != nil {
t.Fatalf("parallel migrate: %v", err)
}
}
}
@@ -0,0 +1,71 @@
ALTER TYPE notif_category ADD VALUE IF NOT EXISTS 'urgent';
ALTER TYPE notif_category ADD VALUE IF NOT EXISTS 'curriculum';
ALTER TYPE notif_category ADD VALUE IF NOT EXISTS 'community';
ALTER TYPE notif_category ADD VALUE IF NOT EXISTS 'system';
CREATE TABLE IF NOT EXISTS announcements (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
body TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'event',
route TEXT NOT NULL DEFAULT '',
pinned BOOLEAN NOT NULL DEFAULT false,
created_by BIGINT REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE notifications ADD COLUMN IF NOT EXISTS announcement_id BIGINT REFERENCES announcements(id) ON DELETE CASCADE;
CREATE TABLE IF NOT EXISTS chat_prefs (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
partner_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
pinned_rank INT,
muted BOOLEAN NOT NULL DEFAULT false,
archived BOOLEAN NOT NULL DEFAULT false,
PRIMARY KEY (user_id, partner_id)
);
ALTER TABLE users ADD COLUMN IF NOT EXISTS chat_muted_all BOOLEAN NOT NULL DEFAULT false;
CREATE TABLE IF NOT EXISTS saved_messages (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
body TEXT NOT NULL DEFAULT '',
attachment_url TEXT NOT NULL DEFAULT '',
attachment_type TEXT NOT NULL DEFAULT '',
attachment_name TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS user_profiles (
user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
banner_photo TEXT NOT NULL DEFAULT '',
profile_public BOOLEAN NOT NULL DEFAULT false,
show_activity BOOLEAN NOT NULL DEFAULT true,
show_stats BOOLEAN NOT NULL DEFAULT true,
show_path BOOLEAN NOT NULL DEFAULT true,
show_badges BOOLEAN NOT NULL DEFAULT true,
social_links JSONB NOT NULL DEFAULT '[]',
theme TEXT NOT NULL DEFAULT 'system'
);
CREATE TABLE IF NOT EXISTS bot_tokens (
id BIGSERIAL PRIMARY KEY,
bot_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
owner_id BIGINT NOT NULL REFERENCES users(id),
token_hash TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS bot_updates (
id BIGSERIAL PRIMARY KEY,
bot_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS buttons JSONB NOT NULL DEFAULT '[]';
ALTER TABLE users ALTER COLUMN hearts SET DEFAULT 5;
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_hearts_max;
ALTER TABLE users ADD CONSTRAINT users_hearts_max CHECK (hearts BETWEEN 0 AND 5);
@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ;
@@ -0,0 +1,2 @@
ALTER TABLE chat_messages
ADD COLUMN IF NOT EXISTS attachments JSONB;
@@ -0,0 +1,2 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS username_change_count INTEGER NOT NULL DEFAULT 0;
ALTER TABLE users ADD COLUMN IF NOT EXISTS username_cooldown_until TIMESTAMPTZ;
@@ -0,0 +1,2 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS frozen_at TIMESTAMPTZ;
ALTER TABLE users ADD COLUMN IF NOT EXISTS closed_at TIMESTAMPTZ;
@@ -0,0 +1,2 @@
ALTER TABLE bot_tokens ADD COLUMN IF NOT EXISTS request_count BIGINT NOT NULL DEFAULT 0;
ALTER TABLE bot_tokens ADD COLUMN IF NOT EXISTS revoked_at TIMESTAMPTZ;
@@ -0,0 +1,26 @@
-- 0029_lesson_delete_cascades.sql
-- Make deleting a lesson (directly, or via its chapter) work cleanly.
--
-- Previously only mcq_questions cascaded; watch/quiz history blocked the delete
-- with a 500. Progress/history rows now go away with the lesson, while lessons
-- that merely *required* the deleted one are unlinked (never deleted).
ALTER TABLE watch_sessions DROP CONSTRAINT IF EXISTS watch_sessions_lesson_id_fkey;
ALTER TABLE watch_sessions ADD CONSTRAINT watch_sessions_lesson_id_fkey
FOREIGN KEY (lesson_id) REFERENCES lessons(id) ON DELETE CASCADE;
ALTER TABLE watch_heartbeats DROP CONSTRAINT IF EXISTS watch_heartbeats_lesson_id_fkey;
ALTER TABLE watch_heartbeats ADD CONSTRAINT watch_heartbeats_lesson_id_fkey
FOREIGN KEY (lesson_id) REFERENCES lessons(id) ON DELETE CASCADE;
ALTER TABLE lesson_progress DROP CONSTRAINT IF EXISTS lesson_progress_lesson_id_fkey;
ALTER TABLE lesson_progress ADD CONSTRAINT lesson_progress_lesson_id_fkey
FOREIGN KEY (lesson_id) REFERENCES lessons(id) ON DELETE CASCADE;
ALTER TABLE quiz_attempts DROP CONSTRAINT IF EXISTS quiz_attempts_lesson_id_fkey;
ALTER TABLE quiz_attempts ADD CONSTRAINT quiz_attempts_lesson_id_fkey
FOREIGN KEY (lesson_id) REFERENCES lessons(id) ON DELETE CASCADE;
ALTER TABLE lessons DROP CONSTRAINT IF EXISTS lessons_requires_lesson_id_fkey;
ALTER TABLE lessons ADD CONSTRAINT lessons_requires_lesson_id_fkey
FOREIGN KEY (requires_lesson_id) REFERENCES lessons(id) ON DELETE SET NULL;
@@ -0,0 +1,9 @@
-- Add draft/published state to chapters and lessons.
-- Existing content is published immediately so nothing goes dark on deploy.
-- New lessons created via the admin API default to is_published = false (draft).
ALTER TABLE lessons ADD COLUMN IF NOT EXISTS is_published BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE chapters ADD COLUMN IF NOT EXISTS is_published BOOLEAN NOT NULL DEFAULT false;
UPDATE lessons SET is_published = true;
UPDATE chapters SET is_published = true;
@@ -0,0 +1,13 @@
-- Add manual approval flow for certificates.
-- Existing certs keep status='issued'. New completions create status='pending'
-- until an admin or mentor explicitly issues the certificate.
ALTER TABLE certificates ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'issued';
ALTER TABLE certificates ALTER COLUMN public_id DROP NOT NULL;
UPDATE certificates SET status = 'issued';
INSERT INTO app_settings (key, value) VALUES
('certificate_enabled', 'true'),
('certificate_requires_approval', 'true')
ON CONFLICT (key) DO NOTHING;
+61 -1
View File
@@ -47,15 +47,71 @@ type User struct {
SecurityAnswerHash string `json:"-"` SecurityAnswerHash string `json:"-"`
HasSecurityAnswer bool `json:"hasSecurityAnswer"` HasSecurityAnswer bool `json:"hasSecurityAnswer"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UsernameChangeCount int `json:"-"`
UsernameCooldownUntil *time.Time `json:"usernameCooldownUntil,omitempty"`
UsernameChangesRemaining int `json:"usernameChangesRemaining"`
FrozenAt *time.Time `json:"frozenAt,omitempty"`
ClosedAt *time.Time `json:"closedAt,omitempty"`
IsFrozen bool `json:"isFrozen"`
IsClosed bool `json:"isClosed"`
ClosesAt *time.Time `json:"closesAt,omitempty"`
}
const (
UsernameChangeLimit = 3
UsernameChangeCooldown = 7 * 24 * time.Hour
AccountFreezeGraceDays = 30
)
// ApplyUsernameQuota fills UsernameChangesRemaining from the stored count.
// An expired cooldown clears the allowance so the next window starts at the limit.
func (u *User) ApplyUsernameQuota(now time.Time) {
if u.UsernameCooldownUntil != nil && !now.Before(*u.UsernameCooldownUntil) {
u.UsernameChangeCount = 0
u.UsernameCooldownUntil = nil
}
if u.UsernameCooldownUntil != nil && now.Before(*u.UsernameCooldownUntil) {
u.UsernameChangesRemaining = 0
return
}
remaining := UsernameChangeLimit - u.UsernameChangeCount
if remaining < 0 {
remaining = 0
}
u.UsernameChangesRemaining = remaining
}
// ApplyFreezeState derives freeze and closure from frozen_at and closed_at.
// Frozen is frozen_at set, closed_at empty, and now still inside the 30-day window.
// Closed is closed_at set, or the window having elapsed.
func (u *User) ApplyFreezeState(now time.Time) {
u.IsFrozen = false
u.IsClosed = false
u.ClosesAt = nil
if u.ClosedAt != nil {
u.IsClosed = true
return
}
if u.FrozenAt == nil {
return
}
closes := u.FrozenAt.AddDate(0, 0, AccountFreezeGraceDays)
if !now.Before(closes) {
u.IsClosed = true
return
}
u.IsFrozen = true
u.ClosesAt = &closes
} }
type Certificate struct { type Certificate struct {
ID int64 `json:"id"` ID int64 `json:"id"`
UserID int64 `json:"userId"` UserID int64 `json:"userId"`
PublicID string `json:"publicId"` PublicID string `json:"publicId,omitempty"`
FullName string `json:"fullName"` FullName string `json:"fullName"`
IssuedAt time.Time `json:"issuedAt"` IssuedAt time.Time `json:"issuedAt"`
RevokedAt *time.Time `json:"revokedAt,omitempty"` RevokedAt *time.Time `json:"revokedAt,omitempty"`
Status string `json:"status"`
} }
type PhysicalOrderStatus string type PhysicalOrderStatus string
@@ -109,6 +165,7 @@ type Chapter struct {
Description string `json:"description"` Description string `json:"description"`
Icon string `json:"icon"` Icon string `json:"icon"`
SortOrder int `json:"sortOrder"` SortOrder int `json:"sortOrder"`
IsPublished bool `json:"isPublished"`
} }
type Lesson struct { type Lesson struct {
@@ -125,6 +182,7 @@ type Lesson struct {
SortOrder int `json:"sortOrder"` SortOrder int `json:"sortOrder"`
XPReward int `json:"xpReward"` XPReward int `json:"xpReward"`
IsActive bool `json:"isActive"` IsActive bool `json:"isActive"`
IsPublished bool `json:"isPublished"`
} }
type MCQQuestion struct { type MCQQuestion struct {
@@ -221,7 +279,9 @@ type ChatMessage struct {
Pinned bool `json:"pinned"` Pinned bool `json:"pinned"`
EditedAt *time.Time `json:"editedAt,omitempty"` EditedAt *time.Time `json:"editedAt,omitempty"`
Attachment *Attachment `json:"attachment,omitempty"` Attachment *Attachment `json:"attachment,omitempty"`
Attachments []Attachment `json:"attachments,omitempty"`
Reactions []ChatReaction `json:"reactions,omitempty"` Reactions []ChatReaction `json:"reactions,omitempty"`
Buttons json.RawMessage `json:"buttons,omitempty"`
} }
type ChatReaction struct { type ChatReaction struct {
@@ -77,10 +77,12 @@ func SetupTracing(ctx context.Context, cfg Config) (func(context.Context) error,
if err != nil { if err != nil {
return nil, err 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( res, err := resource.Merge(
resource.Default(), resource.Default(),
resource.NewWithAttributes( resource.NewSchemaless(
semconv.SchemaURL,
semconv.ServiceName(name), semconv.ServiceName(name),
), ),
) )
+50 -6
View File
@@ -2,6 +2,7 @@ package service
import ( import (
"context" "context"
"strconv"
"time" "time"
"pargar/backend/internal/config" "pargar/backend/internal/config"
@@ -9,19 +10,62 @@ import (
"pargar/backend/internal/store" "pargar/backend/internal/store"
) )
// HeartsService manages the fixed 3-heart pool and lockout. // HeartsService manages a regenerating heart pool.
// Hearts do not regenerate over time — only a mentor/admin unlock restores them. // Reaching zero waits for the next heart unless an admin turns lock-on-empty on.
type HeartsService struct { type HeartsService struct {
store *store.Store store *store.Store
} }
type HeartPolicy struct {
Max int
Every time.Duration
LockEmpty bool
}
func NewHeartsService(st *store.Store, _ *config.Config) *HeartsService { func NewHeartsService(st *store.Store, _ *config.Config) *HeartsService {
return &HeartsService{store: st} return &HeartsService{store: st}
} }
// RefreshHearts is a no-op kept for call-site compatibility. func (h *HeartsService) Policy(ctx context.Context) HeartPolicy {
// The game has no timed heart refill; the stored count is authoritative. p := HeartPolicy{Max: store.MaxHearts, Every: 4 * time.Hour, LockEmpty: false}
func (h *HeartsService) RefreshHearts(_ context.Context, u *models.User) (*models.User, error) { if v, err := h.store.GetSetting(ctx, "hearts_max"); err == nil {
if n, convErr := strconv.Atoi(v); convErr == nil && n >= 1 && n <= store.MaxHearts {
p.Max = n
}
}
if v, err := h.store.GetSetting(ctx, "hearts_regen_minutes"); err == nil {
if n, convErr := strconv.Atoi(v); convErr == nil && n >= 5 && n <= 24*60 {
p.Every = time.Duration(n) * time.Minute
}
}
if v, err := h.store.GetSetting(ctx, "hearts_lock_on_empty"); err == nil {
p.LockEmpty = v == "true" || v == "1"
}
return p
}
// RefreshHearts adds hearts that have come due since hearts_updated_at.
func (h *HeartsService) RefreshHearts(ctx context.Context, u *models.User) (*models.User, error) {
if u == nil || u.Role.IsStaff() {
return u, nil
}
p := h.Policy(ctx)
if u.Hearts >= p.Max || u.HeartsUpdatedAt.IsZero() {
return u, nil
}
gain := int(time.Since(u.HeartsUpdatedAt) / p.Every)
if gain <= 0 {
return u, nil
}
next := u.Hearts + gain
if next > p.Max {
next = p.Max
}
if err := h.store.SetHearts(ctx, u.ID, next); err != nil {
return u, err
}
u.Hearts = next
u.HeartsUpdatedAt = time.Now()
return u, nil return u, nil
} }
@@ -45,7 +89,7 @@ func (h *HeartsService) ConsumeHearts(ctx context.Context, u *models.User, loss
return nil, 0, err return nil, 0, err
} }
u.Hearts = cur u.Hearts = cur
if cur == 0 && !u.IsLocked { if cur == 0 && h.Policy(ctx).LockEmpty && !u.IsLocked {
locked, err := h.store.LockUser(ctx, u.ID) locked, err := h.store.LockUser(ctx, u.ID)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
+277
View File
@@ -0,0 +1,277 @@
package store
import (
"context"
"encoding/json"
"time"
)
type Announcement struct {
ID int64
Title string
Body string
Category string
Route string
Pinned bool
CreatedBy int64
CreatedAt time.Time
UpdatedAt time.Time
}
func (s *Store) CreateAnnouncement(ctx context.Context, a *Announcement) error {
if a.Pinned {
_, _ = s.pool.Exec(ctx, `UPDATE announcements SET pinned=false WHERE pinned`)
}
return s.pool.QueryRow(ctx, `
INSERT INTO announcements (title, body, category, route, pinned, created_by)
VALUES ($1,$2,$3,$4,$5,$6)
RETURNING id, created_at, updated_at`,
a.Title, a.Body, a.Category, a.Route, a.Pinned, a.CreatedBy,
).Scan(&a.ID, &a.CreatedAt, &a.UpdatedAt)
}
func (s *Store) ListAnnouncements(ctx context.Context) ([]Announcement, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, title, body, category, route, pinned, COALESCE(created_by,0), created_at, updated_at
FROM announcements ORDER BY pinned DESC, id DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Announcement
for rows.Next() {
var a Announcement
if err := rows.Scan(&a.ID, &a.Title, &a.Body, &a.Category, &a.Route, &a.Pinned, &a.CreatedBy, &a.CreatedAt, &a.UpdatedAt); err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
func (s *Store) UpdateAnnouncement(ctx context.Context, a Announcement) error {
if a.Pinned {
_, _ = s.pool.Exec(ctx, `UPDATE announcements SET pinned=false WHERE pinned AND id<>$1`, a.ID)
}
tag, err := s.pool.Exec(ctx, `
UPDATE announcements
SET title=$2, body=$3, category=$4, route=$5, pinned=$6, updated_at=now()
WHERE id=$1`, a.ID, a.Title, a.Body, a.Category, a.Route, a.Pinned)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
_, err = s.pool.Exec(ctx, `
UPDATE notifications
SET title=$2, body=$3, category=$4::notif_category, route=$5
WHERE announcement_id=$1`, a.ID, a.Title, a.Body, a.Category, a.Route)
return err
}
func (s *Store) DeleteAnnouncement(ctx context.Context, id int64) error {
tag, err := s.pool.Exec(ctx, `DELETE FROM announcements WHERE id=$1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) RecentAnnouncements(ctx context.Context, limit int) ([]Announcement, error) {
if limit <= 0 || limit > 20 {
limit = 8
}
rows, err := s.pool.Query(ctx, `
SELECT id, title, body, category, route, pinned, COALESCE(created_by,0), created_at, updated_at
FROM announcements ORDER BY pinned DESC, id DESC LIMIT $1`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Announcement
for rows.Next() {
var a Announcement
if err := rows.Scan(&a.ID, &a.Title, &a.Body, &a.Category, &a.Route, &a.Pinned, &a.CreatedBy, &a.CreatedAt, &a.UpdatedAt); err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
func (s *Store) PinnedAnnouncement(ctx context.Context) (*Announcement, error) {
var a Announcement
err := s.pool.QueryRow(ctx, `
SELECT id, title, body, category, route, pinned, COALESCE(created_by,0), created_at, updated_at
FROM announcements WHERE pinned ORDER BY updated_at DESC LIMIT 1`).
Scan(&a.ID, &a.Title, &a.Body, &a.Category, &a.Route, &a.Pinned, &a.CreatedBy, &a.CreatedAt, &a.UpdatedAt)
if err != nil {
return nil, err
}
return &a, nil
}
func (s *Store) AttachAnnouncement(ctx context.Context, notifID, announcementID int64) error {
_, err := s.pool.Exec(ctx, `UPDATE notifications SET announcement_id=$2 WHERE id=$1`, notifID, announcementID)
return err
}
type ChatPref struct {
PinnedRank *int
Muted bool
Archived bool
}
func (s *Store) UpsertChatPref(ctx context.Context, userID, partnerID int64, rank *int, muted, archived bool) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO chat_prefs (user_id, partner_id, pinned_rank, muted, archived)
VALUES ($1,$2,$3,$4,$5)
ON CONFLICT (user_id, partner_id) DO UPDATE
SET pinned_rank=$3, muted=$4, archived=$5`, userID, partnerID, rank, muted, archived)
return err
}
func (s *Store) ChatPref(ctx context.Context, userID, partnerID int64) ChatPref {
var p ChatPref
_ = s.pool.QueryRow(ctx, `
SELECT pinned_rank, muted, archived FROM chat_prefs WHERE user_id=$1 AND partner_id=$2`,
userID, partnerID).Scan(&p.PinnedRank, &p.Muted, &p.Archived)
return p
}
func (s *Store) SetChatMutedAll(ctx context.Context, userID int64, muted bool) error {
_, err := s.pool.Exec(ctx, `UPDATE users SET chat_muted_all=$2 WHERE id=$1`, userID, muted)
return err
}
func (s *Store) ChatMutedAll(ctx context.Context, userID int64) bool {
var v bool
_ = s.pool.QueryRow(ctx, `SELECT chat_muted_all FROM users WHERE id=$1`, userID).Scan(&v)
return v
}
func (s *Store) TouchLastSeen(ctx context.Context, userID int64) error {
_, err := s.pool.Exec(ctx, `UPDATE users SET last_seen_at = now() WHERE id=$1`, userID)
return err
}
func (s *Store) LastSeenByIDs(ctx context.Context, ids []int64) (map[int64]time.Time, error) {
out := map[int64]time.Time{}
if len(ids) == 0 {
return out, nil
}
rows, err := s.pool.Query(ctx, `
SELECT id, last_seen_at FROM users
WHERE id = ANY($1::bigint[]) AND last_seen_at IS NOT NULL`, ids)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var id int64
var ts time.Time
if err := rows.Scan(&id, &ts); err != nil {
return nil, err
}
out[id] = ts
}
return out, rows.Err()
}
func (s *Store) MarkChatUnread(ctx context.Context, meID, partnerID int64, meRole string) error {
_, err := s.pool.Exec(ctx, `
UPDATE chat_messages SET read_at=NULL
WHERE read_at IS NOT NULL
AND sender_role <> $3
AND ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1))`,
meID, partnerID, meRole)
return err
}
func (s *Store) DeleteChatBoth(ctx context.Context, a, b int64) error {
_, err := s.pool.Exec(ctx, `
DELETE FROM chat_messages
WHERE (user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1)`, a, b)
return err
}
func (s *Store) SaveMessage(ctx context.Context, userID int64, body, url, kind, name string) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO saved_messages (user_id, body, attachment_url, attachment_type, attachment_name)
VALUES ($1,$2,$3,$4,$5)`, userID, body, url, kind, name)
return err
}
func (s *Store) ListSaved(ctx context.Context, userID int64) ([]map[string]any, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, body, attachment_url, attachment_type, attachment_name, created_at
FROM saved_messages WHERE user_id=$1 ORDER BY id DESC LIMIT 100`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []map[string]any
for rows.Next() {
var id int64
var body, url, kind, name string
var at time.Time
if err := rows.Scan(&id, &body, &url, &kind, &name, &at); err != nil {
return nil, err
}
out = append(out, map[string]any{
"id": id, "body": body, "attachmentUrl": url, "attachmentType": kind, "attachmentName": name, "createdAt": at,
})
}
if out == nil {
out = []map[string]any{}
}
return out, rows.Err()
}
type ProfileExtras struct {
Banner string
Public bool
ShowActivity bool
ShowStats bool
ShowPath bool
ShowBadges bool
SocialLinks json.RawMessage
Theme string
}
func (s *Store) EnsureProfile(ctx context.Context, userID int64) error {
_, err := s.pool.Exec(ctx, `INSERT INTO user_profiles (user_id) VALUES ($1) ON CONFLICT DO NOTHING`, userID)
return err
}
func (s *Store) GetProfileExtras(ctx context.Context, userID int64) (ProfileExtras, error) {
if err := s.EnsureProfile(ctx, userID); err != nil {
return ProfileExtras{}, err
}
var p ProfileExtras
err := s.pool.QueryRow(ctx, `
SELECT banner_photo, profile_public, show_activity, show_stats, show_path, show_badges, social_links, theme
FROM user_profiles WHERE user_id=$1`, userID).
Scan(&p.Banner, &p.Public, &p.ShowActivity, &p.ShowStats, &p.ShowPath, &p.ShowBadges, &p.SocialLinks, &p.Theme)
return p, err
}
func (s *Store) SaveProfileExtras(ctx context.Context, userID int64, p ProfileExtras) error {
if err := s.EnsureProfile(ctx, userID); err != nil {
return err
}
if len(p.SocialLinks) == 0 {
p.SocialLinks = json.RawMessage(`[]`)
}
_, err := s.pool.Exec(ctx, `
UPDATE user_profiles SET
banner_photo=$2, profile_public=$3, show_activity=$4, show_stats=$5,
show_path=$6, show_badges=$7, social_links=$8, theme=$9
WHERE user_id=$1`,
userID, p.Banner, p.Public, p.ShowActivity, p.ShowStats, p.ShowPath, p.ShowBadges, p.SocialLinks, p.Theme)
return err
}
+448 -68
View File
@@ -17,8 +17,19 @@ import (
var ErrNotFound = errors.New("not found") var ErrNotFound = errors.New("not found")
var ErrPinLimit = errors.New("pin limit") var ErrPinLimit = errors.New("pin limit")
var ErrUsernameTaken = errors.New("username taken")
var ErrAccountFrozen = errors.New("account frozen")
var ErrAccountClosed = errors.New("account closed")
var ErrAccountNotFrozen = errors.New("account not frozen")
const MaxHearts = 3 // UsernameCooldownError means the caller already used the username-change allowance.
type UsernameCooldownError struct {
Until time.Time
}
func (e *UsernameCooldownError) Error() string { return "username cooldown" }
const MaxHearts = 5
type Store struct { type Store struct {
pool *pgxpool.Pool pool *pgxpool.Pool
@@ -80,18 +91,26 @@ func scanUser(row rowScanner) (*models.User, error) {
&u.LastActivityDate, &u.IsLocked, &u.LockedAt, &u.UnlockedBy, &u.IsActive, &u.LastActivityDate, &u.IsLocked, &u.LockedAt, &u.UnlockedBy, &u.IsActive,
&u.AvatarVariant, &u.AvatarPalette, &u.AvatarPhoto, &u.AvatarVariant, &u.AvatarPalette, &u.AvatarPhoto,
&u.Phone, &u.TelegramID, &u.TelegramUsername, &u.SecurityQuestion, &u.SecurityAnswerHash, &u.Phone, &u.TelegramID, &u.TelegramUsername, &u.SecurityQuestion, &u.SecurityAnswerHash,
&u.CreatedAt) &u.CreatedAt, &u.UsernameChangeCount, &u.UsernameCooldownUntil,
&u.FrozenAt, &u.ClosedAt)
if err != nil { if err != nil {
return nil, err return nil, err
} }
u.HasSecurityAnswer = u.SecurityAnswerHash != "" u.HasSecurityAnswer = u.SecurityAnswerHash != ""
now := time.Now()
u.ApplyUsernameQuota(now)
u.ApplyFreezeState(now)
return &u, nil return &u, nil
} }
// humanOnly keeps bot accounts, which share the mentor role, out of people lists.
const humanOnly = `NOT EXISTS (SELECT 1 FROM bot_tokens WHERE bot_user_id = users.id)`
const userCols = `id, name, email, username, password_hash, role, xp, hearts, hearts_updated_at, const userCols = `id, name, email, username, password_hash, role, xp, hearts, hearts_updated_at,
streak_current, streak_longest, last_activity_date, is_locked, locked_at, streak_current, streak_longest, last_activity_date, is_locked, locked_at,
unlocked_by, is_active, avatar_variant, avatar_palette, avatar_photo, unlocked_by, is_active, avatar_variant, avatar_palette, avatar_photo,
phone, telegram_id, telegram_username, security_question, security_answer_hash, created_at` phone, telegram_id, telegram_username, security_question, security_answer_hash, created_at,
username_change_count, username_cooldown_until, frozen_at, closed_at`
func (s *Store) GetUserByEmail(ctx context.Context, email string) (*models.User, error) { func (s *Store) GetUserByEmail(ctx context.Context, email string) (*models.User, error) {
row := s.pool.QueryRow(ctx, `SELECT `+userCols+` FROM users WHERE email=$1`, email) row := s.pool.QueryRow(ctx, `SELECT `+userCols+` FROM users WHERE email=$1`, email)
@@ -195,7 +214,7 @@ func (s *Store) LockUser(ctx context.Context, userID int64) (*models.User, error
func (s *Store) UnlockUser(ctx context.Context, userID int64, by int64) (*models.User, error) { func (s *Store) UnlockUser(ctx context.Context, userID int64, by int64) (*models.User, error) {
row := s.pool.QueryRow(ctx, ` row := s.pool.QueryRow(ctx, `
UPDATE users SET is_locked=false, locked_at=NULL, unlocked_by=$1, hearts=3, hearts_updated_at=now() UPDATE users SET is_locked=false, locked_at=NULL, unlocked_by=$1, hearts=5, hearts_updated_at=now()
WHERE id=$2 RETURNING `+userCols, by, userID) WHERE id=$2 RETURNING `+userCols, by, userID)
u, err := scanUser(row) u, err := scanUser(row)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
@@ -204,6 +223,65 @@ func (s *Store) UnlockUser(ctx context.Context, userID int64, by int64) (*models
return u, err return u, err
} }
// FreezeUser records the start of the 30-day self-service freeze. It does not touch is_locked.
func (s *Store) FreezeUser(ctx context.Context, id int64) (*models.User, error) {
row := s.pool.QueryRow(ctx, `
UPDATE users SET frozen_at = now()
WHERE id=$1 AND frozen_at IS NULL AND closed_at IS NULL
RETURNING `+userCols, id)
u, err := scanUser(row)
if errors.Is(err, pgx.ErrNoRows) {
existing, gerr := s.GetUserByID(ctx, id)
if gerr != nil {
return nil, gerr
}
if existing.IsClosed {
return nil, ErrAccountClosed
}
return nil, ErrAccountFrozen
}
return u, err
}
// UnfreezeUser clears frozen_at only while the 30-day window is still open and the account is not closed.
func (s *Store) UnfreezeUser(ctx context.Context, id int64) (*models.User, error) {
row := s.pool.QueryRow(ctx, `
UPDATE users SET frozen_at = NULL
WHERE id=$1
AND closed_at IS NULL
AND frozen_at IS NOT NULL
AND now() < frozen_at + interval '30 days'
RETURNING `+userCols, id)
u, err := scanUser(row)
if errors.Is(err, pgx.ErrNoRows) {
existing, gerr := s.GetUserByID(ctx, id)
if gerr != nil {
return nil, gerr
}
if existing.IsClosed {
return nil, ErrAccountClosed
}
return nil, ErrAccountNotFrozen
}
return u, err
}
// MarkAccountClosed sets closed_at once the freeze window has elapsed. A no-op returns the current user.
func (s *Store) MarkAccountClosed(ctx context.Context, id int64) (*models.User, error) {
row := s.pool.QueryRow(ctx, `
UPDATE users SET closed_at = now()
WHERE id=$1
AND closed_at IS NULL
AND frozen_at IS NOT NULL
AND now() >= frozen_at + interval '30 days'
RETURNING `+userCols, id)
u, err := scanUser(row)
if errors.Is(err, pgx.ErrNoRows) {
return s.GetUserByID(ctx, id)
}
return u, err
}
func (s *Store) ListUsers(ctx context.Context, query string, role string, limit, offset int) ([]models.User, int64, error) { func (s *Store) ListUsers(ctx context.Context, query string, role string, limit, offset int) ([]models.User, int64, error) {
if limit <= 0 { if limit <= 0 {
limit = 10 limit = 10
@@ -214,7 +292,7 @@ func (s *Store) ListUsers(ctx context.Context, query string, role string, limit,
if offset < 0 { if offset < 0 {
offset = 0 offset = 0
} }
where := `($1='' OR name ILIKE '%'||$1||'%' OR email ILIKE '%'||$1||'%' OR username ILIKE '%'||$1||'%')` where := `($1='' OR name ILIKE '%'||$1||'%' OR email ILIKE '%'||$1||'%' OR username ILIKE '%'||$1||'%') AND ` + humanOnly
args := []any{query} args := []any{query}
if role != "" { if role != "" {
args = append(args, role) args = append(args, role)
@@ -246,8 +324,47 @@ func (s *Store) ListUsers(ctx context.Context, query string, role string, limit,
return out, total, rows.Err() return out, total, rows.Err()
} }
// ListActiveByRoles returns active users whose role is in roles, ordered by name.
func (s *Store) ListActiveByRoles(ctx context.Context, roles []string, limit, offset int) ([]models.User, int64, error) {
if limit <= 0 {
limit = 20
}
if limit > 50 {
limit = 50
}
if offset < 0 {
offset = 0
}
if len(roles) == 0 {
return []models.User{}, 0, nil
}
var total int64
if err := s.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM users WHERE is_active AND role::text = ANY($1::text[]) AND `+humanOnly, roles).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := s.pool.Query(ctx, `
SELECT `+userCols+` FROM users
WHERE is_active AND role::text = ANY($1::text[]) AND `+humanOnly+`
ORDER BY name ASC
LIMIT $2 OFFSET $3`, roles, limit, offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var out []models.User
for rows.Next() {
u, err := scanUser(rows)
if err != nil {
return nil, 0, err
}
out = append(out, *u)
}
return out, total, rows.Err()
}
func (s *Store) ListMentors(ctx context.Context) ([]models.User, error) { func (s *Store) ListMentors(ctx context.Context) ([]models.User, error) {
rows, err := s.pool.Query(ctx, `SELECT `+userCols+` FROM users WHERE role IN ('mentor','admin') AND is_active ORDER BY name`) rows, err := s.pool.Query(ctx, `SELECT `+userCols+` FROM users WHERE role IN ('mentor','admin') AND is_active AND `+humanOnly+` ORDER BY name`)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -294,6 +411,101 @@ func (s *Store) UpdateUser(ctx context.Context, id int64, name, email, username,
return u, err return u, err
} }
// ChangeUsername sets a new username and consumes one allowance slot.
// The same username is a no-op. The third change in a window starts a cooldown;
// after that cooldown the allowance returns to the limit.
func (s *Store) ChangeUsername(ctx context.Context, id int64, username string, now time.Time) (*models.User, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
var current string
var count int
var until *time.Time
err = tx.QueryRow(ctx, `
SELECT username, username_change_count, username_cooldown_until
FROM users WHERE id=$1 FOR UPDATE`, id).Scan(&current, &count, &until)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
reset := false
if until != nil && !now.Before(*until) {
count = 0
until = nil
reset = true
}
if strings.EqualFold(current, username) {
if reset {
if _, err := tx.Exec(ctx, `
UPDATE users SET username_change_count=0, username_cooldown_until=NULL WHERE id=$1`, id); err != nil {
return nil, err
}
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return s.GetUserByID(ctx, id)
}
if count >= models.UsernameChangeLimit {
if until == nil {
t := now.Add(models.UsernameChangeCooldown)
until = &t
if _, err := tx.Exec(ctx, `
UPDATE users SET username_change_count=$2, username_cooldown_until=$3 WHERE id=$1`,
id, count, until); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
}
return nil, &UsernameCooldownError{Until: *until}
}
var taken int64
err = tx.QueryRow(ctx, `SELECT id FROM users WHERE lower(username)=lower($1) AND id<>$2`, username, id).Scan(&taken)
if err == nil {
return nil, ErrUsernameTaken
}
if !errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
count++
var nextUntil *time.Time
if count >= models.UsernameChangeLimit {
t := now.Add(models.UsernameChangeCooldown)
nextUntil = &t
}
row := tx.QueryRow(ctx, `
UPDATE users
SET username=$2, username_change_count=$3, username_cooldown_until=$4
WHERE id=$1
RETURNING `+userCols, id, username, count, nextUntil)
u, err := scanUser(row)
if err != nil {
if isUniqueViolation(err) {
return nil, ErrUsernameTaken
}
return nil, err
}
if err := tx.Commit(ctx); err != nil {
if isUniqueViolation(err) {
return nil, ErrUsernameTaken
}
return nil, err
}
return u, nil
}
func (s *Store) GetUserByPhone(ctx context.Context, phone string) (*models.User, error) { func (s *Store) GetUserByPhone(ctx context.Context, phone string) (*models.User, error) {
phone = strings.TrimSpace(phone) phone = strings.TrimSpace(phone)
if phone == "" { if phone == "" {
@@ -406,7 +618,7 @@ func IsUniqueViolation(err error) bool {
func (s *Store) CountActiveLessons(ctx context.Context) (int, error) { func (s *Store) CountActiveLessons(ctx context.Context) (int, error) {
var n int var n int
err := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM lessons WHERE is_active`).Scan(&n) err := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM lessons WHERE is_active AND is_published`).Scan(&n)
return n, err return n, err
} }
@@ -415,11 +627,16 @@ func (s *Store) CountPassedActiveLessons(ctx context.Context, userID int64) (int
err := s.pool.QueryRow(ctx, ` err := s.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM lesson_progress lp SELECT COUNT(*) FROM lesson_progress lp
JOIN lessons l ON l.id = lp.lesson_id JOIN lessons l ON l.id = lp.lesson_id
WHERE lp.user_id=$1 AND lp.passed_quiz AND l.is_active`, userID).Scan(&n) WHERE lp.user_id=$1 AND lp.passed_quiz AND l.is_active AND l.is_published`, userID).Scan(&n)
return n, err return n, err
} }
func (s *Store) HasCompletedCurriculum(ctx context.Context, userID int64) (bool, error) { func (s *Store) HasCompletedCurriculum(ctx context.Context, userID int64) (bool, error) {
// An issued (non-revoked) certificate is permanent proof of completion regardless
// of subsequent curriculum changes (e.g. new lessons added after the student finished).
if cert, err := s.GetCertificateByUser(ctx, userID); err == nil && cert.RevokedAt == nil {
return true, nil
}
active, err := s.CountActiveLessons(ctx) active, err := s.CountActiveLessons(ctx)
if err != nil { if err != nil {
return false, err return false, err
@@ -436,42 +653,106 @@ func (s *Store) HasCompletedCurriculum(ctx context.Context, userID int64) (bool,
func (s *Store) GetCertificateByUser(ctx context.Context, userID int64) (*models.Certificate, error) { func (s *Store) GetCertificateByUser(ctx context.Context, userID int64) (*models.Certificate, error) {
row := s.pool.QueryRow(ctx, ` row := s.pool.QueryRow(ctx, `
SELECT id, user_id, public_id, full_name, issued_at, revoked_at SELECT id, user_id, public_id, full_name, issued_at, revoked_at, status
FROM certificates WHERE user_id=$1`, userID) FROM certificates WHERE user_id=$1`, userID)
return scanCertificate(row) return scanCertificate(row)
} }
func (s *Store) GetCertificateByPublicID(ctx context.Context, publicID string) (*models.Certificate, error) { func (s *Store) GetCertificateByPublicID(ctx context.Context, publicID string) (*models.Certificate, error) {
row := s.pool.QueryRow(ctx, ` row := s.pool.QueryRow(ctx, `
SELECT id, user_id, public_id, full_name, issued_at, revoked_at SELECT id, user_id, public_id, full_name, issued_at, revoked_at, status
FROM certificates WHERE public_id=$1`, publicID) FROM certificates WHERE public_id=$1 AND status='issued'`, publicID)
return scanCertificate(row) return scanCertificate(row)
} }
func scanCertificate(row rowScanner) (*models.Certificate, error) { func scanCertificate(row rowScanner) (*models.Certificate, error) {
var c models.Certificate var c models.Certificate
err := row.Scan(&c.ID, &c.UserID, &c.PublicID, &c.FullName, &c.IssuedAt, &c.RevokedAt) var publicID *string
err := row.Scan(&c.ID, &c.UserID, &publicID, &c.FullName, &c.IssuedAt, &c.RevokedAt, &c.Status)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound return nil, ErrNotFound
} }
if err != nil { if err != nil {
return nil, err return nil, err
} }
if publicID != nil {
c.PublicID = *publicID
}
return &c, nil return &c, nil
} }
func (s *Store) CreateCertificate(ctx context.Context, userID int64, publicID, fullName string) (*models.Certificate, error) { func (s *Store) CreateCertificate(ctx context.Context, userID int64, publicID, fullName string) (*models.Certificate, error) {
row := s.pool.QueryRow(ctx, ` row := s.pool.QueryRow(ctx, `
INSERT INTO certificates (user_id, public_id, full_name) INSERT INTO certificates (user_id, public_id, full_name, status)
VALUES ($1, $2, $3) VALUES ($1, $2, $3, 'issued')
ON CONFLICT (user_id) DO UPDATE SET ON CONFLICT (user_id) DO UPDATE SET
full_name = EXCLUDED.full_name, full_name = EXCLUDED.full_name,
public_id = EXCLUDED.public_id,
status = 'issued',
revoked_at = NULL revoked_at = NULL
RETURNING id, user_id, public_id, full_name, issued_at, revoked_at`, RETURNING id, user_id, public_id, full_name, issued_at, revoked_at, status`,
userID, publicID, fullName) userID, publicID, fullName)
return scanCertificate(row) return scanCertificate(row)
} }
func (s *Store) CreatePendingCertificate(ctx context.Context, userID int64, fullName string) (*models.Certificate, error) {
row := s.pool.QueryRow(ctx, `
INSERT INTO certificates (user_id, public_id, full_name, status)
VALUES ($1, NULL, $2, 'pending')
ON CONFLICT (user_id) DO NOTHING
RETURNING id, user_id, public_id, full_name, issued_at, revoked_at, status`,
userID, fullName)
cert, err := scanCertificate(row)
if errors.Is(err, ErrNotFound) {
return s.GetCertificateByUser(ctx, userID)
}
return cert, err
}
func (s *Store) IssuePendingCertificate(ctx context.Context, userID int64, publicID, fullName string) (*models.Certificate, error) {
row := s.pool.QueryRow(ctx, `
UPDATE certificates SET
public_id = $1,
full_name = $2,
status = 'issued',
revoked_at = NULL,
issued_at = NOW()
WHERE user_id = $3
RETURNING id, user_id, public_id, full_name, issued_at, revoked_at, status`,
publicID, fullName, userID)
return scanCertificate(row)
}
func (s *Store) RevokeCertificate(ctx context.Context, userID int64) error {
_, err := s.pool.Exec(ctx, `UPDATE certificates SET revoked_at=NOW() WHERE user_id=$1`, userID)
return err
}
func (s *Store) ListCertificatesByStatus(ctx context.Context, status string) ([]models.Certificate, error) {
rows, err := s.pool.Query(ctx, `
SELECT c.id, c.user_id, c.public_id, c.full_name, c.issued_at, c.revoked_at, c.status
FROM certificates c
WHERE c.status=$1 AND c.revoked_at IS NULL
ORDER BY c.issued_at DESC`, status)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.Certificate
for rows.Next() {
var c models.Certificate
var publicID *string
if err := rows.Scan(&c.ID, &c.UserID, &publicID, &c.FullName, &c.IssuedAt, &c.RevokedAt, &c.Status); err != nil {
return nil, err
}
if publicID != nil {
c.PublicID = *publicID
}
out = append(out, c)
}
return out, rows.Err()
}
const physicalOrderCols = `id, certificate_id, user_id, status, note, const physicalOrderCols = `id, certificate_id, user_id, status, note,
recipient_name, phone, address, city, postal_code, tracking_code, recipient_name, phone, address, city, postal_code, tracking_code,
window_ends_at, created_at, updated_at` window_ends_at, created_at, updated_at`
@@ -667,16 +948,22 @@ func (s *Store) DeleteUser(ctx context.Context, id int64) error {
// ---- Chapters / Lessons / Questions ---- // ---- Chapters / Lessons / Questions ----
func (s *Store) CreateChapter(ctx context.Context, c *models.Chapter) (int64, error) { func (s *Store) CreateChapter(ctx context.Context, c *models.Chapter) (int64, error) {
c.IsPublished = false
err := s.pool.QueryRow(ctx, ` err := s.pool.QueryRow(ctx, `
INSERT INTO chapters (title, description, icon, sort_order) VALUES ($1,$2,$3,$4) RETURNING id`, INSERT INTO chapters (title, description, icon, sort_order, is_published) VALUES ($1,$2,$3,$4,$5) RETURNING id`,
c.Title, c.Description, c.Icon, c.SortOrder).Scan(&c.ID) c.Title, c.Description, c.Icon, c.SortOrder, c.IsPublished).Scan(&c.ID)
return c.ID, err return c.ID, err
} }
func (s *Store) UpdateChapter(ctx context.Context, id int64, c *models.Chapter) error { func (s *Store) UpdateChapter(ctx context.Context, id int64, c *models.Chapter) error {
_, err := s.pool.Exec(ctx, ` _, err := s.pool.Exec(ctx, `
UPDATE chapters SET title=$1, description=$2, icon=$3, sort_order=$4 WHERE id=$5`, UPDATE chapters SET title=$1, description=$2, icon=$3, sort_order=$4, is_published=$5 WHERE id=$6`,
c.Title, c.Description, c.Icon, c.SortOrder, id) c.Title, c.Description, c.Icon, c.SortOrder, c.IsPublished, id)
return err
}
func (s *Store) PublishChapter(ctx context.Context, id int64, publish bool) error {
_, err := s.pool.Exec(ctx, `UPDATE chapters SET is_published=$1 WHERE id=$2`, publish, id)
return err return err
} }
@@ -686,7 +973,7 @@ func (s *Store) DeleteChapter(ctx context.Context, id int64) error {
} }
func (s *Store) ListChapters(ctx context.Context) ([]models.Chapter, error) { func (s *Store) ListChapters(ctx context.Context) ([]models.Chapter, error) {
rows, err := s.pool.Query(ctx, `SELECT id, title, description, icon, sort_order FROM chapters ORDER BY sort_order, id`) rows, err := s.pool.Query(ctx, `SELECT id, title, description, icon, sort_order, is_published FROM chapters ORDER BY sort_order, id`)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -694,7 +981,7 @@ func (s *Store) ListChapters(ctx context.Context) ([]models.Chapter, error) {
var out []models.Chapter var out []models.Chapter
for rows.Next() { for rows.Next() {
var c models.Chapter var c models.Chapter
if err := rows.Scan(&c.ID, &c.Title, &c.Description, &c.Icon, &c.SortOrder); err != nil { if err := rows.Scan(&c.ID, &c.Title, &c.Description, &c.Icon, &c.SortOrder, &c.IsPublished); err != nil {
return nil, err return nil, err
} }
out = append(out, c) out = append(out, c)
@@ -703,22 +990,28 @@ func (s *Store) ListChapters(ctx context.Context) ([]models.Chapter, error) {
} }
func (s *Store) CreateLesson(ctx context.Context, l *models.Lesson) (int64, error) { func (s *Store) CreateLesson(ctx context.Context, l *models.Lesson) (int64, error) {
l.IsPublished = false
err := s.pool.QueryRow(ctx, ` err := s.pool.QueryRow(ctx, `
INSERT INTO lessons (chapter_id, title, description, video_key, duration_seconds, INSERT INTO lessons (chapter_id, title, description, video_key, duration_seconds,
completion_threshold_pct, requires_lesson_id, x, y, sort_order, xp_reward, is_active) completion_threshold_pct, requires_lesson_id, x, y, sort_order, xp_reward, is_active, is_published)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) RETURNING id`, VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING id`,
l.ChapterID, l.Title, l.Description, l.VideoKey, l.DurationSeconds, l.ChapterID, l.Title, l.Description, l.VideoKey, l.DurationSeconds,
l.CompletionThresholdPct, l.RequiresLessonID, l.X, l.Y, l.SortOrder, l.XPReward, l.IsActive).Scan(&l.ID) l.CompletionThresholdPct, l.RequiresLessonID, l.X, l.Y, l.SortOrder, l.XPReward, l.IsActive, l.IsPublished).Scan(&l.ID)
return l.ID, err return l.ID, err
} }
func (s *Store) UpdateLesson(ctx context.Context, id int64, l *models.Lesson) error { func (s *Store) UpdateLesson(ctx context.Context, id int64, l *models.Lesson) error {
_, err := s.pool.Exec(ctx, ` _, err := s.pool.Exec(ctx, `
UPDATE lessons SET chapter_id=$1, title=$2, description=$3, video_key=$4, duration_seconds=$5, UPDATE lessons SET chapter_id=$1, title=$2, description=$3, video_key=$4, duration_seconds=$5,
completion_threshold_pct=$6, requires_lesson_id=$7, x=$8, y=$9, sort_order=$10, xp_reward=$11, is_active=$12 completion_threshold_pct=$6, requires_lesson_id=$7, x=$8, y=$9, sort_order=$10, xp_reward=$11, is_active=$12, is_published=$13
WHERE id=$13`, WHERE id=$14`,
l.ChapterID, l.Title, l.Description, l.VideoKey, l.DurationSeconds, l.ChapterID, l.Title, l.Description, l.VideoKey, l.DurationSeconds,
l.CompletionThresholdPct, l.RequiresLessonID, l.X, l.Y, l.SortOrder, l.XPReward, l.IsActive, id) l.CompletionThresholdPct, l.RequiresLessonID, l.X, l.Y, l.SortOrder, l.XPReward, l.IsActive, l.IsPublished, id)
return err
}
func (s *Store) PublishLesson(ctx context.Context, id int64, publish bool) error {
_, err := s.pool.Exec(ctx, `UPDATE lessons SET is_published=$1 WHERE id=$2`, publish, id)
return err return err
} }
@@ -730,11 +1023,11 @@ func (s *Store) DeleteLesson(ctx context.Context, id int64) error {
func (s *Store) GetLesson(ctx context.Context, id int64) (*models.Lesson, error) { func (s *Store) GetLesson(ctx context.Context, id int64) (*models.Lesson, error) {
row := s.pool.QueryRow(ctx, ` row := s.pool.QueryRow(ctx, `
SELECT id, chapter_id, title, description, video_key, duration_seconds, SELECT id, chapter_id, title, description, video_key, duration_seconds,
completion_threshold_pct, requires_lesson_id, x, y, sort_order, xp_reward, is_active completion_threshold_pct, requires_lesson_id, x, y, sort_order, xp_reward, is_active, is_published
FROM lessons WHERE id=$1`, id) FROM lessons WHERE id=$1`, id)
var l models.Lesson var l models.Lesson
err := row.Scan(&l.ID, &l.ChapterID, &l.Title, &l.Description, &l.VideoKey, &l.DurationSeconds, err := row.Scan(&l.ID, &l.ChapterID, &l.Title, &l.Description, &l.VideoKey, &l.DurationSeconds,
&l.CompletionThresholdPct, &l.RequiresLessonID, &l.X, &l.Y, &l.SortOrder, &l.XPReward, &l.IsActive) &l.CompletionThresholdPct, &l.RequiresLessonID, &l.X, &l.Y, &l.SortOrder, &l.XPReward, &l.IsActive, &l.IsPublished)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound return nil, ErrNotFound
} }
@@ -743,9 +1036,9 @@ func (s *Store) GetLesson(ctx context.Context, id int64) (*models.Lesson, error)
func (s *Store) ListLessons(ctx context.Context, includeInactive bool) ([]models.Lesson, error) { func (s *Store) ListLessons(ctx context.Context, includeInactive bool) ([]models.Lesson, error) {
q := `SELECT id, chapter_id, title, description, video_key, duration_seconds, q := `SELECT id, chapter_id, title, description, video_key, duration_seconds,
completion_threshold_pct, requires_lesson_id, x, y, sort_order, xp_reward, is_active FROM lessons` completion_threshold_pct, requires_lesson_id, x, y, sort_order, xp_reward, is_active, is_published FROM lessons`
if !includeInactive { if !includeInactive {
q += ` WHERE is_active` q += ` WHERE is_active AND is_published`
} }
q += ` ORDER BY chapter_id, sort_order, id` q += ` ORDER BY chapter_id, sort_order, id`
rows, err := s.pool.Query(ctx, q) rows, err := s.pool.Query(ctx, q)
@@ -757,7 +1050,7 @@ func (s *Store) ListLessons(ctx context.Context, includeInactive bool) ([]models
for rows.Next() { for rows.Next() {
var l models.Lesson var l models.Lesson
if err := rows.Scan(&l.ID, &l.ChapterID, &l.Title, &l.Description, &l.VideoKey, &l.DurationSeconds, if err := rows.Scan(&l.ID, &l.ChapterID, &l.Title, &l.Description, &l.VideoKey, &l.DurationSeconds,
&l.CompletionThresholdPct, &l.RequiresLessonID, &l.X, &l.Y, &l.SortOrder, &l.XPReward, &l.IsActive); err != nil { &l.CompletionThresholdPct, &l.RequiresLessonID, &l.X, &l.Y, &l.SortOrder, &l.XPReward, &l.IsActive, &l.IsPublished); err != nil {
return nil, err return nil, err
} }
out = append(out, l) out = append(out, l)
@@ -1072,29 +1365,48 @@ func (s *Store) EventRsvpUsers(ctx context.Context, eventID int64) ([]models.Use
// ---- Chat ---- // ---- Chat ----
func (s *Store) AddChatMessage(ctx context.Context, userID, mentorID int64, senderRole models.Role, body string, replyTo *int64, att *models.Attachment) (*models.ChatMessage, error) { func packChatAttachments(atts []models.Attachment) (typ, url, name *string, size *int64, raw []byte) {
var attType, attURL, attName *string if len(atts) == 0 {
var attSize *int64 return nil, nil, nil, nil, nil
if att != nil { }
attType, attURL, attName = &att.Type, &att.URL, &att.Name first := atts[0]
attSize = &att.Size typ = new(string)
*typ = first.Type
url = new(string)
*url = first.URL
name = new(string)
*name = first.Name
sz := first.Size
size = &sz
if len(atts) > 1 {
if b, err := json.Marshal(atts); err == nil {
raw = b
}
}
return
}
func (s *Store) AddChatMessage(ctx context.Context, userID, mentorID int64, senderRole models.Role, body string, replyTo *int64, atts []models.Attachment) (*models.ChatMessage, error) {
attType, attURL, attName, attSize, raw := packChatAttachments(atts)
var rawArg any
if len(raw) > 0 {
rawArg = string(raw)
} }
row := s.pool.QueryRow(ctx, ` row := s.pool.QueryRow(ctx, `
INSERT INTO chat_messages (user_id, mentor_id, sender_role, body, reply_to, attachment_type, attachment_url, attachment_name, attachment_size) INSERT INTO chat_messages (user_id, mentor_id, sender_role, body, reply_to, attachment_type, attachment_url, attachment_name, attachment_size, attachments)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb)
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to, 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`, attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at, buttons, attachments`,
userID, mentorID, senderRole.String(), body, replyTo, attType, attURL, attName, attSize) userID, mentorID, senderRole.String(), body, replyTo, attType, attURL, attName, attSize, rawArg)
return scanMessage(row) return scanMessage(row)
} }
// ImportChatMessage inserts a historical message (backup restore), preserving created_at / pin. // ImportChatMessage inserts a historical message (backup restore), preserving created_at / pin.
func (s *Store) ImportChatMessage(ctx context.Context, userID, mentorID int64, senderRole models.Role, body string, att *models.Attachment, createdAt time.Time, pinned bool) (*models.ChatMessage, error) { func (s *Store) ImportChatMessage(ctx context.Context, userID, mentorID int64, senderRole models.Role, body string, atts []models.Attachment, createdAt time.Time, pinned bool) (*models.ChatMessage, error) {
var attType, attURL, attName *string attType, attURL, attName, attSize, raw := packChatAttachments(atts)
var attSize *int64 var rawArg any
if att != nil { if len(raw) > 0 {
attType, attURL, attName = &att.Type, &att.URL, &att.Name rawArg = string(raw)
attSize = &att.Size
} }
if createdAt.IsZero() { if createdAt.IsZero() {
createdAt = time.Now().UTC() createdAt = time.Now().UTC()
@@ -1105,11 +1417,11 @@ func (s *Store) ImportChatMessage(ctx context.Context, userID, mentorID int64, s
pinnedAt = &t pinnedAt = &t
} }
row := s.pool.QueryRow(ctx, ` row := s.pool.QueryRow(ctx, `
INSERT INTO chat_messages (user_id, mentor_id, sender_role, body, attachment_type, attachment_url, attachment_name, attachment_size, created_at, pinned_at) INSERT INTO chat_messages (user_id, mentor_id, sender_role, body, attachment_type, attachment_url, attachment_name, attachment_size, attachments, created_at, pinned_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,$10,$11)
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to, 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`, attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at, buttons, attachments`,
userID, mentorID, senderRole.String(), body, attType, attURL, attName, attSize, createdAt, pinnedAt) userID, mentorID, senderRole.String(), body, attType, attURL, attName, attSize, rawArg, createdAt, pinnedAt)
return scanMessage(row) return scanMessage(row)
} }
@@ -1155,18 +1467,28 @@ func (s *Store) DeleteChatMessage(ctx context.Context, meID, partnerID, msgID in
// ReplaceChatAttachment swaps the photo, video, or voice on one of my messages and marks it edited. // ReplaceChatAttachment swaps the photo, video, or voice on one of my messages and marks it edited.
// The new type must match the current attachment. // 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) { func (s *Store) ReplaceChatAttachment(ctx context.Context, meID, partnerID, msgID int64, meRole models.Role, body, attType, attURL, attName string, attSize int64) (*models.ChatMessage, error) {
elem, err := json.Marshal(models.Attachment{Type: attType, URL: attURL, Name: attName, Size: attSize})
if err != nil {
return nil, err
}
row := s.pool.QueryRow(ctx, ` row := s.pool.QueryRow(ctx, `
UPDATE chat_messages UPDATE chat_messages
SET body=$1, edited_at=now(), SET body=$1, edited_at=now(),
attachment_type=$2, attachment_url=$3, attachment_name=$4, attachment_size=$5 attachment_type=$2, attachment_url=$3, attachment_name=$4, attachment_size=$5,
WHERE id=$6 attachments = CASE
AND ((user_id=$7 AND mentor_id=$8) OR (user_id=$8 AND mentor_id=$7)) WHEN attachments IS NULL THEN attachments
AND sender_role=$9 WHEN jsonb_typeof(attachments) <> 'array' THEN attachments
AND (user_id=$7 OR mentor_id=$7) WHEN jsonb_array_length(attachments) = 0 THEN attachments
ELSE jsonb_set(attachments, '{0}', $6::jsonb, false)
END
WHERE id=$7
AND ((user_id=$8 AND mentor_id=$9) OR (user_id=$9 AND mentor_id=$8))
AND sender_role=$10
AND (user_id=$8 OR mentor_id=$8)
AND attachment_type=$2 AND attachment_type=$2
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to, 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`, attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at, buttons, attachments`,
body, attType, attURL, attName, attSize, msgID, meID, partnerID, meRole.String()) body, attType, attURL, attName, attSize, string(elem), msgID, meID, partnerID, meRole.String())
m, err := scanMessage(row) m, err := scanMessage(row)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound return nil, ErrNotFound
@@ -1185,7 +1507,7 @@ func (s *Store) EditChatMessage(ctx context.Context, meID, partnerID, msgID int6
AND (user_id=$3 OR mentor_id=$3) AND (user_id=$3 OR mentor_id=$3)
AND ($1 <> '' OR attachment_url IS NOT NULL) AND ($1 <> '' OR attachment_url IS NOT NULL)
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to, 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`, attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at, buttons, attachments`,
body, msgID, meID, partnerID, meRole.String()) body, msgID, meID, partnerID, meRole.String())
m, err := scanMessage(row) m, err := scanMessage(row)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
@@ -1194,6 +1516,51 @@ func (s *Store) EditChatMessage(ctx context.Context, meID, partnerID, msgID int6
return m, err return m, err
} }
// GetOwnChatMessage loads one of my messages in this conversation, including its attachments.
func (s *Store) GetOwnChatMessage(ctx context.Context, meID, partnerID, msgID int64, meRole models.Role) (*models.ChatMessage, error) {
row := s.pool.QueryRow(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, edited_at, buttons, attachments
FROM chat_messages
WHERE id=$1
AND ((user_id=$2 AND mentor_id=$3) OR (user_id=$3 AND mentor_id=$2))
AND sender_role=$4
AND (user_id=$2 OR mentor_id=$2)`,
msgID, meID, partnerID, meRole.String())
m, err := scanMessage(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return m, err
}
// SaveEditedChatMessage writes the caption and the full attachment set for one of my messages.
// Callers pass the attachments that should remain; an empty list is stored as no attachment.
func (s *Store) SaveEditedChatMessage(ctx context.Context, meID, partnerID, msgID int64, meRole models.Role, body string, atts []models.Attachment) (*models.ChatMessage, error) {
attType, attURL, attName, attSize, raw := packChatAttachments(atts)
var rawArg any
if len(raw) > 0 {
rawArg = string(raw)
}
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,
attachments=$6::jsonb
WHERE id=$7
AND ((user_id=$8 AND mentor_id=$9) OR (user_id=$9 AND mentor_id=$8))
AND sender_role=$10
AND (user_id=$8 OR mentor_id=$8)
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, buttons, attachments`,
body, attType, attURL, attName, attSize, rawArg, msgID, meID, partnerID, meRole.String())
m, err := scanMessage(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return m, err
}
const maxPinnedMessages = 3 const maxPinnedMessages = 3
// TogglePinChatMessage pins or unpins a message. If already at the pin limit, // TogglePinChatMessage pins or unpins a message. If already at the pin limit,
@@ -1216,7 +1583,7 @@ func (s *Store) TogglePinChatMessage(ctx context.Context, meID, partnerID, msgID
out := s.pool.QueryRow(ctx, ` out := s.pool.QueryRow(ctx, `
UPDATE chat_messages SET pinned_at=NULL WHERE id=$1 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, 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`, msgID) attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at, buttons, attachments`, msgID)
return scanMessage(out) return scanMessage(out)
} }
var n int var n int
@@ -1244,7 +1611,7 @@ func (s *Store) TogglePinChatMessage(ctx context.Context, meID, partnerID, msgID
out := s.pool.QueryRow(ctx, ` out := s.pool.QueryRow(ctx, `
UPDATE chat_messages SET pinned_at=now() WHERE id=$1 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, 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`, msgID) attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at, buttons, attachments`, msgID)
return scanMessage(out) return scanMessage(out)
} }
@@ -1252,7 +1619,7 @@ func (s *Store) TogglePinChatMessage(ctx context.Context, meID, partnerID, msgID
func (s *Store) ListPinnedChatMessages(ctx context.Context, userID, mentorID int64) ([]models.ChatMessage, error) { func (s *Store) ListPinnedChatMessages(ctx context.Context, userID, mentorID int64) ([]models.ChatMessage, error) {
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to, 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, edited_at attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at, buttons, attachments
FROM chat_messages FROM chat_messages
WHERE pinned_at IS NOT NULL WHERE pinned_at IS NOT NULL
AND ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1)) AND ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1))
@@ -1278,7 +1645,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) { func (s *Store) ListChatMessages(ctx context.Context, userID, mentorID, before, limit int64) ([]models.ChatMessage, error) {
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to, 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, edited_at attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at, buttons, attachments
FROM chat_messages FROM chat_messages
WHERE ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1)) WHERE ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1))
AND ($3 = 0 OR id < $3) AND ($3 = 0 OR id < $3)
@@ -1326,7 +1693,7 @@ func (s *Store) ListChatMessagesAround(ctx context.Context, userID, partnerID, a
older, err := s.pool.Query(ctx, ` older, err := s.pool.Query(ctx, `
SELECT id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to, 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, edited_at attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at, buttons, attachments
FROM chat_messages FROM chat_messages
WHERE ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1)) WHERE ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1))
AND id <= $3 AND id <= $3
@@ -1358,7 +1725,7 @@ func (s *Store) ListChatMessagesAround(ctx context.Context, userID, partnerID, a
newer, err := s.pool.Query(ctx, ` newer, err := s.pool.Query(ctx, `
SELECT id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to, 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, edited_at attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at, buttons, attachments
FROM chat_messages FROM chat_messages
WHERE ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1)) WHERE ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1))
AND id > $3 AND id > $3
@@ -1399,14 +1766,27 @@ func scanMessage(row messageScanner) (*models.ChatMessage, error) {
var m models.ChatMessage var m models.ChatMessage
var attType, attURL, attName *string var attType, attURL, attName *string
var attSize *int64 var attSize *int64
var buttons []byte
var rawAtts []byte
if err := row.Scan( if err := row.Scan(
&m.ID, &m.UserID, &m.MentorID, &m.SenderRole, &m.Body, &m.ReadAt, &m.CreatedAt, &m.ReplyTo, &m.ID, &m.UserID, &m.MentorID, &m.SenderRole, &m.Body, &m.ReadAt, &m.CreatedAt, &m.ReplyTo,
&attType, &attURL, &attName, &attSize, &m.PinnedAt, &m.EditedAt, &attType, &attURL, &attName, &attSize, &m.PinnedAt, &m.EditedAt, &buttons, &rawAtts,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
if len(buttons) > 0 && string(buttons) != "[]" && string(buttons) != "null" {
m.Buttons = buttons
}
m.Pinned = m.PinnedAt != nil m.Pinned = m.PinnedAt != nil
if attURL != nil && *attURL != "" { if len(rawAtts) > 0 && string(rawAtts) != "null" && string(rawAtts) != "[]" {
var list []models.Attachment
if err := json.Unmarshal(rawAtts, &list); err == nil && len(list) > 0 {
m.Attachments = list
first := list[0]
m.Attachment = &first
}
}
if m.Attachment == nil && attURL != nil && *attURL != "" {
m.Attachment = &models.Attachment{ m.Attachment = &models.Attachment{
Type: derefStr(attType), URL: *attURL, Name: derefStr(attName), Size: derefInt(attSize), Type: derefStr(attType), URL: *attURL, Name: derefStr(attName), Size: derefInt(attSize),
} }
+3
View File
@@ -63,6 +63,9 @@ services:
- --entrypoints.web.http.redirections.entrypoint.permanent=true - --entrypoints.web.http.redirections.entrypoint.permanent=true
- --entrypoints.web.http.aliasHeadersStrategy=delete - --entrypoints.web.http.aliasHeadersStrategy=delete
- --entrypoints.websecure.http.aliasHeadersStrategy=delete - --entrypoints.websecure.http.aliasHeadersStrategy=delete
# Allow slow, large uploads (admin video ≤ 200MB); default is 60s.
- --entrypoints.web.transport.respondingTimeouts.readTimeout=3600
- --entrypoints.websecure.transport.respondingTimeouts.readTimeout=3600
# Dev: Traefik default certificate (browser warning expected on localhost). # Dev: Traefik default certificate (browser warning expected on localhost).
- --api.dashboard=true - --api.dashboard=true
- --api.insecure=true - --api.insecure=true
+3
View File
@@ -71,6 +71,9 @@ services:
- --entrypoints.web.http.redirections.entrypoint.permanent=true - --entrypoints.web.http.redirections.entrypoint.permanent=true
- --entrypoints.web.http.aliasHeadersStrategy=delete - --entrypoints.web.http.aliasHeadersStrategy=delete
- --entrypoints.websecure.http.aliasHeadersStrategy=delete - --entrypoints.websecure.http.aliasHeadersStrategy=delete
# Allow slow, large uploads (admin video ≤ 200MB); default is 60s.
- --entrypoints.web.transport.respondingTimeouts.readTimeout=3600
- --entrypoints.websecure.transport.respondingTimeouts.readTimeout=3600
- --certificatesresolvers.le.acme.email=${ACME_EMAIL:?ACME_EMAIL must be set} - --certificatesresolvers.le.acme.email=${ACME_EMAIL:?ACME_EMAIL must be set}
- --certificatesresolvers.le.acme.storage=/data/acme.json - --certificatesresolvers.le.acme.storage=/data/acme.json
- --certificatesresolvers.le.acme.httpchallenge=true - --certificatesresolvers.le.acme.httpchallenge=true
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: pargar name: pargar
description: Pargar gated LMS - Go API, Next.js frontend, Postgres, Redis, S3-compatible storage description: Pargar gated LMS - Go API, Next.js frontend, Postgres, Redis, S3-compatible storage
type: application type: application
version: 0.2.1 version: 0.2.3
appVersion: "0.1.3" appVersion: "0.2.1"
keywords: keywords:
- lms - lms
- pargar - pargar
+81
View File
@@ -0,0 +1,81 @@
backend:
autoscaling:
enabled: false
env:
devMode: "false"
donationEnabled: "true"
otelEndpoint: http://tempo.infra.svc.cluster.local:4318
physicalCertEnabled: "false"
seedCurriculum: "false"
existingSecret: pargar-app
image:
pullPolicy: IfNotPresent
repository: pargar-backend
tag: 0.2.1
metrics:
serviceMonitor:
enabled: true
interval: 30s
podDisruptionBudget:
enabled: true
minAvailable: 1
replicaCount: 3
resources:
limits:
cpu: "1"
memory: 512Mi
requests:
cpu: 100m
memory: 128Mi
backup:
enabled: true
persistence:
size: 2Gi
retentionDays: 7
schedule: 0 3 * * *
certManager:
enabled: false
frontend:
autoscaling:
enabled: false
image:
pullPolicy: IfNotPresent
repository: pargar-frontend
tag: 0.2.1
podDisruptionBudget:
enabled: false
replicaCount: 2
resources:
limits:
cpu: "1"
memory: 512Mi
requests:
cpu: 100m
memory: 128Mi
global:
domain: pargar.panahifar.ir
frontendOrigins: https://pargar.panahifar.ir
imageRegistry: ""
publicUrl: https://pargar.panahifar.ir
imagePullSecrets: []
ingress:
enabled: true
tls:
enabled: true
existingSecret: pargar-wildcard-tls
useIngressRoute: true
networkPolicy:
enabled: false
postgres:
enabled: true
persistence:
size: 10Gi
redis:
enabled: true
persistence:
size: 2Gi
rustfs:
consoleEnable: false
enabled: true
persistence:
size: 20Gi
+2 -2
View File
@@ -22,7 +22,7 @@ backend:
replicaCount: 2 replicaCount: 2
image: image:
repository: backend repository: backend
tag: "0.1.3" tag: "0.2.1"
pullPolicy: IfNotPresent pullPolicy: IfNotPresent
existingSecret: pargar-app existingSecret: pargar-app
env: env:
@@ -49,7 +49,7 @@ frontend:
replicaCount: 2 replicaCount: 2
image: image:
repository: frontend repository: frontend
tag: "0.1.3" tag: "0.2.1"
pullPolicy: IfNotPresent pullPolicy: IfNotPresent
resources: resources:
requests: requests:
+43
View File
@@ -0,0 +1,43 @@
# k3s Traefik customisation (HelmChartConfig for the built-in `traefik` chart).
#
# Apply with: kubectl apply -f deploy/k3s/traefik-helmchartconfig.yaml
#
# Why: Traefik's entrypoint `readTimeout` defaults to 60s and covers reading the
# *entire request including the body*. Admin video uploads (up to 200MB) that take
# longer than 60s were cut off with a 502 before reaching the backend. Raise it to
# 1 hour (use 0 to disable entirely).
apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
name: traefik
namespace: kube-system
spec:
failurePolicy: reinstall
valuesContent: |-
# ACCESSLOG tile -> ON; access logs go to container stdout (kubectl logs).
# Key is logs.access.enabled on the chart k3s ships (40.1.5);
# the top-level accessLog.enabled only exists on newer chart versions.
logs:
access:
enabled: true
# Allow IngressRoutes to reference Services living in other namespaces,
# e.g. argocd-server in `argocd`.
providers:
kubernetesCRD:
allowCrossNamespace: true
deployment:
podAnnotations:
# fix: metrics entrypoint listens on :9100, annotation said 8082
prometheus.io/port: "9100"
prometheus.io/scrape: "true"
# Allow slow, large uploads (admin video upload ≤ 200MB) to finish.
# Default is 60s; 3600s = 1h. Set to 0 to disable the cap.
ports:
web:
transport:
respondingTimeouts:
readTimeout: 3600
websecure:
transport:
respondingTimeouts:
readTimeout: 3600
+844 -3
View File
@@ -16,13 +16,20 @@
"@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2",
"@tanstack/react-query": "^5.62.0", "@tanstack/react-query": "^5.62.0",
"@tiptap/extension-placeholder": "^3.32.0",
"@tiptap/react": "^3.32.0",
"@tiptap/starter-kit": "^3.32.0",
"@types/dompurify": "^3.0.5",
"boring-avatars": "^2.0.4", "boring-avatars": "^2.0.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dompurify": "^3.4.17",
"emoji-datasource-apple": "^15.1.2", "emoji-datasource-apple": "^15.1.2",
"framer-motion": "^12.0.0", "framer-motion": "^12.0.0",
"hls.js": "^1.7.3",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"marked": "^18.1.1",
"next": "15.5.27", "next": "15.5.27",
"next-themes": "^0.4.4", "next-themes": "^0.4.4",
"react": "^19.0.0", "react": "^19.0.0",
@@ -30,6 +37,7 @@
"react-multi-date-picker": "^4.5.2", "react-multi-date-picker": "^4.5.2",
"sonner": "^1.7.1", "sonner": "^1.7.1",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"tiptap-markdown": "^0.9.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"zustand": "^5.0.2" "zustand": "^5.0.2"
}, },
@@ -1772,6 +1780,480 @@
"react": "^18 || ^19" "react": "^18 || ^19"
} }
}, },
"node_modules/@tiptap/core": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.32.0.tgz",
"integrity": "sha512-Ghh2h02GKSqzytQGcLV4espYOG4EHxjj0MsrUB2Vnr47xmlZYRf6r7k2J09H+JpBdaGrWwrTkyBEi5i3c3yy2Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "3.32.0"
}
},
"node_modules/@tiptap/extension-blockquote": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.32.0.tgz",
"integrity": "sha512-wmuthEdlYVArNoUbqFPH35giutBABFu7fqEB0lTD1k0UXJUs4dFvcbRyGWWgnHFZgP3TT4u8e+xiayYw0eQ0NQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0",
"@tiptap/pm": "3.32.0"
}
},
"node_modules/@tiptap/extension-bold": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.32.0.tgz",
"integrity": "sha512-SQYewnYDoIBN+8oH53IDcD8p+Pz+Jnyq3hrxjy/FAo5ctc+96d3ZLdqc9gzLVSqFFo/SUINv2FVGoq9PvU7yqw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0"
}
},
"node_modules/@tiptap/extension-bubble-menu": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.32.0.tgz",
"integrity": "sha512-X3ll8i9Z8QNvt7+tcqlwopJG+68ugO13Av0Ox4Ico+jXcTnFdDuKQJBSI9sLuMR5vTQOeTHhKYXhjF88aahMSA==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/dom": "^1.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0",
"@tiptap/pm": "3.32.0"
}
},
"node_modules/@tiptap/extension-bullet-list": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.32.0.tgz",
"integrity": "sha512-Y5P/l06pVCUG46vXpBl3z0fW2b6JpSMX4Cvr+5sfQh8zB3TxwSiLQaaPwI3R5MFD1KfRk3AjiPRieEyXdWcNHw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.32.0"
}
},
"node_modules/@tiptap/extension-code": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.32.0.tgz",
"integrity": "sha512-I+aEVl0dTmkfnU6HzBGEefb9U7ja2MkucwBBwrmoMwU+uIKpC4xw8NcHC9X6sjAcom3Pxs2HX+8WEo4Vp13R/Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0"
}
},
"node_modules/@tiptap/extension-code-block": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.32.0.tgz",
"integrity": "sha512-9NZW/yXP9j9XKr+hRS5m58xiyZI+tQo1PJ7eLHXjGNFWaF601NG/h6741mbKh5uCY51XsbyqG+7que+RavngPA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0",
"@tiptap/pm": "3.32.0"
}
},
"node_modules/@tiptap/extension-document": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.32.0.tgz",
"integrity": "sha512-GugeKE6AnZDluRXJUNBuX43TPcaJxy5wck6vT2J4a0/EnKStJ43suSZZf1IPfb1ij9EL9bYvAwg9DOcrvdEzTQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0"
}
},
"node_modules/@tiptap/extension-dropcursor": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.32.0.tgz",
"integrity": "sha512-t4Ug8hFLt30dwwYvrh63BVUt6N1Yg5PVI3tMllhCmN3Q68oD47v4MB/i5nGfzIsQqMyXUOetwHJWcZ4MoLTx4Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.32.0"
}
},
"node_modules/@tiptap/extension-floating-menu": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.32.0.tgz",
"integrity": "sha512-AzKCWceL+4Q1pTc8Whw1kPqN/aYGPQ70/jXPsblWl501t/pXi6Kz0Pi/NggKkuu73VGjykh68YYf/dcOJ/mPZQ==",
"license": "MIT",
"optional": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@floating-ui/dom": "^1.0.0",
"@tiptap/core": "3.32.0",
"@tiptap/pm": "3.32.0"
}
},
"node_modules/@tiptap/extension-gapcursor": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.32.0.tgz",
"integrity": "sha512-HUpKKWJLtthnpQY5LIjHlJJOmh562RusmOMIe/4iP/H7y+WUR9ZFiZjEs1NBkRjig9fwj8F3BfsrVPYBnM4nLA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.32.0"
}
},
"node_modules/@tiptap/extension-hard-break": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.32.0.tgz",
"integrity": "sha512-IKUYvK+bO2hGrDWhhTVg0CSh9C2belWg9GyLzvpyd3SZyQ9EgdccLfoytNjOBKbAE/TQ4Oo3kSP+XF6RsUIKPA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0"
}
},
"node_modules/@tiptap/extension-heading": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.32.0.tgz",
"integrity": "sha512-XRiZxZuhfjvbgwCvoBHj04gOsPANEXxxRwrFCQVuFi5yeTHYIzMEioc81j6iAH9pV7aQeJCwLxuh6VHNz3dXEQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0"
}
},
"node_modules/@tiptap/extension-horizontal-rule": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.32.0.tgz",
"integrity": "sha512-Iry7ynsG4xgcxnH1cm+uXJnEtUzoop4sGA10NUN9I+08TOjhj/GrDiRKMQHKAiVI/R7t2MVav1MJlxFf2FdzGw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0",
"@tiptap/pm": "3.32.0"
}
},
"node_modules/@tiptap/extension-italic": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.32.0.tgz",
"integrity": "sha512-KB/i1xoHilbBGcQ+UotDWhi60KpdCde3RzRIpl2na0Yol4z4Fe2IRWg8ljIrlsssDRwel/fsHJbG7a13EP8oKA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0"
}
},
"node_modules/@tiptap/extension-link": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.32.0.tgz",
"integrity": "sha512-DRdQSgWPSEjrDg5EDBB5yxRygHtUVLhceVGzAsTz8RziiFyvcwAvzBHCO9hd9jutjCXQ2DLIuZB8P+aHhFWnpg==",
"license": "MIT",
"dependencies": {
"linkifyjs": "^4.3.3"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0",
"@tiptap/pm": "3.32.0"
}
},
"node_modules/@tiptap/extension-list": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.32.0.tgz",
"integrity": "sha512-QdzS5nFz9agAQhBgIuQluobWib39W2cMKx1XB6tYSue7bHMJdgPFP0EnGfFajaeVX42CG6RXtQWrNfyQGKhBvg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0",
"@tiptap/pm": "3.32.0"
}
},
"node_modules/@tiptap/extension-list-item": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.32.0.tgz",
"integrity": "sha512-FdJTCAeALixzyyGSSEmroXmkIEclbo/4t2Pl03aRERqpPOSPJcC3evDtIhLazTuP6v+FjCmJ+pXNql6ov7qitA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.32.0"
}
},
"node_modules/@tiptap/extension-list-keymap": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.32.0.tgz",
"integrity": "sha512-vXFre18Dk2m5N/gjo408C02GSrolZ8ZIf0wz6/U8lNC4FcYOISgZFj3BND6uabVntGh2GCSPrVFuNpVPhFnn/A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.32.0"
}
},
"node_modules/@tiptap/extension-ordered-list": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.32.0.tgz",
"integrity": "sha512-htZ5hoMLBkGiDP6M5mGemjOoaBmkrj911wiGoTmMMKzO7qJq9QxUBkLY7ILPaKlUWL8U3ccLdDV2TB/nysp4Jg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.32.0"
}
},
"node_modules/@tiptap/extension-paragraph": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.32.0.tgz",
"integrity": "sha512-afKLMc7fWMkkar9H5FilizStSbhFzUoCzdwwdZf6DDsn3LUt8tJlFHACK78cAyx/TzMJBo0N8/8T1u9bL+N67Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0"
}
},
"node_modules/@tiptap/extension-placeholder": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.32.0.tgz",
"integrity": "sha512-TSjyQmVFq56gfURPotxbMsXU3seNTy9VzMACdb6b0bQ66yGWBcjBfWmhpiSRVLdBjQrRvl1+jQbWx0YPSCSpsw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.32.0"
}
},
"node_modules/@tiptap/extension-strike": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.32.0.tgz",
"integrity": "sha512-J9Vw6wmh/wV6SxSsN9RhurvAGcXSY5J3p4EQrROD5PARBl0VOnDFi7/UVoz1RkSdqOKPgAiqDZpaLRgtdBO9Rg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0"
}
},
"node_modules/@tiptap/extension-text": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.32.0.tgz",
"integrity": "sha512-sF0jIK67IHdZMnzv2mz5vPTyK7R0ui6/YZgFg+/bLnFCNmweVAoYcHZLlC7ISRnwu0mqJr+epNtzJepY1ZDCjg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0"
}
},
"node_modules/@tiptap/extension-underline": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.32.0.tgz",
"integrity": "sha512-rMLdd4vg6sed/JnaXnaupLbCtYiWikTnQ8qir9ItKOXwf1Ui9P1Y4G0bYU4+gcnMsFzaSGtrOhMqG4/c66AogQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0"
}
},
"node_modules/@tiptap/extensions": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.32.0.tgz",
"integrity": "sha512-qO+ndNvlOjzU2MWCtQHoRg1tIortCXLvqPjDx58F1pUYFNCOVZegXi+jqLGDODUA31er+TVz66umzRbbN1bO1Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0",
"@tiptap/pm": "3.32.0"
}
},
"node_modules/@tiptap/pm": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.32.0.tgz",
"integrity": "sha512-H9eN+xHFboJybE7KPGVsu24S8aBlCIgYz38vCHRSnNv17nwmqm06owq/IQ5scza0n3Y7IfUfpgPOtm9SgeHmNQ==",
"license": "MIT",
"dependencies": {
"prosemirror-changeset": "^2.4.4",
"prosemirror-commands": "^1.7.2",
"prosemirror-dropcursor": "^1.8.4",
"prosemirror-gapcursor": "^1.4.1",
"prosemirror-history": "^1.5.1",
"prosemirror-inputrules": "^1.5.1",
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.12",
"prosemirror-schema-list": "^1.5.1",
"prosemirror-state": "^1.4.4",
"prosemirror-tables": "^1.8.5",
"prosemirror-transform": "^1.12.2",
"prosemirror-view": "^1.42.6"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tiptap/react": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.32.0.tgz",
"integrity": "sha512-+iS9RKj6Ve8SXqJ49xptDiifF7PQXmUOh4cjMu+BCXXqKphQkiUccvbWYAeOJCTceM01wWgFhrpfy8qG+CHJYw==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"fast-equals": "^5.3.3",
"use-sync-external-store": "^1.4.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"optionalDependencies": {
"@tiptap/extension-bubble-menu": "3.32.0",
"@tiptap/extension-floating-menu": "3.32.0"
},
"peerDependencies": {
"@tiptap/core": "3.32.0",
"@tiptap/pm": "3.32.0",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tiptap/starter-kit": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.32.0.tgz",
"integrity": "sha512-aPYxaevci4PgMrTLsg+baiqI7M1ICd4V9H+zTTdrqs8OUZIbAvj/pNDuHU8uWZyKMu3Dh72t2D6ZdqRu/6aSWw==",
"license": "MIT",
"dependencies": {
"@tiptap/core": "3.32.0",
"@tiptap/extension-blockquote": "3.32.0",
"@tiptap/extension-bold": "3.32.0",
"@tiptap/extension-bullet-list": "3.32.0",
"@tiptap/extension-code": "3.32.0",
"@tiptap/extension-code-block": "3.32.0",
"@tiptap/extension-document": "3.32.0",
"@tiptap/extension-dropcursor": "3.32.0",
"@tiptap/extension-gapcursor": "3.32.0",
"@tiptap/extension-hard-break": "3.32.0",
"@tiptap/extension-heading": "3.32.0",
"@tiptap/extension-horizontal-rule": "3.32.0",
"@tiptap/extension-italic": "3.32.0",
"@tiptap/extension-link": "3.32.0",
"@tiptap/extension-list": "3.32.0",
"@tiptap/extension-list-item": "3.32.0",
"@tiptap/extension-list-keymap": "3.32.0",
"@tiptap/extension-ordered-list": "3.32.0",
"@tiptap/extension-paragraph": "3.32.0",
"@tiptap/extension-strike": "3.32.0",
"@tiptap/extension-text": "3.32.0",
"@tiptap/extension-underline": "3.32.0",
"@tiptap/extensions": "3.32.0",
"@tiptap/pm": "3.32.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@types/dompurify": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
"license": "MIT",
"dependencies": {
"@types/trusted-types": "*"
}
},
"node_modules/@types/linkify-it": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.5.tgz",
"integrity": "sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==",
"license": "MIT"
},
"node_modules/@types/markdown-it": {
"version": "13.0.9",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-13.0.9.tgz",
"integrity": "sha512-1XPwR0+MgXLWfTn9gCsZ55AHOKW1WN+P9vr0PaQh5aerR9LLQXUbjfEAFhjmEmyoYFWAyuN2Mqkn40MZ4ukjBw==",
"license": "MIT",
"dependencies": {
"@types/linkify-it": "^3",
"@types/mdurl": "^1"
}
},
"node_modules/@types/mdurl": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-1.0.5.tgz",
"integrity": "sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA==",
"license": "MIT"
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.20.2", "version": "22.20.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.2.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.2.tgz",
@@ -1786,7 +2268,6 @@
"version": "19.3.0", "version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
@@ -1796,12 +2277,29 @@
"version": "19.3.0", "version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
"integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.3.0" "@types/react": "^19.3.0"
} }
}, },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/aria-hidden": { "node_modules/aria-hidden": {
"version": "1.2.6", "version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
@@ -1875,7 +2373,6 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/date-fns": { "node_modules/date-fns": {
@@ -1904,6 +2401,15 @@
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/dompurify": {
"version": "3.4.17",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.17.tgz",
"integrity": "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/emoji-datasource-apple": { "node_modules/emoji-datasource-apple": {
"version": "15.1.2", "version": "15.1.2",
"resolved": "https://registry.npmjs.org/emoji-datasource-apple/-/emoji-datasource-apple-15.1.2.tgz", "resolved": "https://registry.npmjs.org/emoji-datasource-apple/-/emoji-datasource-apple-15.1.2.tgz",
@@ -1924,6 +2430,27 @@
"node": ">=10.13.0" "node": ">=10.13.0"
} }
}, },
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/fast-equals": {
"version": "5.4.5",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.5.tgz",
"integrity": "sha512-yKEpcFuRPu/pTQL2VB8YKE2yFldrLlIsoWtJeBnAdqEhvu21OgkFT0C8njf/xYc2LSZGEr3K9JGjthHJxnVLvQ==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/framer-motion": { "node_modules/framer-motion": {
"version": "12.43.0", "version": "12.43.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.43.0.tgz", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.43.0.tgz",
@@ -1967,6 +2494,12 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/hls.js": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.7.3.tgz",
"integrity": "sha512-MsPlx6yVW4Qv4C7mEVou4/gk/5cN2dVxOTnrNjPSmyH2f0Ln+/UkG10Ax1PE6OvrmTVPKgQYozTtBaPOuCzq7A==",
"license": "Apache-2.0"
},
"node_modules/jiti": { "node_modules/jiti": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -2238,6 +2771,31 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/linkify-it": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz",
"integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/markdown-it"
}
],
"license": "MIT",
"dependencies": {
"uc.micro": "^2.0.0"
}
},
"node_modules/linkifyjs": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
"integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==",
"license": "MIT"
},
"node_modules/lucide-react": { "node_modules/lucide-react": {
"version": "0.468.0", "version": "0.468.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz",
@@ -2257,6 +2815,57 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/markdown-it": {
"version": "14.3.3",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.3.tgz",
"integrity": "sha512-L//tDAkjTjJCU6pBUSBTgs3Kg4InVaZuUZog/b6V12UKhcDQ7S78877ibbCanRExwPuYP1CZD7Gd2dxSy1GXRg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/markdown-it"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1",
"entities": "^4.5.0",
"linkify-it": "^5.0.2",
"mdurl": "^2.0.0",
"punycode.js": "^2.3.1",
"uc.micro": "^2.1.0"
},
"bin": {
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/markdown-it-task-lists": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/markdown-it-task-lists/-/markdown-it-task-lists-2.1.1.tgz",
"integrity": "sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==",
"license": "ISC"
},
"node_modules/marked": {
"version": "18.1.1",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.1.1.tgz",
"integrity": "sha512-zEGgrtUG8crk+TrMacJSVABU4DYDDkS8uqgU3ohrn/2fW9C2AEfUrO3m6IhpIpQpl7YcVpiXXC9cSMWDANT3dg==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/mdurl": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz",
"integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==",
"license": "MIT"
},
"node_modules/motion-dom": { "node_modules/motion-dom": {
"version": "12.43.0", "version": "12.43.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.43.0.tgz", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.43.0.tgz",
@@ -2352,6 +2961,12 @@
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
} }
}, },
"node_modules/orderedmap": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
"license": "MIT"
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2386,6 +3001,187 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/prosemirror-changeset": {
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.4.tgz",
"integrity": "sha512-Gyuz9jYdrAeA9I7AholyOes9KUmv/lEwsC5O9s5xF5VB3EgTPsXMGUxz4+H1B+wKVPdZbvRcg1Cp3x8qX+8ofA==",
"license": "MIT",
"dependencies": {
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-commands": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz",
"integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.10.2"
}
},
"node_modules/prosemirror-dropcursor": {
"version": "1.8.4",
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.4.tgz",
"integrity": "sha512-VmKxhcA6a+h5oPSYmfF1ZNeQqPbyYaIxqZsKqeYCyTlPtrjUgNaHL3P8vVaO19oqAV0zuaM/axkowZHrB1P3Tg==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0",
"prosemirror-view": "^1.1.0"
}
},
"node_modules/prosemirror-gapcursor": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.0.0",
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-view": "^1.0.0"
}
},
"node_modules/prosemirror-history": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.1.tgz",
"integrity": "sha512-onlwnqKvgtFg6srZOmDOAhlAy1FIHQpOExORybX0S+DvJepE5tPALrB9iGet/2sqqqrelSnB10OoiOJqAvu2DA==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.2.2",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.31.0",
"rope-sequence": "^1.3.0"
}
},
"node_modules/prosemirror-inputrules": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-keymap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"w3c-keyname": "^2.2.0"
}
},
"node_modules/prosemirror-markdown": {
"version": "1.13.8",
"resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.8.tgz",
"integrity": "sha512-Zi43BXX28jmxDJKvk8TDS2gMkBY/mgP8ynd6l/Vxrv8rgNFs5hsV1G0VscxTID1UCzVPcidADP9fUOBY3xxUeA==",
"license": "MIT",
"dependencies": {
"@types/markdown-it": "^14.0.0",
"markdown-it": "^14.0.0",
"prosemirror-model": "^1.25.0"
}
},
"node_modules/prosemirror-markdown/node_modules/@types/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
"license": "MIT"
},
"node_modules/prosemirror-markdown/node_modules/@types/markdown-it": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.2.0.tgz",
"integrity": "sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==",
"license": "MIT",
"dependencies": {
"@types/linkify-it": "^5",
"@types/mdurl": "^2"
}
},
"node_modules/prosemirror-markdown/node_modules/@types/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
"license": "MIT"
},
"node_modules/prosemirror-model": {
"version": "1.25.12",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.12.tgz",
"integrity": "sha512-Ue2gTmXMa7EhpLNhC7J+h4+ykD8ha12K6rrZFFKKJHBForfIStw5gJ6Zrf1mqqAa9NmpmEB4wp9bKmA4eUVjWg==",
"license": "MIT",
"dependencies": {
"orderedmap": "^2.0.0"
}
},
"node_modules/prosemirror-schema-list": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.7.3"
}
},
"node_modules/prosemirror-state": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.27.0"
}
},
"node_modules/prosemirror-tables": {
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.4",
"prosemirror-state": "^1.4.4",
"prosemirror-transform": "^1.10.5",
"prosemirror-view": "^1.41.4"
}
},
"node_modules/prosemirror-transform": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.2.tgz",
"integrity": "sha512-PE/aY0HEY4zczvmqrilgkUK/WautF0chvMqkmM/iN5/aPHvwrCWaQZjS6CIZp+K84YrvPFqrL+YArrfJX8xX7g==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.21.0"
}
},
"node_modules/prosemirror-view": {
"version": "1.42.6",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.6.tgz",
"integrity": "sha512-3g25f6hKV2bBxo3puH3EcYWmmVNSH0iWmNsMVT7sugDSpA025jgpOLarcDWr39SdzPjYMaiYtRdNtXialPSjEQ==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.25.12",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/react": { "node_modules/react": {
"version": "19.3.0", "version": "19.3.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
@@ -2506,6 +3302,12 @@
} }
} }
}, },
"node_modules/rope-sequence": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
"license": "MIT"
},
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.28.0", "version": "0.28.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
@@ -2648,6 +3450,24 @@
"url": "https://opencollective.com/webpack" "url": "https://opencollective.com/webpack"
} }
}, },
"node_modules/tiptap-markdown": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/tiptap-markdown/-/tiptap-markdown-0.9.0.tgz",
"integrity": "sha512-dKLQ9iiuGNgrlGVjrNauF/UBzWu4LYOx5pkD0jNkmQt/GOwfCJsBuzZTsf1jZ204ANHOm572mZ9PYvGh1S7tpQ==",
"license": "MIT",
"workspaces": [
"example"
],
"dependencies": {
"@types/markdown-it": "^13.0.7",
"markdown-it": "^14.1.0",
"markdown-it-task-lists": "^2.1.1",
"prosemirror-markdown": "^1.11.1"
},
"peerDependencies": {
"@tiptap/core": "^3.0.1"
}
},
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -2677,6 +3497,12 @@
"node": ">=14.17" "node": ">=14.17"
} }
}, },
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"license": "MIT"
},
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -2727,6 +3553,21 @@
} }
} }
}, },
"node_modules/use-sync-external-store": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz",
"integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/zustand": { "node_modules/zustand": {
"version": "5.0.15", "version": "5.0.15",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz",
+8
View File
@@ -20,13 +20,20 @@
"@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2",
"@tanstack/react-query": "^5.62.0", "@tanstack/react-query": "^5.62.0",
"@tiptap/extension-placeholder": "^3.32.0",
"@tiptap/react": "^3.32.0",
"@tiptap/starter-kit": "^3.32.0",
"@types/dompurify": "^3.0.5",
"boring-avatars": "^2.0.4", "boring-avatars": "^2.0.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dompurify": "^3.4.17",
"emoji-datasource-apple": "^15.1.2", "emoji-datasource-apple": "^15.1.2",
"framer-motion": "^12.0.0", "framer-motion": "^12.0.0",
"hls.js": "^1.7.3",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"marked": "^18.1.1",
"next": "15.5.27", "next": "15.5.27",
"next-themes": "^0.4.4", "next-themes": "^0.4.4",
"react": "^19.0.0", "react": "^19.0.0",
@@ -34,6 +41,7 @@
"react-multi-date-picker": "^4.5.2", "react-multi-date-picker": "^4.5.2",
"sonner": "^1.7.1", "sonner": "^1.7.1",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"tiptap-markdown": "^0.9.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"zustand": "^5.0.2" "zustand": "^5.0.2"
}, },
+115
View File
@@ -0,0 +1,115 @@
"use client";
import { useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth-store";
export default function BotDocsPage() {
const user = useAuth((s) => s.user);
const router = useRouter();
useEffect(() => {
if (user && user.role !== "admin") {
router.replace("/admin");
}
}, [user, router]);
if (!user || user.role !== "admin") {
return null;
}
return (
<div className="mx-auto max-w-3xl space-y-4">
<header className="space-y-2">
<p className="text-xs font-bold text-muted-foreground">
<Link href="/admin?tab=bots" className="underline-offset-4 hover:underline">
ربات
</Link>
</p>
<h1 className="text-2xl font-black tracking-tight">راهنمای ربات</h1>
<p className="text-sm leading-relaxed text-muted-foreground">
با یک کلید API می‌توانید از بیرون پرگار به هنرجو پیام بفرستید و جواب‌های او را بخوانید.
</p>
</header>
<section className="space-y-3 rounded-2xl border border-border bg-card p-5">
<h2 className="text-base font-black">ساخت کلید</h2>
<p className="text-sm leading-relaxed text-muted-foreground">
در مدیریت، بخش ربات، یک نام بگذارید و «ساخت ربات» را بزنید. پاسخ ساخت شامل فیلد <Code>token</Code> است.
توکن فقط یک بار نشان داده می‌شود. بعد از بستن یا بارگذاری دوبارهٔ صفحه دیگر برنمی‌گردد؛ سرور فقط هش آن را نگه می‌دارد.
</p>
<p className="text-sm leading-relaxed text-muted-foreground">
هر ردیف نام، زمان ساخت، وضعیت و تعداد درخواست‌های تأییدشده را نشان می‌دهد. با «توقف» کلید متوقف می‌شود و با «فعال‌کردن» دوباره کار می‌کند.
</p>
</section>
<section className="space-y-3 rounded-2xl border border-border bg-card p-5">
<h2 className="text-base font-black">احراز هویت</h2>
<p className="text-sm leading-relaxed text-muted-foreground">
هر دو مسیر زیر هدر <Code>X-Bot-Token</Code> می‌خواهند. توکن خالی، ناشناس یا متوقف با وضعیت ۴۰۱ و پیام «توکن ربات نامعتبر است» رد می‌شود.
درخواست ناموفق در شمارنده حساب نمی‌شود. هر فراخوانی که توکن فعالش پذیرفته شود، یک واحد به شمار درخواست همان کلید اضافه می‌کند.
</p>
</section>
<section className="space-y-3 rounded-2xl border border-border bg-card p-5">
<h2 className="text-base font-black">ارسال پیام</h2>
<p className="text-sm leading-relaxed text-muted-foreground">
<Code>POST /api/bot/send</Code> پیامی از طرف ربات در گفتگوی هنرجو می‌سازد. بدنه JSON:
</p>
<ul className="list-disc space-y-1 pe-5 text-sm leading-relaxed text-muted-foreground">
<li>
<Code>userId</Code> شناسهٔ عددی هنرجو. لازم است.
</li>
<li>
<Code>text</Code> متن پیام. لازم است و نباید خالی باشد.
</li>
<li>
<Code>buttons</Code> آرایهٔ دکمه‌ها. هر عضو شیئی با فیلد <Code>text</Code> است. در گفتگو، زدن دکمه همان متن را به عنوان پیام هنرجو برای ربات می‌فرستد.
</li>
</ul>
<p className="text-sm leading-relaxed text-muted-foreground">
پاسخ موفق: <Code>{`{ "messageId": 123 }`}</Code>. اگر <Code>userId</Code> یا <Code>text</Code> نباشد، وضعیت ۴۰۰ و پیام «userId و text لازم است».
</p>
<Example
title="نمونه"
code={`curl -X POST /api/bot/send \\
-H 'Content-Type: application/json' \\
-H 'X-Bot-Token: YOUR_TOKEN' \\
-d '{"userId":12,"text":"سلام","buttons":[{"text":"باشه"}]}'`}
/>
</section>
<section className="space-y-3 rounded-2xl border border-border bg-card p-5">
<h2 className="text-base font-black">خواندن پاسخ‌ها</h2>
<p className="text-sm leading-relaxed text-muted-foreground">
<Code>GET /api/bot/updates</Code> صف پاسخ‌هایی را برمی‌گرداند که هنرجو برای این ربات فرستاده است؛ چه متن آزاد، چه متن یک دکمه.
پارامتر <Code>offset</Code> آخرین شناسه‌ای است که قبلاً دیده‌اید. فقط ردیف‌های با <Code>id</Code> بزرگ‌تر برمی‌گردند، حداکثر ۵۰ تا. اگر نفرستید، از صفر شروع می‌شود.
</p>
<p className="text-sm leading-relaxed text-muted-foreground">
پاسخ: <Code>{`{ "updates": [ { "id": 1, "payload": { "from": 12, "name": "نام", "text": "جواب" }, "createdAt": "..." } ] }`}</Code>.
<Code>from</Code> شناسهٔ فرستنده است، <Code>name</Code> نام او و <Code>text</Code> متن پیام. اگر چیزی نباشد، <Code>updates</Code> آرایهٔ خالی است.
</p>
</section>
</div>
);
}
function Code({ children }: { children: string }) {
return (
<code dir="ltr" className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-[0.8em]">
{children}
</code>
);
}
function Example({ title, code }: { title: string; code: string }) {
return (
<div className="space-y-2">
<p className="text-xs font-bold text-muted-foreground">{title}</p>
<pre dir="ltr" className="overflow-x-auto rounded-2xl border border-border bg-muted/40 p-3 text-left font-mono text-xs leading-relaxed">
{code}
</pre>
</div>
);
}
+16 -162
View File
@@ -1,45 +1,23 @@
"use client"; "use client";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { Loader2, Package } from "lucide-react"; import { Loader2 } from "lucide-react";
import { CertificateCard } from "@/components/certificate-card"; import { CertificateCard } from "@/components/certificate-card";
import { Button } from "@/components/ui/button"; import { NotFoundState, notFoundSentence } from "@/components/not-found-state";
import { Input } from "@/components/ui/input";
import { http, toUserError } from "@/lib/api"; import { http, toUserError } from "@/lib/api";
import { useAuth } from "@/lib/auth-store"; import { useAuth } from "@/lib/auth-store";
import type { Certificate, CertificatePhysicalOrder, PhysicalCertSettings } from "@/lib/types"; import type { Certificate } from "@/lib/types";
import { toast } from "@/components/providers";
import { formatFaNumber } from "@/lib/utils";
import { clampPhoneInput, normalizePhone, PHONE_INPUT_MAX_LEN } from "@/lib/validation";
import { useEffect, useState } from "react";
type PublicCertResponse = { type PublicCertResponse = {
certificate: { publicId: string; fullName: string; issuedAt: string }; certificate: { publicId: string; fullName: string; issuedAt: string };
course: string; course: string;
}; };
const STATUS_LABEL: Record<string, string> = {
requested: "درخواست‌شده — منتظر تأیید پرداخت",
paid: "پرداخت تأیید شد — در صف ارسال",
shipped: "ارسال شد",
cancelled: "لغو شده",
};
export default function CertificatePage() { export default function CertificatePage() {
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const publicId = params?.id ?? ""; const publicId = params?.id ?? "";
const authUser = useAuth((s) => s.user); const authUser = useAuth((s) => s.user);
const qc = useQueryClient();
const [requesting, setRequesting] = useState(false);
const [form, setForm] = useState({
recipientName: "",
phone: "",
address: "",
city: "",
postalCode: "",
});
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ["certificate", publicId], queryKey: ["certificate", publicId],
@@ -50,42 +28,12 @@ export default function CertificatePage() {
const { data: mine } = useQuery({ const { data: mine } = useQuery({
queryKey: ["me", "certificate"], queryKey: ["me", "certificate"],
queryFn: () => queryFn: () => http.get<{ certificate: Certificate | null }>("/api/me/certificate"),
http.get<{
certificate: Certificate | null;
physicalOrder: CertificatePhysicalOrder | null;
physical: PhysicalCertSettings;
}>("/api/me/certificate"),
enabled: !!authUser, enabled: !!authUser,
}); });
const isOwner = !!authUser && mine?.certificate?.publicId === publicId; const isOwner = !!authUser && mine?.certificate?.publicId === publicId;
useEffect(() => {
if (!isOwner || !authUser) return;
setForm((f) => ({
...f,
recipientName: f.recipientName || authUser.name || "",
phone: f.phone || authUser.phone || "",
}));
}, [isOwner, authUser]);
const requestPhysical = async () => {
setRequesting(true);
try {
await http.post("/api/me/certificate/physical", {
...form,
phone: normalizePhone(form.phone),
});
toast.success("درخواست ثبت شد — پس از واریز با ادمین هماهنگ کنید");
qc.invalidateQueries({ queryKey: ["me", "certificate"] });
} catch (e) {
toast.error(toUserError(e));
} finally {
setRequesting(false);
}
};
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex min-h-[60vh] items-center justify-center"> <div className="flex min-h-[60vh] items-center justify-center">
@@ -96,27 +44,20 @@ export default function CertificatePage() {
if (error || !data?.certificate) { if (error || !data?.certificate) {
return ( return (
<div className="mx-auto max-w-lg px-4 py-16 text-center"> <NotFoundState
<p className="text-lg font-bold">گواهینامه پیدا نشد</p> title="گواهینامه پیدا نشد"
<Button asChild className="mt-4" variant="outline"> description={notFoundSentence(
<Link href="/">بازگشت</Link> error ? toUserError(error, "") : "",
</Button> "این گواهینامه در سامانه نیست.",
</div> "گواهینامه پیدا نشد",
)}
href="/"
actionLabel="بازگشت به خانه"
/>
); );
} }
const cert = data.certificate; const cert = data.certificate;
const order = isOwner ? mine?.physicalOrder : null;
const physical = isOwner ? mine?.physical : null;
const windowEndsAt = mine?.certificate?.issuedAt
? new Date(mine.certificate.issuedAt).getTime() + (physical?.windowDays ?? 30) * 86400000
: 0;
const windowStillOpen = !windowEndsAt || Date.now() < windowEndsAt;
const windowOpen =
physical?.enabled &&
windowStillOpen &&
(!order || order.status === "cancelled") &&
!!mine?.certificate;
return ( return (
<CertificateCard <CertificateCard
@@ -126,94 +67,7 @@ export default function CertificatePage() {
issuedAt: cert.issuedAt, issuedAt: cert.issuedAt,
course: data.course, course: data.course,
}} }}
showOwnerLinks={!!authUser} showOwnerLinks={isOwner}
>
{isOwner && physical?.enabled && (
<div className="mt-8 space-y-3 border-t border-border pt-6">
<p className="flex items-center gap-2 text-sm font-bold">
<Package className="h-4 w-4" /> نسخه فیزیکی
</p>
<p className="text-xs text-muted-foreground">
قیمت حدودی {formatFaNumber(physical.priceIrr)} ریال · مهلت درخواست{" "}
{formatFaNumber(physical.windowDays)} روز از صدور
{windowEndsAt ? ` (تا ${new Date(windowEndsAt).toLocaleDateString("fa-IR")})` : ""}.
پرداخت آنلاین در سامانه نداریم. پس از واریز بیرونی با ادمین هماهنگ کنید تا وضعیت سفارش به‌روز شود.
</p>
{order && order.status !== "cancelled" ? (
<div className="space-y-2 rounded-xl border border-border bg-muted/30 p-3 text-sm">
<p>
وضعیت: <span className="font-medium">{STATUS_LABEL[order.status] ?? order.status}</span>
</p>
{order.trackingCode && (
<p className="font-mono text-xs" dir="ltr">
کد پیگیری: {order.trackingCode}
</p>
)}
{order.city && (
<p className="text-xs text-muted-foreground">
ارسال به {order.recipientName || "—"} · {order.city}
</p>
)}
{order.note && <p className="text-xs text-muted-foreground">یادداشت ادمین: {order.note}</p>}
<Button
size="sm"
variant="outline"
onClick={() =>
window.dispatchEvent(new CustomEvent("pargar:chat", { detail: { open: true } }))
}
>
هماهنگی با تیم
</Button>
</div>
) : windowOpen ? (
<div className="space-y-2">
<div className="grid gap-2 sm:grid-cols-2">
<Input
placeholder="نام گیرنده"
value={form.recipientName}
onChange={(e) => setForm((f) => ({ ...f, recipientName: e.target.value }))}
/> />
<Input
placeholder="موبایل"
value={form.phone}
onChange={(e) => setForm((f) => ({ ...f, phone: clampPhoneInput(e.target.value) }))}
dir="ltr"
inputMode="tel"
maxLength={PHONE_INPUT_MAX_LEN}
className="text-left"
/>
<Input
placeholder="شهر"
value={form.city}
onChange={(e) => setForm((f) => ({ ...f, city: e.target.value }))}
/>
<Input
placeholder="کد پستی"
value={form.postalCode}
onChange={(e) => setForm((f) => ({ ...f, postalCode: e.target.value }))}
dir="ltr"
/>
</div>
<textarea
className="min-h-[72px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder="آدرس کامل پستی"
value={form.address}
onChange={(e) => setForm((f) => ({ ...f, address: e.target.value }))}
/>
<Button
size="sm"
disabled={requesting || !form.address.trim() || !form.city.trim()}
onClick={() => void requestPhysical()}
>
{requesting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Package className="h-3.5 w-3.5" />}
ثبت درخواست نسخه فیزیکی
</Button>
</div>
) : !windowStillOpen ? (
<p className="text-sm text-muted-foreground">مهلت درخواست نسخه فیزیکی به پایان رسیده است.</p>
) : null}
</div>
)}
</CertificateCard>
); );
} }
+266 -45
View File
@@ -2,7 +2,7 @@
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Suspense, useEffect, useRef } from "react"; import { Suspense, useEffect, useLayoutEffect, useRef, useState } from "react";
import { import {
Award, Award,
ArrowLeft, ArrowLeft,
@@ -13,7 +13,6 @@ import {
Heart, Heart,
Lock, Lock,
MessageSquare, MessageSquare,
Package,
PlayCircle, PlayCircle,
Sparkles, Sparkles,
TrendingUp, TrendingUp,
@@ -22,12 +21,13 @@ import {
import { SkillTree } from "@/components/skill-tree"; import { SkillTree } from "@/components/skill-tree";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { http, toUserError } from "@/lib/api"; import { http, toUserError } from "@/lib/api";
import type { Certificate, CertificatePhysicalOrder, MeetingEvent, PhysicalCertSettings, TreeData, TreeLesson } from "@/lib/types"; import type { Certificate, MeetingEvent, TreeData, TreeLesson } from "@/lib/types";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useAuth } from "@/lib/auth-store"; import { useAuth } from "@/lib/auth-store";
import { cn, formatFaNumber, formatStreakLabel } from "@/lib/utils"; import { cn, formatFaNumber, formatStreakLabel } from "@/lib/utils";
import { clampHearts, hasInfiniteHearts, INFINITE_HEARTS_LABEL, MAX_HEARTS } from "@/lib/hearts"; import { clampHearts, hasInfiniteHearts, INFINITE_HEARTS_LABEL, MAX_HEARTS } from "@/lib/hearts";
import { toast } from "@/components/providers"; import { toast } from "@/components/providers";
import { SafeMarkdown } from "@/components/safe-markdown";
export default function CapPage() { export default function CapPage() {
return ( return (
@@ -57,8 +57,8 @@ function CapPageInner() {
}); });
const allLessons = (data?.chapters ?? []).flatMap((ch) => ch.lessons ?? []); const allLessons = (data?.chapters ?? []).flatMap((ch) => ch.lessons ?? []);
const completedCount = allLessons.filter((l) => l.progress?.passedQuiz).length; const completedCount = data?.completedLessons ?? allLessons.filter((l) => l.progress?.passedQuiz).length;
const curriculumDone = allLessons.length > 0 && completedCount === allLessons.length; const curriculumDone = (data?.progressPct ?? 0) >= 100;
useEffect(() => { useEffect(() => {
const lesson = params.get("lesson"); const lesson = params.get("lesson");
@@ -69,12 +69,7 @@ function CapPageInner() {
const { data: certData } = useQuery({ const { data: certData } = useQuery({
queryKey: ["me", "certificate"], queryKey: ["me", "certificate"],
queryFn: () => queryFn: () => http.get<{ certificate: Certificate | null }>("/api/me/certificate"),
http.get<{
certificate: Certificate | null;
physicalOrder: CertificatePhysicalOrder | null;
physical: PhysicalCertSettings;
}>("/api/me/certificate"),
enabled: !!user && curriculumDone, enabled: !!user && curriculumDone,
}); });
@@ -101,20 +96,10 @@ function CapPageInner() {
const firstLocked = all.find((l) => l.locked); const firstLocked = all.find((l) => l.locked);
const done = curriculumDone; const done = curriculumDone;
const pct = all.length ? Math.round((completedCount / all.length) * 100) : 0; const pct = Math.round(data?.progressPct ?? 0);
const hearts = clampHearts(me?.hearts ?? user?.hearts ?? MAX_HEARTS); const hearts = clampHearts(me?.hearts ?? user?.hearts ?? MAX_HEARTS);
const lowHearts = !hasInfiniteHearts(user?.role) && hearts > 0 && hearts <= 1; const lowHearts = !hasInfiniteHearts(user?.role) && hearts > 0 && hearts <= 1;
const nextEvent = (eventsData?.events ?? [])[0]; const nextEvent = (eventsData?.events ?? [])[0];
const physicalOpen =
done &&
certData?.physical?.enabled &&
!!certData.certificate &&
(!certData.physicalOrder || certData.physicalOrder.status === "cancelled") &&
(!certData.certificate.issuedAt ||
Date.now() <
new Date(certData.certificate.issuedAt).getTime() +
(certData.physical.windowDays || 30) * 24 * 60 * 60 * 1000);
const next = nextAction(inProgress, readyQuiz, openAndNew, firstLocked, done); const next = nextAction(inProgress, readyQuiz, openAndNew, firstLocked, done);
return ( return (
@@ -150,6 +135,8 @@ function CapPageInner() {
</div> </div>
</div> </div>
<PinnedAnnouncement />
{lowHearts && ( {lowHearts && (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border-2 border-destructive/30 bg-destructive/5 px-5 py-4"> <div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border-2 border-destructive/30 bg-destructive/5 px-5 py-4">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
@@ -157,7 +144,7 @@ function CapPageInner() {
<div> <div>
<p className="text-sm font-extrabold text-destructive">فقط {formatFaNumber(hearts)} جان مانده</p> <p className="text-sm font-extrabold text-destructive">فقط {formatFaNumber(hearts)} جان مانده</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
یک اشتباه دیگر حساب را قفل می‌کند. قبل از آزمون مطمئن شوید؛ اگر گیر کردید با منتور حرف بزنید. جان‌ها خودشان برمی‌گردند. تا برگشتن یک جان، آزمون تازه صبر می‌کند.
</p> </p>
</div> </div>
</div> </div>
@@ -178,33 +165,29 @@ function CapPageInner() {
<div> <div>
<p className="text-sm font-extrabold">دوره را تمام کردید</p> <p className="text-sm font-extrabold">دوره را تمام کردید</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
گواهینامه دیجیتال آماده است {certData?.certificate?.status === "pending"
{physicalOpen ? " — می‌توانید نسخه فیزیکی هم درخواست کنید." : "."} ? "درخواست گواهینامه ثبت شد — پس از تأیید تیم آموزش صادر می‌شود."
: certData?.certificate?.status === "issued"
? "گواهینامه دیجیتال آماده است."
: "گواهینامه دیجیتال آماده است."}
</p> </p>
</div> </div>
<div className="flex flex-wrap gap-2"> {certData?.certificate?.status === "issued" && certData.certificate.publicId ? (
<Button <Button onClick={() => router.push(`/c/${certData.certificate!.publicId!}`)}>
onClick={() => {
const id = certData?.certificate?.publicId;
if (id) router.push(`/c/${id}`);
else {
void http
.post<{ certificate: { publicId: string } }>("/api/me/certificate/issue", {})
.then((r) => {
if (r.certificate?.publicId) router.push(`/c/${r.certificate.publicId}`);
})
.catch((e) => toast.error(toUserError(e, "صدور گواهینامه ممکن نشد")));
}
}}
>
<Award className="h-4 w-4" /> مشاهده گواهینامه <Award className="h-4 w-4" /> مشاهده گواهینامه
</Button> </Button>
{physicalOpen && certData?.certificate?.publicId && ( ) : certData?.certificate?.status !== "pending" ? (
<Button variant="outline" onClick={() => router.push(`/c/${certData.certificate!.publicId}`)}> <Button
<Package className="h-4 w-4" /> نسخه فیزیکی onClick={() => {
void http
.post("/api/me/certificate/issue", {})
.then(() => qc.invalidateQueries({ queryKey: ["me", "certificate"] }))
.catch((e) => toast.error(toUserError(e, "صدور گواهینامه ممکن نشد")));
}}
>
<Award className="h-4 w-4" /> دریافت گواهینامه
</Button> </Button>
)} ) : null}
</div>
</div> </div>
)} )}
@@ -450,6 +433,244 @@ function CapSkeleton() {
); );
} }
type CapAnnouncement = { title: string; body: string; route: string };
function PinnedAnnouncement() {
const router = useRouter();
const scroller = useRef<HTMLDivElement>(null);
const gesture = useRef<{
x: number;
y: number;
scroll: number;
index: number;
pointerId: number;
axis: "x" | "y" | null;
dx: number;
} | null>(null);
const moved = useRef(0);
const [index, setIndex] = useState(0);
const q = useQuery({
queryKey: ["announcement-pinned"],
queryFn: () =>
http.get<{ announcement: CapAnnouncement | null; announcements?: CapAnnouncement[] }>("/api/announcements/pinned"),
});
const items = q.data?.announcements?.length
? q.data.announcements
: q.data?.announcement
? [q.data.announcement]
: [];
const count = items.length;
const indexRef = useRef(0);
const hoverRef = useRef(false);
const downRef = useRef(false);
const programmatic = useRef(0);
const scrollSeq = useRef(0);
const wantScroll = useRef(false);
const scrollBehavior = useRef<ScrollBehavior>("smooth");
const [epoch, setEpoch] = useState(0);
const stride = (el: HTMLDivElement) => {
const card = el.firstElementChild as HTMLElement | null;
const gap = 12;
return (card?.offsetWidth || el.clientWidth || 1) + gap;
};
const goTo = (next: number, behavior: ScrollBehavior = "smooth") => {
if (count < 2) return;
const i = ((next % count) + count) % count;
const from = indexRef.current;
const neighbor = Math.abs(i - from) === 1;
scrollBehavior.current = neighbor && behavior === "smooth" ? "smooth" : "auto";
wantScroll.current = true;
indexRef.current = i;
setIndex(i);
setEpoch((n) => n + 1);
};
const stepBy = (delta: number) => {
const from = indexRef.current;
const dest = from + delta < 0 ? count - 1 : from + delta >= count ? 0 : from + delta;
goTo(dest, Math.abs(dest - from) === 1 ? "smooth" : "auto");
};
const stepRef = useRef(stepBy);
stepRef.current = stepBy;
useLayoutEffect(() => {
const el = scroller.current;
if (!el || count < 2 || !wantScroll.current) return;
wantScroll.current = false;
const token = scrollSeq.current + 1;
scrollSeq.current = token;
programmatic.current = token;
const left = indexRef.current * stride(el);
let settledOnce = false;
const finish = (force: boolean) => {
if (settledOnce || scrollSeq.current !== token) return;
if (gesture.current?.axis === "x") return;
const target = indexRef.current * stride(el);
const settled = Math.abs(el.scrollLeft - target) <= 2;
if (!settled && !force) return;
if (!settled) {
el.style.scrollBehavior = "auto";
el.scrollLeft = target;
}
settledOnce = true;
if (programmatic.current === token) programmatic.current = 0;
el.removeEventListener("scrollend", onEnd);
};
const onEnd = () => {
if (scrollSeq.current !== token || !programmatic.current) return;
finish(false);
};
el.addEventListener("scrollend", onEnd);
el.style.scrollBehavior = scrollBehavior.current === "smooth" ? "smooth" : "auto";
el.scrollTo({ left, behavior: scrollBehavior.current });
const soon = window.setTimeout(() => finish(false), scrollBehavior.current === "auto" ? 40 : 700);
const later = window.setTimeout(() => finish(true), 1200);
return () => {
window.clearTimeout(soon);
window.clearTimeout(later);
el.removeEventListener("scrollend", onEnd);
};
}, [index, epoch, count]);
useEffect(() => {
if (count < 2) return;
const id = window.setInterval(() => {
if (hoverRef.current || downRef.current) return;
stepRef.current(1);
}, 6000);
return () => window.clearInterval(id);
}, [count, epoch]);
if (count === 0) return null;
const hold = (hover: boolean | null, down: boolean | null) => {
if (hover != null) hoverRef.current = hover;
if (down != null) downRef.current = down;
};
const go = (next: number) => goTo(next);
const onTrackPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
const el = scroller.current;
if (!el) return;
gesture.current = {
x: e.clientX,
y: e.clientY,
scroll: el.scrollLeft,
index: indexRef.current,
pointerId: e.pointerId,
axis: null,
dx: 0,
};
moved.current = 0;
};
const onTrackPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
const g = gesture.current;
if (!g || g.pointerId !== e.pointerId) return;
const dx = e.clientX - g.x;
const dy = e.clientY - g.y;
g.dx = dx;
if (g.axis == null) {
if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return;
g.axis = Math.abs(dx) > Math.abs(dy) ? "x" : "y";
}
if (g.axis !== "x" || count < 2) return;
const el = scroller.current;
if (!el) return;
if (!el.hasPointerCapture(e.pointerId)) el.setPointerCapture(e.pointerId);
const width = stride(el);
const max = width * (count - 1);
const pulled = g.scroll - dx;
const limited = Math.min(g.scroll + width, Math.max(g.scroll - width, pulled));
el.style.scrollBehavior = "auto";
el.scrollLeft = Math.min(max, Math.max(0, limited));
};
const onTrackPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
const g = gesture.current;
if (!g || g.pointerId !== e.pointerId) return;
moved.current = Math.max(Math.abs(g.dx), Math.abs(e.clientY - g.y));
const el = scroller.current;
if (el?.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);
if (g.axis !== "x" || count < 2) {
gesture.current = null;
if (el) el.style.scrollBehavior = "";
return;
}
const threshold = 40;
let next = g.index;
if (g.dx <= -threshold) next = g.index + 1;
else if (g.dx >= threshold) next = g.index - 1;
go(next);
gesture.current = null;
};
return (
<div
className="relative"
onPointerEnter={() => hold(true, null)}
onPointerLeave={() => {
if (gesture.current?.axis === "x") {
hold(false, null);
return;
}
hold(false, false);
setEpoch((n) => n + 1);
}}
onPointerDown={() => hold(null, true)}
onPointerUp={() => hold(null, false)}
onPointerCancel={() => hold(null, false)}
>
<div
ref={scroller}
dir="ltr"
style={{ direction: "ltr" }}
className="flex gap-3 touch-pan-y select-none overflow-hidden [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
onPointerDown={onTrackPointerDown}
onPointerMove={onTrackPointerMove}
onPointerUp={onTrackPointerUp}
onPointerCancel={onTrackPointerUp}
>
{items.map((a, i) => (
<button
key={`${a.title}-${i}`}
type="button"
dir="rtl"
draggable={false}
onClick={() => {
if (Math.abs(moved.current) > 12) return;
if (a.route) router.push(a.route);
}}
className="block w-[calc(100%-2rem)] min-w-[calc(100%-2rem)] max-w-[calc(100%-2rem)] shrink-0 touch-pan-y rounded-2xl border-2 border-primary/30 bg-primary/5 px-5 py-4 text-start"
>
<p className="text-xs font-bold text-primary">اطلاعیه</p>
<p className="mt-1 text-base font-black">{a.title}</p>
<SafeMarkdown text={a.body} lines={3} className="mt-1 text-sm text-muted-foreground" />
</button>
))}
</div>
{items.length > 1 && (
<div className="mt-2 flex items-center justify-center gap-1.5">
{items.map((a, i) => (
<button
key={`${a.title}-dot-${i}`}
type="button"
aria-label={`اطلاعیه ${i + 1}`}
onClick={() => go(i)}
className={cn("h-1.5 rounded-full transition-all", i === index ? "w-4 bg-primary" : "w-1.5 bg-primary/30")}
/>
))}
</div>
)}
</div>
);
}
function ZapIcon(props: { className?: string }) { function ZapIcon(props: { className?: string }) {
return ( return (
<svg viewBox="0 0 24 24" fill="currentColor" {...props}> <svg viewBox="0 0 24 24" fill="currentColor" {...props}>
+25
View File
@@ -254,6 +254,31 @@
border-radius: 999px; border-radius: 999px;
border-width: 0; border-width: 0;
} }
textarea.chat-composer {
unicode-bidi: plaintext;
text-align: start;
white-space: pre-wrap;
overflow-wrap: anywhere;
field-sizing: content;
max-height: 16rem;
overflow-y: auto;
}
textarea.chat-composer:placeholder-shown {
direction: rtl;
text-align: right;
}
textarea.chat-composer::placeholder {
unicode-bidi: plaintext;
text-align: start;
}
.chat-bubble-text {
unicode-bidi: plaintext;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
max-width: 100%;
text-align: start;
}
.section-kicker { .section-kicker {
display: flex; display: flex;
align-items: center; align-items: center;
+8 -1
View File
@@ -1,4 +1,4 @@
import type { Metadata } from "next"; import type { Metadata, Viewport } from "next";
import localFont from "next/font/local"; import localFont from "next/font/local";
import "./globals.css"; import "./globals.css";
import { Providers } from "@/components/providers"; import { Providers } from "@/components/providers";
@@ -37,6 +37,13 @@ export const metadata: Metadata = {
description: "مسیر یادگیری واقعی پرگار", description: "مسیر یادگیری واقعی پرگار",
}; };
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
interactiveWidget: "resizes-content",
};
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html <html
+2 -3
View File
@@ -89,8 +89,7 @@ export default function LockoutPage() {
<p className="text-xs font-bold tracking-wider text-destructive">حساب قفل شد</p> <p className="text-xs font-bold tracking-wider text-destructive">حساب قفل شد</p>
<h1 className="mt-2 text-3xl font-bold tracking-tight">جان‌هایتان تمام شد</h1> <h1 className="mt-2 text-3xl font-bold tracking-tight">جان‌هایتان تمام شد</h1>
<p className="mt-3 text-sm leading-relaxed text-muted-foreground"> <p className="mt-3 text-sm leading-relaxed text-muted-foreground">
هر پاسخ اشتباه در آزمون یک جان کم می‌کند. با تمام شدن سه جان، یادگیری و آزمون قفل می‌شود تا جان‌ها معمولاً خودشان برمی‌گردند. این صفحه وقتی دیده می‌شود که مدیریت حساب را قفل کرده باشد.
تیم مسیر را بررسی کند — خودتان نمی‌توانید قفل را باز کنید.
</p> </p>
<div className="mt-6 flex items-center gap-2"> <div className="mt-6 flex items-center gap-2">
@@ -144,7 +143,7 @@ export default function LockoutPage() {
className={cn("w-full text-muted-foreground")} className={cn("w-full text-muted-foreground")}
onClick={() => router.push("/unwrap?tab=community")} onClick={() => router.push("/unwrap?tab=community")}
> >
جامعه و حمایت شبکه‌های پرگار
</Button> </Button>
</div> </div>
</div> </div>
+12 -4
View File
@@ -7,7 +7,7 @@ import { useQuery } from "@tanstack/react-query";
import { CheckCircle2, Loader2, ShieldCheck, Timer, Zap } from "lucide-react"; import { CheckCircle2, Loader2, ShieldCheck, Timer, Zap } from "lucide-react";
import { BrandMark } from "@/components/brand-mark"; import { BrandMark } from "@/components/brand-mark";
import { useAuth } from "@/lib/auth-store"; import { useAuth } from "@/lib/auth-store";
import { http, toUserError } from "@/lib/api"; import { ApiError, http, toUserError } from "@/lib/api";
import type { User } from "@/lib/types"; import type { User } from "@/lib/types";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -34,6 +34,7 @@ export default function LoginPage() {
const [rememberMe, setRememberMe] = useState(false); const [rememberMe, setRememberMe] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [captchaError, setCaptchaError] = useState<string | null>(null);
const { data: registerStatus } = useQuery({ const { data: registerStatus } = useQuery({
queryKey: ["auth", "register-status"], queryKey: ["auth", "register-status"],
@@ -43,7 +44,7 @@ export default function LoginPage() {
const submit = async (e: React.FormEvent) => { const submit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!challenge) { if (!challenge) {
setError("ابتدا کد امنیتی را بارگذاری کنید"); setCaptchaError("ابتدا کد امنیتی را بارگذاری کنید");
setCaptchaKey((k) => k + 1); setCaptchaKey((k) => k + 1);
return; return;
} }
@@ -61,7 +62,10 @@ export default function LoginPage() {
router.push("/cap"); router.push("/cap");
} catch (err) { } catch (err) {
setError(toUserError(err, "ورود ناموفق بود")); setError(toUserError(err, "ورود ناموفق بود"));
// 429 is returned before the captcha is consumed, so keep this challenge.
if (!(err instanceof ApiError) || err.status !== 429) {
setCaptchaKey((k) => k + 1); setCaptchaKey((k) => k + 1);
}
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -137,7 +141,7 @@ export default function LoginPage() {
value={captchaAnswer} value={captchaAnswer}
onChange={setCaptchaAnswer} onChange={setCaptchaAnswer}
onChallenge={setChallenge} onChallenge={setChallenge}
onError={setError} onError={setCaptchaError}
/> />
<label className="flex items-center justify-between gap-3 text-sm"> <label className="flex items-center justify-between gap-3 text-sm">
<span>مرا به یاد داشته باش</span> <span>مرا به یاد داشته باش</span>
@@ -147,7 +151,11 @@ export default function LoginPage() {
aria-label="مرا به یاد داشته باش" aria-label="مرا به یاد داشته باش"
/> />
</label> </label>
{error && <p className="text-sm text-destructive">{error}</p>} {(error || captchaError) && (
<p className="text-sm text-destructive" role="alert">
{error || captchaError}
</p>
)}
<Button <Button
type="submit" type="submit"
variant="gradient" variant="gradient"
+7 -53
View File
@@ -1,58 +1,12 @@
"use client"; import { NotFoundState } from "@/components/not-found-state";
import Link from "next/link";
import { BrandMark } from "@/components/brand-mark";
import { Button } from "@/components/ui/button";
import { useAuth, useAuthHydrated } from "@/lib/auth-store";
import { Home, LogIn, Trees } from "lucide-react";
export default function NotFound() { export default function NotFound() {
const hydrated = useAuthHydrated();
const user = useAuth((s) => s.user);
const loggedIn = hydrated && !!user;
return ( return (
<div className="mx-auto flex max-w-lg flex-col items-center px-4 py-16 text-center"> <NotFoundState
<div className="relative w-full overflow-hidden rounded-2xl border-2 border-border bg-card px-8 py-14 shadow-offset"> title="صفحه پیدا نشد"
<div className="dot-grid dot-grid-primary pointer-events-none absolute -end-10 -top-10 h-40 w-40 opacity-60" aria-hidden /> description="این آدرس در سامانه نیست. از خانه می‌توانید دوباره شروع کنید."
<p className="text-7xl font-black tabular-nums text-primary/25">۴۰۴</p> href="/"
<div className="mt-4 flex justify-center"> actionLabel="بازگشت به خانه"
<BrandMark locale="fa" size="lg" /> />
</div>
<h1 className="mt-6 text-2xl font-black tracking-tight">این مسیر پیدا نشد</h1>
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
{loggedIn
? "این آدرس در سامانه نیست. از درخت مهارت یا مرکز ادامه دهید."
: "صفحه‌ای که دنبالش بودید پیدا نشد. می‌توانید به خانه برگردید یا وارد حساب شوید."}
</p>
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
{loggedIn ? (
<>
<Button asChild className="gap-2">
<Link href="/cap">
<Trees className="h-4 w-4" /> درخت مهارت
</Link>
</Button>
<Button asChild variant="outline" className="gap-2">
<Link href="/unwrap?tab=community">مرکز · حمایت</Link>
</Button>
</>
) : (
<>
<Button asChild className="gap-2">
<Link href="/">
<Home className="h-4 w-4" /> خانه
</Link>
</Button>
<Button asChild variant="outline" className="gap-2">
<Link href="/login">
<LogIn className="h-4 w-4" /> ورود
</Link>
</Button>
</>
)}
</div>
</div>
</div>
); );
} }
+24 -1
View File
@@ -6,6 +6,7 @@ import { useEffect } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { ArrowRight, Lock, TreePine } from "lucide-react"; import { ArrowRight, Lock, TreePine } from "lucide-react";
import { VideoPlayer } from "@/components/video-player"; import { VideoPlayer } from "@/components/video-player";
import { NotFoundState, notFoundSentence } from "@/components/not-found-state";
import { ApiError, http, toUserError } from "@/lib/api"; import { ApiError, http, toUserError } from "@/lib/api";
import type { Lesson, LessonProgress } from "@/lib/types"; import type { Lesson, LessonProgress } from "@/lib/types";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -24,6 +25,7 @@ function isLessonLockedError(error: unknown): boolean {
export default function PlayerPage() { export default function PlayerPage() {
const { lessonId } = useParams<{ lessonId: string }>(); const { lessonId } = useParams<{ lessonId: string }>();
const id = Number(lessonId); const id = Number(lessonId);
const idOk = Number.isFinite(id) && id > 0;
const router = useRouter(); const router = useRouter();
const user = useAuth((s) => s.user); const user = useAuth((s) => s.user);
@@ -37,7 +39,7 @@ export default function PlayerPage() {
progress: LessonProgress | null; progress: LessonProgress | null;
quizStatus: string; quizStatus: string;
}>(`/api/lessons/${id}`), }>(`/api/lessons/${id}`),
enabled: !!user && (!user.isLocked || hasInfiniteHearts(user.role)), enabled: idOk && !!user && (!user.isLocked || hasInfiniteHearts(user.role)),
staleTime: 5 * 60_000, staleTime: 5 * 60_000,
retry: (count, err) => { retry: (count, err) => {
if (isLessonLockedError(err)) return false; if (isLessonLockedError(err)) return false;
@@ -53,6 +55,17 @@ export default function PlayerPage() {
return null; return null;
} }
if (!idOk) {
return (
<NotFoundState
title="درس پیدا نشد"
description="این درس در درخت مهارت نیست."
href="/cap"
actionLabel="بازگشت به درخت مهارت"
/>
);
}
if (isLoading) { if (isLoading) {
return ( return (
<div className="mx-auto max-w-4xl space-y-6"> <div className="mx-auto max-w-4xl space-y-6">
@@ -79,6 +92,16 @@ export default function PlayerPage() {
if (isLessonLockedError(error)) { if (isLessonLockedError(error)) {
return <LockedLessonState onBack={() => router.push("/cap")} />; return <LockedLessonState onBack={() => router.push("/cap")} />;
} }
if (error instanceof ApiError && error.status === 404) {
return (
<NotFoundState
title="درس پیدا نشد"
description={notFoundSentence(toUserError(error, ""), "این درس در درخت مهارت نیست.", "درس پیدا نشد")}
href="/cap"
actionLabel="بازگشت به درخت مهارت"
/>
);
}
return ( return (
<div className="mx-auto max-w-lg space-y-4 py-16 text-center"> <div className="mx-auto max-w-lg space-y-4 py-16 text-center">
<p className="font-medium text-destructive">{toUserError(error, "بارگذاری درس ممکن نشد")}</p> <p className="font-medium text-destructive">{toUserError(error, "بارگذاری درس ممکن نشد")}</p>
+55 -43
View File
@@ -2,17 +2,15 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Flame, Heart, Sparkles, User as UserIcon } from "lucide-react"; import { Flame, Heart, Sparkles } from "lucide-react";
import { ProfileHeader } from "@/components/profile-header";
import { NotFoundState, notFoundSentence } from "@/components/not-found-state";
import { http, toUserError } from "@/lib/api"; import { http, toUserError } from "@/lib/api";
import { useAuth } from "@/lib/auth-store"; import { useAuth } from "@/lib/auth-store";
import type { PublicProfile } from "@/lib/types"; import type { PublicProfile } from "@/lib/types";
import { Panel } from "@/components/panel";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { UserAvatar, avatarPropsOf } from "@/components/ui/user-avatar";
import { formatFaNumber, formatStreakLabel } from "@/lib/utils"; import { formatFaNumber, formatStreakLabel } from "@/lib/utils";
const AdminProfileManage = dynamic( const AdminProfileManage = dynamic(
@@ -20,12 +18,6 @@ const AdminProfileManage = dynamic(
{ ssr: false }, { ssr: false },
); );
function roleLabel(role: string) {
if (role === "mentor") return "منتور";
if (role === "admin") return "ادمین";
return "هنرجو";
}
export default function PublicProfilePage() { export default function PublicProfilePage() {
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const id = Number(params.id); const id = Number(params.id);
@@ -41,9 +33,12 @@ export default function PublicProfilePage() {
if (!enabled) { if (!enabled) {
return ( return (
<div className="mx-auto max-w-2xl py-16 text-center text-sm text-muted-foreground"> <NotFoundState
کاربر پیدا نشد. title="کاربر پیدا نشد"
</div> description="این شناسه در سامانه نیست."
href="/unwrap"
actionLabel="بازگشت به مرکز"
/>
); );
} }
@@ -62,14 +57,16 @@ export default function PublicProfilePage() {
if (profileQ.isError || !profileQ.data?.user) { if (profileQ.isError || !profileQ.data?.user) {
return ( return (
<div className="mx-auto max-w-2xl space-y-4 py-16 text-center"> <NotFoundState
<p className="text-sm font-bold text-destructive"> title="کاربر پیدا نشد"
{toUserError(profileQ.error, "کاربر پیدا نشد")} description={notFoundSentence(
</p> toUserError(profileQ.error, ""),
<Button asChild variant="outline"> "این شناسه در سامانه نیست.",
<Link href="/unwrap">بازگشت</Link> "کاربر پیدا نشد",
</Button> )}
</div> href="/unwrap"
actionLabel="بازگشت به مرکز"
/>
); );
} }
@@ -77,40 +74,55 @@ export default function PublicProfilePage() {
return ( return (
<div className="mx-auto max-w-2xl space-y-6"> <div className="mx-auto max-w-2xl space-y-6">
<Panel <ProfileHeader
title={person.name} person={{
description={roleLabel(person.role)} name: person.name,
icon={<UserIcon className="h-5 w-5" />} role: person.role,
tint="bg-primary/10 text-primary" banner: person.banner,
> createdAt: person.private ? undefined : person.createdAt,
<div className="flex flex-col items-center gap-4 sm:flex-row sm:items-start"> avatarVariant: person.avatarVariant,
<UserAvatar name={person.name} className="h-20 w-20" {...avatarPropsOf(person)} /> avatarPalette: person.avatarPalette,
<div className="min-w-0 flex-1 space-y-2 text-center sm:text-start"> avatarPhoto: person.avatarPhoto,
<h1 className="text-2xl font-black tracking-tight">{person.name}</h1> isFrozen: person.isFrozen,
<p className="font-latin text-sm font-bold" dir="ltr"> isClosed: person.isClosed,
@{person.username} }}
</p> secondary={person.private ? "این پروفایل خصوصی است" : undefined}
{person.isLocked && ( />
<p className="text-xs font-bold text-destructive">حساب قفل است</p> {person.isLocked && <p className="text-xs font-bold text-destructive">حساب قفل است</p>}
)}
</div>
</div>
</Panel>
{person.private ? (
<p className="text-sm text-muted-foreground">جز نام و آواتار چیزی نمایش داده نمی‌شود.</p>
) : (
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<Stat icon={<Sparkles className="h-4 w-4 text-gold" />} label="امتیاز" value={formatFaNumber(person.xp)} /> {person.xp != null && <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)} /> {person.hearts != null && <Stat icon={<Heart className="h-4 w-4 text-destructive" />} label="قلب‌ها" value={formatFaNumber(person.hearts)} />}
{person.streakCurrent != null && (
<Stat <Stat
icon={<Flame className="h-4 w-4 text-accent" />} icon={<Flame className="h-4 w-4 text-accent" />}
label="زنجیرهٔ فعلی" label="زنجیرهٔ فعلی"
value={formatStreakLabel(person.streakCurrent)} value={formatStreakLabel(person.streakCurrent)}
/> />
)}
{person.streakLongest != null && (
<Stat <Stat
icon={<Flame className="h-4 w-4 text-muted-foreground" />} icon={<Flame className="h-4 w-4 text-muted-foreground" />}
label="بلندترین زنجیره" label="بلندترین زنجیره"
value={formatStreakLabel(person.streakLongest)} value={formatStreakLabel(person.streakLongest)}
/> />
)}
</div> </div>
)}
{(person.socialLinks ?? []).length > 0 && (
<ul className="space-y-1 text-sm">
{person.socialLinks!.map((l) => (
<li key={l.url}>
<a href={l.url} className="text-primary underline" target="_blank" rel="noreferrer">
{l.name}
</a>
</li>
))}
</ul>
)}
{isAdmin && <AdminProfileManage userId={person.id} />} {isAdmin && <AdminProfileManage userId={person.id} />}
</div> </div>
+176 -58
View File
@@ -2,16 +2,16 @@
import Link from "next/link"; import Link from "next/link";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Flame, Heart, Sparkles, Trophy, User as UserIcon } from "lucide-react"; import { Flame, Heart, Sparkles, Trophy } from "lucide-react";
import { useState } from "react";
import { useAuth } from "@/lib/auth-store"; import { useAuth } from "@/lib/auth-store";
import { http, toUserError } from "@/lib/api"; import { http, toUserError } from "@/lib/api";
import type { LeaderboardData } from "@/lib/types"; import type { LeaderboardData, MeetingEvent } from "@/lib/types";
import { Panel } from "@/components/panel";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { UserAvatar, avatarPropsOf } from "@/components/ui/user-avatar";
import { ProfileCertificateSlot } from "@/components/profile-certificate-slot"; import { ProfileCertificateSlot } from "@/components/profile-certificate-slot";
import { OwnerProfileHeader } from "@/components/profile-header";
import { ChallengeCards } from "@/components/challenge-cards"; import { ChallengeCards } from "@/components/challenge-cards";
import { AvatarStudio } from "@/components/avatar-studio"; import { AvatarStudio } from "@/components/avatar-studio";
import { import {
@@ -20,8 +20,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { formatFaNumber, formatStreakLabel } from "@/lib/utils"; import { formatFaNumber, formatJalaliStamp, formatStreakLabel } from "@/lib/utils";
import { useState } from "react";
export default function ProfilePage() { export default function ProfilePage() {
const user = useAuth((s) => s.user); const user = useAuth((s) => s.user);
@@ -45,56 +44,7 @@ export default function ProfilePage() {
return ( return (
<div className="mx-auto max-w-2xl space-y-6"> <div className="mx-auto max-w-2xl space-y-6">
<Panel <OwnerProfileHeader user={user} onEditAvatar={() => setStudioOpen(true)} />
title="پروفایل من"
description="هویت، آمار یادگیری، چالش‌ها و گواهینامه در یک نگاه."
icon={<UserIcon className="h-5 w-5" />}
tint="bg-primary/10 text-primary"
actions={
<Button variant="outline" size="sm" onClick={() => setStudioOpen(true)}>
ویرایش آواتار
</Button>
}
>
<div className="flex flex-col items-center gap-4 sm:flex-row sm:items-start">
<UserAvatar name={user.name} className="h-20 w-20" {...avatarPropsOf(user)} />
<div className="min-w-0 flex-1 space-y-2 text-center sm:text-start">
<h1 className="text-2xl font-black tracking-tight">{user.name}</h1>
<dl className="mt-1 space-y-2 text-sm">
<div className="flex items-baseline justify-between gap-3">
<dt className="shrink-0 text-xs text-muted-foreground">نام کاربری</dt>
<dd className="min-w-0 font-latin font-bold tracking-tight" dir="ltr">
@{user.username}
</dd>
</div>
<div className="flex items-baseline justify-between gap-3">
<dt className="shrink-0 text-xs text-muted-foreground">ایمیل</dt>
<dd className="min-w-0 break-all font-latin font-medium" dir="ltr">
{user.email}
</dd>
</div>
{user.phone && (
<div className="flex items-baseline justify-between gap-3">
<dt className="shrink-0 text-xs text-muted-foreground">تلفن</dt>
<dd className="min-w-0 font-latin font-medium" dir="ltr">
{user.phone}
</dd>
</div>
)}
<div className="flex items-baseline justify-between gap-3">
<dt className="shrink-0 text-xs text-muted-foreground">عضویت از</dt>
<dd className="min-w-0 font-medium">
{new Date(user.createdAt).toLocaleDateString("fa-IR", {
year: "numeric",
month: "long",
day: "numeric",
})}
</dd>
</div>
</dl>
</div>
</div>
</Panel>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<StatCard icon={<Sparkles className="h-4 w-4 text-gold" />} label="امتیاز کل" value={formatFaNumber(user.xp)} /> <StatCard icon={<Sparkles className="h-4 w-4 text-gold" />} label="امتیاز کل" value={formatFaNumber(user.xp)} />
@@ -139,14 +89,16 @@ export default function ProfilePage() {
<ChallengeCards /> <ChallengeCards />
</div> </div>
<ProfileActivity />
<div> <div>
<h2 className="mb-2 text-sm font-black text-muted-foreground">گواهینامه</h2> <h2 className="mb-2 text-sm font-black text-muted-foreground">گواهینامه</h2>
<ProfileCertificateSlot /> <ProfileCertificateSlot />
</div> </div>
<Dialog open={studioOpen} onOpenChange={setStudioOpen}> <Dialog open={studioOpen} onOpenChange={setStudioOpen}>
<DialogContent className="max-w-lg"> <DialogContent className="flex max-h-[min(85vh,40rem)] max-w-lg flex-col gap-3 overflow-hidden">
<DialogHeader> <DialogHeader className="shrink-0">
<DialogTitle className="text-center">ویرایش آواتار</DialogTitle> <DialogTitle className="text-center">ویرایش آواتار</DialogTitle>
</DialogHeader> </DialogHeader>
<AvatarStudio user={user} onClose={() => setStudioOpen(false)} /> <AvatarStudio user={user} onClose={() => setStudioOpen(false)} />
@@ -156,6 +108,172 @@ export default function ProfilePage() {
); );
} }
function ProfileActivity() {
const activityQ = useQuery({
queryKey: ["me-activity"],
queryFn: () => http.get<{ days: { day: string; count: number }[] }>("/api/me/activity"),
});
const eventsQ = useQuery({
queryKey: ["events", "profile"],
queryFn: () => http.get<{ events: MeetingEvent[] }>("/api/events"),
});
return (
<div>
<h2 className="mb-2 text-sm font-black text-muted-foreground">فعالیت</h2>
<Card>
<CardContent className="space-y-6 p-4">
<div>
<h3 className="text-sm font-black">تاریخچهٔ ماهانه</h3>
<p className="mt-1 text-xs text-muted-foreground">هر بار یک ماه. پررنگ‌تر یعنی فعالیت بیشتر.</p>
{activityQ.isPending ? (
<Skeleton className="mt-3 h-24 rounded-xl" aria-busy="true" aria-label="در حال بارگذاری نمودار فعالیت" />
) : activityQ.isError ? (
<p className="mt-3 text-sm font-bold text-destructive">{toUserError(activityQ.error, "بارگذاری فعالیت ممکن نشد")}</p>
) : (
<ActivityGrid days={activityQ.data?.days ?? []} />
)}
</div>
<div>
<h3 className="text-sm font-black">رویدادها</h3>
{eventsQ.isPending ? (
<p className="mt-2 text-sm text-muted-foreground" aria-busy="true">
در حال بارگذاری…
</p>
) : eventsQ.isError ? (
<p className="mt-2 text-sm font-bold text-destructive">{toUserError(eventsQ.error, "بارگذاری رویدادها ممکن نشد")}</p>
) : (eventsQ.data?.events ?? []).length === 0 ? (
<p className="mt-2 text-sm text-muted-foreground">رویدادی نیست.</p>
) : (
<ul className="mt-3 divide-y divide-border rounded-xl border border-border">
{(eventsQ.data?.events ?? []).map((ev) => (
<li key={ev.id} className="flex items-baseline justify-between gap-3 px-3 py-2.5 text-sm">
<span className="font-bold">{ev.title}</span>
<time className="shrink-0 text-xs text-muted-foreground" dateTime={ev.startsAt}>
{formatJalaliStamp(ev.startsAt)}
</time>
</li>
))}
</ul>
)}
</div>
</CardContent>
</Card>
</div>
);
}
function activityCountLabel(count: number) {
if (count <= 0) return "بدون فعالیت";
return `${formatFaNumber(count)} فعالیت`;
}
const WEEKDAYS = ["ش", "ی", "د", "س", "چ", "پ", "ج"];
function ActivityGrid({ days }: { days: { day: string; count: number }[] }) {
const [index, setIndex] = useState(0);
const map = new Map(days.map((d) => [d.day.slice(0, 10), d.count]));
const order: string[] = [];
const months = new Map<string, { label: string; cells: { day: string; count: number }[] }>();
const today = new Date();
today.setHours(12, 0, 0, 0);
for (let i = 119; i >= 0; i--) {
const d = new Date(today);
d.setDate(today.getDate() - i);
const key = d.toISOString().slice(0, 10);
const id = jalaliMonthId(d);
if (!months.has(id)) {
order.push(id);
months.set(id, { label: jalaliMonth(d), cells: [] });
}
months.get(id)!.cells.push({ day: key, count: map.get(key) ?? 0 });
}
const newestFirst = [...order].reverse();
const safeIndex = Math.min(index, Math.max(newestFirst.length - 1, 0));
const id = newestFirst[safeIndex];
const month = id ? months.get(id) : undefined;
if (!month || !id) return null;
const pad = weekdayOffset(month.cells[0]?.day ?? "");
return (
<div className="mt-3" role="group" aria-label="تاریخچهٔ ماهانهٔ فعالیت، به تاریخ جلالی">
<div className="mb-3 flex items-center justify-between gap-3">
<Button type="button" variant="outline" size="sm" disabled={safeIndex <= 0} onClick={() => setIndex(safeIndex - 1)}>
ماه جدیدتر
</Button>
<h4 className="text-sm font-black">{month.label}</h4>
<Button
type="button"
variant="outline"
size="sm"
disabled={safeIndex >= newestFirst.length - 1}
onClick={() => setIndex(safeIndex + 1)}
>
ماه قدیمی‌تر
</Button>
</div>
<div className="grid grid-cols-7 gap-1" dir="ltr">
{WEEKDAYS.map((name) => (
<span key={name} className="text-center text-[10px] font-bold text-muted-foreground" dir="rtl">
{name}
</span>
))}
{Array.from({ length: pad }, (_, i) => (
<span key={`${id}-pad-${i}`} />
))}
{month.cells.map((c) => {
const date = jalaliDay(c.day);
const countLabel = activityCountLabel(c.count);
return (
<button
key={c.day}
type="button"
aria-label={`${date}، ${countLabel}`}
className="group relative z-0 aspect-square min-h-0 w-full min-w-0 cursor-default appearance-none rounded-sm border-0 p-0 outline-none hover:z-30 focus-visible:z-30 focus-visible:ring-2 focus-visible:ring-ring"
style={{ background: c.count === 0 ? "var(--muted)" : `color-mix(in srgb, var(--primary) ${30 + Math.min(c.count, 6) * 12}%, white)` }}
>
<span
role="tooltip"
dir="rtl"
className="pointer-events-none absolute bottom-[calc(100%+0.35rem)] left-1/2 z-30 hidden w-max -translate-x-1/2 rounded-lg border border-border bg-card px-2.5 py-1.5 text-start text-sm leading-snug text-card-foreground shadow-soft group-hover:block group-focus-visible:block"
>
<span className="block font-bold whitespace-nowrap">{date}</span>
<span className="block whitespace-nowrap text-muted-foreground">{countLabel}</span>
</span>
</button>
);
})}
</div>
</div>
);
}
function weekdayOffset(day: string) {
if (!day) return 0;
const d = new Date(day + "T12:00:00");
return (d.getDay() + 1) % 7;
}
function jalaliMonthId(d: Date) {
const parts = new Intl.DateTimeFormat("en-u-ca-persian", { year: "numeric", month: "2-digit" }).formatToParts(d);
const year = parts.find((p) => p.type === "year")?.value ?? "";
const month = parts.find((p) => p.type === "month")?.value ?? "";
return `${year}-${month}`;
}
function jalaliMonth(d: Date) {
const parts = new Intl.DateTimeFormat("fa-IR-u-ca-persian", { month: "long", year: "numeric" }).formatToParts(d);
const month = parts.find((p) => p.type === "month")?.value ?? "";
const year = parts.find((p) => p.type === "year")?.value ?? "";
return `${month} ${year}`;
}
function jalaliDay(day: string) {
return new Intl.DateTimeFormat("fa-IR-u-ca-persian", { year: "numeric", month: "short", day: "numeric" }).format(
new Date(day + "T12:00:00"),
);
}
function StatCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) { function StatCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
return ( return (
<Card> <Card>
+874
View File
@@ -0,0 +1,874 @@
"use client";
import { useEffect, useState, type ReactNode } from "react";
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
import { CalendarDays, Flame, Sparkles } from "lucide-react";
import { AccountIdentityFields, AccountUsernameSection } from "@/components/account-identity-fields";
import { SectionColumns, SectionNav, SectionPanel } from "@/components/section-nav";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { PasswordInput } from "@/components/ui/password-input";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { ApiError, http, toUserError } from "@/lib/api";
import { validateEmail, validateName, validatePassword, validatePhone, validateUsername } from "@/lib/validation";
import { useAuth } from "@/lib/auth-store";
import { clearMediaCache } from "@/lib/transfer";
import { toast } from "@/components/providers";
import type { Conversation } from "@/lib/types";
import { formatFaNumber, formatJalaliStamp, formatStreakLabel } from "@/lib/utils";
type SocialLink = { name: string; url: string };
type Extras = {
banner: string;
public: boolean;
showActivity: boolean;
showStats: boolean;
showPath: boolean;
showBadges: boolean;
socialLinks: SocialLink[];
theme: string;
};
type Activity = { days: { day: string; count: number }[]; weekDays: number; monthDays: number };
type BadgeItem = { id: string; title: string; unlocked: boolean };
const SECTIONS = [
{ id: "account", label: "حساب", title: "حساب", description: "نام، ایمیل و تلفن را اینجا ذخیره می‌کنید. نام کاربری جدا عوض می‌شود. برای گذرواژه، گذرواژهٔ فعلی لازم است." },
{ id: "privacy", label: "حریم", title: "حریم", description: "مشخص کنید دیگران در پروفایل شما چه چیزی ببینند." },
{ id: "stats", label: "آمار", title: "آمار", description: "امتیاز، زنجیره و نشان‌های همین حساب." },
{ id: "export", label: "خروجی", title: "خروجی", description: "گفتگوها را دانلود کنید یا حافظه موقت را از همین دستگاه حذف کنید." },
] as const;
const EXPORT_PAGE_SIZE = 8;
type SectionId = (typeof SECTIONS)[number]["id"];
export default function SettingsPage() {
const user = useAuth((s) => s.user);
const setUser = useAuth((s) => s.setUser);
const profileQ = useQuery({
queryKey: ["me-profile"],
queryFn: () => http.get<{ profile: Extras }>("/api/me/profile"),
enabled: !!user,
});
const activityQ = useQuery({
queryKey: ["me-activity"],
queryFn: () => http.get<Activity>("/api/me/activity"),
enabled: !!user && (profileQ.data?.profile.showActivity ?? true),
});
const badgesQ = useQuery({
queryKey: ["me-achievements"],
queryFn: () => http.get<{ achievements: BadgeItem[] }>("/api/me/achievements"),
enabled: !!user && (profileQ.data?.profile.showBadges ?? true),
});
const [section, setSection] = useState<SectionId>("account");
const [exportingId, setExportingId] = useState<number | null>(null);
const [exportPage, setExportPage] = useState(1);
const exportChatsQ = useQuery({
queryKey: ["settings-export-chats", user?.id],
queryFn: () => http.get<{ conversations: Conversation[] }>("/api/chats/conversations"),
enabled: !!user && section === "export",
});
const exportChats = exportChatsQ.data?.conversations ?? [];
const exportPageCount = Math.max(1, Math.ceil(exportChats.length / EXPORT_PAGE_SIZE));
const exportPageSafe = Math.min(Math.max(exportPage, 1), exportPageCount);
const exportPageStart = (exportPageSafe - 1) * EXPORT_PAGE_SIZE;
const exportPageChats = exportChats.slice(exportPageStart, exportPageStart + EXPORT_PAGE_SIZE);
const exportSizes = useQueries({
queries: exportChats.map((chat) => ({
queryKey: ["me-export-meta", user?.id, chat.partner.id],
queryFn: () => http.get<{ bytes: number; chat: number; lastAt?: string }>(`/api/me/export?meta=1&chat=${chat.partner.id}`),
enabled: !!user && section === "export",
})),
});
const [form, setForm] = useState<Extras | null>(null);
const [displayName, setDisplayName] = useState(user?.name ?? "");
const [email, setEmail] = useState(user?.email ?? "");
const [phone, setPhone] = useState(user?.phone ?? "");
const [nextUsername, setNextUsername] = useState("");
const [password, setPassword] = useState("");
const [currentPassword, setCurrentPassword] = useState("");
const [identityDirty, setIdentityDirty] = useState(false);
const [savingIdentity, setSavingIdentity] = useState(false);
const [savingUsername, setSavingUsername] = useState(false);
const [savingPassword, setSavingPassword] = useState(false);
const [freezeConfirm, setFreezeConfirm] = useState(false);
const [freezePassword, setFreezePassword] = useState("");
const [freezeBusy, setFreezeBusy] = useState(false);
const qc = useQueryClient();
useEffect(() => {
const profile = profileQ.data?.profile;
if (!profile) return;
setForm({
...profile,
socialLinks: normalizeLinks(profile.socialLinks),
});
}, [profileQ.data]);
useEffect(() => {
if (!user || identityDirty) return;
setDisplayName(user.name ?? "");
setEmail(user.email ?? "");
setPhone(user.phone ?? "");
}, [user, identityDirty]);
if (!user) return null;
const usernameCooling = Boolean(
user.usernameCooldownUntil && new Date(user.usernameCooldownUntil).getTime() > Date.now(),
);
const usernameRemaining = user.usernameChangesRemaining ?? 3;
const active = SECTIONS.find((item) => item.id === section) ?? SECTIONS[0];
const activityOn = profileQ.data?.profile.showActivity ?? true;
const badgesOn = profileQ.data?.profile.showBadges ?? true;
const patch = (partial: Partial<Extras>) => {
setForm((prev) => (prev ? { ...prev, ...partial } : prev));
};
const saveProfile = async () => {
if (!form) return;
try {
await http.put("/api/me/profile", {
public: form.public,
showActivity: form.showActivity,
showStats: form.showStats,
showPath: form.showPath,
showBadges: form.showBadges,
socialLinks: form.socialLinks.slice(0, 5),
banner: form.banner,
});
toast.success("تنظیمات ذخیره شد");
} catch (e) {
toast.error(toUserError(e, "ذخیره ممکن نشد"));
}
};
const saveIdentity = async () => {
const nameErr = validateName(displayName);
if (nameErr) {
toast.error(nameErr);
return;
}
const emailErr = validateEmail(email);
if (emailErr) {
toast.error(emailErr);
return;
}
const phoneErr = validatePhone(phone, false);
if (phoneErr) {
toast.error(phoneErr);
return;
}
setSavingIdentity(true);
try {
const res = await http.put<{ user: typeof user }>("/api/me/account", {
name: displayName.trim(),
email: email.trim().toLowerCase(),
phone: phone.trim(),
});
setUser(res.user);
setDisplayName(res.user.name ?? "");
setEmail(res.user.email ?? "");
setPhone(res.user.phone ?? "");
setIdentityDirty(false);
toast.success("مشخصات ذخیره شد");
} catch (e) {
toast.error(toUserError(e, "ذخیره مشخصات ممکن نشد"));
} finally {
setSavingIdentity(false);
}
};
const changeUsername = async () => {
const err = validateUsername(nextUsername);
if (err) {
toast.error(err);
return;
}
const next = nextUsername.trim().toLowerCase();
if (next === user.username) {
toast.success("نام کاربری همان است");
return;
}
setSavingUsername(true);
try {
const res = await http.put<{ user: typeof user }>("/api/me/username", { username: next });
setUser(res.user);
setNextUsername("");
toast.success("نام کاربری تغییر کرد");
} catch (e) {
if (!(e instanceof ApiError && e.status === 429)) {
toast.error(toUserError(e, "تغییر نام کاربری ممکن نشد"));
}
} finally {
setSavingUsername(false);
}
};
const savePassword = async () => {
const err = validatePassword(password);
if (err) {
toast.error(err);
return;
}
setSavingPassword(true);
try {
const res = await http.put<{ user: typeof user }>("/api/me/account", {
currentPassword,
password,
});
setUser(res.user);
setPassword("");
setCurrentPassword("");
toast.success("گذرواژه ذخیره شد");
} catch (e) {
toast.error(toUserError(e, "تغییر گذرواژه ممکن نشد"));
} finally {
setSavingPassword(false);
}
};
const freezeAccount = async () => {
if (!freezeConfirm) {
setFreezeConfirm(true);
return;
}
if (!freezePassword.trim()) {
toast.error("گذرواژهٔ فعلی را بنویسید");
return;
}
setFreezeBusy(true);
try {
const res = await http.post<{ user: typeof user }>("/api/me/freeze", { currentPassword: freezePassword });
setUser(res.user);
setFreezeConfirm(false);
setFreezePassword("");
await qc.invalidateQueries({ queryKey: ["me"] });
toast.success("حساب فریز شد");
} catch (e) {
toast.error(toUserError(e, "فریز حساب ممکن نشد"));
} finally {
setFreezeBusy(false);
}
};
const unfreezeAccount = async () => {
setFreezeBusy(true);
try {
const res = await http.post<{ user: typeof user }>("/api/me/unfreeze");
setUser(res.user);
await qc.invalidateQueries({ queryKey: ["me"] });
toast.success("فریز لغو شد");
} catch (e) {
toast.error(toUserError(e, "لغو فریز ممکن نشد"));
} finally {
setFreezeBusy(false);
}
};
const closeDate = formatFreezeCloseDate(user.closesAt || freezeCloseIso(user.frozenAt));
return (
<div className="mx-auto max-w-4xl">
<header className="mb-6 space-y-1">
<h1 className="text-2xl font-black tracking-tight">تنظیمات</h1>
<p className="text-sm text-muted-foreground">حساب، حریم و داده‌های همین حساب.</p>
</header>
<SectionColumns>
<SectionNav
label="بخش‌های تنظیمات"
groups={[{ items: SECTIONS.map((item) => ({ id: item.id, label: item.label })) }]}
activeId={section}
onSelect={setSection}
/>
<SectionPanel
id="settings-section"
titleId="settings-section-title"
title={active.title}
description={active.description}
>
{section === "account" && (
<div className="space-y-8">
<AccountIdentityFields
idPrefix="settings"
name={displayName}
email={email}
phone={phone}
onName={(value) => {
setIdentityDirty(true);
setDisplayName(value);
}}
onEmail={(value) => {
setIdentityDirty(true);
setEmail(value);
}}
onPhone={(value) => {
setIdentityDirty(true);
setPhone(value);
}}
action={
<Button type="button" disabled={savingIdentity} onClick={() => void saveIdentity()}>
ذخیره مشخصات
</Button>
}
/>
<AccountUsernameSection
mode="self"
idPrefix="settings"
current={user.username}
next={nextUsername}
onNext={setNextUsername}
disabled={usernameCooling}
hint={
usernameCooling && user.usernameCooldownUntil ? (
<p className="text-sm text-muted-foreground">
سه بار نام کاربری را عوض کرده‌اید. تغییر بعدی از {formatJalaliStamp(user.usernameCooldownUntil)} ممکن است.
</p>
) : usernameAllowanceLabel(usernameRemaining) ? (
<p className="text-xs font-bold text-muted-foreground">{usernameAllowanceLabel(usernameRemaining)}</p>
) : null
}
action={
<Button
type="button"
disabled={usernameCooling || savingUsername || usernameRemaining <= 0}
onClick={() => void changeUsername()}
>
تغییر نام کاربری
</Button>
}
/>
<section className="space-y-4 border-t border-border pt-6" aria-labelledby="settings-password-heading">
<div className="space-y-1">
<h3 id="settings-password-heading" className="text-sm font-black">گذرواژه</h3>
<p className="text-xs text-muted-foreground">برای ذخیره، گذرواژهٔ فعلی لازم است.</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Field id="settings-new-password" label="گذرواژهٔ جدید">
<PasswordInput
id="settings-new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
dir="ltr"
autoComplete="new-password"
/>
</Field>
<Field id="settings-current-password" label="گذرواژهٔ فعلی">
<PasswordInput
id="settings-current-password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
dir="ltr"
autoComplete="current-password"
/>
</Field>
</div>
<Button type="button" disabled={savingPassword} onClick={() => void savePassword()}>
ذخیره
</Button>
</section>
<section className="space-y-3 border-t border-border pt-6" aria-labelledby="settings-freeze-heading">
<h3 id="settings-freeze-heading" className="text-sm font-black">فریز حساب</h3>
<p className="text-sm text-muted-foreground">
فریز تا ۳۰ روز قابل بازگشت است، بعد از آن حساب بسته می‌شود و حذف دائمی فقط با مجوز ادمین است.
</p>
{user.isClosed ? (
<p className="text-sm font-bold">حساب بسته شده است. لغو فریز ممکن نیست.</p>
) : user.isFrozen ? (
<div className="space-y-3">
<p className="text-sm">
{closeDate ? `این حساب در ${closeDate} بسته می‌شود.` : "این حساب تا ۳۰ روز دیگر بسته می‌شود."}
</p>
<Button type="button" variant="outline" disabled={freezeBusy} onClick={() => void unfreezeAccount()}>
لغو فریز
</Button>
</div>
) : (
<div className="space-y-3">
{freezeConfirm && (
<>
<p className="text-sm font-bold">برای فریز، گذرواژهٔ فعلی را بنویسید.</p>
<Field id="settings-freeze-password" label="گذرواژهٔ فعلی">
<PasswordInput
id="settings-freeze-password"
value={freezePassword}
onChange={(e) => setFreezePassword(e.target.value)}
dir="ltr"
autoComplete="current-password"
/>
</Field>
</>
)}
<div className="flex flex-wrap gap-2">
<Button type="button" variant={freezeConfirm ? "destructive" : "outline"} disabled={freezeBusy} onClick={() => void freezeAccount()}>
فریز حساب
</Button>
{freezeConfirm && (
<Button
type="button"
variant="ghost"
disabled={freezeBusy}
onClick={() => {
setFreezeConfirm(false);
setFreezePassword("");
}}
>
انصراف
</Button>
)}
</div>
</div>
)}
</section>
</div>
)}
{section === "privacy" && (
<PrivacyFields
form={form}
loading={profileQ.isPending}
error={profileQ.isError ? toUserError(profileQ.error, "بارگذاری حریم ممکن نشد") : null}
onPatch={patch}
onSave={() => void saveProfile()}
/>
)}
{section === "stats" && (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<StatTile
icon={<Sparkles className="h-4 w-4 text-gold" />}
label="امتیاز"
value={formatFaNumber(user.xp)}
/>
<StatTile
icon={<Flame className="h-4 w-4 text-accent" />}
label="زنجیرهٔ فعلی"
value={formatStreakLabel(user.streakCurrent)}
/>
<StatTile
icon={<Flame className="h-4 w-4 text-muted-foreground" />}
label="بلندترین زنجیره"
value={formatStreakLabel(user.streakLongest)}
/>
<StatTile
icon={<CalendarDays className="h-4 w-4 text-primary" />}
label="روزهای این هفته"
value={countLabel(activityOn, activityQ.isPending, activityQ.data?.weekDays)}
/>
<StatTile
icon={<CalendarDays className="h-4 w-4 text-secondary" />}
label="روزهای این ماه"
value={countLabel(activityOn, activityQ.isPending, activityQ.data?.monthDays)}
/>
</div>
<div>
<h3 className="text-sm font-black">نشان‌ها</h3>
{badgesOn ? (
<div className="mt-3">
<p className="mb-3 text-xs text-muted-foreground">رنگ‌دارها باز شده‌اند و کمرنگ‌ها هنوز قفل‌اند.</p>
{badgesQ.isPending ? (
<div className="flex flex-wrap gap-2" aria-busy="true" aria-label="در حال بارگذاری نشان‌ها">
<Skeleton className="h-6 w-24 rounded-full" />
<Skeleton className="h-6 w-20 rounded-full" />
<Skeleton className="h-6 w-28 rounded-full" />
</div>
) : badgesQ.isError ? (
<p className="text-sm font-bold text-destructive">{toUserError(badgesQ.error, "بارگذاری نشان‌ها ممکن نشد")}</p>
) : (badgesQ.data?.achievements ?? []).length === 0 ? (
<p className="text-sm text-muted-foreground">نشانی ثبت نشده.</p>
) : (
<ul className="flex flex-wrap gap-2">
{(badgesQ.data?.achievements ?? []).map((b) => (
<li key={b.id}>
<Badge variant={b.unlocked ? "success" : "secondary"} className="rounded-full px-2.5 py-1">
{b.title}
</Badge>
</li>
))}
</ul>
)}
</div>
) : (
<p className="mt-2 text-sm text-muted-foreground">نمایش نشان‌ها در بخش حریم خاموش است.</p>
)}
</div>
</div>
)}
{section === "export" && (
<div className="space-y-6">
{exportChatsQ.isPending ? (
<div className="space-y-3" aria-busy="true" aria-label="در حال بارگذاری گفتگوها">
<Skeleton className="h-16 rounded-xl" />
<Skeleton className="h-16 rounded-xl" />
</div>
) : exportChatsQ.isError ? (
<p className="text-sm font-bold text-destructive">{toUserError(exportChatsQ.error, "بارگذاری گفتگوها ممکن نشد")}</p>
) : exportChats.length === 0 ? (
<p className="text-sm text-muted-foreground">گفتگویی نیست.</p>
) : (
<div className="space-y-4">
<ul className="divide-y divide-border">
{exportPageChats.map((chat, offset) => {
const saved = chat.partner.saved || chat.partner.id === user.id;
const label = saved ? "پیام‌های ذخیره‌شده" : chat.partner.name;
const size = exportSizes[exportPageStart + offset];
const last = chat.lastMessage?.createdAt || size?.data?.lastAt;
return (
<li key={chat.partner.id} className="flex flex-col gap-3 py-4 first:pt-0 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 space-y-1">
<p className="truncate text-sm font-bold">{label}</p>
<p className="text-xs text-muted-foreground">
{last ? `آخرین فعالیت: ${formatJalaliStamp(last)}` : "هنوز پیامی نیست"}
</p>
<p className="text-xs font-bold text-foreground">
{size?.isPending
? "در حال برآورد حجم…"
: size?.isError
? "برآورد حجم ممکن نشد"
: `حجم تقریبی: ${formatApproxSize(size?.data?.bytes ?? 0)}`}
</p>
</div>
<Button
type="button"
variant="outline"
className="shrink-0"
disabled={exportingId === chat.partner.id}
onClick={() => {
setExportingId(chat.partner.id);
void downloadChatExport(chat.partner.id, exportFileName(label, chat.partner.id)).finally(() => {
setExportingId((cur) => (cur === chat.partner.id ? null : cur));
});
}}
>
دانلود
</Button>
</li>
);
})}
</ul>
{exportChats.length > EXPORT_PAGE_SIZE && (
<div className="flex items-center justify-between gap-3">
<Button
type="button"
variant="outline"
size="sm"
disabled={exportPageSafe <= 1}
onClick={() => setExportPage(exportPageSafe - 1)}
>
قبلی
</Button>
<p className="text-sm font-bold tabular-nums text-muted-foreground">
{formatFaNumber(exportPageSafe)} از {formatFaNumber(exportPageCount)}
</p>
<Button
type="button"
variant="outline"
size="sm"
disabled={exportPageSafe >= exportPageCount}
onClick={() => setExportPage(exportPageSafe + 1)}
>
بعدی
</Button>
</div>
)}
</div>
)}
<ul className="divide-y divide-border border-t border-border pt-4">
<li className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 space-y-1">
<p className="text-sm font-bold">حذف حافظه موقت</p>
<p className="text-xs leading-relaxed text-muted-foreground">عکس و صدای ذخیره‌شده روی همین دستگاه حذف می‌شود.</p>
</div>
<Button
type="button"
variant="outline"
className="shrink-0"
onClick={() => {
void clearMediaCache().then(() => toast.success("حافظه موقت حذف شد"));
}}
>
حذف
</Button>
</li>
</ul>
</div>
)}
</SectionPanel>
</SectionColumns>
</div>
);
}
function PrivacyFields({
form,
loading,
error,
onPatch,
onSave,
}: {
form: Extras | null;
loading: boolean;
error: string | null;
onPatch: (partial: Partial<Extras>) => void;
onSave: () => void;
}) {
if (loading || !form) {
if (error) return <p className="text-sm font-bold text-destructive">{error}</p>;
return (
<div className="space-y-3" aria-busy="true" aria-label="در حال بارگذاری حریم">
<Skeleton className="h-14 rounded-xl" />
<Skeleton className="h-14 rounded-xl" />
<Skeleton className="h-14 rounded-xl" />
</div>
);
}
const links = form.socialLinks;
return (
<div className="space-y-6">
<div className="divide-y divide-border">
<PrivacyRow
id="privacy-public"
label="پروفایل عمومی"
hint="تا وقتی خاموش باشد، دیگران فقط نام و آواتار را می‌بینند."
checked={form.public}
onCheckedChange={(v) => onPatch({ public: v })}
/>
<PrivacyRow
id="privacy-activity"
label="نمایش فعالیت"
hint="روزهای فعال در پروفایل عمومی دیده می‌شود."
checked={form.showActivity}
onCheckedChange={(v) => onPatch({ showActivity: v })}
/>
<PrivacyRow
id="privacy-stats"
label="نمایش آمار"
hint="امتیاز و زنجیره در پروفایل عمومی دیده می‌شود."
checked={form.showStats}
onCheckedChange={(v) => onPatch({ showStats: v })}
/>
<PrivacyRow
id="privacy-path"
label="نمایش مسیر"
hint="مسیر یادگیری در پروفایل عمومی دیده می‌شود."
checked={form.showPath}
onCheckedChange={(v) => onPatch({ showPath: v })}
/>
<PrivacyRow
id="privacy-badges"
label="نمایش نشان‌ها"
hint="نشان‌ها در پروفایل عمومی دیده می‌شوند."
checked={form.showBadges}
onCheckedChange={(v) => onPatch({ showBadges: v })}
/>
</div>
<div className="space-y-3 border-t border-border pt-5">
<div>
<h3 className="text-sm font-black">لینک‌های شخصی</h3>
<p className="mt-1 text-xs text-muted-foreground">حداکثر پنج لینک. نام و نشانی را وارد کنید.</p>
</div>
{links.length === 0 ? (
<p className="text-sm text-muted-foreground">هنوز لینکی اضافه نشده.</p>
) : (
<ul className="space-y-3">
{links.map((link, i) => (
<li key={i} className="flex items-end gap-2">
<div className="w-28 shrink-0 space-y-1.5 sm:w-36">
<Label htmlFor={`settings-link-name-${i}`} className="text-xs font-bold text-muted-foreground">
نام
</Label>
<Input
id={`settings-link-name-${i}`}
value={link.name}
placeholder="تلگرام"
onChange={(e) => {
const socialLinks = links.map((row, idx) => (idx === i ? { ...row, name: e.target.value } : row));
onPatch({ socialLinks });
}}
/>
</div>
<div className="min-w-0 flex-1 space-y-1.5">
<Label htmlFor={`settings-link-url-${i}`} className="text-xs font-bold text-muted-foreground">
نشانی
</Label>
<Input
id={`settings-link-url-${i}`}
dir="ltr"
value={link.url}
placeholder="https://"
onChange={(e) => {
const socialLinks = links.map((row, idx) => (idx === i ? { ...row, url: e.target.value } : row));
onPatch({ socialLinks });
}}
/>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="h-11 shrink-0"
aria-label={`حذف لینک ${link.name || i + 1}`}
onClick={() => onPatch({ socialLinks: links.filter((_, idx) => idx !== i) })}
>
حذف
</Button>
</li>
))}
</ul>
)}
{links.length < 5 && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onPatch({ socialLinks: [...links, { name: "", url: "" }] })}
>
افزودن لینک
</Button>
)}
</div>
<Button type="button" onClick={onSave}>
ذخیره حریم
</Button>
</div>
);
}
function PrivacyRow({
id,
label,
hint,
checked,
onCheckedChange,
}: {
id: string;
label: string;
hint: string;
checked: boolean;
onCheckedChange: (v: boolean) => void;
}) {
const hintId = `${id}-hint`;
return (
<div className="flex items-center justify-between gap-4 py-4 first:pt-0 last:pb-0">
<div className="min-w-0 space-y-1">
<Label htmlFor={id} className="text-sm font-bold text-foreground">
{label}
</Label>
<p id={hintId} className="text-xs leading-relaxed text-muted-foreground">
{hint}
</p>
</div>
<Switch id={id} checked={checked} onCheckedChange={onCheckedChange} aria-describedby={hintId} />
</div>
);
}
function formatFreezeCloseDate(iso?: string) {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleDateString("fa-IR", { year: "numeric", month: "long", day: "numeric" });
}
function freezeCloseIso(frozenAt?: string) {
if (!frozenAt) return "";
const start = new Date(frozenAt);
if (Number.isNaN(start.getTime())) return "";
const close = new Date(start);
close.setDate(close.getDate() + 30);
return close.toISOString();
}
function usernameAllowanceLabel(remaining: number): string {
if (remaining >= 3) return "۳ بار دیگر";
if (remaining === 2) return "۲ بار دیگر";
if (remaining === 1) return "۱ بار دیگر";
return "";
}
function Field({ id, label, children }: { id: string; label: string; children: ReactNode }) {
return (
<div className="space-y-1.5">
<Label htmlFor={id} className="text-xs font-bold text-muted-foreground">
{label}
</Label>
{children}
</div>
);
}
function StatTile({ icon, label, value }: { icon: ReactNode; label: string; value: string }) {
return (
<div className="flex items-center gap-3 rounded-2xl border border-border bg-muted/30 px-3 py-3 sm:px-4">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-card">{icon}</span>
<div className="min-w-0">
<p className="text-[11px] font-bold text-muted-foreground">{label}</p>
<p className="text-lg font-black tabular-nums">{value}</p>
</div>
</div>
);
}
function countLabel(enabled: boolean, pending: boolean, value: number | undefined) {
if (!enabled) return "خاموش";
if (pending) return "…";
return formatFaNumber(value ?? 0);
}
function normalizeLinks(links: SocialLink[] | null | undefined): SocialLink[] {
if (!Array.isArray(links)) return [];
return links.slice(0, 5).map((row) => ({ name: row?.name ?? "", url: row?.url ?? "" }));
}
function formatApproxSize(bytes: number) {
if (!Number.isFinite(bytes) || bytes < 1024) return `حدود ${formatFaNumber(1)} کیلوبایت`;
if (bytes < 1024 * 1024) return `حدود ${formatFaNumber(Math.round(bytes / 1024))} کیلوبایت`;
const mb = bytes / (1024 * 1024);
return `حدود ${formatFaNumber(mb, mb < 10 ? 1 : 0)} مگابایت`;
}
function exportFileName(label: string, id: number) {
const clean = label.replace(/[\\/:*?"<>|]/g, " ").replace(/\s+/g, " ").trim();
return `${clean || `chat-${id}`}.zip`;
}
async function downloadChatExport(chatId: number, filename: string) {
const token = useAuth.getState().accessToken;
const base = (process.env.NEXT_PUBLIC_API_URL ?? "").replace(/\/$/, "");
try {
const res = await fetch(`${base}/api/me/export?chat=${chatId}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
credentials: "include",
});
if (!res.ok) {
toast.error("دانلود خروجی گفتگو ممکن نشد");
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
} catch {
toast.error("دانلود خروجی گفتگو ممکن نشد");
}
}
+122 -62
View File
@@ -1,8 +1,9 @@
"use client"; "use client";
import { Suspense, useEffect, useState } from "react"; import { Suspense, useEffect, useMemo, useState } from "react";
import { motion } from "framer-motion";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { Award, CalendarDays, HeartHandshake, MessageSquareHeart, Sparkles } from "lucide-react"; import { Award, CalendarDays, MessageSquareHeart, Share2, Sparkles } from "lucide-react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
@@ -17,7 +18,9 @@ import { Badge } from "@/components/ui/badge";
import type { Mentor } from "@/lib/types"; import type { Mentor } from "@/lib/types";
import { http } from "@/lib/api"; import { http } from "@/lib/api";
import type { ChatMessage, Conversation } from "@/lib/types"; import type { ChatMessage, Conversation } from "@/lib/types";
import { cn } from "@/lib/utils"; import { cn, formatJalaliStamp } from "@/lib/utils";
import { useAuth } from "@/lib/auth-store";
import { isVerifiedRole, VerifiedBadge } from "@/components/verified-badge";
type Tab = "events" | "chats" | "leagues" | "community"; type Tab = "events" | "chats" | "leagues" | "community";
@@ -69,7 +72,7 @@ function HubTabs() {
جامعه، <span className="text-gradient-brand">استمرار و منتورها</span> جامعه، <span className="text-gradient-brand">استمرار و منتورها</span>
</> </>
} }
description="رویدادهای زنده برای تقویت مهارت، لیگ‌های هفتگی برای ثبات، گفتگو با منتور، و هماهنگی حمایت مالی." description="رویدادهای زنده برای تقویت مهارت، لیگ‌های هفتگی برای ثبات، گفتگو، و شبکه‌های پرگار."
className="mb-0" className="mb-0"
/> />
@@ -88,26 +91,11 @@ function HubTabs() {
<span className="truncate">لیگ‌ها</span> <span className="truncate">لیگ‌ها</span>
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="community" className="h-10 gap-1.5 px-2 sm:px-3"> <TabsTrigger value="community" className="h-10 gap-1.5 px-2 sm:px-3">
<HeartHandshake className="h-4 w-4 shrink-0" /> <Share2 className="h-4 w-4 shrink-0" />
<span className="truncate">حمایت</span> <span className="truncate">شبکه‌ها</span>
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="events" className="mt-0 space-y-4"> <TabsContent value="events" className="mt-0 space-y-4">
<button
type="button"
onClick={() => onTab("community")}
className="flex w-full items-center gap-3 rounded-2xl border-2 border-accent/30 bg-accent/5 px-4 py-3.5 text-start transition hover:bg-accent/10"
>
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-accent/15 text-accent">
<HeartHandshake className="h-5 w-5" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-extrabold">حمایت مالی</span>
<span className="mt-0.5 block text-xs text-muted-foreground">
هماهنگی دونیت از طریق گفتگو با تیم
</span>
</span>
</button>
<EventsPanel /> <EventsPanel />
</TabsContent> </TabsContent>
<TabsContent value="chats" className="mt-0"> <TabsContent value="chats" className="mt-0">
@@ -128,36 +116,62 @@ function openChat(withId?: number) {
window.dispatchEvent(new CustomEvent("pargar:chat", { detail: { open: true, with: withId } })); window.dispatchEvent(new CustomEvent("pargar:chat", { detail: { open: true, with: withId } }));
} }
function roleWord(role?: string) {
if (role === "admin") return "مدیر";
if (role === "mentor") return "منتور";
if (role === "student") return "هنرجو";
return "کاربر";
}
function ChatsLauncher() { function ChatsLauncher() {
const me = useAuth((s) => s.user);
const staff = me?.role === "admin" || me?.role === "mentor";
const [peoplePage, setPeoplePage] = useState(1);
const [alpha, setAlpha] = useState(false);
const [convPage, setConvPage] = useState(1);
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ["mentors"], queryKey: ["mentors", peoplePage],
queryFn: () => http.get<{ mentors: Mentor[] }>("/api/mentors"), queryFn: () => http.get<{ mentors: Mentor[]; total: number; pageSize: number }>(`/api/mentors?page=${peoplePage}&pageSize=20`),
}); });
const { data: convData, isLoading: convLoading } = useQuery({ const { data: convData, isLoading: convLoading } = useQuery({
queryKey: ["conversations"], queryKey: ["conversations", convPage],
queryFn: () => http.get<{ conversations: Conversation[] }>("/api/chats/conversations"), queryFn: () =>
http.get<{ conversations: Conversation[]; total: number; pageSize: number }>(
`/api/chats/conversations?page=${convPage}&pageSize=10`,
),
}); });
const mentors = data?.mentors ?? []; const mentors = data?.mentors ?? [];
const people = useMemo(() => {
const list = [...mentors];
if (alpha) list.sort((a, b) => a.name.localeCompare(b.name, "fa"));
else list.sort((a, b) => Number(!!b.online) - Number(!!a.online) || a.name.localeCompare(b.name, "fa"));
return list;
}, [mentors, alpha]);
const conversations = convData?.conversations ?? []; const conversations = convData?.conversations ?? [];
const peopleTotal = data?.total ?? mentors.length;
const convTotal = convData?.total ?? conversations.length;
return ( return (
<div className="grid gap-4 lg:grid-cols-2"> <div className="space-y-4">
<Panel className="justify-center text-center" title="گفتگوها" description="یک پنجرهٔ شناور شیشه‌ای در تمام صفحات — با صدا، عکس، ویدیو و پیام صوتی."> {/* ── Centered header banner ── */}
<div className="flex flex-col items-center gap-4 pt-1"> <div className="flex flex-col items-center gap-3 rounded-2xl border border-border bg-card px-6 py-6 text-center">
<div className="grid h-14 w-14 place-items-center rounded-2xl bg-primary/10"> <div className="inline-flex items-center gap-2 rounded-full border border-primary/20 bg-primary/10 px-4 py-1.5 text-sm font-medium text-primary backdrop-blur-sm">
<MessageSquareHeart className="h-7 w-7 text-primary" /> <MessageSquareHeart className="h-4 w-4 shrink-0" />
یک پنجرهٔ شناور شیشه‌ای در تمام صفحات — با صدا، عکس، ویدیو و پیام صوتی.
</div> </div>
<p className="max-w-sm text-sm text-muted-foreground"> <Button onClick={() => openChat()} className="gap-2">
<Sparkles className="h-4 w-4" /> باز کردن گفتگوها ✨
</Button>
<p className="max-w-md text-sm text-muted-foreground">
گفتگو همیشه در دکمهٔ شناور پایین صفحه در دسترس شماست؛ گفتگوها خصوصی هستند و پاسخ منتورها معمولاً کمتر از یک روز طول می‌کشد. گفتگو همیشه در دکمهٔ شناور پایین صفحه در دسترس شماست؛ گفتگوها خصوصی هستند و پاسخ منتورها معمولاً کمتر از یک روز طول می‌کشد.
</p> </p>
<Button onClick={() => openChat()} className="gap-2">
<Sparkles className="h-4 w-4" /> باز کردن گفتگوها
</Button>
</div> </div>
</Panel>
<Panel title="گفتگوهای شما" description="مکالمات جاری با منتورها — روی هر کدام بزنید تا باز شود."> {/* ── Side-by-side panels ── */}
<div className="space-y-1.5 pt-1"> <div className="grid gap-4 lg:grid-cols-2">
{/* Conversations panel */}
<Panel title="گفتگوهای شما" description="مکالمات جاری — روی هر کدام بزنید تا باز شود.">
<div className="max-h-[32rem] overflow-y-auto space-y-1.5 pt-1 pb-1">
{convLoading && ( {convLoading && (
<div className="space-y-2"> <div className="space-y-2">
{[0, 1, 2].map((i) => ( {[0, 1, 2].map((i) => (
@@ -183,9 +197,12 @@ function ChatsLauncher() {
<UserAvatar name={c.partner.name} className="h-10 w-10" {...avatarPropsOf(c.partner)} /> <UserAvatar name={c.partner.name} className="h-10 w-10" {...avatarPropsOf(c.partner)} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<p className="truncate font-bold">{c.partner.name}</p> <p className="flex min-w-0 items-center gap-1 font-bold">
<span className="truncate">{c.partner.name}</span>
{isVerifiedRole(c.partner.role) && <VerifiedBadge role={c.partner.role} />}
</p>
{c.lastMessage && ( {c.lastMessage && (
<span className="shrink-0 text-[10px] text-muted-foreground">{timeAgo(c.lastMessage.createdAt)}</span> <span className="shrink-0 text-[10px] text-muted-foreground">{formatJalaliStamp(c.lastMessage.createdAt)}</span>
)} )}
</div> </div>
<p <p
@@ -207,14 +224,40 @@ function ChatsLauncher() {
))} ))}
{!convLoading && conversations.length === 0 && ( {!convLoading && conversations.length === 0 && (
<p className="px-1 text-sm text-muted-foreground"> <p className="px-1 text-sm text-muted-foreground">
هنوز گفتگویی ندارید — از «شروع سریع» یک منتور انتخاب کنید. هنوز گفتگویی ندارید — از فهرست کنار، یک نفر را انتخاب کنید.
</p> </p>
)} )}
{convTotal > (convData?.pageSize ?? 10) && (
<div className="flex items-center justify-between pt-2 text-xs">
<Button type="button" size="sm" variant="outline" disabled={convPage <= 1} onClick={() => setConvPage((p) => p - 1)}>
قبلی
</Button>
<span className="text-muted-foreground">{convPage}</span>
<Button
type="button"
size="sm"
variant="outline"
disabled={convPage * (convData?.pageSize ?? 10) >= convTotal}
onClick={() => setConvPage((p) => p + 1)}
>
بعدی
</Button>
</div>
)}
</div> </div>
</Panel> </Panel>
<Panel title="شروع سریع با منتور" description="یک گفتگو را مستقیم باز کنید."> {/* Start chat panel */}
<div className="space-y-1 pt-1"> <Panel
title={staff ? "شروع گفتگو" : "شروع سریع با منتور"}
description="نقش هر نفر روی ردیف نوشته شده است."
actions={
<Button type="button" size="sm" variant="outline" onClick={() => setAlpha((v) => !v)}>
{alpha ? "آنلاین بالا" : "الفبایی"}
</Button>
}
>
<div className="max-h-[32rem] overflow-y-auto space-y-1 pt-1 pb-1">
{isLoading && ( {isLoading && (
<div className="space-y-2"> <div className="space-y-2">
{[0, 1, 2, 3].map((i) => ( {[0, 1, 2, 3].map((i) => (
@@ -229,46 +272,63 @@ function ChatsLauncher() {
))} ))}
</div> </div>
)} )}
{mentors.map((m) => ( {people.map((m) => (
<button <motion.button
layout
key={m.id} key={m.id}
onClick={() => openChat(m.id)} onClick={() => openChat(m.id)}
className="flex w-full items-center gap-3 rounded-xl border-2 border-border/70 px-3 py-2.5 text-right transition-colors hover:border-primary/50 hover:bg-primary/5" className="flex w-full items-center gap-3 rounded-xl border-2 border-border/70 px-3 py-2.5 text-right transition-colors hover:border-primary/50 hover:bg-primary/5"
> >
<UserAvatar name={m.name} className="h-10 w-10" {...avatarPropsOf(m)} /> <UserAvatar name={m.name} className="h-10 w-10" {...avatarPropsOf(m)} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate font-bold">{m.name}</p> <p className="flex min-w-0 items-center gap-1 font-bold">
<p className="text-xs text-muted-foreground">منتور</p> <span className="truncate">{m.name}</span>
{isVerifiedRole(m.role) && <VerifiedBadge role={m.role} />}
</p>
<p className="text-xs text-muted-foreground">{roleWord(m.role)}</p>
</div> </div>
<span className="inline-flex items-center gap-1.5 text-xs font-bold text-success"> <span className={cn("inline-flex items-center gap-1.5 text-xs font-bold", m.online ? "text-success" : "text-muted-foreground")}>
<span className="h-1.5 w-1.5 rounded-full bg-success" /> در دسترس <span className={cn("h-1.5 w-1.5 rounded-full", m.online ? "bg-success" : "bg-muted-foreground/40")} />
{m.online ? "آنلاین" : "آفلاین"}
</span> </span>
</button> </motion.button>
))} ))}
{!isLoading && mentors.length === 0 && ( {!isLoading && mentors.length === 0 && (
<p className="text-sm text-muted-foreground">هنوز منتوری در دسترس نیست.</p> <p className="text-sm text-muted-foreground">{staff ? "کسی برای گفتگو نیست." : "هنوز منتوری در دسترس نیست."}</p>
)}
{peopleTotal > (data?.pageSize ?? 20) && (
<div className="flex items-center justify-between pt-2 text-xs">
<Button type="button" size="sm" variant="outline" disabled={peoplePage <= 1} onClick={() => setPeoplePage((p) => p - 1)}>
قبلی
</Button>
<span className="text-muted-foreground">{peoplePage}</span>
<Button
type="button"
size="sm"
variant="outline"
disabled={peoplePage * (data?.pageSize ?? 20) >= peopleTotal}
onClick={() => setPeoplePage((p) => p + 1)}
>
بعدی
</Button>
</div>
)} )}
</div> </div>
</Panel> </Panel>
</div> </div>
</div>
); );
} }
function messagePreview(m: ChatMessage) { function messagePreview(m: ChatMessage) {
if (m.body) return m.body; if (m.body) return m.body;
if (m.attachment) { const atts = m.attachments?.length ? m.attachments : m.attachment ? [m.attachment] : [];
if (atts.length > 1 && atts.every((a) => a.type === "image")) return `📷 ${atts.length.toLocaleString("fa-IR")} عکس`;
if (atts.length > 1 && atts.every((a) => a.type === "file")) return `📎 ${atts.length.toLocaleString("fa-IR")} فایل`;
if (atts.length > 1) return `📎 ${atts.length.toLocaleString("fa-IR")} پیوست`;
if (atts[0]) {
const labels: Record<string, string> = { image: "📷 عکس", video: "🎬 ویدیو", audio: "🎵 پیام صوتی" }; const labels: Record<string, string> = { image: "📷 عکس", video: "🎬 ویدیو", audio: "🎵 پیام صوتی" };
return labels[m.attachment.type] ?? `📎 ${m.attachment.name}`; return labels[atts[0].type] ?? `📎 ${atts[0].name}`;
} }
return ""; return "";
} }
function timeAgo(iso: string) {
const diff = Date.now() - new Date(iso).getTime();
const m = Math.floor(diff / 60_000);
if (m < 1) return "الان";
if (m < 60) return `${m} دقیقه قبل`;
const h = Math.floor(m / 60);
if (h < 24) return `${h} ساعت قبل`;
return new Date(iso).toLocaleDateString("fa-IR");
}
@@ -0,0 +1,138 @@
"use client";
import type { ReactNode } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
function Field({ id, label, children }: { id: string; label: string; children: ReactNode }) {
return (
<div className="space-y-1.5">
<Label htmlFor={id} className="text-xs font-bold text-muted-foreground">
{label}
</Label>
{children}
</div>
);
}
/** نام، ایمیل، تلفن — same block on /settings and in the admin account editor. */
export function AccountIdentityFields({
idPrefix,
name,
email,
phone,
onName,
onEmail,
onPhone,
phoneMaxLength,
action,
}: {
idPrefix: string;
name: string;
email: string;
phone: string;
onName: (value: string) => void;
onEmail: (value: string) => void;
onPhone: (value: string) => void;
phoneMaxLength?: number;
action?: ReactNode;
}) {
return (
<section className="space-y-4" aria-labelledby={`${idPrefix}-identity-heading`}>
<h3 id={`${idPrefix}-identity-heading`} className="text-sm font-black">هویت</h3>
<div className="grid gap-4 sm:grid-cols-2">
<Field id={`${idPrefix}-name`} label="نام">
<Input id={`${idPrefix}-name`} value={name} onChange={(e) => onName(e.target.value)} autoComplete="name" />
</Field>
<Field id={`${idPrefix}-email`} label="ایمیل">
<Input
id={`${idPrefix}-email`}
value={email}
onChange={(e) => onEmail(e.target.value)}
dir="ltr"
autoComplete="email"
inputMode="email"
/>
</Field>
<Field id={`${idPrefix}-phone`} label="تلفن">
<Input
id={`${idPrefix}-phone`}
value={phone}
onChange={(e) => onPhone(e.target.value)}
dir="ltr"
autoComplete="tel"
inputMode="tel"
maxLength={phoneMaxLength}
/>
</Field>
</div>
{action}
</section>
);
}
type UsernameSelf = {
mode: "self";
idPrefix: string;
current: string;
next: string;
onNext: (value: string) => void;
disabled?: boolean;
hint?: ReactNode;
action?: ReactNode;
};
type UsernameAdmin = {
mode: "admin";
idPrefix: string;
value: string;
onChange: (value: string) => void;
};
/** نام کاربری follows هویت. Self-service keeps the cooldown fields; admin sets the name directly. */
export function AccountUsernameSection(props: UsernameSelf | UsernameAdmin) {
const headingId = `${props.idPrefix}-username-heading`;
return (
<section className="space-y-3 border-t border-border pt-6" aria-labelledby={headingId}>
<h3 id={headingId} className="text-sm font-black">نام کاربری</h3>
{props.mode === "self" && props.hint}
{props.mode === "self" ? (
<div className="grid gap-4 sm:grid-cols-2">
<Field id={`${props.idPrefix}-username-current`} label="نام کاربری فعلی">
<Input
id={`${props.idPrefix}-username-current`}
value={props.current}
readOnly
dir="ltr"
autoComplete="username"
/>
</Field>
<Field id={`${props.idPrefix}-username`} label="نام کاربری جدید">
<Input
id={`${props.idPrefix}-username`}
value={props.next}
onChange={(e) => props.onNext(e.target.value.toLowerCase())}
dir="ltr"
autoComplete="off"
disabled={props.disabled}
/>
</Field>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2">
<Field id={`${props.idPrefix}-username`} label="نام کاربری">
<Input
id={`${props.idPrefix}-username`}
value={props.value}
onChange={(e) => props.onChange(e.target.value.toLowerCase())}
dir="ltr"
autoComplete="off"
placeholder="مثلاً ahp"
/>
</Field>
</div>
)}
{props.mode === "self" ? props.action : null}
</section>
);
}
+94 -148
View File
@@ -25,7 +25,6 @@ import {
Plus, Plus,
Rocket, Rocket,
Save, Save,
ShieldCheck,
Sparkles, Sparkles,
Target, Target,
Terminal, Terminal,
@@ -65,6 +64,7 @@ import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { UserAvatar, avatarPropsOf } from "@/components/ui/user-avatar"; import { UserAvatar, avatarPropsOf } from "@/components/ui/user-avatar";
import { RichEditor } from "@/components/ui/rich-editor";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -82,13 +82,14 @@ import {
isValidAdminTab, isValidAdminTab,
type AdminTabId, type AdminTabId,
} from "@/components/admin/nav"; } from "@/components/admin/nav";
import { AdminPanelHeader } from "@/components/admin/panel-header"; import { SectionColumns, SectionNav, SectionPanel } from "@/components/section-nav";
import { useConfirm } from "@/components/admin/use-confirm"; import { useConfirm } from "@/components/admin/use-confirm";
import { StatsPanel } from "@/components/admin/stats-panel"; import { StatsPanel } from "@/components/admin/stats-panel";
import { LearningPanel } from "@/components/admin/learning-panel"; import { LearningPanel } from "@/components/admin/learning-panel";
import { QuestionsPanel } from "@/components/admin/questions-panel"; import { QuestionsPanel } from "@/components/admin/questions-panel";
import { VideosPanel, VideoKeyPicker } from "@/components/admin/videos-panel"; import { VideosPanel, VideoKeyPicker } from "@/components/admin/videos-panel";
import { AnnouncePanel } from "@/components/admin/announce-panel"; import { AnnouncePanel } from "@/components/admin/announce-panel";
import { BotPanel } from "@/components/admin/bot-panel";
import { PhysicalOrdersPanel } from "@/components/admin/physical-orders-panel"; import { PhysicalOrdersPanel } from "@/components/admin/physical-orders-panel";
import { import {
DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZE,
@@ -96,6 +97,7 @@ import {
buildPageQuery, buildPageQuery,
type Paginated, type Paginated,
} from "@/components/admin/list-pagination"; } from "@/components/admin/list-pagination";
import { AccountIdentityFields, AccountUsernameSection } from "@/components/account-identity-fields";
import { AdminSelect, ListToolbar } from "@/components/admin/list-toolbar"; import { AdminSelect, ListToolbar } from "@/components/admin/list-toolbar";
import { SiteSettingsPanel } from "@/components/admin/site-settings-panel"; import { SiteSettingsPanel } from "@/components/admin/site-settings-panel";
import { RegistrationPanel } from "@/components/admin/registration-panel"; import { RegistrationPanel } from "@/components/admin/registration-panel";
@@ -196,113 +198,32 @@ export function AdminStudio() {
})).filter((g) => g.items.length > 0); })).filter((g) => g.items.length > 0);
return ( return (
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div> <div>
<p className="section-kicker">{isAdmin ? "استودیو ادمین" : "استودیو منتور"}</p> <header className="mb-6 space-y-1">
<h1 className="mt-1.5 text-2xl font-bold tracking-tight sm:text-3xl"> <h1 className="text-2xl font-black tracking-tight">{isAdmin ? "مدیریت" : "منتور"}</h1>
{isAdmin ? ( <p className="text-sm text-muted-foreground">
<>
پنل <span className="text-gradient-brand">مدیریت</span>
</>
) : (
<>
پنل <span className="text-gradient-brand">منتور</span>
</>
)}
</h1>
<p className="mt-1.5 max-w-2xl text-sm leading-relaxed text-muted-foreground">
{isAdmin {isAdmin
? "افراد، ثبت‌نام، محتوای دوره، ارتباط و تنظیمات سایت — گروه‌بندی‌شده تا چیزی گم نشود." ? "افراد، ثبت‌نام، محتوای دوره، ارتباط و تنظیمات سایت."
: MENTOR_HINT} : MENTOR_HINT}
</p> </p>
</div> </header>
<Badge variant="accent" className="gap-1 rounded-lg px-3 py-1.5">
<ShieldCheck className="h-4 w-4" /> {isAdmin ? "ادمین" : "منتور"}
</Badge>
</div>
{/* Mobile: horizontal grouped chips */} <SectionColumns>
<nav className="lg:hidden" aria-label={isAdmin ? "بخش‌های مدیریت" : "بخش‌های منتور"}> <SectionNav
<div className="-mx-1 flex gap-3 overflow-x-auto px-1 pb-1"> label={isAdmin ? "بخش‌های مدیریت" : "بخش‌های منتور"}
{visibleGroups.map((group) => ( groups={visibleGroups.map((group) => ({
<div key={group.title} className="flex shrink-0 flex-col gap-1.5"> title: group.title,
<p className="px-1 text-[10px] font-bold text-muted-foreground">{group.title}</p> items: group.items.map((item) => ({ id: item.id, label: item.label })),
<div className="flex gap-1.5"> }))}
{group.items.map((item) => { activeId={tab}
const Icon = item.icon; onSelect={setTab}
const active = tab === item.id;
return (
<button
key={item.id}
type="button"
onClick={() => setTab(item.id)}
className={cn(
"inline-flex items-center gap-1.5 whitespace-nowrap rounded-xl px-3 py-2 text-xs font-bold transition-colors",
active
? "bg-primary text-primary-foreground shadow-offset-sm"
: "border border-border bg-card text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<Icon className="h-3.5 w-3.5 shrink-0" />
{item.label}
</button>
);
})}
</div>
</div>
))}
</div>
</nav>
<div className="flex flex-col gap-5 lg:flex-row lg:items-start">
<aside className="hidden w-52 shrink-0 lg:sticky lg:top-24 lg:block">
<nav
className="space-y-3 rounded-xl border border-border bg-card p-2.5"
aria-label={isAdmin ? "منوی مدیریت" : "منوی منتور"}
>
{visibleGroups.map((group) => (
<div key={group.title}>
<p className="mb-1 px-2 text-[11px] font-bold text-muted-foreground">
{group.title}
</p>
<ul className="space-y-0.5">
{group.items.map((item) => {
const Icon = item.icon;
const active = tab === item.id;
return (
<li key={item.id}>
<button
type="button"
onClick={() => setTab(item.id)}
aria-current={active ? "page" : undefined}
className={cn(
"flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-sm font-bold transition-colors",
active
? "bg-primary text-primary-foreground shadow-offset-sm"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<Icon className="h-4 w-4 shrink-0" />
{item.label}
</button>
</li>
);
})}
</ul>
</div>
))}
</nav>
</aside>
<div className="min-w-0 flex-1">
{navItem && (
<AdminPanelHeader
title={navItem.label}
description={navItem.description}
icon={navItem.icon}
/> />
)} <SectionPanel
id="admin-section"
titleId="admin-section-title"
title={navItem?.label ?? ""}
description={navItem?.description ?? ""}
>
{tab === "stats" && isAdmin && <StatsPanel />} {tab === "stats" && isAdmin && <StatsPanel />}
{tab === "learning" && <LearningPanel />} {tab === "learning" && <LearningPanel />}
{tab === "users" && isAdmin && <UsersPanel />} {tab === "users" && isAdmin && <UsersPanel />}
@@ -318,9 +239,10 @@ export function AdminStudio() {
{tab === "events" && <AdminEventsPanel />} {tab === "events" && <AdminEventsPanel />}
{tab === "challenges" && isAdmin && <ChallengesPanel />} {tab === "challenges" && isAdmin && <ChallengesPanel />}
{tab === "announce" && isAdmin && <AnnouncePanel />} {tab === "announce" && isAdmin && <AnnouncePanel />}
{tab === "bots" && isAdmin && <BotPanel />}
{tab === "chat-export" && isAdmin && <ChatExportPanel />} {tab === "chat-export" && isAdmin && <ChatExportPanel />}
</div> </SectionPanel>
</div> </SectionColumns>
</div> </div>
); );
} }
@@ -374,7 +296,7 @@ function UsersPanel() {
const del = async (u: User) => { const del = async (u: User) => {
if ( if (
!(await confirm( !(await confirm(
"حذف کاربر", "حذف دائمی",
`حساب «${u.name}» (${u.email}) برای همیشه حذف شود؟ این عمل بازگشت‌ناپذیر است.` `حساب «${u.name}» (${u.email}) برای همیشه حذف شود؟ این عمل بازگشت‌ناپذیر است.`
)) ))
) { ) {
@@ -497,8 +419,8 @@ function UsersPanel() {
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
</Button> </Button>
)} )}
{canMutate && u.id !== currentUser?.id && ( {canMutate && u.id !== currentUser?.id && (u.isFrozen || u.isClosed) && (
<Button size="sm" variant="ghost" className="h-8 px-2 text-destructive" disabled={busy === u.id} onClick={() => del(u)} title="حذف"> <Button size="sm" variant="ghost" className="h-8 px-2 text-destructive" disabled={busy === u.id} onClick={() => del(u)} title="حذف دائمی" aria-label="حذف دائمی">
{busy === u.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Trash2 className="h-3.5 w-3.5" />} {busy === u.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Trash2 className="h-3.5 w-3.5" />}
</Button> </Button>
)} )}
@@ -644,37 +566,24 @@ export function UserDialog({
: "اطلاعات کاربر را به‌روزرسانی کنید."} : "اطلاعات کاربر را به‌روزرسانی کنید."}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-3"> <div className="space-y-8">
<div className="space-y-1.5"> <AccountIdentityFields
<Label>نام</Label> idPrefix="admin-user"
<Input value={form.name ?? ""} onChange={(e) => setForm({ ...form, name: e.target.value })} /> name={form.name ?? ""}
</div> email={form.email ?? ""}
<div className="space-y-1.5"> phone={form.phone ?? ""}
<Label>شناسه</Label> onName={(value) => setForm({ ...form, name: value })}
<Input onEmail={(value) => setForm({ ...form, email: value })}
onPhone={(value) => setForm({ ...form, phone: clampPhoneInput(value) })}
phoneMaxLength={PHONE_INPUT_MAX_LEN}
/>
<AccountUsernameSection
mode="admin"
idPrefix="admin-user"
value={form.username ?? ""} value={form.username ?? ""}
onChange={(e) => setForm({ ...form, username: e.target.value.toLowerCase() })} onChange={(value) => setForm({ ...form, username: value })}
dir="ltr"
className="text-left"
placeholder="مثلاً ahp"
/> />
</div> <section className="space-y-4 border-t border-border pt-6">
<div className="space-y-1.5">
<Label>ایمیل</Label>
<Input type="email" value={form.email ?? ""} onChange={(e) => setForm({ ...form, email: e.target.value })} />
</div>
<div className="space-y-1.5">
<Label>تلفن</Label>
<Input
value={form.phone ?? ""}
onChange={(e) => setForm({ ...form, phone: clampPhoneInput(e.target.value) })}
dir="ltr"
className="text-left"
placeholder="09xxxxxxxxx"
inputMode="tel"
maxLength={PHONE_INPUT_MAX_LEN}
/>
</div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>نقش</Label> <Label>نقش</Label>
<select <select
@@ -745,6 +654,7 @@ export function UserDialog({
/> />
</div> </div>
)} )}
</section>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={onClose}>انصراف</Button> <Button variant="outline" onClick={onClose}>انصراف</Button>
@@ -842,11 +752,31 @@ function CurriculumPanel() {
} }
}; };
const publishLesson = async (l: Lesson, publish: boolean) => {
try {
await http.post(`/api/admin/lessons/${l.id}/publish`, { publish });
qc.invalidateQueries({ queryKey: ["admin", "lessons"] });
toast.success(publish ? "درس منتشر شد" : "درس به پیش‌نویس برگشت");
} catch (e) {
toast.error(toUserError(e));
}
};
const publishChapter = async (ch: Chapter, publish: boolean) => {
try {
await http.post(`/api/admin/chapters/${ch.id}/publish`, { publish });
qc.invalidateQueries({ queryKey: ["admin", "lessons"] });
toast.success(publish ? "فصل منتشر شد" : "فصل به پیش‌نویس برگشت");
} catch (e) {
toast.error(toUserError(e));
}
};
const newLesson = (chapterId: number) => const newLesson = (chapterId: number) =>
setLessonModal({ setLessonModal({
id: 0, chapterId, title: "", description: "", videoKey: "", id: 0, chapterId, title: "", description: "", videoKey: "",
durationSeconds: 45, completionThresholdPct: 85, requiresLessonId: undefined, durationSeconds: 45, completionThresholdPct: 85, requiresLessonId: undefined,
x: lessons.filter((l) => l.chapterId === chapterId).length, y: 0, sortOrder: 1, xpReward: 50, isActive: true, x: lessons.filter((l) => l.chapterId === chapterId).length, y: 0, sortOrder: 1, xpReward: 50, isActive: true, isPublished: false,
}); });
const duplicateLesson = async (l: Lesson) => { const duplicateLesson = async (l: Lesson) => {
@@ -901,7 +831,7 @@ function CurriculumPanel() {
{chapters.length} فصل · {lessons.length} درس · ترتیب با کشیدن یا فلش · موقعیت x,y {chapters.length} فصل · {lessons.length} درس · ترتیب با کشیدن یا فلش · موقعیت x,y
</p> </p>
</div> </div>
<Button className="gap-1.5 rounded-2xl" onClick={() => setChapterModal({ id: 0, title: "", description: "", icon: "terminal", sortOrder: chapters.length + 1 })}> <Button className="gap-1.5 rounded-2xl" onClick={() => setChapterModal({ id: 0, title: "", description: "", icon: "terminal", sortOrder: chapters.length + 1, isPublished: false })}>
<Plus className="h-4 w-4" /> فصل جدید <Plus className="h-4 w-4" /> فصل جدید
</Button> </Button>
</CardHeader> </CardHeader>
@@ -921,8 +851,8 @@ function CurriculumPanel() {
{ch.description && <p className="truncate text-xs text-muted-foreground">{ch.description}</p>} {ch.description && <p className="truncate text-xs text-muted-foreground">{ch.description}</p>}
</div> </div>
<Badge variant="outline" className="shrink-0">{ls.length} درس</Badge> <Badge variant="outline" className="shrink-0">{ls.length} درس</Badge>
<Badge variant="secondary" className="hidden shrink-0 sm:inline-flex"> <Badge variant={ch.isPublished ? "success" : "secondary"} className="shrink-0 px-1.5 text-[10px]">
{ls.filter((l) => l.videoKey).length}/{ls.length} ویدیو {ch.isPublished ? "منتشر" : "پیش‌نویس"}
</Badge> </Badge>
<GroupNav <GroupNav
onUp={ci > 0 ? () => moveChapter(ci, -1) : undefined} onUp={ci > 0 ? () => moveChapter(ci, -1) : undefined}
@@ -931,6 +861,15 @@ function CurriculumPanel() {
<Button size="sm" variant="outline" className="h-7 gap-1 text-xs" onClick={() => setChapterModal(ch)}> <Button size="sm" variant="outline" className="h-7 gap-1 text-xs" onClick={() => setChapterModal(ch)}>
<Pencil className="h-3 w-3" /> فصل <Pencil className="h-3 w-3" /> فصل
</Button> </Button>
<Button
size="sm"
variant="outline"
className="h-7 gap-1 text-xs"
onClick={() => publishChapter(ch, !ch.isPublished)}
title={ch.isPublished ? "برگشت به پیش‌نویس" : "انتشار فصل"}
>
{ch.isPublished ? "برگشت" : "انتشار"}
</Button>
<Button size="sm" variant="outline" className="h-7 gap-1 text-xs" onClick={() => newLesson(ch.id)}> <Button size="sm" variant="outline" className="h-7 gap-1 text-xs" onClick={() => newLesson(ch.id)}>
<Plus className="h-3.5 w-3.5" /> درس <Plus className="h-3.5 w-3.5" /> درس
</Button> </Button>
@@ -986,8 +925,8 @@ function CurriculumPanel() {
{cols.length > 0 && <Badge variant="destructive" className="px-1 text-[9px]">تداخل</Badge>} {cols.length > 0 && <Badge variant="destructive" className="px-1 text-[9px]">تداخل</Badge>}
</div> </div>
</div> </div>
<Badge variant={l.isActive ? "success" : "secondary"} className="px-1.5 text-[10px]"> <Badge variant={l.isPublished ? "success" : "secondary"} className="shrink-0 px-1.5 text-[10px]">
{l.isActive ? "فعال" : "پیش‌نویس"} {l.isPublished ? "منتشر" : "پیش‌نویس"}
</Badge> </Badge>
<div className="flex shrink-0 gap-0.5"> <div className="flex shrink-0 gap-0.5">
<GroupNav onUp={li > 0 ? () => moveLesson(ch.id, li, -1) : undefined} onDown={li < ls.length - 1 ? () => moveLesson(ch.id, li, 1) : undefined} compact /> <GroupNav onUp={li > 0 ? () => moveLesson(ch.id, li, -1) : undefined} onDown={li < ls.length - 1 ? () => moveLesson(ch.id, li, 1) : undefined} compact />
@@ -997,6 +936,15 @@ function CurriculumPanel() {
<Button size="sm" variant="ghost" className="h-7 px-1.5" onClick={() => setLessonModal(l)} title="ویرایش"> <Button size="sm" variant="ghost" className="h-7 px-1.5" onClick={() => setLessonModal(l)} title="ویرایش">
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
</Button> </Button>
<Button
size="sm"
variant="ghost"
className="h-7 px-1.5 text-[10px]"
title={l.isPublished ? "برگشت به پیش‌نویس" : "انتشار درس"}
onClick={() => publishLesson(l, !l.isPublished)}
>
{l.isPublished ? "برگشت" : "انتشار"}
</Button>
<Button size="sm" variant="ghost" className="h-7 w-7 px-0 text-destructive opacity-60 hover:opacity-100" onClick={() => delLesson(l)} title="حذف درس"> <Button size="sm" variant="ghost" className="h-7 w-7 px-0 text-destructive opacity-60 hover:opacity-100" onClick={() => delLesson(l)} title="حذف درس">
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
@@ -1147,7 +1095,7 @@ function LessonDialog({
onClose: () => void; onClose: () => void;
onSave: (l: Lesson) => void; onSave: (l: Lesson) => void;
}) { }) {
const [draft, setDraft] = useState<Lesson>(() => lesson ?? { id: 0, chapterId: 1, title: "", description: "", videoKey: "", durationSeconds: 90, completionThresholdPct: 85, requiresLessonId: undefined, x: 5, y: 3, sortOrder: 1, xpReward: 50, isActive: true }); const [draft, setDraft] = useState<Lesson>(() => lesson ?? { id: 0, chapterId: 1, title: "", description: "", videoKey: "", durationSeconds: 90, completionThresholdPct: 85, requiresLessonId: undefined, x: 5, y: 3, sortOrder: 1, xpReward: 50, isActive: true, isPublished: false });
useEffect(() => { useEffect(() => {
if (lesson) setDraft(lesson); if (lesson) setDraft(lesson);
}, [lesson]); }, [lesson]);
@@ -1216,12 +1164,10 @@ function LessonDialog({
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>توضیحات</Label> <Label>توضیحات</Label>
<textarea <RichEditor
value={draft.description} value={draft.description}
onChange={(e) => set({ description: e.target.value })} onChange={(v) => set({ description: v })}
rows={3}
placeholder="چرا این درس مهم است و چه چیزی می‌آموزد؟" placeholder="چرا این درس مهم است و چه چیزی می‌آموزد؟"
className="w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
/> />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
+143 -18
View File
@@ -1,14 +1,19 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
AlertTriangle,
Bell, Bell,
BookOpen,
Calendar, Calendar,
Eye, Eye,
Gamepad2, Gamepad2,
Loader2, Loader2,
Megaphone, Megaphone,
Route, Route,
Settings2,
Trash2,
TrendingUp, TrendingUp,
Users, Users,
} from "lucide-react"; } from "lucide-react";
@@ -28,14 +33,28 @@ import {
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { toast } from "@/components/providers"; import { toast } from "@/components/providers";
import { SafeMarkdown } from "@/components/safe-markdown";
const CATEGORIES = [ const CATEGORIES = [
{ id: "event", label: "رویداد", icon: Calendar, hint: "جلسه زنده، کارگاه، ددلاین" }, { id: "event", label: "رویداد", icon: Calendar, hint: "جلسه زنده، کارگاه، ددلاین" },
{ id: "progress", label: "پیشرفت", icon: TrendingUp, hint: "درس جدید، فصل بازشده" }, { id: "progress", label: "پیشرفت", icon: TrendingUp, hint: "درس جدید، فصل بازشده" },
{ id: "gamification", label: "بازی‌سازی", icon: Gamepad2, hint: "جایزه، استریک، چالش" }, { id: "gamification", label: "بازی‌سازی", icon: Gamepad2, hint: "جایزه، استریک، چالش" },
{ id: "mentor", label: "منتور", icon: Users, hint: "پیام مستقیم تیم منتور" }, { id: "mentor", label: "منتور", icon: Users, hint: "پیام مستقیم تیم منتور" },
{ id: "urgent", label: "فوری", icon: AlertTriangle, hint: "نیاز به توجه همین حالا" },
{ id: "curriculum", label: "درس", icon: BookOpen, hint: "محتوای آموزشی" },
{ id: "community", label: "جامعه", icon: Users, hint: "فضای مشترک دوره" },
{ id: "system", label: "سیستم", icon: Settings2, hint: "وضعیت خود پرگار" },
] as const; ] as const;
type Announcement = {
id: number;
title: string;
body: string;
category: string;
route: string;
pinned: boolean;
};
const ROUTES = [ const ROUTES = [
{ path: "/unwrap", label: "نقشه مسیر" }, { path: "/unwrap", label: "نقشه مسیر" },
{ path: "/notifications", label: "اعلان‌ها" }, { path: "/notifications", label: "اعلان‌ها" },
@@ -43,6 +62,20 @@ const ROUTES = [
{ path: "/cap", label: "داشبورد" }, { path: "/cap", label: "داشبورد" },
]; ];
function announcementExcerpt(body: string) {
const plain = body
.replace(/```[\s\S]*?```/g, " ")
.replace(/`([^`]*)`/g, "$1")
.replace(/!\[[^\]]*]\([^)]*\)/g, " ")
.replace(/\[([^\]]*)]\([^)]*\)/g, "$1")
.replace(/^#{1,6}\s+/gm, "")
.replace(/[*_~>]+/g, "")
.replace(/\s+/g, " ")
.trim();
if (plain.length <= 180) return plain;
return `${plain.slice(0, 180).trimEnd()}…`;
}
function PreviewCard({ function PreviewCard({
title, title,
body, body,
@@ -66,9 +99,10 @@ function PreviewCard({
</Badge> </Badge>
</div> </div>
<p className="mt-2.5 text-sm font-black leading-snug">{title.trim() || "عنوان اطلاعیه"}</p> <p className="mt-2.5 text-sm font-black leading-snug">{title.trim() || "عنوان اطلاعیه"}</p>
<p className="mt-1 min-h-[2.5rem] text-xs leading-relaxed text-muted-foreground"> <SafeMarkdown
{body.trim() || "متن پیام اینجا دیده می‌شود…"} text={body.trim() || "متن پیام اینجا دیده می‌شود…"}
</p> className="mt-1 min-h-[2.5rem] space-y-1 text-xs leading-relaxed text-muted-foreground"
/>
<p className="mt-2.5 truncate text-[10px] font-mono text-primary" dir="ltr"> <p className="mt-2.5 truncate text-[10px] font-mono text-primary" dir="ltr">
→ {route || "/"} → {route || "/"}
</p> </p>
@@ -80,8 +114,15 @@ export function AnnouncePanel() {
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [body, setBody] = useState(""); const [body, setBody] = useState("");
const [category, setCategory] = useState<string>("event"); const [category, setCategory] = useState<string>("event");
const [route, setRoute] = useState("/unwrap"); const [route, setRoute] = useState("");
const [pinned, setPinned] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const qc = useQueryClient();
const listQ = useQuery({
queryKey: ["admin-announcements"],
queryFn: () => http.get<{ announcements: Announcement[] }>("/api/admin/announcements"),
});
const [confirmOpen, setConfirmOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false);
const [lastSent, setLastSent] = useState<{ title: string; recipients: number } | null>(null); const [lastSent, setLastSent] = useState<{ title: string; recipients: number } | null>(null);
@@ -93,17 +134,21 @@ export function AnnouncePanel() {
if (!valid) return; if (!valid) return;
setBusy(true); setBusy(true);
try { try {
const res = await http.post<{ recipients: number }>("/api/admin/announce", { const payload = { title: title.trim(), body: body.trim(), category, route: route.trim(), pinned };
title: title.trim(), if (editingId) {
body: body.trim(), await http.put(`/api/admin/announcements/${editingId}`, payload);
category, toast.success("اطلاعیه به‌روز شد");
route, } else {
}); const res = await http.post<{ recipients: number }>("/api/admin/announce", payload);
toast.success(`اطلاعیه برای ${res.recipients} نفر ارسال شد`); toast.success(`اطلاعیه برای ${res.recipients} نفر ارسال شد`);
setLastSent({ title: title.trim(), recipients: res.recipients }); setLastSent({ title: title.trim(), recipients: res.recipients });
}
setTitle(""); setTitle("");
setBody(""); setBody("");
setPinned(false);
setEditingId(null);
setConfirmOpen(false); setConfirmOpen(false);
void qc.invalidateQueries({ queryKey: ["admin-announcements"] });
} catch (e) { } catch (e) {
toast.error(toUserError(e, "ارسال اطلاعیه ممکن نشد")); toast.error(toUserError(e, "ارسال اطلاعیه ممکن نشد"));
} finally { } finally {
@@ -113,7 +158,8 @@ export function AnnouncePanel() {
return ( return (
<> <>
<div className="grid items-start gap-4 lg:grid-cols-[minmax(0,1fr)_20rem]"> <div className="flex flex-col gap-6">
<div data-announce-compose className="grid items-start gap-4 lg:grid-cols-[minmax(0,1fr)_20rem]">
<Card> <Card>
<CardHeader className="space-y-1"> <CardHeader className="space-y-1">
<CardTitle className="text-base">نگارش اعلان</CardTitle> <CardTitle className="text-base">نگارش اعلان</CardTitle>
@@ -136,13 +182,13 @@ export function AnnouncePanel() {
<div className="space-y-2"> <div className="space-y-2">
<div className="flex h-5 items-center justify-between gap-3"> <div className="flex h-5 items-center justify-between gap-3">
<Label className="text-sm font-bold">پیام</Label> <Label className="text-sm font-bold">پیام</Label>
<span className="text-[11px] tabular-nums text-muted-foreground">{body.length}/۵۰۰</span> <span className="text-[11px] tabular-nums text-muted-foreground">{body.length}/۴۰۰۰</span>
</div> </div>
<textarea <textarea
value={body} value={body}
onChange={(e) => setBody(e.target.value.slice(0, 500))} onChange={(e) => setBody(e.target.value.slice(0, 4000))}
rows={5} rows={5}
maxLength={500} maxLength={4000}
className={cn( className={cn(
"w-full resize-y rounded-xl border bg-background px-4 py-3 text-sm leading-relaxed focus:outline-none focus:ring-2 focus:ring-primary", "w-full resize-y rounded-xl border bg-background px-4 py-3 text-sm leading-relaxed focus:outline-none focus:ring-2 focus:ring-primary",
!bodyOk && body.length > 0 ? "border-destructive/50" : "border-input" !bodyOk && body.length > 0 ? "border-destructive/50" : "border-input"
@@ -190,8 +236,9 @@ export function AnnouncePanel() {
<div className="space-y-2"> <div className="space-y-2">
<Label className="flex h-5 items-center gap-1.5 text-sm font-bold"> <Label className="flex h-5 items-center gap-1.5 text-sm font-bold">
<Route className="h-3.5 w-3.5" /> مقصد کلیک <Route className="h-3.5 w-3.5" /> با زدن اعلان این صفحه باز می‌شود
</Label> </Label>
<p className="text-[11px] text-muted-foreground">خالی بگذارید تا اعلان فقط خوانده شود و صفحه‌ای باز نکند.</p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{ROUTES.map((r) => ( {ROUTES.map((r) => (
<button <button
@@ -214,8 +261,12 @@ export function AnnouncePanel() {
onChange={(e) => setRoute(e.target.value)} onChange={(e) => setRoute(e.target.value)}
dir="ltr" dir="ltr"
className="h-10 rounded-2xl font-mono text-xs" className="h-10 rounded-2xl font-mono text-xs"
placeholder="/unwrap" placeholder="خالی مجاز است"
/> />
<label className="flex items-center gap-2 text-sm font-bold">
<input type="checkbox" checked={pinned} onChange={(e) => setPinned(e.target.checked)} />
سنجاق روی درخت مهارت
</label>
</div> </div>
<div className="flex flex-wrap items-center gap-3 border-t border-border pt-4"> <div className="flex flex-wrap items-center gap-3 border-t border-border pt-4">
@@ -235,7 +286,7 @@ export function AnnouncePanel() {
</CardContent> </CardContent>
</Card> </Card>
<div className="space-y-4 lg:sticky lg:top-24"> <div className="space-y-4">
<Card className="border bg-muted/20"> <Card className="border bg-muted/20">
<CardHeader className="space-y-1 pb-3"> <CardHeader className="space-y-1 pb-3">
<CardTitle className="flex h-6 items-center gap-2 text-sm"> <CardTitle className="flex h-6 items-center gap-2 text-sm">
@@ -260,6 +311,80 @@ export function AnnouncePanel() {
</div> </div>
</div> </div>
<Card data-announce-list className="rounded-2xl border border-border">
<CardHeader>
<CardTitle className="text-base">اطلاعیه‌های ثبت‌شده</CardTitle>
</CardHeader>
<CardContent>
{(listQ.data?.announcements ?? []).length > 0 ? (
<ul className="flex list-none flex-col gap-3 p-0">
{(listQ.data?.announcements ?? []).map((a) => {
const cat = CATEGORIES.find((c) => c.id === a.category);
const excerpt = announcementExcerpt(a.body);
return (
<li key={a.id} className="rounded-2xl border border-border bg-background p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<p className="min-w-0 text-sm font-bold leading-snug">{a.title}</p>
<Badge variant="secondary" className="text-[10px]">
{cat?.label ?? a.category}
</Badge>
{a.pinned ? (
<Badge variant="gold" className="text-[10px]">
سنجاق
</Badge>
) : null}
</div>
{excerpt ? (
<p className="line-clamp-2 text-xs leading-relaxed text-muted-foreground">{excerpt}</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
setEditingId(a.id);
setTitle(a.title);
setBody(a.body);
setCategory(a.category);
setRoute(a.route);
setPinned(a.pinned);
}}
>
ویرایش
</Button>
<Button
type="button"
size="sm"
variant="outline"
aria-label="حذف"
onClick={() => {
void http
.del(`/api/admin/announcements/${a.id}`)
.then(() => qc.invalidateQueries({ queryKey: ["admin-announcements"] }))
.catch((e) => toast.error(toUserError(e, "حذف ممکن نشد")));
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</li>
);
})}
</ul>
) : !listQ.isPending ? (
<p className="rounded-2xl border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
هنوز اطلاعیه‌ای ثبت نشده است.
</p>
) : null}
</CardContent>
</Card>
</div>
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}> <Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent className="max-w-md gap-0 overflow-hidden p-0 sm:rounded-2xl"> <DialogContent className="max-w-md gap-0 overflow-hidden p-0 sm:rounded-2xl">
<DialogHeader className="space-y-1 border-b border-border px-6 py-4 text-start"> <DialogHeader className="space-y-1 border-b border-border px-6 py-4 text-start">
@@ -275,7 +400,7 @@ export function AnnouncePanel() {
</Button> </Button>
<Button className="h-10 gap-2" disabled={busy || !valid} onClick={() => void send()}> <Button className="h-10 gap-2" disabled={busy || !valid} onClick={() => void send()}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Bell className="h-4 w-4" />} {busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Bell className="h-4 w-4" />}
تأیید و ارسال {editingId ? "تأیید و ذخیره" : "تأیید و ارسال"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+192
View File
@@ -0,0 +1,192 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { BookOpen } from "lucide-react";
import { http, toUserError } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { toast } from "@/components/providers";
import { formatFaNumber, formatJalaliStamp } from "@/lib/utils";
const PAGE_SIZE = 8;
type BotKey = {
id: number;
botId: number;
name: string;
createdAt: string;
active: boolean;
requestCount: number;
};
type BotList = {
items: BotKey[];
total: number;
page: number;
pageSize: number;
};
export function BotPanel() {
const qc = useQueryClient();
const [name, setName] = useState("");
const [token, setToken] = useState("");
const [busy, setBusy] = useState(false);
const [page, setPage] = useState(1);
const [toggling, setToggling] = useState<number | null>(null);
const listQ = useQuery({
queryKey: ["admin-bots", page],
queryFn: () => http.get<BotList>(`/api/admin/bots?page=${page}&pageSize=${PAGE_SIZE}`),
});
const items = listQ.data?.items ?? [];
const total = listQ.data?.total ?? 0;
const pageSize = listQ.data?.pageSize ?? PAGE_SIZE;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const pageSafe = Math.min(Math.max(page, 1), totalPages);
async function copyToken() {
try {
await navigator.clipboard.writeText(token);
toast.success("توکن کپی شد");
} catch {
toast.error("کپی ممکن نشد");
}
}
function setActive(row: BotKey, active: boolean) {
setToggling(row.id);
void http
.put(`/api/admin/bots/${row.id}`, { active })
.then(() => {
toast.success(active ? "کلید فعال شد" : "کلید متوقف شد");
return qc.invalidateQueries({ queryKey: ["admin-bots"] });
})
.catch((e) => toast.error(toUserError(e, "تغییر وضعیت ممکن نشد")))
.finally(() => setToggling(null));
}
return (
<div className="space-y-4">
<Card className="rounded-2xl">
<CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0">
<div className="space-y-1">
<CardTitle className="text-base">کلید API</CardTitle>
<p className="text-sm text-muted-foreground">توکن فقط یک بار نشان داده می‌شود.</p>
</div>
<Button type="button" variant="outline" size="sm" asChild>
<Link href="/admin/bot-docs">
<BookOpen className="h-3.5 w-3.5" />
راهنما
</Link>
</Button>
</CardHeader>
<CardContent className="space-y-3">
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="نام ربات" />
<Button
type="button"
disabled={busy}
onClick={() => {
setBusy(true);
void http
.post<{ token: string }>("/api/admin/bots", { name })
.then((res) => {
setToken(res.token);
setName("");
setPage(1);
toast.success("ربات ساخته شد");
return qc.invalidateQueries({ queryKey: ["admin-bots"] });
})
.catch((e) => toast.error(toUserError(e, "ساخت ربات ممکن نشد")))
.finally(() => setBusy(false));
}}
>
ساخت ربات
</Button>
{token && (
<div className="space-y-2 rounded-2xl border border-border bg-muted/30 p-3">
<p className="text-sm text-muted-foreground">توکن فقط یک بار نشان داده می‌شود.</p>
<div className="flex flex-col gap-2 sm:flex-row">
<Input readOnly value={token} dir="ltr" className="font-mono text-xs" aria-label="توکن ربات" />
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={() => void copyToken()}>
کپی
</Button>
<Button type="button" variant="ghost" onClick={() => setToken("")}>
بستن
</Button>
</div>
</div>
</div>
)}
</CardContent>
</Card>
<Card className="rounded-2xl">
<CardHeader>
<CardTitle className="text-base">کلیدها</CardTitle>
</CardHeader>
<CardContent>
{listQ.isPending ? (
<p className="text-sm text-muted-foreground">در حال بارگذاری…</p>
) : listQ.isError ? (
<p className="text-sm font-bold text-destructive">{toUserError(listQ.error, "بارگذاری کلیدها ممکن نشد")}</p>
) : items.length === 0 ? (
<p className="text-sm text-muted-foreground">هنوز کلیدی نیست.</p>
) : (
<div className="space-y-3">
<ul className="divide-y divide-border">
{items.map((row) => (
<li key={row.id} className="flex flex-col gap-3 py-4 first:pt-0 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 space-y-1">
<p className="truncate text-sm font-bold">{row.name || "ربات"}</p>
<p className="text-xs text-muted-foreground">{formatJalaliStamp(row.createdAt)}</p>
<p className="text-xs">
<span className={row.active ? "font-bold text-success" : "font-bold text-muted-foreground"}>
{row.active ? "فعال" : "متوقف"}
</span>
<span className="text-muted-foreground"> · {formatFaNumber(row.requestCount)} درخواست</span>
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0"
disabled={toggling === row.id}
onClick={() => setActive(row, !row.active)}
>
{row.active ? "توقف" : "فعال‌کردن"}
</Button>
</li>
))}
</ul>
{totalPages > 1 && (
<div className="flex items-center justify-between gap-3 border-t border-border pt-4">
<Button type="button" variant="outline" size="sm" disabled={pageSafe <= 1} onClick={() => setPage(pageSafe - 1)}>
قبلی
</Button>
<p className="text-sm font-bold tabular-nums text-muted-foreground">
{formatFaNumber(pageSafe)} از {formatFaNumber(totalPages)}
</p>
<Button
type="button"
variant="outline"
size="sm"
disabled={pageSafe >= totalPages}
onClick={() => setPage(pageSafe + 1)}
>
بعدی
</Button>
</div>
)}
</div>
)}
</CardContent>
</Card>
</div>
);
}
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query"; import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
AlertTriangle, AlertTriangle,
Award,
Flame, Flame,
Heart, Heart,
Loader2, Loader2,
@@ -64,6 +65,7 @@ export function LearningPanel() {
const [sort, setSort] = useState<SortKey>("xp"); const [sort, setSort] = useState<SortKey>("xp");
const [selectedId, setSelectedId] = useState<number | null>(null); const [selectedId, setSelectedId] = useState<number | null>(null);
const [unlocking, setUnlocking] = useState(false); const [unlocking, setUnlocking] = useState(false);
const [issuingCertFor, setIssuingCertFor] = useState<number | null>(null);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
useEffect(() => { useEffect(() => {
@@ -114,6 +116,19 @@ export function LearningPanel() {
} }
}; };
const issueCertificate = async (id: number) => {
setIssuingCertFor(id);
try {
await http.post(`/api/admin/users/${id}/certificate/issue`, {});
toast.success("گواهینامه با موفقیت صادر شد");
await qc.invalidateQueries({ queryKey: ["admin", "learning", "user", id] });
} catch (e) {
toast.error(toUserError(e, "صدور گواهینامه ممکن نشد"));
} finally {
setIssuingCertFor(null);
}
};
const overview = overviewQ.data?.overview; const overview = overviewQ.data?.overview;
const students = studentsQ.data?.items ?? []; const students = studentsQ.data?.items ?? [];
const studentsTotal = studentsQ.data?.total ?? 0; const studentsTotal = studentsQ.data?.total ?? 0;
@@ -369,6 +384,29 @@ export function LearningPanel() {
))} ))}
</div> </div>
{detailQ.data.learning.user.progressPct >= 100 && (
<div className="flex items-center justify-between gap-3 rounded-xl border border-primary/30 bg-primary/5 px-3 py-2.5">
<div className="flex items-center gap-2">
<Award className="h-4 w-4 shrink-0 text-primary" />
<p className="text-xs font-bold">دوره تکمیل شده — گواهینامه قابل صدور است</p>
</div>
<Button
size="sm"
variant="default"
disabled={issuingCertFor === detailQ.data.learning.user.id}
onClick={() => issueCertificate(detailQ.data!.learning.user.id)}
className="gap-1.5"
>
{issuingCertFor === detailQ.data.learning.user.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Award className="h-3.5 w-3.5" />
)}
صدور گواهینامه
</Button>
</div>
)}
<section className="space-y-2"> <section className="space-y-2">
<h3 className="text-sm font-black">دروس</h3> <h3 className="text-sm font-black">دروس</h3>
<div className="max-h-64 space-y-1.5 overflow-y-auto"> <div className="max-h-64 space-y-1.5 overflow-y-auto">
+10 -1
View File
@@ -1,6 +1,7 @@
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { import {
Activity, Activity,
Bot,
BookOpen, BookOpen,
CalendarPlus, CalendarPlus,
DatabaseBackup, DatabaseBackup,
@@ -33,6 +34,7 @@ export type AdminTabId =
| "events" | "events"
| "challenges" | "challenges"
| "announce" | "announce"
| "bots"
| "chat-export" | "chat-export"
| "settings"; | "settings";
@@ -166,6 +168,13 @@ export const ADMIN_NAV: AdminNavGroup[] = [
icon: Megaphone, icon: Megaphone,
adminOnly: true, adminOnly: true,
}, },
{
id: "bots",
label: "ربات",
description: "کلیدهای API، وضعیت، شمار درخواست و راهنمای اتصال.",
icon: Bot,
adminOnly: true,
},
{ {
id: "chat-export", id: "chat-export",
label: "آرشیو گفتگوها", label: "آرشیو گفتگوها",
@@ -181,7 +190,7 @@ export const ADMIN_NAV: AdminNavGroup[] = [
{ {
id: "settings", id: "settings",
label: "تنظیمات سایت", label: "تنظیمات سایت",
description: "حمایت مالی، گواهینامه فیزیکی و اسپانسرهای لندینگ.", description: "شبکه‌ها، قلب‌ها، گواهینامه فیزیکی و اسپانسرهای لندینگ.",
icon: Settings2, icon: Settings2,
adminOnly: true, adminOnly: true,
}, },
@@ -45,6 +45,7 @@ export function AdminProfileManage({ userId }: { userId: number }) {
const account = accountQ.data?.user; const account = accountQ.data?.user;
const isSelf = account?.id === me?.id; const isSelf = account?.id === me?.id;
const canDelete = !!account && !isSelf && (account.isFrozen || account.isClosed);
const act = async (kind: "lock" | "unlock") => { const act = async (kind: "lock" | "unlock") => {
setBusy("lock"); setBusy("lock");
@@ -89,7 +90,7 @@ export function AdminProfileManage({ userId }: { userId: number }) {
} }
if ( if (
!(await confirm( !(await confirm(
"حذف کاربر", "حذف دائمی",
`حساب «${account.name}» (${account.email}) برای همیشه حذف شود؟ این عمل بازگشت‌ناپذیر است.`, `حساب «${account.name}» (${account.email}) برای همیشه حذف شود؟ این عمل بازگشت‌ناپذیر است.`,
)) ))
) { ) {
@@ -156,17 +157,17 @@ export function AdminProfileManage({ userId }: { userId: number }) {
قفل قفل
</Button> </Button>
))} ))}
<div className={cn("grid gap-2", isSelf ? "sm:grid-cols-2" : "sm:grid-cols-3")}> <div className={cn("grid gap-2", canDelete ? "sm:grid-cols-3" : "sm:grid-cols-2")}>
<Button size="sm" variant="outline" className="w-full" onClick={() => setEditing(account)}> <Button size="sm" variant="outline" className="w-full" onClick={() => setEditing(account)}>
<Pencil className="h-3.5 w-3.5" /> ویرایش <Pencil className="h-3.5 w-3.5" /> ویرایش
</Button> </Button>
<Button size="sm" variant="outline" className="w-full" disabled={busy !== null} onClick={() => setResetOpen(true)}> <Button size="sm" variant="outline" className="w-full" disabled={busy !== null} onClick={() => setResetOpen(true)}>
<KeyRound className="h-3.5 w-3.5" /> ریست رمز <KeyRound className="h-3.5 w-3.5" /> ریست رمز
</Button> </Button>
{!isSelf && ( {canDelete && (
<Button size="sm" variant="destructive" className="w-full" disabled={busy !== null} onClick={del}> <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" />} {busy === "delete" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Trash2 className="h-3.5 w-3.5" />}
حذف حذف دائمی
</Button> </Button>
)} )}
</div> </div>
@@ -2,7 +2,7 @@
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { HeartHandshake, Loader2, Package, Save, Settings2, Sparkles } from "lucide-react"; import { Heart, Loader2, Package, Save, Settings2, Share2, Sparkles } from "lucide-react";
import { http, toUserError } from "@/lib/api"; import { http, toUserError } from "@/lib/api";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -15,6 +15,7 @@ import type { AdminTabId } from "@/components/admin/nav";
type SettingsMap = Record<string, string>; type SettingsMap = Record<string, string>;
type SponsorDraft = { name: string; url: string; blurb: string }; type SponsorDraft = { name: string; url: string; blurb: string };
type SocialDraft = { name: string; url: string };
function SectionHeading({ function SectionHeading({
icon: Icon, icon: Icon,
@@ -44,6 +45,7 @@ export function SiteSettingsPanel({
const qc = useQueryClient(); const qc = useQueryClient();
const [draft, setDraft] = useState<SettingsMap>({}); const [draft, setDraft] = useState<SettingsMap>({});
const [sponsors, setSponsors] = useState<SponsorDraft[]>([]); const [sponsors, setSponsors] = useState<SponsorDraft[]>([]);
const [socials, setSocials] = useState<SocialDraft[]>([]);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
@@ -68,15 +70,17 @@ export function SiteSettingsPanel({
} catch { } catch {
setSponsors([]); setSponsors([]);
} }
try {
const parsed = JSON.parse(data.settings.social_links || "[]") as SocialDraft[];
setSocials(Array.isArray(parsed) ? parsed.map((s) => ({ name: s.name ?? "", url: s.url ?? "" })) : []);
} catch {
setSocials([]);
}
}, [data]); }, [data]);
const set = (key: string, value: string) => setDraft((d) => ({ ...d, [key]: value })); const set = (key: string, value: string) => setDraft((d) => ({ ...d, [key]: value }));
const save = async () => { const save = async () => {
if ((draft.donation_note ?? "").trim().length > 2000) {
toast.error("یادداشت دونیت خیلی طولانی است");
return;
}
const price = Number(draft.physical_cert_price_irr); const price = Number(draft.physical_cert_price_irr);
const days = Number(draft.physical_cert_window_days); const days = Number(draft.physical_cert_window_days);
if (!Number.isFinite(price) || price < 0) { if (!Number.isFinite(price) || price < 0) {
@@ -110,6 +114,9 @@ export function SiteSettingsPanel({
try { try {
const settings = { const settings = {
...draft, ...draft,
social_links: JSON.stringify(
socials.filter((s) => s.name.trim() && s.url.trim()).map((s) => ({ name: s.name.trim(), url: s.url.trim() })),
),
sponsors: JSON.stringify( sponsors: JSON.stringify(
sponsors sponsors
.filter((s) => s.name.trim()) .filter((s) => s.name.trim())
@@ -173,24 +180,60 @@ export function SiteSettingsPanel({
<section className="space-y-3"> <section className="space-y-3">
<SectionHeading <SectionHeading
icon={HeartHandshake} icon={Share2}
title="حمایت / دونیت" title="شبکه‌های پرگار"
hint="متن راهنما برای گفتگو با تیم دربارهٔ حمایت مالی" hint="حداکثر ۸ لینک رسمی. در فوتر و زبانه شبکه‌ها دیده می‌شود."
/> />
{socials.map((s, i) => (
<div key={i} className="grid gap-2 sm:grid-cols-2">
<Input
value={s.name}
placeholder="نام"
onChange={(e) =>
setSocials((list) => list.map((row, idx) => (idx === i ? { ...row, name: e.target.value } : row)))
}
/>
<Input
dir="ltr"
value={s.url}
placeholder="https://"
onChange={(e) =>
setSocials((list) => list.map((row, idx) => (idx === i ? { ...row, url: e.target.value } : row)))
}
/>
</div>
))}
{socials.length < 8 && (
<Button type="button" size="sm" variant="outline" onClick={() => setSocials((list) => [...list, { name: "", url: "" }])}>
افزودن شبکه
</Button>
)}
</section>
<section className="space-y-3">
<SectionHeading icon={Heart} title="قلب‌ها" hint="سقف، فاصله بازگشت، و قفل در صفر." />
<div className="grid gap-2 sm:grid-cols-2">
<Input
type="number"
value={draft.hearts_max ?? "5"}
onChange={(e) => set("hearts_max", e.target.value)}
aria-label="سقف قلب"
/>
<Input
type="number"
value={draft.hearts_regen_minutes ?? "240"}
onChange={(e) => set("hearts_regen_minutes", e.target.value)}
aria-label="فاصله بازگشت به دقیقه"
/>
</div>
<label className="flex items-center justify-between gap-3 text-sm"> <label className="flex items-center justify-between gap-3 text-sm">
<span>فعال باشد</span> <span>صفر قلب حساب را قفل کند</span>
<Switch <Switch
checked={(draft.donation_enabled ?? "true") === "true"} checked={(draft.hearts_lock_on_empty ?? "false") === "true"}
onCheckedChange={(v) => set("donation_enabled", v ? "true" : "false")} onCheckedChange={(v) => set("hearts_lock_on_empty", v ? "true" : "false")}
aria-label="حمایت مالی" aria-label="قفل در صفر"
/> />
</label> </label>
<Textarea
className="min-h-[96px]"
value={draft.donation_note ?? ""}
onChange={(e) => set("donation_note", e.target.value)}
placeholder="متن راهنما برای دانشجو"
/>
</section> </section>
<section className="space-y-3"> <section className="space-y-3">
+41 -50
View File
@@ -5,6 +5,8 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
AlertTriangle, AlertTriangle,
Check, Check,
ChevronLeft,
ChevronRight,
Eye, Eye,
Film, Film,
Link2, Link2,
@@ -32,7 +34,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { cn } from "@/lib/utils"; import { cn, formatFaNumber } from "@/lib/utils";
import { toast } from "@/components/providers"; import { toast } from "@/components/providers";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
@@ -59,59 +61,19 @@ function VideoThumb({
className?: string; className?: string;
onOpen?: () => void; onOpen?: () => void;
}) { }) {
const [url, setUrl] = useState<string | null>(null);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
useEffect(() => {
let cancelled = false;
setState("loading");
setUrl(null);
signedMediaUrl(videoKey)
.then((u) => {
if (!cancelled) {
setUrl(u);
setState("ready");
}
})
.catch(() => {
if (!cancelled) setState("error");
});
return () => {
cancelled = true;
};
}, [videoKey]);
return ( return (
<button <button
type="button" type="button"
onClick={onOpen} onClick={onOpen}
className={cn( className={cn(
"group relative aspect-video w-full overflow-hidden rounded-xl border border-border bg-muted/40", "group relative aspect-video w-full overflow-hidden rounded-xl border border-border bg-muted/40",
className className,
)} )}
title="پیش‌نمایش" title="پیش‌نمایش"
> >
{state === "loading" && ( <span className="absolute inset-0 grid place-items-center text-muted-foreground/30">
<span className="absolute inset-0 grid place-items-center text-muted-foreground"> <Film className="h-10 w-10" />
<Loader2 className="h-5 w-5 animate-spin" />
</span> </span>
)}
{state === "error" && (
<span className="absolute inset-0 grid place-items-center gap-1 text-muted-foreground">
<Film className="h-5 w-5" />
<span className="text-[10px] font-bold">پیش‌نمایش نیست</span>
</span>
)}
{url && state === "ready" && (
<video
src={url}
muted
playsInline
preload="metadata"
className="h-full w-full object-cover"
onError={() => setState("error")}
/>
)}
<span className="absolute inset-0 grid place-items-center bg-black/0 transition-colors group-hover:bg-black/35"> <span className="absolute inset-0 grid place-items-center bg-black/0 transition-colors group-hover:bg-black/35">
<span className="grid h-10 w-10 place-items-center rounded-full bg-background/90 text-foreground opacity-80 shadow-sm group-hover:opacity-100"> <span className="grid h-10 w-10 place-items-center rounded-full bg-background/90 text-foreground opacity-80 shadow-sm group-hover:opacity-100">
<Play className="h-4 w-4 fill-current" /> <Play className="h-4 w-4 fill-current" />
@@ -169,7 +131,7 @@ function VideoPreviewDialog({
</div> </div>
)} )}
{url && ( {url && (
<video src={url} controls playsInline className="max-h-[70vh] w-full bg-black" autoPlay /> <video src={url} controls playsInline preload="none" className="max-h-[70vh] w-full bg-black" />
)} )}
</div> </div>
<DialogFooter className="border-t border-border px-6 py-3 sm:justify-end"> <DialogFooter className="border-t border-border px-6 py-3 sm:justify-end">
@@ -182,12 +144,15 @@ function VideoPreviewDialog({
); );
} }
const PAGE_SIZE = 24;
export function VideosPanel() { export function VideosPanel() {
const qc = useQueryClient(); const qc = useQueryClient();
const { confirm, dialog } = useConfirm(); const { confirm, dialog } = useConfirm();
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const [page, setPage] = useState(1);
const [attachKey, setAttachKey] = useState<string | null>(null); const [attachKey, setAttachKey] = useState<string | null>(null);
const [previewKey, setPreviewKey] = useState<string | null>(null); const [previewKey, setPreviewKey] = useState<string | null>(null);
@@ -230,10 +195,13 @@ export function VideosPanel() {
const filtered = useMemo(() => { const filtered = useMemo(() => {
const term = q.trim().toLowerCase(); const term = q.trim().toLowerCase();
if (!term) return videos; return term ? videos.filter((v) => v.key.toLowerCase().includes(term)) : videos;
return videos.filter((v) => v.key.toLowerCase().includes(term));
}, [videos, q]); }, [videos, q]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages);
const paginated = filtered.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
const pick = (file: File | undefined) => { const pick = (file: File | undefined) => {
if (!file) return; if (!file) return;
const invalid = validateVideoFile(file); const invalid = validateVideoFile(file);
@@ -333,14 +301,14 @@ export function VideosPanel() {
{isFetching && !isLoading && <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />} {isFetching && !isLoading && <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />}
</CardTitle> </CardTitle>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
{videos.length} فایل · روی تصویر بزنید تا پخش شود {videos.length} فایل · برای پخش روی دکمه کلیک کنید
</p> </p>
</div> </div>
<div className="relative w-full sm:max-w-xs"> <div className="relative w-full sm:max-w-xs">
<Search className="pointer-events-none absolute start-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> <Search className="pointer-events-none absolute start-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input <Input
value={q} value={q}
onChange={(e) => setQ(e.target.value)} onChange={(e) => { setQ(e.target.value); setPage(1); }}
placeholder="جستجوی نام فایل…" placeholder="جستجوی نام فایل…"
className="h-10 ps-9" className="h-10 ps-9"
/> />
@@ -355,7 +323,7 @@ export function VideosPanel() {
</div> </div>
)} )}
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
{filtered.map((v) => { {paginated.map((v) => {
const used = usedByKey.get(v.key) ?? []; const used = usedByKey.get(v.key) ?? [];
return ( return (
<div <div
@@ -431,6 +399,29 @@ export function VideosPanel() {
{q ? "نتیجه‌ای نیست" : "هنوز ویدیویی آپلود نشده — یک MP4 انتخاب کنید"} {q ? "نتیجه‌ای نیست" : "هنوز ویدیویی آپلود نشده — یک MP4 انتخاب کنید"}
</p> </p>
)} )}
{totalPages > 1 && (
<div className="col-span-full flex items-center justify-center gap-2 pt-2">
<Button
size="sm"
variant="outline"
disabled={safePage <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronRight className="h-4 w-4" />
</Button>
<span className="text-xs text-muted-foreground">
صفحه {formatFaNumber(safePage)} از {formatFaNumber(totalPages)}
</span>
<Button
size="sm"
variant="outline"
disabled={safePage >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
<ChevronLeft className="h-4 w-4" />
</Button>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
+22 -8
View File
@@ -10,6 +10,16 @@ import { useAuth, useAuthHydrated } from "@/lib/auth-store";
import { http } from "@/lib/api"; import { http } from "@/lib/api";
import type { User } from "@/lib/types"; import type { User } from "@/lib/types";
import { hasInfiniteHearts } from "@/lib/hearts"; import { hasInfiniteHearts } from "@/lib/hearts";
import { BottomNav } from "@/components/bottom-nav";
function SiteFooter() {
return (
<div className="mx-auto flex w-full max-w-6xl flex-col items-center justify-between gap-3 px-4 py-8 sm:flex-row sm:px-6">
<BrandMark size="sm" logoOnly />
<p className="text-xs text-muted-foreground">بوت‌کمپ ۱۲ هفته‌ای · توسعه‌یافته توسط امیرحسین پناهی‌فر</p>
</div>
);
}
function isPublic(p: string) { function isPublic(p: string) {
return p === "/" || p === "/login"; return p === "/" || p === "/login";
@@ -25,6 +35,7 @@ const PROTECTED_PREFIXES = [
"/notifications", "/notifications",
"/lockout", "/lockout",
"/profile", "/profile",
"/settings",
]; ];
function needsAuth(p: string) { function needsAuth(p: string) {
@@ -137,18 +148,21 @@ export function AppShell({ children }: { children: React.ReactNode }) {
</button> </button>
</div> </div>
)} )}
<main className="mx-auto w-full max-w-6xl flex-1 px-4 py-10 sm:px-6"> <main
className="mx-auto w-full max-w-6xl flex-1 px-4 py-10 sm:px-6 md:pb-10"
// Bottom nav is 56px + safe area; only shown for authed non-admin on mobile
style={user && !pathname.startsWith("/admin") ? { paddingBottom: "calc(4.5rem + env(safe-area-inset-bottom, 0px))" } : undefined}
>
{allowChildren ? children : null} {allowChildren ? children : null}
</main> </main>
<footer className="border-t border-border/70 bg-muted/20 backdrop-blur"> <footer
<div className="mx-auto flex w-full max-w-6xl flex-col items-center justify-between gap-3 px-4 py-8 sm:flex-row sm:px-6"> className="border-t border-border/70 bg-muted/20 backdrop-blur"
<BrandMark size="sm" logoOnly /> style={{ paddingBottom: "env(safe-area-inset-bottom, 0px)" }}
<p className="text-xs text-muted-foreground"> >
بوت‌کمپ ۱۲ هفته‌ای · توسعه‌یافته توسط امیرحسین پناهی‌فر <SiteFooter />
</p>
</div>
</footer> </footer>
{user && <ChatWidget />} {user && <ChatWidget />}
{user && !pathname.startsWith("/admin") && <BottomNav />}
</div> </div>
); );
} }
+70 -17
View File
@@ -20,12 +20,16 @@ function clamp(v: number, lo: number, hi: number) {
export function AvatarCropDialog({ export function AvatarCropDialog({
file, file,
busy, busy,
error,
onCancel, onCancel,
onInvalid,
onCrop, onCrop,
}: { }: {
file: File | null; file: File | null;
busy?: boolean; busy?: boolean;
error?: string | null;
onCancel: () => void; onCancel: () => void;
onInvalid?: () => void;
onCrop: (blob: Blob) => Promise<void> | void; onCrop: (blob: Blob) => Promise<void> | void;
}) { }) {
const boxRef = useRef<HTMLDivElement>(null); const boxRef = useRef<HTMLDivElement>(null);
@@ -33,25 +37,68 @@ export function AvatarCropDialog({
const [C, setC] = useState(280); const [C, setC] = useState(280);
const [scale, setScale] = useState(1); const [scale, setScale] = useState(1);
const [pan, setPan] = useState({ x: 0, y: 0 }); const [pan, setPan] = useState({ x: 0, y: 0 });
const [localError, setLocalError] = useState<string | null>(null);
const drag = useRef<{ sx: number; sy: number; px: number; py: number } | null>(null); const drag = useRef<{ sx: number; sy: number; px: number; py: number } | null>(null);
const onInvalidRef = useRef(onInvalid);
onInvalidRef.current = onInvalid;
const fittedFor = useRef<HTMLImageElement | null>(null);
const fittedSize = useRef(0);
useEffect(() => { useEffect(() => {
if (!file) return; if (!file) {
setImg(null);
return;
}
let cancel = false;
setLocalError(null);
const url = URL.createObjectURL(file); const url = URL.createObjectURL(file);
const el = new Image(); const el = new Image();
el.onload = () => setImg({ el, w: el.naturalWidth, h: el.naturalHeight }); el.onload = () => {
if (cancel) return;
setImg({ el, w: el.naturalWidth, h: el.naturalHeight });
};
el.onerror = () => {
if (cancel) return;
setImg(null);
onInvalidRef.current?.();
};
el.src = url; el.src = url;
return () => URL.revokeObjectURL(url); return () => {
cancel = true;
URL.revokeObjectURL(url);
};
}, [file]); }, [file]);
useEffect(() => { useEffect(() => {
if (!img) return; if (!img) return;
let ro: ResizeObserver | null = null;
let frame = 0;
let tries = 0;
const fit = () => {
const box = boxRef.current; const box = boxRef.current;
const size = Math.min(box?.clientWidth ?? 280, 320); if (!box) {
if (tries++ < 40) frame = requestAnimationFrame(fit);
return;
}
if (!ro) {
ro = new ResizeObserver(fit);
ro.observe(box);
}
const size = Math.round(Math.min(box.clientWidth, 320));
if (size < 8) return;
setC(size); setC(size);
const minScale = size / Math.min(img.w, img.h); if (fittedFor.current === img.el && Math.abs(fittedSize.current - size) < 2) return;
setScale(minScale); fittedFor.current = img.el;
setPan({ x: (size - img.w * minScale) / 2, y: (size - img.h * minScale) / 2 }); fittedSize.current = size;
const min = size / Math.min(img.w, img.h);
setScale(min);
setPan({ x: (size - img.w * min) / 2, y: (size - img.h * min) / 2 });
};
fit();
return () => {
cancelAnimationFrame(frame);
ro?.disconnect();
};
}, [img]); }, [img]);
const minScale = img ? C / Math.min(img.w, img.h) : 1; const minScale = img ? C / Math.min(img.w, img.h) : 1;
@@ -94,12 +141,13 @@ export function AvatarCropDialog({
}; };
const confirm = async () => { const confirm = async () => {
if (!img) return; if (!img || !(scale > 0) || C < 8) return;
const cropSize = C / scale; const cropSize = C / scale;
let cx = -pan.x / scale; let cx = -pan.x / scale;
let cy = -pan.y / scale; let cy = -pan.y / scale;
cx = clamp(cx, 0, img.w - cropSize); if (!(cropSize > 0) || !Number.isFinite(cx) || !Number.isFinite(cy)) return;
cy = clamp(cy, 0, img.h - cropSize); cx = clamp(cx, 0, Math.max(0, img.w - cropSize));
cy = clamp(cy, 0, Math.max(0, img.h - cropSize));
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
canvas.width = OUTPUT_SIZE; canvas.width = OUTPUT_SIZE;
@@ -113,10 +161,14 @@ export function AvatarCropDialog({
const blob = await new Promise<Blob | null>((resolve) => const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, "image/png") canvas.toBlob(resolve, "image/png")
); );
if (blob) await onCrop(blob); if (!blob) {
setLocalError("ساخت تصویر آواتار ممکن نشد");
return;
}
await onCrop(blob);
}; };
const zoomPct = Math.round((scale / maxScale) * 100); const zoomPct = maxScale > 0 ? Math.round((scale / maxScale) * 100) : 17;
return ( return (
<Dialog open={!!file && !!img} onOpenChange={(o) => !o && !busy && onCancel()}> <Dialog open={!!file && !!img} onOpenChange={(o) => !o && !busy && onCancel()}>
@@ -145,10 +197,9 @@ export function AvatarCropDialog({
position: "absolute", position: "absolute",
left: 0, left: 0,
top: 0, top: 0,
transform: `translate(${pan.x}px, ${pan.y}px) scale(${scale})`, transform: `translate(${pan.x}px, ${pan.y}px)`,
transformOrigin: "0 0", width: img.w * scale,
width: img.w, height: img.h * scale,
height: img.h,
maxWidth: "none", maxWidth: "none",
maxHeight: "none", maxHeight: "none",
}} }}
@@ -184,6 +235,8 @@ export function AvatarCropDialog({
/> />
</div> </div>
{(error || localError) && <p className="text-[11px] font-bold text-destructive">{error || localError}</p>}
<DialogFooter className="sm:space-x-0 sm:space-x-reverse sm:justify-between"> <DialogFooter className="sm:space-x-0 sm:space-x-reverse sm:justify-between">
<Button variant="outline" onClick={onCancel} disabled={busy}> <Button variant="outline" onClick={onCancel} disabled={busy}>
<X /> انصراف <X /> انصراف
@@ -192,7 +245,7 @@ export function AvatarCropDialog({
<Button variant="ghost" size="sm" onClick={setZoomIn} disabled={busy} title="تنظیم خودکار"> <Button variant="ghost" size="sm" onClick={setZoomIn} disabled={busy} title="تنظیم خودکار">
<Scan /> برازش <Scan /> برازش
</Button> </Button>
<Button onClick={confirm} disabled={busy || !img}> <Button onClick={confirm} disabled={busy || !img || !(scale > 0) || C < 8}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check />} {busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check />}
{busy ? "در حال آپلود…" : "تأیید و ذخیره"} {busy ? "در حال آپلود…" : "تأیید و ذخیره"}
</Button> </Button>
+24 -18
View File
@@ -6,7 +6,7 @@ import { useAuth } from "@/lib/auth-store";
import { queryClient } from "@/lib/query-client"; import { queryClient } from "@/lib/query-client";
import { apiForm, http, toUserError } from "@/lib/api"; import { apiForm, http, toUserError } from "@/lib/api";
import type { User } from "@/lib/types"; import type { User } from "@/lib/types";
import { AVATAR_COLORS, AVATAR_VARIANTS, UserAvatar, avatarPaletteOf } from "@/components/ui/user-avatar"; import { AVATAR_PALETTES, AVATAR_VARIANTS, UserAvatar, avatarPaletteOf } from "@/components/ui/user-avatar";
import { AvatarCropDialog } from "@/components/avatar-crop"; import { AvatarCropDialog } from "@/components/avatar-crop";
import { import {
Dialog, Dialog,
@@ -18,15 +18,6 @@ import {
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const PALETTES: { label: string; colors: string[] }[] = [
{ label: "پیش‌فرض", colors: AVATAR_COLORS },
{ label: "رزین", colors: ["#0f172a", "#38bdf8", "#f0abfc", "#a5f3fc", "#7c3aed", "#f5d0fe"] },
{ label: "صحرایی", colors: ["#1c1917", "#fbbf24", "#fb923c", "#fde68a", "#f97316", "#fed7aa"] },
{ label: "اسکاندی", colors: ["#022c22", "#34d399", "#fef3c7", "#a7f3d0", "#166534", "#d1fae5"] },
{ label: "نئون", colors: ["#18181b", "#a3e635", "#22d3ee", "#f97316", "#e879f9", "#fb7185"] },
{ label: "سلطنتی", colors: ["#20123a", "#9370db", "#e6e6fa", "#6b21a8", "#c084fc", "#f3e8ff"] },
];
export function AvatarStudio({ user, onClose }: { user: User; onClose: () => void }) { export function AvatarStudio({ user, onClose }: { user: User; onClose: () => void }) {
const setUser = useAuth((s) => s.setUser); const setUser = useAuth((s) => s.setUser);
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
@@ -54,13 +45,18 @@ export function AvatarStudio({ user, onClose }: { user: User; onClose: () => voi
void queryClient.invalidateQueries({ queryKey: ["leaderboard"] }); void queryClient.invalidateQueries({ queryKey: ["leaderboard"] });
}; };
const publish = (fresh: User) => {
queryClient.setQueryData<{ user: User }>(["me"], { user: fresh });
setUser(fresh);
invalidate();
};
const doSave = async (body: { avatarVariant?: string; avatarPalette?: string; avatarPhoto?: string }) => { const doSave = async (body: { avatarVariant?: string; avatarPalette?: string; avatarPhoto?: string }) => {
setBusy(true); setBusy(true);
setError(null); setError(null);
try { try {
const { user: fresh } = await http.put<{ user: User }>("/api/me/avatar", body); const { user: fresh } = await http.put<{ user: User }>("/api/me/avatar", body);
setUser(fresh); publish(fresh);
invalidate();
} catch (e) { } catch (e) {
setError(toUserError(e, "خطا در ذخیره")); setError(toUserError(e, "خطا در ذخیره"));
} finally { } finally {
@@ -95,8 +91,7 @@ export function AvatarStudio({ user, onClose }: { user: User; onClose: () => voi
form.append("file", new File([blob], "avatar.png", { type: "image/png" }), "avatar.png"); form.append("file", new File([blob], "avatar.png", { type: "image/png" }), "avatar.png");
const { url } = await apiForm<{ url: string }>("/api/me/avatar-photo", form); const { url } = await apiForm<{ url: string }>("/api/me/avatar-photo", form);
const { user: fresh } = await http.put<{ user: User }>("/api/me/avatar", { avatarPhoto: url }); const { user: fresh } = await http.put<{ user: User }>("/api/me/avatar", { avatarPhoto: url });
setUser(fresh); publish(fresh);
invalidate();
setPending(null); setPending(null);
} catch (e) { } catch (e) {
setError(toUserError(e, "خطا در آپلود")); setError(toUserError(e, "خطا در آپلود"));
@@ -106,7 +101,8 @@ export function AvatarStudio({ user, onClose }: { user: User; onClose: () => voi
}; };
return ( return (
<div className="space-y-4"> <div className="flex min-h-0 flex-1 flex-col">
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto pe-1">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="relative shrink-0"> <div className="relative shrink-0">
<UserAvatar <UserAvatar
@@ -162,7 +158,7 @@ export function AvatarStudio({ user, onClose }: { user: User; onClose: () => voi
<div> <div>
<p className="mb-1.5 text-[11px] font-bold text-muted-foreground">رنگ‌ها</p> <p className="mb-1.5 text-[11px] font-bold text-muted-foreground">رنگ‌ها</p>
<div className="space-y-1"> <div className="space-y-1">
{PALETTES.map((p) => { {AVATAR_PALETTES.map((p) => {
const active = JSON.stringify(p.colors) === draftP; const active = JSON.stringify(p.colors) === draftP;
return ( return (
<button <button
@@ -224,8 +220,9 @@ export function AvatarStudio({ user, onClose }: { user: User; onClose: () => voi
</div> </div>
{error && <p className="text-[11px] font-bold text-destructive">{error}</p>} {error && <p className="text-[11px] font-bold text-destructive">{error}</p>}
</div>
<div className="sticky -bottom-3 z-10 -mx-2.5 flex items-center justify-between gap-2 bg-popover px-2.5 pb-1 pt-2"> <div className="mt-3 flex shrink-0 items-center justify-between gap-2 border-t border-border bg-popover pt-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{dirty && ( {dirty && (
<Button variant="ghost" size="sm" onClick={reset} disabled={busy}> <Button variant="ghost" size="sm" onClick={reset} disabled={busy}>
@@ -250,7 +247,16 @@ export function AvatarStudio({ user, onClose }: { user: User; onClose: () => voi
<AvatarCropDialog <AvatarCropDialog
file={pending} file={pending}
busy={busy} busy={busy}
onCancel={() => !busy && setPending(null)} error={error}
onCancel={() => {
if (busy) return;
setPending(null);
setError(null);
}}
onInvalid={() => {
setPending(null);
setError("خواندن این عکس ممکن نشد. JPEG، PNG یا WebP را انتخاب کنید.");
}}
onCrop={(blob) => onUpload(blob)} onCrop={(blob) => onUpload(blob)}
/> />
+225
View File
@@ -0,0 +1,225 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Check, Loader2 } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { AVATAR_PALETTES } from "@/components/ui/user-avatar";
import { BANNER_FILLS, BANNER_HEIGHT, BANNER_WIDTH, drawBannerFill, type BannerFill } from "@/lib/banner-pattern";
import { toast } from "@/components/providers";
import { cn } from "@/lib/utils";
function FillThumb({
colors,
fill,
seed,
label,
active,
onClick,
}: {
colors: string[];
fill: BannerFill;
seed: string;
label: string;
active: boolean;
onClick: () => void;
}) {
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = ref.current;
const ctx = canvas?.getContext("2d");
if (!canvas || !ctx) return;
drawBannerFill(ctx, canvas.width, canvas.height, colors, fill, seed);
}, [colors, fill, seed]);
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={cn(
"flex flex-col gap-1 rounded-lg border p-1 text-center transition-colors",
active ? "border-primary bg-primary/10 ring-2 ring-primary" : "border-border hover:bg-muted",
)}
>
<canvas ref={ref} width={240} height={80} className="h-10 w-full rounded-md" />
<span className={cn("text-[10px] font-bold", active ? "text-primary" : "text-muted-foreground")}>{label}</span>
</button>
);
}
function PatternCanvas({
canvasRef,
colors,
seed,
fill,
onReady,
}: {
canvasRef: React.RefObject<HTMLCanvasElement | null>;
colors: string[];
seed: string;
fill: BannerFill;
onReady: (ready: boolean) => void;
}) {
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!canvas || !ctx) {
onReady(false);
return;
}
drawBannerFill(ctx, BANNER_WIDTH, BANNER_HEIGHT, colors, fill, seed);
onReady(true);
}, [canvasRef, colors, seed, fill, onReady]);
return <canvas ref={canvasRef} width={BANNER_WIDTH} height={BANNER_HEIGHT} className="absolute inset-0 h-full w-full" />;
}
function HeaderPreview({ children }: { children: React.ReactNode }) {
return (
<div className="relative aspect-[3/1] w-full overflow-hidden rounded-xl border-2 border-border bg-muted">
{children}
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-card via-card/25 to-transparent" />
<div
aria-hidden
className="pointer-events-none absolute bottom-0 left-1/2 aspect-square w-[18%] -translate-x-1/2 translate-y-1/2 rounded-full bg-muted ring-4 ring-card"
/>
</div>
);
}
export function BannerEditor({
open,
busy,
onOpenChange,
onSave,
onRemove,
}: {
open: boolean;
busy?: boolean;
onOpenChange: (open: boolean) => void;
onSave: (blob: Blob) => Promise<void>;
onRemove?: () => void;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [paletteIndex, setPaletteIndex] = useState(0);
const [fill, setFill] = useState<BannerFill>("gradient");
const [ready, setReady] = useState(false);
const palette = AVATAR_PALETTES[paletteIndex] ?? AVATAR_PALETTES[0];
const save = async () => {
try {
const canvas = canvasRef.current;
if (!canvas || !ready) return;
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/png"));
if (!blob) {
toast.error("ساخت تصویر بنر ممکن نشد");
return;
}
await onSave(blob);
} catch {
toast.error("ذخیره بنر ممکن نشد");
}
};
return (
<Dialog open={open} onOpenChange={(next) => !busy && onOpenChange(next)}>
<DialogContent className="flex max-h-[min(92dvh,44rem)] w-[calc(100%-1.5rem)] max-w-xl flex-col gap-0 overflow-hidden p-0 sm:max-w-xl">
<DialogHeader className="shrink-0 space-y-1 px-4 pe-12 pt-4 text-start sm:px-6">
<DialogTitle>ویرایش بنر</DialogTitle>
<DialogDescription>تصویر عریض پشت نام و آواتار در هدر پروفایل. نسبت حدود ۳ به ۱.</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto overscroll-contain px-4 py-3 sm:px-6">
<div className="space-y-1.5">
<HeaderPreview>
<PatternCanvas
canvasRef={canvasRef}
colors={palette.colors}
seed={palette.label}
fill={fill}
onReady={setReady}
/>
</HeaderPreview>
<p className="text-[11px] text-muted-foreground">پیش‌نمایش هدر — سایه و جای آواتار فقط برای دیدن نتیجه‌اند و داخل تصویر ذخیره نمی‌شوند.</p>
</div>
<div>
<p className="mb-1.5 text-[11px] font-bold text-muted-foreground">رنگ‌ها</p>
<div className="space-y-1">
{AVATAR_PALETTES.map((p, i) => {
const active = i === paletteIndex;
return (
<button
key={p.label}
type="button"
onClick={() => setPaletteIndex(i)}
aria-pressed={active}
className={cn(
"flex w-full items-center justify-between rounded-lg border px-2.5 py-1.5 transition-colors",
active ? "border-primary bg-primary/10" : "border-border hover:bg-muted",
)}
>
<span className="text-xs font-bold">{p.label}</span>
<span className="flex items-center gap-0.5">
{p.colors.slice(0, 5).map((c) => (
<span
key={c}
className="h-3.5 w-3.5 rounded-full ring-1 ring-black/10 dark:ring-white/10"
style={{ background: c }}
/>
))}
{active && <Check className="ms-1 h-3.5 w-3.5 text-primary" />}
</span>
</button>
);
})}
</div>
</div>
<div>
<p className="mb-1.5 text-[11px] font-bold text-muted-foreground">پس‌زمینه</p>
<div className="grid grid-cols-3 gap-2">
{BANNER_FILLS.map((item) => (
<FillThumb
key={item.id}
colors={palette.colors}
fill={item.id}
seed={palette.label}
label={item.label}
active={fill === item.id}
onClick={() => setFill(item.id)}
/>
))}
</div>
</div>
</div>
<DialogFooter className="shrink-0 gap-2 border-t border-border bg-popover px-4 py-3 sm:flex-row sm:justify-between sm:space-x-0 sm:px-6">
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
انصراف
</Button>
{onRemove && (
<Button type="button" variant="outline" onClick={onRemove} disabled={busy}>
برداشتن بنر
</Button>
)}
</div>
<Button type="button" onClick={() => void save()} disabled={!ready || busy}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check />}
{busy ? "در حال ذخیره…" : "ذخیره بنر"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+47
View File
@@ -0,0 +1,47 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { LayoutGrid, Trophy, TreePine } from "lucide-react";
import { cn } from "@/lib/utils";
const items = [
{ href: "/cap", label: "مسیر", icon: TreePine },
{ href: "/unwrap", label: "مرکز", icon: LayoutGrid },
{ href: "/leaderboard", label: "لیگ", icon: Trophy },
];
export function BottomNav() {
const pathname = usePathname();
return (
<nav
className="fixed inset-x-0 bottom-0 z-40 border-t border-border bg-card/95 backdrop-blur md:hidden"
style={{ paddingBottom: "env(safe-area-inset-bottom, 0px)" }}
>
<div className="flex">
{items.map(({ href, label, icon: Icon }) => {
const active = pathname === href || pathname.startsWith(href + "/");
return (
<Link
key={href}
href={href}
className={cn(
"flex flex-1 flex-col items-center justify-center gap-0.5 py-2 text-[11px] font-bold transition-colors min-h-[56px] touch-manipulation",
active ? "text-primary" : "text-muted-foreground",
)}
>
<Icon
className={cn(
"h-5 w-5 transition-transform",
active && "scale-110",
)}
strokeWidth={active ? 2.5 : 2}
/>
{label}
</Link>
);
})}
</div>
</nav>
);
}
+29 -52
View File
@@ -1,42 +1,25 @@
"use client"; "use client";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { HeartHandshake, LogIn, MessageSquareHeart } from "lucide-react"; import { ExternalLink, Share2 } from "lucide-react";
import Link from "next/link";
import { http, toUserError } from "@/lib/api"; import { http, toUserError } from "@/lib/api";
import { useAuth } from "@/lib/auth-store";
import { Panel } from "@/components/panel"; import { Panel } from "@/components/panel";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
export type CommunityPayload = { export type SocialLink = { name: string; url: string };
donation: {
enabled: boolean;
note: string;
mode: "chat";
contactId?: number;
draft: string;
};
};
function openDonationChat(contactId: number | undefined, draft: string) { export type CommunityPayload = {
window.dispatchEvent( socials: SocialLink[];
new CustomEvent("pargar:chat", { };
detail: { open: true, with: contactId, draft },
}),
);
}
export function CommunityPanel() { export function CommunityPanel() {
const user = useAuth((s) => s.user);
const { data, isLoading, isError, error, refetch } = useQuery({ const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: ["community", user?.id ?? "guest"], queryKey: ["community"],
queryFn: () => http.get<CommunityPayload>("/api/community"), queryFn: () => http.get<CommunityPayload>("/api/community"),
}); });
if (isLoading) { if (isLoading) {
return ( return <div className="h-72 animate-pulse rounded-2xl border-2 border-border bg-muted/40" />;
<div className="h-72 animate-pulse rounded-2xl border-2 border-border bg-muted/40" />
);
} }
if (isError) { if (isError) {
@@ -50,40 +33,34 @@ export function CommunityPanel() {
); );
} }
const donation = data?.donation; const socials = data?.socials ?? [];
return ( return (
<div className="mx-auto max-w-xl"> <div className="mx-auto max-w-xl">
<Panel <Panel
title="حمایت مالی" title="شبکه‌های پرگار"
description="بدون کارت عمومی — اگر لاگین باشید، گفتگو با تیم باز می‌شود." description="راه‌های رسمی ارتباط با برنامه. لینک شخصی هر کاربر روی پروفایل خودش است."
icon={<HeartHandshake className="h-5 w-5" />} icon={<Share2 className="h-5 w-5" />}
tint="bg-accent/15 text-accent" tint="bg-primary/15 text-primary"
> >
{donation?.enabled ? ( {socials.length === 0 ? (
<div className="space-y-4 pt-1"> <p className="py-8 text-center text-sm text-muted-foreground">هنوز شبکه‌ای ثبت نشده است.</p>
<p className="text-sm leading-relaxed text-muted-foreground">{donation.note}</p> ) : (
<div className="rounded-2xl border border-border bg-muted/20 p-5 text-sm leading-relaxed"> <ul className="space-y-2 pt-1">
جزئیات واریز فقط در پیام خصوصی رد و بدل می‌شود؛ اینجا چیزی عمومی نیست. {socials.map((s) => (
</div> <li key={s.url}>
{user ? ( <a
<Button href={s.url}
className="w-full gap-2" target="_blank"
size="lg" rel="noreferrer"
onClick={() => openDonationChat(donation.contactId, donation.draft)} className="flex items-center justify-between gap-3 rounded-xl border-2 border-border/70 px-3 py-2.5 text-sm font-bold transition-colors hover:border-primary/50 hover:bg-primary/5"
> >
<MessageSquareHeart className="h-4 w-4" /> پیام دربارهٔ دونیت <span>{s.name}</span>
</Button> <ExternalLink className="h-4 w-4 shrink-0 text-muted-foreground" />
) : ( </a>
<Button asChild className="w-full gap-2" size="lg"> </li>
<Link href="/login"> ))}
<LogIn className="h-4 w-4" /> ورود برای هماهنگی دونیت </ul>
</Link>
</Button>
)}
</div>
) : (
<p className="py-8 text-center text-sm text-muted-foreground">حمایت فعلاً غیرفعال است.</p>
)} )}
</Panel> </Panel>
</div> </div>
+17
View File
@@ -0,0 +1,17 @@
import { Snowflake } from "lucide-react";
export function isAccountMonochrome(account?: { isFrozen?: boolean; isClosed?: boolean } | null) {
return Boolean(account?.isFrozen || account?.isClosed);
}
export function FreezeMark({ closed }: { closed?: boolean }) {
const label = closed ? "حساب بسته شده" : "حساب فریز شده";
return (
<Snowflake
aria-hidden={false}
role="img"
aria-label={label}
className="size-[0.85em] shrink-0"
/>
);
}
+65
View File
@@ -0,0 +1,65 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Search } from "lucide-react";
import { http } from "@/lib/api";
type Hit = { kind: string; title: string; href: string };
export function GlobalSearch() {
const router = useRouter();
const [q, setQ] = useState("");
const [hits, setHits] = useState<Hit[]>([]);
const [open, setOpen] = useState(false);
useEffect(() => {
const query = q.trim();
if (query.length < 2) {
setHits([]);
return;
}
const t = setTimeout(() => {
void http
.get<{ results: Hit[] }>(`/api/search?q=${encodeURIComponent(query)}`)
.then((res) => setHits(res.results ?? []))
.catch(() => setHits([]));
}, 250);
return () => clearTimeout(t);
}, [q]);
return (
<div className="relative min-w-0 w-28 sm:w-56">
<Search className="pointer-events-none absolute start-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<input
value={q}
onChange={(e) => {
setQ(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
placeholder="جستجو"
className="h-9 w-full rounded-full border border-border bg-background/70 ps-8 pe-3 text-sm outline-none focus:border-primary"
/>
{open && hits.length > 0 && (
<div className="absolute z-50 mt-1 w-full overflow-hidden rounded-xl border bg-popover shadow-lg">
{hits.map((h) => (
<button
key={h.kind + h.href + h.title}
type="button"
className="block w-full px-3 py-2 text-start text-sm hover:bg-muted"
onClick={() => {
setOpen(false);
setQ("");
router.push(h.href);
}}
>
<span className="text-[10px] text-muted-foreground">{h.kind}</span>
<span className="block truncate font-bold">{h.title}</span>
</button>
))}
</div>
)}
</div>
);
}
+26 -2
View File
@@ -3,10 +3,10 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Crown, Sparkles, Timer, Trophy } from "lucide-react"; import { Crown, Medal, Sparkles, Timer, Trophy } from "lucide-react";
import { http, toUserError } from "@/lib/api"; import { http, toUserError } from "@/lib/api";
import { useAuth } from "@/lib/auth-store"; import { useAuth } from "@/lib/auth-store";
import type { LeaderboardData, LeaderboardEntry } from "@/lib/types"; import type { LeaderboardData, LeaderboardEntry, LeaderboardTier } from "@/lib/types";
import { cn, formatFaNumber } from "@/lib/utils"; import { cn, formatFaNumber } from "@/lib/utils";
import { Panel } from "@/components/panel"; import { Panel } from "@/components/panel";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
@@ -21,6 +21,28 @@ const medalClasses = {
3: "bg-accent text-accent-foreground shadow-offset-sm", 3: "bg-accent text-accent-foreground shadow-offset-sm",
}; };
const tierConfig: Record<LeaderboardTier, { label: string; className: string }> = {
champion: { label: "قهرمان", className: "bg-gold/20 text-gold border-gold/30" },
gold: { label: "طلا", className: "bg-gold/15 text-gold border-gold/25" },
silver: { label: "نقره", className: "bg-secondary/30 text-secondary-foreground border-secondary/40" },
bronze: { label: "برنز", className: "bg-accent/20 text-accent-foreground border-accent/30" },
};
function TierBadge({ tier }: { tier: LeaderboardTier }) {
const cfg = tierConfig[tier];
return (
<span
className={cn(
"inline-flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-bold",
cfg.className,
)}
>
<Medal className="h-2.5 w-2.5" />
{cfg.label}
</span>
);
}
export function LeaderboardPanel() { export function LeaderboardPanel() {
const user = useAuth((s) => s.user); const user = useAuth((s) => s.user);
const { data, isLoading, isError, error, refetch } = useQuery({ const { data, isLoading, isError, error, refetch } = useQuery({
@@ -89,6 +111,7 @@ export function LeaderboardPanel() {
رتبه {fa(me.rank)} شمایید رتبه {fa(me.rank)} شمایید
</span> </span>
)} )}
{me && me.tier && <TierBadge tier={me.tier} />}
</div> </div>
<div className="relative overflow-hidden rounded-2xl border border-gold/25 bg-gradient-to-l from-gold/10 via-card to-primary/5 px-5 py-6 sm:px-8"> <div className="relative overflow-hidden rounded-2xl border border-gold/25 bg-gradient-to-l from-gold/10 via-card to-primary/5 px-5 py-6 sm:px-8">
@@ -134,6 +157,7 @@ export function LeaderboardPanel() {
شما شما
</span> </span>
)} )}
{e.tier && <TierBadge tier={e.tier} />}
<span className="shrink-0 text-sm font-bold text-gold"> <span className="shrink-0 text-sm font-bold text-gold">
{formatFaNumber(Math.round(e.xp))} امتیاز {formatFaNumber(Math.round(e.xp))} امتیاز
</span> </span>
File diff suppressed because it is too large Load Diff
+12 -3
View File
@@ -28,6 +28,8 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn, formatFaNumber } from "@/lib/utils"; import { cn, formatFaNumber } from "@/lib/utils";
import { GlobalSearch } from "@/components/global-search";
import { isVerifiedRole, VerifiedBadge } from "@/components/verified-badge";
const navLinks = [ const navLinks = [
{ href: "/cap", label: "درخت مهارت" }, { href: "/cap", label: "درخت مهارت" },
@@ -63,6 +65,7 @@ export function Navbar() {
<BrandMark /> <BrandMark />
</Link> </Link>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<GlobalSearch />
<ThemeToggle /> <ThemeToggle />
{pathname !== "/login" && ( {pathname !== "/login" && (
<Button asChild size="sm" className="font-bold"> <Button asChild size="sm" className="font-bold">
@@ -173,7 +176,10 @@ export function Navbar() {
className="h-11 w-11" className="h-11 w-11"
/> />
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-sm font-extrabold">{liveUser.name}</p> <p className="flex min-w-0 items-center gap-1 text-sm font-extrabold">
<span className="truncate">{liveUser.name}</span>
{isVerifiedRole(liveUser.role) && <VerifiedBadge role={liveUser.role} />}
</p>
<p className="truncate text-[11px] text-muted-foreground">{liveUser.email}</p> <p className="truncate text-[11px] text-muted-foreground">{liveUser.email}</p>
<p className="text-[11px] text-primary">{roleLabel(liveUser.role)}</p> <p className="text-[11px] text-primary">{roleLabel(liveUser.role)}</p>
</div> </div>
@@ -181,6 +187,9 @@ export function Navbar() {
<DropdownMenuItem onClick={() => router.push("/profile")}> <DropdownMenuItem onClick={() => router.push("/profile")}>
<User /> پروفایل من <User /> پروفایل من
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push("/settings")}>
<User /> تنظیمات
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setStudioOpen(true)}> <DropdownMenuItem onSelect={() => setStudioOpen(true)}>
<User /> ویرایش آواتار <User /> ویرایش آواتار
</DropdownMenuItem> </DropdownMenuItem>
@@ -200,8 +209,8 @@ export function Navbar() {
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<Dialog open={studioOpen} onOpenChange={setStudioOpen}> <Dialog open={studioOpen} onOpenChange={setStudioOpen}>
<DialogContent className="max-h-[85vh] max-w-sm gap-3 overflow-y-auto chat-scroll rounded-2xl"> <DialogContent className="flex max-h-[min(85vh,40rem)] max-w-sm flex-col gap-3 overflow-hidden rounded-2xl">
<DialogHeader> <DialogHeader className="shrink-0">
<DialogTitle className="text-center">ویرایش آواتار</DialogTitle> <DialogTitle className="text-center">ویرایش آواتار</DialogTitle>
</DialogHeader> </DialogHeader>
<AvatarStudio user={liveUser} onClose={() => setStudioOpen(false)} /> <AvatarStudio user={liveUser} onClose={() => setStudioOpen(false)} />
@@ -0,0 +1,48 @@
import Link from "next/link";
import type { LucideIcon } from "lucide-react";
import { SearchX } from "lucide-react";
import { Button } from "@/components/ui/button";
const PERSIAN_LETTER = /[\u0600-\u06FF]/;
/** Keep a short Persian sentence. Drop blank, English, trace-like text, and a copy of the title. */
export function notFoundSentence(
candidate: string | null | undefined,
fallback: string,
title?: string,
): string {
const msg = candidate?.replace(/\s+/g, " ").trim() ?? "";
if (!msg || msg.length > 180 || !PERSIAN_LETTER.test(msg)) return fallback;
if (title && msg === title.trim()) return fallback;
if (/stack trace|uncaught|typeerror|referenceerror|at \S+\(/i.test(msg)) return fallback;
return msg;
}
export function NotFoundState({
title,
description,
href,
actionLabel,
icon: Icon = SearchX,
}: {
title: string;
description: string;
href: string;
actionLabel: string;
icon?: LucideIcon;
}) {
return (
<div className="mx-auto w-full max-w-lg py-6 sm:py-10">
<div className="rounded-2xl border border-border bg-card px-6 py-12 text-center sm:px-10 sm:py-16">
<div className="mx-auto grid h-14 w-14 place-items-center rounded-2xl bg-muted text-muted-foreground">
<Icon className="h-6 w-6" strokeWidth={1.75} aria-hidden />
</div>
<h1 className="mt-6 text-xl font-black tracking-tight sm:text-2xl">{title}</h1>
<p className="mx-auto mt-3 max-w-sm text-sm leading-relaxed text-muted-foreground">{description}</p>
<Button asChild className="mt-8">
<Link href={href}>{actionLabel}</Link>
</Button>
</div>
</div>
);
}
+58 -32
View File
@@ -11,11 +11,11 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { http } from "@/lib/api"; import { http } from "@/lib/api";
import type { NotificationItem } from "@/lib/types"; import type { NotificationItem } from "@/lib/types";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { SafeMarkdown } from "@/components/safe-markdown";
export function NotificationBell() { export function NotificationBell() {
const router = useRouter(); const router = useRouter();
@@ -32,11 +32,15 @@ export function NotificationBell() {
qc.invalidateQueries({ queryKey: ["me"] }); qc.invalidateQueries({ queryKey: ["me"] });
}; };
const categoryDot: Record<string, string> = { const categoryMeta: Record<string, { label: string; dot: string }> = {
progress: "bg-primary", progress: { label: "پیشرفت", dot: "bg-primary" },
gamification: "bg-gold", gamification: { label: "بازی‌سازی", dot: "bg-gold" },
mentor: "bg-accent", mentor: { label: "منتور", dot: "bg-accent" },
event: "bg-secondary", event: { label: "رویداد", dot: "bg-secondary" },
urgent: { label: "فوری", dot: "bg-destructive" },
curriculum: { label: "درس", dot: "bg-primary" },
community: { label: "جامعه", dot: "bg-accent" },
system: { label: "سیستم", dot: "bg-muted-foreground" },
}; };
return ( return (
@@ -51,26 +55,40 @@ export function NotificationBell() {
)} )}
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80 p-0"> <DropdownMenuContent
<div className="flex items-center justify-between rounded-t-md border-b border-border bg-muted/40 px-3 py-2.5"> align="end"
<span className="text-sm font-semibold">اعلان‌ها</span> side="bottom"
<div className="flex items-center gap-1"> collisionPadding={12}
{unread > 0 && <Badge variant="destructive" className="px-2 py-0">{unread} جدید</Badge>} className="w-[min(22rem,calc(100vw-1.25rem))] overflow-hidden p-0"
<Button variant="ghost" size="sm" onClick={markAll} className="h-7 gap-1 px-2 text-xs"> >
<CheckCheck className="h-3.5 w-3.5" /> خواندن همه <div dir="rtl" className="text-start">
<div className="flex items-center justify-between gap-3 border-b border-border px-3 py-2.5">
<div className="min-w-0">
<p className="text-sm font-semibold">اعلان‌ها</p>
<p className="text-[11px] text-muted-foreground">
{unread > 0 ? `${unread} خوانده‌نشده` : "همه خوانده شده"}
</p>
</div>
<Button
variant="ghost"
size="icon"
onClick={markAll}
disabled={unread === 0}
className="h-8 w-8 shrink-0"
aria-label="خواندن همه"
title="خواندن همه"
>
<CheckCheck className="h-4 w-4" />
</Button> </Button>
</div> </div>
</div> <div className="max-h-[min(22rem,calc(100vh-8rem))] overflow-y-auto">
<div className="max-h-[360px] overflow-y-auto">
{isLoading && ( {isLoading && (
<div className="space-y-3 px-3 py-4"> <div className="space-y-3 px-3 py-4">
{Array.from({ length: 3 }).map((_, i) => ( {Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-start gap-3"> <div key={i} className="space-y-1.5">
<Skeleton className="mt-1.5 h-2 w-2 rounded-full" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-4 w-3/4" /> <Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" /> <Skeleton className="h-3 w-full" />
</div> <Skeleton className="h-3 w-1/3" />
</div> </div>
))} ))}
</div> </div>
@@ -82,36 +100,44 @@ export function NotificationBell() {
<span className="text-xs">با قبولی در یک آزمون یا ثبت‌نام در رویداد، اعلان می‌گیرید.</span> <span className="text-xs">با قبولی در یک آزمون یا ثبت‌نام در رویداد، اعلان می‌گیرید.</span>
</div> </div>
)} )}
{data?.notifications.map((n) => ( {data?.notifications.map((n) => {
const cat = categoryMeta[n.category] ?? { label: "اعلان", dot: "bg-primary" };
return (
<button <button
key={n.id} key={n.id}
type="button"
onClick={() => { onClick={() => {
if (n.route) router.push(n.route); if (n.route) router.push(n.route);
}} }}
className={cn( className={cn(
"flex w-full items-start gap-3 border-b border-border px-3 py-3 text-right transition-colors hover:bg-muted/60", "flex w-full items-start gap-2.5 border-b border-border px-3 py-2.5 text-start transition-colors last:border-b-0 hover:bg-muted/60",
!n.readAt && "bg-primary/5" !n.readAt && "bg-primary/[0.06]",
)} )}
> >
<span className={cn("mt-1.5 h-2 w-2 shrink-0 rounded-full", categoryDot[n.category] ?? "bg-primary")} /> <span className={cn("mt-1.5 h-2 w-2 shrink-0 rounded-full", n.readAt ? "bg-border" : cat.dot)} />
<span className="flex-1 space-y-0.5"> <span className="min-w-0 flex-1">
<span className="flex items-center justify-between gap-2"> <span dir="auto" className="block truncate text-sm font-medium [unicode-bidi:plaintext]">
<span className="text-sm font-medium">{n.title}</span> {n.title}
<span className="shrink-0 text-[10px] text-muted-foreground">
{formatDistanceToNow(new Date(n.createdAt), { addSuffix: true, locale: faIR })}
</span> </span>
{n.body && <SafeMarkdown text={n.body} lines={2} className="mt-0.5 text-xs text-muted-foreground" />}
<span className="mt-1 flex items-center gap-1.5 text-[11px] text-muted-foreground">
<span>{cat.label}</span>
<span aria-hidden>·</span>
<span>{formatDistanceToNow(new Date(n.createdAt), { addSuffix: true, locale: faIR })}</span>
</span> </span>
{n.body && <span className="block text-xs text-muted-foreground">{n.body}</span>}
</span> </span>
</button> </button>
))} );
})}
</div> </div>
<button <button
type="button"
onClick={() => router.push("/notifications")} onClick={() => router.push("/notifications")}
className="w-full rounded-b-md bg-muted/40 py-2.5 text-xs font-semibold text-primary hover:bg-muted" className="w-full border-t border-border bg-muted/40 py-2.5 text-xs font-semibold text-primary hover:bg-muted"
> >
مشاهده همه اعلان‌ها مشاهده همه اعلان‌ها
</button> </button>
</div>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
); );
+13 -11
View File
@@ -8,10 +8,10 @@ import { faIR } from "date-fns/locale";
import { http } from "@/lib/api"; import { http } from "@/lib/api";
import type { NotificationItem } from "@/lib/types"; import type { NotificationItem } from "@/lib/types";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { EmptyState } from "@/components/empty-state"; import { EmptyState } from "@/components/empty-state";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { SafeMarkdown } from "@/components/safe-markdown";
const categoryMap: Record< const categoryMap: Record<
string, string,
@@ -21,6 +21,10 @@ const categoryMap: Record<
gamification: { label: "بازی‌سازی", dot: "bg-gold", classes: "bg-gold/15 text-gold" }, gamification: { label: "بازی‌سازی", dot: "bg-gold", classes: "bg-gold/15 text-gold" },
mentor: { label: "منتور", dot: "bg-accent", classes: "bg-accent/15 text-accent" }, mentor: { label: "منتور", dot: "bg-accent", classes: "bg-accent/15 text-accent" },
event: { label: "رویداد", dot: "bg-secondary", classes: "bg-secondary/15 text-secondary" }, event: { label: "رویداد", dot: "bg-secondary", classes: "bg-secondary/15 text-secondary" },
urgent: { label: "فوری", dot: "bg-destructive", classes: "bg-destructive/10 text-destructive" },
curriculum: { label: "درس", dot: "bg-primary", classes: "bg-primary/10 text-primary" },
community: { label: "جامعه", dot: "bg-accent", classes: "bg-accent/15 text-accent" },
system: { label: "سیستم", dot: "bg-muted-foreground", classes: "bg-muted text-foreground" },
}; };
export function NotificationsPanel() { export function NotificationsPanel() {
@@ -82,17 +86,15 @@ export function NotificationsPanel() {
!n.readAt ? "border-primary/30 bg-primary/5" : "border-border bg-card" !n.readAt ? "border-primary/30 bg-primary/5" : "border-border bg-card"
)} )}
> >
<span className={cn("mt-2 h-2.5 w-2.5 shrink-0 rounded-full", cat.dot)} /> <span className={cn("mt-2 h-2 w-2 shrink-0 rounded-full", n.readAt ? "bg-border" : cat.dot)} />
<span className="flex-1 space-y-1"> <span className="min-w-0 flex-1">
<span className="flex flex-wrap items-center gap-2"> <span dir="auto" className="block truncate font-medium [unicode-bidi:plaintext]">
<span className="font-medium">{n.title}</span> {n.title}
<Badge variant="outline" className={cn("px-1.5 py-0 text-[10px] rounded-lg", cat.classes)}>
{cat.label}
</Badge>
</span> </span>
{n.body && <span className="block text-sm text-muted-foreground">{n.body}</span>} {n.body && <SafeMarkdown text={n.body} lines={2} className="mt-0.5 text-sm text-muted-foreground" />}
<span className="block text-[11px] text-muted-foreground/70"> <span className="mt-1.5 flex items-center gap-1.5 text-[11px] text-muted-foreground">
{formatDistanceToNow(new Date(n.createdAt), { addSuffix: true, locale: faIR })} <span className={cn("rounded-md px-1.5 py-0.5", cat.classes)}>{cat.label}</span>
<span>{formatDistanceToNow(new Date(n.createdAt), { addSuffix: true, locale: faIR })}</span>
</span> </span>
</span> </span>
</button> </button>
@@ -1,34 +1,22 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Award, Copy, ExternalLink, Loader2, Lock, Package } from "lucide-react"; import { Award, Clock, Copy, ExternalLink, Loader2, Lock } from "lucide-react";
import { http, toUserError } from "@/lib/api"; import { http, toUserError } from "@/lib/api";
import type { Certificate, CertificatePhysicalOrder, PhysicalCertSettings } from "@/lib/types"; import type { Certificate } from "@/lib/types";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { toast } from "@/components/providers"; import { toast } from "@/components/providers";
import { cn, formatFaNumber } from "@/lib/utils"; import { cn } from "@/lib/utils";
type MineCertificateResponse = { type MineCertificateResponse = {
certificate: Certificate | null; certificate: Certificate | null;
eligible: boolean; eligible: boolean;
physicalOrder: CertificatePhysicalOrder | null;
physical: PhysicalCertSettings;
};
const ORDER_LABEL: Record<string, string> = {
requested: "درخواست فیزیکی ثبت شد",
paid: "پرداخت تأیید شد",
shipped: "ارسال شد",
cancelled: "درخواست فیزیکی لغو شده",
}; };
export function ProfileCertificateSlot() { export function ProfileCertificateSlot() {
const router = useRouter(); const router = useRouter();
const qc = useQueryClient();
const [issuing, setIssuing] = useState(false);
const autoIssueTried = useRef(false);
const { data, isLoading, isError, error, refetch } = useQuery({ const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: ["me", "certificate"], queryKey: ["me", "certificate"],
@@ -37,20 +25,6 @@ export function ProfileCertificateSlot() {
const cert = data?.certificate ?? null; const cert = data?.certificate ?? null;
const eligible = !!data?.eligible; const eligible = !!data?.eligible;
const physical = data?.physical;
const order = data?.physicalOrder;
// Auto-issue once when the student becomes eligible (same path as /cap).
useEffect(() => {
if (!eligible || cert || autoIssueTried.current) return;
autoIssueTried.current = true;
setIssuing(true);
void http
.post<{ certificate: Certificate }>("/api/me/certificate/issue", {})
.then(() => qc.invalidateQueries({ queryKey: ["me", "certificate"] }))
.catch((e) => toast.error(toUserError(e, "صدور خودکار گواهینامه ممکن نشد — دستی تلاش کنید")))
.finally(() => setIssuing(false));
}, [eligible, cert, qc]);
const copyLink = async () => { const copyLink = async () => {
if (!cert?.publicId) return; if (!cert?.publicId) return;
@@ -68,29 +42,6 @@ export function ProfileCertificateSlot() {
router.push(`/c/${cert.publicId}`); router.push(`/c/${cert.publicId}`);
}; };
const claim = async () => {
setIssuing(true);
try {
const res = await http.post<{ certificate: Certificate }>("/api/me/certificate/issue", {});
await qc.invalidateQueries({ queryKey: ["me", "certificate"] });
if (res.certificate?.publicId) {
router.push(`/c/${res.certificate.publicId}`);
}
} catch (e) {
toast.error(toUserError(e, "صدور گواهینامه ممکن نشد"));
} finally {
setIssuing(false);
}
};
const physicalOpen =
!!physical?.enabled &&
!!cert &&
(!order || order.status === "cancelled") &&
(!cert.issuedAt ||
Date.now() <
new Date(cert.issuedAt).getTime() + (physical.windowDays || 30) * 24 * 60 * 60 * 1000);
return ( return (
<section <section
aria-label="جایگاه گواهینامه" aria-label="جایگاه گواهینامه"
@@ -113,12 +64,22 @@ export function ProfileCertificateSlot() {
تلاش دوباره تلاش دوباره
</Button> </Button>
</div> </div>
) : isLoading || (eligible && !cert && issuing) ? ( ) : isLoading ? (
<div className="flex items-center gap-2 py-3 text-xs text-muted-foreground"> <div className="flex items-center gap-2 py-3 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" /> <Loader2 className="h-3.5 w-3.5 animate-spin" />
در حال بارگذاری… در حال بارگذاری…
</div> </div>
) : cert ? ( ) : cert?.status === "pending" ? (
<div className="flex items-start gap-2 rounded-lg border border-border/70 bg-background/60 px-3 py-2.5">
<Clock className="mt-0.5 h-3.5 w-3.5 shrink-0 text-warning" />
<div>
<p className="text-[11px] font-bold text-foreground">در انتظار تأیید</p>
<p className="mt-0.5 text-[10px] leading-relaxed text-muted-foreground">
درخواست گواهینامه ثبت شد و پس از بررسی توسط تیم آموزش صادر می‌شود.
</p>
</div>
</div>
) : cert?.status === "issued" && cert.publicId ? (
<div className="space-y-2.5"> <div className="space-y-2.5">
<div className="rounded-lg border border-primary/25 bg-primary/5 px-3 py-2.5"> <div className="rounded-lg border border-primary/25 bg-primary/5 px-3 py-2.5">
<p className="text-sm font-bold text-foreground">{cert.fullName}</p> <p className="text-sm font-bold text-foreground">{cert.fullName}</p>
@@ -129,12 +90,6 @@ export function ProfileCertificateSlot() {
{cert.publicId} {cert.publicId}
</p> </p>
</div> </div>
{order && order.status !== "cancelled" ? (
<p className="text-[11px] font-medium text-muted-foreground">
{ORDER_LABEL[order.status] ?? order.status}
{order.trackingCode ? ` · کد پیگیری ${order.trackingCode}` : ""}
</p>
) : null}
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
<Button type="button" size="sm" className="h-8 flex-1" onClick={openCert}> <Button type="button" size="sm" className="h-8 flex-1" onClick={openCert}>
<ExternalLink className="h-3.5 w-3.5" /> <ExternalLink className="h-3.5 w-3.5" />
@@ -144,23 +99,17 @@ export function ProfileCertificateSlot() {
<Copy className="h-3.5 w-3.5" /> <Copy className="h-3.5 w-3.5" />
کپی لینک کپی لینک
</Button> </Button>
{physicalOpen ? (
<Button type="button" size="sm" variant="outline" className="h-8" onClick={openCert}>
<Package className="h-3.5 w-3.5" />
فیزیکی
</Button>
) : null}
</div> </div>
</div> </div>
) : eligible ? ( ) : eligible ? (
<div className="space-y-2.5"> <div className="flex items-start gap-2 rounded-lg border border-border/70 bg-background/60 px-3 py-2.5">
<p className="text-[11px] leading-relaxed text-muted-foreground"> <Clock className="mt-0.5 h-3.5 w-3.5 shrink-0 text-warning" />
دوره را تمام کرده‌اید. گواهینامه دیجیتال آمادهٔ صدور است. <div>
<p className="text-[11px] font-bold text-foreground">در انتظار تأیید</p>
<p className="mt-0.5 text-[10px] leading-relaxed text-muted-foreground">
درخواست گواهینامه ثبت شد و پس از بررسی توسط تیم آموزش صادر می‌شود.
</p> </p>
<Button type="button" size="sm" className="h-8 w-full" disabled={issuing} onClick={() => void claim()}> </div>
{issuing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Award className="h-3.5 w-3.5" />}
دریافت گواهینامه
</Button>
</div> </div>
) : ( ) : (
<div <div
@@ -172,10 +121,7 @@ export function ProfileCertificateSlot() {
<div> <div>
<p className="text-[11px] font-bold text-foreground">هنوز صادر نشده</p> <p className="text-[11px] font-bold text-foreground">هنوز صادر نشده</p>
<p className="mt-0.5 text-[10px] leading-relaxed text-muted-foreground"> <p className="mt-0.5 text-[10px] leading-relaxed text-muted-foreground">
با پاس کردن همه درس‌های فعال، گواهینامه همین‌جا نمایش داده می‌شود با پاس کردن همه درس‌های فعال، گواهینامه همین‌جا نمایش داده می‌شود.
{physical?.enabled
? `؛ در صورت فعال بودن، نسخه فیزیکی (پرداخت آفلاین) تا ${formatFaNumber(physical.windowDays)} روز پس از صدور قابل درخواست است.`
: "."}
</p> </p>
</div> </div>
</div> </div>
+254
View File
@@ -0,0 +1,254 @@
"use client";
import Link from "next/link";
import { useEffect, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { BannerEditor } from "@/components/banner-editor";
import { FreezeMark, isAccountMonochrome } from "@/components/freeze-mark";
import { toast } from "@/components/providers";
import { Button } from "@/components/ui/button";
import { UserAvatar, avatarPropsOf, type AvatarVariantName } from "@/components/ui/user-avatar";
import { isVerifiedRole, VerifiedBadge } from "@/components/verified-badge";
import { apiForm, http, toUserError } from "@/lib/api";
import { queryClient } from "@/lib/query-client";
import type { User } from "@/lib/types";
import { cn } from "@/lib/utils";
export type ProfileHeaderModel = {
name: string;
role?: string | null;
banner?: string;
createdAt?: string;
avatarVariant?: AvatarVariantName;
avatarPalette?: string;
avatarPhoto?: string;
isFrozen?: boolean;
isClosed?: boolean;
};
export function membershipLine(createdAt?: string) {
if (!createdAt) return "";
const date = new Date(createdAt);
if (Number.isNaN(date.getTime())) return "";
return `عضویت از ${date.toLocaleDateString("fa-IR", { year: "numeric", month: "long", day: "numeric" })}`;
}
function useHoverBanner() {
const [on, setOn] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(hover: hover) and (min-width: 640px)");
const sync = () => setOn(mq.matches);
sync();
mq.addEventListener("change", sync);
return () => mq.removeEventListener("change", sync);
}, []);
return on;
}
/** Same header card on the owner profile, a public profile, and anyone opened from the league. */
export function ProfileHeader({
person,
secondary,
editable = false,
bannerBusy = false,
onEditBanner,
onRemoveBanner,
onEditAvatar,
children,
}: {
person: ProfileHeaderModel;
/** When set, replaces the membership line. Owner and public profiles leave this empty. */
secondary?: string;
editable?: boolean;
bannerBusy?: boolean;
onEditBanner?: () => void;
onRemoveBanner?: () => void;
onEditAvatar?: () => void;
children?: ReactNode;
}) {
const [bannerHot, setBannerHot] = useState(false);
const hoverBanner = useHoverBanner();
const hideBanner = () => setBannerHot(false);
const banner = person.banner ?? "";
const bannerLabel = banner ? "جایگزینی بنر" : "گذاشتن بنر";
const line = secondary ?? membershipLine(person.createdAt);
const canEditBanner = editable && !!onEditBanner;
return (
<header className={cn("relative overflow-hidden rounded-2xl border border-border bg-card", isAccountMonochrome(person) && "[filter:grayscale(1)]")}>
<div className="pointer-events-none absolute inset-0" aria-hidden>
{banner ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={banner} alt="" className="h-full w-full object-cover" />
) : (
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-accent/20 to-muted" />
)}
<div className="absolute inset-0 bg-gradient-to-t from-card from-20% via-card/50 via-45% to-transparent" />
</div>
{canEditBanner && hoverBanner ? (
<div
className="absolute inset-x-0 top-0 z-10 h-44"
onPointerEnter={() => setBannerHot(true)}
onPointerLeave={hideBanner}
>
<div
className={`absolute inset-0 bg-background/45 backdrop-blur-[2px] transition-opacity ${
bannerHot ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"
}`}
>
<div className="flex h-full items-center justify-center">
<div className="flex flex-wrap items-center justify-center gap-2 px-3">
<button
type="button"
className="rounded-lg bg-card px-3 py-1.5 text-xs font-bold text-foreground shadow-sm"
disabled={bannerBusy}
onClick={onEditBanner}
>
{bannerLabel}
</button>
{banner && onRemoveBanner && (
<button
type="button"
className="rounded-lg bg-card px-3 py-1.5 text-xs font-bold text-foreground shadow-sm disabled:opacity-50"
disabled={bannerBusy}
onClick={onRemoveBanner}
>
برداشتن بنر
</button>
)}
</div>
</div>
</div>
</div>
) : canEditBanner ? (
<button
type="button"
className="absolute inset-0 z-10"
aria-label={bannerLabel}
onClick={onEditBanner}
/>
) : null}
{editable && (
<div className="absolute end-3 top-3 z-30">
<Button type="button" variant="outline" size="sm" className="h-7 border-border/70 bg-card/80 px-2.5 text-[11px] shadow-none backdrop-blur" asChild>
<Link href="/settings">تنظیمات</Link>
</Button>
</div>
)}
<div className="pointer-events-none relative z-20 h-44" />
<div className="pointer-events-none relative z-20 -mt-12 flex items-end justify-start gap-4 px-5 pb-5">
{editable && onEditAvatar ? (
<button
type="button"
onClick={onEditAvatar}
onPointerEnter={hideBanner}
aria-label="ویرایش آواتار"
className="group/avatar pointer-events-auto relative z-20 h-24 w-24 shrink-0 rounded-full"
>
<UserAvatar name={person.name} className="h-24 w-24 ring-4 ring-card" {...avatarPropsOf(person)} />
<span
aria-hidden
className={`pointer-events-none absolute inset-0 grid place-items-center rounded-full bg-background/60 px-1 text-center text-[10px] font-bold leading-tight ring-4 ring-card transition-opacity ${
hoverBanner
? "opacity-0 group-hover/avatar:opacity-100 group-focus-visible/avatar:opacity-100"
: "opacity-0"
}`}
>
ویرایش آواتار
</span>
</button>
) : (
<UserAvatar name={person.name} className="pointer-events-auto h-24 w-24 shrink-0 ring-4 ring-card" {...avatarPropsOf(person)} />
)}
<div
className="pointer-events-auto flex w-fit max-w-full min-w-0 flex-col items-start gap-1"
onPointerEnter={(event) => {
event.stopPropagation();
hideBanner();
}}
>
<h1 className="flex max-w-full items-center gap-1 text-2xl font-black leading-tight tracking-tight">
<span className="min-w-0 truncate">{person.name}</span>
{isVerifiedRole(person.role) && <VerifiedBadge role={person.role} />}
{isAccountMonochrome(person) && <FreezeMark closed={person.isClosed} />}
</h1>
{line ? <p className="w-fit max-w-full text-sm text-muted-foreground">{line}</p> : null}
</div>
</div>
{children}
</header>
);
}
export function OwnerProfileHeader({ user, onEditAvatar }: { user: User; onEditAvatar: () => void }) {
const [editorOpen, setEditorOpen] = useState(false);
const [busy, setBusy] = useState(false);
const profileQ = useQuery({
queryKey: ["me-profile"],
queryFn: () => http.get<{ profile: { banner: string } }>("/api/me/profile"),
});
const banner = profileQ.data?.profile.banner ?? "";
const upload = async (blob: Blob) => {
setBusy(true);
try {
const form = new FormData();
form.append("file", new File([blob], "banner.png", { type: "image/png" }), "banner.png");
const { url } = await apiForm<{ url: string }>("/api/me/avatar-photo", form);
await http.put("/api/me/profile", { banner: url });
await queryClient.invalidateQueries({ queryKey: ["me-profile"] });
toast.success("بنر ذخیره شد");
return true;
} catch (e) {
toast.error(toUserError(e, "ذخیره بنر ممکن نشد"));
return false;
} finally {
setBusy(false);
}
};
const removeBanner = async () => {
setBusy(true);
try {
await http.put("/api/me/profile", { banner: "" });
await queryClient.invalidateQueries({ queryKey: ["me-profile"] });
setEditorOpen(false);
} catch (e) {
toast.error(toUserError(e, "برداشتن بنر ممکن نشد"));
} finally {
setBusy(false);
}
};
return (
<ProfileHeader
person={{
name: user.name,
role: user.role,
banner,
createdAt: user.createdAt,
avatarVariant: user.avatarVariant,
avatarPalette: user.avatarPalette,
avatarPhoto: user.avatarPhoto,
isFrozen: user.isFrozen,
isClosed: user.isClosed,
}}
editable
bannerBusy={busy}
onEditBanner={() => setEditorOpen(true)}
onRemoveBanner={banner ? () => void removeBanner() : undefined}
onEditAvatar={onEditAvatar}
>
<BannerEditor
open={editorOpen}
busy={busy}
onOpenChange={setEditorOpen}
onRemove={banner ? () => void removeBanner() : undefined}
onSave={async (blob) => {
const ok = await upload(blob);
if (ok) setEditorOpen(false);
}}
/>
</ProfileHeader>
);
}
+8 -1
View File
@@ -16,7 +16,14 @@ export function Providers({ children }: { children: React.ReactNode }) {
<MotionConfig reducedMotion="user" transition={softTween}> <MotionConfig reducedMotion="user" transition={softTween}>
<ErrorBoundary area="app">{children}</ErrorBoundary> <ErrorBoundary area="app">{children}</ErrorBoundary>
</MotionConfig> </MotionConfig>
<Toaster position="top-center" richColors closeButton /> <Toaster
position="top-center"
richColors
offset={76}
mobileOffset={{ top: "4.25rem", left: "0.75rem", right: "0.75rem" }}
closeButton
toastOptions={{ duration: 4000 }}
/>
</ThemeProvider> </ThemeProvider>
</QueryClientProvider> </QueryClientProvider>
); );
+19 -1
View File
@@ -15,7 +15,8 @@ import {
Sparkles, Sparkles,
Zap, Zap,
} from "lucide-react"; } from "lucide-react";
import { api, http, toUserError } from "@/lib/api"; import { NotFoundState, notFoundSentence } from "@/components/not-found-state";
import { ApiError, api, http, toUserError } from "@/lib/api";
import type { QuizData, QuizResult } from "@/lib/types"; import type { QuizData, QuizResult } from "@/lib/types";
import { HeartsMeter } from "@/components/hearts-meter"; import { HeartsMeter } from "@/components/hearts-meter";
import { useLiveUser } from "@/hooks/use-live-user"; import { useLiveUser } from "@/hooks/use-live-user";
@@ -36,9 +37,11 @@ export function QuizExperience({ lessonId }: { lessonId: number }) {
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const idOk = Number.isFinite(lessonId) && lessonId > 0;
const { data, isLoading, isError, error: loadError } = useQuery({ const { data, isLoading, isError, error: loadError } = useQuery({
queryKey: ["quiz", lessonId], queryKey: ["quiz", lessonId],
queryFn: () => http.get<QuizData>(`/api/lessons/${lessonId}/quiz`), queryFn: () => http.get<QuizData>(`/api/lessons/${lessonId}/quiz`),
enabled: idOk,
retry: false, retry: false,
meta: { silentError: true }, meta: { silentError: true },
}); });
@@ -73,6 +76,21 @@ export function QuizExperience({ lessonId }: { lessonId: number }) {
} }
}; };
if (!idOk || (loadError instanceof ApiError && loadError.status === 404)) {
return (
<NotFoundState
title="آزمون پیدا نشد"
description={notFoundSentence(
loadError ? toUserError(loadError, "") : "",
"این آزمون در سامانه نیست.",
"آزمون پیدا نشد",
)}
href="/cap"
actionLabel="بازگشت به درخت مهارت"
/>
);
}
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex min-h-[50vh] items-center justify-center"> <div className="flex min-h-[50vh] items-center justify-center">
+13 -4
View File
@@ -5,7 +5,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { ArrowLeft, ArrowRight, Loader2, UserPlus } from "lucide-react"; import { ArrowLeft, ArrowRight, Loader2, UserPlus } from "lucide-react";
import { useAuth } from "@/lib/auth-store"; import { useAuth } from "@/lib/auth-store";
import { http, toUserError } from "@/lib/api"; import { ApiError, http, toUserError } from "@/lib/api";
import type { User } from "@/lib/types"; import type { User } from "@/lib/types";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -72,9 +72,11 @@ export function RegisterForm({ endpoint, submitLabel, description }: RegisterFor
const [captchaKey, setCaptchaKey] = useState(0); const [captchaKey, setCaptchaKey] = useState(0);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [captchaError, setCaptchaError] = useState<string | null>(null);
const goNext = () => { const goNext = () => {
setError(null); setError(null);
setCaptchaError(null);
if (step === 1) { if (step === 1) {
const nErr = validateName(name); const nErr = validateName(name);
if (nErr) { if (nErr) {
@@ -111,6 +113,7 @@ export function RegisterForm({ endpoint, submitLabel, description }: RegisterFor
const goBack = () => { const goBack = () => {
setError(null); setError(null);
setCaptchaError(null);
setStep((s) => Math.max(1, s - 1)); setStep((s) => Math.max(1, s - 1));
}; };
@@ -127,7 +130,7 @@ export function RegisterForm({ endpoint, submitLabel, description }: RegisterFor
return; return;
} }
if (!challenge) { if (!challenge) {
setError("ابتدا کد امنیتی را بارگذاری کنید"); setCaptchaError("ابتدا کد امنیتی را بارگذاری کنید");
setCaptchaKey((k) => k + 1); setCaptchaKey((k) => k + 1);
return; return;
} }
@@ -152,7 +155,9 @@ export function RegisterForm({ endpoint, submitLabel, description }: RegisterFor
router.push("/cap"); router.push("/cap");
} catch (err) { } catch (err) {
setError(toUserError(err, "ثبت‌نام ناموفق بود")); setError(toUserError(err, "ثبت‌نام ناموفق بود"));
if (!(err instanceof ApiError) || err.status !== 429) {
setCaptchaKey((k) => k + 1); setCaptchaKey((k) => k + 1);
}
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -304,12 +309,16 @@ export function RegisterForm({ endpoint, submitLabel, description }: RegisterFor
value={captchaAnswer} value={captchaAnswer}
onChange={setCaptchaAnswer} onChange={setCaptchaAnswer}
onChallenge={setChallenge} onChallenge={setChallenge}
onError={setError} onError={setCaptchaError}
/> />
</> </>
)} )}
{error && <p className="text-sm text-destructive">{error}</p>} {(error || captchaError) && (
<p className="text-sm text-destructive" role="alert">
{error || captchaError}
</p>
)}
<div className="flex items-stretch gap-2.5 pt-1"> <div className="flex items-stretch gap-2.5 pt-1">
{step > 1 && ( {step > 1 && (
+62
View File
@@ -0,0 +1,62 @@
"use client";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
export function SafeMarkdown({
text,
className,
lines,
}: {
text: string;
className?: string;
lines?: 2 | 3;
}) {
const [html, setHtml] = useState("");
useEffect(() => {
if (!text) { setHtml(""); return; }
let cancelled = false;
void (async () => {
const { marked } = await import("marked");
const DOMPurify = (await import("dompurify")).default;
const raw = await marked(text);
if (!cancelled) setHtml(DOMPurify.sanitize(raw));
})();
return () => { cancelled = true; };
}, [text]);
const mdClasses =
"[&_strong]:font-bold [&_em]:italic [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:text-[0.85em] [&_h2]:text-base [&_h2]:font-bold [&_h2]:mt-3 [&_h3]:text-sm [&_h3]:font-bold [&_h3]:mt-2 [&_ul]:list-disc [&_ul]:pr-5 [&_ol]:list-decimal [&_ol]:pr-5 [&_li]:mt-0.5 [&_p]:leading-relaxed [&_p+p]:mt-2 [&_a]:underline [&_a]:text-primary";
if (!html) {
return (
<div
dir="auto"
className={cn(
"break-words text-sm [unicode-bidi:plaintext]",
lines === 2 && "line-clamp-2",
lines === 3 && "line-clamp-3",
!lines && "whitespace-pre-wrap",
className,
)}
>
{text}
</div>
);
}
return (
<div
dir="auto"
className={cn(
"break-words text-sm [unicode-bidi:plaintext]",
mdClasses,
lines === 2 && "line-clamp-2",
lines === 3 && "line-clamp-3",
className,
)}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
+125
View File
@@ -0,0 +1,125 @@
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
export type SectionNavItem<T extends string = string> = {
id: T;
label: string;
};
export type SectionNavGroup<T extends string = string> = {
title?: string;
items: SectionNavItem<T>[];
};
const DESKTOP_STICKY_TOP = 80;
export function SectionColumns({ children }: { children: ReactNode }) {
return (
<div className="sm:grid sm:grid-cols-[15rem_minmax(0,1fr)] sm:items-start sm:gap-8">
{children}
</div>
);
}
export function SectionNav<T extends string>({
label,
groups,
activeId,
onSelect,
}: {
label: string;
groups: SectionNavGroup<T>[];
activeId: T;
onSelect: (id: T) => void;
}) {
const navRef = useRef<HTMLElement>(null);
const [stick, setStick] = useState(false);
useEffect(() => {
const el = navRef.current;
if (!el) return;
const measure = () => {
const desktop = window.matchMedia("(min-width: 640px)").matches;
setStick(desktop && el.offsetHeight <= window.innerHeight - DESKTOP_STICKY_TOP);
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(el);
window.addEventListener("resize", measure);
return () => {
observer.disconnect();
window.removeEventListener("resize", measure);
};
}, []);
return (
<nav
ref={navRef}
aria-label={label}
className={cn(
"z-20 -mx-1 mb-4 flex gap-2 bg-background/95 px-1 py-2 backdrop-blur max-sm:sticky max-sm:top-16 max-sm:overflow-x-auto",
"sm:z-10 sm:mx-0 sm:mb-0 sm:flex-col sm:gap-3 sm:self-start sm:overflow-visible sm:rounded-2xl sm:border-2 sm:border-border sm:bg-card sm:p-2 sm:backdrop-blur-none",
stick && "sm:sticky sm:top-20",
)}
>
{groups.map((group) => (
<div
key={group.title ?? group.items[0]?.id ?? "group"}
className="contents sm:flex sm:flex-col sm:gap-1"
>
{group.title ? (
<p className="hidden px-3 pt-2 text-sm font-bold text-muted-foreground sm:block">{group.title}</p>
) : null}
{group.items.map((item) => {
const selected = activeId === item.id;
return (
<button
key={item.id}
type="button"
aria-current={selected ? "true" : undefined}
onClick={() => onSelect(item.id)}
className={cn(
"shrink-0 rounded-full border px-3.5 py-1.5 text-sm font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"sm:w-full sm:rounded-xl sm:px-3 sm:py-2 sm:text-start",
selected
? "border-primary bg-primary text-primary-foreground sm:border-transparent sm:bg-primary/10 sm:text-primary"
: "border-border bg-card text-muted-foreground hover:bg-muted hover:text-foreground sm:border-transparent sm:bg-transparent",
)}
>
{item.label}
</button>
);
})}
</div>
))}
</nav>
);
}
export function SectionPanel({
id,
titleId,
title,
description,
children,
}: {
id?: string;
titleId: string;
title: string;
description: string;
children: ReactNode;
}) {
return (
<section id={id} aria-labelledby={titleId} className="min-w-0 rounded-2xl border-2 border-border bg-card">
<header className="border-b border-border/70 px-5 py-4 sm:px-6">
<h2 id={titleId} className="text-lg font-black">
{title}
</h2>
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
</header>
<div className="p-5 sm:p-6">{children}</div>
</section>
);
}
+4 -4
View File
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg border font-sans text-sm font-bold transition-all duration-300 ease-[var(--ease-soft)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 cursor-pointer select-none active:translate-y-px active:shadow-none", "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg border font-sans text-sm font-bold transition-all duration-300 ease-[var(--ease-soft)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 cursor-pointer select-none active:translate-y-px active:shadow-none touch-manipulation",
{ {
variants: { variants: {
variant: { variant: {
@@ -18,10 +18,10 @@ const buttonVariants = cva(
link: "border-transparent text-primary underline-offset-4 hover:underline", link: "border-transparent text-primary underline-offset-4 hover:underline",
}, },
size: { size: {
default: "h-10 rounded-lg px-4 py-2", default: "h-11 rounded-lg px-4 py-2",
sm: "h-8 rounded-lg px-3 text-xs", sm: "h-9 rounded-lg px-3 text-xs",
lg: "h-[3.25rem] rounded-2xl px-7 text-base", lg: "h-[3.25rem] rounded-2xl px-7 text-base",
icon: "h-10 w-10 rounded-xl", icon: "h-11 w-11 rounded-xl",
}, },
}, },
defaultVariants: { variant: "default", size: "default" }, defaultVariants: { variant: "default", size: "default" },
+15 -2
View File
@@ -34,13 +34,26 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border border-border bg-popover p-6 shadow-md rounded-md duration-300 sm:max-w-lg data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95", // Base (mobile-first): full-width bottom sheet with safe-area clearance
"fixed bottom-0 left-0 right-0 z-50 grid w-full max-w-none gap-4 border border-border bg-popover shadow-md duration-300",
"rounded-t-2xl rounded-b-none p-5 pb-[max(1.25rem,env(safe-area-inset-bottom))]",
"max-h-[90svh] overflow-y-auto",
"translate-x-0 translate-y-0",
"data-[state=open]:animate-in data-[state=open]:slide-in-from-bottom-4",
"data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom-full",
// Desktop (sm+): centered modal
"sm:bottom-auto sm:left-1/2 sm:right-auto sm:top-1/2 sm:max-w-lg sm:max-h-[calc(100svh-4rem)] sm:rounded-md sm:p-6",
"sm:-translate-x-1/2 sm:-translate-y-1/2",
"sm:data-[state=open]:zoom-in-95 sm:data-[state=closed]:zoom-out-95",
"sm:data-[state=open]:fade-in-0 sm:data-[state=closed]:fade-out-0",
className className
)} )}
{...props} {...props}
> >
{/* Mobile drag handle affordance */}
<span className="mx-auto -mt-1 mb-2 block h-1.5 w-10 shrink-0 rounded-full bg-muted-foreground/20 sm:hidden" aria-hidden />
{children} {children}
<DialogPrimitive.Close className="absolute end-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none"> <DialogPrimitive.Close className="absolute end-4 top-4 grid h-8 w-8 place-items-center rounded-full opacity-70 transition-opacity hover:opacity-100 focus:outline-none sm:h-auto sm:w-auto sm:rounded-sm">
<X className="h-4 w-4" /> <X className="h-4 w-4" />
<span className="sr-only">بستن</span> <span className="sr-only">بستن</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
+28 -1
View File
@@ -40,4 +40,31 @@ const DropdownMenuItem = React.forwardRef<
)); ));
DropdownMenuItem.displayName = "DropdownMenuItem"; DropdownMenuItem.displayName = "DropdownMenuItem";
export { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem }; const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2.5 py-1.5 text-[11px] font-bold text-muted-foreground", className)}
{...props}
/>
));
DropdownMenuLabel.displayName = "DropdownMenuLabel";
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-border", className)} {...props} />
));
DropdownMenuSeparator.displayName = "DropdownMenuSeparator";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
};
+2 -1
View File
@@ -33,7 +33,8 @@ const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLI
type={type} type={type}
className={cn( className={cn(
// Fixed height + symmetric vertical padding; avoid clipping Vazirmatn descenders. // Fixed height + symmetric vertical padding; avoid clipping Vazirmatn descenders.
"box-border h-11 w-full rounded-lg border border-input bg-card px-3.5 py-0 font-sans text-sm font-medium leading-none outline-none transition-colors placeholder:font-sans placeholder:font-medium placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50", // text-base (16px) prevents iOS Safari auto-zoom on focus; sm: downsizes on desktop
"box-border h-11 w-full rounded-lg border border-input bg-card px-3.5 py-0 font-sans text-base sm:text-sm font-medium leading-none outline-none transition-colors touch-manipulation placeholder:font-sans placeholder:font-medium placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
latin && "font-latin placeholder:font-latin", latin && "font-latin placeholder:font-latin",
className className
)} )}
+196
View File
@@ -0,0 +1,196 @@
"use client";
import { useEffect, useState } from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import { Markdown } from "tiptap-markdown";
import { Bold, Code, Heading2, Heading3, Italic, List, ListOrdered } from "lucide-react";
import { cn } from "@/lib/utils";
type Props = {
value: string;
onChange: (v: string) => void;
placeholder?: string;
className?: string;
};
const mdClasses =
"[&_strong]:font-bold [&_em]:italic [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:text-[0.85em] [&_h2]:text-base [&_h2]:font-bold [&_h2]:mt-3 [&_h3]:text-sm [&_h3]:font-bold [&_h3]:mt-2 [&_ul]:list-disc [&_ul]:pr-5 [&_ol]:list-decimal [&_ol]:pr-5 [&_li]:mt-0.5 [&_p]:leading-relaxed [&_p+p]:mt-2 [&_a]:underline [&_a]:text-primary";
export function RichEditor({ value, onChange, placeholder, className }: Props) {
const [tab, setTab] = useState<"edit" | "preview">("edit");
const [preview, setPreview] = useState("");
const editor = useEditor({
extensions: [
StarterKit.configure({ codeBlock: false }),
Placeholder.configure({ placeholder: placeholder ?? "توضیحات را اینجا بنویسید…" }),
Markdown.configure({ transformPastedText: true }),
],
content: value,
immediatelyRender: false,
onUpdate({ editor: e }) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const md = (e.storage as any).markdown?.getMarkdown?.() ?? e.getText();
onChange(md);
},
});
useEffect(() => {
if (!editor) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const current = (editor.storage as any).markdown?.getMarkdown?.() ?? editor.getText();
if (current !== value) {
editor.commands.setContent(value, { emitUpdate: false });
}
}, [value, editor]);
useEffect(() => {
if (tab !== "preview") return;
let cancelled = false;
void (async () => {
const { marked } = await import("marked");
const DOMPurify = (await import("dompurify")).default;
const html = await marked(value);
if (!cancelled) setPreview(DOMPurify.sanitize(html));
})();
return () => { cancelled = true; };
}, [tab, value]);
const ToolBtn = ({
onClick,
active,
title,
children,
}: {
onClick: () => void;
active?: boolean;
title: string;
children: React.ReactNode;
}) => (
<button
type="button"
onMouseDown={(e) => {
e.preventDefault();
onClick();
}}
title={title}
className={cn(
"grid h-7 w-7 place-items-center rounded transition-colors",
active
? "bg-primary/15 text-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
{children}
</button>
);
return (
<div className={cn("rounded-md border border-input bg-background", className)}>
{/* Tab bar */}
<div className="flex items-center justify-between gap-1 border-b border-input px-2 py-1">
<div className="flex gap-0.5">
{editor && (
<>
<ToolBtn
onClick={() => editor.chain().focus().toggleBold().run()}
active={editor.isActive("bold")}
title="توپر"
>
<Bold className="h-3.5 w-3.5" />
</ToolBtn>
<ToolBtn
onClick={() => editor.chain().focus().toggleItalic().run()}
active={editor.isActive("italic")}
title="مورب"
>
<Italic className="h-3.5 w-3.5" />
</ToolBtn>
<ToolBtn
onClick={() => editor.chain().focus().toggleCode().run()}
active={editor.isActive("code")}
title="کد درون‌خطی"
>
<Code className="h-3.5 w-3.5" />
</ToolBtn>
<ToolBtn
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
active={editor.isActive("heading", { level: 2 })}
title="سرتیتر ۲"
>
<Heading2 className="h-3.5 w-3.5" />
</ToolBtn>
<ToolBtn
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
active={editor.isActive("heading", { level: 3 })}
title="سرتیتر ۳"
>
<Heading3 className="h-3.5 w-3.5" />
</ToolBtn>
<ToolBtn
onClick={() => editor.chain().focus().toggleBulletList().run()}
active={editor.isActive("bulletList")}
title="فهرست"
>
<List className="h-3.5 w-3.5" />
</ToolBtn>
<ToolBtn
onClick={() => editor.chain().focus().toggleOrderedList().run()}
active={editor.isActive("orderedList")}
title="فهرست عددی"
>
<ListOrdered className="h-3.5 w-3.5" />
</ToolBtn>
</>
)}
</div>
<div className="flex gap-1 text-xs">
<button
type="button"
onClick={() => setTab("edit")}
className={cn(
"rounded px-2 py-0.5 transition-colors",
tab === "edit" ? "bg-primary/15 text-primary font-medium" : "text-muted-foreground hover:text-foreground",
)}
>
ویرایش
</button>
<button
type="button"
onClick={() => setTab("preview")}
className={cn(
"rounded px-2 py-0.5 transition-colors",
tab === "preview" ? "bg-primary/15 text-primary font-medium" : "text-muted-foreground hover:text-foreground",
)}
>
پیش‌نمایش
</button>
</div>
</div>
{/* Editor / Preview */}
{tab === "edit" ? (
<EditorContent
editor={editor}
dir="auto"
className={cn(
"px-3 py-2 text-sm focus-within:outline-none",
mdClasses,
"[&_.ProseMirror]:min-h-[80px] [&_.ProseMirror]:outline-none",
"[&_.ProseMirror_p.is-editor-empty:first-child::before]:text-muted-foreground/50 [&_.ProseMirror_p.is-editor-empty:first-child::before]:content-[attr(data-placeholder)] [&_.ProseMirror_p.is-editor-empty:first-child::before]:float-right [&_.ProseMirror_p.is-editor-empty:first-child::before]:pointer-events-none",
)}
/>
) : (
<div
dir="auto"
className={cn("px-3 py-2 text-sm", mdClasses)}
dangerouslySetInnerHTML={{
__html: preview || "<p class='text-muted-foreground text-sm'>پیش‌نمایشی نیست.</p>",
}}
/>
)}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More