705 lines
21 KiB
JavaScript
705 lines
21 KiB
JavaScript
import React, { useEffect, useRef, useState } from "react";
|
|
import "maplibre-gl/dist/maplibre-gl.css";
|
|
import "../../styles/map.css";
|
|
import "../../styles/mapMarkers.css";
|
|
import "../../styles/posts.css";
|
|
import "../../styles/filters.css";
|
|
|
|
import { createPost } from "../../api/client";
|
|
import {
|
|
FILTER_MAIN_CATEGORIES,
|
|
FILTER_CATEGORY_MAP,
|
|
CREATE_CATEGORY_MAP,
|
|
} from "./mapConfig";
|
|
import { useMapCore } from "./useMapCore";
|
|
import { useUserPosition } from "./useUserPosition";
|
|
import { usePostsEngine } from "./usePostsEngine";
|
|
import { useAuth } from "../../auth/AuthContext";
|
|
import FilterButtons from "../Filters/FilterButtons";
|
|
import TimeFilterButtons from "../Filters/TimeFilterButtons";
|
|
import SmartSearchBar from "../Search/SmartSearchBar";
|
|
|
|
export default function MapView({
|
|
theme = "dark",
|
|
onPostsState,
|
|
selectedPost,
|
|
onSelectPost,
|
|
}) {
|
|
const { authenticated, username, token, needsEmailVerification } = useAuth();
|
|
|
|
const { containerRef, mapRef, markersRef, expandedElRef, viewParams, hasLastView } =
|
|
useMapCore(theme);
|
|
|
|
const [mainFilter, setMainFilter] = useState("All");
|
|
const TIME_FILTER_KEY = "sociowire:timeFilter";
|
|
const [timeFilter, setTimeFilter] = useState(() => {
|
|
try {
|
|
const v = localStorage.getItem(TIME_FILTER_KEY);
|
|
const ok = ["NOW", "TODAY", "RECENT", "PAST"].includes(v);
|
|
return ok ? v : "TODAY";
|
|
} catch {
|
|
return "TODAY";
|
|
}
|
|
});
|
|
const [subFilter, setSubFilter] = useState("All");
|
|
|
|
useEffect(() => {
|
|
try {
|
|
localStorage.setItem(TIME_FILTER_KEY, timeFilter);
|
|
} catch {}
|
|
}, [timeFilter]);
|
|
|
|
const stageRef = useRef(null);
|
|
const [showSubcats, setShowSubcats] = useState(true);
|
|
|
|
const userPosition = useUserPosition(mapRef, hasLastView);
|
|
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
|
|
const { status, visiblePosts, loadingPosts, loadError, handleIncomingPost } =
|
|
usePostsEngine({
|
|
theme,
|
|
mapRef,
|
|
viewParams,
|
|
mainFilter,
|
|
subFilter,
|
|
timeFilter,
|
|
searchQuery,
|
|
markersRef,
|
|
expandedElRef,
|
|
});
|
|
|
|
const [isCreating, setIsCreating] = useState(false);
|
|
const [draftTitle, setDraftTitle] = useState("");
|
|
const [draftBody, setDraftBody] = useState("");
|
|
const [draftCategory, setDraftCategory] = useState("News");
|
|
const [draftSubCategory, setDraftSubCategory] = useState(
|
|
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("");
|
|
|
|
const bottomCategories = FILTER_CATEGORY_MAP[mainFilter] || ["All"];
|
|
|
|
const getSubcatIcon = (main, sub) => {
|
|
const M = {
|
|
All: {
|
|
All: "fa-solid fa-layer-group",
|
|
},
|
|
|
|
News: {
|
|
All: "fa-solid fa-layer-group",
|
|
World: "fa-solid fa-globe",
|
|
Politics: "fa-solid fa-landmark-flag",
|
|
Tech: "fa-solid fa-microchip",
|
|
Finance: "fa-solid fa-chart-line",
|
|
Local: "fa-solid fa-location-dot",
|
|
},
|
|
|
|
Friends: {
|
|
All: "fa-solid fa-layer-group",
|
|
Nearby: "fa-solid fa-location-crosshairs",
|
|
Chats: "fa-solid fa-comments",
|
|
Groups: "fa-solid fa-user-group",
|
|
Stories: "fa-solid fa-book-open",
|
|
Favs: "fa-solid fa-heart",
|
|
},
|
|
|
|
Events: {
|
|
All: "fa-solid fa-layer-group",
|
|
Today: "fa-solid fa-calendar-day",
|
|
Weekend: "fa-solid fa-calendar-week",
|
|
Music: "fa-solid fa-music",
|
|
Sports: "fa-solid fa-football",
|
|
Local: "fa-solid fa-location-dot",
|
|
},
|
|
|
|
Market: {
|
|
All: "fa-solid fa-layer-group",
|
|
Actu: "fa-solid fa-newspaper",
|
|
Tech: "fa-solid fa-microchip",
|
|
Finan: "fa-solid fa-money-bill-trend-up",
|
|
Sport: "fa-solid fa-basketball",
|
|
Deals: "fa-solid fa-tags",
|
|
},
|
|
};
|
|
|
|
return (M[main] && M[main][sub]) || "";
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!bottomCategories.length) return;
|
|
if (!subFilter || !bottomCategories.includes(subFilter)) setSubFilter("All");
|
|
}, [mainFilter, bottomCategories, subFilter]);
|
|
|
|
useEffect(() => {
|
|
if (!onPostsState) return;
|
|
onPostsState({
|
|
status,
|
|
posts: visiblePosts,
|
|
loading: loadingPosts,
|
|
error: loadError,
|
|
});
|
|
}, [status, visiblePosts, loadingPosts, loadError, onPostsState]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedPost) return;
|
|
const map = mapRef.current;
|
|
if (!map) return;
|
|
|
|
const lng = selectedPost.lon ?? selectedPost.lng;
|
|
const lat = selectedPost.lat ?? selectedPost.latitude;
|
|
|
|
if (typeof lng === "number" && typeof lat === "number") {
|
|
map.flyTo({ center: [lng, lat], zoom: 8, speed: 1.4 });
|
|
}
|
|
}, [selectedPost, mapRef]);
|
|
|
|
useEffect(() => {
|
|
const el = stageRef.current;
|
|
if (!el || typeof IntersectionObserver === "undefined") return;
|
|
|
|
const obs = new IntersectionObserver(
|
|
(entries) => {
|
|
const e = entries && entries[0];
|
|
const ok = !!(e && e.isIntersecting && e.intersectionRatio > 0.15);
|
|
setShowSubcats(ok);
|
|
},
|
|
{ threshold: [0, 0.15, 0.3, 0.6] }
|
|
);
|
|
|
|
obs.observe(el);
|
|
return () => obs.disconnect();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const proto = window.location.protocol === "https:" ? "wss" : "ws";
|
|
const host =
|
|
window.location.port === "5173"
|
|
? `${window.location.hostname}:8081`
|
|
: window.location.host;
|
|
const wsUrl = `${proto}://${host}/ws`;
|
|
|
|
let ws;
|
|
try {
|
|
ws = new WebSocket(wsUrl);
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
ws.onmessage = (ev) => {
|
|
try {
|
|
const msg = JSON.parse(ev.data);
|
|
if (msg.type === "new_post" && msg.post) handleIncomingPost(msg.post);
|
|
} catch {}
|
|
};
|
|
|
|
return () => ws && ws.close();
|
|
}, [handleIncomingPost]);
|
|
|
|
const handleFlyToMe = () => {
|
|
const map = mapRef.current;
|
|
if (!map || !userPosition) return;
|
|
map.flyTo({ center: userPosition, zoom: 9, speed: 1.2 });
|
|
};
|
|
|
|
const handlePickSuggestion = (item, { zoom }) => {
|
|
const map = mapRef.current;
|
|
if (!map) return;
|
|
|
|
// Extract coords from item
|
|
let coords = null;
|
|
if (Array.isArray(item.coords) && item.coords.length === 2) {
|
|
coords = item.coords; // [lon, lat]
|
|
} else if (typeof item.lon === 'number' && typeof item.lat === 'number') {
|
|
coords = [item.lon, item.lat];
|
|
}
|
|
|
|
if (!coords) {
|
|
console.warn('[MapView] No coords for suggestion:', item);
|
|
return;
|
|
}
|
|
|
|
console.log('[MapView] Flying to:', item.title, coords, 'zoom:', zoom);
|
|
map.flyTo({
|
|
center: coords,
|
|
zoom: zoom || 12,
|
|
speed: 1.4,
|
|
essential: true
|
|
});
|
|
|
|
// If picking a post, filter by its title; otherwise clear search
|
|
if (item.type === 'post' && item.title) {
|
|
setSearchQuery(item.title);
|
|
} else {
|
|
setSearchQuery("");
|
|
}
|
|
};
|
|
|
|
const handleSearch = (query) => {
|
|
console.log('[MapView] Search/filter by:', query);
|
|
setSearchQuery(query);
|
|
};
|
|
|
|
// Zoom intelligent: quand on cherche, zoom pour voir tous les résultats
|
|
useEffect(() => {
|
|
if (!searchQuery || !visiblePosts.length) return;
|
|
|
|
const map = mapRef.current;
|
|
if (!map) return;
|
|
|
|
// Attendre un peu que les markers soient créés
|
|
const timer = setTimeout(() => {
|
|
const postsWithCoords = visiblePosts.filter(p => {
|
|
const lat = typeof p.lat === 'number' ? p.lat : p.latitude;
|
|
const lon = typeof p.lon === 'number' ? p.lon : p.lng;
|
|
return lat != null && lon != null;
|
|
});
|
|
|
|
if (postsWithCoords.length === 0) return;
|
|
|
|
if (postsWithCoords.length === 1) {
|
|
// Un seul résultat: zoom dessus
|
|
const p = postsWithCoords[0];
|
|
const lat = typeof p.lat === 'number' ? p.lat : p.latitude;
|
|
const lon = typeof p.lon === 'number' ? p.lon : p.lng;
|
|
map.flyTo({
|
|
center: [lon, lat],
|
|
zoom: 14,
|
|
speed: 1.2,
|
|
essential: true
|
|
});
|
|
} else {
|
|
// Plusieurs résultats: calculer bounds et fitter
|
|
const lats = postsWithCoords.map(p => typeof p.lat === 'number' ? p.lat : p.latitude);
|
|
const lons = postsWithCoords.map(p => typeof p.lon === 'number' ? p.lon : p.lng);
|
|
|
|
const minLat = Math.min(...lats);
|
|
const maxLat = Math.max(...lats);
|
|
const minLon = Math.min(...lons);
|
|
const maxLon = Math.max(...lons);
|
|
|
|
map.fitBounds(
|
|
[[minLon, minLat], [maxLon, maxLat]],
|
|
{
|
|
padding: { top: 100, bottom: 100, left: 100, right: 100 },
|
|
maxZoom: 15,
|
|
duration: 1000
|
|
}
|
|
);
|
|
}
|
|
}, 300); // Délai pour laisser les markers se créer
|
|
|
|
return () => clearTimeout(timer);
|
|
}, [searchQuery, visiblePosts, mapRef]);
|
|
|
|
const handleOpenCreate = () => {
|
|
if (!authenticated) {
|
|
alert("You must be logged in to place a wire.");
|
|
return;
|
|
}
|
|
|
|
if (needsEmailVerification) {
|
|
alert(
|
|
"You must verify your email before posting. Use 'Resend email' in the top bar, then login again."
|
|
);
|
|
return;
|
|
}
|
|
|
|
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;
|
|
setDraftSubCategory(list[0]);
|
|
setDraftCoords(null);
|
|
};
|
|
|
|
const handleSearchSubmit = (e) => {
|
|
e.preventDefault();
|
|
};
|
|
|
|
const handleClearSearch = () => {
|
|
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("");
|
|
|
|
const map = mapRef.current;
|
|
if (!map) return;
|
|
|
|
if (!authenticated) {
|
|
setSaveError("You must be logged in to post.");
|
|
return;
|
|
}
|
|
|
|
if (needsEmailVerification) {
|
|
setSaveError(
|
|
"Verify your email to post. Use 'Resend email' in the top bar, then login again."
|
|
);
|
|
return;
|
|
}
|
|
|
|
const authorName = (username || "").trim() || "anon";
|
|
|
|
let baseLng;
|
|
let baseLat;
|
|
|
|
if (draftCoords && draftCoords.length === 2) {
|
|
baseLng = draftCoords[0];
|
|
baseLat = draftCoords[1];
|
|
} else {
|
|
const center = map.getCenter();
|
|
baseLng = center.lng;
|
|
baseLat = center.lat;
|
|
}
|
|
|
|
const pixelOffsetY = -20;
|
|
const screenPoint = map.project([baseLng, baseLat]);
|
|
const correctedPoint = { x: screenPoint.x, y: screenPoint.y + pixelOffsetY };
|
|
const correctedLngLat = map.unproject([correctedPoint.x, correctedPoint.y]);
|
|
const lng = correctedLngLat.lng;
|
|
const lat = correctedLngLat.lat;
|
|
|
|
if (!draftTitle.trim()) {
|
|
setSaveError("Title required.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setIsSaving(true);
|
|
|
|
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);
|
|
|
|
setIsSaving(false);
|
|
setIsCreating(false);
|
|
} catch (e) {
|
|
console.error(e);
|
|
setIsSaving(false);
|
|
|
|
const code = e && (e.code || "");
|
|
const msg = String((e && e.message) || "").toLowerCase();
|
|
const isNotVerified =
|
|
code === "email_not_verified" ||
|
|
(e &&
|
|
e.status === 403 &&
|
|
(msg.includes("not verified") || msg.includes("verify")));
|
|
|
|
if (isNotVerified) {
|
|
setSaveError(
|
|
"Verify your email to post. Use 'Resend email' in the top bar, then login again."
|
|
);
|
|
} else {
|
|
setSaveError((e && e.message) || "Error creating post.");
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleMainFilterSelect = (code) => {
|
|
if (!FILTER_MAIN_CATEGORIES.includes(code)) return;
|
|
setMainFilter(code);
|
|
};
|
|
|
|
const handleSelectPost = (post) => {
|
|
if (onSelectPost) onSelectPost(post);
|
|
const map = mapRef.current;
|
|
if (!map) return;
|
|
const lng = post.lon ?? post.lng;
|
|
const lat = post.lat ?? post.latitude;
|
|
if (typeof lng === "number" && typeof lat === "number") {
|
|
map.flyTo({ center: [lng, lat], zoom: 8, speed: 1.4 });
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="map-page">
|
|
<div className="map-stage" ref={stageRef}>
|
|
<div ref={containerRef} className="map-container" />
|
|
{status && <div className="map-status">{status}</div>}
|
|
|
|
{/* TOP: Search avec suggestions intelligentes */}
|
|
<div className="map-overlay map-overlay-top">
|
|
<div className="sw-top-row">
|
|
<SmartSearchBar
|
|
placeholder="Search cities, places, posts..."
|
|
onPick={handlePickSuggestion}
|
|
onSearch={handleSearch}
|
|
onMySpot={handleFlyToMe}
|
|
onPlaceTourWire={handleOpenCreate}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="map-overlay map-overlay-left">
|
|
<TimeFilterButtons
|
|
active={timeFilter}
|
|
onSelect={(code) => setTimeFilter(code)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="map-overlay map-overlay-right">
|
|
<FilterButtons active={mainFilter} onSelect={handleMainFilterSelect} />
|
|
</div>
|
|
|
|
<div className="map-overlay map-overlay-bottom">
|
|
<div className="sw-bottom-row">
|
|
{bottomCategories.map((c) => (
|
|
<button
|
|
key={c}
|
|
type="button"
|
|
className={
|
|
"sw-bottom-item" + (subFilter === c ? " sw-bottom-active" : "")
|
|
}
|
|
onClick={() => setSubFilter(c)}
|
|
>
|
|
<div className="sw-bottom-circle">
|
|
{(() => {
|
|
const ic = getSubcatIcon(mainFilter, c);
|
|
return ic ? (
|
|
<i className={ic} />
|
|
) : c === "All" ? (
|
|
"All"
|
|
) : (
|
|
c.slice(0, 2)
|
|
);
|
|
})()}
|
|
</div>
|
|
<div className="sw-bottom-label">{c}</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{isCreating && (
|
|
<div className="map-overlay map-crosshair">
|
|
<div className="crosshair-aim" />
|
|
</div>
|
|
)}
|
|
|
|
{isCreating && (
|
|
<div className="map-overlay create-post-panel">
|
|
<div className="create-post-header">
|
|
<span>Create wire</span>
|
|
<button
|
|
className="create-close"
|
|
onClick={() => setIsCreating(false)}
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
|
|
<span className="crosshair-label">
|
|
Move the map, aim with the crosshair — your wire will be pinned
|
|
there.
|
|
</span>
|
|
|
|
<div className="create-row">
|
|
<select
|
|
value={draftCategory}
|
|
onChange={(e) => {
|
|
const cat = e.target.value;
|
|
setDraftCategory(cat);
|
|
setDraftSubCategory(
|
|
(CREATE_CATEGORY_MAP[cat] || CREATE_CATEGORY_MAP.News)[0]
|
|
);
|
|
}}
|
|
>
|
|
{["News", "Friends", "Events", "Market"].map((cat) => (
|
|
<option key={cat}>{cat}</option>
|
|
))}
|
|
</select>
|
|
|
|
<select
|
|
value={draftSubCategory}
|
|
onChange={(e) => setDraftSubCategory(e.target.value)}
|
|
>
|
|
{(CREATE_CATEGORY_MAP[draftCategory] ||
|
|
CREATE_CATEGORY_MAP.News
|
|
).map((sub) => (
|
|
<option key={sub}>{sub}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<input
|
|
className="create-input"
|
|
placeholder="Short title..."
|
|
value={draftTitle}
|
|
onChange={(e) => setDraftTitle(e.target.value)}
|
|
/>
|
|
|
|
<textarea
|
|
className="create-textarea"
|
|
rows={2}
|
|
placeholder="Context (optional)..."
|
|
value={draftBody}
|
|
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: "40px", height: "40px", objectFit: "cover", borderRadius: "6px" }}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setDraftImageFile(null);
|
|
setDraftImagePreview("");
|
|
setDraftImage("");
|
|
}}
|
|
style={{ padding: "4px 8px", fontSize: "11px" }}
|
|
>
|
|
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">
|
|
<button
|
|
className="chip-pill"
|
|
onClick={handleSubmitPost}
|
|
disabled={isSaving}
|
|
>
|
|
{isSaving ? "Posting..." : "Post"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|