allow multiple images when creating posts

This commit is contained in:
Your Name 2026-01-02 01:23:34 -05:00
parent 3babb277d0
commit 97f8ce3c11
2 changed files with 249 additions and 113 deletions

View File

@ -298,6 +298,7 @@ export default function MapView({
const [draftImageLarge, setDraftImageLarge] = useState(""); const [draftImageLarge, setDraftImageLarge] = useState("");
const [draftImageFile, setDraftImageFile] = useState(null); const [draftImageFile, setDraftImageFile] = useState(null);
const [draftImagePreview, setDraftImagePreview] = useState(""); const [draftImagePreview, setDraftImagePreview] = useState("");
const [draftMediaUrls, setDraftMediaUrls] = useState([]);
const [useCustomImage, setUseCustomImage] = useState(false); const [useCustomImage, setUseCustomImage] = useState(false);
const [postVisibility, setPostVisibility] = useState("public"); const [postVisibility, setPostVisibility] = useState("public");
const [postGroupId, setPostGroupId] = useState(""); const [postGroupId, setPostGroupId] = useState("");
@ -1037,6 +1038,7 @@ export default function MapView({
setDraftImage(""); setDraftImage("");
setDraftImageFile(null); setDraftImageFile(null);
setDraftImagePreview(""); setDraftImagePreview("");
setDraftMediaUrls([]);
setUseCustomImage(false); setUseCustomImage(false);
setPostVisibility("public"); setPostVisibility("public");
setPostGroupId(""); setPostGroupId("");
@ -1067,84 +1069,135 @@ export default function MapView({
setSearchQuery(""); setSearchQuery("");
}; };
const updateImageVariantsFromGallery = (urls) => {
const cleaned = (urls || []).filter(Boolean);
const small = cleaned[0] || "";
const medium = cleaned[1] || small;
const large = cleaned[2] || medium || small;
setDraftImageSmall(small);
setDraftImageMedium(medium);
setDraftImageLarge(large);
setDraftImage(small);
setDraftImagePreview(small);
};
const addMediaUrlToGallery = (rawUrl) => {
const trimmed = (rawUrl || "").trim();
if (!trimmed) return false;
setDraftMediaUrls((prev) => {
if (prev.includes(trimmed)) {
updateImageVariantsFromGallery(prev);
return prev;
}
const next = [...prev, trimmed];
updateImageVariantsFromGallery(next);
return next;
});
setUseCustomImage(true);
return true;
};
const removeMediaUrlAtIndex = (index) => {
setDraftMediaUrls((prev) => {
if (index < 0 || index >= prev.length) return prev;
const next = prev.filter((_, idx) => idx !== index);
updateImageVariantsFromGallery(next);
if (next.length === 0) {
setDraftImage("");
setDraftImagePreview("");
} else {
setDraftImage(next[0]);
setDraftImagePreview(next[0]);
}
return next;
});
};
const handleAddCustomImageUrl = () => {
const raw = (draftImage || "").trim();
if (!raw) {
setSaveError("Paste an image URL first.");
return;
}
if (!/^https?:\/\//i.test(raw)) {
setSaveError("Image URL must start with http:// or https://");
return;
}
const added = addMediaUrlToGallery(raw);
if (added) {
setDraftImage("");
setSaveError("");
}
};
const handleImageFileChange = async (e) => { const handleImageFileChange = async (e) => {
const file = e.target.files?.[0]; const files = Array.from(e.target.files || []);
if (!file) return; if (!files.length) return;
setImageTouched(true); setImageTouched(true);
// 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(""); setSaveError("");
try { setUploadingImage(true);
const formData = new FormData(); for (const file of files) {
formData.append('file', file); if (!file.type.startsWith("image/")) {
formData.append('visibility', imageVisibility); setSaveError("Please select an image file");
if (imageGroupId.trim()) formData.append('group_id', imageGroupId.trim()); continue;
if (imageUsers.trim()) formData.append('users', imageUsers.trim()); }
if (file.size > 5 * 1024 * 1024) {
const res = await fetch('/api/assets/upload', { setSaveError("Image must be less than 5MB");
method: 'POST', continue;
body: formData,
credentials: 'include',
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
});
if (!res.ok) {
throw new Error('Upload failed');
} }
const data = await res.json(); setDraftImageFile(file);
const variants = data && data.variants ? data.variants : null; const reader = new FileReader();
const pickRef = (v) => { reader.onloadend = () => {
if (!v) return ""; setDraftImagePreview(reader.result);
if (v.asset_id) return `asset://${v.asset_id}`;
return v.url || "";
}; };
const smallRef = variants ? pickRef(variants.small) : ""; reader.readAsDataURL(file);
const mediumRef = variants ? pickRef(variants.medium) : "";
let largeRef = variants ? pickRef(variants.large) : ""; try {
if (!largeRef) { const formData = new FormData();
if (data && data.url) largeRef = data.url; formData.append("file", file);
else if (data && data.asset_id) largeRef = `asset://${data.asset_id}`; formData.append("visibility", imageVisibility);
if (imageGroupId.trim()) formData.append("group_id", imageGroupId.trim());
if (imageUsers.trim()) formData.append("users", imageUsers.trim());
const res = await fetch("/api/assets/upload", {
method: "POST",
body: formData,
credentials: "include",
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) {
throw new Error("Upload failed");
}
const data = await res.json();
const variants = data && data.variants ? data.variants : null;
const pickRef = (v) => {
if (!v) return "";
if (v.asset_id) return `asset://${v.asset_id}`;
return v.url || "";
};
const smallRef = variants ? pickRef(variants.small) : "";
const mediumRef = variants ? pickRef(variants.medium) : "";
let largeRef = variants ? pickRef(variants.large) : "";
if (!largeRef) {
if (data && data.url) largeRef = data.url;
else if (data && data.asset_id) largeRef = `asset://${data.asset_id}`;
}
const fallbackRef = largeRef || mediumRef || smallRef;
const selected = fallbackRef || mediumRef || smallRef;
if (selected) {
addMediaUrlToGallery(selected);
}
} catch (err) {
console.error("Image upload error:", err);
setSaveError("Failed to upload image");
} }
const fallbackRef = largeRef || mediumRef || smallRef;
setDraftImageSmall(smallRef || fallbackRef);
setDraftImageMedium(mediumRef || fallbackRef);
setDraftImageLarge(largeRef || fallbackRef);
if (fallbackRef) setDraftImage(fallbackRef);
setUploadingImage(false);
} catch (err) {
console.error('Image upload error:', err);
setSaveError('Failed to upload image');
setUploadingImage(false);
setDraftImageFile(null);
setDraftImagePreview("");
setDraftImageSmall("");
setDraftImageMedium("");
setDraftImageLarge("");
} }
setUploadingImage(false);
e.target.value = "";
}; };
const handleUrlPreview = async (rawOverride) => { const handleUrlPreview = async (rawOverride) => {
@ -1173,10 +1226,7 @@ export default function MapView({
if (!imageTouched && image) { if (!imageTouched && image) {
setUseCustomImage(true); setUseCustomImage(true);
if (!draftImageFile && !draftImagePreview) { if (!draftImageFile && !draftImagePreview) {
setDraftImage(image); addMediaUrlToGallery(image);
setDraftImageSmall(image);
setDraftImageMedium(image);
setDraftImageLarge(image);
} }
} }
} catch (err) { } catch (err) {
@ -1280,16 +1330,18 @@ export default function MapView({
payload.published_at = draftPublishedAt.trim(); payload.published_at = draftPublishedAt.trim();
} }
if ( const mediaCandidates = draftMediaUrls.filter(Boolean);
draftImageSmall || if (mediaCandidates.length > 0) {
draftImageMedium || payload.image_small = mediaCandidates[0];
draftImageLarge || payload.image_medium = mediaCandidates[1] || mediaCandidates[0];
(useCustomImage && draftImage.trim()) payload.image_large =
) { mediaCandidates[2] || mediaCandidates[1] || mediaCandidates[0];
payload.media_urls = mediaCandidates;
} else if (useCustomImage && draftImage.trim()) {
const fallback = draftImage.trim(); const fallback = draftImage.trim();
payload.image_small = (draftImageSmall || fallback).trim(); payload.image_small = fallback;
payload.image_medium = (draftImageMedium || fallback).trim(); payload.image_medium = fallback;
payload.image_large = (draftImageLarge || fallback).trim(); payload.image_large = fallback;
} }
const newPost = await createPost(payload, token); const newPost = await createPost(payload, token);
@ -1336,6 +1388,13 @@ export default function MapView({
} }
}; };
const galleryPreviewImage =
draftMediaUrls[0] ||
draftImageLarge ||
draftImageMedium ||
draftImageSmall ||
draftImage;
return ( return (
<div className="map-page"> <div className="map-page">
<div className="map-stage" ref={stageRef}> <div className="map-stage" ref={stageRef}>
@ -1587,22 +1646,9 @@ export default function MapView({
)} )}
{createStep === 2 && ( {createStep === 2 && (
<> <>
{(draftImagePreview || {galleryPreviewImage && (
draftImageLarge ||
draftImageMedium ||
draftImageSmall ||
draftImage) && (
<div className="create-link-preview create-link-preview-tight"> <div className="create-link-preview create-link-preview-tight">
<img <img src={galleryPreviewImage} alt="Link preview" />
src={
draftImagePreview ||
draftImageLarge ||
draftImageMedium ||
draftImageSmall ||
draftImage
}
alt="Link preview"
/>
<div className="create-link-preview-meta"> <div className="create-link-preview-meta">
{draftSource ? <span>{draftSource}</span> : null} {draftSource ? <span>{draftSource}</span> : null}
{draftPublishedAt ? ( {draftPublishedAt ? (
@ -1611,6 +1657,29 @@ export default function MapView({
</div> </div>
</div> </div>
)} )}
{draftMediaUrls.length > 0 && (
<div className="create-image-gallery">
<div className="create-gallery-title">
{draftMediaUrls.length} image
{draftMediaUrls.length > 1 ? "s" : ""}
</div>
<div className="create-gallery-items">
{draftMediaUrls.map((url, idx) => (
<figure key={`${url}-${idx}`} className="create-gallery-item">
<img src={url} alt={`Selected image ${idx + 1}`} />
<button
type="button"
className="create-gallery-remove"
onClick={() => removeMediaUrlAtIndex(idx)}
aria-label="Remove image"
>
×
</button>
</figure>
))}
</div>
</div>
)}
<textarea <textarea
className="create-textarea create-textarea-tight" className="create-textarea create-textarea-tight"
rows={3} rows={3}
@ -1657,6 +1726,7 @@ export default function MapView({
className="create-file-input" className="create-file-input"
type="file" type="file"
accept="image/*" accept="image/*"
multiple
onChange={handleImageFileChange} onChange={handleImageFileChange}
disabled={uploadingImage} disabled={uploadingImage}
/> />
@ -1668,6 +1738,7 @@ export default function MapView({
type="file" type="file"
accept="image/*" accept="image/*"
capture="environment" capture="environment"
multiple
onChange={handleImageFileChange} onChange={handleImageFileChange}
disabled={uploadingImage} disabled={uploadingImage}
/> />
@ -1691,22 +1762,27 @@ export default function MapView({
</button> </button>
</div> </div>
)} )}
{!draftUrl.trim() && !draftImageFile && !draftImagePreview ? ( <div className="create-muted">Or paste an image URL:</div>
<> <div className="create-image-url-row">
<div className="create-muted">Or paste an image URL:</div> <input
<input className="create-input"
className="create-input" placeholder="https://example.com/image.jpg"
placeholder="https://example.com/image.jpg" value={draftImage}
value={draftImage} onChange={(e) => {
onChange={(e) => { setDraftImage(e.target.value);
setDraftImage(e.target.value); setDraftImageFile(null);
setDraftImageFile(null); setDraftImagePreview("");
setDraftImagePreview(""); setImageTouched(true);
setImageTouched(true); }}
}} />
/> <button
</> type="button"
) : null} className="create-gallery-add"
onClick={handleAddCustomImageUrl}
>
Add
</button>
</div>
</div> </div>
)} )}
</> </>

View File

@ -764,6 +764,66 @@
border-radius:999px; border-radius:999px;
font-size:.68rem; font-size:.68rem;
} }
.create-image-gallery{
display:flex;
flex-direction:column;
gap:.3rem;
margin-bottom:.4rem;
}
.create-gallery-title{
font-size:.7rem;
text-transform:uppercase;
letter-spacing:0.2em;
color:rgba(226, 232, 240, 0.7);
}
.create-gallery-items{
display:flex;
flex-wrap:wrap;
gap:.4rem;
}
.create-gallery-item{
position:relative;
width:54px;
height:54px;
}
.create-gallery-item img{
width:100%;
height:100%;
object-fit:cover;
border-radius:10px;
border:1px solid rgba(148, 163, 184, 0.5);
}
.create-gallery-remove{
position:absolute;
top:-6px;
right:-6px;
width:22px;
height:22px;
border-radius:50%;
border:1px solid rgba(255,255,255,0.4);
background:rgba(15,23,42,0.8);
color:#f8fafc;
font-size:12px;
display:flex;
align-items:center;
justify-content:center;
cursor:pointer;
}
.create-image-url-row{
display:flex;
gap:.4rem;
}
.create-gallery-add{
flex:0 0 auto;
padding:.35rem .65rem;
border-radius:999px;
border:1px solid rgba(56, 189, 248, 0.7);
background:rgba(56, 189, 248, 0.15);
color:#e0f2fe;
font-size:.72rem;
font-weight:700;
cursor:pointer;
}
.create-link-preview{ .create-link-preview{
display:flex; display:flex;
align-items:center; align-items:center;