feat: manual cert gating, chat layout overhaul, and video player UX fixes

- Certificate: Hardcode approval gate — completely removed the auto-issue path;
  `certRequiresApproval()` now strictly returns `true`, ignoring DB configuration.
- Certificate: Quiz completion now queues a pending record and triggers a
  "Pending approval" notification instead of falsely confirming issuance.
- Certificate: Public endpoints now enforce a guard on `status=issued`; frontend
  strips the auto-issue side effect and claim button (issuance restricted to admins).
- Chats tab: Refactor layout into a full-width centered banner above two
  equal-height side-by-side panels (`grid-cols-1 lg:grid-cols-2`), utilizing
  `max-h` and `overflow-y-auto` to eliminate whitespace bleed.
- Video player: Fix settings flyout prematurely closing on mouse movement (removed
  `setSettingsOpen` from `poke`); add a click-outside listener via `mousedown`.
- Video player: Deduplicate controls — the speed pill now directly cycles playback
  speed, leaving the gear icon as the single entry point for settings (eliminating
  overlapping popovers).
- Video player: Implement full-bleed theater mode using the `-mx-[50vw] w-screen`
  technique, keeping the progress strip constrained to `max-w-5xl` below.
This commit is contained in:
ahp
2026-10-18 12:09:30 +03:30
parent 6228e66d2c
commit 3bf6dae724
5 changed files with 250 additions and 234 deletions
+10 -20
View File
@@ -28,13 +28,13 @@ func (s *Server) certEnabled(ctx context.Context) bool {
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
}
// 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) {
if !s.certEnabled(ctx) {
return nil, false, nil
@@ -53,22 +53,12 @@ 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()
// Always create a pending row — never auto-issue.
cert, err := s.store.CreatePendingCertificate(ctx, user.ID, user.Name)
if err != nil {
return nil, false, err
}
cert, err := s.store.CreateCertificate(ctx, user.ID, publicID, user.Name)
if err != nil {
return nil, false, err
}
return cert, true, nil
return cert, cert.Status == "pending", nil
}
func (s *Server) handleIssueCertificate(w http.ResponseWriter, r *http.Request) {
@@ -124,7 +114,7 @@ func (s *Server) handlePublicCertificate(w http.ResponseWriter, r *http.Request)
return
}
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, "گواهینامه پیدا نشد")
return
}
+4 -3
View File
@@ -158,9 +158,10 @@ func (s *Server) handleQuizSubmit(w http.ResponseWriter, r *http.Request) {
return
}
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)
_ = s.notify.Notify(r.Context(), u.ID, "progress", "certificate_issued",
"گواهینامه آماده است", "دوره را به پایان رساندید. گواهینامه دیجیتال شما صادر شد.", "/c/"+cert.PublicID, nil)
observability.Activity("certificate.pending", "user_id", u.ID)
_ = s.notify.Notify(r.Context(), u.ID, "progress", "certificate_pending",
"درخواست گواهینامه ثبت شد", "دوره را با موفقیت به پایان رساندید. درخواست گواهینامه شما ثبت شد و پس از تأیید مدیریت صادر خواهد شد.", "/profile", nil)
_ = cert // pending; no publicId yet
}
s.notifyQuizPassed(u.ID, lesson.Title, id, activeUser.StreakCurrent)
_ = s.notifyUnlockedLessons(u.ID, id)