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:
parent
d8b4be20fd
commit
2c4c9a7065
|
|
@ -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,8 +347,7 @@ export default function MapView({
|
|||
try {
|
||||
setIsSaving(true);
|
||||
|
||||
const newPost = await createPost(
|
||||
{
|
||||
const payload = {
|
||||
title: draftTitle.trim(),
|
||||
snippet: draftBody.trim() || draftTitle.trim(),
|
||||
category:
|
||||
|
|
@ -292,9 +356,14 @@ export default function MapView({
|
|||
lat,
|
||||
lon: lng,
|
||||
author: authorName,
|
||||
},
|
||||
token
|
||||
);
|
||||
};
|
||||
|
||||
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">
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
Loading…
Reference in New Issue