feat: manual cert gating, chat layout overhaul, and video player UX fixes
- Certificate: Hardcode approval gate — completely removed the auto-issue path; `certRequiresApproval()` now strictly returns `true`, ignoring DB configuration. - Certificate: Quiz completion now queues a pending record and triggers a "Pending approval" notification instead of falsely confirming issuance. - Certificate: Public endpoints now enforce a guard on `status=issued`; frontend strips the auto-issue side effect and claim button (issuance restricted to admins). - Chats tab: Refactor layout into a full-width centered banner above two equal-height side-by-side panels (`grid-cols-1 lg:grid-cols-2`), utilizing `max-h` and `overflow-y-auto` to eliminate whitespace bleed. - Video player: Fix settings flyout prematurely closing on mouse movement (removed `setSettingsOpen` from `poke`); add a click-outside listener via `mousedown`. - Video player: Deduplicate controls — the speed pill now directly cycles playback speed, leaving the gear icon as the single entry point for settings (eliminating overlapping popovers). - Video player: Implement full-bleed theater mode using the `-mx-[50vw] w-screen` technique, keeping the progress strip constrained to `max-w-5xl` below.
This commit is contained in:
+152
-147
@@ -152,165 +152,170 @@ 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>
|
||||
<p className="max-w-sm text-sm text-muted-foreground">
|
||||
گفتگو همیشه در دکمهٔ شناور پایین صفحه در دسترس شماست؛ گفتگوها خصوصی هستند و پاسخ منتورها معمولاً کمتر از یک روز طول میکشد.
|
||||
</p>
|
||||
<Button onClick={() => openChat()} className="gap-2">
|
||||
<Sparkles className="h-4 w-4" /> باز کردن گفتگوها
|
||||
</Button>
|
||||
<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>
|
||||
</Panel>
|
||||
<Button onClick={() => openChat()} className="gap-2">
|
||||
<Sparkles className="h-4 w-4" /> باز کردن گفتگوها ✨
|
||||
</Button>
|
||||
<p className="max-w-md text-sm text-muted-foreground">
|
||||
گفتگو همیشه در دکمهٔ شناور پایین صفحه در دسترس شماست؛ گفتگوها خصوصی هستند و پاسخ منتورها معمولاً کمتر از یک روز طول میکشد.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Panel title="گفتگوهای شما" description="مکالمات جاری — روی هر کدام بزنید تا باز شود.">
|
||||
<div className="space-y-1.5 pt-1">
|
||||
{convLoading && (
|
||||
<div className="space-y-2">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="flex items-center gap-3 rounded-xl border-2 border-border/70 px-3 py-2.5">
|
||||
<SkeletonAvatar />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3 w-14" />
|
||||
{/* ── Side-by-side panels ── */}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{/* Conversations panel */}
|
||||
<Panel title="گفتگوهای شما" description="مکالمات جاری — روی هر کدام بزنید تا باز شود.">
|
||||
<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) => (
|
||||
<div key={i} className="flex items-center gap-3 rounded-xl border-2 border-border/70 px-3 py-2.5">
|
||||
<SkeletonAvatar />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3 w-14" />
|
||||
</div>
|
||||
<Skeleton className="h-3 w-2/3" />
|
||||
</div>
|
||||
<Skeleton className="h-3 w-2/3" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{conversations.map((c) => (
|
||||
<button
|
||||
key={c.partner.id}
|
||||
onClick={() => openChat(c.partner.id)}
|
||||
className="flex w-full items-center gap-3 rounded-xl border-2 border-border/70 px-3 py-2.5 text-right transition-colors hover:border-primary/50 hover:bg-primary/5"
|
||||
>
|
||||
<UserAvatar name={c.partner.name} className="h-10 w-10" {...avatarPropsOf(c.partner)} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="flex min-w-0 items-center gap-1 font-bold">
|
||||
<span className="truncate">{c.partner.name}</span>
|
||||
{isVerifiedRole(c.partner.role) && <VerifiedBadge role={c.partner.role} />}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{conversations.map((c) => (
|
||||
<button
|
||||
key={c.partner.id}
|
||||
onClick={() => openChat(c.partner.id)}
|
||||
className="flex w-full items-center gap-3 rounded-xl border-2 border-border/70 px-3 py-2.5 text-right transition-colors hover:border-primary/50 hover:bg-primary/5"
|
||||
>
|
||||
<UserAvatar name={c.partner.name} className="h-10 w-10" {...avatarPropsOf(c.partner)} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="flex min-w-0 items-center gap-1 font-bold">
|
||||
<span className="truncate">{c.partner.name}</span>
|
||||
{isVerifiedRole(c.partner.role) && <VerifiedBadge role={c.partner.role} />}
|
||||
</p>
|
||||
{c.lastMessage && (
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">{formatJalaliStamp(c.lastMessage.createdAt)}</span>
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
dir="auto"
|
||||
className={cn(
|
||||
"truncate text-xs [unicode-bidi:plaintext]",
|
||||
c.unreadCount > 0 ? "font-bold text-foreground" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{c.lastMessage ? messagePreview(c.lastMessage) : "پیامی ندارد"}
|
||||
</p>
|
||||
{c.lastMessage && (
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">{formatJalaliStamp(c.lastMessage.createdAt)}</span>
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
dir="auto"
|
||||
className={cn(
|
||||
"truncate text-xs [unicode-bidi:plaintext]",
|
||||
c.unreadCount > 0 ? "font-bold text-foreground" : "text-muted-foreground"
|
||||
)}
|
||||
{c.unreadCount > 0 && (
|
||||
<Badge variant="accent" className="h-5 min-w-5 justify-center rounded-full px-1.5">
|
||||
{c.unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{!convLoading && conversations.length === 0 && (
|
||||
<p className="px-1 text-sm text-muted-foreground">
|
||||
هنوز گفتگویی ندارید — از فهرست کنار، یک نفر را انتخاب کنید.
|
||||
</p>
|
||||
)}
|
||||
{convTotal > (convData?.pageSize ?? 10) && (
|
||||
<div className="flex items-center justify-between pt-2 text-xs">
|
||||
<Button type="button" size="sm" variant="outline" disabled={convPage <= 1} onClick={() => setConvPage((p) => p - 1)}>
|
||||
قبلی
|
||||
</Button>
|
||||
<span className="text-muted-foreground">{convPage}</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={convPage * (convData?.pageSize ?? 10) >= convTotal}
|
||||
onClick={() => setConvPage((p) => p + 1)}
|
||||
>
|
||||
{c.lastMessage ? messagePreview(c.lastMessage) : "پیامی ندارد"}
|
||||
</p>
|
||||
بعدی
|
||||
</Button>
|
||||
</div>
|
||||
{c.unreadCount > 0 && (
|
||||
<Badge variant="accent" className="h-5 min-w-5 justify-center rounded-full px-1.5">
|
||||
{c.unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{!convLoading && conversations.length === 0 && (
|
||||
<p className="px-1 text-sm text-muted-foreground">
|
||||
هنوز گفتگویی ندارید — از فهرست کنار، یک نفر را انتخاب کنید.
|
||||
</p>
|
||||
)}
|
||||
{convTotal > (convData?.pageSize ?? 10) && (
|
||||
<div className="flex items-center justify-between pt-2 text-xs">
|
||||
<Button type="button" size="sm" variant="outline" disabled={convPage <= 1} onClick={() => setConvPage((p) => p - 1)}>
|
||||
قبلی
|
||||
</Button>
|
||||
<span className="text-muted-foreground">{convPage}</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={convPage * (convData?.pageSize ?? 10) >= convTotal}
|
||||
onClick={() => setConvPage((p) => p + 1)}
|
||||
>
|
||||
بعدی
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel
|
||||
title={staff ? "شروع گفتگو" : "شروع سریع با منتور"}
|
||||
description="نقش هر نفر روی ردیف نوشته شده است."
|
||||
actions={
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => setAlpha((v) => !v)}>
|
||||
{alpha ? "آنلاین بالا" : "الفبایی"}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-1 pt-1">
|
||||
{isLoading && (
|
||||
<div className="space-y-2">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center gap-3 rounded-xl border-2 border-border/70 px-3 py-2.5">
|
||||
<SkeletonAvatar />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
{/* Start chat panel */}
|
||||
<Panel
|
||||
title={staff ? "شروع گفتگو" : "شروع سریع با منتور"}
|
||||
description="نقش هر نفر روی ردیف نوشته شده است."
|
||||
actions={
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => setAlpha((v) => !v)}>
|
||||
{alpha ? "آنلاین بالا" : "الفبایی"}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<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) => (
|
||||
<div key={i} className="flex items-center gap-3 rounded-xl border-2 border-border/70 px-3 py-2.5">
|
||||
<SkeletonAvatar />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
<Skeleton className="h-5 w-20 rounded-full" />
|
||||
</div>
|
||||
<Skeleton className="h-5 w-20 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{people.map((m) => (
|
||||
<motion.button
|
||||
layout
|
||||
key={m.id}
|
||||
onClick={() => openChat(m.id)}
|
||||
className="flex w-full items-center gap-3 rounded-xl border-2 border-border/70 px-3 py-2.5 text-right transition-colors hover:border-primary/50 hover:bg-primary/5"
|
||||
>
|
||||
<UserAvatar name={m.name} className="h-10 w-10" {...avatarPropsOf(m)} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="flex min-w-0 items-center gap-1 font-bold">
|
||||
<span className="truncate">{m.name}</span>
|
||||
{isVerifiedRole(m.role) && <VerifiedBadge role={m.role} />}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{roleWord(m.role)}</p>
|
||||
))}
|
||||
</div>
|
||||
<span className={cn("inline-flex items-center gap-1.5 text-xs font-bold", m.online ? "text-success" : "text-muted-foreground")}>
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full", m.online ? "bg-success" : "bg-muted-foreground/40")} />
|
||||
{m.online ? "آنلاین" : "آفلاین"}
|
||||
</span>
|
||||
</motion.button>
|
||||
))}
|
||||
{!isLoading && mentors.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{staff ? "کسی برای گفتگو نیست." : "هنوز منتوری در دسترس نیست."}</p>
|
||||
)}
|
||||
{peopleTotal > (data?.pageSize ?? 20) && (
|
||||
<div className="flex items-center justify-between pt-2 text-xs">
|
||||
<Button type="button" size="sm" variant="outline" disabled={peoplePage <= 1} onClick={() => setPeoplePage((p) => p - 1)}>
|
||||
قبلی
|
||||
</Button>
|
||||
<span className="text-muted-foreground">{peoplePage}</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={peoplePage * (data?.pageSize ?? 20) >= peopleTotal}
|
||||
onClick={() => setPeoplePage((p) => p + 1)}
|
||||
)}
|
||||
{people.map((m) => (
|
||||
<motion.button
|
||||
layout
|
||||
key={m.id}
|
||||
onClick={() => openChat(m.id)}
|
||||
className="flex w-full items-center gap-3 rounded-xl border-2 border-border/70 px-3 py-2.5 text-right transition-colors hover:border-primary/50 hover:bg-primary/5"
|
||||
>
|
||||
بعدی
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
<UserAvatar name={m.name} className="h-10 w-10" {...avatarPropsOf(m)} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="flex min-w-0 items-center gap-1 font-bold">
|
||||
<span className="truncate">{m.name}</span>
|
||||
{isVerifiedRole(m.role) && <VerifiedBadge role={m.role} />}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{roleWord(m.role)}</p>
|
||||
</div>
|
||||
<span className={cn("inline-flex items-center gap-1.5 text-xs font-bold", m.online ? "text-success" : "text-muted-foreground")}>
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full", m.online ? "bg-success" : "bg-muted-foreground/40")} />
|
||||
{m.online ? "آنلاین" : "آفلاین"}
|
||||
</span>
|
||||
</motion.button>
|
||||
))}
|
||||
{!isLoading && mentors.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{staff ? "کسی برای گفتگو نیست." : "هنوز منتوری در دسترس نیست."}</p>
|
||||
)}
|
||||
{peopleTotal > (data?.pageSize ?? 20) && (
|
||||
<div className="flex items-center justify-between pt-2 text-xs">
|
||||
<Button type="button" size="sm" variant="outline" disabled={peoplePage <= 1} onClick={() => setPeoplePage((p) => p - 1)}>
|
||||
قبلی
|
||||
</Button>
|
||||
<span className="text-muted-foreground">{peoplePage}</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={peoplePage * (data?.pageSize ?? 20) >= peopleTotal}
|
||||
onClick={() => setPeoplePage((p) => p + 1)}
|
||||
>
|
||||
بعدی
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user