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
+243 -2
View File
@@ -2,7 +2,7 @@
import { useRouter, useSearchParams } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Suspense, useEffect, useRef } from "react";
import { Suspense, useEffect, useLayoutEffect, useRef, useState } from "react";
import {
Award,
ArrowLeft,
@@ -28,6 +28,7 @@ import { useAuth } from "@/lib/auth-store";
import { cn, formatFaNumber, formatStreakLabel } from "@/lib/utils";
import { clampHearts, hasInfiniteHearts, INFINITE_HEARTS_LABEL, MAX_HEARTS } from "@/lib/hearts";
import { toast } from "@/components/providers";
import { SafeMarkdown } from "@/components/safe-markdown";
export default function CapPage() {
return (
@@ -150,6 +151,8 @@ function CapPageInner() {
</div>
</div>
<PinnedAnnouncement />
{lowHearts && (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border-2 border-destructive/30 bg-destructive/5 px-5 py-4">
<div className="flex items-start gap-3">
@@ -157,7 +160,7 @@ function CapPageInner() {
<div>
<p className="text-sm font-extrabold text-destructive">فقط {formatFaNumber(hearts)} جان مانده</p>
<p className="text-xs text-muted-foreground">
یک اشتباه دیگر حساب را قفل می‌کند. قبل از آزمون مطمئن شوید؛ اگر گیر کردید با منتور حرف بزنید.
جان‌ها خودشان برمی‌گردند. تا برگشتن یک جان، آزمون تازه صبر می‌کند.
</p>
</div>
</div>
@@ -450,6 +453,244 @@ function CapSkeleton() {
);
}
type CapAnnouncement = { title: string; body: string; route: string };
function PinnedAnnouncement() {
const router = useRouter();
const scroller = useRef<HTMLDivElement>(null);
const gesture = useRef<{
x: number;
y: number;
scroll: number;
index: number;
pointerId: number;
axis: "x" | "y" | null;
dx: number;
} | null>(null);
const moved = useRef(0);
const [index, setIndex] = useState(0);
const q = useQuery({
queryKey: ["announcement-pinned"],
queryFn: () =>
http.get<{ announcement: CapAnnouncement | null; announcements?: CapAnnouncement[] }>("/api/announcements/pinned"),
});
const items = q.data?.announcements?.length
? q.data.announcements
: q.data?.announcement
? [q.data.announcement]
: [];
const count = items.length;
const indexRef = useRef(0);
const hoverRef = useRef(false);
const downRef = useRef(false);
const programmatic = useRef(0);
const scrollSeq = useRef(0);
const wantScroll = useRef(false);
const scrollBehavior = useRef<ScrollBehavior>("smooth");
const [epoch, setEpoch] = useState(0);
const stride = (el: HTMLDivElement) => {
const card = el.firstElementChild as HTMLElement | null;
const gap = 12;
return (card?.offsetWidth || el.clientWidth || 1) + gap;
};
const goTo = (next: number, behavior: ScrollBehavior = "smooth") => {
if (count < 2) return;
const i = ((next % count) + count) % count;
const from = indexRef.current;
const neighbor = Math.abs(i - from) === 1;
scrollBehavior.current = neighbor && behavior === "smooth" ? "smooth" : "auto";
wantScroll.current = true;
indexRef.current = i;
setIndex(i);
setEpoch((n) => n + 1);
};
const stepBy = (delta: number) => {
const from = indexRef.current;
const dest = from + delta < 0 ? count - 1 : from + delta >= count ? 0 : from + delta;
goTo(dest, Math.abs(dest - from) === 1 ? "smooth" : "auto");
};
const stepRef = useRef(stepBy);
stepRef.current = stepBy;
useLayoutEffect(() => {
const el = scroller.current;
if (!el || count < 2 || !wantScroll.current) return;
wantScroll.current = false;
const token = scrollSeq.current + 1;
scrollSeq.current = token;
programmatic.current = token;
const left = indexRef.current * stride(el);
let settledOnce = false;
const finish = (force: boolean) => {
if (settledOnce || scrollSeq.current !== token) return;
if (gesture.current?.axis === "x") return;
const target = indexRef.current * stride(el);
const settled = Math.abs(el.scrollLeft - target) <= 2;
if (!settled && !force) return;
if (!settled) {
el.style.scrollBehavior = "auto";
el.scrollLeft = target;
}
settledOnce = true;
if (programmatic.current === token) programmatic.current = 0;
el.removeEventListener("scrollend", onEnd);
};
const onEnd = () => {
if (scrollSeq.current !== token || !programmatic.current) return;
finish(false);
};
el.addEventListener("scrollend", onEnd);
el.style.scrollBehavior = scrollBehavior.current === "smooth" ? "smooth" : "auto";
el.scrollTo({ left, behavior: scrollBehavior.current });
const soon = window.setTimeout(() => finish(false), scrollBehavior.current === "auto" ? 40 : 700);
const later = window.setTimeout(() => finish(true), 1200);
return () => {
window.clearTimeout(soon);
window.clearTimeout(later);
el.removeEventListener("scrollend", onEnd);
};
}, [index, epoch, count]);
useEffect(() => {
if (count < 2) return;
const id = window.setInterval(() => {
if (hoverRef.current || downRef.current) return;
stepRef.current(1);
}, 6000);
return () => window.clearInterval(id);
}, [count, epoch]);
if (count === 0) return null;
const hold = (hover: boolean | null, down: boolean | null) => {
if (hover != null) hoverRef.current = hover;
if (down != null) downRef.current = down;
};
const go = (next: number) => goTo(next);
const onTrackPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
const el = scroller.current;
if (!el) return;
gesture.current = {
x: e.clientX,
y: e.clientY,
scroll: el.scrollLeft,
index: indexRef.current,
pointerId: e.pointerId,
axis: null,
dx: 0,
};
moved.current = 0;
};
const onTrackPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
const g = gesture.current;
if (!g || g.pointerId !== e.pointerId) return;
const dx = e.clientX - g.x;
const dy = e.clientY - g.y;
g.dx = dx;
if (g.axis == null) {
if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return;
g.axis = Math.abs(dx) > Math.abs(dy) ? "x" : "y";
}
if (g.axis !== "x" || count < 2) return;
const el = scroller.current;
if (!el) return;
if (!el.hasPointerCapture(e.pointerId)) el.setPointerCapture(e.pointerId);
const width = stride(el);
const max = width * (count - 1);
const pulled = g.scroll - dx;
const limited = Math.min(g.scroll + width, Math.max(g.scroll - width, pulled));
el.style.scrollBehavior = "auto";
el.scrollLeft = Math.min(max, Math.max(0, limited));
};
const onTrackPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
const g = gesture.current;
if (!g || g.pointerId !== e.pointerId) return;
moved.current = Math.max(Math.abs(g.dx), Math.abs(e.clientY - g.y));
const el = scroller.current;
if (el?.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);
if (g.axis !== "x" || count < 2) {
gesture.current = null;
if (el) el.style.scrollBehavior = "";
return;
}
const threshold = 40;
let next = g.index;
if (g.dx <= -threshold) next = g.index + 1;
else if (g.dx >= threshold) next = g.index - 1;
go(next);
gesture.current = null;
};
return (
<div
className="relative"
onPointerEnter={() => hold(true, null)}
onPointerLeave={() => {
if (gesture.current?.axis === "x") {
hold(false, null);
return;
}
hold(false, false);
setEpoch((n) => n + 1);
}}
onPointerDown={() => hold(null, true)}
onPointerUp={() => hold(null, false)}
onPointerCancel={() => hold(null, false)}
>
<div
ref={scroller}
dir="ltr"
style={{ direction: "ltr" }}
className="flex gap-3 touch-pan-y select-none overflow-hidden [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
onPointerDown={onTrackPointerDown}
onPointerMove={onTrackPointerMove}
onPointerUp={onTrackPointerUp}
onPointerCancel={onTrackPointerUp}
>
{items.map((a, i) => (
<button
key={`${a.title}-${i}`}
type="button"
dir="rtl"
draggable={false}
onClick={() => {
if (Math.abs(moved.current) > 12) return;
if (a.route) router.push(a.route);
}}
className="block w-[calc(100%-2rem)] min-w-[calc(100%-2rem)] max-w-[calc(100%-2rem)] shrink-0 touch-pan-y rounded-2xl border-2 border-primary/30 bg-primary/5 px-5 py-4 text-start"
>
<p className="text-xs font-bold text-primary">اطلاعیه</p>
<p className="mt-1 text-base font-black">{a.title}</p>
<SafeMarkdown text={a.body} lines={3} className="mt-1 text-sm text-muted-foreground" />
</button>
))}
</div>
{items.length > 1 && (
<div className="mt-2 flex items-center justify-center gap-1.5">
{items.map((a, i) => (
<button
key={`${a.title}-dot-${i}`}
type="button"
aria-label={`اطلاعیه ${i + 1}`}
onClick={() => go(i)}
className={cn("h-1.5 rounded-full transition-all", i === index ? "w-4 bg-primary" : "w-1.5 bg-primary/30")}
/>
))}
</div>
)}
</div>
);
}
function ZapIcon(props: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>