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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user