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

@@ -1,3 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2C6.49 2 2 6.49 2 12C2 17.51 6.49 22 12 22C17.51 22 22 17.51 22 12C22 6.49 17.51 2 12 2ZM14.79 12.53L11.26 16.06C11.11 16.21 10.92 16.28 10.73 16.28C10.54 16.28 10.35 16.21 10.2 16.06C9.91 15.77 9.91 15.29 10.2 15L13.2 12L10.2 9C9.91 8.71 9.91 8.23 10.2 7.94C10.49 7.65 10.97 7.65 11.26 7.94L14.79 11.47C15.09 11.76 15.09 12.24 14.79 12.53Z" fill="#292D32"/>
<path d="M8.9101 20.67C8.7201 20.67 8.5301 20.6 8.3801 20.45C8.0901 20.16 8.0901 19.68 8.3801 19.39L14.9001 12.87C15.3801 12.39 15.3801 11.61 14.9001 11.13L8.3801 4.60999C8.0901 4.31999 8.0901 3.83999 8.3801 3.54999C8.6701 3.25999 9.1501 3.25999 9.4401 3.54999L15.9601 10.07C16.4701 10.58 16.7601 11.27 16.7601 12C16.7601 12.73 16.4801 13.42 15.9601 13.93L9.4401 20.45C9.2901 20.59 9.1001 20.67 8.9101 20.67Z" fill="#292D32"/>
</svg>

Before

Width:  |  Height:  |  Size: 475 B

After

Width:  |  Height:  |  Size: 530 B

View File

@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.1299 19.06H7.12988C6.71988 19.06 6.37988 18.72 6.37988 18.31C6.37988 17.9 6.71988 17.56 7.12988 17.56H15.1299C17.4699 17.56 19.3799 15.65 19.3799 13.31C19.3799 10.97 17.4699 9.06 15.1299 9.06H4.12988C3.71988 9.06 3.37988 8.72 3.37988 8.31C3.37988 7.9 3.71988 7.56 4.12988 7.56H15.1299C18.2999 7.56 20.8799 10.14 20.8799 13.31C20.8799 16.48 18.2999 19.06 15.1299 19.06Z" fill="#292D32"/>
<path d="M6.43006 11.56C6.24006 11.56 6.05006 11.49 5.90006 11.34L3.34006 8.78C3.05006 8.49 3.05006 8.01 3.34006 7.72L5.90006 5.16C6.19006 4.87 6.67006 4.87 6.96006 5.16C7.25006 5.45 7.25006 5.93 6.96006 6.22L4.93006 8.25001L6.96006 10.28C7.25006 10.57 7.25006 11.05 6.96006 11.34C6.82006 11.49 6.62006 11.56 6.43006 11.56Z" fill="#292D32"/>
</svg>

After

Width:  |  Height:  |  Size: 845 B

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 },