├── src ├── vite-env.d.ts ├── assets │ ├── gradients │ │ ├── left.png │ │ └── right.png │ └── logos │ │ ├── Apple.svg │ │ ├── Apple_dark.svg │ │ ├── Lumix_logo.svg │ │ ├── Lumix_logo_dark.svg │ │ ├── SIGMA.svg │ │ ├── SIGMA_dark.svg │ │ ├── SONY.svg │ │ ├── SONY_dark.svg │ │ ├── Sony_Alpha_logo.svg │ │ ├── Panasonic.svg │ │ └── Panasonic_dark.svg ├── routes │ ├── index.tsx │ ├── shuin_list.tsx │ ├── prefecture.tsx │ ├── cluster.tsx │ ├── map_openlayers.tsx │ ├── shuin.tsx │ ├── root.tsx │ └── photo.tsx ├── contexts │ ├── loading.tsx │ └── map_token.tsx ├── hooks │ └── useMediaQuery.tsx ├── i18n │ ├── ja.ts │ ├── zh_CN.ts │ └── en.ts ├── main.tsx ├── index.css ├── App.tsx ├── components │ ├── manufacture_icon.tsx │ ├── camera_name.tsx │ ├── dialog_map.tsx │ ├── shuin_masonry.tsx │ ├── photo_masonry.tsx │ ├── shuin_modal.tsx │ └── photo_modal.tsx ├── models │ └── gallery.ts └── App.css ├── public └── logo.png ├── postcss.config.js ├── vercel.json ├── README.md ├── tsconfig.node.json ├── .gitignore ├── vite.config.ts ├── .eslintrc.cjs ├── tsconfig.json ├── index.html ├── package.json └── tailwind.config.js /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hebingchang/boar-gallery-web/HEAD/public/logo.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | '@tailwindcss/postcss': {}, 4 | }, 5 | } 6 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "rewrites": [ 3 | {"source": "/(.*)", "destination": "/"} 4 | ] 5 | } 6 | 7 | -------------------------------------------------------------------------------- /src/assets/gradients/left.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hebingchang/boar-gallery-web/HEAD/src/assets/gradients/left.png -------------------------------------------------------------------------------- /src/assets/gradients/right.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hebingchang/boar-gallery-web/HEAD/src/assets/gradients/right.png -------------------------------------------------------------------------------- /src/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import PhotoMasonry from "../components/photo_masonry.tsx"; 2 | 3 | 4 | export default function Index() { 5 | return
6 | 7 |
8 | } 9 | -------------------------------------------------------------------------------- /src/contexts/loading.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext } from "react"; 2 | 3 | export const LoadingContext = createContext<{ 4 | loading: boolean, 5 | setLoading: React.Dispatch> 6 | } | null>(null); 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Boar Gallery 2 | 3 | This is the frontend service for [Boar Gallery](https://gallery.boar.osaka). 4 | 5 | It has to work with the specific backend, which is not yet open source. 6 | 7 | ## Development 8 | 9 | ```bash 10 | yarn dev 11 | ``` 12 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true 9 | }, 10 | "include": ["vite.config.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /src/contexts/map_token.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext } from "react"; 2 | 3 | export enum MapType { 4 | Apple, 5 | Baidu, 6 | MapBox, 7 | } 8 | 9 | export interface MapToken { 10 | type: MapType, 11 | token: string 12 | } 13 | 14 | export const MapTokenContext = createContext<{ 15 | token: MapToken | undefined, 16 | setToken: React.Dispatch> 17 | } | null>(null); 18 | -------------------------------------------------------------------------------- /src/routes/shuin_list.tsx: -------------------------------------------------------------------------------- 1 | import ShuinMasonry from "../components/shuin_masonry.tsx"; 2 | import { useTranslation } from "react-i18next"; 3 | 4 | export default function ShuinList() { 5 | const { t } = useTranslation() 6 | 7 | return
8 |
9 | {t('sidebar.shuin')} 10 |
11 | 12 | 13 |
14 | } 15 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react-swc' 3 | import Unfonts from 'unplugin-fonts/vite' 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [ 8 | react(), 9 | Unfonts({ 10 | // Google Fonts API V2 11 | fontsource: { 12 | families: [ 13 | 'Dancing Script Variable' 14 | ] 15 | }, 16 | }), 17 | ], 18 | }) 19 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs'], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['react-refresh'], 12 | rules: { 13 | 'react-refresh/only-export-components': [ 14 | 'warn', 15 | { allowConstantExport: true }, 16 | ], 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /src/hooks/useMediaQuery.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | 3 | const useMediaQuery = (query: string) => { 4 | const [matches, setMatches] = useState(false); 5 | 6 | useEffect(() => { 7 | const media = window.matchMedia(query); 8 | if (media.matches !== matches) { 9 | setMatches(media.matches); 10 | } 11 | const listener = () => setMatches(media.matches); 12 | window.addEventListener("resize", listener); 13 | return () => window.removeEventListener("resize", listener); 14 | }, [matches, query]); 15 | 16 | return matches; 17 | } 18 | 19 | export default useMediaQuery; 20 | -------------------------------------------------------------------------------- /src/assets/logos/Apple.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/assets/logos/Apple_dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /src/assets/logos/Lumix_logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | -------------------------------------------------------------------------------- /src/assets/logos/Lumix_logo_dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Boar Gallery 11 | 12 | 13 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/i18n/ja.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | translation: { 3 | "sidebar.home": "ホーム", 4 | "sidebar.map": "マップ", 5 | "sidebar.shuin": "御朱印", 6 | "sidebar.lucky": "I'm Feeling Lucky", 7 | 8 | "map.countries": "国・地域", 9 | "map.select_country": "国・地域を選択してください", 10 | 11 | "unknown_lens": "レンズ不明", 12 | 13 | "prefecture.all_area": "{{name}}全域", 14 | "prefecture.city": "市・区・町・村", 15 | 16 | "copyright.reserved": "© {{year}}。無断使用・無断転載を禁じます。", 17 | "copyright.description": "当サイトに掲載されている写真やコンテンツの著作権は、各ページに表示された著作権者に帰属します。無断複製、転載、配布、改変、商用利用を禁じます。", 18 | 19 | "photo.enable_hdr": "HDR", 20 | "photo.hdr_tooltip": "すべてのデバイスが高ダイナミックレンジ(HDR)をサポートしているわけではありません。", 21 | 22 | "shuin.basic_information": "御朱印情報", 23 | "shuin.is_limited": "限定", 24 | "shuin.price": "初穂料", 25 | "shuin.type.written": "直書き", 26 | "shuin.type.leaflet": "書き置き", 27 | "shuin.yen": "円", 28 | "shuin.genre.shrine": "神社", 29 | "shuin.genre.temple": "お寺", 30 | } 31 | } -------------------------------------------------------------------------------- /src/i18n/zh_CN.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | translation: { 3 | "sidebar.home": "主页", 4 | "sidebar.map": "地图", 5 | "sidebar.shuin": "御朱印", 6 | "sidebar.lucky": "手气不错", 7 | 8 | "map.countries": "国家 / 地区", 9 | "map.select_country": "请选择一个国家或地区", 10 | 11 | "unknown_lens": "未知镜头", 12 | 13 | "prefecture.all_area": "{{name}}全域", 14 | "prefecture.city": "市区町村", 15 | 16 | "copyright.reserved": "版权所有 © {{year}}。保留一切权利。", 17 | "copyright.description": "本网站的所有内容,包括但不限于文字、图像、音频和视频资料,均受到版权法的保护,归作品署名者所有,除非另有标注。未经作品署名者明确书面许可,任何人不得复制、分发、传播、展示或以其他任何方式使用这些内容。特定内容的使用应遵守网站上公布的任何附加许可条件。", 18 | 19 | "photo.enable_hdr": "HDR", 20 | "photo.hdr_tooltip": "并非所有终端都支持高动态范围(HDR)的显示。", 21 | 22 | "shuin.basic_information": "基本信息", 23 | "shuin.is_limited": "限定", 24 | "shuin.price": "价格", 25 | "shuin.type.written": "手写", 26 | "shuin.type.leaflet": "预印", 27 | "shuin.yen": "日元", 28 | "shuin.genre.shrine": "神社", 29 | "shuin.genre.temple": "寺院", 30 | } 31 | } -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import { 4 | BrowserRouter, 5 | } from "react-router-dom"; 6 | import './index.css' 7 | import 'unfonts.css' 8 | import App from "./App.tsx"; 9 | import i18n from "i18next"; 10 | import { initReactI18next } from "react-i18next"; 11 | import LanguageDetector from 'i18next-browser-languagedetector'; 12 | import en from "./i18n/en.ts"; 13 | import zh_CN from "./i18n/zh_CN.ts"; 14 | import ja from "./i18n/ja.ts"; 15 | 16 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 17 | // @ts-expect-error 18 | window.global = globalThis; 19 | 20 | i18n 21 | .use(LanguageDetector) 22 | .use(initReactI18next) 23 | .init({ 24 | resources: { 25 | en: en, 26 | "zh-CN": zh_CN, 27 | ja: ja, 28 | }, 29 | interpolation: { 30 | escapeValue: false 31 | } 32 | }); 33 | 34 | ReactDOM.createRoot(document.getElementById('root')!).render( 35 | 36 | 37 | 38 | 39 | , 40 | ) 41 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @import 'mapbox-gl/dist/mapbox-gl.css' layer(base); 2 | @import 'ol/ol.css' layer(base); 3 | 4 | @import 'tailwindcss'; 5 | 6 | @config '../tailwind.config.js'; 7 | 8 | /* 9 | The default border color has changed to `currentcolor` in Tailwind CSS v4, 10 | so we've added these compatibility styles to make sure everything still 11 | looks the same as it did with Tailwind CSS v3. 12 | 13 | If we ever want to remove these styles, we need to add an explicit border 14 | color utility to any element that depends on these defaults. 15 | */ 16 | @layer base { 17 | *, 18 | ::after, 19 | ::before, 20 | ::backdrop, 21 | ::file-selector-button { 22 | border-color: var(--color-gray-200, currentcolor); 23 | } 24 | } 25 | 26 | /*html, body, #root, #root > div {*/ 27 | /* height: 100%;*/ 28 | /*}*/ 29 | 30 | /*#root > div > main {*/ 31 | /* height: 100%;*/ 32 | /*}*/ 33 | 34 | .text-logo { 35 | font-family: "Dancing Script Variable", cursive; 36 | font-optical-sizing: auto; 37 | font-weight: 700; 38 | font-style: normal; 39 | font-size: 1.5em; 40 | } 41 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { HeroUIProvider } from "@heroui/react"; 2 | import { Route, Routes, useNavigate } from "react-router-dom"; 3 | import Root from "./routes/root.tsx"; 4 | import Index from "./routes"; 5 | import Map from "./routes/map_openlayers.tsx"; 6 | import Photo from "./routes/photo.tsx"; 7 | import Prefecture from "./routes/prefecture.tsx"; 8 | import "./App.css" 9 | import Mapkit from "./routes/cluster.tsx"; 10 | import ShuinList from "./routes/shuin_list.tsx"; 11 | import ShuinPage from "./routes/shuin.tsx"; 12 | 13 | function App() { 14 | const navigate = useNavigate(); 15 | 16 | return ( 17 | 18 | 19 | }> 20 | }/> 21 | }/> 22 | }/> 23 | }/> 24 | }/> 25 | }/> 26 | }/> 27 | }/> 28 | 29 | 30 | 31 | ) 32 | } 33 | 34 | export default App 35 | -------------------------------------------------------------------------------- /src/i18n/en.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | translation: { 3 | "sidebar.home": "Timeline", 4 | "sidebar.map": "Map", 5 | "sidebar.shuin": "Shuin", 6 | "sidebar.lucky": "I'm Feeling Lucky", 7 | 8 | "map.countries": "Country / Region", 9 | "map.select_country": "Select a country or region", 10 | 11 | "unknown_lens": "Unknown lens", 12 | 13 | "prefecture.all_area": "All of {{name}}", 14 | "prefecture.city": "Municipalities", 15 | 16 | "copyright.reserved": "© {{year}}. All rights reserved.", 17 | "copyright.description": "All contents of this website, including but not limited to text, images, audio, and video materials, are protected by copyright laws and are the property of the copyright holder, unless otherwise indicated. Without the explicit written permission of the copyright holder, no one may copy, distribute, transmit, display, or use these contents in any other way. The use of specific content must comply with any additional license conditions published on the website.", 18 | 19 | "photo.enable_hdr": "HDR", 20 | "photo.hdr_tooltip": "Not all devices support High Dynamic Range (HDR) display.", 21 | 22 | "shuin.basic_information": "Information", 23 | "shuin.is_limited": "LIMITED", 24 | "shuin.price": "Price", 25 | "shuin.type.written": "Hand-written", 26 | "shuin.type.leaflet": "Pre-printed", 27 | "shuin.yen": "yen", 28 | "shuin.genre.shrine": "Shrine", 29 | "shuin.genre.temple": "Temple", 30 | } 31 | } -------------------------------------------------------------------------------- /src/components/manufacture_icon.tsx: -------------------------------------------------------------------------------- 1 | import useDarkMode from "use-dark-mode"; 2 | import sonyLogo from '../assets/logos/SONY.svg'; 3 | import sonyLogoDark from '../assets/logos/SONY_dark.svg'; 4 | import sigmaLogo from '../assets/logos/SIGMA.svg'; 5 | import sigmaLogoDark from '../assets/logos/SIGMA_dark.svg'; 6 | import appleLogo from '../assets/logos/Apple.svg'; 7 | import appleLogoDark from '../assets/logos/Apple_dark.svg'; 8 | import panasonicLogo from '../assets/logos/Panasonic.svg'; 9 | import panasonicLogoDark from '../assets/logos/Panasonic_dark.svg'; 10 | 11 | const logos: { [k: string]: { light: string, dark: string, style: string } } = { 12 | 'SONY': { 13 | light: sonyLogo, 14 | dark: sonyLogoDark, 15 | style: 'h-[0.7rem]', 16 | }, 17 | 'SIGMA': { 18 | light: sigmaLogo, 19 | dark: sigmaLogoDark, 20 | style: 'h-[0.7rem]', 21 | }, 22 | 'Apple': { 23 | light: appleLogo, 24 | dark: appleLogoDark, 25 | style: 'h-4', 26 | }, 27 | 'Panasonic': { 28 | light: panasonicLogo, 29 | dark: panasonicLogoDark, 30 | style: 'h-[0.7rem]', 31 | }, 32 | } 33 | 34 | export interface ManufactureIconProps { 35 | name?: string 36 | } 37 | 38 | export default function ManufactureIcon(props: ManufactureIconProps) { 39 | const darkmode = useDarkMode() 40 | 41 | if (!props.name) return; 42 | 43 | const manufacture = logos[props.name] 44 | if (!manufacture) return props.name 45 | 46 | return ( 47 | {props.name} 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /src/components/camera_name.tsx: -------------------------------------------------------------------------------- 1 | import sonyAlpha from '../assets/logos/Sony_Alpha_logo.svg'; 2 | import lumix from '../assets/logos/Lumix_logo.svg'; 3 | import lumixDark from '../assets/logos/Lumix_logo_dark.svg'; 4 | import { Camera } from "../models/gallery.ts"; 5 | import useDarkMode from "use-dark-mode"; 6 | import { Tooltip } from "@heroui/react"; 7 | import { JSX } from "react"; 8 | 9 | export interface CameraNameProps { 10 | camera?: Camera 11 | } 12 | 13 | export default function CameraName(props: CameraNameProps) { 14 | const darkmode = useDarkMode() 15 | 16 | if (!props.camera) return; 17 | if (!props.camera?.general_name) return props.camera?.model 18 | 19 | let cameraName: JSX.Element 20 | 21 | if (props.camera.manufacture.name === 'SONY' && props.camera.general_name.startsWith('α')) { 22 | cameraName = 23 |
24 | α 25 | {props.camera.general_name.replace('α', '')} 26 |
27 |
28 | } else if (props.camera.manufacture.name === 'Panasonic' && props.camera.general_name.startsWith('Lumix')) { 29 | cameraName = 30 |
31 | Lumix 32 | {props.camera.general_name.replace('Lumix', '')} 33 |
34 |
35 | } else { 36 | cameraName = {props.camera.general_name} 37 | } 38 | 39 | return cameraName; 40 | } 41 | -------------------------------------------------------------------------------- /src/assets/logos/SIGMA.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/assets/logos/SIGMA_dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "boar-gallery", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@fontsource-variable/dancing-script": "^5.1.1", 14 | "@heroui/react": "2.8.5", 15 | "@uiw/react-baidu-map": "^2.7.1", 16 | "axios": "^1.12.2", 17 | "framer-motion": "12.23.24", 18 | "i18next": "^25.6.0", 19 | "i18next-browser-languagedetector": "^8.2.0", 20 | "localforage": "^1.10.0", 21 | "mapbox-gl": "^3.16.0", 22 | "mapkit-react": "^1.16.1", 23 | "masonic": "^4.1.0", 24 | "match-sorter": "^8.0.0", 25 | "mini-virtual-list": "^0.3.2", 26 | "moment": "^2.30.1", 27 | "ol": "^10.3.1", 28 | "react": "19.2.0", 29 | "react-dom": "19.2.0", 30 | "react-i18next": "^16.2.0", 31 | "react-icons": "^5.5.0", 32 | "react-map-gl": "^8.1.0", 33 | "react-medium-image-zoom": "^5.4.0", 34 | "react-router-dom": "^7.9.4", 35 | "react-simple-maps": "^3.0.0", 36 | "sort-by": "^1.2.0", 37 | "use-dark-mode": "^2.3.1" 38 | }, 39 | "devDependencies": { 40 | "@tailwindcss/postcss": "^4.1.16", 41 | "@types/apple-mapkit-js-browser": "^5.78.1", 42 | "@types/mapbox-gl": "^3.4.1", 43 | "@types/react": "^19.0.7", 44 | "@types/react-dom": "^19.0.3", 45 | "@types/react-simple-maps": "^3.0.6", 46 | "@typescript-eslint/eslint-plugin": "^8.20.0", 47 | "@typescript-eslint/parser": "^8.20.0", 48 | "@vitejs/plugin-react-swc": "^3.7.2", 49 | "eslint": "^9.18.0", 50 | "eslint-plugin-react-hooks": "^5.1.0", 51 | "eslint-plugin-react-refresh": "^0.4.18", 52 | "postcss": "^8.5.1", 53 | "tailwindcss": "4.1.16", 54 | "typescript": "^5.7.3", 55 | "unplugin-fonts": "^1.3.1", 56 | "vite": "^7.1.12" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/routes/prefecture.tsx: -------------------------------------------------------------------------------- 1 | import PhotoMasonry from "../components/photo_masonry.tsx"; 2 | import { useNavigate, useParams } from "react-router-dom"; 3 | import { useEffect, useState } from "react"; 4 | import axios from "axios"; 5 | import { Prefecture, Response } from "../models/gallery.ts"; 6 | import { Chip, Select, SelectItem } from "@heroui/react"; 7 | import { useTranslation } from "react-i18next"; 8 | 9 | export default function PrefecturePage() { 10 | const params = useParams() 11 | const [prefecture, setPrefecture] = useState() 12 | const { t } = useTranslation() 13 | const navigate = useNavigate(); 14 | 15 | useEffect(() => { 16 | axios.get>('https://api.gallery.boar.ac.cn/geo/prefecture', { 17 | params: { 18 | id: params.prefectureId, 19 | with_cities: true 20 | } 21 | }).then((res) => { 22 | setPrefecture(res.data.payload) 23 | }) 24 | }, [params.prefectureId]); 25 | 26 | if (!prefecture) return; 27 | 28 | return
29 |
30 | {prefecture.name} 31 |
32 | 33 |
34 | 51 |
52 | 53 |
54 | { 55 | !params.cityId ? 56 | 57 | : 58 | 59 | } 60 |
61 |
62 | } 63 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | const { heroui } = require("@heroui/react"); 2 | 3 | const generateGrid = (size) => { 4 | const gridColumn = {}; 5 | const gridTemplateColumns = {}; 6 | const gridRow = {}; 7 | const gridTemplateRows = {}; 8 | const gridRowStart = {}; 9 | const gridRowEnd = {}; 10 | const gridColumnStart = {}; 11 | const gridColumnEnd = {}; 12 | for (let i = 1; i <= size; i++) { 13 | gridRow[`span-${i}`] = `span ${i} / span ${i}`; 14 | gridColumn[`span-${i}`] = `span ${i} / span ${i}`; 15 | gridTemplateColumns[i] = `repeat(${i}, minmax(0, 1fr))`; 16 | gridTemplateRows[i] = `repeat(${i}, minmax(0, 1fr))`; 17 | gridRowStart[i] = `${i}`; 18 | gridRowEnd[i] = `${i}`; 19 | gridColumnStart[i] = `${i}`; 20 | gridColumnEnd[i] = `${i}`; 21 | } 22 | return { 23 | gridColumn, 24 | gridTemplateColumns, 25 | gridRow, 26 | gridTemplateRows, 27 | gridRowStart, 28 | gridRowEnd, 29 | gridColumnStart, 30 | gridColumnEnd, 31 | }; 32 | } 33 | 34 | /** @type {import('tailwindcss').Config} */ 35 | export default { 36 | content: [ 37 | "./index.html", 38 | "./src/**/*.{js,ts,jsx,tsx}", 39 | "./node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}", 40 | ], 41 | theme: { 42 | extend: { 43 | ...generateGrid(24), 44 | }, 45 | }, 46 | darkMode: "class", 47 | plugins: [heroui({ 48 | themes: { 49 | light: { 50 | colors: { 51 | primary: { 52 | 100: "#E9EEF4", 53 | 200: "#D5DEEA", 54 | 300: "#A7B2C2", 55 | 400: "#6E7685", 56 | 500: "#282C34", 57 | 600: "#1D212C", 58 | 700: "#141825", 59 | 800: "#0C101E", 60 | 900: "#070B18", 61 | DEFAULT: "#282C34", 62 | } 63 | } 64 | }, 65 | dark: { 66 | colors: { 67 | primary: { 68 | 100: "#E9EEF4", 69 | 200: "#D5DEEA", 70 | 300: "#A7B2C2", 71 | 400: "#6E7685", 72 | 500: "#282C34", 73 | 600: "#1D212C", 74 | 700: "#141825", 75 | 800: "#0C101E", 76 | 900: "#070B18", 77 | DEFAULT: "#6E7685", 78 | } 79 | } 80 | } 81 | } 82 | })], 83 | } 84 | 85 | -------------------------------------------------------------------------------- /src/models/gallery.ts: -------------------------------------------------------------------------------- 1 | export interface Response { 2 | code: number 3 | payload: T 4 | success: boolean 5 | } 6 | 7 | export interface Photo { 8 | id: number 9 | title: string 10 | description: string 11 | author?: Author 12 | metadata: Metadata 13 | thumb_file: File 14 | medium_file?: File 15 | large_file?: File 16 | hdr_file?: File 17 | } 18 | 19 | export interface Shuin { 20 | id: number 21 | place: Place 22 | is_limited: boolean 23 | price: number 24 | type: string 25 | thumb_file: File 26 | medium_file?: File 27 | large_file?: File 28 | hdr_file?: File 29 | date: string 30 | genre: string 31 | } 32 | 33 | export interface PhotoClusterItem { 34 | id: number 35 | coordinate?: Coordinate 36 | thumb_file: File 37 | clustering_identifier: string 38 | } 39 | 40 | export interface Author { 41 | id: number 42 | name: string 43 | } 44 | 45 | export interface Metadata { 46 | camera?: Camera 47 | lens?: Lens 48 | has_location?: boolean 49 | location?: Coordinate 50 | datetime: string 51 | exposure_time: number 52 | exposure_time_rat: string 53 | f_number: number 54 | photographic_sensitivity: number 55 | focal_length: number 56 | city?: City 57 | place?: Place 58 | timezone: string 59 | altitude?: number 60 | } 61 | 62 | export interface Camera { 63 | id: number 64 | model: string 65 | manufacture: Manufacture 66 | general_name?: string 67 | } 68 | 69 | export interface Manufacture { 70 | id: number 71 | name: string 72 | } 73 | 74 | export interface Place { 75 | id: number 76 | name: string 77 | geom: Coordinate 78 | city?: City 79 | } 80 | 81 | export interface Coordinate { 82 | longitude: number 83 | latitude: number 84 | } 85 | 86 | export interface Lens { 87 | id: number 88 | model: string 89 | manufacture: Manufacture 90 | min_focal_length: number 91 | max_focal_length: number 92 | min_f_number_in_min_focal_length: number 93 | min_f_number_in_max_focal_length: number 94 | } 95 | 96 | export interface City { 97 | id: number 98 | name: string 99 | prefecture: Prefecture 100 | photos_count: number 101 | } 102 | 103 | export interface Prefecture { 104 | id: number 105 | name: string 106 | country: Country 107 | photos_count?: number 108 | cities: City[] 109 | } 110 | 111 | export interface Country { 112 | id: number 113 | name: string 114 | code: string 115 | center: [number, number] 116 | extent: [number, number, number, number] 117 | zoom: [number, number, number] 118 | } 119 | 120 | export interface File { 121 | height: number 122 | url: string 123 | width: number 124 | } 125 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | [data-rmiz-ghost] { 2 | position: absolute; 3 | pointer-events: none; 4 | } 5 | 6 | [data-rmiz-btn-zoom], 7 | [data-rmiz-btn-unzoom] { 8 | background-color: rgba(0, 0, 0, 0.7); 9 | border-radius: 50%; 10 | border: none; 11 | box-shadow: 0 0 1px rgba(255, 255, 255, 0.5); 12 | color: #fff; 13 | height: 40px; 14 | margin: 0; 15 | outline-offset: 2px; 16 | padding: 9px; 17 | touch-action: manipulation; 18 | width: 40px; 19 | -webkit-appearance: none; 20 | -moz-appearance: none; 21 | appearance: none; 22 | } 23 | 24 | [data-rmiz-btn-zoom]:not(:focus):not(:active) { 25 | position: absolute; 26 | clip: rect(0 0 0 0); 27 | clip-path: inset(50%); 28 | height: 1px; 29 | overflow: hidden; 30 | pointer-events: none; 31 | white-space: nowrap; 32 | width: 1px; 33 | } 34 | 35 | [data-rmiz-btn-zoom] { 36 | position: absolute; 37 | inset: 10px 10px auto auto; 38 | cursor: zoom-in; 39 | } 40 | 41 | [data-rmiz-btn-unzoom] { 42 | position: absolute; 43 | inset: 20px 20px auto auto; 44 | cursor: zoom-out; 45 | z-index: 1; 46 | } 47 | 48 | [data-rmiz-content="found"] img, 49 | [data-rmiz-content="found"] svg, 50 | [data-rmiz-content="found"] [role="img"], 51 | [data-rmiz-content="found"] [data-zoom] { 52 | cursor: zoom-in; 53 | } 54 | 55 | [data-rmiz-modal]::backdrop { 56 | display: none; 57 | } 58 | 59 | [data-rmiz-modal][open] { 60 | position: fixed; 61 | width: 100vw; 62 | width: 100dvw; 63 | height: 100vh; 64 | height: 100dvh; 65 | max-width: none; 66 | max-height: none; 67 | margin: 0; 68 | padding: 0; 69 | border: 0; 70 | background: transparent; 71 | overflow: hidden; 72 | } 73 | 74 | [data-rmiz-modal-overlay] { 75 | position: absolute; 76 | inset: 0; 77 | transition: background-color 0.3s; 78 | } 79 | 80 | [data-rmiz-modal-overlay="hidden"] { 81 | background-color: rgba(255, 255, 255, 0); 82 | } 83 | 84 | [data-rmiz-modal-overlay="visible"] { 85 | background-color: rgba(255, 255, 255, 1); 86 | } 87 | 88 | /* If data-rmiz-modal-overlay inside .dark */ 89 | .dark [data-rmiz-modal-overlay="visible"] { 90 | background-color: rgba(0, 0, 0, 255); 91 | } 92 | 93 | [data-rmiz-modal-content] { 94 | position: relative; 95 | width: 100%; 96 | height: 100%; 97 | } 98 | 99 | [data-rmiz-modal-img] { 100 | position: absolute; 101 | cursor: zoom-out; 102 | image-rendering: high-quality; 103 | transform-origin: top left; 104 | transition: transform 0.3s; 105 | } 106 | 107 | @media (prefers-reduced-motion: reduce) { 108 | [data-rmiz-modal-overlay], 109 | [data-rmiz-modal-img] { 110 | transition-duration: 0.01ms !important; 111 | } 112 | } -------------------------------------------------------------------------------- /src/routes/cluster.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect, useRef, useState } from "react"; 2 | import { Country, PhotoClusterItem, Response } from "../models/gallery.ts"; 3 | import axios from "axios"; 4 | import { Card } from "@heroui/react"; 5 | import useDarkMode from "use-dark-mode"; 6 | import { Annotation, ColorScheme, Map, MapType } from "mapkit-react"; 7 | import { MapTokenContext } from "../contexts/map_token.tsx"; 8 | 9 | export default function ClusterPage() { 10 | const darkmode = useDarkMode() 11 | const [clusterItems, setClusterItems] = useState([]) 12 | const [country, setCountry] = useState() 13 | // const [countries, setCountries] = useState([]) 14 | const token = useContext(MapTokenContext) 15 | const appleRef = useRef(null) 16 | 17 | useEffect(() => { 18 | axios.get>('https://api.gallery.boar.ac.cn/geo/countries').then((res) => { 19 | // setCountries(res.data.payload) 20 | setCountry(res.data.payload[0]) 21 | }) 22 | }, []); 23 | 24 | useEffect(() => { 25 | axios.get>(`https://api.gallery.boar.ac.cn/photos/cluster?country_id=${country?.id}`).then((res) => { 26 | setClusterItems(res.data.payload) 27 | }) 28 | }, [country]) 29 | 30 | if (!token?.token) return; 31 | if (!country) return; 32 | 33 | return
34 | 48 | { 49 | clusterItems.map((item) => { 50 | if (!item.coordinate) return null; 51 | 52 | return 58 | 62 | 68 | 69 | 70 | }) 71 | } 72 | 73 |
74 | } 75 | -------------------------------------------------------------------------------- /src/assets/logos/SONY.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/assets/logos/SONY_dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/components/dialog_map.tsx: -------------------------------------------------------------------------------- 1 | import { Coordinate } from "../models/gallery.ts"; 2 | import { useContext, useEffect, useRef } from "react"; 3 | import { MapTokenContext, MapType } from "../contexts/map_token.tsx"; 4 | import useDarkMode from "use-dark-mode"; 5 | import { ColorScheme, Map as AppleMap, MapType as AppleMapType, Marker as AppleMarker } from "mapkit-react"; 6 | import MapBox, { MapRef, Marker as MapBoxMarker } from 'react-map-gl/mapbox'; 7 | 8 | export interface DialogMapProps { 9 | coordinate: Coordinate 10 | } 11 | 12 | export default function DialogMap(props: DialogMapProps) { 13 | const token = useContext(MapTokenContext) 14 | const appleRef = useRef(null) 15 | const mapboxRef = useRef(null) 16 | const darkmode = useDarkMode() 17 | 18 | useEffect(() => { 19 | if (appleRef && appleRef.current) { 20 | appleRef.current.setCenterAnimated(new mapkit.Coordinate(props.coordinate.latitude, props.coordinate.longitude), true) 21 | } else if (mapboxRef && mapboxRef.current) { 22 | mapboxRef.current.setCenter({ lat: props.coordinate.latitude, lng: props.coordinate.longitude }) 23 | } 24 | }, [props.coordinate.latitude, props.coordinate.longitude]) 25 | 26 | if (!token?.token) return; 27 | 28 | return ( 29 | token!.token.type === MapType.Apple ? 30 | 44 | 45 | 46 | : 47 | { 58 | const map = e.target 59 | map.getStyle()?.layers.forEach((layer) => { 60 | if (layer.id.endsWith("-label")) { 61 | map.setLayoutProperty(layer.id, "text-field", ["get", "name_ja"]) 62 | } 63 | }) 64 | }} 65 | > 66 | 71 | 72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /src/assets/logos/Sony_Alpha_logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 19 | 20 | 22 | image/svg+xml 23 | 25 | 26 | 27 | 28 | 30 | 54 | 58 | 62 | 63 | 67 | 71 | 72 | 76 | 80 | 81 | 82 | -------------------------------------------------------------------------------- /src/assets/logos/Panasonic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/assets/logos/Panasonic_dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/components/shuin_masonry.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useRef, useState } from "react"; 2 | import axios from "axios"; 3 | import { Response, Shuin } from "../models/gallery.ts"; 4 | import { Card, Image, CardFooter, CardBody, useDisclosure } from "@heroui/react"; 5 | import useMediaQuery from "../hooks/useMediaQuery.tsx"; 6 | import { useWindowSize } from "@react-hook/window-size"; 7 | import { 8 | useContainerPosition, 9 | useInfiniteLoader, 10 | useMasonry, 11 | usePositioner, 12 | useScroller 13 | } from "masonic"; 14 | import { useNavigate } from "react-router-dom"; 15 | import ShuinModal from "./shuin_modal.tsx"; 16 | import { useTranslation } from "react-i18next"; 17 | 18 | 19 | export default function ShuinMasonry() { 20 | const [shuin, setShuin] = useState([]) 21 | const isDesktop = useMediaQuery('(min-width: 720px)'); 22 | const loadedIndex = useRef<{ startIndex: number, stopIndex: number }[]>([]); 23 | 24 | const containerRef = useRef(null); 25 | const [windowWidth, height] = useWindowSize(); 26 | const { offset, width } = useContainerPosition(containerRef, [ 27 | windowWidth, 28 | height 29 | ]); 30 | const positioner = usePositioner({ 31 | width, 32 | columnGutter: 8, 33 | columnCount: isDesktop ? 4 : 2, 34 | }); 35 | const { scrollTop, isScrolling } = useScroller(offset); 36 | 37 | useEffect(() => { 38 | axios.get>('https://api.gallery.boar.ac.cn/shuin/all', { 39 | params: { 40 | page_size: 20 41 | } 42 | }).then(res => { 43 | setShuin(res.data.payload) 44 | }) 45 | }, []) 46 | 47 | const maybeLoadMore = useInfiniteLoader((startIndex, stopIndex, items) => { 48 | if (loadedIndex.current.find((e) => e.startIndex === startIndex && e.stopIndex === stopIndex)) { 49 | return; 50 | } 51 | loadedIndex.current.push({ startIndex, stopIndex }) 52 | 53 | const lastDate = (items[items.length - 1] as Shuin).date 54 | axios.get>('https://api.gallery.boar.ac.cn/shuin/all', { 55 | params: { 56 | page_size: stopIndex - startIndex, 57 | last_date: lastDate, 58 | } 59 | }).then((res) => { 60 | const newItems = res.data.payload.filter((item) => !shuin.find(s => s.id === item.id)); 61 | if (newItems.length > 0) { 62 | setShuin((current) => [...current, ...newItems]); 63 | } 64 | }) 65 | }, { 66 | isItemLoaded: (index, items) => !!items[index], 67 | }); 68 | 69 | return useMasonry({ 70 | positioner, 71 | scrollTop, 72 | isScrolling, 73 | height, 74 | containerRef, 75 | items: shuin, 76 | overscanBy: 3, 77 | itemHeightEstimate: 0, 78 | onRender: maybeLoadMore, 79 | render: MasonryCard, 80 | itemKey: (item) => item.id, 81 | }) 82 | } 83 | 84 | const MasonryCard = ({ data }: { data: Shuin }) => { 85 | const { isOpen, onOpen, onOpenChange } = useDisclosure(); 86 | const isDesktop = useMediaQuery('(min-width: 960px)'); 87 | const navigate = useNavigate() 88 | const { t } = useTranslation() 89 | 90 | const openPhotoModel = useMemo(() => () => { 91 | history.pushState({}, '', `/shuin/${data.id}`) 92 | onOpen(); 93 | }, [onOpen, data.id]) 94 | 95 | return 101 | 102 | 114 | 115 | 116 | 117 | {data.place.name} 118 | 119 |

120 | {t(`shuin.genre.${data.genre}`)} 121 |

122 |
123 | 124 | { 125 | if (!isOpen && path) { 126 | navigate(path) 127 | } else if (!isOpen) { 128 | history.back() 129 | } 130 | onOpenChange() 131 | }}/> 132 |
133 | }; 134 | -------------------------------------------------------------------------------- /src/components/photo_masonry.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useRef, useState } from "react"; 2 | import axios from "axios"; 3 | import { Photo, Response } from "../models/gallery.ts"; 4 | import { Card, Image, CardFooter, CardBody, useDisclosure } from "@heroui/react"; 5 | import useMediaQuery from "../hooks/useMediaQuery.tsx"; 6 | import PhotoModal from "../components/photo_modal.tsx"; 7 | import { useWindowSize } from "@react-hook/window-size"; 8 | import { 9 | useContainerPosition, 10 | useInfiniteLoader, 11 | useMasonry, 12 | usePositioner, 13 | useScroller 14 | } from "masonic"; 15 | import { useNavigate } from "react-router-dom"; 16 | 17 | 18 | export default function PhotoMasonry(props: { prefectureId?: string, cityId?: string }) { 19 | const [photos, setPhotos] = useState([]) 20 | const isDesktop = useMediaQuery('(min-width: 960px)'); 21 | const loadedIndex = useRef<{ startIndex: number, stopIndex: number }[]>([]); 22 | 23 | const containerRef = useRef(null); 24 | const [windowWidth, height] = useWindowSize(); 25 | const { offset, width } = useContainerPosition(containerRef, [ 26 | windowWidth, 27 | height 28 | ]); 29 | const positioner = usePositioner({ 30 | width, 31 | columnGutter: 8, 32 | columnCount: isDesktop ? 3 : 2, 33 | }); 34 | const { scrollTop, isScrolling } = useScroller(offset); 35 | const query = useMemo(() => ({ 36 | prefecture_id: props.prefectureId && props.prefectureId !== '0' ? props.prefectureId : undefined, 37 | city_id: props.cityId && props.cityId !== '0' ? props.cityId : undefined, 38 | }), [props.cityId, props.prefectureId]) 39 | 40 | useEffect(() => { 41 | axios.get>('https://api.gallery.boar.ac.cn/photos/all', { 42 | params: { 43 | ...query, 44 | page_size: 20 45 | } 46 | }).then(res => { 47 | setPhotos(res.data.payload) 48 | }) 49 | }, [query]) 50 | 51 | const maybeLoadMore = useInfiniteLoader((startIndex, stopIndex, items) => { 52 | if (loadedIndex.current.find((e) => e.startIndex === startIndex && e.stopIndex === stopIndex)) { 53 | return; 54 | } 55 | loadedIndex.current.push({ startIndex, stopIndex }) 56 | 57 | const lastDate = (items[items.length - 1] as Photo).metadata.datetime 58 | axios.get>('https://api.gallery.boar.ac.cn/photos/all', { 59 | params: { 60 | ...query, 61 | page_size: stopIndex - startIndex, 62 | last_datetime: lastDate, 63 | } 64 | }).then((res) => { 65 | if (res.data.payload.length > 0) { 66 | setPhotos((current) => [...current, ...res.data.payload]); 67 | } 68 | }) 69 | }, { 70 | isItemLoaded: (index, items) => !!items[index], 71 | }); 72 | 73 | return useMasonry({ 74 | positioner, 75 | scrollTop, 76 | isScrolling, 77 | height, 78 | containerRef, 79 | items: photos, 80 | overscanBy: 3, 81 | itemHeightEstimate: 0, 82 | onRender: maybeLoadMore, 83 | render: MasonryCard, 84 | itemKey: (item) => item.id, 85 | }) 86 | } 87 | 88 | const MasonryCard = ({ data }: { data: Photo }) => { 89 | const { isOpen, onOpen, onOpenChange } = useDisclosure(); 90 | const isDesktop = useMediaQuery('(min-width: 960px)'); 91 | const navigate = useNavigate() 92 | 93 | const openPhotoModel = useMemo(() => () => { 94 | history.pushState({}, '', `/photo/${data.id}`) 95 | onOpen(); 96 | }, [onOpen, data.id]) 97 | 98 | return 104 | 105 | 117 | 118 | { 119 | data.metadata.city ? 120 | 121 | 122 | {`${data.metadata.city.prefecture.name} ${data.metadata.city.name}`} 123 | 124 |

125 | {`${data.metadata.city.prefecture.country.name}`} 126 |

127 |
128 | : 129 | null 130 | } 131 | 132 | { 133 | if (!isOpen && path) { 134 | navigate(path) 135 | } else if (!isOpen) { 136 | history.back() 137 | } 138 | onOpenChange() 139 | }}/> 140 |
141 | }; 142 | -------------------------------------------------------------------------------- /src/routes/map_openlayers.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | import { Country, Prefecture, Response } from "../models/gallery.ts"; 3 | import axios from "axios"; 4 | import VectorLayer from "ol/layer/Vector"; 5 | import VectorSource from "ol/source/Vector"; 6 | import GeoJSON from 'ol/format/GeoJSON'; 7 | import { fromLonLat, transformExtent } from "ol/proj"; 8 | import { View } from "ol"; 9 | import Map from 'ol/Map.js'; 10 | import { Fill, Stroke, Style, Text } from 'ol/style.js'; 11 | import { getArea } from 'ol/sphere'; 12 | import { MultiPolygon } from "ol/geom"; 13 | import { useTranslation } from "react-i18next"; 14 | import { Select, SelectItem } from "@heroui/react"; 15 | import useDarkMode from "use-dark-mode"; 16 | import { useNavigate } from "react-router-dom"; 17 | 18 | export default function MapPage() { 19 | const darkmode = useDarkMode() 20 | const mapElement = useRef(null) 21 | const map = useRef(null) 22 | const [country, setCountry] = useState() 23 | const [countries, setCountries] = useState([]) 24 | const [prefectures, setPrefectures] = useState([]) 25 | const hoverIds = useRef([]); 26 | const navigate = useNavigate() 27 | const { t } = useTranslation() 28 | 29 | useEffect(() => { 30 | axios.get>('https://api.gallery.boar.ac.cn/geo/countries').then((res) => { 31 | setCountries(res.data.payload) 32 | setCountry(res.data.payload[0]) 33 | }) 34 | }, []) 35 | 36 | useEffect(() => { 37 | if (country) { 38 | axios.get>('https://api.gallery.boar.ac.cn/geo/prefectures', { 39 | params: { 40 | country_id: country.id, 41 | with_photos_count: true 42 | } 43 | }).then((res) => { 44 | setPrefectures(res.data.payload) 45 | }) 46 | } 47 | }, [country]) 48 | 49 | useEffect(() => { 50 | if (!country) return 51 | if (!mapElement || !mapElement.current) return 52 | if (!prefectures.length) return 53 | if (!navigate) return 54 | 55 | if (map.current) { 56 | map.current?.setTarget(undefined) 57 | } 58 | 59 | // create and add vector source layer 60 | const vectorLayer = new VectorLayer({ 61 | source: new VectorSource({ 62 | url: `/geojson/${country.code}.json`, 63 | format: new GeoJSON(), 64 | }), 65 | renderBuffer: Math.max(window.outerWidth, window.outerHeight), 66 | style: function (feature) { 67 | if (feature.getGeometry()?.getType() === 'MultiPolygon') { 68 | const hasPhotos = (prefectures.find((p) => p.id === feature.get('id'))?.photos_count ?? 0) > 0 69 | 70 | let largestPolygon = null; 71 | let maxArea = 0; 72 | 73 | (feature.getGeometry() as MultiPolygon).getPolygons().forEach((polygon) => { 74 | const area = getArea(polygon); 75 | if (area > maxArea) { 76 | maxArea = area; 77 | largestPolygon = polygon; 78 | } 79 | }); 80 | 81 | const labelStyle = new Style({ 82 | text: new Text({ 83 | font: '13px "SF Pro SC","SF Pro Display","SF Pro Icons","PingFang SC","Helvetica Neue","Helvetica","Arial",sans-serif', 84 | overflow: true, 85 | fill: new Fill({ 86 | color: darkmode.value ? '#fff' : '#000', 87 | }), 88 | stroke: new Stroke({ 89 | color: darkmode.value ? '#000' : '#fff', 90 | width: 3, 91 | }), 92 | text: feature.get('name'), 93 | }), 94 | geometry: new MultiPolygon([largestPolygon!.getCoordinates()]), 95 | }); 96 | 97 | const prefStyle = new Style({ 98 | fill: new Fill({ 99 | color: (hasPhotos ? 100 | darkmode.value ? '#792A4C' : '#FEDFE1' : 101 | darkmode.value ? '#222222' : '#DDDDDD') + (hoverIds.current.includes(feature.get('id')) ? 'CC' : 'FF'), 102 | }), 103 | stroke: new Stroke({ 104 | color: darkmode.value ? '#000' : '#FFF', 105 | width: 1, 106 | }), 107 | }); 108 | 109 | return [prefStyle, labelStyle] 110 | } 111 | }, 112 | declutter: true, 113 | }) 114 | 115 | // create map 116 | map.current = new Map({ 117 | target: mapElement.current, 118 | view: new View({ 119 | center: fromLonLat(country.center), 120 | zoom: country.zoom[0], 121 | minZoom: country.zoom[1], 122 | maxZoom: country.zoom[2], 123 | extent: transformExtent(country.extent, 'EPSG:4326', 'EPSG:3857'), 124 | }), 125 | controls: [], 126 | }) 127 | 128 | map.current?.addLayer(vectorLayer) 129 | 130 | map.current.on('pointermove', (e) => { 131 | if (!e.dragging) { 132 | const features = map.current!.getFeaturesAtPixel(map.current!.getEventPixel(e.originalEvent)) 133 | if (features.length) { 134 | hoverIds.current = features.map((f) => f.get('id')) 135 | } else { 136 | hoverIds.current = [] 137 | } 138 | map.current!.getTargetElement().style.cursor = features.length ? 'pointer' : ''; 139 | vectorLayer.changed(); 140 | } 141 | }); 142 | 143 | map.current.on('click', (e) => { 144 | const feature = map.current!.forEachFeatureAtPixel(map.current!.getEventPixel(e.originalEvent), (feature) => feature); 145 | if (feature) { 146 | navigate(`/prefecture/${feature.get('id')}`) 147 | } 148 | }); 149 | }, [country, darkmode.value, navigate, prefectures] 150 | ) 151 | 152 | if (!country) return; 153 | 154 | return
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 | 70 | 71 | 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 | --------------------------------------------------------------------------------