feat(frontend): add external apps UI components
- AppStore for browsing and subscribing to apps - SubscribeModal with consent flow - ExtAppPostCard for rendering app posts - CreateAppPost for posting to subscribed apps - MySubscriptions for managing subscriptions - useExtAppFeed hook for fetching app posts - extAppsApi client for all API calls Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
6d92a984ca
commit
b3fc07b001
|
|
@ -0,0 +1,324 @@
|
||||||
|
/**
|
||||||
|
* External Apps API Client
|
||||||
|
*
|
||||||
|
* Handles communication with apps-gateway for:
|
||||||
|
* - App registry (browse, install)
|
||||||
|
* - Subscriptions (with consent)
|
||||||
|
* - Federated feed (posts from subscribed apps)
|
||||||
|
* - Asset proxy URLs
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { loadAuth } from "../auth/authStorage";
|
||||||
|
|
||||||
|
function apiBase() {
|
||||||
|
const raw = (import.meta?.env?.VITE_API_BASE_URL || "")
|
||||||
|
.toString()
|
||||||
|
.replace(/\/+$/, "");
|
||||||
|
if (raw) return raw;
|
||||||
|
try {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const host = window.location.hostname || "";
|
||||||
|
if (host.startsWith("www.")) {
|
||||||
|
return `https://${host.replace(/^www\./, "")}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function authHeaders() {
|
||||||
|
try {
|
||||||
|
const auth = loadAuth();
|
||||||
|
const token = auth?.access_token;
|
||||||
|
if (!token) return {};
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all available apps in the registry
|
||||||
|
*/
|
||||||
|
export async function listApps({ category, level, status = "active" } = {}) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (category) params.set("category", category);
|
||||||
|
if (level) params.set("level", level);
|
||||||
|
if (status) params.set("status", status);
|
||||||
|
|
||||||
|
const res = await fetch(`${apiBase()}/api/registry?${params.toString()}`, {
|
||||||
|
credentials: "include",
|
||||||
|
headers: authHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to list apps: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single app by ID
|
||||||
|
*/
|
||||||
|
export async function getApp(appId) {
|
||||||
|
const res = await fetch(`${apiBase()}/api/registry/${appId}`, {
|
||||||
|
credentials: "include",
|
||||||
|
headers: authHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 404) return null;
|
||||||
|
throw new Error(`Failed to get app: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to an app with consent
|
||||||
|
*/
|
||||||
|
export async function subscribeToApp(appId, consent, tier = "free") {
|
||||||
|
const res = await fetch(`${apiBase()}/api/subscriptions/${appId}`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: {
|
||||||
|
...authHeaders(),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ consent, tier }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || `Failed to subscribe: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsubscribe from an app
|
||||||
|
*/
|
||||||
|
export async function unsubscribeFromApp(appId) {
|
||||||
|
const res = await fetch(`${apiBase()}/api/subscriptions/${appId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
credentials: "include",
|
||||||
|
headers: authHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to unsubscribe: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List user's subscribed apps
|
||||||
|
*/
|
||||||
|
export async function listSubscriptions() {
|
||||||
|
const res = await fetch(`${apiBase()}/api/subscriptions`, {
|
||||||
|
credentials: "include",
|
||||||
|
headers: authHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to list subscriptions: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get subscription details for an app
|
||||||
|
*/
|
||||||
|
export async function getSubscription(appId) {
|
||||||
|
const res = await fetch(`${apiBase()}/api/subscriptions/${appId}`, {
|
||||||
|
credentials: "include",
|
||||||
|
headers: authHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 404) return null;
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to get subscription: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update subscription (tier, consent)
|
||||||
|
*/
|
||||||
|
export async function updateSubscription(appId, updates) {
|
||||||
|
const res = await fetch(`${apiBase()}/api/subscriptions/${appId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
credentials: "include",
|
||||||
|
headers: {
|
||||||
|
...authHeaders(),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(updates),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to update subscription: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get federated feed from subscribed apps
|
||||||
|
*/
|
||||||
|
export async function getFeed({ bbox, zoom, apps, limit = 50 } = {}) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (bbox) {
|
||||||
|
params.set("bbox", `${bbox.west},${bbox.south},${bbox.east},${bbox.north}`);
|
||||||
|
}
|
||||||
|
if (zoom) params.set("zoom", zoom);
|
||||||
|
if (apps && apps.length > 0) params.set("apps", apps.join(","));
|
||||||
|
if (limit) params.set("limit", limit);
|
||||||
|
|
||||||
|
const res = await fetch(`${apiBase()}/api/feed?${params.toString()}`, {
|
||||||
|
credentials: "include",
|
||||||
|
headers: authHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to get feed: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search across subscribed apps
|
||||||
|
*/
|
||||||
|
export async function searchApps({ query, location, apps, filters, limit = 20 } = {}) {
|
||||||
|
const res = await fetch(`${apiBase()}/api/feed/search`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: {
|
||||||
|
...authHeaders(),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ query, location, apps, filters, limit }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to search: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build asset URL with optional resize params
|
||||||
|
*/
|
||||||
|
export function buildAssetUrl(appId, path, { w, h, q } = {}) {
|
||||||
|
const base = `${apiBase()}/api/assets/${appId}/${path}`;
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (w) params.set("w", w);
|
||||||
|
if (h) params.set("h", h);
|
||||||
|
if (q) params.set("q", q);
|
||||||
|
const qs = params.toString();
|
||||||
|
return qs ? `${base}?${qs}` : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload images for a post
|
||||||
|
*/
|
||||||
|
export async function uploadPostImages(appId, files) {
|
||||||
|
const formData = new FormData();
|
||||||
|
for (const file of files) {
|
||||||
|
formData.append("images", file);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${apiBase()}/api/posts/${appId}/upload`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: authHeaders(),
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || `Upload failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a post for an external app
|
||||||
|
*/
|
||||||
|
export async function createPost(appId, postData) {
|
||||||
|
const res = await fetch(`${apiBase()}/api/posts/${appId}`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: {
|
||||||
|
...authHeaders(),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(postData),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || `Create post failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get post status
|
||||||
|
*/
|
||||||
|
export async function getPostStatus(appId, postId) {
|
||||||
|
const res = await fetch(`${apiBase()}/api/posts/${appId}/${postId}`, {
|
||||||
|
credentials: "include",
|
||||||
|
headers: authHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 404) return null;
|
||||||
|
throw new Error(`Get post failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a post
|
||||||
|
*/
|
||||||
|
export async function deletePost(appId, postId) {
|
||||||
|
const res = await fetch(`${apiBase()}/api/posts/${appId}/${postId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
credentials: "include",
|
||||||
|
headers: authHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Delete post failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
listApps,
|
||||||
|
getApp,
|
||||||
|
subscribeToApp,
|
||||||
|
unsubscribeFromApp,
|
||||||
|
listSubscriptions,
|
||||||
|
getSubscription,
|
||||||
|
updateSubscription,
|
||||||
|
getFeed,
|
||||||
|
searchApps,
|
||||||
|
buildAssetUrl,
|
||||||
|
uploadPostImages,
|
||||||
|
createPost,
|
||||||
|
getPostStatus,
|
||||||
|
deletePost,
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
/**
|
||||||
|
* AppCard - Display an app in the store
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { buildAssetUrl } from "../../api/extAppsApi";
|
||||||
|
|
||||||
|
export default function AppCard({ app, subscription, onSubscribe }) {
|
||||||
|
const isSubscribed = !!subscription;
|
||||||
|
const iconUrl = app.icon
|
||||||
|
? buildAssetUrl(app.app_id, app.icon, { w: 80, h: 80 })
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-zinc-800/50 rounded-xl p-4 border border-zinc-700/50 hover:border-purple-500/50 transition-colors">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start gap-3 mb-3">
|
||||||
|
{/* Icon */}
|
||||||
|
<div
|
||||||
|
className="w-14 h-14 rounded-xl bg-zinc-700 flex items-center justify-center flex-shrink-0 overflow-hidden"
|
||||||
|
style={
|
||||||
|
iconUrl
|
||||||
|
? {
|
||||||
|
backgroundImage: `url(${iconUrl})`,
|
||||||
|
backgroundSize: "cover",
|
||||||
|
backgroundPosition: "center",
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{!iconUrl && (
|
||||||
|
<i className={`fas ${app.fa_icon || "fa-puzzle-piece"} text-xl text-purple-400`} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-semibold text-white truncate">{app.name}</h3>
|
||||||
|
<p className="text-sm text-zinc-400 truncate">{app.developer || "Développeur"}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
{app.level === "advanced" && (
|
||||||
|
<span className="text-xs px-2 py-0.5 bg-purple-600/20 text-purple-400 rounded">
|
||||||
|
Avancé
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{app.category && (
|
||||||
|
<span className="text-xs text-zinc-500">{app.category}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<p className="text-sm text-zinc-400 mb-4 line-clamp-2">
|
||||||
|
{app.description || "Aucune description disponible"}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
{app.stats && (
|
||||||
|
<div className="flex items-center gap-4 mb-4 text-xs text-zinc-500">
|
||||||
|
<span>
|
||||||
|
<i className="fas fa-users mr-1" />
|
||||||
|
{app.stats.subscribers || 0}
|
||||||
|
</span>
|
||||||
|
{app.stats.posts && (
|
||||||
|
<span>
|
||||||
|
<i className="fas fa-file-alt mr-1" />
|
||||||
|
{app.stats.posts}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isSubscribed ? (
|
||||||
|
<button
|
||||||
|
className="flex-1 py-2 px-4 bg-zinc-700 text-zinc-300 rounded-lg text-sm font-medium"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<i className="fas fa-check mr-2" />
|
||||||
|
Abonné
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={onSubscribe}
|
||||||
|
className="flex-1 py-2 px-4 bg-purple-600 hover:bg-purple-500 text-white rounded-lg text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
S'abonner
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button className="p-2 bg-zinc-700 hover:bg-zinc-600 rounded-lg transition-colors">
|
||||||
|
<i className="fas fa-ellipsis-v text-zinc-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
/**
|
||||||
|
* App Store - Browse and subscribe to external apps
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { listApps, subscribeToApp, listSubscriptions } from "../../api/extAppsApi";
|
||||||
|
import AppCard from "./AppCard";
|
||||||
|
import SubscribeModal from "./SubscribeModal";
|
||||||
|
|
||||||
|
const CATEGORIES = [
|
||||||
|
{ id: "all", label: "Tout", icon: "fa-globe" },
|
||||||
|
{ id: "marketplace", label: "Marché", icon: "fa-store" },
|
||||||
|
{ id: "services", label: "Services", icon: "fa-wrench" },
|
||||||
|
{ id: "social", label: "Social", icon: "fa-users" },
|
||||||
|
{ id: "info", label: "Info", icon: "fa-info-circle" },
|
||||||
|
{ id: "entertainment", label: "Divertissement", icon: "fa-gamepad" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AppStore({ onClose }) {
|
||||||
|
const [apps, setApps] = useState([]);
|
||||||
|
const [subscriptions, setSubscriptions] = useState({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [category, setCategory] = useState("all");
|
||||||
|
const [selectedApp, setSelectedApp] = useState(null);
|
||||||
|
const [showSubscribeModal, setShowSubscribeModal] = useState(false);
|
||||||
|
|
||||||
|
const loadApps = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const [appsResult, subsResult] = await Promise.all([
|
||||||
|
listApps({ category: category === "all" ? undefined : category }),
|
||||||
|
listSubscriptions(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setApps(appsResult.apps || []);
|
||||||
|
|
||||||
|
// Build subscriptions lookup
|
||||||
|
const subsMap = {};
|
||||||
|
for (const sub of subsResult.subscriptions || []) {
|
||||||
|
subsMap[sub.app_id] = sub;
|
||||||
|
}
|
||||||
|
setSubscriptions(subsMap);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load apps:", err);
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [category]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadApps();
|
||||||
|
}, [loadApps]);
|
||||||
|
|
||||||
|
const handleSubscribe = (app) => {
|
||||||
|
setSelectedApp(app);
|
||||||
|
setShowSubscribeModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmSubscribe = async (consent, tier) => {
|
||||||
|
if (!selectedApp) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await subscribeToApp(selectedApp.app_id, consent, tier);
|
||||||
|
setShowSubscribeModal(false);
|
||||||
|
setSelectedApp(null);
|
||||||
|
await loadApps(); // Refresh
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Subscribe failed:", err);
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-zinc-900 rounded-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-zinc-800">
|
||||||
|
<h1 className="text-xl font-semibold text-white">
|
||||||
|
<i className="fas fa-puzzle-piece mr-2 text-purple-400" />
|
||||||
|
App Store
|
||||||
|
</h1>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 hover:bg-zinc-800 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
<i className="fas fa-times text-zinc-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Categories */}
|
||||||
|
<div className="flex gap-2 p-4 overflow-x-auto border-b border-zinc-800">
|
||||||
|
{CATEGORIES.map((cat) => (
|
||||||
|
<button
|
||||||
|
key={cat.id}
|
||||||
|
onClick={() => setCategory(cat.id)}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-full text-sm whitespace-nowrap transition-colors ${
|
||||||
|
category === cat.id
|
||||||
|
? "bg-purple-600 text-white"
|
||||||
|
: "bg-zinc-800 text-zinc-300 hover:bg-zinc-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<i className={`fas ${cat.icon}`} />
|
||||||
|
{cat.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-48">
|
||||||
|
<i className="fas fa-spinner fa-spin text-2xl text-purple-400" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="text-center text-red-400 py-8">
|
||||||
|
<i className="fas fa-exclamation-triangle text-2xl mb-2" />
|
||||||
|
<p>{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={loadApps}
|
||||||
|
className="mt-4 px-4 py-2 bg-zinc-800 rounded-lg hover:bg-zinc-700"
|
||||||
|
>
|
||||||
|
Réessayer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : apps.length === 0 ? (
|
||||||
|
<div className="text-center text-zinc-500 py-8">
|
||||||
|
<i className="fas fa-box-open text-4xl mb-4" />
|
||||||
|
<p>Aucune app disponible dans cette catégorie</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{apps.map((app) => (
|
||||||
|
<AppCard
|
||||||
|
key={app.app_id}
|
||||||
|
app={app}
|
||||||
|
subscription={subscriptions[app.app_id]}
|
||||||
|
onSubscribe={() => handleSubscribe(app)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subscribe Modal */}
|
||||||
|
{showSubscribeModal && selectedApp && (
|
||||||
|
<SubscribeModal
|
||||||
|
app={selectedApp}
|
||||||
|
onConfirm={handleConfirmSubscribe}
|
||||||
|
onCancel={() => {
|
||||||
|
setShowSubscribeModal(false);
|
||||||
|
setSelectedApp(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,390 @@
|
||||||
|
/**
|
||||||
|
* CreateAppPost - Create a post for an external app
|
||||||
|
*
|
||||||
|
* User selects which app to post to (from subscribed apps),
|
||||||
|
* fills out form with images, and submits through Sociowire.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
|
import { listSubscriptions, uploadPostImages, createPost, buildAssetUrl } from "../../api/extAppsApi";
|
||||||
|
|
||||||
|
export default function CreateAppPost({ onClose, onSuccess, initialLocation }) {
|
||||||
|
const [subscriptions, setSubscriptions] = useState([]);
|
||||||
|
const [selectedApp, setSelectedApp] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [category, setCategory] = useState("");
|
||||||
|
const [price, setPrice] = useState("");
|
||||||
|
const [images, setImages] = useState([]);
|
||||||
|
const [imagePreviews, setImagePreviews] = useState([]);
|
||||||
|
const [location, setLocation] = useState(initialLocation || null);
|
||||||
|
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
|
|
||||||
|
// Load subscribed apps
|
||||||
|
useEffect(() => {
|
||||||
|
loadSubscriptions();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadSubscriptions = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const result = await listSubscriptions();
|
||||||
|
const apps = (result.subscriptions || []).filter(
|
||||||
|
(sub) => sub.capabilities?.includes("posts")
|
||||||
|
);
|
||||||
|
setSubscriptions(apps);
|
||||||
|
if (apps.length === 1) {
|
||||||
|
setSelectedApp(apps[0]);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageSelect = (e) => {
|
||||||
|
const files = Array.from(e.target.files);
|
||||||
|
if (files.length === 0) return;
|
||||||
|
|
||||||
|
// Limit to 5 images
|
||||||
|
const newImages = [...images, ...files].slice(0, 5);
|
||||||
|
setImages(newImages);
|
||||||
|
|
||||||
|
// Generate previews
|
||||||
|
const newPreviews = newImages.map((file) => URL.createObjectURL(file));
|
||||||
|
// Clean up old previews
|
||||||
|
imagePreviews.forEach((url) => URL.revokeObjectURL(url));
|
||||||
|
setImagePreviews(newPreviews);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeImage = (index) => {
|
||||||
|
const newImages = images.filter((_, i) => i !== index);
|
||||||
|
setImages(newImages);
|
||||||
|
|
||||||
|
URL.revokeObjectURL(imagePreviews[index]);
|
||||||
|
const newPreviews = imagePreviews.filter((_, i) => i !== index);
|
||||||
|
setImagePreviews(newPreviews);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!selectedApp) {
|
||||||
|
setError("Sélectionnez une app");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!title.trim()) {
|
||||||
|
setError("Le titre est requis");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!location) {
|
||||||
|
setError("La localisation est requise");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Upload images
|
||||||
|
let uploadedImages = [];
|
||||||
|
if (images.length > 0) {
|
||||||
|
const uploadResult = await uploadPostImages(selectedApp.app_id, images);
|
||||||
|
uploadedImages = uploadResult.files.map((f) => f.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Create post
|
||||||
|
const postData = {
|
||||||
|
title: title.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
images: uploadedImages,
|
||||||
|
location: {
|
||||||
|
lat: location.lat,
|
||||||
|
lon: location.lon || location.lng,
|
||||||
|
address: location.address
|
||||||
|
},
|
||||||
|
category: category || undefined,
|
||||||
|
content: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add app-specific fields
|
||||||
|
if (price) {
|
||||||
|
postData.content.price = parseFloat(price);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await createPost(selectedApp.app_id, postData);
|
||||||
|
|
||||||
|
onSuccess?.(result);
|
||||||
|
onClose?.();
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center">
|
||||||
|
<div className="bg-zinc-900 rounded-2xl p-8">
|
||||||
|
<i className="fas fa-spinner fa-spin text-2xl text-purple-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscriptions.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-zinc-900 rounded-2xl p-6 max-w-sm text-center">
|
||||||
|
<i className="fas fa-puzzle-piece text-4xl text-zinc-600 mb-4" />
|
||||||
|
<p className="text-zinc-400 mb-4">
|
||||||
|
Vous n'êtes abonné à aucune app qui accepte les posts
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-6 py-2 bg-zinc-800 text-zinc-300 rounded-lg"
|
||||||
|
>
|
||||||
|
Fermer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-zinc-900 rounded-2xl w-full max-w-lg max-h-[90vh] overflow-hidden flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-zinc-800">
|
||||||
|
<h1 className="text-lg font-semibold text-white">
|
||||||
|
<i className="fas fa-plus-circle mr-2 text-purple-400" />
|
||||||
|
Créer un post
|
||||||
|
</h1>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 hover:bg-zinc-800 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
<i className="fas fa-times text-zinc-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
|
{/* App selector */}
|
||||||
|
{subscriptions.length > 1 && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-zinc-400 mb-2">
|
||||||
|
Publier sur
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{subscriptions.map((sub) => (
|
||||||
|
<button
|
||||||
|
key={sub.app_id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedApp(sub)}
|
||||||
|
className={`p-3 rounded-lg border text-left transition-colors ${
|
||||||
|
selectedApp?.app_id === sub.app_id
|
||||||
|
? "border-purple-500 bg-purple-600/20"
|
||||||
|
: "border-zinc-700 bg-zinc-800/50 hover:bg-zinc-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="font-medium text-white text-sm">{sub.app_name}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedApp && subscriptions.length === 1 && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-zinc-800/50 rounded-lg">
|
||||||
|
<i className="fas fa-puzzle-piece text-purple-400" />
|
||||||
|
<span className="text-white">{selectedApp.app_name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-zinc-400 mb-2">
|
||||||
|
Titre *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Ex: iPhone 14 Pro Max 256GB"
|
||||||
|
className="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-lg text-white placeholder-zinc-500 focus:border-purple-500 focus:outline-none"
|
||||||
|
maxLength={200}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-zinc-400 mb-2">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Décrivez votre article..."
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-lg text-white placeholder-zinc-500 focus:border-purple-500 focus:outline-none resize-none"
|
||||||
|
maxLength={2000}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Price (if marketplace-like app) */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-zinc-400 mb-2">
|
||||||
|
Prix
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={price}
|
||||||
|
onChange={(e) => setPrice(e.target.value)}
|
||||||
|
placeholder="0.00"
|
||||||
|
className="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-lg text-white placeholder-zinc-500 focus:border-purple-500 focus:outline-none pr-12"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
/>
|
||||||
|
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-500">$</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Images */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-zinc-400 mb-2">
|
||||||
|
Photos ({images.length}/5)
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{imagePreviews.map((url, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="relative w-20 h-20 rounded-lg overflow-hidden bg-zinc-800"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt=""
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeImage(i)}
|
||||||
|
className="absolute top-1 right-1 w-5 h-5 bg-red-600 rounded-full flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<i className="fas fa-times text-xs text-white" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{images.length < 5 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="w-20 h-20 rounded-lg border-2 border-dashed border-zinc-700 flex items-center justify-center hover:border-purple-500 transition-colors"
|
||||||
|
>
|
||||||
|
<i className="fas fa-plus text-zinc-500" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
onChange={handleImageSelect}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Location */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-zinc-400 mb-2">
|
||||||
|
Localisation *
|
||||||
|
</label>
|
||||||
|
{location ? (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-zinc-800/50 rounded-lg">
|
||||||
|
<i className="fas fa-map-marker-alt text-green-400" />
|
||||||
|
<span className="text-white text-sm flex-1 truncate">
|
||||||
|
{location.address || `${location.lat.toFixed(4)}, ${(location.lon || location.lng).toFixed(4)}`}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLocation(null)}
|
||||||
|
className="text-zinc-500 hover:text-zinc-300"
|
||||||
|
>
|
||||||
|
<i className="fas fa-times" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
// TODO: Open map picker or use current location
|
||||||
|
if (navigator.geolocation) {
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(pos) => {
|
||||||
|
setLocation({
|
||||||
|
lat: pos.coords.latitude,
|
||||||
|
lon: pos.coords.longitude
|
||||||
|
});
|
||||||
|
},
|
||||||
|
() => setError("Impossible d'obtenir la localisation")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full p-3 border-2 border-dashed border-zinc-700 rounded-lg text-zinc-500 hover:border-purple-500 hover:text-purple-400 transition-colors"
|
||||||
|
>
|
||||||
|
<i className="fas fa-map-marker-alt mr-2" />
|
||||||
|
Définir la localisation
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 bg-red-600/20 border border-red-600/50 rounded-lg text-red-400 text-sm">
|
||||||
|
<i className="fas fa-exclamation-triangle mr-2" />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="p-4 border-t border-zinc-800 flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 py-3 px-4 bg-zinc-800 text-zinc-300 rounded-lg font-medium hover:bg-zinc-700 transition-colors"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={submitting || !selectedApp || !title.trim() || !location}
|
||||||
|
className="flex-1 py-3 px-4 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{submitting ? (
|
||||||
|
<i className="fas fa-spinner fa-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<i className="fas fa-paper-plane mr-2" />
|
||||||
|
Publier
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,182 @@
|
||||||
|
/**
|
||||||
|
* ExtAppPostCard - Render posts from external apps
|
||||||
|
*
|
||||||
|
* Supports two rendering modes:
|
||||||
|
* - Level 1: JSON config (uses CardRenderer)
|
||||||
|
* - Level 3: Pre-rendered HTML from sandbox (uses dangerouslySetInnerHTML)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import CardRenderer from "../Cards/CardRenderer";
|
||||||
|
import { buildAssetUrl } from "../../api/extAppsApi";
|
||||||
|
|
||||||
|
// Default theme tokens for Level 1 cards
|
||||||
|
const DEFAULT_TOKENS = {
|
||||||
|
colors: {
|
||||||
|
surface: "#1a1a1a",
|
||||||
|
primary: "#8b5cf6",
|
||||||
|
text: "#ffffff",
|
||||||
|
textSecondary: "#a1a1aa",
|
||||||
|
accent: "#22c55e",
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
title: { fontSize: 16, fontWeight: 600, color: "#ffffff" },
|
||||||
|
subtitle: { fontSize: 14, fontWeight: 400, color: "#a1a1aa" },
|
||||||
|
body: { fontSize: 13, color: "#d4d4d8" },
|
||||||
|
caption: { fontSize: 11, color: "#71717a" },
|
||||||
|
price: { fontSize: 18, fontWeight: 700, color: "#22c55e" },
|
||||||
|
},
|
||||||
|
chips: {
|
||||||
|
default: { background: "#27272a", color: "#d4d4d8", borderRadius: 12 },
|
||||||
|
primary: { background: "#8b5cf6", color: "#ffffff", borderRadius: 12 },
|
||||||
|
success: { background: "#166534", color: "#86efac", borderRadius: 12 },
|
||||||
|
},
|
||||||
|
buttons: {
|
||||||
|
primary: { background: "#8b5cf6", color: "#ffffff", borderRadius: 8 },
|
||||||
|
secondary: { background: "#27272a", color: "#d4d4d8", borderRadius: 8 },
|
||||||
|
},
|
||||||
|
radius: { card: 16 },
|
||||||
|
shadow: { card: "0 4px 12px rgba(0,0,0,0.3)" },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process image URLs in post data to use asset proxy
|
||||||
|
*/
|
||||||
|
function processAssetUrls(post, appId) {
|
||||||
|
const processed = { ...post };
|
||||||
|
|
||||||
|
const processUrl = (url) => {
|
||||||
|
if (!url || typeof url !== "string") return url;
|
||||||
|
// If it's an app-relative path (not http)
|
||||||
|
if (!url.startsWith("http") && !url.startsWith("/api/assets")) {
|
||||||
|
return buildAssetUrl(appId, url, { w: 400 });
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (processed.image) processed.image = processUrl(processed.image);
|
||||||
|
if (processed.image_small) processed.image_small = processUrl(processed.image_small);
|
||||||
|
if (processed.image_medium) processed.image_medium = processUrl(processed.image_medium);
|
||||||
|
if (processed.thumbnail) processed.thumbnail = processUrl(processed.thumbnail);
|
||||||
|
|
||||||
|
return processed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default card spec for apps without custom card_style
|
||||||
|
*/
|
||||||
|
const DEFAULT_CARD_SPEC = {
|
||||||
|
size: { w: 280, h: 180 },
|
||||||
|
radius: 16,
|
||||||
|
background: { type: "image", bind: "data.image" },
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id: "gradient",
|
||||||
|
type: "rect",
|
||||||
|
x: 0,
|
||||||
|
y: 80,
|
||||||
|
w: 280,
|
||||||
|
h: 100,
|
||||||
|
style: "gradients.bottom",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "title",
|
||||||
|
type: "text",
|
||||||
|
x: 12,
|
||||||
|
y: 120,
|
||||||
|
w: 256,
|
||||||
|
bind: "data.title",
|
||||||
|
style: "typography.title",
|
||||||
|
maxLines: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "source",
|
||||||
|
type: "text",
|
||||||
|
x: 12,
|
||||||
|
y: 155,
|
||||||
|
w: 200,
|
||||||
|
bind: "data.source_name",
|
||||||
|
style: "typography.caption",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ExtAppPostCard({
|
||||||
|
post,
|
||||||
|
appId,
|
||||||
|
cardStyle,
|
||||||
|
onAction,
|
||||||
|
className = "",
|
||||||
|
}) {
|
||||||
|
// Process asset URLs
|
||||||
|
const processedPost = useMemo(
|
||||||
|
() => processAssetUrls(post, appId),
|
||||||
|
[post, appId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// If post has pre-rendered content from Level 3 sandbox
|
||||||
|
if (post.rendered && post.rendered.html) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`ext-app-card ext-app-card--rendered ${className}`}
|
||||||
|
style={{
|
||||||
|
width: post.rendered.width || 280,
|
||||||
|
height: post.rendered.height || 180,
|
||||||
|
borderRadius: 16,
|
||||||
|
overflow: "hidden",
|
||||||
|
boxShadow: DEFAULT_TOKENS.shadow.card,
|
||||||
|
}}
|
||||||
|
dangerouslySetInnerHTML={{ __html: post.rendered.html }}
|
||||||
|
onClick={() => onAction?.({ type: "open", bind: "post_id" }, post.post_id)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Level 1: Use CardRenderer with JSON spec
|
||||||
|
const spec = cardStyle || DEFAULT_CARD_SPEC;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardRenderer
|
||||||
|
spec={spec}
|
||||||
|
data={processedPost}
|
||||||
|
themeTokens={{
|
||||||
|
...DEFAULT_TOKENS,
|
||||||
|
gradients: {
|
||||||
|
bottom: "linear-gradient(to bottom, transparent, rgba(0,0,0,0.8))",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onAction={(action, value) => onAction?.(action, value)}
|
||||||
|
className={`ext-app-card ${className}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small version of the card for map markers
|
||||||
|
*/
|
||||||
|
export function ExtAppPostMarker({ post, appId, onClick }) {
|
||||||
|
const imageUrl = post.image_small || post.thumbnail || post.image;
|
||||||
|
const processedUrl = imageUrl && !imageUrl.startsWith("http")
|
||||||
|
? buildAssetUrl(appId, imageUrl, { w: 80, h: 80 })
|
||||||
|
: imageUrl;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className="w-12 h-12 rounded-full bg-zinc-800 border-2 border-purple-500 overflow-hidden shadow-lg hover:scale-110 transition-transform"
|
||||||
|
style={
|
||||||
|
processedUrl
|
||||||
|
? {
|
||||||
|
backgroundImage: `url(${processedUrl})`,
|
||||||
|
backgroundSize: "cover",
|
||||||
|
backgroundPosition: "center",
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{!processedUrl && (
|
||||||
|
<i className="fas fa-map-marker-alt text-purple-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
/**
|
||||||
|
* MySubscriptions - Manage user's app subscriptions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { listSubscriptions, unsubscribeFromApp, updateSubscription } from "../../api/extAppsApi";
|
||||||
|
|
||||||
|
export default function MySubscriptions({ onOpenStore }) {
|
||||||
|
const [subscriptions, setSubscriptions] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const loadSubscriptions = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const result = await listSubscriptions();
|
||||||
|
setSubscriptions(result.subscriptions || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load subscriptions:", err);
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSubscriptions();
|
||||||
|
}, [loadSubscriptions]);
|
||||||
|
|
||||||
|
const handleUnsubscribe = async (appId, appName) => {
|
||||||
|
if (!confirm(`Voulez-vous vraiment vous désabonner de ${appName}?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await unsubscribeFromApp(appId);
|
||||||
|
await loadSubscriptions();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Unsubscribe failed:", err);
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleConsent = async (sub, permission) => {
|
||||||
|
try {
|
||||||
|
const newConsent = {
|
||||||
|
...sub.consent,
|
||||||
|
[permission]: !sub.consent[permission],
|
||||||
|
};
|
||||||
|
await updateSubscription(sub.app_id, { consent: newConsent });
|
||||||
|
await loadSubscriptions();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Update consent failed:", err);
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="p-4 text-center">
|
||||||
|
<i className="fas fa-spinner fa-spin text-xl text-purple-400" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="p-4 text-center text-red-400">
|
||||||
|
<i className="fas fa-exclamation-triangle mb-2" />
|
||||||
|
<p>{error}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscriptions.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 text-center">
|
||||||
|
<i className="fas fa-puzzle-piece text-4xl text-zinc-600 mb-4" />
|
||||||
|
<p className="text-zinc-400 mb-4">Vous n'êtes abonné à aucune app</p>
|
||||||
|
<button
|
||||||
|
onClick={onOpenStore}
|
||||||
|
className="px-6 py-2 bg-purple-600 hover:bg-purple-500 text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Découvrir les apps
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 p-4">
|
||||||
|
{subscriptions.map((sub) => (
|
||||||
|
<div
|
||||||
|
key={sub.app_id}
|
||||||
|
className="bg-zinc-800/50 rounded-xl p-4 border border-zinc-700/50"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-white">{sub.app_name}</h3>
|
||||||
|
<p className="text-xs text-zinc-500">
|
||||||
|
Abonné depuis {new Date(sub.subscribed_at).toLocaleDateString("fr-CA")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{sub.tier === "premium" && (
|
||||||
|
<span className="text-xs px-2 py-1 bg-yellow-600/20 text-yellow-400 rounded">
|
||||||
|
Premium
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{sub.app_description && (
|
||||||
|
<p className="text-sm text-zinc-400 mb-3 line-clamp-2">
|
||||||
|
{sub.app_description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Consent toggles */}
|
||||||
|
<div className="flex flex-wrap gap-2 mb-3">
|
||||||
|
{Object.entries(sub.consent || {}).map(([perm, enabled]) => (
|
||||||
|
<button
|
||||||
|
key={perm}
|
||||||
|
onClick={() => handleToggleConsent(sub, perm)}
|
||||||
|
className={`text-xs px-2 py-1 rounded flex items-center gap-1 transition-colors ${
|
||||||
|
enabled
|
||||||
|
? "bg-purple-600/20 text-purple-400"
|
||||||
|
: "bg-zinc-700 text-zinc-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<i className={`fas ${enabled ? "fa-check" : "fa-times"}`} />
|
||||||
|
{perm}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-2 pt-2 border-t border-zinc-700/50">
|
||||||
|
<button
|
||||||
|
onClick={() => handleUnsubscribe(sub.app_id, sub.app_name)}
|
||||||
|
className="text-xs text-red-400 hover:text-red-300 transition-colors"
|
||||||
|
>
|
||||||
|
Se désabonner
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Add more apps button */}
|
||||||
|
<button
|
||||||
|
onClick={onOpenStore}
|
||||||
|
className="w-full py-3 border border-dashed border-zinc-700 rounded-xl text-zinc-500 hover:text-zinc-400 hover:border-zinc-600 transition-colors"
|
||||||
|
>
|
||||||
|
<i className="fas fa-plus mr-2" />
|
||||||
|
Ajouter une app
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
/**
|
||||||
|
* SubscribeModal - Consent flow for subscribing to an app
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
|
||||||
|
const PERMISSIONS = {
|
||||||
|
username: {
|
||||||
|
label: "Nom d'utilisateur",
|
||||||
|
description: "L'app peut voir votre nom d'utilisateur public",
|
||||||
|
icon: "fa-user",
|
||||||
|
},
|
||||||
|
location: {
|
||||||
|
label: "Localisation",
|
||||||
|
description: "L'app peut voir votre position approximative",
|
||||||
|
icon: "fa-map-marker-alt",
|
||||||
|
},
|
||||||
|
profile: {
|
||||||
|
label: "Profil",
|
||||||
|
description: "L'app peut accéder à votre profil public",
|
||||||
|
icon: "fa-id-card",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SubscribeModal({ app, onConfirm, onCancel }) {
|
||||||
|
const required = app.permissions?.required || ["username", "location"];
|
||||||
|
const optional = app.permissions?.optional || [];
|
||||||
|
|
||||||
|
const [consent, setConsent] = useState(() => {
|
||||||
|
const initial = {};
|
||||||
|
for (const perm of required) {
|
||||||
|
initial[perm] = true;
|
||||||
|
}
|
||||||
|
for (const perm of optional) {
|
||||||
|
initial[perm] = false;
|
||||||
|
}
|
||||||
|
return initial;
|
||||||
|
});
|
||||||
|
|
||||||
|
const [tier, setTier] = useState("free");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleToggle = (perm) => {
|
||||||
|
if (required.includes(perm)) return; // Can't toggle required
|
||||||
|
setConsent((prev) => ({ ...prev, [perm]: !prev[perm] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await onConfirm(consent, tier);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const allRequiredConsented = required.every((p) => consent[p]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[60] flex items-center justify-center p-4">
|
||||||
|
<div className="bg-zinc-900 rounded-2xl w-full max-w-md overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-4 border-b border-zinc-800">
|
||||||
|
<h2 className="text-lg font-semibold text-white">
|
||||||
|
S'abonner à {app.name}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-zinc-400 mt-1">
|
||||||
|
Cette app requiert votre consentement pour accéder à certaines informations.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Permissions */}
|
||||||
|
<div className="p-4 space-y-3 max-h-[50vh] overflow-y-auto">
|
||||||
|
{/* Required */}
|
||||||
|
{required.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-semibold text-zinc-500 uppercase mb-2">
|
||||||
|
Requis
|
||||||
|
</h3>
|
||||||
|
{required.map((perm) => {
|
||||||
|
const info = PERMISSIONS[perm] || { label: perm, icon: "fa-question" };
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={perm}
|
||||||
|
className="flex items-center gap-3 p-3 bg-zinc-800/50 rounded-lg mb-2"
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 rounded-full bg-purple-600/20 flex items-center justify-center">
|
||||||
|
<i className={`fas ${info.icon} text-purple-400`} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-white">{info.label}</p>
|
||||||
|
<p className="text-xs text-zinc-500">{info.description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-10 h-6 bg-purple-600 rounded-full flex items-center justify-end pr-1">
|
||||||
|
<div className="w-4 h-4 bg-white rounded-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Optional */}
|
||||||
|
{optional.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-semibold text-zinc-500 uppercase mb-2">
|
||||||
|
Optionnel
|
||||||
|
</h3>
|
||||||
|
{optional.map((perm) => {
|
||||||
|
const info = PERMISSIONS[perm] || { label: perm, icon: "fa-question" };
|
||||||
|
const enabled = consent[perm];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={perm}
|
||||||
|
onClick={() => handleToggle(perm)}
|
||||||
|
className="flex items-center gap-3 p-3 bg-zinc-800/50 rounded-lg mb-2 w-full text-left hover:bg-zinc-800 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 rounded-full bg-zinc-700 flex items-center justify-center">
|
||||||
|
<i className={`fas ${info.icon} text-zinc-400`} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-white">{info.label}</p>
|
||||||
|
<p className="text-xs text-zinc-500">{info.description}</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`w-10 h-6 rounded-full flex items-center transition-colors ${
|
||||||
|
enabled ? "bg-purple-600 justify-end pr-1" : "bg-zinc-700 justify-start pl-1"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="w-4 h-4 bg-white rounded-full" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tier selection (if app has premium) */}
|
||||||
|
{app.tiers && app.tiers.includes("premium") && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<h3 className="text-xs font-semibold text-zinc-500 uppercase mb-2">
|
||||||
|
Abonnement
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setTier("free")}
|
||||||
|
className={`p-3 rounded-lg border text-sm transition-colors ${
|
||||||
|
tier === "free"
|
||||||
|
? "border-purple-500 bg-purple-600/20 text-white"
|
||||||
|
: "border-zinc-700 bg-zinc-800/50 text-zinc-400 hover:bg-zinc-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="font-medium">Gratuit</div>
|
||||||
|
<div className="text-xs opacity-70">Fonctions de base</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setTier("premium")}
|
||||||
|
className={`p-3 rounded-lg border text-sm transition-colors ${
|
||||||
|
tier === "premium"
|
||||||
|
? "border-purple-500 bg-purple-600/20 text-white"
|
||||||
|
: "border-zinc-700 bg-zinc-800/50 text-zinc-400 hover:bg-zinc-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="font-medium">Premium</div>
|
||||||
|
<div className="text-xs opacity-70">{app.premium_price || "4.99$"}/mois</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="p-4 border-t border-zinc-800 flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="flex-1 py-3 px-4 bg-zinc-800 text-zinc-300 rounded-lg font-medium hover:bg-zinc-700 transition-colors"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!allRequiredConsented || loading}
|
||||||
|
className="flex-1 py-3 px-4 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<i className="fas fa-spinner fa-spin" />
|
||||||
|
) : (
|
||||||
|
"Confirmer"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
/**
|
||||||
|
* Apps Components - External apps integration
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { default as AppStore } from "./AppStore";
|
||||||
|
export { default as AppCard } from "./AppCard";
|
||||||
|
export { default as SubscribeModal } from "./SubscribeModal";
|
||||||
|
export { default as ExtAppPostCard, ExtAppPostMarker } from "./ExtAppPostCard";
|
||||||
|
export { default as MySubscriptions } from "./MySubscriptions";
|
||||||
|
export { default as useExtAppFeed } from "./useExtAppFeed";
|
||||||
|
export { default as CreateAppPost } from "./CreateAppPost";
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
/**
|
||||||
|
* useExtAppFeed - Hook for fetching posts from external apps
|
||||||
|
*
|
||||||
|
* Fetches posts from subscribed apps based on current map viewport.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import { getFeed, listSubscriptions } from "../../api/extAppsApi";
|
||||||
|
|
||||||
|
const CACHE_TTL = 30000; // 30 seconds
|
||||||
|
const DEBOUNCE_MS = 500;
|
||||||
|
|
||||||
|
export default function useExtAppFeed({ bbox, zoom, enabled = true }) {
|
||||||
|
const [posts, setPosts] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [hasSubscriptions, setHasSubscriptions] = useState(false);
|
||||||
|
|
||||||
|
const cacheRef = useRef(new Map());
|
||||||
|
const debounceRef = useRef(null);
|
||||||
|
const lastFetchRef = useRef(null);
|
||||||
|
|
||||||
|
// Check if user has any subscriptions
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
listSubscriptions()
|
||||||
|
.then((result) => {
|
||||||
|
setHasSubscriptions((result.subscriptions || []).length > 0);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.warn("Failed to check subscriptions:", err);
|
||||||
|
setHasSubscriptions(false);
|
||||||
|
});
|
||||||
|
}, [enabled]);
|
||||||
|
|
||||||
|
const fetchPosts = useCallback(async () => {
|
||||||
|
if (!bbox || !hasSubscriptions) {
|
||||||
|
setPosts([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build cache key
|
||||||
|
const cacheKey = `${bbox.west.toFixed(4)},${bbox.south.toFixed(4)},${bbox.east.toFixed(4)},${bbox.north.toFixed(4)}:${zoom}`;
|
||||||
|
|
||||||
|
// Check cache
|
||||||
|
const cached = cacheRef.current.get(cacheKey);
|
||||||
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||||
|
setPosts(cached.posts);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent duplicate fetches
|
||||||
|
if (lastFetchRef.current === cacheKey) return;
|
||||||
|
lastFetchRef.current = cacheKey;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const result = await getFeed({ bbox, zoom });
|
||||||
|
|
||||||
|
const fetchedPosts = result.posts || [];
|
||||||
|
setPosts(fetchedPosts);
|
||||||
|
|
||||||
|
// Cache result
|
||||||
|
cacheRef.current.set(cacheKey, {
|
||||||
|
posts: fetchedPosts,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean old cache entries
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, value] of cacheRef.current.entries()) {
|
||||||
|
if (now - value.timestamp > CACHE_TTL * 2) {
|
||||||
|
cacheRef.current.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch ext app feed:", err);
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
lastFetchRef.current = null;
|
||||||
|
}
|
||||||
|
}, [bbox, zoom, hasSubscriptions]);
|
||||||
|
|
||||||
|
// Debounced fetch on bbox/zoom change
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !hasSubscriptions) return;
|
||||||
|
|
||||||
|
if (debounceRef.current) {
|
||||||
|
clearTimeout(debounceRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
debounceRef.current = setTimeout(() => {
|
||||||
|
fetchPosts();
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (debounceRef.current) {
|
||||||
|
clearTimeout(debounceRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [fetchPosts, enabled, hasSubscriptions]);
|
||||||
|
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
// Clear cache and refetch
|
||||||
|
cacheRef.current.clear();
|
||||||
|
fetchPosts();
|
||||||
|
}, [fetchPosts]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
posts,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
hasSubscriptions,
|
||||||
|
refresh,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue