Add image upload and category icons to map cards

- Add image upload functionality with file picker and URL input
- Display custom uploaded images or category-specific FontAwesome icons
- Add icon styles for all themes (dark, blue, light)
- Map unique icons to each category and subcategory (News, Friends, Events, Market)
- Update card templates to show 72x72px icon/image on the left
- Add image preview and validation (max 5MB, images only)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Your Name 2025-12-22 22:36:20 -05:00
parent d8b4be20fd
commit 2c4c9a7065
3 changed files with 231 additions and 32 deletions

View File

@ -76,6 +76,11 @@ export default function MapView({
CREATE_CATEGORY_MAP.News[0]
);
const [draftCoords, setDraftCoords] = useState(null);
const [draftImage, setDraftImage] = useState("");
const [draftImageFile, setDraftImageFile] = useState(null);
const [draftImagePreview, setDraftImagePreview] = useState("");
const [useCustomImage, setUseCustomImage] = useState(false);
const [uploadingImage, setUploadingImage] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState("");
@ -219,6 +224,10 @@ export default function MapView({
setIsCreating(true);
setDraftTitle("");
setDraftBody("");
setDraftImage("");
setDraftImageFile(null);
setDraftImagePreview("");
setUseCustomImage(false);
const main = mainFilter === "All" ? "News" : mainFilter;
setDraftCategory(main);
const list = CREATE_CATEGORY_MAP[main] || CREATE_CATEGORY_MAP.News;
@ -234,6 +243,62 @@ export default function MapView({
setSearchQuery("");
};
const handleImageFileChange = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
// Vérifier le type de fichier
if (!file.type.startsWith('image/')) {
setSaveError("Please select an image file");
return;
}
// Vérifier la taille (max 5MB)
if (file.size > 5 * 1024 * 1024) {
setSaveError("Image must be less than 5MB");
return;
}
setDraftImageFile(file);
// Créer preview
const reader = new FileReader();
reader.onloadend = () => {
setDraftImagePreview(reader.result);
};
reader.readAsDataURL(file);
// Upload immédiatement
setUploadingImage(true);
setSaveError("");
try {
const formData = new FormData();
formData.append('image', file);
const res = await fetch('/api/upload-image', {
method: 'POST',
body: formData,
credentials: 'include',
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
});
if (!res.ok) {
throw new Error('Upload failed');
}
const data = await res.json();
setDraftImage(data.url || data.image_url);
setUploadingImage(false);
} catch (err) {
console.error('Image upload error:', err);
setSaveError('Failed to upload image');
setUploadingImage(false);
setDraftImageFile(null);
setDraftImagePreview("");
}
};
const handleSubmitPost = async () => {
if (isSaving) return;
setSaveError("");
@ -282,19 +347,23 @@ export default function MapView({
try {
setIsSaving(true);
const newPost = await createPost(
{
title: draftTitle.trim(),
snippet: draftBody.trim() || draftTitle.trim(),
category:
draftCategory === "Events" ? "EVENT" : draftCategory.toUpperCase(),
sub_category: draftSubCategory || "ALL",
lat,
lon: lng,
author: authorName,
},
token
);
const payload = {
title: draftTitle.trim(),
snippet: draftBody.trim() || draftTitle.trim(),
category:
draftCategory === "Events" ? "EVENT" : draftCategory.toUpperCase(),
sub_category: draftSubCategory || "ALL",
lat,
lon: lng,
author: authorName,
};
if (useCustomImage && draftImage.trim()) {
payload.image_small = draftImage.trim();
payload.image_large = draftImage.trim();
}
const newPost = await createPost(payload, token);
if (newPost && newPost.id) handleIncomingPost(newPost);
@ -510,6 +579,68 @@ export default function MapView({
onChange={(e) => setDraftBody(e.target.value)}
/>
<div className="create-image-option">
<label style={{ display: "flex", alignItems: "center", gap: "8px", cursor: "pointer" }}>
<input
type="checkbox"
checked={useCustomImage}
onChange={(e) => setUseCustomImage(e.target.checked)}
/>
<span>Use custom image instead of category icon</span>
</label>
</div>
{useCustomImage && (
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
<div style={{ display: "flex", gap: "8px" }}>
<input
type="file"
accept="image/*"
onChange={handleImageFileChange}
disabled={uploadingImage}
style={{ flex: 1 }}
/>
{uploadingImage && <span>Uploading...</span>}
</div>
{draftImagePreview && (
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<img
src={draftImagePreview}
alt="Preview"
style={{ width: "60px", height: "60px", objectFit: "cover", borderRadius: "8px" }}
/>
<button
type="button"
onClick={() => {
setDraftImageFile(null);
setDraftImagePreview("");
setDraftImage("");
}}
style={{ padding: "4px 8px", fontSize: "12px" }}
>
Remove
</button>
</div>
)}
<div style={{ fontSize: "12px", color: "#888" }}>
Or paste an image URL:
</div>
<input
className="create-input"
placeholder="https://example.com/image.jpg"
value={draftImageFile ? "" : draftImage}
onChange={(e) => {
setDraftImage(e.target.value);
setDraftImageFile(null);
setDraftImagePreview("");
}}
disabled={!!draftImageFile}
/>
</div>
)}
{saveError && <div className="create-error">{saveError}</div>}
<div className="create-actions">

View File

@ -15,9 +15,11 @@ export const TEMPLATE_SPECS = {
background: { type: "gradient", value: "newsBlue" },
radius: 18,
layers: [
{ id: "badge", type: "chip", x: 12, y: 10, text: "NEWS", style: "chip.news" },
{ id: "title", type: "text", x: 12, y: 34, w: 216, h: 42, bind: "data.headline", style: "text.title", maxLines: 2 },
{ id: "meta", type: "text", x: 12, y: 76, w: 216, h: 16, bind: "data.source", style: "text.meta", maxLines: 1 },
{ id: "icon", type: "icon", x: 12, y: 12, w: 72, h: 72, text: "\uf1ea", style: "text.icon", bind: "data.categoryIcon" },
{ id: "imageOverlay", type: "image", x: 12, y: 12, w: 72, h: 72, bind: "data.image", radius: 12 },
{ id: "badge", type: "chip", x: 94, y: 10, text: "NEWS", style: "chip.news" },
{ id: "title", type: "text", x: 94, y: 34, w: 134, h: 42, bind: "data.headline", style: "text.title", maxLines: 2 },
{ id: "meta", type: "text", x: 94, y: 76, w: 134, h: 16, bind: "data.source", style: "text.meta", maxLines: 1 },
],
},
full_spec: {
@ -38,9 +40,11 @@ export const TEMPLATE_SPECS = {
background: { type: "gradient", value: "breakingRed" },
radius: 18,
layers: [
{ id: "badge", type: "chip", x: 12, y: 10, text: "BREAKING", style: "chip.danger" },
{ id: "title", type: "text", x: 12, y: 34, w: 216, h: 42, bind: "data.headline", style: "text.title", maxLines: 2 },
{ id: "meta", type: "text", x: 12, y: 76, w: 216, h: 16, bind: "data.source", style: "text.meta", maxLines: 1 },
{ id: "icon", type: "icon", x: 12, y: 12, w: 72, h: 72, text: "\uf0e7", style: "text.icon", bind: "data.categoryIcon" },
{ id: "imageOverlay", type: "image", x: 12, y: 12, w: 72, h: 72, bind: "data.image", radius: 12 },
{ id: "badge", type: "chip", x: 94, y: 10, text: "BREAKING", style: "chip.danger" },
{ id: "title", type: "text", x: 94, y: 34, w: 134, h: 42, bind: "data.headline", style: "text.title", maxLines: 2 },
{ id: "meta", type: "text", x: 94, y: 76, w: 134, h: 16, bind: "data.source", style: "text.meta", maxLines: 1 },
],
},
full_spec: {
@ -61,9 +65,11 @@ export const TEMPLATE_SPECS = {
background: { type: "gradient", value: "financeTeal" },
radius: 18,
layers: [
{ id: "badge", type: "chip", x: 12, y: 10, text: "FINANCE", style: "chip.news" },
{ id: "title", type: "text", x: 12, y: 34, w: 216, h: 42, bind: "data.headline", style: "text.title", maxLines: 2 },
{ id: "meta", type: "text", x: 12, y: 76, w: 216, h: 16, bind: "data.source", style: "text.meta", maxLines: 1 },
{ id: "icon", type: "icon", x: 12, y: 12, w: 72, h: 72, text: "\uf201", style: "text.icon", bind: "data.categoryIcon" },
{ id: "imageOverlay", type: "image", x: 12, y: 12, w: 72, h: 72, bind: "data.image", radius: 12 },
{ id: "badge", type: "chip", x: 94, y: 10, text: "FINANCE", style: "chip.news" },
{ id: "title", type: "text", x: 94, y: 34, w: 134, h: 42, bind: "data.headline", style: "text.title", maxLines: 2 },
{ id: "meta", type: "text", x: 94, y: 76, w: 134, h: 16, bind: "data.source", style: "text.meta", maxLines: 1 },
],
},
full_spec: {
@ -84,9 +90,11 @@ export const TEMPLATE_SPECS = {
background: { type: "gradient", value: "marketGold" },
radius: 18,
layers: [
{ id: "badge", type: "chip", x: 12, y: 10, text: "MARKET", style: "chip.news" },
{ id: "title", type: "text", x: 12, y: 34, w: 216, h: 42, bind: "data.headline", style: "text.title", maxLines: 2 },
{ id: "meta", type: "text", x: 12, y: 76, w: 216, h: 16, bind: "data.source", style: "text.meta", maxLines: 1 },
{ id: "icon", type: "icon", x: 12, y: 12, w: 72, h: 72, text: "\uf3d1", style: "text.icon", bind: "data.categoryIcon" },
{ id: "imageOverlay", type: "image", x: 12, y: 12, w: 72, h: 72, bind: "data.image", radius: 12 },
{ id: "badge", type: "chip", x: 94, y: 10, text: "MARKET", style: "chip.news" },
{ id: "title", type: "text", x: 94, y: 34, w: 134, h: 42, bind: "data.headline", style: "text.title", maxLines: 2 },
{ id: "meta", type: "text", x: 94, y: 76, w: 134, h: 16, bind: "data.source", style: "text.meta", maxLines: 1 },
],
},
full_spec: {
@ -107,9 +115,11 @@ export const TEMPLATE_SPECS = {
background: { type: "gradient", value: "friendsGreen" },
radius: 18,
layers: [
{ id: "badge", type: "chip", x: 12, y: 10, text: "FRIENDS", style: "chip.news" },
{ id: "title", type: "text", x: 12, y: 34, w: 216, h: 42, bind: "data.headline", style: "text.title", maxLines: 2 },
{ id: "meta", type: "text", x: 12, y: 76, w: 216, h: 16, bind: "data.source", style: "text.meta", maxLines: 1 },
{ id: "icon", type: "icon", x: 12, y: 12, w: 72, h: 72, text: "\uf500", style: "text.icon", bind: "data.categoryIcon" },
{ id: "imageOverlay", type: "image", x: 12, y: 12, w: 72, h: 72, bind: "data.image", radius: 12 },
{ id: "badge", type: "chip", x: 94, y: 10, text: "FRIENDS", style: "chip.news" },
{ id: "title", type: "text", x: 94, y: 34, w: 134, h: 42, bind: "data.headline", style: "text.title", maxLines: 2 },
{ id: "meta", type: "text", x: 94, y: 76, w: 134, h: 16, bind: "data.source", style: "text.meta", maxLines: 1 },
],
},
full_spec: {
@ -130,9 +140,11 @@ export const TEMPLATE_SPECS = {
background: { type: "gradient", value: "eventsPurple" },
radius: 18,
layers: [
{ id: "badge", type: "chip", x: 12, y: 10, text: "EVENTS", style: "chip.news" },
{ id: "title", type: "text", x: 12, y: 34, w: 216, h: 42, bind: "data.headline", style: "text.title", maxLines: 2 },
{ id: "meta", type: "text", x: 12, y: 76, w: 216, h: 16, bind: "data.source", style: "text.meta", maxLines: 1 },
{ id: "icon", type: "icon", x: 12, y: 12, w: 72, h: 72, text: "\uf073", style: "text.icon", bind: "data.categoryIcon" },
{ id: "imageOverlay", type: "image", x: 12, y: 12, w: 72, h: 72, bind: "data.image", radius: 12 },
{ id: "badge", type: "chip", x: 94, y: 10, text: "EVENTS", style: "chip.news" },
{ id: "title", type: "text", x: 94, y: 34, w: 134, h: 42, bind: "data.headline", style: "text.title", maxLines: 2 },
{ id: "meta", type: "text", x: 94, y: 76, w: 134, h: 16, bind: "data.source", style: "text.meta", maxLines: 1 },
],
},
full_spec: {
@ -195,6 +207,58 @@ export function getTemplateSpecForPost(post, variant /* "mini"|"full" */) {
return variant === "full" ? t.full_spec : t.mini_spec;
}
function getCategoryIcon(post) {
const category = (post?.category || "").toUpperCase();
const subCategory = (post?.sub_category || "").toLowerCase();
// Mapping des icônes par catégorie et sous-catégorie
const iconMap = {
NEWS: {
default: "\uf1ea", // fa-newspaper
world: "\uf57d", // fa-globe-americas
politics: "\uf0a3", // fa-landmark-flag
tech: "\uf2db", // fa-microchip
finance: "\uf201", // fa-chart-line
local: "\uf3c5", // fa-map-marker-alt
},
FRIENDS: {
default: "\uf500", // fa-user-group
nearby: "\uf3c5", // fa-location-crosshairs
chats: "\uf086", // fa-comments
groups: "\uf0c0", // fa-users
stories: "\uf02d", // fa-book-open
favs: "\uf004", // fa-heart
},
EVENT: {
default: "\uf073", // fa-calendar
today: "\uf783", // fa-calendar-day
weekend: "\uf784", // fa-calendar-week
music: "\uf001", // fa-music
sports: "\uf1e3", // fa-football
local: "\uf3c5", // fa-map-marker-alt
},
EVENTS: {
default: "\uf073", // fa-calendar
today: "\uf783", // fa-calendar-day
weekend: "\uf784", // fa-calendar-week
music: "\uf001", // fa-music
sports: "\uf1e3", // fa-football
local: "\uf3c5", // fa-map-marker-alt
},
MARKET: {
default: "\uf3d1", // fa-store
actu: "\uf1ea", // fa-newspaper
tech: "\uf2db", // fa-microchip
finan: "\uf555", // fa-money-bill-trend-up
sport: "\uf434", // fa-basketball
deals: "\uf02b", // fa-tags
},
};
const categoryIcons = iconMap[category] || iconMap.NEWS;
return categoryIcons[subCategory] || categoryIcons.default;
}
export function adaptPostToTemplateData(post) {
const headline = post?.title || "Untitled";
const author = post?.author ? `by ${post.author}` : "";
@ -203,5 +267,6 @@ export function adaptPostToTemplateData(post) {
const summary = post?.snippet || post?.body || "";
const url = post?.url || "";
const image = post?.image_large || post?.image_small || post?.image || post?.media_url || "";
return { headline, source, summary, url, image };
const categoryIcon = getCategoryIcon(post);
return { headline, source, summary, url, image, categoryIcon };
}

View File

@ -22,6 +22,7 @@ export const cardTokens = {
meta: { color: "#A9B3C7", fontSize: 12, fontWeight: 600 },
h1: { color: "#E9EEF8", fontSize: 22, fontWeight: 900, lineHeight: "26px" },
body: { color: "#D7DEED", fontSize: 14, fontWeight: 600, lineHeight: "18px" },
icon: { color: "rgba(255,255,255,0.3)", fontSize: 48, fontFamily: "Font Awesome 6 Free", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 900 },
},
chip: {
news: { background: "rgba(78,161,255,0.18)", color: "#CFE6FF", fontWeight: 800, fontSize: 12, borderRadius: 999 },
@ -55,6 +56,7 @@ export const cardTokens = {
meta: { color: "#B5C7E6", fontSize: 12, fontWeight: 700 },
h1: { color: "#F0F7FF", fontSize: 22, fontWeight: 900, lineHeight: "26px" },
body: { color: "#D7E9FF", fontSize: 14, fontWeight: 700, lineHeight: "18px" },
icon: { color: "rgba(255,255,255,0.3)", fontSize: 48, fontFamily: "Font Awesome 6 Free", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 900 },
},
chip: {
news: { background: "rgba(56,189,248,0.18)", color: "#D7F3FF", fontWeight: 900, fontSize: 12, borderRadius: 999 },
@ -88,6 +90,7 @@ export const cardTokens = {
meta: { color: "#5B677A", fontSize: 12, fontWeight: 700 },
h1: { color: "#0B1220", fontSize: 22, fontWeight: 900, lineHeight: "26px" },
body: { color: "#1A2740", fontSize: 14, fontWeight: 600, lineHeight: "18px" },
icon: { color: "rgba(0,0,0,0.15)", fontSize: 48, fontFamily: "Font Awesome 6 Free", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 900 },
},
chip: {
news: { background: "rgba(30,107,255,0.12)", color: "#0B1220", fontWeight: 900, fontSize: 12, borderRadius: 999 },