Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 176 additions & 0 deletions apps/geolibre-desktop/src/components/comments/AddCommentDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
Input,
Textarea,
} from "@geolibre/ui";
import { MapPin, Layers, MessageSquare, Send, User } from "lucide-react";
import type { PendingCommentState } from "./useCommentTool";

interface AddCommentDialogProps {
pendingComment: PendingCommentState;
onSubmit: (body: string, authorName?: string) => void;
onCancel: () => void;
}

export function AddCommentDialog({ pendingComment, onSubmit, onCancel }: AddCommentDialogProps) {
const { t } = useTranslation();
const [savedName, setSavedName] = useState<string | null>(() => {
try {
return typeof localStorage !== "undefined"
? localStorage.getItem("geolibre_author_name")
: null;
} catch {
return null;
}
});
const hasSavedName = !!savedName && savedName.trim().length > 0;

const [text, setText] = useState("");
const [authorName, setAuthorName] = useState(savedName ?? "");

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!text.trim()) return;

const finalName = authorName.trim() || savedName?.trim() || "Author";
if (typeof localStorage !== "undefined" && finalName) {
try {
localStorage.setItem("geolibre_author_name", finalName);
setSavedName(finalName);
} catch {
// ignore storage errors
}
}

onSubmit(text.trim(), finalName);
setText("");
};

const anchor = pendingComment.anchor;
const lngLat = anchor.lngLat;

return (
<Dialog
open={true}
onOpenChange={(open) => {
if (!open) onCancel();
}}
>
<DialogContent className="max-w-md p-5 sm:max-w-md">
<DialogHeader className="mb-2">
<DialogTitle className="flex items-center gap-2 text-sm">
<MessageSquare className="h-4 w-4 text-primary" />
<span>{t("comments.addDialogTitle")}</span>
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("comments.addDialogDescription")}
</DialogDescription>
</DialogHeader>

<form onSubmit={handleSubmit} className="space-y-3.5">
<div className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 p-2.5 rounded-md border border-border/60">
{anchor.type === "feature" ? (
<>
<Layers className="h-3.5 w-3.5 text-sky-400 shrink-0" />
<span className="truncate font-medium text-foreground">
Anchored to Feature #{String(anchor.featureId)} ({anchor.layerId})
</span>
</>
) : (
<>
<MapPin className="h-3.5 w-3.5 text-amber-400 shrink-0" />
<span className="font-medium text-foreground">
Anchored to (
{lngLat ? `${lngLat[1].toFixed(4)}, ${lngLat[0].toFixed(4)}` : "Map Point"})
</span>
</>
)}
</div>

{/* Prompt for name 1 time if not saved in localStorage */}
{!hasSavedName ? (
<div className="space-y-1">
<label
htmlFor="author-name-input"
className="text-[11px] font-medium text-muted-foreground flex items-center gap-1"
>
<User className="h-3 w-3 text-primary" />
<span>{t("comments.authorNameLabel")}</span>
</label>
<Input
id="author-name-input"
value={authorName}
onChange={(e) => setAuthorName(e.target.value)}
placeholder={t("comments.authorNamePlaceholder")}
className="text-xs h-8"
autoFocus
/>
</div>
) : (
<div className="flex items-center justify-between text-[11px] text-muted-foreground px-1">
<span>
Posting as <strong className="text-foreground">{savedName}</strong>
</span>
<button
type="button"
onClick={() => {
if (typeof localStorage !== "undefined") {
try {
localStorage.removeItem("geolibre_author_name");
} catch {}
}
setSavedName(null);
setAuthorName("");
}}
className="text-primary hover:underline text-[10px]"
>
Change Name
</button>
</div>
)}

<div className="space-y-1">
<label
htmlFor="comment-text-input"
className="text-[11px] font-medium text-muted-foreground"
>
{t("comments.commentLabel")}
</label>
<Textarea
id="comment-text-input"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={t("comments.commentPlaceholder")}
rows={3}
className="text-xs min-h-20 resize-none"
autoFocus={hasSavedName}
/>
</div>

<div className="flex items-center justify-end gap-2 pt-1">
<Button type="button" variant="outline" size="sm" onClick={onCancel}>
Cancel
</Button>
<Button
type="submit"
variant="default"
size="sm"
className="gap-1.5"
disabled={!text.trim() || (!hasSavedName && !authorName.trim())}
>
<Send className="h-3.5 w-3.5" />
<span>Post Comment</span>
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
186 changes: 186 additions & 0 deletions apps/geolibre-desktop/src/components/comments/CommentMapOverlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import { useEffect, useRef } from "react";
import { useAppStore, type ProjectComment } from "@geolibre/core";
import type { MapController } from "@geolibre/map";
import maplibreGl from "maplibre-gl";

interface CommentMapOverlayProps {
mapControllerRef: React.RefObject<MapController | null>;
onSelectComment?: (commentId: string) => void;
showResolved?: boolean;
}

function extractGeometryCoords(geometry: any): [number, number] | null {
if (!geometry) return null;
const { type, coordinates, geometries } = geometry;
if (type === "Point" && Array.isArray(coordinates)) {
return coordinates as [number, number];
}
if (type === "MultiPoint" && Array.isArray(coordinates?.[0])) {
return coordinates[0] as [number, number];
}
if (type === "LineString" && Array.isArray(coordinates?.[0])) {
return coordinates[0] as [number, number];
}
if (type === "MultiLineString" && Array.isArray(coordinates?.[0]?.[0])) {
return coordinates[0][0] as [number, number];
}
if (type === "Polygon" && Array.isArray(coordinates?.[0]?.[0])) {
return coordinates[0][0] as [number, number];
}
if (type === "MultiPolygon" && Array.isArray(coordinates?.[0]?.[0]?.[0])) {
return coordinates[0][0][0] as [number, number];
}
if (type === "GeometryCollection" && Array.isArray(geometries)) {
for (const g of geometries) {
const coords = extractGeometryCoords(g);
if (coords) return coords;
}
}
return null;
}

export function resolveCommentCoordinates(
comment: ProjectComment,
map: maplibreGl.Map | null,
): [number, number] | null {
// 1. Direct lngLat on point or feature anchor
if (comment.anchor.lngLat) {
return comment.anchor.lngLat;
}

if (!map) return null;

// 2. Query rendered features on active layer
if (comment.anchor.type === "feature") {
const { layerId, featureId } = comment.anchor;
const storeLayer = useAppStore.getState().layers.find((l) => l.id === layerId);
const styleLayerIds =
storeLayer &&
Array.isArray(storeLayer.metadata?.sourceIds) &&
storeLayer.metadata.sourceIds.length > 0
? (storeLayer.metadata.sourceIds as string[])
: [layerId];
const validLayers = styleLayerIds.filter((id) => {
try {
return !!map.getLayer(id);
} catch {
return false;
}
});

if (validLayers.length > 0) {
try {
const features = map.queryRenderedFeatures(undefined, {
layers: validLayers,
filter: ["==", ["id"], featureId],
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (features.length > 0 && features[0].geometry) {
const coords = extractGeometryCoords(features[0].geometry);
if (coords) return coords;
}
} catch {
// Ignore query errors
}
}

// 3. Fallback to GeoJSON features in store layers
const layer = useAppStore.getState().layers.find((l) => l.id === layerId);
if (layer?.geojson?.features) {
const feat = layer.geojson.features.find((f) => String(f.id) === String(featureId));
if (feat?.geometry) {
const coords = extractGeometryCoords(feat.geometry);
if (coords) return coords;
}
}
}

return null;
}

export function CommentMapOverlay({
mapControllerRef,
onSelectComment,
showResolved = false,
}: CommentMapOverlayProps): null {
const comments = useAppStore((s) => s.comments);
const markersRef = useRef<maplibreGl.Marker[]>([]);

useEffect(() => {
const map = mapControllerRef.current?.getMap() ?? null;
if (!map) return;

const renderMarkers = () => {
// Clear existing markers
markersRef.current.forEach((m) => m.remove());
markersRef.current = [];

comments.forEach((comment, idx) => {
if (comment.resolved && !showResolved) return;

const coords = resolveCommentCoordinates(comment, map);
if (!coords) return;

const pinColor = comment.author?.color || "#3b82f6";

const container = document.createElement("div");
container.className =
"group relative cursor-pointer select-none transition-transform duration-150 ease-out hover:scale-[1.15]";
container.style.zIndex = comment.resolved ? "9" : "10";
Comment thread
HarshShinde0 marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Build the pin with DOM APIs so the author color is set as a style
// property, never interpolated into markup — defense-in-depth against
// a hand-edited project file with a hostile color value.
const pin = document.createElement("div");
pin.style.cssText = [
"display:flex",
"align-items:center",
"justify-content:center",
"width:28px",
"height:28px",
"border-radius:50% 50% 50% 0",
"transform:rotate(-45deg)",
`border:2px solid ${comment.resolved ? "#10b981" : "#ffffff"}`,
"box-shadow:0 4px 10px rgba(0,0,0,0.35)",
`opacity:${comment.resolved ? 0.65 : 1}`,
].join(";");
pin.style.backgroundColor = pinColor;

const label = document.createElement("span");
label.style.cssText =
"transform:rotate(45deg);color:#ffffff;font-size:11px;font-weight:700;font-family:system-ui,sans-serif;line-height:1";
label.textContent = `#${idx + 1}`;
pin.appendChild(label);
container.appendChild(pin);

container.addEventListener("click", (e) => {
e.stopPropagation();
onSelectComment?.(comment.id);
});

const marker = new maplibreGl.Marker({
element: container,
anchor: "bottom",
})
.setLngLat(coords)
.addTo(map);

markersRef.current.push(marker);
});
};

renderMarkers();

// Re-render when the style reloads (basemap switch wipes all markers).
// No moveend listener needed: MapLibre Marker objects are positioned in
// geographic space and track the map viewport automatically.
map.on("styledata", renderMarkers);

return () => {
map.off("styledata", renderMarkers);
markersRef.current.forEach((m) => m.remove());
markersRef.current = [];
};
}, [comments, showResolved, mapControllerRef, onSelectComment]);
Comment thread
HarshShinde0 marked this conversation as resolved.

return null;
}
Loading
Loading