+ * - Single newlines within a paragraph →
+ */
+export function textToHTML(text) {
+ if (!text) return '';
+ if (text.trimStart().startsWith('<')) return text;
+
+ return text
+ .split(/\n{2,}/)
+ .map((para) => `
${para.replaceAll('\n', '
')}
,
,
+ */ +export function htmlToText(html) { + if (!html) return ''; + + return html + .replaceAll(/<\/p>/gi, '\n') + .replaceAll(/<\/h[1-6]>/gi, '\n') + .replaceAll(/<\/li>/gi, '\n') + .replaceAll(/<\/blockquote>/gi, '\n') + .replaceAll(/
/gi, '\n') + .replaceAll(/<[^>]+>/g, '') + .replace(/\n+$/, '') + .trim(); +} + +/** + * Returns plain text with collapsed whitespace — useful for search and comparison. + */ +export function stripHtml(html) { + return htmlToText(html).replace(/\s+/g, ' ').trim(); +} diff --git a/map/src/frame/components/items/SectionRow.jsx b/map/src/frame/components/items/SectionRow.jsx new file mode 100644 index 0000000000..8dfd4b068f --- /dev/null +++ b/map/src/frame/components/items/SectionRow.jsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { MenuItem } from '@mui/material'; +import { ReactComponent as ArrowUpIcon } from '../../../assets/icons/ic_action_arrow_up.svg'; +import styles from './items.module.css'; + +export default function SectionRow({ id, name, onClick, maxLines = 2 }) { + return ( + + ); +} diff --git a/map/src/frame/components/items/items.module.css b/map/src/frame/components/items/items.module.css index 37a2f1162e..0af3b5fddc 100644 --- a/map/src/frame/components/items/items.module.css +++ b/map/src/frame/components/items/items.module.css @@ -132,3 +132,38 @@ display: flex !important; align-items: center !important; } + +.sectionRow { + width: 360px !important; + min-height: 48px !important; + padding: 12px 20px !important; + display: flex !important; + align-items: center !important; + justify-content: space-between !important; + gap: 8px !important; +} + +.sectionRowText { + flex: 1 1 0; + min-width: 0; + color: var(--text-secondary); + font-size: 14px; + font-weight: 400; + line-height: 140%; + display: -webkit-box; + -webkit-box-orient: vertical; + overflow: hidden; + word-break: break-word; + white-space: pre-wrap; +} + +.sectionRowChevron { + width: 24px !important; + height: 24px !important; + flex-shrink: 0 !important; + fill: var(--svg-icon-color) !important; +} + +.sectionRow:hover .sectionRowChevron { + fill: var(--selected-color) !important; +} diff --git a/map/src/infoblock/components/favorite/FavoriteHelper.js b/map/src/infoblock/components/favorite/FavoriteHelper.js index 5b31f50036..8d1d49552b 100644 --- a/map/src/infoblock/components/favorite/FavoriteHelper.js +++ b/map/src/infoblock/components/favorite/FavoriteHelper.js @@ -24,6 +24,7 @@ function updateSelectedFile({ ctx, result, favoriteName, selectedGroup, deleted newSelectedFile.file.updatetimems = result.updatetimems; } newSelectedFile.id = selectedGroup.id; + newSelectedFile.groupId = selectedGroup.id; updateMarker(newSelectedFile, deleted, favoriteName); ctx.setSelectedGpxFile(newSelectedFile); ctx.setSelectedWpt(newSelectedFile); diff --git a/map/src/infoblock/components/favorite/WptEditPanel.jsx b/map/src/infoblock/components/favorite/WptEditPanel.jsx index 7bd91037b6..3a99184395 100644 --- a/map/src/infoblock/components/favorite/WptEditPanel.jsx +++ b/map/src/infoblock/components/favorite/WptEditPanel.jsx @@ -7,6 +7,7 @@ import MarkerOptions from '../../../map/markers/MarkerOptions'; import FavoriteName from './structure/FavoriteName'; import FavoriteAddress from './structure/FavoriteAddress'; import FavoriteDescription from './structure/FavoriteDescription'; +import DescriptionPanel from './structure/DescriptionPanel'; import FavoriteGroup from './structure/FavoriteGroup'; import FavoriteIcon from './structure/FavoriteIcon'; import FavoriteColor from './structure/FavoriteColor'; @@ -55,8 +56,8 @@ export default function WptEditPanel({ setShowInfoBlock }) { const [favoriteName, setFavoriteName] = useState(editWpt?.name ?? ''); const [favoriteAddress, setFavoriteAddress] = useState(editWpt?.address ?? ctx.addFavorite?.address ?? ''); const [favoriteDescription, setFavoriteDescription] = useState(editWpt?.desc ?? ''); - const [addAddress, setAddAddress] = useState(isEditMode || isPoi); - const [addDescription, setAddDescription] = useState(isEditMode); + const [addAddress, setAddAddress] = useState(isEditMode || isPoi || (isAddMode && !isAddTrackWpt)); + const [showDescriptionPanel, setShowDescriptionPanel] = useState(false); const [favoriteGroup, setFavoriteGroup] = useState(null); const [favoriteIcon, setFavoriteIcon] = useState( editWpt?.icon ?? poi?.options?.[FINAL_POI_ICON_NAME] ?? MarkerOptions.DEFAULT_WPT_ICON @@ -431,7 +432,7 @@ export default function WptEditPanel({ setShowInfoBlock }) { const defaultGroup = isAddTrackWpt ? DEFAULT_GROUP_NAME_POINTS_GROUPS : isEditMode - ? editWpt.category + ? (editWpt.category ?? FavoritesManager.DEFAULT_GROUP_NAME) : FavoritesManager.DEFAULT_GROUP_NAME; const title = isEditMode ? isEditTrackWpt @@ -442,124 +443,123 @@ export default function WptEditPanel({ setShowInfoBlock }) { : t('web:add_favorite'); return ( -- {process && + > ); } diff --git a/map/src/infoblock/components/favorite/structure/DescriptionPanel.jsx b/map/src/infoblock/components/favorite/structure/DescriptionPanel.jsx new file mode 100644 index 0000000000..845fc50bd8 --- /dev/null +++ b/map/src/infoblock/components/favorite/structure/DescriptionPanel.jsx @@ -0,0 +1,47 @@ +import React, { useState } from 'react'; +import { Tooltip } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as CloseRoundedIcon } from '../../../../assets/icons/ic_action_close_rounded.svg'; +import SecondaryMenuDrawer from '../../../../frame/components/other/SecondaryMenuDrawer'; +import HeaderWithUnderline from '../../../../frame/components/header/HeaderWithUnderline'; +import ActionIconBtn from '../../../../frame/components/btns/ActionIconBtn'; +import RichTextEditor from '../../../../frame/components/editor/RichTextEditor'; +import { textToHTML } from '../../../../frame/components/editor/htmlUtils'; + +export default function DescriptionPanel({ description, setDescription, onClose }) { + const { t } = useTranslation(); + const [html, setHtml] = useState(textToHTML(description)); + + function handleSave() { + setDescription(html); + onClose(); + } + + function handleClear() { + setDescription(''); + onClose(); + } + + return ( +} - - save()} + <> + {showDescriptionPanel && ( + setShowDescriptionPanel(false)} + /> + )} + + {process && + } + /> +} + + + + -+ - } - /> -+ {!addAddress && ( + + + )} + {addAddress && ( +setAddAddress(true)} + > + + {t('web:fav_add_address')} ++ + )} - - + {isEditMode && !isEditTrackWpt && ( + <> ++ setShowDescriptionPanel(true)} /> - {!addAddress && ( - - - )} - {addAddress && ( -setAddAddress(true)} - > - - Add address -- + + - {isEditMode && !isEditTrackWpt && ( - <> -- )} - {!addDescription && ( - - - )} - {addDescription && ( -setAddDescription(true)} - > - - Add description -- - )} - - - - - - } - name={t('web:remove_favorite')} - onClick={() => setDeleteWptDialogOpen(true)} + - {deleteWptDialogOpen && ( - + + } + name={t('web:remove_favorite')} + onClick={() => setDeleteWptDialogOpen(true)} /> - )} - > - )} - + {deleteWptDialogOpen && ( + + )} + > + )} + + + + ); +} diff --git a/map/src/infoblock/components/favorite/structure/FavoriteAddress.jsx b/map/src/infoblock/components/favorite/structure/FavoriteAddress.jsx index a8463f0c27..be13d0ce77 100644 --- a/map/src/infoblock/components/favorite/structure/FavoriteAddress.jsx +++ b/map/src/infoblock/components/favorite/structure/FavoriteAddress.jsx @@ -1,48 +1,59 @@ -import { IconButton, ListItemText, TextField } from '@mui/material'; -import { Delete } from '@mui/icons-material'; -import React from 'react'; +import { Box, IconButton, InputAdornment, TextField, Tooltip } from '@mui/material'; +import { ReactComponent as CancelIcon } from '../../../../assets/icons/ic_action_cancel.svg'; +import { ReactComponent as LocationIcon } from '../../../../assets/icons/ic_action_location_marker_outlined.svg'; +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { getAddressByLatLon } from '../../wpt/WptDetails'; +import styles from '../wptEditPanel.module.css'; + +export default function FavoriteAddress({ favoriteAddress, setFavoriteAddress, widthDialog, latLon }) { + const { t } = useTranslation(); + + const [searching, setSearching] = useState(false); + + useEffect(() => { + if (!latLon?.lat || !latLon?.lon || favoriteAddress) return; + searchAddress(); + }, [latLon]); + + function searchAddress() { + if (!latLon?.lat || !latLon?.lon) return; + setSearching(true); + getAddressByLatLon(latLon.lat, latLon.lon).then((address) => { + setFavoriteAddress(address ?? ''); + setSearching(false); + }); + } -export default function FavoriteAddress({ favoriteAddress, setFavoriteAddress, setClose, widthDialog }) { return ( -+ + } + onClick={handleClear} + /> + + + } + /> + + + + ); } diff --git a/map/src/infoblock/components/favorite/structure/FavoriteDescription.jsx b/map/src/infoblock/components/favorite/structure/FavoriteDescription.jsx index 4daca92da4..62582d147c 100644 --- a/map/src/infoblock/components/favorite/structure/FavoriteDescription.jsx +++ b/map/src/infoblock/components/favorite/structure/FavoriteDescription.jsx @@ -1,50 +1,36 @@ -import { IconButton, ListItemText, TextField } from '@mui/material'; -import { Delete } from '@mui/icons-material'; -import React from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import SubTitleMenu from '../../../../frame/components/titles/SubTitleMenu'; +import SectionRow from '../../../../frame/components/items/SectionRow'; +import { htmlToText } from '../../../../frame/components/editor/htmlUtils'; +import styles from '../wptEditPanel.module.css'; + +const ROW_MULTILINE_THRESHOLD = 50; + +export default function FavoriteDescription({ favoriteDescription, onClick }) { + const { t } = useTranslation(); + const ref = useRef(null); + const [isMultiline, setIsMultiline] = useState(false); + const preview = htmlToText(favoriteDescription) || t('web:add_notes'); + + useEffect(() => { + const wrap = ref.current; + if (!wrap) return; + const update = () => { + const row = wrap.querySelector('[role="menuitem"]'); + setIsMultiline((row?.clientHeight ?? 0) > ROW_MULTILINE_THRESHOLD); + }; + update(); + const observer = new ResizeObserver(update); + observer.observe(wrap); + + return () => observer.disconnect(); + }, [preview]); -export default function FavoriteDescription({ favoriteDescription, setFavoriteDescription, setClose, widthDialog }) { return ( -setFavoriteAddress(e.target.value)} - value={favoriteAddress} - autoFocus - sx={{ - maxWidth: '450px !important', - resize: 'none', - fontFamily: 'Arial', - color: 'black', - fontSize: 20, - ml: '-2px', - borderColor: '#bebdb4', - backgroundColor: 'transparent', - outlineColor: '#757575', - cursor: 'pointer', - '&[disabled]': { border: 'none' }, - mb: '-10px', - pb: '8px', - pt: '8px', + value={searching ? t('web:fav_address_searching') : favoriteAddress} + inputProps={{ className: styles.fieldInput }} + InputProps={{ + endAdornment: ( + + {favoriteAddress ? ( + + ), }} /> - {favoriteAddress && favoriteAddress !== '' && ( -setFavoriteAddress('')}> + + ) : ( ++ + + )} ++ ++ { - if (setClose) { - setClose(false); - } - setFavoriteAddress(''); - }} - > - - )} -- - +setFavoriteDescription(e.target.value)} - value={favoriteDescription} - autoFocus - multiline - rows={2} - sx={{ - maxWidth: '450px !important', - resize: 'none', - fontFamily: 'Arial', - color: 'black', - fontSize: 20, - ml: '-2px', - borderColor: '#bebdb4', - backgroundColor: 'transparent', - outlineColor: '#757575', - cursor: 'pointer', - '&[disabled]': { border: 'none' }, - mb: '-10px', - pb: '8px', - pt: '8px', - }} - /> - {favoriteDescription && favoriteDescription !== '' && ( - { - if (setClose) { - setClose(false); - } - setFavoriteDescription(''); - }} - > - - )} -- +); } diff --git a/map/src/infoblock/components/favorite/structure/FavoriteName.jsx b/map/src/infoblock/components/favorite/structure/FavoriteName.jsx index cd21001c94..6658555ad4 100644 --- a/map/src/infoblock/components/favorite/structure/FavoriteName.jsx +++ b/map/src/infoblock/components/favorite/structure/FavoriteName.jsx @@ -1,8 +1,9 @@ -import { ListItemText, TextField } from '@mui/material'; +import { Box, TextField } from '@mui/material'; import React, { useContext, useEffect, useState } from 'react'; import AppContext from '../../../../context/AppContext'; import { getPropsFromSearchResultItem } from '../../../../menu/search/search/SearchResultItem'; import { useTranslation } from 'react-i18next'; +import styles from '../wptEditPanel.module.css'; export default function FavoriteName({ favoriteName, @@ -27,16 +28,15 @@ export default function FavoriteName({ } let group = ctx.favorites?.mapObjs?.[id]; let names = []; - group && - group.wpts.forEach((wpt) => { - if (favorite) { - if (wpt.name !== favorite.name) { - names.push(wpt.name); - } - } else { + group?.wpts.forEach((wpt) => { + if (favorite) { + if (wpt.name !== favorite.name) { names.push(wpt.name); } - }); + } else { + names.push(wpt.name); + } + }); validateName(favoriteName, names); setFavNames(names); }, [favoriteGroup]); @@ -60,14 +60,14 @@ export default function FavoriteName({ setErrorName(nameExists); } - function gerErrorText(favoriteName) { - if (favoriteName === '') { - return 'Empty name!'; + function getErrorText(name) { + if (name === '') { + return t('web:fav_name_empty'); } else if (nameAlreadyExist) { - return 'This name already exists!'; - } else { - return ' '; + return t('web:fav_name_already_exists'); } + + return ' '; } useEffect(() => { @@ -86,33 +86,19 @@ export default function FavoriteName({ }, [ctx.selectedWpt]); return ( -+ + + + ); } diff --git a/map/src/infoblock/components/favorite/wptEditPanel.module.css b/map/src/infoblock/components/favorite/wptEditPanel.module.css index 29d107d420..c1fe1cd11b 100644 --- a/map/src/infoblock/components/favorite/wptEditPanel.module.css +++ b/map/src/infoblock/components/favorite/wptEditPanel.module.css @@ -13,7 +13,20 @@ } .fields { - padding: 8px 16px 16px 16px; + padding: 16px; +} + +.helperText { + height: 16px; + line-height: 16px; + margin: 2px 0 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.fieldInput { + padding-left: 8px !important; } .actions { @@ -28,3 +41,7 @@ justify-content: flex-end; align-items: center; } + +.descriptionSection { + padding-bottom: 9px; +} diff --git a/map/src/infoblock/components/wpt/MoreInfoDialog.jsx b/map/src/infoblock/components/wpt/MoreInfoDialog.jsx index ad480c17b3..ef49088d35 100644 --- a/map/src/infoblock/components/wpt/MoreInfoDialog.jsx +++ b/map/src/infoblock/components/wpt/MoreInfoDialog.jsx @@ -18,7 +18,7 @@ export default function MoreInfoDialog({ setOpenMoreDialog, title, content }) {setFavoriteName(e.target.value)} value={favoriteName} autoFocus error={favoriteName === '' || nameAlreadyExist} - helperText={gerErrorText(favoriteName)} - sx={{ - maxWidth: '450px !important', - resize: 'none', - fontFamily: 'Arial', - color: 'black', - fontSize: 20, - ml: '-2px', - borderColor: '#bebdb4', - backgroundColor: 'transparent', - outlineColor: '#757575', - cursor: 'pointer', - '&[disabled]': { border: 'none' }, - mb: '-10px', - pb: '8px', - pt: '8px', - }} + helperText={getErrorText(favoriteName)} + FormHelperTextProps={{ className: styles.helperText }} /> - - ); diff --git a/map/src/infoblock/components/wpt/WptDetails.jsx b/map/src/infoblock/components/wpt/WptDetails.jsx index 5dc444ec79..ad0959bb8a 100644 --- a/map/src/infoblock/components/wpt/WptDetails.jsx +++ b/map/src/infoblock/components/wpt/WptDetails.jsx @@ -161,6 +161,22 @@ export const ADDRESS_NOT_FOUND = i18n.t('web:no_data'); export const TYPE_NOT_FOUND = 'No type'; export const EMPTY_STRING = ''; +export async function getAddressByLatLon(lat, lon) { + if (lat == null || lon == null) return null; + const response = await apiGet(`${process.env.REACT_APP_ROUTING_API_SITE}/search/get-poi-address`, { + apiCache: true, + params: { lat, lon }, + }); + if (response?.data) { + return response.data + .replace(/ str\./g, '') + .replace(/ city/g, ',') + .replace(/ dist.*/g, ''); + } + + return null; +} + export default function WptDetails({ setOpenWptTab, setShowInfoBlock }) { const ctx = useContext(AppContext); const { t } = useTranslation(); @@ -639,25 +655,8 @@ export default function WptDetails({ setOpenWptTab, setShowInfoBlock }) { return fmt.dateTimeShort(time); } - async function getPoiAddress(wpt) { - if (wpt.latlon?.lat == null || wpt.latlon?.lon == null) { - return null; - } - const response = await apiGet(`${process.env.REACT_APP_ROUTING_API_SITE}/search/get-poi-address`, { - apiCache: true, - params: { - lat: wpt.latlon.lat, - lon: wpt.latlon.lon, - }, - }); - if (response?.data) { - return response.data - .replace(/ str\./g, '') - .replace(/ city/g, ',') - .replace(/ dist.*/g, ''); - } else { - return null; - } + function getPoiAddress(wpt) { + return getAddressByLatLon(wpt.latlon?.lat, wpt.latlon?.lon); } async function getPhotos(wpt) { diff --git a/map/src/infoblock/components/wpt/WptTagInfo.jsx b/map/src/infoblock/components/wpt/WptTagInfo.jsx index 858899080c..d4c8a6e639 100644 --- a/map/src/infoblock/components/wpt/WptTagInfo.jsx +++ b/map/src/infoblock/components/wpt/WptTagInfo.jsx @@ -2,6 +2,7 @@ import { Collapse, IconButton, Link, ListItemIcon, ListItemText, MenuItem, Toolt import styles from './wptDetails.module.css'; import React, { useContext, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import parse from 'html-react-parser'; import { CUISINE_PREFIX, openWikipediaContent, POI_PREFIX, SEPARATOR, WIKIPEDIA } from './WptTagsProvider'; import { ExpandLess, ExpandMore } from '@mui/icons-material'; import MenuItemWithLines from '../../../menu/components/MenuItemWithLines'; @@ -9,8 +10,9 @@ import i18n from 'i18next'; import MoreInfoDialog from './MoreInfoDialog'; import AppContext from '../../../context/AppContext'; import capitalize from 'lodash-es/capitalize'; -import { translateWithSplit } from '../../../manager/PoiManager'; +import { cleanHtml, translateWithSplit } from '../../../manager/PoiManager'; import { getLanguageName } from '../../../util/LanguageDisplayName'; +import { stripHtml } from '../../../frame/components/editor/htmlUtils'; export default function WptTagInfo({ tag = null, baseTag = null, copy = false, setDevWikiContent = null }) { const ctx = useContext(AppContext); @@ -295,8 +297,11 @@ export default function WptTagInfo({ tag = null, baseTag = null, copy = false, s{content} +{content} {baseTag.icon} { - if (baseTag.value?.length > 50) { - setOpenMoreDialog({ title: baseTag.name, content: baseTag.value }); + if (baseTag.isDesc || baseTag.value?.length > 50) { + setOpenMoreDialog({ + title: baseTag.name, + content: baseTag.isDesc ? parse(cleanHtml(baseTag.value)) : baseTag.value, + }); } }} > @@ -311,7 +316,11 @@ export default function WptTagInfo({ tag = null, baseTag = null, copy = false, s open={hover && copy} onClick={() => handleCopy(baseTag.value)} > - + )} {baseTag.link} diff --git a/map/src/menu/search/explore/PhotosModal.jsx b/map/src/menu/search/explore/PhotosModal.jsx index 65ec5cb342..9914a0ae3c 100644 --- a/map/src/menu/search/explore/PhotosModal.jsx +++ b/map/src/menu/search/explore/PhotosModal.jsx @@ -1,6 +1,7 @@ -import React, { useContext, useEffect, useState } from 'react'; +import React, { useContext, useEffect, useRef, useState } from 'react'; import { AppBar, Box, Button, Drawer, IconButton, Toolbar, Typography, Skeleton } from '@mui/material'; -import SwipeableViews from 'react-swipeable-views'; +import { Swiper, SwiperSlide } from 'swiper/react'; +import 'swiper/css'; import { useWindowSize } from '../../../util/hooks/useWindowSize'; import { ReactComponent as BackIcon } from '../../../assets/icons/ic_arrow_back.svg'; import { ReactComponent as BackForward } from '../../../assets/icons/ic_arrow_forward.svg'; @@ -18,20 +19,33 @@ import PropTypes from 'prop-types'; import { fmt } from '../../../util/dateFmt'; import { getPhotoTitle } from '../../../manager/SearchManager'; +const HEADER_HEIGHT = 60; +const LEFT_MARGIN = 423; +const FOOTER_HEIGHT = 88; +const METADATA_MARGIN = 62; + export default function PhotosModal({ photos }) { const ctx = useContext(AppContext); const { t, i18n } = useTranslation(); + const swiperRef = useRef(null); + const [width, height] = useWindowSize(); + const [open, setOpen] = useState(true); const [activeStep, setActiveStep] = useState(ctx.selectedPhotoInd); - const [width, height] = useWindowSize(); const [showInfo, setShowInfo] = useState(false); const [activePhoto, setActivePhoto] = useState(null); - const HEADER_HEIGHT = 60; - const LEFT_MARGIN = 423; - const METADATA_MARGIN = 62; - const FOOTER_HEIGHT = 88; + const handleClose = () => ctx.setSelectedPhotoInd(-1); + + const handleNext = () => ctx.setSelectedPhotoInd(Math.min(activeStep + 1, photos.length - 1)); + + const handleBack = () => ctx.setSelectedPhotoInd(Math.max(activeStep - 1, 0)); + + const handleStepChange = (step) => { + setActiveStep(step); + ctx.setSelectedPhotoInd(step); + }; useEffect(() => { if (ctx.selectedPhotoInd !== -1) { @@ -55,18 +69,6 @@ export default function PhotosModal({ photos }) { } }, [ctx.selectedPhotoInd]); - const handleClose = () => { - ctx.setSelectedPhotoInd(-1); - }; - - const handleNext = () => { - ctx.setSelectedPhotoInd(Math.min(activeStep + 1, photos.length - 1)); - }; - - const handleBack = () => { - ctx.setSelectedPhotoInd(Math.max(activeStep - 1, 0)); - }; - useEffect(() => { const handleKeyDown = (event) => { if (event.key === 'ArrowRight' || event.key === ' ') { @@ -75,90 +77,31 @@ export default function PhotosModal({ photos }) { handleBack(); } }; + globalThis.addEventListener('keydown', handleKeyDown); - window.addEventListener('keydown', handleKeyDown); return () => { - window.removeEventListener('keydown', handleKeyDown); + globalThis.removeEventListener('keydown', handleKeyDown); }; }, [handleNext, handleBack]); - const handleStepChange = (step) => setActiveStep(step); - - function getHeight() { - return height - HEADER_HEIGHT; - } - - function getPhotoHeight() { - return height - HEADER_HEIGHT - FOOTER_HEIGHT; - } + useEffect(() => { + if (swiperRef.current && swiperRef.current.activeIndex !== activeStep) { + swiperRef.current.slideTo(activeStep); + } + }, [activeStep]); - function getWidth() { - return width - LEFT_MARGIN; - } + const getHeight = () => height - HEADER_HEIGHT; + const getWidth = () => width - LEFT_MARGIN; - function needOpenMoreModal(str) { - const fontSize = 16; - const avgCharWidth = fontSize * 0.6; - const textWidth = str.length * avgCharWidth; - const containerWidth = getWidth() - METADATA_MARGIN; - return textWidth > containerWidth; - } + const needOpenMoreModal = (str) => { + const textWidth = str.length * 16 * 0.6; // approx: font-size 16px * ~0.6 char-width ratio + return textWidth > getWidth() - METADATA_MARGIN; + }; if (!photos || photos.length === 0) { return null; } - function hasFooterInfo(photo) { - return ( - photo?.properties?.date || - photo?.properties?.author || - photo?.properties?.license || - photo?.properties?.description - ); - } - - const formatDate = (dateStr) => { - if (!dateStr) { - return ''; - } - const clean = dateStr?.startsWith('+') ? dateStr.slice(1) : dateStr; - // Format YYYY 2025 -> 2025 - const yearRegex = /^\d{4}$/; - if (yearRegex.test(clean)) { - return clean; - } - // Format YYYY-MM 2025-09 -> September 2025 - const yearMonthRegex = /^\d{4}-\d{2}$/; - if (yearMonthRegex.test(clean)) { - const d = new Date(clean + '-01'); // Add the first day of the month for browser compatibility - return isNaN(d) ? dateStr : fmt.monthYearLong(d); - } - // Format YYYY-MM-DD 2025-09-04 -> 4 September 2025 - const d = new Date(clean); - return isNaN(d) ? dateStr : fmt.dMMMMY(d); - }; - - const parseDescription = (descriptionStr) => { - if (!descriptionStr) { - return ''; - } - try { - const parsed = JSON.parse(descriptionStr); - const currentLang = i18n.language?.split('-')[0] || 'en'; - - if (parsed[currentLang]) { - return parsed[currentLang]; - } else if (parsed['en']) { - return parsed['en']; - } else { - const firstKey = Object.keys(parsed)[0]; - return firstKey ? parsed[firstKey] : descriptionStr; - } - } catch (e) { - return descriptionStr; - } - }; - return ( - Photos + {t('web:photos')} -+ +(swiperRef.current = swiper)} + onSlideChange={(swiper) => handleStepChange(swiper.activeIndex)} + > {photos.map((photo, index) => ( - + + ))} -+