diff --git a/backend/internal/api/admin.go b/backend/internal/api/admin.go
index ed5f9ee..139ad77 100644
--- a/backend/internal/api/admin.go
+++ b/backend/internal/api/admin.go
@@ -575,6 +575,38 @@ func (s *Server) handleAdminDeleteLesson(w http.ResponseWriter, r *http.Request)
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 ----
func (s *Server) handleAdminListQuestions(w http.ResponseWriter, r *http.Request) {
diff --git a/backend/internal/api/certificate.go b/backend/internal/api/certificate.go
index 7c5bd33..16ee51d 100644
--- a/backend/internal/api/certificate.go
+++ b/backend/internal/api/certificate.go
@@ -7,7 +7,6 @@ import (
"net/http"
"strconv"
"strings"
- "time"
"pargar/backend/internal/models"
"pargar/backend/internal/observability"
@@ -22,7 +21,24 @@ func newPublicID() (string, error) {
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
+}
+
+func (s *Server) certRequiresApproval(ctx context.Context) bool {
+ if v, err := s.store.GetSetting(ctx, "certificate_requires_approval"); err == nil {
+ return v == "true" || v == "1"
+ }
+ return true
+}
+
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)
if err != nil {
return nil, false, err
@@ -37,6 +53,13 @@ func (s *Server) issueCertificateIfEligible(ctx context.Context, user *models.Us
} else if err != store.ErrNotFound {
return nil, false, err
}
+ if s.certRequiresApproval(ctx) {
+ cert, err := s.store.CreatePendingCertificate(ctx, user.ID, user.Name)
+ if err != nil {
+ return nil, false, err
+ }
+ return cert, cert.Status == "pending", nil
+ }
publicID, err := newPublicID()
if err != nil {
return nil, false, err
@@ -140,90 +163,66 @@ func (s *Server) physicalSettings(ctx context.Context) map[string]any {
}
func (s *Server) handleRequestPhysicalCertificate(w http.ResponseWriter, r *http.Request) {
- u := currentUser(r)
- 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 {
- writeErr(w, http.StatusConflict, "ابتدا گواهینامه دیجیتال را دریافت کنید")
- return
- }
- 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)
+ writeErr(w, http.StatusGone, "سفارش فیزیکی موقتاً غیرفعال است")
+}
+
+func (s *Server) handleAdminIssueCertificate(w http.ResponseWriter, r *http.Request) {
+ userID := routeID(r, "id")
+ u, err := s.store.GetUserByID(r.Context(), userID)
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
}
- staff, _ := s.store.ListMentors(r.Context())
go func() {
- for _, m := range staff {
- if m.Role != models.RoleAdmin {
- continue
- }
- _ = s.notify.Notify(context.Background(), m.ID, "progress", "physical_order",
- "سفارش گواهینامه فیزیکی", u.Name+" درخواست نسخه فیزیکی ثبت کرد.", "/admin",
- map[string]any{"orderId": order.ID, "userId": u.ID})
- }
+ _ = s.notify.Notify(context.Background(), userID, "progress", "certificate_issued",
+ "گواهینامه صادر شد", "گواهینامه پرگار شما صادر شد. برای مشاهده به صفحه گواهینامه مراجعه کنید.", "/cap",
+ map[string]any{"publicId": cert.PublicID})
}()
- observability.Activity("certificate.physical_requested", "user_id", u.ID, "order_id", order.ID)
- writeJSON(w, http.StatusCreated, map[string]any{
- "order": order,
- "message": "پس از واریز با ادمین هماهنگ کنید",
- "physical": settings,
- })
+ observability.Activity("certificate.admin_issued", "user_id", userID, "actor_id", currentUser(r).ID)
+ writeJSON(w, http.StatusOK, map[string]any{"certificate": cert})
+}
+
+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) {
diff --git a/backend/internal/api/leaderboard.go b/backend/internal/api/leaderboard.go
index 5fc929b..4270cbc 100644
--- a/backend/internal/api/leaderboard.go
+++ b/backend/internal/api/leaderboard.go
@@ -3,6 +3,7 @@ package api
import (
"context"
"net/http"
+ "sort"
"strconv"
"time"
@@ -15,21 +16,40 @@ type lbEntry struct {
Email string `json:"email"`
XP float64 `json:"xp"`
Rank int `json:"rank"`
+ Tier string `json:"tier"`
AvatarVariant string `json:"avatarVariant"`
AvatarPalette string `json:"avatarPalette"`
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) {
u := currentUser(r)
key := "lb:" + service.ISOWeek(time.Now())
- entries := make([]lbEntry, 0, 20)
type scored struct {
userID int64
xp float64
}
var scoredList []scored
+ var totalParticipants int64
if s.redis != nil {
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})
}
}
+ 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 {
- rows, err := s.store.Pool().Query(context.Background(), `
- SELECT user_id, xp FROM weekly_leaderboard WHERE week=$1 ORDER BY xp DESC LIMIT 20`,
+ rows, err := s.store.Pool().Query(context.Background(),
+ `SELECT user_id, xp FROM weekly_leaderboard WHERE week=$1
+ ORDER BY xp DESC, user_id ASC LIMIT 20`,
service.ISOWeek(time.Now()))
if err == nil {
for rows.Next() {
@@ -56,30 +80,50 @@ func (s *Server) handleWeeklyLeaderboard(w http.ResponseWriter, r *http.Request)
}
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 {
usr, err := s.store.GetUserByID(context.Background(), sc.userID)
if err != nil {
continue
}
s.signUserMedia(usr)
+ rank := i + 1
entries = append(entries, lbEntry{
- UserID: sc.userID, Name: usr.Name, Email: usr.Email, XP: sc.xp, Rank: i + 1,
- AvatarVariant: usr.AvatarVariant, AvatarPalette: usr.AvatarPalette, AvatarPhoto: usr.AvatarPhoto,
+ UserID: sc.userID,
+ 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
var myXP float64
if s.redis != nil {
- rank, err := s.redis.ZRevRank(context.Background(), key, strconv.FormatInt(u.ID, 10)).Result()
- if err == nil {
+ if rank, err := s.redis.ZRevRank(context.Background(), key, strconv.FormatInt(u.ID, 10)).Result(); err == nil {
myRank = int(rank) + 1
}
- score, err := s.redis.ZScore(context.Background(), key, strconv.FormatInt(u.ID, 10)).Result()
- if err == nil {
+ if score, err := s.redis.ZScore(context.Background(), key, strconv.FormatInt(u.ID, 10)).Result(); err == nil {
myXP = score
}
}
@@ -88,8 +132,14 @@ func (s *Server) handleWeeklyLeaderboard(w http.ResponseWriter, r *http.Request)
entries = []lbEntry{}
}
writeJSON(w, http.StatusOK, map[string]any{
- "week": service.ISOWeek(time.Now()),
- "entries": entries,
- "me": map[string]any{"rank": myRank, "xp": myXP, "streak": u.StreakCurrent},
+ "week": service.ISOWeek(time.Now()),
+ "entries": entries,
+ "totalParticipants": totalParticipants,
+ "me": map[string]any{
+ "rank": myRank,
+ "xp": myXP,
+ "streak": u.StreakCurrent,
+ "tier": tierFromRank(myRank, int(totalParticipants)),
+ },
})
}
diff --git a/backend/internal/api/leaderboard_test.go b/backend/internal/api/leaderboard_test.go
new file mode 100644
index 0000000..01e007c
--- /dev/null
+++ b/backend/internal/api/leaderboard_test.go
@@ -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)
+ }
+ }
+}
diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go
index 925779a..f806dda 100644
--- a/backend/internal/api/router.go
+++ b/backend/internal/api/router.go
@@ -101,6 +101,9 @@ func newRouter(s *Server) http.Handler {
mux.HandleFunc("POST /api/admin/users/{id}/reset-password", admin(s.handleAdminResetPassword))
mux.HandleFunc("GET /api/admin/physical-orders", admin(s.handleAdminListPhysicalOrders))
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("PUT /api/admin/settings", admin(s.handleAdminPutSettings))
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("PUT /api/admin/chapters/{id}", staff(s.handleAdminUpdateChapter))
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("POST /api/admin/lessons", staff(s.handleAdminCreateLesson))
mux.HandleFunc("PUT /api/admin/lessons/{id}", staff(s.handleAdminUpdateLesson))
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("POST /api/admin/lessons/{id}/questions", staff(s.handleAdminCreateQuestion))
mux.HandleFunc("PUT /api/admin/questions/{id}", staff(s.handleAdminUpdateQuestion))
diff --git a/backend/internal/api/tree.go b/backend/internal/api/tree.go
index b77305d..e91ec98 100644
--- a/backend/internal/api/tree.go
+++ b/backend/internal/api/tree.go
@@ -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))
for _, ch := range chapters {
cv := chapterView{Chapter: ch, Lessons: []lessonView{}}
@@ -84,7 +101,10 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, map[string]any{
- "chapters": out,
+ "chapters": out,
+ "totalLessons": totalActive,
+ "completedLessons": completedActive,
+ "progressPct": progressPct,
"me": map[string]any{
"xp": u.XP,
"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())
return false
}
- if !lesson.IsActive && !u.Role.IsStaff() {
+ if (!lesson.IsActive || !lesson.IsPublished) && !u.Role.IsStaff() {
writeErrCode(w, http.StatusNotFound, CodeNotFound, "درس پیدا نشد")
return false
}
diff --git a/backend/internal/db/migrations/0030_lesson_draft_status.sql b/backend/internal/db/migrations/0030_lesson_draft_status.sql
new file mode 100644
index 0000000..81b1169
--- /dev/null
+++ b/backend/internal/db/migrations/0030_lesson_draft_status.sql
@@ -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;
diff --git a/backend/internal/db/migrations/0031_certificate_approval.sql b/backend/internal/db/migrations/0031_certificate_approval.sql
new file mode 100644
index 0000000..7277d85
--- /dev/null
+++ b/backend/internal/db/migrations/0031_certificate_approval.sql
@@ -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;
diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go
index a9570fb..61f9e3b 100644
--- a/backend/internal/models/models.go
+++ b/backend/internal/models/models.go
@@ -107,10 +107,11 @@ func (u *User) ApplyFreezeState(now time.Time) {
type Certificate struct {
ID int64 `json:"id"`
UserID int64 `json:"userId"`
- PublicID string `json:"publicId"`
+ PublicID string `json:"publicId,omitempty"`
FullName string `json:"fullName"`
IssuedAt time.Time `json:"issuedAt"`
RevokedAt *time.Time `json:"revokedAt,omitempty"`
+ Status string `json:"status"`
}
type PhysicalOrderStatus string
@@ -164,6 +165,7 @@ type Chapter struct {
Description string `json:"description"`
Icon string `json:"icon"`
SortOrder int `json:"sortOrder"`
+ IsPublished bool `json:"isPublished"`
}
type Lesson struct {
@@ -180,6 +182,7 @@ type Lesson struct {
SortOrder int `json:"sortOrder"`
XPReward int `json:"xpReward"`
IsActive bool `json:"isActive"`
+ IsPublished bool `json:"isPublished"`
}
type MCQQuestion struct {
diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go
index 56c2223..4f1c8a0 100644
--- a/backend/internal/store/store.go
+++ b/backend/internal/store/store.go
@@ -618,7 +618,7 @@ func IsUniqueViolation(err error) bool {
func (s *Store) CountActiveLessons(ctx context.Context) (int, error) {
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
}
@@ -627,11 +627,16 @@ func (s *Store) CountPassedActiveLessons(ctx context.Context, userID int64) (int
err := s.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM lesson_progress lp
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
}
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)
if err != nil {
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) {
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)
return scanCertificate(row)
}
func (s *Store) GetCertificateByPublicID(ctx context.Context, publicID string) (*models.Certificate, error) {
row := s.pool.QueryRow(ctx, `
- SELECT id, user_id, public_id, full_name, issued_at, revoked_at
- FROM certificates WHERE public_id=$1`, publicID)
+ SELECT id, user_id, public_id, full_name, issued_at, revoked_at, status
+ FROM certificates WHERE public_id=$1 AND status='issued'`, publicID)
return scanCertificate(row)
}
func scanCertificate(row rowScanner) (*models.Certificate, error) {
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) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
+ if publicID != nil {
+ c.PublicID = *publicID
+ }
return &c, nil
}
func (s *Store) CreateCertificate(ctx context.Context, userID int64, publicID, fullName string) (*models.Certificate, error) {
row := s.pool.QueryRow(ctx, `
- INSERT INTO certificates (user_id, public_id, full_name)
- VALUES ($1, $2, $3)
+ INSERT INTO certificates (user_id, public_id, full_name, status)
+ VALUES ($1, $2, $3, 'issued')
ON CONFLICT (user_id) DO UPDATE SET
full_name = EXCLUDED.full_name,
+ public_id = EXCLUDED.public_id,
+ status = 'issued',
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)
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,
recipient_name, phone, address, city, postal_code, tracking_code,
window_ends_at, created_at, updated_at`
@@ -879,16 +948,22 @@ func (s *Store) DeleteUser(ctx context.Context, id int64) error {
// ---- Chapters / Lessons / Questions ----
func (s *Store) CreateChapter(ctx context.Context, c *models.Chapter) (int64, error) {
+ c.IsPublished = false
err := s.pool.QueryRow(ctx, `
- INSERT INTO chapters (title, description, icon, sort_order) VALUES ($1,$2,$3,$4) RETURNING id`,
- c.Title, c.Description, c.Icon, c.SortOrder).Scan(&c.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, c.IsPublished).Scan(&c.ID)
return c.ID, err
}
func (s *Store) UpdateChapter(ctx context.Context, id int64, c *models.Chapter) error {
_, err := s.pool.Exec(ctx, `
- UPDATE chapters SET title=$1, description=$2, icon=$3, sort_order=$4 WHERE id=$5`,
- c.Title, c.Description, c.Icon, c.SortOrder, id)
+ 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, 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
}
@@ -898,7 +973,7 @@ func (s *Store) DeleteChapter(ctx context.Context, id int64) 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 {
return nil, err
}
@@ -906,7 +981,7 @@ func (s *Store) ListChapters(ctx context.Context) ([]models.Chapter, error) {
var out []models.Chapter
for rows.Next() {
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
}
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) {
+ l.IsPublished = false
err := s.pool.QueryRow(ctx, `
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)
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) RETURNING id`,
+ 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,$13) RETURNING 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).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
}
func (s *Store) UpdateLesson(ctx context.Context, id int64, l *models.Lesson) error {
_, err := s.pool.Exec(ctx, `
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
- WHERE id=$13`,
+ 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=$14`,
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
}
@@ -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) {
row := s.pool.QueryRow(ctx, `
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)
var l models.Lesson
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) {
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) {
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 {
- q += ` WHERE is_active`
+ q += ` WHERE is_active AND is_published`
}
q += ` ORDER BY chapter_id, sort_order, id`
rows, err := s.pool.Query(ctx, q)
@@ -969,7 +1050,7 @@ func (s *Store) ListLessons(ctx context.Context, includeInactive bool) ([]models
for rows.Next() {
var l models.Lesson
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
}
out = append(out, l)
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index ecd9eb1..da9acb1 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -16,13 +16,19 @@
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2",
"@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",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
+ "dompurify": "^3.4.17",
"emoji-datasource-apple": "^15.1.2",
"framer-motion": "^12.0.0",
"lucide-react": "^0.468.0",
+ "marked": "^18.1.1",
"next": "15.5.27",
"next-themes": "^0.4.4",
"react": "^19.0.0",
@@ -30,6 +36,7 @@
"react-multi-date-picker": "^4.5.2",
"sonner": "^1.7.1",
"tailwind-merge": "^2.6.0",
+ "tiptap-markdown": "^0.9.0",
"tw-animate-css": "^1.4.0",
"zustand": "^5.0.2"
},
@@ -1772,6 +1779,480 @@
"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": {
"version": "22.20.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.2.tgz",
@@ -1786,7 +2267,6 @@
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
- "devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -1796,12 +2276,29 @@
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
"integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
- "devOptional": true,
"license": "MIT",
"peerDependencies": {
"@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": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
@@ -1875,7 +2372,6 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "devOptional": true,
"license": "MIT"
},
"node_modules/date-fns": {
@@ -1904,6 +2400,15 @@
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"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": {
"version": "15.1.2",
"resolved": "https://registry.npmjs.org/emoji-datasource-apple/-/emoji-datasource-apple-15.1.2.tgz",
@@ -1924,6 +2429,27 @@
"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": {
"version": "12.43.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.43.0.tgz",
@@ -2238,6 +2764,31 @@
"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": {
"version": "0.468.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz",
@@ -2257,6 +2808,57 @@
"@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": {
"version": "12.43.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.43.0.tgz",
@@ -2352,6 +2954,12 @@
"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": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2386,6 +2994,187 @@
"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": {
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
@@ -2506,6 +3295,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": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
@@ -2648,6 +3443,24 @@
"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": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -2677,6 +3490,12 @@
"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": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -2727,6 +3546,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": {
"version": "5.0.15",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index d850371..4489491 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -20,13 +20,19 @@
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2",
"@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",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
+ "dompurify": "^3.4.17",
"emoji-datasource-apple": "^15.1.2",
"framer-motion": "^12.0.0",
"lucide-react": "^0.468.0",
+ "marked": "^18.1.1",
"next": "15.5.27",
"next-themes": "^0.4.4",
"react": "^19.0.0",
@@ -34,6 +40,7 @@
"react-multi-date-picker": "^4.5.2",
"sonner": "^1.7.1",
"tailwind-merge": "^2.6.0",
+ "tiptap-markdown": "^0.9.0",
"tw-animate-css": "^1.4.0",
"zustand": "^5.0.2"
},
diff --git a/frontend/src/app/c/[id]/page.tsx b/frontend/src/app/c/[id]/page.tsx
index f995474..0a930ce 100644
--- a/frontend/src/app/c/[id]/page.tsx
+++ b/frontend/src/app/c/[id]/page.tsx
@@ -1,45 +1,23 @@
"use client";
-import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { useQuery } from "@tanstack/react-query";
import { useParams } from "next/navigation";
-import { Loader2, Package } from "lucide-react";
+import { Loader2 } from "lucide-react";
import { CertificateCard } from "@/components/certificate-card";
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 { useAuth } from "@/lib/auth-store";
-import type { Certificate, CertificatePhysicalOrder, PhysicalCertSettings } 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";
+import type { Certificate } from "@/lib/types";
type PublicCertResponse = {
certificate: { publicId: string; fullName: string; issuedAt: string };
course: string;
};
-const STATUS_LABEL: Record
-
- قیمت حدودی {formatFaNumber(physical.priceIrr)} ریال · مهلت درخواست{" "}
- {formatFaNumber(physical.windowDays)} روز از صدور
- {windowEndsAt ? ` (تا ${new Date(windowEndsAt).toLocaleDateString("fa-IR")})` : ""}.
- پرداخت آنلاین در سامانه نداریم. پس از واریز بیرونی با ادمین هماهنگ کنید تا وضعیت سفارش بهروز شود.
-
- وضعیت: {STATUS_LABEL[order.status] ?? order.status}
-
- کد پیگیری: {order.trackingCode}
-
- ارسال به {order.recipientName || "—"} · {order.city}
- یادداشت ادمین: {order.note} مهلت درخواست نسخه فیزیکی به پایان رسیده است. دوره را تمام کردید
- گواهینامه دیجیتال آماده است
- {physicalOpen ? " — میتوانید نسخه فیزیکی هم درخواست کنید." : "."}
+ {certData?.certificate?.status === "pending"
+ ? "درخواست گواهینامه ثبت شد — پس از تأیید تیم آموزش صادر میشود."
+ : certData?.certificate?.status === "issued"
+ ? "گواهینامه دیجیتال آماده است."
+ : "گواهینامه دیجیتال آماده است."}
{ch.description}
}دوره تکمیل شده — گواهینامه قابل صدور است
+- {videos.length} فایل · روی تصویر بزنید تا پخش شود + {videos.length} فایل · برای پخش روی دکمه کلیک کنید
در انتظار تأیید
++ درخواست گواهینامه ثبت شد و پس از بررسی توسط تیم آموزش صادر میشود. +
+{cert.fullName}
@@ -129,12 +129,6 @@ export function ProfileCertificateSlot() { {cert.publicId}- {ORDER_LABEL[order.status] ?? order.status} - {order.trackingCode ? ` · کد پیگیری ${order.trackingCode}` : ""} -
- ) : null}هنوز صادر نشده
- با پاس کردن همه درسهای فعال، گواهینامه همینجا نمایش داده میشود - {physical?.enabled - ? `؛ در صورت فعال بودن، نسخه فیزیکی (پرداخت آفلاین) تا ${formatFaNumber(physical.windowDays)} روز پس از صدور قابل درخواست است.` - : "."} + با پاس کردن همه درسهای فعال، گواهینامه همینجا نمایش داده میشود.
- {inline(block)} -
- ))} -
- {m[4]}
- ,
- );
- }
- last = m.index + m[0].length;
- }
- if (last < src.length) nodes.push(src.slice(last));
- return nodes;
-}
diff --git a/frontend/src/components/ui/rich-editor.tsx b/frontend/src/components/ui/rich-editor.tsx
new file mode 100644
index 0000000..fca1550
--- /dev/null
+++ b/frontend/src/components/ui/rich-editor.tsx
@@ -0,0 +1,182 @@
+"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;
+};
+
+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 }) {
+ const md = e.storage.markdown?.getMarkdown?.() ?? e.getText();
+ onChange(md);
+ },
+ });
+
+ // Sync external value changes (e.g. when a new lesson is loaded).
+ useEffect(() => {
+ if (!editor) return;
+ const current = editor.storage.markdown?.getMarkdown?.() ?? editor.getText();
+ if (current !== value) {
+ editor.commands.setContent(value, false);
+ }
+ }, [value, editor]);
+
+ useEffect(() => {
+ if (tab !== "preview") return;
+ import("marked").then(async ({ marked }) => {
+ const DOMPurify = (await import("dompurify")).default;
+ const html = await marked(value);
+ setPreview(DOMPurify.sanitize(html));
+ });
+ }, [tab, value]);
+
+ const ToolBtn = ({
+ onClick,
+ active,
+ title,
+ children,
+ }: {
+ onClick: () => void;
+ active?: boolean;
+ title: string;
+ children: React.ReactNode;
+ }) => (
+
+