feat: release v0.2.0 profiles, chat, freeze, and bots

Ship the product pass on the v0.1.3 schema with additive migrations, a replica migration lock, and bot accounts kept out of people lists.
This commit is contained in:
ahp
2026-10-10 16:42:35 +03:30
parent 8586bbafcc
commit 22d92c133d
85 changed files with 10510 additions and 1341 deletions
+145 -20
View File
@@ -1,14 +1,19 @@
"use client";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
Bell,
BookOpen,
Calendar,
Eye,
Gamepad2,
Loader2,
Megaphone,
Route,
Settings2,
Trash2,
TrendingUp,
Users,
} from "lucide-react";
@@ -28,14 +33,28 @@ import {
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { toast } from "@/components/providers";
import { SafeMarkdown } from "@/components/safe-markdown";
const CATEGORIES = [
{ id: "event", label: "رویداد", icon: Calendar, hint: "جلسه زنده، کارگاه، ددلاین" },
{ id: "progress", label: "پیشرفت", icon: TrendingUp, hint: "درس جدید، فصل بازشده" },
{ id: "gamification", label: "بازی‌سازی", icon: Gamepad2, hint: "جایزه، استریک، چالش" },
{ id: "mentor", label: "منتور", icon: Users, hint: "پیام مستقیم تیم منتور" },
{ id: "urgent", label: "فوری", icon: AlertTriangle, hint: "نیاز به توجه همین حالا" },
{ id: "curriculum", label: "درس", icon: BookOpen, hint: "محتوای آموزشی" },
{ id: "community", label: "جامعه", icon: Users, hint: "فضای مشترک دوره" },
{ id: "system", label: "سیستم", icon: Settings2, hint: "وضعیت خود پرگار" },
] as const;
type Announcement = {
id: number;
title: string;
body: string;
category: string;
route: string;
pinned: boolean;
};
const ROUTES = [
{ path: "/unwrap", label: "نقشه مسیر" },
{ path: "/notifications", label: "اعلان‌ها" },
@@ -43,6 +62,20 @@ const ROUTES = [
{ path: "/cap", label: "داشبورد" },
];
function announcementExcerpt(body: string) {
const plain = body
.replace(/```[\s\S]*?```/g, " ")
.replace(/`([^`]*)`/g, "$1")
.replace(/!\[[^\]]*]\([^)]*\)/g, " ")
.replace(/\[([^\]]*)]\([^)]*\)/g, "$1")
.replace(/^#{1,6}\s+/gm, "")
.replace(/[*_~>]+/g, "")
.replace(/\s+/g, " ")
.trim();
if (plain.length <= 180) return plain;
return `${plain.slice(0, 180).trimEnd()}…`;
}
function PreviewCard({
title,
body,
@@ -66,9 +99,10 @@ function PreviewCard({
</Badge>
</div>
<p className="mt-2.5 text-sm font-black leading-snug">{title.trim() || "عنوان اطلاعیه"}</p>
<p className="mt-1 min-h-[2.5rem] text-xs leading-relaxed text-muted-foreground">
{body.trim() || "متن پیام اینجا دیده می‌شود…"}
</p>
<SafeMarkdown
text={body.trim() || "متن پیام اینجا دیده می‌شود…"}
className="mt-1 min-h-[2.5rem] space-y-1 text-xs leading-relaxed text-muted-foreground"
/>
<p className="mt-2.5 truncate text-[10px] font-mono text-primary" dir="ltr">
→ {route || "/"}
</p>
@@ -80,8 +114,15 @@ export function AnnouncePanel() {
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [category, setCategory] = useState<string>("event");
const [route, setRoute] = useState("/unwrap");
const [route, setRoute] = useState("");
const [pinned, setPinned] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [busy, setBusy] = useState(false);
const qc = useQueryClient();
const listQ = useQuery({
queryKey: ["admin-announcements"],
queryFn: () => http.get<{ announcements: Announcement[] }>("/api/admin/announcements"),
});
const [confirmOpen, setConfirmOpen] = useState(false);
const [lastSent, setLastSent] = useState<{ title: string; recipients: number } | null>(null);
@@ -93,17 +134,21 @@ export function AnnouncePanel() {
if (!valid) return;
setBusy(true);
try {
const res = await http.post<{ recipients: number }>("/api/admin/announce", {
title: title.trim(),
body: body.trim(),
category,
route,
});
toast.success(`اطلاعیه برای ${res.recipients} نفر ارسال شد`);
setLastSent({ title: title.trim(), recipients: res.recipients });
const payload = { title: title.trim(), body: body.trim(), category, route: route.trim(), pinned };
if (editingId) {
await http.put(`/api/admin/announcements/${editingId}`, payload);
toast.success("اطلاعیه به‌روز شد");
} else {
const res = await http.post<{ recipients: number }>("/api/admin/announce", payload);
toast.success(`اطلاعیه برای ${res.recipients} نفر ارسال شد`);
setLastSent({ title: title.trim(), recipients: res.recipients });
}
setTitle("");
setBody("");
setPinned(false);
setEditingId(null);
setConfirmOpen(false);
void qc.invalidateQueries({ queryKey: ["admin-announcements"] });
} catch (e) {
toast.error(toUserError(e, "ارسال اطلاعیه ممکن نشد"));
} finally {
@@ -113,7 +158,8 @@ export function AnnouncePanel() {
return (
<>
<div className="grid items-start gap-4 lg:grid-cols-[minmax(0,1fr)_20rem]">
<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>
@@ -136,13 +182,13 @@ export function AnnouncePanel() {
<div className="space-y-2">
<div className="flex h-5 items-center justify-between gap-3">
<Label className="text-sm font-bold">پیام</Label>
<span className="text-[11px] tabular-nums text-muted-foreground">{body.length}/۵۰۰</span>
<span className="text-[11px] tabular-nums text-muted-foreground">{body.length}/۴۰۰۰</span>
</div>
<textarea
value={body}
onChange={(e) => setBody(e.target.value.slice(0, 500))}
onChange={(e) => setBody(e.target.value.slice(0, 4000))}
rows={5}
maxLength={500}
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"
@@ -190,8 +236,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>
<div className="flex flex-wrap gap-2">
{ROUTES.map((r) => (
<button
@@ -214,8 +261,12 @@ export function AnnouncePanel() {
onChange={(e) => setRoute(e.target.value)}
dir="ltr"
className="h-10 rounded-2xl font-mono text-xs"
placeholder="/unwrap"
placeholder="خالی مجاز است"
/>
<label className="flex items-center gap-2 text-sm font-bold">
<input type="checkbox" checked={pinned} onChange={(e) => setPinned(e.target.checked)} />
سنجاق روی درخت مهارت
</label>
</div>
<div className="flex flex-wrap items-center gap-3 border-t border-border pt-4">
@@ -235,7 +286,7 @@ export function AnnouncePanel() {
</CardContent>
</Card>
<div className="space-y-4 lg:sticky lg:top-24">
<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">
@@ -260,6 +311,80 @@ export function AnnouncePanel() {
</div>
</div>
<Card data-announce-list className="rounded-2xl border border-border">
<CardHeader>
<CardTitle className="text-base">اطلاعیه‌های ثبت‌شده</CardTitle>
</CardHeader>
<CardContent>
{(listQ.data?.announcements ?? []).length > 0 ? (
<ul className="flex list-none flex-col gap-3 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">
<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}
</div>
{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
type="button"
size="sm"
variant="outline"
onClick={() => {
setEditingId(a.id);
setTitle(a.title);
setBody(a.body);
setCategory(a.category);
setRoute(a.route);
setPinned(a.pinned);
}}
>
ویرایش
</Button>
<Button
type="button"
size="sm"
variant="outline"
aria-label="حذف"
onClick={() => {
void http
.del(`/api/admin/announcements/${a.id}`)
.then(() => qc.invalidateQueries({ queryKey: ["admin-announcements"] }))
.catch((e) => toast.error(toUserError(e, "حذف ممکن نشد")));
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</li>
);
})}
</ul>
) : !listQ.isPending ? (
<p className="rounded-2xl border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
هنوز اطلاعیه‌ای ثبت نشده است.
</p>
) : null}
</CardContent>
</Card>
</div>
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent className="max-w-md gap-0 overflow-hidden p-0 sm:rounded-2xl">
<DialogHeader className="space-y-1 border-b border-border px-6 py-4 text-start">
@@ -275,7 +400,7 @@ export function AnnouncePanel() {
</Button>
<Button className="h-10 gap-2" disabled={busy || !valid} onClick={() => void send()}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Bell className="h-4 w-4" />}
تأیید و ارسال
{editingId ? "تأیید و ذخیره" : "تأیید و ارسال"}
</Button>
</DialogFooter>
</DialogContent>