From 0735c11d75c2e78744daf47885b227acc9cdeccc Mon Sep 17 00:00:00 2001 From: Johnny Date: Tue, 30 Dec 2025 20:41:44 +0800 Subject: [PATCH] feat: implement memo map in user profile --- web/package.json | 2 + web/pnpm-lock.yaml | 31 ++++ web/src/components/LeafletMap.tsx | 27 ++- .../components/UserMemoMap/UserMemoMap.tsx | 145 +++++++++++++++ web/src/components/UserMemoMap/index.ts | 1 + web/src/index.css | 23 ++- web/src/locales/en.json | 1 + web/src/pages/UserProfile.tsx | 166 ++++++++++++------ 8 files changed, 331 insertions(+), 65 deletions(-) create mode 100644 web/src/components/UserMemoMap/UserMemoMap.tsx create mode 100644 web/src/components/UserMemoMap/index.ts diff --git a/web/package.json b/web/package.json index 647fae622..e613ac113 100644 --- a/web/package.json +++ b/web/package.json @@ -40,6 +40,7 @@ "highlight.js": "^11.11.1", "i18next": "^25.6.3", "leaflet": "^1.9.4", + "leaflet.markercluster": "^1.5.3", "lodash-es": "^4.17.21", "lucide-react": "^0.544.0", "mdast-util-from-markdown": "^2.0.2", @@ -53,6 +54,7 @@ "react-hot-toast": "^2.6.0", "react-i18next": "^15.7.4", "react-leaflet": "^4.2.1", + "react-leaflet-cluster": "^2.1.0", "react-markdown": "^10.1.0", "react-router-dom": "^7.9.6", "react-use": "^17.6.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 762d7585f..2f173c9ac 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -98,6 +98,9 @@ importers: leaflet: specifier: ^1.9.4 version: 1.9.4 + leaflet.markercluster: + specifier: ^1.5.3 + version: 1.5.3(leaflet@1.9.4) lodash-es: specifier: ^4.17.21 version: 4.17.21 @@ -137,6 +140,9 @@ importers: react-leaflet: specifier: ^4.2.1 version: 4.2.1(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-leaflet-cluster: + specifier: ^2.1.0 + version: 2.1.0(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react-leaflet@4.2.1(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@18.3.27)(react@18.3.1) @@ -2156,6 +2162,11 @@ packages: layout-base@2.0.1: resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + leaflet.markercluster@1.5.3: + resolution: {integrity: sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==} + peerDependencies: + leaflet: ^1.3.1 + leaflet@1.9.4: resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==} @@ -2552,6 +2563,14 @@ packages: peerDependencies: react: '>=16.13.1' + react-leaflet-cluster@2.1.0: + resolution: {integrity: sha512-16X7XQpRThQFC4PH4OpXHimGg19ouWmjxjtpxOeBKpvERSvIRqTx7fvhTwkEPNMFTQ8zTfddz6fRTUmUEQul7g==} + peerDependencies: + leaflet: ^1.8.0 + react: ^18.0.0 + react-dom: ^18.0.0 + react-leaflet: ^4.0.0 + react-leaflet@4.2.1: resolution: {integrity: sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==} peerDependencies: @@ -4889,6 +4908,10 @@ snapshots: layout-base@2.0.1: {} + leaflet.markercluster@1.5.3(leaflet@1.9.4): + dependencies: + leaflet: 1.9.4 + leaflet@1.9.4: {} lightningcss-android-arm64@1.30.2: @@ -5517,6 +5540,14 @@ snapshots: jerrypick: 1.1.2 react: 18.3.1 + react-leaflet-cluster@2.1.0(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react-leaflet@4.2.1(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1): + dependencies: + leaflet: 1.9.4 + leaflet.markercluster: 1.5.3(leaflet@1.9.4) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-leaflet: 4.2.1(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-leaflet@4.2.1(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@react-leaflet/core': 2.1.0(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) diff --git a/web/src/components/LeafletMap.tsx b/web/src/components/LeafletMap.tsx index 304b2c912..23070134c 100644 --- a/web/src/components/LeafletMap.tsx +++ b/web/src/components/LeafletMap.tsx @@ -1,10 +1,12 @@ import L, { DivIcon, LatLng } from "leaflet"; import { ExternalLinkIcon, MapPinIcon, MinusIcon, PlusIcon } from "lucide-react"; -import { type ReactNode, useEffect, useRef, useState } from "react"; +import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; import { createRoot } from "react-dom/client"; import ReactDOMServer from "react-dom/server"; import { MapContainer, Marker, TileLayer, useMap, useMapEvents } from "react-leaflet"; +import { useAuth } from "@/contexts/AuthContext"; import { cn } from "@/lib/utils"; +import { resolveTheme } from "@/utils/theme"; const markerIcon = new DivIcon({ className: "relative border-none", @@ -72,10 +74,11 @@ const GlassButton = ({ icon, onClick, ariaLabel, title }: GlassButtonProps) => { aria-label={ariaLabel} title={title} className={cn( - "w-8 h-8 flex items-center justify-center rounded-lg", - "transition-all duration-200 cursor-pointer", + "h-8 w-8 flex items-center justify-center rounded-lg", + "cursor-pointer transition-all duration-200", "bg-white/80 backdrop-blur-md border border-white/30 shadow-lg", "hover:bg-white/90 hover:scale-105 active:scale-95", + "dark:bg-black/80 dark:border-white/10 dark:hover:bg-black/90", "focus:outline-none focus:ring-2 focus:ring-blue-500", )} > @@ -226,10 +229,24 @@ interface MapProps { const DEFAULT_CENTER_LAT_LNG = new LatLng(48.8584, 2.2945); const LeafletMap = (props: MapProps) => { + const { userGeneralSetting } = useAuth(); const position = props.latlng || DEFAULT_CENTER_LAT_LNG; + const isDark = useMemo(() => resolveTheme(userGeneralSetting?.theme || "system").includes("dark"), [userGeneralSetting?.theme]); + return ( - - + + {}} /> diff --git a/web/src/components/UserMemoMap/UserMemoMap.tsx b/web/src/components/UserMemoMap/UserMemoMap.tsx new file mode 100644 index 000000000..117909b98 --- /dev/null +++ b/web/src/components/UserMemoMap/UserMemoMap.tsx @@ -0,0 +1,145 @@ +import { timestampDate } from "@bufbuild/protobuf/wkt"; +import L, { DivIcon } from "leaflet"; +import "leaflet.markercluster/dist/MarkerCluster.Default.css"; +import "leaflet.markercluster/dist/MarkerCluster.css"; +import { ArrowUpRightIcon, MapPinIcon } from "lucide-react"; +import { useEffect, useMemo } from "react"; +import ReactDOMServer from "react-dom/server"; +import { MapContainer, Marker, Popup, TileLayer, useMap } from "react-leaflet"; +import MarkerClusterGroup from "react-leaflet-cluster"; +import { Link } from "react-router-dom"; +import Spinner from "@/components/Spinner"; +import { useAuth } from "@/contexts/AuthContext"; +import { useInfiniteMemos } from "@/hooks/useMemoQueries"; +import { cn } from "@/lib/utils"; +import { State } from "@/types/proto/api/v1/common_pb"; +import { Memo } from "@/types/proto/api/v1/memo_service_pb"; +import { resolveTheme } from "@/utils/theme"; + +interface Props { + creator: string; + className?: string; +} + +const markerIcon = new DivIcon({ + className: "relative border-none", + html: ReactDOMServer.renderToString( + , + ), +}); + +interface ClusterGroup { + getChildCount(): number; +} + +const createClusterCustomIcon = (cluster: ClusterGroup) => { + return new DivIcon({ + html: `${cluster.getChildCount()}`, + className: "custom-marker-cluster", + iconSize: L.point(32, 32, true), + }); +}; + +const extractUserIdFromName = (name: string): string => { + const match = name.match(/users\/(\d+)/); + return match ? match[1] : ""; +}; + +const MapFitBounds = ({ memos }: { memos: Memo[] }) => { + const map = useMap(); + + useEffect(() => { + if (memos.length === 0) return; + + const validMemos = memos.filter((m) => m.location); + if (validMemos.length === 0) return; + + const bounds = L.latLngBounds(validMemos.map((memo) => [memo.location!.latitude, memo.location!.longitude])); + map.fitBounds(bounds, { padding: [50, 50] }); + }, [memos, map]); + + return null; +}; + +const UserMemoMap = ({ creator, className }: Props) => { + const { userGeneralSetting } = useAuth(); + const creatorId = useMemo(() => extractUserIdFromName(creator), [creator]); + const isDark = useMemo(() => resolveTheme(userGeneralSetting?.theme || "system").includes("dark"), [userGeneralSetting?.theme]); + + const { data, isLoading } = useInfiniteMemos({ + state: State.NORMAL, + orderBy: "display_time desc", + pageSize: 1000, + filter: `creator_id == ${creatorId}`, + }); + + const memosWithLocation = useMemo(() => data?.pages.flatMap((page) => page.memos).filter((memo) => memo.location) || [], [data]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + const defaultCenter = { lat: 48.8566, lng: 2.3522 }; + + return ( +
+ {memosWithLocation.length === 0 && ( +
+
+ +

No location data found

+
+
+ )} + + + + + {memosWithLocation.map((memo) => ( + + +
+
+ + {memo.displayTime && + timestampDate(memo.displayTime).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + })} + + + View + + +
+
{memo.snippet || "No content"}
+
+
+
+ ))} +
+ +
+
+ ); +}; + +export default UserMemoMap; diff --git a/web/src/components/UserMemoMap/index.ts b/web/src/components/UserMemoMap/index.ts new file mode 100644 index 000000000..f64b3914b --- /dev/null +++ b/web/src/components/UserMemoMap/index.ts @@ -0,0 +1 @@ +export { default } from "./UserMemoMap"; diff --git a/web/src/index.css b/web/src/index.css index b5504ec24..9f68b0ec5 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -347,11 +347,26 @@ vertical-align: baseline; } - /* ======================================== - * Strikethrough (GFM) - * ======================================== */ - + /* Strikethrough (GFM) */ .markdown-content del { text-decoration: line-through; } + + /* Leaflet Popup Overrides */ + .leaflet-popup-content-wrapper { + border-radius: 0.5rem !important; + border: 1px solid var(--border) !important; + background-color: var(--background) !important; + box-shadow: var(--shadow-lg) !important; + } + + .leaflet-popup-content { + margin: 4px !important; + line-height: inherit !important; + font-size: inherit !important; + } + + .leaflet-popup-tip { + background-color: var(--background) !important; + } } diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 60240180a..41d897415 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -57,6 +57,7 @@ "layout": "Layout", "learn-more": "Learn more", "link": "Link", + "map": "Map", "mark": "Mark", "memo": "Memo", "memos": "Memos", diff --git a/web/src/pages/UserProfile.tsx b/web/src/pages/UserProfile.tsx index 54e5185ad..981723e68 100644 --- a/web/src/pages/UserProfile.tsx +++ b/web/src/pages/UserProfile.tsx @@ -1,101 +1,155 @@ import copy from "copy-to-clipboard"; -import { ExternalLinkIcon } from "lucide-react"; +import { ExternalLinkIcon, LayoutListIcon, type LucideIcon, MapIcon } from "lucide-react"; import { toast } from "react-hot-toast"; -import { useParams } from "react-router-dom"; +import { useParams, useSearchParams } from "react-router-dom"; import { MemoRenderContext } from "@/components/MasonryView"; import MemoView from "@/components/MemoView"; import PagedMemoList from "@/components/PagedMemoList"; import UserAvatar from "@/components/UserAvatar"; +import UserMemoMap from "@/components/UserMemoMap"; import { Button } from "@/components/ui/button"; import { useMemoFilters, useMemoSorting } from "@/hooks"; import { useUser } from "@/hooks/useUserQueries"; +import { cn } from "@/lib/utils"; import { State } from "@/types/proto/api/v1/common_pb"; import { Memo } from "@/types/proto/api/v1/memo_service_pb"; import { useTranslate } from "@/utils/i18n"; +type TabView = "memos" | "map"; + +const TabButton = ({ + icon: Icon, + label, + isActive, + onClick, +}: { + icon: LucideIcon; + label: string; + isActive: boolean; + onClick: () => void; +}) => ( + +); + +interface User { + name: string; + username: string; + displayName: string; + avatarUrl?: string; + description?: string; +} + +const ProfileHeader = ({ user, onCopyProfileLink, shareLabel }: { user: User; onCopyProfileLink: () => void; shareLabel: string }) => ( +
+
+ +
+
+

{user.displayName || user.username}

+ {user.displayName &&

@{user.username}

} +
+ {user.description &&

{user.description}

} + +
+
+
+); + const UserProfile = () => { const t = useTranslate(); - const params = useParams(); - const username = params.username; + const username = useParams().username; + const [searchParams, setSearchParams] = useSearchParams(); + const activeTab = (searchParams.get("view") === "map" ? "map" : "memos") as TabView; - // Fetch user with React Query - const { - data: user, - isLoading, - error, - } = useUser(`users/${username}`, { - enabled: !!username, - }); + const { data: user, isLoading, error } = useUser(`users/${username}`, { enabled: !!username }); - // Handle errors if (error && !isLoading) { toast.error(t("message.user-not-found")); } - // Build filter using unified hook (no shortcuts, but includes pinned) const memoFilter = useMemoFilters({ creatorName: user?.name, includeShortcuts: false, includePinned: true, }); - // Get sorting logic using unified hook const { listSort, orderBy } = useMemoSorting({ pinnedFirst: true, state: State.NORMAL, }); const handleCopyProfileLink = () => { - if (!user) { - return; - } - + if (!user) return; copy(`${window.location.origin}/u/${encodeURIComponent(user.username)}`); toast.success(t("message.copied")); }; + const toggleTab = (view: TabView) => { + setSearchParams((prev) => { + view === "map" ? prev.set("view", "map") : prev.delete("view"); + return prev; + }); + }; + + if (isLoading) return null; + return ( -
- {!isLoading && - (user ? ( - <> - {/* User profile header - centered with max width */} -
-
-
- -
-

{user.displayName || user.username}

- {user.username && user.displayName &&

@{user.username}

} -
-
- -
- {user.description && ( -
-

{user.description}

+
+ {user ? ( + <> + + +
+
+ toggleTab("memos")} + /> + toggleTab("map")} /> +
+
+ +
+
+ {activeTab === "memos" ? ( + ( + + )} + listSort={listSort} + orderBy={orderBy} + filter={memoFilter} + /> + ) : ( +
+
)}
- - {/* Memo list - full width for proper masonry layout */} - ( - - )} - listSort={listSort} - orderBy={orderBy} - filter={memoFilter} - /> - - ) : ( -
-

Not found

- ))} + + ) : ( +
+

{t("message.user-not-found")}

+
+ )}
); };