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:
parent
498b41a2bf
commit
fca617681a
|
|
@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,7 +296,8 @@ export default function PostCreateModal({
|
||||||
</motion.button>
|
</motion.button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content modes */}
|
{/* Content modes (hidden for Apps category) */}
|
||||||
|
{category !== "Apps" && (
|
||||||
<div className="flex gap-1 px-4 py-3 border-b border-white/5 overflow-x-auto">
|
<div className="flex gap-1 px-4 py-3 border-b border-white/5 overflow-x-auto">
|
||||||
{CONTENT_MODES.map((m) => (
|
{CONTENT_MODES.map((m) => (
|
||||||
<motion.button
|
<motion.button
|
||||||
|
|
@ -316,10 +315,12 @@ export default function PostCreateModal({
|
||||||
</motion.button>
|
</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,40 +341,91 @@ 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">
|
<>
|
||||||
|
{/* App selector */}
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
{subscribedApps.map((app) => (
|
{subscribedApps.map((app) => (
|
||||||
<button
|
<motion.button
|
||||||
key={app.app_id}
|
key={app.app_id}
|
||||||
className={`flex items-center gap-2 px-3 py-2 rounded-xl text-sm font-medium whitespace-nowrap transition-all border
|
className={`flex flex-col items-center gap-2 p-3 rounded-xl
|
||||||
${selectedApp?.app_id === app.app_id
|
border transition-all ${selectedApp?.app_id === app.app_id
|
||||||
? "bg-gradient-to-r from-pink-500 to-purple-500 text-white border-transparent"
|
? "bg-pink-500/20 border-pink-500/50"
|
||||||
: "bg-surface-700/30 text-surface-400 border-surface-600/50 hover:border-surface-500"}`}
|
: "bg-surface-700/30 border-white/5 hover:border-pink-500/30"}`}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
onClick={() => setSelectedApp(app)}
|
onClick={() => setSelectedApp(app)}
|
||||||
>
|
>
|
||||||
<i className="fa-solid fa-puzzle-piece" />
|
<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}
|
{app.app_name}
|
||||||
</button>
|
</p>
|
||||||
|
</motion.button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Sub-category (for non-Apps/non-News modes) */}
|
||||||
|
{mode !== "news" && (
|
||||||
<div className="flex gap-1.5 overflow-x-auto pb-1">
|
<div className="flex gap-1.5 overflow-x-auto pb-1">
|
||||||
{SUB_CATEGORIES[category]?.map((sub) => (
|
{SUB_CATEGORIES[category]?.map((sub) => (
|
||||||
<button
|
<button
|
||||||
|
|
@ -450,7 +503,86 @@ export default function PostCreateModal({
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Title input */}
|
{/* 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="text"
|
type="text"
|
||||||
placeholder={mode === "live" ? "Stream title..." : "What's happening?"}
|
placeholder={mode === "live" ? "Stream title..." : "What's happening?"}
|
||||||
|
|
@ -462,9 +594,10 @@ export default function PostCreateModal({
|
||||||
placeholder:text-surface-500
|
placeholder:text-surface-500
|
||||||
focus:outline-none focus:border-cyan-500/50"
|
focus:outline-none focus:border-cyan-500/50"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Body textarea (hidden for live mode) */}
|
{/* Body textarea (hidden for live and news modes) */}
|
||||||
{mode !== "live" && (
|
{mode !== "live" && mode !== "news" && (
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
placeholder="Add more details..."
|
placeholder="Add more details..."
|
||||||
|
|
@ -479,29 +612,11 @@ export default function PostCreateModal({
|
||||||
resize-none"
|
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) */}
|
{/* Image gallery (for photo/text/apps modes) */}
|
||||||
{(mode === "photo" || mode === "text") && images.length > 0 && (
|
{(mode === "photo" || mode === "text" || category === "Apps") && 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,12 +676,13 @@ 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>
|
||||||
|
|
||||||
|
{category !== "Apps" && (
|
||||||
<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
|
||||||
bg-surface-700/50 border border-white/10
|
bg-surface-700/50 border border-white/10
|
||||||
|
|
@ -587,6 +695,7 @@ export default function PostCreateModal({
|
||||||
<i className="fa-solid fa-camera text-blue-400" />
|
<i className="fa-solid fa-camera text-blue-400" />
|
||||||
Camera
|
Camera
|
||||||
</motion.button>
|
</motion.button>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue