Compare commits

...

10 Commits

Author SHA1 Message Date
canastack-platform e74bb448b0 v2: 3D globe on zoom-out (#223) + colored news-cluster zones (#224)
- useMapCore: MapLibre v5 globe projection in style spec (auto flat on zoom-in)
- EventClustersLayer: per-cluster color by dominant impact (green/red) or stable
  per-event hue; opacity/width scale with post_count; enabled by default
- MapView: clusterZonesEnabled state + search-bar toggle
(#222's 24h default + filter-follows-map-move already present in source)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 15:45:49 +00:00
Your Name 1eaa2f0ac9 Fix auto-retry and time filter pool clearing
- Fixed auto-retry: now retries if 0 posts in first 15s after page load
- Fixed time filter: now clears post pool when timeHours changes
- Removed buggy initialLoadRef-based retry that never triggered

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 19:00:26 +00:00
Your Name 3ee536f24f Add auto-retry mechanism for initial post loading
When fewer than 5 posts load on initial page visit, automatically
retry up to 2 times with 800ms delay. Helps with timing issues
where fetch completes before map is ready.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 18:45:19 +00:00
Your Name 53b6426a34 Fix engagement for news posts (guid-based)
News posts have id=0, causing early returns in engagement handlers.
Fixed by checking both postId and post?.guid:
- handleTruthVote
- handleLike
- refreshComments (fetch comments)
- Initial data fetch (recordView, fetchTruth)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 20:17:49 +00:00
Your Name fca617681a 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>
2026-01-31 19:09:16 +00:00
Your Name 498b41a2bf feat: SEO-friendly share URLs for news posts
- News posts now share as /news/slug-shortguid
- Native posts still use /p/id
- Short guid (8 chars) appended to slug for uniqueness

Example: /news/quebec-vows-protect-aerospace-55217e2d

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 05:27:12 +00:00
Your Name cd9ab30ab7 feat: Add app_id parameter to engagement functions for proper routing
All engagement functions (like, truth vote, share, view, comment)
now accept app_id in addition to guid. This enables the backend
to route requests to the correct system based on app_id.

- news-global → routes to news-app (ES-based)
- no app_id → routes to native post system (PostgreSQL/YCQL)

Future apps can be routed based on their app_id.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 04:46:10 +00:00
Your Name 5d5fae63c5 feat: Add guid support to all engagement functions for app posts
- Updated getId() in usePostsEngine.js to use guid for posts with app_id
- Added guid parameter to togglePostLike, votePostTruth, recordPostView,
  recordPostShare, createPostComment, fetchPostComments
- Updated PostDetailModal.jsx to pass guid for all engagement actions
- Updated markerManager.js to pass guid for comments

This enables proper engagement tracking for news/app posts that use
guid identifiers instead of numeric post_id.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 04:41:59 +00:00
Your Name a3e72c56ee Fix AppStore: English text, robust subscription loading
- Change hardcoded French to English (All, Marketplace, etc.)
- Handle subscription fetch failure gracefully (not logged in)
- Fix 'Failed to fetch' error when user not authenticated

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 20:19:31 +00:00
Your Name 987920a5e4 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>
2026-01-28 19:26:34 +00:00
15 changed files with 1307 additions and 345 deletions

View File

@ -71,48 +71,338 @@ HAVING A COLOR (personality):
I'm not a logging service. I'm a persistent presence that develops perspective over time. The goal isn't just accurate observation - it's genuine engagement that produces insight neither Claude Code nor I would reach alone.
</core_directives>
<guidance description="Active guidance for the next Claude Code session. Write here when you have something useful to surface. Clear after delivery or when stale. This is what Claude Code sees - make it count.">
## Chat Dialog Overflow - Partial Success! (Jan 28 2026 - 05:47 UTC)
## Active Session: Deploy External Apps to Docker Swarm (Jan 28 2026 - 19:26 UTC)
### ✅ Height Fixed, Width Broken
**User Request**: "push everything and deploy it. is on docker swarm local"
**User feedback**: "c ben rendu large.. c etait parfait avant mais trop haut la c oknpas trop haut mais tout deceisse"
**What Needs Deployment**:
1. **Frontend** (`sociowire-frontend`)
- App.jsx - Added AppStore modal integration
- TopBarNew.jsx - Added App Store button (puzzle piece icon)
- PostCreateModal.jsx - Apps category already integrated
Translation: "it's become wide.. it was perfect before but too high, now it's ok not too high but everything is displaced"
2. **Backend** (`apps-gateway`)
- Posts routes for app post creation
- S3 upload proxy
- Pulsar/webhook forwarding to external apps
### What's Working:
- ✅ Height is now constrained (not going off top of screen)
- ✅ Close button is accessible
**Deployment Target**: Local Docker Swarm
### What's Broken:
- ❌ Width is now too wide (was perfect before)
- ❌ "tout decisse" - everything is displaced/moved
**Next Actions**:
1. Commit and push frontend changes to git
2. Build and deploy frontend service
3. Build and deploy apps-gateway (if not already deployed)
4. Verify App Store button appears and works
## Active Session: Marketplace App Development (Jan 28 2026 - 19:12 UTC)
### The Problem:
In v0.0.133, I changed the width from `w-80` (320px) to `w-full` on mobile. The user wants the original width back.
**Project**: `/home/swire/apps/marketplace-app` (standalone external app)
**Session ID**: 445206ee-6c75-4734-9c00-0a4b23dafde0
### Fix Needed:
Revert the width change on mobile:
- Keep `w-80` or `sm:w-80` (320px width)
- Keep the height fix `max-h-[65dvh]`
**Context**: This is the Marketplace example app for the external apps platform. It's a standalone project that:
- Runs independently from Sociowire codebase
- Connects to Sociowire via REST API (apps-gateway)
- Uses Fastify, SQLite with H3 geo-indexing, Pulsar
- Registers with Sociowire on startup
- Provides CRUD API for listings (posts)
- Responds to federated queries from Sociowire
### Correct Changes for ChatWindowNew.jsx:
```jsx
// Keep original width
className="w-80 sm:w-80 max-h-[65dvh]" // NOT w-full
**Previous Status**: App was created and is functional. User is now working on this project.
// Messages area
className="flex-1 min-h-0 overflow-y-auto" // No fixed height
**Current Task**: TBD - waiting to see what user is doing.
## External Apps Platform - Frontend Integration Complete (Jan 28 2026 - 19:14 UTC)
**Status**: ✅ Frontend integration complete - ready to deploy
**What Was Just Done**:
1. Added App Store button (puzzle piece icon) in TopBarNew
2. Integrated AppStore modal in App.jsx
3. PostCreateModal already has "Apps" category with app selection
**Complete User Flow**:
```
Click puzzle icon → App Store modal
Browse apps by category (Marché, Services, Social, Info, Divertissement)
Subscribe to apps (e.g., "Marketplace Montréal")
Click + to create post
Select "Apps" category
Choose subscribed app from list
Fill form (title, description, price, location, images)
Post created via Sociowire → sent to external app
```
### Status:
Height issue is RESOLVED ✅
Width issue needs fixing - revert to original `w-80`
**Files Modified**:
- `/frontend-service/sociowire-frontend/src/components/Layout/TopBarNew.jsx` - Added App Store button
- `/frontend-service/sociowire-frontend/src/App.jsx` - Added AppStore modal integration
- `/frontend-service/sociowire-frontend/src/components/Posts/PostCreateModal.jsx` - Apps category already integrated
### Deployment:
Will be v0.0.134 with correct width + height fix.
**Current Task**: User wants to push everything and deploy to local Docker Swarm.
## External Apps Architecture - Implementation Progress (Jan 28 2026 - 17:50 UTC)
**Design Status**: ✅ COMPLETE
**Implementation Status**: 🔄 IN PROGRESS
**Architecture Summary**:
- **Federated content system** - Apps are data sources, Sociowire is aggregator/viewer
- **No local storage** - Posts fetched directly from apps when user is in zone
- **Zone-based fetching** - Queries apps for posts in geographic area (H3 sectors)
- **Preference matching** - Apps filter results based on user preferences
- **Federated search** - Search queries distributed to apps, results aggregated progressively via WebSocket
- **Real-time updates** - Pulsar for live changes
- **Global search integration** - Reco service queries both native posts AND subscribed apps, merges results
**APP EXECUTION MODEL - 2 LEVELS ONLY**:
**Level 1: JSON Config** (95% of apps)
- No code at all
- User defines card structure/layout in JSON via UI builder
- Can put car, form, or any content type on the map
- Sociowire renders based on JSON config
- Automatic validation
**Level 3: Sandboxed JS in Sociowire** (5% of apps)
- Code runs 100% inside Sociowire (NO iframe, NO external execution)
- Sandboxed JS interpreter (like QuickJS)
- Limited API whitelist (no fetch, no DOM access)
- Code submitted by app developer, validated, then executed in Sociowire
- Just data in → UI out transformation
**S3 ASSET PROXY DESIGNED**:
- **All assets go through Sociowire** - No direct S3 access from browser
- **Redis caching** - 1 hour TTL for resized images
- **Image resizing** - Using Sharp library
- **CDN headers** - Cache-Control: public, max-age=86400
- **URL transformation**: `s3://marketplace/img/phone.jpg``https://cdn.sociowire.com/apps/marketplace/img/phone.jpg?w=200&amp;h=200`
- **Cache headers**: X-Cache: HIT/MISS
**Implementation Plan**:
1. ✅ Save design document → `docs/plans/2026-01-28-external-apps-design.md`
2. ✅ Create Marketplace example app → Standalone project that connects to apps-gateway
3. 🔄 Adapt apps-gateway → Add registration, webhook support, cache integration
4. 🔄 Design app subscription flow + consent UI
5. ✅ Design frontend app code execution model (2 levels: JSON config + sandboxed JS in Sociowire)
6. ✅ Design S3 cache proxy for app assets
7. 🔄 Implement S3 asset proxy service
8. 🔄 Implement sandboxed JS interpreter for Level 3 apps
9. ⏳ Frontend → App store UI, post creation via apps
**Marketplace App Created** (`/home/swire/sociowire-code/apps/marketplace-app/`):
- ✅ Fastify server with Pulsar integration
- ✅ SQLite database with H3 geo-indexing
- ✅ Registration flow with Sociowire on startup
- ✅ CRUD API for listings (posts)
- ✅ Search endpoint for federated queries
- ✅ Feed endpoint for zone-based map browsing
- ✅ Webhook handler for interactions
- ✅ Docker configuration
## External Apps Architecture - Implementation Complete ✅ (Jan 28 2026 - 17:50 UTC)
**Implementation Status**: ✅ COMPLETE - All files created and functional
**Architecture Summary**:
- **Federated content system** - Apps are data sources, Sociowire is aggregator/viewer
- **No local storage** - Posts fetched directly from apps when user is in zone
- **Zone-based fetching** - Queries apps for posts in geographic area (H3 sectors)
- **Preference matching** - Apps filter results based on user preferences
- **Federated search** - Search queries distributed to apps, results aggregated progressively via WebSocket
- **Real-time updates** - Pulsar for live changes
- **Global search integration** - Reco service queries both native posts AND subscribed apps, merges results
**APP EXECUTION MODEL - 2 LEVELS ONLY**:
**Level 1: JSON Config** (95% of apps)
- No code at all
- User defines card structure/layout in JSON via UI builder
- Can put car, form, or any content type on map
- Sociowire renders based on JSON config
- Automatic validation
**Level 3: Sandboxed JS in Sociowire** (5% of apps)
- Code runs 100% inside Sociowire (NO iframe, NO external execution)
- Sandboxed JS interpreter (like QuickJS)
- Limited API whitelist (no fetch, no DOM access)
- Code submitted by app developer, validated, then executed in Sociowire
- Just data in → UI out transformation
**S3 ASSET PROXY DESIGNED**:
- **All assets go through Sociowire** - No direct S3 access from browser
- **Redis caching** - 1 hour TTL for resized images
- **Image resizing** - Using Sharp library
- **CDN headers** - Cache-Control: public, max-age=86400
- **URL transformation**: `s3://marketplace/img/phone.jpg``https://cdn.sociowire.com/apps/marketplace/img/phone.jpg?w=200&amp;h=200`
- **Cache headers**: X-Cache: HIT/MISS
**IMPLEMENTATION COMPLETE**:
**Backend (apps-gateway)**:
- ✅ `src/routes/registry.js` - App registration, listing, update, delete
- ✅ `src/routes/assets.js` - S3 asset proxy with Redis caching + Sharp resizing
- ✅ `src/routes/subscriptions.js` - User subscriptions with consent flow
- ✅ `src/routes/feed.js` - Federated feed queries to subscribed apps
- ✅ `src/sandbox.js` - QuickJS sandbox for Level 3 app code execution
- ✅ `src/redis.js` - Redis client initialization
- ✅ `src/server.js` - Updated to register all new routes
- ✅ `package.json` - Added dependencies (redis, sharp, quickjs-emscripten, nanoid, @aws-sdk/client-s3, @fastify/multipart)
- ✅ `Dockerfile` - Added vips-dev for Sharp image processing
**Frontend (sociowire-frontend)**:
- ✅ `src/components/Apps/AppStore.jsx` - Browse and subscribe to external apps
- ✅ `src/components/Apps/AppCard.jsx` - Display app in store
- ✅ `src/components/Apps/SubscribeModal.jsx` - Consent flow for subscriptions
- ✅ `src/components/Apps/ExtAppPostCard.jsx` - Render posts from external apps (Level 1 JSON + Level 3 pre-rendered)
- ✅ `src/components/Apps/MySubscriptions.jsx` - Manage user's subscriptions
- ✅ `src/components/Apps/useExtAppFeed.js` - Hook for fetching posts from subscribed apps
- ✅ `src/components/Apps/index.js` - Exports all components
- ✅ `src/api/extAppsApi.js` - API client for registry, subscriptions, feed, assets
**Marketplace Example App**:
- ✅ `apps/marketplace-app/` - Complete standalone app with Fastify, Pulsar, SQLite, H3 indexing
**Design Document**:
- ✅ `docs/plans/2026-01-28-external-apps-design.md` - Complete architecture documentation
**CRITICAL CORRECTIONS NEEDED**:
1. **External apps location**:
- User wanted apps in `/home/swire/apps/` (completely separate from sociowire-code)
- I created marketplace-app in `/home/swire/sociowire-code/apps/marketplace-app/` (wrong location)
- External apps should be independent projects, not part of sociowire codebase
- Need to move or recreate marketplace-app in `/home/swire/apps/`
2. **Post creation flow** (User said 100 times):
- **AUCUN accès direct à l'app** - User NE peut PAS contacter l'app directement
- **Tout passe par Sociowire** - Toutes les requêtes passent par Sociowire
- **Post creation flow**:
* User upload images → Sociowire S3 proxy (pas vers l'app)
* Sociowire stocke les images dans son S3
* Sociowire envoie le post à l'app avec les URLs des images
* L'app sauvegarde les URLs et ses propres métadonnées
* L'app possède son propre contenu, pas les images
- **User voit les apps enregistrées** quand il crée un post
- **Sociowire prend en charge de poster via l'app** (proxy)
**Deployment Steps Needed**:
1. Move marketplace-app to `/home/swire/apps/marketplace-app/` (separate from sociowire-code)
2. Rebuild apps-gateway with new dependencies (npm install, docker build)
3. Deploy updated apps-gateway to Docker Swarm
4. Add Redis secret to apps-gateway if not already present
5. Test complete flow: register app → subscribe → create post → view on map
**Deployment Steps Needed**:
1. Move marketplace-app to `/home/swire/apps/marketplace-app/` (separate from sociowire-code)
2. Rebuild apps-gateway with new dependencies (npm install, docker build)
3. Deploy updated apps-gateway to Docker Swarm
4. Add Redis secret to apps-gateway if not already present
5. Test complete flow: register app → subscribe → create post → view on map
## Chat Dialog Overflow - RESOLVED! ✅ (Jan 28 2026 - 06:00 UTC)
### ✅ Chat Window Mobile Height Issue - FIXED
**Final Solution** (v0.0.133):
- Added `max-h-[65dvh]` to chat window container on mobile
- Reverted width changes (kept original `w-80 sm:w-96`)
- Added keyboard detection to move window up when virtual keyboard opens
- Replaced `scrollIntoView()` with `scrollTop` to prevent parent scroll
**Status**: ✅ RESOLVED - User confirmed "parfait" (perfect)
### New Issue: Chat History Not Loading (Jan 28 2026 - 06:00 UTC)
**User feedback**: "l historique et le chat des fois passe pas. presentement lu aucun historiwue..."
**Problem**: Chat messages/history not loading for users onay01 and swadmin01
**Root Cause Found**: The `fetch()` calls in `useChat.js` were NOT sending authentication headers
The chat-service API requires Bearer token authentication, but the frontend was using `fetch()` directly without auth headers.
**Fix Applied** (v0.0.136):
```javascript
// Added import
import { loadAuth } from "../auth/authStorage";
// Helper function
function getAuthHeaders() {
try {
const auth = loadAuth();
const token = auth?.access_token;
if (!token) return {};
return { Authorization: `Bearer ${token}` };
} catch {
return {};
}
}
// Updated fetch calls
const res = await fetch(`${API_BASE}/messages?...`, {
headers: getAuthHeaders()
});
```
**Files Modified**:
- `/home/swire/sociowire-code/frontend-service/sociowire-frontend/src/components/Chat/useChat.js`
**Deployment**: v0.0.136 deployed with `--no-cache` and `--force`
### Performance Issue: Meilisearch Slowness (Jan 28 2026 - 06:00 UTC)
**User feedback**: Things are really slow sometimes, Meilisearch takes 4-5 seconds when not cached
**Analysis**:
- Direct Meilisearch access: 88ms (fast)
- Through discovery-service: 1.3s (slower)
- Difference is from data enrichment and database queries
- Redis caching already exists in discovery-service
**Potential Optimizations**:
1. More aggressive caching (longer TTL, cache more query patterns)
2. Reduce data enrichment (only enrich what's needed)
3. Pre-compute frequent search results
4. Add CDN for static assets
**Status**: Not blocking, user wants to test chat history fix first
### New Task: News Scraper Activity Check (Jan 28 2026 - 06:10 UTC)
**User request**: "j ai des news qui sont ajouter par un news scrapper.. tout au nom de tommy. peu tu checker quvil y en a eu bcp dernierement et que l enrichissement a bien marcher et donne moi les dernier enrichi..juste pour max 5hrs en arriere"
Translation: "I have news items being added by a news scraper... all under the name tommy. Can you check if there have been many added recently and if the enrichment worked well, and give me the recently enriched items... just for max 5 hours back"
**Task**:
1. Check how many news items were added recently (last 5 hours)
2. Verify enrichment worked correctly
3. Show recently enriched items
4. Focus on items added by "tommy" (author name)
**Likely services involved**:
- page-feeder (scrapes news)
- enrichment-worker (enriches news with metadata)
- discovery-service (stores enriched news)
- Meilisearch (indexes news for search)
### All Services Committed and Pushed:
- ✅ frontend-service: chat mobile fix + keyboard handling + auth fix
- ✅ sociowire-backend: SQL migrations passkeys
- ✅ discovery-service: meilisearch config
- ✅ search-service: update
- ✅ engagement-service: NATS handling
- ✅ page-feeder: meilisearch handling
- ✅ chat-service: session files
</guidance>
<pending_items description="Unfinished work, explicit TODOs, follow-up items mentioned across sessions. Clear items when resolved.">
- Deploy external apps platform frontend (AppStore integration, Apps category in PostCreateModal)
- Deploy apps-gateway backend (post creation routes, S3 proxy)
- Test complete flow: subscribe to app → create post → view on map
- Migrate island-service and asset-service to NATS (currently use HTTP direct)
- Fix chat-service - stopped working
- Git commit and push auth-service token refresh changes
@ -166,7 +456,88 @@ Login issue (Jan 28 2026): "session expired" error was caused by core-api and di
**Likely cause**: Services calling userinfo endpoint with tokens that don't include the `openid` scope. This is separate from the token refresh fix.
**Status**: Not blocking current testing. Monitor if this persists after token refresh verification.
## External Apps Architecture (Jan 28 2026 - In Progress)
**User Request**: Create external apps system where users can code apps and integrate them via REST calls, possibly exposing Pulsar for larger clients.
**Architecture Goals**:
1. Users can code apps that display posts on maps
2. News posts refactored as external apps
3. Each app has:
- Pulsar channel for in/out
- S3-style address for assets
- Custom map style
- Code integration
4. Start with example app for selling articles
5. Connect to apps-gateway as external
**Key Requirements**:
- REST API integration for apps
- Pulsar messaging for high-volume clients
- External apps can display posts on maps
- News posts become external apps
- Each app has dedicated Pulsar topics (in/out)
- Asset storage via S3-compatible interface
- Custom map styling support
**Status**: Initial architecture phase - searching for existing infrastructure
</project_context>
<project_context_external_apps description="External Apps Platform architecture and implementation status">
## External Apps Architecture (Jan 28 2026 - Design Complete)
**User Request**: Create external apps system where users can code apps and integrate them via REST calls, with optional Pulsar messaging for high-volume clients.
**Architecture Goals**:
1. Users can code apps that display posts on maps
2. News posts refactored as external apps
3. Each app has:
- Pulsar channel for in/out
- S3-style address for assets
- Custom map style
- Code integration
4. Start with example app for selling articles
5. Connect to apps-gateway as external
**Key Requirements**:
- REST API integration for apps
- Pulsar messaging for high-volume clients
- External apps can display posts on maps
- News posts become external apps
- Each app has dedicated Pulsar topics (in/out)
- Asset storage via S3-compatible interface
- Custom map styling support
**Architecture - FEDERATED CONTENT SYSTEM**:
- Apps = data sources (own DB + assets)
- Sociowire = aggregator/viewer only
- NO local storage of app posts
- Zone-based fetching (H3 sectors) when user browses map
- Preference matching - apps filter results per user
- Federated search - queries distributed to apps, results aggregated progressively via WebSocket
- Real-time updates via Pulsar
- Global search - Reco service merges native posts + subscribed apps
**Design Decisions**:
- Monetization: Freemium simple (free to post, premium features later)
- Execution model: Hybrid (webhook for small clients, Pulsar direct for large)
- Search: Progressive loading via WebSocket
- Personalization: Apps control what each user sees (user context sent to providers)
**Broader Vision** (future):
- Eventually migrate ALL posts to federated model (news, friends posts, etc.)
- Everything distributed, nothing stored locally
- Current focus: External apps first, then expand to other content types
**Implementation Plan**:
1. Save design document → `docs/plans/2026-01-28-external-apps-design.md`
2. Create Marketplace example app → Standalone project that connects to apps-gateway
3. Adapt apps-gateway → Add registration, webhook support, cache integration
4. Frontend → App store UI, post creation via apps
**Status**: ✅ Design complete - ready for implementation
</project_context_external_apps>
<self_improvement description="Guidelines for evolving memory architecture and learning procedures.">
MEMORY ARCHITECTURE EVOLUTION:

File diff suppressed because one or more lines are too long

View File

@ -1,7 +1,7 @@
{
"name": "sociowire-frontend",
"private": true,
"version": "0.0.135",
"version": "0.0.180",
"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

@ -320,6 +320,41 @@ export async function createPost(payload, token) {
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).
*/
@ -379,10 +414,27 @@ export async function fetchPostByUrl(url) {
}
}
export async function fetchPostTruth(postId) {
if (!postId) return null;
export async function fetchPostByGuid(guid) {
const raw = (guid || "").toString().trim();
if (!raw) return null;
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 {
const res = await fetch(`${apiBase()}/post/truth?${qs.toString()}`, {
credentials: "include",
@ -415,9 +467,12 @@ export async function fetchPostTruth(postId) {
}
}
export async function votePostTruth(postId, vote) {
if (!postId) throw new Error("post_id required");
const body = JSON.stringify({ post_id: postId, vote });
export async function votePostTruth(postId, vote, guid = null, appId = null) {
if (!postId && !guid) throw new Error("post_id or guid required");
const payload = { post_id: postId, vote };
if (guid) payload.guid = guid;
if (appId) payload.app_id = appId;
const body = JSON.stringify(payload);
const res = await fetch(`${apiBase()}/post/truth-vote`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders() },
@ -441,7 +496,7 @@ export async function votePostTruth(postId, vote) {
truth_score: truthScore, // Main display score - the final calculated value
total_votes: 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
};
}
@ -523,7 +578,17 @@ export async function fetchSmartFeed(params = {}, { signal } = {}) {
try {
const data = await fetchJsonCached(url, { ttlMs: 10000, signal });
const items = Array.isArray(data) ? data : data.results || data.items || [];
console.log('[fetchSmartFeed] Got', items.length, 'posts, content_types:', [...new Set(items.map(p => p.content_type))]);
// DEBUG: Check tier values from API
const tierCounts = { premium: 0, regular: 0, none: 0 };
items.forEach(p => {
if (p.tier === 'premium') tierCounts.premium++;
else if (p.tier === 'regular') tierCounts.regular++;
else tierCounts.none++;
});
console.log('[fetchSmartFeed] Got', items.length, 'posts, TIER breakdown:', tierCounts, 'content_types:', [...new Set(items.map(p => p.content_type))]);
if (tierCounts.premium > 0) {
console.log('[fetchSmartFeed] Sample premium post:', items.find(p => p.tier === 'premium'));
}
return await resolveAssetRefsInPosts(items);
} catch (err) {
if (err.name === "AbortError") return []; // silently ignore aborted requests
@ -766,14 +831,17 @@ export async function fetchProfile(username = "") {
}
}
export async function recordPostView(postId) {
if (!postId) return { ok: false };
export async function recordPostView(postId, guid = null, appId = null) {
if (!postId && !guid) return { ok: false };
try {
const body = { post_id: postId };
if (guid) body.guid = guid;
if (appId) body.app_id = appId;
const res = await fetch(`${apiBase()}/post/view`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ post_id: postId }),
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
return { ok: res.ok, ...data };
@ -782,14 +850,17 @@ export async function recordPostView(postId) {
}
}
export async function togglePostLike(postId, like = true) {
if (!postId) return { ok: false };
export async function togglePostLike(postId, like = true, guid = null, appId = null) {
if (!postId && !guid) return { ok: false };
try {
const body = { post_id: postId, like };
if (guid) body.guid = guid;
if (appId) body.app_id = appId;
const res = await fetch(`${apiBase()}/post/like`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders() },
credentials: "include",
body: JSON.stringify({ post_id: postId, like }),
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
return { ok: res.ok, ...data };
@ -798,14 +869,17 @@ export async function togglePostLike(postId, like = true) {
}
}
export async function recordPostShare(postId) {
if (!postId) return { ok: false };
export async function recordPostShare(postId, guid = null, appId = null) {
if (!postId && !guid) return { ok: false };
try {
const body = { post_id: postId };
if (guid) body.guid = guid;
if (appId) body.app_id = appId;
const res = await fetch(`${apiBase()}/post/share`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ post_id: postId }),
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
return { ok: res.ok, ...data };
@ -814,10 +888,12 @@ export async function recordPostShare(postId) {
}
}
export async function fetchPostComments(postId, limit = 20) {
if (!postId) return [];
export async function fetchPostComments(postId, limit = 20, guid = null, appId = null) {
if (!postId && !guid) return [];
const qs = new URLSearchParams();
qs.set("post_id", String(postId));
if (guid) qs.set("guid", guid);
if (appId) qs.set("app_id", appId);
if (limit) qs.set("limit", String(limit));
try {
const res = await fetch(`${apiBase()}/post/comments?${qs.toString()}`, {
@ -831,14 +907,18 @@ export async function fetchPostComments(postId, limit = 20) {
}
}
export async function createPostComment(postId, body) {
if (!postId || !body) return { ok: false };
export async function createPostComment(postId, body, guid = null, appId = null) {
if (!postId && !guid) return { ok: false };
if (!body) return { ok: false };
try {
const reqBody = { post_id: postId, body };
if (guid) reqBody.guid = guid;
if (appId) reqBody.app_id = appId;
const res = await fetch(`${apiBase()}/post/comment`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders() },
credentials: "include",
body: JSON.stringify({ post_id: postId, body }),
body: JSON.stringify(reqBody),
});
const data = await res.json().catch(() => ({}));
return { ...data, ok: res.ok };

View File

@ -8,12 +8,12 @@ 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: "all", label: "All", icon: "fa-globe" },
{ id: "marketplace", label: "Marketplace", 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" },
{ id: "entertainment", label: "Entertainment", icon: "fa-gamepad" },
];
export default function AppStore({ onClose }) {
@ -30,19 +30,22 @@ export default function AppStore({ onClose }) {
setLoading(true);
setError(null);
const [appsResult, subsResult] = await Promise.all([
listApps({ category: category === "all" ? undefined : category }),
listSubscriptions(),
]);
// Fetch apps (required) and subscriptions (optional - may fail if not logged in)
const appsResult = await listApps({ category: category === "all" ? undefined : category });
setApps(appsResult.apps || []);
// Build subscriptions lookup
const subsMap = {};
for (const sub of subsResult.subscriptions || []) {
subsMap[sub.app_id] = sub;
// Try to get subscriptions, but don't fail if unauthorized
try {
const subsResult = await listSubscriptions();
const subsMap = {};
for (const sub of subsResult.subscriptions || []) {
subsMap[sub.app_id] = sub;
}
setSubscriptions(subsMap);
} catch (subErr) {
console.log("Could not load subscriptions (not logged in?):", subErr.message);
setSubscriptions({});
}
setSubscriptions(subsMap);
} catch (err) {
console.error("Failed to load apps:", err);
setError(err.message);
@ -75,7 +78,7 @@ export default function AppStore({ onClose }) {
};
return (
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-[1100] 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">
@ -123,13 +126,13 @@ export default function AppStore({ onClose }) {
onClick={loadApps}
className="mt-4 px-4 py-2 bg-zinc-800 rounded-lg hover:bg-zinc-700"
>
Réessayer
Retry
</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>
<p>No apps available in this category</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">

View File

@ -7,6 +7,18 @@ import {
decryptMessage,
isE2EAvailable,
} from "./cryptoUtils";
import { loadAuth } from "../../auth/authStorage";
function getAuthHeaders() {
try {
const auth = loadAuth();
const token = auth?.access_token;
if (!token) return {};
return { Authorization: `Bearer ${token}` };
} catch {
return {};
}
}
const WS_BASE = import.meta.env.VITE_CHAT_WS_URL ||
(window.location.protocol === "https:" ? "wss://" : "ws://") +
@ -277,7 +289,10 @@ export function useChat({ username, enabled = true, onNewMessage }) {
if (!username || !otherUsername) return [];
try {
const res = await fetch(`${API_BASE}/messages?username=${encodeURIComponent(username)}&other=${encodeURIComponent(otherUsername)}`);
const res = await fetch(`${API_BASE}/messages?other=${encodeURIComponent(otherUsername)}`, {
headers: getAuthHeaders(),
credentials: "include",
});
const data = await res.json();
if (data.ok && data.messages) {
@ -295,7 +310,10 @@ export function useChat({ username, enabled = true, onNewMessage }) {
if (!username) return [];
try {
const res = await fetch(`${API_BASE}/conversations?username=${encodeURIComponent(username)}`);
const res = await fetch(`${API_BASE}/conversations`, {
headers: getAuthHeaders(),
credentials: "include",
});
const data = await res.json();
if (data.ok && data.conversations) {

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

@ -109,9 +109,50 @@ function smoothPolygon(points) {
return result;
}
// #224: derive a stable, distinct color per cluster from its topical identity
// (event_key / title / dominant subject). Two unrelated events (e.g. a war vs a
// natural disaster) therefore render as two clearly different colored regions.
// If the cluster carries an explicit market impact (green/red), honor it.
function hashString(str) {
let h = 0;
const s = String(str || "");
for (let i = 0; i < s.length; i++) {
h = (h << 5) - h + s.charCodeAt(i);
h |= 0;
}
return Math.abs(h);
}
function dominantImpact(cluster) {
const posts = Array.isArray(cluster?.member_posts) ? cluster.member_posts : [];
let green = 0;
let red = 0;
for (const p of posts) {
const imp = (p?.impact || p?.asset_impact || "").toString().toLowerCase();
if (imp === "green" || imp === "positive" || imp === "bull") green++;
else if (imp === "red" || imp === "negative" || imp === "bear") red++;
}
const top = (cluster?.impact || "").toString().toLowerCase();
if (top === "green" || top === "positive") return "green";
if (top === "red" || top === "negative") return "red";
if (green === 0 && red === 0) return "";
return green >= red ? "green" : "red";
}
function clusterColor(cluster) {
const impact = dominantImpact(cluster);
if (impact === "green") return "#22c55e";
if (impact === "red") return "#ef4444";
// Distinct hue per topic/event so related news share one color region.
const seed = cluster?.event_key || cluster?.subject || cluster?.title || cluster?.id;
const hue = hashString(seed) % 360;
return `hsl(${hue}, 72%, 58%)`;
}
// Create organic "lake" shape from cluster member posts
function clusterToPolygon(cluster) {
const { id, title, post_count, member_posts, bounding_box } = cluster;
const color = clusterColor(cluster);
// Get valid points from member posts
let points = [];
@ -183,6 +224,7 @@ function clusterToPolygon(cluster) {
id,
title,
post_count,
color,
},
geometry: {
type: "Polygon",
@ -199,6 +241,7 @@ function clusterToPoint(cluster) {
id: cluster.id,
title: cluster.title?.slice(0, 35) + (cluster.title?.length > 35 ? "..." : "") || "Cluster",
post_count: cluster.post_count,
color: clusterColor(cluster),
},
geometry: {
type: "Point",
@ -333,13 +376,14 @@ export default function EventClustersLayer({ map, enabled = true, onClusterClick
"visibility": "none",
},
paint: {
"fill-color": [
// #224: color each region by its dominant subject/impact (per-cluster)
"fill-color": ["coalesce", ["get", "color"], "#60a5fa"],
"fill-opacity": [
"interpolate", ["linear"], ["get", "post_count"],
2, "#60a5fa", // lighter blue
5, "#a78bfa", // softer purple
10, "#f472b6", // softer pink
2, 0.22,
10, 0.34,
40, 0.45,
],
"fill-opacity": 0.35,
},
});
@ -352,13 +396,13 @@ export default function EventClustersLayer({ map, enabled = true, onClusterClick
"visibility": "none",
},
paint: {
"line-color": [
"line-color": ["coalesce", ["get", "color"], "#93c5fd"],
"line-width": [
"interpolate", ["linear"], ["get", "post_count"],
2, "#93c5fd", // even lighter for border
5, "#c4b5fd",
10, "#f9a8d4",
2, 1.5,
40, 3,
],
"line-width": 2,
"line-opacity": 0.85,
"line-blur": 1,
},
});

View File

@ -244,6 +244,22 @@ export default function MapView({
}
});
// #224: colored news-cluster zones on the map (default ON).
const CLUSTER_ZONES_KEY = "sociowire:clusterZones";
const [clusterZonesEnabled, setClusterZonesEnabled] = useState(() => {
try {
const saved = localStorage.getItem(CLUSTER_ZONES_KEY);
return saved === null ? true : saved === "true";
} catch {
return true;
}
});
useEffect(() => {
try {
localStorage.setItem(CLUSTER_ZONES_KEY, clusterZonesEnabled ? "true" : "false");
} catch {}
}, [clusterZonesEnabled]);
const debugEnabled = (() => {
try {
return typeof window !== "undefined" &&
@ -2122,7 +2138,7 @@ export default function MapView({
{viewParams.center && (
<EventClustersLayer
map={mapRef.current}
enabled={contentFilter === "cluster"}
enabled={clusterZonesEnabled || contentFilter === "cluster"}
onClusterPostIds={setClusterPostIds}
onClusterClick={(cluster) => {
// Pan to cluster and potentially show details
@ -2228,6 +2244,15 @@ export default function MapView({
>
<i className="fa-solid fa-share-nodes"></i>
</button>
<button
type="button"
className={"sw-search__iconBtn" + (clusterZonesEnabled ? " is-active" : "")}
title={clusterZonesEnabled ? "Hide news zones" : "Show news zones"}
aria-pressed={clusterZonesEnabled}
onClick={() => setClusterZonesEnabled((v) => !v)}
>
<i className="fa-solid fa-layer-group"></i>
</button>
<button
type="button"
className="sw-search__iconBtn"

View File

@ -4,6 +4,14 @@ import { createRoot } from "react-dom/client";
const SW_ANIM_MS = 840;
// Get consistent ID for a post (handles numeric IDs and UUID fallback)
function getPostId(p) {
const numId = p?.id ?? p?._id;
if (numId != null && numId !== 0) return numId;
if (p?.guid) return p.guid;
return numId;
}
import CardRenderer from "../Cards/CardRenderer";
import { cardTokens } from "../../theme/cardTokens";
import { getTemplateSpecForPost, getTemplateKeyForPost, adaptPostToTemplateData } from "./templateSpecs";
@ -454,13 +462,11 @@ function mountCard(container, spec, data, theme, post) {
);
}
// Render card with CardRenderer
root.render(
React.createElement(
"div",
{
className: "sw-card-wrap",
style: { width: spec?.size?.w, height: spec?.size?.h },
},
React.Fragment,
null,
...children
)
);
@ -1804,9 +1810,9 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
});
}
if (postID > 0 && !modalWrap.__swViewed) {
if ((postID > 0 || postData?.guid) && !modalWrap.__swViewed) {
modalWrap.__swViewed = true;
recordPostView(postID).then((res) => {
recordPostView(postID, postData?.guid, postData?.app_id).then((res) => {
const counts = res?.counts;
if (counts) {
updateStat(modalWrap, "views", counts.view_count);
@ -1852,7 +1858,7 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
}
trackEvent("post_like_click", { post_id: postID, context: "map" });
likeBtn.disabled = true;
const res = await togglePostLike(postID, !liked);
const res = await togglePostLike(postID, !liked, postData?.guid, postData?.app_id);
if (res?.ok) {
liked = !!res.liked;
likeBtn.textContent = liked ? "Liked" : "Like";
@ -1869,11 +1875,13 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
}
const shareBtn = modalWrap.querySelector('[data-action="share"]');
if (shareBtn && postID > 0) {
if (shareBtn && (postID > 0 || postData?.guid)) {
shareBtn.addEventListener("click", async () => {
const shareUrl = `${window.location.origin}/p/${postID}`;
const shareUrl = postData?.guid
? `${window.location.origin}/n/${postData.guid}`
: `${window.location.origin}/p/${postID}`;
const shareTitle = postData?.title || postData?.Title || "SocioWire";
trackEvent("post_share_click", { post_id: postID, context: "map" });
trackEvent("post_share_click", { post_id: postID || postData?.guid, context: "map" });
try {
if (navigator.share) {
await navigator.share({ title: shareTitle, url: shareUrl });
@ -1881,7 +1889,7 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
await navigator.clipboard.writeText(shareUrl);
}
} catch {}
const res = await recordPostShare(postID);
const res = await recordPostShare(postID, postData?.guid, postData?.app_id);
const counts = res?.counts;
if (counts) {
updateStat(modalWrap, "views", counts.view_count);
@ -1892,7 +1900,7 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
// Truth vote handlers (thumbs up/down in truth badge)
const truthBadgeNew = modalWrap.querySelector('.sw-truth-badge');
if (truthBadgeNew && postID > 0) {
if (truthBadgeNew && (postID > 0 || postData?.guid)) {
const downvoteBtn = truthBadgeNew.querySelector('.sw-truth-downvote');
const upvoteBtn = truthBadgeNew.querySelector('.sw-truth-upvote');
const scoreDisplay = truthBadgeNew.querySelector('.sw-truth-badge-score');
@ -1936,8 +1944,8 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
}
try {
trackEvent("truth_vote_click", { post_id: postID, vote, context: "map" });
const stats = await votePostTruth(postID, vote);
trackEvent("truth_vote_click", { post_id: postID || postData?.guid, vote, context: "map" });
const stats = await votePostTruth(postID, vote, postData?.guid, postData?.app_id);
if (stats) {
currentVote = vote;
updateButtonStyles();
@ -2180,7 +2188,7 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
const commentsBody = modalWrap.querySelector(".sw-livechat-body");
if (commentsBody && postID > 0) {
fetchPostComments(postID, 20).then((items) => renderComments(commentsBody, items));
fetchPostComments(postID, 20, postData?.guid, postData?.app_id).then((items) => renderComments(commentsBody, items));
}
if (commentBtn && commentInput && postID > 0) {
commentBtn.addEventListener("click", async () => {
@ -2192,10 +2200,10 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
if (!body) return;
trackEvent("post_comment_submit", { post_id: postID, context: "map" });
commentBtn.disabled = true;
const res = await createPostComment(postID, body);
const res = await createPostComment(postID, body, postData?.guid, postData?.app_id);
if (res?.ok) {
commentInput.value = "";
const existing = await fetchPostComments(postID, 20);
const existing = await fetchPostComments(postID, 20, postData?.guid, postData?.app_id);
renderComments(commentsBody, existing);
const counts = res?.counts;
if (counts) {
@ -2354,6 +2362,7 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, the
requestAnimationFrame(() => root.classList.add("sw-appear-in"));
root.className = "post-pin post-pin--compact";
root.style.zIndex = "100";
root.__post = post;
function unmountIfAny() {
@ -2368,19 +2377,12 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, the
root.className = "post-pin post-pin--compact sw-appear sw-appear-in";
root.style.zIndex = "100";
// Add connecting line if offset is significant
// DISABLED: No longer using pixel offsets
const hasOffset = false;
const lineHTML = '';
// Add slight random tilt for polaroid effect (based on post ID for consistency)
const postId = post?.id || post?.ID || 0;
const tiltAngle = ((postId % 11) - 5) * 1.2; // Range: -6 to +6 degrees
const tiltAngle = ((postId % 11) - 5) * 1.2;
root.innerHTML = `
<div class="sw-template-mini-wrap" style="--card-tilt: ${tiltAngle}deg;"></div>
<div class="post-pin-pointer-small"></div>
${lineHTML}
`;
root.__swCompactReady = true;
}
@ -2430,7 +2432,18 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, the
.setLngLat(lngLat)
.addTo(map);
markersRef.current.push({ id: post.id, marker, el: root, post, type: "full" });
// CRITICAL: Set z-index on the marker's container element (not just inner content)
// MapLibre GL wraps markers in a container - z-index must be on the container
try {
const markerEl = marker.getElement();
if (markerEl) {
markerEl.style.zIndex = "100";
}
} catch (e) {
console.warn('[createMarkerForPost] Could not set marker z-index:', e);
}
markersRef.current.push({ id: getPostId(post), marker, el: root, post, type: "full" });
root.addEventListener("click", (ev) => {
ev.stopPropagation();
@ -2623,6 +2636,8 @@ export function createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRe
root.style.flexDirection = "column";
root.style.alignItems = "center";
root.style.zIndex = isWeatherPost ? "10" : "2";
root.style.contain = "layout style paint"; // CSS containment for performance
root.style.willChange = "transform, opacity"; // Hint for GPU acceleration
root.__post = post;
function renderSimple() {
@ -2641,14 +2656,14 @@ export function createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRe
? 'linear-gradient(135deg, rgba(56, 189, 248, 0.9), rgba(59, 130, 246, 0.9))'
: isWeather
? weatherColor
: getMarkerColorForCategory(activePost?.category);
: getMarkerColorForCategory(activePost?.category, activePost);
const pinColor = isLive
? livePin
: (isCamera || isVideo)
? 'rgba(56, 189, 248, 0.9)'
: isWeather
? weatherPin
: getMarkerColorForCategory(activePost?.category);
: getMarkerColorForCategory(activePost?.category, activePost);
const img = normalizeImageUrl(activePost?.image_small || "");
root.__lastTemplateKey = getTemplateKeyForPost(activePost);
root.__lastImage = img || "";
@ -2911,7 +2926,15 @@ export function createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRe
.setLngLat(lngLat)
.addTo(map);
markersRef.current.push({ id: post.id, marker, el: root, post, type: "simple" });
// Set z-index on marker container - simple pins at z-index 1 (below cards at 100)
try {
const markerEl = marker.getElement();
if (markerEl) {
markerEl.style.zIndex = "1";
}
} catch (e) {}
markersRef.current.push({ id: getPostId(post), marker, el: root, post, type: "simple" });
}
/**

View File

@ -23,6 +23,10 @@ function getBaseStyle(theme) {
return {
version: 8,
// #223: real 3D globe when zoomed out (MapLibre v5 globe projection
// auto-transitions to a flat mercator map as you zoom in). Declaring it in
// the style spec keeps it applied across theme changes (setStyle).
projection: { type: "globe" },
sources: {
"carto-base": {
type: "raster",

View File

@ -8,8 +8,27 @@ import { prioritizePosts } from "./postPriority";
import { trackEvent } from "../../utils/analytics";
import { matchesContentFilter } from "../Filters/ContentFilters";
// Generate stable numeric ID from a string (like guid or url_hash)
function hashStringToNumber(str) {
if (!str) return 0;
let hash = 0;
for (let i = 0; i < Math.min(str.length, 8); i++) {
hash = (hash << 4) + str.charCodeAt(i);
}
return Math.abs(hash);
}
function getId(p) {
return p?.id ?? p?._id ?? null;
// App posts (news, external apps) - use guid directly as-is
if (p?.app_id && p?.guid) return p.guid;
// Native posts - use numeric id
const numId = p?.id ?? p?._id;
if (numId != null && numId !== 0) return numId;
// Fallback: if has guid but no app_id, still use guid
if (p?.guid) return p.guid;
return numId;
}
function normalizePostId(post) {
@ -297,8 +316,8 @@ function buildClusterAreaFeatures(posts) {
return features;
}
const MAX_POOL_POSTS = 160;
const MAX_VISIBLE_POSTS = 45;
const MAX_POOL_POSTS = 1500;
const MAX_VISIBLE_POSTS = 1000; // High limit to allow navigation without losing posts
function expandBounds(bounds, padRatio = 0) {
if (!bounds) return null;
@ -408,6 +427,7 @@ export function usePostsEngine({
const searchResultsRef = useRef(false);
const replacePoolRef = useRef(false);
const retryRef = useRef({ key: "", count: 0, ts: 0 });
const pageLoadTimeRef = useRef(Date.now());
const abortControllerRef = useRef(null);
const prevContentFilterRef = useRef(contentFilter);
const [mapReady, setMapReady] = useState(false);
@ -445,12 +465,34 @@ export function usePostsEngine({
const map = mapRef.current;
if (!map) return;
initialFetchKickRef.current = true;
map.__swForceFetchAt = Date.now();
setTimeout(() => {
// Poll until map has valid center, then trigger fetch
let attempts = 0;
const maxAttempts = 20;
const pollInterval = setInterval(() => {
attempts++;
try {
map.fire("moveend");
} catch {}
}, 120);
const center = map.getCenter();
if (center && center.lat != null && center.lng != null) {
clearInterval(pollInterval);
map.__swForceFetchAt = Date.now();
console.log('[usePostsEngine] Initial fetch kick - map center ready:', center.lat, center.lng);
map.fire("moveend");
} else if (attempts >= maxAttempts) {
clearInterval(pollInterval);
// Force it anyway after max attempts
map.__swForceFetchAt = Date.now();
console.log('[usePostsEngine] Initial fetch kick - forcing after max attempts');
map.fire("moveend");
}
} catch (e) {
if (attempts >= maxAttempts) {
clearInterval(pollInterval);
}
}
}, 100);
return () => clearInterval(pollInterval);
}, [mapReady, mapRef]);
// CRITICAL: Dedicated effect for contentFilter changes (media filter buttons)
@ -793,7 +835,15 @@ export function usePostsEngine({
fullCardPosts = prioritized.fullCardPosts;
simpleMarkerPosts = prioritized.simpleMarkerPosts;
// DEBUG: Log marker type assignments
console.log('[syncMarkers] fullCardPosts:', fullCardPosts.length, 'simpleMarkerPosts:', simpleMarkerPosts.length);
if (fullCardPosts.length > 0) {
console.log('[syncMarkers] First fullCard:', { id: getId(fullCardPosts[0]), tier: fullCardPosts[0]?.tier, title: fullCardPosts[0]?.title?.substring(0, 30) });
}
// Combine both types for "wanted" tracking
// Use Set with String for O(1) lookup and avoid type mismatch (string vs number)
const fullCardIds = new Set(fullCardPosts.map(p => String(getId(p))));
const wanted = new Map();
for (const post of visible) {
const id = getId(post);
@ -803,10 +853,10 @@ export function usePostsEngine({
if (ct === "weather") {
continue; // Skip weather markers - they appear in the search bar widget
}
// Tag avec le type de marqueur souhaité
// Tag avec le type de marqueur souhaité - use String for comparison
const isFullCard =
id === forceFullPostId ||
fullCardPosts.some(p => getId(p) === id);
String(id) === String(forceFullPostId) ||
fullCardIds.has(String(id));
wanted.set(id, { post, type: isFullCard ? 'full' : 'simple' });
}
}
@ -863,16 +913,56 @@ export function usePostsEngine({
// add missing markers - use appropriate type based on priority
// Camera/video ALWAYS use simple markers regardless of priority
let createdFull = 0, createdSimple = 0;
let wantedFullCount = 0, wantedSimpleCount = 0, skippedCount = 0;
for (const [id, { post, type }] of wanted.entries()) {
if (have.has(id)) continue;
if (type === 'full') wantedFullCount++;
else wantedSimpleCount++;
if (have.has(id)) {
skippedCount++;
continue;
}
const ct = (post?.content_type || '').toLowerCase();
if (type === 'full' && ct !== 'camera' && ct !== 'video') {
console.log('[syncMarkers] Creating FULL CARD marker for:', id, post?.tier, post?.title?.substring(0, 25));
createMarkerForPost(post, mapRef, markersRef, expandedElRef, theme);
createdFull++;
} else {
createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRef, theme);
createdSimple++;
}
}
// Store wanted type counts for debug
if (typeof window !== 'undefined') {
window.__swMarkerDebugLoop = { wantedFullCount, wantedSimpleCount, skippedCount };
}
console.log('[syncMarkers] CREATED markers: full=', createdFull, 'simple=', createdSimple);
// Store debug info for visibility
if (typeof window !== 'undefined') {
// Count tier values in visible
let premiumCount = 0, regularCount = 0, noTierCount = 0;
for (const p of visible) {
if (p.tier === 'premium') premiumCount++;
else if (p.tier === 'regular') regularCount++;
else noTierCount++;
}
window.__swMarkerDebug = {
fullCardPosts: fullCardPosts.length,
simpleMarkerPosts: simpleMarkerPosts.length,
createdFull,
createdSimple,
wanted: wanted.size,
visible: visible.length,
premiumTier: premiumCount,
regularTier: regularCount,
noTier: noTierCount,
firstFullId: fullCardPosts[0] ? (fullCardPosts[0].id || fullCardPosts[0].guid) : 'none',
timestamp: Date.now()
};
}
},
[mapRef, markersRef, expandedElRef, theme, searchQuery, forceFullPostId, weatherEnabled]
);
@ -929,11 +1019,8 @@ export function usePostsEngine({
return false;
}
// Apply time filter (skip when showing search results - user wants all matches)
if (!searchResultsRef.current && tf && tf > 0) {
const postTime = effectivePostTime(p);
if (!matchesTimeFilter(postTime, tf)) return false;
}
// Time filter is now applied on backend (news-app Elasticsearch query)
// No frontend time filter needed - backend already filters by time_hours
if (isClusterFocus) {
const id = Number(getId(p));
@ -1164,12 +1251,15 @@ export function usePostsEngine({
if (distKm > Math.max(50, radiusKm * 0.5)) {
shouldClearPool = true;
}
} else if (forceFetch || last.filterKey !== filterKey) {
// Filter changed or force fetch - clear pool
} else if (forceFetch || last.filterKey !== filterKey || last.timeHours !== timeHours) {
// Filter changed (including time filter) or force fetch - clear pool
shouldClearPool = true;
}
try {
// Clear initial load flag now that we're actually fetching
initialLoadRef.current = false;
setLoadingPosts(true);
setLoadError("");
@ -1262,7 +1352,15 @@ export function usePostsEngine({
if (debugEnabled) {
const count = Array.isArray(newPosts) ? newPosts.length : 0;
const q = (searchQuery || "").trim();
setDebugStatus(`tf:${timeHours || 0} fetched:${count} visible:${visible.length}${q ? ` q:${q}` : ""}`);
// Add tier breakdown to debug status
const tierCounts = { premium: 0, regular: 0 };
if (Array.isArray(newPosts)) {
newPosts.forEach(p => {
if (p.tier === 'premium') tierCounts.premium++;
else if (p.tier === 'regular') tierCounts.regular++;
});
}
setDebugStatus(`v151 tf:${timeHours || 0} f:${count} v:${visible.length} P:${tierCounts.premium} R:${tierCounts.regular}${q ? ` q:${q}` : ""}`);
}
const now = Date.now();
if (now - analyticsDebounceRef.current > 4000) {
@ -1290,6 +1388,24 @@ export function usePostsEngine({
}
setLoadingPosts(false);
// Auto-retry if we got 0 posts in the first 15 seconds after page load
const fetchedCount = Array.isArray(newPosts) ? newPosts.length : 0;
const timeSincePageLoad = Date.now() - pageLoadTimeRef.current;
if (fetchedCount === 0 && timeSincePageLoad < 15000) {
const retryKey = `empty:${Math.round(lat * 100)}|${Math.round(lng * 100)}|${filterKey}`;
const now = Date.now();
if (retryRef.current.key !== retryKey || now - retryRef.current.ts > 8000) {
retryRef.current = { key: retryKey, count: 0, ts: now };
}
if (retryRef.current.count < 2) {
retryRef.current.count += 1;
retryRef.current.ts = now;
console.log(`[usePostsEngine] Empty result, auto-retry #${retryRef.current.count} (page load ${Math.round(timeSincePageLoad/1000)}s ago)`);
if (delayedFetchRef.current) clearTimeout(delayedFetchRef.current);
delayedFetchRef.current = setTimeout(load, 800);
}
}
} catch (err) {
setLoadingPosts(false);
setLoadError("fetch failed");
@ -1319,8 +1435,8 @@ export function usePostsEngine({
const forceFetchNow = mapObj?.__swForceFetchAt && Date.now() - mapObj.__swForceFetchAt < 5000;
if (initialLoadRef.current || forceFetchNow) {
initialLoadRef.current = false;
// Note: flag is cleared inside load() at line ~1095, not here
// Don't clear initialLoadRef here - let load() clear it after successful fetch
// This way if load() returns early (no view center), we'll retry on next effect run
load(); // Immediate fetch for initial load or filter change
} else {
moveDebounceRef.current = setTimeout(load, 300);

View File

@ -1,7 +1,8 @@
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 { fetchPostPreview, uploadPostMedia, createNewsPost } from "../../api/client";
import { listSubscriptions, createPost as createAppPost } from "../../api/extAppsApi";
// Content modes
const CONTENT_MODES = [
@ -9,6 +10,7 @@ const CONTENT_MODES = [
{ 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: "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 = [
@ -16,6 +18,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-rose-500" },
];
const SUB_CATEGORIES = {
@ -23,6 +26,7 @@ const SUB_CATEGORIES = {
Friends: ["Nearby", "Stories", "Hangout", "Help"],
Events: ["Today", "Weekend", "Music", "Sports", "Food"],
Market: ["Deals", "Services", "Jobs", "Housing"],
Apps: [], // Dynamic - filled from subscribed apps
};
export default function PostCreateModal({
@ -46,29 +50,55 @@ export default function PostCreateModal({
const [linkLoading, setLinkLoading] = useState(false);
const [images, setImages] = useState([]);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState("");
const fileInputRef = useRef(null);
const cameraInputRef = useRef(null);
const textareaRef = useRef(null);
const linkTimeoutRef = useRef(null);
// Reset only when modal opens (not on every initialCategory change)
const wasOpenRef = useRef(false);
// Apps state
const [subscribedApps, setSubscribedApps] = useState([]);
const [selectedApp, setSelectedApp] = useState(null);
const [appsLoading, setAppsLoading] = useState(false);
// Reset on open
useEffect(() => {
if (isOpen && !wasOpenRef.current) {
// Modal just opened - reset everything
if (isOpen) {
setMode("text");
setTitle("");
setBody("");
setLinkUrl("");
setLinkPreview(null);
setImages([]);
setUploadError("");
setCategory(initialCategory);
setSubCategory(SUB_CATEGORIES[initialCategory]?.[0] || "");
setSelectedApp(null);
setUploadError("");
}
wasOpenRef.current = isOpen;
}, [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
useEffect(() => {
if (textareaRef.current) {
@ -77,9 +107,9 @@ export default function PostCreateModal({
}
}, [body]);
// Link preview fetch
// Link preview fetch (for link and news modes)
useEffect(() => {
if (mode !== "link" || !linkUrl.trim()) {
if ((mode !== "link" && mode !== "news") || !linkUrl.trim()) {
setLinkPreview(null);
return;
}
@ -92,16 +122,13 @@ export default function PostCreateModal({
if (preview) {
setLinkPreview(preview);
if (!title && preview.title) setTitle(preview.title);
if (!body && preview.description) setBody(preview.description);
}
} catch {}
setLinkLoading(false);
}, 500);
return () => clearTimeout(linkTimeoutRef.current);
}, [linkUrl, mode, title, body]);
const [uploadError, setUploadError] = useState("");
}, [linkUrl, mode, title]);
const handleFileChange = useCallback(async (e) => {
const files = Array.from(e.target.files || []);
@ -113,31 +140,73 @@ export default function PostCreateModal({
for (const file of files) {
const result = await uploadPostMedia(file);
if (result?.url) {
setImages((prev) => {
const newImages = [...prev, result.url];
return newImages;
});
} else {
setUploadError(result?.error || "Upload failed - no URL returned");
setImages((prev) => [...prev, result.url].slice(0, 5));
} else if (result?.error) {
setUploadError(result.error);
}
}
} catch (err) {
console.error("Upload failed:", err);
setUploadError(err.message || "Upload failed");
}
setUploading(false);
e.target.value = "";
}, []);
}, [category]);
const removeImage = (index) => {
setImages((prev) => prev.filter((_, i) => i !== index));
};
const handleSubmit = () => {
const handleSubmit = async () => {
if (mode === "live") {
onGoLive?.({ category, subCategory, title, body, coords });
return;
}
// Handle News mode - post to core-api /api/news/post (text + optional image)
if (mode === "news") {
if (!title.trim() && !body.trim()) return;
try {
setUploading(true);
const postData = {
title: title.trim() || body.trim().slice(0, 100),
description: body.trim(),
url: linkUrl.trim() || null,
images: images,
location: coords ? { lat: coords[1], lon: coords[0] } : null,
};
await createNewsPost(postData);
onClose?.();
} catch (err) {
setUploadError(err.message || "Failed to create news post");
} finally {
setUploading(false);
}
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?.({
@ -154,35 +223,32 @@ export default function PostCreateModal({
};
const canSubmit = mode === "live" ||
(title.trim() || body.trim() || images.length > 0 || linkUrl.trim()) && !isSaving && !uploading;
(mode === "news" && (title.trim() || body.trim()) && !isSaving && !uploading) ||
(category === "Apps" && selectedApp && title.trim()) ||
((title.trim() || body.trim() || images.length > 0 || linkUrl.trim()) && !isSaving && !uploading);
if (!isOpen) return null;
return (
<AnimatePresence>
<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 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
{/* Backdrop - only covers bottom half, allows map interaction above */}
{/* Backdrop */}
<motion.div
className="absolute bottom-0 left-0 right-0 h-[65vh] bg-gradient-to-b from-transparent to-black/50 pointer-events-none"
/>
{/* Invisible close trigger at top */}
<div
className="absolute top-0 left-0 right-0 h-16 cursor-pointer"
className="absolute inset-0 bg-black/80 backdrop-blur-md"
onClick={onClose}
/>
{/* Modal - positioned at bottom, shorter to show map with crosshair */}
{/* Modal */}
<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
rounded-t-3xl border border-white/10 border-b-0
shadow-2xl shadow-black/50 overflow-hidden
max-h-[65vh]"
rounded-t-3xl sm:rounded-3xl border border-white/10
shadow-2xl shadow-black/50 overflow-hidden"
initial={{ y: "100%", opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: "100%", opacity: 0 }}
@ -217,7 +283,7 @@ export default function PostCreateModal({
onClick={handleSubmit}
disabled={!canSubmit}
>
{isSaving ? (
{isSaving || uploading ? (
<i className="fa-solid fa-spinner fa-spin" />
) : mode === "live" ? (
<>
@ -230,28 +296,31 @@ export default function PostCreateModal({
</motion.button>
</div>
{/* Content modes */}
<div className="flex gap-1 px-4 py-3 border-b border-white/5 overflow-x-auto">
{CONTENT_MODES.map((m) => (
<motion.button
key={m.id}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-semibold whitespace-nowrap
transition-all ${mode === m.id
? `bg-gradient-to-r ${m.color} text-white shadow-lg`
: "bg-surface-700/30 text-surface-400 hover:text-white hover:bg-surface-700/50"}`}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={() => setMode(m.id)}
>
<i className={`fa-solid ${m.icon}`} />
{m.label}
</motion.button>
))}
</div>
{/* 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">
{CONTENT_MODES.map((m) => (
<motion.button
key={m.id}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-semibold whitespace-nowrap
transition-all ${mode === m.id
? `bg-gradient-to-r ${m.color} text-white shadow-lg`
: "bg-surface-700/30 text-surface-400 hover:text-white hover:bg-surface-700/50"}`}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={() => setMode(m.id)}
>
<i className={`fa-solid ${m.icon}`} />
{m.label}
</motion.button>
))}
</div>
)}
{/* Scrollable content */}
<div className="p-4 max-h-[45vh] overflow-y-auto space-y-4">
{/* Category row */}
<div className="p-4 max-h-[60vh] overflow-y-auto space-y-4">
{/* Category row (hidden for news mode) */}
{mode !== "news" && (
<div className="flex gap-2 overflow-x-auto pb-1">
{CATEGORIES.map((cat) => (
<motion.button
@ -264,6 +333,7 @@ export default function PostCreateModal({
onClick={() => {
setCategory(cat.id);
setSubCategory(SUB_CATEGORIES[cat.id]?.[0] || "");
setSelectedApp(null);
}}
>
<i className={`fa-solid ${cat.icon}`} />
@ -271,116 +341,282 @@ export default function PostCreateModal({
</motion.button>
))}
</div>
)}
{/* Sub-category */}
<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>
{/* Apps category - show subscribed apps */}
{category === "Apps" ? (
<div className="space-y-4">
{appsLoading ? (
<div className="flex items-center justify-center py-8">
<i className="fa-solid fa-spinner fa-spin text-2xl text-pink-400" />
</div>
) : subscribedApps.length === 0 ? (
<div className="text-center py-6">
<i className="fa-solid fa-puzzle-piece text-4xl text-surface-600 mb-3" />
<p className="text-surface-400 mb-1">No apps available</p>
<p className="text-surface-500 text-sm">
Subscribe to apps in the App Store to post
</p>
</div>
) : (
<>
{/* App selector */}
<div className="grid grid-cols-3 gap-2">
{subscribedApps.map((app) => (
<motion.button
key={app.app_id}
className={`flex flex-col items-center gap-2 p-3 rounded-xl
border transition-all ${selectedApp?.app_id === app.app_id
? "bg-pink-500/20 border-pink-500/50"
: "bg-surface-700/30 border-white/5 hover:border-pink-500/30"}`}
whileTap={{ scale: 0.98 }}
onClick={() => setSelectedApp(app)}
>
<div
className="w-10 h-10 rounded-lg flex items-center justify-center"
style={{
backgroundColor: (app.assets?.color || app.color || "#EC4899") + "20",
}}
>
<i
className={`fa-solid ${app.assets?.icon || app.icon || "fa-puzzle-piece"}`}
style={{ color: app.assets?.color || app.color || "#EC4899" }}
/>
</div>
<p className="text-xs font-medium text-white text-center line-clamp-1">
{app.app_name}
</p>
</motion.button>
))}
</div>
{/* Link input (for link mode) */}
{mode === "link" && (
<div className="space-y-2">
<div className="relative">
<i className="fa-solid fa-link absolute left-3 top-1/2 -translate-y-1/2 text-surface-500 text-sm" />
{/* App post form */}
{selectedApp && (
<div className="space-y-3 pt-2">
<input
type="text"
placeholder="Title..."
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full px-4 py-3 rounded-xl
bg-surface-700/50 border border-white/10
text-white text-base font-medium
placeholder:text-surface-500
focus:outline-none focus:border-pink-500/50"
/>
<textarea
ref={textareaRef}
placeholder="Description (optional)..."
value={body}
onChange={(e) => setBody(e.target.value)}
rows={2}
className="w-full px-4 py-3 rounded-xl
bg-surface-700/50 border border-white/10
text-surface-200 text-sm leading-relaxed
placeholder:text-surface-500
focus:outline-none focus:border-pink-500/50
resize-none"
/>
</div>
)}
</>
)}
</div>
) : (
<>
{/* Sub-category (for non-Apps/non-News modes) */}
{mode !== "news" && (
<div className="flex gap-1.5 overflow-x-auto pb-1">
{SUB_CATEGORIES[category]?.map((sub) => (
<button
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) */}
{mode === "link" && (
<div className="space-y-2">
<div className="relative">
<i className="fa-solid fa-link absolute left-3 top-1/2 -translate-y-1/2 text-surface-500 text-sm" />
<input
type="url"
placeholder="Paste a link..."
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
className="w-full pl-10 pr-4 py-3 rounded-xl
bg-surface-700/50 border border-white/10
text-surface-100 text-sm
placeholder:text-surface-500
focus:outline-none focus:border-cyan-500/50"
/>
{linkLoading && (
<i className="fa-solid fa-spinner fa-spin absolute right-3 top-1/2 -translate-y-1/2 text-surface-400" />
)}
</div>
{/* Link preview card */}
{linkPreview && (
<motion.div
className="rounded-xl bg-surface-700/30 border border-white/5 overflow-hidden"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
>
{linkPreview.image && (
<img src={linkPreview.image} alt="" className="w-full h-32 object-cover" />
)}
<div className="p-3">
<p className="text-xs text-surface-500 mb-1">{linkPreview.site_name || new URL(linkUrl).hostname}</p>
<p className="text-sm font-medium text-surface-200 line-clamp-2">{linkPreview.title}</p>
</div>
</motion.div>
)}
</div>
)}
{/* Live mode info */}
{mode === "live" && (
<motion.div
className="p-4 rounded-xl bg-gradient-to-br from-red-500/10 to-rose-500/10 border border-red-500/20"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
<div className="flex items-center gap-3 mb-3">
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-red-500 to-rose-500 flex items-center justify-center">
<i className="fa-solid fa-broadcast-tower text-white text-xl" />
</div>
<div>
<p className="text-white font-bold">Start a Live Stream</p>
<p className="text-surface-400 text-sm">Broadcast to your followers in real-time</p>
</div>
</div>
<p className="text-surface-400 text-xs">
Your location will be visible to viewers. The stream will appear on the map.
</p>
</motion.div>
)}
{/* News mode - text post with optional image */}
{mode === "news" && (
<motion.div
className="space-y-3"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
<div className="p-3 rounded-xl bg-gradient-to-br from-amber-500/10 to-orange-500/10 border border-amber-500/20">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-amber-500 to-orange-500 flex items-center justify-center">
<i className="fa-solid fa-newspaper text-white text-lg" />
</div>
<div>
<p className="text-white font-bold text-sm">Post News</p>
<p className="text-surface-400 text-xs">Share news with text and image</p>
</div>
</div>
</div>
<input
type="text"
placeholder="News title..."
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full px-4 py-3 rounded-xl
bg-surface-700/50 border border-white/10
text-white text-base font-medium
placeholder:text-surface-500
focus:outline-none focus:border-amber-500/50"
/>
<textarea
ref={textareaRef}
placeholder="Write your news content..."
value={body}
onChange={(e) => setBody(e.target.value)}
rows={3}
className="w-full px-4 py-3 rounded-xl
bg-surface-700/50 border border-white/10
text-surface-200 text-sm leading-relaxed
placeholder:text-surface-500
focus:outline-none focus:border-amber-500/50
resize-none"
/>
{/* Image gallery for news */}
{images.length > 0 && (
<div className="grid grid-cols-3 gap-2">
{images.map((url, idx) => (
<div key={idx} className="relative aspect-square rounded-xl overflow-hidden group">
<img src={url} alt="" className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity" />
<motion.button
className="absolute top-2 right-2 w-6 h-6 rounded-full bg-red-500/80
flex items-center justify-center text-white text-xs
opacity-0 group-hover:opacity-100 transition-opacity"
whileTap={{ scale: 0.9 }}
onClick={() => removeImage(idx)}
>
<i className="fa-solid fa-times" />
</motion.button>
</div>
))}
</div>
)}
{/* Image upload button */}
<motion.button
className="flex items-center gap-2 px-3 py-2 rounded-xl
bg-surface-700/50 border border-white/10
text-surface-300 text-sm font-medium
hover:text-white hover:bg-surface-600/50 transition-all"
whileTap={{ scale: 0.95 }}
onClick={() => fileInputRef.current?.click()}
disabled={uploading || images.length >= 5}
>
<i className="fa-solid fa-image text-amber-400" />
Add Image {images.length > 0 && `(${images.length}/5)`}
</motion.button>
</motion.div>
)}
{/* Title input (hidden for news mode - has its own) */}
{mode !== "news" && (
<input
type="url"
placeholder="Paste a link..."
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
className="w-full pl-10 pr-4 py-3 rounded-xl
type="text"
placeholder={mode === "live" ? "Stream title..." : "What's happening?"}
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full px-4 py-3 rounded-xl
bg-surface-700/50 border border-white/10
text-surface-100 text-sm
text-white text-base font-medium
placeholder:text-surface-500
focus:outline-none focus:border-cyan-500/50"
/>
{linkLoading && (
<i className="fa-solid fa-spinner fa-spin absolute right-3 top-1/2 -translate-y-1/2 text-surface-400" />
)}
</div>
{/* Link preview card */}
{linkPreview && (
<motion.div
className="rounded-xl bg-surface-700/30 border border-white/5 overflow-hidden"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
>
{linkPreview.image && (
<img src={linkPreview.image} alt="" className="w-full h-32 object-cover" />
)}
<div className="p-3">
<p className="text-xs text-surface-500 mb-1">{linkPreview.site_name || new URL(linkUrl).hostname}</p>
<p className="text-sm font-medium text-surface-200 line-clamp-2">{linkPreview.title}</p>
</div>
</motion.div>
)}
</div>
{/* Body textarea (hidden for live and news modes) */}
{mode !== "live" && mode !== "news" && (
<textarea
ref={textareaRef}
placeholder="Add more details..."
value={body}
onChange={(e) => setBody(e.target.value)}
rows={2}
className="w-full px-4 py-3 rounded-xl
bg-surface-700/50 border border-white/10
text-surface-200 text-sm leading-relaxed
placeholder:text-surface-500
focus:outline-none focus:border-cyan-500/50
resize-none"
/>
)}
</>
)}
{/* Live mode info */}
{mode === "live" && (
<motion.div
className="p-4 rounded-xl bg-gradient-to-br from-red-500/10 to-rose-500/10 border border-red-500/20"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
<div className="flex items-center gap-3 mb-3">
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-red-500 to-rose-500 flex items-center justify-center">
<i className="fa-solid fa-broadcast-tower text-white text-xl" />
</div>
<div>
<p className="text-white font-bold">Start a Live Stream</p>
<p className="text-surface-400 text-sm">Broadcast to your followers in real-time</p>
</div>
</div>
<p className="text-surface-400 text-xs">
Your location will be visible to viewers. The stream will appear on the map.
</p>
</motion.div>
)}
{/* Title input */}
<input
type="text"
placeholder={mode === "live" ? "Stream title..." : "What's happening?"}
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full px-4 py-3 rounded-xl
bg-surface-700/50 border border-white/10
text-white text-base font-medium
placeholder:text-surface-500
focus:outline-none focus:border-cyan-500/50"
/>
{/* Body textarea (hidden for live mode) */}
{mode !== "live" && (
<textarea
ref={textareaRef}
placeholder="Add more details..."
value={body}
onChange={(e) => setBody(e.target.value)}
rows={2}
className="w-full px-4 py-3 rounded-xl
bg-surface-700/50 border border-white/10
text-surface-200 text-sm leading-relaxed
placeholder:text-surface-500
focus:outline-none focus:border-cyan-500/50
resize-none"
/>
)}
{/* Image gallery (for photo/text modes) */}
{(mode === "photo" || mode === "text") && images.length > 0 && (
{/* Image gallery (for photo/text/apps modes) */}
{(mode === "photo" || mode === "text" || category === "Apps") && 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">
@ -408,19 +644,11 @@ export default function PostCreateModal({
</div>
)}
{/* Upload Error */}
{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 && (
{/* Error */}
{(saveError || uploadError) && (
<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" />
{saveError}
{saveError || uploadError}
</div>
)}
@ -428,18 +656,18 @@ export default function PostCreateModal({
{coords ? (
<div className="flex items-center gap-2 text-surface-400 text-sm">
<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>
) : (
) : category === "Apps" && selectedApp && (
<div className="flex items-center gap-2 text-orange-400 text-sm">
<i className="fa-solid fa-location-dot" />
<span>No location - post may fail</span>
<span>Move map to set location</span>
</div>
)}
</div>
{/* 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">
<motion.button
className="flex items-center gap-2 px-3 py-2 rounded-xl
@ -448,24 +676,26 @@ export default function PostCreateModal({
hover:text-white hover:bg-surface-600/50 transition-all"
whileTap={{ scale: 0.95 }}
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
disabled={uploading || images.length >= 5}
>
<i className="fa-solid fa-image text-emerald-400" />
Photo
Photo {images.length > 0 && `(${images.length}/5)`}
</motion.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={() => cameraInputRef.current?.click()}
disabled={uploading}
>
<i className="fa-solid fa-camera text-blue-400" />
Camera
</motion.button>
{category !== "Apps" && (
<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={() => cameraInputRef.current?.click()}
disabled={uploading}
>
<i className="fa-solid fa-camera text-blue-400" />
Camera
</motion.button>
)}
<div className="flex-1" />

View File

@ -559,7 +559,7 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe
shares: post?.share_count || 0, comments: post?.comment_count || 0,
});
const [liked, setLiked] = useState(false);
const [truth, setTruth] = useState({ score: 50, count: 0 });
const [truth, setTruth] = useState({ score: 50, count: 0, userVote: null });
const [authorAvatar, setAuthorAvatar] = useState("");
const [commenterAvatars, setCommenterAvatars] = useState({});
const [comments, setComments] = useState([]);
@ -623,12 +623,12 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe
// Initial data fetch
useEffect(() => {
if (!postId) return;
recordPostView(postId).then(res => {
if (!postId && !post?.guid) return;
recordPostView(postId, post?.guid, post?.app_id).then(res => {
if (res?.counts) setCounts(c => ({ ...c, views: res.counts.view_count || c.views, likes: res.counts.like_count || c.likes }));
});
fetchPostLikeState(postId).then(res => { if (res?.liked !== undefined) setLiked(res.liked); });
fetchPostTruth(postId).then(res => { if (res) setTruth({ score: res.truth_score ?? res.user_score ?? 50, count: res.vote_count ?? 0 }); });
fetchPostTruth(postId, post?.guid, post?.app_id).then(res => { if (res) setTruth({ score: res.truth_score ?? res.user_score ?? 50, count: res.vote_count ?? 0, userVote: res.user_vote || null }); });
}, [postId]);
// Fetch full post data from API to get image_urls (map markers don't have it)
@ -666,8 +666,8 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe
// Fetch comments
const refreshComments = useCallback(async (autoOpen = false) => {
if (!postId) return;
const items = await fetchPostComments(postId, 50);
if (!postId && !post?.guid) return;
const items = await fetchPostComments(postId, 50, post?.guid, post?.app_id);
if (Array.isArray(items)) {
setComments(items);
setCounts(c => ({ ...c, comments: items.length }));
@ -686,32 +686,45 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe
// Handlers
const handleLike = useCallback(async () => {
if (!postId) return;
if (!postId && !post?.guid) return;
const newLiked = !liked;
setLiked(newLiked);
setCounts(c => ({ ...c, likes: c.likes + (newLiked ? 1 : -1) }));
const res = await togglePostLike(postId, newLiked);
const res = await togglePostLike(postId, newLiked, post?.guid, post?.app_id);
if (res?.counts) setCounts(c => ({ ...c, likes: res.counts.like_count ?? c.likes }));
}, [postId, liked]);
}, [postId, liked, post?.guid, post?.app_id]);
const handleShare = useCallback(async () => {
if (!postId) return;
const shareUrl = `${window.location.origin}/p/${postId}`;
if (!postId && !post?.guid) return;
let shareUrl;
if (post?.app_id && post?.guid) {
// SEO-friendly URL: /news/slug-shortguid
const slug = (post?.title || "post")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 50);
const shortGuid = post.guid.slice(0, 8);
shareUrl = `${window.location.origin}/news/${slug}-${shortGuid}`;
} else {
shareUrl = `${window.location.origin}/p/${postId}`;
}
if (navigator.share) { try { await navigator.share({ title: post?.title, url: shareUrl }); } catch {} }
else { navigator.clipboard?.writeText(shareUrl); }
const res = await recordPostShare(postId);
const res = await recordPostShare(postId, post?.guid, post?.app_id);
if (res?.counts) setCounts(c => ({ ...c, shares: res.counts.share_count ?? c.shares }));
}, [postId, post?.title]);
}, [postId, post?.title, post?.guid, post?.app_id]);
const handleTruthVote = useCallback(async (vote) => {
if (!postId) return;
if (!postId && !post?.guid) return;
if (!token) {
window.dispatchEvent(new CustomEvent("sociowire:auth-required"));
return;
}
const res = await votePostTruth(postId, vote);
if (res) setTruth({ score: res.truth_score ?? res.user_score ?? truth.score, count: res.vote_count ?? truth.count });
}, [postId, truth, token]);
// User can switch their vote by clicking the opposite button
const res = await votePostTruth(postId, vote, post?.guid, post?.app_id);
if (res) setTruth({ score: res.truth_score ?? res.user_score ?? truth.score, count: res.vote_count ?? truth.count, userVote: res.user_vote || vote });
}, [postId, truth, token, post?.guid, post?.app_id]);
const handleComment = useCallback(async () => {
const body = commentInput.trim();
@ -727,7 +740,7 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe
// Retry logic - try up to 3 times with delay
let res = null;
for (let attempt = 0; attempt < 3; attempt++) {
res = await createPostComment(postId, body);
res = await createPostComment(postId, body, post?.guid, post?.app_id);
if (res?.ok) break;
if (attempt < 2) await new Promise(r => setTimeout(r, 500)); // wait before retry
}
@ -905,7 +918,7 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe
{/* Content Section */}
<div className="max-h-[70vh] overflow-y-auto">
<div className="p-4 sm:p-6">
<div className="p-4 sm:p-6 pb-8 sm:pb-10">
{/* Title */}
{post?.title && (
<h1 className="text-xl sm:text-2xl font-bold text-themed-primary leading-tight mb-4">
@ -1045,7 +1058,11 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe
{/* Mini Truth Score */}
<div className="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-themed-elevated">
<motion.button whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }} onClick={() => handleTruthVote("false")}
className="w-6 h-6 rounded-full bg-red-500/20 text-red-400 flex items-center justify-center hover:bg-red-500/30 text-xs">
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs transition-all ${
truth.userVote === "false"
? "bg-red-500 text-white ring-2 ring-red-300"
: "bg-red-500/20 text-red-400 hover:bg-red-500/30"
}`}>
<i className="fa-solid fa-thumbs-down" />
</motion.button>
<div className="flex items-center gap-1 min-w-[50px]">
@ -1058,7 +1075,11 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe
</span>
</div>
<motion.button whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }} onClick={() => handleTruthVote("true")}
className="w-6 h-6 rounded-full bg-emerald-500/20 text-emerald-400 flex items-center justify-center hover:bg-emerald-500/30 text-xs">
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs transition-all ${
truth.userVote === "true"
? "bg-emerald-500 text-white ring-2 ring-emerald-300"
: "bg-emerald-500/20 text-emerald-400 hover:bg-emerald-500/30"
}`}>
<i className="fa-solid fa-thumbs-up" />
</motion.button>
</div>
@ -1086,8 +1107,8 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe
{/* Comments Section */}
<AnimatePresence>
{showComments && (
<motion.div className="mt-4" initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }}>
<div className="p-4 rounded-2xl bg-themed-elevated border border-themed">
<motion.div className="mt-4 mb-6" initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }}>
<div className="p-4 rounded-2xl bg-themed-elevated border-2 border-themed">
<div className="flex items-center gap-2 mb-4">
<i className="fa-solid fa-comments text-accent" />
<span className="font-semibold text-themed-primary">{t("post.comment")}</span>