This commit is contained in:
payacom
2026-07-11 20:48:36 +03:30
parent 8e75913441
commit 0864ae4723
27 changed files with 917 additions and 146 deletions

View File

@@ -18,6 +18,7 @@ export async function fetchPosts(
lng?: string;
feedMode?: "grid" | "reels";
seedPostId?: string;
sort?: "latest";
},
token: string
) {

View File

@@ -1,9 +1,21 @@
import { getApiBaseUrl } from "@/components/main/BaseUrl";
export type StoryTextOverlay = {
text: string;
x: number;
y: number;
font_size: number;
color: string;
background?: string;
rotation?: number;
align?: "left" | "center" | "right";
};
export type StoryItem = {
_id: string;
media_path: string;
media_type: "image" | "video";
overlays?: StoryTextOverlay[];
createdAt?: string;
expires_at?: string;
viewed?: boolean;
@@ -56,7 +68,8 @@ export async function markStoryViewed(
export async function createStoryBase64(
file: { name: string; type: string; data: string },
token: string
token: string,
overlays: StoryTextOverlay[] = []
): Promise<void> {
const res = await fetch(`${getApiBaseUrl()}/stories/create-base64`, {
method: "POST",
@@ -64,10 +77,29 @@ export async function createStoryBase64(
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ file }),
body: JSON.stringify({ file, overlays }),
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error(json?.message || "خطا در انتشار استوری");
}
}
export async function updateStoryOverlays(
storyId: string,
overlays: StoryTextOverlay[],
token: string
): Promise<void> {
const res = await fetch(`${getApiBaseUrl()}/stories/${storyId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ overlays }),
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error(json?.message || "خطا در ویرایش استوری");
}
}

View File

@@ -609,16 +609,16 @@ select {
color: var(--chat-bubble-out-fg);
}
/* ─── Instagram-style typing dots ─── */
/* ─── Instagram-style typing dots (waving) ─── */
@keyframes typing-bounce {
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
30% { transform: translateY(-4px); opacity: 1; }
0%, 60%, 100% { transform: translateY(0) scale(1); opacity: 0.45; }
30% { transform: translateY(-5px) scale(1.15); opacity: 1; }
}
.typing-dot {
animation: typing-bounce 1.2s ease-in-out infinite;
animation: typing-bounce 1.3s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
.typing-dot:nth-child(2) { animation-delay: 0.15s; }
.typing-dot:nth-child(3) { animation-delay: 0.3s; }
.typing-dot:nth-child(2) { animation-delay: 0.18s; }
.typing-dot:nth-child(3) { animation-delay: 0.36s; }
/* ─── Read receipt check animation ─── */
@keyframes check-pop {

View File

@@ -4,7 +4,8 @@
import useAxios from "@/hooks/useAxios";
import { optimizeImageToWebP, optimizeVideoToMp4 } from "@/lib/media";
import { createStoryBase64 } from "@/api/fetchStories";
import { createStoryBase64, StoryTextOverlay } from "@/api/fetchStories";
import StoryEditor from "@/components/stories/StoryEditor";
import TagUsersPicker, { TaggedUser } from "@/components/posts/TagUsersPicker";
import BoldIcon from "@/components/ui/BoldIcon";
import Cookies from "js-cookie";
@@ -44,6 +45,7 @@ export default function NewPostPage() {
const [extraPreviews, setExtraPreviews] = useState<string[]>([""]);
const [description, setDescription] = useState("");
const [taggedUsers, setTaggedUsers] = useState<TaggedUser[]>([]);
const [storyOverlays, setStoryOverlays] = useState<StoryTextOverlay[]>([]);
useEffect(() => {
const t = searchParams.get("type") as CreateMode;
@@ -57,6 +59,7 @@ export default function NewPostPage() {
setPreview("");
setExtraImages([null]);
setExtraPreviews([""]);
setStoryOverlays([]);
};
const switchMode = (next: CreateMode) => {
@@ -119,17 +122,29 @@ export default function NewPostPage() {
}
try {
toast.loading("در حال انتشار استوری…", { id: "story-upload" });
const optimized =
file.type.startsWith("video/")
? await optimizeVideoToMp4(file)
: await optimizeImageToWebP(file);
let optimized: File;
if (file.type.startsWith("video/")) {
// اگر تبدیل ویدیو (ffmpeg) در دسترس نبود، فایل اصلی ارسال می‌شود
try {
optimized = await optimizeVideoToMp4(file);
} catch {
optimized = file;
}
} else {
try {
optimized = await optimizeImageToWebP(file);
} catch {
optimized = file;
}
}
await createStoryBase64(
{
name: optimized.name,
type: optimized.type,
data: await fileToBase64(optimized),
},
token
token,
storyOverlays
);
toast.success("استوری منتشر شد", { id: "story-upload" });
router.push("/");
@@ -235,7 +250,27 @@ export default function NewPostPage() {
))}
</div>
{/* Media area */}
{/* Story editor (media + draggable text) */}
{isStory && preview && file ? (
<div className="flex flex-col gap-3 p-4">
<StoryEditor
mediaUrl={preview}
mediaType={file.type.startsWith("video/") ? "video" : "image"}
overlays={storyOverlays}
onChange={setStoryOverlays}
/>
<label className="mx-auto rounded-lg bg-neutral-100 px-4 py-2 text-center text-xs text-neutral-600 dark:bg-neutral-800 dark:text-neutral-300">
تغییر فایل
<input
type="file"
accept={acceptAttr}
className="hidden"
onChange={onPickMain}
/>
</label>
</div>
) : (
/* Media area */
<div
className={`relative flex flex-1 flex-col items-center justify-center bg-neutral-100 dark:bg-neutral-950 ${
isStory ? "aspect-[9/16] max-h-[55vh]" : "min-h-[280px]"
@@ -293,6 +328,7 @@ export default function NewPostPage() {
</label>
)}
</div>
)}
{/* Extra images for photo post */}
{mode === "image" && !isStory && (

View File

@@ -72,11 +72,24 @@ function TicketChat({ params }: ITicketChatProps) {
useEffect(() => {
if (!user?._id || !receiverId) return;
const socket = io(SOCKET_URL);
const socket = io(SOCKET_URL, {
transports: ["websocket", "polling"],
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 800,
});
socketRef.current = socket;
socket.emit("joinChat", { userId: user._id, receiverId });
socket.emit("joinUser", { userId: user._id });
// عضویت در روم‌ها روی هر اتصال/اتصال مجدد تا بعد از قطعی هم پیام‌ها برسند
const joinRooms = () => {
socket.emit("joinChat", { userId: user._id, receiverId });
socket.emit("joinUser", { userId: user._id });
};
socket.on("connect", joinRooms);
if (socket.connected) joinRooms();
return () => {
socket.off("connect", joinRooms);
socket.disconnect();
socketRef.current = null;
};
@@ -527,6 +540,8 @@ function TicketChat({ params }: ITicketChatProps) {
onConfirm={sendAllImages}
viewOnceMedia={viewOnceMedia}
onViewOnceChange={setViewOnceMedia}
selfDestructSeconds={selfDestructSeconds}
onSelfDestructChange={setSelfDestructSeconds}
/>
{forwardMessage && user?._id && (
<ForwardMessageModal

View File

@@ -2,6 +2,7 @@
import { motion } from "framer-motion";
import BoldIcon from "@/components/ui/BoldIcon";
import ChatBoldIcon from "./ChatBoldIcon";
interface ChatActionBarProps {
onReply: () => void;
@@ -35,7 +36,7 @@ export default function ChatActionBar({
onClick={onReply}
className="chat-action-btn gentle-transition flex flex-1 items-center justify-center gap-2 rounded-full py-3 text-sm font-semibold text-white active:scale-[0.98]"
>
<BoldIcon name="direct-right" size={18} tinted className="text-white" />
<ChatBoldIcon name="reply" size={18} className="text-white" />
پاسخ
</button>
<button

View File

@@ -17,13 +17,15 @@ export type ChatBoldIconName =
| "send"
| "search"
| "delete"
| "back";
| "back"
| "reply";
/** Chat-specific SVG assets (custom uploads) */
const CHAT_ICON_SRC: Partial<Record<ChatBoldIconName, string>> = {
timer: staticIconUrl("/images/icons/chat/timer.svg"),
search: staticIconUrl("/images/icons/chat/search-normal.svg"),
back: staticIconUrl("/images/icons/chat/back.svg"),
reply: staticIconUrl("/images/icons/chat/reply.svg"),
};
const ICON_NAME: Record<ChatBoldIconName, string> = {
@@ -40,6 +42,7 @@ const ICON_NAME: Record<ChatBoldIconName, string> = {
search: "search-normal",
delete: "trash",
back: "arrow-circle-right",
reply: "direct-right",
};
interface ChatBoldIconProps {

View File

@@ -174,7 +174,8 @@ const ChatMessageCard = ({
const { dragProps, replyOpacity, replyScale } = useSwipeToReply(
handleSwipeReply,
swipeEnabled
swipeEnabled,
isSender ? "left" : "right"
);
const swipeDragStart =
@@ -404,11 +405,15 @@ const ChatMessageCard = ({
aria-hidden
className={cn(
"pointer-events-none absolute inset-y-0 flex w-11 items-center justify-center text-[#0095f6]",
isSender ? "right-0" : "right-0"
isSender ? "right-0" : "left-0"
)}
style={{ opacity: replyOpacity, scale: replyScale }}
>
<BoldIcon name="direct-right" size={20} tinted className="text-current" />
<ChatBoldIcon
name="reply"
size={20}
className={cn("text-current", !isSender && "-scale-x-100")}
/>
</motion.div>
)}
@@ -629,20 +634,39 @@ const ChatMessageCard = ({
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/95"
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/90 backdrop-blur-xl"
onClick={() => setPreview(null)}
>
<button
type="button"
onClick={() => setPreview(null)}
className="gentle-transition absolute right-4 top-[calc(1rem+env(safe-area-inset-top))] z-[2] flex h-10 w-10 items-center justify-center rounded-full bg-white/15 text-white backdrop-blur-md active:scale-90"
aria-label="بستن"
>
<BoldIcon name="close-circle" size={24} tinted className="text-white" />
</button>
<motion.div
initial={{ scale: 0.9 }}
animate={{ scale: 1 }}
initial={{ scale: 0.92, opacity: 0.6 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.92, opacity: 0 }}
transition={{ duration: 0.22, ease: [0.25, 0.46, 0.45, 0.94] }}
className="max-h-[92vh] max-w-[95vw] p-4"
onClick={(e) => e.stopPropagation()}
>
{fileType === "video" ? (
<video src={preview} controls autoPlay className="max-h-[88vh] rounded-lg" />
<video
src={preview}
controls
autoPlay
className="max-h-[88vh] rounded-2xl shadow-2xl"
/>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img src={preview} alt="" className="max-h-[88vh] object-contain" />
<img
src={preview}
alt=""
className="max-h-[88vh] rounded-2xl object-contain shadow-2xl"
/>
)}
</motion.div>
</motion.div>

View File

@@ -184,9 +184,20 @@ const ChatMessageList = ({
useEffect(() => {
if (!threadReady) return;
const socket = io(SOCKET_URL);
const socket = io(SOCKET_URL, {
transports: ["websocket", "polling"],
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 800,
});
socket.emit("joinChat", { userId: senderId, receiverId });
// عضویت در روم چت روی هر اتصال/اتصال مجدد
const joinRoom = () => {
socket.emit("joinChat", { userId: senderId, receiverId });
socket.emit("joinUser", { userId: senderId });
};
socket.on("connect", joinRoom);
if (socket.connected) joinRoom();
const belongs = (msg: ChatMessage) =>
belongsToThread(msg, senderId, receiverId) ||
@@ -232,10 +243,19 @@ const ChatMessageList = ({
}
);
let typingHideTimer: ReturnType<typeof setTimeout> | null = null;
socket.on("userTyping", ({ userId }: { userId: string }) => {
if (userId === receiverId) {
onTypingChange?.(true);
setTimeout(() => onTypingChange?.(false), 3000);
if (typingHideTimer) clearTimeout(typingHideTimer);
typingHideTimer = setTimeout(() => onTypingChange?.(false), 3500);
}
});
socket.on("userStoppedTyping", ({ userId }: { userId: string }) => {
if (userId === receiverId) {
if (typingHideTimer) clearTimeout(typingHideTimer);
onTypingChange?.(false);
}
});
@@ -261,10 +281,13 @@ const ChatMessageList = ({
);
return () => {
if (typingHideTimer) clearTimeout(typingHideTimer);
socket.off("connect", joinRoom);
socket.off("newMessage");
socket.off("messageStatusUpdate");
socket.off("messagesDeleted");
socket.off("userTyping");
socket.off("userStoppedTyping");
socket.disconnect();
};
}, [threadReady, senderId, receiverId, queryClient, onTypingChange]);

View File

@@ -67,6 +67,8 @@ const MessageInput = ({
const hasText = newMessage.trim().length > 0;
const showSend = hasText && !isRecording;
// ردیف تایمر/یک‌بارمصرف فقط هنگام ضبط ویس (برای عکس داخل پیش‌نمایش نمایش داده می‌شود)
const showMediaOptions = isChatThread && isRecording;
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
@@ -173,9 +175,33 @@ const MessageInput = ({
}`}
>
{(replyLabel ||
showMediaOptions ||
(selfDestructSeconds != null && isChatThread) ||
(viewOnceMedia && isChatThread)) && (
<div className="pointer-events-auto relative z-[2] mb-2 flex w-full max-w-lg flex-col gap-2">
{showMediaOptions && (
<div className="glass-panel flex items-center justify-between gap-2 rounded-2xl px-3 py-2 shadow-lg">
<span className="text-[11px] font-medium text-neutral-500 dark:text-neutral-400">
گزینههای ارسال مدیا
</span>
<div className="flex items-center gap-1.5">
{onSelfDestructChange && (
<TimedMessagePicker
value={selfDestructSeconds}
onChange={onSelfDestructChange}
disabled={blocked_you}
/>
)}
{onViewOnceChange && (
<ViewOnceToggle
value={viewOnceMedia}
onChange={onViewOnceChange}
disabled={blocked_you}
/>
)}
</div>
</div>
)}
{replyLabel && (
<div className="glass-panel flex items-center gap-2 rounded-2xl px-3 py-2.5 text-xs shadow-lg">
<span className="flex-1 truncate text-neutral-700 dark:text-neutral-200">
@@ -214,20 +240,6 @@ const MessageInput = ({
className="pointer-events-auto relative z-[1] flex w-full max-w-lg items-end gap-2 sm:gap-2.5"
>
<div className="relative flex shrink-0 items-center gap-1.5">
{isChatThread && onSelfDestructChange && (
<TimedMessagePicker
value={selfDestructSeconds}
onChange={onSelfDestructChange}
disabled={blocked_you || isRecording}
/>
)}
{isChatThread && onViewOnceChange && (
<ViewOnceToggle
value={viewOnceMedia}
onChange={onViewOnceChange}
disabled={blocked_you}
/>
)}
<button
ref={attachBtnRef}
type="button"

View File

@@ -3,8 +3,12 @@
import Image from "next/image";
import { motion } from "framer-motion";
import Modal from "../elements/Modal";
import { cn } from "@/lib/utils";
import BoldIcon from "@/components/ui/BoldIcon";
import ChatBoldIcon from "./ChatBoldIcon";
import ViewOnceToggle from "./ViewOnceToggle";
import TimedMessagePicker from "./TimedMessagePicker";
import { formatTimedLabel } from "@/lib/chat/timedMessages";
interface MultiImageModalProps {
isOpen: boolean;
@@ -14,6 +18,8 @@ interface MultiImageModalProps {
onConfirm: () => void;
viewOnceMedia?: boolean;
onViewOnceChange?: (value: boolean) => void;
selfDestructSeconds?: number | null;
onSelfDestructChange?: (seconds: number | null) => void;
}
export default function MultiImageModal({
@@ -24,65 +30,116 @@ export default function MultiImageModal({
onConfirm,
viewOnceMedia = false,
onViewOnceChange,
selfDestructSeconds = null,
onSelfDestructChange,
}: MultiImageModalProps) {
if (!isOpen || files.length === 0) return null;
const single = files.length === 1;
return (
<Modal isOpen={isOpen} onClose={onCancel} height="min(85vh, 640px)">
<div className="flex h-full flex-col p-3">
<p className="mb-3 text-center text-sm font-semibold text-neutral-600 dark:text-neutral-300">
{files.length} عکس انتخاب شده
</p>
<div className="grid flex-1 grid-cols-3 gap-2 overflow-y-auto">
<Modal isOpen={isOpen} onClose={onCancel} height="min(88vh, 680px)">
<div className="flex h-full flex-col">
{/* Header */}
<div className="flex items-center justify-between px-4 pb-3 pt-1">
<button
type="button"
onClick={onCancel}
className="gentle-transition flex h-9 w-9 items-center justify-center rounded-full bg-black/5 text-lg leading-none text-neutral-600 active:scale-90 dark:bg-white/10 dark:text-neutral-300"
aria-label="بستن"
>
×
</button>
<span className="text-sm font-semibold text-neutral-800 dark:text-neutral-100">
{single ? "پیش‌نمایش عکس" : `${files.length} عکس انتخاب شده`}
</span>
<span className="h-9 w-9" aria-hidden />
</div>
{/* Preview area */}
<div
className={
single
? "flex flex-1 items-center justify-center overflow-hidden px-4"
: "grid flex-1 auto-rows-min grid-cols-2 gap-2.5 overflow-y-auto px-4 pb-2 sm:grid-cols-3"
}
>
{files.map((file, i) => (
<motion.div
key={`${file.name}-${i}`}
layout
className="relative aspect-square overflow-hidden rounded-xl"
initial={{ opacity: 0, scale: 0.92 }}
animate={{ opacity: 1, scale: 1 }}
className={cn(
"group relative overflow-hidden rounded-2xl bg-neutral-100 shadow-sm ring-1 ring-black/5 dark:bg-neutral-800 dark:ring-white/10",
single ? "max-h-[52vh] w-full" : "aspect-square"
)}
style={single ? { aspectRatio: "auto" } : undefined}
>
<Image
src={URL.createObjectURL(file)}
alt=""
fill
className="object-cover"
width={single ? 900 : 400}
height={single ? 900 : 400}
className={cn(
"h-full w-full",
single ? "max-h-[52vh] w-auto object-contain" : "object-cover"
)}
unoptimized
/>
<button
type="button"
onClick={() => onRemove(i)}
className="absolute left-1 top-1 flex h-6 w-6 items-center justify-center rounded-full bg-black/60 text-xs text-white"
className="gentle-transition absolute left-2 top-2 flex h-7 w-7 items-center justify-center rounded-full bg-black/55 text-white backdrop-blur-md active:scale-90"
aria-label="حذف عکس"
>
×
<BoldIcon name="close-circle" size={16} tinted className="text-white" />
</button>
{!single && (
<span className="absolute bottom-2 right-2 flex h-5 min-w-5 items-center justify-center rounded-full bg-black/45 px-1.5 text-[10px] font-semibold text-white backdrop-blur-md">
{i + 1}
</span>
)}
</motion.div>
))}
</div>
<div className="mt-4 flex flex-col items-center gap-3">
{onViewOnceChange && (
<div className="flex items-center gap-2 text-xs text-neutral-600 dark:text-neutral-300">
<ViewOnceToggle
value={viewOnceMedia}
onChange={onViewOnceChange}
/>
<span>ارسال بهصورت یکبار مصرف</span>
</div>
)}
<div className="flex justify-center gap-4">
{/* Footer */}
<div className="flex flex-col items-center gap-3 border-t border-black/5 px-4 pb-4 pt-3 dark:border-white/10">
<div className="flex w-full max-w-xs items-center justify-center gap-5">
{onSelfDestructChange && (
<div className="flex flex-col items-center gap-1">
<TimedMessagePicker
value={selfDestructSeconds}
onChange={onSelfDestructChange}
/>
<span className="text-[10px] text-neutral-500 dark:text-neutral-400">
{selfDestructSeconds != null
? formatTimedLabel(selfDestructSeconds)
: "زماندار"}
</span>
</div>
)}
{onViewOnceChange && (
<div className="flex flex-col items-center gap-1">
<ViewOnceToggle
value={viewOnceMedia}
onChange={onViewOnceChange}
/>
<span className="text-[10px] text-neutral-500 dark:text-neutral-400">
یکبار مصرف
</span>
</div>
)}
</div>
<button
type="button"
onClick={onConfirm}
className="ig-dm-send-btn gentle-transition rounded-full px-8 py-2.5 text-sm font-semibold text-white active:scale-95"
className="ig-dm-send-btn gentle-transition flex w-full max-w-xs items-center justify-center gap-2 rounded-full py-3 text-sm font-semibold text-white shadow-lg active:scale-[0.98]"
>
ارسال همه
<ChatBoldIcon name="send" size={18} className="text-white -mr-0.5" />
{single ? "ارسال" : "ارسال همه"}
</button>
<button
type="button"
onClick={onCancel}
className="gentle-transition rounded-full border border-neutral-300 px-6 py-2.5 text-sm active:scale-95 dark:border-neutral-600"
>
لغو
</button>
</div>
</div>
</div>
</Modal>

View File

@@ -82,7 +82,7 @@ export default function ExploreAcademyCard({
className="gentle-transition block active:scale-[0.98]"
>
<div
className={`relative w-full overflow-hidden rounded-lg bg-neutral-900 ${aspect}`}
className={`relative w-full overflow-hidden rounded-2xl bg-neutral-900 ${aspect}`}
>
{media.kind === "video" ? (
<video
@@ -110,20 +110,20 @@ export default function ExploreAcademyCard({
</span>
)}
</div>
<div className="flex items-center justify-between gap-2 px-0.5 py-1.5">
<ExploreAuthor
userId={item.user_id || ""}
userName={item.user_name}
displayName={displayName}
profileImage={item.profile_image}
/>
<span className="flex shrink-0 items-center gap-1 text-[10px] text-neutral-500">
<BoldIcon name="heart" size={14} tinted className="text-[#FC8EAC]" />
{item.likesCount ?? 0}
</span>
</div>
</Link>
<div className="flex items-center justify-between gap-2 px-0.5 py-1.5">
<ExploreAuthor
userId={item.user_id || ""}
userName={item.user_name}
displayName={displayName}
profileImage={item.profile_image}
/>
<span className="flex shrink-0 items-center gap-1 text-[10px] text-neutral-500">
<BoldIcon name="heart" size={14} tinted className="text-[#FC8EAC]" />
{item.likesCount ?? 0}
</span>
</div>
</article>
);
}

View File

@@ -1,5 +1,6 @@
"use client";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import useAxios from "@/hooks/useAxios";
@@ -32,8 +33,8 @@ export default function ExploreAuthor({
const avatarSrc = profileImage || data?.user?.profile_image;
return (
<span className="flex min-w-0 items-center gap-1.5">
const content = (
<>
<ProfileAvatar
src={avatarSrc}
alt={userName || displayName}
@@ -43,6 +44,22 @@ export default function ExploreAuthor({
<span className="truncate text-[10px] font-medium text-neutral-600 dark:text-neutral-300">
{userName ? `@${userName}` : displayName}
</span>
</span>
</>
);
if (userName) {
return (
<Link
href={`/users/${userName}`}
onClick={(e) => e.stopPropagation()}
className="gentle-transition flex min-w-0 items-center gap-1.5 active:opacity-70"
>
{content}
</Link>
);
}
return (
<span className="flex min-w-0 items-center gap-1.5">{content}</span>
);
}

View File

@@ -124,7 +124,7 @@ function ExploreCard({
className="gentle-transition block active:scale-[0.98]"
>
<div
className={`relative w-full overflow-hidden rounded-lg bg-neutral-900 ${aspect}`}
className={`relative w-full overflow-hidden rounded-2xl bg-neutral-900 ${aspect}`}
>
{media.kind === "video" ? (
<video
@@ -140,7 +140,7 @@ function ExploreCard({
alt=""
fill
className="object-cover"
sizes="33vw"
sizes="50vw"
unoptimized
/>
)}
@@ -150,20 +150,20 @@ function ExploreCard({
</span>
)}
</div>
<div className="flex items-center justify-between gap-2 px-0.5 py-1.5">
<ExploreAuthor
userId={post.user_id}
userName={post.user_name}
displayName={displayName}
profileImage={post.profile_image}
/>
<span className="flex shrink-0 items-center gap-1 text-[10px] text-neutral-500">
<BoldIcon name="heart" size={14} tinted className="text-[#FC8EAC]" />
{post.likesCount ?? 0}
</span>
</div>
</Link>
<div className="flex items-center justify-between gap-2 px-0.5 py-1.5">
<ExploreAuthor
userId={post.user_id}
userName={post.user_name}
displayName={displayName}
profileImage={post.profile_image}
/>
<span className="flex shrink-0 items-center gap-1 text-[10px] text-neutral-500">
<BoldIcon name="heart" size={14} tinted className="text-[#FC8EAC]" />
{post.likesCount ?? 0}
</span>
</div>
</article>
);
}
@@ -334,7 +334,7 @@ export default function ExploreGrid({
return (
<div className="px-2 pb-2">
<div className="columns-3 gap-2">
<div className="columns-2 gap-2">
{items.map((item) =>
item.kind === "post" ? (
<ExploreCard

View File

@@ -25,6 +25,9 @@ import PageLoader from "@/components/ui/PageLoader";
import { ExploreFilterId } from "@/constants/exploreFilters";
import { buildExplorePostFilters } from "@/lib/explore/buildPostFilters";
// چند آیتم مانده به انتها، صفحهٔ بعد در پس‌زمینه پیش‌بارگذاری شود
const PREFETCH_AHEAD = 5;
export type ExploreReelItem =
| { kind: "post"; key: string; post: Post }
| { kind: "academy"; key: string; academy: AcademyExploreItem };
@@ -216,9 +219,9 @@ export default function ExploreReelsView({
setActiveKey(item.key);
}
// پیش‌بارگذاری صفحهٔ بعد چند آیتم زودتر تا از صفحهٔ لودینگ جلوگیری شود
if (
el.scrollTop + el.clientHeight >=
el.scrollHeight - window.innerHeight * 0.5 &&
index >= items.length - PREFETCH_AHEAD &&
hasNextPage &&
!isFetchingNextPage
) {
@@ -233,6 +236,23 @@ export default function ExploreReelsView({
return () => el.removeEventListener("scroll", handleScroll);
}, [handleScroll]);
// بافر پس‌زمینه: با نزدیک‌شدن به انتهای آیتم‌های لودشده، صفحهٔ بعد از قبل گرفته می‌شود
const activeIndex = useMemo(
() => items.findIndex((i) => i.key === activeKey),
[items, activeKey]
);
useEffect(() => {
if (activeIndex < 0) return;
if (
activeIndex >= items.length - PREFETCH_AHEAD &&
hasNextPage &&
!isFetchingNextPage
) {
fetchNextPage();
}
}, [activeIndex, items.length, hasNextPage, isFetchingNextPage, fetchNextPage]);
useEffect(() => {
if (!token) return;
const item = items.find((i) => i.key === activeKey);

View File

@@ -40,7 +40,7 @@ export const IMAGE_BASE_URL = getStorageBaseUrl();
/** نسخه آیکون‌ها — با هر تغییر آیکون این مقدار را افزایش دهید */
export const ICON_VERSION =
process.env.NEXT_PUBLIC_ICON_VERSION ?? "2";
process.env.NEXT_PUBLIC_ICON_VERSION ?? "5";
/** URL آیکون استاتیک با cache-busting */
export function staticIconUrl(path: string): string {

View File

@@ -16,7 +16,7 @@ function Header() {
const { state, startSync, endSync, isOffline, isSyncing } = useNetworkStatus();
const [unreadMessages, setUnreadMessages] = useState<number>();
const [unreadNotification, setUnreadNotification] = useState<number>();
const compact = useScrollDirection(20);
const { compact, hidden } = useScrollDirection(20);
const token = Cookies.get("token");
const refreshBadges = async () => {
@@ -120,8 +120,11 @@ function Header() {
return (
<header
className={cn(
"gentle-transition sticky top-0 z-[999] pt-[env(safe-area-inset-top)]",
compact ? "py-2" : "py-3"
"sticky top-0 z-[999] pt-[env(safe-area-inset-top)] transition-all duration-300 ease-in-out will-change-transform",
compact ? "py-2" : "py-3",
hidden
? "pointer-events-none -translate-y-[130%] opacity-0"
: "translate-y-0 opacity-100"
)}
>
<div className={headerBarClass}>

View File

@@ -63,7 +63,7 @@ export default function InfinitePosts({
fetchPosts(
pageParam as number,
4,
{ ...filters, feedMode: "grid" },
{ ...filters, feedMode: "grid", sort: "latest" },
token
),
getNextPageParam: (lastPage, allPages) =>

View File

@@ -102,7 +102,8 @@ function ModelsFilter({ expertise }: ModelsFilterProps) {
<div>
<div className="flex items-center justify-between relative px-4 text-sm">
<div className="flex items-center gap-3">
{expertiseList.map((item) => (
{/* موقتاً مخفی شد — دکمه‌های فیلتر مدل/آرایشگر/عکاس (حذف نشود) */}
{/* {expertiseList.map((item) => (
<button
key={item.name}
type="button"
@@ -115,7 +116,7 @@ function ModelsFilter({ expertise }: ModelsFilterProps) {
>
{item.name}
</button>
))}
))} */}
</div>
<div className="flex items-center gap-3 shrink-0 self-center">
<button

View File

@@ -16,6 +16,9 @@ import { normalizeUserLevel } from "@/lib/userLevel";
import { readReelsSeedPost } from "@/lib/reelsSeedPost";
import { reorderColdStartFeed } from "@/lib/coldStartFeed";
// چند پست مانده به انتها، صفحهٔ بعد در پس‌زمینه پیش‌بارگذاری شود
const PREFETCH_AHEAD = 5;
interface PostFeedViewProps {
initialPostId: string;
initialPost?: Post | null;
@@ -233,8 +236,9 @@ export default function PostFeedView({
setActivePostId(post._id);
}
// پیش‌بارگذاری صفحهٔ بعد چند پست زودتر تا از صفحهٔ لودینگ جلوگیری شود
if (
el.scrollTop + el.clientHeight >= el.scrollHeight - window.innerHeight * 0.5 &&
index >= posts.length - PREFETCH_AHEAD &&
hasNextPage &&
!isFetchingNextPage
) {
@@ -249,6 +253,24 @@ export default function PostFeedView({
return () => el.removeEventListener("scroll", handleScroll);
}, [handleScroll]);
// بافر پس‌زمینه: وقتی کاربر به انتهای پست‌های لودشده نزدیک می‌شود،
// صفحهٔ بعدی را از قبل می‌گیریم (مثل پیش‌بارگذاری ریلز اینستاگرام)
const activeIndex = useMemo(
() => posts.findIndex((p) => String(p._id) === String(activePostId)),
[posts, activePostId]
);
useEffect(() => {
if (activeIndex < 0) return;
if (
activeIndex >= posts.length - PREFETCH_AHEAD &&
hasNextPage &&
!isFetchingNextPage
) {
fetchNextPage();
}
}, [activeIndex, posts.length, hasNextPage, isFetchingNextPage, fetchNextPage]);
if (feedMode === "user" && !resolvedUserId && isLoading) {
return (
<div className="space-y-4 p-4">

View File

@@ -185,6 +185,7 @@ export default function StoriesBar() {
initialUserIndex={viewerStartIndex}
onClose={handleClose}
token={token}
viewerId={viewerId}
/>
)}
</>

View File

@@ -0,0 +1,341 @@
"use client";
import React, {
useCallback,
useLayoutEffect,
useRef,
useState,
} from "react";
import { StoryTextOverlay } from "@/api/fetchStories";
import BoldIcon from "@/components/ui/BoldIcon";
const TEXT_COLORS = [
"#ffffff",
"#000000",
"#ff3040",
"#ffd60a",
"#0a84ff",
"#30d158",
"#ff2d92",
"#bf5af2",
];
const MIN_FONT = 0.03;
const MAX_FONT = 0.14;
type StoryEditorProps = {
mediaUrl: string;
mediaType: "image" | "video";
overlays: StoryTextOverlay[];
onChange: (overlays: StoryTextOverlay[]) => void;
};
export default function StoryEditor({
mediaUrl,
mediaType,
overlays,
onChange,
}: StoryEditorProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
const [selected, setSelected] = useState<number | null>(null);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [draftText, setDraftText] = useState("");
const dragState = useRef<{
index: number;
moved: boolean;
} | null>(null);
useLayoutEffect(() => {
const el = containerRef.current;
if (!el) return;
const update = () =>
setContainerSize({ width: el.clientWidth, height: el.clientHeight });
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, []);
const updateOverlay = useCallback(
(index: number, patch: Partial<StoryTextOverlay>) => {
onChange(
overlays.map((o, i) => (i === index ? { ...o, ...patch } : o))
);
},
[overlays, onChange]
);
const removeOverlay = useCallback(
(index: number) => {
onChange(overlays.filter((_, i) => i !== index));
setSelected(null);
},
[overlays, onChange]
);
const openTextEditor = (index: number | null) => {
if (index === null) {
setDraftText("");
} else {
setDraftText(overlays[index]?.text ?? "");
}
setEditingIndex(index);
};
const commitText = () => {
const text = draftText.trim();
if (editingIndex === null) {
if (text) {
const next: StoryTextOverlay = {
text,
x: 0.5,
y: 0.4,
font_size: 0.07,
color: "#ffffff",
background: "",
rotation: 0,
align: "center",
};
onChange([...overlays, next]);
setSelected(overlays.length);
}
} else if (text) {
updateOverlay(editingIndex, { text });
} else {
removeOverlay(editingIndex);
}
setEditingIndex(null);
setDraftText("");
};
const handlePointerDown = (
e: React.PointerEvent<HTMLDivElement>,
index: number
) => {
e.stopPropagation();
(e.target as HTMLElement).setPointerCapture?.(e.pointerId);
dragState.current = { index, moved: false };
setSelected(index);
};
const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
const state = dragState.current;
const rect = containerRef.current?.getBoundingClientRect();
if (!state || !rect) return;
state.moved = true;
const x = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
const y = Math.min(1, Math.max(0, (e.clientY - rect.top) / rect.height));
updateOverlay(state.index, { x, y });
};
const handlePointerUp = () => {
dragState.current = null;
};
const selectedOverlay =
selected !== null ? overlays[selected] : null;
return (
<div className="flex w-full flex-col gap-3">
{/* Canvas frame 9:16 */}
<div
ref={containerRef}
className="relative mx-auto aspect-[9/16] w-full max-w-[340px] overflow-hidden rounded-2xl bg-black"
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onClick={() => setSelected(null)}
>
{mediaType === "video" ? (
<video
src={mediaUrl}
className="absolute inset-0 h-full w-full object-contain"
playsInline
muted
loop
autoPlay
/>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
src={mediaUrl}
alt="story"
className="absolute inset-0 h-full w-full object-contain"
/>
)}
{overlays.map((o, index) => {
const fontPx = Math.max(
10,
o.font_size * (containerSize.width || 300)
);
return (
<div
key={index}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => handlePointerDown(e, index)}
onDoubleClick={() => openTextEditor(index)}
style={{
position: "absolute",
left: `${o.x * 100}%`,
top: `${o.y * 100}%`,
transform: `translate(-50%, -50%) rotate(${o.rotation ?? 0}deg)`,
color: o.color,
fontSize: `${fontPx}px`,
lineHeight: 1.2,
fontWeight: 700,
textAlign: (o.align ?? "center") as React.CSSProperties["textAlign"],
background: o.background || "transparent",
padding: o.background ? "0.15em 0.35em" : 0,
borderRadius: o.background ? "0.35em" : 0,
maxWidth: "90%",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
cursor: "move",
touchAction: "none",
userSelect: "none",
textShadow: o.background
? "none"
: "0 1px 3px rgba(0,0,0,0.55)",
outline:
selected === index ? "2px dashed rgba(255,255,255,0.7)" : "none",
outlineOffset: 4,
}}
>
{o.text}
</div>
);
})}
{/* Add-text FAB */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
openTextEditor(null);
}}
className="absolute right-2 top-2 z-10 flex items-center gap-1 rounded-full bg-black/55 px-3 py-1.5 text-xs font-semibold text-white backdrop-blur"
>
<BoldIcon name="add" size={16} tinted className="text-white" />
متن
</button>
</div>
{/* Toolbar for selected overlay */}
{selectedOverlay && (
<div className="mx-auto w-full max-w-[340px] space-y-3 rounded-xl border border-neutral-200 p-3 dark:border-neutral-800">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-neutral-500">
ویرایش متن
</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => openTextEditor(selected)}
className="rounded-md bg-neutral-100 px-2 py-1 text-xs dark:bg-neutral-800"
>
تغییر نوشته
</button>
<button
type="button"
onClick={() => selected !== null && removeOverlay(selected)}
className="rounded-md bg-red-50 px-2 py-1 text-xs text-red-600 dark:bg-red-950/40"
>
حذف
</button>
</div>
</div>
{/* Colors */}
<div className="flex flex-wrap items-center gap-2">
{TEXT_COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() =>
selected !== null && updateOverlay(selected, { color: c })
}
className={`h-7 w-7 rounded-full border ${
selectedOverlay.color === c
? "ring-2 ring-offset-2 ring-[#0095f6] dark:ring-offset-neutral-900"
: "border-neutral-300 dark:border-neutral-600"
}`}
style={{ backgroundColor: c }}
aria-label={c}
/>
))}
</div>
{/* Highlight background toggle */}
<button
type="button"
onClick={() =>
selected !== null &&
updateOverlay(selected, {
background: selectedOverlay.background ? "" : "#000000",
})
}
className="rounded-md bg-neutral-100 px-3 py-1 text-xs dark:bg-neutral-800"
>
{selectedOverlay.background ? "حذف پس‌زمینه متن" : "افزودن پس‌زمینه متن"}
</button>
{/* Font size */}
<label className="flex items-center gap-3 text-xs text-neutral-500">
اندازه
<input
type="range"
min={MIN_FONT}
max={MAX_FONT}
step={0.005}
value={selectedOverlay.font_size}
onChange={(e) =>
selected !== null &&
updateOverlay(selected, {
font_size: parseFloat(e.target.value),
})
}
className="flex-1 accent-[#0095f6]"
/>
</label>
</div>
)}
{/* Text input modal */}
{editingIndex !== null && (
<div className="fixed inset-0 z-[1001] flex flex-col bg-black/85 p-6">
<div className="flex items-center justify-between">
<button
type="button"
onClick={() => {
setEditingIndex(null);
setDraftText("");
}}
className="text-sm text-white/70"
>
انصراف
</button>
<button
type="button"
onClick={commitText}
className="text-sm font-semibold text-[#0095f6]"
>
تأیید
</button>
</div>
<div className="flex flex-1 items-center justify-center">
<textarea
autoFocus
value={draftText}
onChange={(e) => setDraftText(e.target.value)}
placeholder="متن استوری…"
rows={3}
className="w-full max-w-sm resize-none bg-transparent text-center text-2xl font-bold text-white outline-none placeholder:text-white/40"
/>
</div>
</div>
)}
</div>
);
}

View File

@@ -3,15 +3,18 @@
import React, {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import Image from "next/image";
import Link from "next/link";
import { StoryFeedUser } from "@/api/fetchStories";
import { markStoryViewed } from "@/api/fetchStories";
import toast from "react-hot-toast";
import { StoryFeedUser, StoryTextOverlay } from "@/api/fetchStories";
import { markStoryViewed, updateStoryOverlays } from "@/api/fetchStories";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import BoldIcon from "@/components/ui/BoldIcon";
import StoryEditor from "@/components/stories/StoryEditor";
const IMAGE_DURATION_MS = 5000;
@@ -20,6 +23,7 @@ type StoryViewerProps = {
initialUserIndex: number;
onClose: () => void;
token: string;
viewerId?: string | null;
};
export default function StoryViewer({
@@ -27,11 +31,20 @@ export default function StoryViewer({
initialUserIndex,
onClose,
token,
viewerId,
}: StoryViewerProps) {
const [userIndex, setUserIndex] = useState(initialUserIndex);
const [storyIndex, setStoryIndex] = useState(0);
const [progress, setProgress] = useState(0);
const [paused, setPaused] = useState(false);
const [editing, setEditing] = useState(false);
const [draftOverlays, setDraftOverlays] = useState<StoryTextOverlay[]>([]);
const [savingEdit, setSavingEdit] = useState(false);
const [overlayOverrides, setOverlayOverrides] = useState<
Record<string, StoryTextOverlay[]>
>({});
const [mediaWidth, setMediaWidth] = useState(0);
const mediaBoxRef = useRef<HTMLDivElement | null>(null);
const videoRef = useRef<HTMLVideoElement | null>(null);
const timerRef = useRef<number | null>(null);
const startRef = useRef<number>(0);
@@ -79,8 +92,18 @@ export default function StoryViewer({
markStoryViewed(currentStory._id, token);
}, [currentStory?._id, token]);
useLayoutEffect(() => {
const el = mediaBoxRef.current;
if (!el) return;
const update = () => setMediaWidth(el.clientWidth);
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, []);
useEffect(() => {
if (!currentStory || paused) return;
if (!currentStory || paused || editing) return;
if (currentStory.media_type === "video") {
const video = videoRef.current;
@@ -116,7 +139,7 @@ export default function StoryViewer({
return () => {
if (timerRef.current) cancelAnimationFrame(timerRef.current);
};
}, [currentStory, paused, goNext]);
}, [currentStory, paused, editing, goNext]);
useEffect(() => {
elapsedRef.current = 0;
@@ -143,6 +166,40 @@ export default function StoryViewer({
? currentStory.media_path
: buildStorageUrl(currentStory.media_path);
const currentOverlays =
overlayOverrides[currentStory._id] ?? currentStory.overlays ?? [];
const isOwnStory = Boolean(viewerId && currentUser.user._id === viewerId);
const openEditor = () => {
setDraftOverlays(currentOverlays.map((o) => ({ ...o })));
setEditing(true);
setPaused(true);
if (videoRef.current) videoRef.current.pause();
};
const closeEditor = () => {
setEditing(false);
setPaused(false);
startRef.current = Date.now();
};
const saveEditor = async () => {
setSavingEdit(true);
try {
await updateStoryOverlays(currentStory._id, draftOverlays, token);
setOverlayOverrides((prev) => ({
...prev,
[currentStory._id]: draftOverlays,
}));
toast.success("استوری ویرایش شد");
closeEditor();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در ویرایش استوری");
} finally {
setSavingEdit(false);
}
};
const displayName =
currentUser.user.user_name ||
[currentUser.user.first_name, currentUser.user.last_name]
@@ -150,7 +207,7 @@ export default function StoryViewer({
.join(" ");
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black">
<div className="fixed inset-0 z-[1000] flex items-center justify-center bg-black">
{/* Progress bars */}
<div className="absolute left-0 right-0 top-0 z-20 flex gap-1 px-2 pt-[calc(0.5rem+env(safe-area-inset-top))]">
{currentUser.stories.map((_, idx) => (
@@ -199,18 +256,31 @@ export default function StoryViewer({
{formatStoryTime(currentStory.createdAt)}
</span>
</Link>
<button
type="button"
onClick={onClose}
className="flex h-9 w-9 items-center justify-center text-white"
aria-label="بستن"
>
<BoldIcon name="close-circle" size={24} tinted className="text-white" />
</button>
<div className="flex items-center gap-1">
{isOwnStory && (
<button
type="button"
onClick={openEditor}
className="flex h-9 items-center gap-1 rounded-full bg-white/15 px-3 text-xs font-semibold text-white backdrop-blur"
aria-label="ویرایش استوری"
>
<BoldIcon name="edit" size={16} tinted className="text-white" />
ویرایش
</button>
)}
<button
type="button"
onClick={onClose}
className="flex h-9 w-9 items-center justify-center text-white"
aria-label="بستن"
>
<BoldIcon name="close-circle" size={24} tinted className="text-white" />
</button>
</div>
</div>
{/* Media */}
<div className="relative h-full w-full max-w-lg">
<div ref={mediaBoxRef} className="relative h-full w-full max-w-lg">
{currentStory.media_type === "video" ? (
<video
ref={videoRef}
@@ -229,6 +299,34 @@ export default function StoryViewer({
unoptimized
/>
)}
{/* Text overlays */}
{currentOverlays.map((o, idx) => (
<div
key={idx}
style={{
position: "absolute",
left: `${o.x * 100}%`,
top: `${o.y * 100}%`,
transform: `translate(-50%, -50%) rotate(${o.rotation ?? 0}deg)`,
color: o.color,
fontSize: `${Math.max(10, o.font_size * (mediaWidth || 380))}px`,
lineHeight: 1.2,
fontWeight: 700,
textAlign: (o.align ?? "center") as React.CSSProperties["textAlign"],
background: o.background || "transparent",
padding: o.background ? "0.15em 0.35em" : 0,
borderRadius: o.background ? "0.35em" : 0,
maxWidth: "90%",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
pointerEvents: "none",
textShadow: o.background ? "none" : "0 1px 3px rgba(0,0,0,0.55)",
}}
>
{o.text}
</div>
))}
</div>
{/* Tap zones */}
@@ -250,6 +348,36 @@ export default function StoryViewer({
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
/>
{/* Edit overlays modal */}
{editing && (
<div className="absolute inset-0 z-30 flex flex-col overflow-y-auto bg-black/95 pb-6 pt-[calc(0.75rem+env(safe-area-inset-top))]">
<div className="flex items-center justify-between px-4 pb-2">
<button
type="button"
onClick={closeEditor}
className="text-sm text-white/70"
>
انصراف
</button>
<span className="text-sm font-semibold text-white">ویرایش استوری</span>
<button
type="button"
onClick={saveEditor}
disabled={savingEdit}
className="text-sm font-semibold text-[#0095f6] disabled:opacity-40"
>
{savingEdit ? "…" : "ذخیره"}
</button>
</div>
<StoryEditor
mediaUrl={mediaSrc}
mediaType={currentStory.media_type}
overlays={draftOverlays}
onChange={setDraftOverlays}
/>
</div>
)}
</div>
);
}

View File

@@ -2,9 +2,14 @@
import { useEffect, useRef, useState } from "react";
/** Returns true when user scrolls down past threshold (Safari-style compact header) */
/**
* ردیابی جهت اسکرول:
* - compact: هنگام اسکرول (پایین‌تر از threshold) حالت جمع‌وجور
* - hidden: هنگام اسکرول به پایین true (هدر مخفی)، هنگام اسکرول به بالا یا در ابتدای صفحه false
*/
export function useScrollDirection(threshold = 16) {
const [compact, setCompact] = useState(false);
const [hidden, setHidden] = useState(false);
const lastY = useRef(0);
const ticking = useRef(false);
@@ -15,10 +20,13 @@ export function useScrollDirection(threshold = 16) {
const y = window.scrollY;
if (y <= threshold) {
setCompact(false);
setHidden(false);
} else if (y > lastY.current + 4) {
setCompact(true);
setHidden(true);
} else if (y < lastY.current - 4) {
setCompact(false);
setCompact(true);
setHidden(false);
}
lastY.current = y;
ticking.current = false;
@@ -35,5 +43,5 @@ export function useScrollDirection(threshold = 16) {
return () => window.removeEventListener("scroll", onScroll);
}, [threshold]);
return compact;
return { compact, hidden };
}

View File

@@ -11,11 +11,26 @@ import {
const SWIPE_THRESHOLD = 52;
const MAX_DRAG = 72;
export function useSwipeToReply(onReply: () => void, enabled = true) {
/** incoming: سوایپ به راست — outgoing: سوایپ به چپ (مثل اینستاگرام در RTL) */
export function useSwipeToReply(
onReply: () => void,
enabled = true,
direction: "left" | "right" = "left"
) {
const x = useMotionValue(0);
const [dragging, setDragging] = useState(false);
const replyOpacity = useTransform(x, [-10, -40], [0, 1]);
const replyScale = useTransform(x, [-10, -40], [0.6, 1]);
const swipeRight = direction === "right";
const replyOpacity = useTransform(
x,
swipeRight ? [10, 40] : [-10, -40],
[0, 1]
);
const replyScale = useTransform(
x,
swipeRight ? [10, 40] : [-10, -40],
[0.6, 1]
);
const resetPosition = useCallback(() => {
void animate(x, 0, {
@@ -33,7 +48,10 @@ export function useSwipeToReply(onReply: () => void, enabled = true) {
const onDragEnd = useCallback(
(_: unknown, info: PanInfo) => {
setDragging(false);
if (info.offset.x <= -SWIPE_THRESHOLD) {
const triggered = swipeRight
? info.offset.x >= SWIPE_THRESHOLD
: info.offset.x <= -SWIPE_THRESHOLD;
if (triggered) {
if (typeof navigator !== "undefined" && navigator.vibrate) {
navigator.vibrate(12);
}
@@ -41,14 +59,18 @@ export function useSwipeToReply(onReply: () => void, enabled = true) {
}
resetPosition();
},
[onReply, resetPosition]
[onReply, resetPosition, swipeRight]
);
const dragProps = enabled
? {
drag: "x" as const,
dragConstraints: { left: -MAX_DRAG, right: 0 },
dragElastic: { left: 0.15, right: 0 },
dragConstraints: swipeRight
? { left: 0, right: MAX_DRAG }
: { left: -MAX_DRAG, right: 0 },
dragElastic: swipeRight
? { left: 0, right: 0.15 }
: { left: 0.15, right: 0 },
dragMomentum: false,
dragSnapToOrigin: true,
style: { x, touchAction: "pan-y" as const },