mirror of
https://github.com/usememos/memos.git
synced 2026-01-06 00:14:28 +08:00
feat: implement memo map in user profile
This commit is contained in:
parent
f416eb00b0
commit
0735c11d75
8 changed files with 331 additions and 65 deletions
|
|
@ -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
31
web/pnpm-lock.yaml
generated
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
145
web/src/components/UserMemoMap/UserMemoMap.tsx
Normal file
145
web/src/components/UserMemoMap/UserMemoMap.tsx
Normal 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;
|
||||
1
web/src/components/UserMemoMap/index.ts
Normal file
1
web/src/components/UserMemoMap/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./UserMemoMap";
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@
|
|||
"layout": "Layout",
|
||||
"learn-more": "Learn more",
|
||||
"link": "Link",
|
||||
"map": "Map",
|
||||
"mark": "Mark",
|
||||
"memo": "Memo",
|
||||
"memos": "Memos",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue