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.
This commit is contained in:
@@ -10,7 +10,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/Panahifarah/pargar/releases/tag/v0.1.3"><img src="https://img.shields.io/github/v/tag/Panahifarah/pargar?label=release&color=5B4CDB" alt="release" /></a>
|
<a href="https://github.com/Panahifarah/pargar/releases/tag/v0.2.0"><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>
|
<a href="LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-5B4CDB" alt="AGPL-3.0" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -837,58 +848,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)})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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})
|
||||||
}
|
}
|
||||||
|
|||||||
+287
-90
@@ -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),
|
||||||
|
}
|
||||||
|
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})
|
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
|
||||||
}
|
}
|
||||||
|
name := p.Name
|
||||||
|
saved := p.ID == u.ID
|
||||||
|
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,
|
||||||
|
"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{
|
conv := conversation{
|
||||||
Partner: map[string]any{
|
Partner: partner,
|
||||||
"id": p.ID, "name": p.Name, "email": p.Email, "role": p.Role,
|
UnreadCount: unread,
|
||||||
"avatarVariant": p.AvatarVariant, "avatarPalette": p.AvatarPalette, "avatarPhoto": photo,
|
MessageCount: len(msgs),
|
||||||
"online": s.hub.Online(p.ID),
|
PinnedRank: pref.PinnedRank,
|
||||||
},
|
Muted: pref.Muted,
|
||||||
UnreadCount: unread,
|
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) {
|
||||||
@@ -224,43 +330,22 @@ func (s *Server) handleChatSend(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var req struct {
|
var req struct {
|
||||||
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 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 {
|
if req.Body == "" && len(atts) == 0 {
|
||||||
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 {
|
||||||
@@ -352,8 +438,14 @@ func (s *Server) handleChatEdit(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
msgID := routeID(r, "id")
|
msgID := routeID(r, "id")
|
||||||
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 req.Attachment != nil {
|
|
||||||
att := req.Attachment
|
|
||||||
att.Type = strings.ToLower(strings.TrimSpace(att.Type))
|
|
||||||
if att.Type != "image" && att.Type != "video" && att.Type != "audio" {
|
|
||||||
writeErr(w, http.StatusBadRequest, "فقط عکس، ویدیو یا صدا را میتوان جایگزین کرد")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
key := mediaKeyFromURL(att.URL)
|
|
||||||
if key == "" || !mediaKeyOwnedByUser(key, u.ID) {
|
|
||||||
writeErr(w, http.StatusBadRequest, "پیوست متعلق به شما نیست")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if q := strings.Index(att.URL, "?"); q >= 0 {
|
|
||||||
att.URL = att.URL[:q]
|
|
||||||
}
|
|
||||||
if att.Name == "" {
|
|
||||||
att.Name = "file"
|
|
||||||
}
|
|
||||||
msg, err = s.store.ReplaceChatAttachment(r.Context(), u.ID, partner, msgID, u.Role, req.Body, att.Type, att.URL, att.Name, att.Size)
|
|
||||||
} else {
|
|
||||||
if req.Body == "" {
|
|
||||||
writeErr(w, http.StatusBadRequest, "پیام باید بین ۱ تا ۴۰۰۰ نویسه باشد")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
msg, err = s.store.EditChatMessage(r.Context(), u.ID, partner, msgID, u.Role, req.Body)
|
|
||||||
}
|
}
|
||||||
|
if 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 {
|
||||||
|
toAdd = append(toAdd, *req.Attachment)
|
||||||
|
}
|
||||||
|
added, attErr := prepareChatAttachments(u.ID, nil, toAdd)
|
||||||
|
if attErr != "" {
|
||||||
|
writeErr(w, http.StatusBadRequest, attErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
final := make([]models.Attachment, 0, len(existing)+len(added))
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if req.Body == "" && len(final) == 0 {
|
||||||
|
writeErr(w, http.StatusBadRequest, "پیام باید متن یا پیوست داشته باشد")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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,10 +718,75 @@ 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
|
||||||
}
|
}
|
||||||
msg.Attachment.URL = s.signExistingMediaURL(msg.Attachment.URL)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"pargar/backend/internal/models"
|
"pargar/backend/internal/models"
|
||||||
@@ -49,16 +50,17 @@ type chatExportAttachment struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type chatExportMessage struct {
|
type chatExportMessage struct {
|
||||||
ID int64 `json:"id,omitempty"`
|
ID int64 `json:"id,omitempty"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
SenderRole string `json:"senderRole"`
|
SenderRole string `json:"senderRole"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
ReadAt *time.Time `json:"readAt,omitempty"`
|
ReadAt *time.Time `json:"readAt,omitempty"`
|
||||||
ReplyTo *int64 `json:"replyTo,omitempty"`
|
ReplyTo *int64 `json:"replyTo,omitempty"`
|
||||||
Pinned bool `json:"pinned"`
|
Pinned bool `json:"pinned"`
|
||||||
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,
|
exported := make([]chatExportAttachment, 0, len(list))
|
||||||
}
|
for i, src := range list {
|
||||||
key := mediaKeyFromURL(m.Attachment.URL)
|
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,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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,11 +396,13 @@ 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 {
|
||||||
if code != http.StatusForbidden {
|
t.Fatalf("saved messages: %d", code)
|
||||||
t.Fatalf("student→student must be forbidden, got %d", code)
|
}
|
||||||
}
|
code, _ = e.do(t, "POST", "/api/chats/"+itoa(otherStudentID)+"/messages", studentToken, map[string]any{"body": "not allowed"})
|
||||||
|
if code != http.StatusForbidden {
|
||||||
|
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 {
|
||||||
@@ -566,17 +582,16 @@ 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":"حمایت"}]`,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if code != http.StatusOK {
|
if code != http.StatusOK {
|
||||||
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"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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, "<b>راز</b>") {
|
||||||
|
t.Fatal("user text was not escaped")
|
||||||
|
}
|
||||||
|
if !strings.Contains(htmlDoc, "<b>راز</b>\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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.0"
|
||||||
|
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.0+" + 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
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
me := currentUser(r)
|
||||||
"user": map[string]any{
|
extras, err := s.store.GetProfileExtras(r.Context(), u.ID)
|
||||||
"id": u.ID,
|
if err != nil {
|
||||||
"name": u.Name,
|
extras = store.ProfileExtras{}
|
||||||
"username": u.Username,
|
}
|
||||||
"role": u.Role,
|
full := me.ID == u.ID || me.Role.IsStaff()
|
||||||
"xp": u.XP,
|
if !full && !extras.Public {
|
||||||
"hearts": u.Hearts,
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"streakCurrent": u.StreakCurrent,
|
"user": map[string]any{
|
||||||
"streakLongest": u.StreakLongest,
|
"id": u.ID, "name": u.Name, "role": u.Role,
|
||||||
"avatarVariant": u.AvatarVariant,
|
"avatarVariant": u.AvatarVariant, "avatarPalette": u.AvatarPalette, "avatarPhoto": u.AvatarPhoto,
|
||||||
"avatarPalette": u.AvatarPalette,
|
"private": true, "isFrozen": u.IsFrozen, "isClosed": u.IsClosed,
|
||||||
"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})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -155,15 +159,11 @@ func (s *Server) handleQuizSubmit(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
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.issued", "user_id", u.ID, "public_id", cert.PublicID)
|
||||||
go func() {
|
_ = s.notify.Notify(r.Context(), u.ID, "progress", "certificate_issued",
|
||||||
_ = s.notify.Notify(context.Background(), u.ID, "progress", "certificate_issued",
|
"گواهینامه آماده است", "دوره را به پایان رساندید. گواهینامه دیجیتال شما صادر شد.", "/c/"+cert.PublicID, nil)
|
||||||
"گواهینامه آماده است", "دوره را به پایان رساندید. گواهینامه دیجیتال شما صادر شد.", "/c/"+cert.PublicID, nil)
|
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
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.
|
||||||
|
|||||||
@@ -152,6 +152,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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 {
|
if strings.TrimSpace(out["hearts_regen_minutes"]) == "" {
|
||||||
out["donation_enabled"] = "false"
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -16,9 +16,10 @@ const pushChannel = "pargar:push"
|
|||||||
// Hub manages per-user websocket connections and fans out messages.
|
// Hub manages per-user websocket connections and fans out messages.
|
||||||
// Messages are published on a shared Redis channel so all instances deliver to their own local clients.
|
// Messages are published on a shared Redis channel so all instances deliver to their own local clients.
|
||||||
type Hub struct {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -21,32 +21,87 @@ func (r Role) IsStaff() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
PasswordHash string `json:"-"`
|
PasswordHash string `json:"-"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Role Role `json:"role"`
|
Role Role `json:"role"`
|
||||||
XP int `json:"xp"`
|
XP int `json:"xp"`
|
||||||
Hearts int `json:"hearts"`
|
Hearts int `json:"hearts"`
|
||||||
HeartsUpdatedAt time.Time `json:"heartsUpdatedAt"`
|
HeartsUpdatedAt time.Time `json:"heartsUpdatedAt"`
|
||||||
StreakCurrent int `json:"streakCurrent"`
|
StreakCurrent int `json:"streakCurrent"`
|
||||||
StreakLongest int `json:"streakLongest"`
|
StreakLongest int `json:"streakLongest"`
|
||||||
LastActivityDate *time.Time `json:"lastActivityDate,omitempty"`
|
LastActivityDate *time.Time `json:"lastActivityDate,omitempty"`
|
||||||
IsLocked bool `json:"isLocked"`
|
IsLocked bool `json:"isLocked"`
|
||||||
LockedAt *time.Time `json:"lockedAt,omitempty"`
|
LockedAt *time.Time `json:"lockedAt,omitempty"`
|
||||||
UnlockedBy *int64 `json:"unlockedBy,omitempty"`
|
UnlockedBy *int64 `json:"unlockedBy,omitempty"`
|
||||||
IsActive bool `json:"isActive"`
|
IsActive bool `json:"isActive"`
|
||||||
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"`
|
||||||
Phone string `json:"phone"`
|
Phone string `json:"phone"`
|
||||||
TelegramID *int64 `json:"-"`
|
TelegramID *int64 `json:"-"`
|
||||||
TelegramUsername string `json:"-"`
|
TelegramUsername string `json:"-"`
|
||||||
SecurityQuestion string `json:"securityQuestion,omitempty"`
|
SecurityQuestion string `json:"securityQuestion,omitempty"`
|
||||||
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 {
|
||||||
@@ -209,19 +264,21 @@ type Challenge struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ChatMessage struct {
|
type ChatMessage struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
UserID int64 `json:"userId"`
|
UserID int64 `json:"userId"`
|
||||||
MentorID int64 `json:"mentorId"`
|
MentorID int64 `json:"mentorId"`
|
||||||
SenderRole Role `json:"senderRole"`
|
SenderRole Role `json:"senderRole"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
ReadAt *time.Time `json:"readAt,omitempty"`
|
ReadAt *time.Time `json:"readAt,omitempty"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
ReplyTo *int64 `json:"replyTo,omitempty"`
|
ReplyTo *int64 `json:"replyTo,omitempty"`
|
||||||
PinnedAt *time.Time `json:"pinnedAt,omitempty"`
|
PinnedAt *time.Time `json:"pinnedAt,omitempty"`
|
||||||
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"`
|
||||||
Reactions []ChatReaction `json:"reactions,omitempty"`
|
Attachments []Attachment `json:"attachments,omitempty"`
|
||||||
|
Reactions []ChatReaction `json:"reactions,omitempty"`
|
||||||
|
Buttons json.RawMessage `json:"buttons,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChatReaction struct {
|
type ChatReaction struct {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+341
-42
@@ -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(¤t, &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 == "" {
|
||||||
@@ -1072,29 +1284,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 +1336,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 +1386,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 +1426,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 +1435,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 +1502,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 +1530,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 +1538,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 +1564,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 +1612,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 +1644,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 +1685,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),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.2
|
||||||
appVersion: "0.1.3"
|
appVersion: "0.2.0"
|
||||||
keywords:
|
keywords:
|
||||||
- lms
|
- lms
|
||||||
- pargar
|
- pargar
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ backend:
|
|||||||
replicaCount: 2
|
replicaCount: 2
|
||||||
image:
|
image:
|
||||||
repository: backend
|
repository: backend
|
||||||
tag: "0.1.3"
|
tag: "0.2.0"
|
||||||
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.0"
|
||||||
pullPolicy: IfNotPresent
|
pullPolicy: IfNotPresent
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } 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, Package } from "lucide-react";
|
||||||
import { CertificateCard } from "@/components/certificate-card";
|
import { CertificateCard } from "@/components/certificate-card";
|
||||||
|
import { NotFoundState, notFoundSentence } from "@/components/not-found-state";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { http, toUserError } from "@/lib/api";
|
import { http, toUserError } from "@/lib/api";
|
||||||
@@ -96,12 +96,16 @@ 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="بازگشت به خانه"
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -28,6 +28,7 @@ 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 (
|
||||||
@@ -150,6 +151,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 +160,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>
|
||||||
@@ -450,6 +453,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}>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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, "ورود ناموفق بود"));
|
||||||
setCaptchaKey((k) => k + 1);
|
// 429 is returned before the captcha is consumed, so keep this challenge.
|
||||||
|
if (!(err instanceof ApiError) || err.status !== 429) {
|
||||||
|
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"
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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("دانلود خروجی گفتگو ممکن نشد");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,17 +116,40 @@ 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="grid gap-4 lg:grid-cols-2">
|
||||||
@@ -156,7 +167,7 @@ function ChatsLauncher() {
|
|||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<Panel title="گفتگوهای شما" description="مکالمات جاری با منتورها — روی هر کدام بزنید تا باز شود.">
|
<Panel title="گفتگوهای شما" description="مکالمات جاری — روی هر کدام بزنید تا باز شود.">
|
||||||
<div className="space-y-1.5 pt-1">
|
<div className="space-y-1.5 pt-1">
|
||||||
{convLoading && (
|
{convLoading && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -183,9 +194,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,13 +221,38 @@ 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="یک گفتگو را مستقیم باز کنید.">
|
<Panel
|
||||||
|
title={staff ? "شروع گفتگو" : "شروع سریع با منتور"}
|
||||||
|
description="نقش هر نفر روی ردیف نوشته شده است."
|
||||||
|
actions={
|
||||||
|
<Button type="button" size="sm" variant="outline" onClick={() => setAlpha((v) => !v)}>
|
||||||
|
{alpha ? "آنلاین بالا" : "الفبایی"}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
<div className="space-y-1 pt-1">
|
<div className="space-y-1 pt-1">
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -229,24 +268,46 @@ 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>
|
||||||
@@ -256,19 +317,13 @@ function ChatsLauncher() {
|
|||||||
|
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,7 +25,6 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
Rocket,
|
Rocket,
|
||||||
Save,
|
Save,
|
||||||
ShieldCheck,
|
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Target,
|
Target,
|
||||||
Terminal,
|
Terminal,
|
||||||
@@ -82,13 +81,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 +96,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 +197,32 @@ export function AdminStudio() {
|
|||||||
})).filter((g) => g.items.length > 0);
|
})).filter((g) => g.items.length > 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div>
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<header className="mb-6 space-y-1">
|
||||||
<div>
|
<h1 className="text-2xl font-black tracking-tight">{isAdmin ? "مدیریت" : "منتور"}</h1>
|
||||||
<p className="section-kicker">{isAdmin ? "استودیو ادمین" : "استودیو منتور"}</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
<h1 className="mt-1.5 text-2xl font-bold tracking-tight sm:text-3xl">
|
{isAdmin
|
||||||
{isAdmin ? (
|
? "افراد، ثبتنام، محتوای دوره، ارتباط و تنظیمات سایت."
|
||||||
<>
|
: MENTOR_HINT}
|
||||||
پنل <span className="text-gradient-brand">مدیریت</span>
|
</p>
|
||||||
</>
|
</header>
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
پنل <span className="text-gradient-brand">منتور</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</h1>
|
|
||||||
<p className="mt-1.5 max-w-2xl text-sm leading-relaxed text-muted-foreground">
|
|
||||||
{isAdmin
|
|
||||||
? "افراد، ثبتنام، محتوای دوره، ارتباط و تنظیمات سایت — گروهبندیشده تا چیزی گم نشود."
|
|
||||||
: MENTOR_HINT}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<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 (
|
<SectionPanel
|
||||||
<button
|
id="admin-section"
|
||||||
key={item.id}
|
titleId="admin-section-title"
|
||||||
type="button"
|
title={navItem?.label ?? ""}
|
||||||
onClick={() => setTab(item.id)}
|
description={navItem?.description ?? ""}
|
||||||
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}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{tab === "stats" && isAdmin && <StatsPanel />}
|
{tab === "stats" && isAdmin && <StatsPanel />}
|
||||||
{tab === "learning" && <LearningPanel />}
|
{tab === "learning" && <LearningPanel />}
|
||||||
{tab === "users" && isAdmin && <UsersPanel />}
|
{tab === "users" && isAdmin && <UsersPanel />}
|
||||||
@@ -318,9 +238,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 +295,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 +418,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 +565,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 })}
|
||||||
value={form.username ?? ""}
|
onPhone={(value) => setForm({ ...form, phone: clampPhoneInput(value) })}
|
||||||
onChange={(e) => setForm({ ...form, username: e.target.value.toLowerCase() })}
|
phoneMaxLength={PHONE_INPUT_MAX_LEN}
|
||||||
dir="ltr"
|
/>
|
||||||
className="text-left"
|
<AccountUsernameSection
|
||||||
placeholder="مثلاً ahp"
|
mode="admin"
|
||||||
/>
|
idPrefix="admin-user"
|
||||||
</div>
|
value={form.username ?? ""}
|
||||||
<div className="space-y-1.5">
|
onChange={(value) => setForm({ ...form, username: value })}
|
||||||
<Label>ایمیل</Label>
|
/>
|
||||||
<Input type="email" value={form.email ?? ""} onChange={(e) => setForm({ ...form, email: e.target.value })} />
|
<section className="space-y-4 border-t border-border pt-6">
|
||||||
</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 +653,7 @@ export function UserDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={onClose}>انصراف</Button>
|
<Button variant="outline" onClick={onClose}>انصراف</Button>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -11,6 +11,15 @@ 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";
|
||||||
|
|
||||||
|
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 +34,7 @@ const PROTECTED_PREFIXES = [
|
|||||||
"/notifications",
|
"/notifications",
|
||||||
"/lockout",
|
"/lockout",
|
||||||
"/profile",
|
"/profile",
|
||||||
|
"/settings",
|
||||||
];
|
];
|
||||||
|
|
||||||
function needsAuth(p: string) {
|
function needsAuth(p: string) {
|
||||||
@@ -141,12 +151,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
|||||||
{allowChildren ? children : null}
|
{allowChildren ? children : null}
|
||||||
</main>
|
</main>
|
||||||
<footer className="border-t border-border/70 bg-muted/20 backdrop-blur">
|
<footer className="border-t border-border/70 bg-muted/20 backdrop-blur">
|
||||||
<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">
|
<SiteFooter />
|
||||||
<BrandMark size="sm" logoOnly />
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
بوتکمپ ۱۲ هفتهای · توسعهیافته توسط امیرحسین پناهیفر
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</footer>
|
</footer>
|
||||||
{user && <ChatWidget />}
|
{user && <ChatWidget />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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;
|
||||||
const box = boxRef.current;
|
let ro: ResizeObserver | null = null;
|
||||||
const size = Math.min(box?.clientWidth ?? 280, 320);
|
let frame = 0;
|
||||||
setC(size);
|
let tries = 0;
|
||||||
const minScale = size / Math.min(img.w, img.h);
|
const fit = () => {
|
||||||
setScale(minScale);
|
const box = boxRef.current;
|
||||||
setPan({ x: (size - img.w * minScale) / 2, y: (size - img.h * minScale) / 2 });
|
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);
|
||||||
|
if (fittedFor.current === img.el && Math.abs(fittedSize.current - size) < 2) return;
|
||||||
|
fittedFor.current = img.el;
|
||||||
|
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>
|
||||||
|
|||||||
@@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 SocialLink = { name: string; url: string };
|
||||||
|
|
||||||
export type CommunityPayload = {
|
export type CommunityPayload = {
|
||||||
donation: {
|
socials: SocialLink[];
|
||||||
enabled: boolean;
|
|
||||||
note: string;
|
|
||||||
mode: "chat";
|
|
||||||
contactId?: number;
|
|
||||||
draft: string;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function openDonationChat(contactId: number | undefined, draft: string) {
|
|
||||||
window.dispatchEvent(
|
|
||||||
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">
|
|
||||||
جزئیات واریز فقط در پیام خصوصی رد و بدل میشود؛ اینجا چیزی عمومی نیست.
|
|
||||||
</div>
|
|
||||||
{user ? (
|
|
||||||
<Button
|
|
||||||
className="w-full gap-2"
|
|
||||||
size="lg"
|
|
||||||
onClick={() => openDonationChat(donation.contactId, donation.draft)}
|
|
||||||
>
|
|
||||||
<MessageSquareHeart className="h-4 w-4" /> پیام دربارهٔ دونیت
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button asChild className="w-full gap-2" size="lg">
|
|
||||||
<Link href="/login">
|
|
||||||
<LogIn className="h-4 w-4" /> ورود برای هماهنگی دونیت
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">حمایت فعلاً غیرفعال است.</p>
|
<ul className="space-y-2 pt-1">
|
||||||
|
{socials.map((s) => (
|
||||||
|
<li key={s.url}>
|
||||||
|
<a
|
||||||
|
href={s.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<span>{s.name}</span>
|
||||||
|
<ExternalLink className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+2103
-316
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,67 +55,89 @@ 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="space-y-1.5">
|
||||||
<div key={i} className="flex items-start gap-3">
|
|
||||||
<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" />
|
||||||
|
<Skeleton className="h-3 w-1/3" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
{!isLoading && (!data?.notifications || data.notifications.length === 0) && (
|
||||||
{!isLoading && (!data?.notifications || data.notifications.length === 0) && (
|
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||||
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
|
اعلانی ندارید.
|
||||||
اعلانی ندارید.
|
<br />
|
||||||
<br />
|
<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" };
|
||||||
<button
|
return (
|
||||||
key={n.id}
|
<button
|
||||||
onClick={() => {
|
key={n.id}
|
||||||
if (n.route) router.push(n.route);
|
type="button"
|
||||||
}}
|
onClick={() => {
|
||||||
className={cn(
|
if (n.route) router.push(n.route);
|
||||||
"flex w-full items-start gap-3 border-b border-border px-3 py-3 text-right transition-colors hover:bg-muted/60",
|
}}
|
||||||
!n.readAt && "bg-primary/5"
|
className={cn(
|
||||||
)}
|
"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/[0.06]",
|
||||||
<span className={cn("mt-1.5 h-2 w-2 shrink-0 rounded-full", categoryDot[n.category] ?? "bg-primary")} />
|
)}
|
||||||
<span className="flex-1 space-y-0.5">
|
>
|
||||||
<span className="flex items-center justify-between gap-2">
|
<span className={cn("mt-1.5 h-2 w-2 shrink-0 rounded-full", n.readAt ? "bg-border" : cat.dot)} />
|
||||||
<span className="text-sm font-medium">{n.title}</span>
|
<span className="min-w-0 flex-1">
|
||||||
<span className="shrink-0 text-[10px] text-muted-foreground">
|
<span dir="auto" className="block truncate text-sm font-medium [unicode-bidi:plaintext]">
|
||||||
{formatDistanceToNow(new Date(n.createdAt), { addSuffix: true, locale: faIR })}
|
{n.title}
|
||||||
|
</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>
|
</span>
|
||||||
</span>
|
</button>
|
||||||
{n.body && <span className="block text-xs text-muted-foreground">{n.body}</span>}
|
);
|
||||||
</span>
|
})}
|
||||||
</button>
|
</div>
|
||||||
))}
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push("/notifications")}
|
||||||
|
className="w-full border-t border-border bg-muted/40 py-2.5 text-xs font-semibold text-primary hover:bg-muted"
|
||||||
|
>
|
||||||
|
مشاهده همه اعلانها
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
مشاهده همه اعلانها
|
|
||||||
</button>
|
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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, "ثبتنام ناموفق بود"));
|
||||||
setCaptchaKey((k) => k + 1);
|
if (!(err instanceof ApiError) || err.status !== 429) {
|
||||||
|
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 && (
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/** Renders a small markdown subset. Raw HTML is escaped by React text nodes. */
|
||||||
|
export function SafeMarkdown({
|
||||||
|
text,
|
||||||
|
className,
|
||||||
|
lines,
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
className?: string;
|
||||||
|
/** When set, only the first block is shown, clamped to this many lines. */
|
||||||
|
lines?: 2 | 3;
|
||||||
|
}) {
|
||||||
|
const blocks = text.replace(/\r\n/g, "\n").split(/\n{2,}/);
|
||||||
|
const shown = lines ? blocks.slice(0, 1) : blocks;
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{shown.map((block, i) => (
|
||||||
|
<p
|
||||||
|
key={i}
|
||||||
|
dir="auto"
|
||||||
|
className={cn(
|
||||||
|
"break-words [unicode-bidi:plaintext]",
|
||||||
|
lines === 2 && "line-clamp-2",
|
||||||
|
lines === 3 && "line-clamp-3",
|
||||||
|
!lines && "whitespace-pre-wrap",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{inline(block)}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inline(src: string): ReactNode[] {
|
||||||
|
const re = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)|\*\*([^*]+)\*\*|`([^`]+)`/g;
|
||||||
|
const nodes: ReactNode[] = [];
|
||||||
|
let last = 0;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
let k = 0;
|
||||||
|
while ((m = re.exec(src))) {
|
||||||
|
if (m.index > last) nodes.push(src.slice(last, m.index));
|
||||||
|
if (m[1] && m[2]) {
|
||||||
|
nodes.push(
|
||||||
|
<a key={k++} href={m[2]} target="_blank" rel="noreferrer" className="underline">
|
||||||
|
{m[1]}
|
||||||
|
</a>,
|
||||||
|
);
|
||||||
|
} else if (m[3]) {
|
||||||
|
nodes.push(<strong key={k++}>{m[3]}</strong>);
|
||||||
|
} else if (m[4]) {
|
||||||
|
nodes.push(
|
||||||
|
<code key={k++} className="rounded bg-muted px-1">
|
||||||
|
{m[4]}
|
||||||
|
</code>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
last = m.index + m[0].length;
|
||||||
|
}
|
||||||
|
if (last < src.length) nodes.push(src.slice(last));
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -19,6 +19,16 @@ export const AVATAR_VARIANTS: { name: AvatarVariantName; label: string }[] = [
|
|||||||
{ name: "bauhaus", label: "باهاوس" },
|
{ name: "bauhaus", label: "باهاوس" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Same palettes the avatar studio offers, reused for the wide profile banner. */
|
||||||
|
export const AVATAR_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 avatarPaletteOf(raw?: string): string[] {
|
export function avatarPaletteOf(raw?: string): string[] {
|
||||||
if (!raw || !raw.startsWith("[")) return AVATAR_COLORS;
|
if (!raw || !raw.startsWith("[")) return AVATAR_COLORS;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useId, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { BadgeCheck } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function isVerifiedRole(role?: string | null): boolean {
|
||||||
|
return role === "admin" || role === "mentor";
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleLabel(role?: string | null): string | null {
|
||||||
|
if (role === "admin") return "ادمین";
|
||||||
|
if (role === "mentor") return "منتور";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seal sized just under the surrounding name's cap height. Hover and focus show the role. */
|
||||||
|
export function VerifiedBadge({ role, className }: { role?: string | null; className?: string }) {
|
||||||
|
const label = roleLabel(role);
|
||||||
|
const tipId = useId();
|
||||||
|
const anchor = useRef<HTMLSpanElement>(null);
|
||||||
|
const hideTimer = useRef<number | null>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [focusable, setFocusable] = useState(false);
|
||||||
|
const [box, setBox] = useState<{ left: number; top: number; below: boolean } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = anchor.current;
|
||||||
|
if (!el) return;
|
||||||
|
setFocusable(!el.closest("button, a, [role='button']"));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const place = () => {
|
||||||
|
const el = anchor.current;
|
||||||
|
if (!el) return;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const below = rect.top < 36;
|
||||||
|
setBox({
|
||||||
|
left: rect.left + rect.width / 2,
|
||||||
|
top: below ? rect.bottom : rect.top,
|
||||||
|
below,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onMove = () => place();
|
||||||
|
window.addEventListener("scroll", onMove, true);
|
||||||
|
window.addEventListener("resize", onMove);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("scroll", onMove, true);
|
||||||
|
window.removeEventListener("resize", onMove);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (hideTimer.current != null) window.clearTimeout(hideTimer.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const show = (event?: { stopPropagation: () => void }) => {
|
||||||
|
event?.stopPropagation();
|
||||||
|
if (hideTimer.current != null) window.clearTimeout(hideTimer.current);
|
||||||
|
place();
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleHide = () => {
|
||||||
|
if (hideTimer.current != null) window.clearTimeout(hideTimer.current);
|
||||||
|
hideTimer.current = window.setTimeout(() => setOpen(false), 60);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!label) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
ref={anchor}
|
||||||
|
tabIndex={focusable ? 0 : undefined}
|
||||||
|
className="pointer-events-auto inline-flex shrink-0 cursor-default items-center rounded-sm leading-none outline-none focus-visible:ring-2 focus-visible:ring-sky-500/70"
|
||||||
|
onPointerEnter={show}
|
||||||
|
onPointerLeave={scheduleHide}
|
||||||
|
onFocus={() => show()}
|
||||||
|
onBlur={() => setOpen(false)}
|
||||||
|
aria-describedby={open ? tipId : undefined}
|
||||||
|
>
|
||||||
|
<BadgeCheck
|
||||||
|
aria-hidden={false}
|
||||||
|
role="img"
|
||||||
|
aria-label="تأییدشده"
|
||||||
|
className={cn("size-[0.85em] text-sky-500", className)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
{open && box
|
||||||
|
? createPortal(
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
left: box.left,
|
||||||
|
top: box.below ? box.top - 4 : box.top + 4,
|
||||||
|
transform: box.below ? "translate(-50%, 0)" : "translate(-50%, -100%)",
|
||||||
|
paddingTop: box.below ? 8 : 0,
|
||||||
|
paddingBottom: box.below ? 0 : 8,
|
||||||
|
}}
|
||||||
|
className="pointer-events-auto z-[200]"
|
||||||
|
onPointerEnter={show}
|
||||||
|
onPointerLeave={scheduleHide}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
id={tipId}
|
||||||
|
role="tooltip"
|
||||||
|
className="block whitespace-nowrap rounded-md border border-border bg-popover px-2 py-1 text-xs font-bold text-popover-foreground shadow-md"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</span>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,29 @@ import { useAuth } 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";
|
||||||
|
|
||||||
|
function sameLiveUser(a: User, b: User): boolean {
|
||||||
|
return (
|
||||||
|
a.id === b.id &&
|
||||||
|
a.name === b.name &&
|
||||||
|
a.email === b.email &&
|
||||||
|
a.role === b.role &&
|
||||||
|
a.xp === b.xp &&
|
||||||
|
a.hearts === b.hearts &&
|
||||||
|
a.streakCurrent === b.streakCurrent &&
|
||||||
|
a.streakLongest === b.streakLongest &&
|
||||||
|
a.isLocked === b.isLocked &&
|
||||||
|
a.isFrozen === b.isFrozen &&
|
||||||
|
a.isClosed === b.isClosed &&
|
||||||
|
a.frozenAt === b.frozenAt &&
|
||||||
|
a.closedAt === b.closedAt &&
|
||||||
|
a.closesAt === b.closesAt &&
|
||||||
|
a.isActive === b.isActive &&
|
||||||
|
a.avatarVariant === b.avatarVariant &&
|
||||||
|
a.avatarPalette === b.avatarPalette &&
|
||||||
|
a.avatarPhoto === b.avatarPhoto
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function useLiveUser(): User | null {
|
export function useLiveUser(): User | null {
|
||||||
const user = useAuth((s) => s.user);
|
const user = useAuth((s) => s.user);
|
||||||
const setUser = useAuth((s) => s.setUser);
|
const setUser = useAuth((s) => s.setUser);
|
||||||
@@ -19,26 +42,13 @@ export function useLiveUser(): User | null {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fresh = data?.user;
|
const fresh = data?.user;
|
||||||
if (!fresh || !user) return;
|
if (!fresh) return;
|
||||||
if (
|
const current = useAuth.getState().user;
|
||||||
fresh.id === user.id &&
|
if (current && sameLiveUser(fresh, current)) return;
|
||||||
fresh.name === user.name &&
|
|
||||||
fresh.email === user.email &&
|
|
||||||
fresh.role === user.role &&
|
|
||||||
fresh.xp === user.xp &&
|
|
||||||
fresh.hearts === user.hearts &&
|
|
||||||
fresh.streakCurrent === user.streakCurrent &&
|
|
||||||
fresh.streakLongest === user.streakLongest &&
|
|
||||||
fresh.isLocked === user.isLocked &&
|
|
||||||
fresh.isActive === user.isActive &&
|
|
||||||
fresh.avatarVariant === user.avatarVariant &&
|
|
||||||
fresh.avatarPalette === user.avatarPalette &&
|
|
||||||
fresh.avatarPhoto === user.avatarPhoto
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setUser(fresh);
|
setUser(fresh);
|
||||||
}, [data, user, setUser]);
|
}, [data, setUser]);
|
||||||
|
|
||||||
return data?.user ?? user;
|
// Prefer the auth store. A just-saved avatar must show before the ["me"]
|
||||||
|
// cache catches up; applying that stale cache used to put the old photo back.
|
||||||
|
return user ?? data?.user ?? null;
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useAuth } from "@/lib/auth-store";
|
import { useAuth } from "@/lib/auth-store";
|
||||||
import { getWsUrl } from "@/lib/api";
|
import { getWsUrl } from "@/lib/api";
|
||||||
@@ -24,6 +25,7 @@ export function useRealtimeGateway() {
|
|||||||
const accessToken = useAuth((s) => s.accessToken);
|
const accessToken = useAuth((s) => s.accessToken);
|
||||||
const user = useAuth((s) => s.user);
|
const user = useAuth((s) => s.user);
|
||||||
const client = useQueryClient();
|
const client = useQueryClient();
|
||||||
|
const router = useRouter();
|
||||||
const lastEvent = useRef<Record<string, number>>({});
|
const lastEvent = useRef<Record<string, number>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -55,7 +57,31 @@ export function useRealtimeGateway() {
|
|||||||
if (stamp - (lastEvent.current[dedupeKey] ?? 0) < 2000) return;
|
if (stamp - (lastEvent.current[dedupeKey] ?? 0) < 2000) return;
|
||||||
lastEvent.current[dedupeKey] = stamp;
|
lastEvent.current[dedupeKey] = stamp;
|
||||||
|
|
||||||
toast.info(`${n.title}${n.body ? ` — ${n.body}` : ""}`, { duration: 5000 });
|
const title = n.title?.trim() || "اعلان";
|
||||||
|
const body = n.body?.trim();
|
||||||
|
toast.custom(
|
||||||
|
(id) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
dir="rtl"
|
||||||
|
onClick={() => {
|
||||||
|
toast.dismiss(id);
|
||||||
|
if (n.route) router.push(n.route);
|
||||||
|
}}
|
||||||
|
className="w-[min(22rem,calc(100vw-1.5rem))] rounded-xl border border-primary/40 bg-gradient-to-br from-primary/25 via-primary/12 to-accent/20 px-3 py-2.5 text-start shadow-[0_16px_40px_-16px_color-mix(in_srgb,var(--color-primary)_75%,transparent)] backdrop-blur-md"
|
||||||
|
>
|
||||||
|
<span dir="auto" className="block truncate text-sm font-semibold text-foreground [unicode-bidi:plaintext]">
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
{body ? (
|
||||||
|
<span dir="auto" className="mt-0.5 block line-clamp-2 text-xs text-foreground/75 [unicode-bidi:plaintext]">
|
||||||
|
{body}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
{ duration: 5000 },
|
||||||
|
);
|
||||||
client.invalidateQueries({ queryKey: ["notifications"] });
|
client.invalidateQueries({ queryKey: ["notifications"] });
|
||||||
client.invalidateQueries({ queryKey: ["me"] });
|
client.invalidateQueries({ queryKey: ["me"] });
|
||||||
}
|
}
|
||||||
@@ -76,7 +102,7 @@ export function useRealtimeGateway() {
|
|||||||
closed = true;
|
closed = true;
|
||||||
ws?.close();
|
ws?.close();
|
||||||
};
|
};
|
||||||
}, [accessToken, user, client]);
|
}, [accessToken, user, client, router]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
/** Wide profile-header artwork. Palettes come from the avatar studio; these fills stretch them across a 3:1 banner. */
|
||||||
|
|
||||||
|
export const BANNER_WIDTH = 1200;
|
||||||
|
export const BANNER_HEIGHT = 400;
|
||||||
|
|
||||||
|
export type BannerFill = "gradient" | "stripes" | "dots" | "diamonds" | "pixel" | "waves";
|
||||||
|
|
||||||
|
export const BANNER_FILLS: { id: BannerFill; label: string }[] = [
|
||||||
|
{ id: "gradient", label: "گرادیان" },
|
||||||
|
{ id: "stripes", label: "راهراه" },
|
||||||
|
{ id: "dots", label: "نقطهای" },
|
||||||
|
{ id: "diamonds", label: "لوزی" },
|
||||||
|
{ id: "pixel", label: "پیکسلی" },
|
||||||
|
{ id: "waves", label: "موج" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function hash(seed: string): number {
|
||||||
|
let h = 2166136261;
|
||||||
|
for (let i = 0; i < seed.length; i++) {
|
||||||
|
h ^= seed.charCodeAt(i);
|
||||||
|
h = Math.imul(h, 16777619);
|
||||||
|
}
|
||||||
|
return h >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mulberry32(seed: number) {
|
||||||
|
let s = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
s = (s + 0x6d2b79f5) | 0;
|
||||||
|
let t = Math.imul(s ^ (s >>> 15), 1 | s);
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function palette(colors: string[]): string[] {
|
||||||
|
return colors.length >= 2 ? colors : ["#7d6df2", "#0f9b8e"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function drawBannerFill(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
w: number,
|
||||||
|
h: number,
|
||||||
|
colors: string[],
|
||||||
|
fill: BannerFill,
|
||||||
|
seedKey: string,
|
||||||
|
) {
|
||||||
|
const c = palette(colors);
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
|
||||||
|
if (fill === "gradient") {
|
||||||
|
const g = ctx.createLinearGradient(0, h, w, 0);
|
||||||
|
c.forEach((color, i) => g.addColorStop(i / (c.length - 1), color));
|
||||||
|
ctx.fillStyle = g;
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
const glow = ctx.createRadialGradient(w * 0.5, h * 0.42, h * 0.04, w * 0.5, h * 0.42, w * 0.46);
|
||||||
|
glow.addColorStop(0, "rgba(255,255,255,0.22)");
|
||||||
|
glow.addColorStop(1, "rgba(255,255,255,0)");
|
||||||
|
ctx.fillStyle = glow;
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillStyle = c[0];
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
|
||||||
|
if (fill === "stripes") {
|
||||||
|
const stripe = Math.max(14, w / 26);
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(w / 2, h / 2);
|
||||||
|
ctx.rotate(-Math.PI / 7);
|
||||||
|
const span = w + h;
|
||||||
|
let i = 0;
|
||||||
|
for (let x = -span; x < span; x += stripe) {
|
||||||
|
ctx.globalAlpha = 0.88;
|
||||||
|
ctx.fillStyle = c[i % c.length];
|
||||||
|
ctx.fillRect(x, -span, stripe * 0.58, span * 2);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fill === "dots") {
|
||||||
|
const gap = Math.max(22, w / 28);
|
||||||
|
const radius = gap * 0.22;
|
||||||
|
let row = 0;
|
||||||
|
for (let y = gap * 0.55; y < h + gap; y += gap * 0.86) {
|
||||||
|
const offset = row % 2 ? gap / 2 : 0;
|
||||||
|
let col = 0;
|
||||||
|
for (let x = offset; x < w + gap; x += gap) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.fillStyle = c[(row + col) % c.length];
|
||||||
|
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
col++;
|
||||||
|
}
|
||||||
|
row++;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fill === "diamonds") {
|
||||||
|
const gap = Math.max(28, w / 18);
|
||||||
|
const s = gap * 0.42;
|
||||||
|
let row = 0;
|
||||||
|
for (let y = gap * 0.2; y < h + gap; y += gap * 0.72) {
|
||||||
|
const offset = row % 2 ? gap / 2 : 0;
|
||||||
|
let col = 0;
|
||||||
|
for (let x = offset; x < w + gap; x += gap) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(x, y);
|
||||||
|
ctx.rotate(Math.PI / 4);
|
||||||
|
ctx.fillStyle = c[(row + col) % c.length];
|
||||||
|
ctx.fillRect(-s / 2, -s / 2, s, s);
|
||||||
|
ctx.restore();
|
||||||
|
col++;
|
||||||
|
}
|
||||||
|
row++;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fill === "pixel") {
|
||||||
|
const cols = 18;
|
||||||
|
const rows = 6;
|
||||||
|
const cw = w / cols;
|
||||||
|
const ch = h / rows;
|
||||||
|
const rand = mulberry32(hash(`${seedKey}:pixel`));
|
||||||
|
for (let y = 0; y < rows; y++) {
|
||||||
|
for (let x = 0; x < cols; x++) {
|
||||||
|
ctx.fillStyle = c[Math.floor(rand() * c.length)];
|
||||||
|
ctx.fillRect(Math.floor(x * cw), Math.floor(y * ch), Math.ceil(cw) + 1, Math.ceil(ch) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bands = c.length;
|
||||||
|
for (let b = 0; b < bands; b++) {
|
||||||
|
const amp = h * (0.055 + (b % 3) * 0.012);
|
||||||
|
const freq = 1.15 + (b % 4) * 0.35;
|
||||||
|
const base = h * (0.16 + (b / bands) * 0.72);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, h);
|
||||||
|
ctx.lineTo(0, base);
|
||||||
|
for (let x = 0; x <= w; x += 8) {
|
||||||
|
const y = base + Math.sin((x / w) * Math.PI * 2 * freq + b * 0.85) * amp;
|
||||||
|
ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
ctx.lineTo(w, h);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.globalAlpha = b === 0 ? 0.45 : 0.94;
|
||||||
|
ctx.fillStyle = c[b];
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import type { Role, User } from "./types";
|
import type { Role, User } from "./types";
|
||||||
|
|
||||||
/** Hard cap for students — no timed refill. */
|
/** Absolute student cap. A heart returns on the server every few hours. */
|
||||||
export const MAX_HEARTS = 3;
|
export const MAX_HEARTS = 5;
|
||||||
|
|
||||||
/** Infinity glyph for staff heart display. */
|
/** Infinity glyph for staff heart display. */
|
||||||
export const INFINITE_HEARTS_LABEL = "∞";
|
export const INFINITE_HEARTS_LABEL = "∞";
|
||||||
|
|||||||
@@ -169,7 +169,111 @@ export async function saveRemoteFile(
|
|||||||
triggerDownload(blob, filename);
|
triggerDownload(blob, filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function crc32(data: Uint8Array): number {
|
||||||
|
let c = 0xffffffff;
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
c ^= data[i];
|
||||||
|
for (let k = 0; k < 8; k++) c = (c >>> 1) ^ (0xedb88320 & -(c & 1));
|
||||||
|
}
|
||||||
|
return (c ^ 0xffffffff) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueZipName(name: string, used: Map<string, number>): string {
|
||||||
|
const base = (name || "file").split(/[/\\]/).pop() || "file";
|
||||||
|
const clean = base.replace(/[^\w.\-\u0600-\u06FF ]+/g, "_").slice(0, 80) || "file";
|
||||||
|
const n = used.get(clean) ?? 0;
|
||||||
|
used.set(clean, n + 1);
|
||||||
|
if (n === 0) return clean;
|
||||||
|
const dot = clean.lastIndexOf(".");
|
||||||
|
if (dot > 0) return `${clean.slice(0, dot)}-${n + 1}${clean.slice(dot)}`;
|
||||||
|
return `${clean}-${n + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Uncompressed zip so several chat files save as one download. */
|
||||||
|
function buildStoredZip(files: { name: string; data: Uint8Array }[]): Blob {
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
const now = new Date();
|
||||||
|
const dosTime = (now.getHours() << 11) | (now.getMinutes() << 5) | (now.getSeconds() >> 1);
|
||||||
|
const dosDate = ((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate();
|
||||||
|
const locals: Uint8Array[] = [];
|
||||||
|
const central: Uint8Array[] = [];
|
||||||
|
let offset = 0;
|
||||||
|
for (const file of files) {
|
||||||
|
const name = enc.encode(file.name);
|
||||||
|
const crc = crc32(file.data);
|
||||||
|
const local = new Uint8Array(30 + name.length);
|
||||||
|
const view = new DataView(local.buffer);
|
||||||
|
view.setUint32(0, 0x04034b50, true);
|
||||||
|
view.setUint16(4, 20, true);
|
||||||
|
view.setUint16(6, 0x800, true);
|
||||||
|
view.setUint16(8, 0, true);
|
||||||
|
view.setUint16(10, dosTime, true);
|
||||||
|
view.setUint16(12, dosDate, true);
|
||||||
|
view.setUint32(14, crc, true);
|
||||||
|
view.setUint32(18, file.data.length, true);
|
||||||
|
view.setUint32(22, file.data.length, true);
|
||||||
|
view.setUint16(26, name.length, true);
|
||||||
|
local.set(name, 30);
|
||||||
|
locals.push(local, file.data);
|
||||||
|
const cen = new Uint8Array(46 + name.length);
|
||||||
|
const cenView = new DataView(cen.buffer);
|
||||||
|
cenView.setUint32(0, 0x02014b50, true);
|
||||||
|
cenView.setUint16(4, 20, true);
|
||||||
|
cenView.setUint16(6, 20, true);
|
||||||
|
cenView.setUint16(8, 0x800, true);
|
||||||
|
cenView.setUint16(10, 0, true);
|
||||||
|
cenView.setUint16(12, dosTime, true);
|
||||||
|
cenView.setUint16(14, dosDate, true);
|
||||||
|
cenView.setUint32(16, crc, true);
|
||||||
|
cenView.setUint32(20, file.data.length, true);
|
||||||
|
cenView.setUint32(24, file.data.length, true);
|
||||||
|
cenView.setUint16(28, name.length, true);
|
||||||
|
cenView.setUint32(42, offset, true);
|
||||||
|
cen.set(name, 46);
|
||||||
|
central.push(cen);
|
||||||
|
offset += local.length + file.data.length;
|
||||||
|
}
|
||||||
|
const centralSize = central.reduce((sum, part) => sum + part.length, 0);
|
||||||
|
const end = new Uint8Array(22);
|
||||||
|
const endView = new DataView(end.buffer);
|
||||||
|
endView.setUint32(0, 0x06054b50, true);
|
||||||
|
endView.setUint16(8, files.length, true);
|
||||||
|
endView.setUint16(10, files.length, true);
|
||||||
|
endView.setUint32(12, centralSize, true);
|
||||||
|
endView.setUint32(16, offset, true);
|
||||||
|
const parts = [...locals, ...central, end].map((part) => {
|
||||||
|
const copy = new ArrayBuffer(part.byteLength);
|
||||||
|
new Uint8Array(copy).set(part);
|
||||||
|
return copy;
|
||||||
|
});
|
||||||
|
return new Blob(parts, { type: "application/zip" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save every file in one zip. */
|
||||||
|
export async function downloadAllAsZip(
|
||||||
|
items: { url: string; name: string }[],
|
||||||
|
archiveName: string,
|
||||||
|
onProgress?: (ratio: number) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
const used = new Map<string, number>();
|
||||||
|
const files: { name: string; data: Uint8Array }[] = [];
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
onProgress?.(i / Math.max(items.length, 1));
|
||||||
|
const hit = await readCachedMedia(items[i].url);
|
||||||
|
const blob = hit ?? (await fetchBlob(items[i].url));
|
||||||
|
if (!hit) await writeCachedMedia(items[i].url, blob);
|
||||||
|
files.push({ name: uniqueZipName(items[i].name, used), data: new Uint8Array(await blob.arrayBuffer()) });
|
||||||
|
}
|
||||||
|
onProgress?.(1);
|
||||||
|
triggerDownload(buildStoredZip(files), archiveName || "files.zip");
|
||||||
|
}
|
||||||
|
|
||||||
/** One network fetch per object. Later views reuse the saved bytes until the object is replaced. */
|
/** One network fetch per object. Later views reuse the saved bytes until the object is replaced. */
|
||||||
|
export async function clearMediaCache(): Promise<void> {
|
||||||
|
if (typeof caches === "undefined") return;
|
||||||
|
await caches.delete(mediaCacheName);
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadCachedMedia(url: string): Promise<Blob> {
|
export async function loadCachedMedia(url: string): Promise<Blob> {
|
||||||
const hit = await readCachedMedia(url);
|
const hit = await readCachedMedia(url);
|
||||||
if (hit) return hit;
|
if (hit) return hit;
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ export interface User {
|
|||||||
securityQuestion?: string;
|
securityQuestion?: string;
|
||||||
hasSecurityAnswer?: boolean;
|
hasSecurityAnswer?: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
usernameChangesRemaining?: number;
|
||||||
|
usernameCooldownUntil?: string;
|
||||||
|
frozenAt?: string;
|
||||||
|
closedAt?: string;
|
||||||
|
closesAt?: string;
|
||||||
|
isFrozen?: boolean;
|
||||||
|
isClosed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Certificate {
|
export interface Certificate {
|
||||||
@@ -195,7 +202,9 @@ export interface ChatMessage {
|
|||||||
pinned?: boolean;
|
pinned?: boolean;
|
||||||
editedAt?: string;
|
editedAt?: string;
|
||||||
attachment?: ChatAttachment;
|
attachment?: ChatAttachment;
|
||||||
|
attachments?: ChatAttachment[];
|
||||||
reactions?: ChatReaction[];
|
reactions?: ChatReaction[];
|
||||||
|
buttons?: { text: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatReaction {
|
export interface ChatReaction {
|
||||||
@@ -210,6 +219,7 @@ export interface Mentor {
|
|||||||
email: string;
|
email: string;
|
||||||
role: Role;
|
role: Role;
|
||||||
online?: boolean;
|
online?: boolean;
|
||||||
|
lastSeen?: string;
|
||||||
avatarVariant?: "marble" | "beam" | "pixel" | "sunset" | "ring" | "bauhaus";
|
avatarVariant?: "marble" | "beam" | "pixel" | "sunset" | "ring" | "bauhaus";
|
||||||
avatarPalette?: string;
|
avatarPalette?: string;
|
||||||
avatarPhoto?: string;
|
avatarPhoto?: string;
|
||||||
@@ -222,12 +232,18 @@ export interface Conversation {
|
|||||||
email: string;
|
email: string;
|
||||||
role: Role;
|
role: Role;
|
||||||
online?: boolean;
|
online?: boolean;
|
||||||
|
lastSeen?: string;
|
||||||
|
saved?: boolean;
|
||||||
avatarVariant?: "marble" | "beam" | "pixel" | "sunset" | "ring" | "bauhaus";
|
avatarVariant?: "marble" | "beam" | "pixel" | "sunset" | "ring" | "bauhaus";
|
||||||
avatarPalette?: string;
|
avatarPalette?: string;
|
||||||
avatarPhoto?: string;
|
avatarPhoto?: string;
|
||||||
};
|
};
|
||||||
lastMessage?: ChatMessage;
|
lastMessage?: ChatMessage;
|
||||||
unreadCount: number;
|
unreadCount: number;
|
||||||
|
messageCount?: number;
|
||||||
|
pinnedRank: number | null;
|
||||||
|
muted: boolean;
|
||||||
|
archived: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LeaderboardEntry {
|
export interface LeaderboardEntry {
|
||||||
@@ -268,16 +284,23 @@ export interface MCQQuestion {
|
|||||||
export interface PublicProfile {
|
export interface PublicProfile {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
username: string;
|
username?: string;
|
||||||
role: Role;
|
role: Role;
|
||||||
xp: number;
|
xp?: number;
|
||||||
hearts: number;
|
hearts?: number;
|
||||||
streakCurrent: number;
|
streakCurrent?: number;
|
||||||
streakLongest: number;
|
streakLongest?: number;
|
||||||
avatarVariant?: User["avatarVariant"];
|
avatarVariant?: User["avatarVariant"];
|
||||||
avatarPalette?: string;
|
avatarPalette?: string;
|
||||||
avatarPhoto?: string;
|
avatarPhoto?: string;
|
||||||
isLocked: boolean;
|
isLocked?: boolean;
|
||||||
|
isFrozen?: boolean;
|
||||||
|
isClosed?: boolean;
|
||||||
|
private?: boolean;
|
||||||
|
public?: boolean;
|
||||||
|
banner?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
socialLinks?: { name: string; url: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminStats {
|
export interface AdminStats {
|
||||||
|
|||||||
@@ -22,6 +22,17 @@ export function formatStreakLabel(days: number) {
|
|||||||
/** Quiz option letters: الف، ب، ج، د، … */
|
/** Quiz option letters: الف، ب، ج، د، … */
|
||||||
const FA_OPTION_LETTERS = ["الف", "ب", "ج", "د", "ه", "و"] as const;
|
const FA_OPTION_LETTERS = ["الف", "ب", "ج", "د", "ه", "و"] as const;
|
||||||
|
|
||||||
|
/** Jalali day plus clock, for conversation rows. */
|
||||||
|
export function formatJalaliStamp(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const day = new Intl.DateTimeFormat("fa-IR", { day: "numeric", month: "short" }).format(d);
|
||||||
|
const time = new Intl.DateTimeFormat("fa-IR", { hour: "2-digit", minute: "2-digit" }).format(d);
|
||||||
|
return `${day} · ${time}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function formatOptionLetter(index: number): string {
|
export function formatOptionLetter(index: number): string {
|
||||||
if (index >= 0 && index < FA_OPTION_LETTERS.length) {
|
if (index >= 0 && index < FA_OPTION_LETTERS.length) {
|
||||||
return FA_OPTION_LETTERS[index];
|
return FA_OPTION_LETTERS[index];
|
||||||
|
|||||||
Reference in New Issue
Block a user