Skip to content

Commit 386a5f3

Browse files
authored
Merge pull request #400 from besscroft/feat/map
feat: add interactive photo map feature
2 parents 797ea4b + a553c72 commit 386a5f3

File tree

12 files changed

+699
-6
lines changed

12 files changed

+699
-6
lines changed

README.md

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ PicImpact 是一个支持自部署的摄影作品展示网站,基于 Next.js +
1414

1515
- 瀑布流相册展示图片,支持[实况照片(Live Photos)](https://support.apple.com/zh-cn/104966),基于 [LivePhotosKit JS](https://developer.apple.com/documentation/livephotoskitjs) 开发。
1616
- 基于 WebGL 的高性能图片查看器,支持流畅的缩放和平移,采用图片分块(Tiling)和 LOD 技术优化大图加载性能。
17+
- 支持地图模组标记图片,根据图片经纬度标记在地图上。
1718
- 点击图片查看原图,浏览图片信息和 EXIF 信息,支持直链访问。
1819
- 响应式设计,在 PC 和移动端都有不错的体验,支持暗黑模式。
1920
- 图片存储兼容 S3 API、Cloudflare R2、Open List API。
@@ -72,12 +73,6 @@ pnpm run dev
7273

7374
如果您有任何建议,欢迎反馈!
7475

75-
### TODO
76-
77-
- [ ] Map 地图展示
78-
79-
...
80-
8176
### 代码贡献
8277

8378
[提出新想法 & 提交 Bug](https://github.com/besscroft/PicImpact/issues/new) | [Fork & Pull Request](https://github.com/besscroft/PicImpact/fork)

app/(theme)/map/layout.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { fetchAlbumsShow } from '~/server/db/query/albums'
2+
import type { AlbumType } from '~/types'
3+
import type { AlbumDataProps } from '~/types/props'
4+
import DockMenu from '~/components/layout/dock-menu'
5+
6+
export default async function MapLayout({
7+
children,
8+
}: Readonly<{
9+
children: React.ReactNode;
10+
}>) {
11+
const getData = async () => {
12+
'use server'
13+
return await fetchAlbumsShow()
14+
}
15+
16+
const data: AlbumType[] = await getData()
17+
18+
const props: AlbumDataProps = {
19+
data: data
20+
}
21+
22+
return (
23+
<>
24+
<DockMenu {...props} />
25+
{children}
26+
</>
27+
)
28+
}

app/(theme)/map/page.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { fetchMapImages } from '~/server/db/query/images'
2+
import { MapView } from '~/components/layout/theme/map/map-view'
3+
import { fetchConfigsByKeys } from '~/server/db/query/configs'
4+
5+
export const dynamic = 'force-dynamic'
6+
7+
export async function generateMetadata() {
8+
const data = await fetchConfigsByKeys(['custom_title'])
9+
const siteTitle = data?.find(item => item.config_key === 'custom_title')?.config_value || 'PicImpact'
10+
return {
11+
title: `Map | ${siteTitle}`,
12+
}
13+
}
14+
15+
export default async function MapPage() {
16+
const images = await fetchMapImages()
17+
18+
return (
19+
<div className="w-full h-screen">
20+
<MapView images={images} />
21+
</div>
22+
)
23+
}

components/layout/dock-menu.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { useTranslations } from 'next-intl'
77
import { useEffect, useState } from 'react'
88
import { useButtonStore } from '~/app/providers/button-store-providers'
99
import { CompassIcon } from '~/components/icons/compass'
10+
import { MapIcon } from 'lucide-react'
1011
import Command from '~/components/layout/command'
1112
import type { AlbumDataProps } from '~/types/props'
1213
import { Label, ListBox, Modal } from '@heroui/react'
@@ -51,6 +52,14 @@ export default function DockMenu(props: Readonly<AlbumDataProps>) {
5152
aria-label={t('Words.album')}
5253
/>
5354
</DockIcon>
55+
<DockIcon>
56+
<MapIcon
57+
onClick={() => router.push('/map')}
58+
size={18}
59+
className="text-black dark:text-white"
60+
aria-label={t('Link.map')}
61+
/>
62+
</DockIcon>
5463
<DockIcon>
5564
<CompassIcon
5665
onClick={() => setCommand(true)}
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
'use client'
2+
3+
import * as React from 'react'
4+
import Map, { Marker, Popup, NavigationControl, ScaleControl, GeolocateControl, FullscreenControl } from 'react-map-gl/maplibre'
5+
import 'maplibre-gl/dist/maplibre-gl.css'
6+
import { useTheme } from 'next-themes'
7+
import type { ImageType } from '~/types'
8+
import Image from 'next/image'
9+
import Link from 'next/link'
10+
import { Card, CardContent } from '~/components/ui/card'
11+
import { Button } from '~/components/ui/button'
12+
import { ExternalLink, X } from 'lucide-react'
13+
14+
interface MapViewProps {
15+
images: ImageType[]
16+
}
17+
18+
export function MapView({ images }: MapViewProps) {
19+
const { resolvedTheme } = useTheme()
20+
const [popupInfo, setPopupInfo] = React.useState<ImageType | null>(null)
21+
22+
// 过滤无效坐标并转换类型
23+
const validImages = React.useMemo(() => {
24+
return images.filter(img => {
25+
const lat = parseFloat(img.lat || '')
26+
const lon = parseFloat(img.lon || '')
27+
return !isNaN(lat) && !isNaN(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180
28+
})
29+
}, [images])
30+
31+
// 根据主题切换地图样式
32+
const mapStyle = React.useMemo(() => {
33+
return resolvedTheme === 'dark'
34+
? 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json'
35+
: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json'
36+
}, [resolvedTheme])
37+
38+
return (
39+
<div className="w-full h-full relative">
40+
<style jsx global>{`
41+
.map-popup .maplibregl-popup-content {
42+
padding: 0 !important;
43+
background: none !important;
44+
box-shadow: none !important;
45+
border: none !important;
46+
}
47+
.map-popup .maplibregl-popup-tip {
48+
border-bottom-color: var(--card) !important;
49+
border-top-color: var(--card) !important;
50+
border-left-color: var(--card) !important;
51+
border-right-color: var(--card) !important;
52+
}
53+
/* 针对移动端或某些情况下的关闭按钮残留 */
54+
.map-popup .maplibregl-popup-close-button {
55+
display: none !important;
56+
}
57+
`}</style>
58+
<Map
59+
initialViewState={{
60+
longitude: 0,
61+
latitude: 20,
62+
zoom: 1.5
63+
}}
64+
style={{ width: '100%', height: '100%' }}
65+
mapStyle={mapStyle}
66+
attributionControl={true}
67+
>
68+
<GeolocateControl position="top-left" />
69+
<FullscreenControl position="top-left" />
70+
<NavigationControl position="top-left" />
71+
<ScaleControl />
72+
73+
{validImages.map((image) => {
74+
const lat = parseFloat(image.lat!)
75+
const lon = parseFloat(image.lon!)
76+
77+
return (
78+
<Marker
79+
key={image.id}
80+
longitude={lon}
81+
latitude={lat}
82+
anchor="bottom"
83+
onClick={(e) => {
84+
// 阻止事件冒泡,避免点击 Marker 时地图同时也响应
85+
e.originalEvent.stopPropagation()
86+
setPopupInfo(image)
87+
}}
88+
>
89+
<div
90+
className="group relative cursor-pointer transform transition-all duration-300 hover:scale-110 hover:z-10"
91+
title={image.title || 'View Photo'}
92+
>
93+
<div className="relative h-10 w-10 overflow-hidden rounded-full border-2 border-white bg-white shadow-lg dark:border-gray-800 dark:bg-gray-800">
94+
<Image
95+
src={image.preview_url || image.url || ''}
96+
alt={image.title || 'Photo'}
97+
fill
98+
className="object-cover"
99+
sizes="40px"
100+
/>
101+
</div>
102+
{/* 箭头装饰 */}
103+
<div className="absolute -bottom-1 left-1/2 h-3 w-3 -translate-x-1/2 rotate-45 border-r-2 border-b-2 border-white bg-white dark:border-gray-800 dark:bg-gray-800"></div>
104+
</div>
105+
</Marker>
106+
)
107+
})}
108+
109+
{popupInfo && (
110+
<Popup
111+
anchor="top"
112+
longitude={parseFloat(popupInfo.lon!)}
113+
latitude={parseFloat(popupInfo.lat!)}
114+
onClose={() => setPopupInfo(null)}
115+
closeButton={false} // 使用自定义关闭按钮
116+
className="map-popup"
117+
maxWidth="300px"
118+
>
119+
<Card className="w-64 overflow-hidden border border-border bg-card shadow-xl">
120+
<div className="relative aspect-[4/3] w-full overflow-hidden">
121+
<Image
122+
src={popupInfo.preview_url || popupInfo.url || ''}
123+
alt={popupInfo.title || 'Photo'}
124+
fill
125+
className="object-cover"
126+
/>
127+
<Button
128+
variant="ghost"
129+
size="icon"
130+
className="absolute right-1 top-1 h-6 w-6 rounded-full bg-black/50 text-white hover:bg-black/70 z-10"
131+
onClick={(e) => {
132+
e.stopPropagation()
133+
setPopupInfo(null)
134+
}}
135+
>
136+
<X className="h-4 w-4" />
137+
</Button>
138+
</div>
139+
<CardContent className="p-3">
140+
<h3 className="font-semibold truncate text-sm mb-1">{popupInfo.title || 'Untitled'}</h3>
141+
{popupInfo.exif && typeof popupInfo.exif === 'object' && (
142+
<p className="text-xs text-muted-foreground truncate">
143+
{/* @ts-ignore */}
144+
{popupInfo.exif.model || 'Unknown Camera'}
145+
</p>
146+
)}
147+
<div className="mt-3 flex justify-end">
148+
<Link href={`/preview/${popupInfo.id}`} passHref>
149+
<Button size="sm" variant="outline" className="h-7 text-xs">
150+
查看详情 <ExternalLink className="ml-1 h-3 w-3" />
151+
</Button>
152+
</Link>
153+
</div>
154+
</CardContent>
155+
</Card>
156+
</Popup>
157+
)}
158+
</Map>
159+
</div>
160+
)
161+
}

messages/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"album": "Album Management",
3434
"account": "Account Settings",
3535
"about": "About",
36+
"map": "Map",
3637
"settings": "Settings",
3738
"preferences": "Preferences",
3839
"password": "Change Password",

messages/ja.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"list": "画像維持",
3333
"album": "アルバム管理",
3434
"about": "について",
35+
"map": "地図",
3536
"settings": "設定",
3637
"preferences": "優先設定",
3738
"password": "パスワード変更",

messages/zh-TW.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"list": "圖片維護",
3333
"album": "相簿管理",
3434
"about": "關於",
35+
"map": "地圖",
3536
"settings": "設定",
3637
"preferences": "首選項",
3738
"password": "密碼修改",

messages/zh.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"album": "相册管理",
3434
"account": "账户设置",
3535
"about": "关于",
36+
"map": "地图",
3637
"settings": "设置",
3738
"preferences": "首选项",
3839
"password": "密码修改",

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@
7878
"input-otp": "1.4.2",
7979
"livephotoskit": "1.5.6",
8080
"lucide-react": "0.554.0",
81+
"maplibre-gl": "^5.15.0",
8182
"motion": "12.23.24",
8283
"next": "16.1.1",
8384
"next-intl": "4.5.5",
@@ -89,6 +90,7 @@
8990
"react-day-picker": "9.11.1",
9091
"react-dom": "19.2.3",
9192
"react-hook-form": "7.62.0",
93+
"react-map-gl": "^8.1.0",
9294
"react-photo-album": "3.2.1",
9395
"react-resizable-panels": "3.0.6",
9496
"recharts": "3.4.1",

0 commit comments

Comments
 (0)