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)
This commit is contained in:
ahp
2026-10-20 17:51:23 +03:30
parent 161610049c
commit 7716c1677c
18 changed files with 791 additions and 128 deletions
+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>
);
}