feat: release v0.1.3 chat, profiles, and media

Keep read receipts for the other person only, and let photos, voice, and files stay cached until they are actually replaced.
This commit is contained in:
ahp
2026-10-09 19:05:59 +03:30
parent 3cce2158e9
commit d9a2abb44f
45 changed files with 2337 additions and 573 deletions
+15 -77
View File
@@ -1,10 +1,10 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { CheckCircle2, Loader2, RefreshCw, ShieldCheck, Timer, Zap } from "lucide-react";
import { CheckCircle2, Loader2, ShieldCheck, Timer, Zap } from "lucide-react";
import { BrandMark } from "@/components/brand-mark";
import { useAuth } from "@/lib/auth-store";
import { http, toUserError } from "@/lib/api";
@@ -15,6 +15,7 @@ import { PasswordInput } from "@/components/ui/password-input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { CaptchaField, type CaptchaChallenge } from "@/components/captcha-field";
const perks = [
{ icon: Timer, text: "زمان تماشای راستی‌آزمایی‌شده — بدون میان‌بر" },
@@ -22,11 +23,6 @@ const perks = [
{ icon: CheckCircle2, text: "لیگ هفتگی، رویداد زنده و منتور یک‌به‌یک" },
];
type CaptchaChallenge = {
challengeId: string;
imageBase64: string;
};
export default function LoginPage() {
const router = useRouter();
const setSession = useAuth((s) => s.setSession);
@@ -34,7 +30,7 @@ export default function LoginPage() {
const [password, setPassword] = useState("");
const [captchaAnswer, setCaptchaAnswer] = useState("");
const [challenge, setChallenge] = useState<CaptchaChallenge | null>(null);
const [captchaLoading, setCaptchaLoading] = useState(true);
const [captchaKey, setCaptchaKey] = useState(0);
const [rememberMe, setRememberMe] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -44,29 +40,11 @@ export default function LoginPage() {
queryFn: () => http.get<{ enabled: boolean }>("/api/auth/register-status"),
});
const loadCaptcha = useCallback(async () => {
setCaptchaLoading(true);
setCaptchaAnswer("");
try {
const data = await http.get<CaptchaChallenge>("/api/auth/captcha");
setChallenge(data);
} catch (err) {
setChallenge(null);
setError(toUserError(err, "بارگذاری کد امنیتی ممکن نشد"));
} finally {
setCaptchaLoading(false);
}
}, []);
useEffect(() => {
void loadCaptcha();
}, [loadCaptcha]);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!challenge) {
setError("ابتدا کد امنیتی را بارگذاری کنید");
void loadCaptcha();
setCaptchaKey((k) => k + 1);
return;
}
setLoading(true);
@@ -83,7 +61,7 @@ export default function LoginPage() {
router.push("/cap");
} catch (err) {
setError(toUserError(err, "ورود ناموفق بود"));
void loadCaptcha();
setCaptchaKey((k) => k + 1);
} finally {
setLoading(false);
}
@@ -153,54 +131,14 @@ export default function LoginPage() {
placeholder="رمز عبور"
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label htmlFor="captcha">کد امنیتی</Label>
<button
type="button"
onClick={() => void loadCaptcha()}
disabled={captchaLoading}
className="inline-flex items-center gap-1 text-xs font-medium text-primary underline-offset-4 hover:underline disabled:opacity-50"
>
<RefreshCw className={`h-3.5 w-3.5 ${captchaLoading ? "animate-spin" : ""}`} />
تازه‌سازی
</button>
</div>
<div className="flex items-stretch gap-3">
<div
className="relative flex h-14 min-w-[9.5rem] shrink-0 items-center justify-center overflow-hidden rounded-xl border border-border bg-muted/60 shadow-inner"
aria-live="polite"
>
{captchaLoading || !challenge?.imageBase64 ? (
<span className="text-sm text-muted-foreground">…</span>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`data:image/png;base64,${challenge.imageBase64}`}
alt="کد امنیتی"
width={160}
height={52}
className="h-full w-full object-contain select-none"
draggable={false}
/>
)}
</div>
<Input
id="captcha"
type="text"
autoComplete="off"
autoCapitalize="characters"
spellCheck={false}
value={captchaAnswer}
onChange={(e) => setCaptchaAnswer(e.target.value.toUpperCase())}
required
dir="ltr"
className="h-14 text-center font-mono tracking-widest uppercase placeholder:text-center"
placeholder="کد تصویر"
maxLength={6}
/>
</div>
</div>
<CaptchaField
key={captchaKey}
id="captcha"
value={captchaAnswer}
onChange={setCaptchaAnswer}
onChallenge={setChallenge}
onError={setError}
/>
<label className="flex items-center justify-between gap-3 text-sm">
<span>مرا به یاد داشته باش</span>
<Switch
@@ -215,7 +153,7 @@ export default function LoginPage() {
variant="gradient"
className="h-12 w-full"
size="lg"
disabled={loading || captchaLoading || !challenge}
disabled={loading || !challenge}
>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Zap className="h-4 w-4" />} ورود
</Button>