Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
155 changes: 155 additions & 0 deletions apps/geolibre-desktop/src/components/comments/AddCommentDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { useState } from "react";
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 savedName =
typeof localStorage !== "undefined" ? localStorage.getItem("geolibre_author_name") : null;
const hasSavedName = !!savedName && savedName.trim().length > 0;
Comment thread
HarshShinde0 marked this conversation as resolved.
Outdated

const [text, setText] = useState("");
const [authorName, setAuthorName] = useState(savedName ?? "");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

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);
} 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>Add Review Comment</span>
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
Post an anchored note on this project for team review or offline reference.
</DialogDescription>
Comment thread
HarshShinde0 marked this conversation as resolved.
Outdated
</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 className="text-[11px] font-medium text-muted-foreground flex items-center gap-1">
<User className="h-3 w-3 text-primary" />
<span>Your Name (Asked once, saved automatically)</span>
</label>
<Input
value={authorName}
onChange={(e) => setAuthorName(e.target.value)}
placeholder="e.g. Alex or Sarah"
className="text-xs h-8"
autoFocus
/>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
</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") {
localStorage.removeItem("geolibre_author_name");
setAuthorName("");
}
}}
className="text-primary hover:underline text-[10px]"
>
Change Name
</button>
</div>
)}

<div className="space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">
Comment / Feedback
</label>
<Textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Type your review note or feedback here..."
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>
);
}
151 changes: 151 additions & 0 deletions apps/geolibre-desktop/src/components/comments/CommentMapOverlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
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;
}

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;
try {
const features = map.queryRenderedFeatures(undefined, {
layers: [layerId],
filter: ["==", ["id"], featureId],
});
if (features.length > 0 && features[0].geometry) {
const g = features[0].geometry;
if (g.type === "Point") return g.coordinates as [number, number];
if (g.type === "Polygon" && g.coordinates[0]?.[0]) {
return g.coordinates[0][0] as [number, number];
}
if (g.type === "LineString" && g.coordinates[0]) {
return g.coordinates[0] as [number, number];
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
} 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 g = feat.geometry;
if (g.type === "Point") return g.coordinates as [number, number];
if (g.type === "Polygon" && g.coordinates[0]?.[0]) {
return g.coordinates[0][0] as [number, number];
}
if (g.type === "LineString" && g.coordinates[0]) {
return g.coordinates[0] as [number, number];
}
}
}
}

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-115";
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