Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
924cb52a38
|
||
|
|
3c5a32394d
|
||
|
|
f6ce2bd340
|
||
|
|
96b10c9148
|
||
|
|
7716c1677c
|
||
|
|
161610049c
|
||
|
|
617d8ff680
|
||
|
|
88c86ac810
|
||
|
|
f59ff54256
|
||
|
|
12ecbf991e | ||
|
|
951610cda2 | ||
|
|
943897044a | ||
|
|
53c8c8e94c | ||
|
|
c4c9f37bbc |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 253 KiB |
|
After Width: | Height: | Size: 842 B |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||||
|
<title>Compass master</title>
|
||||||
|
<image width="1024" height="1024" href="png/1024x1024.png" xlink:href="png/1024x1024.png"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 267 B |
@@ -0,0 +1,50 @@
|
|||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestPendingCertDoesNotShortCircuitCurriculumCheck ensures that a pending
|
||||||
|
// certificate does not satisfy HasCompletedCurriculum. Only an issued
|
||||||
|
// (non-revoked) certificate should bypass the lesson-completion check.
|
||||||
|
func TestPendingCertDoesNotShortCircuitCurriculumCheck(t *testing.T) {
|
||||||
|
e := setup(t)
|
||||||
|
token, uid := register(t, e, "CertPending", "cert-pending@test.dev")
|
||||||
|
admin := login(t, e, bootstrapAdminEmail)
|
||||||
|
|
||||||
|
// Forcibly create a pending certificate in the DB (simulating a student
|
||||||
|
// who triggered issueCertificateIfEligible via quiz submit).
|
||||||
|
_, err := e.pool.Exec(context.Background(),
|
||||||
|
`INSERT INTO certificates (user_id, full_name, status)
|
||||||
|
VALUES ($1, 'CertPending', 'pending')
|
||||||
|
ON CONFLICT DO NOTHING`, uid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("insert pending cert: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The student has NOT passed any lessons — eligible must be false.
|
||||||
|
code, body := e.do(t, "GET", "/api/me/certificate", token, nil)
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("get cert: %d %v", code, body)
|
||||||
|
}
|
||||||
|
if body["eligible"] != false {
|
||||||
|
t.Fatalf("pending cert must not make student eligible; got eligible=%v", body["eligible"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin issues the certificate manually.
|
||||||
|
code, _ = e.do(t, "POST", "/api/admin/users/"+itoa(uid)+"/certificate/issue", admin, nil)
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("admin issue: %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Physical order endpoint must return 410 Gone.
|
||||||
|
code, _ = e.do(t, "POST", "/api/me/certificate/physical", token, map[string]any{
|
||||||
|
"recipientName": "Test", "phone": "09123456789", "address": "addr",
|
||||||
|
"city": "tehran", "postalCode": "1234567890",
|
||||||
|
})
|
||||||
|
if code != http.StatusGone {
|
||||||
|
t.Fatalf("physical endpoint must be disabled (410), got %d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) handleCommunity(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleCommunity(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"socials": s.loadSocials(r.Context()),
|
"socials": s.loadSocials(ctx),
|
||||||
|
"contributors": s.loadContributors(ctx),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,10 +95,16 @@ func (s *Server) handleWeeklyLeaderboard(w http.ResponseWriter, r *http.Request)
|
|||||||
return scoredList[i].userID < scoredList[j].userID
|
return scoredList[i].userID < scoredList[j].userID
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ids := make([]int64, len(scoredList))
|
||||||
|
for i, sc := range scoredList {
|
||||||
|
ids[i] = sc.userID
|
||||||
|
}
|
||||||
|
usersMap, _ := s.store.GetUsersByIDs(context.Background(), ids)
|
||||||
|
|
||||||
entries := make([]lbEntry, 0, len(scoredList))
|
entries := make([]lbEntry, 0, len(scoredList))
|
||||||
for i, sc := range scoredList {
|
for i, sc := range scoredList {
|
||||||
usr, err := s.store.GetUserByID(context.Background(), sc.userID)
|
usr, ok := usersMap[sc.userID]
|
||||||
if err != nil {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
s.signUserMedia(usr)
|
s.signUserMedia(usr)
|
||||||
|
|||||||
@@ -504,69 +504,6 @@ func (s *Server) handleMeActivity(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{"days": days, "weekDays": week, "monthDays": 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) {
|
func (s *Server) handleMeta(w http.ResponseWriter, r *http.Request) {
|
||||||
env := "production"
|
env := "production"
|
||||||
|
|||||||
@@ -282,8 +282,11 @@ func (s *Server) perUserRateLimit(limit int64, scope string, next http.HandlerFu
|
|||||||
// allowWSHandshake enforces a per-IP budget on WebSocket upgrades.
|
// allowWSHandshake enforces a per-IP budget on WebSocket upgrades.
|
||||||
// Returns false when the caller should receive 429 (already written by caller).
|
// Returns false when the caller should receive 429 (already written by caller).
|
||||||
func (s *Server) allowWSHandshake(r *http.Request) bool {
|
func (s *Server) allowWSHandshake(r *http.Request) bool {
|
||||||
|
if s.cfg.DevMode {
|
||||||
|
return true
|
||||||
|
}
|
||||||
if s.redis == nil {
|
if s.redis == nil {
|
||||||
return s.cfg.DevMode
|
return false
|
||||||
}
|
}
|
||||||
ip := clientAddress(r)
|
ip := clientAddress(r)
|
||||||
if ip == "" {
|
if ip == "" {
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ func newRouter(s *Server) http.Handler {
|
|||||||
mux.HandleFunc("POST /api/admin/users/{id}/certificate/revoke", staff(s.handleAdminRevokeCertificate))
|
mux.HandleFunc("POST /api/admin/users/{id}/certificate/revoke", staff(s.handleAdminRevokeCertificate))
|
||||||
mux.HandleFunc("GET /api/admin/settings", admin(s.handleAdminGetSettings))
|
mux.HandleFunc("GET /api/admin/settings", admin(s.handleAdminGetSettings))
|
||||||
mux.HandleFunc("PUT /api/admin/settings", admin(s.handleAdminPutSettings))
|
mux.HandleFunc("PUT /api/admin/settings", admin(s.handleAdminPutSettings))
|
||||||
|
mux.HandleFunc("POST /api/admin/contributor-avatar", admin(s.handleAdminContributorAvatarUpload))
|
||||||
mux.HandleFunc("GET /api/admin/chat-export", admin(s.handleAdminChatExport))
|
mux.HandleFunc("GET /api/admin/chat-export", admin(s.handleAdminChatExport))
|
||||||
mux.HandleFunc("GET /api/admin/chat-export/stats", admin(s.handleAdminChatExportStats))
|
mux.HandleFunc("GET /api/admin/chat-export/stats", admin(s.handleAdminChatExportStats))
|
||||||
mux.HandleFunc("POST /api/admin/chat-export/restore", admin(s.handleAdminChatExportRestore))
|
mux.HandleFunc("POST /api/admin/chat-export/restore", admin(s.handleAdminChatExportRestore))
|
||||||
@@ -181,7 +182,6 @@ func newRouter(s *Server) http.Handler {
|
|||||||
mux.HandleFunc("GET /api/me/achievements", s.requireAuth(s.handleMeAchievements))
|
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/activity", s.requireAuth(s.handleMeActivity))
|
||||||
mux.HandleFunc("GET /api/me/export", s.requireAuth(s.handleMeExport))
|
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("GET /api/meta", s.handleMeta)
|
||||||
mux.HandleFunc("POST /api/bot/send", s.handleBotSend)
|
mux.HandleFunc("POST /api/bot/send", s.handleBotSend)
|
||||||
mux.HandleFunc("GET /api/bot/updates", s.handleBotUpdates)
|
mux.HandleFunc("GET /api/bot/updates", s.handleBotUpdates)
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ package api
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"pargar/backend/internal/observability"
|
"pargar/backend/internal/observability"
|
||||||
@@ -93,12 +95,138 @@ func validateSocialLinks(v string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) loadContributors(ctx context.Context) []map[string]any {
|
||||||
|
raw, err := s.store.GetSetting(ctx, "contributors")
|
||||||
|
if err != nil || strings.TrimSpace(raw) == "" {
|
||||||
|
return []map[string]any{}
|
||||||
|
}
|
||||||
|
var list []map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &list); err != nil {
|
||||||
|
return []map[string]any{}
|
||||||
|
}
|
||||||
|
out := make([]map[string]any, 0, len(list))
|
||||||
|
for _, item := range list {
|
||||||
|
name, _ := item["name"].(string)
|
||||||
|
if strings.TrimSpace(name) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
role, _ := item["role"].(string)
|
||||||
|
bio, _ := item["bio"].(string)
|
||||||
|
url, _ := item["url"].(string)
|
||||||
|
avatar, _ := item["avatar"].(string)
|
||||||
|
contributions, _ := item["contributions"].([]any)
|
||||||
|
cats := make([]string, 0, len(contributions))
|
||||||
|
for _, c := range contributions {
|
||||||
|
if s, ok := c.(string); ok && s != "" {
|
||||||
|
cats = append(cats, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, map[string]any{
|
||||||
|
"name": strings.TrimSpace(name),
|
||||||
|
"role": strings.TrimSpace(role),
|
||||||
|
"bio": strings.TrimSpace(bio),
|
||||||
|
"url": strings.TrimSpace(url),
|
||||||
|
"avatar": strings.TrimSpace(avatar),
|
||||||
|
"contributions": cats,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateContributors(v string) string {
|
||||||
|
if v == "" || v == "[]" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var tmp []map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(v), &tmp); err != nil {
|
||||||
|
return "فرمت مشارکتکنندگان نامعتبر است"
|
||||||
|
}
|
||||||
|
if len(tmp) > 50 {
|
||||||
|
return "حداکثر ۵۰ مشارکتکننده مجاز است"
|
||||||
|
}
|
||||||
|
for _, item := range tmp {
|
||||||
|
name, _ := item["name"].(string)
|
||||||
|
if strings.TrimSpace(name) == "" {
|
||||||
|
return "نام مشارکتکننده الزامی است"
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(strings.TrimSpace(name)) > 100 {
|
||||||
|
return "نام مشارکتکننده خیلی طولانی است"
|
||||||
|
}
|
||||||
|
if role, _ := item["role"].(string); utf8.RuneCountInString(strings.TrimSpace(role)) > 100 {
|
||||||
|
return "نقش مشارکتکننده خیلی طولانی است"
|
||||||
|
}
|
||||||
|
if bio, _ := item["bio"].(string); utf8.RuneCountInString(strings.TrimSpace(bio)) > 300 {
|
||||||
|
return "بیوگرافی خیلی طولانی است"
|
||||||
|
}
|
||||||
|
if url, _ := item["url"].(string); url != "" {
|
||||||
|
u := strings.TrimSpace(url)
|
||||||
|
if !strings.HasPrefix(u, "https://") && !strings.HasPrefix(u, "http://") {
|
||||||
|
return "لینک پروفایل باید با http شروع شود"
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(u) > 500 {
|
||||||
|
return "لینک پروفایل خیلی طولانی است"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if contributions, ok := item["contributions"].([]any); ok {
|
||||||
|
if len(contributions) > 20 {
|
||||||
|
return "حداکثر ۲۰ دسته مشارکت برای هر نفر مجاز است"
|
||||||
|
}
|
||||||
|
for _, c := range contributions {
|
||||||
|
s, ok := c.(string)
|
||||||
|
if !ok {
|
||||||
|
return "دسته مشارکت باید رشته باشد"
|
||||||
|
}
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return "دسته مشارکت نمیتواند خالی باشد"
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(s) > 50 {
|
||||||
|
return "دسته مشارکت خیلی طولانی است"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAdminContributorAvatarUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
const maxSize = 6 << 20
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxSize+1<<20)
|
||||||
|
if err := r.ParseMultipartForm(maxSize + 1<<20); err != nil {
|
||||||
|
writeErr(w, http.StatusRequestEntityTooLarge, "حجم فایل بیش از حد مجاز است")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, h, err := r.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "فایل ارسال نشده است")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
if h.Size > maxSize {
|
||||||
|
writeErr(w, http.StatusRequestEntityTooLarge, "حجم فایل باید کمتر از ۶ مگابایت باشد")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
accepted, err := acceptAvatarImage(h, file)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := fmt.Sprintf("contributor-avatars/%d%s", time.Now().UnixNano(), accepted.Ext)
|
||||||
|
rawURL, err := s.storage.Save(key, file, h.Size)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, "ذخیرهٔ فایل ممکن نشد")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]any{"url": s.signExistingMediaURL(rawURL)})
|
||||||
|
}
|
||||||
|
|
||||||
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{
|
||||||
"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",
|
"social_links",
|
||||||
|
"contributors",
|
||||||
"hearts_max",
|
"hearts_max",
|
||||||
"hearts_regen_minutes",
|
"hearts_regen_minutes",
|
||||||
"hearts_lock_on_empty",
|
"hearts_lock_on_empty",
|
||||||
@@ -163,6 +291,7 @@ func (s *Server) handleAdminPutSettings(w http.ResponseWriter, r *http.Request)
|
|||||||
"registration_enabled": true,
|
"registration_enabled": true,
|
||||||
"registration_require_whitelist": true,
|
"registration_require_whitelist": true,
|
||||||
"social_links": true,
|
"social_links": true,
|
||||||
|
"contributors": true,
|
||||||
"hearts_max": true,
|
"hearts_max": true,
|
||||||
"hearts_regen_minutes": true,
|
"hearts_regen_minutes": true,
|
||||||
"hearts_lock_on_empty": true,
|
"hearts_lock_on_empty": true,
|
||||||
@@ -226,6 +355,14 @@ func (s *Server) handleAdminPutSettings(w http.ResponseWriter, r *http.Request)
|
|||||||
writeErr(w, http.StatusBadRequest, msg)
|
writeErr(w, http.StatusBadRequest, msg)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
case "contributors":
|
||||||
|
if v == "" {
|
||||||
|
v = "[]"
|
||||||
|
}
|
||||||
|
if msg := validateContributors(v); msg != "" {
|
||||||
|
writeErr(w, http.StatusBadRequest, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
case "hearts_max":
|
case "hearts_max":
|
||||||
n, err := strconv.Atoi(v)
|
n, err := strconv.Atoi(v)
|
||||||
if err != nil || n < 1 || n > store.MaxHearts {
|
if err != nil || n < 1 || n > store.MaxHearts {
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Add contributors setting key. Seed one verified entry (sole git author).
|
||||||
|
-- Admins can update via the site settings panel.
|
||||||
|
INSERT INTO app_settings (key, value) VALUES (
|
||||||
|
'contributors',
|
||||||
|
'[{"name":"Amir Husayn Panahifar","role":"بنیانگذار و توسعهدهنده","bio":"","url":"","avatar":"","contributions":["code"]}]'
|
||||||
|
) ON CONFLICT (key) DO NOTHING;
|
||||||
@@ -148,6 +148,27 @@ func (s *Store) GetUserByID(ctx context.Context, id int64) (*models.User, error)
|
|||||||
return u, err
|
return u, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetUsersByIDs fetches multiple users in a single query. Missing IDs are silently skipped.
|
||||||
|
func (s *Store) GetUsersByIDs(ctx context.Context, ids []int64) (map[int64]*models.User, error) {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return map[int64]*models.User{}, nil
|
||||||
|
}
|
||||||
|
rows, err := s.pool.Query(ctx, `SELECT `+userCols+` FROM users WHERE id = ANY($1)`, ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := make(map[int64]*models.User, len(ids))
|
||||||
|
for rows.Next() {
|
||||||
|
u, err := scanUser(rows)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[u.ID] = u
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) UpdateUserStats(ctx context.Context, userID int64, xp int, streak int, streakLongest int, lastActivity *time.Time) error {
|
func (s *Store) UpdateUserStats(ctx context.Context, userID int64, xp int, streak int, streakLongest int, lastActivity *time.Time) error {
|
||||||
_, err := s.pool.Exec(ctx, `
|
_, err := s.pool.Exec(ctx, `
|
||||||
UPDATE users SET xp=$1, streak_current=$2, streak_longest=$3, last_activity_date=$4
|
UPDATE users SET xp=$1, streak_current=$2, streak_longest=$3, last_activity_date=$4
|
||||||
@@ -634,7 +655,8 @@ func (s *Store) CountPassedActiveLessons(ctx context.Context, userID int64) (int
|
|||||||
func (s *Store) HasCompletedCurriculum(ctx context.Context, userID int64) (bool, error) {
|
func (s *Store) HasCompletedCurriculum(ctx context.Context, userID int64) (bool, error) {
|
||||||
// An issued (non-revoked) certificate is permanent proof of completion regardless
|
// An issued (non-revoked) certificate is permanent proof of completion regardless
|
||||||
// of subsequent curriculum changes (e.g. new lessons added after the student finished).
|
// of subsequent curriculum changes (e.g. new lessons added after the student finished).
|
||||||
if cert, err := s.GetCertificateByUser(ctx, userID); err == nil && cert.RevokedAt == nil {
|
// Pending certs do NOT count — they haven't been approved yet.
|
||||||
|
if cert, err := s.GetCertificateByUser(ctx, userID); err == nil && cert.RevokedAt == nil && cert.Status == "issued" {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
active, err := s.CountActiveLessons(ctx)
|
active, err := s.CountActiveLessons(ctx)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DEV_MODE: ${DEV_MODE:-false}
|
DEV_MODE: ${DEV_MODE:-false}
|
||||||
SEED_CURRICULUM: ${SEED_CURRICULUM:-false}
|
SEED_CURRICULUM: ${SEED_CURRICULUM:-false}
|
||||||
|
CAPTCHA_EXPOSE_ANSWER: ${CAPTCHA_EXPOSE_ANSWER:-false}
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
@@ -66,6 +67,8 @@ services:
|
|||||||
# Allow slow, large uploads (admin video ≤ 200MB); default is 60s.
|
# Allow slow, large uploads (admin video ≤ 200MB); default is 60s.
|
||||||
- --entrypoints.web.transport.respondingTimeouts.readTimeout=3600
|
- --entrypoints.web.transport.respondingTimeouts.readTimeout=3600
|
||||||
- --entrypoints.websecure.transport.respondingTimeouts.readTimeout=3600
|
- --entrypoints.websecure.transport.respondingTimeouts.readTimeout=3600
|
||||||
|
# Disable HTTP/2 so WebSocket upgrades work from Chromium (RFC 8441 not enabled).
|
||||||
|
- --entrypoints.websecure.http2.maxConcurrentStreams=0
|
||||||
# Dev: Traefik default certificate (browser warning expected on localhost).
|
# Dev: Traefik default certificate (browser warning expected on localhost).
|
||||||
- --api.dashboard=true
|
- --api.dashboard=true
|
||||||
- --api.insecure=true
|
- --api.insecure=true
|
||||||
|
|||||||
@@ -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.3
|
version: 0.2.4
|
||||||
appVersion: "0.2.1"
|
appVersion: "0.4.0"
|
||||||
keywords:
|
keywords:
|
||||||
- lms
|
- lms
|
||||||
- pargar
|
- pargar
|
||||||
|
|||||||
@@ -8,7 +8,10 @@
|
|||||||
{{- else if and .Values.postgres.auth.password (not (hasPrefix "change-me" .Values.postgres.auth.password)) (ne .Values.postgres.auth.password "") }}
|
{{- else if and .Values.postgres.auth.password (not (hasPrefix "change-me" .Values.postgres.auth.password)) (ne .Values.postgres.auth.password "") }}
|
||||||
{{- $pgPass = .Values.postgres.auth.password }}
|
{{- $pgPass = .Values.postgres.auth.password }}
|
||||||
{{- else }}
|
{{- else }}
|
||||||
{{- $pgPass = randAlphaNum 32 }}
|
{{- /* Fail loudly instead of silently minting a random password: under
|
||||||
|
Argo CD (helm template) `lookup` is always empty, and a random
|
||||||
|
value would diverge from the live service on every render. */}}
|
||||||
|
{{- fail "postgres.auth.password must be set: no existing pargar-postgres secret was found and .Values.postgres.auth.password is empty or a change-me placeholder" }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Secret
|
kind: Secret
|
||||||
@@ -30,7 +33,7 @@ stringData:
|
|||||||
{{- else if and .Values.redis.auth.password (not (hasPrefix "change-me" .Values.redis.auth.password)) (ne .Values.redis.auth.password "") }}
|
{{- else if and .Values.redis.auth.password (not (hasPrefix "change-me" .Values.redis.auth.password)) (ne .Values.redis.auth.password "") }}
|
||||||
{{- $rdPass = .Values.redis.auth.password }}
|
{{- $rdPass = .Values.redis.auth.password }}
|
||||||
{{- else }}
|
{{- else }}
|
||||||
{{- $rdPass = randAlphaNum 32 }}
|
{{- fail "redis.auth.password must be set: no existing pargar-redis secret was found and .Values.redis.auth.password is empty or a change-me placeholder" }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Secret
|
kind: Secret
|
||||||
@@ -56,7 +59,7 @@ stringData:
|
|||||||
{{- else if and .Values.rustfs.auth.secretKey (not (hasPrefix "change-me" .Values.rustfs.auth.secretKey)) (ne .Values.rustfs.auth.secretKey "") }}
|
{{- else if and .Values.rustfs.auth.secretKey (not (hasPrefix "change-me" .Values.rustfs.auth.secretKey)) (ne .Values.rustfs.auth.secretKey "") }}
|
||||||
{{- $secret = .Values.rustfs.auth.secretKey }}
|
{{- $secret = .Values.rustfs.auth.secretKey }}
|
||||||
{{- else }}
|
{{- else }}
|
||||||
{{- $secret = randAlphaNum 40 }}
|
{{- fail "rustfs.auth.secretKey must be set: no existing pargar-rustfs secret was found and .Values.rustfs.auth.secretKey is empty or a change-me placeholder" }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Secret
|
kind: Secret
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{{- if .Values.backend.metrics.serviceMonitor.enabled }}
|
||||||
|
apiVersion: monitoring.coreos.com/v1
|
||||||
|
kind: ServiceMonitor
|
||||||
|
metadata:
|
||||||
|
name: {{ include "pargar.fullname" . }}-backend
|
||||||
|
labels:
|
||||||
|
{{- include "pargar.labels" . | nindent 4 }}
|
||||||
|
app.kubernetes.io/component: backend
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
{{- include "pargar.selectorLabels" . | nindent 6 }}
|
||||||
|
app.kubernetes.io/component: backend
|
||||||
|
namespaceSelector:
|
||||||
|
matchNames:
|
||||||
|
- {{ .Release.Namespace }}
|
||||||
|
endpoints:
|
||||||
|
- port: http
|
||||||
|
path: /metrics
|
||||||
|
interval: {{ .Values.backend.metrics.serviceMonitor.interval | default "30s" }}
|
||||||
|
scheme: http
|
||||||
|
# /metrics answers 401 without it; Prometheus sends it as
|
||||||
|
# Authorization: Bearer <token>.
|
||||||
|
authorization:
|
||||||
|
type: Bearer
|
||||||
|
credentials:
|
||||||
|
name: {{ include "pargar.appSecretName" . }}
|
||||||
|
key: metrics-token
|
||||||
|
{{- end }}
|
||||||
@@ -11,7 +11,7 @@ backend:
|
|||||||
image:
|
image:
|
||||||
pullPolicy: IfNotPresent
|
pullPolicy: IfNotPresent
|
||||||
repository: pargar-backend
|
repository: pargar-backend
|
||||||
tag: 0.3.7
|
tag: 0.4.0
|
||||||
metrics:
|
metrics:
|
||||||
serviceMonitor:
|
serviceMonitor:
|
||||||
enabled: true
|
enabled: true
|
||||||
@@ -41,7 +41,7 @@ frontend:
|
|||||||
image:
|
image:
|
||||||
pullPolicy: IfNotPresent
|
pullPolicy: IfNotPresent
|
||||||
repository: pargar-frontend
|
repository: pargar-frontend
|
||||||
tag: 0.3.7
|
tag: 0.4.0
|
||||||
podDisruptionBudget:
|
podDisruptionBudget:
|
||||||
enabled: false
|
enabled: false
|
||||||
replicaCount: 2
|
replicaCount: 2
|
||||||
@@ -58,7 +58,10 @@ global:
|
|||||||
# Images published by .gitea/workflows/build-and-push.yaml on tag push.
|
# Images published by .gitea/workflows/build-and-push.yaml on tag push.
|
||||||
imageRegistry: src.panahifar.ir/ahp
|
imageRegistry: src.panahifar.ir/ahp
|
||||||
publicUrl: https://pargar.panahifar.ir
|
publicUrl: https://pargar.panahifar.ir
|
||||||
imagePullSecrets: []
|
# Gitea packages are private: kubelet pulls need secret `gitea-pull`
|
||||||
|
# (docker-registry secret for src.panahifar.ir, created in ns pargar).
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: gitea-pull
|
||||||
ingress:
|
ingress:
|
||||||
enabled: true
|
enabled: true
|
||||||
tls:
|
tls:
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ backend:
|
|||||||
pullPolicy: IfNotPresent
|
pullPolicy: IfNotPresent
|
||||||
service:
|
service:
|
||||||
port: 8080
|
port: 8080
|
||||||
|
metrics:
|
||||||
|
# Prometheus Operator ServiceMonitor. /metrics is bearer-protected by
|
||||||
|
# METRICS_TOKEN, so the endpoint carries authorization credentials
|
||||||
|
# (pargar-app secret) instead of scraping anonymously.
|
||||||
|
serviceMonitor:
|
||||||
|
enabled: false
|
||||||
|
interval: 30s
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
cpu: 50m
|
cpu: 50m
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{"available":true,"accessToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjEsInJvbGUiOiJhZG1pbiIsInN1YiI6IjEiLCJleHAiOjE3OTI0OTc0MjcsImlhdCI6MTc5MjQ5NjUyN30.0RuGRaPhMeQ36aHrn_x2iAgJPpyH7pfXGr_cOOOeG1c","refreshToken":"e4059f725d0648ae77b0147835296228c5bba0d0bcda638ebb9a65adeed6c5f72e56d0da268de11d34393e9d4ea96acb","user":{"id":1,"email":"admin@example.com","username":"admin","name":"Admin","role":"admin","xp":0,"hearts":5,"heartsUpdatedAt":"2026-10-18T06:49:58.329935Z","streakCurrent":0,"streakLongest":0,"isLocked":false,"isActive":true,"avatarVariant":"beam","avatarPalette":"","phone":"","hasSecurityAnswer":false,"createdAt":"2026-10-18T06:49:58.329935Z","usernameChangesRemaining":3,"isFrozen":false,"isClosed":false}}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { ADMIN_EMAIL, ADMIN_PASSWORD, BASE, requireAuth, injectAuth, apiLogin } from "./helpers";
|
||||||
|
|
||||||
|
test.describe("Auth — public endpoints", () => {
|
||||||
|
test("login page renders", async ({ page }) => {
|
||||||
|
await page.goto("/login");
|
||||||
|
await expect(page).toHaveTitle(/.+/);
|
||||||
|
const hasForm = await page.locator("form, input[type=password]").count();
|
||||||
|
expect(hasForm).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("protected routes reject unauthenticated requests", async ({ page }) => {
|
||||||
|
const routes = ["/api/tree", "/api/leaderboard/weekly", "/api/notifications"];
|
||||||
|
for (const route of routes) {
|
||||||
|
const res = await page.request.get(`${BASE}${route}`);
|
||||||
|
expect(res.status(), `${route} should be 401`).toBe(401);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("invalid credentials: bad captcha → 400, bad password → 401", async ({ page }) => {
|
||||||
|
// Bad captcha answer returns 400
|
||||||
|
const r1 = await page.request.post(`${BASE}/api/auth/login`, {
|
||||||
|
data: { username: "x", password: "x", challengeId: "fake", captchaAnswer: "wrong" },
|
||||||
|
});
|
||||||
|
expect([400, 429]).toContain(r1.status());
|
||||||
|
|
||||||
|
// Correct captcha, wrong password → 401
|
||||||
|
const auth = requireAuth();
|
||||||
|
const captchaRes = await page.request.get(`${BASE}/api/auth/captcha`);
|
||||||
|
const { challengeId, answer } = await captchaRes.json();
|
||||||
|
const r2 = await page.request.post(`${BASE}/api/auth/login`, {
|
||||||
|
data: { username: ADMIN_EMAIL, password: "definitely-wrong", challengeId, captchaAnswer: answer },
|
||||||
|
});
|
||||||
|
expect(r2.status()).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Auth — requires working CAPTCHA", () => {
|
||||||
|
test("admin login via API succeeds and /me returns admin role", async ({ page }) => {
|
||||||
|
const auth = requireAuth();
|
||||||
|
const res = await page.request.get(`${BASE}/api/auth/me`, {
|
||||||
|
headers: { Authorization: `Bearer ${auth.accessToken}` },
|
||||||
|
});
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.user.role).toBe("admin");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("logout invalidates refresh token", async ({ page }) => {
|
||||||
|
requireAuth();
|
||||||
|
// Issue a fresh session for this test only (don't use the shared token)
|
||||||
|
const captchaRes = await page.request.get(`${BASE}/api/auth/captcha`);
|
||||||
|
const { challengeId, answer } = await captchaRes.json();
|
||||||
|
if (!answer) { test.skip(true, "CAPTCHA not solvable"); return; }
|
||||||
|
|
||||||
|
const loginRes = await page.request.post(`${BASE}/api/auth/login`, {
|
||||||
|
data: { username: ADMIN_EMAIL, password: ADMIN_PASSWORD, challengeId, captchaAnswer: answer },
|
||||||
|
});
|
||||||
|
const { accessToken, refreshToken } = await loginRes.json();
|
||||||
|
|
||||||
|
const logout = await page.request.post(`${BASE}/api/auth/logout`, {
|
||||||
|
data: { refreshToken },
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
expect(logout.status()).toBe(200);
|
||||||
|
|
||||||
|
const refresh = await page.request.post(`${BASE}/api/auth/refresh`, {
|
||||||
|
data: { refreshToken },
|
||||||
|
});
|
||||||
|
expect(refresh.status()).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cap page loads and stays off /login", async ({ page }) => {
|
||||||
|
await injectAuth(page);
|
||||||
|
await page.goto("/cap");
|
||||||
|
await page.waitForLoadState("networkidle", { timeout: 15_000 });
|
||||||
|
expect(page.url()).not.toContain("/login");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { BASE, requireAuth, injectAuth } from "./helpers";
|
||||||
|
|
||||||
|
test.describe("Chat", () => {
|
||||||
|
test("conversations endpoint returns list", async ({ page }) => {
|
||||||
|
const auth = requireAuth();
|
||||||
|
const res = await page.request.get(`${BASE}/api/chats/conversations`, {
|
||||||
|
headers: { Authorization: `Bearer ${auth.accessToken}` },
|
||||||
|
});
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const data = await res.json();
|
||||||
|
expect(data).toHaveProperty("conversations");
|
||||||
|
expect(Array.isArray(data.conversations)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("student→student chat is rejected (IDOR guard)", async ({ page }) => {
|
||||||
|
const auth = requireAuth();
|
||||||
|
const adminId = auth.user!.id;
|
||||||
|
|
||||||
|
// Create a student
|
||||||
|
const u1 = await page.request.post(`${BASE}/api/admin/users`, {
|
||||||
|
headers: { Authorization: `Bearer ${auth.accessToken}` },
|
||||||
|
data: {
|
||||||
|
name: "ChatTest", email: "chat-idor@e2e.test", username: "chat_idor_e2e",
|
||||||
|
password: "TestPass123!", role: "student",
|
||||||
|
securityQuestion: "Q", securityAnswer: "A",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// 201 = created, 409 = already exists from prior run — both are fine
|
||||||
|
if (u1.status() !== 201 && u1.status() !== 409) {
|
||||||
|
test.skip(true, `Unexpected status creating test user: ${u1.status()}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const studentId = u1.status() === 201
|
||||||
|
? (await u1.json()).user.id
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!studentId) { test.skip(true, "Could not get student ID"); return; }
|
||||||
|
|
||||||
|
// Log in as the student
|
||||||
|
const captchaRes = await page.request.get(`${BASE}/api/auth/captcha`);
|
||||||
|
const { challengeId, answer } = await captchaRes.json();
|
||||||
|
if (!answer) { test.skip(true, "CAPTCHA not solvable"); return; }
|
||||||
|
|
||||||
|
const loginRes = await page.request.post(`${BASE}/api/auth/login`, {
|
||||||
|
data: { username: "chat_idor_e2e", password: "TestPass123!", challengeId, captchaAnswer: answer },
|
||||||
|
});
|
||||||
|
const studentSession = await loginRes.json();
|
||||||
|
if (!studentSession.accessToken) { test.skip(true, "Student login failed"); return; }
|
||||||
|
|
||||||
|
// Student tries to read another student's conversations — must fail
|
||||||
|
const forbidden = await page.request.get(`${BASE}/api/chats/${studentId}/messages`, {
|
||||||
|
headers: { Authorization: `Bearer ${studentSession.accessToken}` },
|
||||||
|
});
|
||||||
|
expect([403, 404]).toContain(forbidden.status());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("chat page renders without redirect to /login", async ({ page }) => {
|
||||||
|
await injectAuth(page);
|
||||||
|
await page.goto("/unwrap?tab=chats");
|
||||||
|
await page.waitForLoadState("networkidle", { timeout: 15_000 });
|
||||||
|
expect(page.url()).not.toContain("/login");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { chromium } from "@playwright/test";
|
||||||
|
import { ADMIN_EMAIL, ADMIN_PASSWORD, BASE } from "./helpers";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as path from "path";
|
||||||
|
|
||||||
|
const AUTH_FILE = path.join(__dirname, ".auth-state.json");
|
||||||
|
|
||||||
|
export default async function globalSetup() {
|
||||||
|
// If answer is not exposed in captcha, skip auth — tests requiring auth will skip themselves.
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
executablePath: `${process.env.HOME}/.cache/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-linux64/chrome-headless-shell`,
|
||||||
|
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
||||||
|
});
|
||||||
|
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const captchaRes = await page.request.get(`${BASE}/api/auth/captcha`);
|
||||||
|
const captchaData = await captchaRes.json();
|
||||||
|
|
||||||
|
if (!captchaData.answer) {
|
||||||
|
console.warn("\n⚠ CAPTCHA_EXPOSE_ANSWER not set — restart the API container to enable auth tests.\n Run: ./scripts/compose-dev.sh restart api\n");
|
||||||
|
fs.writeFileSync(AUTH_FILE, JSON.stringify({ available: false }));
|
||||||
|
await browser.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginRes = await page.request.post(`${BASE}/api/auth/login`, {
|
||||||
|
data: {
|
||||||
|
username: ADMIN_EMAIL,
|
||||||
|
password: ADMIN_PASSWORD,
|
||||||
|
challengeId: captchaData.challengeId,
|
||||||
|
captchaAnswer: captchaData.answer,
|
||||||
|
rememberMe: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await loginRes.json();
|
||||||
|
|
||||||
|
if (!body.accessToken) {
|
||||||
|
console.warn("\n⚠ Admin login failed in global setup:", JSON.stringify(body));
|
||||||
|
fs.writeFileSync(AUTH_FILE, JSON.stringify({ available: false }));
|
||||||
|
await browser.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(AUTH_FILE, JSON.stringify({
|
||||||
|
available: true,
|
||||||
|
accessToken: body.accessToken,
|
||||||
|
refreshToken: body.refreshToken,
|
||||||
|
user: body.user,
|
||||||
|
}));
|
||||||
|
console.log("\n✓ Auth state cached for test suite\n");
|
||||||
|
} catch (e) {
|
||||||
|
fs.writeFileSync(AUTH_FILE, JSON.stringify({ available: false }));
|
||||||
|
console.warn("\n⚠ Global setup failed:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { type Page, test } from "@playwright/test";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as path from "path";
|
||||||
|
|
||||||
|
export const ADMIN_EMAIL = "admin";
|
||||||
|
export const ADMIN_PASSWORD = "wZW64CUbXbv9679dmR1FIbz3";
|
||||||
|
export const BASE = "https://localhost";
|
||||||
|
|
||||||
|
const AUTH_FILE = path.join(__dirname, ".auth-state.json");
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
available: boolean;
|
||||||
|
accessToken?: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
user?: { id: number; role: string; email: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadAuth(): AuthState {
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(AUTH_FILE, "utf-8")) as AuthState;
|
||||||
|
} catch {
|
||||||
|
return { available: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Skip this test if auth is not available (CAPTCHA not exposed). */
|
||||||
|
export function requireAuth(): AuthState {
|
||||||
|
const auth = loadAuth();
|
||||||
|
if (!auth.available) {
|
||||||
|
test.skip(true, "Auth not available — restart API with CAPTCHA_EXPOSE_ANSWER=true");
|
||||||
|
}
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inject cached auth cookies into the page context. */
|
||||||
|
export async function injectAuth(page: Page): Promise<AuthState> {
|
||||||
|
const auth = requireAuth();
|
||||||
|
// Seed Zustand persist store so the app hydrates with a valid user (skips redirect)
|
||||||
|
await page.addInitScript(({ user }) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(
|
||||||
|
"pargar.auth",
|
||||||
|
JSON.stringify({ state: { user }, version: 0 }),
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
}, { user: auth.user });
|
||||||
|
await page.context().addCookies([
|
||||||
|
{ name: "pargar_access", value: auth.accessToken!, domain: "localhost", path: "/", secure: true, sameSite: "Lax" },
|
||||||
|
{ name: "pargar_refresh", value: auth.refreshToken!, domain: "localhost", path: "/", secure: true, httpOnly: true, sameSite: "Lax" },
|
||||||
|
]);
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginAs(page: Page, identity: string, password: string) {
|
||||||
|
const captchaRes = await page.request.get(`${BASE}/api/auth/captcha`);
|
||||||
|
const captchaData = await captchaRes.json();
|
||||||
|
const answer: string = captchaData.answer ?? "";
|
||||||
|
const challengeId: string = captchaData.challengeId ?? "";
|
||||||
|
|
||||||
|
const loginRes = await page.request.post(`${BASE}/api/auth/login`, {
|
||||||
|
data: { username: identity, password, challengeId, captchaAnswer: answer, rememberMe: false },
|
||||||
|
});
|
||||||
|
const loginBody = await loginRes.json();
|
||||||
|
|
||||||
|
if (!loginBody.accessToken) {
|
||||||
|
throw new Error(`Login failed for ${identity}: ${JSON.stringify(loginBody)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.context().addCookies([
|
||||||
|
{ name: "access_token", value: loginBody.accessToken, domain: "localhost", path: "/", secure: true, sameSite: "Lax" },
|
||||||
|
{ name: "refresh_token", value: loginBody.refreshToken, domain: "localhost", path: "/", secure: true, httpOnly: true, sameSite: "Lax" },
|
||||||
|
]);
|
||||||
|
return loginBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiLogin(page: Page, identity: string, password: string) {
|
||||||
|
const captchaRes = await page.request.get(`${BASE}/api/auth/captcha`);
|
||||||
|
const { challengeId, answer } = await captchaRes.json();
|
||||||
|
const res = await page.request.post(`${BASE}/api/auth/login`, {
|
||||||
|
data: { username: identity, password, challengeId, captchaAnswer: answer, rememberMe: false },
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { BASE, requireAuth, injectAuth } from "./helpers";
|
||||||
|
|
||||||
|
test.describe("Leaderboard", () => {
|
||||||
|
test("weekly leaderboard returns valid shape", async ({ page }) => {
|
||||||
|
const auth = requireAuth();
|
||||||
|
const res = await page.request.get(`${BASE}/api/leaderboard/weekly`, {
|
||||||
|
headers: { Authorization: `Bearer ${auth.accessToken}` },
|
||||||
|
});
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const data = await res.json();
|
||||||
|
expect(data).toHaveProperty("week");
|
||||||
|
expect(data).toHaveProperty("entries");
|
||||||
|
expect(data).toHaveProperty("totalParticipants");
|
||||||
|
expect(data).toHaveProperty("me");
|
||||||
|
expect(Array.isArray(data.entries)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leaderboard entries have tier field", async ({ page }) => {
|
||||||
|
const auth = requireAuth();
|
||||||
|
const res = await page.request.get(`${BASE}/api/leaderboard/weekly`, {
|
||||||
|
headers: { Authorization: `Bearer ${auth.accessToken}` },
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
for (const entry of data.entries) {
|
||||||
|
expect(["champion", "gold", "silver", "bronze"]).toContain(entry.tier);
|
||||||
|
expect(entry.rank).toBeGreaterThanOrEqual(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leaderboard page renders without redirect to /login", async ({ page }) => {
|
||||||
|
await injectAuth(page);
|
||||||
|
await page.goto("/leaderboard");
|
||||||
|
await page.waitForLoadState("networkidle", { timeout: 15_000 });
|
||||||
|
expect(page.url()).not.toContain("/login");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { requireAuth, injectAuth } from "./helpers";
|
||||||
|
|
||||||
|
const VIEWPORTS = [
|
||||||
|
{ name: "mobile-sm (375px)", width: 375, height: 812 },
|
||||||
|
{ name: "mobile-lg (430px)", width: 430, height: 932 },
|
||||||
|
{ name: "tablet (768px)", width: 768, height: 1024 },
|
||||||
|
{ name: "desktop (1280px)", width: 1280, height: 800 },
|
||||||
|
{ name: "desktop-xl (1440px)", width: 1440, height: 900 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ROUTES = ["/cap", "/leaderboard", "/community", "/settings", "/notifications"];
|
||||||
|
|
||||||
|
for (const vp of VIEWPORTS) {
|
||||||
|
test(`no horizontal overflow — ${vp.name}`, async ({ page }) => {
|
||||||
|
requireAuth();
|
||||||
|
await page.setViewportSize({ width: vp.width, height: vp.height });
|
||||||
|
await injectAuth(page);
|
||||||
|
|
||||||
|
for (const route of ROUTES) {
|
||||||
|
await page.goto(route);
|
||||||
|
await page.waitForLoadState("networkidle", { timeout: 15_000 });
|
||||||
|
|
||||||
|
const overflow = await page.evaluate(() => {
|
||||||
|
return document.documentElement.scrollWidth > document.documentElement.clientWidth;
|
||||||
|
});
|
||||||
|
expect(overflow, `Horizontal overflow at ${route} / ${vp.name}`).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`no interactive elements clipped off-screen — ${vp.name}`, async ({ page }) => {
|
||||||
|
requireAuth();
|
||||||
|
await page.setViewportSize({ width: vp.width, height: vp.height });
|
||||||
|
await injectAuth(page);
|
||||||
|
|
||||||
|
await page.goto("/cap");
|
||||||
|
await page.waitForLoadState("networkidle", { timeout: 15_000 });
|
||||||
|
|
||||||
|
const clipped = await page.evaluate((vpWidth) => {
|
||||||
|
return Array.from(document.querySelectorAll("button, a, input"))
|
||||||
|
.filter((el) => {
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
return r.right < 0 || r.left > vpWidth + 50;
|
||||||
|
}).length;
|
||||||
|
}, vp.width);
|
||||||
|
|
||||||
|
expect(clipped, `${clipped} elements clipped at ${vp.name}`).toBe(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { BASE, requireAuth, injectAuth } from "./helpers";
|
||||||
|
|
||||||
|
test.describe("Video player & media signing", () => {
|
||||||
|
test("lesson endpoint returns a signed videoUrl", async ({ page }) => {
|
||||||
|
const auth = requireAuth();
|
||||||
|
const treeRes = await page.request.get(`${BASE}/api/tree`, {
|
||||||
|
headers: { Authorization: `Bearer ${auth.accessToken}` },
|
||||||
|
});
|
||||||
|
expect(treeRes.status()).toBe(200);
|
||||||
|
const tree = await treeRes.json();
|
||||||
|
const firstLesson = tree.chapters?.[0]?.lessons?.[0];
|
||||||
|
if (!firstLesson) { test.skip(true, "No lessons in tree"); return; }
|
||||||
|
|
||||||
|
const lessonRes = await page.request.get(`${BASE}/api/lessons/${firstLesson.id}`, {
|
||||||
|
headers: { Authorization: `Bearer ${auth.accessToken}` },
|
||||||
|
});
|
||||||
|
expect(lessonRes.status()).toBe(200);
|
||||||
|
const lesson = await lessonRes.json();
|
||||||
|
|
||||||
|
expect(lesson.videoUrl).toBeTruthy();
|
||||||
|
expect(lesson.videoUrl).toMatch(/^https?:\/\//);
|
||||||
|
|
||||||
|
// Must be signed (has query params) or served via /media/ proxy
|
||||||
|
const url = new URL(lesson.videoUrl);
|
||||||
|
const isSigned = url.searchParams.size > 0 || lesson.videoUrl.includes("/media/");
|
||||||
|
expect(isSigned, `videoUrl not signed: ${lesson.videoUrl}`).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("player page renders without JS errors for first lesson", async ({ page }) => {
|
||||||
|
const auth = requireAuth();
|
||||||
|
const treeRes = await page.request.get(`${BASE}/api/tree`, {
|
||||||
|
headers: { Authorization: `Bearer ${auth.accessToken}` },
|
||||||
|
});
|
||||||
|
const tree = await treeRes.json();
|
||||||
|
const firstLesson = tree.chapters?.[0]?.lessons?.[0];
|
||||||
|
if (!firstLesson) { test.skip(true, "No lessons in tree"); return; }
|
||||||
|
|
||||||
|
const jsErrors: string[] = [];
|
||||||
|
page.on("pageerror", (err) => jsErrors.push(err.message));
|
||||||
|
|
||||||
|
await injectAuth(page);
|
||||||
|
await page.goto(`/player/${firstLesson.id}`);
|
||||||
|
await page.waitForLoadState("networkidle", { timeout: 20_000 });
|
||||||
|
|
||||||
|
// Filter known expected media errors (video load failures in headless are expected)
|
||||||
|
const realErrors = jsErrors.filter(
|
||||||
|
(e) => !e.includes("video") && !e.includes("media") && !e.includes("hls") && !e.includes("AbortError")
|
||||||
|
);
|
||||||
|
expect(realErrors, `JS errors on player page: ${realErrors.join(", ")}`).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("admin videos panel fires no media requests on grid load", async ({ page }) => {
|
||||||
|
requireAuth();
|
||||||
|
const videoRequests: string[] = [];
|
||||||
|
page.on("request", (req) => {
|
||||||
|
if (req.resourceType() === "media" || /\.(mp4|m3u8|ts)(\?|$)/.test(req.url())) {
|
||||||
|
videoRequests.push(req.url());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await injectAuth(page);
|
||||||
|
await page.goto("/admin");
|
||||||
|
await page.waitForLoadState("networkidle", { timeout: 15_000 });
|
||||||
|
|
||||||
|
// Click Videos tab if present
|
||||||
|
const videosTab = page.getByRole("button", { name: /ویدیو/i });
|
||||||
|
if (await videosTab.count() > 0) {
|
||||||
|
await videosTab.first().click();
|
||||||
|
await page.waitForLoadState("networkidle", { timeout: 10_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(videoRequests, `Unexpected media requests on grid load: ${videoRequests.join(", ")}`).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import * as tls from "tls";
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { BASE, requireAuth } from "./helpers";
|
||||||
|
|
||||||
|
test.describe("WebSocket", () => {
|
||||||
|
// Test WS via raw TLS to avoid browser HTTP/2 and Node.js http module quirks.
|
||||||
|
test("WS handshake with valid token succeeds", async () => {
|
||||||
|
const auth = requireAuth();
|
||||||
|
|
||||||
|
const statusCode = await new Promise<number>((resolve) => {
|
||||||
|
const socket = tls.connect(
|
||||||
|
{ host: "localhost", port: 443, rejectUnauthorized: false },
|
||||||
|
() => {
|
||||||
|
const handshake = [
|
||||||
|
"GET /api/ws HTTP/1.1",
|
||||||
|
"Host: localhost",
|
||||||
|
"Upgrade: websocket",
|
||||||
|
"Connection: Upgrade",
|
||||||
|
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==",
|
||||||
|
"Sec-WebSocket-Version: 13",
|
||||||
|
`Authorization: Bearer ${auth.accessToken!}`,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
].join("\r\n");
|
||||||
|
socket.write(handshake);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let buf = "";
|
||||||
|
socket.on("data", (chunk) => {
|
||||||
|
buf += chunk.toString();
|
||||||
|
const firstLine = buf.split("\r\n")[0] ?? "";
|
||||||
|
const m = firstLine.match(/HTTP\/1\.\d\s+(\d+)/);
|
||||||
|
if (m) {
|
||||||
|
socket.destroy();
|
||||||
|
resolve(parseInt(m[1], 10));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on("error", () => resolve(0));
|
||||||
|
setTimeout(() => { socket.destroy(); resolve(0); }, 8000);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(statusCode, `WS upgrade returned ${statusCode}`).toBe(101);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("WS handshake without token is rejected", async ({ page }) => {
|
||||||
|
const result = await page.evaluate((wsUrl) => {
|
||||||
|
return new Promise<{ code: number }>((resolve) => {
|
||||||
|
const ws = new WebSocket(wsUrl);
|
||||||
|
ws.onclose = (e) => resolve({ code: e.code });
|
||||||
|
setTimeout(() => resolve({ code: -1 }), 6000);
|
||||||
|
});
|
||||||
|
}, BASE.replace("https", "wss") + "/api/ws");
|
||||||
|
|
||||||
|
expect(result.code).not.toBe(1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -42,6 +42,7 @@
|
|||||||
"zustand": "^5.0.2"
|
"zustand": "^5.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.64.1",
|
||||||
"@tailwindcss/postcss": "^4.0.0",
|
"@tailwindcss/postcss": "^4.0.0",
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
@@ -796,6 +797,22 @@
|
|||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.64.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz",
|
||||||
|
"integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.64.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/primitive": {
|
"node_modules/@radix-ui/primitive": {
|
||||||
"version": "1.1.7",
|
"version": "1.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz",
|
||||||
@@ -2973,6 +2990,35 @@
|
|||||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.64.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
|
||||||
|
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.64.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.64.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
|
||||||
|
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.28",
|
"version": "8.5.28",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
"zustand": "^5.0.2"
|
"zustand": "^5.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.64.1",
|
||||||
"@tailwindcss/postcss": "^4.0.0",
|
"@tailwindcss/postcss": "^4.0.0",
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./e2e",
|
||||||
|
timeout: 30_000,
|
||||||
|
retries: 0,
|
||||||
|
workers: 1,
|
||||||
|
globalSetup: "./e2e/global-setup.ts",
|
||||||
|
reporter: [["list"], ["html", { open: "never", outputFolder: "playwright-report" }]],
|
||||||
|
use: {
|
||||||
|
baseURL: "https://localhost",
|
||||||
|
ignoreHTTPSErrors: true,
|
||||||
|
screenshot: "only-on-failure",
|
||||||
|
video: "off",
|
||||||
|
trace: "retain-on-failure",
|
||||||
|
locale: "fa-IR",
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: "chromium",
|
||||||
|
use: {
|
||||||
|
browserName: "chromium",
|
||||||
|
viewport: { width: 1280, height: 800 },
|
||||||
|
launchOptions: {
|
||||||
|
executablePath: `${process.env.HOME}/.cache/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-linux64/chrome-headless-shell`,
|
||||||
|
args: ["--no-sandbox", "--disable-dev-shm-usage", "--disable-http2"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 253 KiB |
@@ -73,13 +73,6 @@ function CapPageInner() {
|
|||||||
enabled: !!user && curriculumDone,
|
enabled: !!user && curriculumDone,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!curriculumDone || !user) return;
|
|
||||||
void http
|
|
||||||
.post("/api/me/certificate/issue", {})
|
|
||||||
.then(() => qc.invalidateQueries({ queryKey: ["me", "certificate"] }))
|
|
||||||
.catch((e) => toast.error(toUserError(e, "صدور گواهینامه ممکن نشد")));
|
|
||||||
}, [curriculumDone, user, qc]);
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <CapSkeleton />;
|
return <CapSkeleton />;
|
||||||
|
|||||||
@@ -35,6 +35,14 @@ const vazirmatnLatin = localFont({
|
|||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "پرگار",
|
title: "پرگار",
|
||||||
description: "مسیر یادگیری واقعی پرگار",
|
description: "مسیر یادگیری واقعی پرگار",
|
||||||
|
icons: {
|
||||||
|
icon: [
|
||||||
|
{ url: "/favicon.ico" },
|
||||||
|
{ url: "/icon-192.png", sizes: "192x192", type: "image/png" },
|
||||||
|
{ url: "/icon-512.png", sizes: "512x512", type: "image/png" },
|
||||||
|
],
|
||||||
|
apple: { url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { api, http, toUserError } from "@/lib/api";
|
|||||||
import { MAX_HEARTS, clampHearts, hasInfiniteHearts } from "@/lib/hearts";
|
import { MAX_HEARTS, clampHearts, hasInfiniteHearts } from "@/lib/hearts";
|
||||||
import type { Mentor, User } from "@/lib/types";
|
import type { Mentor, User } from "@/lib/types";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { toast } from "@/components/providers";
|
import { toast } from "@/components/providers";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -116,14 +117,14 @@ export default function LockoutPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="mt-5 w-full space-y-2">
|
<div className="mt-5 w-full space-y-2">
|
||||||
<textarea
|
<Textarea
|
||||||
value={note}
|
value={note}
|
||||||
onChange={(e) => setNote(e.target.value)}
|
onChange={(e) => setNote(e.target.value)}
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
rows={3}
|
rows={3}
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
placeholder="اختیاری: چه آزمونی گیر کردید؟ چه کمکی لازم دارید؟"
|
placeholder="اختیاری: چه آزمونی گیر کردید؟ چه کمکی لازم دارید؟"
|
||||||
className="w-full rounded-xl border-2 border-border bg-background px-3 py-2 text-sm outline-none focus-visible:border-primary/50"
|
className="min-h-0"
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col gap-2 sm:flex-row">
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
<Button className="flex-1" disabled={busy || requested} onClick={() => void requestUnlock()}>
|
<Button className="flex-1" disabled={busy || requested} onClick={() => void requestUnlock()}>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export default function NotificationsPage() {
|
|||||||
<Skeleton className="h-8 w-44" />
|
<Skeleton className="h-8 w-44" />
|
||||||
<Skeleton className="h-4 w-72" />
|
<Skeleton className="h-4 w-72" />
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border border-border/70 bg-card p-6 shadow-soft">
|
<div className="rounded-2xl border-2 border-border/70 bg-card p-6 shadow-soft">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Skeleton className="h-10 w-10 rounded-xl" />
|
<Skeleton className="h-10 w-10 rounded-xl" />
|
||||||
<div className="flex-1 space-y-1.5">
|
<div className="flex-1 space-y-1.5">
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export default function Home() {
|
|||||||
|
|
||||||
const goCap = () => router.push("/cap");
|
const goCap = () => router.push("/cap");
|
||||||
const goLogin = () => router.push("/login");
|
const goLogin = () => router.push("/login");
|
||||||
|
const goRegister = () => router.push("/register");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex max-w-5xl flex-col items-center">
|
<div className="mx-auto flex max-w-5xl flex-col items-center">
|
||||||
@@ -134,7 +135,7 @@ export default function Home() {
|
|||||||
<Button size="lg" onClick={goLogin} className="gap-2 px-8">
|
<Button size="lg" onClick={goLogin} className="gap-2 px-8">
|
||||||
ورود
|
ورود
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="lg" variant="outline" onClick={goLogin} className="gap-2 px-8">
|
<Button size="lg" variant="outline" onClick={goRegister} className="gap-2 px-8">
|
||||||
<Zap className="h-5 w-5" /> شروع مسیر
|
<Zap className="h-5 w-5" /> شروع مسیر
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
@@ -288,7 +289,7 @@ export default function Home() {
|
|||||||
<Link href="/login">ورود</Link>
|
<Link href="/login">ورود</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="lg" variant="outline" asChild className="gap-2 px-8">
|
<Button size="lg" variant="outline" asChild className="gap-2 px-8">
|
||||||
<Link href="/login">
|
<Link href="/register">
|
||||||
<PlayCircle className="h-5 w-5" /> شروع مسیر
|
<PlayCircle className="h-5 w-5" /> شروع مسیر
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
Cpu,
|
Cpu,
|
||||||
Eye,
|
Eye,
|
||||||
|
EyeOff,
|
||||||
Flag,
|
Flag,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Globe,
|
Globe,
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Lock,
|
Lock,
|
||||||
MapPin,
|
MapPin,
|
||||||
|
MoreHorizontal,
|
||||||
Pencil,
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
Rocket,
|
Rocket,
|
||||||
@@ -59,6 +61,15 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { PasswordInput } from "@/components/ui/password-input";
|
import { PasswordInput } from "@/components/ui/password-input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
@@ -685,7 +696,11 @@ function CurriculumPanel() {
|
|||||||
|
|
||||||
const [lessonModal, setLessonModal] = useState<Lesson | null>(null);
|
const [lessonModal, setLessonModal] = useState<Lesson | null>(null);
|
||||||
const [chapterModal, setChapterModal] = useState<Chapter | null>(null);
|
const [chapterModal, setChapterModal] = useState<Chapter | null>(null);
|
||||||
const [dragIdx, setDragIdx] = useState<{ chapter: number; from: number; over?: number } | null>(null);
|
const [activeChapter, setActiveChapter] = useState<number | null>(null);
|
||||||
|
const [dragIdx, setDragIdx] = useState<{ chapter: number; from: number } | null>(null);
|
||||||
|
|
||||||
|
const toggleChapter = (id: number) =>
|
||||||
|
setActiveChapter((prev) => (prev === id ? null : id));
|
||||||
|
|
||||||
const persistChapterOrder = async (list: Chapter[]) => {
|
const persistChapterOrder = async (list: Chapter[]) => {
|
||||||
for (let i = 0; i < list.length; i++) {
|
for (let i = 0; i < list.length; i++) {
|
||||||
@@ -772,12 +787,14 @@ function CurriculumPanel() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const newLesson = (chapterId: number) =>
|
const newLesson = (chapterId: number) => {
|
||||||
|
setActiveChapter(chapterId);
|
||||||
setLessonModal({
|
setLessonModal({
|
||||||
id: 0, chapterId, title: "", description: "", videoKey: "",
|
id: 0, chapterId, title: "", description: "", videoKey: "",
|
||||||
durationSeconds: 45, completionThresholdPct: 85, requiresLessonId: undefined,
|
durationSeconds: 45, completionThresholdPct: 85, requiresLessonId: undefined,
|
||||||
x: lessons.filter((l) => l.chapterId === chapterId).length, y: 0, sortOrder: 1, xpReward: 50, isActive: true, isPublished: false,
|
x: lessons.filter((l) => l.chapterId === chapterId).length, y: 0, sortOrder: 1, xpReward: 50, isActive: true, isPublished: false,
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const duplicateLesson = async (l: Lesson) => {
|
const duplicateLesson = async (l: Lesson) => {
|
||||||
try {
|
try {
|
||||||
@@ -812,6 +829,7 @@ function CurriculumPanel() {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await http.del(`/api/admin/chapters/${ch.id}`);
|
await http.del(`/api/admin/chapters/${ch.id}`);
|
||||||
|
if (activeChapter === ch.id) setActiveChapter(null);
|
||||||
qc.invalidateQueries({ queryKey: ["admin", "lessons"] });
|
qc.invalidateQueries({ queryKey: ["admin", "lessons"] });
|
||||||
qc.invalidateQueries({ queryKey: ["admin", "stats"] });
|
qc.invalidateQueries({ queryKey: ["admin", "stats"] });
|
||||||
toast.success("فصل حذف شد");
|
toast.success("فصل حذف شد");
|
||||||
@@ -820,70 +838,159 @@ function CurriculumPanel() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const publishedChapters = chapters.filter((c) => c.isPublished).length;
|
||||||
|
const publishedLessons = lessons.filter((l) => l.isPublished).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{dialog}
|
{dialog}
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex-row items-center justify-between gap-4">
|
{/* Course overview header */}
|
||||||
<div>
|
<div className="mb-4 flex flex-wrap items-start justify-between gap-3 rounded-xl border border-border bg-muted/30 px-4 py-3">
|
||||||
<CardTitle className="text-base">فصلها و درخت مهارت</CardTitle>
|
<div className="space-y-1">
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="text-sm font-bold">دوره آموزشی</p>
|
||||||
{chapters.length} فصل · {lessons.length} درس · ترتیب با کشیدن یا فلش · موقعیت x,y
|
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
|
||||||
</p>
|
<span>{chapters.length} فصل</span>
|
||||||
|
<span>{lessons.length} درس</span>
|
||||||
|
<span className="text-success">{publishedChapters} فصل منتشر</span>
|
||||||
|
<span className="text-success">{publishedLessons} درس منتشر</span>
|
||||||
</div>
|
</div>
|
||||||
<Button className="gap-1.5 rounded-2xl" onClick={() => setChapterModal({ id: 0, title: "", description: "", icon: "terminal", sortOrder: chapters.length + 1, isPublished: false })}>
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="shrink-0"
|
||||||
|
onClick={() => setChapterModal({ id: 0, title: "", description: "", icon: "terminal", sortOrder: chapters.length + 1, isPublished: false })}
|
||||||
|
>
|
||||||
<Plus className="h-4 w-4" /> فصل جدید
|
<Plus className="h-4 w-4" /> فصل جدید
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent className="space-y-4">
|
|
||||||
|
{/* Chapter list */}
|
||||||
{isLoading && <Loader />}
|
{isLoading && <Loader />}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
{chapters.map((ch, ci) => {
|
{chapters.map((ch, ci) => {
|
||||||
const ls = lessons.filter((l) => l.chapterId === ch.id);
|
const ls = lessons.filter((l) => l.chapterId === ch.id);
|
||||||
const ChIcon = chapterIconOf(ch.icon);
|
const ChIcon = chapterIconOf(ch.icon);
|
||||||
|
const isOpen = activeChapter === ch.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={ch.id} className={cn("rounded-xl border border-border bg-muted/20", dragIdx !== null && dragIdx?.chapter === ch.id && "border-primary/50")}>
|
<div
|
||||||
<div className="flex items-center gap-2 p-3 pb-2">
|
key={ch.id}
|
||||||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-primary/10 text-primary">
|
className={cn(
|
||||||
|
"overflow-hidden rounded-xl border border-border transition-colors",
|
||||||
|
isOpen && "border-primary/40 shadow-sm",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Chapter header row */}
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2.5">
|
||||||
|
{/* Expand toggle + identity — takes full remaining width */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-controls={`chapter-${ch.id}-lessons`}
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-2.5 rounded-md py-0.5 text-start outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
onClick={() => toggleChapter(ch.id)}
|
||||||
|
>
|
||||||
|
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-primary/10 text-primary">
|
||||||
<ChIcon className="h-4 w-4" />
|
<ChIcon className="h-4 w-4" />
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h3 className="truncate font-bold">{ch.title}</h3>
|
<p className="truncate text-sm font-bold">{ch.title}</p>
|
||||||
{ch.description && <p className="truncate text-xs text-muted-foreground">{ch.description}</p>}
|
{ch.description && (
|
||||||
|
<p className="truncate text-[11px] text-muted-foreground">{ch.description}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline" className="shrink-0">{ls.length} درس</Badge>
|
<ChevronDown
|
||||||
<Badge variant={ch.isPublished ? "success" : "secondary"} className="shrink-0 px-1.5 text-[10px]">
|
className={cn(
|
||||||
|
"h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200",
|
||||||
|
isOpen && "rotate-180",
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Status badges */}
|
||||||
|
<Badge variant="outline" className="shrink-0 px-2 text-[10px]">
|
||||||
|
{ls.length} درس
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant={ch.isPublished ? "success" : "secondary"}
|
||||||
|
className="hidden shrink-0 px-1.5 text-[10px] sm:inline-flex"
|
||||||
|
>
|
||||||
{ch.isPublished ? "منتشر" : "پیشنویس"}
|
{ch.isPublished ? "منتشر" : "پیشنویس"}
|
||||||
</Badge>
|
</Badge>
|
||||||
<GroupNav
|
|
||||||
onUp={ci > 0 ? () => moveChapter(ci, -1) : undefined}
|
{/* Chapter actions menu */}
|
||||||
onDown={ci < chapters.length - 1 ? () => moveChapter(ci, 1) : undefined}
|
<DropdownMenu>
|
||||||
/>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button size="sm" variant="outline" className="h-7 gap-1 text-xs" onClick={() => setChapterModal(ch)}>
|
|
||||||
<Pencil className="h-3 w-3" /> فصل
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
className="h-7 gap-1 text-xs"
|
className="h-8 w-8 shrink-0 px-0"
|
||||||
onClick={() => publishChapter(ch, !ch.isPublished)}
|
aria-label={`اقدامات فصل ${ch.title}`}
|
||||||
title={ch.isPublished ? "برگشت به پیشنویس" : "انتشار فصل"}
|
|
||||||
>
|
>
|
||||||
{ch.isPublished ? "برگشت" : "انتشار"}
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="outline" className="h-7 gap-1 text-xs" onClick={() => newLesson(ch.id)}>
|
</DropdownMenuTrigger>
|
||||||
<Plus className="h-3.5 w-3.5" /> درس
|
<DropdownMenuContent align="end" className="w-48">
|
||||||
</Button>
|
<DropdownMenuLabel>فصل {ci + 1}</DropdownMenuLabel>
|
||||||
<Button size="sm" variant="ghost" className="h-7 w-7 px-0 text-destructive" onClick={() => delChapter(ch)} title="حذف فصل">
|
<DropdownMenuItem onClick={() => setChapterModal(ch)}>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Pencil className="h-3.5 w-3.5" /> ویرایش فصل
|
||||||
</Button>
|
</DropdownMenuItem>
|
||||||
</div>
|
<DropdownMenuItem
|
||||||
{ls.length === 0 ? (
|
onClick={() => {
|
||||||
<p className="px-3 pb-3 text-xs text-muted-foreground">هنوز درسی نیست. «+ درس» را بزنید.</p>
|
setActiveChapter(ch.id);
|
||||||
|
newLesson(ch.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" /> درس جدید
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{ci > 0 && (
|
||||||
|
<DropdownMenuItem onClick={() => moveChapter(ci, -1)}>
|
||||||
|
<ChevronUp className="h-3.5 w-3.5" /> جابجا به بالا
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{ci < chapters.length - 1 && (
|
||||||
|
<DropdownMenuItem onClick={() => moveChapter(ci, 1)}>
|
||||||
|
<ChevronDown className="h-3.5 w-3.5" /> جابجا به پایین
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuItem onClick={() => publishChapter(ch, !ch.isPublished)}>
|
||||||
|
{ch.isPublished ? (
|
||||||
|
<><EyeOff className="h-3.5 w-3.5" /> برگشت به پیشنویس</>
|
||||||
) : (
|
) : (
|
||||||
<div className="px-3 pb-3">
|
<><Globe className="h-3.5 w-3.5" /> انتشار فصل</>
|
||||||
<div className="grid gap-1.5 sm:grid-cols-2">
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
onClick={() => delChapter(ch)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" /> حذف فصل
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lessons panel (progressive disclosure) */}
|
||||||
|
{isOpen && (
|
||||||
|
<div
|
||||||
|
id={`chapter-${ch.id}-lessons`}
|
||||||
|
className="border-t border-border/70"
|
||||||
|
>
|
||||||
|
{ls.length === 0 ? (
|
||||||
|
<div className="px-4 py-5 text-center text-sm text-muted-foreground">
|
||||||
|
هنوز درسی نیست — از دکمه زیر درس اضافه کنید.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-border/60">
|
||||||
{ls.map((l, li) => {
|
{ls.map((l, li) => {
|
||||||
const cols = ls.filter((o) => o.x === l.x && o.y === l.y && o.id !== l.id);
|
const collision = ls.some(
|
||||||
|
(o) => o.x === l.x && o.y === l.y && o.id !== l.id,
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={l.id}
|
key={l.id}
|
||||||
@@ -892,80 +999,150 @@ function CurriculumPanel() {
|
|||||||
onDragOver={(e) => {
|
onDragOver={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.dataTransfer.dropEffect = "move";
|
e.dataTransfer.dropEffect = "move";
|
||||||
setDragIdx((d) => (d ? { ...d, over: li, chapter: ch.id } : d));
|
|
||||||
}}
|
}}
|
||||||
onDrop={() => {
|
onDrop={() => {
|
||||||
if (dragIdx && dragIdx.chapter === ch.id && dragIdx.from !== li) {
|
if (dragIdx && dragIdx.chapter === ch.id && dragIdx.from !== li) {
|
||||||
const idx = dragIdx.from;
|
const next = reorderArray(ls, dragIdx.from, li);
|
||||||
const next = reorderArray(ls, idx, li);
|
|
||||||
persistLessonOrder(next).catch(() => {});
|
persistLessonOrder(next).catch(() => {});
|
||||||
}
|
}
|
||||||
setDragIdx(null);
|
setDragIdx(null);
|
||||||
}}
|
}}
|
||||||
onDragEnd={() => setDragIdx(null)}
|
onDragEnd={() => setDragIdx(null)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group flex cursor-grab items-center gap-2 rounded-lg border border-border bg-card px-3 py-2.5 transition-colors active:cursor-grabbing",
|
"flex items-center gap-2 px-3 py-2.5 transition-colors hover:bg-muted/40",
|
||||||
dragIdx?.from === li && dragIdx.chapter === ch.id && "opacity-40",
|
dragIdx?.from === li && dragIdx.chapter === ch.id && "opacity-40",
|
||||||
!l.videoKey && "border-dashed"
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<GripVertical className="h-4 w-4 shrink-0 text-muted-foreground/50" />
|
{/* Grip handle */}
|
||||||
<span className="shrink-0 text-xs font-bold tabular-nums text-muted-foreground">L{l.id}</span>
|
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground/40 active:cursor-grabbing" aria-hidden />
|
||||||
|
|
||||||
|
{/* Lesson identity */}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm font-medium">{l.title}</p>
|
<p className="truncate text-sm font-medium leading-tight">
|
||||||
<div className="flex flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground">
|
{l.title}
|
||||||
<Timer className="h-3 w-3" /> {formatDur(l.durationSeconds)}
|
</p>
|
||||||
<Sparkles className="h-3 w-3 text-gold" /> +{formatFaNumber(l.xpReward)}
|
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
|
||||||
<MapPin className="h-3 w-3" /> {l.x},{l.y}
|
<span className="inline-flex items-center gap-0.5">
|
||||||
|
<Timer className="h-3 w-3" />
|
||||||
|
{formatDur(l.durationSeconds)}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-0.5 text-gold">
|
||||||
|
<Sparkles className="h-3 w-3" />
|
||||||
|
+{formatFaNumber(l.xpReward)}
|
||||||
|
</span>
|
||||||
{l.videoKey ? (
|
{l.videoKey ? (
|
||||||
<span className="inline-flex items-center gap-0.5 text-success"><Video className="h-3 w-3" /> ویدیو</span>
|
<span className="inline-flex items-center gap-0.5 text-success">
|
||||||
|
<Video className="h-3 w-3" /> ویدیو
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-warning">بدون ویدیو</span>
|
<span className="text-warning">بدون ویدیو</span>
|
||||||
)}
|
)}
|
||||||
{cols.length > 0 && <Badge variant="destructive" className="px-1 text-[9px]">تداخل</Badge>}
|
{collision && (
|
||||||
|
<Badge variant="destructive" className="px-1 text-[9px]">
|
||||||
|
تداخل موقعیت
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={l.isPublished ? "success" : "secondary"} className="shrink-0 px-1.5 text-[10px]">
|
|
||||||
|
{/* Publish badge */}
|
||||||
|
<Badge
|
||||||
|
variant={l.isPublished ? "success" : "secondary"}
|
||||||
|
className="hidden shrink-0 px-1.5 text-[10px] sm:inline-flex"
|
||||||
|
>
|
||||||
{l.isPublished ? "منتشر" : "پیشنویس"}
|
{l.isPublished ? "منتشر" : "پیشنویس"}
|
||||||
</Badge>
|
</Badge>
|
||||||
<div className="flex shrink-0 gap-0.5">
|
|
||||||
<GroupNav onUp={li > 0 ? () => moveLesson(ch.id, li, -1) : undefined} onDown={li < ls.length - 1 ? () => moveLesson(ch.id, li, 1) : undefined} compact />
|
{/* Up/down reorder (accessible) */}
|
||||||
<Button size="sm" variant="ghost" className="h-7 px-1.5" title="تکثیر" onClick={() => duplicateLesson(l)}>
|
<GroupNav
|
||||||
<Copy className="h-3.5 w-3.5" />
|
onUp={li > 0 ? () => moveLesson(ch.id, li, -1) : undefined}
|
||||||
</Button>
|
onDown={li < ls.length - 1 ? () => moveLesson(ch.id, li, 1) : undefined}
|
||||||
<Button size="sm" variant="ghost" className="h-7 px-1.5" onClick={() => setLessonModal(l)} title="ویرایش">
|
compact
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
/>
|
||||||
</Button>
|
|
||||||
|
{/* Edit (primary action) */}
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-7 px-1.5 text-[10px]"
|
className="h-8 w-8 shrink-0 px-0"
|
||||||
title={l.isPublished ? "برگشت به پیشنویس" : "انتشار درس"}
|
title="ویرایش درس"
|
||||||
onClick={() => publishLesson(l, !l.isPublished)}
|
aria-label={`ویرایش ${l.title}`}
|
||||||
|
onClick={() => setLessonModal(l)}
|
||||||
>
|
>
|
||||||
{l.isPublished ? "برگشت" : "انتشار"}
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="ghost" className="h-7 w-7 px-0 text-destructive opacity-60 hover:opacity-100" onClick={() => delLesson(l)} title="حذف درس">
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
{/* Secondary actions menu */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 shrink-0 px-0"
|
||||||
|
aria-label={`اقدامات ${l.title}`}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-44">
|
||||||
|
<DropdownMenuLabel className="truncate">L{l.id}</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem onClick={() => duplicateLesson(l)}>
|
||||||
|
<Copy className="h-3.5 w-3.5" /> تکثیر
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => publishLesson(l, !l.isPublished)}>
|
||||||
|
{l.isPublished ? (
|
||||||
|
<><EyeOff className="h-3.5 w-3.5" /> برگشت به پیشنویس</>
|
||||||
|
) : (
|
||||||
|
<><Globe className="h-3.5 w-3.5" /> انتشار درس</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
onClick={() => delLesson(l)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" /> حذف درس
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add lesson footer */}
|
||||||
|
<div className="border-t border-border/60 px-3 py-2.5">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="gap-1.5"
|
||||||
|
onClick={() => newLesson(ch.id)}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" /> درس جدید در این فصل
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
{chapters.length === 0 && !isLoading && (
|
{chapters.length === 0 && !isLoading && (
|
||||||
<div className="rounded-xl border border-dashed border-border p-8 text-center">
|
<div className="rounded-xl border border-dashed border-border p-10 text-center">
|
||||||
<p className="font-bold">هنوز فصلی نیست</p>
|
<p className="font-bold">هنوز فصلی نیست</p>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">با «فصل جدید» شروع کنید؛ درسهای هر فصل روی درخت مهارت هنرجو نمایش داده میشود.</p>
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
با «فصل جدید» شروع کنید؛ درسهای هر فصل روی درخت مهارت هنرجو نمایش داده میشود.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
className="mt-4"
|
||||||
|
onClick={() => setChapterModal({ id: 0, title: "", description: "", icon: "terminal", sortOrder: 1, isPublished: false })}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" /> فصل جدید
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<ChapterDialog chapter={chapterModal} onClose={() => setChapterModal(null)} onSave={saveChapter} />
|
<ChapterDialog chapter={chapterModal} onClose={() => setChapterModal(null)} onSave={saveChapter} />
|
||||||
<LessonDialog
|
<LessonDialog
|
||||||
@@ -1437,11 +1614,11 @@ function EventDialog({ event, onClose, onSave }: { event: AdminEvent | null; onC
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label>توضیحات</Label>
|
<Label>توضیحات</Label>
|
||||||
<textarea
|
<Textarea
|
||||||
value={draft.description}
|
value={draft.description}
|
||||||
onChange={(e) => set({ description: e.target.value })}
|
onChange={(e) => set({ description: e.target.value })}
|
||||||
rows={3}
|
rows={3}
|
||||||
className="w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
className="resize-y"
|
||||||
placeholder="شرح کوتاه رویداد (اختیاری)…"
|
placeholder="شرح کوتاه رویداد (اختیاری)…"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ import {
|
|||||||
} from "lucide-react";
|
} 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 { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -158,16 +158,10 @@ export function AnnouncePanel() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-6">
|
{/* Composer + Live Preview */}
|
||||||
<div data-announce-compose className="grid items-start gap-4 lg:grid-cols-[minmax(0,1fr)_20rem]">
|
<div className="grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_18rem]">
|
||||||
<Card>
|
{/* Form */}
|
||||||
<CardHeader className="space-y-1">
|
<div className="space-y-5">
|
||||||
<CardTitle className="text-base">نگارش اعلان</CardTitle>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
به همه هنرجویان فعال میرسد و در مرکز اعلانها دیده میشود.
|
|
||||||
</p>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-5">
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm font-bold">عنوان</Label>
|
<Label className="text-sm font-bold">عنوان</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -184,15 +178,12 @@ export function AnnouncePanel() {
|
|||||||
<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, 4000))}
|
onChange={(e) => setBody(e.target.value.slice(0, 4000))}
|
||||||
rows={5}
|
rows={5}
|
||||||
maxLength={4000}
|
maxLength={4000}
|
||||||
className={cn(
|
className={cn("resize-y", !bodyOk && body.length > 0 ? "border-destructive/50" : "")}
|
||||||
"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"
|
|
||||||
)}
|
|
||||||
placeholder="یک جملهٔ واضح و کوتاه — لینک یا زمان را حتماً بگویید…"
|
placeholder="یک جملهٔ واضح و کوتاه — لینک یا زمان را حتماً بگویید…"
|
||||||
/>
|
/>
|
||||||
{body.trim().length > 0 && body.trim().length < 8 && (
|
{body.trim().length > 0 && body.trim().length < 8 && (
|
||||||
@@ -236,9 +227,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>
|
<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
|
||||||
@@ -277,68 +268,65 @@ export function AnnouncePanel() {
|
|||||||
onClick={() => setConfirmOpen(true)}
|
onClick={() => setConfirmOpen(true)}
|
||||||
>
|
>
|
||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
پیشنمایش و ارسال
|
{editingId ? "پیشنمایش و ذخیره" : "پیشنمایش و ارسال"}
|
||||||
</Button>
|
</Button>
|
||||||
|
{editingId && (
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => { setEditingId(null); setTitle(""); setBody(""); setPinned(false); }}
|
||||||
|
>
|
||||||
|
انصراف از ویرایش
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{!valid && (
|
{!valid && (
|
||||||
<p className="text-xs text-muted-foreground">عنوان و پیام را کامل کنید تا ادامه فعال شود.</p>
|
<p className="text-xs text-muted-foreground">عنوان و پیام را کامل کنید تا ادامه فعال شود.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
{/* Sidebar: live preview + last sent */}
|
||||||
<Card className="border bg-muted/20">
|
<div className="space-y-4 lg:sticky lg:top-24">
|
||||||
<CardHeader className="space-y-1 pb-3">
|
<div className="space-y-1.5">
|
||||||
<CardTitle className="flex h-6 items-center gap-2 text-sm">
|
<p className="flex items-center gap-1.5 text-xs font-bold text-muted-foreground">
|
||||||
<Eye className="h-3.5 w-3.5" /> پیشنمایش زنده
|
<Eye className="h-3.5 w-3.5" /> پیشنمایش زنده
|
||||||
</CardTitle>
|
</p>
|
||||||
<p className="text-[11px] text-muted-foreground">همزمان با تایپ بهروز میشود.</p>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<PreviewCard title={title} body={body} category={category} route={route} />
|
<PreviewCard title={title} body={body} category={category} route={route} />
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
|
|
||||||
{lastSent && (
|
{lastSent && (
|
||||||
<Card className="border border-success/40 bg-success/5">
|
<div className="rounded-xl border border-success/40 bg-success/5 px-4 py-3 text-sm">
|
||||||
<CardContent className="space-y-1 pt-5 text-sm">
|
|
||||||
<p className="font-bold text-success">آخرین ارسال موفق</p>
|
<p className="font-bold text-success">آخرین ارسال موفق</p>
|
||||||
<p className="truncate font-extrabold">{lastSent.title}</p>
|
<p className="mt-0.5 truncate font-extrabold">{lastSent.title}</p>
|
||||||
<p className="text-xs text-muted-foreground">{lastSent.recipients} گیرنده</p>
|
<p className="text-xs text-muted-foreground">{lastSent.recipients} گیرنده</p>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card data-announce-list className="rounded-2xl border border-border">
|
{/* History */}
|
||||||
<CardHeader>
|
<div className="mt-8 space-y-3 border-t border-border/70 pt-6">
|
||||||
<CardTitle className="text-base">اطلاعیههای ثبتشده</CardTitle>
|
<h3 className="text-sm font-black text-muted-foreground">اطلاعیههای ثبتشده</h3>
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{(listQ.data?.announcements ?? []).length > 0 ? (
|
{(listQ.data?.announcements ?? []).length > 0 ? (
|
||||||
<ul className="flex list-none flex-col gap-3 p-0">
|
<ul className="flex list-none flex-col gap-2.5 p-0">
|
||||||
{(listQ.data?.announcements ?? []).map((a) => {
|
{(listQ.data?.announcements ?? []).map((a) => {
|
||||||
const cat = CATEGORIES.find((c) => c.id === a.category);
|
const cat = CATEGORIES.find((c) => c.id === a.category);
|
||||||
const excerpt = announcementExcerpt(a.body);
|
const excerpt = announcementExcerpt(a.body);
|
||||||
return (
|
return (
|
||||||
<li key={a.id} className="rounded-2xl border border-border bg-background p-4">
|
<li key={a.id} className="flex flex-col gap-3 rounded-2xl border border-border bg-background p-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<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="min-w-0 flex-1 space-y-2">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<p className="min-w-0 text-sm font-bold leading-snug">{a.title}</p>
|
<p className="min-w-0 text-sm font-bold leading-snug">{a.title}</p>
|
||||||
<Badge variant="secondary" className="text-[10px]">
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
{cat?.label ?? a.category}
|
{cat?.label ?? a.category}
|
||||||
</Badge>
|
</Badge>
|
||||||
{a.pinned ? (
|
{a.pinned && (
|
||||||
<Badge variant="gold" className="text-[10px]">
|
<Badge variant="gold" className="text-[10px]">سنجاق</Badge>
|
||||||
سنجاق
|
)}
|
||||||
</Badge>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
{excerpt ? (
|
{excerpt && (
|
||||||
<p className="line-clamp-2 text-xs leading-relaxed text-muted-foreground">{excerpt}</p>
|
<p className="line-clamp-2 text-xs leading-relaxed text-muted-foreground">{excerpt}</p>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -352,6 +340,7 @@ export function AnnouncePanel() {
|
|||||||
setCategory(a.category);
|
setCategory(a.category);
|
||||||
setRoute(a.route);
|
setRoute(a.route);
|
||||||
setPinned(a.pinned);
|
setPinned(a.pinned);
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
ویرایش
|
ویرایش
|
||||||
@@ -371,7 +360,6 @@ export function AnnouncePanel() {
|
|||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -381,8 +369,6 @@ export function AnnouncePanel() {
|
|||||||
هنوز اطلاعیهای ثبت نشده است.
|
هنوز اطلاعیهای ثبت نشده است.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { Plus, Trash2, Trophy } from "lucide-react";
|
import { Plus, Trash2, Trophy } 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 { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
@@ -86,20 +86,7 @@ export function ChallengesPanel() {
|
|||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
}).format(new Date(iso));
|
}).format(new Date(iso));
|
||||||
|
|
||||||
return (
|
const newChallenge = () =>
|
||||||
<>
|
|
||||||
{dialog}
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex-row items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<CardTitle className="text-base">چالشهای امتیاز</CardTitle>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
چالش ماهانه با هدف امتیاز؛ پیشرفت هنرجو با تکمیل آزمونها بهروز میشود.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
className="gap-1.5"
|
|
||||||
onClick={() =>
|
|
||||||
setModal({
|
setModal({
|
||||||
id: 0,
|
id: 0,
|
||||||
title: "",
|
title: "",
|
||||||
@@ -109,13 +96,21 @@ export function ChallengesPanel() {
|
|||||||
endsAt: "",
|
endsAt: "",
|
||||||
isActive: true,
|
isActive: true,
|
||||||
createdAt: "",
|
createdAt: "",
|
||||||
})
|
});
|
||||||
}
|
|
||||||
>
|
return (
|
||||||
|
<>
|
||||||
|
{dialog}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
پیشرفت هنرجو با تکمیل آزمونها بهروز میشود.
|
||||||
|
</p>
|
||||||
|
<Button className="shrink-0 gap-1.5" onClick={newChallenge}>
|
||||||
<Plus className="h-4 w-4" /> چالش جدید
|
<Plus className="h-4 w-4" /> چالش جدید
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent>
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{[0, 1].map((i) => (
|
{[0, 1].map((i) => (
|
||||||
@@ -124,8 +119,8 @@ export function ChallengesPanel() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isError && (
|
{isError && (
|
||||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm">
|
<div className="rounded-md border-2 border-destructive/30 bg-destructive/5 p-4 text-sm">
|
||||||
<p className="font-medium text-destructive">{toUserError(error)}</p>
|
<p className="font-bold text-destructive">{toUserError(error)}</p>
|
||||||
<Button size="sm" variant="outline" className="mt-2" onClick={() => refetch()}>
|
<Button size="sm" variant="outline" className="mt-2" onClick={() => refetch()}>
|
||||||
تلاش دوباره
|
تلاش دوباره
|
||||||
</Button>
|
</Button>
|
||||||
@@ -133,7 +128,7 @@ export function ChallengesPanel() {
|
|||||||
)}
|
)}
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
{(data?.challenges ?? []).map((c) => (
|
{(data?.challenges ?? []).map((c) => (
|
||||||
<div key={c.id} className="rounded-md border border-border p-4">
|
<div key={c.id} className="rounded-xl border border-border p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Trophy className="h-4 w-4 text-gold" />
|
<Trophy className="h-4 w-4 text-gold" />
|
||||||
<Badge variant={c.isActive ? "success" : "secondary"}>{c.isActive ? "فعال" : "خاموش"}</Badge>
|
<Badge variant={c.isActive ? "success" : "secondary"}>{c.isActive ? "فعال" : "خاموش"}</Badge>
|
||||||
@@ -147,7 +142,7 @@ export function ChallengesPanel() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 font-medium">{c.title}</p>
|
<p className="mt-2 font-bold">{c.title}</p>
|
||||||
<p className="line-clamp-2 text-xs text-muted-foreground">{c.description || "—"}</p>
|
<p className="line-clamp-2 text-xs text-muted-foreground">{c.description || "—"}</p>
|
||||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||||
{fmt(c.startsAt)} تا {fmt(c.endsAt)}
|
{fmt(c.startsAt)} تا {fmt(c.endsAt)}
|
||||||
@@ -156,11 +151,12 @@ export function ChallengesPanel() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{!isLoading && !isError && (data?.challenges ?? []).length === 0 && (
|
{!isLoading && !isError && (data?.challenges ?? []).length === 0 && (
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">هنوز چالشی نیست.</p>
|
<p className="rounded-2xl border border-dashed border-border px-4 py-6 text-center text-sm text-muted-foreground">
|
||||||
|
هنوز چالشی نیست.
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
<ChallengeDialog challenge={modal} onClose={() => setModal(null)} onSave={save} />
|
<ChallengeDialog challenge={modal} onClose={() => setModal(null)} onSave={save} />
|
||||||
</Card>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -199,11 +195,11 @@ function ChallengeDialog({
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label>توضیحات</Label>
|
<Label>توضیحات</Label>
|
||||||
<textarea
|
<Textarea
|
||||||
value={draft.description}
|
value={draft.description}
|
||||||
onChange={(e) => set({ description: e.target.value })}
|
onChange={(e) => set({ description: e.target.value })}
|
||||||
rows={3}
|
rows={3}
|
||||||
className="w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
className="resize-y"
|
||||||
placeholder="هدف و انگیزهٔ چالش…"
|
placeholder="هدف و انگیزهٔ چالش…"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ export function LearningPanel() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{(overviewQ.isError || studentsQ.isError) && (
|
{(overviewQ.isError || studentsQ.isError) && (
|
||||||
<div className="flex flex-wrap items-center gap-3 rounded-2xl border border-destructive/40 bg-destructive/5 px-4 py-3 text-sm">
|
<div className="flex flex-wrap items-center gap-3 rounded-2xl border-2 border-destructive/40 bg-destructive/5 px-4 py-3 text-sm">
|
||||||
<AlertTriangle className="h-4 w-4 shrink-0 text-destructive" />
|
<AlertTriangle className="h-4 w-4 shrink-0 text-destructive" />
|
||||||
<p className="min-w-0 flex-1 font-bold text-destructive">
|
<p className="min-w-0 flex-1 font-bold text-destructive">
|
||||||
{toUserError(overviewQ.error ?? studentsQ.error, "بارگذاری داشبورد یادگیری ممکن نشد")}
|
{toUserError(overviewQ.error ?? studentsQ.error, "بارگذاری داشبورد یادگیری ممکن نشد")}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { Loader2 } from "lucide-react";
|
|||||||
import { http, toUserError } from "@/lib/api";
|
import { http, toUserError } from "@/lib/api";
|
||||||
import type { CertificatePhysicalOrder, PhysicalCertSettings, PhysicalOrderStatus } from "@/lib/types";
|
import type { CertificatePhysicalOrder, PhysicalCertSettings, PhysicalOrderStatus } from "@/lib/types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { toast } from "@/components/providers";
|
import { toast } from "@/components/providers";
|
||||||
@@ -75,16 +74,16 @@ export function PhysicalOrdersPanel() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<div className="space-y-4">
|
||||||
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-3 space-y-0">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<CardTitle className="text-base">فهرست سفارشها</CardTitle>
|
<p className="text-xs text-muted-foreground">پیگیری درخواست و ارسال گواهینامه فیزیکی.</p>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<select
|
<select
|
||||||
className="h-10 rounded-lg border border-input bg-card px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="h-10 rounded-lg border border-input bg-card px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={(e) => setStatusFilter(e.target.value)}
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="">همه</option>
|
<option value="">همه وضعیتها</option>
|
||||||
<option value="requested">درخواستشده</option>
|
<option value="requested">درخواستشده</option>
|
||||||
<option value="paid">پرداختشده</option>
|
<option value="paid">پرداختشده</option>
|
||||||
<option value="shipped">ارسالشده</option>
|
<option value="shipped">ارسالشده</option>
|
||||||
@@ -97,15 +96,15 @@ export function PhysicalOrdersPanel() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent className="space-y-2">
|
<div className="space-y-2">
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="flex justify-center py-8">
|
<div className="flex justify-center py-8">
|
||||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(data?.orders ?? []).map((o) => (
|
{(data?.orders ?? []).map((o) => (
|
||||||
<div key={o.id} className="space-y-2 rounded-md border border-border px-3 py-3">
|
<div key={o.id} className="space-y-2 rounded-xl border border-border px-3 py-3">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">{o.userName ?? `کاربر ${o.userId}`}</p>
|
<p className="text-sm font-medium">{o.userName ?? `کاربر ${o.userId}`}</p>
|
||||||
@@ -167,7 +166,9 @@ export function PhysicalOrdersPanel() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{!isLoading && (data?.orders ?? []).length === 0 && (
|
{!isLoading && (data?.orders ?? []).length === 0 && (
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">سفارشی نیست.</p>
|
<p className="rounded-2xl border border-dashed border-border px-4 py-6 text-center text-sm text-muted-foreground">
|
||||||
|
سفارشی نیست.
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
<ListPagination
|
<ListPagination
|
||||||
page={page}
|
page={page}
|
||||||
@@ -175,7 +176,7 @@ export function PhysicalOrdersPanel() {
|
|||||||
total={data?.total ?? 0}
|
total={data?.total ?? 0}
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ function AccountFact({
|
|||||||
{icon}
|
{icon}
|
||||||
{label}
|
{label}
|
||||||
</dt>
|
</dt>
|
||||||
<dd className={cn("mt-1.5 text-sm font-semibold leading-relaxed [overflow-wrap:anywhere]", ltr && "font-latin")} dir={ltr ? "ltr" : undefined}>
|
<dd className={cn("mt-1.5 text-sm font-bold leading-relaxed [overflow-wrap:anywhere]", ltr && "font-latin")} dir={ltr ? "ltr" : undefined}>
|
||||||
{value}
|
{value}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -338,15 +339,15 @@ function QuestionDialog({
|
|||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm font-bold">متن سوال</Label>
|
<Label className="text-sm font-bold">متن سوال</Label>
|
||||||
<textarea
|
<Textarea
|
||||||
value={draft.question}
|
value={draft.question}
|
||||||
onChange={(e) => set({ question: e.target.value })}
|
onChange={(e) => set({ question: e.target.value })}
|
||||||
rows={3}
|
rows={3}
|
||||||
autoFocus
|
autoFocus
|
||||||
placeholder="مثلاً نتیجهٔ اجرای git status چیست؟"
|
placeholder="مثلاً نتیجهٔ اجرای git status چیست؟"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full resize-y rounded-xl border bg-background px-4 py-3 text-base font-medium leading-relaxed focus:outline-none focus:ring-2 focus:ring-primary",
|
"resize-y",
|
||||||
questionOk ? "border-input" : "border-destructive/40"
|
questionOk ? "" : "border-destructive/40"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{!questionOk && (
|
{!questionOk && (
|
||||||
@@ -485,7 +486,7 @@ function QuestionDialog({
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-7 min-w-7 shrink-0 items-center justify-center rounded-md border px-1.5 text-[11px] font-semibold",
|
"flex h-7 min-w-7 shrink-0 items-center justify-center rounded-md border px-1.5 text-[11px] font-bold",
|
||||||
isAns
|
isAns
|
||||||
? "border-primary bg-primary text-primary-foreground"
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
: "border-border text-muted-foreground"
|
: "border-border text-muted-foreground"
|
||||||
|
|||||||
@@ -1,21 +1,139 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Heart, Loader2, Package, Save, Settings2, Share2, Sparkles } from "lucide-react";
|
import { Camera, Heart, Loader2, Package, Save, Settings2, Share2, Sparkles, Trash2, Users } from "lucide-react";
|
||||||
import { http, toUserError } from "@/lib/api";
|
import { apiForm, 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";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { TagInput } from "@/components/ui/tag-input";
|
||||||
|
import { AvatarCropDialog } from "@/components/avatar-crop";
|
||||||
import { toast } from "@/components/providers";
|
import { toast } from "@/components/providers";
|
||||||
import type { AdminTabId } from "@/components/admin/nav";
|
import type { AdminTabId } from "@/components/admin/nav";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
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 };
|
type SocialDraft = { name: string; url: string };
|
||||||
|
type ContributorDraft = { name: string; role: string; bio: string; url: string; avatar: string; contributions: string[] };
|
||||||
|
|
||||||
|
function ContributorAvatarPicker({
|
||||||
|
value,
|
||||||
|
name,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
name: string;
|
||||||
|
onChange: (url: string) => void;
|
||||||
|
}) {
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [pending, setPending] = useState<File | null>(null);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const initials = name
|
||||||
|
.split(" ")
|
||||||
|
.map((w) => w[0])
|
||||||
|
.slice(0, 2)
|
||||||
|
.join("")
|
||||||
|
.toUpperCase() || "؟";
|
||||||
|
|
||||||
|
const onUpload = async (blob: Blob) => {
|
||||||
|
setUploading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", new File([blob], "avatar.png", { type: "image/png" }), "avatar.png");
|
||||||
|
const { url } = await apiForm<{ url: string }>("/api/admin/contributor-avatar", form);
|
||||||
|
onChange(url);
|
||||||
|
setPending(null);
|
||||||
|
} catch (e) {
|
||||||
|
setError(toUserError(e, "خطا در آپلود"));
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="relative shrink-0">
|
||||||
|
{value ? (
|
||||||
|
<img
|
||||||
|
src={value}
|
||||||
|
alt={name}
|
||||||
|
className="h-14 w-14 rounded-full border-2 border-border object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="flex h-14 w-14 items-center justify-center rounded-full border-2 border-dashed border-border bg-muted text-sm font-bold text-muted-foreground">
|
||||||
|
{initials}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{uploading && (
|
||||||
|
<span className="absolute inset-0 flex items-center justify-center rounded-full bg-background/70">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-primary" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={uploading}
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1 rounded-md bg-primary px-2.5 py-1 text-xs font-bold text-primary-foreground disabled:opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Camera className="h-3 w-3" />
|
||||||
|
{value ? "تغییر" : "آپلود"}
|
||||||
|
</button>
|
||||||
|
{value && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={uploading}
|
||||||
|
onClick={() => onChange("")}
|
||||||
|
className="flex items-center gap-1 rounded-md bg-destructive/10 px-2.5 py-1 text-xs font-bold text-destructive disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
حذف
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-[11px] text-destructive">{error}</p>}
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f) setPending(f);
|
||||||
|
e.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<AvatarCropDialog
|
||||||
|
file={pending}
|
||||||
|
busy={uploading}
|
||||||
|
error={error}
|
||||||
|
onCancel={() => {
|
||||||
|
if (uploading) return;
|
||||||
|
setPending(null);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
onInvalid={() => {
|
||||||
|
setPending(null);
|
||||||
|
setError("خواندن این عکس ممکن نشد. JPEG، PNG یا WebP را انتخاب کنید.");
|
||||||
|
}}
|
||||||
|
onCrop={(blob) => onUpload(blob)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SectionHeading({
|
function SectionHeading({
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
@@ -46,6 +164,7 @@ export function SiteSettingsPanel({
|
|||||||
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 [socials, setSocials] = useState<SocialDraft[]>([]);
|
||||||
|
const [contributors, setContributors] = useState<ContributorDraft[]>([]);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
@@ -76,6 +195,25 @@ export function SiteSettingsPanel({
|
|||||||
} catch {
|
} catch {
|
||||||
setSocials([]);
|
setSocials([]);
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data.settings.contributors || "[]") as Array<Record<string, unknown>>;
|
||||||
|
setContributors(
|
||||||
|
Array.isArray(parsed)
|
||||||
|
? parsed.map((c) => ({
|
||||||
|
name: typeof c.name === "string" ? c.name : "",
|
||||||
|
role: typeof c.role === "string" ? c.role : "",
|
||||||
|
bio: typeof c.bio === "string" ? c.bio : "",
|
||||||
|
url: typeof c.url === "string" ? c.url : "",
|
||||||
|
avatar: typeof c.avatar === "string" ? c.avatar : "",
|
||||||
|
contributions: Array.isArray(c.contributions)
|
||||||
|
? (c.contributions as unknown[]).filter((x): x is string => typeof x === "string")
|
||||||
|
: [],
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
setContributors([]);
|
||||||
|
}
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
const set = (key: string, value: string) => setDraft((d) => ({ ...d, [key]: value }));
|
const set = (key: string, value: string) => setDraft((d) => ({ ...d, [key]: value }));
|
||||||
@@ -126,11 +264,24 @@ export function SiteSettingsPanel({
|
|||||||
blurb: s.blurb.trim(),
|
blurb: s.blurb.trim(),
|
||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
|
contributors: JSON.stringify(
|
||||||
|
contributors
|
||||||
|
.filter((c) => c.name.trim())
|
||||||
|
.map((c) => ({
|
||||||
|
name: c.name.trim(),
|
||||||
|
role: c.role.trim(),
|
||||||
|
bio: c.bio.trim(),
|
||||||
|
url: c.url.trim(),
|
||||||
|
avatar: c.avatar.trim(),
|
||||||
|
contributions: c.contributions.map((s) => s.trim()).filter(Boolean),
|
||||||
|
})),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
await http.put("/api/admin/settings", { settings });
|
await http.put("/api/admin/settings", { settings });
|
||||||
toast.success("تنظیمات ذخیره شد");
|
toast.success("تنظیمات ذخیره شد");
|
||||||
qc.invalidateQueries({ queryKey: ["admin", "settings"] });
|
qc.invalidateQueries({ queryKey: ["admin", "settings"] });
|
||||||
qc.invalidateQueries({ queryKey: ["community"] });
|
qc.invalidateQueries({ queryKey: ["community"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["contributors"] });
|
||||||
qc.invalidateQueries({ queryKey: ["sponsors"] });
|
qc.invalidateQueries({ queryKey: ["sponsors"] });
|
||||||
qc.invalidateQueries({ queryKey: ["auth", "register-status"] });
|
qc.invalidateQueries({ queryKey: ["auth", "register-status"] });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -327,6 +478,97 @@ export function SiteSettingsPanel({
|
|||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div className="flex items-end justify-between gap-3">
|
||||||
|
<SectionHeading
|
||||||
|
icon={Users}
|
||||||
|
title="مشارکتکنندگان"
|
||||||
|
hint="افرادی که در ساخت پرگار سهیم بودهاند. در زبانه جامعه نمایش داده میشود."
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="mb-2 shrink-0"
|
||||||
|
onClick={() =>
|
||||||
|
setContributors((list) => [
|
||||||
|
...list,
|
||||||
|
{ name: "", role: "", bio: "", url: "", avatar: "", contributions: [] },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
افزودن
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{contributors.length === 0 && (
|
||||||
|
<p className="rounded-xl border border-dashed border-border px-4 py-8 text-center text-sm text-muted-foreground">
|
||||||
|
هنوز مشارکتکنندهای ثبت نشده. با «افزودن» شروع کنید.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{contributors.map((c, i) => (
|
||||||
|
<div key={i} className="space-y-3 rounded-xl border border-border p-3">
|
||||||
|
<ContributorAvatarPicker
|
||||||
|
value={c.avatar}
|
||||||
|
name={c.name || "مشارکتکننده"}
|
||||||
|
onChange={(url) =>
|
||||||
|
setContributors((list) => list.map((x, j) => (j === i ? { ...x, avatar: url } : x)))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
<Input
|
||||||
|
placeholder="نام"
|
||||||
|
value={c.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setContributors((list) => list.map((x, j) => (j === i ? { ...x, name: e.target.value } : x)))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="نقش (مثال: بنیانگذار)"
|
||||||
|
value={c.role}
|
||||||
|
onChange={(e) =>
|
||||||
|
setContributors((list) => list.map((x, j) => (j === i ? { ...x, role: e.target.value } : x)))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
placeholder="بیوگرافی کوتاه (اختیاری)"
|
||||||
|
value={c.bio}
|
||||||
|
rows={2}
|
||||||
|
onChange={(e) =>
|
||||||
|
setContributors((list) => list.map((x, j) => (j === i ? { ...x, bio: e.target.value } : x)))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="لینک پروفایل (اختیاری، https://…)"
|
||||||
|
value={c.url}
|
||||||
|
dir="ltr"
|
||||||
|
onChange={(e) =>
|
||||||
|
setContributors((list) => list.map((x, j) => (j === i ? { ...x, url: e.target.value } : x)))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-[11px] text-muted-foreground">دستههای مشارکت — Enter یا کاما برای افزودن</p>
|
||||||
|
<TagInput
|
||||||
|
value={c.contributions}
|
||||||
|
onChange={(tags) =>
|
||||||
|
setContributors((list) => list.map((x, j) => (j === i ? { ...x, contributions: tags } : x)))
|
||||||
|
}
|
||||||
|
placeholder="توسعه، طراحی، محتوا…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => setContributors((list) => list.filter((_, j) => j !== i))}
|
||||||
|
>
|
||||||
|
حذف مشارکتکننده
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<p className="text-[11px] text-muted-foreground">
|
||||||
رمزها، دیتابیس و JWT همچنان در فایل محیط (.env) میمانند — اینجا فقط تنظیمات قابلتغییر
|
رمزها، دیتابیس و JWT همچنان در فایل محیط (.env) میمانند — اینجا فقط تنظیمات قابلتغییر
|
||||||
روزمره است.
|
روزمره است.
|
||||||
|
|||||||
@@ -1,17 +1,50 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { ExternalLink, Share2 } from "lucide-react";
|
import { ExternalLink, Share2, Tag, Users } from "lucide-react";
|
||||||
import { http, toUserError } from "@/lib/api";
|
import { http, toUserError } from "@/lib/api";
|
||||||
import { Panel } from "@/components/panel";
|
import { Panel } from "@/components/panel";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import type { Contributor } from "@/lib/types";
|
||||||
|
|
||||||
export type SocialLink = { name: string; url: string };
|
export type SocialLink = { name: string; url: string };
|
||||||
|
|
||||||
export type CommunityPayload = {
|
export type CommunityPayload = {
|
||||||
socials: SocialLink[];
|
socials: SocialLink[];
|
||||||
|
contributors: Contributor[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function ContributorAvatar({ contributor }: { contributor: Contributor }) {
|
||||||
|
if (contributor.avatar) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={contributor.avatar}
|
||||||
|
alt={contributor.name}
|
||||||
|
className="h-12 w-12 shrink-0 rounded-full border-2 border-border object-cover"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.currentTarget as HTMLImageElement).style.display = "none";
|
||||||
|
(e.currentTarget.nextElementSibling as HTMLElement | null)?.removeAttribute("hidden");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const initials = contributor.name
|
||||||
|
.split(" ")
|
||||||
|
.map((w) => w[0])
|
||||||
|
.slice(0, 2)
|
||||||
|
.join("")
|
||||||
|
.toUpperCase();
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full border-2 border-border bg-primary/10 text-sm font-bold text-primary"
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function CommunityPanel() {
|
export function CommunityPanel() {
|
||||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||||
queryKey: ["community"],
|
queryKey: ["community"],
|
||||||
@@ -19,12 +52,17 @@ export function CommunityPanel() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <div className="h-72 animate-pulse rounded-2xl border-2 border-border bg-muted/40" />;
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl space-y-4">
|
||||||
|
<div className="h-40 animate-pulse rounded-2xl border-2 border-border bg-muted/40" />
|
||||||
|
<div className="h-64 animate-pulse rounded-2xl border-2 border-border bg-muted/40" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isError) {
|
if (isError) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-xl rounded-2xl border-2 border-destructive/30 bg-destructive/5 p-8 text-center">
|
<div className="mx-auto max-w-2xl rounded-2xl border-2 border-destructive/30 bg-destructive/5 p-8 text-center">
|
||||||
<p className="font-medium text-destructive">{toUserError(error)}</p>
|
<p className="font-medium text-destructive">{toUserError(error)}</p>
|
||||||
<Button className="mt-4" variant="outline" onClick={() => refetch()}>
|
<Button className="mt-4" variant="outline" onClick={() => refetch()}>
|
||||||
تلاش دوباره
|
تلاش دوباره
|
||||||
@@ -34,9 +72,11 @@ export function CommunityPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const socials = data?.socials ?? [];
|
const socials = data?.socials ?? [];
|
||||||
|
const contributors = data?.contributors ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-xl">
|
<div className="mx-auto max-w-2xl space-y-4">
|
||||||
|
{/* Social links */}
|
||||||
<Panel
|
<Panel
|
||||||
title="شبکههای پرگار"
|
title="شبکههای پرگار"
|
||||||
description="راههای رسمی ارتباط با برنامه. لینک شخصی هر کاربر روی پروفایل خودش است."
|
description="راههای رسمی ارتباط با برنامه. لینک شخصی هر کاربر روی پروفایل خودش است."
|
||||||
@@ -63,6 +103,61 @@ export function CommunityPanel() {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
{/* Contributors */}
|
||||||
|
<Panel
|
||||||
|
title="مشارکتکنندگان"
|
||||||
|
description="افرادی که در ساخت پرگار سهیم بودهاند. ممنون از هر کمکی."
|
||||||
|
icon={<Users className="h-5 w-5" />}
|
||||||
|
tint="bg-emerald-500/15 text-emerald-600 dark:text-emerald-400"
|
||||||
|
>
|
||||||
|
{contributors.length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-sm text-muted-foreground">اطلاعات مشارکتکنندگان هنوز ثبت نشده است.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-border/60">
|
||||||
|
{contributors.map((c, i) => (
|
||||||
|
<li key={i} className="flex flex-col gap-3 py-4 first:pt-0 last:pb-0 sm:flex-row sm:items-start">
|
||||||
|
<ContributorAvatar contributor={c} />
|
||||||
|
<div className="min-w-0 flex-1 space-y-1.5">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{c.url ? (
|
||||||
|
<a
|
||||||
|
href={c.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="flex items-center gap-1 font-bold hover:text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
<ExternalLink className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="font-bold">{c.name}</span>
|
||||||
|
)}
|
||||||
|
{c.role && (
|
||||||
|
<span className="text-xs text-muted-foreground">{c.role}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{c.bio && <p className="text-sm leading-relaxed text-muted-foreground">{c.bio}</p>}
|
||||||
|
{c.contributions.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{c.contributions.map((cat) => (
|
||||||
|
<Badge
|
||||||
|
key={cat}
|
||||||
|
variant="secondary"
|
||||||
|
className="gap-1 px-2 py-0.5 text-xs"
|
||||||
|
>
|
||||||
|
<Tag className="h-3 w-3" />
|
||||||
|
{cat}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function EmptyState({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-1 px-6">
|
<div className="space-y-1 px-6">
|
||||||
<p className="text-sm font-semibold">{title}</p>
|
<p className="text-sm font-bold">{title}</p>
|
||||||
{description && <p className="text-xs leading-relaxed text-muted-foreground sm:text-sm">{description}</p>}
|
{description && <p className="text-xs leading-relaxed text-muted-foreground sm:text-sm">{description}</p>}
|
||||||
</div>
|
</div>
|
||||||
{action && <div className="mt-1">{action}</div>}
|
{action && <div className="mt-1">{action}</div>}
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -68,7 +68,7 @@ export function LeaderboardPanel() {
|
|||||||
|
|
||||||
if (isError) {
|
if (isError) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-2xl rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
<div className="mx-auto max-w-2xl rounded-xl border-2 border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||||
<p className="font-medium text-destructive">{toUserError(error)}</p>
|
<p className="font-medium text-destructive">{toUserError(error)}</p>
|
||||||
<Button className="mt-3" variant="outline" onClick={() => refetch()}>
|
<Button className="mt-3" variant="outline" onClick={() => refetch()}>
|
||||||
تلاش دوباره
|
تلاش دوباره
|
||||||
|
|||||||
@@ -3269,7 +3269,7 @@ ws.onmessage = (ev) => {
|
|||||||
setOpen(true);
|
setOpen(true);
|
||||||
setNotif(null);
|
setNotif(null);
|
||||||
}}
|
}}
|
||||||
className="fixed bottom-24 start-5 end-5 z-[64] flex max-w-full items-center gap-2.5 rounded-2xl border-2 border-white/50 bg-card/85 p-2.5 text-right shadow-offset backdrop-blur-2xl sm:end-auto sm:max-w-[300px]"
|
className="fixed bottom-[calc(5.5rem+env(safe-area-inset-bottom,0px))] start-5 end-5 z-[64] flex max-w-full items-center gap-2.5 rounded-2xl border-2 border-white/50 bg-card/85 p-2.5 text-right shadow-offset backdrop-blur-2xl sm:bottom-24 sm:end-auto sm:max-w-[300px]"
|
||||||
>
|
>
|
||||||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-accent/15">
|
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-accent/15">
|
||||||
<MessageSquare className="h-4 w-4 text-accent" />
|
<MessageSquare className="h-4 w-4 text-accent" />
|
||||||
@@ -3286,7 +3286,9 @@ ws.onmessage = (ev) => {
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{me && !open && (
|
{me && !open && (
|
||||||
<div className="fixed bottom-5 start-5 z-[64]">
|
// On mobile the bottom nav is 56px; lift FAB above it + safe area + gap.
|
||||||
|
// md:bottom-5 restores original position when the bottom nav is hidden.
|
||||||
|
<div className="fixed bottom-[calc(4.5rem+env(safe-area-inset-bottom,0px))] start-5 z-[64] md:bottom-5">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{unreadTotal > 0 && (
|
{unreadTotal > 0 && (
|
||||||
<span className="pointer-events-none absolute -inset-1 -z-10 animate-ping rounded-2xl bg-accent/40" aria-hidden />
|
<span className="pointer-events-none absolute -inset-1 -z-10 animate-ping rounded-2xl bg-accent/40" aria-hidden />
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { Flame, Heart, LogOut, Sparkles, User, UserCog } from "lucide-react";
|
import { Flame, Heart, LogOut, Palette, Settings, Sparkles, User, UserCog } from "lucide-react";
|
||||||
import { NotificationBell } from "@/components/notification-bell";
|
import { NotificationBell } from "@/components/notification-bell";
|
||||||
import { ThemeToggle } from "@/components/theme-toggle";
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
import { BrandMark } from "@/components/brand-mark";
|
import { BrandMark } from "@/components/brand-mark";
|
||||||
@@ -28,7 +28,6 @@ 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";
|
import { isVerifiedRole, VerifiedBadge } from "@/components/verified-badge";
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
@@ -65,7 +64,6 @@ 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">
|
||||||
@@ -188,10 +186,10 @@ export function Navbar() {
|
|||||||
<User /> پروفایل من
|
<User /> پروفایل من
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => router.push("/settings")}>
|
<DropdownMenuItem onClick={() => router.push("/settings")}>
|
||||||
<User /> تنظیمات
|
<Settings /> تنظیمات
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={() => setStudioOpen(true)}>
|
<DropdownMenuItem onSelect={() => setStudioOpen(true)}>
|
||||||
<User /> ویرایش آواتار
|
<Palette /> ویرایش آواتار
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{liveUser.role === "admin" && (
|
{liveUser.role === "admin" && (
|
||||||
<DropdownMenuItem onClick={() => router.push("/admin")}>
|
<DropdownMenuItem onClick={() => router.push("/admin")}>
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export function NotificationBell() {
|
|||||||
<div dir="rtl" className="text-start">
|
<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="flex items-center justify-between gap-3 border-b border-border px-3 py-2.5">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-semibold">اعلانها</p>
|
<p className="text-sm font-bold">اعلانها</p>
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<p className="text-[11px] text-muted-foreground">
|
||||||
{unread > 0 ? `${unread} خواندهنشده` : "همه خوانده شده"}
|
{unread > 0 ? `${unread} خواندهنشده` : "همه خوانده شده"}
|
||||||
</p>
|
</p>
|
||||||
@@ -133,7 +133,7 @@ export function NotificationBell() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => router.push("/notifications")}
|
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"
|
className="w-full border-t border-border bg-muted/40 py-2.5 text-xs font-bold text-primary hover:bg-muted"
|
||||||
>
|
>
|
||||||
مشاهده همه اعلانها
|
مشاهده همه اعلانها
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ export function QuizExperience({ lessonId }: { lessonId: number }) {
|
|||||||
return (
|
return (
|
||||||
<Card className="mx-auto max-w-md p-8 text-center">
|
<Card className="mx-auto max-w-md p-8 text-center">
|
||||||
<Lock className="mx-auto mb-3 h-10 w-10 text-destructive" />
|
<Lock className="mx-auto mb-3 h-10 w-10 text-destructive" />
|
||||||
<h2 className="mb-2 text-lg font-semibold">آزمون در دسترس نیست</h2>
|
<h2 className="mb-2 text-lg font-bold">آزمون در دسترس نیست</h2>
|
||||||
<p className="mb-4 text-sm text-muted-foreground">{msg}</p>
|
<p className="mb-4 text-sm text-muted-foreground">{msg}</p>
|
||||||
<Button onClick={() => router.push(`/player/${lessonId}`)}>ابتدا درس را تماشا کنید</Button>
|
<Button onClick={() => router.push(`/player/${lessonId}`)}>ابتدا درس را تماشا کنید</Button>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -188,7 +188,7 @@ export function QuizExperience({ lessonId }: { lessonId: number }) {
|
|||||||
<Badge variant="secondary" className="h-6 w-6 justify-center rounded-md p-0">
|
<Badge variant="secondary" className="h-6 w-6 justify-center rounded-md p-0">
|
||||||
{qi + 1}
|
{qi + 1}
|
||||||
</Badge>
|
</Badge>
|
||||||
<p className="font-medium leading-snug">{q.question}</p>
|
<p className="font-bold leading-snug">{q.question}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
{q.options.map((opt, oi) => {
|
{q.options.map((opt, oi) => {
|
||||||
@@ -198,7 +198,7 @@ export function QuizExperience({ lessonId }: { lessonId: number }) {
|
|||||||
key={oi}
|
key={oi}
|
||||||
onClick={() => select(q.id, oi)}
|
onClick={() => select(q.id, oi)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-3 rounded-md border px-4 py-3 text-right text-sm transition-colors",
|
"flex items-center gap-3 rounded-md border-2 px-4 py-3 text-right text-sm transition-colors",
|
||||||
active
|
active
|
||||||
? "border-primary bg-primary/10 ring-1 ring-primary"
|
? "border-primary bg-primary/10 ring-1 ring-primary"
|
||||||
: "border-border hover:bg-muted/50"
|
: "border-border hover:bg-muted/50"
|
||||||
@@ -206,7 +206,7 @@ export function QuizExperience({ lessonId }: { lessonId: number }) {
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-7 min-w-7 shrink-0 items-center justify-center rounded-md border px-1.5 text-[11px] font-semibold",
|
"flex h-7 min-w-7 shrink-0 items-center justify-center rounded-md border px-1.5 text-[11px] font-bold",
|
||||||
active ? "border-primary bg-primary text-primary-foreground" : "border-border text-muted-foreground"
|
active ? "border-primary bg-primary text-primary-foreground" : "border-border text-muted-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -305,7 +305,7 @@ function HeartsMeterInline({ hearts, infinite }: { hearts: number; infinite?: bo
|
|||||||
|
|
||||||
function PassScreen({ result, onNext, onLeagues }: { result: QuizResult; onNext: () => void; onLeagues: () => void }) {
|
function PassScreen({ result, onNext, onLeagues }: { result: QuizResult; onNext: () => void; onLeagues: () => void }) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-md rounded-lg border border-border bg-card p-10 text-center">
|
<div className="mx-auto max-w-md rounded-2xl border-2 border-border bg-card p-10 text-center">
|
||||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-lg bg-primary">
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-lg bg-primary">
|
||||||
<PartyPopper className="h-9 w-9 text-primary-foreground" />
|
<PartyPopper className="h-9 w-9 text-primary-foreground" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -103,21 +103,26 @@ export function SectionPanel({
|
|||||||
titleId,
|
titleId,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
|
actions,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
id?: string;
|
id?: string;
|
||||||
titleId: string;
|
titleId: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
actions?: ReactNode;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section id={id} aria-labelledby={titleId} className="min-w-0 rounded-2xl border-2 border-border bg-card">
|
<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">
|
<header className="border-b border-border/70 px-5 py-4 sm:px-6">
|
||||||
<h2 id={titleId} className="text-lg font-black">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
{title}
|
<div className="min-w-0">
|
||||||
</h2>
|
<h2 id={titleId} className="text-lg font-black">{title}</h2>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
{description && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
|
||||||
|
</div>
|
||||||
|
{actions && <div className="shrink-0">{actions}</div>}
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="p-5 sm:p-6">{children}</div>
|
<div className="p-5 sm:p-6">{children}</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import type { TreeChapter, TreeData, TreeLesson } from "@/lib/types";
|
import type { TreeChapter, TreeData, TreeLesson } from "@/lib/types";
|
||||||
|
import { useAuth } from "@/lib/auth-store";
|
||||||
import { cn, formatFaNumber } from "@/lib/utils";
|
import { cn, formatFaNumber } from "@/lib/utils";
|
||||||
|
|
||||||
const CHAPTER_ICONS: Record<string, typeof Terminal> = {
|
const CHAPTER_ICONS: Record<string, typeof Terminal> = {
|
||||||
@@ -38,6 +39,8 @@ const CHAPTER_ICONS: Record<string, typeof Terminal> = {
|
|||||||
|
|
||||||
export function SkillTree({ data, onSelect }: { data: TreeData; onSelect?: (id: number) => void }) {
|
export function SkillTree({ data, onSelect }: { data: TreeData; onSelect?: (id: number) => void }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const role = useAuth((s) => s.user?.role);
|
||||||
|
const isStaff = role === "admin" || role === "mentor";
|
||||||
|
|
||||||
const chapters = useMemo(() => [...data.chapters].sort((a, b) => a.sortOrder - b.sortOrder), [data]);
|
const chapters = useMemo(() => [...data.chapters].sort((a, b) => a.sortOrder - b.sortOrder), [data]);
|
||||||
const byId = useMemo(() => {
|
const byId = useMemo(() => {
|
||||||
@@ -97,6 +100,7 @@ export function SkillTree({ data, onSelect }: { data: TreeData; onSelect?: (id:
|
|||||||
open={expanded.has(ch.id)}
|
open={expanded.has(ch.id)}
|
||||||
onToggle={() => toggle(ch.id)}
|
onToggle={() => toggle(ch.id)}
|
||||||
onSelect={onSelect ?? ((id: number) => router.push(`/player/${id}`))}
|
onSelect={onSelect ?? ((id: number) => router.push(`/player/${id}`))}
|
||||||
|
isStaff={isStaff}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -110,12 +114,14 @@ function ChapterRow({
|
|||||||
open,
|
open,
|
||||||
onToggle,
|
onToggle,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
isStaff,
|
||||||
}: {
|
}: {
|
||||||
ch: TreeChapter;
|
ch: TreeChapter;
|
||||||
byId: Map<number, TreeLesson>;
|
byId: Map<number, TreeLesson>;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
onSelect: (id: number) => void;
|
onSelect: (id: number) => void;
|
||||||
|
isStaff: boolean;
|
||||||
}) {
|
}) {
|
||||||
const lessons = [...(ch.lessons ?? [])].sort((a, b) => a.y - b.y || a.x - b.x);
|
const lessons = [...(ch.lessons ?? [])].sort((a, b) => a.y - b.y || a.x - b.x);
|
||||||
const done = lessons.filter((l) => l.progress?.passedQuiz).length;
|
const done = lessons.filter((l) => l.progress?.passedQuiz).length;
|
||||||
@@ -147,8 +153,13 @@ function ChapterRow({
|
|||||||
<CheckCircle2 className="h-3.5 w-3.5" /> کامل شد
|
<CheckCircle2 className="h-3.5 w-3.5" /> کامل شد
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{isStaff && !ch.isPublished && (
|
||||||
|
<span className="hidden shrink-0 rounded-full border border-dashed border-warning/50 bg-warning/10 px-2 py-0.5 text-[11px] font-bold text-warning sm:inline-block">
|
||||||
|
پیشنویس
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="hidden shrink-0 rounded-full bg-muted px-2 py-0.5 text-[11px] font-bold text-muted-foreground sm:inline-block">
|
<span className="hidden shrink-0 rounded-full bg-muted px-2 py-0.5 text-[11px] font-bold text-muted-foreground sm:inline-block">
|
||||||
هفتهٔ {ch.sortOrder}
|
فصل {ch.sortOrder}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="truncate text-xs text-muted-foreground">{ch.description}</p>
|
<p className="truncate text-xs text-muted-foreground">{ch.description}</p>
|
||||||
@@ -173,7 +184,7 @@ function ChapterRow({
|
|||||||
<div className="overflow-hidden">
|
<div className="overflow-hidden">
|
||||||
<div className="grid gap-2.5 border-t-2 border-border/70 p-4 sm:grid-cols-2">
|
<div className="grid gap-2.5 border-t-2 border-border/70 p-4 sm:grid-cols-2">
|
||||||
{lessons.map((lesson) => (
|
{lessons.map((lesson) => (
|
||||||
<LessonRow key={lesson.id} lesson={lesson} byId={byId} onSelect={onSelect} />
|
<LessonRow key={lesson.id} lesson={lesson} byId={byId} onSelect={onSelect} isStaff={isStaff} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -186,10 +197,12 @@ function LessonRow({
|
|||||||
lesson,
|
lesson,
|
||||||
byId,
|
byId,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
isStaff,
|
||||||
}: {
|
}: {
|
||||||
lesson: TreeLesson;
|
lesson: TreeLesson;
|
||||||
byId: Map<number, TreeLesson>;
|
byId: Map<number, TreeLesson>;
|
||||||
onSelect: (id: number) => void;
|
onSelect: (id: number) => void;
|
||||||
|
isStaff: boolean;
|
||||||
}) {
|
}) {
|
||||||
const passed = !!lesson.progress?.passedQuiz;
|
const passed = !!lesson.progress?.passedQuiz;
|
||||||
const locked = lesson.locked;
|
const locked = lesson.locked;
|
||||||
@@ -254,6 +267,11 @@ function LessonRow({
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-1.5 flex flex-wrap items-center gap-2">
|
<div className="mt-1.5 flex flex-wrap items-center gap-2">
|
||||||
<span className={cn("rounded-full px-2 py-0.5 text-[11px] font-bold", s.pill)}>{s.pillText}</span>
|
<span className={cn("rounded-full px-2 py-0.5 text-[11px] font-bold", s.pill)}>{s.pillText}</span>
|
||||||
|
{isStaff && lesson.isPublished === false && (
|
||||||
|
<span className="rounded-full border border-dashed border-warning/50 bg-warning/10 px-2 py-0.5 text-[11px] font-bold text-warning">
|
||||||
|
پیشنویس
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{inProgress && (
|
{inProgress && (
|
||||||
<div className="h-1.5 w-24 overflow-hidden rounded-full bg-muted">
|
<div className="h-1.5 w-24 overflow-hidden rounded-full bg-muted">
|
||||||
<div className="h-full rounded-full bg-primary" style={{ width: `${pct}%` }} />
|
<div className="h-full rounded-full bg-primary" style={{ width: `${pct}%` }} />
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ CardHeader.displayName = "CardHeader";
|
|||||||
|
|
||||||
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
({ className, ...props }, ref) => (
|
({ className, ...props }, ref) => (
|
||||||
<div ref={ref} className={cn("text-xl font-semibold leading-none", className)} {...props} />
|
<div ref={ref} className={cn("text-xl font-bold leading-none", className)} {...props} />
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
CardTitle.displayName = "CardTitle";
|
CardTitle.displayName = "CardTitle";
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ const DialogTitle = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<DialogPrimitive.Title
|
<DialogPrimitive.Title
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
className={cn("text-lg font-bold leading-none tracking-tight", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -16,12 +16,13 @@ const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
dir="ltr"
|
||||||
className={cn("relative h-2.5 w-full overflow-hidden rounded-full bg-muted", className)}
|
className={cn("relative h-2.5 w-full overflow-hidden rounded-full bg-muted", className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-full rounded-full will-change-[width] rtl:me-auto",
|
"h-full rounded-full will-change-[width]",
|
||||||
gradient ? "bg-primary" : "bg-primary",
|
gradient ? "bg-primary" : "bg-primary",
|
||||||
smooth
|
smooth
|
||||||
? "transition-[width] duration-1000 ease-[cubic-bezier(0.22,1,0.36,1)]"
|
? "transition-[width] duration-1000 ease-[cubic-bezier(0.22,1,0.36,1)]"
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface TagInputProps {
|
||||||
|
value: string[];
|
||||||
|
onChange: (tags: string[]) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
maxTags?: number;
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TagInput({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = "افزودن برچسب…",
|
||||||
|
maxTags = 20,
|
||||||
|
className,
|
||||||
|
disabled,
|
||||||
|
}: TagInputProps) {
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const add = (raw: string) => {
|
||||||
|
const tag = raw.trim();
|
||||||
|
if (!tag) return;
|
||||||
|
if (value.some((t) => t.toLowerCase() === tag.toLowerCase())) return;
|
||||||
|
if (value.length >= maxTags) return;
|
||||||
|
onChange([...value, tag]);
|
||||||
|
setInput("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = (index: number) => {
|
||||||
|
onChange(value.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === "Enter" || e.key === ",") {
|
||||||
|
e.preventDefault();
|
||||||
|
add(input);
|
||||||
|
} else if (e.key === "Backspace" && !input && value.length > 0) {
|
||||||
|
remove(value.length - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-9 flex-wrap gap-1.5 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2",
|
||||||
|
disabled && "cursor-not-allowed opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
onClick={() => inputRef.current?.focus()}
|
||||||
|
>
|
||||||
|
{value.map((tag, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
{!disabled && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
remove(i);
|
||||||
|
}}
|
||||||
|
className="ml-0.5 rounded-sm opacity-60 transition-opacity hover:opacity-100"
|
||||||
|
aria-label={`حذف ${tag}`}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{!disabled && value.length < maxTags && (
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
onBlur={() => add(input)}
|
||||||
|
placeholder={value.length === 0 ? placeholder : ""}
|
||||||
|
className="min-w-[120px] flex-1 bg-transparent outline-none placeholder:text-muted-foreground"
|
||||||
|
dir="auto"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,13 +10,10 @@ import {
|
|||||||
Minimize,
|
Minimize,
|
||||||
Pause,
|
Pause,
|
||||||
Play,
|
Play,
|
||||||
RotateCcw,
|
Rewind,
|
||||||
Settings,
|
|
||||||
SkipBack,
|
|
||||||
Volume1,
|
Volume1,
|
||||||
Volume2,
|
Volume2,
|
||||||
VolumeX,
|
VolumeX,
|
||||||
Zap,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import type Hls from "hls.js";
|
import type Hls from "hls.js";
|
||||||
@@ -35,8 +32,6 @@ const CONTINUOUS_PLAY_MAX_DT = 3;
|
|||||||
const ALLOWED_SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2] as const;
|
const ALLOWED_SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2] as const;
|
||||||
type Speed = (typeof ALLOWED_SPEEDS)[number];
|
type Speed = (typeof ALLOWED_SPEEDS)[number];
|
||||||
const MAX_RETRY_DELAY_MS = 30_000;
|
const MAX_RETRY_DELAY_MS = 30_000;
|
||||||
const AMBIENT_FPS = 8;
|
|
||||||
const AMBIENT_INTERVAL = 1000 / AMBIENT_FPS;
|
|
||||||
const DOUBLE_TAP_MS = 300;
|
const DOUBLE_TAP_MS = 300;
|
||||||
const DOUBLE_TAP_SLOP = 60;
|
const DOUBLE_TAP_SLOP = 60;
|
||||||
|
|
||||||
@@ -54,7 +49,6 @@ function writeStorage(key: string, value: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isNumber = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
|
const isNumber = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
|
||||||
const isBoolean = (v: unknown): v is boolean => typeof v === "boolean";
|
|
||||||
|
|
||||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||||
export function VideoPlayer({
|
export function VideoPlayer({
|
||||||
@@ -73,9 +67,6 @@ export function VideoPlayer({
|
|||||||
// ── Refs ────────────────────────────────────────────────────────────────────
|
// ── Refs ────────────────────────────────────────────────────────────────────
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const ambientCanvasRef = useRef<HTMLCanvasElement>(null);
|
|
||||||
const ambientRafRef = useRef(0);
|
|
||||||
const ambientLastTickRef = useRef(0);
|
|
||||||
const hlsRef = useRef<Hls | null>(null);
|
const hlsRef = useRef<Hls | null>(null);
|
||||||
|
|
||||||
const lockedSrcRef = useRef(videoUrl);
|
const lockedSrcRef = useRef(videoUrl);
|
||||||
@@ -98,7 +89,6 @@ export function VideoPlayer({
|
|||||||
|
|
||||||
// ── Persisted prefs ─────────────────────────────────────────────────────────
|
// ── Persisted prefs ─────────────────────────────────────────────────────────
|
||||||
const [volume, setVolume] = useState(() => readStorage("pargar:volume", 1, isNumber));
|
const [volume, setVolume] = useState(() => readStorage("pargar:volume", 1, isNumber));
|
||||||
const [ambientMode, setAmbientMode] = useState(() => readStorage("pargar:ambient", true, isBoolean));
|
|
||||||
const [speed, setSpeed] = useState<Speed>(() => {
|
const [speed, setSpeed] = useState<Speed>(() => {
|
||||||
const s = readStorage("pargar:speed", 1, isNumber);
|
const s = readStorage("pargar:speed", 1, isNumber);
|
||||||
return (ALLOWED_SPEEDS as readonly number[]).includes(s) ? s as Speed : 1;
|
return (ALLOWED_SPEEDS as readonly number[]).includes(s) ? s as Speed : 1;
|
||||||
@@ -121,12 +111,9 @@ export function VideoPlayer({
|
|||||||
const [mediaDuration, setMediaDuration] = useState(duration);
|
const [mediaDuration, setMediaDuration] = useState(duration);
|
||||||
const [fullscreen, setFullscreen] = useState(false);
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
const [theaterMode, setTheaterMode] = useState(false);
|
const [theaterMode, setTheaterMode] = useState(false);
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
||||||
const [seekIndicator, setSeekIndicator] = useState<"back" | null>(null);
|
const [seekIndicator, setSeekIndicator] = useState<"back" | null>(null);
|
||||||
|
|
||||||
const hideTimer = useRef<number | null>(null);
|
const hideTimer = useRef<number | null>(null);
|
||||||
const settingsPanelRef = useRef<HTMLDivElement>(null);
|
|
||||||
const settingsBtnRef = useRef<HTMLButtonElement>(null);
|
|
||||||
|
|
||||||
// ── Utilities ───────────────────────────────────────────────────────────────
|
// ── Utilities ───────────────────────────────────────────────────────────────
|
||||||
const warnSeekBlocked = useCallback(() => {
|
const warnSeekBlocked = useCallback(() => {
|
||||||
@@ -209,12 +196,6 @@ export function VideoPlayer({
|
|||||||
changeSpeed(next);
|
changeSpeed(next);
|
||||||
}, [changeSpeed]);
|
}, [changeSpeed]);
|
||||||
|
|
||||||
const toggleAmbient = useCallback(() => {
|
|
||||||
setAmbientMode((prev) => {
|
|
||||||
writeStorage("pargar:ambient", !prev);
|
|
||||||
return !prev;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ── HLS init ────────────────────────────────────────────────────────────────
|
// ── HLS init ────────────────────────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -445,25 +426,6 @@ export function VideoPlayer({
|
|||||||
return () => document.removeEventListener("fullscreenchange", onFsChange);
|
return () => document.removeEventListener("fullscreenchange", onFsChange);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ── Ambient mode rAF loop ───────────────────────────────────────────────────
|
|
||||||
useEffect(() => {
|
|
||||||
if (!ambientMode) { cancelAnimationFrame(ambientRafRef.current); return; }
|
|
||||||
const tick = (now: DOMHighResTimeStamp) => {
|
|
||||||
ambientRafRef.current = requestAnimationFrame(tick);
|
|
||||||
if (now - ambientLastTickRef.current < AMBIENT_INTERVAL) return;
|
|
||||||
ambientLastTickRef.current = now;
|
|
||||||
const v = videoRef.current;
|
|
||||||
const canvas = ambientCanvasRef.current;
|
|
||||||
if (!v || !canvas || v.readyState < 2 || v.paused) return;
|
|
||||||
try {
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
if (ctx) ctx.drawImage(v, 0, 0, canvas.width, canvas.height);
|
|
||||||
} catch { setAmbientMode(false); } // CORS taint
|
|
||||||
};
|
|
||||||
ambientRafRef.current = requestAnimationFrame(tick);
|
|
||||||
return () => cancelAnimationFrame(ambientRafRef.current);
|
|
||||||
}, [ambientMode, playing]);
|
|
||||||
|
|
||||||
// ── Keyboard shortcuts ──────────────────────────────────────────────────────
|
// ── Keyboard shortcuts ──────────────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
@@ -492,19 +454,6 @@ export function VideoPlayer({
|
|||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
}, [toggle, skipBack, warnSeekBlocked, poke, toggleFullscreen]);
|
}, [toggle, skipBack, warnSeekBlocked, poke, toggleFullscreen]);
|
||||||
|
|
||||||
// ── Settings click-outside close ────────────────────────────────────────────
|
|
||||||
useEffect(() => {
|
|
||||||
if (!settingsOpen) return;
|
|
||||||
const onMouseDown = (e: MouseEvent) => {
|
|
||||||
const target = e.target as Node;
|
|
||||||
if (settingsPanelRef.current?.contains(target)) return;
|
|
||||||
if (settingsBtnRef.current?.contains(target)) return;
|
|
||||||
setSettingsOpen(false);
|
|
||||||
};
|
|
||||||
document.addEventListener("mousedown", onMouseDown);
|
|
||||||
return () => document.removeEventListener("mousedown", onMouseDown);
|
|
||||||
}, [settingsOpen]);
|
|
||||||
|
|
||||||
// ── Cleanup timers ──────────────────────────────────────────────────────────
|
// ── Cleanup timers ──────────────────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -611,8 +560,8 @@ export function VideoPlayer({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"space-y-4",
|
"space-y-4",
|
||||||
// Theater mode: break out of the page's max-w container with a full-bleed trick
|
// Theater mode: on mobile remove page padding; on sm+ break out to full-bleed
|
||||||
theaterMode && "relative left-1/2 right-1/2 -mx-[50vw] w-screen",
|
theaterMode && "max-sm:-mx-4 max-sm:w-[calc(100%+2rem)] sm:relative sm:left-1/2 sm:right-1/2 sm:-mx-[50vw] sm:w-screen",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* ── Player container ─── */}
|
{/* ── Player container ─── */}
|
||||||
@@ -627,24 +576,6 @@ export function VideoPlayer({
|
|||||||
onMouseMove={poke}
|
onMouseMove={poke}
|
||||||
onPointerDown={handlePointerDown}
|
onPointerDown={handlePointerDown}
|
||||||
>
|
>
|
||||||
{/* Ambient canvas (behind video) */}
|
|
||||||
<div className="pointer-events-none absolute inset-0 z-0 overflow-hidden" aria-hidden>
|
|
||||||
<canvas
|
|
||||||
ref={ambientCanvasRef}
|
|
||||||
width={32}
|
|
||||||
height={18}
|
|
||||||
className={cn(
|
|
||||||
"absolute inset-[-15%] h-[130%] w-[130%] transition-opacity duration-1000",
|
|
||||||
ambientMode && playing ? "opacity-100" : "opacity-0",
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
filter: "blur(55px) saturate(1.4)",
|
|
||||||
transform: "translate3d(0,0,0)",
|
|
||||||
willChange: "filter, transform",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Video element */}
|
{/* Video element */}
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
@@ -716,7 +647,7 @@ export function VideoPlayer({
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Seek indicator (double-tap feedback) */}
|
{/* Seek indicator (rewind feedback — physical right side) */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{seekIndicator && (
|
{seekIndicator && (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -724,10 +655,10 @@ export function VideoPlayer({
|
|||||||
initial={{ opacity: 0, scale: 0.7 }}
|
initial={{ opacity: 0, scale: 0.7 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
exit={{ opacity: 0, scale: 0.7 }}
|
exit={{ opacity: 0, scale: 0.7 }}
|
||||||
className="pointer-events-none absolute start-8 top-1/2 z-30 flex -translate-y-1/2 flex-col items-center gap-1"
|
className="pointer-events-none absolute right-8 top-1/2 z-30 flex -translate-y-1/2 flex-col items-center gap-1"
|
||||||
>
|
>
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-white/20 backdrop-blur-sm">
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-white/20 backdrop-blur-sm">
|
||||||
<SkipBack className="h-6 w-6 text-white" />
|
<Rewind className="h-6 w-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-bold text-white drop-shadow">۱۰ ثانیه</span>
|
<span className="text-xs font-bold text-white drop-shadow">۱۰ ثانیه</span>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -749,159 +680,91 @@ export function VideoPlayer({
|
|||||||
<span className="ms-auto tabular-nums">{fmt(position)} / {fmt(mediaDuration)}</span>
|
<span className="ms-auto tabular-nums">{fmt(position)} / {fmt(mediaDuration)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress bar with hover tooltip */}
|
{/* Timeline — unified dir="ltr" coordinate space */}
|
||||||
<div
|
<div
|
||||||
className="relative mx-4 cursor-pointer"
|
dir="ltr"
|
||||||
|
className="relative mx-4 cursor-pointer py-2"
|
||||||
onMouseMove={onProgressHover}
|
onMouseMove={onProgressHover}
|
||||||
onMouseLeave={() => setHoverTime(null)}
|
onMouseLeave={() => setHoverTime(null)}
|
||||||
>
|
>
|
||||||
{/* Hover timestamp tooltip */}
|
{/* Hover timestamp tooltip */}
|
||||||
{hoverTime !== null && (
|
{hoverTime !== null && (
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none absolute -top-8 z-10 rounded bg-black/80 px-1.5 py-0.5 text-[11px] font-bold text-white"
|
className="pointer-events-none absolute -top-6 z-10 rounded bg-black/80 px-1.5 py-0.5 text-[11px] font-bold text-white"
|
||||||
style={{ left: `${hoverX}px`, transform: "translateX(-50%)" }}
|
style={{ left: `${hoverX}px`, transform: "translateX(-50%)" }}
|
||||||
>
|
>
|
||||||
{fmt(hoverTime)}
|
{fmt(hoverTime)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Buffer track */}
|
{/* Track layers stacked bottom-to-top: base → buffer → progress → thumb */}
|
||||||
<div className="absolute inset-0 overflow-hidden rounded-full">
|
<div className="relative h-2 overflow-hidden rounded-full bg-white/10">
|
||||||
|
{/* Buffer layer */}
|
||||||
<div
|
<div
|
||||||
className="h-full rounded-full bg-white/20 transition-[width] duration-300"
|
className="absolute left-0 top-0 h-full rounded-full bg-white/25 transition-[width] duration-300"
|
||||||
style={{ width: `${bufferedPct}%` }}
|
style={{ width: `${bufferedPct}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
{/* Play progress layer */}
|
||||||
<Progress value={pct} className="h-2 bg-white/10" gradient />
|
|
||||||
{/* Playhead thumb */}
|
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none absolute top-1/2 h-4 w-4 -translate-y-1/2 rounded-full bg-white shadow-md transition-[inset-inline-start] duration-150"
|
className="absolute left-0 top-0 h-full rounded-full bg-primary transition-[width] duration-150"
|
||||||
style={{ insetInlineStart: `calc(${pct}% - 8px)` }}
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Playhead thumb — positioned outside overflow:hidden, same dir="ltr" space */}
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute top-1/2 h-4 w-4 -translate-y-1/2 rounded-full bg-white shadow-md transition-[left] duration-150"
|
||||||
|
style={{ left: `calc(${pct}% - 8px)` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom control row */}
|
{/* Bottom control row — dir="ltr" for physical left→right placement */}
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-1.5 px-4 pb-3">
|
<div dir="ltr" className="mt-1 flex items-center justify-between gap-1 px-4 pb-3">
|
||||||
|
{/* Left group: playback controls + volume + speed */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
{/* Play/Pause */}
|
{/* Play/Pause */}
|
||||||
<button type="button" onClick={toggle} className="rounded-full bg-white/10 p-2.5 text-white transition hover:bg-white/20 min-w-[44px] min-h-[44px] flex items-center justify-center" aria-label="پخش / توقف (Space)">
|
<button type="button" onClick={toggle} className="flex min-h-[44px] min-w-[44px] items-center justify-center rounded-full bg-white/10 p-2.5 text-white transition hover:bg-white/20" aria-label="پخش / توقف (Space)">
|
||||||
{playing ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
|
{playing ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Skip back */}
|
{/* Rewind 10s */}
|
||||||
<button type="button" onClick={() => { skipBack(); setSeekIndicator("back"); setTimeout(() => setSeekIndicator(null), 700); }} className="rounded-full bg-white/10 p-2.5 text-white transition hover:bg-white/20 min-w-[44px] min-h-[44px] flex items-center justify-center" aria-label="بازگشت ۱۰ ثانیه (J)" title="۱۰ ثانیه عقب (J/←)">
|
<button type="button" onClick={() => { skipBack(); setSeekIndicator("back"); setTimeout(() => setSeekIndicator(null), 700); }} className="flex min-h-[44px] min-w-[44px] items-center justify-center rounded-full bg-white/10 p-2.5 text-white transition hover:bg-white/20" aria-label="بازگشت ۱۰ ثانیه (J)" title="۱۰ ثانیه عقب (J/←)">
|
||||||
<SkipBack className="h-5 w-5 scale-x-[-1]" />
|
<Rewind className="h-5 w-5" />
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Restart */}
|
|
||||||
<button type="button" onClick={() => { flushAcc(); seekProgrammatic(0); void videoRef.current?.play().catch(() => undefined); poke(); }} className="inline-flex items-center gap-1.5 rounded-full bg-white/10 px-3 py-2 text-xs font-bold text-white transition hover:bg-white/20 min-h-[44px]" aria-label="شروع از اول">
|
|
||||||
<RotateCcw className="h-4 w-4" />
|
|
||||||
<span className="hidden sm:inline">شروع از اول</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Volume */}
|
{/* Volume */}
|
||||||
<div className="flex items-center gap-1.5">
|
<button type="button" onClick={() => setMuted((m) => !m)} className="flex min-h-[44px] min-w-[44px] items-center justify-center rounded-full bg-white/10 p-2.5 text-white transition hover:bg-white/20" aria-label="قطع صدا (M)" title="قطع/وصل صدا (M)">
|
||||||
<button type="button" onClick={() => setMuted((m) => !m)} className="rounded-full bg-white/10 p-2.5 text-white transition hover:bg-white/20 min-w-[44px] min-h-[44px] flex items-center justify-center" aria-label="قطع صدا (M)" title="قطع/وصل صدا (M)">
|
|
||||||
<VolumeIcon className="h-5 w-5" />
|
<VolumeIcon className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
<input type="range" min={0} max={1} step={0.05} value={muted ? 0 : volume} onChange={(e) => { const v = parseFloat(e.target.value); setVolume(v); if (v > 0 && muted) setMuted(false); }} className="h-1 w-16 cursor-pointer accent-white opacity-80 hover:opacity-100" aria-label="میزان صدا" />
|
<input type="range" min={0} max={1} step={0.05} value={muted ? 0 : volume} onChange={(e) => { const v = parseFloat(e.target.value); setVolume(v); if (v > 0 && muted) setMuted(false); }} className="hidden h-1 w-16 cursor-pointer accent-white opacity-80 hover:opacity-100 sm:block" aria-label="میزان صدا" />
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Speed pill — click cycles through speeds */}
|
{/* Speed pill */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={cycleSpeed}
|
onClick={cycleSpeed}
|
||||||
title={`سرعت پخش: ${speed}× — کلیک برای تغییر`}
|
title={`سرعت پخش: ${speed}× — کلیک برای تغییر`}
|
||||||
className="inline-flex items-center gap-1 rounded-full bg-white/10 px-2.5 py-2 text-xs font-bold text-white transition hover:bg-white/20 min-h-[44px]"
|
className="inline-flex min-h-[44px] items-center gap-1 rounded-full bg-white/10 px-2.5 py-2 text-xs font-bold text-white transition hover:bg-white/20"
|
||||||
>
|
>
|
||||||
<Gauge className="h-4 w-4" />
|
<Gauge className="h-4 w-4" />
|
||||||
<span>{speed}×</span>
|
<span>{speed}×</span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Spacer */}
|
{/* Right group: watched%, theater, fullscreen */}
|
||||||
<span className="flex-1" />
|
<div className="flex items-center gap-1">
|
||||||
|
|
||||||
{/* Watched pct */}
|
|
||||||
<span className="hidden text-xs text-white/70 sm:inline">{Math.round(watchedPct)}٪</span>
|
<span className="hidden text-xs text-white/70 sm:inline">{Math.round(watchedPct)}٪</span>
|
||||||
|
|
||||||
{/* Settings gear — opens ambient / global settings only */}
|
|
||||||
<button
|
|
||||||
ref={settingsBtnRef}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSettingsOpen((o) => !o)}
|
|
||||||
className="rounded-full bg-white/10 p-2.5 text-white transition hover:bg-white/20 min-w-[44px] min-h-[44px] flex items-center justify-center"
|
|
||||||
title="تنظیمات پخش"
|
|
||||||
aria-expanded={settingsOpen}
|
|
||||||
>
|
|
||||||
<Settings className={cn("h-5 w-5 transition-transform duration-200", settingsOpen && "rotate-45")} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Theater mode */}
|
{/* Theater mode */}
|
||||||
<button type="button" onClick={() => setTheaterMode((t) => !t)} className="hidden rounded-full bg-white/10 p-2.5 text-white transition hover:bg-white/20 min-w-[44px] min-h-[44px] sm:flex items-center justify-center" title="حالت تئاتر">
|
<button type="button" onClick={() => setTheaterMode((t) => !t)} className="hidden min-h-[44px] min-w-[44px] items-center justify-center rounded-full bg-white/10 p-2.5 text-white transition hover:bg-white/20 sm:flex" title="حالت تئاتر">
|
||||||
<ChevronDown className={cn("h-5 w-5 transition-transform", theaterMode && "rotate-180")} />
|
<ChevronDown className={cn("h-5 w-5 transition-transform", theaterMode && "rotate-180")} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Fullscreen */}
|
{/* Fullscreen */}
|
||||||
<button type="button" onClick={toggleFullscreen} className="rounded-full bg-white/10 p-2.5 text-white transition hover:bg-white/20 min-w-[44px] min-h-[44px] flex items-center justify-center" aria-label="تمامصفحه (F)" title="تمامصفحه (F)">
|
<button type="button" onClick={toggleFullscreen} className="flex min-h-[44px] min-w-[44px] items-center justify-center rounded-full bg-white/10 p-2.5 text-white transition hover:bg-white/20" aria-label="تمامصفحه (F)" title="تمامصفحه (F)">
|
||||||
{fullscreen ? <Minimize className="h-5 w-5" /> : <Maximize className="h-5 w-5" />}
|
{fullscreen ? <Minimize className="h-5 w-5" /> : <Maximize className="h-5 w-5" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Settings flyout — anchored above the controls bar, closes only on outside click */}
|
|
||||||
<AnimatePresence>
|
|
||||||
{settingsOpen && (
|
|
||||||
<motion.div
|
|
||||||
ref={settingsPanelRef}
|
|
||||||
key="settings"
|
|
||||||
initial={{ opacity: 0, y: 6, scale: 0.97 }}
|
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
||||||
exit={{ opacity: 0, y: 6, scale: 0.97 }}
|
|
||||||
transition={{ duration: 0.13 }}
|
|
||||||
className="absolute bottom-[4.5rem] end-3 z-40 min-w-[200px] rounded-xl border border-white/10 bg-neutral-900/95 p-2 text-sm text-white shadow-xl backdrop-blur-md"
|
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<p className="mb-1.5 px-2 text-[10px] font-bold uppercase tracking-widest text-white/40">تنظیمات پخش</p>
|
|
||||||
|
|
||||||
{/* Speed picker (full grid) */}
|
|
||||||
<p className="mb-1 px-2 text-[11px] font-semibold text-white/60">سرعت</p>
|
|
||||||
<div className="mb-2 grid grid-cols-3 gap-1">
|
|
||||||
{ALLOWED_SPEEDS.map((s) => (
|
|
||||||
<button
|
|
||||||
key={s}
|
|
||||||
type="button"
|
|
||||||
onClick={() => { changeSpeed(s); setSettingsOpen(false); }}
|
|
||||||
className={cn(
|
|
||||||
"rounded-lg py-1.5 text-xs font-bold transition-colors",
|
|
||||||
speed === s
|
|
||||||
? "bg-primary text-primary-foreground"
|
|
||||||
: "bg-white/10 hover:bg-white/20",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{s}×
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="my-1.5 border-t border-white/10" />
|
|
||||||
|
|
||||||
{/* Ambient toggle */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={toggleAmbient}
|
|
||||||
className={cn(
|
|
||||||
"flex w-full items-center gap-2 rounded-lg px-2 py-2 text-xs font-bold transition-colors",
|
|
||||||
ambientMode ? "bg-primary/20 text-primary" : "hover:bg-white/10",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Zap className="h-4 w-4 shrink-0" />
|
|
||||||
<span>حالت محیطی</span>
|
|
||||||
<span className={cn("ms-auto text-[10px]", ambientMode ? "text-primary" : "text-white/40")}>
|
|
||||||
{ambientMode ? "فعال" : "غیرفعال"}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{/* Quiz unlock CTA */}
|
{/* Quiz unlock CTA */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{quizUnlockedNow && (
|
{quizUnlockedNow && (
|
||||||
@@ -933,7 +796,7 @@ export function VideoPlayer({
|
|||||||
</div>
|
</div>
|
||||||
<div className="w-40 shrink-0">
|
<div className="w-40 shrink-0">
|
||||||
<Progress value={watchedPct} gradient smooth />
|
<Progress value={watchedPct} gradient smooth />
|
||||||
<p className="mt-1 text-end text-xs text-muted-foreground">{Math.round(watchedPct)}٪</p>
|
<p className="mt-1 text-right text-xs text-muted-foreground">{Math.round(watchedPct)}٪</p>
|
||||||
</div>
|
</div>
|
||||||
{quizUnlocked ? (
|
{quizUnlocked ? (
|
||||||
<Button asChild variant="gradient">
|
<Button asChild variant="gradient">
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
export type Role = "student" | "mentor" | "admin";
|
export type Role = "student" | "mentor" | "admin";
|
||||||
|
|
||||||
|
export interface Contributor {
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
bio: string;
|
||||||
|
url: string;
|
||||||
|
avatar: string;
|
||||||
|
contributions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: number;
|
id: number;
|
||||||
email: string;
|
email: string;
|
||||||
@@ -117,6 +126,7 @@ export interface TreeLesson {
|
|||||||
xpReward: number;
|
xpReward: number;
|
||||||
durationSeconds: number;
|
durationSeconds: number;
|
||||||
chapterId: number;
|
chapterId: number;
|
||||||
|
isPublished?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TreeChapter extends Chapter {
|
export interface TreeChapter extends Chapter {
|
||||||
|
|||||||