diff --git a/src/components/Map/MapView.jsx b/src/components/Map/MapView.jsx index d176539..f784933 100644 --- a/src/components/Map/MapView.jsx +++ b/src/components/Map/MapView.jsx @@ -66,6 +66,16 @@ const CONTENT_CREATOR_MODES = [ "Use subcategory filters to mark the tone: News, Event, Market, etc.", ], }, + { + id: "evenements", + label: "Event", + icon: "fa-solid fa-calendar-days", + hint: "Announce a local or regional event.", + detail: [ + "Share details about an event: concert, festival, rally, etc.", + "Place the marker at the exact location of the event.", + ], + }, { id: "other", label: "Other", @@ -98,11 +108,17 @@ const MODE_FLOW_CONFIG = { bodyPlaceholder: "Describe the scene in a few words.", }, text: { - hero: "Type a short update and pin the area you’re reporting from.", + hero: "Type a short update and pin the area you're reporting from.", placeholder: "Headline or short note", hint: "Readers should understand the gist before opening the full card.", bodyPlaceholder: "Provide more context, opinions, or call to actions.", }, + evenements: { + hero: "Announce an event and place the marker at the exact location.", + placeholder: "Event name", + hint: "Include the date, time, and a brief description.", + bodyPlaceholder: "Event details: schedule, tickets, program...", + }, other: { hero: "Combine media, notes, or references and pin wherever it matters.", placeholder: "Describe your update", @@ -963,6 +979,22 @@ export default function MapView({ try { const msg = JSON.parse(ev.data); if (msg.type === "new_post" && msg.post) { + // Filter posts by current viewport - only accept posts within exact bounds + const map = mapRef.current; + if (map) { + const postLat = Number(msg.post.lat) || 0; + const postLon = Number(msg.post.lon) || 0; + if (postLat !== 0 && postLon !== 0) { + const bounds = map.getBounds(); + const sw = bounds.getSouthWest(); + const ne = bounds.getNorthEast(); + const inBounds = postLat >= sw.lat && postLat <= ne.lat && + postLon >= sw.lng && postLon <= ne.lng; + if (!inBounds) { + return; // Ignore posts outside viewport + } + } + } handleIncomingPost(msg.post); return; } @@ -1772,6 +1804,15 @@ export default function MapView({ try { setIsSaving(true); + // Map contentMode to content_type (photo -> picture for backend) + const contentTypeMap = { + live: "live", + link: "link", + photo: "picture", + text: "text", + evenements: "evenements", + other: "other", + }; const payload = { title: draftTitle.trim(), snippet: draftBody.trim() || draftTitle.trim(), @@ -1789,6 +1830,7 @@ export default function MapView({ .filter(Boolean), // Mark as user-created post so enrichment preserves the position source: "sociowire", + content_type: contentTypeMap[contentMode] || "other", }; if (draftUrl.trim()) { diff --git a/src/components/Map/postPriority.js b/src/components/Map/postPriority.js index 2fc8e18..ff99e3f 100644 --- a/src/components/Map/postPriority.js +++ b/src/components/Map/postPriority.js @@ -113,82 +113,111 @@ export function clusterPosts(posts, clusterRadius = 0.001) { /** * Décide quels posts afficher comme cartes complètes vs marqueurs simples + * Utilise le tier du backend (premium/regular) si disponible + * Le template (polaroid/video/camera/news) est décidé par content_type dans templateSpecs * Retourne { fullCardPosts: [], simpleMarkerPosts: [] } */ export function prioritizePosts(posts, viewCenter, options = {}) { const { - maxFullCards = 15, // Max de cartes complètes à afficher + maxFullCards = 15, // Max de cartes complètes à afficher (fallback) clusterRadius = 0.001, // Rayon de clustering (~100m) - minScoreForCard = 40, // Score minimum pour carte complète + minScoreForCard = 40, // Score minimum pour carte complète (fallback) } = options; - // 1. Calculer les scores + const fullCardPosts = []; + const simpleMarkerPosts = []; + + // First pass: use backend tier if available + let hasTiers = false; + for (const post of posts) { + const tier = (post?.tier || '').toLowerCase(); + + // DEBUG: Log tier values + if (posts.indexOf(post) < 5) { + console.log('[prioritizePosts] post:', post.id, 'tier:', post.tier, 'content_type:', post.content_type); + } + + if (tier === 'premium') { + fullCardPosts.push(post); + hasTiers = true; + } else if (tier === 'regular') { + simpleMarkerPosts.push(post); + hasTiers = true; + } else { + // No tier - will be processed in fallback + simpleMarkerPosts.push(post); + } + } + + // If backend provided tiers, use them directly + if (hasTiers && fullCardPosts.length > 0) { + return { + fullCardPosts, + simpleMarkerPosts, + clusters: new Map(), + }; + } + + // Fallback: old scoring system if no backend tiers + simpleMarkerPosts.length = 0; // Clear for fallback processing + const postsWithScores = posts.map(post => ({ post, priority: calculatePostPriority(post, viewCenter, posts), })); - // 2. Trier par priorité descendante postsWithScores.sort((a, b) => b.priority - a.priority); - // 3. Détecter les clusters const clusters = clusterPosts(posts, clusterRadius); - - // 4. Sélectionner les posts pour cartes complètes - const fullCardPosts = []; - const simpleMarkerPosts = []; + const finalFull = []; + const finalSimple = []; const clusterRepresentatives = new Set(); - for (const [clusterId, clusterPosts] of clusters.entries()) { - if (clusterPosts.length === 1) { - // Pas de cluster, décision simple basée sur le score - const item = postsWithScores.find(p => p.post.id === clusterPosts[0].id); - if (item && item.priority >= minScoreForCard && fullCardPosts.length < maxFullCards) { - fullCardPosts.push(item.post); + for (const [clusterId, clusterPostsList] of clusters.entries()) { + if (clusterPostsList.length === 1) { + const item = postsWithScores.find(p => p.post.id === clusterPostsList[0].id); + if (item && item.priority >= minScoreForCard && finalFull.length < maxFullCards) { + finalFull.push(item.post); } else { - simpleMarkerPosts.push(clusterPosts[0]); + finalSimple.push(clusterPostsList[0]); } } else { - // Cluster: prendre le meilleur post pour carte complète, autres en marqueurs simples - const sortedCluster = clusterPosts + const sortedCluster = clusterPostsList .map(p => postsWithScores.find(ps => ps.post.id === p.id)) .filter(Boolean) .sort((a, b) => b.priority - a.priority); - // Le meilleur du cluster devient carte si score suffisant if (sortedCluster[0] && sortedCluster[0].priority >= minScoreForCard && - fullCardPosts.length < maxFullCards) { - fullCardPosts.push(sortedCluster[0].post); + finalFull.length < maxFullCards) { + finalFull.push(sortedCluster[0].post); clusterRepresentatives.add(sortedCluster[0].post.id); } - // Les autres deviennent marqueurs simples for (let i = (clusterRepresentatives.has(sortedCluster[0]?.post.id) ? 1 : 0); i < sortedCluster.length; i++) { - simpleMarkerPosts.push(sortedCluster[i].post); + finalSimple.push(sortedCluster[i].post); } } } - // 5. Si pas assez de cartes complètes, ajouter les meilleurs marqueurs simples - if (fullCardPosts.length < maxFullCards) { + if (finalFull.length < maxFullCards) { const remaining = postsWithScores .filter(p => - !fullCardPosts.find(fp => fp.id === p.post.id) && - !simpleMarkerPosts.find(sp => sp.id === p.post.id) + !finalFull.find(fp => fp.id === p.post.id) && + !finalSimple.find(sp => sp.id === p.post.id) ) - .slice(0, maxFullCards - fullCardPosts.length); + .slice(0, maxFullCards - finalFull.length); for (const item of remaining) { - fullCardPosts.push(item.post); - const idx = simpleMarkerPosts.findIndex(p => p.id === item.post.id); - if (idx >= 0) simpleMarkerPosts.splice(idx, 1); + finalFull.push(item.post); + const idx = finalSimple.findIndex(p => p.id === item.post.id); + if (idx >= 0) finalSimple.splice(idx, 1); } } return { - fullCardPosts, - simpleMarkerPosts, + fullCardPosts: finalFull, + simpleMarkerPosts: finalSimple, clusters, }; } diff --git a/src/components/Map/templateSpecs.js b/src/components/Map/templateSpecs.js index 5083cc0..44c8c3f 100644 --- a/src/components/Map/templateSpecs.js +++ b/src/components/Map/templateSpecs.js @@ -9,6 +9,73 @@ * - events */ export const TEMPLATE_SPECS = { + polaroid: { + mini_spec: { + size: { w: 180, h: 210 }, + background: { type: "solid", value: "#ffffff" }, + radius: 8, + layers: [ + { id: "image", type: "image", x: 10, y: 10, w: 160, h: 160, bind: "data.image", radius: 4 }, + { id: "author", type: "text", x: 10, y: 178, w: 160, h: 24, bind: "data.source", style: "text.polaroidCaption", maxLines: 1 }, + ], + }, + full_spec: { + size: { w: 280, h: 340 }, + background: { type: "solid", value: "#ffffff" }, + radius: 12, + layers: [ + { id: "image", type: "image", x: 15, y: 15, w: 250, h: 250, bind: "data.image", radius: 6 }, + { id: "author", type: "text", x: 15, y: 275, w: 250, h: 24, bind: "data.source", style: "text.polaroidCaption", maxLines: 1 }, + { id: "caption", type: "text", x: 15, y: 300, w: 250, h: 32, bind: "data.headline", style: "text.polaroidText", maxLines: 2 }, + ], + }, + }, + video: { + mini_spec: { + size: { w: 240, h: 135 }, + background: { type: "solid", value: "#1a1a2e" }, + radius: 12, + layers: [ + { id: "image", type: "image", x: 0, y: 0, w: 240, h: 135, bind: "data.image", radius: 12 }, + { id: "playIcon", type: "icon", x: 100, y: 47, w: 40, h: 40, text: "\uf04b", style: "text.playIcon" }, + { id: "badge", type: "chip", x: 8, y: 8, text: "VIDEO", style: "chip.video" }, + ], + }, + full_spec: { + size: { w: 360, h: 260 }, + background: { type: "solid", value: "#1a1a2e" }, + radius: 16, + layers: [ + { id: "image", type: "image", x: 0, y: 0, w: 360, h: 200, bind: "data.image", radius: 16 }, + { id: "playIcon", type: "icon", x: 160, y: 80, w: 40, h: 40, text: "\uf04b", style: "text.playIcon" }, + { id: "badge", type: "chip", x: 12, y: 12, text: "VIDEO", style: "chip.video" }, + { id: "title", type: "text", x: 12, y: 210, w: 336, h: 40, bind: "data.headline", style: "text.videoTitle", maxLines: 2 }, + ], + }, + }, + camera: { + mini_spec: { + size: { w: 200, h: 112 }, + background: { type: "solid", value: "#0f172a" }, + radius: 10, + layers: [ + { id: "image", type: "image", x: 0, y: 0, w: 200, h: 112, bind: "data.image", radius: 10 }, + { id: "liveIcon", type: "icon", x: 8, y: 8, w: 24, h: 24, text: "\uf03d", style: "text.liveIcon" }, + { id: "badge", type: "chip", x: 36, y: 8, text: "LIVE", style: "chip.live" }, + ], + }, + full_spec: { + size: { w: 320, h: 200 }, + background: { type: "solid", value: "#0f172a" }, + radius: 14, + layers: [ + { id: "image", type: "image", x: 0, y: 0, w: 320, h: 180, bind: "data.image", radius: 14 }, + { id: "liveIcon", type: "icon", x: 12, y: 12, w: 28, h: 28, text: "\uf03d", style: "text.liveIcon" }, + { id: "badge", type: "chip", x: 44, y: 12, text: "LIVE", style: "chip.live" }, + { id: "title", type: "text", x: 12, y: 185, w: 296, h: 20, bind: "data.headline", style: "text.cameraTitle", maxLines: 1 }, + ], + }, + }, cluster: { mini_spec: { size: { w: 270, h: 108 }, @@ -224,6 +291,25 @@ function normCat(post) { } export function getTemplateKeyForPost(post) { + // Content type determines template style for special types + const ct = (post?.content_type || '').toLowerCase(); + + // Photos → polaroid style + if (ct === 'photo' || ct === 'picture' || ct === 'image') { + return 'polaroid'; + } + + // Videos → video style with play button + if (ct === 'video') { + return 'video'; + } + + // Cameras/live streams → camera style + if (ct === 'camera' || ct === 'live' || ct === 'stream') { + return 'camera'; + } + + // Default: category-based template return normCat(post); } diff --git a/src/components/Map/usePostsEngine.js b/src/components/Map/usePostsEngine.js index f483dbe..5283545 100644 --- a/src/components/Map/usePostsEngine.js +++ b/src/components/Map/usePostsEngine.js @@ -300,7 +300,7 @@ function buildClusterAreaFeatures(posts) { const MAX_POOL_POSTS = 160; const MAX_VISIBLE_POSTS = 45; -function expandBounds(bounds, padRatio = 0.18) { +function expandBounds(bounds, padRatio = 0) { if (!bounds) return null; const spanLat = Math.abs(bounds.maxLat - bounds.minLat); const spanLon = Math.abs(bounds.maxLon - bounds.minLon); @@ -720,6 +720,7 @@ export function usePostsEngine({ if (entry.type === "simple") { createSimpleMarkerForPost(entry.post, mapRef, markersRef, expandedElRef, theme); } else { + // Full cards use createMarkerForPost (template is decided by content_type) createMarkerForPost(entry.post, mapRef, markersRef, expandedElRef, theme); } } diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 9eb9c9f..ce25fef 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -136,6 +136,7 @@ "photoStory": "Photo story", "writtenUpdate": "Written update", "other": "Other", + "event": "Event", "liveHint": "Broadcast a live scene with a headline or link.", "liveDetail1": "Drop the stream URL or quick headline to pin the feed instantly.", "liveDetail2": "We'll open the location where you place the crosshair so viewers know where to tune.", @@ -148,6 +149,9 @@ "textHint": "Type a short report or microblog.", "textDetail1": "Write the key message in the title field (or add more context in the body).", "textDetail2": "Use subcategory filters to mark the tone: News, Event, Market, etc.", + "eventHint": "Announce a local or regional event.", + "eventDetail1": "Share details about an event: concert, festival, rally, etc.", + "eventDetail2": "Place the marker at the exact location of the event.", "otherHint": "Custom content that spans media types.", "otherDetail1": "Mix media, public drafts, or reference notes in one place.", "otherDetail2": "Tag a region or Island to give the update a home.", @@ -169,6 +173,10 @@ "textPlaceholder": "Headline or short note", "textFlowHint": "Readers should understand the gist before opening the full card.", "textBodyPlaceholder": "Provide more context, opinions, or call to actions.", + "eventHero": "Announce an event and place the marker at the exact location.", + "eventPlaceholder": "Event name", + "eventFlowHint": "Include the date, time, and a brief description.", + "eventBodyPlaceholder": "Event details: schedule, tickets, program...", "otherHero": "Combine media, notes, or references and pin wherever it matters.", "otherPlaceholder": "Describe your update", "otherFlowHint": "This mode is flexible - add whatever helps the reader.", diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index a6ee2e9..c3923dc 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -136,6 +136,7 @@ "photoStory": "Foto", "writtenUpdate": "Publicación escrita", "other": "Otro", + "event": "Evento", "liveHint": "Transmite una escena en vivo con un título o enlace.", "liveDetail1": "Pega la URL del stream o un título rápido para fijar el feed al instante.", "liveDetail2": "Abriremos la ubicación donde coloques el cursor para que los espectadores sepan dónde sintonizar.", @@ -148,6 +149,9 @@ "textHint": "Escribe un informe corto o microblog.", "textDetail1": "Escribe el mensaje clave en el campo de título (o añade más contexto en el cuerpo).", "textDetail2": "Usa filtros de subcategoría para marcar el tono: Noticias, Evento, Mercado, etc.", + "eventHint": "Anuncia un evento local o regional.", + "eventDetail1": "Comparte detalles de un evento: concierto, festival, manifestación, etc.", + "eventDetail2": "Coloca el marcador en la ubicación exacta del evento.", "otherHint": "Contenido personalizado que abarca tipos de medios.", "otherDetail1": "Mezcla medios, borradores públicos o notas de referencia en un solo lugar.", "otherDetail2": "Etiqueta una región o Isla para dar un hogar a la actualización.", @@ -169,6 +173,10 @@ "textPlaceholder": "Título o nota corta", "textFlowHint": "Los lectores deben entender lo esencial antes de abrir la tarjeta completa.", "textBodyPlaceholder": "Proporciona más contexto, opiniones o llamadas a la acción.", + "eventHero": "Anuncia un evento y coloca el marcador en la ubicación exacta.", + "eventPlaceholder": "Nombre del evento", + "eventFlowHint": "Incluye la fecha, hora y una breve descripción.", + "eventBodyPlaceholder": "Detalles del evento: horarios, entradas, programa...", "otherHero": "Combina medios, notas o referencias y fija donde sea importante.", "otherPlaceholder": "Describe tu actualización", "otherFlowHint": "Este modo es flexible - añade lo que ayude al lector.", diff --git a/src/locales/fr/translation.json b/src/locales/fr/translation.json index abd2894..3b648dd 100644 --- a/src/locales/fr/translation.json +++ b/src/locales/fr/translation.json @@ -136,6 +136,7 @@ "photoStory": "Photo", "writtenUpdate": "Publication écrite", "other": "Autre", + "event": "Événement", "liveHint": "Diffusez une scène en direct avec un titre ou un lien.", "liveDetail1": "Collez l'URL du stream ou un titre rapide pour épingler le flux instantanément.", "liveDetail2": "Nous ouvrirons l'emplacement où vous placez le curseur pour que les spectateurs sachent où regarder.", @@ -148,6 +149,9 @@ "textHint": "Tapez un court rapport ou microblog.", "textDetail1": "Écrivez le message clé dans le champ titre (ou ajoutez plus de contexte dans le corps).", "textDetail2": "Utilisez les filtres de sous-catégorie pour marquer le ton: Actu, Événement, Marché, etc.", + "eventHint": "Annoncez un événement local ou régional.", + "eventDetail1": "Partagez les détails d'un événement: concert, festival, manifestation, etc.", + "eventDetail2": "Placez le marqueur à l'endroit exact de l'événement.", "otherHint": "Contenu personnalisé multi-média.", "otherDetail1": "Mélangez médias, brouillons publics ou notes de référence en un seul endroit.", "otherDetail2": "Taguez une région ou une Île pour donner un foyer à la mise à jour.", @@ -169,6 +173,10 @@ "textPlaceholder": "Titre ou note courte", "textFlowHint": "Les lecteurs doivent comprendre l'essentiel avant d'ouvrir la carte complète.", "textBodyPlaceholder": "Fournissez plus de contexte, opinions ou appels à l'action.", + "eventHero": "Annoncez un événement et placez le marqueur à l'endroit exact.", + "eventPlaceholder": "Nom de l'événement", + "eventFlowHint": "Incluez la date, l'heure et une brève description.", + "eventBodyPlaceholder": "Détails de l'événement: horaires, billets, programme...", "otherHero": "Combinez médias, notes ou références et épinglez où ça compte.", "otherPlaceholder": "Décrivez votre mise à jour", "otherFlowHint": "Ce mode est flexible - ajoutez ce qui aide le lecteur.",