Add App Store integration to TopBar and wire up Apps category
- Add App Store button (puzzle piece icon) in TopBarNew - Import AppStore component in App.jsx and add modal state - Pass onOpenAppStore handler to TopBar - PostCreateModal already has Apps category with app selector Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
b3fc07b001
commit
987920a5e4
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "sociowire-frontend",
|
"name": "sociowire-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.135",
|
"version": "0.0.136",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
|
|
||||||
14
src/App.jsx
14
src/App.jsx
|
|
@ -8,6 +8,7 @@ import IslandViewer from "./components/Island/IslandViewer";
|
||||||
import ChatContainer from "./components/Chat/ChatContainer";
|
import ChatContainer from "./components/Chat/ChatContainer";
|
||||||
import PostDetailModal from "./components/Posts/PostDetailModal";
|
import PostDetailModal from "./components/Posts/PostDetailModal";
|
||||||
import PostComposer, { CreatePostFAB } from "./components/Posts/PostComposer";
|
import PostComposer, { CreatePostFAB } from "./components/Posts/PostComposer";
|
||||||
|
import { AppStore } from "./components/Apps";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { fetchIslandByUsername } from "./api/client";
|
import { fetchIslandByUsername } from "./api/client";
|
||||||
|
|
||||||
|
|
@ -74,6 +75,9 @@ export default function App() {
|
||||||
// Post detail modal state (triggered from map markers)
|
// Post detail modal state (triggered from map markers)
|
||||||
const [detailPost, setDetailPost] = useState(null);
|
const [detailPost, setDetailPost] = useState(null);
|
||||||
|
|
||||||
|
// App Store state
|
||||||
|
const [showAppStore, setShowAppStore] = useState(false);
|
||||||
|
|
||||||
const handlePostsState = useCallback((next) => {
|
const handlePostsState = useCallback((next) => {
|
||||||
if (!next) return;
|
if (!next) return;
|
||||||
if (Array.isArray(next.posts)) setWallPosts(next.posts);
|
if (Array.isArray(next.posts)) setWallPosts(next.posts);
|
||||||
|
|
@ -244,6 +248,7 @@ export default function App() {
|
||||||
onMapClick={handleOpenMap}
|
onMapClick={handleOpenMap}
|
||||||
onUserIsland={handleOpenUserIsland}
|
onUserIsland={handleOpenUserIsland}
|
||||||
currentView={currentView}
|
currentView={currentView}
|
||||||
|
onOpenAppStore={() => setShowAppStore(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="main-shell">
|
<div className="main-shell">
|
||||||
|
|
@ -419,6 +424,15 @@ export default function App() {
|
||||||
|
|
||||||
{/* Floating Action Button for creating posts */}
|
{/* Floating Action Button for creating posts */}
|
||||||
<CreatePostFAB />
|
<CreatePostFAB />
|
||||||
|
|
||||||
|
{/* App Store Modal */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showAppStore && (
|
||||||
|
<AppStore
|
||||||
|
onClose={() => setShowAppStore(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -252,7 +252,7 @@ function UserChip({ authenticated, loading, username, avatarUrl: propAvatarUrl,
|
||||||
// Main Component
|
// Main Component
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function TopBarNew({ theme = "dark", onChangeTheme, onIslandsClick, onPlanetsClick, onMapClick, onUserIsland, onMyLocation, onSearch, currentView }) {
|
export default function TopBarNew({ theme = "dark", onChangeTheme, onIslandsClick, onPlanetsClick, onMapClick, onUserIsland, onMyLocation, onSearch, currentView, onOpenAppStore }) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const {
|
const {
|
||||||
loading, authenticated, username, login, logout, restore, loginWithProvider, ssoEnabled,
|
loading, authenticated, username, login, logout, restore, loginWithProvider, ssoEnabled,
|
||||||
|
|
@ -923,6 +923,18 @@ export default function TopBarNew({ theme = "dark", onChangeTheme, onIslandsClic
|
||||||
)}
|
)}
|
||||||
</motion.button>
|
</motion.button>
|
||||||
|
|
||||||
|
{/* App Store */}
|
||||||
|
<motion.button
|
||||||
|
className="w-7 h-7 sm:w-8 sm:h-8 md:w-9 md:h-9 rounded-lg sm:rounded-xl flex items-center justify-center transition-all text-xs sm:text-sm
|
||||||
|
bg-surface-800/50 text-surface-400 border-white/5 border hover:bg-purple-500/20 hover:text-purple-400 hover:border-purple-500/40"
|
||||||
|
onClick={() => authenticated ? onOpenAppStore?.() : openModal("login")}
|
||||||
|
whileHover={{ scale: 1.1 }}
|
||||||
|
whileTap={{ scale: 0.9 }}
|
||||||
|
title={t('topbar.appStore', 'App Store')}
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-puzzle-piece" />
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
{/* Separator */}
|
{/* Separator */}
|
||||||
<div className="hidden sm:block w-px h-7 bg-gradient-to-b from-transparent via-white/20 to-transparent mx-1" />
|
<div className="hidden sm:block w-px h-7 bg-gradient-to-b from-transparent via-white/20 to-transparent mx-1" />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ 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 { uploadPostMedia, fetchPostPreview } from "../../api/client";
|
||||||
|
import { listSubscriptions, uploadPostImages, createPost as createAppPost } from "../../api/extAppsApi";
|
||||||
|
|
||||||
// Content modes
|
// Content modes
|
||||||
const CONTENT_MODES = [
|
const CONTENT_MODES = [
|
||||||
|
|
@ -16,6 +17,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" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const SUB_CATEGORIES = {
|
const SUB_CATEGORIES = {
|
||||||
|
|
@ -23,6 +25,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
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PostCreateModal({
|
export default function PostCreateModal({
|
||||||
|
|
@ -46,6 +49,11 @@ 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("");
|
||||||
|
// 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);
|
||||||
|
|
@ -77,6 +85,25 @@ export default function PostCreateModal({
|
||||||
}
|
}
|
||||||
}, [body]);
|
}, [body]);
|
||||||
|
|
||||||
|
// Load subscribed apps when Apps category is selected
|
||||||
|
useEffect(() => {
|
||||||
|
if (category === "Apps" && subscribedApps.length === 0 && !appsLoading) {
|
||||||
|
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
|
// Link preview fetch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode !== "link" || !linkUrl.trim()) {
|
if (mode !== "link" || !linkUrl.trim()) {
|
||||||
|
|
@ -132,7 +159,7 @@ export default function PostCreateModal({
|
||||||
setImages((prev) => prev.filter((_, i) => i !== index));
|
setImages((prev) => prev.filter((_, i) => i !== index));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = async () => {
|
||||||
if (mode === "live") {
|
if (mode === "live") {
|
||||||
onGoLive?.({ category, subCategory, title, body, coords });
|
onGoLive?.({ category, subCategory, title, body, coords });
|
||||||
return;
|
return;
|
||||||
|
|
@ -140,6 +167,45 @@ export default function PostCreateModal({
|
||||||
|
|
||||||
if (!title.trim() && !body.trim() && images.length === 0 && !linkUrl.trim()) return;
|
if (!title.trim() && !body.trim() && images.length === 0 && !linkUrl.trim()) return;
|
||||||
|
|
||||||
|
// Handle external app posts
|
||||||
|
if (category === "Apps" && selectedApp) {
|
||||||
|
try {
|
||||||
|
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 = {
|
||||||
|
title: title.trim(),
|
||||||
|
description: body.trim(),
|
||||||
|
images: uploadedImages,
|
||||||
|
location: coords ? {
|
||||||
|
lat: coords[1],
|
||||||
|
lon: coords[0],
|
||||||
|
} : null,
|
||||||
|
content: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (price) {
|
||||||
|
postData.content.price = parseFloat(price);
|
||||||
|
}
|
||||||
|
|
||||||
|
await createAppPost(selectedApp.app_id, postData);
|
||||||
|
onClose?.();
|
||||||
|
} catch (err) {
|
||||||
|
setUploadError(err.message || "Failed to create app post");
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
onSubmit?.({
|
onSubmit?.({
|
||||||
mode,
|
mode,
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
|
|
@ -154,7 +220,9 @@ export default function PostCreateModal({
|
||||||
};
|
};
|
||||||
|
|
||||||
const canSubmit = mode === "live" ||
|
const canSubmit = mode === "live" ||
|
||||||
(title.trim() || body.trim() || images.length > 0 || linkUrl.trim()) && !isSaving && !uploading;
|
(category === "Apps"
|
||||||
|
? (selectedApp && title.trim() && coords && !isSaving && !uploading)
|
||||||
|
: ((title.trim() || body.trim() || images.length > 0 || linkUrl.trim()) && !isSaving && !uploading));
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
|
@ -272,21 +340,54 @@ export default function PostCreateModal({
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sub-category */}
|
{/* Sub-category or App selector */}
|
||||||
<div className="flex gap-1.5 overflow-x-auto pb-1">
|
{category === "Apps" ? (
|
||||||
{SUB_CATEGORIES[category]?.map((sub) => (
|
<div className="space-y-2">
|
||||||
<button
|
{appsLoading ? (
|
||||||
key={sub}
|
<div className="flex items-center gap-2 text-surface-400 text-sm">
|
||||||
className={`px-2.5 py-1 rounded-lg text-[11px] font-medium whitespace-nowrap transition-all
|
<i className="fa-solid fa-spinner fa-spin" />
|
||||||
${subCategory === sub
|
Chargement des apps...
|
||||||
? "bg-accent/20 text-accent border border-accent/40"
|
</div>
|
||||||
: "bg-surface-700/30 text-surface-500 border border-transparent hover:text-surface-300"}`}
|
) : subscribedApps.length === 0 ? (
|
||||||
onClick={() => setSubCategory(sub)}
|
<div className="p-3 rounded-xl bg-surface-700/30 border border-white/5 text-center">
|
||||||
>
|
<i className="fa-solid fa-puzzle-piece text-2xl text-surface-600 mb-2" />
|
||||||
{sub}
|
<p className="text-surface-400 text-sm">Aucune app disponible</p>
|
||||||
</button>
|
<p className="text-surface-500 text-xs mt-1">Abonnez-vous à une app dans l'App Store</p>
|
||||||
))}
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||||
|
{subscribedApps.map((app) => (
|
||||||
|
<button
|
||||||
|
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
|
||||||
|
${selectedApp?.app_id === app.app_id
|
||||||
|
? "bg-gradient-to-r from-pink-500 to-purple-500 text-white border-transparent"
|
||||||
|
: "bg-surface-700/30 text-surface-400 border-surface-600/50 hover:border-surface-500"}`}
|
||||||
|
onClick={() => setSelectedApp(app)}
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-puzzle-piece" />
|
||||||
|
{app.app_name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</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" && (
|
||||||
|
|
@ -379,6 +480,26 @@ export default function PostCreateModal({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 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 modes) */}
|
||||||
{(mode === "photo" || mode === "text") && images.length > 0 && (
|
{(mode === "photo" || mode === "text") && images.length > 0 && (
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue