Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
C
canifa_note
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Vũ Hoàng Anh
canifa_note
Commits
0735c11d
Commit
0735c11d
authored
Dec 30, 2025
by
Johnny
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: implement memo map in user profile
parent
f416eb00
Changes
8
Show whitespace changes
Inline
Side-by-side
Showing
8 changed files
with
332 additions
and
66 deletions
+332
-66
package.json
web/package.json
+2
-0
pnpm-lock.yaml
web/pnpm-lock.yaml
+31
-0
LeafletMap.tsx
web/src/components/LeafletMap.tsx
+22
-5
UserMemoMap.tsx
web/src/components/UserMemoMap/UserMemoMap.tsx
+145
-0
index.ts
web/src/components/UserMemoMap/index.ts
+1
-0
index.css
web/src/index.css
+19
-4
en.json
web/src/locales/en.json
+1
-0
UserProfile.tsx
web/src/pages/UserProfile.tsx
+111
-57
No files found.
web/package.json
View file @
0735c11d
...
...
@@ -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"
,
...
...
web/pnpm-lock.yaml
View file @
0735c11d
...
...
@@ -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)
...
...
web/src/components/LeafletMap.tsx
View file @
0735c11d
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
,
use
Memo
,
use
Ref
,
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
/>
...
...
web/src/components/UserMemoMap/UserMemoMap.tsx
0 → 100644
View file @
0735c11d
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
;
web/src/components/UserMemoMap/index.ts
0 → 100644
View file @
0735c11d
export
{
default
}
from
"./UserMemoMap"
;
web/src/index.css
View file @
0735c11d
...
...
@@ -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
;
}
}
web/src/locales/en.json
View file @
0735c11d
...
...
@@ -57,6 +57,7 @@
"layout"
:
"Layout"
,
"learn-more"
:
"Learn more"
,
"link"
:
"Link"
,
"map"
:
"Map"
,
"mark"
:
"Mark"
,
"memo"
:
"Memo"
,
"memos"
:
"Memos"
,
...
...
web/src/pages/UserProfile.tsx
View file @
0735c11d
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
;
// Fetch user with React Query
const
{
data
:
user
,
isLoading
,
error
,
}
=
useUser
(
`users/
${
username
}
`
,
{
enabled
:
!!
username
,
});
const
username
=
useParams
().
username
;
const
[
searchParams
,
setSearchParams
]
=
useSearchParams
();
const
activeTab
=
(
searchParams
.
get
(
"view"
)
===
"map"
?
"map"
:
"memos"
)
as
TabView
;
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
?
(
<
section
className=
"flex min-h-screen w-full flex-col bg-background"
>
{
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
>
<
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
>
{
user
.
description
&&
(
<
div
className=
"py-4"
>
<
p
className=
"text-base text-foreground/80 whitespace-pre-wrap"
>
{
user
.
description
}
</
p
>
</
div
>
)
}
</
div
>
{
/* Memo list - full width for proper masonry layout */
}
<
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
}
/>
...
...
@@ -90,12 +137,19 @@ const UserProfile = () => {
orderBy=
{
orderBy
}
filter=
{
memoFilter
}
/>
)
:
(
<
div
className=
""
>
<
UserMemoMap
creator=
{
user
.
name
}
className=
"h-[60dvh] sm:h-[500px] rounded-xl"
/>
</
div
>
)
}
</
div
>
</
div
>
</>
)
:
(
<
div
className=
"w-full max-w-3xl mx-auto
"
>
<
p
className=
"text-center text-muted-foreground mt-8"
>
Not found
</
p
>
<
div
className=
"flex flex-1 items-center justify-center
"
>
<
p
className=
"text-muted-foreground"
>
{
t
(
"message.user-not-found"
)
}
</
p
>
</
div
>
)
)
}
)
}
</
section
>
);
};
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment