explor
This commit is contained in:
@@ -18,6 +18,7 @@ export async function fetchPosts(
|
||||
lng?: string;
|
||||
feedMode?: "grid" | "reels";
|
||||
seedPostId?: string;
|
||||
sort?: "latest";
|
||||
},
|
||||
token: string
|
||||
) {
|
||||
|
||||
@@ -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 || "خطا در ویرایش استوری");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -185,6 +185,7 @@ export default function StoriesBar() {
|
||||
initialUserIndex={viewerStartIndex}
|
||||
onClose={handleClose}
|
||||
token={token}
|
||||
viewerId={viewerId}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
341
src/components/stories/StoryEditor.tsx
Normal file
341
src/components/stories/StoryEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user