29 Commits
Author SHA1 Message Date
ahp 924cb52a38 release v0.4.0: bump image tags and chart appVersion
build-and-push / build (push) Successful in 4m15s
2026-10-20 17:53:19 +03:30
ahp 3c5a32394d chore(infra): update dev compose, add playwright e2e suite & assets 2026-10-20 17:51:23 +03:30
ahp f6ce2bd340 refactor(video-player): LTR timeline, unified buffer/progress layers, controls layout, remove ambient
- timeline: unified dir="ltr" container; buffer and progress as stacked absolute fills in correct z-order
- playhead: physical left property instead of insetInlineStart (fixes RTL mirroring)
- control bar: dir="ltr" explicit groups — left: play/rewind/volume/speed, right: theater/fullscreen
- rewind: replace flipped SkipBack with Rewind icon; seek indicator at physical right-8
- ambient effect: removed entirely (canvas, rAF loop, state, constants, toggle)
- settings menu: removed (speed accessible via pill; ambient gone)
- restart button: removed
2026-10-20 17:51:23 +03:30
ahp 96b10c9148 fix(ui): design-system consistency — typography, borders, admin IA, search removal
- typography: font-semibold/font-medium → font-bold throughout (card, dialog, empty-state, notification-bell, quiz-experience, questions-panel, profile-manage)
- borders: border → border-2 for content containers (leaderboard error, notifications page, quiz pass screen, quiz option tiles, learning-panel error)
- admin IA: remove Card-in-SectionPanel nesting from announce-panel, challenges-panel, physical-orders-panel; SectionPanel gains optional actions slot
- announce-panel: flat composer + live preview; history as flat list; cancel-edit button
- lockout: raw textarea → Textarea design-system component
- global-search: delete component and all references (feature removed)
- progress.tsx: dir="ltr" for correct RTL fill direction; remove rtl:me-auto
2026-10-20 17:51:23 +03:30
ahp 7716c1677c feat(frontend): draft/publish, certificate approval UI, contributors, leaderboard tiers
- admin-studio: chapter/lesson publish toggle with Draft/Published badges; per-chapter accordion
- cap/page: remove auto-issue effect; student requests, admin approves
- learning-panel: admin issue-certificate action per student
- site-settings: contributors editor with avatar upload (ContributorAvatarPicker, TagInput)
- community-panel: render contributors section below social links
- leaderboard-panel: TierBadge, totalParticipants, tier on entries and self
- page.tsx: fix "شروع مسیر" routing to /register instead of /login
- navbar: correct icons (Settings, Palette) in user dropdown
- types: Contributor, LeaderboardTier, Certificate status field, updated LeaderboardData/Entry
- layout: PWA icon metadata (192, 512, apple-touch-icon)
2026-10-20 17:51:23 +03:30
ahp 161610049c feat(backend): leaderboard tiers, N+1 fix, contributors, certificate approval, physical disable
- leaderboard: batch-fetch users (GetUsersByIDs), deterministic tie-break by user_id
- leaderboard: tier system (champion/gold/silver/bronze) based on rank percentile
- store: HasCompletedCurriculum requires cert.Status == "issued"; pending does not satisfy
- certificate: issueCertificateIfEligible creates pending row; admin approves via handleAdminIssueCertificate
- certificate: handleRequestPhysicalCertificate returns 410 Gone (feature disabled)
- settings: contributors CRUD (loadContributors, validateContributors, handleAdminContributorAvatarUpload)
- community: expose contributors in /api/community response
- rate_limit: separate DevMode bypass from redis-nil check in allowWSHandshake
- router: remove /api/search route; fix missing tab indent on /api/meta line
- migration 0032: contributors app_setting with seed entry
2026-10-20 17:51:22 +03:30
ahp 617d8ff680 release v0.3.9: bump frontend image tags
build-and-push / build (push) Successful in 30s
2026-10-19 19:45:14 +03:30
ahp 88c86ac810 release v0.3.9: bump image tags
build-and-push / build (push) Successful in 18s
2026-10-19 19:24:57 +03:30
ahp f59ff54256 feat: chapter labels, staff draft visibility, and chat FAB mobile fix
build-and-push / build (push) Successful in 3m27s
- Rename chapter labels from "هفتهٔ N" to "فصل N" in skill-tree
  - Show dashed "پیش‌نویس" badge on unpublished chapters/lessons for staff
  - Add isPublished to TreeLesson type (backend already exposes it)
  - Fix chat FAB and notification banner overlapping bottom nav on mobile
2026-10-19 18:41:16 +03:30
ahp 12ecbf991e release v0.3.8: chart metadata + image tags
build-and-push / build (push) Successful in 28s
- Chart 0.2.3 -> 0.2.4, appVersion 0.2.1 -> 0.3.7 (metadata labels only;
  pod templates use selectorLabels, no statefulset churn)
- values-apollo image tags 0.3.7 -> 0.3.8 (backend+frontend roll once)
2026-10-19 00:49:16 +03:30
ahp 951610cda2 chart: add backend ServiceMonitor (token-auth /metrics, off by default)
- /metrics is bearer-protected via METRICS_TOKEN; the endpoint carries
  authorization.credentials (pargar-app/metrics-token) so Prometheus
  scrapes it the same way the live probe does.
- disabled by default: plain 'helm template' renders byte-identical;
  values-apollo enables it (enabled: true, interval 30s already present).
- kube-prometheus (ns infra) selects ServiceMonitors in all namespaces;
  its operator holds cluster-wide secret read to resolve the credential.
2026-10-19 00:47:54 +03:30
ahp 943897044a fix(chart): fail loudly instead of generating random secrets on lookup miss
build-and-push / build (push) Successful in 28s
randAlphaNum fallbacks silently minted new db/redis/rustfs passwords on
every cluster-less render (always the case under Argo CD's helm
template), overwriting live credentials. Fresh installs now get a clear
error demanding explicit values; helm upgrades are unaffected (lookup
preserves existing secrets).
2026-10-18 23:20:22 +03:30
ahp 53c8c8e94c fix(deploy): imagePullSecrets items must be objects ({name: ...}), not bare strings
build-and-push / build (push) Successful in 30s
Bare string items rendered an invalid Deployment spec (LocalObjectReference
is a struct) which made Argo CD's strategic-merge patch fail with
'cannot restore struct from: string' on both Deployments.
2026-10-18 22:59:04 +03:30
ahp c4c9f37bbc release: pull private Gitea packages via gitea-pull secret (ns pargar)
build-and-push / build (push) Successful in 29s
2026-10-18 22:14:50 +03:30
ahp a1c429782f release: pin Argo images to 0.3.7 (cutover tag)
build-and-push / build (push) Successful in 31s
2026-10-18 21:57:01 +03:30
ahp 0dcc7bbf76 ci: prebaked job image + fix buildx discovery under DOCKER_CONFIG
download.docker.com resets intermittently from the cluster network, and
/root/.docker/cli-plugins is not searched when the workflow sets
DOCKER_CONFIG=/tmp/.docker ('Docker buildx is required').

- jobs on the apollo runner use act-job:20 (.gitea/job-image: node 20 +
  docker CLI + buildx in system plugin paths) - no runtime downloads
- other runners fall back to installing, now into a system plugin path
2026-10-18 21:57:01 +03:30
ahp 77748bcba2 ci: authenticate registry with REGISTRY_TOKEN secret
build-and-push / build (push) Canceled after 55s
Use an explicit Gitea token (write:package scope) instead of the
built-in job token for docker login.
2026-10-18 19:17:14 +03:30
ahp 994879749b ci: install docker CLI in job container + shared DOCKER_CONFIG
The job runs in node:20-bookworm which ships no docker binary
(run 21 died with 'Unable to locate executable file: docker').
Install static docker client + buildx, and fail fast with an
actionable error if the runner has not mounted the docker socket.
2026-10-18 19:14:01 +03:30
ahp 41e40c9ab2 release: v0.3.6 gitops cutover
build-and-push / build (push) Failing after 16s
- ci: run build jobs on the runner's default linux-amd64 label
- deploy: pin apollo images to 0.3.6 (built+pushed by CI on this tag)

Same app code as v0.3.5; first release deployed via Gitea Actions
(build) + Argo CD (rollout) instead of manual build/import/helm.
2026-10-18 18:54:17 +03:30
ahp 42871c51cd Merge branch 'main' of https://src.panahifar.ir/ahp/pargar 2026-10-18 16:29:00 +03:30
ahp 6dc04de222 ci: gitea actions build/push + argocd registry repoint 2026-10-18 16:27:58 +03:30
ahp 265fb8dde4 docs(readme): prune outdated section
remove github section
2026-10-18 12:19:52 +03:30
ahp 3bf6dae724 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.
2026-10-18 12:09:30 +03:30
ahp 6228e66d2c feat: production-grade video player — ambient mode, speed controls, hls.js, double-tap, settings flyout
- Ambient canvas (32×18, ~8fps rAF, blur(55px)) behind video; toggleable, persisted
  - Speed controls 0.5×–2× via settings flyout; capRate replaces lockRate, anti-cheat
  preserved
  - hls.js dynamic import for .m3u8 URLs; Safari native HLS fallback; MP4 unchanged
  - Double-tap left=seek back 10s, right=blocked; single-tap toggles play on touch
  - Keyboard: Space/K play-pause, J/← back, L/→ blocked, ↑↓ volume, M mute, F fullscreen
  - Settings flyout with speed picker + ambient toggle (animated, framer-motion)
  - Theater mode toggle, fullscreen API, volume slider with localStorage persistence
  - Buffer visualization behind progress bar, hover timestamp tooltip, playhead thumb
  - All anti-cheat intact: maxTrustedPosRef, PiP block, visibility pause, heartbeat system
2026-10-18 11:10:11 +03:30
ahpandClaude Sonnet 4.6 089c114019 feat: mobile-first responsive overhaul — safe areas, touch targets, bottom sheet, bottom nav
Viewport:
- Add `viewport` export with `viewport-fit=cover` (enables CSS safe-area-inset-*
  on bezel-less iOS/Android) and `interactive-widget=resizes-content` (prevents
  layout jumps from virtual keyboard open/close)

iOS zoom prevention:
- input.tsx, textarea.tsx: change text-sm (14px) → text-base sm:text-sm (16px)
  on mobile; iOS Safari auto-zooms any input with font-size < 16px

Touch targets:
- button.tsx: default h-10 → h-11 (44px), sm h-8 → h-9 (36px), icon h-10→h-11
  matching the 44×44px iOS HIG and WCAG 2.5.5 minimum
- Add touch-manipulation to buttons, inputs, textarea to eliminate 300ms tap delay

Dialog → mobile bottom sheet:
- On mobile (< 640px): anchored to bottom with full width, rounded top corners,
  slide-in-from-bottom / slide-out-to-bottom animation, drag handle affordance,
  max-h-[90svh] + overflow-y-auto to prevent viewport overflow
- Safe area padding: pb-[max(1.25rem,env(safe-area-inset-bottom))]
- On desktop (sm+): unchanged centered modal with zoom animation
- Close button gets a 32×32 tap area on mobile

Mobile navigation:
- New bottom-nav.tsx: persistent 3-tab bottom bar (مسیر/مرکز/لیگ) shown below
  md: breakpoint, hidden on desktop; 56px height + safe-area-inset-bottom;
  active tab gets heavier stroke and scale
- Only shown for authenticated non-admin users
- app-shell.tsx: injects BottomNav; main content gets dynamic padding-bottom to
  clear the bar (56px + safe area) on mobile, reverts to py-10 on md:
- Footer: safe-area-inset-bottom padding so content clears iPhone home indicator

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-18 10:51:37 +03:30
ahpandClaude Sonnet 4.6 602d3f2f4d feat: remove glassmorphism from notification toast; improve video player UX
Task 4 — Event Registration Confirmation (glassmorphism removal):
- Replace translucent gradient + backdrop-blur-md toast in use-realtime.tsx
  with a solid bg-card container, full-opacity border, and a colored
  left accent bar — high contrast, readable in all themes, no blur

Task 5 — Video player UX (within anti-cheat constraints):
- Keyboard shortcuts: Space (play/pause), M (mute), ← (seek back 10s),
  F (fullscreen) — active globally, disabled when an input is focused
- Fullscreen button added to the control bar (F key shortcut)
- Volume slider (0–100%) replaces mute-only toggle; volume is persisted
  to localStorage across sessions under key `pargar:volume`
- Buffer range visualized as a translucent white bar behind the playback
  progress track so users see how much is buffered
- Exponential backoff auto-retry on video error: 1s, 2s, 4s … 30s cap;
  retry count shown in error message; manual retry clears the timer
- Speed controls (0.75x–2x) are intentionally absent: the verified-watch
  anti-cheat locks playbackRate=1; changing it would break the heartbeat
  accumulation that gates quiz unlock and certification
- HLS/ABR requires backend FFmpeg transcoding; the player is structured
  so swapping src=mp4 → src=.m3u8 + hls.js is a one-file change when
  the backend pipeline is ready

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-18 10:40:13 +03:30
ahpandClaude Sonnet 4.6 40fa0b8581 fix: quality audit — memory leaks, dead prose classes, stale state scope
- safe-markdown: add `cancelled` flag to async useEffect; no more
  setState after unmount when component is destroyed mid-import
- rich-editor: same cleanup for preview tab effect; also remove dead
  `prose prose-sm max-w-none` classes (Tailwind Typography not installed)
  and replace with explicit arbitrary-selector markdown styling that
  actually renders using existing design tokens
- profile-certificate-slot: drop dead `ORDER_LABEL`, `order` variable,
  and `physicalOrder` from `MineCertificateResponse`; physical orders
  are disabled — these were leftover references causing lint noise
- learning-panel: change `issuingCert: boolean` → `issuingCertFor:
  number | null` so only the targeted user's button shows a spinner;
  a boolean shared across all users caused the wrong row to animate
  when the admin switched selected users mid-request

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-18 10:29:58 +03:30
ahpandClaude Sonnet 4.6 b4a7e7e1d3 fix: resolve TipTap v3 type errors in RichEditor
- Cast editor.storage to any for tiptap-markdown's getMarkdown (untyped extension storage)
- Replace setContent(value, false) with setContent(value, { emitUpdate: false }) for TipTap v3 API

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-18 10:18:51 +03:30
ahp 03610bc3d3 feat: implement 8 features across full stack
- Leaderboard: rank-based percentile tiers (champion/gold/silver/bronze),
    deterministic tie-breaking (xp DESC, user_id ASC)
  - Mentor chat: md: breakpoints, safe-area-inset-bottom padding
  - Learning progress: server-side progressPct, curriculum expansion safety
  - Physical orders: hard-disabled with 410 Gone
  - Certificate approval: pending/issued flow, admin issue/revoke endpoints,
    certificate_requires_approval setting (migration 0031)
  - Draft/publish: is_published on lessons and chapters, admin publish UI
    (migration 0030)
  - Video panel: static thumbnails (no preload), client-side pagination
  - Rich editor: TipTap + tiptap-markdown for lesson descriptions,
    marked + DOMPurify for SafeMarkdown rendering
2026-10-18 10:14:22 +03:30
97 changed files with 4466 additions and 1548 deletions
+14
View File
@@ -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
+90
View File
@@ -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
-1
View File
@@ -10,7 +10,6 @@
</p>
<p align="center">
<a href="https://github.com/Panahifarah/pargar/releases/tag/v0.2.1"><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>
</p>
Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 842 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1024" height="1024" viewBox="0 0 1024 1024">
<title>Compass master</title>
<image width="1024" height="1024" href="png/1024x1024.png" xlink:href="png/1024x1024.png"/>
</svg>

After

Width:  |  Height:  |  Size: 267 B

+32
View File
@@ -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) {
+76 -87
View File
@@ -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
}
// 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
}
ok, err := s.store.HasCompletedCurriculum(ctx, user.ID)
if err != nil {
return nil, false, err
@@ -37,15 +53,12 @@ func (s *Server) issueCertificateIfEligible(ctx context.Context, user *models.Us
} else if err != store.ErrNotFound {
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 {
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) {
@@ -101,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
}
@@ -140,90 +153,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) {
+50
View File
@@ -0,0 +1,50 @@
package api_test
import (
"context"
"net/http"
"testing"
)
// TestPendingCertDoesNotShortCircuitCurriculumCheck ensures that a pending
// certificate does not satisfy HasCompletedCurriculum. Only an issued
// (non-revoked) certificate should bypass the lesson-completion check.
func TestPendingCertDoesNotShortCircuitCurriculumCheck(t *testing.T) {
e := setup(t)
token, uid := register(t, e, "CertPending", "cert-pending@test.dev")
admin := login(t, e, bootstrapAdminEmail)
// Forcibly create a pending certificate in the DB (simulating a student
// who triggered issueCertificateIfEligible via quiz submit).
_, err := e.pool.Exec(context.Background(),
`INSERT INTO certificates (user_id, full_name, status)
VALUES ($1, 'CertPending', 'pending')
ON CONFLICT DO NOTHING`, uid)
if err != nil {
t.Fatalf("insert pending cert: %v", err)
}
// The student has NOT passed any lessons — eligible must be false.
code, body := e.do(t, "GET", "/api/me/certificate", token, nil)
if code != http.StatusOK {
t.Fatalf("get cert: %d %v", code, body)
}
if body["eligible"] != false {
t.Fatalf("pending cert must not make student eligible; got eligible=%v", body["eligible"])
}
// Admin issues the certificate manually.
code, _ = e.do(t, "POST", "/api/admin/users/"+itoa(uid)+"/certificate/issue", admin, nil)
if code != http.StatusOK {
t.Fatalf("admin issue: %d", code)
}
// Physical order endpoint must return 410 Gone.
code, _ = e.do(t, "POST", "/api/me/certificate/physical", token, map[string]any{
"recipientName": "Test", "phone": "09123456789", "address": "addr",
"city": "tehran", "postalCode": "1234567890",
})
if code != http.StatusGone {
t.Fatalf("physical endpoint must be disabled (410), got %d", code)
}
}
+3 -1
View File
@@ -5,7 +5,9 @@ import (
)
func (s *Server) handleCommunity(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
writeJSON(w, http.StatusOK, map[string]any{
"socials": s.loadSocials(r.Context()),
"socials": s.loadSocials(ctx),
"contributors": s.loadContributors(ctx),
})
}
+70 -14
View File
@@ -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,56 @@ 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
})
ids := make([]int64, len(scoredList))
for i, sc := range scoredList {
usr, err := s.store.GetUserByID(context.Background(), sc.userID)
if err != nil {
ids[i] = sc.userID
}
usersMap, _ := s.store.GetUsersByIDs(context.Background(), ids)
entries := make([]lbEntry, 0, len(scoredList))
for i, sc := range scoredList {
usr, ok := usersMap[sc.userID]
if !ok {
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
}
}
@@ -90,6 +140,12 @@ func (s *Server) handleWeeklyLeaderboard(w http.ResponseWriter, r *http.Request)
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},
"totalParticipants": totalParticipants,
"me": map[string]any{
"rank": myRank,
"xp": myXP,
"streak": u.StreakCurrent,
"tier": tierFromRank(myRank, int(totalParticipants)),
},
})
}
+31
View File
@@ -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)
}
}
}
-63
View File
@@ -504,69 +504,6 @@ func (s *Server) handleMeActivity(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"days": days, "weekDays": week, "monthDays": month})
}
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
if utf8.RuneCountInString(q) < 2 {
writeJSON(w, http.StatusOK, map[string]any{"results": []any{}})
return
}
me := currentUser(r)
like := "%" + q + "%"
var results []map[string]any
rows, err := s.store.Pool().Query(r.Context(), `
SELECT id, title FROM lessons WHERE title ILIKE $1 ORDER BY id LIMIT 8`, like)
if err == nil {
for rows.Next() {
var id int64
var title string
if rows.Scan(&id, &title) == nil {
results = append(results, map[string]any{"kind": "lesson", "title": title, "href": "/player/" + strconv.FormatInt(id, 10)})
}
}
rows.Close()
}
rows, err = s.store.Pool().Query(r.Context(), `
SELECT id, title FROM events WHERE title ILIKE $1 ORDER BY starts_at DESC LIMIT 8`, like)
if err == nil {
for rows.Next() {
var id int64
var title string
if rows.Scan(&id, &title) == nil {
results = append(results, map[string]any{"kind": "event", "title": title, "href": "/unwrap"})
}
}
rows.Close()
}
if me.Role.IsStaff() {
people, _, _ := s.store.ListUsers(r.Context(), q, "", 8, 0)
for _, p := range people {
results = append(results, map[string]any{"kind": "person", "title": p.Name, "href": "/profile/" + strconv.FormatInt(p.ID, 10)})
}
} else {
people, _, _ := s.store.ListActiveByRoles(r.Context(), []string{"mentor", "admin"}, 40, 0)
n := 0
for _, p := range people {
if n >= 8 || !strings.Contains(strings.ToLower(p.Name), strings.ToLower(q)) {
continue
}
results = append(results, map[string]any{"kind": "person", "title": p.Name, "href": "/profile/" + strconv.FormatInt(p.ID, 10)})
n++
}
}
pages := []struct{ title, href string }{
{"درخت مهارت", "/cap"}, {"اعلان‌ها", "/notifications"}, {"لیگ", "/leaderboard"},
{"تنظیمات", "/settings"}, {"پروفایل", "/profile"},
}
for _, p := range pages {
if strings.Contains(p.title, q) {
results = append(results, map[string]any{"kind": "page", "title": p.title, "href": p.href})
}
}
if results == nil {
results = []map[string]any{}
}
writeJSON(w, http.StatusOK, map[string]any{"results": results})
}
func (s *Server) handleMeta(w http.ResponseWriter, r *http.Request) {
env := "production"
+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)
+4 -1
View File
@@ -282,8 +282,11 @@ func (s *Server) perUserRateLimit(limit int64, scope string, next http.HandlerFu
// allowWSHandshake enforces a per-IP budget on WebSocket upgrades.
// Returns false when the caller should receive 429 (already written by caller).
func (s *Server) allowWSHandshake(r *http.Request) bool {
if s.cfg.DevMode {
return true
}
if s.redis == nil {
return s.cfg.DevMode
return false
}
ip := clientAddress(r)
if ip == "" {
+6 -1
View File
@@ -101,8 +101,12 @@ 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("POST /api/admin/contributor-avatar", admin(s.handleAdminContributorAvatarUpload))
mux.HandleFunc("GET /api/admin/chat-export", admin(s.handleAdminChatExport))
mux.HandleFunc("GET /api/admin/chat-export/stats", admin(s.handleAdminChatExportStats))
mux.HandleFunc("POST /api/admin/chat-export/restore", admin(s.handleAdminChatExportRestore))
@@ -127,11 +131,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))
@@ -176,7 +182,6 @@ func newRouter(s *Server) http.Handler {
mux.HandleFunc("GET /api/me/achievements", s.requireAuth(s.handleMeAchievements))
mux.HandleFunc("GET /api/me/activity", s.requireAuth(s.handleMeActivity))
mux.HandleFunc("GET /api/me/export", s.requireAuth(s.handleMeExport))
mux.HandleFunc("GET /api/search", s.requireAuth(s.handleSearch))
mux.HandleFunc("GET /api/meta", s.handleMeta)
mux.HandleFunc("POST /api/bot/send", s.handleBotSend)
mux.HandleFunc("GET /api/bot/updates", s.handleBotUpdates)
+137
View File
@@ -3,9 +3,11 @@ package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"unicode/utf8"
"pargar/backend/internal/observability"
@@ -93,12 +95,138 @@ func validateSocialLinks(v string) string {
return ""
}
func (s *Server) loadContributors(ctx context.Context) []map[string]any {
raw, err := s.store.GetSetting(ctx, "contributors")
if err != nil || strings.TrimSpace(raw) == "" {
return []map[string]any{}
}
var list []map[string]any
if err := json.Unmarshal([]byte(raw), &list); err != nil {
return []map[string]any{}
}
out := make([]map[string]any, 0, len(list))
for _, item := range list {
name, _ := item["name"].(string)
if strings.TrimSpace(name) == "" {
continue
}
role, _ := item["role"].(string)
bio, _ := item["bio"].(string)
url, _ := item["url"].(string)
avatar, _ := item["avatar"].(string)
contributions, _ := item["contributions"].([]any)
cats := make([]string, 0, len(contributions))
for _, c := range contributions {
if s, ok := c.(string); ok && s != "" {
cats = append(cats, s)
}
}
out = append(out, map[string]any{
"name": strings.TrimSpace(name),
"role": strings.TrimSpace(role),
"bio": strings.TrimSpace(bio),
"url": strings.TrimSpace(url),
"avatar": strings.TrimSpace(avatar),
"contributions": cats,
})
}
return out
}
func validateContributors(v string) string {
if v == "" || v == "[]" {
return ""
}
var tmp []map[string]any
if err := json.Unmarshal([]byte(v), &tmp); err != nil {
return "فرمت مشارکت‌کنندگان نامعتبر است"
}
if len(tmp) > 50 {
return "حداکثر ۵۰ مشارکت‌کننده مجاز است"
}
for _, item := range tmp {
name, _ := item["name"].(string)
if strings.TrimSpace(name) == "" {
return "نام مشارکت‌کننده الزامی است"
}
if utf8.RuneCountInString(strings.TrimSpace(name)) > 100 {
return "نام مشارکت‌کننده خیلی طولانی است"
}
if role, _ := item["role"].(string); utf8.RuneCountInString(strings.TrimSpace(role)) > 100 {
return "نقش مشارکت‌کننده خیلی طولانی است"
}
if bio, _ := item["bio"].(string); utf8.RuneCountInString(strings.TrimSpace(bio)) > 300 {
return "بیوگرافی خیلی طولانی است"
}
if url, _ := item["url"].(string); url != "" {
u := strings.TrimSpace(url)
if !strings.HasPrefix(u, "https://") && !strings.HasPrefix(u, "http://") {
return "لینک پروفایل باید با http شروع شود"
}
if utf8.RuneCountInString(u) > 500 {
return "لینک پروفایل خیلی طولانی است"
}
}
if contributions, ok := item["contributions"].([]any); ok {
if len(contributions) > 20 {
return "حداکثر ۲۰ دسته مشارکت برای هر نفر مجاز است"
}
for _, c := range contributions {
s, ok := c.(string)
if !ok {
return "دسته مشارکت باید رشته باشد"
}
s = strings.TrimSpace(s)
if s == "" {
return "دسته مشارکت نمی‌تواند خالی باشد"
}
if utf8.RuneCountInString(s) > 50 {
return "دسته مشارکت خیلی طولانی است"
}
}
}
}
return ""
}
func (s *Server) handleAdminContributorAvatarUpload(w http.ResponseWriter, r *http.Request) {
const maxSize = 6 << 20
r.Body = http.MaxBytesReader(w, r.Body, maxSize+1<<20)
if err := r.ParseMultipartForm(maxSize + 1<<20); err != nil {
writeErr(w, http.StatusRequestEntityTooLarge, "حجم فایل بیش از حد مجاز است")
return
}
file, h, err := r.FormFile("file")
if err != nil {
writeErr(w, http.StatusBadRequest, "فایل ارسال نشده است")
return
}
defer file.Close()
if h.Size > maxSize {
writeErr(w, http.StatusRequestEntityTooLarge, "حجم فایل باید کمتر از ۶ مگابایت باشد")
return
}
accepted, err := acceptAvatarImage(h, file)
if err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
key := fmt.Sprintf("contributor-avatars/%d%s", time.Now().UnixNano(), accepted.Ext)
rawURL, err := s.storage.Save(key, file, h.Size)
if err != nil {
writeErr(w, http.StatusInternalServerError, "ذخیرهٔ فایل ممکن نشد")
return
}
writeJSON(w, http.StatusCreated, map[string]any{"url": s.signExistingMediaURL(rawURL)})
}
func (s *Server) handleAdminGetSettings(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
keys := []string{
"physical_cert_enabled", "physical_cert_price_irr", "physical_cert_window_days",
"sponsors",
"social_links",
"contributors",
"hearts_max",
"hearts_regen_minutes",
"hearts_lock_on_empty",
@@ -163,6 +291,7 @@ func (s *Server) handleAdminPutSettings(w http.ResponseWriter, r *http.Request)
"registration_enabled": true,
"registration_require_whitelist": true,
"social_links": true,
"contributors": true,
"hearts_max": true,
"hearts_regen_minutes": true,
"hearts_lock_on_empty": true,
@@ -226,6 +355,14 @@ func (s *Server) handleAdminPutSettings(w http.ResponseWriter, r *http.Request)
writeErr(w, http.StatusBadRequest, msg)
return
}
case "contributors":
if v == "" {
v = "[]"
}
if msg := validateContributors(v); msg != "" {
writeErr(w, http.StatusBadRequest, msg)
return
}
case "hearts_max":
n, err := strconv.Atoi(v)
if err != nil || n < 1 || n > store.MaxHearts {
+21 -1
View File
@@ -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{}}
@@ -85,6 +102,9 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"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
}
@@ -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;
@@ -0,0 +1,6 @@
-- Add contributors setting key. Seed one verified entry (sole git author).
-- Admins can update via the site settings panel.
INSERT INTO app_settings (key, value) VALUES (
'contributors',
'[{"name":"Amir Husayn Panahifar","role":"بنیان‌گذار و توسعه‌دهنده","bio":"","url":"","avatar":"","contributions":["code"]}]'
) ON CONFLICT (key) DO NOTHING;
+4 -1
View File
@@ -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 {
+129 -26
View File
@@ -148,6 +148,27 @@ func (s *Store) GetUserByID(ctx context.Context, id int64) (*models.User, error)
return u, err
}
// GetUsersByIDs fetches multiple users in a single query. Missing IDs are silently skipped.
func (s *Store) GetUsersByIDs(ctx context.Context, ids []int64) (map[int64]*models.User, error) {
if len(ids) == 0 {
return map[int64]*models.User{}, nil
}
rows, err := s.pool.Query(ctx, `SELECT `+userCols+` FROM users WHERE id = ANY($1)`, ids)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[int64]*models.User, len(ids))
for rows.Next() {
u, err := scanUser(rows)
if err != nil {
continue
}
out[u.ID] = u
}
return out, rows.Err()
}
func (s *Store) UpdateUserStats(ctx context.Context, userID int64, xp int, streak int, streakLongest int, lastActivity *time.Time) error {
_, err := s.pool.Exec(ctx, `
UPDATE users SET xp=$1, streak_current=$2, streak_longest=$3, last_activity_date=$4
@@ -618,7 +639,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 +648,17 @@ 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).
// Pending certs do NOT count — they haven't been approved yet.
if cert, err := s.GetCertificateByUser(ctx, userID); err == nil && cert.RevokedAt == nil && cert.Status == "issued" {
return true, nil
}
active, err := s.CountActiveLessons(ctx)
if err != nil {
return false, err
@@ -648,42 +675,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 +970,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 +995,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 +1003,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 +1012,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 +1045,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 +1058,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 +1072,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)
+3
View File
@@ -33,6 +33,7 @@ services:
environment:
DEV_MODE: ${DEV_MODE:-false}
SEED_CURRICULUM: ${SEED_CURRICULUM:-false}
CAPTCHA_EXPOSE_ANSWER: ${CAPTCHA_EXPOSE_ANSWER:-false}
deploy:
resources:
limits:
@@ -66,6 +67,8 @@ services:
# Allow slow, large uploads (admin video ≤ 200MB); default is 60s.
- --entrypoints.web.transport.respondingTimeouts.readTimeout=3600
- --entrypoints.websecure.transport.respondingTimeouts.readTimeout=3600
# Disable HTTP/2 so WebSocket upgrades work from Chromium (RFC 8441 not enabled).
- --entrypoints.websecure.http2.maxConcurrentStreams=0
# Dev: Traefik default certificate (browser warning expected on localhost).
- --api.dashboard=true
- --api.insecure=true
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: pargar
description: Pargar gated LMS - Go API, Next.js frontend, Postgres, Redis, S3-compatible storage
type: application
version: 0.2.3
appVersion: "0.2.1"
version: 0.2.4
appVersion: "0.4.0"
keywords:
- lms
- 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 "") }}
{{- $pgPass = .Values.postgres.auth.password }}
{{- 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 }}
apiVersion: v1
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 "") }}
{{- $rdPass = .Values.redis.auth.password }}
{{- 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 }}
apiVersion: v1
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 "") }}
{{- $secret = .Values.rustfs.auth.secretKey }}
{{- 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 }}
apiVersion: v1
kind: Secret
@@ -0,0 +1,29 @@
{{- if .Values.backend.metrics.serviceMonitor.enabled }}
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: {{ include "pargar.fullname" . }}-backend
labels:
{{- include "pargar.labels" . | nindent 4 }}
app.kubernetes.io/component: backend
spec:
selector:
matchLabels:
{{- include "pargar.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: backend
namespaceSelector:
matchNames:
- {{ .Release.Namespace }}
endpoints:
- port: http
path: /metrics
interval: {{ .Values.backend.metrics.serviceMonitor.interval | default "30s" }}
scheme: http
# /metrics answers 401 without it; Prometheus sends it as
# Authorization: Bearer <token>.
authorization:
type: Bearer
credentials:
name: {{ include "pargar.appSecretName" . }}
key: metrics-token
{{- end }}
+8 -4
View File
@@ -11,7 +11,7 @@ backend:
image:
pullPolicy: IfNotPresent
repository: pargar-backend
tag: 0.2.1
tag: 0.4.0
metrics:
serviceMonitor:
enabled: true
@@ -41,7 +41,7 @@ frontend:
image:
pullPolicy: IfNotPresent
repository: pargar-frontend
tag: 0.2.1
tag: 0.4.0
podDisruptionBudget:
enabled: false
replicaCount: 2
@@ -55,9 +55,13 @@ frontend:
global:
domain: pargar.panahifar.ir
frontendOrigins: https://pargar.panahifar.ir
imageRegistry: ""
# Images published by .gitea/workflows/build-and-push.yaml on tag push.
imageRegistry: src.panahifar.ir/ahp
publicUrl: https://pargar.panahifar.ir
imagePullSecrets: []
# Gitea packages are private: kubelet pulls need secret `gitea-pull`
# (docker-registry secret for src.panahifar.ir, created in ns pargar).
imagePullSecrets:
- name: gitea-pull
ingress:
enabled: true
tls:
+7 -6
View File
@@ -3,25 +3,26 @@
# -f deploy/helm/pargar/values-prod.yaml
#
# 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,
# storage-access-key, storage-secret-key)
# - cert-manager ClusterIssuer letsencrypt-prod
# - imagePullSecrets if the registry is private
# - imagePullSecrets if the Gitea package registry visibility is private
global:
domain: app.example.com
publicUrl: https://app.example.com
frontendOrigins: "https://app.example.com"
imageRegistry: ghcr.io/pargar
imageRegistry: src.panahifar.ir/ahp
imagePullSecrets:
- name: ghcr-pull
- name: gitea-pull
backend:
replicaCount: 2
image:
repository: backend
repository: pargar-backend
tag: "0.2.1"
pullPolicy: IfNotPresent
existingSecret: pargar-app
@@ -48,7 +49,7 @@ backend:
frontend:
replicaCount: 2
image:
repository: frontend
repository: pargar-frontend
tag: "0.2.1"
pullPolicy: IfNotPresent
resources:
+7
View File
@@ -25,6 +25,13 @@ backend:
pullPolicy: IfNotPresent
service:
port: 8080
metrics:
# Prometheus Operator ServiceMonitor. /metrics is bearer-protected by
# METRICS_TOKEN, so the endpoint carries authorization credentials
# (pargar-app secret) instead of scraping anonymously.
serviceMonitor:
enabled: false
interval: 30s
resources:
requests:
cpu: 50m
+1
View File
@@ -0,0 +1 @@
{"available":true,"accessToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjEsInJvbGUiOiJhZG1pbiIsInN1YiI6IjEiLCJleHAiOjE3OTI0OTc0MjcsImlhdCI6MTc5MjQ5NjUyN30.0RuGRaPhMeQ36aHrn_x2iAgJPpyH7pfXGr_cOOOeG1c","refreshToken":"e4059f725d0648ae77b0147835296228c5bba0d0bcda638ebb9a65adeed6c5f72e56d0da268de11d34393e9d4ea96acb","user":{"id":1,"email":"admin@example.com","username":"admin","name":"Admin","role":"admin","xp":0,"hearts":5,"heartsUpdatedAt":"2026-10-18T06:49:58.329935Z","streakCurrent":0,"streakLongest":0,"isLocked":false,"isActive":true,"avatarVariant":"beam","avatarPalette":"","phone":"","hasSecurityAnswer":false,"createdAt":"2026-10-18T06:49:58.329935Z","usernameChangesRemaining":3,"isFrozen":false,"isClosed":false}}
+79
View File
@@ -0,0 +1,79 @@
import { test, expect } from "@playwright/test";
import { ADMIN_EMAIL, ADMIN_PASSWORD, BASE, requireAuth, injectAuth, apiLogin } from "./helpers";
test.describe("Auth — public endpoints", () => {
test("login page renders", async ({ page }) => {
await page.goto("/login");
await expect(page).toHaveTitle(/.+/);
const hasForm = await page.locator("form, input[type=password]").count();
expect(hasForm).toBeGreaterThan(0);
});
test("protected routes reject unauthenticated requests", async ({ page }) => {
const routes = ["/api/tree", "/api/leaderboard/weekly", "/api/notifications"];
for (const route of routes) {
const res = await page.request.get(`${BASE}${route}`);
expect(res.status(), `${route} should be 401`).toBe(401);
}
});
test("invalid credentials: bad captcha → 400, bad password → 401", async ({ page }) => {
// Bad captcha answer returns 400
const r1 = await page.request.post(`${BASE}/api/auth/login`, {
data: { username: "x", password: "x", challengeId: "fake", captchaAnswer: "wrong" },
});
expect([400, 429]).toContain(r1.status());
// Correct captcha, wrong password → 401
const auth = requireAuth();
const captchaRes = await page.request.get(`${BASE}/api/auth/captcha`);
const { challengeId, answer } = await captchaRes.json();
const r2 = await page.request.post(`${BASE}/api/auth/login`, {
data: { username: ADMIN_EMAIL, password: "definitely-wrong", challengeId, captchaAnswer: answer },
});
expect(r2.status()).toBe(401);
});
});
test.describe("Auth — requires working CAPTCHA", () => {
test("admin login via API succeeds and /me returns admin role", async ({ page }) => {
const auth = requireAuth();
const res = await page.request.get(`${BASE}/api/auth/me`, {
headers: { Authorization: `Bearer ${auth.accessToken}` },
});
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.user.role).toBe("admin");
});
test("logout invalidates refresh token", async ({ page }) => {
requireAuth();
// Issue a fresh session for this test only (don't use the shared token)
const captchaRes = await page.request.get(`${BASE}/api/auth/captcha`);
const { challengeId, answer } = await captchaRes.json();
if (!answer) { test.skip(true, "CAPTCHA not solvable"); return; }
const loginRes = await page.request.post(`${BASE}/api/auth/login`, {
data: { username: ADMIN_EMAIL, password: ADMIN_PASSWORD, challengeId, captchaAnswer: answer },
});
const { accessToken, refreshToken } = await loginRes.json();
const logout = await page.request.post(`${BASE}/api/auth/logout`, {
data: { refreshToken },
headers: { Authorization: `Bearer ${accessToken}` },
});
expect(logout.status()).toBe(200);
const refresh = await page.request.post(`${BASE}/api/auth/refresh`, {
data: { refreshToken },
});
expect(refresh.status()).toBe(401);
});
test("cap page loads and stays off /login", async ({ page }) => {
await injectAuth(page);
await page.goto("/cap");
await page.waitForLoadState("networkidle", { timeout: 15_000 });
expect(page.url()).not.toContain("/login");
});
});
+65
View File
@@ -0,0 +1,65 @@
import { test, expect } from "@playwright/test";
import { BASE, requireAuth, injectAuth } from "./helpers";
test.describe("Chat", () => {
test("conversations endpoint returns list", async ({ page }) => {
const auth = requireAuth();
const res = await page.request.get(`${BASE}/api/chats/conversations`, {
headers: { Authorization: `Bearer ${auth.accessToken}` },
});
expect(res.status()).toBe(200);
const data = await res.json();
expect(data).toHaveProperty("conversations");
expect(Array.isArray(data.conversations)).toBe(true);
});
test("student→student chat is rejected (IDOR guard)", async ({ page }) => {
const auth = requireAuth();
const adminId = auth.user!.id;
// Create a student
const u1 = await page.request.post(`${BASE}/api/admin/users`, {
headers: { Authorization: `Bearer ${auth.accessToken}` },
data: {
name: "ChatTest", email: "chat-idor@e2e.test", username: "chat_idor_e2e",
password: "TestPass123!", role: "student",
securityQuestion: "Q", securityAnswer: "A",
},
});
// 201 = created, 409 = already exists from prior run — both are fine
if (u1.status() !== 201 && u1.status() !== 409) {
test.skip(true, `Unexpected status creating test user: ${u1.status()}`);
return;
}
const studentId = u1.status() === 201
? (await u1.json()).user.id
: null;
if (!studentId) { test.skip(true, "Could not get student ID"); return; }
// Log in as the student
const captchaRes = await page.request.get(`${BASE}/api/auth/captcha`);
const { challengeId, answer } = await captchaRes.json();
if (!answer) { test.skip(true, "CAPTCHA not solvable"); return; }
const loginRes = await page.request.post(`${BASE}/api/auth/login`, {
data: { username: "chat_idor_e2e", password: "TestPass123!", challengeId, captchaAnswer: answer },
});
const studentSession = await loginRes.json();
if (!studentSession.accessToken) { test.skip(true, "Student login failed"); return; }
// Student tries to read another student's conversations — must fail
const forbidden = await page.request.get(`${BASE}/api/chats/${studentId}/messages`, {
headers: { Authorization: `Bearer ${studentSession.accessToken}` },
});
expect([403, 404]).toContain(forbidden.status());
});
test("chat page renders without redirect to /login", async ({ page }) => {
await injectAuth(page);
await page.goto("/unwrap?tab=chats");
await page.waitForLoadState("networkidle", { timeout: 15_000 });
expect(page.url()).not.toContain("/login");
});
});
+59
View File
@@ -0,0 +1,59 @@
import { chromium } from "@playwright/test";
import { ADMIN_EMAIL, ADMIN_PASSWORD, BASE } from "./helpers";
import * as fs from "fs";
import * as path from "path";
const AUTH_FILE = path.join(__dirname, ".auth-state.json");
export default async function globalSetup() {
// If answer is not exposed in captcha, skip auth — tests requiring auth will skip themselves.
const browser = await chromium.launch({
executablePath: `${process.env.HOME}/.cache/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-linux64/chrome-headless-shell`,
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
const captchaRes = await page.request.get(`${BASE}/api/auth/captcha`);
const captchaData = await captchaRes.json();
if (!captchaData.answer) {
console.warn("\n⚠ CAPTCHA_EXPOSE_ANSWER not set — restart the API container to enable auth tests.\n Run: ./scripts/compose-dev.sh restart api\n");
fs.writeFileSync(AUTH_FILE, JSON.stringify({ available: false }));
await browser.close();
return;
}
const loginRes = await page.request.post(`${BASE}/api/auth/login`, {
data: {
username: ADMIN_EMAIL,
password: ADMIN_PASSWORD,
challengeId: captchaData.challengeId,
captchaAnswer: captchaData.answer,
rememberMe: true,
},
});
const body = await loginRes.json();
if (!body.accessToken) {
console.warn("\n⚠ Admin login failed in global setup:", JSON.stringify(body));
fs.writeFileSync(AUTH_FILE, JSON.stringify({ available: false }));
await browser.close();
return;
}
fs.writeFileSync(AUTH_FILE, JSON.stringify({
available: true,
accessToken: body.accessToken,
refreshToken: body.refreshToken,
user: body.user,
}));
console.log("\n✓ Auth state cached for test suite\n");
} catch (e) {
fs.writeFileSync(AUTH_FILE, JSON.stringify({ available: false }));
console.warn("\n⚠ Global setup failed:", e);
}
await browser.close();
}
+83
View File
@@ -0,0 +1,83 @@
import { type Page, test } from "@playwright/test";
import * as fs from "fs";
import * as path from "path";
export const ADMIN_EMAIL = "admin";
export const ADMIN_PASSWORD = "wZW64CUbXbv9679dmR1FIbz3";
export const BASE = "https://localhost";
const AUTH_FILE = path.join(__dirname, ".auth-state.json");
interface AuthState {
available: boolean;
accessToken?: string;
refreshToken?: string;
user?: { id: number; role: string; email: string };
}
export function loadAuth(): AuthState {
try {
return JSON.parse(fs.readFileSync(AUTH_FILE, "utf-8")) as AuthState;
} catch {
return { available: false };
}
}
/** Skip this test if auth is not available (CAPTCHA not exposed). */
export function requireAuth(): AuthState {
const auth = loadAuth();
if (!auth.available) {
test.skip(true, "Auth not available — restart API with CAPTCHA_EXPOSE_ANSWER=true");
}
return auth;
}
/** Inject cached auth cookies into the page context. */
export async function injectAuth(page: Page): Promise<AuthState> {
const auth = requireAuth();
// Seed Zustand persist store so the app hydrates with a valid user (skips redirect)
await page.addInitScript(({ user }) => {
try {
localStorage.setItem(
"pargar.auth",
JSON.stringify({ state: { user }, version: 0 }),
);
} catch {}
}, { user: auth.user });
await page.context().addCookies([
{ name: "pargar_access", value: auth.accessToken!, domain: "localhost", path: "/", secure: true, sameSite: "Lax" },
{ name: "pargar_refresh", value: auth.refreshToken!, domain: "localhost", path: "/", secure: true, httpOnly: true, sameSite: "Lax" },
]);
return auth;
}
export async function loginAs(page: Page, identity: string, password: string) {
const captchaRes = await page.request.get(`${BASE}/api/auth/captcha`);
const captchaData = await captchaRes.json();
const answer: string = captchaData.answer ?? "";
const challengeId: string = captchaData.challengeId ?? "";
const loginRes = await page.request.post(`${BASE}/api/auth/login`, {
data: { username: identity, password, challengeId, captchaAnswer: answer, rememberMe: false },
});
const loginBody = await loginRes.json();
if (!loginBody.accessToken) {
throw new Error(`Login failed for ${identity}: ${JSON.stringify(loginBody)}`);
}
await page.context().addCookies([
{ name: "access_token", value: loginBody.accessToken, domain: "localhost", path: "/", secure: true, sameSite: "Lax" },
{ name: "refresh_token", value: loginBody.refreshToken, domain: "localhost", path: "/", secure: true, httpOnly: true, sameSite: "Lax" },
]);
return loginBody;
}
export async function apiLogin(page: Page, identity: string, password: string) {
const captchaRes = await page.request.get(`${BASE}/api/auth/captcha`);
const { challengeId, answer } = await captchaRes.json();
const res = await page.request.post(`${BASE}/api/auth/login`, {
data: { username: identity, password, challengeId, captchaAnswer: answer, rememberMe: false },
});
return res.json();
}
+37
View File
@@ -0,0 +1,37 @@
import { test, expect } from "@playwright/test";
import { BASE, requireAuth, injectAuth } from "./helpers";
test.describe("Leaderboard", () => {
test("weekly leaderboard returns valid shape", async ({ page }) => {
const auth = requireAuth();
const res = await page.request.get(`${BASE}/api/leaderboard/weekly`, {
headers: { Authorization: `Bearer ${auth.accessToken}` },
});
expect(res.status()).toBe(200);
const data = await res.json();
expect(data).toHaveProperty("week");
expect(data).toHaveProperty("entries");
expect(data).toHaveProperty("totalParticipants");
expect(data).toHaveProperty("me");
expect(Array.isArray(data.entries)).toBe(true);
});
test("leaderboard entries have tier field", async ({ page }) => {
const auth = requireAuth();
const res = await page.request.get(`${BASE}/api/leaderboard/weekly`, {
headers: { Authorization: `Bearer ${auth.accessToken}` },
});
const data = await res.json();
for (const entry of data.entries) {
expect(["champion", "gold", "silver", "bronze"]).toContain(entry.tier);
expect(entry.rank).toBeGreaterThanOrEqual(1);
}
});
test("leaderboard page renders without redirect to /login", async ({ page }) => {
await injectAuth(page);
await page.goto("/leaderboard");
await page.waitForLoadState("networkidle", { timeout: 15_000 });
expect(page.url()).not.toContain("/login");
});
});
+49
View File
@@ -0,0 +1,49 @@
import { test, expect } from "@playwright/test";
import { requireAuth, injectAuth } from "./helpers";
const VIEWPORTS = [
{ name: "mobile-sm (375px)", width: 375, height: 812 },
{ name: "mobile-lg (430px)", width: 430, height: 932 },
{ name: "tablet (768px)", width: 768, height: 1024 },
{ name: "desktop (1280px)", width: 1280, height: 800 },
{ name: "desktop-xl (1440px)", width: 1440, height: 900 },
];
const ROUTES = ["/cap", "/leaderboard", "/community", "/settings", "/notifications"];
for (const vp of VIEWPORTS) {
test(`no horizontal overflow — ${vp.name}`, async ({ page }) => {
requireAuth();
await page.setViewportSize({ width: vp.width, height: vp.height });
await injectAuth(page);
for (const route of ROUTES) {
await page.goto(route);
await page.waitForLoadState("networkidle", { timeout: 15_000 });
const overflow = await page.evaluate(() => {
return document.documentElement.scrollWidth > document.documentElement.clientWidth;
});
expect(overflow, `Horizontal overflow at ${route} / ${vp.name}`).toBe(false);
}
});
test(`no interactive elements clipped off-screen — ${vp.name}`, async ({ page }) => {
requireAuth();
await page.setViewportSize({ width: vp.width, height: vp.height });
await injectAuth(page);
await page.goto("/cap");
await page.waitForLoadState("networkidle", { timeout: 15_000 });
const clipped = await page.evaluate((vpWidth) => {
return Array.from(document.querySelectorAll("button, a, input"))
.filter((el) => {
const r = el.getBoundingClientRect();
return r.right < 0 || r.left > vpWidth + 50;
}).length;
}, vp.width);
expect(clipped, `${clipped} elements clipped at ${vp.name}`).toBe(0);
});
}
+75
View File
@@ -0,0 +1,75 @@
import { test, expect } from "@playwright/test";
import { BASE, requireAuth, injectAuth } from "./helpers";
test.describe("Video player & media signing", () => {
test("lesson endpoint returns a signed videoUrl", async ({ page }) => {
const auth = requireAuth();
const treeRes = await page.request.get(`${BASE}/api/tree`, {
headers: { Authorization: `Bearer ${auth.accessToken}` },
});
expect(treeRes.status()).toBe(200);
const tree = await treeRes.json();
const firstLesson = tree.chapters?.[0]?.lessons?.[0];
if (!firstLesson) { test.skip(true, "No lessons in tree"); return; }
const lessonRes = await page.request.get(`${BASE}/api/lessons/${firstLesson.id}`, {
headers: { Authorization: `Bearer ${auth.accessToken}` },
});
expect(lessonRes.status()).toBe(200);
const lesson = await lessonRes.json();
expect(lesson.videoUrl).toBeTruthy();
expect(lesson.videoUrl).toMatch(/^https?:\/\//);
// Must be signed (has query params) or served via /media/ proxy
const url = new URL(lesson.videoUrl);
const isSigned = url.searchParams.size > 0 || lesson.videoUrl.includes("/media/");
expect(isSigned, `videoUrl not signed: ${lesson.videoUrl}`).toBe(true);
});
test("player page renders without JS errors for first lesson", async ({ page }) => {
const auth = requireAuth();
const treeRes = await page.request.get(`${BASE}/api/tree`, {
headers: { Authorization: `Bearer ${auth.accessToken}` },
});
const tree = await treeRes.json();
const firstLesson = tree.chapters?.[0]?.lessons?.[0];
if (!firstLesson) { test.skip(true, "No lessons in tree"); return; }
const jsErrors: string[] = [];
page.on("pageerror", (err) => jsErrors.push(err.message));
await injectAuth(page);
await page.goto(`/player/${firstLesson.id}`);
await page.waitForLoadState("networkidle", { timeout: 20_000 });
// Filter known expected media errors (video load failures in headless are expected)
const realErrors = jsErrors.filter(
(e) => !e.includes("video") && !e.includes("media") && !e.includes("hls") && !e.includes("AbortError")
);
expect(realErrors, `JS errors on player page: ${realErrors.join(", ")}`).toHaveLength(0);
});
test("admin videos panel fires no media requests on grid load", async ({ page }) => {
requireAuth();
const videoRequests: string[] = [];
page.on("request", (req) => {
if (req.resourceType() === "media" || /\.(mp4|m3u8|ts)(\?|$)/.test(req.url())) {
videoRequests.push(req.url());
}
});
await injectAuth(page);
await page.goto("/admin");
await page.waitForLoadState("networkidle", { timeout: 15_000 });
// Click Videos tab if present
const videosTab = page.getByRole("button", { name: /ویدیو/i });
if (await videosTab.count() > 0) {
await videosTab.first().click();
await page.waitForLoadState("networkidle", { timeout: 10_000 });
}
expect(videoRequests, `Unexpected media requests on grid load: ${videoRequests.join(", ")}`).toHaveLength(0);
});
});
+56
View File
@@ -0,0 +1,56 @@
import * as tls from "tls";
import { test, expect } from "@playwright/test";
import { BASE, requireAuth } from "./helpers";
test.describe("WebSocket", () => {
// Test WS via raw TLS to avoid browser HTTP/2 and Node.js http module quirks.
test("WS handshake with valid token succeeds", async () => {
const auth = requireAuth();
const statusCode = await new Promise<number>((resolve) => {
const socket = tls.connect(
{ host: "localhost", port: 443, rejectUnauthorized: false },
() => {
const handshake = [
"GET /api/ws HTTP/1.1",
"Host: localhost",
"Upgrade: websocket",
"Connection: Upgrade",
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==",
"Sec-WebSocket-Version: 13",
`Authorization: Bearer ${auth.accessToken!}`,
"",
"",
].join("\r\n");
socket.write(handshake);
},
);
let buf = "";
socket.on("data", (chunk) => {
buf += chunk.toString();
const firstLine = buf.split("\r\n")[0] ?? "";
const m = firstLine.match(/HTTP\/1\.\d\s+(\d+)/);
if (m) {
socket.destroy();
resolve(parseInt(m[1], 10));
}
});
socket.on("error", () => resolve(0));
setTimeout(() => { socket.destroy(); resolve(0); }, 8000);
});
expect(statusCode, `WS upgrade returned ${statusCode}`).toBe(101);
});
test("WS handshake without token is rejected", async ({ page }) => {
const result = await page.evaluate((wsUrl) => {
return new Promise<{ code: number }>((resolve) => {
const ws = new WebSocket(wsUrl);
ws.onclose = (e) => resolve({ code: e.code });
setTimeout(() => resolve({ code: -1 }), 6000);
});
}, BASE.replace("https", "wss") + "/api/ws");
expect(result.code).not.toBe(1000);
});
});
+890 -3
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -20,13 +20,20 @@
"@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",
"hls.js": "^1.7.3",
"lucide-react": "^0.468.0",
"marked": "^18.1.1",
"next": "15.5.27",
"next-themes": "^0.4.4",
"react": "^19.0.0",
@@ -34,10 +41,12 @@
"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"
},
"devDependencies": {
"@playwright/test": "^1.64.1",
"@tailwindcss/postcss": "^4.0.0",
"@types/node": "^22.10.0",
"@types/react": "^19.0.0",
+31
View File
@@ -0,0 +1,31 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
timeout: 30_000,
retries: 0,
workers: 1,
globalSetup: "./e2e/global-setup.ts",
reporter: [["list"], ["html", { open: "never", outputFolder: "playwright-report" }]],
use: {
baseURL: "https://localhost",
ignoreHTTPSErrors: true,
screenshot: "only-on-failure",
video: "off",
trace: "retain-on-failure",
locale: "fa-IR",
},
projects: [
{
name: "chromium",
use: {
browserName: "chromium",
viewport: { width: 1280, height: 800 },
launchOptions: {
executablePath: `${process.env.HOME}/.cache/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-linux64/chrome-headless-shell`,
args: ["--no-sandbox", "--disable-dev-shm-usage", "--disable-http2"],
},
},
},
],
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

After

Width:  |  Height:  |  Size: 253 KiB

+5 -155
View File
@@ -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<string, string> = {
requested: "درخواست‌شده — منتظر تأیید پرداخت",
paid: "پرداخت تأیید شد — در صف ارسال",
shipped: "ارسال شد",
cancelled: "لغو شده",
};
export default function CertificatePage() {
const params = useParams<{ id: string }>();
const publicId = params?.id ?? "";
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({
queryKey: ["certificate", publicId],
@@ -50,42 +28,12 @@ export default function CertificatePage() {
const { data: mine } = useQuery({
queryKey: ["me", "certificate"],
queryFn: () =>
http.get<{
certificate: Certificate | null;
physicalOrder: CertificatePhysicalOrder | null;
physical: PhysicalCertSettings;
}>("/api/me/certificate"),
queryFn: () => http.get<{ certificate: Certificate | null }>("/api/me/certificate"),
enabled: !!authUser,
});
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) {
return (
<div className="flex min-h-[60vh] items-center justify-center">
@@ -110,17 +58,6 @@ export default function CertificatePage() {
}
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 (
<CertificateCard
@@ -130,94 +67,7 @@ export default function CertificatePage() {
issuedAt: cert.issuedAt,
course: data.course,
}}
showOwnerLinks={!!authUser}
>
{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 }))}
showOwnerLinks={isOwner}
/>
<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>
);
}
+23 -50
View File
@@ -13,7 +13,6 @@ import {
Heart,
Lock,
MessageSquare,
Package,
PlayCircle,
Sparkles,
TrendingUp,
@@ -22,7 +21,7 @@ import {
import { SkillTree } from "@/components/skill-tree";
import { Skeleton } from "@/components/ui/skeleton";
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 { useAuth } from "@/lib/auth-store";
import { cn, formatFaNumber, formatStreakLabel } from "@/lib/utils";
@@ -58,8 +57,8 @@ function CapPageInner() {
});
const allLessons = (data?.chapters ?? []).flatMap((ch) => ch.lessons ?? []);
const completedCount = allLessons.filter((l) => l.progress?.passedQuiz).length;
const curriculumDone = allLessons.length > 0 && completedCount === allLessons.length;
const completedCount = data?.completedLessons ?? allLessons.filter((l) => l.progress?.passedQuiz).length;
const curriculumDone = (data?.progressPct ?? 0) >= 100;
useEffect(() => {
const lesson = params.get("lesson");
@@ -70,22 +69,10 @@ function CapPageInner() {
const { data: certData } = useQuery({
queryKey: ["me", "certificate"],
queryFn: () =>
http.get<{
certificate: Certificate | null;
physicalOrder: CertificatePhysicalOrder | null;
physical: PhysicalCertSettings;
}>("/api/me/certificate"),
queryFn: () => http.get<{ certificate: Certificate | null }>("/api/me/certificate"),
enabled: !!user && curriculumDone,
});
useEffect(() => {
if (!curriculumDone || !user) return;
void http
.post("/api/me/certificate/issue", {})
.then(() => qc.invalidateQueries({ queryKey: ["me", "certificate"] }))
.catch((e) => toast.error(toUserError(e, "صدور گواهینامه ممکن نشد")));
}, [curriculumDone, user, qc]);
if (isLoading) {
return <CapSkeleton />;
@@ -102,20 +89,10 @@ function CapPageInner() {
const firstLocked = all.find((l) => l.locked);
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 lowHearts = !hasInfiniteHearts(user?.role) && hearts > 0 && hearts <= 1;
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);
return (
@@ -181,33 +158,29 @@ function CapPageInner() {
<div>
<p className="text-sm font-extrabold">دوره را تمام کردید</p>
<p className="text-xs text-muted-foreground">
گواهینامه دیجیتال آماده است
{physicalOpen ? " — می‌توانید نسخه فیزیکی هم درخواست کنید." : "."}
{certData?.certificate?.status === "pending"
? "درخواست گواهینامه ثبت شد — پس از تأیید تیم آموزش صادر می‌شود."
: certData?.certificate?.status === "issued"
? "گواهینامه دیجیتال آماده است."
: "گواهینامه دیجیتال آماده است."}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
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, "صدور گواهینامه ممکن نشد")));
}
}}
>
{certData?.certificate?.status === "issued" && certData.certificate.publicId ? (
<Button onClick={() => router.push(`/c/${certData.certificate!.publicId!}`)}>
<Award className="h-4 w-4" /> مشاهده گواهینامه
</Button>
{physicalOpen && certData?.certificate?.publicId && (
<Button variant="outline" onClick={() => router.push(`/c/${certData.certificate!.publicId}`)}>
<Package className="h-4 w-4" /> نسخه فیزیکی
) : certData?.certificate?.status !== "pending" ? (
<Button
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>
)}
</div>
) : null}
</div>
)}
+16 -1
View File
@@ -1,4 +1,4 @@
import type { Metadata } from "next";
import type { Metadata, Viewport } from "next";
import localFont from "next/font/local";
import "./globals.css";
import { Providers } from "@/components/providers";
@@ -35,6 +35,21 @@ const vazirmatnLatin = localFont({
export const metadata: Metadata = {
title: "پرگار",
description: "مسیر یادگیری واقعی پرگار",
icons: {
icon: [
{ url: "/favicon.ico" },
{ url: "/icon-192.png", sizes: "192x192", type: "image/png" },
{ url: "/icon-512.png", sizes: "512x512", type: "image/png" },
],
apple: { url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" },
},
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
interactiveWidget: "resizes-content",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
+3 -2
View File
@@ -8,6 +8,7 @@ import { api, http, toUserError } from "@/lib/api";
import { MAX_HEARTS, clampHearts, hasInfiniteHearts } from "@/lib/hearts";
import type { Mentor, User } from "@/lib/types";
import { Card } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { toast } from "@/components/providers";
import { cn } from "@/lib/utils";
@@ -116,14 +117,14 @@ export default function LockoutPage() {
</Card>
<div className="mt-5 w-full space-y-2">
<textarea
<Textarea
value={note}
onChange={(e) => setNote(e.target.value)}
maxLength={500}
rows={3}
dir="rtl"
placeholder="اختیاری: چه آزمونی گیر کردید؟ چه کمکی لازم دارید؟"
className="w-full rounded-xl border-2 border-border bg-background px-3 py-2 text-sm outline-none focus-visible:border-primary/50"
className="min-h-0"
/>
<div className="flex flex-col gap-2 sm:flex-row">
<Button className="flex-1" disabled={busy || requested} onClick={() => void requestUnlock()}>
+1 -1
View File
@@ -48,7 +48,7 @@ export default function NotificationsPage() {
<Skeleton className="h-8 w-44" />
<Skeleton className="h-4 w-72" />
</div>
<div className="rounded-2xl border border-border/70 bg-card p-6 shadow-soft">
<div className="rounded-2xl border-2 border-border/70 bg-card p-6 shadow-soft">
<div className="flex items-center gap-3">
<Skeleton className="h-10 w-10 rounded-xl" />
<div className="flex-1 space-y-1.5">
+3 -2
View File
@@ -93,6 +93,7 @@ export default function Home() {
const goCap = () => router.push("/cap");
const goLogin = () => router.push("/login");
const goRegister = () => router.push("/register");
return (
<div className="mx-auto flex max-w-5xl flex-col items-center">
@@ -134,7 +135,7 @@ export default function Home() {
<Button size="lg" onClick={goLogin} className="gap-2 px-8">
ورود
</Button>
<Button size="lg" variant="outline" onClick={goLogin} className="gap-2 px-8">
<Button size="lg" variant="outline" onClick={goRegister} className="gap-2 px-8">
<Zap className="h-5 w-5" /> شروع مسیر
</Button>
</>
@@ -288,7 +289,7 @@ export default function Home() {
<Link href="/login">ورود</Link>
</Button>
<Button size="lg" variant="outline" asChild className="gap-2 px-8">
<Link href="/login">
<Link href="/register">
<PlayCircle className="h-5 w-5" /> شروع مسیر
</Link>
</Button>
+17 -12
View File
@@ -152,23 +152,26 @@ function ChatsLauncher() {
const convTotal = convData?.total ?? conversations.length;
return (
<div className="grid gap-4 lg:grid-cols-2">
<Panel className="justify-center text-center" title="گفتگوها" description="یک پنجرهٔ شناور شیشه‌ای در تمام صفحات — با صدا، عکس، ویدیو و پیام صوتی.">
<div className="flex flex-col items-center gap-4 pt-1">
<div className="grid h-14 w-14 place-items-center rounded-2xl bg-primary/10">
<MessageSquareHeart className="h-7 w-7 text-primary" />
<div className="space-y-4">
{/* ── Centered header banner ── */}
<div className="flex flex-col items-center gap-3 rounded-2xl border border-border bg-card px-6 py-6 text-center">
<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-4 w-4 shrink-0" />
یک پنجرهٔ شناور شیشه‌ای در تمام صفحات — با صدا، عکس، ویدیو و پیام صوتی.
</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>
<Button onClick={() => openChat()} className="gap-2">
<Sparkles className="h-4 w-4" /> باز کردن گفتگوها
</Button>
</div>
</Panel>
{/* ── Side-by-side panels ── */}
<div className="grid gap-4 lg:grid-cols-2">
{/* Conversations panel */}
<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 && (
<div className="space-y-2">
{[0, 1, 2].map((i) => (
@@ -244,6 +247,7 @@ function ChatsLauncher() {
</div>
</Panel>
{/* Start chat panel */}
<Panel
title={staff ? "شروع گفتگو" : "شروع سریع با منتور"}
description="نقش هر نفر روی ردیف نوشته شده است."
@@ -253,7 +257,7 @@ function ChatsLauncher() {
</Button>
}
>
<div className="space-y-1 pt-1">
<div className="max-h-[32rem] overflow-y-auto space-y-1 pt-1 pb-1">
{isLoading && (
<div className="space-y-2">
{[0, 1, 2, 3].map((i) => (
@@ -312,6 +316,7 @@ function ChatsLauncher() {
</div>
</Panel>
</div>
</div>
);
}
+294 -80
View File
@@ -12,6 +12,7 @@ import {
Copy,
Cpu,
Eye,
EyeOff,
Flag,
GitBranch,
Globe,
@@ -21,6 +22,7 @@ import {
Loader2,
Lock,
MapPin,
MoreHorizontal,
Pencil,
Plus,
Rocket,
@@ -59,11 +61,21 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { PasswordInput } from "@/components/ui/password-input";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { UserAvatar, avatarPropsOf } from "@/components/ui/user-avatar";
import { RichEditor } from "@/components/ui/rich-editor";
import {
Dialog,
DialogContent,
@@ -684,7 +696,11 @@ function CurriculumPanel() {
const [lessonModal, setLessonModal] = useState<Lesson | null>(null);
const [chapterModal, setChapterModal] = useState<Chapter | null>(null);
const [dragIdx, setDragIdx] = useState<{ chapter: number; from: number; over?: number } | null>(null);
const [activeChapter, setActiveChapter] = useState<number | null>(null);
const [dragIdx, setDragIdx] = useState<{ chapter: number; from: number } | null>(null);
const toggleChapter = (id: number) =>
setActiveChapter((prev) => (prev === id ? null : id));
const persistChapterOrder = async (list: Chapter[]) => {
for (let i = 0; i < list.length; i++) {
@@ -751,12 +767,34 @@ function CurriculumPanel() {
}
};
const newLesson = (chapterId: number) =>
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) => {
setActiveChapter(chapterId);
setLessonModal({
id: 0, chapterId, title: "", description: "", videoKey: "",
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) => {
try {
@@ -791,6 +829,7 @@ function CurriculumPanel() {
}
try {
await http.del(`/api/admin/chapters/${ch.id}`);
if (activeChapter === ch.id) setActiveChapter(null);
qc.invalidateQueries({ queryKey: ["admin", "lessons"] });
qc.invalidateQueries({ queryKey: ["admin", "stats"] });
toast.success("فصل حذف شد");
@@ -799,61 +838,159 @@ function CurriculumPanel() {
}
};
const publishedChapters = chapters.filter((c) => c.isPublished).length;
const publishedLessons = lessons.filter((l) => l.isPublished).length;
return (
<>
{dialog}
<Card>
<CardHeader className="flex-row items-center justify-between gap-4">
<div>
<CardTitle className="text-base">فصل‌ها و درخت مهارت</CardTitle>
<p className="mt-1 text-xs text-muted-foreground">
{chapters.length} فصل · {lessons.length} درس · ترتیب با کشیدن یا فلش · موقعیت x,y
</p>
{/* Course overview header */}
<div className="mb-4 flex flex-wrap items-start justify-between gap-3 rounded-xl border border-border bg-muted/30 px-4 py-3">
<div className="space-y-1">
<p className="text-sm font-bold">دوره آموزشی</p>
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
<span>{chapters.length} فصل</span>
<span>{lessons.length} درس</span>
<span className="text-success">{publishedChapters} فصل منتشر</span>
<span className="text-success">{publishedLessons} درس منتشر</span>
</div>
<Button className="gap-1.5 rounded-2xl" onClick={() => setChapterModal({ id: 0, title: "", description: "", icon: "terminal", sortOrder: chapters.length + 1 })}>
</div>
<Button
size="sm"
className="shrink-0"
onClick={() => setChapterModal({ id: 0, title: "", description: "", icon: "terminal", sortOrder: chapters.length + 1, isPublished: false })}
>
<Plus className="h-4 w-4" /> فصل جدید
</Button>
</CardHeader>
<CardContent className="space-y-4">
</div>
{/* Chapter list */}
{isLoading && <Loader />}
<div className="space-y-2">
{chapters.map((ch, ci) => {
const ls = lessons.filter((l) => l.chapterId === ch.id);
const ChIcon = chapterIconOf(ch.icon);
const isOpen = activeChapter === ch.id;
return (
<div key={ch.id} className={cn("rounded-xl border border-border bg-muted/20", dragIdx !== null && dragIdx?.chapter === ch.id && "border-primary/50")}>
<div className="flex items-center gap-2 p-3 pb-2">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-primary/10 text-primary">
<div
key={ch.id}
className={cn(
"overflow-hidden rounded-xl border border-border transition-colors",
isOpen && "border-primary/40 shadow-sm",
)}
>
{/* Chapter header row */}
<div className="flex items-center gap-2 px-3 py-2.5">
{/* Expand toggle + identity — takes full remaining width */}
<button
type="button"
aria-expanded={isOpen}
aria-controls={`chapter-${ch.id}-lessons`}
className="flex min-w-0 flex-1 items-center gap-2.5 rounded-md py-0.5 text-start outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => toggleChapter(ch.id)}
>
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-primary/10 text-primary">
<ChIcon className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1">
<h3 className="truncate font-bold">{ch.title}</h3>
{ch.description && <p className="truncate text-xs text-muted-foreground">{ch.description}</p>}
<p className="truncate text-sm font-bold">{ch.title}</p>
{ch.description && (
<p className="truncate text-[11px] text-muted-foreground">{ch.description}</p>
)}
</div>
<Badge variant="outline" className="shrink-0">{ls.length} درس</Badge>
<Badge variant="secondary" className="hidden shrink-0 sm:inline-flex">
{ls.filter((l) => l.videoKey).length}/{ls.length} ویدیو
</Badge>
<GroupNav
onUp={ci > 0 ? () => moveChapter(ci, -1) : undefined}
onDown={ci < chapters.length - 1 ? () => moveChapter(ci, 1) : undefined}
<ChevronDown
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200",
isOpen && "rotate-180",
)}
aria-hidden
/>
<Button size="sm" variant="outline" className="h-7 gap-1 text-xs" onClick={() => setChapterModal(ch)}>
<Pencil className="h-3 w-3" /> فصل
</button>
{/* Status badges */}
<Badge variant="outline" className="shrink-0 px-2 text-[10px]">
{ls.length} درس
</Badge>
<Badge
variant={ch.isPublished ? "success" : "secondary"}
className="hidden shrink-0 px-1.5 text-[10px] sm:inline-flex"
>
{ch.isPublished ? "منتشر" : "پیش‌نویس"}
</Badge>
{/* Chapter actions menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-8 w-8 shrink-0 px-0"
aria-label={`اقدامات فصل ${ch.title}`}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
<Button size="sm" variant="outline" className="h-7 gap-1 text-xs" onClick={() => newLesson(ch.id)}>
<Plus className="h-3.5 w-3.5" /> درس
</Button>
<Button size="sm" variant="ghost" className="h-7 w-7 px-0 text-destructive" onClick={() => delChapter(ch)} title="حذف فصل">
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
{ls.length === 0 ? (
<p className="px-3 pb-3 text-xs text-muted-foreground">هنوز درسی نیست. «+ درس» را بزنید.</p>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel>فصل {ci + 1}</DropdownMenuLabel>
<DropdownMenuItem onClick={() => setChapterModal(ch)}>
<Pencil className="h-3.5 w-3.5" /> ویرایش فصل
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setActiveChapter(ch.id);
newLesson(ch.id);
}}
>
<Plus className="h-3.5 w-3.5" /> درس جدید
</DropdownMenuItem>
<DropdownMenuSeparator />
{ci > 0 && (
<DropdownMenuItem onClick={() => moveChapter(ci, -1)}>
<ChevronUp className="h-3.5 w-3.5" /> جابجا به بالا
</DropdownMenuItem>
)}
{ci < chapters.length - 1 && (
<DropdownMenuItem onClick={() => moveChapter(ci, 1)}>
<ChevronDown className="h-3.5 w-3.5" /> جابجا به پایین
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => publishChapter(ch, !ch.isPublished)}>
{ch.isPublished ? (
<><EyeOff className="h-3.5 w-3.5" /> برگشت به پیش‌نویس</>
) : (
<div className="px-3 pb-3">
<div className="grid gap-1.5 sm:grid-cols-2">
<><Globe className="h-3.5 w-3.5" /> انتشار فصل</>
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => delChapter(ch)}
>
<Trash2 className="h-3.5 w-3.5" /> حذف فصل
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Lessons panel (progressive disclosure) */}
{isOpen && (
<div
id={`chapter-${ch.id}-lessons`}
className="border-t border-border/70"
>
{ls.length === 0 ? (
<div className="px-4 py-5 text-center text-sm text-muted-foreground">
هنوز درسی نیست — از دکمه زیر درس اضافه کنید.
</div>
) : (
<div className="divide-y divide-border/60">
{ls.map((l, li) => {
const cols = ls.filter((o) => o.x === l.x && o.y === l.y && o.id !== l.id);
const collision = ls.some(
(o) => o.x === l.x && o.y === l.y && o.id !== l.id,
);
return (
<div
key={l.id}
@@ -862,71 +999,150 @@ function CurriculumPanel() {
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDragIdx((d) => (d ? { ...d, over: li, chapter: ch.id } : d));
}}
onDrop={() => {
if (dragIdx && dragIdx.chapter === ch.id && dragIdx.from !== li) {
const idx = dragIdx.from;
const next = reorderArray(ls, idx, li);
const next = reorderArray(ls, dragIdx.from, li);
persistLessonOrder(next).catch(() => {});
}
setDragIdx(null);
}}
onDragEnd={() => setDragIdx(null)}
className={cn(
"group flex cursor-grab items-center gap-2 rounded-lg border border-border bg-card px-3 py-2.5 transition-colors active:cursor-grabbing",
"flex items-center gap-2 px-3 py-2.5 transition-colors hover:bg-muted/40",
dragIdx?.from === li && dragIdx.chapter === ch.id && "opacity-40",
!l.videoKey && "border-dashed"
)}
>
<GripVertical className="h-4 w-4 shrink-0 text-muted-foreground/50" />
<span className="shrink-0 text-xs font-bold tabular-nums text-muted-foreground">L{l.id}</span>
{/* Grip handle */}
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground/40 active:cursor-grabbing" aria-hidden />
{/* Lesson identity */}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{l.title}</p>
<div className="flex flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground">
<Timer className="h-3 w-3" /> {formatDur(l.durationSeconds)}
<Sparkles className="h-3 w-3 text-gold" /> +{formatFaNumber(l.xpReward)}
<MapPin className="h-3 w-3" /> {l.x},{l.y}
<p className="truncate text-sm font-medium leading-tight">
{l.title}
</p>
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
<span className="inline-flex items-center gap-0.5">
<Timer className="h-3 w-3" />
{formatDur(l.durationSeconds)}
</span>
<span className="inline-flex items-center gap-0.5 text-gold">
<Sparkles className="h-3 w-3" />
+{formatFaNumber(l.xpReward)}
</span>
{l.videoKey ? (
<span className="inline-flex items-center gap-0.5 text-success"><Video className="h-3 w-3" /> ویدیو</span>
<span className="inline-flex items-center gap-0.5 text-success">
<Video className="h-3 w-3" /> ویدیو
</span>
) : (
<span className="text-warning">بدون ویدیو</span>
)}
{cols.length > 0 && <Badge variant="destructive" className="px-1 text-[9px]">تداخل</Badge>}
</div>
</div>
<Badge variant={l.isActive ? "success" : "secondary"} className="px-1.5 text-[10px]">
{l.isActive ? "فعال" : "پیش‌نویس"}
{collision && (
<Badge variant="destructive" className="px-1 text-[9px]">
تداخل موقعیت
</Badge>
<div className="flex shrink-0 gap-0.5">
<GroupNav onUp={li > 0 ? () => moveLesson(ch.id, li, -1) : undefined} onDown={li < ls.length - 1 ? () => moveLesson(ch.id, li, 1) : undefined} compact />
<Button size="sm" variant="ghost" className="h-7 px-1.5" title="تکثیر" onClick={() => duplicateLesson(l)}>
<Copy className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="ghost" className="h-7 px-1.5" onClick={() => setLessonModal(l)} title="ویرایش">
)}
</div>
</div>
{/* Publish badge */}
<Badge
variant={l.isPublished ? "success" : "secondary"}
className="hidden shrink-0 px-1.5 text-[10px] sm:inline-flex"
>
{l.isPublished ? "منتشر" : "پیش‌نویس"}
</Badge>
{/* Up/down reorder (accessible) */}
<GroupNav
onUp={li > 0 ? () => moveLesson(ch.id, li, -1) : undefined}
onDown={li < ls.length - 1 ? () => moveLesson(ch.id, li, 1) : undefined}
compact
/>
{/* Edit (primary action) */}
<Button
size="sm"
variant="ghost"
className="h-8 w-8 shrink-0 px-0"
title="ویرایش درس"
aria-label={`ویرایش ${l.title}`}
onClick={() => setLessonModal(l)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="ghost" className="h-7 w-7 px-0 text-destructive opacity-60 hover:opacity-100" onClick={() => delLesson(l)} title="حذف درس">
<Trash2 className="h-3.5 w-3.5" />
{/* Secondary actions menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-8 w-8 shrink-0 px-0"
aria-label={`اقدامات ${l.title}`}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuLabel className="truncate">L{l.id}</DropdownMenuLabel>
<DropdownMenuItem onClick={() => duplicateLesson(l)}>
<Copy className="h-3.5 w-3.5" /> تکثیر
</DropdownMenuItem>
<DropdownMenuItem onClick={() => publishLesson(l, !l.isPublished)}>
{l.isPublished ? (
<><EyeOff className="h-3.5 w-3.5" /> برگشت به پیش‌نویس</>
) : (
<><Globe className="h-3.5 w-3.5" /> انتشار درس</>
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => delLesson(l)}
>
<Trash2 className="h-3.5 w-3.5" /> حذف درس
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
})}
</div>
)}
{/* Add lesson footer */}
<div className="border-t border-border/60 px-3 py-2.5">
<Button
size="sm"
variant="outline"
className="gap-1.5"
onClick={() => newLesson(ch.id)}
>
<Plus className="h-3.5 w-3.5" /> درس جدید در این فصل
</Button>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
})}
</div>
{chapters.length === 0 && !isLoading && (
<div className="rounded-xl border border-dashed border-border p-8 text-center">
<div className="rounded-xl border border-dashed border-border p-10 text-center">
<p className="font-bold">هنوز فصلی نیست</p>
<p className="mt-1 text-sm text-muted-foreground">با «فصل جدید» شروع کنید؛ درس‌های هر فصل روی درخت مهارت هنرجو نمایش داده می‌شود.</p>
<p className="mt-1 text-sm text-muted-foreground">
با «فصل جدید» شروع کنید؛ درس‌های هر فصل روی درخت مهارت هنرجو نمایش داده می‌شود.
</p>
<Button
className="mt-4"
onClick={() => setChapterModal({ id: 0, title: "", description: "", icon: "terminal", sortOrder: 1, isPublished: false })}
>
<Plus className="h-4 w-4" /> فصل جدید
</Button>
</div>
)}
</CardContent>
</Card>
<ChapterDialog chapter={chapterModal} onClose={() => setChapterModal(null)} onSave={saveChapter} />
<LessonDialog
@@ -1056,7 +1272,7 @@ function LessonDialog({
onClose: () => 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(() => {
if (lesson) setDraft(lesson);
}, [lesson]);
@@ -1125,12 +1341,10 @@ function LessonDialog({
</div>
<div className="space-y-1.5">
<Label>توضیحات</Label>
<textarea
<RichEditor
value={draft.description}
onChange={(e) => set({ description: e.target.value })}
rows={3}
onChange={(v) => set({ description: v })}
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 className="space-y-1.5">
@@ -1400,11 +1614,11 @@ function EventDialog({ event, onClose, onSave }: { event: AdminEvent | null; onC
</div>
<div className="space-y-1.5">
<Label>توضیحات</Label>
<textarea
<Textarea
value={draft.description}
onChange={(e) => set({ description: e.target.value })}
rows={3}
className="w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
className="resize-y"
placeholder="شرح کوتاه رویداد (اختیاری)…"
/>
</div>
@@ -19,9 +19,9 @@ import {
} from "lucide-react";
import { http, toUserError } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
@@ -158,16 +158,10 @@ export function AnnouncePanel() {
return (
<>
<div className="flex flex-col gap-6">
<div data-announce-compose className="grid items-start gap-4 lg:grid-cols-[minmax(0,1fr)_20rem]">
<Card>
<CardHeader className="space-y-1">
<CardTitle className="text-base">نگارش اعلان</CardTitle>
<p className="text-xs text-muted-foreground">
به همه هنرجویان فعال می‌رسد و در مرکز اعلان‌ها دیده می‌شود.
</p>
</CardHeader>
<CardContent className="space-y-5">
{/* Composer + Live Preview */}
<div className="grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_18rem]">
{/* Form */}
<div className="space-y-5">
<div className="space-y-2">
<Label className="text-sm font-bold">عنوان</Label>
<Input
@@ -184,15 +178,12 @@ export function AnnouncePanel() {
<Label className="text-sm font-bold">پیام</Label>
<span className="text-[11px] tabular-nums text-muted-foreground">{body.length}/۴۰۰۰</span>
</div>
<textarea
<Textarea
value={body}
onChange={(e) => setBody(e.target.value.slice(0, 4000))}
rows={5}
maxLength={4000}
className={cn(
"w-full resize-y rounded-xl border bg-background px-4 py-3 text-sm leading-relaxed focus:outline-none focus:ring-2 focus:ring-primary",
!bodyOk && body.length > 0 ? "border-destructive/50" : "border-input"
)}
className={cn("resize-y", !bodyOk && body.length > 0 ? "border-destructive/50" : "")}
placeholder="یک جملهٔ واضح و کوتاه — لینک یا زمان را حتماً بگویید…"
/>
{body.trim().length > 0 && body.trim().length < 8 && (
@@ -236,9 +227,9 @@ export function AnnouncePanel() {
<div className="space-y-2">
<Label className="flex h-5 items-center gap-1.5 text-sm font-bold">
<Route className="h-3.5 w-3.5" /> با زدن اعلان این صفحه باز می‌شود
<Route className="h-3.5 w-3.5" /> صفحه‌ای که با کلیک باز می‌شود
</Label>
<p className="text-[11px] text-muted-foreground">خالی بگذارید تا اعلان فقط خوانده شود و صفحه‌ای باز نکند.</p>
<p className="text-[11px] text-muted-foreground">خالی بگذارید تا اعلان فقط خوانده شود.</p>
<div className="flex flex-wrap gap-2">
{ROUTES.map((r) => (
<button
@@ -277,68 +268,65 @@ export function AnnouncePanel() {
onClick={() => setConfirmOpen(true)}
>
<Eye className="h-4 w-4" />
پیش‌نمایش و ارسال
{editingId ? "پیش‌نمایش و ذخیره" : "پیش‌نمایش و ارسال"}
</Button>
{editingId && (
<Button
size="lg"
variant="outline"
onClick={() => { setEditingId(null); setTitle(""); setBody(""); setPinned(false); }}
>
انصراف از ویرایش
</Button>
)}
{!valid && (
<p className="text-xs text-muted-foreground">عنوان و پیام را کامل کنید تا ادامه فعال شود.</p>
)}
</div>
</CardContent>
</Card>
</div>
<div className="space-y-4">
<Card className="border bg-muted/20">
<CardHeader className="space-y-1 pb-3">
<CardTitle className="flex h-6 items-center gap-2 text-sm">
{/* Sidebar: live preview + last sent */}
<div className="space-y-4 lg:sticky lg:top-24">
<div className="space-y-1.5">
<p className="flex items-center gap-1.5 text-xs font-bold text-muted-foreground">
<Eye className="h-3.5 w-3.5" /> پیش‌نمایش زنده
</CardTitle>
<p className="text-[11px] text-muted-foreground">هم‌زمان با تایپ به‌روز می‌شود.</p>
</CardHeader>
<CardContent>
</p>
<PreviewCard title={title} body={body} category={category} route={route} />
</CardContent>
</Card>
</div>
{lastSent && (
<Card className="border border-success/40 bg-success/5">
<CardContent className="space-y-1 pt-5 text-sm">
<div className="rounded-xl border border-success/40 bg-success/5 px-4 py-3 text-sm">
<p className="font-bold text-success">آخرین ارسال موفق</p>
<p className="truncate font-extrabold">{lastSent.title}</p>
<p className="mt-0.5 truncate font-extrabold">{lastSent.title}</p>
<p className="text-xs text-muted-foreground">{lastSent.recipients} گیرنده</p>
</CardContent>
</Card>
</div>
)}
</div>
</div>
<Card data-announce-list className="rounded-2xl border border-border">
<CardHeader>
<CardTitle className="text-base">اطلاعیه‌های ثبت‌شده</CardTitle>
</CardHeader>
<CardContent>
{/* History */}
<div className="mt-8 space-y-3 border-t border-border/70 pt-6">
<h3 className="text-sm font-black text-muted-foreground">اطلاعیه‌های ثبت‌شده</h3>
{(listQ.data?.announcements ?? []).length > 0 ? (
<ul className="flex list-none flex-col gap-3 p-0">
<ul className="flex list-none flex-col gap-2.5 p-0">
{(listQ.data?.announcements ?? []).map((a) => {
const cat = CATEGORIES.find((c) => c.id === a.category);
const excerpt = announcementExcerpt(a.body);
return (
<li key={a.id} className="rounded-2xl border border-border bg-background p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<li key={a.id} className="flex flex-col gap-3 rounded-2xl border border-border bg-background p-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<p className="min-w-0 text-sm font-bold leading-snug">{a.title}</p>
<Badge variant="secondary" className="text-[10px]">
{cat?.label ?? a.category}
</Badge>
{a.pinned ? (
<Badge variant="gold" className="text-[10px]">
سنجاق
</Badge>
) : null}
{a.pinned && (
<Badge variant="gold" className="text-[10px]">سنجاق</Badge>
)}
</div>
{excerpt ? (
{excerpt && (
<p className="line-clamp-2 text-xs leading-relaxed text-muted-foreground">{excerpt}</p>
) : null}
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
@@ -352,6 +340,7 @@ export function AnnouncePanel() {
setCategory(a.category);
setRoute(a.route);
setPinned(a.pinned);
window.scrollTo({ top: 0, behavior: "smooth" });
}}
>
ویرایش
@@ -371,7 +360,6 @@ export function AnnouncePanel() {
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</li>
);
})}
@@ -381,8 +369,6 @@ export function AnnouncePanel() {
هنوز اطلاعیه‌ای ثبت نشده است.
</p>
) : null}
</CardContent>
</Card>
</div>
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
@@ -5,9 +5,9 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Trash2, Trophy } from "lucide-react";
import { http, toUserError } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Skeleton } from "@/components/ui/skeleton";
@@ -86,20 +86,7 @@ export function ChallengesPanel() {
minute: "2-digit",
}).format(new Date(iso));
return (
<>
{dialog}
<Card>
<CardHeader className="flex-row items-center justify-between gap-4">
<div>
<CardTitle className="text-base">چالش‌های امتیاز</CardTitle>
<p className="mt-1 text-xs text-muted-foreground">
چالش ماهانه با هدف امتیاز؛ پیشرفت هنرجو با تکمیل آزمون‌ها به‌روز می‌شود.
</p>
</div>
<Button
className="gap-1.5"
onClick={() =>
const newChallenge = () =>
setModal({
id: 0,
title: "",
@@ -109,13 +96,21 @@ export function ChallengesPanel() {
endsAt: "",
isActive: true,
createdAt: "",
})
}
>
});
return (
<>
{dialog}
<div className="space-y-4">
<div className="flex items-center justify-between gap-4">
<p className="text-xs text-muted-foreground">
پیشرفت هنرجو با تکمیل آزمون‌ها به‌روز می‌شود.
</p>
<Button className="shrink-0 gap-1.5" onClick={newChallenge}>
<Plus className="h-4 w-4" /> چالش جدید
</Button>
</CardHeader>
<CardContent>
</div>
{isLoading && (
<div className="space-y-3">
{[0, 1].map((i) => (
@@ -124,8 +119,8 @@ export function ChallengesPanel() {
</div>
)}
{isError && (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm">
<p className="font-medium text-destructive">{toUserError(error)}</p>
<div className="rounded-md border-2 border-destructive/30 bg-destructive/5 p-4 text-sm">
<p className="font-bold text-destructive">{toUserError(error)}</p>
<Button size="sm" variant="outline" className="mt-2" onClick={() => refetch()}>
تلاش دوباره
</Button>
@@ -133,7 +128,7 @@ export function ChallengesPanel() {
)}
<div className="grid gap-3 sm:grid-cols-2">
{(data?.challenges ?? []).map((c) => (
<div key={c.id} className="rounded-md border border-border p-4">
<div key={c.id} className="rounded-xl border border-border p-4">
<div className="flex items-center gap-2">
<Trophy className="h-4 w-4 text-gold" />
<Badge variant={c.isActive ? "success" : "secondary"}>{c.isActive ? "فعال" : "خاموش"}</Badge>
@@ -147,7 +142,7 @@ export function ChallengesPanel() {
</Button>
</div>
</div>
<p className="mt-2 font-medium">{c.title}</p>
<p className="mt-2 font-bold">{c.title}</p>
<p className="line-clamp-2 text-xs text-muted-foreground">{c.description || "—"}</p>
<p className="mt-1.5 text-xs text-muted-foreground">
{fmt(c.startsAt)} تا {fmt(c.endsAt)}
@@ -156,11 +151,12 @@ export function ChallengesPanel() {
))}
</div>
{!isLoading && !isError && (data?.challenges ?? []).length === 0 && (
<p className="py-8 text-center text-sm text-muted-foreground">هنوز چالشی نیست.</p>
<p className="rounded-2xl border border-dashed border-border px-4 py-6 text-center text-sm text-muted-foreground">
هنوز چالشی نیست.
</p>
)}
</CardContent>
</div>
<ChallengeDialog challenge={modal} onClose={() => setModal(null)} onSave={save} />
</Card>
</>
);
}
@@ -199,11 +195,11 @@ function ChallengeDialog({
</div>
<div className="space-y-1.5">
<Label>توضیحات</Label>
<textarea
<Textarea
value={draft.description}
onChange={(e) => set({ description: e.target.value })}
rows={3}
className="w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
className="resize-y"
placeholder="هدف و انگیزهٔ چالش…"
/>
</div>
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
Award,
Flame,
Heart,
Loader2,
@@ -64,6 +65,7 @@ export function LearningPanel() {
const [sort, setSort] = useState<SortKey>("xp");
const [selectedId, setSelectedId] = useState<number | null>(null);
const [unlocking, setUnlocking] = useState(false);
const [issuingCertFor, setIssuingCertFor] = useState<number | null>(null);
const [page, setPage] = useState(1);
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 students = studentsQ.data?.items ?? [];
const studentsTotal = studentsQ.data?.total ?? 0;
@@ -133,7 +148,7 @@ export function LearningPanel() {
return (
<div className="space-y-4">
{(overviewQ.isError || studentsQ.isError) && (
<div className="flex flex-wrap items-center gap-3 rounded-2xl border border-destructive/40 bg-destructive/5 px-4 py-3 text-sm">
<div className="flex flex-wrap items-center gap-3 rounded-2xl border-2 border-destructive/40 bg-destructive/5 px-4 py-3 text-sm">
<AlertTriangle className="h-4 w-4 shrink-0 text-destructive" />
<p className="min-w-0 flex-1 font-bold text-destructive">
{toUserError(overviewQ.error ?? studentsQ.error, "بارگذاری داشبورد یادگیری ممکن نشد")}
@@ -369,6 +384,29 @@ export function LearningPanel() {
))}
</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">
<h3 className="text-sm font-black">دروس</h3>
<div className="max-h-64 space-y-1.5 overflow-y-auto">
@@ -6,7 +6,6 @@ import { Loader2 } from "lucide-react";
import { http, toUserError } from "@/lib/api";
import type { CertificatePhysicalOrder, PhysicalCertSettings, PhysicalOrderStatus } from "@/lib/types";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { toast } from "@/components/providers";
@@ -75,16 +74,16 @@ export function PhysicalOrdersPanel() {
};
return (
<Card>
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-3 space-y-0">
<CardTitle className="text-base">فهرست سفارش‌ها</CardTitle>
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">پیگیری درخواست و ارسال گواهینامه فیزیکی.</p>
<div className="flex flex-wrap items-center gap-2">
<select
className="h-10 rounded-lg border border-input bg-card px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value="">همه</option>
<option value="">همه وضعیت‌ها</option>
<option value="requested">درخواست‌شده</option>
<option value="paid">پرداخت‌شده</option>
<option value="shipped">ارسال‌شده</option>
@@ -97,15 +96,15 @@ export function PhysicalOrdersPanel() {
</span>
)}
</div>
</CardHeader>
<CardContent className="space-y-2">
</div>
<div className="space-y-2">
{isLoading && (
<div className="flex justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)}
{(data?.orders ?? []).map((o) => (
<div key={o.id} className="space-y-2 rounded-md border border-border px-3 py-3">
<div key={o.id} className="space-y-2 rounded-xl border border-border px-3 py-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<p className="text-sm font-medium">{o.userName ?? `کاربر ${o.userId}`}</p>
@@ -167,7 +166,9 @@ export function PhysicalOrdersPanel() {
</div>
))}
{!isLoading && (data?.orders ?? []).length === 0 && (
<p className="py-8 text-center text-sm text-muted-foreground">سفارشی نیست.</p>
<p className="rounded-2xl border border-dashed border-border px-4 py-6 text-center text-sm text-muted-foreground">
سفارشی نیست.
</p>
)}
<ListPagination
page={page}
@@ -175,7 +176,7 @@ export function PhysicalOrdersPanel() {
total={data?.total ?? 0}
onPageChange={setPage}
/>
</CardContent>
</Card>
</div>
</div>
);
}
@@ -238,7 +238,7 @@ function AccountFact({
{icon}
{label}
</dt>
<dd className={cn("mt-1.5 text-sm font-semibold leading-relaxed [overflow-wrap:anywhere]", ltr && "font-latin")} dir={ltr ? "ltr" : undefined}>
<dd className={cn("mt-1.5 text-sm font-bold leading-relaxed [overflow-wrap:anywhere]", ltr && "font-latin")} dir={ltr ? "ltr" : undefined}>
{value}
</dd>
</div>
@@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import {
Dialog,
@@ -338,15 +339,15 @@ function QuestionDialog({
<div className="space-y-5">
<div className="space-y-2">
<Label className="text-sm font-bold">متن سوال</Label>
<textarea
<Textarea
value={draft.question}
onChange={(e) => set({ question: e.target.value })}
rows={3}
autoFocus
placeholder="مثلاً نتیجهٔ اجرای git status چیست؟"
className={cn(
"w-full resize-y rounded-xl border bg-background px-4 py-3 text-base font-medium leading-relaxed focus:outline-none focus:ring-2 focus:ring-primary",
questionOk ? "border-input" : "border-destructive/40"
"resize-y",
questionOk ? "" : "border-destructive/40"
)}
/>
{!questionOk && (
@@ -485,7 +486,7 @@ function QuestionDialog({
>
<span
className={cn(
"flex h-7 min-w-7 shrink-0 items-center justify-center rounded-md border px-1.5 text-[11px] font-semibold",
"flex h-7 min-w-7 shrink-0 items-center justify-center rounded-md border px-1.5 text-[11px] font-bold",
isAns
? "border-primary bg-primary text-primary-foreground"
: "border-border text-muted-foreground"
@@ -1,21 +1,139 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { Heart, Loader2, Package, Save, Settings2, Share2, Sparkles } from "lucide-react";
import { http, toUserError } from "@/lib/api";
import { useEffect, useRef, useState } from "react";
import { Camera, Heart, Loader2, Package, Save, Settings2, Share2, Sparkles, Trash2, Users } from "lucide-react";
import { apiForm, http, toUserError } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { TagInput } from "@/components/ui/tag-input";
import { AvatarCropDialog } from "@/components/avatar-crop";
import { toast } from "@/components/providers";
import type { AdminTabId } from "@/components/admin/nav";
import { cn } from "@/lib/utils";
type SettingsMap = Record<string, string>;
type SponsorDraft = { name: string; url: string; blurb: string };
type SocialDraft = { name: string; url: string };
type ContributorDraft = { name: string; role: string; bio: string; url: string; avatar: string; contributions: string[] };
function ContributorAvatarPicker({
value,
name,
onChange,
}: {
value: string;
name: string;
onChange: (url: string) => void;
}) {
const fileRef = useRef<HTMLInputElement>(null);
const [pending, setPending] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const initials = name
.split(" ")
.map((w) => w[0])
.slice(0, 2)
.join("")
.toUpperCase() || "؟";
const onUpload = async (blob: Blob) => {
setUploading(true);
setError(null);
try {
const form = new FormData();
form.append("file", new File([blob], "avatar.png", { type: "image/png" }), "avatar.png");
const { url } = await apiForm<{ url: string }>("/api/admin/contributor-avatar", form);
onChange(url);
setPending(null);
} catch (e) {
setError(toUserError(e, "خطا در آپلود"));
} finally {
setUploading(false);
}
};
return (
<div className="flex items-center gap-3">
<div className="relative shrink-0">
{value ? (
<img
src={value}
alt={name}
className="h-14 w-14 rounded-full border-2 border-border object-cover"
/>
) : (
<span className="flex h-14 w-14 items-center justify-center rounded-full border-2 border-dashed border-border bg-muted text-sm font-bold text-muted-foreground">
{initials}
</span>
)}
{uploading && (
<span className="absolute inset-0 flex items-center justify-center rounded-full bg-background/70">
<Loader2 className="h-5 w-5 animate-spin text-primary" />
</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<div className="flex gap-1.5">
<button
type="button"
disabled={uploading}
onClick={() => fileRef.current?.click()}
className={cn(
"flex items-center gap-1 rounded-md bg-primary px-2.5 py-1 text-xs font-bold text-primary-foreground disabled:opacity-50",
)}
>
<Camera className="h-3 w-3" />
{value ? "تغییر" : "آپلود"}
</button>
{value && (
<button
type="button"
disabled={uploading}
onClick={() => onChange("")}
className="flex items-center gap-1 rounded-md bg-destructive/10 px-2.5 py-1 text-xs font-bold text-destructive disabled:opacity-50"
>
<Trash2 className="h-3 w-3" />
حذف
</button>
)}
</div>
{error && <p className="text-[11px] text-destructive">{error}</p>}
<input
ref={fileRef}
type="file"
accept="image/png,image/jpeg,image/webp"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) setPending(f);
e.target.value = "";
}}
/>
</div>
<AvatarCropDialog
file={pending}
busy={uploading}
error={error}
onCancel={() => {
if (uploading) return;
setPending(null);
setError(null);
}}
onInvalid={() => {
setPending(null);
setError("خواندن این عکس ممکن نشد. JPEG، PNG یا WebP را انتخاب کنید.");
}}
onCrop={(blob) => onUpload(blob)}
/>
</div>
);
}
function SectionHeading({
icon: Icon,
@@ -46,6 +164,7 @@ export function SiteSettingsPanel({
const [draft, setDraft] = useState<SettingsMap>({});
const [sponsors, setSponsors] = useState<SponsorDraft[]>([]);
const [socials, setSocials] = useState<SocialDraft[]>([]);
const [contributors, setContributors] = useState<ContributorDraft[]>([]);
const [busy, setBusy] = useState(false);
const { data, isLoading } = useQuery({
@@ -76,6 +195,25 @@ export function SiteSettingsPanel({
} catch {
setSocials([]);
}
try {
const parsed = JSON.parse(data.settings.contributors || "[]") as Array<Record<string, unknown>>;
setContributors(
Array.isArray(parsed)
? parsed.map((c) => ({
name: typeof c.name === "string" ? c.name : "",
role: typeof c.role === "string" ? c.role : "",
bio: typeof c.bio === "string" ? c.bio : "",
url: typeof c.url === "string" ? c.url : "",
avatar: typeof c.avatar === "string" ? c.avatar : "",
contributions: Array.isArray(c.contributions)
? (c.contributions as unknown[]).filter((x): x is string => typeof x === "string")
: [],
}))
: [],
);
} catch {
setContributors([]);
}
}, [data]);
const set = (key: string, value: string) => setDraft((d) => ({ ...d, [key]: value }));
@@ -126,11 +264,24 @@ export function SiteSettingsPanel({
blurb: s.blurb.trim(),
})),
),
contributors: JSON.stringify(
contributors
.filter((c) => c.name.trim())
.map((c) => ({
name: c.name.trim(),
role: c.role.trim(),
bio: c.bio.trim(),
url: c.url.trim(),
avatar: c.avatar.trim(),
contributions: c.contributions.map((s) => s.trim()).filter(Boolean),
})),
),
};
await http.put("/api/admin/settings", { settings });
toast.success("تنظیمات ذخیره شد");
qc.invalidateQueries({ queryKey: ["admin", "settings"] });
qc.invalidateQueries({ queryKey: ["community"] });
qc.invalidateQueries({ queryKey: ["contributors"] });
qc.invalidateQueries({ queryKey: ["sponsors"] });
qc.invalidateQueries({ queryKey: ["auth", "register-status"] });
} catch (e) {
@@ -327,6 +478,97 @@ export function SiteSettingsPanel({
))}
</section>
<section className="space-y-3">
<div className="flex items-end justify-between gap-3">
<SectionHeading
icon={Users}
title="مشارکت‌کنندگان"
hint="افرادی که در ساخت پرگار سهیم بوده‌اند. در زبانه جامعه نمایش داده می‌شود."
/>
<Button
type="button"
size="sm"
variant="outline"
className="mb-2 shrink-0"
onClick={() =>
setContributors((list) => [
...list,
{ name: "", role: "", bio: "", url: "", avatar: "", contributions: [] },
])
}
>
افزودن
</Button>
</div>
{contributors.length === 0 && (
<p className="rounded-xl border border-dashed border-border px-4 py-8 text-center text-sm text-muted-foreground">
هنوز مشارکت‌کننده‌ای ثبت نشده. با «افزودن» شروع کنید.
</p>
)}
{contributors.map((c, i) => (
<div key={i} className="space-y-3 rounded-xl border border-border p-3">
<ContributorAvatarPicker
value={c.avatar}
name={c.name || "مشارکت‌کننده"}
onChange={(url) =>
setContributors((list) => list.map((x, j) => (j === i ? { ...x, avatar: url } : x)))
}
/>
<div className="grid gap-2 sm:grid-cols-2">
<Input
placeholder="نام"
value={c.name}
onChange={(e) =>
setContributors((list) => list.map((x, j) => (j === i ? { ...x, name: e.target.value } : x)))
}
/>
<Input
placeholder="نقش (مثال: بنیان‌گذار)"
value={c.role}
onChange={(e) =>
setContributors((list) => list.map((x, j) => (j === i ? { ...x, role: e.target.value } : x)))
}
/>
</div>
<Textarea
placeholder="بیوگرافی کوتاه (اختیاری)"
value={c.bio}
rows={2}
onChange={(e) =>
setContributors((list) => list.map((x, j) => (j === i ? { ...x, bio: e.target.value } : x)))
}
/>
<Input
placeholder="لینک پروفایل (اختیاری، https://…)"
value={c.url}
dir="ltr"
onChange={(e) =>
setContributors((list) => list.map((x, j) => (j === i ? { ...x, url: e.target.value } : x)))
}
/>
<div className="space-y-1">
<p className="text-[11px] text-muted-foreground">دسته‌های مشارکت — Enter یا کاما برای افزودن</p>
<TagInput
value={c.contributions}
onChange={(tags) =>
setContributors((list) => list.map((x, j) => (j === i ? { ...x, contributions: tags } : x)))
}
placeholder="توسعه، طراحی، محتوا…"
/>
</div>
<Button
type="button"
size="sm"
variant="ghost"
className="text-destructive"
onClick={() => setContributors((list) => list.filter((_, j) => j !== i))}
>
حذف مشارکت‌کننده
</Button>
</div>
))}
</section>
<p className="text-[11px] text-muted-foreground">
رمزها، دیتابیس و JWT همچنان در فایل محیط (.env) می‌مانند — اینجا فقط تنظیمات قابل‌تغییر
روزمره است.
+41 -50
View File
@@ -5,6 +5,8 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
Check,
ChevronLeft,
ChevronRight,
Eye,
Film,
Link2,
@@ -32,7 +34,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { cn, formatFaNumber } from "@/lib/utils";
import { toast } from "@/components/providers";
import { Skeleton } from "@/components/ui/skeleton";
@@ -59,59 +61,19 @@ function VideoThumb({
className?: string;
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 (
<button
type="button"
onClick={onOpen}
className={cn(
"group relative aspect-video w-full overflow-hidden rounded-xl border border-border bg-muted/40",
className
className,
)}
title="پیش‌نمایش"
>
{state === "loading" && (
<span className="absolute inset-0 grid place-items-center text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
<span className="absolute inset-0 grid place-items-center text-muted-foreground/30">
<Film className="h-10 w-10" />
</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="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" />
@@ -169,7 +131,7 @@ function VideoPreviewDialog({
</div>
)}
{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>
<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() {
const qc = useQueryClient();
const { confirm, dialog } = useConfirm();
const fileRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [q, setQ] = useState("");
const [page, setPage] = useState(1);
const [attachKey, setAttachKey] = useState<string | null>(null);
const [previewKey, setPreviewKey] = useState<string | null>(null);
@@ -230,10 +195,13 @@ export function VideosPanel() {
const filtered = useMemo(() => {
const term = q.trim().toLowerCase();
if (!term) return videos;
return videos.filter((v) => v.key.toLowerCase().includes(term));
return term ? videos.filter((v) => v.key.toLowerCase().includes(term)) : videos;
}, [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) => {
if (!file) return;
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" />}
</CardTitle>
<p className="mt-1 text-xs text-muted-foreground">
{videos.length} فایل · روی تصویر بزنید تا پخش شود
{videos.length} فایل · برای پخش روی دکمه کلیک کنید
</p>
</div>
<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" />
<Input
value={q}
onChange={(e) => setQ(e.target.value)}
onChange={(e) => { setQ(e.target.value); setPage(1); }}
placeholder="جستجوی نام فایل…"
className="h-10 ps-9"
/>
@@ -355,7 +323,7 @@ export function VideosPanel() {
</div>
)}
<div className="grid gap-3 sm:grid-cols-2">
{filtered.map((v) => {
{paginated.map((v) => {
const used = usedByKey.get(v.key) ?? [];
return (
<div
@@ -431,6 +399,29 @@ export function VideosPanel() {
{q ? "نتیجه‌ای نیست" : "هنوز ویدیویی آپلود نشده — یک MP4 انتخاب کنید"}
</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>
</Card>
</div>
+11 -2
View File
@@ -10,6 +10,7 @@ import { useAuth, useAuthHydrated } from "@/lib/auth-store";
import { http } from "@/lib/api";
import type { User } from "@/lib/types";
import { hasInfiniteHearts } from "@/lib/hearts";
import { BottomNav } from "@/components/bottom-nav";
function SiteFooter() {
return (
@@ -147,13 +148,21 @@ export function AppShell({ children }: { children: React.ReactNode }) {
</button>
</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}
</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 />
</footer>
{user && <ChatWidget />}
{user && !pathname.startsWith("/admin") && <BottomNav />}
</div>
);
}
+47
View File
@@ -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>
);
}
+99 -4
View File
@@ -1,17 +1,50 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { ExternalLink, Share2 } from "lucide-react";
import { ExternalLink, Share2, Tag, Users } from "lucide-react";
import { http, toUserError } from "@/lib/api";
import { Panel } from "@/components/panel";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import type { Contributor } from "@/lib/types";
export type SocialLink = { name: string; url: string };
export type CommunityPayload = {
socials: SocialLink[];
contributors: Contributor[];
};
function ContributorAvatar({ contributor }: { contributor: Contributor }) {
if (contributor.avatar) {
return (
<img
src={contributor.avatar}
alt={contributor.name}
className="h-12 w-12 shrink-0 rounded-full border-2 border-border object-cover"
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = "none";
(e.currentTarget.nextElementSibling as HTMLElement | null)?.removeAttribute("hidden");
}}
/>
);
}
const initials = contributor.name
.split(" ")
.map((w) => w[0])
.slice(0, 2)
.join("")
.toUpperCase();
return (
<span
aria-hidden
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full border-2 border-border bg-primary/10 text-sm font-bold text-primary"
>
{initials}
</span>
);
}
export function CommunityPanel() {
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: ["community"],
@@ -19,12 +52,17 @@ export function CommunityPanel() {
});
if (isLoading) {
return <div className="h-72 animate-pulse rounded-2xl border-2 border-border bg-muted/40" />;
return (
<div className="mx-auto max-w-2xl space-y-4">
<div className="h-40 animate-pulse rounded-2xl border-2 border-border bg-muted/40" />
<div className="h-64 animate-pulse rounded-2xl border-2 border-border bg-muted/40" />
</div>
);
}
if (isError) {
return (
<div className="mx-auto max-w-xl rounded-2xl border-2 border-destructive/30 bg-destructive/5 p-8 text-center">
<div className="mx-auto max-w-2xl rounded-2xl border-2 border-destructive/30 bg-destructive/5 p-8 text-center">
<p className="font-medium text-destructive">{toUserError(error)}</p>
<Button className="mt-4" variant="outline" onClick={() => refetch()}>
تلاش دوباره
@@ -34,9 +72,11 @@ export function CommunityPanel() {
}
const socials = data?.socials ?? [];
const contributors = data?.contributors ?? [];
return (
<div className="mx-auto max-w-xl">
<div className="mx-auto max-w-2xl space-y-4">
{/* Social links */}
<Panel
title="شبکه‌های پرگار"
description="راه‌های رسمی ارتباط با برنامه. لینک شخصی هر کاربر روی پروفایل خودش است."
@@ -63,6 +103,61 @@ export function CommunityPanel() {
</ul>
)}
</Panel>
{/* Contributors */}
<Panel
title="مشارکت‌کنندگان"
description="افرادی که در ساخت پرگار سهیم بوده‌اند. ممنون از هر کمکی."
icon={<Users className="h-5 w-5" />}
tint="bg-emerald-500/15 text-emerald-600 dark:text-emerald-400"
>
{contributors.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">اطلاعات مشارکت‌کنندگان هنوز ثبت نشده است.</p>
) : (
<ul className="divide-y divide-border/60">
{contributors.map((c, i) => (
<li key={i} className="flex flex-col gap-3 py-4 first:pt-0 last:pb-0 sm:flex-row sm:items-start">
<ContributorAvatar contributor={c} />
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex flex-wrap items-center gap-2">
{c.url ? (
<a
href={c.url}
target="_blank"
rel="noreferrer"
className="flex items-center gap-1 font-bold hover:text-primary hover:underline"
>
{c.name}
<ExternalLink className="h-3 w-3 shrink-0 text-muted-foreground" />
</a>
) : (
<span className="font-bold">{c.name}</span>
)}
{c.role && (
<span className="text-xs text-muted-foreground">{c.role}</span>
)}
</div>
{c.bio && <p className="text-sm leading-relaxed text-muted-foreground">{c.bio}</p>}
{c.contributions.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{c.contributions.map((cat) => (
<Badge
key={cat}
variant="secondary"
className="gap-1 px-2 py-0.5 text-xs"
>
<Tag className="h-3 w-3" />
{cat}
</Badge>
))}
</div>
)}
</div>
</li>
))}
</ul>
)}
</Panel>
</div>
);
}
+1 -1
View File
@@ -27,7 +27,7 @@ export function EmptyState({
</span>
)}
<div className="space-y-1 px-6">
<p className="text-sm font-semibold">{title}</p>
<p className="text-sm font-bold">{title}</p>
{description && <p className="text-xs leading-relaxed text-muted-foreground sm:text-sm">{description}</p>}
</div>
{action && <div className="mt-1">{action}</div>}
-65
View File
@@ -1,65 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Search } from "lucide-react";
import { http } from "@/lib/api";
type Hit = { kind: string; title: string; href: string };
export function GlobalSearch() {
const router = useRouter();
const [q, setQ] = useState("");
const [hits, setHits] = useState<Hit[]>([]);
const [open, setOpen] = useState(false);
useEffect(() => {
const query = q.trim();
if (query.length < 2) {
setHits([]);
return;
}
const t = setTimeout(() => {
void http
.get<{ results: Hit[] }>(`/api/search?q=${encodeURIComponent(query)}`)
.then((res) => setHits(res.results ?? []))
.catch(() => setHits([]));
}, 250);
return () => clearTimeout(t);
}, [q]);
return (
<div className="relative min-w-0 w-28 sm:w-56">
<Search className="pointer-events-none absolute start-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<input
value={q}
onChange={(e) => {
setQ(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
placeholder="جستجو"
className="h-9 w-full rounded-full border border-border bg-background/70 ps-8 pe-3 text-sm outline-none focus:border-primary"
/>
{open && hits.length > 0 && (
<div className="absolute z-50 mt-1 w-full overflow-hidden rounded-xl border bg-popover shadow-lg">
{hits.map((h) => (
<button
key={h.kind + h.href + h.title}
type="button"
className="block w-full px-3 py-2 text-start text-sm hover:bg-muted"
onClick={() => {
setOpen(false);
setQ("");
router.push(h.href);
}}
>
<span className="text-[10px] text-muted-foreground">{h.kind}</span>
<span className="block truncate font-bold">{h.title}</span>
</button>
))}
</div>
)}
</div>
);
}
+27 -3
View File
@@ -3,10 +3,10 @@
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
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 { 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 { Panel } from "@/components/panel";
import { Skeleton } from "@/components/ui/skeleton";
@@ -21,6 +21,28 @@ const medalClasses = {
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() {
const user = useAuth((s) => s.user);
const { data, isLoading, isError, error, refetch } = useQuery({
@@ -46,7 +68,7 @@ export function LeaderboardPanel() {
if (isError) {
return (
<div className="mx-auto max-w-2xl rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<div className="mx-auto max-w-2xl rounded-xl border-2 border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="font-medium text-destructive">{toUserError(error)}</p>
<Button className="mt-3" variant="outline" onClick={() => refetch()}>
تلاش دوباره
@@ -89,6 +111,7 @@ export function LeaderboardPanel() {
رتبه {fa(me.rank)} شمایید
</span>
)}
{me && me.tier && <TierBadge tier={me.tier} />}
</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">
@@ -134,6 +157,7 @@ export function LeaderboardPanel() {
شما
</span>
)}
{e.tier && <TierBadge tier={e.tier} />}
<span className="shrink-0 text-sm font-bold text-gold">
{formatFaNumber(Math.round(e.xp))} امتیاز
</span>
+6 -4
View File
@@ -1944,7 +1944,7 @@ ws.onmessage = (ev) => {
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 24, scale: 0.96 }}
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 -end-16 -top-16 h-40 w-40 rounded-full bg-accent/25 blur-3xl" />
@@ -3010,7 +3010,7 @@ ws.onmessage = (ev) => {
</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 && (
<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]">
@@ -3269,7 +3269,7 @@ ws.onmessage = (ev) => {
setOpen(true);
setNotif(null);
}}
className="fixed bottom-24 start-5 end-5 z-[64] flex max-w-full items-center gap-2.5 rounded-2xl border-2 border-white/50 bg-card/85 p-2.5 text-right shadow-offset backdrop-blur-2xl sm:end-auto sm:max-w-[300px]"
className="fixed bottom-[calc(5.5rem+env(safe-area-inset-bottom,0px))] start-5 end-5 z-[64] flex max-w-full items-center gap-2.5 rounded-2xl border-2 border-white/50 bg-card/85 p-2.5 text-right shadow-offset backdrop-blur-2xl sm:bottom-24 sm:end-auto sm:max-w-[300px]"
>
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-accent/15">
<MessageSquare className="h-4 w-4 text-accent" />
@@ -3286,7 +3286,9 @@ ws.onmessage = (ev) => {
</AnimatePresence>
{me && !open && (
<div className="fixed bottom-5 start-5 z-[64]">
// On mobile the bottom nav is 56px; lift FAB above it + safe area + gap.
// md:bottom-5 restores original position when the bottom nav is hidden.
<div className="fixed bottom-[calc(4.5rem+env(safe-area-inset-bottom,0px))] start-5 z-[64] md:bottom-5">
<div className="relative">
{unreadTotal > 0 && (
<span className="pointer-events-none absolute -inset-1 -z-10 animate-ping rounded-2xl bg-accent/40" aria-hidden />
+3 -5
View File
@@ -2,7 +2,7 @@
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { Flame, Heart, LogOut, Sparkles, User, UserCog } from "lucide-react";
import { Flame, Heart, LogOut, Palette, Settings, Sparkles, User, UserCog } from "lucide-react";
import { NotificationBell } from "@/components/notification-bell";
import { ThemeToggle } from "@/components/theme-toggle";
import { BrandMark } from "@/components/brand-mark";
@@ -28,7 +28,6 @@ import {
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { cn, formatFaNumber } from "@/lib/utils";
import { GlobalSearch } from "@/components/global-search";
import { isVerifiedRole, VerifiedBadge } from "@/components/verified-badge";
const navLinks = [
@@ -65,7 +64,6 @@ export function Navbar() {
<BrandMark />
</Link>
<div className="flex items-center gap-2">
<GlobalSearch />
<ThemeToggle />
{pathname !== "/login" && (
<Button asChild size="sm" className="font-bold">
@@ -188,10 +186,10 @@ export function Navbar() {
<User /> پروفایل من
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push("/settings")}>
<User /> تنظیمات
<Settings /> تنظیمات
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setStudioOpen(true)}>
<User /> ویرایش آواتار
<Palette /> ویرایش آواتار
</DropdownMenuItem>
{liveUser.role === "admin" && (
<DropdownMenuItem onClick={() => router.push("/admin")}>
@@ -64,7 +64,7 @@ export function NotificationBell() {
<div dir="rtl" className="text-start">
<div className="flex items-center justify-between gap-3 border-b border-border px-3 py-2.5">
<div className="min-w-0">
<p className="text-sm font-semibold">اعلان‌ها</p>
<p className="text-sm font-bold">اعلان‌ها</p>
<p className="text-[11px] text-muted-foreground">
{unread > 0 ? `${unread} خوانده‌نشده` : "همه خوانده شده"}
</p>
@@ -133,7 +133,7 @@ export function NotificationBell() {
<button
type="button"
onClick={() => router.push("/notifications")}
className="w-full border-t border-border bg-muted/40 py-2.5 text-xs font-semibold text-primary hover:bg-muted"
className="w-full border-t border-border bg-muted/40 py-2.5 text-xs font-bold text-primary hover:bg-muted"
>
مشاهده همه اعلان‌ها
</button>
@@ -1,34 +1,22 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Award, Copy, ExternalLink, Loader2, Lock, Package } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { Award, Clock, Copy, ExternalLink, Loader2, Lock } from "lucide-react";
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 { toast } from "@/components/providers";
import { cn, formatFaNumber } from "@/lib/utils";
import { cn } from "@/lib/utils";
type MineCertificateResponse = {
certificate: Certificate | null;
eligible: boolean;
physicalOrder: CertificatePhysicalOrder | null;
physical: PhysicalCertSettings;
};
const ORDER_LABEL: Record<string, string> = {
requested: "درخواست فیزیکی ثبت شد",
paid: "پرداخت تأیید شد",
shipped: "ارسال شد",
cancelled: "درخواست فیزیکی لغو شده",
};
export function ProfileCertificateSlot() {
const router = useRouter();
const qc = useQueryClient();
const [issuing, setIssuing] = useState(false);
const autoIssueTried = useRef(false);
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: ["me", "certificate"],
@@ -37,20 +25,6 @@ export function ProfileCertificateSlot() {
const cert = data?.certificate ?? null;
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 () => {
if (!cert?.publicId) return;
@@ -68,29 +42,6 @@ export function ProfileCertificateSlot() {
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 (
<section
aria-label="جایگاه گواهینامه"
@@ -113,12 +64,22 @@ export function ProfileCertificateSlot() {
تلاش دوباره
</Button>
</div>
) : isLoading || (eligible && !cert && issuing) ? (
) : isLoading ? (
<div className="flex items-center gap-2 py-3 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
در حال بارگذاری…
</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="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>
@@ -129,12 +90,6 @@ export function ProfileCertificateSlot() {
{cert.publicId}
</p>
</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">
<Button type="button" size="sm" className="h-8 flex-1" onClick={openCert}>
<ExternalLink className="h-3.5 w-3.5" />
@@ -144,23 +99,17 @@ export function ProfileCertificateSlot() {
<Copy className="h-3.5 w-3.5" />
کپی لینک
</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>
) : eligible ? (
<div className="space-y-2.5">
<p className="text-[11px] leading-relaxed text-muted-foreground">
دوره را تمام کرده‌اید. گواهینامه دیجیتال آمادهٔ صدور است.
<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>
<Button type="button" size="sm" className="h-8 w-full" disabled={issuing} onClick={() => void claim()}>
{issuing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Award className="h-3.5 w-3.5" />}
دریافت گواهینامه
</Button>
</div>
</div>
) : (
<div
@@ -172,10 +121,7 @@ export function ProfileCertificateSlot() {
<div>
<p className="text-[11px] font-bold text-foreground">هنوز صادر نشده</p>
<p className="mt-0.5 text-[10px] leading-relaxed text-muted-foreground">
با پاس کردن همه درس‌های فعال، گواهینامه همین‌جا نمایش داده می‌شود
{physical?.enabled
? `؛ در صورت فعال بودن، نسخه فیزیکی (پرداخت آفلاین) تا ${formatFaNumber(physical.windowDays)} روز پس از صدور قابل درخواست است.`
: "."}
با پاس کردن همه درس‌های فعال، گواهینامه همین‌جا نمایش داده می‌شود.
</p>
</div>
</div>
+5 -5
View File
@@ -119,7 +119,7 @@ export function QuizExperience({ lessonId }: { lessonId: number }) {
return (
<Card className="mx-auto max-w-md p-8 text-center">
<Lock className="mx-auto mb-3 h-10 w-10 text-destructive" />
<h2 className="mb-2 text-lg font-semibold">آزمون در دسترس نیست</h2>
<h2 className="mb-2 text-lg font-bold">آزمون در دسترس نیست</h2>
<p className="mb-4 text-sm text-muted-foreground">{msg}</p>
<Button onClick={() => router.push(`/player/${lessonId}`)}>ابتدا درس را تماشا کنید</Button>
</Card>
@@ -188,7 +188,7 @@ export function QuizExperience({ lessonId }: { lessonId: number }) {
<Badge variant="secondary" className="h-6 w-6 justify-center rounded-md p-0">
{qi + 1}
</Badge>
<p className="font-medium leading-snug">{q.question}</p>
<p className="font-bold leading-snug">{q.question}</p>
</div>
<div className="grid gap-2">
{q.options.map((opt, oi) => {
@@ -198,7 +198,7 @@ export function QuizExperience({ lessonId }: { lessonId: number }) {
key={oi}
onClick={() => select(q.id, oi)}
className={cn(
"flex items-center gap-3 rounded-md border px-4 py-3 text-right text-sm transition-colors",
"flex items-center gap-3 rounded-md border-2 px-4 py-3 text-right text-sm transition-colors",
active
? "border-primary bg-primary/10 ring-1 ring-primary"
: "border-border hover:bg-muted/50"
@@ -206,7 +206,7 @@ export function QuizExperience({ lessonId }: { lessonId: number }) {
>
<span
className={cn(
"flex h-7 min-w-7 shrink-0 items-center justify-center rounded-md border px-1.5 text-[11px] font-semibold",
"flex h-7 min-w-7 shrink-0 items-center justify-center rounded-md border px-1.5 text-[11px] font-bold",
active ? "border-primary bg-primary text-primary-foreground" : "border-border text-muted-foreground"
)}
>
@@ -305,7 +305,7 @@ function HeartsMeterInline({ hearts, infinite }: { hearts: number; infinite?: bo
function PassScreen({ result, onNext, onLeagues }: { result: QuizResult; onNext: () => void; onLeagues: () => void }) {
return (
<div className="mx-auto max-w-md rounded-lg border border-border bg-card p-10 text-center">
<div className="mx-auto max-w-md rounded-2xl border-2 border-border bg-card p-10 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-lg bg-primary">
<PartyPopper className="h-9 w-9 text-primary-foreground" />
</div>
+36 -40
View File
@@ -1,9 +1,8 @@
"use client";
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
/** Renders a small markdown subset. Raw HTML is escaped by React text nodes. */
export function SafeMarkdown({
text,
className,
@@ -11,56 +10,53 @@ export function SafeMarkdown({
}: {
text: string;
className?: string;
/** When set, only the first block is shown, clamped to this many lines. */
lines?: 2 | 3;
}) {
const blocks = text.replace(/\r\n/g, "\n").split(/\n{2,}/);
const shown = lines ? blocks.slice(0, 1) : blocks;
const [html, setHtml] = useState("");
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 (
<div className={className}>
{shown.map((block, i) => (
<p
key={i}
<div
dir="auto"
className={cn(
"break-words [unicode-bidi:plaintext]",
"break-words text-sm [unicode-bidi:plaintext]",
lines === 2 && "line-clamp-2",
lines === 3 && "line-clamp-3",
!lines && "whitespace-pre-wrap",
className,
)}
>
{inline(block)}
</p>
))}
{text}
</div>
);
}
}
function inline(src: string): ReactNode[] {
const re = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)|\*\*([^*]+)\*\*|`([^`]+)`/g;
const nodes: ReactNode[] = [];
let last = 0;
let m: RegExpExecArray | null;
let k = 0;
while ((m = re.exec(src))) {
if (m.index > last) nodes.push(src.slice(last, m.index));
if (m[1] && m[2]) {
nodes.push(
<a key={k++} href={m[2]} target="_blank" rel="noreferrer" className="underline">
{m[1]}
</a>,
return (
<div
dir="auto"
className={cn(
"break-words text-sm [unicode-bidi:plaintext]",
mdClasses,
lines === 2 && "line-clamp-2",
lines === 3 && "line-clamp-3",
className,
)}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
} 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;
}
+9 -4
View File
@@ -103,21 +103,26 @@ export function SectionPanel({
titleId,
title,
description,
actions,
children,
}: {
id?: string;
titleId: string;
title: string;
description: string;
actions?: ReactNode;
children: ReactNode;
}) {
return (
<section id={id} aria-labelledby={titleId} className="min-w-0 rounded-2xl border-2 border-border bg-card">
<header className="border-b border-border/70 px-5 py-4 sm:px-6">
<h2 id={titleId} className="text-lg font-black">
{title}
</h2>
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h2 id={titleId} className="text-lg font-black">{title}</h2>
{description && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
</div>
{actions && <div className="shrink-0">{actions}</div>}
</div>
</header>
<div className="p-5 sm:p-6">{children}</div>
</section>
+20 -2
View File
@@ -21,6 +21,7 @@ import {
} from "lucide-react";
import { useMemo, useState } from "react";
import type { TreeChapter, TreeData, TreeLesson } from "@/lib/types";
import { useAuth } from "@/lib/auth-store";
import { cn, formatFaNumber } from "@/lib/utils";
const CHAPTER_ICONS: Record<string, typeof Terminal> = {
@@ -38,6 +39,8 @@ const CHAPTER_ICONS: Record<string, typeof Terminal> = {
export function SkillTree({ data, onSelect }: { data: TreeData; onSelect?: (id: number) => void }) {
const router = useRouter();
const role = useAuth((s) => s.user?.role);
const isStaff = role === "admin" || role === "mentor";
const chapters = useMemo(() => [...data.chapters].sort((a, b) => a.sortOrder - b.sortOrder), [data]);
const byId = useMemo(() => {
@@ -97,6 +100,7 @@ export function SkillTree({ data, onSelect }: { data: TreeData; onSelect?: (id:
open={expanded.has(ch.id)}
onToggle={() => toggle(ch.id)}
onSelect={onSelect ?? ((id: number) => router.push(`/player/${id}`))}
isStaff={isStaff}
/>
))}
</div>
@@ -110,12 +114,14 @@ function ChapterRow({
open,
onToggle,
onSelect,
isStaff,
}: {
ch: TreeChapter;
byId: Map<number, TreeLesson>;
open: boolean;
onToggle: () => void;
onSelect: (id: number) => void;
isStaff: boolean;
}) {
const lessons = [...(ch.lessons ?? [])].sort((a, b) => a.y - b.y || a.x - b.x);
const done = lessons.filter((l) => l.progress?.passedQuiz).length;
@@ -147,8 +153,13 @@ function ChapterRow({
<CheckCircle2 className="h-3.5 w-3.5" /> کامل شد
</span>
)}
{isStaff && !ch.isPublished && (
<span className="hidden shrink-0 rounded-full border border-dashed border-warning/50 bg-warning/10 px-2 py-0.5 text-[11px] font-bold text-warning sm:inline-block">
پیش‌نویس
</span>
)}
<span className="hidden shrink-0 rounded-full bg-muted px-2 py-0.5 text-[11px] font-bold text-muted-foreground sm:inline-block">
هفتهٔ {ch.sortOrder}
فصل {ch.sortOrder}
</span>
</div>
<p className="truncate text-xs text-muted-foreground">{ch.description}</p>
@@ -173,7 +184,7 @@ function ChapterRow({
<div className="overflow-hidden">
<div className="grid gap-2.5 border-t-2 border-border/70 p-4 sm:grid-cols-2">
{lessons.map((lesson) => (
<LessonRow key={lesson.id} lesson={lesson} byId={byId} onSelect={onSelect} />
<LessonRow key={lesson.id} lesson={lesson} byId={byId} onSelect={onSelect} isStaff={isStaff} />
))}
</div>
</div>
@@ -186,10 +197,12 @@ function LessonRow({
lesson,
byId,
onSelect,
isStaff,
}: {
lesson: TreeLesson;
byId: Map<number, TreeLesson>;
onSelect: (id: number) => void;
isStaff: boolean;
}) {
const passed = !!lesson.progress?.passedQuiz;
const locked = lesson.locked;
@@ -254,6 +267,11 @@ function LessonRow({
</div>
<div className="mt-1.5 flex flex-wrap items-center gap-2">
<span className={cn("rounded-full px-2 py-0.5 text-[11px] font-bold", s.pill)}>{s.pillText}</span>
{isStaff && lesson.isPublished === false && (
<span className="rounded-full border border-dashed border-warning/50 bg-warning/10 px-2 py-0.5 text-[11px] font-bold text-warning">
پیش‌نویس
</span>
)}
{inProgress && (
<div className="h-1.5 w-24 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-primary" style={{ width: `${pct}%` }} />
+4 -4
View File
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
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: {
variant: {
@@ -18,10 +18,10 @@ const buttonVariants = cva(
link: "border-transparent text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 rounded-lg px-4 py-2",
sm: "h-8 rounded-lg px-3 text-xs",
default: "h-11 rounded-lg px-4 py-2",
sm: "h-9 rounded-lg px-3 text-xs",
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" },
+1 -1
View File
@@ -24,7 +24,7 @@ CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("text-xl font-semibold leading-none", className)} {...props} />
<div ref={ref} className={cn("text-xl font-bold leading-none", className)} {...props} />
)
);
CardTitle.displayName = "CardTitle";
+16 -3
View File
@@ -34,13 +34,26 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
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
)}
{...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}
<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" />
<span className="sr-only">بستن</span>
</DialogPrimitive.Close>
@@ -65,7 +78,7 @@ const DialogTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
className={cn("text-lg font-bold leading-none tracking-tight", className)}
{...props}
/>
));
+2 -1
View File
@@ -33,7 +33,8 @@ const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLI
type={type}
className={cn(
// 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",
className
)}
+2 -1
View File
@@ -16,12 +16,13 @@ const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
return (
<div
ref={ref}
dir="ltr"
className={cn("relative h-2.5 w-full overflow-hidden rounded-full bg-muted", className)}
{...props}
>
<div
className={cn(
"h-full rounded-full will-change-[width] rtl:me-auto",
"h-full rounded-full will-change-[width]",
gradient ? "bg-primary" : "bg-primary",
smooth
? "transition-[width] duration-1000 ease-[cubic-bezier(0.22,1,0.36,1)]"
+196
View File
@@ -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>
);
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
import { useRef, useState } from "react";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
interface TagInputProps {
value: string[];
onChange: (tags: string[]) => void;
placeholder?: string;
maxTags?: number;
className?: string;
disabled?: boolean;
}
export function TagInput({
value,
onChange,
placeholder = "افزودن برچسب…",
maxTags = 20,
className,
disabled,
}: TagInputProps) {
const [input, setInput] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const add = (raw: string) => {
const tag = raw.trim();
if (!tag) return;
if (value.some((t) => t.toLowerCase() === tag.toLowerCase())) return;
if (value.length >= maxTags) return;
onChange([...value, tag]);
setInput("");
};
const remove = (index: number) => {
onChange(value.filter((_, i) => i !== index));
};
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
add(input);
} else if (e.key === "Backspace" && !input && value.length > 0) {
remove(value.length - 1);
}
};
return (
<div
className={cn(
"flex min-h-9 flex-wrap gap-1.5 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2",
disabled && "cursor-not-allowed opacity-50",
className,
)}
onClick={() => inputRef.current?.focus()}
>
{value.map((tag, i) => (
<span
key={i}
className="inline-flex items-center gap-1 rounded-md bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground"
>
{tag}
{!disabled && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
remove(i);
}}
className="ml-0.5 rounded-sm opacity-60 transition-opacity hover:opacity-100"
aria-label={`حذف ${tag}`}
>
<X className="h-3 w-3" />
</button>
)}
</span>
))}
{!disabled && value.length < maxTags && (
<input
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={onKeyDown}
onBlur={() => add(input)}
placeholder={value.length === 0 ? placeholder : ""}
className="min-w-[120px] flex-1 bg-transparent outline-none placeholder:text-muted-foreground"
dir="auto"
/>
)}
</div>
);
}
+2 -1
View File
@@ -7,7 +7,8 @@ const Textarea = React.forwardRef<
>(({ className, ...props }, ref) => (
<textarea
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,
)}
ref={ref}
+457 -238
View File
@@ -3,26 +3,54 @@
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import {
ChevronDown,
Gauge,
Lock,
Maximize,
Minimize,
Pause,
Play,
RotateCcw,
SkipBack,
Rewind,
Volume1,
Volume2,
VolumeX,
} from "lucide-react";
import { motion } from "framer-motion";
import { motion, AnimatePresence } from "framer-motion";
import type Hls from "hls.js";
import { api, http, toUserError } from "@/lib/api";
import type { Lesson, LessonProgress } from "@/lib/types";
import { Skeleton } from "@/components/ui/skeleton";
import { Progress } from "@/components/ui/progress";
import { Button } from "@/components/ui/button";
import { toast } from "@/components/providers";
import { cn } from "@/lib/utils";
// ─── Constants ────────────────────────────────────────────────────────────────
const INTERVAL_MS = 10_000;
const SEEK_FORWARD_SLACK = 0.35;
const CONTINUOUS_PLAY_MAX_DT = 3;
const ALLOWED_SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2] as const;
type Speed = (typeof ALLOWED_SPEEDS)[number];
const MAX_RETRY_DELAY_MS = 30_000;
const DOUBLE_TAP_MS = 300;
const DOUBLE_TAP_SLOP = 60;
// ─── localStorage helpers ─────────────────────────────────────────────────────
function readStorage<T>(key: string, fallback: T, guard: (v: unknown) => v is T): T {
try {
const raw = localStorage.getItem(key);
if (raw === null) return fallback;
const parsed: unknown = JSON.parse(raw);
return guard(parsed) ? parsed : fallback;
} catch { return fallback; }
}
function writeStorage(key: string, value: unknown) {
try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* ignore */ }
}
const isNumber = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
// ─── Main Component ───────────────────────────────────────────────────────────
export function VideoPlayer({
lessonId,
videoUrl,
@@ -36,23 +64,43 @@ export function VideoPlayer({
completionThresholdPct: number;
initialProgress?: LessonProgress | null;
}) {
// ── Refs ────────────────────────────────────────────────────────────────────
const videoRef = useRef<HTMLVideoElement>(null);
// Lock first signed URL for this mount so React Query refetch cannot reset src.
const containerRef = useRef<HTMLDivElement>(null);
const hlsRef = useRef<Hls | null>(null);
const lockedSrcRef = useRef(videoUrl);
const resumeAppliedRef = useRef(false);
const accRef = useRef(0);
const lastTimeRef = useRef(0);
/** Furthest continuous playback position — forward seeks snap back to this. */
const maxTrustedPosRef = useRef(0);
const seekToastAtRef = useRef(0);
const seqRef = useRef(0);
const programmaticSeekRef = useRef(false);
const refreshingSrcRef = useRef(false);
const retryCountRef = useRef(0);
const retryTimerRef = useRef<number | null>(null);
const userSpeedRef = useRef<Speed>(1);
const sendHeartbeatRef = useRef<(pos: number, delta: number) => Promise<void>>(async () => undefined);
// Double-tap detection
const lastTapRef = useRef<{ time: number; x: number } | null>(null);
const tapTimeoutRef = useRef<number | null>(null);
// ── Persisted prefs ─────────────────────────────────────────────────────────
const [volume, setVolume] = useState(() => readStorage("pargar:volume", 1, isNumber));
const [speed, setSpeed] = useState<Speed>(() => {
const s = readStorage("pargar:speed", 1, isNumber);
return (ALLOWED_SPEEDS as readonly number[]).includes(s) ? s as Speed : 1;
});
// ── Playback state ──────────────────────────────────────────────────────────
const [playing, setPlaying] = useState(false);
const [muted, setMuted] = useState(false);
const [position, setPosition] = useState(0);
const [bufferedEnd, setBufferedEnd] = useState(0);
const [hoverTime, setHoverTime] = useState<number | null>(null);
const [hoverX, setHoverX] = useState(0);
const [watchedPct, setWatchedPct] = useState(initialProgress?.watchedPct ?? 0);
const [quizUnlocked, setQuizUnlocked] = useState(!!initialProgress?.quizUnlocked);
const [quizUnlockedNow, setQuizUnlockedNow] = useState(false);
@@ -61,8 +109,13 @@ export function VideoPlayer({
const [loadError, setLoadError] = useState<string | null>(null);
const [uiIdle, setUiIdle] = useState(false);
const [mediaDuration, setMediaDuration] = useState(duration);
const [fullscreen, setFullscreen] = useState(false);
const [theaterMode, setTheaterMode] = useState(false);
const [seekIndicator, setSeekIndicator] = useState<"back" | null>(null);
const hideTimer = useRef<number | null>(null);
// ── Utilities ───────────────────────────────────────────────────────────────
const warnSeekBlocked = useCallback(() => {
const now = Date.now();
if (now - seekToastAtRef.current < 2500) return;
@@ -86,12 +139,101 @@ export function VideoPlayer({
v.currentTime = Math.max(0, to);
lastTimeRef.current = v.currentTime;
setPosition(v.currentTime);
window.setTimeout(() => {
programmaticSeekRef.current = false;
}, 120);
window.setTimeout(() => { programmaticSeekRef.current = false; }, 120);
}, []);
// resume once, only if still near the start (don't yank mid-play)
const fmt = (s: number) => {
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec.toString().padStart(2, "0")}`;
};
const poke = useCallback(() => {
setUiIdle(false);
if (hideTimer.current) window.clearTimeout(hideTimer.current);
const v = videoRef.current;
if (v && !v.paused && !v.ended) {
hideTimer.current = window.setTimeout(() => setUiIdle(true), 2600);
}
}, []);
const skipBack = useCallback(() => {
const v = videoRef.current;
if (!v) return;
flushAcc();
seekProgrammatic(Math.max(0, v.currentTime - 10));
poke();
}, [flushAcc, seekProgrammatic, poke]);
const toggle = useCallback(() => {
const v = videoRef.current;
if (!v) return;
if (v.paused) void v.play().catch(() => toast.error("پخش ویدیو ممکن نشد"));
else v.pause();
}, []);
const toggleFullscreen = useCallback(() => {
const el = containerRef.current;
if (!el) return;
if (!document.fullscreenElement) {
void el.requestFullscreen().catch(() => undefined);
} else {
void document.exitFullscreen().catch(() => undefined);
}
}, []);
const changeSpeed = useCallback((s: Speed) => {
userSpeedRef.current = s;
setSpeed(s);
writeStorage("pargar:speed", s);
const v = videoRef.current;
if (v) v.playbackRate = s;
}, []);
const cycleSpeed = useCallback(() => {
const idx = ALLOWED_SPEEDS.indexOf(userSpeedRef.current);
const next = ALLOWED_SPEEDS[(idx + 1) % ALLOWED_SPEEDS.length];
changeSpeed(next);
}, [changeSpeed]);
// ── HLS init ────────────────────────────────────────────────────────────────
useEffect(() => {
const v = videoRef.current;
if (!v) return;
const src = lockedSrcRef.current;
const isHLS = src.includes(".m3u8");
if (isHLS) {
let cancelled = false;
import("hls.js").then(({ default: HlsClass }) => {
if (cancelled) return;
if (HlsClass.isSupported()) {
const hls = new HlsClass({
maxBufferLength: 45,
maxMaxBufferLength: 60,
startLevel: -1,
autoStartLoad: true,
});
hls.loadSource(src);
hls.attachMedia(v);
hlsRef.current = hls;
} else if (v.canPlayType("application/vnd.apple.mpegurl")) {
// Safari native HLS
v.src = src;
}
});
return () => {
cancelled = true;
hlsRef.current?.destroy();
hlsRef.current = null;
};
} else {
v.src = src;
}
}, [lessonId]);
// ── Resume position ─────────────────────────────────────────────────────────
useEffect(() => {
let cancelled = false;
resumeAppliedRef.current = false;
@@ -100,17 +242,12 @@ export function VideoPlayer({
const { position: p, lastSeq } = await api<{ position: number; lastSeq?: number }>(
`/api/lessons/${lessonId}/resume`
);
if (typeof lastSeq === "number" && lastSeq > seqRef.current) {
seqRef.current = lastSeq;
}
if (typeof lastSeq === "number" && lastSeq > seqRef.current) seqRef.current = lastSeq;
if (cancelled || resumeAppliedRef.current || !(p > 1)) return;
const apply = () => {
const v = videoRef.current;
if (!v || resumeAppliedRef.current) return;
if (v.currentTime >= 1) {
resumeAppliedRef.current = true;
return;
}
if (v.currentTime >= 1) { resumeAppliedRef.current = true; return; }
const dur = Number.isFinite(v.duration) && v.duration > 0 ? v.duration : duration;
if (p < dur - 2) {
programmaticSeekRef.current = true;
@@ -118,9 +255,7 @@ export function VideoPlayer({
setPosition(p);
lastTimeRef.current = p;
maxTrustedPosRef.current = Math.max(maxTrustedPosRef.current, p);
window.setTimeout(() => {
programmaticSeekRef.current = false;
}, 120);
window.setTimeout(() => { programmaticSeekRef.current = false; }, 120);
}
resumeAppliedRef.current = true;
};
@@ -128,24 +263,19 @@ export function VideoPlayer({
if (videoRef.current.readyState >= 1) apply();
else videoRef.current.addEventListener("loadedmetadata", apply, { once: true });
}
} catch {
/* ignore */
}
} catch { /* ignore */ }
})();
return () => {
cancelled = true;
};
return () => { cancelled = true; };
}, [lessonId, duration]);
// Seed trusted position from known server progress so resume + scrub-back stay consistent.
useEffect(() => {
if (initialProgress?.lastPosition && initialProgress.lastPosition > 0) {
maxTrustedPosRef.current = Math.max(maxTrustedPosRef.current, initialProgress.lastPosition);
}
}, [initialProgress?.lastPosition]);
const sendHeartbeat = useCallback(
async (pos: number, delta: number) => {
// ── Heartbeat ───────────────────────────────────────────────────────────────
const sendHeartbeat = useCallback(async (pos: number, delta: number) => {
seqRef.current += 1;
try {
const res = await api<{ watchedPct: number; quizUnlocked: boolean; quizUnlockedNow: boolean }>(
@@ -153,46 +283,33 @@ export function VideoPlayer({
{ method: "POST", body: { position: pos, delta, seq: seqRef.current } }
);
setWatchedPct(res.watchedPct);
if (res.quizUnlocked) {
setQuizUnlocked(true);
}
if (res.quizUnlocked) setQuizUnlocked(true);
if (res.quizUnlockedNow) {
setQuizUnlockedNow(true);
toast.success("آزمون باز شد! می‌توانید آموخته‌هایتان را محک بزنید.");
}
} catch (e) {
const msg = (e as Error)?.message ?? "";
if (msg.includes("locked") || msg.includes("قفل")) {
toast.error(msg);
return;
}
if (msg.includes("locked") || msg.includes("قفل")) { toast.error(msg); return; }
toast.error(toUserError(e, "همگام‌سازی تماشا قطع شد — اتصال را بررسی کنید"));
}
},
[lessonId]
);
}, [lessonId]);
useEffect(() => {
sendHeartbeatRef.current = sendHeartbeat;
}, [sendHeartbeat]);
useEffect(() => { sendHeartbeatRef.current = sendHeartbeat; }, [sendHeartbeat]);
// anti-cheat: real playback only, lock rate/PiP, snap forward seeks
// ── Anti-cheat: rate cap, forward-seek block, PiP block ────────────────────
useEffect(() => {
const v = videoRef.current;
if (!v) return;
v.disablePictureInPicture = true;
v.disableRemotePlayback = true;
v.playbackRate = 1;
v.defaultPlaybackRate = 1;
const lockRate = () => {
if (v.playbackRate !== 1) {
v.playbackRate = 1;
warnSeekBlocked();
}
if (v.defaultPlaybackRate !== 1) {
v.defaultPlaybackRate = 1;
const capRate = () => {
const target = userSpeedRef.current;
if (Math.abs(v.playbackRate - target) > 0.01) {
v.playbackRate = target;
if (v.playbackRate > 2.01) warnSeekBlocked();
}
};
@@ -204,9 +321,7 @@ export function VideoPlayer({
v.currentTime = max;
lastTimeRef.current = max;
setPosition(max);
window.setTimeout(() => {
programmaticSeekRef.current = false;
}, 120);
window.setTimeout(() => { programmaticSeekRef.current = false; }, 120);
warnSeekBlocked();
return true;
}
@@ -214,37 +329,30 @@ export function VideoPlayer({
};
const onTimeUpdate = () => {
lockRate();
capRate();
if (snapForwardSeek()) return;
const dt = v.currentTime - lastTimeRef.current;
// Accumulate content-time delta (backend credits content coverage)
if (dt > 0 && dt < CONTINUOUS_PLAY_MAX_DT) {
accRef.current += dt;
if (v.currentTime > maxTrustedPosRef.current) {
maxTrustedPosRef.current = v.currentTime;
}
if (v.currentTime > maxTrustedPosRef.current) maxTrustedPosRef.current = v.currentTime;
}
lastTimeRef.current = v.currentTime;
setPosition(v.currentTime);
if (v.buffered.length > 0) setBufferedEnd(v.buffered.end(v.buffered.length - 1));
};
const onEnded = () => {
setPlaying(false);
const delta = accRef.current;
accRef.current = 0;
const delta = accRef.current; accRef.current = 0;
void sendHeartbeatRef.current(v.currentTime, delta);
};
const blockPiP = () => {
void (async () => {
try {
if (document.pictureInPictureElement === v) {
await document.exitPictureInPicture();
}
} catch {
/* ignore */
}
v.pause();
warnSeekBlocked();
try { if (document.pictureInPictureElement === v) await document.exitPictureInPicture(); }
catch { /* ignore */ }
v.pause(); warnSeekBlocked();
})();
};
@@ -252,23 +360,22 @@ export function VideoPlayer({
v.addEventListener("ended", onEnded);
v.addEventListener("seeking", snapForwardSeek);
v.addEventListener("seeked", snapForwardSeek);
v.addEventListener("ratechange", lockRate);
v.addEventListener("ratechange", capRate);
v.addEventListener("enterpictureinpicture", blockPiP);
return () => {
v.removeEventListener("timeupdate", onTimeUpdate);
v.removeEventListener("ended", onEnded);
v.removeEventListener("seeking", snapForwardSeek);
v.removeEventListener("seeked", snapForwardSeek);
v.removeEventListener("ratechange", lockRate);
v.removeEventListener("ratechange", capRate);
v.removeEventListener("enterpictureinpicture", blockPiP);
};
}, [lessonId, warnSeekBlocked]);
// periodic verified heartbeats + visibility flush
// ── Periodic heartbeats + visibility flush ──────────────────────────────────
useEffect(() => {
const v = videoRef.current;
if (!v) return;
const sendPending = (opts?: { evenIfPaused?: boolean }) => {
const delta = accRef.current;
if (delta < 0.05) return;
@@ -276,19 +383,12 @@ export function VideoPlayer({
accRef.current = 0;
void sendHeartbeatRef.current(v.currentTime, delta);
};
const iv = window.setInterval(() => sendPending(), INTERVAL_MS);
const flush = () => sendPending({ evenIfPaused: true });
const onPause = () => sendPending({ evenIfPaused: true });
const vis = () => {
if (document.hidden) {
sendPending({ evenIfPaused: true });
if (!v.paused) v.pause();
}
if (document.pictureInPictureElement === v) {
void document.exitPictureInPicture().catch(() => undefined);
v.pause();
}
if (document.hidden) { sendPending({ evenIfPaused: true }); if (!v.paused) v.pause(); }
if (document.pictureInPictureElement === v) { void document.exitPictureInPicture().catch(() => undefined); v.pause(); }
};
v.addEventListener("pause", onPause);
document.addEventListener("visibilitychange", vis);
@@ -304,10 +404,75 @@ export function VideoPlayer({
};
}, [lessonId]);
// ── Volume / mute sync ──────────────────────────────────────────────────────
useEffect(() => {
const v = videoRef.current;
if (v) v.muted = muted;
}, [muted]);
if (!v) return;
v.volume = volume;
v.muted = muted || volume === 0;
writeStorage("pargar:volume", volume);
}, [volume, muted]);
// ── Apply speed on mount and change ────────────────────────────────────────
useEffect(() => {
const v = videoRef.current;
if (v) { v.playbackRate = speed; userSpeedRef.current = speed; }
}, [speed]);
// ── Fullscreen change listener ──────────────────────────────────────────────
useEffect(() => {
const onFsChange = () => setFullscreen(!!document.fullscreenElement);
document.addEventListener("fullscreenchange", onFsChange);
return () => document.removeEventListener("fullscreenchange", onFsChange);
}, []);
// ── Keyboard shortcuts ──────────────────────────────────────────────────────
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const active = document.activeElement;
if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA" || active.tagName === "SELECT")) return;
const v = videoRef.current;
if (!v) return;
switch (e.code) {
case "Space": case "KeyK": e.preventDefault(); toggle(); poke(); break;
case "KeyJ": case "ArrowLeft":
e.preventDefault(); skipBack(); setSeekIndicator("back");
setTimeout(() => setSeekIndicator(null), 700);
break;
case "KeyL": case "ArrowRight":
e.preventDefault(); warnSeekBlocked(); break;
case "ArrowUp":
e.preventDefault(); setVolume((vol) => Math.min(1, Math.round((vol + 0.1) * 10) / 10)); poke(); break;
case "ArrowDown":
e.preventDefault(); setVolume((vol) => Math.max(0, Math.round((vol - 0.1) * 10) / 10)); poke(); break;
case "KeyM": e.preventDefault(); setMuted((m) => !m); poke(); break;
case "KeyF": e.preventDefault(); toggleFullscreen(); break;
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [toggle, skipBack, warnSeekBlocked, poke, toggleFullscreen]);
// ── Cleanup timers ──────────────────────────────────────────────────────────
useEffect(() => {
return () => {
if (retryTimerRef.current) window.clearTimeout(retryTimerRef.current);
if (tapTimeoutRef.current) window.clearTimeout(tapTimeoutRef.current);
if (hideTimer.current) window.clearTimeout(hideTimer.current);
};
}, []);
// ── Refresh signed URL (error recovery) ────────────────────────────────────
const scheduleRetry = useCallback(() => {
if (retryTimerRef.current) return;
retryCountRef.current += 1;
const delay = Math.min(1000 * 2 ** (retryCountRef.current - 1), MAX_RETRY_DELAY_MS);
retryTimerRef.current = window.setTimeout(() => {
retryTimerRef.current = null;
void refreshSignedUrl();
}, delay);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const refreshSignedUrl = useCallback(async () => {
if (refreshingSrcRef.current) return;
@@ -316,106 +481,105 @@ export function VideoPlayer({
const data = await http.get<{ lesson: Lesson; videoUrl: string; progress: LessonProgress | null }>(
`/api/lessons/${lessonId}`
);
if (!data.videoUrl) {
setLoadError("ویدیوی این درس در دسترس نیست.");
return;
}
if (!data.videoUrl) { setLoadError("ویدیوی این درس در دسترس نیست."); return; }
lockedSrcRef.current = data.videoUrl;
const v = videoRef.current;
if (v) {
const keep = v.currentTime;
const wasPlaying = !v.paused;
if (hlsRef.current) {
hlsRef.current.loadSource(data.videoUrl);
} else {
v.src = data.videoUrl;
v.load();
}
const restore = () => {
if (keep > 0) {
programmaticSeekRef.current = true;
v.currentTime = keep;
lastTimeRef.current = keep;
window.setTimeout(() => {
programmaticSeekRef.current = false;
}, 120);
v.currentTime = keep; lastTimeRef.current = keep;
window.setTimeout(() => { programmaticSeekRef.current = false; }, 120);
}
if (wasPlaying) void v.play().catch(() => undefined);
};
v.addEventListener("loadedmetadata", restore, { once: true });
}
if (data.progress) {
setWatchedPct(data.progress.watchedPct);
if (data.progress.quizUnlocked) setQuizUnlocked(true);
}
setLoadError(null);
setLoading(true);
} catch {
setLoadError("بارگذاری مجدد ویدیو ممکن نشد. دوباره تلاش کنید.");
} finally {
refreshingSrcRef.current = false;
}
if (data.progress) { setWatchedPct(data.progress.watchedPct); if (data.progress.quizUnlocked) setQuizUnlocked(true); }
setLoadError(null); setLoading(true); retryCountRef.current = 0;
} catch { setLoadError("بارگذاری مجدد ویدیو ممکن نشد. دوباره تلاش کنید."); }
finally { refreshingSrcRef.current = false; }
}, [lessonId]);
const toggle = () => {
const v = videoRef.current;
if (!v) return;
if (v.paused) void v.play().catch(() => toast.error("پخش ویدیو ممکن نشد"));
else v.pause();
};
// ── Double-tap detection ────────────────────────────────────────────────────
const handlePointerDown = useCallback((e: React.PointerEvent) => {
poke();
if (e.pointerType !== "touch") return; // only touch triggers double-tap
const onPlayChange = useCallback(() => {
const v = videoRef.current;
setPlaying(!!v && !v.paused);
}, []);
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const x = e.clientX - rect.left;
const now = Date.now();
const lastTap = lastTapRef.current;
const poke = useCallback(() => {
setUiIdle(false);
if (hideTimer.current) window.clearTimeout(hideTimer.current);
const v = videoRef.current;
if (v && !v.paused && !v.ended) {
hideTimer.current = window.setTimeout(() => setUiIdle(true), 2600);
if (lastTap && now - lastTap.time < DOUBLE_TAP_MS && Math.abs(x - lastTap.x) < DOUBLE_TAP_SLOP) {
lastTapRef.current = null;
if (tapTimeoutRef.current) { window.clearTimeout(tapTimeoutRef.current); tapTimeoutRef.current = null; }
// Double-tap: left half = seek back, right half = blocked
if (x < rect.width / 2) {
skipBack();
setSeekIndicator("back");
setTimeout(() => setSeekIndicator(null), 700);
} else {
warnSeekBlocked();
}
}, []);
} else {
lastTapRef.current = { time: now, x };
if (tapTimeoutRef.current) window.clearTimeout(tapTimeoutRef.current);
tapTimeoutRef.current = window.setTimeout(() => {
lastTapRef.current = null;
tapTimeoutRef.current = null;
// Single tap: toggle play on mobile
toggle();
}, DOUBLE_TAP_MS);
}
}, [poke, skipBack, warnSeekBlocked, toggle]);
const skipBack = () => {
const v = videoRef.current;
if (!v) return;
flushAcc();
seekProgrammatic(Math.max(0, v.currentTime - 10));
poke();
// ── Progress bar hover ──────────────────────────────────────────────────────
const onProgressHover = (e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const frac = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
setHoverTime(frac * mediaDuration);
setHoverX((e.clientX - rect.left));
};
const restartFromStart = () => {
const v = videoRef.current;
if (!v) return;
flushAcc();
seekProgrammatic(0);
void v.play().catch(() => undefined);
poke();
};
useEffect(() => {
if (playing) poke();
return () => {
if (hideTimer.current) window.clearTimeout(hideTimer.current);
};
}, [playing, poke]);
// ── Derived values ──────────────────────────────────────────────────────────
const pct = mediaDuration > 0 ? Math.min(100, (position / mediaDuration) * 100) : 0;
const fmt = (s: number) => {
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec.toString().padStart(2, "0")}`;
};
const bufferedPct = mediaDuration > 0 ? Math.min(100, (bufferedEnd / mediaDuration) * 100) : 0;
const VolumeIcon = muted || volume === 0 ? VolumeX : volume < 0.5 ? Volume1 : Volume2;
return (
<div className="space-y-4">
<div
className="group relative overflow-hidden rounded-xl border border-border bg-black shadow-soft"
onMouseMove={poke}
onPointerDown={poke}
className={cn(
"space-y-4",
// Theater mode: on mobile remove page padding; on sm+ break out to full-bleed
theaterMode && "max-sm:-mx-4 max-sm:w-[calc(100%+2rem)] sm:relative sm:left-1/2 sm:right-1/2 sm:-mx-[50vw] sm:w-screen",
)}
>
{/* ── Player container ─── */}
<div
ref={containerRef}
className={cn(
"group relative overflow-hidden bg-black shadow-soft",
theaterMode
? "rounded-none border-y border-border"
: "rounded-xl border border-border",
)}
onMouseMove={poke}
onPointerDown={handlePointerDown}
>
{/* Video element */}
<video
ref={videoRef}
src={lockedSrcRef.current}
className="aspect-video w-full"
className="relative z-10 aspect-video w-full"
controls={false}
controlsList="nodownload noplaybackrate noremoteplayback nofullscreen"
disablePictureInPicture
@@ -426,136 +590,186 @@ export function VideoPlayer({
onContextMenu={(e) => e.preventDefault()}
onPlay={() => {
const el = videoRef.current;
if (el) {
el.playbackRate = 1;
el.defaultPlaybackRate = 1;
}
setLoadError(null);
onPlayChange();
poke();
}}
onPause={() => {
onPlayChange();
poke();
if (el) el.playbackRate = userSpeedRef.current;
setLoadError(null); setPlaying(true); poke();
}}
onPause={() => { setPlaying(false); poke(); }}
onWaiting={() => setBuffering(true)}
onPlaying={() => {
setLoading(false);
setBuffering(false);
}}
onCanPlay={() => {
setLoading(false);
setBuffering(false);
}}
onPlaying={() => { setLoading(false); setBuffering(false); retryCountRef.current = 0; }}
onCanPlay={() => { setLoading(false); setBuffering(false); }}
onLoadedMetadata={() => {
const el = videoRef.current;
if (el && Number.isFinite(el.duration) && el.duration > 0) {
setMediaDuration(el.duration);
}
if (el && Number.isFinite(el.duration) && el.duration > 0) setMediaDuration(el.duration);
}}
onError={() => {
setLoading(false);
setBuffering(false);
setLoadError("پخش ویدیو قطع شد. در حال تلاش برای اتصال مجدد…");
void refreshSignedUrl();
setLoading(false); setBuffering(false);
setLoadError(`پخش ویدیو قطع شد. تلاش مجدد ${retryCountRef.current > 0 ? `(${retryCountRef.current})` : ""}…`);
scheduleRetry();
}}
muted={muted}
muted={muted || volume === 0}
/>
{/* Loading spinner */}
{(loading || buffering) && !loadError && (
<div className="absolute inset-0 z-30 flex items-center justify-center bg-black/60">
<div className="flex flex-col items-center gap-3">
<Skeleton className="h-24 w-24 rounded-full" />
<Skeleton className="h-3 w-24 rounded-full" />
</div>
<div className="absolute inset-0 z-30 flex items-center justify-center bg-black/50">
<Skeleton className="h-16 w-16 rounded-full" />
</div>
)}
{/* Error overlay */}
{loadError && (
<div className="absolute inset-0 z-30 flex flex-col items-center justify-center gap-3 bg-black/80 px-6 text-center">
<div className="absolute inset-0 z-30 flex flex-col items-center justify-center gap-3 bg-black/85 px-6 text-center">
<p className="text-sm font-bold text-white">{loadError}</p>
<Button type="button" variant="secondary" onClick={() => void refreshSignedUrl()}>
تلاش دوباره
</Button>
<Button type="button" variant="secondary" onClick={() => {
if (retryTimerRef.current) { window.clearTimeout(retryTimerRef.current); retryTimerRef.current = null; }
void refreshSignedUrl();
}}>تلاش دوباره</Button>
</div>
)}
<div className="absolute inset-0 flex items-center justify-center" onClick={poke}>
{/* Center play button (idle, non-playing) */}
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
<AnimatePresence>
{!playing && !buffering && !loading && !loadError && (
<motion.button
key="play-btn"
type="button"
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
whileHover={{ scale: 1.05 }}
exit={{ scale: 0.8, opacity: 0 }}
onClick={toggle}
className="flex h-16 w-16 items-center justify-center rounded-full bg-primary text-primary-foreground transition-opacity hover:opacity-90"
className="pointer-events-auto flex h-16 w-16 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-opacity hover:opacity-90"
aria-label="پخش"
>
<Play className="ms-1 h-8 w-8 fill-white" />
</motion.button>
)}
</AnimatePresence>
</div>
<div
className={`absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/90 via-black/40 to-transparent p-4 transition-opacity duration-300 ${
uiIdle ? "pointer-events-none opacity-0" : "opacity-100"
}`}
{/* Seek indicator (rewind feedback — physical right side) */}
<AnimatePresence>
{seekIndicator && (
<motion.div
key="seek-indicator"
initial={{ opacity: 0, scale: 0.7 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.7 }}
className="pointer-events-none absolute right-8 top-1/2 z-30 flex -translate-y-1/2 flex-col items-center gap-1"
>
<div className="mb-2 flex items-center gap-1 text-[11px] font-medium text-white/90">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-white/20 backdrop-blur-sm">
<Rewind className="h-6 w-6 text-white" />
</div>
<span className="text-xs font-bold text-white drop-shadow">۱۰ ثانیه</span>
</motion.div>
)}
</AnimatePresence>
{/* Controls overlay */}
<div
className={cn(
"absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/90 via-black/50 to-transparent pb-[env(safe-area-inset-bottom,0px)] transition-opacity duration-300",
uiIdle ? "pointer-events-none opacity-0" : "opacity-100",
)}
>
{/* Status bar */}
<div className="mb-2 flex items-center gap-1 px-4 pt-4 text-[11px] font-medium text-white/90">
<span className="rounded bg-white/10 px-2 py-0.5">تماشای تاییدشده</span>
<Lock className="h-3 w-3 opacity-60" />
<span className="opacity-80">پرش سریع غیرفعال است</span>
<span className="ms-auto tabular-nums">
{fmt(position)} / {fmt(mediaDuration)}
</span>
<span className="ms-auto tabular-nums">{fmt(position)} / {fmt(mediaDuration)}</span>
</div>
<div className="relative">
<Progress value={pct} className="h-2 bg-white/15" gradient />
{/* Timeline — unified dir="ltr" coordinate space */}
<div
className="pointer-events-none absolute top-1/2 h-4 w-4 -translate-y-1/2 rounded-full bg-white shadow transition-[inset-inline-start] duration-300 ease-out"
style={{ insetInlineStart: `calc(${pct}% - 8px)` }}
dir="ltr"
className="relative mx-4 cursor-pointer py-2"
onMouseMove={onProgressHover}
onMouseLeave={() => setHoverTime(null)}
>
{/* Hover timestamp tooltip */}
{hoverTime !== null && (
<div
className="pointer-events-none absolute -top-6 z-10 rounded bg-black/80 px-1.5 py-0.5 text-[11px] font-bold text-white"
style={{ left: `${hoverX}px`, transform: "translateX(-50%)" }}
>
{fmt(hoverTime)}
</div>
)}
{/* Track layers stacked bottom-to-top: base → buffer → progress → thumb */}
<div className="relative h-2 overflow-hidden rounded-full bg-white/10">
{/* Buffer layer */}
<div
className="absolute left-0 top-0 h-full rounded-full bg-white/25 transition-[width] duration-300"
style={{ width: `${bufferedPct}%` }}
/>
{/* Play progress layer */}
<div
className="absolute left-0 top-0 h-full rounded-full bg-primary transition-[width] duration-150"
style={{ width: `${pct}%` }}
/>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={toggle}
className="rounded-full bg-white/10 p-2 text-white transition hover:bg-white/20"
aria-label="پخش / توقف"
>
{/* Playhead thumb — positioned outside overflow:hidden, same dir="ltr" space */}
<div
className="pointer-events-none absolute top-1/2 h-4 w-4 -translate-y-1/2 rounded-full bg-white shadow-md transition-[left] duration-150"
style={{ left: `calc(${pct}% - 8px)` }}
/>
</div>
{/* Bottom control row — dir="ltr" for physical left→right placement */}
<div dir="ltr" className="mt-1 flex items-center justify-between gap-1 px-4 pb-3">
{/* Left group: playback controls + volume + speed */}
<div className="flex items-center gap-1">
{/* Play/Pause */}
<button type="button" onClick={toggle} className="flex min-h-[44px] min-w-[44px] items-center justify-center rounded-full bg-white/10 p-2.5 text-white transition hover:bg-white/20" aria-label="پخش / توقف (Space)">
{playing ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
</button>
{/* Rewind 10s */}
<button type="button" onClick={() => { skipBack(); setSeekIndicator("back"); setTimeout(() => setSeekIndicator(null), 700); }} className="flex min-h-[44px] min-w-[44px] items-center justify-center rounded-full bg-white/10 p-2.5 text-white transition hover:bg-white/20" aria-label="بازگشت ۱۰ ثانیه (J)" title="۱۰ ثانیه عقب (J/←)">
<Rewind className="h-5 w-5" />
</button>
{/* Volume */}
<button type="button" onClick={() => setMuted((m) => !m)} className="flex min-h-[44px] min-w-[44px] items-center justify-center rounded-full bg-white/10 p-2.5 text-white transition hover:bg-white/20" aria-label="قطع صدا (M)" title="قطع/وصل صدا (M)">
<VolumeIcon className="h-5 w-5" />
</button>
<input type="range" min={0} max={1} step={0.05} value={muted ? 0 : volume} onChange={(e) => { const v = parseFloat(e.target.value); setVolume(v); if (v > 0 && muted) setMuted(false); }} className="hidden h-1 w-16 cursor-pointer accent-white opacity-80 hover:opacity-100 sm:block" aria-label="میزان صدا" />
{/* Speed pill */}
<button
type="button"
onClick={skipBack}
className="rounded-full bg-white/10 p-2 text-white transition hover:bg-white/20"
aria-label="بازگشت ۱۰ ثانیه"
title="۱۰ ثانیه عقب"
onClick={cycleSpeed}
title={`سرعت پخش: ${speed}× — کلیک برای تغییر`}
className="inline-flex min-h-[44px] items-center gap-1 rounded-full bg-white/10 px-2.5 py-2 text-xs font-bold text-white transition hover:bg-white/20"
>
<SkipBack className="h-5 w-5 scale-x-[-1]" />
<Gauge className="h-4 w-4" />
<span>{speed}×</span>
</button>
<button
type="button"
onClick={restartFromStart}
className="inline-flex items-center gap-1.5 rounded-full bg-white/10 px-3 py-2 text-xs font-bold text-white transition hover:bg-white/20"
aria-label="شروع از اول"
title="شروع از اول"
>
<RotateCcw className="h-4 w-4" />
شروع از اول
</div>
{/* Right group: watched%, theater, fullscreen */}
<div className="flex items-center gap-1">
<span className="hidden text-xs text-white/70 sm:inline">{Math.round(watchedPct)}٪</span>
{/* Theater mode */}
<button type="button" onClick={() => setTheaterMode((t) => !t)} className="hidden min-h-[44px] min-w-[44px] items-center justify-center rounded-full bg-white/10 p-2.5 text-white transition hover:bg-white/20 sm:flex" title="حالت تئاتر">
<ChevronDown className={cn("h-5 w-5 transition-transform", theaterMode && "rotate-180")} />
</button>
<button
type="button"
onClick={() => setMuted((m) => !m)}
className="rounded-full bg-white/10 p-2 text-white transition hover:bg-white/20"
aria-label="قطع صدا"
>
{muted ? <VolumeX className="h-5 w-5" /> : <Volume2 className="h-5 w-5" />}
{/* Fullscreen */}
<button type="button" onClick={toggleFullscreen} className="flex min-h-[44px] min-w-[44px] items-center justify-center rounded-full bg-white/10 p-2.5 text-white transition hover:bg-white/20" aria-label="تمام‌صفحه (F)" title="تمام‌صفحه (F)">
{fullscreen ? <Minimize className="h-5 w-5" /> : <Maximize className="h-5 w-5" />}
</button>
<span className="ms-auto text-xs text-white/70">
{Math.round(watchedPct)}٪ تماشای تاییدشده
</span>
</div>
</div>
</div>
{/* Quiz unlock CTA */}
<AnimatePresence>
{quizUnlockedNow && (
<motion.div
key="quiz-cta"
initial={{ y: 40, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
className="absolute end-4 top-4 z-30"
@@ -565,9 +779,14 @@ export function VideoPlayer({
</Button>
</motion.div>
)}
</AnimatePresence>
</div>
<div className="flex flex-wrap items-center justify-between gap-4 rounded-xl border border-border bg-card p-4">
{/* ── Progress strip below player ─── */}
<div className={cn(
"flex flex-wrap items-center justify-between gap-4 rounded-xl border border-border bg-card p-4",
theaterMode && "mx-auto max-w-5xl px-6",
)}>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">پیشرفت تماشای تاییدشده</p>
<p className="text-xs text-muted-foreground">
@@ -577,7 +796,7 @@ export function VideoPlayer({
</div>
<div className="w-40 shrink-0">
<Progress value={watchedPct} gradient smooth />
<p className="mt-1 text-end text-xs text-muted-foreground">{Math.round(watchedPct)}٪</p>
<p className="mt-1 text-right text-xs text-muted-foreground">{Math.round(watchedPct)}٪</p>
</div>
{quizUnlocked ? (
<Button asChild variant="gradient">
+5 -2
View File
@@ -68,16 +68,19 @@ export function useRealtimeGateway() {
toast.dismiss(id);
if (n.route) router.push(n.route);
}}
className="w-[min(22rem,calc(100vw-1.5rem))] rounded-xl border border-primary/40 bg-gradient-to-br from-primary/25 via-primary/12 to-accent/20 px-3 py-2.5 text-start shadow-[0_16px_40px_-16px_color-mix(in_srgb,var(--color-primary)_75%,transparent)] backdrop-blur-md"
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]">
{title}
</span>
{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}
</span>
) : null}
</span>
</button>
),
{ duration: 5000 },
+22 -2
View File
@@ -1,5 +1,14 @@
export type Role = "student" | "mentor" | "admin";
export interface Contributor {
name: string;
role: string;
bio: string;
url: string;
avatar: string;
contributions: string[];
}
export interface User {
id: number;
email: string;
@@ -33,10 +42,11 @@ export interface User {
export interface Certificate {
id: number;
userId: number;
publicId: string;
publicId?: string;
fullName: string;
issuedAt: string;
revokedAt?: string;
status: "pending" | "issued";
}
export type PhysicalOrderStatus = "requested" | "paid" | "shipped" | "cancelled";
@@ -91,6 +101,7 @@ export interface Lesson {
sortOrder: number;
xpReward: number;
isActive: boolean;
isPublished: boolean;
}
export interface Chapter {
@@ -99,6 +110,7 @@ export interface Chapter {
description: string;
icon: string;
sortOrder: number;
isPublished: boolean;
}
export interface TreeLesson {
@@ -114,6 +126,7 @@ export interface TreeLesson {
xpReward: number;
durationSeconds: number;
chapterId: number;
isPublished?: boolean;
}
export interface TreeChapter extends Chapter {
@@ -122,6 +135,9 @@ export interface TreeChapter extends Chapter {
export interface TreeData {
chapters: TreeChapter[];
totalLessons: number;
completedLessons: number;
progressPct: number;
me: { xp: number; streak: number; hearts: number; isLocked: boolean; role: Role };
}
@@ -246,12 +262,15 @@ export interface Conversation {
archived: boolean;
}
export type LeaderboardTier = "champion" | "gold" | "silver" | "bronze";
export interface LeaderboardEntry {
userId: number;
name: string;
email: string;
xp: number;
rank: number;
tier: LeaderboardTier;
avatarVariant?: "marble" | "beam" | "pixel" | "sunset" | "ring" | "bauhaus";
avatarPalette?: string;
avatarPhoto?: string;
@@ -260,7 +279,8 @@ export interface LeaderboardEntry {
export interface LeaderboardData {
week: string;
entries: LeaderboardEntry[];
me: { rank: number; xp: number; streak: number };
totalParticipants: number;
me: { rank: number; xp: number; streak: number; tier: LeaderboardTier };
}
export interface NotificationPrefs {