Add news post creation support

- Add createNewsPost function in client.js to call /api/news/post
- Update PostCreateModal to use createNewsPost for news mode
- News posts support text + images via core-api

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-01-31 19:09:16 +00:00
parent 498b41a2bf
commit fca617681a
2 changed files with 446 additions and 285 deletions

View File

@ -320,6 +320,41 @@ export async function createPost(payload, token) {
return res.json().catch(() => ({})); return res.json().catch(() => ({}));
} }
/**
* Create a news post (text + image format)
* Goes directly to core-api's /api/news/post endpoint
*/
export async function createNewsPost(payload) {
const headers = {
"Content-Type": "application/json",
...authHeaders(),
};
const res = await fetch(`${apiBase()}/news/post`, {
method: "POST",
headers,
credentials: "include",
body: JSON.stringify(payload),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
let json = null;
try { json = text ? JSON.parse(text) : null; } catch {}
const errMsg =
(json && (json.message || json.error_description || json.error)) ||
(text && text.slice(0, 300)) ||
`HTTP ${res.status}`;
const err = new Error(errMsg);
err.status = res.status;
throw err;
}
return res.json().catch(() => ({}));
}
/** /**
* Fetch link preview data for a URL (auth required). * Fetch link preview data for a URL (auth required).
*/ */
@ -379,10 +414,27 @@ export async function fetchPostByUrl(url) {
} }
} }
export async function fetchPostTruth(postId) { export async function fetchPostByGuid(guid) {
if (!postId) return null; const raw = (guid || "").toString().trim();
if (!raw) return null;
const qs = new URLSearchParams(); const qs = new URLSearchParams();
qs.set("post_id", String(postId)); qs.set("guid", raw);
try {
const data = await fetchJsonCached(`${apiBase()}/post?${qs.toString()}`, { ttlMs: 15000 });
if (!data || typeof data !== "object") return null;
const resolved = await resolveAssetRefsInPosts([data]);
return resolved && resolved[0] ? resolved[0] : data;
} catch {
return null;
}
}
export async function fetchPostTruth(postId, guid = null, appId = null) {
if (!postId && !guid) return null;
const qs = new URLSearchParams();
if (postId) qs.set("post_id", String(postId));
if (guid) qs.set("guid", guid);
if (appId) qs.set("app_id", appId);
try { try {
const res = await fetch(`${apiBase()}/post/truth?${qs.toString()}`, { const res = await fetch(`${apiBase()}/post/truth?${qs.toString()}`, {
credentials: "include", credentials: "include",
@ -444,7 +496,7 @@ export async function votePostTruth(postId, vote, guid = null, appId = null) {
truth_score: truthScore, // Main display score - the final calculated value truth_score: truthScore, // Main display score - the final calculated value
total_votes: voteCount, total_votes: voteCount,
vote_count: voteCount, vote_count: voteCount,
user_vote: vote, // The vote that was just cast user_vote: truth.user_vote || vote, // Use API response, fallback to submitted vote
}; };
} }

View File

@ -1,8 +1,8 @@
import React, { useState, useRef, useCallback, useEffect } from "react"; import React, { useState, useRef, useCallback, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { uploadPostMedia, fetchPostPreview } from "../../api/client"; import { fetchPostPreview, uploadPostMedia, createNewsPost } from "../../api/client";
import { listSubscriptions, uploadPostImages, createPost as createAppPost } from "../../api/extAppsApi"; import { listSubscriptions, createPost as createAppPost } from "../../api/extAppsApi";
// Content modes // Content modes
const CONTENT_MODES = [ const CONTENT_MODES = [
@ -10,6 +10,7 @@ const CONTENT_MODES = [
{ id: "photo", icon: "fa-image", label: "Photo", color: "from-emerald-500 to-green-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: "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" }, { id: "live", icon: "fa-broadcast-tower", label: "Live", color: "from-red-500 to-rose-500" },
{ id: "news", icon: "fa-newspaper", label: "News", color: "from-amber-500 to-orange-500" },
]; ];
const CATEGORIES = [ const CATEGORIES = [
@ -17,7 +18,7 @@ const CATEGORIES = [
{ id: "Friends", icon: "fa-user-group", color: "from-emerald-500 to-green-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: "Events", icon: "fa-calendar", color: "from-purple-500 to-violet-500" },
{ id: "Market", icon: "fa-store", color: "from-amber-500 to-orange-500" }, { id: "Market", icon: "fa-store", color: "from-amber-500 to-orange-500" },
{ id: "Apps", icon: "fa-puzzle-piece", color: "from-pink-500 to-purple-500" }, { id: "Apps", icon: "fa-puzzle-piece", color: "from-pink-500 to-rose-500" },
]; ];
const SUB_CATEGORIES = { const SUB_CATEGORIES = {
@ -25,7 +26,7 @@ const SUB_CATEGORIES = {
Friends: ["Nearby", "Stories", "Hangout", "Help"], Friends: ["Nearby", "Stories", "Hangout", "Help"],
Events: ["Today", "Weekend", "Music", "Sports", "Food"], Events: ["Today", "Weekend", "Music", "Sports", "Food"],
Market: ["Deals", "Services", "Jobs", "Housing"], Market: ["Deals", "Services", "Jobs", "Housing"],
Apps: [], // Dynamic - loaded from subscriptions Apps: [], // Dynamic - filled from subscribed apps
}; };
export default function PostCreateModal({ export default function PostCreateModal({
@ -49,34 +50,55 @@ export default function PostCreateModal({
const [linkLoading, setLinkLoading] = useState(false); const [linkLoading, setLinkLoading] = useState(false);
const [images, setImages] = useState([]); const [images, setImages] = useState([]);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [price, setPrice] = useState(""); const [uploadError, setUploadError] = useState("");
// External apps state
const [subscribedApps, setSubscribedApps] = useState([]);
const [selectedApp, setSelectedApp] = useState(null);
const [appsLoading, setAppsLoading] = useState(false);
const fileInputRef = useRef(null); const fileInputRef = useRef(null);
const cameraInputRef = useRef(null); const cameraInputRef = useRef(null);
const textareaRef = useRef(null); const textareaRef = useRef(null);
const linkTimeoutRef = useRef(null); const linkTimeoutRef = useRef(null);
// Reset only when modal opens (not on every initialCategory change) // Apps state
const wasOpenRef = useRef(false); const [subscribedApps, setSubscribedApps] = useState([]);
const [selectedApp, setSelectedApp] = useState(null);
const [appsLoading, setAppsLoading] = useState(false);
// Reset on open
useEffect(() => { useEffect(() => {
if (isOpen && !wasOpenRef.current) { if (isOpen) {
// Modal just opened - reset everything
setMode("text"); setMode("text");
setTitle(""); setTitle("");
setBody(""); setBody("");
setLinkUrl(""); setLinkUrl("");
setLinkPreview(null); setLinkPreview(null);
setImages([]); setImages([]);
setUploadError("");
setCategory(initialCategory); setCategory(initialCategory);
setSubCategory(SUB_CATEGORIES[initialCategory]?.[0] || ""); setSubCategory(SUB_CATEGORIES[initialCategory]?.[0] || "");
setSelectedApp(null);
setUploadError("");
} }
wasOpenRef.current = isOpen;
}, [isOpen, initialCategory]); }, [isOpen, initialCategory]);
// Load apps when Apps category is selected
useEffect(() => {
if (category === "Apps" && subscribedApps.length === 0) {
loadApps();
}
}, [category]);
const loadApps = async () => {
try {
setAppsLoading(true);
const result = await listSubscriptions();
const apps = (result.subscriptions || []).filter(
(sub) => sub.capabilities?.includes("posts")
);
setSubscribedApps(apps);
} catch (err) {
console.error("Failed to load apps:", err);
} finally {
setAppsLoading(false);
}
};
// Auto-resize textarea // Auto-resize textarea
useEffect(() => { useEffect(() => {
if (textareaRef.current) { if (textareaRef.current) {
@ -85,28 +107,9 @@ export default function PostCreateModal({
} }
}, [body]); }, [body]);
// Load subscribed apps when Apps category is selected // Link preview fetch (for link and news modes)
useEffect(() => { useEffect(() => {
if (category === "Apps" && subscribedApps.length === 0 && !appsLoading) { if ((mode !== "link" && mode !== "news") || !linkUrl.trim()) {
setAppsLoading(true);
listSubscriptions()
.then((result) => {
const apps = (result.subscriptions || []).filter(
(sub) => sub.capabilities?.includes("posts")
);
setSubscribedApps(apps);
if (apps.length > 0 && !selectedApp) {
setSelectedApp(apps[0]);
}
})
.catch((err) => console.error("Failed to load apps:", err))
.finally(() => setAppsLoading(false));
}
}, [category, subscribedApps.length, appsLoading, selectedApp]);
// Link preview fetch
useEffect(() => {
if (mode !== "link" || !linkUrl.trim()) {
setLinkPreview(null); setLinkPreview(null);
return; return;
} }
@ -119,16 +122,13 @@ export default function PostCreateModal({
if (preview) { if (preview) {
setLinkPreview(preview); setLinkPreview(preview);
if (!title && preview.title) setTitle(preview.title); if (!title && preview.title) setTitle(preview.title);
if (!body && preview.description) setBody(preview.description);
} }
} catch {} } catch {}
setLinkLoading(false); setLinkLoading(false);
}, 500); }, 500);
return () => clearTimeout(linkTimeoutRef.current); return () => clearTimeout(linkTimeoutRef.current);
}, [linkUrl, mode, title, body]); }, [linkUrl, mode, title]);
const [uploadError, setUploadError] = useState("");
const handleFileChange = useCallback(async (e) => { const handleFileChange = useCallback(async (e) => {
const files = Array.from(e.target.files || []); const files = Array.from(e.target.files || []);
@ -140,20 +140,18 @@ export default function PostCreateModal({
for (const file of files) { for (const file of files) {
const result = await uploadPostMedia(file); const result = await uploadPostMedia(file);
if (result?.url) { if (result?.url) {
setImages((prev) => { setImages((prev) => [...prev, result.url].slice(0, 5));
const newImages = [...prev, result.url]; } else if (result?.error) {
return newImages; setUploadError(result.error);
});
} else {
setUploadError(result?.error || "Upload failed - no URL returned");
} }
} }
} catch (err) { } catch (err) {
console.error("Upload failed:", err);
setUploadError(err.message || "Upload failed"); setUploadError(err.message || "Upload failed");
} }
setUploading(false); setUploading(false);
e.target.value = ""; e.target.value = "";
}, []); }, [category]);
const removeImage = (index) => { const removeImage = (index) => {
setImages((prev) => prev.filter((_, i) => i !== index)); setImages((prev) => prev.filter((_, i) => i !== index));
@ -165,47 +163,52 @@ export default function PostCreateModal({
return; return;
} }
if (!title.trim() && !body.trim() && images.length === 0 && !linkUrl.trim()) return; // Handle News mode - post to core-api /api/news/post (text + optional image)
if (mode === "news") {
// Handle external app posts if (!title.trim() && !body.trim()) return;
if (category === "Apps" && selectedApp) {
try { try {
setUploading(true); setUploading(true);
// Upload images to app's S3 via Sociowire
let uploadedImages = [];
if (images.length > 0) {
// Convert URLs to files for re-upload to app
// Note: images are already uploaded to Sociowire, we need to reference them
uploadedImages = images; // For now, pass the URLs directly
}
// Create post via apps-gateway
const postData = { const postData = {
title: title.trim(), title: title.trim() || body.trim().slice(0, 100),
description: body.trim(), description: body.trim(),
images: uploadedImages, url: linkUrl.trim() || null,
location: coords ? { images: images,
lat: coords[1], location: coords ? { lat: coords[1], lon: coords[0] } : null,
lon: coords[0],
} : null,
content: {}
}; };
await createNewsPost(postData);
if (price) {
postData.content.price = parseFloat(price);
}
await createAppPost(selectedApp.app_id, postData);
onClose?.(); onClose?.();
} catch (err) { } catch (err) {
setUploadError(err.message || "Failed to create app post"); setUploadError(err.message || "Failed to create news post");
} finally { } finally {
setUploading(false); setUploading(false);
} }
return; return;
} }
// Handle Apps category - post to external app
if (category === "Apps" && selectedApp) {
try {
setUploading(true);
const postData = {
title: title.trim(),
description: body.trim(),
images,
location: coords ? { lat: coords[1], lon: coords[0] } : null,
content: {}
};
await createAppPost(selectedApp.app_id, postData);
onClose?.();
} catch (err) {
setUploadError(err.message || "Failed to create post");
} finally {
setUploading(false);
}
return;
}
// Regular post
if (!title.trim() && !body.trim() && images.length === 0 && !linkUrl.trim()) return;
onSubmit?.({ onSubmit?.({
mode, mode,
title: title.trim(), title: title.trim(),
@ -220,37 +223,32 @@ export default function PostCreateModal({
}; };
const canSubmit = mode === "live" || const canSubmit = mode === "live" ||
(category === "Apps" (mode === "news" && (title.trim() || body.trim()) && !isSaving && !uploading) ||
? (selectedApp && title.trim() && coords && !isSaving && !uploading) (category === "Apps" && selectedApp && title.trim()) ||
: ((title.trim() || body.trim() || images.length > 0 || linkUrl.trim()) && !isSaving && !uploading)); ((title.trim() || body.trim() || images.length > 0 || linkUrl.trim()) && !isSaving && !uploading);
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<AnimatePresence> <AnimatePresence>
<motion.div <motion.div
className="fixed inset-0 z-[1200] flex items-end justify-center" className="fixed inset-0 z-[1200] flex items-end sm:items-center justify-center"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
> >
{/* Backdrop - only covers bottom half, allows map interaction above */} {/* Backdrop */}
<motion.div <motion.div
className="absolute bottom-0 left-0 right-0 h-[65vh] bg-gradient-to-b from-transparent to-black/50 pointer-events-none" className="absolute inset-0 bg-black/80 backdrop-blur-md"
/>
{/* Invisible close trigger at top */}
<div
className="absolute top-0 left-0 right-0 h-16 cursor-pointer"
onClick={onClose} onClick={onClose}
/> />
{/* Modal - positioned at bottom, shorter to show map with crosshair */} {/* Modal */}
<motion.div <motion.div
className="relative w-full max-w-xl mx-0 sm:mx-4 className="relative w-full max-w-xl mx-2 sm:mx-4
bg-gradient-to-b from-surface-800 to-surface-900 bg-gradient-to-b from-surface-800 to-surface-900
rounded-t-3xl border border-white/10 border-b-0 rounded-t-3xl sm:rounded-3xl border border-white/10
shadow-2xl shadow-black/50 overflow-hidden shadow-2xl shadow-black/50 overflow-hidden"
max-h-[65vh]"
initial={{ y: "100%", opacity: 0 }} initial={{ y: "100%", opacity: 0 }}
animate={{ y: 0, opacity: 1 }} animate={{ y: 0, opacity: 1 }}
exit={{ y: "100%", opacity: 0 }} exit={{ y: "100%", opacity: 0 }}
@ -285,7 +283,7 @@ export default function PostCreateModal({
onClick={handleSubmit} onClick={handleSubmit}
disabled={!canSubmit} disabled={!canSubmit}
> >
{isSaving ? ( {isSaving || uploading ? (
<i className="fa-solid fa-spinner fa-spin" /> <i className="fa-solid fa-spinner fa-spin" />
) : mode === "live" ? ( ) : mode === "live" ? (
<> <>
@ -298,28 +296,31 @@ export default function PostCreateModal({
</motion.button> </motion.button>
</div> </div>
{/* Content modes */} {/* Content modes (hidden for Apps category) */}
<div className="flex gap-1 px-4 py-3 border-b border-white/5 overflow-x-auto"> {category !== "Apps" && (
{CONTENT_MODES.map((m) => ( <div className="flex gap-1 px-4 py-3 border-b border-white/5 overflow-x-auto">
<motion.button {CONTENT_MODES.map((m) => (
key={m.id} <motion.button
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-semibold whitespace-nowrap key={m.id}
transition-all ${mode === m.id className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-semibold whitespace-nowrap
? `bg-gradient-to-r ${m.color} text-white shadow-lg` transition-all ${mode === m.id
: "bg-surface-700/30 text-surface-400 hover:text-white hover:bg-surface-700/50"}`} ? `bg-gradient-to-r ${m.color} text-white shadow-lg`
whileHover={{ scale: 1.02 }} : "bg-surface-700/30 text-surface-400 hover:text-white hover:bg-surface-700/50"}`}
whileTap={{ scale: 0.98 }} whileHover={{ scale: 1.02 }}
onClick={() => setMode(m.id)} whileTap={{ scale: 0.98 }}
> onClick={() => setMode(m.id)}
<i className={`fa-solid ${m.icon}`} /> >
{m.label} <i className={`fa-solid ${m.icon}`} />
</motion.button> {m.label}
))} </motion.button>
</div> ))}
</div>
)}
{/* Scrollable content */} {/* Scrollable content */}
<div className="p-4 max-h-[45vh] overflow-y-auto space-y-4"> <div className="p-4 max-h-[60vh] overflow-y-auto space-y-4">
{/* Category row */} {/* Category row (hidden for news mode) */}
{mode !== "news" && (
<div className="flex gap-2 overflow-x-auto pb-1"> <div className="flex gap-2 overflow-x-auto pb-1">
{CATEGORIES.map((cat) => ( {CATEGORIES.map((cat) => (
<motion.button <motion.button
@ -332,6 +333,7 @@ export default function PostCreateModal({
onClick={() => { onClick={() => {
setCategory(cat.id); setCategory(cat.id);
setSubCategory(SUB_CATEGORIES[cat.id]?.[0] || ""); setSubCategory(SUB_CATEGORIES[cat.id]?.[0] || "");
setSelectedApp(null);
}} }}
> >
<i className={`fa-solid ${cat.icon}`} /> <i className={`fa-solid ${cat.icon}`} />
@ -339,169 +341,282 @@ export default function PostCreateModal({
</motion.button> </motion.button>
))} ))}
</div> </div>
)}
{/* Sub-category or App selector */} {/* Apps category - show subscribed apps */}
{category === "Apps" ? ( {category === "Apps" ? (
<div className="space-y-2"> <div className="space-y-4">
{appsLoading ? ( {appsLoading ? (
<div className="flex items-center gap-2 text-surface-400 text-sm"> <div className="flex items-center justify-center py-8">
<i className="fa-solid fa-spinner fa-spin" /> <i className="fa-solid fa-spinner fa-spin text-2xl text-pink-400" />
Chargement des apps...
</div> </div>
) : subscribedApps.length === 0 ? ( ) : subscribedApps.length === 0 ? (
<div className="p-3 rounded-xl bg-surface-700/30 border border-white/5 text-center"> <div className="text-center py-6">
<i className="fa-solid fa-puzzle-piece text-2xl text-surface-600 mb-2" /> <i className="fa-solid fa-puzzle-piece text-4xl text-surface-600 mb-3" />
<p className="text-surface-400 text-sm">Aucune app disponible</p> <p className="text-surface-400 mb-1">No apps available</p>
<p className="text-surface-500 text-xs mt-1">Abonnez-vous à une app dans l'App Store</p> <p className="text-surface-500 text-sm">
Subscribe to apps in the App Store to post
</p>
</div> </div>
) : ( ) : (
<div className="flex gap-2 overflow-x-auto pb-1"> <>
{subscribedApps.map((app) => ( {/* App selector */}
<div className="grid grid-cols-3 gap-2">
{subscribedApps.map((app) => (
<motion.button
key={app.app_id}
className={`flex flex-col items-center gap-2 p-3 rounded-xl
border transition-all ${selectedApp?.app_id === app.app_id
? "bg-pink-500/20 border-pink-500/50"
: "bg-surface-700/30 border-white/5 hover:border-pink-500/30"}`}
whileTap={{ scale: 0.98 }}
onClick={() => setSelectedApp(app)}
>
<div
className="w-10 h-10 rounded-lg flex items-center justify-center"
style={{
backgroundColor: (app.assets?.color || app.color || "#EC4899") + "20",
}}
>
<i
className={`fa-solid ${app.assets?.icon || app.icon || "fa-puzzle-piece"}`}
style={{ color: app.assets?.color || app.color || "#EC4899" }}
/>
</div>
<p className="text-xs font-medium text-white text-center line-clamp-1">
{app.app_name}
</p>
</motion.button>
))}
</div>
{/* App post form */}
{selectedApp && (
<div className="space-y-3 pt-2">
<input
type="text"
placeholder="Title..."
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-pink-500/50"
/>
<textarea
ref={textareaRef}
placeholder="Description (optional)..."
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-pink-500/50
resize-none"
/>
</div>
)}
</>
)}
</div>
) : (
<>
{/* Sub-category (for non-Apps/non-News modes) */}
{mode !== "news" && (
<div className="flex gap-1.5 overflow-x-auto pb-1">
{SUB_CATEGORIES[category]?.map((sub) => (
<button <button
key={app.app_id} key={sub}
className={`flex items-center gap-2 px-3 py-2 rounded-xl text-sm font-medium whitespace-nowrap transition-all border className={`px-2.5 py-1 rounded-lg text-[11px] font-medium whitespace-nowrap transition-all
${selectedApp?.app_id === app.app_id ${subCategory === sub
? "bg-gradient-to-r from-pink-500 to-purple-500 text-white border-transparent" ? "bg-accent/20 text-accent border border-accent/40"
: "bg-surface-700/30 text-surface-400 border-surface-600/50 hover:border-surface-500"}`} : "bg-surface-700/30 text-surface-500 border border-transparent hover:text-surface-300"}`}
onClick={() => setSelectedApp(app)} onClick={() => setSubCategory(sub)}
> >
<i className="fa-solid fa-puzzle-piece" /> {sub}
{app.app_name}
</button> </button>
))} ))}
</div> </div>
)} )}
</div>
) : (
<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) */} {/* Link input (for link mode) */}
{mode === "link" && ( {mode === "link" && (
<div className="space-y-2"> <div className="space-y-2">
<div className="relative"> <div className="relative">
<i className="fa-solid fa-link absolute left-3 top-1/2 -translate-y-1/2 text-surface-500 text-sm" /> <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>
)}
{/* News mode - text post with optional image */}
{mode === "news" && (
<motion.div
className="space-y-3"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
<div className="p-3 rounded-xl bg-gradient-to-br from-amber-500/10 to-orange-500/10 border border-amber-500/20">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-amber-500 to-orange-500 flex items-center justify-center">
<i className="fa-solid fa-newspaper text-white text-lg" />
</div>
<div>
<p className="text-white font-bold text-sm">Post News</p>
<p className="text-surface-400 text-xs">Share news with text and image</p>
</div>
</div>
</div>
<input
type="text"
placeholder="News title..."
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-amber-500/50"
/>
<textarea
ref={textareaRef}
placeholder="Write your news content..."
value={body}
onChange={(e) => setBody(e.target.value)}
rows={3}
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-amber-500/50
resize-none"
/>
{/* Image gallery for news */}
{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>
)}
{/* Image upload 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={() => fileInputRef.current?.click()}
disabled={uploading || images.length >= 5}
>
<i className="fa-solid fa-image text-amber-400" />
Add Image {images.length > 0 && `(${images.length}/5)`}
</motion.button>
</motion.div>
)}
{/* Title input (hidden for news mode - has its own) */}
{mode !== "news" && (
<input <input
type="url" type="text"
placeholder="Paste a link..." placeholder={mode === "live" ? "Stream title..." : "What's happening?"}
value={linkUrl} value={title}
onChange={(e) => setLinkUrl(e.target.value)} onChange={(e) => setTitle(e.target.value)}
className="w-full pl-10 pr-4 py-3 rounded-xl className="w-full px-4 py-3 rounded-xl
bg-surface-700/50 border border-white/10 bg-surface-700/50 border border-white/10
text-surface-100 text-sm text-white text-base font-medium
placeholder:text-surface-500 placeholder:text-surface-500
focus:outline-none focus:border-cyan-500/50" 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>
{/* Body textarea (hidden for live and news modes) */}
{mode !== "live" && mode !== "news" && (
<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"
/>
)}
</>
)} )}
{/* Live mode info */} {/* Image gallery (for photo/text/apps modes) */}
{mode === "live" && ( {(mode === "photo" || mode === "text" || category === "Apps") && images.length > 0 && (
<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"
/>
)}
{/* Price input for Apps */}
{category === "Apps" && selectedApp && (
<div className="relative">
<i className="fa-solid fa-dollar-sign absolute left-4 top-1/2 -translate-y-1/2 text-emerald-500" />
<input
type="number"
placeholder="Prix (optionnel)"
value={price}
onChange={(e) => setPrice(e.target.value)}
min="0"
step="0.01"
className="w-full pl-10 pr-4 py-3 rounded-xl
bg-surface-700/50 border border-white/10
text-white text-base
placeholder:text-surface-500
focus:outline-none focus:border-emerald-500/50"
/>
</div>
)}
{/* Image gallery (for photo/text modes) */}
{(mode === "photo" || mode === "text") && images.length > 0 && (
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
{images.map((url, idx) => ( {images.map((url, idx) => (
<div key={idx} className="relative aspect-square rounded-xl overflow-hidden group"> <div key={idx} className="relative aspect-square rounded-xl overflow-hidden group">
@ -529,19 +644,11 @@ export default function PostCreateModal({
</div> </div>
)} )}
{/* Upload Error */} {/* Error */}
{uploadError && ( {(saveError || 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"> <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" /> <i className="fa-solid fa-exclamation-circle" />
{saveError} {saveError || uploadError}
</div> </div>
)} )}
@ -549,18 +656,18 @@ export default function PostCreateModal({
{coords ? ( {coords ? (
<div className="flex items-center gap-2 text-surface-400 text-sm"> <div className="flex items-center gap-2 text-surface-400 text-sm">
<i className="fa-solid fa-location-dot text-emerald-500" /> <i className="fa-solid fa-location-dot text-emerald-500" />
<span>Location: {coords[1]?.toFixed(4)}, {coords[0]?.toFixed(4)}</span> <span>Location pinned on map</span>
</div> </div>
) : ( ) : category === "Apps" && selectedApp && (
<div className="flex items-center gap-2 text-orange-400 text-sm"> <div className="flex items-center gap-2 text-orange-400 text-sm">
<i className="fa-solid fa-location-dot" /> <i className="fa-solid fa-location-dot" />
<span>No location - post may fail</span> <span>Move map to set location</span>
</div> </div>
)} )}
</div> </div>
{/* Bottom toolbar */} {/* Bottom toolbar */}
{(mode === "photo" || mode === "text") && ( {(mode === "photo" || mode === "text" || category === "Apps") && (
<div className="flex items-center gap-2 px-4 py-3 border-t border-white/5 bg-surface-800/50"> <div className="flex items-center gap-2 px-4 py-3 border-t border-white/5 bg-surface-800/50">
<motion.button <motion.button
className="flex items-center gap-2 px-3 py-2 rounded-xl className="flex items-center gap-2 px-3 py-2 rounded-xl
@ -569,24 +676,26 @@ export default function PostCreateModal({
hover:text-white hover:bg-surface-600/50 transition-all" hover:text-white hover:bg-surface-600/50 transition-all"
whileTap={{ scale: 0.95 }} whileTap={{ scale: 0.95 }}
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
disabled={uploading} disabled={uploading || images.length >= 5}
> >
<i className="fa-solid fa-image text-emerald-400" /> <i className="fa-solid fa-image text-emerald-400" />
Photo Photo {images.length > 0 && `(${images.length}/5)`}
</motion.button> </motion.button>
<motion.button {category !== "Apps" && (
className="flex items-center gap-2 px-3 py-2 rounded-xl <motion.button
bg-surface-700/50 border border-white/10 className="flex items-center gap-2 px-3 py-2 rounded-xl
text-surface-300 text-sm font-medium bg-surface-700/50 border border-white/10
hover:text-white hover:bg-surface-600/50 transition-all" text-surface-300 text-sm font-medium
whileTap={{ scale: 0.95 }} hover:text-white hover:bg-surface-600/50 transition-all"
onClick={() => cameraInputRef.current?.click()} whileTap={{ scale: 0.95 }}
disabled={uploading} onClick={() => cameraInputRef.current?.click()}
> disabled={uploading}
<i className="fa-solid fa-camera text-blue-400" /> >
Camera <i className="fa-solid fa-camera text-blue-400" />
</motion.button> Camera
</motion.button>
)}
<div className="flex-1" /> <div className="flex-1" />