Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
943897044a | ||
|
|
53c8c8e94c | ||
|
|
c4c9f37bbc | ||
|
|
a1c429782f | ||
|
|
0dcc7bbf76 | ||
|
|
77748bcba2 | ||
|
|
994879749b | ||
|
|
41e40c9ab2 | ||
|
|
42871c51cd | ||
|
|
6dc04de222 | ||
|
|
265fb8dde4
|
||
|
|
3bf6dae724
|
||
|
|
6228e66d2c
|
||
|
|
089c114019
|
||
|
|
602d3f2f4d
|
||
|
|
40fa0b8581
|
||
|
|
b4a7e7e1d3
|
||
|
|
03610bc3d3
|
||
|
|
75e2892e36 |
@@ -0,0 +1,14 @@
|
|||||||
|
# CI job image for the Gitea Actions runners.
|
||||||
|
#
|
||||||
|
# JS actions (actions/checkout, docker/build-push-action) execute INSIDE the
|
||||||
|
# job container and need node; builds talk to the runner's daemon through the
|
||||||
|
# mounted /var/run/docker.sock, so this image needs the docker CLI + buildx
|
||||||
|
# but no daemon. Baking them in means jobs never download binaries at runtime:
|
||||||
|
# download.docker.com and github.com releases are unreachable (connection
|
||||||
|
# reset) from the cluster's network.
|
||||||
|
#
|
||||||
|
# Tagged as src.panahifar.ir/ahp/act-job:20 (node 20 / bookworm).
|
||||||
|
FROM node:20-bookworm
|
||||||
|
|
||||||
|
COPY --from=docker:27-cli /usr/local/bin/docker /usr/local/bin/docker
|
||||||
|
COPY --from=docker:27-cli /usr/local/libexec/docker/cli-plugins/docker-buildx /usr/local/libexec/docker/cli-plugins/docker-buildx
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
name: build-and-push
|
||||||
|
|
||||||
|
# On a version tag push (v*), build both images and publish them to the
|
||||||
|
# Gitea container registry. Argo CD then rolls them out via
|
||||||
|
# deploy/helm/pargar (values-apollo.yaml pins the matching image tags).
|
||||||
|
#
|
||||||
|
# Requires a registered Gitea Actions runner, a `REGISTRY_TOKEN` secret
|
||||||
|
# (Gitea access token with write:package scope) so docker login can push to
|
||||||
|
# the registry.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: src.panahifar.ir
|
||||||
|
BACKEND_IMAGE: ahp/pargar-backend
|
||||||
|
FRONTEND_IMAGE: ahp/pargar-frontend
|
||||||
|
# Single credential location shared by login + build steps (the job runs
|
||||||
|
# in a container whose HOME can differ between steps).
|
||||||
|
DOCKER_CONFIG: /tmp/.docker
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
# Gitea runners match on their registered labels (default: linux-amd64)
|
||||||
|
runs-on: linux-amd64
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Image tag (strip leading v)
|
||||||
|
id: meta
|
||||||
|
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# Builds run docker-outside-of-docker through the runner-mounted
|
||||||
|
# /var/run/docker.sock. The apollo runner's job image (act-job:20,
|
||||||
|
# .gitea/job-image) already ships the docker CLI + buildx in system
|
||||||
|
# plugin paths; runners on other networks fall back to installing them
|
||||||
|
# at runtime (needs access to download.docker.com + github.com, and the
|
||||||
|
# plugin must land where docker looks with DOCKER_CONFIG set).
|
||||||
|
- name: Set up Docker CLI (preinstalled on apollo; install elsewhere)
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
if [ ! -S /var/run/docker.sock ]; then
|
||||||
|
echo "::error::/var/run/docker.sock is not mounted in the job container - the act_runner config must mount it (docker_host: \"\" auto-mounts it)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! command -v docker >/dev/null 2>&1 || ! docker buildx version >/dev/null 2>&1; then
|
||||||
|
curl -fsSL "https://download.docker.com/linux/static/stable/x86_64/docker-27.5.1.tgz" | tar xz --strip-components=1 -C /usr/local/bin docker/docker
|
||||||
|
mkdir -p /root/.docker/cli-plugins /usr/local/libexec/docker/cli-plugins
|
||||||
|
curl -fsSL "https://github.com/docker/buildx/releases/download/v0.19.3/buildx-v0.19.3.linux-amd64" -o /root/.docker/cli-plugins/docker-buildx
|
||||||
|
cp /root/.docker/cli-plugins/docker-buildx /usr/local/libexec/docker/cli-plugins/docker-buildx
|
||||||
|
chmod +x /root/.docker/cli-plugins/docker-buildx /usr/local/libexec/docker/cli-plugins/docker-buildx
|
||||||
|
fi
|
||||||
|
docker version
|
||||||
|
docker buildx version
|
||||||
|
|
||||||
|
- name: Login to Gitea container registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push backend
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: ./backend
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}:${{ steps.meta.outputs.version }}
|
||||||
|
${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}:latest
|
||||||
|
|
||||||
|
- name: Build and push frontend
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: ./frontend
|
||||||
|
push: true
|
||||||
|
# Empty = same-origin API/WS through the Traefik reverse proxy
|
||||||
|
# (the production topology). Override via repo variables if needed.
|
||||||
|
build-args: |
|
||||||
|
NEXT_PUBLIC_API_URL=${{ vars.NEXT_PUBLIC_API_URL }}
|
||||||
|
NEXT_PUBLIC_WS_URL=${{ vars.NEXT_PUBLIC_WS_URL }}
|
||||||
|
tags: |
|
||||||
|
${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}:${{ steps.meta.outputs.version }}
|
||||||
|
${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}:latest
|
||||||
@@ -10,7 +10,6 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/Panahifarah/pargar/releases/tag/v0.2.0"><img src="https://img.shields.io/github/v/tag/Panahifarah/pargar?label=release&color=5B4CDB" alt="release" /></a>
|
|
||||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-5B4CDB" alt="AGPL-3.0" /></a>
|
<a href="LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-5B4CDB" alt="AGPL-3.0" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -575,6 +575,38 @@ func (s *Server) handleAdminDeleteLesson(w http.ResponseWriter, r *http.Request)
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAdminPublishLesson(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := routeID(r, "id")
|
||||||
|
var body struct {
|
||||||
|
Publish bool `json:"publish"`
|
||||||
|
}
|
||||||
|
if err := bodyJSON(r, &body); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.PublishLesson(r.Context(), id, body.Publish); err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, "بهروزرسانی وضعیت درس ممکن نشد")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAdminPublishChapter(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := routeID(r, "id")
|
||||||
|
var body struct {
|
||||||
|
Publish bool `json:"publish"`
|
||||||
|
}
|
||||||
|
if err := bodyJSON(r, &body); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.PublishChapter(r.Context(), id, body.Publish); err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, "بهروزرسانی وضعیت فصل ممکن نشد")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
// ---- questions ----
|
// ---- questions ----
|
||||||
|
|
||||||
func (s *Server) handleAdminListQuestions(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAdminListQuestions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"pargar/backend/internal/models"
|
"pargar/backend/internal/models"
|
||||||
"pargar/backend/internal/observability"
|
"pargar/backend/internal/observability"
|
||||||
@@ -22,7 +21,24 @@ func newPublicID() (string, error) {
|
|||||||
return hex.EncodeToString(b), nil
|
return hex.EncodeToString(b), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) certEnabled(ctx context.Context) bool {
|
||||||
|
if v, err := s.store.GetSetting(ctx, "certificate_enabled"); err == nil {
|
||||||
|
return v == "true" || v == "1"
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// certRequiresApproval always returns true — certificates are never auto-issued.
|
||||||
|
// The DB setting is kept for observability but cannot override the hard gate.
|
||||||
|
func (s *Server) certRequiresApproval(_ context.Context) bool { return true }
|
||||||
|
|
||||||
|
// issueCertificateIfEligible creates a pending certificate row when the user has
|
||||||
|
// completed the curriculum. It never issues (publishes) the certificate directly;
|
||||||
|
// that step requires explicit admin action via handleAdminIssueCertificate.
|
||||||
func (s *Server) issueCertificateIfEligible(ctx context.Context, user *models.User) (*models.Certificate, bool, error) {
|
func (s *Server) issueCertificateIfEligible(ctx context.Context, user *models.User) (*models.Certificate, bool, error) {
|
||||||
|
if !s.certEnabled(ctx) {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
ok, err := s.store.HasCompletedCurriculum(ctx, user.ID)
|
ok, err := s.store.HasCompletedCurriculum(ctx, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
@@ -37,15 +53,12 @@ func (s *Server) issueCertificateIfEligible(ctx context.Context, user *models.Us
|
|||||||
} else if err != store.ErrNotFound {
|
} else if err != store.ErrNotFound {
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
publicID, err := newPublicID()
|
// Always create a pending row — never auto-issue.
|
||||||
|
cert, err := s.store.CreatePendingCertificate(ctx, user.ID, user.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
cert, err := s.store.CreateCertificate(ctx, user.ID, publicID, user.Name)
|
return cert, cert.Status == "pending", nil
|
||||||
if err != nil {
|
|
||||||
return nil, false, err
|
|
||||||
}
|
|
||||||
return cert, true, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleIssueCertificate(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleIssueCertificate(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -101,7 +114,7 @@ func (s *Server) handlePublicCertificate(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
cert, err := s.store.GetCertificateByPublicID(r.Context(), publicID)
|
cert, err := s.store.GetCertificateByPublicID(r.Context(), publicID)
|
||||||
if err != nil || cert.RevokedAt != nil {
|
if err != nil || cert.RevokedAt != nil || cert.Status != "issued" {
|
||||||
writeErr(w, http.StatusNotFound, "گواهینامه پیدا نشد")
|
writeErr(w, http.StatusNotFound, "گواهینامه پیدا نشد")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -140,90 +153,66 @@ func (s *Server) physicalSettings(ctx context.Context) map[string]any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleRequestPhysicalCertificate(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleRequestPhysicalCertificate(w http.ResponseWriter, r *http.Request) {
|
||||||
u := currentUser(r)
|
writeErr(w, http.StatusGone, "سفارش فیزیکی موقتاً غیرفعال است")
|
||||||
settings := s.physicalSettings(r.Context())
|
|
||||||
if enabled, _ := settings["enabled"].(bool); !enabled {
|
|
||||||
writeErr(w, http.StatusBadRequest, "درخواست نسخه فیزیکی فعلاً فعال نیست")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
cert, err := s.store.GetCertificateByUser(r.Context(), u.ID)
|
|
||||||
if err != nil || cert.RevokedAt != nil {
|
func (s *Server) handleAdminIssueCertificate(w http.ResponseWriter, r *http.Request) {
|
||||||
writeErr(w, http.StatusConflict, "ابتدا گواهینامه دیجیتال را دریافت کنید")
|
userID := routeID(r, "id")
|
||||||
return
|
u, err := s.store.GetUserByID(r.Context(), userID)
|
||||||
}
|
|
||||||
days, _ := settings["windowDays"].(int)
|
|
||||||
if days <= 0 {
|
|
||||||
days = 30
|
|
||||||
}
|
|
||||||
windowEnds := cert.IssuedAt.Add(time.Duration(days) * 24 * time.Hour)
|
|
||||||
if time.Now().After(windowEnds) {
|
|
||||||
writeErr(w, http.StatusConflict, "مهلت درخواست نسخه فیزیکی به پایان رسیده است")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if existing, err := s.store.GetLatestPhysicalOrder(r.Context(), u.ID); err == nil {
|
|
||||||
if existing.Status != models.PhysicalCancelled {
|
|
||||||
writeErr(w, http.StatusConflict, "سفارش فیزیکی فعال دارید")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var req struct {
|
|
||||||
RecipientName string `json:"recipientName"`
|
|
||||||
Phone string `json:"phone"`
|
|
||||||
Address string `json:"address"`
|
|
||||||
City string `json:"city"`
|
|
||||||
PostalCode string `json:"postalCode"`
|
|
||||||
}
|
|
||||||
if err := bodyJSON(r, &req); err != nil {
|
|
||||||
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ship := models.CertificatePhysicalOrder{
|
|
||||||
RecipientName: strings.TrimSpace(req.RecipientName),
|
|
||||||
Phone: normalizePhone(req.Phone),
|
|
||||||
Address: strings.TrimSpace(req.Address),
|
|
||||||
City: strings.TrimSpace(req.City),
|
|
||||||
PostalCode: strings.TrimSpace(req.PostalCode),
|
|
||||||
}
|
|
||||||
if ship.RecipientName == "" {
|
|
||||||
ship.RecipientName = u.Name
|
|
||||||
}
|
|
||||||
if ship.Phone == "" {
|
|
||||||
ship.Phone = normalizePhone(u.Phone)
|
|
||||||
}
|
|
||||||
if ship.Phone == "" || !validPhone(ship.Phone) {
|
|
||||||
writeErr(w, http.StatusBadRequest, msgPhoneInvalid)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if ship.Address == "" || ship.City == "" {
|
|
||||||
writeErr(w, http.StatusBadRequest, "آدرس و شهر برای ارسال الزامی است")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(ship.Address) > 500 || len(ship.City) > 80 || len(ship.RecipientName) > 120 {
|
|
||||||
writeErr(w, http.StatusBadRequest, "اطلاعات ارسال بیش از حد طولانی است")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
order, err := s.store.CreatePhysicalOrder(r.Context(), cert.ID, u.ID, windowEnds, ship)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, http.StatusInternalServerError, "ثبت درخواست ممکن نشد")
|
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
existing, err := s.store.GetCertificateByUser(r.Context(), userID)
|
||||||
|
if err != nil && err != store.ErrNotFound {
|
||||||
|
writeInternalErr(w, "cert.admin.issue", err, "بارگذاری گواهینامه ممکن نشد")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if existing != nil && existing.RevokedAt == nil && existing.Status == "issued" {
|
||||||
|
writeErr(w, http.StatusConflict, "گواهینامه قبلاً صادر شده است")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
publicID, err := newPublicID()
|
||||||
|
if err != nil {
|
||||||
|
writeInternalErr(w, "cert.admin.pubid", err, "تولید شناسه ممکن نشد")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var cert *models.Certificate
|
||||||
|
if existing != nil {
|
||||||
|
cert, err = s.store.IssuePendingCertificate(r.Context(), userID, publicID, u.Name)
|
||||||
|
} else {
|
||||||
|
cert, err = s.store.CreateCertificate(r.Context(), userID, publicID, u.Name)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeInternalErr(w, "cert.admin.create", err, "صدور گواهینامه ممکن نشد")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
staff, _ := s.store.ListMentors(r.Context())
|
|
||||||
go func() {
|
go func() {
|
||||||
for _, m := range staff {
|
_ = s.notify.Notify(context.Background(), userID, "progress", "certificate_issued",
|
||||||
if m.Role != models.RoleAdmin {
|
"گواهینامه صادر شد", "گواهینامه پرگار شما صادر شد. برای مشاهده به صفحه گواهینامه مراجعه کنید.", "/cap",
|
||||||
continue
|
map[string]any{"publicId": cert.PublicID})
|
||||||
}
|
|
||||||
_ = s.notify.Notify(context.Background(), m.ID, "progress", "physical_order",
|
|
||||||
"سفارش گواهینامه فیزیکی", u.Name+" درخواست نسخه فیزیکی ثبت کرد.", "/admin",
|
|
||||||
map[string]any{"orderId": order.ID, "userId": u.ID})
|
|
||||||
}
|
|
||||||
}()
|
}()
|
||||||
observability.Activity("certificate.physical_requested", "user_id", u.ID, "order_id", order.ID)
|
observability.Activity("certificate.admin_issued", "user_id", userID, "actor_id", currentUser(r).ID)
|
||||||
writeJSON(w, http.StatusCreated, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{"certificate": cert})
|
||||||
"order": order,
|
}
|
||||||
"message": "پس از واریز با ادمین هماهنگ کنید",
|
|
||||||
"physical": settings,
|
func (s *Server) handleAdminRevokeCertificate(w http.ResponseWriter, r *http.Request) {
|
||||||
})
|
userID := routeID(r, "id")
|
||||||
|
if err := s.store.RevokeCertificate(r.Context(), userID); err != nil {
|
||||||
|
writeInternalErr(w, "cert.admin.revoke", err, "لغو گواهینامه ممکن نشد")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
observability.Activity("certificate.admin_revoked", "user_id", userID, "actor_id", currentUser(r).ID)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAdminListPendingCertificates(w http.ResponseWriter, r *http.Request) {
|
||||||
|
certs, err := s.store.ListCertificatesByStatus(r.Context(), "pending")
|
||||||
|
if err != nil {
|
||||||
|
writeInternalErr(w, "cert.admin.pending", err, "بارگذاری گواهینامههای در انتظار ممکن نشد")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"certificates": certs})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAdminListPhysicalOrders(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAdminListPhysicalOrders(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package api
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -15,21 +16,40 @@ type lbEntry struct {
|
|||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
XP float64 `json:"xp"`
|
XP float64 `json:"xp"`
|
||||||
Rank int `json:"rank"`
|
Rank int `json:"rank"`
|
||||||
|
Tier string `json:"tier"`
|
||||||
AvatarVariant string `json:"avatarVariant"`
|
AvatarVariant string `json:"avatarVariant"`
|
||||||
AvatarPalette string `json:"avatarPalette"`
|
AvatarPalette string `json:"avatarPalette"`
|
||||||
AvatarPhoto string `json:"avatarPhoto,omitempty"`
|
AvatarPhoto string `json:"avatarPhoto,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tierFromRank assigns a league tier based on rank relative to total participants.
|
||||||
|
// Thresholds: rank 1 = champion, top 10% = gold, top 30% = silver, rest = bronze.
|
||||||
|
func tierFromRank(rank, total int) string {
|
||||||
|
if rank <= 0 || total <= 0 {
|
||||||
|
return "bronze"
|
||||||
|
}
|
||||||
|
if rank == 1 {
|
||||||
|
return "champion"
|
||||||
|
}
|
||||||
|
if rank*10 <= total {
|
||||||
|
return "gold"
|
||||||
|
}
|
||||||
|
if rank*10 <= total*3 {
|
||||||
|
return "silver"
|
||||||
|
}
|
||||||
|
return "bronze"
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleWeeklyLeaderboard(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleWeeklyLeaderboard(w http.ResponseWriter, r *http.Request) {
|
||||||
u := currentUser(r)
|
u := currentUser(r)
|
||||||
key := "lb:" + service.ISOWeek(time.Now())
|
key := "lb:" + service.ISOWeek(time.Now())
|
||||||
|
|
||||||
entries := make([]lbEntry, 0, 20)
|
|
||||||
type scored struct {
|
type scored struct {
|
||||||
userID int64
|
userID int64
|
||||||
xp float64
|
xp float64
|
||||||
}
|
}
|
||||||
var scoredList []scored
|
var scoredList []scored
|
||||||
|
var totalParticipants int64
|
||||||
|
|
||||||
if s.redis != nil {
|
if s.redis != nil {
|
||||||
z, err := s.redis.ZRevRangeWithScores(context.Background(), key, 0, 19).Result()
|
z, err := s.redis.ZRevRangeWithScores(context.Background(), key, 0, 19).Result()
|
||||||
@@ -39,12 +59,16 @@ func (s *Server) handleWeeklyLeaderboard(w http.ResponseWriter, r *http.Request)
|
|||||||
scoredList = append(scoredList, scored{userID: id, xp: member.Score})
|
scoredList = append(scoredList, scored{userID: id, xp: member.Score})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if count, err := s.redis.ZCard(context.Background(), key).Result(); err == nil {
|
||||||
|
totalParticipants = count
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// fallback to DB if redis empty (e.g. dev bootstrap)
|
// Fallback to DB if Redis is empty (e.g. dev bootstrap).
|
||||||
if len(scoredList) == 0 {
|
if len(scoredList) == 0 {
|
||||||
rows, err := s.store.Pool().Query(context.Background(), `
|
rows, err := s.store.Pool().Query(context.Background(),
|
||||||
SELECT user_id, xp FROM weekly_leaderboard WHERE week=$1 ORDER BY xp DESC LIMIT 20`,
|
`SELECT user_id, xp FROM weekly_leaderboard WHERE week=$1
|
||||||
|
ORDER BY xp DESC, user_id ASC LIMIT 20`,
|
||||||
service.ISOWeek(time.Now()))
|
service.ISOWeek(time.Now()))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
@@ -56,30 +80,50 @@ func (s *Server) handleWeeklyLeaderboard(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
rows.Close()
|
rows.Close()
|
||||||
}
|
}
|
||||||
|
if err := s.store.Pool().QueryRow(context.Background(),
|
||||||
|
`SELECT COUNT(*) FROM weekly_leaderboard WHERE week=$1`,
|
||||||
|
service.ISOWeek(time.Now())).Scan(&totalParticipants); err != nil {
|
||||||
|
totalParticipants = int64(len(scoredList))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deterministic tie-breaking: XP descending, then user_id ascending.
|
||||||
|
sort.SliceStable(scoredList, func(i, j int) bool {
|
||||||
|
if scoredList[i].xp != scoredList[j].xp {
|
||||||
|
return scoredList[i].xp > scoredList[j].xp
|
||||||
|
}
|
||||||
|
return scoredList[i].userID < scoredList[j].userID
|
||||||
|
})
|
||||||
|
|
||||||
|
entries := make([]lbEntry, 0, len(scoredList))
|
||||||
for i, sc := range scoredList {
|
for i, sc := range scoredList {
|
||||||
usr, err := s.store.GetUserByID(context.Background(), sc.userID)
|
usr, err := s.store.GetUserByID(context.Background(), sc.userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
s.signUserMedia(usr)
|
s.signUserMedia(usr)
|
||||||
|
rank := i + 1
|
||||||
entries = append(entries, lbEntry{
|
entries = append(entries, lbEntry{
|
||||||
UserID: sc.userID, Name: usr.Name, Email: usr.Email, XP: sc.xp, Rank: i + 1,
|
UserID: sc.userID,
|
||||||
AvatarVariant: usr.AvatarVariant, AvatarPalette: usr.AvatarPalette, AvatarPhoto: usr.AvatarPhoto,
|
Name: usr.Name,
|
||||||
|
Email: usr.Email,
|
||||||
|
XP: sc.xp,
|
||||||
|
Rank: rank,
|
||||||
|
Tier: tierFromRank(rank, int(totalParticipants)),
|
||||||
|
AvatarVariant: usr.AvatarVariant,
|
||||||
|
AvatarPalette: usr.AvatarPalette,
|
||||||
|
AvatarPhoto: usr.AvatarPhoto,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// my position
|
// Current user's position (may be outside top 20).
|
||||||
myRank := -1
|
myRank := -1
|
||||||
var myXP float64
|
var myXP float64
|
||||||
if s.redis != nil {
|
if s.redis != nil {
|
||||||
rank, err := s.redis.ZRevRank(context.Background(), key, strconv.FormatInt(u.ID, 10)).Result()
|
if rank, err := s.redis.ZRevRank(context.Background(), key, strconv.FormatInt(u.ID, 10)).Result(); err == nil {
|
||||||
if err == nil {
|
|
||||||
myRank = int(rank) + 1
|
myRank = int(rank) + 1
|
||||||
}
|
}
|
||||||
score, err := s.redis.ZScore(context.Background(), key, strconv.FormatInt(u.ID, 10)).Result()
|
if score, err := s.redis.ZScore(context.Background(), key, strconv.FormatInt(u.ID, 10)).Result(); err == nil {
|
||||||
if err == nil {
|
|
||||||
myXP = score
|
myXP = score
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,6 +134,12 @@ func (s *Server) handleWeeklyLeaderboard(w http.ResponseWriter, r *http.Request)
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"week": service.ISOWeek(time.Now()),
|
"week": service.ISOWeek(time.Now()),
|
||||||
"entries": entries,
|
"entries": entries,
|
||||||
"me": map[string]any{"rank": myRank, "xp": myXP, "streak": u.StreakCurrent},
|
"totalParticipants": totalParticipants,
|
||||||
|
"me": map[string]any{
|
||||||
|
"rank": myRank,
|
||||||
|
"xp": myXP,
|
||||||
|
"streak": u.StreakCurrent,
|
||||||
|
"tier": tierFromRank(myRank, int(totalParticipants)),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestTierFromRank(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
rank, total int
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{1, 1, "champion"},
|
||||||
|
{1, 100, "champion"},
|
||||||
|
{1, 0, "bronze"}, // zero total → bronze (guard)
|
||||||
|
{0, 100, "bronze"}, // zero rank → bronze (guard)
|
||||||
|
{10, 100, "gold"}, // exactly 10%
|
||||||
|
{11, 100, "silver"}, // 11%
|
||||||
|
{30, 100, "silver"}, // exactly 30%
|
||||||
|
{31, 100, "bronze"}, // 31%
|
||||||
|
{5, 50, "gold"}, // 10%
|
||||||
|
{15, 50, "silver"}, // exactly 30%
|
||||||
|
{16, 50, "bronze"}, // 32% → bronze
|
||||||
|
{50, 100, "bronze"},
|
||||||
|
{2, 10, "silver"}, // 20% → silver
|
||||||
|
{1, 10, "champion"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := tierFromRank(c.rank, c.total)
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("tierFromRank(%d, %d) = %q; want %q", c.rank, c.total, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -583,7 +583,7 @@ func (s *Server) handleMeta(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func appBuild() (version, built string) {
|
func appBuild() (version, built string) {
|
||||||
version = "0.2.0"
|
version = "0.2.1"
|
||||||
built = "dev"
|
built = "dev"
|
||||||
info, ok := debug.ReadBuildInfo()
|
info, ok := debug.ReadBuildInfo()
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -594,7 +594,7 @@ func appBuild() (version, built string) {
|
|||||||
built = setting.Value[:10]
|
built = setting.Value[:10]
|
||||||
}
|
}
|
||||||
if setting.Key == "vcs.revision" && len(setting.Value) >= 7 {
|
if setting.Key == "vcs.revision" && len(setting.Value) >= 7 {
|
||||||
version = "0.2.0+" + setting.Value[:7]
|
version = "0.2.1+" + setting.Value[:7]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return version, built
|
return version, built
|
||||||
|
|||||||
@@ -158,9 +158,10 @@ func (s *Server) handleQuizSubmit(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if cert, created, cerr := s.issueCertificateIfEligible(r.Context(), activeUser); cerr == nil && created && cert != nil {
|
if cert, created, cerr := s.issueCertificateIfEligible(r.Context(), activeUser); cerr == nil && created && cert != nil {
|
||||||
observability.Activity("certificate.issued", "user_id", u.ID, "public_id", cert.PublicID)
|
observability.Activity("certificate.pending", "user_id", u.ID)
|
||||||
_ = s.notify.Notify(r.Context(), u.ID, "progress", "certificate_issued",
|
_ = s.notify.Notify(r.Context(), u.ID, "progress", "certificate_pending",
|
||||||
"گواهینامه آماده است", "دوره را به پایان رساندید. گواهینامه دیجیتال شما صادر شد.", "/c/"+cert.PublicID, nil)
|
"درخواست گواهینامه ثبت شد", "دوره را با موفقیت به پایان رساندید. درخواست گواهینامه شما ثبت شد و پس از تأیید مدیریت صادر خواهد شد.", "/profile", nil)
|
||||||
|
_ = cert // pending; no publicId yet
|
||||||
}
|
}
|
||||||
s.notifyQuizPassed(u.ID, lesson.Title, id, activeUser.StreakCurrent)
|
s.notifyQuizPassed(u.ID, lesson.Title, id, activeUser.StreakCurrent)
|
||||||
_ = s.notifyUnlockedLessons(u.ID, id)
|
_ = s.notifyUnlockedLessons(u.ID, id)
|
||||||
|
|||||||
@@ -101,6 +101,9 @@ func newRouter(s *Server) http.Handler {
|
|||||||
mux.HandleFunc("POST /api/admin/users/{id}/reset-password", admin(s.handleAdminResetPassword))
|
mux.HandleFunc("POST /api/admin/users/{id}/reset-password", admin(s.handleAdminResetPassword))
|
||||||
mux.HandleFunc("GET /api/admin/physical-orders", admin(s.handleAdminListPhysicalOrders))
|
mux.HandleFunc("GET /api/admin/physical-orders", admin(s.handleAdminListPhysicalOrders))
|
||||||
mux.HandleFunc("PUT /api/admin/physical-orders/{id}", admin(s.handleAdminUpdatePhysicalOrder))
|
mux.HandleFunc("PUT /api/admin/physical-orders/{id}", admin(s.handleAdminUpdatePhysicalOrder))
|
||||||
|
mux.HandleFunc("GET /api/admin/certificates/pending", staff(s.handleAdminListPendingCertificates))
|
||||||
|
mux.HandleFunc("POST /api/admin/users/{id}/certificate/issue", staff(s.handleAdminIssueCertificate))
|
||||||
|
mux.HandleFunc("POST /api/admin/users/{id}/certificate/revoke", staff(s.handleAdminRevokeCertificate))
|
||||||
mux.HandleFunc("GET /api/admin/settings", admin(s.handleAdminGetSettings))
|
mux.HandleFunc("GET /api/admin/settings", admin(s.handleAdminGetSettings))
|
||||||
mux.HandleFunc("PUT /api/admin/settings", admin(s.handleAdminPutSettings))
|
mux.HandleFunc("PUT /api/admin/settings", admin(s.handleAdminPutSettings))
|
||||||
mux.HandleFunc("GET /api/admin/chat-export", admin(s.handleAdminChatExport))
|
mux.HandleFunc("GET /api/admin/chat-export", admin(s.handleAdminChatExport))
|
||||||
@@ -127,11 +130,13 @@ func newRouter(s *Server) http.Handler {
|
|||||||
mux.HandleFunc("POST /api/admin/chapters", staff(s.handleAdminCreateChapter))
|
mux.HandleFunc("POST /api/admin/chapters", staff(s.handleAdminCreateChapter))
|
||||||
mux.HandleFunc("PUT /api/admin/chapters/{id}", staff(s.handleAdminUpdateChapter))
|
mux.HandleFunc("PUT /api/admin/chapters/{id}", staff(s.handleAdminUpdateChapter))
|
||||||
mux.HandleFunc("DELETE /api/admin/chapters/{id}", staff(s.handleAdminDeleteChapter))
|
mux.HandleFunc("DELETE /api/admin/chapters/{id}", staff(s.handleAdminDeleteChapter))
|
||||||
|
mux.HandleFunc("POST /api/admin/chapters/{id}/publish", staff(s.handleAdminPublishChapter))
|
||||||
|
|
||||||
mux.HandleFunc("GET /api/admin/lessons", staff(s.handleAdminListLessons))
|
mux.HandleFunc("GET /api/admin/lessons", staff(s.handleAdminListLessons))
|
||||||
mux.HandleFunc("POST /api/admin/lessons", staff(s.handleAdminCreateLesson))
|
mux.HandleFunc("POST /api/admin/lessons", staff(s.handleAdminCreateLesson))
|
||||||
mux.HandleFunc("PUT /api/admin/lessons/{id}", staff(s.handleAdminUpdateLesson))
|
mux.HandleFunc("PUT /api/admin/lessons/{id}", staff(s.handleAdminUpdateLesson))
|
||||||
mux.HandleFunc("DELETE /api/admin/lessons/{id}", staff(s.handleAdminDeleteLesson))
|
mux.HandleFunc("DELETE /api/admin/lessons/{id}", staff(s.handleAdminDeleteLesson))
|
||||||
|
mux.HandleFunc("POST /api/admin/lessons/{id}/publish", staff(s.handleAdminPublishLesson))
|
||||||
mux.HandleFunc("GET /api/admin/lessons/{id}/questions", staff(s.handleAdminListQuestions))
|
mux.HandleFunc("GET /api/admin/lessons/{id}/questions", staff(s.handleAdminListQuestions))
|
||||||
mux.HandleFunc("POST /api/admin/lessons/{id}/questions", staff(s.handleAdminCreateQuestion))
|
mux.HandleFunc("POST /api/admin/lessons/{id}/questions", staff(s.handleAdminCreateQuestion))
|
||||||
mux.HandleFunc("PUT /api/admin/questions/{id}", staff(s.handleAdminUpdateQuestion))
|
mux.HandleFunc("PUT /api/admin/questions/{id}", staff(s.handleAdminUpdateQuestion))
|
||||||
|
|||||||
@@ -57,6 +57,23 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compute progress aggregates using only active+published lessons as the denominator so
|
||||||
|
// that frontend and backend always agree on the percentage.
|
||||||
|
totalActive, completedActive := 0, 0
|
||||||
|
for _, l := range lessons {
|
||||||
|
if !l.IsActive || !l.IsPublished {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
totalActive++
|
||||||
|
if p, ok := progress[l.ID]; ok && p.PassedQuiz {
|
||||||
|
completedActive++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var progressPct float64
|
||||||
|
if totalActive > 0 {
|
||||||
|
progressPct = float64(completedActive) / float64(totalActive) * 100
|
||||||
|
}
|
||||||
|
|
||||||
out := make([]chapterView, 0, len(chapters))
|
out := make([]chapterView, 0, len(chapters))
|
||||||
for _, ch := range chapters {
|
for _, ch := range chapters {
|
||||||
cv := chapterView{Chapter: ch, Lessons: []lessonView{}}
|
cv := chapterView{Chapter: ch, Lessons: []lessonView{}}
|
||||||
@@ -85,6 +102,9 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"chapters": out,
|
"chapters": out,
|
||||||
|
"totalLessons": totalActive,
|
||||||
|
"completedLessons": completedActive,
|
||||||
|
"progressPct": progressPct,
|
||||||
"me": map[string]any{
|
"me": map[string]any{
|
||||||
"xp": u.XP,
|
"xp": u.XP,
|
||||||
"streak": u.StreakCurrent,
|
"streak": u.StreakCurrent,
|
||||||
@@ -244,7 +264,7 @@ func (s *Server) allowLessonAccess(w http.ResponseWriter, r *http.Request, lesso
|
|||||||
writeErrCode(w, http.StatusLocked, CodeLocked, service.ErrLocked.Error())
|
writeErrCode(w, http.StatusLocked, CodeLocked, service.ErrLocked.Error())
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if !lesson.IsActive && !u.Role.IsStaff() {
|
if (!lesson.IsActive || !lesson.IsPublished) && !u.Role.IsStaff() {
|
||||||
writeErrCode(w, http.StatusNotFound, CodeNotFound, "درس پیدا نشد")
|
writeErrCode(w, http.StatusNotFound, CodeNotFound, "درس پیدا نشد")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- 0029_lesson_delete_cascades.sql
|
||||||
|
-- Make deleting a lesson (directly, or via its chapter) work cleanly.
|
||||||
|
--
|
||||||
|
-- Previously only mcq_questions cascaded; watch/quiz history blocked the delete
|
||||||
|
-- with a 500. Progress/history rows now go away with the lesson, while lessons
|
||||||
|
-- that merely *required* the deleted one are unlinked (never deleted).
|
||||||
|
|
||||||
|
ALTER TABLE watch_sessions DROP CONSTRAINT IF EXISTS watch_sessions_lesson_id_fkey;
|
||||||
|
ALTER TABLE watch_sessions ADD CONSTRAINT watch_sessions_lesson_id_fkey
|
||||||
|
FOREIGN KEY (lesson_id) REFERENCES lessons(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE watch_heartbeats DROP CONSTRAINT IF EXISTS watch_heartbeats_lesson_id_fkey;
|
||||||
|
ALTER TABLE watch_heartbeats ADD CONSTRAINT watch_heartbeats_lesson_id_fkey
|
||||||
|
FOREIGN KEY (lesson_id) REFERENCES lessons(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE lesson_progress DROP CONSTRAINT IF EXISTS lesson_progress_lesson_id_fkey;
|
||||||
|
ALTER TABLE lesson_progress ADD CONSTRAINT lesson_progress_lesson_id_fkey
|
||||||
|
FOREIGN KEY (lesson_id) REFERENCES lessons(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE quiz_attempts DROP CONSTRAINT IF EXISTS quiz_attempts_lesson_id_fkey;
|
||||||
|
ALTER TABLE quiz_attempts ADD CONSTRAINT quiz_attempts_lesson_id_fkey
|
||||||
|
FOREIGN KEY (lesson_id) REFERENCES lessons(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE lessons DROP CONSTRAINT IF EXISTS lessons_requires_lesson_id_fkey;
|
||||||
|
ALTER TABLE lessons ADD CONSTRAINT lessons_requires_lesson_id_fkey
|
||||||
|
FOREIGN KEY (requires_lesson_id) REFERENCES lessons(id) ON DELETE SET NULL;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Add draft/published state to chapters and lessons.
|
||||||
|
-- Existing content is published immediately so nothing goes dark on deploy.
|
||||||
|
-- New lessons created via the admin API default to is_published = false (draft).
|
||||||
|
|
||||||
|
ALTER TABLE lessons ADD COLUMN IF NOT EXISTS is_published BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE chapters ADD COLUMN IF NOT EXISTS is_published BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
UPDATE lessons SET is_published = true;
|
||||||
|
UPDATE chapters SET is_published = true;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- Add manual approval flow for certificates.
|
||||||
|
-- Existing certs keep status='issued'. New completions create status='pending'
|
||||||
|
-- until an admin or mentor explicitly issues the certificate.
|
||||||
|
|
||||||
|
ALTER TABLE certificates ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'issued';
|
||||||
|
ALTER TABLE certificates ALTER COLUMN public_id DROP NOT NULL;
|
||||||
|
|
||||||
|
UPDATE certificates SET status = 'issued';
|
||||||
|
|
||||||
|
INSERT INTO app_settings (key, value) VALUES
|
||||||
|
('certificate_enabled', 'true'),
|
||||||
|
('certificate_requires_approval', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
@@ -107,10 +107,11 @@ func (u *User) ApplyFreezeState(now time.Time) {
|
|||||||
type Certificate struct {
|
type Certificate struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
UserID int64 `json:"userId"`
|
UserID int64 `json:"userId"`
|
||||||
PublicID string `json:"publicId"`
|
PublicID string `json:"publicId,omitempty"`
|
||||||
FullName string `json:"fullName"`
|
FullName string `json:"fullName"`
|
||||||
IssuedAt time.Time `json:"issuedAt"`
|
IssuedAt time.Time `json:"issuedAt"`
|
||||||
RevokedAt *time.Time `json:"revokedAt,omitempty"`
|
RevokedAt *time.Time `json:"revokedAt,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PhysicalOrderStatus string
|
type PhysicalOrderStatus string
|
||||||
@@ -164,6 +165,7 @@ type Chapter struct {
|
|||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Icon string `json:"icon"`
|
Icon string `json:"icon"`
|
||||||
SortOrder int `json:"sortOrder"`
|
SortOrder int `json:"sortOrder"`
|
||||||
|
IsPublished bool `json:"isPublished"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Lesson struct {
|
type Lesson struct {
|
||||||
@@ -180,6 +182,7 @@ type Lesson struct {
|
|||||||
SortOrder int `json:"sortOrder"`
|
SortOrder int `json:"sortOrder"`
|
||||||
XPReward int `json:"xpReward"`
|
XPReward int `json:"xpReward"`
|
||||||
IsActive bool `json:"isActive"`
|
IsActive bool `json:"isActive"`
|
||||||
|
IsPublished bool `json:"isPublished"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MCQQuestion struct {
|
type MCQQuestion struct {
|
||||||
|
|||||||
+107
-26
@@ -618,7 +618,7 @@ func IsUniqueViolation(err error) bool {
|
|||||||
|
|
||||||
func (s *Store) CountActiveLessons(ctx context.Context) (int, error) {
|
func (s *Store) CountActiveLessons(ctx context.Context) (int, error) {
|
||||||
var n int
|
var n int
|
||||||
err := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM lessons WHERE is_active`).Scan(&n)
|
err := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM lessons WHERE is_active AND is_published`).Scan(&n)
|
||||||
return n, err
|
return n, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -627,11 +627,16 @@ func (s *Store) CountPassedActiveLessons(ctx context.Context, userID int64) (int
|
|||||||
err := s.pool.QueryRow(ctx, `
|
err := s.pool.QueryRow(ctx, `
|
||||||
SELECT COUNT(*) FROM lesson_progress lp
|
SELECT COUNT(*) FROM lesson_progress lp
|
||||||
JOIN lessons l ON l.id = lp.lesson_id
|
JOIN lessons l ON l.id = lp.lesson_id
|
||||||
WHERE lp.user_id=$1 AND lp.passed_quiz AND l.is_active`, userID).Scan(&n)
|
WHERE lp.user_id=$1 AND lp.passed_quiz AND l.is_active AND l.is_published`, userID).Scan(&n)
|
||||||
return n, err
|
return n, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) HasCompletedCurriculum(ctx context.Context, userID int64) (bool, error) {
|
func (s *Store) HasCompletedCurriculum(ctx context.Context, userID int64) (bool, error) {
|
||||||
|
// An issued (non-revoked) certificate is permanent proof of completion regardless
|
||||||
|
// of subsequent curriculum changes (e.g. new lessons added after the student finished).
|
||||||
|
if cert, err := s.GetCertificateByUser(ctx, userID); err == nil && cert.RevokedAt == nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
active, err := s.CountActiveLessons(ctx)
|
active, err := s.CountActiveLessons(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
@@ -648,42 +653,106 @@ func (s *Store) HasCompletedCurriculum(ctx context.Context, userID int64) (bool,
|
|||||||
|
|
||||||
func (s *Store) GetCertificateByUser(ctx context.Context, userID int64) (*models.Certificate, error) {
|
func (s *Store) GetCertificateByUser(ctx context.Context, userID int64) (*models.Certificate, error) {
|
||||||
row := s.pool.QueryRow(ctx, `
|
row := s.pool.QueryRow(ctx, `
|
||||||
SELECT id, user_id, public_id, full_name, issued_at, revoked_at
|
SELECT id, user_id, public_id, full_name, issued_at, revoked_at, status
|
||||||
FROM certificates WHERE user_id=$1`, userID)
|
FROM certificates WHERE user_id=$1`, userID)
|
||||||
return scanCertificate(row)
|
return scanCertificate(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) GetCertificateByPublicID(ctx context.Context, publicID string) (*models.Certificate, error) {
|
func (s *Store) GetCertificateByPublicID(ctx context.Context, publicID string) (*models.Certificate, error) {
|
||||||
row := s.pool.QueryRow(ctx, `
|
row := s.pool.QueryRow(ctx, `
|
||||||
SELECT id, user_id, public_id, full_name, issued_at, revoked_at
|
SELECT id, user_id, public_id, full_name, issued_at, revoked_at, status
|
||||||
FROM certificates WHERE public_id=$1`, publicID)
|
FROM certificates WHERE public_id=$1 AND status='issued'`, publicID)
|
||||||
return scanCertificate(row)
|
return scanCertificate(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanCertificate(row rowScanner) (*models.Certificate, error) {
|
func scanCertificate(row rowScanner) (*models.Certificate, error) {
|
||||||
var c models.Certificate
|
var c models.Certificate
|
||||||
err := row.Scan(&c.ID, &c.UserID, &c.PublicID, &c.FullName, &c.IssuedAt, &c.RevokedAt)
|
var publicID *string
|
||||||
|
err := row.Scan(&c.ID, &c.UserID, &publicID, &c.FullName, &c.IssuedAt, &c.RevokedAt, &c.Status)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if publicID != nil {
|
||||||
|
c.PublicID = *publicID
|
||||||
|
}
|
||||||
return &c, nil
|
return &c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) CreateCertificate(ctx context.Context, userID int64, publicID, fullName string) (*models.Certificate, error) {
|
func (s *Store) CreateCertificate(ctx context.Context, userID int64, publicID, fullName string) (*models.Certificate, error) {
|
||||||
row := s.pool.QueryRow(ctx, `
|
row := s.pool.QueryRow(ctx, `
|
||||||
INSERT INTO certificates (user_id, public_id, full_name)
|
INSERT INTO certificates (user_id, public_id, full_name, status)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3, 'issued')
|
||||||
ON CONFLICT (user_id) DO UPDATE SET
|
ON CONFLICT (user_id) DO UPDATE SET
|
||||||
full_name = EXCLUDED.full_name,
|
full_name = EXCLUDED.full_name,
|
||||||
|
public_id = EXCLUDED.public_id,
|
||||||
|
status = 'issued',
|
||||||
revoked_at = NULL
|
revoked_at = NULL
|
||||||
RETURNING id, user_id, public_id, full_name, issued_at, revoked_at`,
|
RETURNING id, user_id, public_id, full_name, issued_at, revoked_at, status`,
|
||||||
userID, publicID, fullName)
|
userID, publicID, fullName)
|
||||||
return scanCertificate(row)
|
return scanCertificate(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreatePendingCertificate(ctx context.Context, userID int64, fullName string) (*models.Certificate, error) {
|
||||||
|
row := s.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO certificates (user_id, public_id, full_name, status)
|
||||||
|
VALUES ($1, NULL, $2, 'pending')
|
||||||
|
ON CONFLICT (user_id) DO NOTHING
|
||||||
|
RETURNING id, user_id, public_id, full_name, issued_at, revoked_at, status`,
|
||||||
|
userID, fullName)
|
||||||
|
cert, err := scanCertificate(row)
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return s.GetCertificateByUser(ctx, userID)
|
||||||
|
}
|
||||||
|
return cert, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) IssuePendingCertificate(ctx context.Context, userID int64, publicID, fullName string) (*models.Certificate, error) {
|
||||||
|
row := s.pool.QueryRow(ctx, `
|
||||||
|
UPDATE certificates SET
|
||||||
|
public_id = $1,
|
||||||
|
full_name = $2,
|
||||||
|
status = 'issued',
|
||||||
|
revoked_at = NULL,
|
||||||
|
issued_at = NOW()
|
||||||
|
WHERE user_id = $3
|
||||||
|
RETURNING id, user_id, public_id, full_name, issued_at, revoked_at, status`,
|
||||||
|
publicID, fullName, userID)
|
||||||
|
return scanCertificate(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) RevokeCertificate(ctx context.Context, userID int64) error {
|
||||||
|
_, err := s.pool.Exec(ctx, `UPDATE certificates SET revoked_at=NOW() WHERE user_id=$1`, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListCertificatesByStatus(ctx context.Context, status string) ([]models.Certificate, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT c.id, c.user_id, c.public_id, c.full_name, c.issued_at, c.revoked_at, c.status
|
||||||
|
FROM certificates c
|
||||||
|
WHERE c.status=$1 AND c.revoked_at IS NULL
|
||||||
|
ORDER BY c.issued_at DESC`, status)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []models.Certificate
|
||||||
|
for rows.Next() {
|
||||||
|
var c models.Certificate
|
||||||
|
var publicID *string
|
||||||
|
if err := rows.Scan(&c.ID, &c.UserID, &publicID, &c.FullName, &c.IssuedAt, &c.RevokedAt, &c.Status); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if publicID != nil {
|
||||||
|
c.PublicID = *publicID
|
||||||
|
}
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
const physicalOrderCols = `id, certificate_id, user_id, status, note,
|
const physicalOrderCols = `id, certificate_id, user_id, status, note,
|
||||||
recipient_name, phone, address, city, postal_code, tracking_code,
|
recipient_name, phone, address, city, postal_code, tracking_code,
|
||||||
window_ends_at, created_at, updated_at`
|
window_ends_at, created_at, updated_at`
|
||||||
@@ -879,16 +948,22 @@ func (s *Store) DeleteUser(ctx context.Context, id int64) error {
|
|||||||
// ---- Chapters / Lessons / Questions ----
|
// ---- Chapters / Lessons / Questions ----
|
||||||
|
|
||||||
func (s *Store) CreateChapter(ctx context.Context, c *models.Chapter) (int64, error) {
|
func (s *Store) CreateChapter(ctx context.Context, c *models.Chapter) (int64, error) {
|
||||||
|
c.IsPublished = false
|
||||||
err := s.pool.QueryRow(ctx, `
|
err := s.pool.QueryRow(ctx, `
|
||||||
INSERT INTO chapters (title, description, icon, sort_order) VALUES ($1,$2,$3,$4) RETURNING id`,
|
INSERT INTO chapters (title, description, icon, sort_order, is_published) VALUES ($1,$2,$3,$4,$5) RETURNING id`,
|
||||||
c.Title, c.Description, c.Icon, c.SortOrder).Scan(&c.ID)
|
c.Title, c.Description, c.Icon, c.SortOrder, c.IsPublished).Scan(&c.ID)
|
||||||
return c.ID, err
|
return c.ID, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) UpdateChapter(ctx context.Context, id int64, c *models.Chapter) error {
|
func (s *Store) UpdateChapter(ctx context.Context, id int64, c *models.Chapter) error {
|
||||||
_, err := s.pool.Exec(ctx, `
|
_, err := s.pool.Exec(ctx, `
|
||||||
UPDATE chapters SET title=$1, description=$2, icon=$3, sort_order=$4 WHERE id=$5`,
|
UPDATE chapters SET title=$1, description=$2, icon=$3, sort_order=$4, is_published=$5 WHERE id=$6`,
|
||||||
c.Title, c.Description, c.Icon, c.SortOrder, id)
|
c.Title, c.Description, c.Icon, c.SortOrder, c.IsPublished, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) PublishChapter(ctx context.Context, id int64, publish bool) error {
|
||||||
|
_, err := s.pool.Exec(ctx, `UPDATE chapters SET is_published=$1 WHERE id=$2`, publish, id)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -898,7 +973,7 @@ func (s *Store) DeleteChapter(ctx context.Context, id int64) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) ListChapters(ctx context.Context) ([]models.Chapter, error) {
|
func (s *Store) ListChapters(ctx context.Context) ([]models.Chapter, error) {
|
||||||
rows, err := s.pool.Query(ctx, `SELECT id, title, description, icon, sort_order FROM chapters ORDER BY sort_order, id`)
|
rows, err := s.pool.Query(ctx, `SELECT id, title, description, icon, sort_order, is_published FROM chapters ORDER BY sort_order, id`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -906,7 +981,7 @@ func (s *Store) ListChapters(ctx context.Context) ([]models.Chapter, error) {
|
|||||||
var out []models.Chapter
|
var out []models.Chapter
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var c models.Chapter
|
var c models.Chapter
|
||||||
if err := rows.Scan(&c.ID, &c.Title, &c.Description, &c.Icon, &c.SortOrder); err != nil {
|
if err := rows.Scan(&c.ID, &c.Title, &c.Description, &c.Icon, &c.SortOrder, &c.IsPublished); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out = append(out, c)
|
out = append(out, c)
|
||||||
@@ -915,22 +990,28 @@ func (s *Store) ListChapters(ctx context.Context) ([]models.Chapter, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) CreateLesson(ctx context.Context, l *models.Lesson) (int64, error) {
|
func (s *Store) CreateLesson(ctx context.Context, l *models.Lesson) (int64, error) {
|
||||||
|
l.IsPublished = false
|
||||||
err := s.pool.QueryRow(ctx, `
|
err := s.pool.QueryRow(ctx, `
|
||||||
INSERT INTO lessons (chapter_id, title, description, video_key, duration_seconds,
|
INSERT INTO lessons (chapter_id, title, description, video_key, duration_seconds,
|
||||||
completion_threshold_pct, requires_lesson_id, x, y, sort_order, xp_reward, is_active)
|
completion_threshold_pct, requires_lesson_id, x, y, sort_order, xp_reward, is_active, is_published)
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) RETURNING id`,
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING id`,
|
||||||
l.ChapterID, l.Title, l.Description, l.VideoKey, l.DurationSeconds,
|
l.ChapterID, l.Title, l.Description, l.VideoKey, l.DurationSeconds,
|
||||||
l.CompletionThresholdPct, l.RequiresLessonID, l.X, l.Y, l.SortOrder, l.XPReward, l.IsActive).Scan(&l.ID)
|
l.CompletionThresholdPct, l.RequiresLessonID, l.X, l.Y, l.SortOrder, l.XPReward, l.IsActive, l.IsPublished).Scan(&l.ID)
|
||||||
return l.ID, err
|
return l.ID, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) UpdateLesson(ctx context.Context, id int64, l *models.Lesson) error {
|
func (s *Store) UpdateLesson(ctx context.Context, id int64, l *models.Lesson) error {
|
||||||
_, err := s.pool.Exec(ctx, `
|
_, err := s.pool.Exec(ctx, `
|
||||||
UPDATE lessons SET chapter_id=$1, title=$2, description=$3, video_key=$4, duration_seconds=$5,
|
UPDATE lessons SET chapter_id=$1, title=$2, description=$3, video_key=$4, duration_seconds=$5,
|
||||||
completion_threshold_pct=$6, requires_lesson_id=$7, x=$8, y=$9, sort_order=$10, xp_reward=$11, is_active=$12
|
completion_threshold_pct=$6, requires_lesson_id=$7, x=$8, y=$9, sort_order=$10, xp_reward=$11, is_active=$12, is_published=$13
|
||||||
WHERE id=$13`,
|
WHERE id=$14`,
|
||||||
l.ChapterID, l.Title, l.Description, l.VideoKey, l.DurationSeconds,
|
l.ChapterID, l.Title, l.Description, l.VideoKey, l.DurationSeconds,
|
||||||
l.CompletionThresholdPct, l.RequiresLessonID, l.X, l.Y, l.SortOrder, l.XPReward, l.IsActive, id)
|
l.CompletionThresholdPct, l.RequiresLessonID, l.X, l.Y, l.SortOrder, l.XPReward, l.IsActive, l.IsPublished, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) PublishLesson(ctx context.Context, id int64, publish bool) error {
|
||||||
|
_, err := s.pool.Exec(ctx, `UPDATE lessons SET is_published=$1 WHERE id=$2`, publish, id)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -942,11 +1023,11 @@ func (s *Store) DeleteLesson(ctx context.Context, id int64) error {
|
|||||||
func (s *Store) GetLesson(ctx context.Context, id int64) (*models.Lesson, error) {
|
func (s *Store) GetLesson(ctx context.Context, id int64) (*models.Lesson, error) {
|
||||||
row := s.pool.QueryRow(ctx, `
|
row := s.pool.QueryRow(ctx, `
|
||||||
SELECT id, chapter_id, title, description, video_key, duration_seconds,
|
SELECT id, chapter_id, title, description, video_key, duration_seconds,
|
||||||
completion_threshold_pct, requires_lesson_id, x, y, sort_order, xp_reward, is_active
|
completion_threshold_pct, requires_lesson_id, x, y, sort_order, xp_reward, is_active, is_published
|
||||||
FROM lessons WHERE id=$1`, id)
|
FROM lessons WHERE id=$1`, id)
|
||||||
var l models.Lesson
|
var l models.Lesson
|
||||||
err := row.Scan(&l.ID, &l.ChapterID, &l.Title, &l.Description, &l.VideoKey, &l.DurationSeconds,
|
err := row.Scan(&l.ID, &l.ChapterID, &l.Title, &l.Description, &l.VideoKey, &l.DurationSeconds,
|
||||||
&l.CompletionThresholdPct, &l.RequiresLessonID, &l.X, &l.Y, &l.SortOrder, &l.XPReward, &l.IsActive)
|
&l.CompletionThresholdPct, &l.RequiresLessonID, &l.X, &l.Y, &l.SortOrder, &l.XPReward, &l.IsActive, &l.IsPublished)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -955,9 +1036,9 @@ func (s *Store) GetLesson(ctx context.Context, id int64) (*models.Lesson, error)
|
|||||||
|
|
||||||
func (s *Store) ListLessons(ctx context.Context, includeInactive bool) ([]models.Lesson, error) {
|
func (s *Store) ListLessons(ctx context.Context, includeInactive bool) ([]models.Lesson, error) {
|
||||||
q := `SELECT id, chapter_id, title, description, video_key, duration_seconds,
|
q := `SELECT id, chapter_id, title, description, video_key, duration_seconds,
|
||||||
completion_threshold_pct, requires_lesson_id, x, y, sort_order, xp_reward, is_active FROM lessons`
|
completion_threshold_pct, requires_lesson_id, x, y, sort_order, xp_reward, is_active, is_published FROM lessons`
|
||||||
if !includeInactive {
|
if !includeInactive {
|
||||||
q += ` WHERE is_active`
|
q += ` WHERE is_active AND is_published`
|
||||||
}
|
}
|
||||||
q += ` ORDER BY chapter_id, sort_order, id`
|
q += ` ORDER BY chapter_id, sort_order, id`
|
||||||
rows, err := s.pool.Query(ctx, q)
|
rows, err := s.pool.Query(ctx, q)
|
||||||
@@ -969,7 +1050,7 @@ func (s *Store) ListLessons(ctx context.Context, includeInactive bool) ([]models
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var l models.Lesson
|
var l models.Lesson
|
||||||
if err := rows.Scan(&l.ID, &l.ChapterID, &l.Title, &l.Description, &l.VideoKey, &l.DurationSeconds,
|
if err := rows.Scan(&l.ID, &l.ChapterID, &l.Title, &l.Description, &l.VideoKey, &l.DurationSeconds,
|
||||||
&l.CompletionThresholdPct, &l.RequiresLessonID, &l.X, &l.Y, &l.SortOrder, &l.XPReward, &l.IsActive); err != nil {
|
&l.CompletionThresholdPct, &l.RequiresLessonID, &l.X, &l.Y, &l.SortOrder, &l.XPReward, &l.IsActive, &l.IsPublished); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out = append(out, l)
|
out = append(out, l)
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ services:
|
|||||||
- --entrypoints.web.http.redirections.entrypoint.permanent=true
|
- --entrypoints.web.http.redirections.entrypoint.permanent=true
|
||||||
- --entrypoints.web.http.aliasHeadersStrategy=delete
|
- --entrypoints.web.http.aliasHeadersStrategy=delete
|
||||||
- --entrypoints.websecure.http.aliasHeadersStrategy=delete
|
- --entrypoints.websecure.http.aliasHeadersStrategy=delete
|
||||||
|
# Allow slow, large uploads (admin video ≤ 200MB); default is 60s.
|
||||||
|
- --entrypoints.web.transport.respondingTimeouts.readTimeout=3600
|
||||||
|
- --entrypoints.websecure.transport.respondingTimeouts.readTimeout=3600
|
||||||
# Dev: Traefik default certificate (browser warning expected on localhost).
|
# Dev: Traefik default certificate (browser warning expected on localhost).
|
||||||
- --api.dashboard=true
|
- --api.dashboard=true
|
||||||
- --api.insecure=true
|
- --api.insecure=true
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ services:
|
|||||||
- --entrypoints.web.http.redirections.entrypoint.permanent=true
|
- --entrypoints.web.http.redirections.entrypoint.permanent=true
|
||||||
- --entrypoints.web.http.aliasHeadersStrategy=delete
|
- --entrypoints.web.http.aliasHeadersStrategy=delete
|
||||||
- --entrypoints.websecure.http.aliasHeadersStrategy=delete
|
- --entrypoints.websecure.http.aliasHeadersStrategy=delete
|
||||||
|
# Allow slow, large uploads (admin video ≤ 200MB); default is 60s.
|
||||||
|
- --entrypoints.web.transport.respondingTimeouts.readTimeout=3600
|
||||||
|
- --entrypoints.websecure.transport.respondingTimeouts.readTimeout=3600
|
||||||
- --certificatesresolvers.le.acme.email=${ACME_EMAIL:?ACME_EMAIL must be set}
|
- --certificatesresolvers.le.acme.email=${ACME_EMAIL:?ACME_EMAIL must be set}
|
||||||
- --certificatesresolvers.le.acme.storage=/data/acme.json
|
- --certificatesresolvers.le.acme.storage=/data/acme.json
|
||||||
- --certificatesresolvers.le.acme.httpchallenge=true
|
- --certificatesresolvers.le.acme.httpchallenge=true
|
||||||
|
|||||||
@@ -2,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.2
|
version: 0.2.3
|
||||||
appVersion: "0.2.0"
|
appVersion: "0.2.1"
|
||||||
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,85 @@
|
|||||||
|
backend:
|
||||||
|
autoscaling:
|
||||||
|
enabled: false
|
||||||
|
env:
|
||||||
|
devMode: "false"
|
||||||
|
donationEnabled: "true"
|
||||||
|
otelEndpoint: http://tempo.infra.svc.cluster.local:4318
|
||||||
|
physicalCertEnabled: "false"
|
||||||
|
seedCurriculum: "false"
|
||||||
|
existingSecret: pargar-app
|
||||||
|
image:
|
||||||
|
pullPolicy: IfNotPresent
|
||||||
|
repository: pargar-backend
|
||||||
|
tag: 0.3.7
|
||||||
|
metrics:
|
||||||
|
serviceMonitor:
|
||||||
|
enabled: true
|
||||||
|
interval: 30s
|
||||||
|
podDisruptionBudget:
|
||||||
|
enabled: true
|
||||||
|
minAvailable: 1
|
||||||
|
replicaCount: 3
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpu: "1"
|
||||||
|
memory: 512Mi
|
||||||
|
requests:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 128Mi
|
||||||
|
backup:
|
||||||
|
enabled: true
|
||||||
|
persistence:
|
||||||
|
size: 2Gi
|
||||||
|
retentionDays: 7
|
||||||
|
schedule: 0 3 * * *
|
||||||
|
certManager:
|
||||||
|
enabled: false
|
||||||
|
frontend:
|
||||||
|
autoscaling:
|
||||||
|
enabled: false
|
||||||
|
image:
|
||||||
|
pullPolicy: IfNotPresent
|
||||||
|
repository: pargar-frontend
|
||||||
|
tag: 0.3.7
|
||||||
|
podDisruptionBudget:
|
||||||
|
enabled: false
|
||||||
|
replicaCount: 2
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpu: "1"
|
||||||
|
memory: 512Mi
|
||||||
|
requests:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 128Mi
|
||||||
|
global:
|
||||||
|
domain: pargar.panahifar.ir
|
||||||
|
frontendOrigins: https://pargar.panahifar.ir
|
||||||
|
# Images published by .gitea/workflows/build-and-push.yaml on tag push.
|
||||||
|
imageRegistry: src.panahifar.ir/ahp
|
||||||
|
publicUrl: https://pargar.panahifar.ir
|
||||||
|
# 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:
|
||||||
|
enabled: true
|
||||||
|
tls:
|
||||||
|
enabled: true
|
||||||
|
existingSecret: pargar-wildcard-tls
|
||||||
|
useIngressRoute: true
|
||||||
|
networkPolicy:
|
||||||
|
enabled: false
|
||||||
|
postgres:
|
||||||
|
enabled: true
|
||||||
|
persistence:
|
||||||
|
size: 10Gi
|
||||||
|
redis:
|
||||||
|
enabled: true
|
||||||
|
persistence:
|
||||||
|
size: 2Gi
|
||||||
|
rustfs:
|
||||||
|
consoleEnable: false
|
||||||
|
enabled: true
|
||||||
|
persistence:
|
||||||
|
size: 20Gi
|
||||||
@@ -3,26 +3,27 @@
|
|||||||
# -f deploy/helm/pargar/values-prod.yaml
|
# -f deploy/helm/pargar/values-prod.yaml
|
||||||
#
|
#
|
||||||
# Prerequisites:
|
# Prerequisites:
|
||||||
# - images pushed to ghcr.io/pargar/{backend,frontend}:<tag>
|
# - images pushed to src.panahifar.ir/ahp/{pargar-backend,pargar-frontend}:<tag>
|
||||||
|
# (by .gitea/workflows/build-and-push.yaml on a v* tag push)
|
||||||
# - Secret pargar-app (jwt-secret, admin-password, metrics-token, database-url, redis-url,
|
# - Secret pargar-app (jwt-secret, admin-password, metrics-token, database-url, redis-url,
|
||||||
# storage-access-key, storage-secret-key)
|
# storage-access-key, storage-secret-key)
|
||||||
# - cert-manager ClusterIssuer letsencrypt-prod
|
# - cert-manager ClusterIssuer letsencrypt-prod
|
||||||
# - imagePullSecrets if the registry is private
|
# - imagePullSecrets if the Gitea package registry visibility is private
|
||||||
|
|
||||||
global:
|
global:
|
||||||
domain: app.example.com
|
domain: app.example.com
|
||||||
publicUrl: https://app.example.com
|
publicUrl: https://app.example.com
|
||||||
frontendOrigins: "https://app.example.com"
|
frontendOrigins: "https://app.example.com"
|
||||||
imageRegistry: ghcr.io/pargar
|
imageRegistry: src.panahifar.ir/ahp
|
||||||
|
|
||||||
imagePullSecrets:
|
imagePullSecrets:
|
||||||
- name: ghcr-pull
|
- name: gitea-pull
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
replicaCount: 2
|
replicaCount: 2
|
||||||
image:
|
image:
|
||||||
repository: backend
|
repository: pargar-backend
|
||||||
tag: "0.2.0"
|
tag: "0.2.1"
|
||||||
pullPolicy: IfNotPresent
|
pullPolicy: IfNotPresent
|
||||||
existingSecret: pargar-app
|
existingSecret: pargar-app
|
||||||
env:
|
env:
|
||||||
@@ -48,8 +49,8 @@ backend:
|
|||||||
frontend:
|
frontend:
|
||||||
replicaCount: 2
|
replicaCount: 2
|
||||||
image:
|
image:
|
||||||
repository: frontend
|
repository: pargar-frontend
|
||||||
tag: "0.2.0"
|
tag: "0.2.1"
|
||||||
pullPolicy: IfNotPresent
|
pullPolicy: IfNotPresent
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# k3s Traefik customisation (HelmChartConfig for the built-in `traefik` chart).
|
||||||
|
#
|
||||||
|
# Apply with: kubectl apply -f deploy/k3s/traefik-helmchartconfig.yaml
|
||||||
|
#
|
||||||
|
# Why: Traefik's entrypoint `readTimeout` defaults to 60s and covers reading the
|
||||||
|
# *entire request including the body*. Admin video uploads (up to 200MB) that take
|
||||||
|
# longer than 60s were cut off with a 502 before reaching the backend. Raise it to
|
||||||
|
# 1 hour (use 0 to disable entirely).
|
||||||
|
apiVersion: helm.cattle.io/v1
|
||||||
|
kind: HelmChartConfig
|
||||||
|
metadata:
|
||||||
|
name: traefik
|
||||||
|
namespace: kube-system
|
||||||
|
spec:
|
||||||
|
failurePolicy: reinstall
|
||||||
|
valuesContent: |-
|
||||||
|
# ACCESSLOG tile -> ON; access logs go to container stdout (kubectl logs).
|
||||||
|
# Key is logs.access.enabled on the chart k3s ships (40.1.5);
|
||||||
|
# the top-level accessLog.enabled only exists on newer chart versions.
|
||||||
|
logs:
|
||||||
|
access:
|
||||||
|
enabled: true
|
||||||
|
# Allow IngressRoutes to reference Services living in other namespaces,
|
||||||
|
# e.g. argocd-server in `argocd`.
|
||||||
|
providers:
|
||||||
|
kubernetesCRD:
|
||||||
|
allowCrossNamespace: true
|
||||||
|
deployment:
|
||||||
|
podAnnotations:
|
||||||
|
# fix: metrics entrypoint listens on :9100, annotation said 8082
|
||||||
|
prometheus.io/port: "9100"
|
||||||
|
prometheus.io/scrape: "true"
|
||||||
|
# Allow slow, large uploads (admin video upload ≤ 200MB) to finish.
|
||||||
|
# Default is 60s; 3600s = 1h. Set to 0 to disable the cap.
|
||||||
|
ports:
|
||||||
|
web:
|
||||||
|
transport:
|
||||||
|
respondingTimeouts:
|
||||||
|
readTimeout: 3600
|
||||||
|
websecure:
|
||||||
|
transport:
|
||||||
|
respondingTimeouts:
|
||||||
|
readTimeout: 3600
|
||||||
Generated
+844
-3
@@ -16,13 +16,20 @@
|
|||||||
"@radix-ui/react-switch": "^1.1.2",
|
"@radix-ui/react-switch": "^1.1.2",
|
||||||
"@radix-ui/react-tabs": "^1.1.2",
|
"@radix-ui/react-tabs": "^1.1.2",
|
||||||
"@tanstack/react-query": "^5.62.0",
|
"@tanstack/react-query": "^5.62.0",
|
||||||
|
"@tiptap/extension-placeholder": "^3.32.0",
|
||||||
|
"@tiptap/react": "^3.32.0",
|
||||||
|
"@tiptap/starter-kit": "^3.32.0",
|
||||||
|
"@types/dompurify": "^3.0.5",
|
||||||
"boring-avatars": "^2.0.4",
|
"boring-avatars": "^2.0.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"dompurify": "^3.4.17",
|
||||||
"emoji-datasource-apple": "^15.1.2",
|
"emoji-datasource-apple": "^15.1.2",
|
||||||
"framer-motion": "^12.0.0",
|
"framer-motion": "^12.0.0",
|
||||||
|
"hls.js": "^1.7.3",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
|
"marked": "^18.1.1",
|
||||||
"next": "15.5.27",
|
"next": "15.5.27",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
@@ -30,6 +37,7 @@
|
|||||||
"react-multi-date-picker": "^4.5.2",
|
"react-multi-date-picker": "^4.5.2",
|
||||||
"sonner": "^1.7.1",
|
"sonner": "^1.7.1",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
|
"tiptap-markdown": "^0.9.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"zustand": "^5.0.2"
|
"zustand": "^5.0.2"
|
||||||
},
|
},
|
||||||
@@ -1772,6 +1780,480 @@
|
|||||||
"react": "^18 || ^19"
|
"react": "^18 || ^19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tiptap/core": {
|
||||||
|
"version": "3.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.32.0.tgz",
|
||||||
|
"integrity": "sha512-Ghh2h02GKSqzytQGcLV4espYOG4EHxjj0MsrUB2Vnr47xmlZYRf6r7k2J09H+JpBdaGrWwrTkyBEi5i3c3yy2Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/pm": "3.32.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-blockquote": {
|
||||||
|
"version": "3.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.32.0.tgz",
|
||||||
|
"integrity": "sha512-wmuthEdlYVArNoUbqFPH35giutBABFu7fqEB0lTD1k0UXJUs4dFvcbRyGWWgnHFZgP3TT4u8e+xiayYw0eQ0NQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.32.0",
|
||||||
|
"@tiptap/pm": "3.32.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-bold": {
|
||||||
|
"version": "3.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.32.0.tgz",
|
||||||
|
"integrity": "sha512-SQYewnYDoIBN+8oH53IDcD8p+Pz+Jnyq3hrxjy/FAo5ctc+96d3ZLdqc9gzLVSqFFo/SUINv2FVGoq9PvU7yqw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.32.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-bubble-menu": {
|
||||||
|
"version": "3.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.32.0.tgz",
|
||||||
|
"integrity": "sha512-X3ll8i9Z8QNvt7+tcqlwopJG+68ugO13Av0Ox4Ico+jXcTnFdDuKQJBSI9sLuMR5vTQOeTHhKYXhjF88aahMSA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/dom": "^1.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.32.0",
|
||||||
|
"@tiptap/pm": "3.32.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-bullet-list": {
|
||||||
|
"version": "3.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.32.0.tgz",
|
||||||
|
"integrity": "sha512-Y5P/l06pVCUG46vXpBl3z0fW2b6JpSMX4Cvr+5sfQh8zB3TxwSiLQaaPwI3R5MFD1KfRk3AjiPRieEyXdWcNHw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-list": "3.32.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-code": {
|
||||||
|
"version": "3.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.32.0.tgz",
|
||||||
|
"integrity": "sha512-I+aEVl0dTmkfnU6HzBGEefb9U7ja2MkucwBBwrmoMwU+uIKpC4xw8NcHC9X6sjAcom3Pxs2HX+8WEo4Vp13R/Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.32.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-code-block": {
|
||||||
|
"version": "3.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.32.0.tgz",
|
||||||
|
"integrity": "sha512-9NZW/yXP9j9XKr+hRS5m58xiyZI+tQo1PJ7eLHXjGNFWaF601NG/h6741mbKh5uCY51XsbyqG+7que+RavngPA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.32.0",
|
||||||
|
"@tiptap/pm": "3.32.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-document": {
|
||||||
|
"version": "3.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.32.0.tgz",
|
||||||
|
"integrity": "sha512-GugeKE6AnZDluRXJUNBuX43TPcaJxy5wck6vT2J4a0/EnKStJ43suSZZf1IPfb1ij9EL9bYvAwg9DOcrvdEzTQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.32.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-dropcursor": {
|
||||||
|
"version": "3.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.32.0.tgz",
|
||||||
|
"integrity": "sha512-t4Ug8hFLt30dwwYvrh63BVUt6N1Yg5PVI3tMllhCmN3Q68oD47v4MB/i5nGfzIsQqMyXUOetwHJWcZ4MoLTx4Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extensions": "3.32.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-floating-menu": {
|
||||||
|
"version": "3.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.32.0.tgz",
|
||||||
|
"integrity": "sha512-AzKCWceL+4Q1pTc8Whw1kPqN/aYGPQ70/jXPsblWl501t/pXi6Kz0Pi/NggKkuu73VGjykh68YYf/dcOJ/mPZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@floating-ui/dom": "^1.0.0",
|
||||||
|
"@tiptap/core": "3.32.0",
|
||||||
|
"@tiptap/pm": "3.32.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-gapcursor": {
|
||||||
|
"version": "3.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.32.0.tgz",
|
||||||
|
"integrity": "sha512-HUpKKWJLtthnpQY5LIjHlJJOmh562RusmOMIe/4iP/H7y+WUR9ZFiZjEs1NBkRjig9fwj8F3BfsrVPYBnM4nLA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extensions": "3.32.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-hard-break": {
|
||||||
|
"version": "3.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.32.0.tgz",
|
||||||
|
"integrity": "sha512-IKUYvK+bO2hGrDWhhTVg0CSh9C2belWg9GyLzvpyd3SZyQ9EgdccLfoytNjOBKbAE/TQ4Oo3kSP+XF6RsUIKPA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.32.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-heading": {
|
||||||
|
"version": "3.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.32.0.tgz",
|
||||||
|
"integrity": "sha512-XRiZxZuhfjvbgwCvoBHj04gOsPANEXxxRwrFCQVuFi5yeTHYIzMEioc81j6iAH9pV7aQeJCwLxuh6VHNz3dXEQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.32.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-horizontal-rule": {
|
||||||
|
"version": "3.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.32.0.tgz",
|
||||||
|
"integrity": "sha512-Iry7ynsG4xgcxnH1cm+uXJnEtUzoop4sGA10NUN9I+08TOjhj/GrDiRKMQHKAiVI/R7t2MVav1MJlxFf2FdzGw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.32.0",
|
||||||
|
"@tiptap/pm": "3.32.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-italic": {
|
||||||
|
"version": "3.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.32.0.tgz",
|
||||||
|
"integrity": "sha512-KB/i1xoHilbBGcQ+UotDWhi60KpdCde3RzRIpl2na0Yol4z4Fe2IRWg8ljIrlsssDRwel/fsHJbG7a13EP8oKA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.32.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-link": {
|
||||||
|
"version": "3.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.32.0.tgz",
|
||||||
|
"integrity": "sha512-DRdQSgWPSEjrDg5EDBB5yxRygHtUVLhceVGzAsTz8RziiFyvcwAvzBHCO9hd9jutjCXQ2DLIuZB8P+aHhFWnpg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"linkifyjs": "^4.3.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.32.0",
|
||||||
|
"@tiptap/pm": "3.32.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-list": {
|
||||||
|
"version": "3.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.32.0.tgz",
|
||||||
|
"integrity": "sha512-QdzS5nFz9agAQhBgIuQluobWib39W2cMKx1XB6tYSue7bHMJdgPFP0EnGfFajaeVX42CG6RXtQWrNfyQGKhBvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.32.0",
|
||||||
|
"@tiptap/pm": "3.32.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-list-item": {
|
||||||
|
"version": "3.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.32.0.tgz",
|
||||||
|
"integrity": "sha512-FdJTCAeALixzyyGSSEmroXmkIEclbo/4t2Pl03aRERqpPOSPJcC3evDtIhLazTuP6v+FjCmJ+pXNql6ov7qitA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-list": "3.32.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-list-keymap": {
|
||||||
|
"version": "3.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.32.0.tgz",
|
||||||
|
"integrity": "sha512-vXFre18Dk2m5N/gjo408C02GSrolZ8ZIf0wz6/U8lNC4FcYOISgZFj3BND6uabVntGh2GCSPrVFuNpVPhFnn/A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-list": "3.32.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-ordered-list": {
|
||||||
|
"version": "3.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.32.0.tgz",
|
||||||
|
"integrity": "sha512-htZ5hoMLBkGiDP6M5mGemjOoaBmkrj911wiGoTmMMKzO7qJq9QxUBkLY7ILPaKlUWL8U3ccLdDV2TB/nysp4Jg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-list": "3.32.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-paragraph": {
|
||||||
|
"version": "3.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.32.0.tgz",
|
||||||
|
"integrity": "sha512-afKLMc7fWMkkar9H5FilizStSbhFzUoCzdwwdZf6DDsn3LUt8tJlFHACK78cAyx/TzMJBo0N8/8T1u9bL+N67Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.32.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-placeholder": {
|
||||||
|
"version": "3.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.32.0.tgz",
|
||||||
|
"integrity": "sha512-TSjyQmVFq56gfURPotxbMsXU3seNTy9VzMACdb6b0bQ66yGWBcjBfWmhpiSRVLdBjQrRvl1+jQbWx0YPSCSpsw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extensions": "3.32.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-strike": {
|
||||||
|
"version": "3.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.32.0.tgz",
|
||||||
|
"integrity": "sha512-J9Vw6wmh/wV6SxSsN9RhurvAGcXSY5J3p4EQrROD5PARBl0VOnDFi7/UVoz1RkSdqOKPgAiqDZpaLRgtdBO9Rg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.32.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-text": {
|
||||||
|
"version": "3.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.32.0.tgz",
|
||||||
|
"integrity": "sha512-sF0jIK67IHdZMnzv2mz5vPTyK7R0ui6/YZgFg+/bLnFCNmweVAoYcHZLlC7ISRnwu0mqJr+epNtzJepY1ZDCjg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.32.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-underline": {
|
||||||
|
"version": "3.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.32.0.tgz",
|
||||||
|
"integrity": "sha512-rMLdd4vg6sed/JnaXnaupLbCtYiWikTnQ8qir9ItKOXwf1Ui9P1Y4G0bYU4+gcnMsFzaSGtrOhMqG4/c66AogQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.32.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extensions": {
|
||||||
|
"version": "3.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.32.0.tgz",
|
||||||
|
"integrity": "sha512-qO+ndNvlOjzU2MWCtQHoRg1tIortCXLvqPjDx58F1pUYFNCOVZegXi+jqLGDODUA31er+TVz66umzRbbN1bO1Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.32.0",
|
||||||
|
"@tiptap/pm": "3.32.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/pm": {
|
||||||
|
"version": "3.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.32.0.tgz",
|
||||||
|
"integrity": "sha512-H9eN+xHFboJybE7KPGVsu24S8aBlCIgYz38vCHRSnNv17nwmqm06owq/IQ5scza0n3Y7IfUfpgPOtm9SgeHmNQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-changeset": "^2.4.4",
|
||||||
|
"prosemirror-commands": "^1.7.2",
|
||||||
|
"prosemirror-dropcursor": "^1.8.4",
|
||||||
|
"prosemirror-gapcursor": "^1.4.1",
|
||||||
|
"prosemirror-history": "^1.5.1",
|
||||||
|
"prosemirror-inputrules": "^1.5.1",
|
||||||
|
"prosemirror-keymap": "^1.2.3",
|
||||||
|
"prosemirror-model": "^1.25.12",
|
||||||
|
"prosemirror-schema-list": "^1.5.1",
|
||||||
|
"prosemirror-state": "^1.4.4",
|
||||||
|
"prosemirror-tables": "^1.8.5",
|
||||||
|
"prosemirror-transform": "^1.12.2",
|
||||||
|
"prosemirror-view": "^1.42.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/react": {
|
||||||
|
"version": "3.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.32.0.tgz",
|
||||||
|
"integrity": "sha512-+iS9RKj6Ve8SXqJ49xptDiifF7PQXmUOh4cjMu+BCXXqKphQkiUccvbWYAeOJCTceM01wWgFhrpfy8qG+CHJYw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
|
"fast-equals": "^5.3.3",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@tiptap/extension-bubble-menu": "3.32.0",
|
||||||
|
"@tiptap/extension-floating-menu": "3.32.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.32.0",
|
||||||
|
"@tiptap/pm": "3.32.0",
|
||||||
|
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/starter-kit": {
|
||||||
|
"version": "3.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.32.0.tgz",
|
||||||
|
"integrity": "sha512-aPYxaevci4PgMrTLsg+baiqI7M1ICd4V9H+zTTdrqs8OUZIbAvj/pNDuHU8uWZyKMu3Dh72t2D6ZdqRu/6aSWw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tiptap/core": "3.32.0",
|
||||||
|
"@tiptap/extension-blockquote": "3.32.0",
|
||||||
|
"@tiptap/extension-bold": "3.32.0",
|
||||||
|
"@tiptap/extension-bullet-list": "3.32.0",
|
||||||
|
"@tiptap/extension-code": "3.32.0",
|
||||||
|
"@tiptap/extension-code-block": "3.32.0",
|
||||||
|
"@tiptap/extension-document": "3.32.0",
|
||||||
|
"@tiptap/extension-dropcursor": "3.32.0",
|
||||||
|
"@tiptap/extension-gapcursor": "3.32.0",
|
||||||
|
"@tiptap/extension-hard-break": "3.32.0",
|
||||||
|
"@tiptap/extension-heading": "3.32.0",
|
||||||
|
"@tiptap/extension-horizontal-rule": "3.32.0",
|
||||||
|
"@tiptap/extension-italic": "3.32.0",
|
||||||
|
"@tiptap/extension-link": "3.32.0",
|
||||||
|
"@tiptap/extension-list": "3.32.0",
|
||||||
|
"@tiptap/extension-list-item": "3.32.0",
|
||||||
|
"@tiptap/extension-list-keymap": "3.32.0",
|
||||||
|
"@tiptap/extension-ordered-list": "3.32.0",
|
||||||
|
"@tiptap/extension-paragraph": "3.32.0",
|
||||||
|
"@tiptap/extension-strike": "3.32.0",
|
||||||
|
"@tiptap/extension-text": "3.32.0",
|
||||||
|
"@tiptap/extension-underline": "3.32.0",
|
||||||
|
"@tiptap/extensions": "3.32.0",
|
||||||
|
"@tiptap/pm": "3.32.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/dompurify": {
|
||||||
|
"version": "3.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
|
||||||
|
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/trusted-types": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/linkify-it": {
|
||||||
|
"version": "3.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.5.tgz",
|
||||||
|
"integrity": "sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/markdown-it": {
|
||||||
|
"version": "13.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-13.0.9.tgz",
|
||||||
|
"integrity": "sha512-1XPwR0+MgXLWfTn9gCsZ55AHOKW1WN+P9vr0PaQh5aerR9LLQXUbjfEAFhjmEmyoYFWAyuN2Mqkn40MZ4ukjBw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/linkify-it": "^3",
|
||||||
|
"@types/mdurl": "^1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/mdurl": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.20.2",
|
"version": "22.20.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.2.tgz",
|
||||||
@@ -1786,7 +2268,6 @@
|
|||||||
"version": "19.3.0",
|
"version": "19.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
|
||||||
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
|
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -1796,12 +2277,29 @@
|
|||||||
"version": "19.3.0",
|
"version": "19.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
|
||||||
"integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
|
"integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.3.0"
|
"@types/react": "^19.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/trusted-types": {
|
||||||
|
"version": "2.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/use-sync-external-store": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/argparse": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||||
|
"license": "Python-2.0"
|
||||||
|
},
|
||||||
"node_modules/aria-hidden": {
|
"node_modules/aria-hidden": {
|
||||||
"version": "1.2.6",
|
"version": "1.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
|
||||||
@@ -1875,7 +2373,6 @@
|
|||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/date-fns": {
|
"node_modules/date-fns": {
|
||||||
@@ -1904,6 +2401,15 @@
|
|||||||
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
|
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/dompurify": {
|
||||||
|
"version": "3.4.17",
|
||||||
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.17.tgz",
|
||||||
|
"integrity": "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==",
|
||||||
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@types/trusted-types": "^2.0.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/emoji-datasource-apple": {
|
"node_modules/emoji-datasource-apple": {
|
||||||
"version": "15.1.2",
|
"version": "15.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-datasource-apple/-/emoji-datasource-apple-15.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-datasource-apple/-/emoji-datasource-apple-15.1.2.tgz",
|
||||||
@@ -1924,6 +2430,27 @@
|
|||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/entities": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fast-equals": {
|
||||||
|
"version": "5.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.5.tgz",
|
||||||
|
"integrity": "sha512-yKEpcFuRPu/pTQL2VB8YKE2yFldrLlIsoWtJeBnAdqEhvu21OgkFT0C8njf/xYc2LSZGEr3K9JGjthHJxnVLvQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/framer-motion": {
|
"node_modules/framer-motion": {
|
||||||
"version": "12.43.0",
|
"version": "12.43.0",
|
||||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.43.0.tgz",
|
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.43.0.tgz",
|
||||||
@@ -1967,6 +2494,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/hls.js": {
|
||||||
|
"version": "1.7.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.7.3.tgz",
|
||||||
|
"integrity": "sha512-MsPlx6yVW4Qv4C7mEVou4/gk/5cN2dVxOTnrNjPSmyH2f0Ln+/UkG10Ax1PE6OvrmTVPKgQYozTtBaPOuCzq7A==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/jiti": {
|
"node_modules/jiti": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||||
@@ -2238,6 +2771,31 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"url": "https://opencollective.com/parcel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/linkify-it": {
|
||||||
|
"version": "5.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz",
|
||||||
|
"integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/puzrin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/markdown-it"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"uc.micro": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/linkifyjs": {
|
||||||
|
"version": "4.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
|
||||||
|
"integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lucide-react": {
|
"node_modules/lucide-react": {
|
||||||
"version": "0.468.0",
|
"version": "0.468.0",
|
||||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz",
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz",
|
||||||
@@ -2257,6 +2815,57 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/markdown-it": {
|
||||||
|
"version": "14.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.3.tgz",
|
||||||
|
"integrity": "sha512-L//tDAkjTjJCU6pBUSBTgs3Kg4InVaZuUZog/b6V12UKhcDQ7S78877ibbCanRExwPuYP1CZD7Gd2dxSy1GXRg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/puzrin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/markdown-it"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"argparse": "^2.0.1",
|
||||||
|
"entities": "^4.5.0",
|
||||||
|
"linkify-it": "^5.0.2",
|
||||||
|
"mdurl": "^2.0.0",
|
||||||
|
"punycode.js": "^2.3.1",
|
||||||
|
"uc.micro": "^2.1.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"markdown-it": "bin/markdown-it.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/markdown-it-task-lists": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/markdown-it-task-lists/-/markdown-it-task-lists-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/marked": {
|
||||||
|
"version": "18.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/marked/-/marked-18.1.1.tgz",
|
||||||
|
"integrity": "sha512-zEGgrtUG8crk+TrMacJSVABU4DYDDkS8uqgU3ohrn/2fW9C2AEfUrO3m6IhpIpQpl7YcVpiXXC9cSMWDANT3dg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"marked": "bin/marked.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mdurl": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/motion-dom": {
|
"node_modules/motion-dom": {
|
||||||
"version": "12.43.0",
|
"version": "12.43.0",
|
||||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.43.0.tgz",
|
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.43.0.tgz",
|
||||||
@@ -2352,6 +2961,12 @@
|
|||||||
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/orderedmap": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -2386,6 +3001,187 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/prosemirror-changeset": {
|
||||||
|
"version": "2.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.4.tgz",
|
||||||
|
"integrity": "sha512-Gyuz9jYdrAeA9I7AholyOes9KUmv/lEwsC5O9s5xF5VB3EgTPsXMGUxz4+H1B+wKVPdZbvRcg1Cp3x8qX+8ofA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-transform": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-commands": {
|
||||||
|
"version": "1.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz",
|
||||||
|
"integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.10.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-dropcursor": {
|
||||||
|
"version": "1.8.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.4.tgz",
|
||||||
|
"integrity": "sha512-VmKxhcA6a+h5oPSYmfF1ZNeQqPbyYaIxqZsKqeYCyTlPtrjUgNaHL3P8vVaO19oqAV0zuaM/axkowZHrB1P3Tg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.1.0",
|
||||||
|
"prosemirror-view": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-gapcursor": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-keymap": "^1.0.0",
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-view": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-history": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-onlwnqKvgtFg6srZOmDOAhlAy1FIHQpOExORybX0S+DvJepE5tPALrB9iGet/2sqqqrelSnB10OoiOJqAvu2DA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.2.2",
|
||||||
|
"prosemirror-transform": "^1.0.0",
|
||||||
|
"prosemirror-view": "^1.31.0",
|
||||||
|
"rope-sequence": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-inputrules": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-keymap": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"w3c-keyname": "^2.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-markdown": {
|
||||||
|
"version": "1.13.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.8.tgz",
|
||||||
|
"integrity": "sha512-Zi43BXX28jmxDJKvk8TDS2gMkBY/mgP8ynd6l/Vxrv8rgNFs5hsV1G0VscxTID1UCzVPcidADP9fUOBY3xxUeA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/markdown-it": "^14.0.0",
|
||||||
|
"markdown-it": "^14.0.0",
|
||||||
|
"prosemirror-model": "^1.25.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-markdown/node_modules/@types/linkify-it": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-markdown/node_modules/@types/markdown-it": {
|
||||||
|
"version": "14.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.2.0.tgz",
|
||||||
|
"integrity": "sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/linkify-it": "^5",
|
||||||
|
"@types/mdurl": "^2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-markdown/node_modules/@types/mdurl": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-model": {
|
||||||
|
"version": "1.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.12.tgz",
|
||||||
|
"integrity": "sha512-Ue2gTmXMa7EhpLNhC7J+h4+ykD8ha12K6rrZFFKKJHBForfIStw5gJ6Zrf1mqqAa9NmpmEB4wp9bKmA4eUVjWg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"orderedmap": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-schema-list": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.7.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-state": {
|
||||||
|
"version": "1.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||||
|
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.0.0",
|
||||||
|
"prosemirror-view": "^1.27.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-tables": {
|
||||||
|
"version": "1.8.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
|
||||||
|
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-keymap": "^1.2.3",
|
||||||
|
"prosemirror-model": "^1.25.4",
|
||||||
|
"prosemirror-state": "^1.4.4",
|
||||||
|
"prosemirror-transform": "^1.10.5",
|
||||||
|
"prosemirror-view": "^1.41.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-transform": {
|
||||||
|
"version": "1.12.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.2.tgz",
|
||||||
|
"integrity": "sha512-PE/aY0HEY4zczvmqrilgkUK/WautF0chvMqkmM/iN5/aPHvwrCWaQZjS6CIZp+K84YrvPFqrL+YArrfJX8xX7g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-view": {
|
||||||
|
"version": "1.42.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.6.tgz",
|
||||||
|
"integrity": "sha512-3g25f6hKV2bBxo3puH3EcYWmmVNSH0iWmNsMVT7sugDSpA025jgpOLarcDWr39SdzPjYMaiYtRdNtXialPSjEQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.25.12",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/punycode.js": {
|
||||||
|
"version": "2.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
|
||||||
|
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "19.3.0",
|
"version": "19.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
|
||||||
@@ -2506,6 +3302,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rope-sequence": {
|
||||||
|
"version": "1.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
|
||||||
|
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
|
||||||
@@ -2648,6 +3450,24 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"url": "https://opencollective.com/webpack"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tiptap-markdown": {
|
||||||
|
"version": "0.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiptap-markdown/-/tiptap-markdown-0.9.0.tgz",
|
||||||
|
"integrity": "sha512-dKLQ9iiuGNgrlGVjrNauF/UBzWu4LYOx5pkD0jNkmQt/GOwfCJsBuzZTsf1jZ204ANHOm572mZ9PYvGh1S7tpQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"example"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@types/markdown-it": "^13.0.7",
|
||||||
|
"markdown-it": "^14.1.0",
|
||||||
|
"markdown-it-task-lists": "^2.1.1",
|
||||||
|
"prosemirror-markdown": "^1.11.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tslib": {
|
"node_modules/tslib": {
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
@@ -2677,6 +3497,12 @@
|
|||||||
"node": ">=14.17"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uc.micro": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
@@ -2727,6 +3553,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"version": "1.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz",
|
||||||
|
"integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/w3c-keyname": {
|
||||||
|
"version": "2.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||||
|
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/zustand": {
|
"node_modules/zustand": {
|
||||||
"version": "5.0.15",
|
"version": "5.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz",
|
||||||
|
|||||||
@@ -20,13 +20,20 @@
|
|||||||
"@radix-ui/react-switch": "^1.1.2",
|
"@radix-ui/react-switch": "^1.1.2",
|
||||||
"@radix-ui/react-tabs": "^1.1.2",
|
"@radix-ui/react-tabs": "^1.1.2",
|
||||||
"@tanstack/react-query": "^5.62.0",
|
"@tanstack/react-query": "^5.62.0",
|
||||||
|
"@tiptap/extension-placeholder": "^3.32.0",
|
||||||
|
"@tiptap/react": "^3.32.0",
|
||||||
|
"@tiptap/starter-kit": "^3.32.0",
|
||||||
|
"@types/dompurify": "^3.0.5",
|
||||||
"boring-avatars": "^2.0.4",
|
"boring-avatars": "^2.0.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"dompurify": "^3.4.17",
|
||||||
"emoji-datasource-apple": "^15.1.2",
|
"emoji-datasource-apple": "^15.1.2",
|
||||||
"framer-motion": "^12.0.0",
|
"framer-motion": "^12.0.0",
|
||||||
|
"hls.js": "^1.7.3",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
|
"marked": "^18.1.1",
|
||||||
"next": "15.5.27",
|
"next": "15.5.27",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
@@ -34,6 +41,7 @@
|
|||||||
"react-multi-date-picker": "^4.5.2",
|
"react-multi-date-picker": "^4.5.2",
|
||||||
"sonner": "^1.7.1",
|
"sonner": "^1.7.1",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
|
"tiptap-markdown": "^0.9.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"zustand": "^5.0.2"
|
"zustand": "^5.0.2"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,45 +1,23 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { Loader2, Package } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { CertificateCard } from "@/components/certificate-card";
|
import { CertificateCard } from "@/components/certificate-card";
|
||||||
import { NotFoundState, notFoundSentence } from "@/components/not-found-state";
|
import { NotFoundState, notFoundSentence } from "@/components/not-found-state";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { http, toUserError } from "@/lib/api";
|
import { http, toUserError } from "@/lib/api";
|
||||||
import { useAuth } from "@/lib/auth-store";
|
import { useAuth } from "@/lib/auth-store";
|
||||||
import type { Certificate, CertificatePhysicalOrder, PhysicalCertSettings } from "@/lib/types";
|
import type { Certificate } from "@/lib/types";
|
||||||
import { toast } from "@/components/providers";
|
|
||||||
import { formatFaNumber } from "@/lib/utils";
|
|
||||||
import { clampPhoneInput, normalizePhone, PHONE_INPUT_MAX_LEN } from "@/lib/validation";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
type PublicCertResponse = {
|
type PublicCertResponse = {
|
||||||
certificate: { publicId: string; fullName: string; issuedAt: string };
|
certificate: { publicId: string; fullName: string; issuedAt: string };
|
||||||
course: string;
|
course: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_LABEL: Record<string, string> = {
|
|
||||||
requested: "درخواستشده — منتظر تأیید پرداخت",
|
|
||||||
paid: "پرداخت تأیید شد — در صف ارسال",
|
|
||||||
shipped: "ارسال شد",
|
|
||||||
cancelled: "لغو شده",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function CertificatePage() {
|
export default function CertificatePage() {
|
||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
const publicId = params?.id ?? "";
|
const publicId = params?.id ?? "";
|
||||||
const authUser = useAuth((s) => s.user);
|
const authUser = useAuth((s) => s.user);
|
||||||
const qc = useQueryClient();
|
|
||||||
const [requesting, setRequesting] = useState(false);
|
|
||||||
const [form, setForm] = useState({
|
|
||||||
recipientName: "",
|
|
||||||
phone: "",
|
|
||||||
address: "",
|
|
||||||
city: "",
|
|
||||||
postalCode: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data, isLoading, error } = useQuery({
|
const { data, isLoading, error } = useQuery({
|
||||||
queryKey: ["certificate", publicId],
|
queryKey: ["certificate", publicId],
|
||||||
@@ -50,42 +28,12 @@ export default function CertificatePage() {
|
|||||||
|
|
||||||
const { data: mine } = useQuery({
|
const { data: mine } = useQuery({
|
||||||
queryKey: ["me", "certificate"],
|
queryKey: ["me", "certificate"],
|
||||||
queryFn: () =>
|
queryFn: () => http.get<{ certificate: Certificate | null }>("/api/me/certificate"),
|
||||||
http.get<{
|
|
||||||
certificate: Certificate | null;
|
|
||||||
physicalOrder: CertificatePhysicalOrder | null;
|
|
||||||
physical: PhysicalCertSettings;
|
|
||||||
}>("/api/me/certificate"),
|
|
||||||
enabled: !!authUser,
|
enabled: !!authUser,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isOwner = !!authUser && mine?.certificate?.publicId === publicId;
|
const isOwner = !!authUser && mine?.certificate?.publicId === publicId;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOwner || !authUser) return;
|
|
||||||
setForm((f) => ({
|
|
||||||
...f,
|
|
||||||
recipientName: f.recipientName || authUser.name || "",
|
|
||||||
phone: f.phone || authUser.phone || "",
|
|
||||||
}));
|
|
||||||
}, [isOwner, authUser]);
|
|
||||||
|
|
||||||
const requestPhysical = async () => {
|
|
||||||
setRequesting(true);
|
|
||||||
try {
|
|
||||||
await http.post("/api/me/certificate/physical", {
|
|
||||||
...form,
|
|
||||||
phone: normalizePhone(form.phone),
|
|
||||||
});
|
|
||||||
toast.success("درخواست ثبت شد — پس از واریز با ادمین هماهنگ کنید");
|
|
||||||
qc.invalidateQueries({ queryKey: ["me", "certificate"] });
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(toUserError(e));
|
|
||||||
} finally {
|
|
||||||
setRequesting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[60vh] items-center justify-center">
|
<div className="flex min-h-[60vh] items-center justify-center">
|
||||||
@@ -110,17 +58,6 @@ export default function CertificatePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cert = data.certificate;
|
const cert = data.certificate;
|
||||||
const order = isOwner ? mine?.physicalOrder : null;
|
|
||||||
const physical = isOwner ? mine?.physical : null;
|
|
||||||
const windowEndsAt = mine?.certificate?.issuedAt
|
|
||||||
? new Date(mine.certificate.issuedAt).getTime() + (physical?.windowDays ?? 30) * 86400000
|
|
||||||
: 0;
|
|
||||||
const windowStillOpen = !windowEndsAt || Date.now() < windowEndsAt;
|
|
||||||
const windowOpen =
|
|
||||||
physical?.enabled &&
|
|
||||||
windowStillOpen &&
|
|
||||||
(!order || order.status === "cancelled") &&
|
|
||||||
!!mine?.certificate;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CertificateCard
|
<CertificateCard
|
||||||
@@ -130,94 +67,7 @@ export default function CertificatePage() {
|
|||||||
issuedAt: cert.issuedAt,
|
issuedAt: cert.issuedAt,
|
||||||
course: data.course,
|
course: data.course,
|
||||||
}}
|
}}
|
||||||
showOwnerLinks={!!authUser}
|
showOwnerLinks={isOwner}
|
||||||
>
|
|
||||||
{isOwner && physical?.enabled && (
|
|
||||||
<div className="mt-8 space-y-3 border-t border-border pt-6">
|
|
||||||
<p className="flex items-center gap-2 text-sm font-bold">
|
|
||||||
<Package className="h-4 w-4" /> نسخه فیزیکی
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
قیمت حدودی {formatFaNumber(physical.priceIrr)} ریال · مهلت درخواست{" "}
|
|
||||||
{formatFaNumber(physical.windowDays)} روز از صدور
|
|
||||||
{windowEndsAt ? ` (تا ${new Date(windowEndsAt).toLocaleDateString("fa-IR")})` : ""}.
|
|
||||||
پرداخت آنلاین در سامانه نداریم. پس از واریز بیرونی با ادمین هماهنگ کنید تا وضعیت سفارش بهروز شود.
|
|
||||||
</p>
|
|
||||||
{order && order.status !== "cancelled" ? (
|
|
||||||
<div className="space-y-2 rounded-xl border border-border bg-muted/30 p-3 text-sm">
|
|
||||||
<p>
|
|
||||||
وضعیت: <span className="font-medium">{STATUS_LABEL[order.status] ?? order.status}</span>
|
|
||||||
</p>
|
|
||||||
{order.trackingCode && (
|
|
||||||
<p className="font-mono text-xs" dir="ltr">
|
|
||||||
کد پیگیری: {order.trackingCode}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{order.city && (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
ارسال به {order.recipientName || "—"} · {order.city}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{order.note && <p className="text-xs text-muted-foreground">یادداشت ادمین: {order.note}</p>}
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() =>
|
|
||||||
window.dispatchEvent(new CustomEvent("pargar:chat", { detail: { open: true } }))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
هماهنگی با تیم
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : windowOpen ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
|
||||||
<Input
|
|
||||||
placeholder="نام گیرنده"
|
|
||||||
value={form.recipientName}
|
|
||||||
onChange={(e) => setForm((f) => ({ ...f, recipientName: e.target.value }))}
|
|
||||||
/>
|
/>
|
||||||
<Input
|
|
||||||
placeholder="موبایل"
|
|
||||||
value={form.phone}
|
|
||||||
onChange={(e) => setForm((f) => ({ ...f, phone: clampPhoneInput(e.target.value) }))}
|
|
||||||
dir="ltr"
|
|
||||||
inputMode="tel"
|
|
||||||
maxLength={PHONE_INPUT_MAX_LEN}
|
|
||||||
className="text-left"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
placeholder="شهر"
|
|
||||||
value={form.city}
|
|
||||||
onChange={(e) => setForm((f) => ({ ...f, city: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
placeholder="کد پستی"
|
|
||||||
value={form.postalCode}
|
|
||||||
onChange={(e) => setForm((f) => ({ ...f, postalCode: e.target.value }))}
|
|
||||||
dir="ltr"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
className="min-h-[72px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
|
||||||
placeholder="آدرس کامل پستی"
|
|
||||||
value={form.address}
|
|
||||||
onChange={(e) => setForm((f) => ({ ...f, address: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
disabled={requesting || !form.address.trim() || !form.city.trim()}
|
|
||||||
onClick={() => void requestPhysical()}
|
|
||||||
>
|
|
||||||
{requesting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Package className="h-3.5 w-3.5" />}
|
|
||||||
ثبت درخواست نسخه فیزیکی
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : !windowStillOpen ? (
|
|
||||||
<p className="text-sm text-muted-foreground">مهلت درخواست نسخه فیزیکی به پایان رسیده است.</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CertificateCard>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
Heart,
|
Heart,
|
||||||
Lock,
|
Lock,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Package,
|
|
||||||
PlayCircle,
|
PlayCircle,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
@@ -22,7 +21,7 @@ import {
|
|||||||
import { SkillTree } from "@/components/skill-tree";
|
import { SkillTree } from "@/components/skill-tree";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { http, toUserError } from "@/lib/api";
|
import { http, toUserError } from "@/lib/api";
|
||||||
import type { Certificate, CertificatePhysicalOrder, MeetingEvent, PhysicalCertSettings, TreeData, TreeLesson } from "@/lib/types";
|
import type { Certificate, MeetingEvent, TreeData, TreeLesson } from "@/lib/types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useAuth } from "@/lib/auth-store";
|
import { useAuth } from "@/lib/auth-store";
|
||||||
import { cn, formatFaNumber, formatStreakLabel } from "@/lib/utils";
|
import { cn, formatFaNumber, formatStreakLabel } from "@/lib/utils";
|
||||||
@@ -58,8 +57,8 @@ function CapPageInner() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const allLessons = (data?.chapters ?? []).flatMap((ch) => ch.lessons ?? []);
|
const allLessons = (data?.chapters ?? []).flatMap((ch) => ch.lessons ?? []);
|
||||||
const completedCount = allLessons.filter((l) => l.progress?.passedQuiz).length;
|
const completedCount = data?.completedLessons ?? allLessons.filter((l) => l.progress?.passedQuiz).length;
|
||||||
const curriculumDone = allLessons.length > 0 && completedCount === allLessons.length;
|
const curriculumDone = (data?.progressPct ?? 0) >= 100;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const lesson = params.get("lesson");
|
const lesson = params.get("lesson");
|
||||||
@@ -70,12 +69,7 @@ function CapPageInner() {
|
|||||||
|
|
||||||
const { data: certData } = useQuery({
|
const { data: certData } = useQuery({
|
||||||
queryKey: ["me", "certificate"],
|
queryKey: ["me", "certificate"],
|
||||||
queryFn: () =>
|
queryFn: () => http.get<{ certificate: Certificate | null }>("/api/me/certificate"),
|
||||||
http.get<{
|
|
||||||
certificate: Certificate | null;
|
|
||||||
physicalOrder: CertificatePhysicalOrder | null;
|
|
||||||
physical: PhysicalCertSettings;
|
|
||||||
}>("/api/me/certificate"),
|
|
||||||
enabled: !!user && curriculumDone,
|
enabled: !!user && curriculumDone,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -102,20 +96,10 @@ function CapPageInner() {
|
|||||||
const firstLocked = all.find((l) => l.locked);
|
const firstLocked = all.find((l) => l.locked);
|
||||||
const done = curriculumDone;
|
const done = curriculumDone;
|
||||||
|
|
||||||
const pct = all.length ? Math.round((completedCount / all.length) * 100) : 0;
|
const pct = Math.round(data?.progressPct ?? 0);
|
||||||
const hearts = clampHearts(me?.hearts ?? user?.hearts ?? MAX_HEARTS);
|
const hearts = clampHearts(me?.hearts ?? user?.hearts ?? MAX_HEARTS);
|
||||||
const lowHearts = !hasInfiniteHearts(user?.role) && hearts > 0 && hearts <= 1;
|
const lowHearts = !hasInfiniteHearts(user?.role) && hearts > 0 && hearts <= 1;
|
||||||
const nextEvent = (eventsData?.events ?? [])[0];
|
const nextEvent = (eventsData?.events ?? [])[0];
|
||||||
const physicalOpen =
|
|
||||||
done &&
|
|
||||||
certData?.physical?.enabled &&
|
|
||||||
!!certData.certificate &&
|
|
||||||
(!certData.physicalOrder || certData.physicalOrder.status === "cancelled") &&
|
|
||||||
(!certData.certificate.issuedAt ||
|
|
||||||
Date.now() <
|
|
||||||
new Date(certData.certificate.issuedAt).getTime() +
|
|
||||||
(certData.physical.windowDays || 30) * 24 * 60 * 60 * 1000);
|
|
||||||
|
|
||||||
const next = nextAction(inProgress, readyQuiz, openAndNew, firstLocked, done);
|
const next = nextAction(inProgress, readyQuiz, openAndNew, firstLocked, done);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -181,33 +165,29 @@ function CapPageInner() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-extrabold">دوره را تمام کردید</p>
|
<p className="text-sm font-extrabold">دوره را تمام کردید</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
گواهینامه دیجیتال آماده است
|
{certData?.certificate?.status === "pending"
|
||||||
{physicalOpen ? " — میتوانید نسخه فیزیکی هم درخواست کنید." : "."}
|
? "درخواست گواهینامه ثبت شد — پس از تأیید تیم آموزش صادر میشود."
|
||||||
|
: certData?.certificate?.status === "issued"
|
||||||
|
? "گواهینامه دیجیتال آماده است."
|
||||||
|
: "گواهینامه دیجیتال آماده است."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
{certData?.certificate?.status === "issued" && certData.certificate.publicId ? (
|
||||||
<Button
|
<Button onClick={() => router.push(`/c/${certData.certificate!.publicId!}`)}>
|
||||||
onClick={() => {
|
|
||||||
const id = certData?.certificate?.publicId;
|
|
||||||
if (id) router.push(`/c/${id}`);
|
|
||||||
else {
|
|
||||||
void http
|
|
||||||
.post<{ certificate: { publicId: string } }>("/api/me/certificate/issue", {})
|
|
||||||
.then((r) => {
|
|
||||||
if (r.certificate?.publicId) router.push(`/c/${r.certificate.publicId}`);
|
|
||||||
})
|
|
||||||
.catch((e) => toast.error(toUserError(e, "صدور گواهینامه ممکن نشد")));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Award className="h-4 w-4" /> مشاهده گواهینامه
|
<Award className="h-4 w-4" /> مشاهده گواهینامه
|
||||||
</Button>
|
</Button>
|
||||||
{physicalOpen && certData?.certificate?.publicId && (
|
) : certData?.certificate?.status !== "pending" ? (
|
||||||
<Button variant="outline" onClick={() => router.push(`/c/${certData.certificate!.publicId}`)}>
|
<Button
|
||||||
<Package className="h-4 w-4" /> نسخه فیزیکی
|
onClick={() => {
|
||||||
|
void http
|
||||||
|
.post("/api/me/certificate/issue", {})
|
||||||
|
.then(() => qc.invalidateQueries({ queryKey: ["me", "certificate"] }))
|
||||||
|
.catch((e) => toast.error(toUserError(e, "صدور گواهینامه ممکن نشد")));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Award className="h-4 w-4" /> دریافت گواهینامه
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import localFont from "next/font/local";
|
import localFont from "next/font/local";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { Providers } from "@/components/providers";
|
import { Providers } from "@/components/providers";
|
||||||
@@ -37,6 +37,13 @@ export const metadata: Metadata = {
|
|||||||
description: "مسیر یادگیری واقعی پرگار",
|
description: "مسیر یادگیری واقعی پرگار",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
viewportFit: "cover",
|
||||||
|
interactiveWidget: "resizes-content",
|
||||||
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
|
|||||||
@@ -152,23 +152,26 @@ function ChatsLauncher() {
|
|||||||
const convTotal = convData?.total ?? conversations.length;
|
const convTotal = convData?.total ?? conversations.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
<div className="space-y-4">
|
||||||
<Panel className="justify-center text-center" title="گفتگوها" description="یک پنجرهٔ شناور شیشهای در تمام صفحات — با صدا، عکس، ویدیو و پیام صوتی.">
|
{/* ── Centered header banner ── */}
|
||||||
<div className="flex flex-col items-center gap-4 pt-1">
|
<div className="flex flex-col items-center gap-3 rounded-2xl border border-border bg-card px-6 py-6 text-center">
|
||||||
<div className="grid h-14 w-14 place-items-center rounded-2xl bg-primary/10">
|
<div className="inline-flex items-center gap-2 rounded-full border border-primary/20 bg-primary/10 px-4 py-1.5 text-sm font-medium text-primary backdrop-blur-sm">
|
||||||
<MessageSquareHeart className="h-7 w-7 text-primary" />
|
<MessageSquareHeart className="h-4 w-4 shrink-0" />
|
||||||
|
یک پنجرهٔ شناور شیشهای در تمام صفحات — با صدا، عکس، ویدیو و پیام صوتی.
|
||||||
</div>
|
</div>
|
||||||
<p className="max-w-sm text-sm text-muted-foreground">
|
<Button onClick={() => openChat()} className="gap-2">
|
||||||
|
<Sparkles className="h-4 w-4" /> باز کردن گفتگوها ✨
|
||||||
|
</Button>
|
||||||
|
<p className="max-w-md text-sm text-muted-foreground">
|
||||||
گفتگو همیشه در دکمهٔ شناور پایین صفحه در دسترس شماست؛ گفتگوها خصوصی هستند و پاسخ منتورها معمولاً کمتر از یک روز طول میکشد.
|
گفتگو همیشه در دکمهٔ شناور پایین صفحه در دسترس شماست؛ گفتگوها خصوصی هستند و پاسخ منتورها معمولاً کمتر از یک روز طول میکشد.
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={() => openChat()} className="gap-2">
|
|
||||||
<Sparkles className="h-4 w-4" /> باز کردن گفتگوها
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
|
||||||
|
|
||||||
|
{/* ── Side-by-side panels ── */}
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
{/* Conversations panel */}
|
||||||
<Panel title="گفتگوهای شما" description="مکالمات جاری — روی هر کدام بزنید تا باز شود.">
|
<Panel title="گفتگوهای شما" description="مکالمات جاری — روی هر کدام بزنید تا باز شود.">
|
||||||
<div className="space-y-1.5 pt-1">
|
<div className="max-h-[32rem] overflow-y-auto space-y-1.5 pt-1 pb-1">
|
||||||
{convLoading && (
|
{convLoading && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{[0, 1, 2].map((i) => (
|
{[0, 1, 2].map((i) => (
|
||||||
@@ -244,6 +247,7 @@ function ChatsLauncher() {
|
|||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
{/* Start chat panel */}
|
||||||
<Panel
|
<Panel
|
||||||
title={staff ? "شروع گفتگو" : "شروع سریع با منتور"}
|
title={staff ? "شروع گفتگو" : "شروع سریع با منتور"}
|
||||||
description="نقش هر نفر روی ردیف نوشته شده است."
|
description="نقش هر نفر روی ردیف نوشته شده است."
|
||||||
@@ -253,7 +257,7 @@ function ChatsLauncher() {
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="space-y-1 pt-1">
|
<div className="max-h-[32rem] overflow-y-auto space-y-1 pt-1 pb-1">
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{[0, 1, 2, 3].map((i) => (
|
{[0, 1, 2, 3].map((i) => (
|
||||||
@@ -312,6 +316,7 @@ function ChatsLauncher() {
|
|||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { UserAvatar, avatarPropsOf } from "@/components/ui/user-avatar";
|
import { UserAvatar, avatarPropsOf } from "@/components/ui/user-avatar";
|
||||||
|
import { RichEditor } from "@/components/ui/rich-editor";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -751,11 +752,31 @@ function CurriculumPanel() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const publishLesson = async (l: Lesson, publish: boolean) => {
|
||||||
|
try {
|
||||||
|
await http.post(`/api/admin/lessons/${l.id}/publish`, { publish });
|
||||||
|
qc.invalidateQueries({ queryKey: ["admin", "lessons"] });
|
||||||
|
toast.success(publish ? "درس منتشر شد" : "درس به پیشنویس برگشت");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(toUserError(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const publishChapter = async (ch: Chapter, publish: boolean) => {
|
||||||
|
try {
|
||||||
|
await http.post(`/api/admin/chapters/${ch.id}/publish`, { publish });
|
||||||
|
qc.invalidateQueries({ queryKey: ["admin", "lessons"] });
|
||||||
|
toast.success(publish ? "فصل منتشر شد" : "فصل به پیشنویس برگشت");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(toUserError(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const newLesson = (chapterId: number) =>
|
const newLesson = (chapterId: number) =>
|
||||||
setLessonModal({
|
setLessonModal({
|
||||||
id: 0, chapterId, title: "", description: "", videoKey: "",
|
id: 0, chapterId, title: "", description: "", videoKey: "",
|
||||||
durationSeconds: 45, completionThresholdPct: 85, requiresLessonId: undefined,
|
durationSeconds: 45, completionThresholdPct: 85, requiresLessonId: undefined,
|
||||||
x: lessons.filter((l) => l.chapterId === chapterId).length, y: 0, sortOrder: 1, xpReward: 50, isActive: true,
|
x: lessons.filter((l) => l.chapterId === chapterId).length, y: 0, sortOrder: 1, xpReward: 50, isActive: true, isPublished: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const duplicateLesson = async (l: Lesson) => {
|
const duplicateLesson = async (l: Lesson) => {
|
||||||
@@ -810,7 +831,7 @@ function CurriculumPanel() {
|
|||||||
{chapters.length} فصل · {lessons.length} درس · ترتیب با کشیدن یا فلش · موقعیت x,y
|
{chapters.length} فصل · {lessons.length} درس · ترتیب با کشیدن یا فلش · موقعیت x,y
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button className="gap-1.5 rounded-2xl" onClick={() => setChapterModal({ id: 0, title: "", description: "", icon: "terminal", sortOrder: chapters.length + 1 })}>
|
<Button className="gap-1.5 rounded-2xl" onClick={() => setChapterModal({ id: 0, title: "", description: "", icon: "terminal", sortOrder: chapters.length + 1, isPublished: false })}>
|
||||||
<Plus className="h-4 w-4" /> فصل جدید
|
<Plus className="h-4 w-4" /> فصل جدید
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -830,8 +851,8 @@ function CurriculumPanel() {
|
|||||||
{ch.description && <p className="truncate text-xs text-muted-foreground">{ch.description}</p>}
|
{ch.description && <p className="truncate text-xs text-muted-foreground">{ch.description}</p>}
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline" className="shrink-0">{ls.length} درس</Badge>
|
<Badge variant="outline" className="shrink-0">{ls.length} درس</Badge>
|
||||||
<Badge variant="secondary" className="hidden shrink-0 sm:inline-flex">
|
<Badge variant={ch.isPublished ? "success" : "secondary"} className="shrink-0 px-1.5 text-[10px]">
|
||||||
{ls.filter((l) => l.videoKey).length}/{ls.length} ویدیو
|
{ch.isPublished ? "منتشر" : "پیشنویس"}
|
||||||
</Badge>
|
</Badge>
|
||||||
<GroupNav
|
<GroupNav
|
||||||
onUp={ci > 0 ? () => moveChapter(ci, -1) : undefined}
|
onUp={ci > 0 ? () => moveChapter(ci, -1) : undefined}
|
||||||
@@ -840,6 +861,15 @@ function CurriculumPanel() {
|
|||||||
<Button size="sm" variant="outline" className="h-7 gap-1 text-xs" onClick={() => setChapterModal(ch)}>
|
<Button size="sm" variant="outline" className="h-7 gap-1 text-xs" onClick={() => setChapterModal(ch)}>
|
||||||
<Pencil className="h-3 w-3" /> فصل
|
<Pencil className="h-3 w-3" /> فصل
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 gap-1 text-xs"
|
||||||
|
onClick={() => publishChapter(ch, !ch.isPublished)}
|
||||||
|
title={ch.isPublished ? "برگشت به پیشنویس" : "انتشار فصل"}
|
||||||
|
>
|
||||||
|
{ch.isPublished ? "برگشت" : "انتشار"}
|
||||||
|
</Button>
|
||||||
<Button size="sm" variant="outline" className="h-7 gap-1 text-xs" onClick={() => newLesson(ch.id)}>
|
<Button size="sm" variant="outline" className="h-7 gap-1 text-xs" onClick={() => newLesson(ch.id)}>
|
||||||
<Plus className="h-3.5 w-3.5" /> درس
|
<Plus className="h-3.5 w-3.5" /> درس
|
||||||
</Button>
|
</Button>
|
||||||
@@ -895,8 +925,8 @@ function CurriculumPanel() {
|
|||||||
{cols.length > 0 && <Badge variant="destructive" className="px-1 text-[9px]">تداخل</Badge>}
|
{cols.length > 0 && <Badge variant="destructive" className="px-1 text-[9px]">تداخل</Badge>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={l.isActive ? "success" : "secondary"} className="px-1.5 text-[10px]">
|
<Badge variant={l.isPublished ? "success" : "secondary"} className="shrink-0 px-1.5 text-[10px]">
|
||||||
{l.isActive ? "فعال" : "پیشنویس"}
|
{l.isPublished ? "منتشر" : "پیشنویس"}
|
||||||
</Badge>
|
</Badge>
|
||||||
<div className="flex shrink-0 gap-0.5">
|
<div className="flex shrink-0 gap-0.5">
|
||||||
<GroupNav onUp={li > 0 ? () => moveLesson(ch.id, li, -1) : undefined} onDown={li < ls.length - 1 ? () => moveLesson(ch.id, li, 1) : undefined} compact />
|
<GroupNav onUp={li > 0 ? () => moveLesson(ch.id, li, -1) : undefined} onDown={li < ls.length - 1 ? () => moveLesson(ch.id, li, 1) : undefined} compact />
|
||||||
@@ -906,6 +936,15 @@ function CurriculumPanel() {
|
|||||||
<Button size="sm" variant="ghost" className="h-7 px-1.5" onClick={() => setLessonModal(l)} title="ویرایش">
|
<Button size="sm" variant="ghost" className="h-7 px-1.5" onClick={() => setLessonModal(l)} title="ویرایش">
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 px-1.5 text-[10px]"
|
||||||
|
title={l.isPublished ? "برگشت به پیشنویس" : "انتشار درس"}
|
||||||
|
onClick={() => publishLesson(l, !l.isPublished)}
|
||||||
|
>
|
||||||
|
{l.isPublished ? "برگشت" : "انتشار"}
|
||||||
|
</Button>
|
||||||
<Button size="sm" variant="ghost" className="h-7 w-7 px-0 text-destructive opacity-60 hover:opacity-100" onClick={() => delLesson(l)} title="حذف درس">
|
<Button size="sm" variant="ghost" className="h-7 w-7 px-0 text-destructive opacity-60 hover:opacity-100" onClick={() => delLesson(l)} title="حذف درس">
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1056,7 +1095,7 @@ function LessonDialog({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: (l: Lesson) => void;
|
onSave: (l: Lesson) => void;
|
||||||
}) {
|
}) {
|
||||||
const [draft, setDraft] = useState<Lesson>(() => lesson ?? { id: 0, chapterId: 1, title: "", description: "", videoKey: "", durationSeconds: 90, completionThresholdPct: 85, requiresLessonId: undefined, x: 5, y: 3, sortOrder: 1, xpReward: 50, isActive: true });
|
const [draft, setDraft] = useState<Lesson>(() => lesson ?? { id: 0, chapterId: 1, title: "", description: "", videoKey: "", durationSeconds: 90, completionThresholdPct: 85, requiresLessonId: undefined, x: 5, y: 3, sortOrder: 1, xpReward: 50, isActive: true, isPublished: false });
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (lesson) setDraft(lesson);
|
if (lesson) setDraft(lesson);
|
||||||
}, [lesson]);
|
}, [lesson]);
|
||||||
@@ -1125,12 +1164,10 @@ function LessonDialog({
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label>توضیحات</Label>
|
<Label>توضیحات</Label>
|
||||||
<textarea
|
<RichEditor
|
||||||
value={draft.description}
|
value={draft.description}
|
||||||
onChange={(e) => set({ description: e.target.value })}
|
onChange={(v) => set({ description: v })}
|
||||||
rows={3}
|
|
||||||
placeholder="چرا این درس مهم است و چه چیزی میآموزد؟"
|
placeholder="چرا این درس مهم است و چه چیزی میآموزد؟"
|
||||||
className="w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
Award,
|
||||||
Flame,
|
Flame,
|
||||||
Heart,
|
Heart,
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -64,6 +65,7 @@ export function LearningPanel() {
|
|||||||
const [sort, setSort] = useState<SortKey>("xp");
|
const [sort, setSort] = useState<SortKey>("xp");
|
||||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||||
const [unlocking, setUnlocking] = useState(false);
|
const [unlocking, setUnlocking] = useState(false);
|
||||||
|
const [issuingCertFor, setIssuingCertFor] = useState<number | null>(null);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -114,6 +116,19 @@ export function LearningPanel() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const issueCertificate = async (id: number) => {
|
||||||
|
setIssuingCertFor(id);
|
||||||
|
try {
|
||||||
|
await http.post(`/api/admin/users/${id}/certificate/issue`, {});
|
||||||
|
toast.success("گواهینامه با موفقیت صادر شد");
|
||||||
|
await qc.invalidateQueries({ queryKey: ["admin", "learning", "user", id] });
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(toUserError(e, "صدور گواهینامه ممکن نشد"));
|
||||||
|
} finally {
|
||||||
|
setIssuingCertFor(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const overview = overviewQ.data?.overview;
|
const overview = overviewQ.data?.overview;
|
||||||
const students = studentsQ.data?.items ?? [];
|
const students = studentsQ.data?.items ?? [];
|
||||||
const studentsTotal = studentsQ.data?.total ?? 0;
|
const studentsTotal = studentsQ.data?.total ?? 0;
|
||||||
@@ -369,6 +384,29 @@ export function LearningPanel() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{detailQ.data.learning.user.progressPct >= 100 && (
|
||||||
|
<div className="flex items-center justify-between gap-3 rounded-xl border border-primary/30 bg-primary/5 px-3 py-2.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Award className="h-4 w-4 shrink-0 text-primary" />
|
||||||
|
<p className="text-xs font-bold">دوره تکمیل شده — گواهینامه قابل صدور است</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="default"
|
||||||
|
disabled={issuingCertFor === detailQ.data.learning.user.id}
|
||||||
|
onClick={() => issueCertificate(detailQ.data!.learning.user.id)}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
{issuingCertFor === detailQ.data.learning.user.id ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Award className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
صدور گواهینامه
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
<h3 className="text-sm font-black">دروس</h3>
|
<h3 className="text-sm font-black">دروس</h3>
|
||||||
<div className="max-h-64 space-y-1.5 overflow-y-auto">
|
<div className="max-h-64 space-y-1.5 overflow-y-auto">
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Check,
|
Check,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
Eye,
|
Eye,
|
||||||
Film,
|
Film,
|
||||||
Link2,
|
Link2,
|
||||||
@@ -32,7 +34,7 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn, formatFaNumber } from "@/lib/utils";
|
||||||
import { toast } from "@/components/providers";
|
import { toast } from "@/components/providers";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
@@ -59,59 +61,19 @@ function VideoThumb({
|
|||||||
className?: string;
|
className?: string;
|
||||||
onOpen?: () => void;
|
onOpen?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [url, setUrl] = useState<string | null>(null);
|
|
||||||
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
setState("loading");
|
|
||||||
setUrl(null);
|
|
||||||
signedMediaUrl(videoKey)
|
|
||||||
.then((u) => {
|
|
||||||
if (!cancelled) {
|
|
||||||
setUrl(u);
|
|
||||||
setState("ready");
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (!cancelled) setState("error");
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [videoKey]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onOpen}
|
onClick={onOpen}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group relative aspect-video w-full overflow-hidden rounded-xl border border-border bg-muted/40",
|
"group relative aspect-video w-full overflow-hidden rounded-xl border border-border bg-muted/40",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
title="پیشنمایش"
|
title="پیشنمایش"
|
||||||
>
|
>
|
||||||
{state === "loading" && (
|
<span className="absolute inset-0 grid place-items-center text-muted-foreground/30">
|
||||||
<span className="absolute inset-0 grid place-items-center text-muted-foreground">
|
<Film className="h-10 w-10" />
|
||||||
<Loader2 className="h-5 w-5 animate-spin" />
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
{state === "error" && (
|
|
||||||
<span className="absolute inset-0 grid place-items-center gap-1 text-muted-foreground">
|
|
||||||
<Film className="h-5 w-5" />
|
|
||||||
<span className="text-[10px] font-bold">پیشنمایش نیست</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{url && state === "ready" && (
|
|
||||||
<video
|
|
||||||
src={url}
|
|
||||||
muted
|
|
||||||
playsInline
|
|
||||||
preload="metadata"
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
onError={() => setState("error")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span className="absolute inset-0 grid place-items-center bg-black/0 transition-colors group-hover:bg-black/35">
|
<span className="absolute inset-0 grid place-items-center bg-black/0 transition-colors group-hover:bg-black/35">
|
||||||
<span className="grid h-10 w-10 place-items-center rounded-full bg-background/90 text-foreground opacity-80 shadow-sm group-hover:opacity-100">
|
<span className="grid h-10 w-10 place-items-center rounded-full bg-background/90 text-foreground opacity-80 shadow-sm group-hover:opacity-100">
|
||||||
<Play className="h-4 w-4 fill-current" />
|
<Play className="h-4 w-4 fill-current" />
|
||||||
@@ -169,7 +131,7 @@ function VideoPreviewDialog({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{url && (
|
{url && (
|
||||||
<video src={url} controls playsInline className="max-h-[70vh] w-full bg-black" autoPlay />
|
<video src={url} controls playsInline preload="none" className="max-h-[70vh] w-full bg-black" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter className="border-t border-border px-6 py-3 sm:justify-end">
|
<DialogFooter className="border-t border-border px-6 py-3 sm:justify-end">
|
||||||
@@ -182,12 +144,15 @@ function VideoPreviewDialog({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 24;
|
||||||
|
|
||||||
export function VideosPanel() {
|
export function VideosPanel() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const { confirm, dialog } = useConfirm();
|
const { confirm, dialog } = useConfirm();
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
const [attachKey, setAttachKey] = useState<string | null>(null);
|
const [attachKey, setAttachKey] = useState<string | null>(null);
|
||||||
const [previewKey, setPreviewKey] = useState<string | null>(null);
|
const [previewKey, setPreviewKey] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -230,10 +195,13 @@ export function VideosPanel() {
|
|||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const term = q.trim().toLowerCase();
|
const term = q.trim().toLowerCase();
|
||||||
if (!term) return videos;
|
return term ? videos.filter((v) => v.key.toLowerCase().includes(term)) : videos;
|
||||||
return videos.filter((v) => v.key.toLowerCase().includes(term));
|
|
||||||
}, [videos, q]);
|
}, [videos, q]);
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||||
|
const safePage = Math.min(page, totalPages);
|
||||||
|
const paginated = filtered.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
|
||||||
|
|
||||||
const pick = (file: File | undefined) => {
|
const pick = (file: File | undefined) => {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
const invalid = validateVideoFile(file);
|
const invalid = validateVideoFile(file);
|
||||||
@@ -333,14 +301,14 @@ export function VideosPanel() {
|
|||||||
{isFetching && !isLoading && <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />}
|
{isFetching && !isLoading && <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
{videos.length} فایل · روی تصویر بزنید تا پخش شود
|
{videos.length} فایل · برای پخش روی دکمه کلیک کنید
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative w-full sm:max-w-xs">
|
<div className="relative w-full sm:max-w-xs">
|
||||||
<Search className="pointer-events-none absolute start-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="pointer-events-none absolute start-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
value={q}
|
value={q}
|
||||||
onChange={(e) => setQ(e.target.value)}
|
onChange={(e) => { setQ(e.target.value); setPage(1); }}
|
||||||
placeholder="جستجوی نام فایل…"
|
placeholder="جستجوی نام فایل…"
|
||||||
className="h-10 ps-9"
|
className="h-10 ps-9"
|
||||||
/>
|
/>
|
||||||
@@ -355,7 +323,7 @@ export function VideosPanel() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
{filtered.map((v) => {
|
{paginated.map((v) => {
|
||||||
const used = usedByKey.get(v.key) ?? [];
|
const used = usedByKey.get(v.key) ?? [];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -431,6 +399,29 @@ export function VideosPanel() {
|
|||||||
{q ? "نتیجهای نیست" : "هنوز ویدیویی آپلود نشده — یک MP4 انتخاب کنید"}
|
{q ? "نتیجهای نیست" : "هنوز ویدیویی آپلود نشده — یک MP4 انتخاب کنید"}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="col-span-full flex items-center justify-center gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={safePage <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
صفحه {formatFaNumber(safePage)} از {formatFaNumber(totalPages)}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={safePage >= totalPages}
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useAuth, useAuthHydrated } from "@/lib/auth-store";
|
|||||||
import { http } from "@/lib/api";
|
import { http } from "@/lib/api";
|
||||||
import type { User } from "@/lib/types";
|
import type { User } from "@/lib/types";
|
||||||
import { hasInfiniteHearts } from "@/lib/hearts";
|
import { hasInfiniteHearts } from "@/lib/hearts";
|
||||||
|
import { BottomNav } from "@/components/bottom-nav";
|
||||||
|
|
||||||
function SiteFooter() {
|
function SiteFooter() {
|
||||||
return (
|
return (
|
||||||
@@ -147,13 +148,21 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<main className="mx-auto w-full max-w-6xl flex-1 px-4 py-10 sm:px-6">
|
<main
|
||||||
|
className="mx-auto w-full max-w-6xl flex-1 px-4 py-10 sm:px-6 md:pb-10"
|
||||||
|
// Bottom nav is 56px + safe area; only shown for authed non-admin on mobile
|
||||||
|
style={user && !pathname.startsWith("/admin") ? { paddingBottom: "calc(4.5rem + env(safe-area-inset-bottom, 0px))" } : undefined}
|
||||||
|
>
|
||||||
{allowChildren ? children : null}
|
{allowChildren ? children : null}
|
||||||
</main>
|
</main>
|
||||||
<footer className="border-t border-border/70 bg-muted/20 backdrop-blur">
|
<footer
|
||||||
|
className="border-t border-border/70 bg-muted/20 backdrop-blur"
|
||||||
|
style={{ paddingBottom: "env(safe-area-inset-bottom, 0px)" }}
|
||||||
|
>
|
||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
</footer>
|
</footer>
|
||||||
{user && <ChatWidget />}
|
{user && <ChatWidget />}
|
||||||
|
{user && !pathname.startsWith("/admin") && <BottomNav />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { LayoutGrid, Trophy, TreePine } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ href: "/cap", label: "مسیر", icon: TreePine },
|
||||||
|
{ href: "/unwrap", label: "مرکز", icon: LayoutGrid },
|
||||||
|
{ href: "/leaderboard", label: "لیگ", icon: Trophy },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function BottomNav() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
className="fixed inset-x-0 bottom-0 z-40 border-t border-border bg-card/95 backdrop-blur md:hidden"
|
||||||
|
style={{ paddingBottom: "env(safe-area-inset-bottom, 0px)" }}
|
||||||
|
>
|
||||||
|
<div className="flex">
|
||||||
|
{items.map(({ href, label, icon: Icon }) => {
|
||||||
|
const active = pathname === href || pathname.startsWith(href + "/");
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={href}
|
||||||
|
href={href}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 flex-col items-center justify-center gap-0.5 py-2 text-[11px] font-bold transition-colors min-h-[56px] touch-manipulation",
|
||||||
|
active ? "text-primary" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className={cn(
|
||||||
|
"h-5 w-5 transition-transform",
|
||||||
|
active && "scale-110",
|
||||||
|
)}
|
||||||
|
strokeWidth={active ? 2.5 : 2}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,10 +3,10 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Crown, Sparkles, Timer, Trophy } from "lucide-react";
|
import { Crown, Medal, Sparkles, Timer, Trophy } from "lucide-react";
|
||||||
import { http, toUserError } from "@/lib/api";
|
import { http, toUserError } from "@/lib/api";
|
||||||
import { useAuth } from "@/lib/auth-store";
|
import { useAuth } from "@/lib/auth-store";
|
||||||
import type { LeaderboardData, LeaderboardEntry } from "@/lib/types";
|
import type { LeaderboardData, LeaderboardEntry, LeaderboardTier } from "@/lib/types";
|
||||||
import { cn, formatFaNumber } from "@/lib/utils";
|
import { cn, formatFaNumber } from "@/lib/utils";
|
||||||
import { Panel } from "@/components/panel";
|
import { Panel } from "@/components/panel";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
@@ -21,6 +21,28 @@ const medalClasses = {
|
|||||||
3: "bg-accent text-accent-foreground shadow-offset-sm",
|
3: "bg-accent text-accent-foreground shadow-offset-sm",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const tierConfig: Record<LeaderboardTier, { label: string; className: string }> = {
|
||||||
|
champion: { label: "قهرمان", className: "bg-gold/20 text-gold border-gold/30" },
|
||||||
|
gold: { label: "طلا", className: "bg-gold/15 text-gold border-gold/25" },
|
||||||
|
silver: { label: "نقره", className: "bg-secondary/30 text-secondary-foreground border-secondary/40" },
|
||||||
|
bronze: { label: "برنز", className: "bg-accent/20 text-accent-foreground border-accent/30" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function TierBadge({ tier }: { tier: LeaderboardTier }) {
|
||||||
|
const cfg = tierConfig[tier];
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-bold",
|
||||||
|
cfg.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Medal className="h-2.5 w-2.5" />
|
||||||
|
{cfg.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function LeaderboardPanel() {
|
export function LeaderboardPanel() {
|
||||||
const user = useAuth((s) => s.user);
|
const user = useAuth((s) => s.user);
|
||||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||||
@@ -89,6 +111,7 @@ export function LeaderboardPanel() {
|
|||||||
رتبه {fa(me.rank)} شمایید
|
رتبه {fa(me.rank)} شمایید
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{me && me.tier && <TierBadge tier={me.tier} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative overflow-hidden rounded-2xl border border-gold/25 bg-gradient-to-l from-gold/10 via-card to-primary/5 px-5 py-6 sm:px-8">
|
<div className="relative overflow-hidden rounded-2xl border border-gold/25 bg-gradient-to-l from-gold/10 via-card to-primary/5 px-5 py-6 sm:px-8">
|
||||||
@@ -134,6 +157,7 @@ export function LeaderboardPanel() {
|
|||||||
شما
|
شما
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{e.tier && <TierBadge tier={e.tier} />}
|
||||||
<span className="shrink-0 text-sm font-bold text-gold">
|
<span className="shrink-0 text-sm font-bold text-gold">
|
||||||
{formatFaNumber(Math.round(e.xp))} امتیاز
|
{formatFaNumber(Math.round(e.xp))} امتیاز
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1944,7 +1944,7 @@ ws.onmessage = (ev) => {
|
|||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
exit={{ opacity: 0, y: 24, scale: 0.96 }}
|
exit={{ opacity: 0, y: 24, scale: 0.96 }}
|
||||||
transition={softSpring}
|
transition={softSpring}
|
||||||
className="fixed bottom-24 start-3 end-3 z-[66] flex h-[min(640px,calc(100svh-10rem))] w-auto flex-col overflow-hidden rounded-[26px] border border-white/60 bg-white/55 shadow-[0_24px_70px_-18px_rgba(42,39,69,0.4)] ring-1 ring-black/5 backdrop-blur-2xl sm:bottom-6 sm:start-5 sm:end-auto sm:h-[min(720px,calc(100svh-3rem))] sm:w-[420px] dark:border-white/15 dark:bg-[#14172b]/60 dark:ring-white/10 xl:start-8 xl:w-[440px]"
|
className="fixed bottom-24 start-3 end-3 z-[66] flex h-[min(640px,calc(100svh-10rem))] w-auto flex-col overflow-hidden rounded-[26px] border border-white/60 bg-white/55 shadow-[0_24px_70px_-18px_rgba(42,39,69,0.4)] ring-1 ring-black/5 backdrop-blur-2xl sm:bottom-6 sm:start-5 sm:end-auto sm:h-[min(720px,calc(100svh-3rem))] sm:w-[420px] md:w-[440px] dark:border-white/15 dark:bg-[#14172b]/60 dark:ring-white/10 xl:start-8 xl:w-[460px]"
|
||||||
>
|
>
|
||||||
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-l from-primary via-accent to-gold" />
|
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-l from-primary via-accent to-gold" />
|
||||||
<div aria-hidden className="pointer-events-none absolute -end-16 -top-16 h-40 w-40 rounded-full bg-accent/25 blur-3xl" />
|
<div aria-hidden className="pointer-events-none absolute -end-16 -top-16 h-40 w-40 rounded-full bg-accent/25 blur-3xl" />
|
||||||
@@ -3010,7 +3010,7 @@ ws.onmessage = (ev) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="relative z-20 mx-2 mb-2 shrink-0 pt-1">
|
<div className="relative z-20 mx-2 mb-2 shrink-0 pb-[env(safe-area-inset-bottom)] pt-1">
|
||||||
{recording && (
|
{recording && (
|
||||||
<div className="mb-2 flex items-center gap-2 rounded-xl bg-destructive/10 px-3 py-2">
|
<div className="mb-2 flex items-center gap-2 rounded-xl bg-destructive/10 px-3 py-2">
|
||||||
<div className="flex h-3.5 min-w-9 items-center justify-center gap-[2px]">
|
<div className="flex h-3.5 min-w-9 items-center justify-center gap-[2px]">
|
||||||
|
|||||||
@@ -1,34 +1,22 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Award, Copy, ExternalLink, Loader2, Lock, Package } from "lucide-react";
|
import { Award, Clock, Copy, ExternalLink, Loader2, Lock } from "lucide-react";
|
||||||
import { http, toUserError } from "@/lib/api";
|
import { http, toUserError } from "@/lib/api";
|
||||||
import type { Certificate, CertificatePhysicalOrder, PhysicalCertSettings } from "@/lib/types";
|
import type { Certificate } from "@/lib/types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { toast } from "@/components/providers";
|
import { toast } from "@/components/providers";
|
||||||
import { cn, formatFaNumber } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type MineCertificateResponse = {
|
type MineCertificateResponse = {
|
||||||
certificate: Certificate | null;
|
certificate: Certificate | null;
|
||||||
eligible: boolean;
|
eligible: boolean;
|
||||||
physicalOrder: CertificatePhysicalOrder | null;
|
|
||||||
physical: PhysicalCertSettings;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ORDER_LABEL: Record<string, string> = {
|
|
||||||
requested: "درخواست فیزیکی ثبت شد",
|
|
||||||
paid: "پرداخت تأیید شد",
|
|
||||||
shipped: "ارسال شد",
|
|
||||||
cancelled: "درخواست فیزیکی لغو شده",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ProfileCertificateSlot() {
|
export function ProfileCertificateSlot() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const qc = useQueryClient();
|
|
||||||
const [issuing, setIssuing] = useState(false);
|
|
||||||
const autoIssueTried = useRef(false);
|
|
||||||
|
|
||||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||||
queryKey: ["me", "certificate"],
|
queryKey: ["me", "certificate"],
|
||||||
@@ -37,20 +25,6 @@ export function ProfileCertificateSlot() {
|
|||||||
|
|
||||||
const cert = data?.certificate ?? null;
|
const cert = data?.certificate ?? null;
|
||||||
const eligible = !!data?.eligible;
|
const eligible = !!data?.eligible;
|
||||||
const physical = data?.physical;
|
|
||||||
const order = data?.physicalOrder;
|
|
||||||
|
|
||||||
// Auto-issue once when the student becomes eligible (same path as /cap).
|
|
||||||
useEffect(() => {
|
|
||||||
if (!eligible || cert || autoIssueTried.current) return;
|
|
||||||
autoIssueTried.current = true;
|
|
||||||
setIssuing(true);
|
|
||||||
void http
|
|
||||||
.post<{ certificate: Certificate }>("/api/me/certificate/issue", {})
|
|
||||||
.then(() => qc.invalidateQueries({ queryKey: ["me", "certificate"] }))
|
|
||||||
.catch((e) => toast.error(toUserError(e, "صدور خودکار گواهینامه ممکن نشد — دستی تلاش کنید")))
|
|
||||||
.finally(() => setIssuing(false));
|
|
||||||
}, [eligible, cert, qc]);
|
|
||||||
|
|
||||||
const copyLink = async () => {
|
const copyLink = async () => {
|
||||||
if (!cert?.publicId) return;
|
if (!cert?.publicId) return;
|
||||||
@@ -68,29 +42,6 @@ export function ProfileCertificateSlot() {
|
|||||||
router.push(`/c/${cert.publicId}`);
|
router.push(`/c/${cert.publicId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const claim = async () => {
|
|
||||||
setIssuing(true);
|
|
||||||
try {
|
|
||||||
const res = await http.post<{ certificate: Certificate }>("/api/me/certificate/issue", {});
|
|
||||||
await qc.invalidateQueries({ queryKey: ["me", "certificate"] });
|
|
||||||
if (res.certificate?.publicId) {
|
|
||||||
router.push(`/c/${res.certificate.publicId}`);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(toUserError(e, "صدور گواهینامه ممکن نشد"));
|
|
||||||
} finally {
|
|
||||||
setIssuing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const physicalOpen =
|
|
||||||
!!physical?.enabled &&
|
|
||||||
!!cert &&
|
|
||||||
(!order || order.status === "cancelled") &&
|
|
||||||
(!cert.issuedAt ||
|
|
||||||
Date.now() <
|
|
||||||
new Date(cert.issuedAt).getTime() + (physical.windowDays || 30) * 24 * 60 * 60 * 1000);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
aria-label="جایگاه گواهینامه"
|
aria-label="جایگاه گواهینامه"
|
||||||
@@ -113,12 +64,22 @@ export function ProfileCertificateSlot() {
|
|||||||
تلاش دوباره
|
تلاش دوباره
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : isLoading || (eligible && !cert && issuing) ? (
|
) : isLoading ? (
|
||||||
<div className="flex items-center gap-2 py-3 text-xs text-muted-foreground">
|
<div className="flex items-center gap-2 py-3 text-xs text-muted-foreground">
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
در حال بارگذاری…
|
در حال بارگذاری…
|
||||||
</div>
|
</div>
|
||||||
) : cert ? (
|
) : cert?.status === "pending" ? (
|
||||||
|
<div className="flex items-start gap-2 rounded-lg border border-border/70 bg-background/60 px-3 py-2.5">
|
||||||
|
<Clock className="mt-0.5 h-3.5 w-3.5 shrink-0 text-warning" />
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-bold text-foreground">در انتظار تأیید</p>
|
||||||
|
<p className="mt-0.5 text-[10px] leading-relaxed text-muted-foreground">
|
||||||
|
درخواست گواهینامه ثبت شد و پس از بررسی توسط تیم آموزش صادر میشود.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : cert?.status === "issued" && cert.publicId ? (
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
<div className="rounded-lg border border-primary/25 bg-primary/5 px-3 py-2.5">
|
<div className="rounded-lg border border-primary/25 bg-primary/5 px-3 py-2.5">
|
||||||
<p className="text-sm font-bold text-foreground">{cert.fullName}</p>
|
<p className="text-sm font-bold text-foreground">{cert.fullName}</p>
|
||||||
@@ -129,12 +90,6 @@ export function ProfileCertificateSlot() {
|
|||||||
{cert.publicId}
|
{cert.publicId}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{order && order.status !== "cancelled" ? (
|
|
||||||
<p className="text-[11px] font-medium text-muted-foreground">
|
|
||||||
{ORDER_LABEL[order.status] ?? order.status}
|
|
||||||
{order.trackingCode ? ` · کد پیگیری ${order.trackingCode}` : ""}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
<Button type="button" size="sm" className="h-8 flex-1" onClick={openCert}>
|
<Button type="button" size="sm" className="h-8 flex-1" onClick={openCert}>
|
||||||
<ExternalLink className="h-3.5 w-3.5" />
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
@@ -144,23 +99,17 @@ export function ProfileCertificateSlot() {
|
|||||||
<Copy className="h-3.5 w-3.5" />
|
<Copy className="h-3.5 w-3.5" />
|
||||||
کپی لینک
|
کپی لینک
|
||||||
</Button>
|
</Button>
|
||||||
{physicalOpen ? (
|
|
||||||
<Button type="button" size="sm" variant="outline" className="h-8" onClick={openCert}>
|
|
||||||
<Package className="h-3.5 w-3.5" />
|
|
||||||
فیزیکی
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : eligible ? (
|
) : eligible ? (
|
||||||
<div className="space-y-2.5">
|
<div className="flex items-start gap-2 rounded-lg border border-border/70 bg-background/60 px-3 py-2.5">
|
||||||
<p className="text-[11px] leading-relaxed text-muted-foreground">
|
<Clock className="mt-0.5 h-3.5 w-3.5 shrink-0 text-warning" />
|
||||||
دوره را تمام کردهاید. گواهینامه دیجیتال آمادهٔ صدور است.
|
<div>
|
||||||
|
<p className="text-[11px] font-bold text-foreground">در انتظار تأیید</p>
|
||||||
|
<p className="mt-0.5 text-[10px] leading-relaxed text-muted-foreground">
|
||||||
|
درخواست گواهینامه ثبت شد و پس از بررسی توسط تیم آموزش صادر میشود.
|
||||||
</p>
|
</p>
|
||||||
<Button type="button" size="sm" className="h-8 w-full" disabled={issuing} onClick={() => void claim()}>
|
</div>
|
||||||
{issuing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Award className="h-3.5 w-3.5" />}
|
|
||||||
دریافت گواهینامه
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
@@ -172,10 +121,7 @@ export function ProfileCertificateSlot() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-[11px] font-bold text-foreground">هنوز صادر نشده</p>
|
<p className="text-[11px] font-bold text-foreground">هنوز صادر نشده</p>
|
||||||
<p className="mt-0.5 text-[10px] leading-relaxed text-muted-foreground">
|
<p className="mt-0.5 text-[10px] leading-relaxed text-muted-foreground">
|
||||||
با پاس کردن همه درسهای فعال، گواهینامه همینجا نمایش داده میشود
|
با پاس کردن همه درسهای فعال، گواهینامه همینجا نمایش داده میشود.
|
||||||
{physical?.enabled
|
|
||||||
? `؛ در صورت فعال بودن، نسخه فیزیکی (پرداخت آفلاین) تا ${formatFaNumber(physical.windowDays)} روز پس از صدور قابل درخواست است.`
|
|
||||||
: "."}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
/** Renders a small markdown subset. Raw HTML is escaped by React text nodes. */
|
|
||||||
export function SafeMarkdown({
|
export function SafeMarkdown({
|
||||||
text,
|
text,
|
||||||
className,
|
className,
|
||||||
@@ -11,56 +10,53 @@ export function SafeMarkdown({
|
|||||||
}: {
|
}: {
|
||||||
text: string;
|
text: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
/** When set, only the first block is shown, clamped to this many lines. */
|
|
||||||
lines?: 2 | 3;
|
lines?: 2 | 3;
|
||||||
}) {
|
}) {
|
||||||
const blocks = text.replace(/\r\n/g, "\n").split(/\n{2,}/);
|
const [html, setHtml] = useState("");
|
||||||
const shown = lines ? blocks.slice(0, 1) : blocks;
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!text) { setHtml(""); return; }
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
const { marked } = await import("marked");
|
||||||
|
const DOMPurify = (await import("dompurify")).default;
|
||||||
|
const raw = await marked(text);
|
||||||
|
if (!cancelled) setHtml(DOMPurify.sanitize(raw));
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [text]);
|
||||||
|
|
||||||
|
const mdClasses =
|
||||||
|
"[&_strong]:font-bold [&_em]:italic [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:text-[0.85em] [&_h2]:text-base [&_h2]:font-bold [&_h2]:mt-3 [&_h3]:text-sm [&_h3]:font-bold [&_h3]:mt-2 [&_ul]:list-disc [&_ul]:pr-5 [&_ol]:list-decimal [&_ol]:pr-5 [&_li]:mt-0.5 [&_p]:leading-relaxed [&_p+p]:mt-2 [&_a]:underline [&_a]:text-primary";
|
||||||
|
|
||||||
|
if (!html) {
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div
|
||||||
{shown.map((block, i) => (
|
|
||||||
<p
|
|
||||||
key={i}
|
|
||||||
dir="auto"
|
dir="auto"
|
||||||
className={cn(
|
className={cn(
|
||||||
"break-words [unicode-bidi:plaintext]",
|
"break-words text-sm [unicode-bidi:plaintext]",
|
||||||
lines === 2 && "line-clamp-2",
|
lines === 2 && "line-clamp-2",
|
||||||
lines === 3 && "line-clamp-3",
|
lines === 3 && "line-clamp-3",
|
||||||
!lines && "whitespace-pre-wrap",
|
!lines && "whitespace-pre-wrap",
|
||||||
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{inline(block)}
|
{text}
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function inline(src: string): ReactNode[] {
|
return (
|
||||||
const re = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)|\*\*([^*]+)\*\*|`([^`]+)`/g;
|
<div
|
||||||
const nodes: ReactNode[] = [];
|
dir="auto"
|
||||||
let last = 0;
|
className={cn(
|
||||||
let m: RegExpExecArray | null;
|
"break-words text-sm [unicode-bidi:plaintext]",
|
||||||
let k = 0;
|
mdClasses,
|
||||||
while ((m = re.exec(src))) {
|
lines === 2 && "line-clamp-2",
|
||||||
if (m.index > last) nodes.push(src.slice(last, m.index));
|
lines === 3 && "line-clamp-3",
|
||||||
if (m[1] && m[2]) {
|
className,
|
||||||
nodes.push(
|
)}
|
||||||
<a key={k++} href={m[2]} target="_blank" rel="noreferrer" className="underline">
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
{m[1]}
|
/>
|
||||||
</a>,
|
|
||||||
);
|
|
||||||
} else if (m[3]) {
|
|
||||||
nodes.push(<strong key={k++}>{m[3]}</strong>);
|
|
||||||
} else if (m[4]) {
|
|
||||||
nodes.push(
|
|
||||||
<code key={k++} className="rounded bg-muted px-1">
|
|
||||||
{m[4]}
|
|
||||||
</code>,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
last = m.index + m[0].length;
|
|
||||||
}
|
|
||||||
if (last < src.length) nodes.push(src.slice(last));
|
|
||||||
return nodes;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg border font-sans text-sm font-bold transition-all duration-300 ease-[var(--ease-soft)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 cursor-pointer select-none active:translate-y-px active:shadow-none",
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg border font-sans text-sm font-bold transition-all duration-300 ease-[var(--ease-soft)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 cursor-pointer select-none active:translate-y-px active:shadow-none touch-manipulation",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
@@ -18,10 +18,10 @@ const buttonVariants = cva(
|
|||||||
link: "border-transparent text-primary underline-offset-4 hover:underline",
|
link: "border-transparent text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-10 rounded-lg px-4 py-2",
|
default: "h-11 rounded-lg px-4 py-2",
|
||||||
sm: "h-8 rounded-lg px-3 text-xs",
|
sm: "h-9 rounded-lg px-3 text-xs",
|
||||||
lg: "h-[3.25rem] rounded-2xl px-7 text-base",
|
lg: "h-[3.25rem] rounded-2xl px-7 text-base",
|
||||||
icon: "h-10 w-10 rounded-xl",
|
icon: "h-11 w-11 rounded-xl",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: { variant: "default", size: "default" },
|
defaultVariants: { variant: "default", size: "default" },
|
||||||
|
|||||||
@@ -34,13 +34,26 @@ const DialogContent = React.forwardRef<
|
|||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border border-border bg-popover p-6 shadow-md rounded-md duration-300 sm:max-w-lg data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
// Base (mobile-first): full-width bottom sheet with safe-area clearance
|
||||||
|
"fixed bottom-0 left-0 right-0 z-50 grid w-full max-w-none gap-4 border border-border bg-popover shadow-md duration-300",
|
||||||
|
"rounded-t-2xl rounded-b-none p-5 pb-[max(1.25rem,env(safe-area-inset-bottom))]",
|
||||||
|
"max-h-[90svh] overflow-y-auto",
|
||||||
|
"translate-x-0 translate-y-0",
|
||||||
|
"data-[state=open]:animate-in data-[state=open]:slide-in-from-bottom-4",
|
||||||
|
"data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom-full",
|
||||||
|
// Desktop (sm+): centered modal
|
||||||
|
"sm:bottom-auto sm:left-1/2 sm:right-auto sm:top-1/2 sm:max-w-lg sm:max-h-[calc(100svh-4rem)] sm:rounded-md sm:p-6",
|
||||||
|
"sm:-translate-x-1/2 sm:-translate-y-1/2",
|
||||||
|
"sm:data-[state=open]:zoom-in-95 sm:data-[state=closed]:zoom-out-95",
|
||||||
|
"sm:data-[state=open]:fade-in-0 sm:data-[state=closed]:fade-out-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
{/* Mobile drag handle affordance */}
|
||||||
|
<span className="mx-auto -mt-1 mb-2 block h-1.5 w-10 shrink-0 rounded-full bg-muted-foreground/20 sm:hidden" aria-hidden />
|
||||||
{children}
|
{children}
|
||||||
<DialogPrimitive.Close className="absolute end-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none">
|
<DialogPrimitive.Close className="absolute end-4 top-4 grid h-8 w-8 place-items-center rounded-full opacity-70 transition-opacity hover:opacity-100 focus:outline-none sm:h-auto sm:w-auto sm:rounded-sm">
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
<span className="sr-only">بستن</span>
|
<span className="sr-only">بستن</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLI
|
|||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
className={cn(
|
||||||
// Fixed height + symmetric vertical padding; avoid clipping Vazirmatn descenders.
|
// Fixed height + symmetric vertical padding; avoid clipping Vazirmatn descenders.
|
||||||
"box-border h-11 w-full rounded-lg border border-input bg-card px-3.5 py-0 font-sans text-sm font-medium leading-none outline-none transition-colors placeholder:font-sans placeholder:font-medium placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
// text-base (16px) prevents iOS Safari auto-zoom on focus; sm: downsizes on desktop
|
||||||
|
"box-border h-11 w-full rounded-lg border border-input bg-card px-3.5 py-0 font-sans text-base sm:text-sm font-medium leading-none outline-none transition-colors touch-manipulation placeholder:font-sans placeholder:font-medium placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
latin && "font-latin placeholder:font-latin",
|
latin && "font-latin placeholder:font-latin",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useEditor, EditorContent } from "@tiptap/react";
|
||||||
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
|
import Placeholder from "@tiptap/extension-placeholder";
|
||||||
|
import { Markdown } from "tiptap-markdown";
|
||||||
|
import { Bold, Code, Heading2, Heading3, Italic, List, ListOrdered } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mdClasses =
|
||||||
|
"[&_strong]:font-bold [&_em]:italic [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:text-[0.85em] [&_h2]:text-base [&_h2]:font-bold [&_h2]:mt-3 [&_h3]:text-sm [&_h3]:font-bold [&_h3]:mt-2 [&_ul]:list-disc [&_ul]:pr-5 [&_ol]:list-decimal [&_ol]:pr-5 [&_li]:mt-0.5 [&_p]:leading-relaxed [&_p+p]:mt-2 [&_a]:underline [&_a]:text-primary";
|
||||||
|
|
||||||
|
export function RichEditor({ value, onChange, placeholder, className }: Props) {
|
||||||
|
const [tab, setTab] = useState<"edit" | "preview">("edit");
|
||||||
|
const [preview, setPreview] = useState("");
|
||||||
|
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions: [
|
||||||
|
StarterKit.configure({ codeBlock: false }),
|
||||||
|
Placeholder.configure({ placeholder: placeholder ?? "توضیحات را اینجا بنویسید…" }),
|
||||||
|
Markdown.configure({ transformPastedText: true }),
|
||||||
|
],
|
||||||
|
content: value,
|
||||||
|
immediatelyRender: false,
|
||||||
|
onUpdate({ editor: e }) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const md = (e.storage as any).markdown?.getMarkdown?.() ?? e.getText();
|
||||||
|
onChange(md);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const current = (editor.storage as any).markdown?.getMarkdown?.() ?? editor.getText();
|
||||||
|
if (current !== value) {
|
||||||
|
editor.commands.setContent(value, { emitUpdate: false });
|
||||||
|
}
|
||||||
|
}, [value, editor]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tab !== "preview") return;
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
const { marked } = await import("marked");
|
||||||
|
const DOMPurify = (await import("dompurify")).default;
|
||||||
|
const html = await marked(value);
|
||||||
|
if (!cancelled) setPreview(DOMPurify.sanitize(html));
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [tab, value]);
|
||||||
|
|
||||||
|
const ToolBtn = ({
|
||||||
|
onClick,
|
||||||
|
active,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
onClick: () => void;
|
||||||
|
active?: boolean;
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onClick();
|
||||||
|
}}
|
||||||
|
title={title}
|
||||||
|
className={cn(
|
||||||
|
"grid h-7 w-7 place-items-center rounded transition-colors",
|
||||||
|
active
|
||||||
|
? "bg-primary/15 text-primary"
|
||||||
|
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("rounded-md border border-input bg-background", className)}>
|
||||||
|
{/* Tab bar */}
|
||||||
|
<div className="flex items-center justify-between gap-1 border-b border-input px-2 py-1">
|
||||||
|
<div className="flex gap-0.5">
|
||||||
|
{editor && (
|
||||||
|
<>
|
||||||
|
<ToolBtn
|
||||||
|
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||||
|
active={editor.isActive("bold")}
|
||||||
|
title="توپر"
|
||||||
|
>
|
||||||
|
<Bold className="h-3.5 w-3.5" />
|
||||||
|
</ToolBtn>
|
||||||
|
<ToolBtn
|
||||||
|
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||||
|
active={editor.isActive("italic")}
|
||||||
|
title="مورب"
|
||||||
|
>
|
||||||
|
<Italic className="h-3.5 w-3.5" />
|
||||||
|
</ToolBtn>
|
||||||
|
<ToolBtn
|
||||||
|
onClick={() => editor.chain().focus().toggleCode().run()}
|
||||||
|
active={editor.isActive("code")}
|
||||||
|
title="کد درونخطی"
|
||||||
|
>
|
||||||
|
<Code className="h-3.5 w-3.5" />
|
||||||
|
</ToolBtn>
|
||||||
|
<ToolBtn
|
||||||
|
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||||
|
active={editor.isActive("heading", { level: 2 })}
|
||||||
|
title="سرتیتر ۲"
|
||||||
|
>
|
||||||
|
<Heading2 className="h-3.5 w-3.5" />
|
||||||
|
</ToolBtn>
|
||||||
|
<ToolBtn
|
||||||
|
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||||
|
active={editor.isActive("heading", { level: 3 })}
|
||||||
|
title="سرتیتر ۳"
|
||||||
|
>
|
||||||
|
<Heading3 className="h-3.5 w-3.5" />
|
||||||
|
</ToolBtn>
|
||||||
|
<ToolBtn
|
||||||
|
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||||
|
active={editor.isActive("bulletList")}
|
||||||
|
title="فهرست"
|
||||||
|
>
|
||||||
|
<List className="h-3.5 w-3.5" />
|
||||||
|
</ToolBtn>
|
||||||
|
<ToolBtn
|
||||||
|
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||||
|
active={editor.isActive("orderedList")}
|
||||||
|
title="فهرست عددی"
|
||||||
|
>
|
||||||
|
<ListOrdered className="h-3.5 w-3.5" />
|
||||||
|
</ToolBtn>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 text-xs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab("edit")}
|
||||||
|
className={cn(
|
||||||
|
"rounded px-2 py-0.5 transition-colors",
|
||||||
|
tab === "edit" ? "bg-primary/15 text-primary font-medium" : "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
ویرایش
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab("preview")}
|
||||||
|
className={cn(
|
||||||
|
"rounded px-2 py-0.5 transition-colors",
|
||||||
|
tab === "preview" ? "bg-primary/15 text-primary font-medium" : "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
پیشنمایش
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Editor / Preview */}
|
||||||
|
{tab === "edit" ? (
|
||||||
|
<EditorContent
|
||||||
|
editor={editor}
|
||||||
|
dir="auto"
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-2 text-sm focus-within:outline-none",
|
||||||
|
mdClasses,
|
||||||
|
"[&_.ProseMirror]:min-h-[80px] [&_.ProseMirror]:outline-none",
|
||||||
|
"[&_.ProseMirror_p.is-editor-empty:first-child::before]:text-muted-foreground/50 [&_.ProseMirror_p.is-editor-empty:first-child::before]:content-[attr(data-placeholder)] [&_.ProseMirror_p.is-editor-empty:first-child::before]:float-right [&_.ProseMirror_p.is-editor-empty:first-child::before]:pointer-events-none",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
dir="auto"
|
||||||
|
className={cn("px-3 py-2 text-sm", mdClasses)}
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: preview || "<p class='text-muted-foreground text-sm'>پیشنمایشی نیست.</p>",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,7 +7,8 @@ const Textarea = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<textarea
|
<textarea
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-[120px] w-full rounded-lg border border-input bg-card px-3.5 py-2.5 font-sans text-sm font-medium leading-normal outline-none transition-colors placeholder:font-sans placeholder:font-medium placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
// text-base (16px) prevents iOS Safari auto-zoom on focus; sm: downsizes on desktop
|
||||||
|
"flex min-h-[120px] w-full rounded-lg border border-input bg-card px-3.5 py-2.5 font-sans text-base sm:text-sm font-medium leading-normal outline-none transition-colors touch-manipulation placeholder:font-sans placeholder:font-medium placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -68,16 +68,19 @@ export function useRealtimeGateway() {
|
|||||||
toast.dismiss(id);
|
toast.dismiss(id);
|
||||||
if (n.route) router.push(n.route);
|
if (n.route) router.push(n.route);
|
||||||
}}
|
}}
|
||||||
className="w-[min(22rem,calc(100vw-1.5rem))] rounded-xl border border-primary/40 bg-gradient-to-br from-primary/25 via-primary/12 to-accent/20 px-3 py-2.5 text-start shadow-[0_16px_40px_-16px_color-mix(in_srgb,var(--color-primary)_75%,transparent)] backdrop-blur-md"
|
className="flex w-[min(22rem,calc(100vw-1.5rem))] items-stretch overflow-hidden rounded-xl border border-border bg-card text-start shadow-lg"
|
||||||
>
|
>
|
||||||
|
<span className="w-1 shrink-0 bg-primary" aria-hidden />
|
||||||
|
<span className="flex min-w-0 flex-col px-3 py-2.5">
|
||||||
<span dir="auto" className="block truncate text-sm font-semibold text-foreground [unicode-bidi:plaintext]">
|
<span dir="auto" className="block truncate text-sm font-semibold text-foreground [unicode-bidi:plaintext]">
|
||||||
{title}
|
{title}
|
||||||
</span>
|
</span>
|
||||||
{body ? (
|
{body ? (
|
||||||
<span dir="auto" className="mt-0.5 block line-clamp-2 text-xs text-foreground/75 [unicode-bidi:plaintext]">
|
<span dir="auto" className="mt-0.5 block line-clamp-2 text-xs text-muted-foreground [unicode-bidi:plaintext]">
|
||||||
{body}
|
{body}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
),
|
),
|
||||||
{ duration: 5000 },
|
{ duration: 5000 },
|
||||||
|
|||||||
@@ -33,10 +33,11 @@ export interface User {
|
|||||||
export interface Certificate {
|
export interface Certificate {
|
||||||
id: number;
|
id: number;
|
||||||
userId: number;
|
userId: number;
|
||||||
publicId: string;
|
publicId?: string;
|
||||||
fullName: string;
|
fullName: string;
|
||||||
issuedAt: string;
|
issuedAt: string;
|
||||||
revokedAt?: string;
|
revokedAt?: string;
|
||||||
|
status: "pending" | "issued";
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PhysicalOrderStatus = "requested" | "paid" | "shipped" | "cancelled";
|
export type PhysicalOrderStatus = "requested" | "paid" | "shipped" | "cancelled";
|
||||||
@@ -91,6 +92,7 @@ export interface Lesson {
|
|||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
xpReward: number;
|
xpReward: number;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
isPublished: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Chapter {
|
export interface Chapter {
|
||||||
@@ -99,6 +101,7 @@ export interface Chapter {
|
|||||||
description: string;
|
description: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
|
isPublished: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TreeLesson {
|
export interface TreeLesson {
|
||||||
@@ -122,6 +125,9 @@ export interface TreeChapter extends Chapter {
|
|||||||
|
|
||||||
export interface TreeData {
|
export interface TreeData {
|
||||||
chapters: TreeChapter[];
|
chapters: TreeChapter[];
|
||||||
|
totalLessons: number;
|
||||||
|
completedLessons: number;
|
||||||
|
progressPct: number;
|
||||||
me: { xp: number; streak: number; hearts: number; isLocked: boolean; role: Role };
|
me: { xp: number; streak: number; hearts: number; isLocked: boolean; role: Role };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,12 +252,15 @@ export interface Conversation {
|
|||||||
archived: boolean;
|
archived: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type LeaderboardTier = "champion" | "gold" | "silver" | "bronze";
|
||||||
|
|
||||||
export interface LeaderboardEntry {
|
export interface LeaderboardEntry {
|
||||||
userId: number;
|
userId: number;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
xp: number;
|
xp: number;
|
||||||
rank: number;
|
rank: number;
|
||||||
|
tier: LeaderboardTier;
|
||||||
avatarVariant?: "marble" | "beam" | "pixel" | "sunset" | "ring" | "bauhaus";
|
avatarVariant?: "marble" | "beam" | "pixel" | "sunset" | "ring" | "bauhaus";
|
||||||
avatarPalette?: string;
|
avatarPalette?: string;
|
||||||
avatarPhoto?: string;
|
avatarPhoto?: string;
|
||||||
@@ -260,7 +269,8 @@ export interface LeaderboardEntry {
|
|||||||
export interface LeaderboardData {
|
export interface LeaderboardData {
|
||||||
week: string;
|
week: string;
|
||||||
entries: LeaderboardEntry[];
|
entries: LeaderboardEntry[];
|
||||||
me: { rank: number; xp: number; streak: number };
|
totalParticipants: number;
|
||||||
|
me: { rank: number; xp: number; streak: number; tier: LeaderboardTier };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotificationPrefs {
|
export interface NotificationPrefs {
|
||||||
|
|||||||
Reference in New Issue
Block a user