Add polaroid/video/camera templates and respect backend tiers

- Add polaroid, video, camera template specs
- getTemplateKeyForPost selects template based on content_type
- prioritizePosts uses backend tier values (premium/regular)
- Tier determines SIZE, content_type determines STYLE

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Sociowire Dev 2026-01-14 09:08:38 +00:00
parent e32b8a531a
commit 2a46aff450
7 changed files with 217 additions and 35 deletions

View File

@ -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 youre 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()) {

View File

@ -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,
};
}

View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -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.",

View File

@ -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.",

View File

@ -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.",