155 |
156 |
157 |
171 |
172 | }
173 |
--------------------------------------------------------------------------------
/src/routes/shuin.tsx:
--------------------------------------------------------------------------------
1 | import { useParams } from "react-router-dom";
2 | import { useEffect, useState, cloneElement, MouseEvent } from "react";
3 | import { Shuin, Response } from "../models/gallery.ts";
4 | import axios from "axios";
5 | import {
6 | Button,
7 | Card,
8 | CardBody,
9 | CardFooter,
10 | CardHeader, Chip,
11 | Divider,
12 | Image,
13 | Link,
14 | } from "@heroui/react";
15 | import moment from "moment/moment";
16 | import useMediaQuery from "../hooks/useMediaQuery.tsx";
17 | import DialogMap from "../components/dialog_map.tsx";
18 | import { MdOutlineOpenInNew } from "react-icons/md";
19 | import { IoCalendarOutline, IoLocationOutline } from "react-icons/io5";
20 | import { useTranslation } from "react-i18next";
21 | import Zoom from 'react-medium-image-zoom'
22 |
23 | export default function ShuinPage() {
24 | const { id } = useParams()
25 | const [shuin, setShuin] = useState
()
26 | const isDesktop = useMediaQuery('(min-width: 960px)');
27 | const { t } = useTranslation()
28 |
29 | useEffect(() => {
30 | axios.get>('https://api.gallery.boar.ac.cn/shuin/get', {
31 | params: { id }
32 | }).then((res) => {
33 | setShuin(res.data.payload)
34 | })
35 | }, [id])
36 |
37 | if (!shuin) return null;
38 |
39 | return (
40 |
41 |
42 | #S{id}
43 |
44 |
45 |
50 | <>
52 | {buttonUnzoom}
53 | {img ? cloneElement(img, {
54 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
55 | // @ts-expect-error
56 | draggable: false,
57 | onContextMenu: (e: MouseEvent) => e.preventDefault()
58 | }) : null}
59 | >}
60 | >
61 | e.preventDefault()}
70 | src={shuin.large_file!.url}
71 | width={shuin.large_file!.width}
72 | height={shuin.large_file!.height}
73 | style={{ height: 'auto' }}
74 | />
75 |
76 |
78 | © {moment(shuin.date).year()} {shuin.place.name}
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 | {
89 | shuin.place.city ?
90 |
91 |
92 |
93 |
94 |
{shuin.place.city?.prefecture.name}
96 |
{shuin.place.city?.name}
98 |
99 |
100 | {shuin.place.name}
101 |
102 |
103 |
104 | :
105 | null
106 | }
107 |
108 |
109 |
110 |
111 | {moment(shuin.date).format('YYYY/MM')}
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 | {t('shuin.basic_information')}
121 |
122 |
123 | {t('shuin.price')}:{shuin.price} {t('shuin.yen')}
124 |
125 |
126 |
127 |
128 | {t(`shuin.type.${shuin.type}`)}
129 |
130 |
131 | {
132 | shuin.is_limited ?
133 |
134 | {t('shuin.is_limited')}
135 |
136 | :
137 | null
138 | }
139 |
140 |
141 |
142 |
143 |
144 |
145 |
147 |
160 |
161 |
162 |
163 |
164 | );
165 | }
166 |
--------------------------------------------------------------------------------
/src/routes/root.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Button, Divider, Dropdown,
3 | DropdownItem, DropdownMenu, DropdownTrigger, Link,
4 | Listbox,
5 | ListboxItem,
6 | Navbar,
7 | NavbarBrand,
8 | NavbarContent,
9 | NavbarItem, NavbarMenu, NavbarMenuItem, NavbarMenuToggle, Spacer
10 | } from "@heroui/react";
11 | import useDarkMode from "use-dark-mode";
12 | import { TbBook, TbHome, TbMap, TbMoon, TbSun } from "react-icons/tb";
13 | import { Outlet, useNavigate } from "react-router-dom";
14 | import { LoadingContext } from "../contexts/loading";
15 | import { useEffect, useState } from "react";
16 | import { MapToken, MapTokenContext, MapType } from "../contexts/map_token.tsx";
17 | import axios from "axios";
18 | import { Response } from "../models/gallery.ts";
19 | import { HiOutlineTranslate } from "react-icons/hi";
20 | import { useTranslation } from "react-i18next";
21 | import moment from "moment";
22 | import gradLeft from '../assets/gradients/left.png';
23 | import gradRight from '../assets/gradients/right.png';
24 | import { FaDice } from "react-icons/fa6";
25 |
26 | const routes = [
27 | { route: '/', text: 'sidebar.home', icon: },
28 | { route: '/shuin', text: 'sidebar.shuin', icon: },
29 | { route: '/map', text: 'sidebar.map', icon: },
30 | ]
31 |
32 | export default function Root() {
33 | const darkMode = useDarkMode(false, {
34 | classNameDark: 'dark',
35 | classNameLight: 'light',
36 | element: document.documentElement,
37 | });
38 |
39 | useEffect(() => {
40 | axios.get>('https://api.gallery.boar.ac.cn/geo/ip').then(async (res) => {
41 | if (res.data.payload === 'CN') {
42 | // mapbox
43 | axios.get>('https://api.gallery.boar.ac.cn/mapbox/token').then((res) => {
44 | setToken({ type: MapType.MapBox, token: res.data.payload })
45 | })
46 | } else {
47 | // apple map
48 | axios.get>('https://api.gallery.boar.ac.cn/mapkit-js/token').then((res) => {
49 | setToken({ type: MapType.Apple, token: res.data.payload })
50 | })
51 | }
52 | })
53 | }, [])
54 |
55 | const [loading, setLoading] = useState(false)
56 | const [token, setToken] = useState()
57 | const [isMenuOpen, setIsMenuOpen] = useState(false);
58 | const { t, i18n } = useTranslation()
59 | const navigate = useNavigate();
60 |
61 | return (
62 |
63 |
64 |
66 |

69 |
70 |
71 |
73 |

76 |
77 |
78 |
80 |
81 |
82 | Boar Gallery
83 |
84 |
85 |
86 |
87 |
88 |
91 |
92 | i18n.changeLanguage((l as Set).values().next().value)}
99 | >
100 | 简体中文
101 | English
102 | 日本語
103 |
104 |
105 |
106 |
107 |
115 |
116 |
117 |
118 |
119 |
120 | {
121 | routes.map((r) => (
122 |
123 | {
127 | navigate(r.route)
128 | setIsMenuOpen(false)
129 | }}
130 | color='foreground'
131 | >
132 | {r.icon}
133 |
134 | {t(r.text)}
135 |
136 |
137 | ))
138 | }
139 |
140 |
141 | {
145 | const id = (await axios.get>('https://api.gallery.boar.ac.cn/photos/lucky')).data.payload
146 | navigate(`/photo/${id}`)
147 | setIsMenuOpen(false)
148 | }}
149 | color='foreground'
150 | >
151 |
152 |
153 | {t('sidebar.lucky')}
154 |
155 |
156 |
157 |
158 |
159 |
160 |
{t('copyright.reserved', { year: moment().year() })}
161 |
{t('copyright.description')}
162 |
163 |
164 |
165 |
166 |
170 |
171 |
172 | {
173 | [...routes.map((r) => (
174 |
181 | {t(r.text)}
182 |
183 | )),
184 | {
187 | const id = (await axios.get>('https://api.gallery.boar.ac.cn/photos/lucky')).data.payload
188 | navigate(`/photo/${id}`)
189 | }}
190 | className="px-4 py-3"
191 | variant="flat"
192 | startContent={}
193 | >
194 | {t('sidebar.lucky')}
195 |
196 | ]
197 | }
198 |
199 |
200 |
201 |
202 |
203 |
{t('copyright.reserved', { year: moment().year() })}
204 |
{t('copyright.description')}
205 |
206 |
207 |
208 |
209 |
210 |
211 |
212 |
213 |
214 | );
215 | }
216 |
--------------------------------------------------------------------------------
/src/routes/photo.tsx:
--------------------------------------------------------------------------------
1 | import { useParams } from "react-router-dom";
2 | import { useEffect, useState, cloneElement, MouseEvent } from "react";
3 | import { Photo, Response } from "../models/gallery.ts";
4 | import axios from "axios";
5 | import {
6 | Button,
7 | Card,
8 | CardBody,
9 | CardFooter,
10 | CardHeader,
11 | Divider,
12 | Image,
13 | Link,
14 | Switch,
15 | Tooltip
16 | } from "@heroui/react";
17 | import moment from "moment/moment";
18 | import useMediaQuery from "../hooks/useMediaQuery.tsx";
19 | import ManufactureIcon from "../components/manufacture_icon.tsx";
20 | import DialogMap from "../components/dialog_map.tsx";
21 | import { MdOutlineOpenInNew } from "react-icons/md";
22 | import { IoCalendarOutline, IoLocationOutline } from "react-icons/io5";
23 | import { PiMountains } from "react-icons/pi";
24 | import { useTranslation } from "react-i18next";
25 | import CameraName from "../components/camera_name.tsx";
26 | import { RxQuestionMarkCircled } from "react-icons/rx";
27 | import Zoom from 'react-medium-image-zoom'
28 |
29 | export default function PhotoPage() {
30 | const { id } = useParams()
31 | const [photo, setPhoto] = useState()
32 | const isDesktop = useMediaQuery('(min-width: 960px)');
33 | const { t } = useTranslation()
34 | const [showHDR, setShowHDR] = useState(false);
35 |
36 | useEffect(() => {
37 | axios.get>('https://api.gallery.boar.ac.cn/photos/get', {
38 | params: { id }
39 | }).then((res) => {
40 | setPhoto(res.data.payload)
41 | })
42 | }, [id])
43 |
44 | if (!photo) return null;
45 |
46 | return (
47 |
48 |
49 | #{id}
50 |
51 |
52 |
57 | <>
59 | {buttonUnzoom}
60 | {img ? cloneElement(img, {
61 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
62 | // @ts-expect-error
63 | draggable: false,
64 | onContextMenu: (e: MouseEvent) => e.preventDefault()
65 | }) : null}
66 | >}
67 | >
68 | e.preventDefault()}
77 | src={showHDR ? photo.hdr_file!.url : photo.large_file!.url}
78 | width={showHDR ? photo.hdr_file!.width : photo.large_file!.width}
79 | height={showHDR ? photo.hdr_file!.height : photo.large_file!.height}
80 | style={{ height: 'auto' }}
81 | />
82 |
83 |
85 | © {moment(photo.metadata.datetime).year()} {photo.author?.name}
87 |
88 |
89 |
90 | {
91 | photo.hdr_file ?
92 |
93 |
94 |
95 | {t('photo.enable_hdr')}
96 |
97 |
101 |
102 |
103 |
104 |
105 |
106 |
107 | :
108 | null
109 | }
110 |
111 |
112 |
113 |
114 |
115 |
116 | {
117 | photo.metadata.city ?
118 |
119 |
120 |
121 |
122 | {photo.metadata.city.prefecture.country.name}
124 | {photo.metadata.city.prefecture.name}
126 | {photo.metadata.city.name}
128 |
129 | {
130 | photo.metadata.place ?
131 |
132 | {photo.metadata.place.name}
133 |
134 | :
135 | null
136 | }
137 |
138 |
139 | :
140 | null
141 | }
142 |
143 |
144 |
145 |
146 | {moment(photo.metadata.datetime).utcOffset(`+${photo.metadata.timezone.split('+')[1]}`).format('M/D HH:mm ([GMT]Z)')}
147 |
148 |
149 |
150 | {
151 | photo.metadata.altitude ?
152 |
153 |
154 |
155 | {parseFloat(photo.metadata.altitude.toFixed(2))}m
156 |
157 |
158 | :
159 | null
160 | }
161 |
162 |
163 |
164 |
165 |
166 |
167 |
168 |
169 |
170 |
171 | {photo.metadata.lens ?
172 | `${photo.metadata.lens.manufacture.name} ${photo.metadata.lens.model}`
173 | :
174 | t('unknown_lens')
175 | }
176 |
177 |
178 |
179 | ISO {photo.metadata.photographic_sensitivity}
180 | |
181 | ƒ{photo.metadata.f_number}
182 | |
183 | {photo.metadata.exposure_time_rat} s
184 | |
185 | {photo.metadata.focal_length} mm
186 |
187 |
188 |
189 |
190 | {
191 | photo.metadata.location &&
192 |
193 |
194 |
196 |
209 |
210 |
211 | }
212 |
213 |
214 | );
215 | }
216 |
--------------------------------------------------------------------------------
/src/components/shuin_modal.tsx:
--------------------------------------------------------------------------------
1 | import { Response, Shuin } from "../models/gallery.ts";
2 | import {
3 | Image,
4 | Modal,
5 | ModalContent,
6 | ModalHeader,
7 | ModalBody, Spacer, Card, CardFooter, Button, Chip
8 | } from "@heroui/react";
9 | import { IoCalendarOutline, IoLocationOutline, IoPricetagOutline } from "react-icons/io5";
10 | import moment from "moment";
11 | import useMediaQuery from "../hooks/useMediaQuery.tsx";
12 | import DialogMap from "./dialog_map.tsx";
13 | import { MdOutlineOpenInNew } from "react-icons/md";
14 | import { useTranslation } from "react-i18next";
15 | import { useEffect, useMemo, useState } from "react";
16 | import axios from "axios";
17 |
18 | export interface ShuinModalProps {
19 | shuin: Shuin
20 | isOpen: boolean,
21 | onOpenChange: (isOpen: boolean, path?: string) => void;
22 | }
23 |
24 | export default function ShuinModal(props: ShuinModalProps) {
25 | const isDesktop = useMediaQuery('(min-width: 960px)');
26 | const { t } = useTranslation()
27 | const [shuin, setShuin] = useState(props.shuin)
28 | const [loading, setLoading] = useState(true)
29 |
30 | useEffect(() => {
31 | if (props.isOpen) {
32 | setLoading(true)
33 | setTimeout(() => {
34 | axios.get>(`https://api.gallery.boar.ac.cn/shuin/get?id=${props.shuin.id}`).then(res => {
35 | setShuin(res.data.payload)
36 | setLoading(false)
37 | })
38 | }, 100)
39 | }
40 | }, [props.isOpen, props.shuin.id])
41 |
42 | // if (!photo.medium_file) return null;
43 | const isPortrait = shuin.thumb_file.width <= shuin.thumb_file.height;
44 |
45 | const cityLinks = useMemo(() =>
46 |
{shuin.place.city?.prefecture.name}
48 |
{shuin.place.city?.name}
50 |
, [shuin.place.city?.prefecture.name, shuin.place.city?.name])
51 |
52 | const modal = useMemo(() => {
53 | return
54 | {() => (
55 | (!isDesktop) || (!isPortrait) ?
56 | <>
57 |
58 |
63 |
76 |
78 | © {moment(shuin.date).year()} {shuin.place.name}
80 |
81 |
82 |
83 |
84 |
85 | {
86 | shuin.place.city ?
87 |
88 |
89 |
90 | {cityLinks}
91 |
92 |
{shuin.place.name}
93 |
94 |
95 |
96 | :
97 |
98 | }
99 |
100 |
101 |
102 |
103 |
104 | {moment(shuin.date).format('YYYY/MM')}
105 |
106 |
107 |
108 |
109 |
110 |
111 | {shuin.price} {t('shuin.yen')}
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 | {t(`shuin.type.${shuin.type}`)}
121 |
122 |
123 | {
124 | shuin.is_limited ?
125 |
126 | {t('shuin.is_limited')}
127 |
128 | :
129 | null
130 | }
131 |
132 |
133 |
134 |
135 |
137 |
150 |
151 |
152 |
153 |
154 | >
155 | :
156 | <>
157 |
158 |
159 |
160 |
161 |
166 |
180 |
182 | © {moment(shuin.date).year()} {shuin.place.name}
184 |
185 |
186 |
187 |
188 |
189 | {
190 | shuin.place.city ?
191 |
192 |
193 |
194 | {cityLinks}
195 |
196 |
{shuin.place.name}
197 |
198 |
199 |
200 | :
201 | null
202 | }
203 |
204 |
205 |
206 |
207 | {moment(shuin.date).format('YYYY/MM')}
208 |
209 |
210 |
211 |
212 | {shuin.price} {t('shuin.yen')}
213 |
214 |
215 |
216 |
217 |
218 | {t(`shuin.type.${shuin.type}`)}
219 |
220 |
221 | {
222 | shuin.is_limited ?
223 |
224 | {t('shuin.is_limited')}
225 |
226 | :
227 | null
228 | }
229 |
230 |
231 |
232 |
233 |
234 |
235 |
236 |
238 |
251 |
252 |
253 |
254 |
255 |
256 |
257 | >
258 | )}
259 |
260 | }, [cityLinks, isDesktop, isPortrait, loading, shuin.date, shuin.medium_file?.height, shuin.medium_file?.url, shuin.medium_file?.width, shuin.place.city, shuin.place.geom, shuin.place.name])
261 |
262 | return
272 | {modal}
273 | ;
274 | }
275 |
--------------------------------------------------------------------------------
/src/components/photo_modal.tsx:
--------------------------------------------------------------------------------
1 | import { Photo, Response } from "../models/gallery.ts";
2 | import {
3 | Image,
4 | Modal,
5 | ModalContent,
6 | ModalHeader,
7 | ModalBody, Link, Spacer, Card, CardFooter, CardHeader, CardBody, Divider, Button, Skeleton
8 | } from "@heroui/react";
9 | import { IoCalendarOutline, IoLocationOutline } from "react-icons/io5";
10 | import moment from "moment";
11 | import useMediaQuery from "../hooks/useMediaQuery.tsx";
12 | import ManufactureIcon from "./manufacture_icon.tsx";
13 | import DialogMap from "./dialog_map.tsx";
14 | import { MdOutlineOpenInNew } from "react-icons/md";
15 | import { useTranslation } from "react-i18next";
16 | import { useEffect, useMemo, useState } from "react";
17 | import axios from "axios";
18 | import CameraName from "./camera_name.tsx";
19 |
20 | export interface PhotoModalProps {
21 | photo: Photo
22 | isOpen: boolean,
23 | onOpenChange: (isOpen: boolean, path?: string) => void;
24 | }
25 |
26 | export default function PhotoModal(props: PhotoModalProps) {
27 | const isDesktop = useMediaQuery('(min-width: 960px)');
28 | const { t } = useTranslation()
29 | const [photo, setPhoto] = useState(props.photo)
30 | const [loading, setLoading] = useState(true)
31 |
32 | useEffect(() => {
33 | if (props.isOpen) {
34 | setLoading(true)
35 | setTimeout(() => {
36 | axios.get>(`https://api.gallery.boar.ac.cn/photos/get?id=${props.photo.id}`).then(res => {
37 | setPhoto(res.data.payload)
38 | setLoading(false)
39 | })
40 | }, 100)
41 | }
42 | }, [props.isOpen, props.photo.id])
43 |
44 | // if (!photo.medium_file) return null;
45 | const isPortrait = photo.thumb_file.width <= photo.thumb_file.height;
46 | const photoDateTime = moment(photo.metadata.datetime).utcOffset(`+${photo.metadata.timezone.split('+')[1]}`).format('M/D HH:mm ([GMT]Z)');
47 |
48 | const cityLinks = useMemo(() =>
49 | {photo.metadata.city?.prefecture.country.name}
51 | props.onOpenChange(false, `/prefecture/${photo.metadata.city?.prefecture.id}`)}
55 | >
56 | {photo.metadata.city?.prefecture.name}
57 |
58 | props.onOpenChange(false, `/prefecture/${photo.metadata.city?.prefecture.id}/city/${photo.metadata.city?.id}`)}
62 | >
63 | {photo.metadata.city?.name}
64 |
65 |
, [photo.metadata.city?.id, photo.metadata.city?.name, photo.metadata.city?.prefecture.country.name, photo.metadata.city?.prefecture.id, photo.metadata.city?.prefecture.name, props])
66 |
67 | const modal = useMemo(() => {
68 | return
69 | {() => (
70 | (!isDesktop) || (!isPortrait) ?
71 | <>
72 |
73 |
78 |
91 |
93 | © {moment(photo.metadata.datetime).year()} {photo.author?.name}
95 |
96 |
97 |
98 |
99 |
100 | {
101 | photo.metadata.city ?
102 |
103 |
104 |
105 | {cityLinks}
106 | {
107 | photo.metadata.place ?
108 |
109 | {photo.metadata.place.name}
110 |
111 | :
112 | null
113 | }
114 |
115 |
116 | :
117 | null
118 | }
119 |
120 |
121 |
122 |
123 | {photoDateTime}
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 | {
132 | loading ?
133 |
134 |
135 |
136 | :
137 | <>
138 |
140 |
141 | >
142 | }
143 |
144 |
145 | {
146 | loading ?
147 |
148 |
149 |
150 | :
151 | (photo.metadata.lens ?
152 | `${photo.metadata.lens?.manufacture.name} ${photo.metadata.lens?.model}`
153 | :
154 | t('unknown_lens')
155 | )
156 | }
157 |
158 |
159 |
160 | ISO {photo.metadata.photographic_sensitivity}
161 | |
162 | ƒ{photo.metadata.f_number}
163 | |
164 | {photo.metadata.exposure_time_rat} s
165 | |
166 | {photo.metadata.focal_length} mm
167 |
168 |
169 |
170 | {
171 | (photo.metadata.has_location || photo.metadata.location) ?
172 | (
173 | photo.metadata.location ?
174 |
175 |
176 |
178 |
191 |
192 |
193 | :
194 |
195 | )
196 | :
197 | null
198 | }
199 |
200 |
201 | >
202 | :
203 | <>
204 |
205 |
206 |
207 |
208 |
213 |
227 |
229 | © {moment(photo.metadata.datetime).year()} {photo.author?.name}
231 |
232 |
233 |
234 |
235 |
236 | {
237 | photo.metadata.city ?
238 |
239 |
240 |
241 | {cityLinks}
242 | {
243 | photo.metadata.place ?
244 |
245 | {photo.metadata.place.name}
246 |
247 | :
248 | null
249 | }
250 |
251 |
252 | :
253 | null
254 | }
255 |
256 |
257 |
258 | {photoDateTime}
259 |
260 |
261 |
262 |
263 |
264 |
265 |
266 | {
267 | loading ?
268 |
269 |
270 |
271 | :
272 | <>
273 |
275 |
276 | >
277 | }
278 |
279 |
280 | {
281 | loading ?
282 |
283 |
284 |
285 | :
286 | `${photo.metadata.lens?.manufacture.name} ${photo.metadata.lens?.model}`
287 | }
288 |
289 |
290 |
291 | ISO {photo.metadata.photographic_sensitivity}
292 | |
293 | ƒ{photo.metadata.f_number}
294 | |
295 | {photo.metadata.exposure_time_rat} s
296 | |
297 | {photo.metadata.focal_length} mm
298 |
299 |
300 |
301 | {
302 | (photo.metadata.has_location || photo.metadata.location) ?
303 | (
304 | photo.metadata.location ?
305 |
306 |
307 |
309 |
322 |
323 |
324 | :
325 |
326 | )
327 | :
328 | null
329 | }
330 |
331 |
332 |
333 |
334 | >
335 | )}
336 |
337 | }, [cityLinks, isDesktop, isPortrait, loading, photo.author?.name, photo.medium_file?.height, photo.medium_file?.url, photo.medium_file?.width, photo.metadata.camera, photo.metadata.city, photo.metadata.datetime, photo.metadata.exposure_time_rat, photo.metadata.f_number, photo.metadata.focal_length, photo.metadata.has_location, photo.metadata.lens, photo.metadata.location, photo.metadata.photographic_sensitivity, photo.metadata.place, photo.metadata.timezone, t])
338 |
339 | return
349 | {modal}
350 | ;
351 | }
352 |
--------------------------------------------------------------------------------