feat: implement memo map in user profile

This commit is contained in:
Johnny 2025-12-30 20:41:44 +08:00
parent f416eb00b0
commit 0735c11d75
8 changed files with 331 additions and 65 deletions

View file

@ -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",

31
web/pnpm-lock.yaml generated
View file

@ -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)

View file

@ -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 (
<MapContainer className="w-full h-72" center={position} zoom={13} scrollWheelZoom={false} zoomControl={false}>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
<MapContainer
className="w-full h-72"
center={position}
zoom={13}
scrollWheelZoom={false}
zoomControl={false}
attributionControl={false}
>
<TileLayer
url={
isDark ? "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png" : "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
}
/>
<LocationMarker position={position} readonly={props.readonly} onChange={props.onChange ? props.onChange : () => {}} />
<MapControls position={props.latlng} />
<MapCleanup />

View file

@ -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(
<MapPinIcon className="absolute bottom-1/2 -left-1/2 text-red-500 drop-shadow-md" fill="currentColor" size={32} />,
),
});
interface ClusterGroup {
getChildCount(): number;
}
const createClusterCustomIcon = (cluster: ClusterGroup) => {
return new DivIcon({
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>`,
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 (
<div className="w-full h-[380px] flex items-center justify-center rounded-xl border border-border bg-muted/30">
<Spinner className="w-8 h-8" />
</div>
);
}
const defaultCenter = { lat: 48.8566, lng: 2.3522 };
return (
<div className={cn("relative z-0 w-full h-[380px] rounded-xl overflow-hidden border border-border shadow-sm", className)}>
{memosWithLocation.length === 0 && (
<div className="absolute inset-0 z-[1000] flex items-center justify-center pointer-events-none">
<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">
<MapPinIcon className="h-5 w-5 text-muted-foreground opacity-60" />
<p className="text-xs font-medium text-muted-foreground">No location data found</p>
</div>
</div>
)}
<MapContainer center={defaultCenter} zoom={2} className="h-full w-full z-0" scrollWheelZoom attributionControl={false}>
<TileLayer
url={
isDark ? "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png" : "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
}
/>
<MarkerClusterGroup
chunkedLoading
iconCreateFunction={createClusterCustomIcon}
maxClusterRadius={40}
spiderfyOnMaxZoom
showCoverageOnHover={false}
>
{memosWithLocation.map((memo) => (
<Marker key={memo.name} position={[memo.location!.latitude, memo.location!.longitude]} icon={markerIcon}>
<Popup closeButton={false} className="w-48!">
<div className="flex flex-col p-0.5">
<div className="flex items-center justify-between border-b border-border pb-1 mb-1">
<span className="text-[10px] font-medium text-muted-foreground">
{memo.displayTime &&
timestampDate(memo.displayTime).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})}
</span>
<Link
to={`/m/${memo.name.split("/").pop()}`}
className="flex items-center gap-0.5 text-[10px] text-primary hover:opacity-80"
>
View
<ArrowUpRightIcon className="h-3 w-3" />
</Link>
</div>
<div className="line-clamp-3 py-0.5 text-xs font-sans leading-snug text-foreground">{memo.snippet || "No content"}</div>
</div>
</Popup>
</Marker>
))}
</MarkerClusterGroup>
<MapFitBounds memos={memosWithLocation} />
</MapContainer>
</div>
);
};
export default UserMemoMap;

View file

@ -0,0 +1 @@
export { default } from "./UserMemoMap";

View file

@ -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;
}
}

View file

@ -57,6 +57,7 @@
"layout": "Layout",
"learn-more": "Learn more",
"link": "Link",
"map": "Map",
"mark": "Mark",
"memo": "Memo",
"memos": "Memos",

View file

@ -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;
}) => (
<button
type="button"
onClick={onClick}
className={cn(
"flex items-center gap-2 px-3 py-2 text-sm font-medium transition-all duration-200 border-b-2 rounded-t-lg",
isActive
? "border-primary text-primary bg-primary/5"
: "border-transparent text-muted-foreground hover:text-foreground hover:bg-muted/50",
)}
>
<Icon className="h-4 w-4" />
{label}
</button>
);
interface User {
name: string;
username: string;
displayName: string;
avatarUrl?: string;
description?: string;
}
const ProfileHeader = ({ user, onCopyProfileLink, shareLabel }: { user: User; onCopyProfileLink: () => void; shareLabel: string }) => (
<div className="border-b border-border/10 px-4 py-8 sm:px-6">
<div className="mx-auto flex max-w-2xl gap-4 sm:gap-6">
<UserAvatar className="h-20 w-20 shrink-0 rounded-2xl shadow-sm sm:h-24 sm:w-24" avatarUrl={user.avatarUrl} />
<div className="flex flex-1 flex-col gap-3">
<div>
<h1 className="text-2xl font-bold text-foreground sm:text-3xl">{user.displayName || user.username}</h1>
{user.displayName && <p className="text-sm text-muted-foreground">@{user.username}</p>}
</div>
{user.description && <p className="text-sm text-foreground/70">{user.description}</p>}
<Button variant="outline" size="sm" onClick={onCopyProfileLink} className="w-fit gap-2">
<ExternalLinkIcon className="h-4 w-4" />
{shareLabel}
</Button>
</div>
</div>
</div>
);
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 (
<section className="w-full min-h-full flex flex-col justify-start items-center">
{!isLoading &&
(user ? (
<>
{/* User profile header - centered with max width */}
<div className="w-full max-w-4xl mx-auto mb-8">
<div className="w-full flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 py-6 border-b border-border">
<div className="flex items-center gap-4">
<UserAvatar className="w-20! h-20! drop-shadow rounded-full" avatarUrl={user?.avatarUrl} />
<div className="flex flex-col justify-center items-start">
<h1 className="text-2xl sm:text-3xl font-semibold text-foreground">{user.displayName || user.username}</h1>
{user.username && user.displayName && <p className="text-sm text-muted-foreground">@{user.username}</p>}
</div>
</div>
<Button variant="outline" onClick={handleCopyProfileLink} className="shrink-0">
{t("common.share")}
<ExternalLinkIcon className="ml-1 w-4 h-auto opacity-60" />
</Button>
</div>
{user.description && (
<div className="py-4">
<p className="text-base text-foreground/80 whitespace-pre-wrap">{user.description}</p>
<section className="flex min-h-screen w-full flex-col bg-background">
{user ? (
<>
<ProfileHeader user={user} onCopyProfileLink={handleCopyProfileLink} shareLabel={t("common.share")} />
<div className="border-b border-border/10 mb-4">
<div className="mx-auto flex max-w-2xl">
<TabButton
icon={LayoutListIcon}
label={t("common.memos")}
isActive={activeTab === "memos"}
onClick={() => toggleTab("memos")}
/>
<TabButton icon={MapIcon} label={t("common.map")} isActive={activeTab === "map"} onClick={() => toggleTab("map")} />
</div>
</div>
<div className="flex-1">
<div className="mx-auto w-full max-w-2xl">
{activeTab === "memos" ? (
<PagedMemoList
renderer={(memo: Memo, context?: MemoRenderContext) => (
<MemoView key={`${memo.name}-${memo.displayTime}`} memo={memo} showVisibility showPinned compact={context?.compact} />
)}
listSort={listSort}
orderBy={orderBy}
filter={memoFilter}
/>
) : (
<div className="">
<UserMemoMap creator={user.name} className="h-[60dvh] sm:h-[500px] rounded-xl" />
</div>
)}
</div>
{/* Memo list - full width for proper masonry layout */}
<PagedMemoList
renderer={(memo: Memo, context?: MemoRenderContext) => (
<MemoView key={`${memo.name}-${memo.displayTime}`} memo={memo} showVisibility showPinned compact={context?.compact} />
)}
listSort={listSort}
orderBy={orderBy}
filter={memoFilter}
/>
</>
) : (
<div className="w-full max-w-3xl mx-auto">
<p className="text-center text-muted-foreground mt-8">Not found</p>
</div>
))}
</>
) : (
<div className="flex flex-1 items-center justify-center">
<p className="text-muted-foreground">{t("message.user-not-found")}</p>
</div>
)}
</section>
);
};