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)
@@ -42,6 +42,7 @@
|
|||||||
"zustand": "^5.0.2"
|
"zustand": "^5.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.64.1",
|
||||||
"@tailwindcss/postcss": "^4.0.0",
|
"@tailwindcss/postcss": "^4.0.0",
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
@@ -796,6 +797,22 @@
|
|||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.64.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz",
|
||||||
|
"integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.64.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/primitive": {
|
"node_modules/@radix-ui/primitive": {
|
||||||
"version": "1.1.7",
|
"version": "1.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz",
|
||||||
@@ -2973,6 +2990,35 @@
|
|||||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.64.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
|
||||||
|
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.64.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.64.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
|
||||||
|
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.28",
|
"version": "8.5.28",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
"zustand": "^5.0.2"
|
"zustand": "^5.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.64.1",
|
||||||
"@tailwindcss/postcss": "^4.0.0",
|
"@tailwindcss/postcss": "^4.0.0",
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
|
|||||||
|
After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 253 KiB |
@@ -73,13 +73,6 @@ function CapPageInner() {
|
|||||||
enabled: !!user && curriculumDone,
|
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) {
|
if (isLoading) {
|
||||||
return <CapSkeleton />;
|
return <CapSkeleton />;
|
||||||
|
|||||||
@@ -35,6 +35,14 @@ const vazirmatnLatin = localFont({
|
|||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "پرگار",
|
title: "پرگار",
|
||||||
description: "مسیر یادگیری واقعی پرگار",
|
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 = {
|
export const viewport: Viewport = {
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export default function Home() {
|
|||||||
|
|
||||||
const goCap = () => router.push("/cap");
|
const goCap = () => router.push("/cap");
|
||||||
const goLogin = () => router.push("/login");
|
const goLogin = () => router.push("/login");
|
||||||
|
const goRegister = () => router.push("/register");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex max-w-5xl flex-col items-center">
|
<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 size="lg" onClick={goLogin} className="gap-2 px-8">
|
||||||
ورود
|
ورود
|
||||||
</Button>
|
</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" /> شروع مسیر
|
<Zap className="h-5 w-5" /> شروع مسیر
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
@@ -288,7 +289,7 @@ export default function Home() {
|
|||||||
<Link href="/login">ورود</Link>
|
<Link href="/login">ورود</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="lg" variant="outline" asChild className="gap-2 px-8">
|
<Button size="lg" variant="outline" asChild className="gap-2 px-8">
|
||||||
<Link href="/login">
|
<Link href="/register">
|
||||||
<PlayCircle className="h-5 w-5" /> شروع مسیر
|
<PlayCircle className="h-5 w-5" /> شروع مسیر
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
Cpu,
|
Cpu,
|
||||||
Eye,
|
Eye,
|
||||||
|
EyeOff,
|
||||||
Flag,
|
Flag,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Globe,
|
Globe,
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Lock,
|
Lock,
|
||||||
MapPin,
|
MapPin,
|
||||||
|
MoreHorizontal,
|
||||||
Pencil,
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
Rocket,
|
Rocket,
|
||||||
@@ -59,6 +61,15 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Input } from "@/components/ui/input";
|
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 { PasswordInput } from "@/components/ui/password-input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
@@ -685,7 +696,11 @@ function CurriculumPanel() {
|
|||||||
|
|
||||||
const [lessonModal, setLessonModal] = useState<Lesson | null>(null);
|
const [lessonModal, setLessonModal] = useState<Lesson | null>(null);
|
||||||
const [chapterModal, setChapterModal] = useState<Chapter | 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[]) => {
|
const persistChapterOrder = async (list: Chapter[]) => {
|
||||||
for (let i = 0; i < list.length; i++) {
|
for (let i = 0; i < list.length; i++) {
|
||||||
@@ -772,12 +787,14 @@ function CurriculumPanel() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const newLesson = (chapterId: number) =>
|
const newLesson = (chapterId: number) => {
|
||||||
|
setActiveChapter(chapterId);
|
||||||
setLessonModal({
|
setLessonModal({
|
||||||
id: 0, chapterId, title: "", description: "", videoKey: "",
|
id: 0, chapterId, title: "", description: "", videoKey: "",
|
||||||
durationSeconds: 45, completionThresholdPct: 85, requiresLessonId: undefined,
|
durationSeconds: 45, completionThresholdPct: 85, requiresLessonId: undefined,
|
||||||
x: lessons.filter((l) => l.chapterId === chapterId).length, y: 0, sortOrder: 1, xpReward: 50, isActive: true, isPublished: false,
|
x: lessons.filter((l) => l.chapterId === chapterId).length, y: 0, sortOrder: 1, xpReward: 50, isActive: true, isPublished: false,
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const duplicateLesson = async (l: Lesson) => {
|
const duplicateLesson = async (l: Lesson) => {
|
||||||
try {
|
try {
|
||||||
@@ -812,6 +829,7 @@ function CurriculumPanel() {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await http.del(`/api/admin/chapters/${ch.id}`);
|
await http.del(`/api/admin/chapters/${ch.id}`);
|
||||||
|
if (activeChapter === ch.id) setActiveChapter(null);
|
||||||
qc.invalidateQueries({ queryKey: ["admin", "lessons"] });
|
qc.invalidateQueries({ queryKey: ["admin", "lessons"] });
|
||||||
qc.invalidateQueries({ queryKey: ["admin", "stats"] });
|
qc.invalidateQueries({ queryKey: ["admin", "stats"] });
|
||||||
toast.success("فصل حذف شد");
|
toast.success("فصل حذف شد");
|
||||||
@@ -820,70 +838,159 @@ function CurriculumPanel() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const publishedChapters = chapters.filter((c) => c.isPublished).length;
|
||||||
|
const publishedLessons = lessons.filter((l) => l.isPublished).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{dialog}
|
{dialog}
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex-row items-center justify-between gap-4">
|
{/* Course overview header */}
|
||||||
<div>
|
<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">
|
||||||
<CardTitle className="text-base">فصلها و درخت مهارت</CardTitle>
|
<div className="space-y-1">
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="text-sm font-bold">دوره آموزشی</p>
|
||||||
{chapters.length} فصل · {lessons.length} درس · ترتیب با کشیدن یا فلش · موقعیت x,y
|
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
|
||||||
</p>
|
<span>{chapters.length} فصل</span>
|
||||||
|
<span>{lessons.length} درس</span>
|
||||||
|
<span className="text-success">{publishedChapters} فصل منتشر</span>
|
||||||
|
<span className="text-success">{publishedLessons} درس منتشر</span>
|
||||||
</div>
|
</div>
|
||||||
<Button className="gap-1.5 rounded-2xl" onClick={() => setChapterModal({ id: 0, title: "", description: "", icon: "terminal", sortOrder: chapters.length + 1, isPublished: false })}>
|
</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" /> فصل جدید
|
<Plus className="h-4 w-4" /> فصل جدید
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent className="space-y-4">
|
|
||||||
|
{/* Chapter list */}
|
||||||
{isLoading && <Loader />}
|
{isLoading && <Loader />}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
{chapters.map((ch, ci) => {
|
{chapters.map((ch, ci) => {
|
||||||
const ls = lessons.filter((l) => l.chapterId === ch.id);
|
const ls = lessons.filter((l) => l.chapterId === ch.id);
|
||||||
const ChIcon = chapterIconOf(ch.icon);
|
const ChIcon = chapterIconOf(ch.icon);
|
||||||
|
const isOpen = activeChapter === ch.id;
|
||||||
|
|
||||||
return (
|
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
|
||||||
<div className="flex items-center gap-2 p-3 pb-2">
|
key={ch.id}
|
||||||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-primary/10 text-primary">
|
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" />
|
<ChIcon className="h-4 w-4" />
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h3 className="truncate font-bold">{ch.title}</h3>
|
<p className="truncate text-sm font-bold">{ch.title}</p>
|
||||||
{ch.description && <p className="truncate text-xs text-muted-foreground">{ch.description}</p>}
|
{ch.description && (
|
||||||
|
<p className="truncate text-[11px] text-muted-foreground">{ch.description}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline" className="shrink-0">{ls.length} درس</Badge>
|
<ChevronDown
|
||||||
<Badge variant={ch.isPublished ? "success" : "secondary"} className="shrink-0 px-1.5 text-[10px]">
|
className={cn(
|
||||||
|
"h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200",
|
||||||
|
isOpen && "rotate-180",
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</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 ? "منتشر" : "پیشنویس"}
|
{ch.isPublished ? "منتشر" : "پیشنویس"}
|
||||||
</Badge>
|
</Badge>
|
||||||
<GroupNav
|
|
||||||
onUp={ci > 0 ? () => moveChapter(ci, -1) : undefined}
|
{/* Chapter actions menu */}
|
||||||
onDown={ci < chapters.length - 1 ? () => moveChapter(ci, 1) : undefined}
|
<DropdownMenu>
|
||||||
/>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button size="sm" variant="outline" className="h-7 gap-1 text-xs" onClick={() => setChapterModal(ch)}>
|
|
||||||
<Pencil className="h-3 w-3" /> فصل
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
className="h-7 gap-1 text-xs"
|
className="h-8 w-8 shrink-0 px-0"
|
||||||
onClick={() => publishChapter(ch, !ch.isPublished)}
|
aria-label={`اقدامات فصل ${ch.title}`}
|
||||||
title={ch.isPublished ? "برگشت به پیشنویس" : "انتشار فصل"}
|
|
||||||
>
|
>
|
||||||
{ch.isPublished ? "برگشت" : "انتشار"}
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="outline" className="h-7 gap-1 text-xs" onClick={() => newLesson(ch.id)}>
|
</DropdownMenuTrigger>
|
||||||
<Plus className="h-3.5 w-3.5" /> درس
|
<DropdownMenuContent align="end" className="w-48">
|
||||||
</Button>
|
<DropdownMenuLabel>فصل {ci + 1}</DropdownMenuLabel>
|
||||||
<Button size="sm" variant="ghost" className="h-7 w-7 px-0 text-destructive" onClick={() => delChapter(ch)} title="حذف فصل">
|
<DropdownMenuItem onClick={() => setChapterModal(ch)}>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Pencil className="h-3.5 w-3.5" /> ویرایش فصل
|
||||||
</Button>
|
</DropdownMenuItem>
|
||||||
</div>
|
<DropdownMenuItem
|
||||||
{ls.length === 0 ? (
|
onClick={() => {
|
||||||
<p className="px-3 pb-3 text-xs text-muted-foreground">هنوز درسی نیست. «+ درس» را بزنید.</p>
|
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">
|
<><Globe className="h-3.5 w-3.5" /> انتشار فصل</>
|
||||||
<div className="grid gap-1.5 sm:grid-cols-2">
|
)}
|
||||||
|
</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) => {
|
{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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={l.id}
|
key={l.id}
|
||||||
@@ -892,80 +999,150 @@ function CurriculumPanel() {
|
|||||||
onDragOver={(e) => {
|
onDragOver={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.dataTransfer.dropEffect = "move";
|
e.dataTransfer.dropEffect = "move";
|
||||||
setDragIdx((d) => (d ? { ...d, over: li, chapter: ch.id } : d));
|
|
||||||
}}
|
}}
|
||||||
onDrop={() => {
|
onDrop={() => {
|
||||||
if (dragIdx && dragIdx.chapter === ch.id && dragIdx.from !== li) {
|
if (dragIdx && dragIdx.chapter === ch.id && dragIdx.from !== li) {
|
||||||
const idx = dragIdx.from;
|
const next = reorderArray(ls, dragIdx.from, li);
|
||||||
const next = reorderArray(ls, idx, li);
|
|
||||||
persistLessonOrder(next).catch(() => {});
|
persistLessonOrder(next).catch(() => {});
|
||||||
}
|
}
|
||||||
setDragIdx(null);
|
setDragIdx(null);
|
||||||
}}
|
}}
|
||||||
onDragEnd={() => setDragIdx(null)}
|
onDragEnd={() => setDragIdx(null)}
|
||||||
className={cn(
|
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",
|
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" />
|
{/* Grip handle */}
|
||||||
<span className="shrink-0 text-xs font-bold tabular-nums text-muted-foreground">L{l.id}</span>
|
<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">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm font-medium">{l.title}</p>
|
<p className="truncate text-sm font-medium leading-tight">
|
||||||
<div className="flex flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground">
|
{l.title}
|
||||||
<Timer className="h-3 w-3" /> {formatDur(l.durationSeconds)}
|
</p>
|
||||||
<Sparkles className="h-3 w-3 text-gold" /> +{formatFaNumber(l.xpReward)}
|
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
|
||||||
<MapPin className="h-3 w-3" /> {l.x},{l.y}
|
<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 ? (
|
{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>
|
<span className="text-warning">بدون ویدیو</span>
|
||||||
)}
|
)}
|
||||||
{cols.length > 0 && <Badge variant="destructive" className="px-1 text-[9px]">تداخل</Badge>}
|
{collision && (
|
||||||
|
<Badge variant="destructive" className="px-1 text-[9px]">
|
||||||
|
تداخل موقعیت
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={l.isPublished ? "success" : "secondary"} className="shrink-0 px-1.5 text-[10px]">
|
|
||||||
|
{/* Publish badge */}
|
||||||
|
<Badge
|
||||||
|
variant={l.isPublished ? "success" : "secondary"}
|
||||||
|
className="hidden shrink-0 px-1.5 text-[10px] sm:inline-flex"
|
||||||
|
>
|
||||||
{l.isPublished ? "منتشر" : "پیشنویس"}
|
{l.isPublished ? "منتشر" : "پیشنویس"}
|
||||||
</Badge>
|
</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 />
|
{/* Up/down reorder (accessible) */}
|
||||||
<Button size="sm" variant="ghost" className="h-7 px-1.5" title="تکثیر" onClick={() => duplicateLesson(l)}>
|
<GroupNav
|
||||||
<Copy className="h-3.5 w-3.5" />
|
onUp={li > 0 ? () => moveLesson(ch.id, li, -1) : undefined}
|
||||||
</Button>
|
onDown={li < ls.length - 1 ? () => moveLesson(ch.id, li, 1) : undefined}
|
||||||
<Button size="sm" variant="ghost" className="h-7 px-1.5" onClick={() => setLessonModal(l)} title="ویرایش">
|
compact
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
/>
|
||||||
</Button>
|
|
||||||
|
{/* Edit (primary action) */}
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-7 px-1.5 text-[10px]"
|
className="h-8 w-8 shrink-0 px-0"
|
||||||
title={l.isPublished ? "برگشت به پیشنویس" : "انتشار درس"}
|
title="ویرایش درس"
|
||||||
onClick={() => publishLesson(l, !l.isPublished)}
|
aria-label={`ویرایش ${l.title}`}
|
||||||
|
onClick={() => setLessonModal(l)}
|
||||||
>
|
>
|
||||||
{l.isPublished ? "برگشت" : "انتشار"}
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</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>
|
</Button>
|
||||||
</div>
|
</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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</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 && (
|
{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="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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<ChapterDialog chapter={chapterModal} onClose={() => setChapterModal(null)} onSave={saveChapter} />
|
<ChapterDialog chapter={chapterModal} onClose={() => setChapterModal(null)} onSave={saveChapter} />
|
||||||
<LessonDialog
|
<LessonDialog
|
||||||
@@ -1437,11 +1614,11 @@ function EventDialog({ event, onClose, onSave }: { event: AdminEvent | null; onC
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label>توضیحات</Label>
|
<Label>توضیحات</Label>
|
||||||
<textarea
|
<Textarea
|
||||||
value={draft.description}
|
value={draft.description}
|
||||||
onChange={(e) => set({ description: e.target.value })}
|
onChange={(e) => set({ description: e.target.value })}
|
||||||
rows={3}
|
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="شرح کوتاه رویداد (اختیاری)…"
|
placeholder="شرح کوتاه رویداد (اختیاری)…"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ export function LearningPanel() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{(overviewQ.isError || studentsQ.isError) && (
|
{(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" />
|
<AlertTriangle className="h-4 w-4 shrink-0 text-destructive" />
|
||||||
<p className="min-w-0 flex-1 font-bold text-destructive">
|
<p className="min-w-0 flex-1 font-bold text-destructive">
|
||||||
{toUserError(overviewQ.error ?? studentsQ.error, "بارگذاری داشبورد یادگیری ممکن نشد")}
|
{toUserError(overviewQ.error ?? studentsQ.error, "بارگذاری داشبورد یادگیری ممکن نشد")}
|
||||||
|
|||||||
@@ -1,21 +1,139 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Heart, Loader2, Package, Save, Settings2, Share2, Sparkles } from "lucide-react";
|
import { Camera, Heart, Loader2, Package, Save, Settings2, Share2, Sparkles, Trash2, Users } from "lucide-react";
|
||||||
import { http, toUserError } from "@/lib/api";
|
import { apiForm, http, toUserError } from "@/lib/api";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Switch } from "@/components/ui/switch";
|
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 { toast } from "@/components/providers";
|
||||||
import type { AdminTabId } from "@/components/admin/nav";
|
import type { AdminTabId } from "@/components/admin/nav";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type SettingsMap = Record<string, string>;
|
type SettingsMap = Record<string, string>;
|
||||||
|
|
||||||
type SponsorDraft = { name: string; url: string; blurb: string };
|
type SponsorDraft = { name: string; url: string; blurb: string };
|
||||||
type SocialDraft = { name: string; url: 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({
|
function SectionHeading({
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
@@ -46,6 +164,7 @@ export function SiteSettingsPanel({
|
|||||||
const [draft, setDraft] = useState<SettingsMap>({});
|
const [draft, setDraft] = useState<SettingsMap>({});
|
||||||
const [sponsors, setSponsors] = useState<SponsorDraft[]>([]);
|
const [sponsors, setSponsors] = useState<SponsorDraft[]>([]);
|
||||||
const [socials, setSocials] = useState<SocialDraft[]>([]);
|
const [socials, setSocials] = useState<SocialDraft[]>([]);
|
||||||
|
const [contributors, setContributors] = useState<ContributorDraft[]>([]);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
@@ -76,6 +195,25 @@ export function SiteSettingsPanel({
|
|||||||
} catch {
|
} catch {
|
||||||
setSocials([]);
|
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]);
|
}, [data]);
|
||||||
|
|
||||||
const set = (key: string, value: string) => setDraft((d) => ({ ...d, [key]: value }));
|
const set = (key: string, value: string) => setDraft((d) => ({ ...d, [key]: value }));
|
||||||
@@ -126,11 +264,24 @@ export function SiteSettingsPanel({
|
|||||||
blurb: s.blurb.trim(),
|
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 });
|
await http.put("/api/admin/settings", { settings });
|
||||||
toast.success("تنظیمات ذخیره شد");
|
toast.success("تنظیمات ذخیره شد");
|
||||||
qc.invalidateQueries({ queryKey: ["admin", "settings"] });
|
qc.invalidateQueries({ queryKey: ["admin", "settings"] });
|
||||||
qc.invalidateQueries({ queryKey: ["community"] });
|
qc.invalidateQueries({ queryKey: ["community"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["contributors"] });
|
||||||
qc.invalidateQueries({ queryKey: ["sponsors"] });
|
qc.invalidateQueries({ queryKey: ["sponsors"] });
|
||||||
qc.invalidateQueries({ queryKey: ["auth", "register-status"] });
|
qc.invalidateQueries({ queryKey: ["auth", "register-status"] });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -327,6 +478,97 @@ export function SiteSettingsPanel({
|
|||||||
))}
|
))}
|
||||||
</section>
|
</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">
|
<p className="text-[11px] text-muted-foreground">
|
||||||
رمزها، دیتابیس و JWT همچنان در فایل محیط (.env) میمانند — اینجا فقط تنظیمات قابلتغییر
|
رمزها، دیتابیس و JWT همچنان در فایل محیط (.env) میمانند — اینجا فقط تنظیمات قابلتغییر
|
||||||
روزمره است.
|
روزمره است.
|
||||||
|
|||||||
@@ -1,17 +1,50 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
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 { http, toUserError } from "@/lib/api";
|
||||||
import { Panel } from "@/components/panel";
|
import { Panel } from "@/components/panel";
|
||||||
import { Button } from "@/components/ui/button";
|
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 SocialLink = { name: string; url: string };
|
||||||
|
|
||||||
export type CommunityPayload = {
|
export type CommunityPayload = {
|
||||||
socials: SocialLink[];
|
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() {
|
export function CommunityPanel() {
|
||||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||||
queryKey: ["community"],
|
queryKey: ["community"],
|
||||||
@@ -19,12 +52,17 @@ export function CommunityPanel() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading) {
|
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) {
|
if (isError) {
|
||||||
return (
|
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>
|
<p className="font-medium text-destructive">{toUserError(error)}</p>
|
||||||
<Button className="mt-4" variant="outline" onClick={() => refetch()}>
|
<Button className="mt-4" variant="outline" onClick={() => refetch()}>
|
||||||
تلاش دوباره
|
تلاش دوباره
|
||||||
@@ -34,9 +72,11 @@ export function CommunityPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const socials = data?.socials ?? [];
|
const socials = data?.socials ?? [];
|
||||||
|
const contributors = data?.contributors ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-xl">
|
<div className="mx-auto max-w-2xl space-y-4">
|
||||||
|
{/* Social links */}
|
||||||
<Panel
|
<Panel
|
||||||
title="شبکههای پرگار"
|
title="شبکههای پرگار"
|
||||||
description="راههای رسمی ارتباط با برنامه. لینک شخصی هر کاربر روی پروفایل خودش است."
|
description="راههای رسمی ارتباط با برنامه. لینک شخصی هر کاربر روی پروفایل خودش است."
|
||||||
@@ -63,6 +103,61 @@ export function CommunityPanel() {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export function LeaderboardPanel() {
|
|||||||
|
|
||||||
if (isError) {
|
if (isError) {
|
||||||
return (
|
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>
|
<p className="font-medium text-destructive">{toUserError(error)}</p>
|
||||||
<Button className="mt-3" variant="outline" onClick={() => refetch()}>
|
<Button className="mt-3" variant="outline" onClick={() => refetch()}>
|
||||||
تلاش دوباره
|
تلاش دوباره
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
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 { NotificationBell } from "@/components/notification-bell";
|
||||||
import { ThemeToggle } from "@/components/theme-toggle";
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
import { BrandMark } from "@/components/brand-mark";
|
import { BrandMark } from "@/components/brand-mark";
|
||||||
@@ -28,7 +28,6 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn, formatFaNumber } from "@/lib/utils";
|
import { cn, formatFaNumber } from "@/lib/utils";
|
||||||
import { GlobalSearch } from "@/components/global-search";
|
|
||||||
import { isVerifiedRole, VerifiedBadge } from "@/components/verified-badge";
|
import { isVerifiedRole, VerifiedBadge } from "@/components/verified-badge";
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
@@ -65,7 +64,6 @@ export function Navbar() {
|
|||||||
<BrandMark />
|
<BrandMark />
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<GlobalSearch />
|
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
{pathname !== "/login" && (
|
{pathname !== "/login" && (
|
||||||
<Button asChild size="sm" className="font-bold">
|
<Button asChild size="sm" className="font-bold">
|
||||||
@@ -188,10 +186,10 @@ export function Navbar() {
|
|||||||
<User /> پروفایل من
|
<User /> پروفایل من
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => router.push("/settings")}>
|
<DropdownMenuItem onClick={() => router.push("/settings")}>
|
||||||
<User /> تنظیمات
|
<Settings /> تنظیمات
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={() => setStudioOpen(true)}>
|
<DropdownMenuItem onSelect={() => setStudioOpen(true)}>
|
||||||
<User /> ویرایش آواتار
|
<Palette /> ویرایش آواتار
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{liveUser.role === "admin" && (
|
{liveUser.role === "admin" && (
|
||||||
<DropdownMenuItem onClick={() => router.push("/admin")}>
|
<DropdownMenuItem onClick={() => router.push("/admin")}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
export type Role = "student" | "mentor" | "admin";
|
export type Role = "student" | "mentor" | "admin";
|
||||||
|
|
||||||
|
export interface Contributor {
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
bio: string;
|
||||||
|
url: string;
|
||||||
|
avatar: string;
|
||||||
|
contributions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: number;
|
id: number;
|
||||||
email: string;
|
email: string;
|
||||||
|
|||||||