fix(lib): improved item header (#383)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Anton Tranelis 2025-10-13 13:15:06 +02:00 committed by GitHub
parent 2411017c33
commit 15fbd3e6ce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 1521 additions and 273 deletions

View File

@ -124,7 +124,7 @@ function MapContainer({ layers, map }: { layers: LayerProps[]; map: any }) {
parameterField={ parameterField={
layer.itemType.custom_profile_url ? 'extended.external_profile_id' : 'id' layer.itemType.custom_profile_url ? 'extended.external_profile_id' : 'id'
} }
text={layer.itemType.botton_label ?? 'Profile'} text={layer.itemType.button_label ?? 'Profile'}
target={layer.itemType.custom_profile_url ? '_blank' : '_self'} target={layer.itemType.custom_profile_url ? '_blank' : '_self'}
/> />
)} )}

View File

@ -60,6 +60,8 @@
"public_registration_role": null, "public_registration_role": null,
"public_registration_email_filter": null, "public_registration_email_filter": null,
"visual_editor_urls": null, "visual_editor_urls": null,
"accepted_terms": true,
"project_id": "0199aa52-4dd7-7293-984a-f2af93b5f8fd",
"_syncId": "55f04445-0c26-4201-ab9c-d6e0fbadf6bf" "_syncId": "55f04445-0c26-4201-ab9c-d6e0fbadf6bf"
} }
] ]

View File

@ -0,0 +1,32 @@
{
"collection": "types",
"field": "Header",
"type": "alias",
"meta": {
"collection": "types",
"conditions": null,
"display": null,
"display_options": null,
"field": "Header",
"group": null,
"hidden": false,
"interface": "group-detail",
"note": null,
"options": {
"headerIcon": "credit_card",
"start": "closed"
},
"readonly": false,
"required": false,
"sort": 7,
"special": [
"alias",
"no-data",
"group"
],
"translations": null,
"validation": null,
"validation_message": null,
"width": "full"
}
}

View File

@ -13,11 +13,12 @@
"interface": "group-detail", "interface": "group-detail",
"note": null, "note": null,
"options": { "options": {
"headerIcon": "lab_profile" "headerIcon": "lab_profile",
"start": "closed"
}, },
"readonly": false, "readonly": false,
"required": false, "required": false,
"sort": 9, "sort": 10,
"special": [ "special": [
"alias", "alias",
"no-data", "no-data",

View File

@ -0,0 +1,59 @@
{
"collection": "types",
"field": "cta_button_label",
"type": "string",
"meta": {
"collection": "types",
"conditions": [
{
"hidden": true,
"name": "show cta button",
"readonly": false,
"required": false,
"rule": {
"_and": [
{
"show_cta_button": {
"_eq": false
}
}
]
}
}
],
"display": null,
"display_options": null,
"field": "cta_button_label",
"group": "header_elements",
"hidden": false,
"interface": "input",
"note": null,
"options": null,
"readonly": false,
"required": false,
"sort": 5,
"special": null,
"translations": null,
"validation": null,
"validation_message": null,
"width": "half"
},
"schema": {
"name": "cta_button_label",
"table": "types",
"data_type": "character varying",
"default_value": null,
"max_length": 255,
"numeric_precision": null,
"numeric_scale": null,
"is_nullable": true,
"is_unique": false,
"is_indexed": false,
"is_primary_key": false,
"is_generated": false,
"generation_expression": null,
"has_auto_increment": false,
"foreign_key_table": null,
"foreign_key_column": null
}
}

View File

@ -0,0 +1,29 @@
{
"collection": "types",
"field": "header_elements",
"type": "alias",
"meta": {
"collection": "types",
"conditions": null,
"display": null,
"display_options": null,
"field": "header_elements",
"group": "Header",
"hidden": false,
"interface": "group-raw",
"note": null,
"options": null,
"readonly": false,
"required": false,
"sort": 3,
"special": [
"alias",
"no-data",
"group"
],
"translations": null,
"validation": null,
"validation_message": null,
"width": "full"
}
}

View File

@ -0,0 +1,45 @@
{
"collection": "types",
"field": "show_cta_button",
"type": "boolean",
"meta": {
"collection": "types",
"conditions": null,
"display": null,
"display_options": null,
"field": "show_cta_button",
"group": "header_elements",
"hidden": false,
"interface": "boolean",
"note": null,
"options": null,
"readonly": false,
"required": false,
"sort": 4,
"special": [
"cast-boolean"
],
"translations": null,
"validation": null,
"validation_message": null,
"width": "half"
},
"schema": {
"name": "show_cta_button",
"table": "types",
"data_type": "boolean",
"default_value": false,
"max_length": null,
"numeric_precision": null,
"numeric_scale": null,
"is_nullable": true,
"is_unique": false,
"is_indexed": false,
"is_primary_key": false,
"is_generated": false,
"generation_expression": null,
"has_auto_increment": false,
"foreign_key_table": null,
"foreign_key_column": null
}
}

View File

@ -0,0 +1,45 @@
{
"collection": "types",
"field": "show_navigation_button",
"type": "boolean",
"meta": {
"collection": "types",
"conditions": null,
"display": null,
"display_options": null,
"field": "show_navigation_button",
"group": "header_elements",
"hidden": false,
"interface": "boolean",
"note": null,
"options": null,
"readonly": false,
"required": false,
"sort": 2,
"special": [
"cast-boolean"
],
"translations": null,
"validation": null,
"validation_message": null,
"width": "half"
},
"schema": {
"name": "show_navigation_button",
"table": "types",
"data_type": "boolean",
"default_value": false,
"max_length": null,
"numeric_precision": null,
"numeric_scale": null,
"is_nullable": true,
"is_unique": false,
"is_indexed": false,
"is_primary_key": false,
"is_generated": false,
"generation_expression": null,
"has_auto_increment": false,
"foreign_key_table": null,
"foreign_key_column": null
}
}

View File

@ -0,0 +1,45 @@
{
"collection": "types",
"field": "show_qr_button",
"type": "boolean",
"meta": {
"collection": "types",
"conditions": null,
"display": null,
"display_options": null,
"field": "show_qr_button",
"group": "header_elements",
"hidden": false,
"interface": "boolean",
"note": null,
"options": null,
"readonly": false,
"required": false,
"sort": 1,
"special": [
"cast-boolean"
],
"translations": null,
"validation": null,
"validation_message": null,
"width": "half"
},
"schema": {
"name": "show_qr_button",
"table": "types",
"data_type": "boolean",
"default_value": false,
"max_length": null,
"numeric_precision": null,
"numeric_scale": null,
"is_nullable": true,
"is_unique": false,
"is_indexed": false,
"is_primary_key": false,
"is_generated": false,
"generation_expression": null,
"has_auto_increment": false,
"foreign_key_table": null,
"foreign_key_column": null
}
}

View File

@ -0,0 +1,45 @@
{
"collection": "types",
"field": "show_share_button",
"type": "boolean",
"meta": {
"collection": "types",
"conditions": null,
"display": null,
"display_options": null,
"field": "show_share_button",
"group": "header_elements",
"hidden": false,
"interface": "boolean",
"note": null,
"options": null,
"readonly": false,
"required": false,
"sort": 3,
"special": [
"cast-boolean"
],
"translations": null,
"validation": null,
"validation_message": null,
"width": "half"
},
"schema": {
"name": "show_share_button",
"table": "types",
"data_type": "boolean",
"default_value": false,
"max_length": null,
"numeric_precision": null,
"numeric_scale": null,
"is_nullable": true,
"is_unique": false,
"is_indexed": false,
"is_primary_key": false,
"is_generated": false,
"generation_expression": null,
"has_auto_increment": false,
"foreign_key_table": null,
"foreign_key_column": null
}
}

View File

@ -13,11 +13,12 @@
"interface": "group-detail", "interface": "group-detail",
"note": null, "note": null,
"options": { "options": {
"headerIcon": "edit_square" "headerIcon": "edit_square",
"start": "closed"
}, },
"readonly": false, "readonly": false,
"required": false, "required": false,
"sort": 8, "sort": 9,
"special": [ "special": [
"alias", "alias",
"no-data", "no-data",

View File

@ -13,11 +13,12 @@
"interface": "group-detail", "interface": "group-detail",
"note": null, "note": null,
"options": { "options": {
"headerIcon": "wysiwyg" "headerIcon": "wysiwyg",
"start": "closed"
}, },
"readonly": false, "readonly": false,
"required": false, "required": false,
"sort": 7, "sort": 8,
"special": [ "special": [
"alias", "alias",
"no-data", "no-data",

View File

@ -0,0 +1,74 @@
{
"collection": "types",
"field": "subtitle_label",
"type": "string",
"meta": {
"collection": "types",
"conditions": [
{
"hidden": false,
"name": "subtitle=custom",
"readonly": false,
"required": true,
"rule": {
"_and": [
{
"subtitle_mode": {
"_eq": "custom"
}
}
]
}
},
{
"hidden": true,
"name": "subtitle != custom",
"readonly": true,
"required": false,
"rule": {
"_and": [
{
"subtitle_mode": {
"_neq": "custom"
}
}
]
}
}
],
"display": null,
"display_options": null,
"field": "subtitle_label",
"group": "Header",
"hidden": false,
"interface": "input",
"note": null,
"options": null,
"readonly": false,
"required": false,
"sort": 2,
"special": null,
"translations": null,
"validation": null,
"validation_message": null,
"width": "half"
},
"schema": {
"name": "subtitle_label",
"table": "types",
"data_type": "character varying",
"default_value": "Subname",
"max_length": 255,
"numeric_precision": null,
"numeric_scale": null,
"is_nullable": true,
"is_unique": false,
"is_indexed": false,
"is_primary_key": false,
"is_generated": false,
"generation_expression": null,
"has_auto_increment": false,
"foreign_key_table": null,
"foreign_key_column": null
}
}

View File

@ -0,0 +1,58 @@
{
"collection": "types",
"field": "subtitle_mode",
"type": "string",
"meta": {
"collection": "types",
"conditions": null,
"display": null,
"display_options": null,
"field": "subtitle_mode",
"group": "Header",
"hidden": false,
"interface": "select-dropdown",
"note": null,
"options": {
"choices": [
{
"text": "address",
"value": "address"
},
{
"text": "custom",
"value": "custom"
},
{
"text": "none",
"value": "none"
}
]
},
"readonly": false,
"required": false,
"sort": 1,
"special": null,
"translations": null,
"validation": null,
"validation_message": null,
"width": "half"
},
"schema": {
"name": "subtitle_mode",
"table": "types",
"data_type": "character varying",
"default_value": "address",
"max_length": 255,
"numeric_precision": null,
"numeric_scale": null,
"is_nullable": true,
"is_unique": false,
"is_indexed": false,
"is_primary_key": false,
"is_generated": false,
"generation_expression": null,
"has_auto_increment": false,
"foreign_key_table": null,
"foreign_key_column": null
}
}

View File

@ -173,7 +173,7 @@ export const PopupView = ({ children }: { children?: React.ReactNode }) => {
</ItemViewPopup> </ItemViewPopup>
<Tooltip offset={[0, -38]} direction='top'> <Tooltip offset={[0, -38]} direction='top'>
{item.name || item.layer?.item_default_name} {item.name ?? item.layer?.item_default_name}
</Tooltip> </Tooltip>
</Marker> </Marker>
</TemplateItemContext.Provider> </TemplateItemContext.Provider>

View File

@ -79,7 +79,7 @@ export const SearchControl = () => {
items.filter((item) => { items.filter((item) => {
return ( return (
value.length > 2 && value.length > 2 &&
((item.layer?.listed && item.name.toLowerCase().includes(value.toLowerCase())) || ((item.layer?.listed && item.name?.toLowerCase().includes(value.toLowerCase())) ||
item.text?.toLowerCase().includes(value.toLowerCase())) item.text?.toLowerCase().includes(value.toLowerCase()))
) )
}), }),

View File

@ -146,7 +146,7 @@ export function ItemFormPopup(props: Props) {
(i) => i.user_created?.id === user?.id && i.layer === popupForm.layer, (i) => i.user_created?.id === user?.id && i.layer === popupForm.layer,
) )
const itemName = formItem.name || user?.first_name const itemName = formItem.name ?? user?.first_name
if (!itemName) { if (!itemName) {
toast.error('Name must be defined') toast.error('Name must be defined')
return false return false

View File

@ -1,222 +1 @@
/* eslint-disable @typescript-eslint/no-unnecessary-condition */ export { HeaderView } from './HeaderView/index'
/* eslint-disable @typescript-eslint/no-non-null-asserted-optional-chain */
/* eslint-disable @typescript-eslint/no-misused-promises */
/* eslint-disable @typescript-eslint/require-await */
/* eslint-disable @typescript-eslint/restrict-template-expressions */
/* eslint-disable @typescript-eslint/restrict-plus-operands */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import EllipsisVerticalIcon from '@heroicons/react/16/solid/EllipsisVerticalIcon'
import PencilIcon from '@heroicons/react/24/solid/PencilIcon'
import TrashIcon from '@heroicons/react/24/solid/TrashIcon'
import { useState } from 'react'
import SVG from 'react-inlinesvg'
import { useNavigate } from 'react-router-dom'
import TargetDotSVG from '#assets/targetDot.svg'
import { useAppState } from '#components/AppShell/hooks/useAppState'
import { useHasUserPermission } from '#components/Map/hooks/usePermissions'
import DialogModal from '#components/Templates/DialogModal'
import type { Item } from '#types/Item'
import type { ItemsApi } from '#types/ItemsApi'
export function HeaderView({
item,
api,
editCallback,
deleteCallback,
setPositionCallback,
loading,
hideMenu = false,
big = false,
truncateSubname = true,
hideSubname = false,
showAddress = false,
}: {
item?: Item
api?: ItemsApi<any>
editCallback?: any
deleteCallback?: any
setPositionCallback?: any
loading?: boolean
hideMenu?: boolean
big?: boolean
hideSubname?: boolean
truncateSubname?: boolean
showAddress?: boolean
}) {
const [modalOpen, setModalOpen] = useState<boolean>(false)
const hasUserPermission = useHasUserPermission()
const navigate = useNavigate()
const appState = useAppState()
const [imageLoaded, setImageLoaded] = useState(false)
const avatar =
(item?.image && appState.assetsApi.url + item.image + '?width=160&heigth=160') ||
item?.image_external
const title = item?.name ?? item?.layer?.item_default_name
const subtitle = item?.subname
const [address] = useState<string>('')
const params = new URLSearchParams(window.location.search)
const openDeleteModal = async (event: React.MouseEvent<HTMLElement>) => {
setModalOpen(true)
event.stopPropagation()
}
if (!item) return null
return (
<>
<div className='tw:flex tw:flex-row'>
<div className={'tw:grow tw:max-w-[calc(100%-60px)] }'}>
<div className='tw:flex tw:items-center'>
{avatar && (
<div className='tw:avatar'>
<div
className={`${
big ? 'tw:w-20' : 'tw:w-10'
} tw:inline tw:items-center tw:justify-center overflow-hidden`}
>
<img
className={'tw:w-full tw:h-full tw:object-cover tw:rounded-full'}
src={avatar}
alt={item.name + ' logo'}
onLoad={() => setImageLoaded(true)}
onError={() => setImageLoaded(false)}
style={{ display: imageLoaded ? 'block' : 'none' }}
/>
{!imageLoaded && (
<div className='tw:w-full tw:h-full tw:bg-gray-200 tw:rounded-full' />
)}
</div>
</div>
)}
<div className={`${avatar ? 'tw:ml-2' : ''} tw:overflow-hidden`}>
<div
className={`${big ? 'tw:xl:text-3xl tw:text-2xl' : 'tw:text-xl'} tw:font-semibold tw:truncate`}
title={title}
data-cy='profile-title'
>
{title}
</div>
{showAddress && address && !hideSubname && (
<div className={`tw:text-xs tw:text-gray-500 ${truncateSubname && 'tw:truncate'}`}>
{address}
</div>
)}
{subtitle && !hideSubname && (
<div className={`tw:text-xs tw:opacity-50 ${truncateSubname && 'tw:truncate'}`}>
{subtitle}
</div>
)}
</div>
</div>
</div>
<div onClick={(e) => e.stopPropagation()} className={`${big ? 'tw:mt-5' : 'tw:mt-1'}`}>
{(api?.deleteItem || item.layer?.api?.updateItem) &&
(hasUserPermission(api?.collectionName!, 'delete', item) ||
hasUserPermission(api?.collectionName!, 'update', item)) &&
!hideMenu && (
<div className='tw:dropdown tw:dropdown-bottom'>
<label
tabIndex={0}
className='tw:bg-base-100 tw:btn tw:m-1 tw:leading-3 tw:border-none tw:min-h-0 tw:h-6'
>
<EllipsisVerticalIcon className='tw:h-5 tw:w-5' />
</label>
<ul
tabIndex={0}
className='tw:dropdown-content tw:menu tw:p-2 tw:shadow tw:bg-base-100 tw:rounded-box tw:z-1000'
>
{api?.updateItem &&
hasUserPermission(api.collectionName!, 'update', item) &&
editCallback && (
<li>
<a
className='tw:text-base-content! tw:tooltip tw:tooltip-right tw:cursor-pointer'
data-tip='Edit'
onClick={(e) =>
item.layer?.customEditLink
? navigate(
`${item.layer.customEditLink}${item.layer.customEditParameter ? `/${item.id}${params && '?' + params}` : ''} `,
)
: editCallback(e)
}
>
<PencilIcon className='tw:h-5 tw:w-5' />
</a>
</li>
)}
{api?.updateItem &&
hasUserPermission(api.collectionName!, 'update', item) &&
setPositionCallback && (
<li>
<a
className='tw:text-base-content! tw:tooltip tw:tooltip-right tw:cursor-pointer'
data-tip='Set position'
onClick={setPositionCallback}
>
<SVG src={TargetDotSVG} className='tw:w-5 tw:h-5' />
</a>
</li>
)}
{api?.deleteItem &&
hasUserPermission(api.collectionName!, 'delete', item) &&
deleteCallback && (
<li>
<a
className='tw:text-error! tw:tooltip tw:tooltip-right tw:cursor-pointer'
data-tip='Delete'
onClick={openDeleteModal}
>
{loading ? (
<span className='tw:loading tw:loading-spinner tw:loading-sm'></span>
) : (
<TrashIcon className='tw:h-5 tw:w-5' />
)}
</a>
</li>
)}
</ul>
</div>
)}
</div>
</div>
<DialogModal
isOpened={modalOpen}
title='Are you sure?'
showCloseButton={false}
onClose={() => setModalOpen(false)}
>
<div onClick={(e) => e.stopPropagation()}>
<span>
Do you want to delete <b>{item.name}</b>?
</span>
<div className='tw:grid'>
<div className='tw:flex tw:justify-between'>
<label
className='tw:btn tw:mt-4 tw:btn-error'
onClick={(e) => {
deleteCallback(e)
setModalOpen(false)
}}
>
Yes
</label>
<label className='tw:btn tw:mt-4' onClick={() => setModalOpen(false)}>
No
</label>
</div>
</div>
</div>
</DialogModal>
</>
)
}

View File

@ -0,0 +1,42 @@
import { LuNavigation } from 'react-icons/lu'
import { useMyProfile } from '#components/Map/hooks/useMyProfile'
import { useNavigationUrl } from './hooks'
import { ShareButton } from './ShareButton'
import type { Item } from '#types/Item'
interface ActionButtonsProps {
item: Item
}
export function ActionButtons({ item }: ActionButtonsProps) {
const myProfile = useMyProfile()
const { getNavigationUrl, isMobile, isIOS } = useNavigationUrl(
item.position?.coordinates as [number, number] | undefined,
)
const showNavigationButton = item.layer?.itemType.show_navigation_button ?? true
const showShareButton = item.layer?.itemType.show_share_button ?? true
const isOtherProfile = myProfile.myProfile?.id !== item.id
return (
<>
{item.position?.coordinates && isOtherProfile && showNavigationButton && (
<a
href={getNavigationUrl()}
target='_blank'
data-tip='Navigate'
rel='noopener noreferrer'
className='tw:btn tw:mr-2 tw:px-3 tw:tooltip tw:tooltip-top'
style={{ color: 'inherit' }}
title={`Navigate with ${isMobile ? 'default navigation app' : isIOS ? 'Apple Maps' : 'Google Maps'}`}
>
<LuNavigation className='tw:h-4 tw:w-4' />
</a>
)}
{isOtherProfile && showShareButton && <ShareButton item={item} />}
</>
)
}

View File

@ -0,0 +1,46 @@
import { FaPlus } from 'react-icons/fa6'
import { useMyProfile } from '#components/Map/hooks/useMyProfile'
import { useGetItemTags } from '#components/Map/hooks/useTags'
import type { Item } from '#types/Item'
interface ConnectionStatusProps {
item: Item
}
export function ConnectionStatus({ item }: ConnectionStatusProps) {
const myProfile = useMyProfile()
const getItemTags = useGetItemTags()
if (myProfile.myProfile?.id === item.id) {
return null
}
const isConnected = item.relations?.some(
(r) =>
r.type === item.layer?.itemType.cta_relation &&
r.related_items_id === myProfile.myProfile?.id,
)
if (!item.layer?.itemType.show_cta_button) {
return null
}
if (isConnected) {
return <p className='tw:flex tw:items-center tw:mr-2'> Connected</p>
}
const tags = getItemTags(item)
return (
<button
style={{
backgroundColor: `${item.color ?? (tags[0]?.color ? tags[0].color : item.layer.markerDefaultColor || '#000')}`,
}}
className='tw:btn tw:text-white tw:mr-2 tw:tooltip tw:tooltip-top '
data-tip={'Connect'}
>
<FaPlus className='tw:w-5' /> Connect
</button>
)
}

View File

@ -0,0 +1,37 @@
import DialogModal from '#components/Templates/DialogModal'
import type { Item } from '#types/Item'
interface DeleteModalProps {
item: Item
isOpen: boolean
onClose: () => void
onConfirm: (e: React.MouseEvent) => void
}
export function DeleteModal({ item, isOpen, onClose, onConfirm }: DeleteModalProps) {
const handleConfirm = (e: React.MouseEvent) => {
onConfirm(e)
onClose()
}
return (
<DialogModal isOpened={isOpen} title='Are you sure?' showCloseButton={false} onClose={onClose}>
<div onClick={(e) => e.stopPropagation()}>
<span>
Do you want to delete <b>{item.name}</b>?
</span>
<div className='tw:grid'>
<div className='tw:flex tw:justify-between'>
<label className='tw:btn tw:mt-4 tw:btn-error' onClick={handleConfirm}>
Yes
</label>
<label className='tw:btn tw:mt-4' onClick={onClose}>
No
</label>
</div>
</div>
</div>
</DialogModal>
)
}

View File

@ -0,0 +1,112 @@
import EllipsisVerticalIcon from '@heroicons/react/16/solid/EllipsisVerticalIcon'
import PencilIcon from '@heroicons/react/24/solid/PencilIcon'
import TrashIcon from '@heroicons/react/24/solid/TrashIcon'
import SVG from 'react-inlinesvg'
import { useNavigate } from 'react-router-dom'
import TargetDotSVG from '#assets/targetDot.svg'
import { useHasUserPermission } from '#components/Map/hooks/usePermissions'
import type { Item } from '#types/Item'
import type { ItemsApi } from '#types/ItemsApi'
interface EditMenuProps {
item: Item
api?: ItemsApi<unknown>
editCallback?: (e: React.MouseEvent) => void
deleteCallback?: (e: React.MouseEvent) => void
setPositionCallback?: () => void
loading?: boolean
hideMenu?: boolean
big?: boolean
onDeleteModalOpen: () => void
}
export function EditMenu({
item,
api,
editCallback,
deleteCallback,
setPositionCallback,
loading = false,
hideMenu = false,
big = false,
onDeleteModalOpen,
}: EditMenuProps) {
const hasUserPermission = useHasUserPermission()
const navigate = useNavigate()
const params = new URLSearchParams(window.location.search)
const handleDeleteClick = (event: React.MouseEvent<HTMLElement>) => {
onDeleteModalOpen()
event.stopPropagation()
}
if (hideMenu) return null
const hasDeletePermission =
api?.deleteItem && api.collectionName && hasUserPermission(api.collectionName, 'delete', item)
const hasUpdatePermission =
api?.updateItem && api.collectionName && hasUserPermission(api.collectionName, 'update', item)
if (!hasDeletePermission && !hasUpdatePermission) return null
return (
<div onClick={(e) => e.stopPropagation()} className={`${big ? 'tw:mt-5' : 'tw:mt-1'}`}>
<div className='tw:dropdown tw:dropdown-bottom tw:dropdown-center'>
<label tabIndex={0} className='tw:btn tw:btn-ghost tw:px-2.5'>
<EllipsisVerticalIcon className='tw:h-5 tw:w-5' />
</label>
<ul
tabIndex={0}
className='tw:dropdown-content tw:menu tw:p-2 tw:shadow tw:bg-base-100 tw:rounded-box tw:z-1000'
>
{hasUpdatePermission && editCallback && (
<li>
<a
className='tw:text-base-content! tw:tooltip tw:tooltip-top tw:cursor-pointer'
data-tip='Edit'
onClick={(e) =>
item.layer?.customEditLink
? navigate(
`${item.layer.customEditLink}${item.layer.customEditParameter ? `/${item.id}${params.toString() ? '?' + params.toString() : ''}` : ''}`,
)
: editCallback(e)
}
>
<PencilIcon className='tw:h-5 tw:w-5' />
</a>
</li>
)}
{hasUpdatePermission && setPositionCallback && (
<li>
<a
className='tw:text-base-content! tw:tooltip tw:tooltip-top tw:cursor-pointer'
data-tip='Set position'
onClick={setPositionCallback}
>
<SVG src={TargetDotSVG} className='tw:w-5 tw:h-5' />
</a>
</li>
)}
{hasDeletePermission && deleteCallback && (
<li>
<a
className='tw:text-error! tw:tooltip tw:tooltip-top tw:cursor-pointer'
data-tip='Delete'
onClick={handleDeleteClick}
>
{loading ? (
<span className='tw:loading tw:loading-spinner tw:loading-sm'></span>
) : (
<TrashIcon className='tw:h-5 tw:w-5' />
)}
</a>
</li>
)}
</ul>
</div>
</div>
)
}

View File

@ -0,0 +1,73 @@
import { QrCodeIcon } from '@heroicons/react/24/solid'
import { useState } from 'react'
import { useAppState } from '#components/AppShell/hooks/useAppState'
import type { Item } from '#types/Item'
interface ItemAvatarProps {
item: Item
big?: boolean
extraLarge?: boolean
showQrButton?: boolean
onQrClick?: () => void
}
export function ItemAvatar({
item,
big = false,
extraLarge = false,
showQrButton = false,
onQrClick,
}: ItemAvatarProps) {
const appState = useAppState()
const [imageLoaded, setImageLoaded] = useState(false)
const imageSize = extraLarge ? 320 : 160
const avatar =
(item.image &&
appState.assetsApi.url + item.image + `?width=${imageSize}&height=${imageSize}`) ??
item.image_external
const hasAvatar = !!avatar
// If no avatar but QR button should be shown, show only the QR button
if (!hasAvatar && showQrButton) {
return (
<button onClick={onQrClick} className='tw:btn tw:btn-lg tw:p-3 tw:mr-2' title='QR-Code'>
<QrCodeIcon className='tw:h-6 tw:w-6' />
</button>
)
}
if (!hasAvatar) return null
const avatarSize = extraLarge ? 'tw:w-32' : big ? 'tw:w-16' : 'tw:w-10'
return (
<div className='tw:avatar tw:relative'>
<div
className={`${avatarSize} tw:inline tw:items-center tw:justify-center tw:overflow-visible`}
>
<img
className='tw:w-full tw:h-full tw:object-cover tw:rounded-full tw:border-white'
src={avatar}
alt={(item.name ?? '') + ' logo'}
onLoad={() => setImageLoaded(true)}
onError={() => setImageLoaded(false)}
style={{ display: imageLoaded ? 'block' : 'none' }}
/>
{!imageLoaded && <div className='tw:w-full tw:h-full tw:bg-gray-200 tw:rounded-full' />}
</div>
{showQrButton && (
<button
onClick={onQrClick}
className='tw:btn tw:p-1 tw:btn-sm tw:absolute tw:bottom-[-6px] tw:right-[-6px]'
title='QR-Code'
>
<QrCodeIcon className='tw:h-5 tw:w-5' />
</button>
)}
</div>
)
}

View File

@ -0,0 +1,132 @@
import { MapPinIcon } from '@heroicons/react/24/solid'
import { useEffect, useRef, useState } from 'react'
import { useGeoDistance } from '#components/Map/hooks/useGeoDistance'
import { useReverseGeocode } from '#components/Map/hooks/useReverseGeocode'
import { useFormatDistance } from './hooks'
import type { Item } from '#types/Item'
interface ItemTitleProps {
item: Item
big?: boolean
truncateSubname?: boolean
subtitleMode?: 'address' | 'custom' | 'none'
hasAvatar?: boolean
}
export function ItemTitle({
item,
big = false,
truncateSubname = true,
subtitleMode = 'address',
hasAvatar = false,
}: ItemTitleProps) {
const { distance } = useGeoDistance(item.position ?? undefined)
const { formatDistance } = useFormatDistance()
const titleRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [fontSize, setFontSize] = useState<string>('tw:text-xl')
const { address } = useReverseGeocode(
item.position?.coordinates as [number, number] | undefined,
subtitleMode === 'address',
'municipality',
)
const title = item.name ?? item.layer?.item_default_name
const subtitle = item.subname
useEffect(() => {
if (!containerRef.current || !title) {
return
}
const calculateFontSize = () => {
const container = containerRef.current
if (!container) return
const containerWidth = container.offsetWidth
// Create temporary element to measure text width
const measureElement = document.createElement('span')
measureElement.style.position = 'absolute'
measureElement.style.visibility = 'hidden'
measureElement.style.whiteSpace = 'nowrap'
measureElement.style.fontWeight = '700' // font-bold
measureElement.textContent = title
document.body.appendChild(measureElement)
// Measure at different font sizes - include larger sizes only if big is true
const fontSizes = big
? [
{ class: 'tw:text-2xl', pixels: 24 },
{ class: 'tw:text-xl', pixels: 20 },
{ class: 'tw:text-lg', pixels: 18 },
]
: [
{ class: 'tw:text-xl', pixels: 20 },
{ class: 'tw:text-lg', pixels: 18 },
]
let selectedSize = 'tw:text-lg'
for (const size of fontSizes) {
measureElement.style.fontSize = `${size.pixels}px`
const textWidth = measureElement.offsetWidth
if (textWidth <= containerWidth) {
selectedSize = size.class
break
}
}
document.body.removeChild(measureElement)
setFontSize(selectedSize)
}
// Initial calculation
calculateFontSize()
// Watch for container size changes
const resizeObserver = new ResizeObserver(calculateFontSize)
resizeObserver.observe(containerRef.current)
return () => {
resizeObserver.disconnect()
}
}, [title, big])
return (
<div
ref={containerRef}
className={`${hasAvatar ? 'tw:ml-3' : ''} tw:overflow-hidden tw:flex-1 tw:min-w-0 `}
>
<div
ref={titleRef}
className={`${fontSize} tw:font-bold ${!big ? 'tw:truncate' : ''}`}
title={title}
data-cy='profile-title'
>
{title}
</div>
{subtitleMode === 'address' && address && (
<div className='tw:text-sm tw:flex tw:items-center tw:text-gray-500 tw:w-full'>
<MapPinIcon className='tw:w-4 tw:mr-1 tw:flex-shrink-0' />
<span title={address} className='tw:truncate'>
{address}
{distance && distance >= 0.1 ? ` (${formatDistance(distance) ?? ''})` : ''}
</span>
</div>
)}
{subtitleMode === 'custom' && subtitle && (
<div
className={`tw:text-sm tw:opacity-50 tw:items-center ${truncateSubname ? 'tw:truncate' : ''}`}
>
{subtitle}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,44 @@
import QRCode from 'react-qr-code'
import DialogModal from '#components/Templates/DialogModal'
import { useShareLogic } from './hooks'
import { ItemAvatar } from './ItemAvatar'
import { ShareButton } from './ShareButton'
import type { Item } from '#types/Item'
interface QRModalProps {
item: Item
isOpen: boolean
onClose: () => void
}
export function QRModal({ item, isOpen, onClose }: QRModalProps) {
const { inviteLink } = useShareLogic(item)
return (
<DialogModal
isOpened={isOpen}
showCloseButton={true}
onClose={onClose}
className='tw:w-[calc(100vw-2rem)] tw:!max-w-96'
>
<div onClick={(e) => e.stopPropagation()} className='tw:text-center tw:p-4'>
<p className='tw:text-xl tw:font-bold'>Share your Profile to expand your Network!</p>
<div className='tw:flex tw:flex-col tw:items-center tw:gap-4 tw:my-8'>
<ItemAvatar item={item} extraLarge={true} />
<div className='tw:p-8 tw:mt-4 tw:rounded-lg tw:inline-block tw:border-base-300 tw:bg-base-200 tw:border-1'>
<QRCode value={inviteLink} size={164} />
</div>
</div>
<div className='tw:flex tw:items-center tw:gap-2 tw:w-full tw:border-base-300 tw:border-1 tw:rounded-selector tw:p-2'>
<span className='tw:text-sm tw:truncate tw:flex-1 tw:min-w-0'>{inviteLink}</span>
<ShareButton item={item} dropdownDirection='up' />
</div>
</div>
</DialogModal>
)
}

View File

@ -0,0 +1,168 @@
import { ShareIcon } from '@heroicons/react/24/solid'
import { useRef } from 'react'
import ChevronSVG from '#assets/chevron.svg'
import ClipboardSVG from '#assets/share/clipboard.svg'
import FacebookSVG from '#assets/share/facebook.svg'
import LinkedinSVG from '#assets/share/linkedin.svg'
import TelegramSVG from '#assets/share/telegram.svg'
import TwitterSVG from '#assets/share/twitter.svg'
import WhatsappSVG from '#assets/share/whatsapp.svg'
import XingSVG from '#assets/share/xing.svg'
import { useShareLogic } from './hooks'
import type { Item } from '#types/Item'
import type { PlatformConfig, SharePlatformConfigs } from './types'
interface ShareButtonProps {
item: Item
dropdownDirection?: 'up' | 'down'
}
export function ShareButton({ item, dropdownDirection = 'down' }: ShareButtonProps) {
const { shareUrl, shareTitle, copyLink, getShareUrl } = useShareLogic(item)
const detailsRef = useRef<HTMLDetailsElement>(null)
const closeDropdown = () => {
if (detailsRef.current) {
detailsRef.current.open = false
}
}
const handleCopyLink = () => {
copyLink()
closeDropdown()
}
const canUseNativeShare =
typeof navigator !== 'undefined' && typeof navigator.share !== 'undefined'
const handleNativeShare = () => {
void navigator
.share({
title: shareTitle,
url: shareUrl,
})
.then(closeDropdown)
.catch(() => {
// User cancelled or error occurred - ignore
})
}
const platformConfigs: SharePlatformConfigs = {
facebook: {
shareUrl: 'https://www.facebook.com/sharer/sharer.php?u={url}',
icon: <img src={FacebookSVG} alt='Facebook' className='tw:w-4 tw:h-4' />,
label: 'Facebook',
bgColor: '#3b5998',
},
twitter: {
shareUrl: 'https://twitter.com/intent/tweet?text={title}:%20{url}',
icon: <img src={TwitterSVG} alt='Twitter' className='tw:w-4 tw:h-4' />,
label: 'Twitter',
bgColor: '#55acee',
},
linkedin: {
shareUrl: 'http://www.linkedin.com/shareArticle?mini=true&url={url}&title={title}',
icon: <img src={LinkedinSVG} alt='Linkedin' className='tw:w-4 tw:h-4' />,
label: 'LinkedIn',
bgColor: '#4875b4',
},
whatsapp: {
shareUrl: 'https://api.whatsapp.com/send?text={title}%20{url}',
icon: <img src={WhatsappSVG} alt='Whatsapp' className='tw:w-4 tw:h-4' />,
label: 'WhatsApp',
bgColor: '#25D366',
},
telegram: {
shareUrl: 'https://t.me/share/url?url={url}&text={title}',
icon: <img src={TelegramSVG} alt='Telegram' className='tw:w-4 tw:h-4' />,
label: 'Telegram',
bgColor: '#0088cc',
},
xing: {
shareUrl: 'https://www.xing-share.com/app/user?op=share;sc_p=xing-share;url={url}',
icon: <img src={XingSVG} alt='Xing' className='tw:w-4 tw:h-4' />,
label: 'Xing',
bgColor: '#026466',
},
}
const dropdownClass = dropdownDirection === 'up' ? 'tw:dropdown-top' : ''
// If native share is available, render a simple button instead of dropdown
if (canUseNativeShare) {
return (
<button
onClick={handleNativeShare}
className='tw:btn tw:px-3 tw:tooltip tw:tooltip-top'
data-tip='Share'
>
<ShareIcon className='tw:w-4 tw:h-4' />
</button>
)
}
// Otherwise, render the dropdown with manual share options
return (
<details ref={detailsRef} className={`tw:dropdown tw:dropdown-end ${dropdownClass}`}>
<summary className='tw:btn tw:px-3 tw:tooltip tw:tooltip-top' data-tip='Share'>
<ShareIcon className='tw:w-4 tw:h-4' />
</summary>
<ul className='tw:dropdown-content tw:menu tw:bg-base-100 tw:rounded-box tw:z-[1] tw:p-2 tw:shadow-sm'>
<li>
<a
onClick={handleCopyLink}
className='tw:flex tw:items-center tw:gap-3'
style={{ color: 'inherit' }}
>
<div
className='tw:w-6 tw:h-6 tw:rounded-full tw:flex tw:items-center tw:justify-center'
style={{ backgroundColor: '#888' }}
>
<img src={ClipboardSVG} className='tw:w-3 tw:h-3' alt='Copy' />
</div>
Copy Link
</a>
</li>
<li>
<a
href={`mailto:?subject=${encodeURIComponent(shareTitle)}&body=${encodeURIComponent(shareUrl)}`}
onClick={closeDropdown}
className='tw:flex tw:items-center tw:gap-3'
style={{ color: 'inherit' }}
>
<div
className='tw:w-6 tw:h-6 tw:rounded-full tw:flex tw:items-center tw:justify-center tw:text-white'
style={{ backgroundColor: '#444' }}
>
<img src={ChevronSVG} className='tw:w-3 tw:h-3' alt='Email' />
</div>
Email
</a>
</li>
{Object.entries(platformConfigs).map(([platform, config]) => (
<li key={platform}>
<a
href={getShareUrl(platform as keyof SharePlatformConfigs, platformConfigs)}
target='_blank'
rel='noopener noreferrer'
onClick={closeDropdown}
className='tw:flex tw:items-center tw:gap-3'
style={{ color: 'inherit' }}
>
<div
className='tw:w-6 tw:h-6 tw:rounded-full tw:flex tw:items-center tw:justify-center'
style={{ backgroundColor: (config as PlatformConfig).bgColor }}
>
{(config as PlatformConfig).icon}
</div>
{(config as PlatformConfig).label}
</a>
</li>
))}
</ul>
</details>
)
}

View File

@ -0,0 +1,77 @@
import { toast } from 'react-toastify'
import type { Item } from '#types/Item'
import type { SharePlatformConfigs } from './types'
export const useNavigationUrl = (coordinates?: [number, number]) => {
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent)
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent,
)
const getNavigationUrl = () => {
if (!coordinates) return ''
const [longitude, latitude] = coordinates
if (isIOS) {
return `https://maps.apple.com/?daddr=${latitude},${longitude}`
} else if (isMobile) {
return `geo:${latitude},${longitude}`
} else {
return `https://www.google.com/maps/dir/?api=1&destination=${latitude},${longitude}`
}
}
return {
getNavigationUrl,
isMobile,
isIOS,
}
}
export const useShareLogic = (item?: Item) => {
const shareUrl = window.location.href
const shareTitle = item?.name ?? 'Utopia Map Item'
const inviteLink = shareUrl
const copyLink = () => {
navigator.clipboard
.writeText(inviteLink)
.then(() => {
toast.success('Link copied to clipboard')
return null
})
.catch(() => {
toast.error('Error copying link')
})
}
const getShareUrl = (
platform: keyof SharePlatformConfigs,
platformConfigs: SharePlatformConfigs,
) => {
// eslint-disable-next-line security/detect-object-injection
const config = platformConfigs[platform]
return config.shareUrl
.replace('{url}', encodeURIComponent(shareUrl))
.replace('{title}', encodeURIComponent(shareTitle))
}
return {
shareUrl,
shareTitle,
inviteLink,
copyLink,
getShareUrl,
}
}
export const useFormatDistance = () => {
const formatDistance = (dist: number | null): string | null => {
if (!dist) return null
return dist < 10 ? `${dist.toFixed(1)} km` : `${Math.round(dist)} km`
}
return { formatDistance }
}

View File

@ -0,0 +1,91 @@
import { useState } from 'react'
import { useMyProfile } from '#components/Map/hooks/useMyProfile'
import { ActionButtons } from './ActionButtons'
import { ConnectionStatus } from './ConnectionStatus'
import { DeleteModal } from './DeleteModal'
import { EditMenu } from './EditMenu'
import { ItemAvatar } from './ItemAvatar'
import { ItemTitle } from './ItemTitle'
import { QRModal } from './QRModal'
import type { HeaderViewProps } from './types'
export function HeaderView({
item,
api,
editCallback,
deleteCallback,
setPositionCallback,
loading,
hideMenu = false,
big = false,
truncateSubname = true,
showAddress = true,
}: HeaderViewProps) {
const [modalOpen, setModalOpen] = useState<boolean>(false)
const [qrModalOpen, setQrModalOpen] = useState<boolean>(false)
const myProfile = useMyProfile()
if (!item) return null
const hasAvatar = !!(item.image ?? item.image_external)
const isMyProfile = myProfile.myProfile?.id === item.id
const showQrButton = big && isMyProfile && (item.layer?.itemType.show_qr_button ?? true)
const subtitleMode = item.layer?.itemType.subtitle_mode ?? (showAddress ? 'address' : 'none')
return (
<>
<div className='tw:flex tw:flex-row'>
<div className={'tw:grow tw:flex tw:flex-1 tw:min-w-0'}>
<div className='tw:flex tw:flex-1 tw:min-w-0 tw:items-center'>
<ItemAvatar
item={item}
big={big}
showQrButton={showQrButton}
onQrClick={() => setQrModalOpen(true)}
/>
<ItemTitle
item={item}
big={big}
truncateSubname={truncateSubname}
subtitleMode={subtitleMode}
hasAvatar={hasAvatar}
/>
</div>
</div>
<EditMenu
item={item}
api={api}
editCallback={editCallback}
deleteCallback={deleteCallback}
setPositionCallback={setPositionCallback}
loading={loading}
hideMenu={hideMenu}
big={big}
onDeleteModalOpen={() => setModalOpen(true)}
/>
</div>
{big && (
<div className='tw:flex tw:row tw:mt-2 '>
<div className='tw:grow'></div>
<div className='tw:flex'>
<ConnectionStatus item={item} />
<ActionButtons item={item} />
</div>
</div>
)}
<DeleteModal
item={item}
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onConfirm={deleteCallback ?? (() => undefined)}
/>
<QRModal item={item} isOpen={qrModalOpen} onClose={() => setQrModalOpen(false)} />
</>
)
}

View File

@ -0,0 +1,31 @@
import type { Item } from '#types/Item'
import type { ItemsApi } from '#types/ItemsApi'
export interface HeaderViewProps {
item?: Item
api?: ItemsApi<unknown>
editCallback?: (e: React.MouseEvent) => void
deleteCallback?: (e: React.MouseEvent) => void
setPositionCallback?: () => void
loading?: boolean
hideMenu?: boolean
big?: boolean
truncateSubname?: boolean
showAddress?: boolean
}
export interface PlatformConfig {
shareUrl: string
icon: JSX.Element
label: string
bgColor: string
}
export interface SharePlatformConfigs {
facebook: PlatformConfig
twitter: PlatformConfig
linkedin: PlatformConfig
whatsapp: PlatformConfig
telegram: PlatformConfig
xing: PlatformConfig
}

View File

@ -8,25 +8,19 @@ import type { Item } from '#types/Item'
*/ */
export const StartEndView = ({ item }: { item?: Item }) => { export const StartEndView = ({ item }: { item?: Item }) => {
return ( return (
<div className='tw:flex tw:flex-row tw:mb-4 tw:mt-1'> <div className='tw:flex tw:flex-row tw:mb-2.5 tw:mt-2.5 tw:bg-base-200 tw:px-3 tw:py-2.5 tw:rounded-selector tw:w-full'>
<div className='tw:basis-2/5 tw:flex tw:flex-row'> <div className='tw:basis-2/5 tw:flex tw:flex-row tw:items-center tw:font-bold'>
<CalendarIcon className='tw:h-4 tw:w-4 tw:mr-2' /> <CalendarIcon className='tw:h-5 tw:w-5 tw:mr-2' />
<time <time dateTime={item && item.start ? item.start.substring(0, 10) : ''}>
className='tw:align-middle'
dateTime={item && item.start ? item.start.substring(0, 10) : ''}
>
{item && item.start ? new Date(item.start).toLocaleDateString() : ''} {item && item.start ? new Date(item.start).toLocaleDateString() : ''}
</time> </time>
</div> </div>
<div className='tw:basis-1/5 tw:place-content-center'> <div className='tw:basis-1/5 tw:flex tw:items-center tw:justify-center'>
<span>-</span> <span>-</span>
</div> </div>
<div className='tw:basis-2/5 tw:flex tw:flex-row'> <div className='tw:basis-2/5 tw:flex tw:flex-row tw:items-center tw:font-bold'>
<CalendarIcon className='tw:h-4 tw:w-4 tw:mr-2' /> <CalendarIcon className='tw:h-5 tw:w-5 tw:mr-2' />
<time <time dateTime={item && item.end ? item.end.substring(0, 10) : ''}>
className='tw:align-middle'
dateTime={item && item.end ? item.end.substring(0, 10) : ''}
>
{item && item.end ? new Date(item.end).toLocaleDateString() : ''} {item && item.end ? new Date(item.end).toLocaleDateString() : ''}
</time> </time>
</div> </div>

View File

@ -92,7 +92,11 @@ export const ItemViewPopup = forwardRef((props: ItemViewPopupProps, ref: any) =>
api={props.item.layer?.api} api={props.item.layer?.api}
item={props.item} item={props.item}
editCallback={handleEdit} editCallback={handleEdit}
deleteCallback={handleDelete} deleteCallback={(e: React.MouseEvent<HTMLElement>) => {
handleDelete(e).catch(() => {
// Error handling is already in handleDelete
})
}}
setPositionCallback={() => { setPositionCallback={() => {
map.closePopup() map.closePopup()
setSelectPosition(props.item) setSelectPosition(props.item)

View File

@ -188,7 +188,7 @@ export function UtopiaMapInner({
document.title = `${document.title.split('-')[0]} - ${title}` document.title = `${document.title.split('-')[0]} - ${title}`
document document
.querySelector('meta[property="og:title"]') .querySelector('meta[property="og:title"]')
?.setAttribute('content', ref.item.name) ?.setAttribute('content', ref.item.name ?? '')
document document
.querySelector('meta[property="og:description"]') .querySelector('meta[property="og:description"]')
?.setAttribute('content', ref.item.text ?? '') ?.setAttribute('content', ref.item.text ?? '')

View File

@ -0,0 +1,56 @@
import { useState, useEffect } from 'react'
import { useMyProfile } from './useMyProfile'
import type { Point } from 'geojson'
const getDistance = (lat1: number, lon1: number, lat2: number, lon2: number): number => {
const R = 6371 // Earth's radius in km
const dLat = ((lat2 - lat1) * Math.PI) / 180
const dLon = ((lon2 - lon1) * Math.PI) / 180
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos((lat1 * Math.PI) / 180) *
Math.cos((lat2 * Math.PI) / 180) *
Math.sin(dLon / 2) *
Math.sin(dLon / 2)
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
return R * c // Distance in km
}
export const useGeoDistance = (targetPoint?: Point) => {
const [distance, setDistance] = useState<number | null>(null)
const [error, setError] = useState<string | null>(null)
const { myProfile, isMyProfileLoaded } = useMyProfile()
useEffect(() => {
setError(null)
setDistance(null)
if (!isMyProfileLoaded) {
return
}
if (!myProfile?.position || !targetPoint) {
setError('Missing location data')
return
}
try {
const userGeoJson = myProfile.position
const [userLon, userLat] = userGeoJson.coordinates
const [targetLon, targetLat] = targetPoint.coordinates
const dist = getDistance(userLat, userLon, targetLat, targetLon)
setDistance(dist)
} catch (err) {
if (err instanceof Error) {
setError(err.message)
} else {
throw err
}
}
}, [myProfile, isMyProfileLoaded, targetPoint])
return { distance, error, userLocation: myProfile?.position as Point | undefined }
}

View File

@ -0,0 +1,108 @@
import { useState, useEffect } from 'react'
interface GeocodeResult {
street?: string
housenumber?: string
postcode?: string
city?: string
town?: string
village?: string
district?: string
suburb?: string
neighbourhood?: string
state?: string
country?: string
}
interface GeocodeFeature {
properties: GeocodeResult
}
interface GeocodeResponse {
features?: GeocodeFeature[]
}
export function useReverseGeocode(
coordinates?: [number, number] | null,
enabled = true,
accuracy: 'municipality' | 'street' | 'house_number' = 'municipality',
) {
const [address, setAddress] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!enabled || !coordinates) {
setAddress('')
return
}
const [longitude, latitude] = coordinates
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
return
}
const reverseGeocode = async () => {
setLoading(true)
setError(null)
try {
const response = await fetch(
`https://photon.komoot.io/reverse?lat=${latitude}&lon=${longitude}&lang=de&limit=1`,
)
if (!response.ok) {
throw new Error('Geocoding request failed')
}
const data = (await response.json()) as GeocodeResponse
if (data.features && data.features.length > 0) {
const props = data.features[0].properties
const municipality = props.city ?? props.town ?? props.village
let addressString = ''
switch (accuracy) {
case 'municipality':
addressString = municipality ?? ''
break
case 'street':
if (props.street && municipality) {
addressString = `${props.street}, ${municipality}`
} else {
addressString = municipality ?? ''
}
break
case 'house_number':
if (props.street && props.housenumber && municipality) {
addressString = `${props.street} ${props.housenumber}, ${municipality}`
} else if (props.street && municipality) {
addressString = `${props.street}, ${municipality}`
} else {
addressString = municipality ?? ''
}
break
}
setAddress(addressString)
} else {
setAddress('')
}
} catch (err) {
if (err instanceof Error) {
setError(err.message)
setAddress('')
} else {
throw err
}
} finally {
setLoading(false)
}
}
void reverseGeocode()
}, [coordinates, enabled, accuracy])
return { address, loading, error }
}

View File

@ -3,7 +3,6 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-non-null-assertion */
/* eslint-disable @typescript-eslint/no-non-null-asserted-optional-chain */ /* eslint-disable @typescript-eslint/no-non-null-asserted-optional-chain */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-call */
@ -175,14 +174,18 @@ export function ProfileView({ attestationApi }: { attestationApi?: ItemsApi<any>
<MapOverlayPage <MapOverlayPage
key={item.id} key={item.id}
data-cy='profile-view' data-cy='profile-view'
className={`tw:p-0! tw:overflow-scroll tw:m-4! tw:md:w-[calc(50%-32px)] tw:w-[calc(100%-32px)] tw:min-w-80 tw:max-w-3xl tw:left-0! tw:sm:left-auto! tw:top-0 tw:bottom-0 tw:transition-opacity tw:duration-500 ${!selectPosition ? 'tw:opacity-100 tw:pointer-events-auto' : 'tw:opacity-0 tw:pointer-events-none'} tw:max-h-[1000px]`} className={`tw:@container tw:overflow-hidden tw:p-0! tw:m-4! tw:md:w-[calc(50%-32px)] tw:w-[calc(100%-32px)] tw:min-w-80 tw:max-w-3xl tw:left-0! tw:sm:left-auto! tw:top-0 tw:bottom-0 tw:transition-opacity tw:duration-500 ${!selectPosition ? 'tw:opacity-100 tw:pointer-events-auto' : 'tw:opacity-0 tw:pointer-events-none'} tw:max-h-[1000px]`}
> >
<> <>
<div className={'tw:px-6 tw:pt-6'} data-cy='profile-header'> <div className={'tw:px-6 tw:pt-6'} data-cy='profile-header'>
<HeaderView <HeaderView
api={item.layer?.api} api={item.layer?.api}
item={item} item={item}
deleteCallback={(e) => handleDelete(e, item, setLoading, removeItem, map, navigate)} deleteCallback={(e: React.MouseEvent<HTMLElement>) => {
handleDelete(e, item, setLoading, removeItem, map, navigate).catch(() => {
// Error handling is already in handleDelete
})
}}
editCallback={() => navigate('/edit-item/' + item.id)} editCallback={() => navigate('/edit-item/' + item.id)}
setPositionCallback={() => { setPositionCallback={() => {
map.closePopup() map.closePopup()

View File

@ -105,7 +105,7 @@ export function ActionButton({
.filter((item) => { .filter((item) => {
return search === '' return search === ''
? item ? item
: item.name.toLowerCase().includes(search.toLowerCase()) : item.name?.toLowerCase().includes(search.toLowerCase())
}) })
.map((i) => ( .map((i) => (
<div <div

View File

@ -38,7 +38,9 @@ export const FormHeader = ({ item, state, setState }: Props) => {
} }
className={'tw:-left-6 tw:top-14 tw:-mr-6'} className={'tw:-left-6 tw:top-14 tw:-mr-6'}
/> />
<div className='tw:grow tw:mr-4 tw:pt-1'> <div
className={`tw:grow tw:mr-4 ${item.layer?.itemType.subtitle_mode === 'custom' ? 'tw:pt-1' : 'tw:flex tw:items-center'}`}
>
<TextInput <TextInput
placeholder='Name' placeholder='Name'
defaultValue={item.name ? item.name : ''} defaultValue={item.name ? item.name : ''}
@ -51,19 +53,21 @@ export const FormHeader = ({ item, state, setState }: Props) => {
containerStyle='tw:grow tw:px-4' containerStyle='tw:grow tw:px-4'
inputStyle='tw:input-md' inputStyle='tw:input-md'
/> />
<TextInput {item.layer?.itemType.subtitle_mode === 'custom' && (
placeholder='Subtitle' <TextInput
required={false} placeholder={item.layer.itemType.subtitle_label}
defaultValue={item.subname ? item.subname : ''} required={false}
updateFormValue={(v) => defaultValue={item.subname ? item.subname : ''}
setState((prevState) => ({ updateFormValue={(v) =>
...prevState, setState((prevState) => ({
subname: v, ...prevState,
})) subname: v,
} }))
containerStyle='tw:grow tw:px-4 tw:mt-1' }
inputStyle='tw:input-sm' containerStyle='tw:grow tw:px-4 tw:mt-1'
/> inputStyle='tw:input-sm'
/>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -35,7 +35,7 @@ export const GroupSubHeaderView = ({
? shareBaseUrl + item.slug ? shareBaseUrl + item.slug
: window.location.protocol + '//' + window.location.host + '/item/' + item.id : window.location.protocol + '//' + window.location.host + '/item/' + item.id
} }
title={item.name} title={item.name ?? ''}
platforms={platforms} platforms={platforms}
/> />
</div> </div>

View File

@ -40,7 +40,7 @@ export function LinkedItemsHeaderView({
<img <img
className={'tw:w-10 tw:inline tw:rounded-full'} className={'tw:w-10 tw:inline tw:rounded-full'}
src={avatar} src={avatar}
alt={item.name + ' logo'} alt={(item.name ?? '') + ' logo'}
/> />
)} )}
<div className={`${avatar ? 'tw:ml-2' : ''} tw:overflow-hidden`}> <div className={`${avatar ? 'tw:ml-2' : ''} tw:overflow-hidden`}>

View File

@ -4,7 +4,7 @@ import type { Item } from '#types/Item'
export const ProfileStartEndView = ({ item }: { item: Item }) => { export const ProfileStartEndView = ({ item }: { item: Item }) => {
return ( return (
<div className='tw:mt-2 tw:px-6 tw:max-w-xs'> <div className='tw:mt-2 tw:px-6'>
<StartEndView item={item}></StartEndView> <StartEndView item={item}></StartEndView>
</div> </div>
) )

View File

@ -9,7 +9,7 @@ const isClickInsideRectangle = (e: MouseEvent, element: HTMLElement) => {
} }
interface Props { interface Props {
title: string title?: string
isOpened: boolean isOpened: boolean
onClose: () => void onClose: () => void
children: React.ReactNode children: React.ReactNode
@ -52,7 +52,9 @@ const DialogModal = ({
} }
> >
<div className='tw:card-body tw:p-2'> <div className='tw:card-body tw:p-2'>
<h2 className='tw:text-2xl tw:font-semibold tw:mb-2 tw:text-center'>{title}</h2> {title && (
<h2 className='tw:text-2xl tw:font-semibold tw:mb-2 tw:text-center'>{title}</h2>
)}
{children} {children}
{showCloseButton && ( {showCloseButton && (

View File

@ -27,7 +27,7 @@ interface ItemSecret {
*/ */
export interface Item { export interface Item {
id: string id: string
name: string name?: string
text?: string text?: string
data?: string data?: string
position?: Point | null position?: Point | null

View File

@ -18,7 +18,15 @@ export interface ItemType {
questlog: boolean questlog: boolean
custom_profile_url?: string custom_profile_url?: string
small_form_edit?: boolean small_form_edit?: boolean
botton_label?: string button_label?: string
text_input_label?: string text_input_label?: string
show_header_view_in_form?: boolean show_header_view_in_form?: boolean
cta_button_label?: string
subtitle_mode?: 'address' | 'custom' | 'none'
subtitle_label?: string
cta_relation?: string
show_cta_button?: boolean
show_qr_button?: boolean
show_navigation_button?: boolean
show_share_button?: boolean
} }