Skip to content

Commit 0735c11

Browse files
committed
feat: implement memo map in user profile
1 parent f416eb0 commit 0735c11

File tree

8 files changed

+332
-66
lines changed

8 files changed

+332
-66
lines changed

web/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
"highlight.js": "^11.11.1",
4141
"i18next": "^25.6.3",
4242
"leaflet": "^1.9.4",
43+
"leaflet.markercluster": "^1.5.3",
4344
"lodash-es": "^4.17.21",
4445
"lucide-react": "^0.544.0",
4546
"mdast-util-from-markdown": "^2.0.2",
@@ -53,6 +54,7 @@
5354
"react-hot-toast": "^2.6.0",
5455
"react-i18next": "^15.7.4",
5556
"react-leaflet": "^4.2.1",
57+
"react-leaflet-cluster": "^2.1.0",
5658
"react-markdown": "^10.1.0",
5759
"react-router-dom": "^7.9.6",
5860
"react-use": "^17.6.0",

web/pnpm-lock.yaml

Lines changed: 31 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

web/src/components/LeafletMap.tsx

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import L, { DivIcon, LatLng } from "leaflet";
22
import { ExternalLinkIcon, MapPinIcon, MinusIcon, PlusIcon } from "lucide-react";
3-
import { type ReactNode, useEffect, useRef, useState } from "react";
3+
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
44
import { createRoot } from "react-dom/client";
55
import ReactDOMServer from "react-dom/server";
66
import { MapContainer, Marker, TileLayer, useMap, useMapEvents } from "react-leaflet";
7+
import { useAuth } from "@/contexts/AuthContext";
78
import { cn } from "@/lib/utils";
9+
import { resolveTheme } from "@/utils/theme";
810

911
const markerIcon = new DivIcon({
1012
className: "relative border-none",
@@ -72,10 +74,11 @@ const GlassButton = ({ icon, onClick, ariaLabel, title }: GlassButtonProps) => {
7274
aria-label={ariaLabel}
7375
title={title}
7476
className={cn(
75-
"w-8 h-8 flex items-center justify-center rounded-lg",
76-
"transition-all duration-200 cursor-pointer",
77+
"h-8 w-8 flex items-center justify-center rounded-lg",
78+
"cursor-pointer transition-all duration-200",
7779
"bg-white/80 backdrop-blur-md border border-white/30 shadow-lg",
7880
"hover:bg-white/90 hover:scale-105 active:scale-95",
81+
"dark:bg-black/80 dark:border-white/10 dark:hover:bg-black/90",
7982
"focus:outline-none focus:ring-2 focus:ring-blue-500",
8083
)}
8184
>
@@ -226,10 +229,24 @@ interface MapProps {
226229
const DEFAULT_CENTER_LAT_LNG = new LatLng(48.8584, 2.2945);
227230

228231
const LeafletMap = (props: MapProps) => {
232+
const { userGeneralSetting } = useAuth();
229233
const position = props.latlng || DEFAULT_CENTER_LAT_LNG;
234+
const isDark = useMemo(() => resolveTheme(userGeneralSetting?.theme || "system").includes("dark"), [userGeneralSetting?.theme]);
235+
230236
return (
231-
<MapContainer className="w-full h-72" center={position} zoom={13} scrollWheelZoom={false} zoomControl={false}>
232-
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
237+
<MapContainer
238+
className="w-full h-72"
239+
center={position}
240+
zoom={13}
241+
scrollWheelZoom={false}
242+
zoomControl={false}
243+
attributionControl={false}
244+
>
245+
<TileLayer
246+
url={
247+
isDark ? "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png" : "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
248+
}
249+
/>
233250
<LocationMarker position={position} readonly={props.readonly} onChange={props.onChange ? props.onChange : () => {}} />
234251
<MapControls position={props.latlng} />
235252
<MapCleanup />
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { timestampDate } from "@bufbuild/protobuf/wkt";
2+
import L, { DivIcon } from "leaflet";
3+
import "leaflet.markercluster/dist/MarkerCluster.Default.css";
4+
import "leaflet.markercluster/dist/MarkerCluster.css";
5+
import { ArrowUpRightIcon, MapPinIcon } from "lucide-react";
6+
import { useEffect, useMemo } from "react";
7+
import ReactDOMServer from "react-dom/server";
8+
import { MapContainer, Marker, Popup, TileLayer, useMap } from "react-leaflet";
9+
import MarkerClusterGroup from "react-leaflet-cluster";
10+
import { Link } from "react-router-dom";
11+
import Spinner from "@/components/Spinner";
12+
import { useAuth } from "@/contexts/AuthContext";
13+
import { useInfiniteMemos } from "@/hooks/useMemoQueries";
14+
import { cn } from "@/lib/utils";
15+
import { State } from "@/types/proto/api/v1/common_pb";
16+
import { Memo } from "@/types/proto/api/v1/memo_service_pb";
17+
import { resolveTheme } from "@/utils/theme";
18+
19+
interface Props {
20+
creator: string;
21+
className?: string;
22+
}
23+
24+
const markerIcon = new DivIcon({
25+
className: "relative border-none",
26+
html: ReactDOMServer.renderToString(
27+
<MapPinIcon className="absolute bottom-1/2 -left-1/2 text-red-500 drop-shadow-md" fill="currentColor" size={32} />,
28+
),
29+
});
30+
31+
interface ClusterGroup {
32+
getChildCount(): number;
33+
}
34+
35+
const createClusterCustomIcon = (cluster: ClusterGroup) => {
36+
return new DivIcon({
37+
html: `<span class="flex items-center justify-center w-full h-full bg-primary text-primary-foreground text-xs font-bold rounded-full shadow-md border-2 border-background">${cluster.getChildCount()}</span>`,
38+
className: "custom-marker-cluster",
39+
iconSize: L.point(32, 32, true),
40+
});
41+
};
42+
43+
const extractUserIdFromName = (name: string): string => {
44+
const match = name.match(/users\/(\d+)/);
45+
return match ? match[1] : "";
46+
};
47+
48+
const MapFitBounds = ({ memos }: { memos: Memo[] }) => {
49+
const map = useMap();
50+
51+
useEffect(() => {
52+
if (memos.length === 0) return;
53+
54+
const validMemos = memos.filter((m) => m.location);
55+
if (validMemos.length === 0) return;
56+
57+
const bounds = L.latLngBounds(validMemos.map((memo) => [memo.location!.latitude, memo.location!.longitude]));
58+
map.fitBounds(bounds, { padding: [50, 50] });
59+
}, [memos, map]);
60+
61+
return null;
62+
};
63+
64+
const UserMemoMap = ({ creator, className }: Props) => {
65+
const { userGeneralSetting } = useAuth();
66+
const creatorId = useMemo(() => extractUserIdFromName(creator), [creator]);
67+
const isDark = useMemo(() => resolveTheme(userGeneralSetting?.theme || "system").includes("dark"), [userGeneralSetting?.theme]);
68+
69+
const { data, isLoading } = useInfiniteMemos({
70+
state: State.NORMAL,
71+
orderBy: "display_time desc",
72+
pageSize: 1000,
73+
filter: `creator_id == ${creatorId}`,
74+
});
75+
76+
const memosWithLocation = useMemo(() => data?.pages.flatMap((page) => page.memos).filter((memo) => memo.location) || [], [data]);
77+
78+
if (isLoading) {
79+
return (
80+
<div className="w-full h-[380px] flex items-center justify-center rounded-xl border border-border bg-muted/30">
81+
<Spinner className="w-8 h-8" />
82+
</div>
83+
);
84+
}
85+
86+
const defaultCenter = { lat: 48.8566, lng: 2.3522 };
87+
88+
return (
89+
<div className={cn("relative z-0 w-full h-[380px] rounded-xl overflow-hidden border border-border shadow-sm", className)}>
90+
{memosWithLocation.length === 0 && (
91+
<div className="absolute inset-0 z-[1000] flex items-center justify-center pointer-events-none">
92+
<div className="flex flex-col items-center gap-1 rounded-2xl border border-border bg-background/70 px-4 py-2 shadow-sm backdrop-blur-sm">
93+
<MapPinIcon className="h-5 w-5 text-muted-foreground opacity-60" />
94+
<p className="text-xs font-medium text-muted-foreground">No location data found</p>
95+
</div>
96+
</div>
97+
)}
98+
99+
<MapContainer center={defaultCenter} zoom={2} className="h-full w-full z-0" scrollWheelZoom attributionControl={false}>
100+
<TileLayer
101+
url={
102+
isDark ? "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png" : "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
103+
}
104+
/>
105+
<MarkerClusterGroup
106+
chunkedLoading
107+
iconCreateFunction={createClusterCustomIcon}
108+
maxClusterRadius={40}
109+
spiderfyOnMaxZoom
110+
showCoverageOnHover={false}
111+
>
112+
{memosWithLocation.map((memo) => (
113+
<Marker key={memo.name} position={[memo.location!.latitude, memo.location!.longitude]} icon={markerIcon}>
114+
<Popup closeButton={false} className="w-48!">
115+
<div className="flex flex-col p-0.5">
116+
<div className="flex items-center justify-between border-b border-border pb-1 mb-1">
117+
<span className="text-[10px] font-medium text-muted-foreground">
118+
{memo.displayTime &&
119+
timestampDate(memo.displayTime).toLocaleDateString(undefined, {
120+
year: "numeric",
121+
month: "short",
122+
day: "numeric",
123+
})}
124+
</span>
125+
<Link
126+
to={`/m/${memo.name.split("/").pop()}`}
127+
className="flex items-center gap-0.5 text-[10px] text-primary hover:opacity-80"
128+
>
129+
View
130+
<ArrowUpRightIcon className="h-3 w-3" />
131+
</Link>
132+
</div>
133+
<div className="line-clamp-3 py-0.5 text-xs font-sans leading-snug text-foreground">{memo.snippet || "No content"}</div>
134+
</div>
135+
</Popup>
136+
</Marker>
137+
))}
138+
</MarkerClusterGroup>
139+
<MapFitBounds memos={memosWithLocation} />
140+
</MapContainer>
141+
</div>
142+
);
143+
};
144+
145+
export default UserMemoMap;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default } from "./UserMemoMap";

web/src/index.css

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -347,11 +347,26 @@
347347
vertical-align: baseline;
348348
}
349349

350-
/* ========================================
351-
* Strikethrough (GFM)
352-
* ======================================== */
353-
350+
/* Strikethrough (GFM) */
354351
.markdown-content del {
355352
text-decoration: line-through;
356353
}
354+
355+
/* Leaflet Popup Overrides */
356+
.leaflet-popup-content-wrapper {
357+
border-radius: 0.5rem !important;
358+
border: 1px solid var(--border) !important;
359+
background-color: var(--background) !important;
360+
box-shadow: var(--shadow-lg) !important;
361+
}
362+
363+
.leaflet-popup-content {
364+
margin: 4px !important;
365+
line-height: inherit !important;
366+
font-size: inherit !important;
367+
}
368+
369+
.leaflet-popup-tip {
370+
background-color: var(--background) !important;
371+
}
357372
}

web/src/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
"layout": "Layout",
5858
"learn-more": "Learn more",
5959
"link": "Link",
60+
"map": "Map",
6061
"mark": "Mark",
6162
"memo": "Memo",
6263
"memos": "Memos",

0 commit comments

Comments
 (0)