500 lines
20 KiB
JavaScript
500 lines
20 KiB
JavaScript
import React, { useState, useRef, useCallback, useEffect } from "react";
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
import { useTranslation } from "react-i18next";
|
|
import { uploadPostMedia, fetchPostPreview } from "../../api/client";
|
|
|
|
// Content modes
|
|
const CONTENT_MODES = [
|
|
{ id: "text", icon: "fa-pen", label: "Text", color: "from-blue-500 to-cyan-500" },
|
|
{ id: "photo", icon: "fa-image", label: "Photo", color: "from-emerald-500 to-green-500" },
|
|
{ id: "link", icon: "fa-link", label: "Link", color: "from-purple-500 to-violet-500" },
|
|
{ id: "live", icon: "fa-broadcast-tower", label: "Live", color: "from-red-500 to-rose-500" },
|
|
];
|
|
|
|
const CATEGORIES = [
|
|
{ id: "News", icon: "fa-newspaper", color: "from-blue-500 to-cyan-500" },
|
|
{ id: "Friends", icon: "fa-user-group", color: "from-emerald-500 to-green-500" },
|
|
{ id: "Events", icon: "fa-calendar", color: "from-purple-500 to-violet-500" },
|
|
{ id: "Market", icon: "fa-store", color: "from-amber-500 to-orange-500" },
|
|
];
|
|
|
|
const SUB_CATEGORIES = {
|
|
News: ["World", "Local", "Politics", "Tech", "Finance", "Sports"],
|
|
Friends: ["Nearby", "Stories", "Hangout", "Help"],
|
|
Events: ["Today", "Weekend", "Music", "Sports", "Food"],
|
|
Market: ["Deals", "Services", "Jobs", "Housing"],
|
|
};
|
|
|
|
export default function PostCreateModal({
|
|
isOpen,
|
|
onClose,
|
|
onSubmit,
|
|
onGoLive,
|
|
coords,
|
|
initialCategory = "News",
|
|
isSaving = false,
|
|
saveError = "",
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [mode, setMode] = useState("text");
|
|
const [category, setCategory] = useState(initialCategory);
|
|
const [subCategory, setSubCategory] = useState(SUB_CATEGORIES[initialCategory]?.[0] || "");
|
|
const [title, setTitle] = useState("");
|
|
const [body, setBody] = useState("");
|
|
const [linkUrl, setLinkUrl] = useState("");
|
|
const [linkPreview, setLinkPreview] = useState(null);
|
|
const [linkLoading, setLinkLoading] = useState(false);
|
|
const [images, setImages] = useState([]);
|
|
const [uploading, setUploading] = useState(false);
|
|
const fileInputRef = useRef(null);
|
|
const cameraInputRef = useRef(null);
|
|
const textareaRef = useRef(null);
|
|
const linkTimeoutRef = useRef(null);
|
|
|
|
// Reset only when modal opens (not on every initialCategory change)
|
|
const wasOpenRef = useRef(false);
|
|
useEffect(() => {
|
|
if (isOpen && !wasOpenRef.current) {
|
|
// Modal just opened - reset everything
|
|
setMode("text");
|
|
setTitle("");
|
|
setBody("");
|
|
setLinkUrl("");
|
|
setLinkPreview(null);
|
|
setImages([]);
|
|
setUploadError("");
|
|
setCategory(initialCategory);
|
|
setSubCategory(SUB_CATEGORIES[initialCategory]?.[0] || "");
|
|
}
|
|
wasOpenRef.current = isOpen;
|
|
}, [isOpen, initialCategory]);
|
|
|
|
// Auto-resize textarea
|
|
useEffect(() => {
|
|
if (textareaRef.current) {
|
|
textareaRef.current.style.height = "auto";
|
|
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 200) + "px";
|
|
}
|
|
}, [body]);
|
|
|
|
// Link preview fetch
|
|
useEffect(() => {
|
|
if (mode !== "link" || !linkUrl.trim()) {
|
|
setLinkPreview(null);
|
|
return;
|
|
}
|
|
|
|
clearTimeout(linkTimeoutRef.current);
|
|
linkTimeoutRef.current = setTimeout(async () => {
|
|
try {
|
|
setLinkLoading(true);
|
|
const preview = await fetchPostPreview(linkUrl.trim());
|
|
if (preview) {
|
|
setLinkPreview(preview);
|
|
if (!title && preview.title) setTitle(preview.title);
|
|
if (!body && preview.description) setBody(preview.description);
|
|
}
|
|
} catch {}
|
|
setLinkLoading(false);
|
|
}, 500);
|
|
|
|
return () => clearTimeout(linkTimeoutRef.current);
|
|
}, [linkUrl, mode, title, body]);
|
|
|
|
const [uploadError, setUploadError] = useState("");
|
|
|
|
const handleFileChange = useCallback(async (e) => {
|
|
const files = Array.from(e.target.files || []);
|
|
if (files.length === 0) return;
|
|
|
|
setUploading(true);
|
|
setUploadError("");
|
|
try {
|
|
for (const file of files) {
|
|
const result = await uploadPostMedia(file);
|
|
if (result?.url) {
|
|
setImages((prev) => {
|
|
const newImages = [...prev, result.url];
|
|
return newImages;
|
|
});
|
|
} else {
|
|
setUploadError(result?.error || "Upload failed - no URL returned");
|
|
}
|
|
}
|
|
} catch (err) {
|
|
setUploadError(err.message || "Upload failed");
|
|
}
|
|
setUploading(false);
|
|
e.target.value = "";
|
|
}, []);
|
|
|
|
const removeImage = (index) => {
|
|
setImages((prev) => prev.filter((_, i) => i !== index));
|
|
};
|
|
|
|
const handleSubmit = () => {
|
|
if (mode === "live") {
|
|
onGoLive?.({ category, subCategory, title, body, coords });
|
|
return;
|
|
}
|
|
|
|
if (!title.trim() && !body.trim() && images.length === 0 && !linkUrl.trim()) return;
|
|
|
|
onSubmit?.({
|
|
mode,
|
|
title: title.trim(),
|
|
body: body.trim(),
|
|
category,
|
|
subCategory,
|
|
images,
|
|
linkUrl: linkUrl.trim(),
|
|
linkPreview,
|
|
coords,
|
|
});
|
|
};
|
|
|
|
const canSubmit = mode === "live" ||
|
|
(title.trim() || body.trim() || images.length > 0 || linkUrl.trim()) && !isSaving && !uploading;
|
|
|
|
if (!isOpen) return null;
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
<motion.div
|
|
className="fixed inset-0 z-[1200] flex items-end justify-center"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
>
|
|
{/* Backdrop - only covers bottom half, allows map interaction above */}
|
|
<motion.div
|
|
className="absolute bottom-0 left-0 right-0 h-[65vh] bg-gradient-to-b from-transparent to-black/50 pointer-events-none"
|
|
/>
|
|
{/* Invisible close trigger at top */}
|
|
<div
|
|
className="absolute top-0 left-0 right-0 h-16 cursor-pointer"
|
|
onClick={onClose}
|
|
/>
|
|
|
|
{/* Modal - positioned at bottom, shorter to show map with crosshair */}
|
|
<motion.div
|
|
className="relative w-full max-w-xl mx-0 sm:mx-4
|
|
bg-gradient-to-b from-surface-800 to-surface-900
|
|
rounded-t-3xl border border-white/10 border-b-0
|
|
shadow-2xl shadow-black/50 overflow-hidden
|
|
max-h-[65vh]"
|
|
initial={{ y: "100%", opacity: 0 }}
|
|
animate={{ y: 0, opacity: 1 }}
|
|
exit={{ y: "100%", opacity: 0 }}
|
|
transition={{ type: "spring", damping: 30, stiffness: 400 }}
|
|
>
|
|
{/* Glowing top border */}
|
|
<div className="absolute top-0 left-0 right-0 h-[2px] bg-gradient-to-r from-blue-500 via-cyan-500 to-emerald-500" />
|
|
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-4 py-3 border-b border-white/5">
|
|
<motion.button
|
|
className="w-8 h-8 rounded-full bg-surface-700/50 border border-white/10
|
|
flex items-center justify-center text-surface-400 hover:text-white
|
|
hover:bg-red-500/20 hover:border-red-500/30 transition-all"
|
|
whileTap={{ scale: 0.9 }}
|
|
onClick={onClose}
|
|
>
|
|
<i className="fa-solid fa-times text-sm" />
|
|
</motion.button>
|
|
|
|
<h2 className="text-base font-bold text-white">Create Post</h2>
|
|
|
|
<motion.button
|
|
className={`px-4 py-1.5 rounded-full font-bold text-sm transition-all
|
|
${canSubmit
|
|
? mode === "live"
|
|
? "bg-gradient-to-r from-red-500 to-rose-500 text-white shadow-lg shadow-red-500/30"
|
|
: "bg-gradient-to-r from-blue-500 to-cyan-500 text-white shadow-lg shadow-cyan-500/30"
|
|
: "bg-surface-700/50 text-surface-500 cursor-not-allowed"}`}
|
|
whileHover={canSubmit ? { scale: 1.05 } : {}}
|
|
whileTap={canSubmit ? { scale: 0.95 } : {}}
|
|
onClick={handleSubmit}
|
|
disabled={!canSubmit}
|
|
>
|
|
{isSaving ? (
|
|
<i className="fa-solid fa-spinner fa-spin" />
|
|
) : mode === "live" ? (
|
|
<>
|
|
<i className="fa-solid fa-broadcast-tower mr-1.5" />
|
|
Go Live
|
|
</>
|
|
) : (
|
|
"Post"
|
|
)}
|
|
</motion.button>
|
|
</div>
|
|
|
|
{/* Content modes */}
|
|
<div className="flex gap-1 px-4 py-3 border-b border-white/5 overflow-x-auto">
|
|
{CONTENT_MODES.map((m) => (
|
|
<motion.button
|
|
key={m.id}
|
|
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-semibold whitespace-nowrap
|
|
transition-all ${mode === m.id
|
|
? `bg-gradient-to-r ${m.color} text-white shadow-lg`
|
|
: "bg-surface-700/30 text-surface-400 hover:text-white hover:bg-surface-700/50"}`}
|
|
whileHover={{ scale: 1.02 }}
|
|
whileTap={{ scale: 0.98 }}
|
|
onClick={() => setMode(m.id)}
|
|
>
|
|
<i className={`fa-solid ${m.icon}`} />
|
|
{m.label}
|
|
</motion.button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Scrollable content */}
|
|
<div className="p-4 max-h-[45vh] overflow-y-auto space-y-4">
|
|
{/* Category row */}
|
|
<div className="flex gap-2 overflow-x-auto pb-1">
|
|
{CATEGORIES.map((cat) => (
|
|
<motion.button
|
|
key={cat.id}
|
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-bold whitespace-nowrap
|
|
transition-all border ${category === cat.id
|
|
? `bg-gradient-to-r ${cat.color} text-white border-transparent`
|
|
: "bg-transparent text-surface-400 border-surface-600/50 hover:border-surface-500"}`}
|
|
whileTap={{ scale: 0.95 }}
|
|
onClick={() => {
|
|
setCategory(cat.id);
|
|
setSubCategory(SUB_CATEGORIES[cat.id]?.[0] || "");
|
|
}}
|
|
>
|
|
<i className={`fa-solid ${cat.icon}`} />
|
|
{cat.id}
|
|
</motion.button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Sub-category */}
|
|
<div className="flex gap-1.5 overflow-x-auto pb-1">
|
|
{SUB_CATEGORIES[category]?.map((sub) => (
|
|
<button
|
|
key={sub}
|
|
className={`px-2.5 py-1 rounded-lg text-[11px] font-medium whitespace-nowrap transition-all
|
|
${subCategory === sub
|
|
? "bg-accent/20 text-accent border border-accent/40"
|
|
: "bg-surface-700/30 text-surface-500 border border-transparent hover:text-surface-300"}`}
|
|
onClick={() => setSubCategory(sub)}
|
|
>
|
|
{sub}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Link input (for link mode) */}
|
|
{mode === "link" && (
|
|
<div className="space-y-2">
|
|
<div className="relative">
|
|
<i className="fa-solid fa-link absolute left-3 top-1/2 -translate-y-1/2 text-surface-500 text-sm" />
|
|
<input
|
|
type="url"
|
|
placeholder="Paste a link..."
|
|
value={linkUrl}
|
|
onChange={(e) => setLinkUrl(e.target.value)}
|
|
className="w-full pl-10 pr-4 py-3 rounded-xl
|
|
bg-surface-700/50 border border-white/10
|
|
text-surface-100 text-sm
|
|
placeholder:text-surface-500
|
|
focus:outline-none focus:border-cyan-500/50"
|
|
/>
|
|
{linkLoading && (
|
|
<i className="fa-solid fa-spinner fa-spin absolute right-3 top-1/2 -translate-y-1/2 text-surface-400" />
|
|
)}
|
|
</div>
|
|
{/* Link preview card */}
|
|
{linkPreview && (
|
|
<motion.div
|
|
className="rounded-xl bg-surface-700/30 border border-white/5 overflow-hidden"
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
>
|
|
{linkPreview.image && (
|
|
<img src={linkPreview.image} alt="" className="w-full h-32 object-cover" />
|
|
)}
|
|
<div className="p-3">
|
|
<p className="text-xs text-surface-500 mb-1">{linkPreview.site_name || new URL(linkUrl).hostname}</p>
|
|
<p className="text-sm font-medium text-surface-200 line-clamp-2">{linkPreview.title}</p>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Live mode info */}
|
|
{mode === "live" && (
|
|
<motion.div
|
|
className="p-4 rounded-xl bg-gradient-to-br from-red-500/10 to-rose-500/10 border border-red-500/20"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
>
|
|
<div className="flex items-center gap-3 mb-3">
|
|
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-red-500 to-rose-500 flex items-center justify-center">
|
|
<i className="fa-solid fa-broadcast-tower text-white text-xl" />
|
|
</div>
|
|
<div>
|
|
<p className="text-white font-bold">Start a Live Stream</p>
|
|
<p className="text-surface-400 text-sm">Broadcast to your followers in real-time</p>
|
|
</div>
|
|
</div>
|
|
<p className="text-surface-400 text-xs">
|
|
Your location will be visible to viewers. The stream will appear on the map.
|
|
</p>
|
|
</motion.div>
|
|
)}
|
|
|
|
{/* Title input */}
|
|
<input
|
|
type="text"
|
|
placeholder={mode === "live" ? "Stream title..." : "What's happening?"}
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
className="w-full px-4 py-3 rounded-xl
|
|
bg-surface-700/50 border border-white/10
|
|
text-white text-base font-medium
|
|
placeholder:text-surface-500
|
|
focus:outline-none focus:border-cyan-500/50"
|
|
/>
|
|
|
|
{/* Body textarea (hidden for live mode) */}
|
|
{mode !== "live" && (
|
|
<textarea
|
|
ref={textareaRef}
|
|
placeholder="Add more details..."
|
|
value={body}
|
|
onChange={(e) => setBody(e.target.value)}
|
|
rows={2}
|
|
className="w-full px-4 py-3 rounded-xl
|
|
bg-surface-700/50 border border-white/10
|
|
text-surface-200 text-sm leading-relaxed
|
|
placeholder:text-surface-500
|
|
focus:outline-none focus:border-cyan-500/50
|
|
resize-none"
|
|
/>
|
|
)}
|
|
|
|
{/* Image gallery (for photo/text modes) */}
|
|
{(mode === "photo" || mode === "text") && images.length > 0 && (
|
|
<div className="grid grid-cols-3 gap-2">
|
|
{images.map((url, idx) => (
|
|
<div key={idx} className="relative aspect-square rounded-xl overflow-hidden group">
|
|
<img src={url} alt="" className="w-full h-full object-cover" />
|
|
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity" />
|
|
<motion.button
|
|
className="absolute top-2 right-2 w-6 h-6 rounded-full bg-red-500/80
|
|
flex items-center justify-center text-white text-xs
|
|
opacity-0 group-hover:opacity-100 transition-opacity"
|
|
whileTap={{ scale: 0.9 }}
|
|
onClick={() => removeImage(idx)}
|
|
>
|
|
<i className="fa-solid fa-times" />
|
|
</motion.button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Upload progress */}
|
|
{uploading && (
|
|
<div className="flex items-center justify-center gap-2 py-3 text-cyan-400 text-sm">
|
|
<i className="fa-solid fa-cloud-arrow-up animate-bounce" />
|
|
Uploading...
|
|
</div>
|
|
)}
|
|
|
|
{/* Upload Error */}
|
|
{uploadError && (
|
|
<div className="flex items-center gap-2 p-3 rounded-xl bg-orange-500/10 border border-orange-500/30 text-orange-400 text-sm">
|
|
<i className="fa-solid fa-exclamation-triangle" />
|
|
{uploadError}
|
|
</div>
|
|
)}
|
|
|
|
{/* Save Error */}
|
|
{saveError && (
|
|
<div className="flex items-center gap-2 p-3 rounded-xl bg-red-500/10 border border-red-500/30 text-red-400 text-sm">
|
|
<i className="fa-solid fa-exclamation-circle" />
|
|
{saveError}
|
|
</div>
|
|
)}
|
|
|
|
{/* Location */}
|
|
{coords ? (
|
|
<div className="flex items-center gap-2 text-surface-400 text-sm">
|
|
<i className="fa-solid fa-location-dot text-emerald-500" />
|
|
<span>Location: {coords[1]?.toFixed(4)}, {coords[0]?.toFixed(4)}</span>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-2 text-orange-400 text-sm">
|
|
<i className="fa-solid fa-location-dot" />
|
|
<span>No location - post may fail</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Bottom toolbar */}
|
|
{(mode === "photo" || mode === "text") && (
|
|
<div className="flex items-center gap-2 px-4 py-3 border-t border-white/5 bg-surface-800/50">
|
|
<motion.button
|
|
className="flex items-center gap-2 px-3 py-2 rounded-xl
|
|
bg-surface-700/50 border border-white/10
|
|
text-surface-300 text-sm font-medium
|
|
hover:text-white hover:bg-surface-600/50 transition-all"
|
|
whileTap={{ scale: 0.95 }}
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={uploading}
|
|
>
|
|
<i className="fa-solid fa-image text-emerald-400" />
|
|
Photo
|
|
</motion.button>
|
|
|
|
<motion.button
|
|
className="flex items-center gap-2 px-3 py-2 rounded-xl
|
|
bg-surface-700/50 border border-white/10
|
|
text-surface-300 text-sm font-medium
|
|
hover:text-white hover:bg-surface-600/50 transition-all"
|
|
whileTap={{ scale: 0.95 }}
|
|
onClick={() => cameraInputRef.current?.click()}
|
|
disabled={uploading}
|
|
>
|
|
<i className="fa-solid fa-camera text-blue-400" />
|
|
Camera
|
|
</motion.button>
|
|
|
|
<div className="flex-1" />
|
|
|
|
<span className="text-xs text-surface-500">
|
|
{title.length + body.length}/500
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Hidden file inputs */}
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
multiple
|
|
onChange={handleFileChange}
|
|
hidden
|
|
/>
|
|
<input
|
|
ref={cameraInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
capture="environment"
|
|
onChange={handleFileChange}
|
|
hidden
|
|
/>
|
|
</motion.div>
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
);
|
|
}
|