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:
Your Name 2026-01-28 19:26:34 +00:00
parent b3fc07b001
commit 987920a5e4
4 changed files with 166 additions and 19 deletions

View File

@ -1,7 +1,7 @@
{
"name": "sociowire-frontend",
"private": true,
"version": "0.0.135",
"version": "0.0.136",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",

View File

@ -8,6 +8,7 @@ import IslandViewer from "./components/Island/IslandViewer";
import ChatContainer from "./components/Chat/ChatContainer";
import PostDetailModal from "./components/Posts/PostDetailModal";
import PostComposer, { CreatePostFAB } from "./components/Posts/PostComposer";
import { AppStore } from "./components/Apps";
import { AnimatePresence, motion } from "framer-motion";
import { fetchIslandByUsername } from "./api/client";
@ -74,6 +75,9 @@ export default function App() {
// Post detail modal state (triggered from map markers)
const [detailPost, setDetailPost] = useState(null);
// App Store state
const [showAppStore, setShowAppStore] = useState(false);
const handlePostsState = useCallback((next) => {
if (!next) return;
if (Array.isArray(next.posts)) setWallPosts(next.posts);
@ -244,6 +248,7 @@ export default function App() {
onMapClick={handleOpenMap}
onUserIsland={handleOpenUserIsland}
currentView={currentView}
onOpenAppStore={() => setShowAppStore(true)}
/>
<div className="main-shell">
@ -419,6 +424,15 @@ export default function App() {
{/* Floating Action Button for creating posts */}
<CreatePostFAB />
{/* App Store Modal */}
<AnimatePresence>
{showAppStore && (
<AppStore
onClose={() => setShowAppStore(false)}
/>
)}
</AnimatePresence>
</div>
);
}

View File

@ -252,7 +252,7 @@ function UserChip({ authenticated, loading, username, avatarUrl: propAvatarUrl,
// 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 {
loading, authenticated, username, login, logout, restore, loginWithProvider, ssoEnabled,
@ -923,6 +923,18 @@ export default function TopBarNew({ theme = "dark", onChangeTheme, onIslandsClic
)}
</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 */}
<div className="hidden sm:block w-px h-7 bg-gradient-to-b from-transparent via-white/20 to-transparent mx-1" />

View File

@ -2,6 +2,7 @@ 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";
import { listSubscriptions, uploadPostImages, createPost as createAppPost } from "../../api/extAppsApi";
// 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: "Events", icon: "fa-calendar", color: "from-purple-500 to-violet-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 = {
@ -23,6 +25,7 @@ const SUB_CATEGORIES = {
Friends: ["Nearby", "Stories", "Hangout", "Help"],
Events: ["Today", "Weekend", "Music", "Sports", "Food"],
Market: ["Deals", "Services", "Jobs", "Housing"],
Apps: [], // Dynamic - loaded from subscriptions
};
export default function PostCreateModal({
@ -46,6 +49,11 @@ export default function PostCreateModal({
const [linkLoading, setLinkLoading] = useState(false);
const [images, setImages] = useState([]);
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 cameraInputRef = useRef(null);
const textareaRef = useRef(null);
@ -77,6 +85,25 @@ export default function PostCreateModal({
}
}, [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
useEffect(() => {
if (mode !== "link" || !linkUrl.trim()) {
@ -132,7 +159,7 @@ export default function PostCreateModal({
setImages((prev) => prev.filter((_, i) => i !== index));
};
const handleSubmit = () => {
const handleSubmit = async () => {
if (mode === "live") {
onGoLive?.({ category, subCategory, title, body, coords });
return;
@ -140,6 +167,45 @@ export default function PostCreateModal({
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?.({
mode,
title: title.trim(),
@ -154,7 +220,9 @@ export default function PostCreateModal({
};
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;
@ -272,7 +340,39 @@ export default function PostCreateModal({
))}
</div>
{/* Sub-category */}
{/* Sub-category or App selector */}
{category === "Apps" ? (
<div className="space-y-2">
{appsLoading ? (
<div className="flex items-center gap-2 text-surface-400 text-sm">
<i className="fa-solid fa-spinner fa-spin" />
Chargement des apps...
</div>
) : subscribedApps.length === 0 ? (
<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" />
<p className="text-surface-400 text-sm">Aucune app disponible</p>
<p className="text-surface-500 text-xs mt-1">Abonnez-vous à une app dans l'App Store</p>
</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
@ -287,6 +387,7 @@ export default function PostCreateModal({
</button>
))}
</div>
)}
{/* Link input (for link mode) */}
{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) */}
{(mode === "photo" || mode === "text") && images.length > 0 && (
<div className="grid grid-cols-3 gap-2">