diff --git a/map/CLAUDE.md b/map/CLAUDE.md index dfd6cee992..3d87c8d811 100644 --- a/map/CLAUDE.md +++ b/map/CLAUDE.md @@ -112,6 +112,14 @@ Use `?.` whenever a value may be `null` or `undefined`. Avoid `&&`-chains for pr Before writing anything — check if it already exists. Extract shared logic into modules, pass data that's already fetched instead of re-fetching it. +## No unnecessary refactoring + +When asked to make a focused change — fix a bug, add a feature, rename something — do exactly that and nothing else. Do not restructure surrounding code, rename unrelated variables, reorder logic, or "clean up" things that weren't part of the request. Unrelated changes make diffs harder to review and can introduce bugs. + +## Never remove existing comments + +Do not delete inline comments or block comments that already exist in code you are editing, even if they seem obvious or redundant. Comments represent the author's intent and are part of the code's documentation. + ## refs — last resort Use `useRef` / `ref` only when the problem **cannot be solved any other way**. Always look for a solution via state, context, props, or derived values first. A `ref` is acceptable only when all other options are unsuitable (e.g. direct DOM node access, storing a value without triggering a re-render). diff --git a/map/package.json b/map/package.json index 7b3e864252..0a58446162 100644 --- a/map/package.json +++ b/map/package.json @@ -10,12 +10,11 @@ "@hello-pangea/dnd": "^15.0.0", "@mui/icons-material": "^5.8.3", "@mui/material": "^5.8.3", - "@testing-library/jest-dom": "^5.16.4", - "@testing-library/react": "^13.3.0", - "@testing-library/user-event": "^14.2.0", + "@tiptap/pm": "^3.22.4", + "@tiptap/react": "^3.22.4", + "@tiptap/starter-kit": "^3.22.4", "@types/leaflet.awesome-markers": "^2.0.25", "anchorme": "^3.0.5", - "axios": "1.11.0", "blueimp-md5": "^2.19.0", "chart.js": "^4.3.0", "chartjs-plugin-annotation": "^3.0.1", @@ -28,13 +27,11 @@ "i18next": "^23.7.16", "i18next-browser-languagedetector": "^7.2.0", "i18next-http-backend": "^2.4.2", - "ionicons": "^6.0.2", "leaflet": "^1.9.3", "leaflet-contextmenu": "^1.4.0", "leaflet-geometryutil": "^0.10.1", "leaflet-hash": "^0.2.1", "leaflet-spin": "^1.1.2", - "leaflet.awesome-markers": "^2.0.5", "leaflet.vectorgrid": "^1.3.0", "lodash-es": "^4.17.21", "pako": "^2.0.4", @@ -51,11 +48,10 @@ "react-leaflet-markercluster": "^3.0.0-rc1", "react-router-dom": "^6.3.0", "react-scripts": "^5.0.1", - "react-swipeable-views": "^0.14.0", "react-use-cookie": "^1.4.0", "react-window": "^1.8.7", - "recharts": "^2.1.10", "source-map-explorer": "^2.5.3", + "swiper": "^12.1.3", "web-vitals": "^2.1.4" }, "scripts": { diff --git a/map/src/assets/icons/ic_action_arrow_up.svg b/map/src/assets/icons/ic_action_arrow_up.svg new file mode 100644 index 0000000000..439cc45501 --- /dev/null +++ b/map/src/assets/icons/ic_action_arrow_up.svg @@ -0,0 +1,3 @@ + + + diff --git a/map/src/assets/icons/ic_action_close_rounded.svg b/map/src/assets/icons/ic_action_close_rounded.svg new file mode 100644 index 0000000000..f72c5a7002 --- /dev/null +++ b/map/src/assets/icons/ic_action_close_rounded.svg @@ -0,0 +1,3 @@ + + + diff --git a/map/src/frame/components/editor/EditorToolbar.jsx b/map/src/frame/components/editor/EditorToolbar.jsx new file mode 100644 index 0000000000..54a79ee70b --- /dev/null +++ b/map/src/frame/components/editor/EditorToolbar.jsx @@ -0,0 +1,156 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Box, Divider, IconButton, Tooltip } from '@mui/material'; +import FormatBoldIcon from '@mui/icons-material/FormatBold'; +import FormatItalicIcon from '@mui/icons-material/FormatItalic'; +import FormatUnderlinedIcon from '@mui/icons-material/FormatUnderlined'; +import StrikethroughSIcon from '@mui/icons-material/StrikethroughS'; +import FormatListBulletedIcon from '@mui/icons-material/FormatListBulleted'; +import FormatListNumberedIcon from '@mui/icons-material/FormatListNumbered'; +import FormatQuoteIcon from '@mui/icons-material/FormatQuote'; +import LinkIcon from '@mui/icons-material/Link'; +import LinkOffIcon from '@mui/icons-material/LinkOff'; +import UndoIcon from '@mui/icons-material/Undo'; +import RedoIcon from '@mui/icons-material/Redo'; +import styles from './editor.module.css'; + +export default function EditorToolbar({ editor }) { + const { t } = useTranslation(); + const canUndo = editor?.can().undo() ?? false; + const canRedo = editor?.can().redo() ?? false; + + return ( + + } + disabled={!canUndo} + onClick={() => editor?.chain().focus().undo().run()} + /> + } + disabled={!canRedo} + onClick={() => editor?.chain().focus().redo().run()} + /> + + + + + + + + + + } + active={editor?.isActive('bulletList') ?? false} + onClick={() => editor?.chain().focus().toggleBulletList().run()} + /> + } + active={editor?.isActive('orderedList') ?? false} + onClick={() => editor?.chain().focus().toggleOrderedList().run()} + /> + } + active={editor?.isActive('blockquote') ?? false} + onClick={() => editor?.chain().focus().toggleBlockquote().run()} + /> + + + + } + active={editor?.isActive('bold') ?? false} + onClick={() => editor?.chain().focus().toggleBold().run()} + /> + } + active={editor?.isActive('italic') ?? false} + onClick={() => editor?.chain().focus().toggleItalic().run()} + /> + } + active={editor?.isActive('underline') ?? false} + onClick={() => editor?.chain().focus().toggleUnderline().run()} + /> + } + active={editor?.isActive('strike') ?? false} + onClick={() => editor?.chain().focus().toggleStrike().run()} + /> + + + + + + ); +} + +function ToolbarBtn({ title, icon, active, onClick, disabled }) { + return ( + + + { + e.preventDefault(); + onClick(); + }} + > + {icon} + + + + ); +} + +function HeadingBtn({ editor, level, label, title }) { + return ( + {label}} + active={editor?.isActive('heading', { level }) ?? false} + onClick={() => editor?.chain().focus().toggleHeading({ level }).run()} + /> + ); +} + +function LinkButton({ editor }) { + const { t } = useTranslation(); + + if (!editor) return null; + + const isLink = editor.isActive('link'); + + function handleClick() { + if (isLink) { + editor.chain().focus().unsetLink().run(); + + return; + } + const url = window.prompt(t('web:editor_link_url_prompt')); + if (url) { + editor.chain().focus().setLink({ href: url }).run(); + } + } + + return ( + : } + active={isLink} + onClick={handleClick} + /> + ); +} diff --git a/map/src/frame/components/editor/RichTextEditor.jsx b/map/src/frame/components/editor/RichTextEditor.jsx new file mode 100644 index 0000000000..342fa3de5b --- /dev/null +++ b/map/src/frame/components/editor/RichTextEditor.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Box } from '@mui/material'; +import { useEditor, EditorContent } from '@tiptap/react'; +import StarterKit from '@tiptap/starter-kit'; +import EditorToolbar from './EditorToolbar'; +import styles from './editor.module.css'; + +export default function RichTextEditor({ content, onChange, autofocus = true, editorId }) { + const editor = useEditor({ + extensions: [StarterKit.configure({ link: { openOnClick: false } })], + content, + autofocus, + onUpdate: ({ editor }) => onChange?.(editor.getHTML()), + editorProps: editorId ? { attributes: { id: editorId } } : undefined, + }); + + return ( + <> + + + + + + ); +} diff --git a/map/src/frame/components/editor/editor.module.css b/map/src/frame/components/editor/editor.module.css new file mode 100644 index 0000000000..f5f2dd6e4c --- /dev/null +++ b/map/src/frame/components/editor/editor.module.css @@ -0,0 +1,103 @@ +.prosemirror :global(.ProseMirror) { + outline: none; + font-size: 16px; + line-height: 1.6; + color: var(--text-primary, #212121); + padding: 16px; + min-height: 120px; +} + +.prosemirror :global(.ProseMirror p) { + margin: 0 0 4px 0; +} + +.prosemirror :global(.ProseMirror p:last-child) { + margin-bottom: 0; +} + +.prosemirror :global(.ProseMirror h1) { + font-size: 20px; + font-weight: 700; + margin: 0 0 8px 0; +} + +.prosemirror :global(.ProseMirror h2) { + font-size: 17px; + font-weight: 600; + margin: 0 0 6px 0; +} + +.prosemirror :global(.ProseMirror h3) { + font-size: 15px; + font-weight: 600; + margin: 0 0 4px 0; +} + +.prosemirror :global(.ProseMirror ul), +.prosemirror :global(.ProseMirror ol) { + padding-left: 24px; + margin: 4px 0; +} + +.editorContent { + display: flex; + flex-direction: column; + flex-grow: 1; + min-height: 0; + overflow-y: auto; + scrollbar-gutter: stable; +} + +.toolbar { + display: flex; + align-items: center; + justify-content: center; + height: 43px; + min-height: 43px; + border-bottom: 1px solid #e0e0e0; + gap: 0; + flex-shrink: 0; +} + +.toolbarBtn { + color: var(--text-secondary, #666) !important; + border-radius: 4px !important; + padding: 2px !important; +} + +.toolbarBtn:hover { + background-color: #f0f0f0 !important; +} + +.toolbarBtnActive { + color: #237BFF !important; + background-color: #e9f0fb !important; + border-radius: 4px !important; + padding: 2px !important; +} + +.toolbarDivider { + margin: 0 4px !important; + height: 20px !important; + align-self: center !important; +} + +.headingBtnLabel { + font-size: 13px; + font-weight: 600; + line-height: 1; +} + +.prosemirror :global(.ProseMirror blockquote) { + border-left: 3px solid #ccc; + margin: 4px 0; + padding-left: 12px; + color: var(--text-secondary, #666); + font-style: italic; +} + +.prosemirror :global(.ProseMirror a) { + color: #237BFF; + text-decoration: underline; + cursor: pointer; +} diff --git a/map/src/frame/components/editor/htmlUtils.js b/map/src/frame/components/editor/htmlUtils.js new file mode 100644 index 0000000000..198e6bb74f --- /dev/null +++ b/map/src/frame/components/editor/htmlUtils.js @@ -0,0 +1,44 @@ +/** + * Converts legacy plain text to HTML for loading into the rich text editor. + * Used only for backward compatibility with descriptions saved before rich text was introduced. + * + * - Already-HTML strings (starting with '<') are returned as-is. + * - Double (or more) newlines → paragraph breaks

+ * - 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', '
')}

`) + .join(''); +} + +/** + * Strips HTML tags and converts block-level elements to newlines. + * Used to generate plain-text previews from rich text HTML. + * + * Handles:

, ,

  • ,
    ,
    + */ +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 ( + +
    + {name} +
    + +
    + ); +} 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 && } - - save()} + <> + {showDescriptionPanel && ( + setShowDescriptionPanel(false)} + /> + )} + + {process && } + + + + } + /> + + + + {!addAddress && ( + + setAddAddress(true)} + > + + + {t('web:fav_add_address')} + + )} + {addAddress && ( + + )} - } - /> - - - + setShowDescriptionPanel(true)} /> - {!addAddress && ( - - setAddAddress(true)} - > - - - Add address - - )} - {addAddress && ( - + + - )} - {!addDescription && ( - - setAddDescription(true)} - > - - - Add description - - )} - {addDescription && ( - - )} - - - - - - {isEditMode && !isEditTrackWpt && ( - <> - - } - name={t('web:remove_favorite')} - onClick={() => setDeleteWptDialogOpen(true)} + - {deleteWptDialogOpen && ( - + + {isEditMode && !isEditTrackWpt && ( + <> + + } + name={t('web:remove_favorite')} + onClick={() => setDeleteWptDialogOpen(true)} /> - )} - - )} - + {deleteWptDialogOpen && ( + + )} + + )} + + - + ); } 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 ( + + + + } + onClick={handleClear} + /> + + + } + /> + + + ); +} 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 ( - + 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 ? ( + setFavoriteAddress('')}> + + + ) : ( + + + + + + )} + + ), }} /> - {favoriteAddress && favoriteAddress !== '' && ( - { - if (setClose) { - setClose(false); - } - setFavoriteAddress(''); - }} - > - - - )} - + ); } 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 ( - - 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 ( - + 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/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 }) { - {content} + {content} ); 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 {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) => ( - + + + ))} - +