This commit is contained in:
Anton Tranelis 2025-10-10 09:39:11 +02:00
parent 05209bb62a
commit 3233efb6d3
14 changed files with 700 additions and 477 deletions

View File

@ -1,475 +1 @@
/* eslint-disable security/detect-object-injection */
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
/* 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 { MapPinIcon, ShareIcon, QrCodeIcon } from '@heroicons/react/24/solid'
import PencilIcon from '@heroicons/react/24/solid/PencilIcon'
import TrashIcon from '@heroicons/react/24/solid/TrashIcon'
import { useState } from 'react'
import { FaPlus } from 'react-icons/fa6'
import { LuNavigation } from 'react-icons/lu'
import SVG from 'react-inlinesvg'
import QRCode from 'react-qr-code'
import { useNavigate } from 'react-router-dom'
import { toast } from 'react-toastify'
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 TargetDotSVG from '#assets/targetDot.svg'
import { useAppState } from '#components/AppShell/hooks/useAppState'
import { useGeoDistance } from '#components/Map/hooks/useGeoDistance'
import { useHasUserPermission } from '#components/Map/hooks/usePermissions'
import { useReverseGeocode } from '#components/Map/hooks/useReverseGeocode'
import { useGetItemTags } from '#components/Map/hooks/useTags'
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,
showAddress = true,
}: {
item?: Item
api?: ItemsApi<any>
editCallback?: any
deleteCallback?: any
setPositionCallback?: any
loading?: boolean
hideMenu?: boolean
big?: boolean
truncateSubname?: boolean
showAddress?: boolean
}) {
const [modalOpen, setModalOpen] = useState<boolean>(false)
const [qrModalOpen, setQrModalOpen] = useState<boolean>(false)
const hasUserPermission = useHasUserPermission()
const navigate = useNavigate()
const appState = useAppState()
const getItemTags = useGetItemTags()
const [imageLoaded, setImageLoaded] = useState(false)
const { distance } = useGeoDistance(item?.position ?? undefined)
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 } = useReverseGeocode(
item?.position?.coordinates as [number, number] | undefined,
showAddress,
'municipality',
)
const params = new URLSearchParams(window.location.search)
const formatDistance = (dist: number | null): string | null => {
if (!dist) return null
return dist < 10 ? `${dist.toFixed(1)} km` : `${Math.round(dist)} km`
}
const platformConfigs = {
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 shareUrl = window.location.href
const shareTitle = item?.name ?? 'Utopia Map Item'
const inviteLink =
item?.secrets && item.secrets.length > 0
? `${window.location.origin}/invite/${item.secrets[0].secret}`
: 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 typeof platformConfigs) => {
const config = platformConfigs[platform]
return config.shareUrl
.replace('{url}', encodeURIComponent(shareUrl))
.replace('{title}', encodeURIComponent(shareTitle))
}
const coordinates = item?.position?.coordinates
const latitude = coordinates?.[1]
const longitude = coordinates?.[0]
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 || !latitude || !longitude) return ''
// Try geo: link first (works on most mobile devices)
if (isMobile) {
return `geo:${latitude},${longitude}`
}
// Fallback to web-based maps
if (isIOS) {
return `https://maps.apple.com/?daddr=${latitude},${longitude}`
} else {
return `https://www.google.com/maps/dir/?api=1&destination=${latitude},${longitude}`
}
}
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:flex tw:flex-1 tw:min-w-0'}>
<div className='tw:flex tw:flex-1 tw:min-w-0 tw:items-center'>
{avatar && (
<div className='tw:avatar'>
<div
className={`${
big ? 'tw:w-16' : 'tw:w-10'
} 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>
</div>
)}
<div className={`${avatar ? 'tw:ml-3' : ''} tw:overflow-hidden tw:flex-1 tw:min-w-0 `}>
<div
className={`${big ? 'tw:xl:text-3xl tw:text-2xl' : 'tw:text-xl'} tw:font-bold`}
title={title}
data-cy='profile-title'
>
{title}
</div>
{showAddress && 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>
)}
{subtitle && !showAddress && (
<div
className={`tw:text-sm tw:opacity-50 tw:items-center ${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 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'
>
{api?.updateItem &&
hasUserPermission(api.collectionName!, 'update', item) &&
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 && '?' + 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-top 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-top 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>
{big && (
<div className='tw:flex tw:row tw:mt-2 '>
<div className='tw:grow'></div>
<div className=''>
<button
style={{
backgroundColor: `${item?.color ?? (item && (getItemTags(item) && getItemTags(item)[0] && getItemTags(item)[0].color ? getItemTags(item)[0].color : (item?.layer?.markerDefaultColor ?? '#000')))}`,
}}
className='tw:btn tw:text-white tw:mr-2 tw:tooltip tw:tooltip-top '
data-tip={item.layer?.itemType.cta_button_label}
>
<FaPlus className='tw:w-5' /> Connect
</button>
{item?.position?.coordinates ? (
<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>
) : (
<div className='tw:btn tw:mr-2 tw:px-3 tw:btn-disabled'>
<LuNavigation className='tw:h-4 tw:w-4' />
</div>
)}
<button
onClick={() => setQrModalOpen(true)}
className='tw:btn tw:mr-2 tw:px-3 tw:tooltip tw:tooltip-top'
title='QR-Code'
data-tip='QR Code'
>
<QrCodeIcon className='tw:h-4 tw:w-4' />
</button>
<div className='tw:dropdown tw:dropdown-end'>
<div
tabIndex={0}
role='button'
className='tw:btn tw:px-3 tw:tooltip tw:tooltip-top'
data-tip='Share'
>
<ShareIcon className='tw:w-4 tw:h-4' />
</div>
<ul
tabIndex={0}
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={copyLink}
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)}`}
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='Copy' />
</div>
Email
</a>
</li>
{Object.entries(platformConfigs).map(([platform, config]) => (
<li key={platform}>
<a
href={getShareUrl(platform as keyof typeof platformConfigs)}
target='_blank'
rel='noopener noreferrer'
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.bgColor }}
>
{config.icon}
</div>
{config.label}
</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>
<DialogModal
isOpened={qrModalOpen}
showCloseButton={true}
onClose={() => setQrModalOpen(false)}
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'>Share your profile with others to expand your network.</p>
<div className='tw:p-8 tw:my-8 tw:rounded-lg tw:inline-block tw:border-base-300 tw:border-2 '>
<QRCode value={inviteLink} size={192} />
</div>
<div className='tw:flex tw:items-center tw:gap-2 tw:w-full tw:border-base-300 tw:border-2 tw:rounded-lg tw:p-3'>
<span className='tw:text-sm tw:truncate tw:flex-1 tw:min-w-0'>{inviteLink}</span>
<button
onClick={copyLink}
className='tw:btn tw:btn-primary tw:btn-sm tw:flex-shrink-0'
title='Link kopieren'
>
<img src={ClipboardSVG} className='tw:w-4 tw:h-4' alt='Copy' />
</button>
</div>
</div>
</DialogModal>
</>
)
}
export { HeaderView } from './HeaderView/index'

View File

@ -0,0 +1,163 @@
import { QrCodeIcon, ShareIcon } from '@heroicons/react/24/solid'
import { LuNavigation } from 'react-icons/lu'
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 { useMyProfile } from '#components/Map/hooks/useMyProfile'
import { useNavigationUrl, useShareLogic } from './hooks'
import type { Item } from '#types/Item'
import type { PlatformConfig, SharePlatformConfigs } from './types'
interface ActionButtonsProps {
item: Item
onQrModalOpen: () => void
}
export function ActionButtons({ item, onQrModalOpen }: ActionButtonsProps) {
const myProfile = useMyProfile()
const { getNavigationUrl, isMobile, isIOS } = useNavigationUrl(
item.position?.coordinates as [number, number] | undefined,
)
const { shareUrl, shareTitle, copyLink, getShareUrl } = useShareLogic(item)
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',
},
}
return (
<>
{item.position?.coordinates && myProfile.myProfile?.id !== item.id && (
<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>
)}
{myProfile.myProfile?.id === item.id && (
<button
onClick={onQrModalOpen}
className='tw:btn tw:mr-2 tw:px-3 tw:tooltip tw:tooltip-top'
title='QR-Code'
data-tip='QR Code'
>
<QrCodeIcon className='tw:h-4 tw:w-4' />
</button>
)}
{myProfile.myProfile?.id !== item.id && (
<div className='tw:dropdown tw:dropdown-end'>
<div
tabIndex={0}
role='button'
className='tw:btn tw:px-3 tw:tooltip tw:tooltip-top'
data-tip='Share'
>
<ShareIcon className='tw:w-4 tw:h-4' />
</div>
<ul
tabIndex={0}
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={copyLink}
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)}`}
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='Copy' />
</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'
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>
</div>
)}
</>
)
}

View File

@ -0,0 +1,41 @@
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 (isConnected) {
return <p className='tw:flex tw:items-center tw:mr-2'> Connected</p>
}
return (
<button
style={{
backgroundColor: `${item.color ?? (getItemTags(item)[0]?.color ? getItemTags(item)[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,41 @@
import { useState } from 'react'
import { useAppState } from '#components/AppShell/hooks/useAppState'
import type { Item } from '#types/Item'
interface ItemAvatarProps {
item: Item
big?: boolean
}
export function ItemAvatar({ item, big = false }: ItemAvatarProps) {
const appState = useAppState()
const [imageLoaded, setImageLoaded] = useState(false)
const avatar =
(item.image && appState.assetsApi.url + item.image + '?width=160&heigth=160') ??
item.image_external
if (!avatar) return null
return (
<div className='tw:avatar'>
<div
className={`${
big ? 'tw:w-16' : 'tw:w-10'
} 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>
</div>
)
}

View File

@ -0,0 +1,64 @@
import { MapPinIcon } from '@heroicons/react/24/solid'
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
showAddress?: boolean
hasAvatar?: boolean
}
export function ItemTitle({
item,
big = false,
truncateSubname = true,
showAddress = true,
hasAvatar = false,
}: ItemTitleProps) {
const { distance } = useGeoDistance(item.position ?? undefined)
const { formatDistance } = useFormatDistance()
const { address } = useReverseGeocode(
item.position?.coordinates as [number, number] | undefined,
showAddress,
'municipality',
)
const title = item.name ?? item.layer?.item_default_name
const subtitle = item.subname
return (
<div className={`${hasAvatar ? 'tw:ml-3' : ''} tw:overflow-hidden tw:flex-1 tw:min-w-0 `}>
<div
className={`${big ? 'tw:xl:text-3xl tw:text-2xl' : 'tw:text-xl'} tw:font-bold`}
title={title}
data-cy='profile-title'
>
{title}
</div>
{showAddress && 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>
)}
{subtitle && !showAddress && (
<div
className={`tw:text-sm tw:opacity-50 tw:items-center ${truncateSubname ? 'tw:truncate' : ''}`}
>
{subtitle}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,46 @@
import QRCode from 'react-qr-code'
import ClipboardSVG from '#assets/share/clipboard.svg'
import DialogModal from '#components/Templates/DialogModal'
import { useShareLogic } from './hooks'
import type { Item } from '#types/Item'
interface QRModalProps {
item: Item
isOpen: boolean
onClose: () => void
}
export function QRModal({ item, isOpen, onClose }: QRModalProps) {
const { inviteLink, copyLink } = 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'>Share your profile with others to expand your network.</p>
<div className='tw:p-8 tw:my-8 tw:rounded-lg tw:inline-block tw:border-base-300 tw:border-2 '>
<QRCode value={inviteLink} size={192} />
</div>
<div className='tw:flex tw:items-center tw:gap-2 tw:w-full tw:border-base-300 tw:border-2 tw:rounded-lg tw:p-3'>
<span className='tw:text-sm tw:truncate tw:flex-1 tw:min-w-0'>{inviteLink}</span>
<button
onClick={copyLink}
className='tw:btn tw:btn-primary tw:btn-sm tw:flex-shrink-0'
title='Link kopieren'
>
<img src={ClipboardSVG} className='tw:w-4 tw:h-4' alt='Copy' />
</button>
</div>
</div>
</DialogModal>
)
}

View File

@ -0,0 +1,82 @@
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 (isMobile) {
return `geo:${latitude},${longitude}`
}
if (isIOS) {
return `https://maps.apple.com/?daddr=${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 =
item?.secrets && item.secrets.length > 0
? `${window.location.origin}/invite/${item.secrets[0].secret}`
: 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,80 @@
import { useState } from 'react'
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)
if (!item) return null
const hasAvatar = !!(item.image ?? item.image_external)
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'>
{hasAvatar && <ItemAvatar item={item} big={big} />}
<ItemTitle
item={item}
big={big}
truncateSubname={truncateSubname}
showAddress={showAddress}
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} onQrModalOpen={() => setQrModalOpen(true)} />
</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

@ -3,7 +3,6 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/* 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-call */

View File

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

View File

@ -23,4 +23,5 @@ export interface ItemType {
show_header_view_in_form?: boolean
cta_button_label?: string
show_address?: boolean
cta_relation?: string
}