fix(lib): optimized layout elements (#424)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Anton Tranelis 2025-10-14 12:00:23 +02:00 committed by GitHub
parent 15fbd3e6ce
commit 590be2b7e5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 261 additions and 70 deletions

View File

@ -149,6 +149,7 @@ function App() {
/>
),
name: l.name, // name that appear in Sidebar
color: l.menuColor,
})),
)
// eslint-disable-next-line no-catch-all/no-catch-all

View File

@ -61,7 +61,7 @@
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^3.0.5",
"cypress": "^14.0.3",
"daisyui": "^5.0.6",
"daisyui": "^5.2.3",
"eslint": "^8.24.0",
"eslint-config-prettier": "^9.1.0",
"eslint-config-standard": "^17.1.0",

View File

@ -10,6 +10,7 @@ export interface Route {
name: string
submenu?: Route[]
blank?: boolean
color?: string
}
/**
@ -35,7 +36,7 @@ export function SideBar({ routes, bottomRoutes }: { routes: Route[]; bottomRoute
<nav
id='sidenav'
className={`${appState.sideBarOpen ? 'tw:translate-x-0' : 'tw:-translate-x-full'}
${appState.sideBarSlim ? 'tw:w-14' : 'tw:w-48'}
${appState.sideBarSlim ? 'tw:w-15' : 'tw:w-48'}
${appState.embedded ? 'tw:mt-5.5 tw:h-[calc(100dvh-22px)]' : 'tw:mt-16 tw:h-[calc(100dvh-64px)]'}
tw:fixed tw:left-0 tw:transition-all tw:duration-300 tw:top-0 tw:z-10035
tw:overflow-hidden tw:shadow-xl tw:dark:bg-zinc-800`}
@ -62,7 +63,12 @@ export function SideBar({ routes, bottomRoutes }: { routes: Route[]; bottomRoute
if (screen.width < 640 && !appState.sideBarSlim) toggleSidebarOpen()
}}
>
{route.icon}
<div
className='tw:p-1.5 tw:rounded-selector tw:text-white'
style={{ backgroundColor: route.color ?? '#777' }}
>
{route.icon}
</div>
<span
className={`${appState.sideBarSlim ? 'tw:hidden' : ''}`}
data-te-sidenav-slim='false'
@ -72,7 +78,8 @@ export function SideBar({ routes, bottomRoutes }: { routes: Route[]; bottomRoute
{(location.pathname.includes(route.path) && route.path.length > 1) ||
location.pathname === route.path ? (
<span
className='tw:absolute tw:inset-y-0 tw:left-0 tw:w-1 tw:rounded-tr-md tw:rounded-br-md tw:bg-primary '
className='tw:absolute tw:inset-y-0 tw:left-0 tw:w-1 tw:rounded-tr-md tw:rounded-br-md'
style={{ backgroundColor: route.color ?? '#777' }}
aria-hidden='true'
></span>
) : null}
@ -104,7 +111,7 @@ export function SideBar({ routes, bottomRoutes }: { routes: Route[]; bottomRoute
target={route.blank ? '_blank' : '_self'}
to={route.path}
className={({ isActive }) =>
`${isActive ? 'tw:font-semibold tw:bg-base-200 tw:rounded-none!' : 'tw:font-normal tw:rounded-none!'}`
`tw:px-4 ${isActive ? 'tw:font-semibold tw:bg-base-200 tw:rounded-none!' : 'tw:font-normal tw:rounded-none!'}`
}
onClick={() => {
if (screen.width < 640 && !appState.sideBarSlim) toggleSidebarOpen()

View File

@ -94,7 +94,9 @@ export const UserControl = () => {
</div>
</div>
)}
<div className='tw:ml-2 tw:mr-2'>{userProfile.name ?? user?.first_name}</div>
<div className='tw:ml-2 tw:mr-2 tw:hidden tw:sm:block'>
{userProfile.name ?? user?.first_name}
</div>
</Link>
<div className='tw:dropdown tw:dropdown-end'>
<label tabIndex={0} className='tw:btn tw:btn-ghost tw:btn-square'>

View File

@ -1,12 +1,34 @@
import { useEffect } from 'react'
/**
* Apply a theme based on the saved preference, a provided default theme, or the user's system preference.
* Falls back to the system dark/light preference when no theme is provided.
*/
export const useTheme = (defaultTheme = 'default') => {
useEffect(() => {
const savedTheme = localStorage.getItem('theme')
const initialTheme = savedTheme ? (JSON.parse(savedTheme) as string) : defaultTheme
if (initialTheme !== 'default') {
document.documentElement.setAttribute('data-theme', defaultTheme)
localStorage.setItem('theme', JSON.stringify(initialTheme))
const savedThemeRaw = localStorage.getItem('theme')
const savedTheme = savedThemeRaw ? (JSON.parse(savedThemeRaw) as string) : undefined
const prefersDark =
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
? window.matchMedia('(prefers-color-scheme: dark)').matches
: false
const fallbackTheme =
defaultTheme && defaultTheme !== 'default' ? defaultTheme : prefersDark ? 'dark' : 'light'
const themeToApply = savedTheme ?? fallbackTheme
if (themeToApply === 'default') {
document.documentElement.removeAttribute('data-theme')
localStorage.removeItem('theme')
return
}
document.documentElement.setAttribute('data-theme', themeToApply)
if (!savedTheme || savedTheme !== themeToApply) {
localStorage.setItem('theme', JSON.stringify(themeToApply))
}
}, [defaultTheme])
}

View File

@ -1,11 +1,13 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { useState } from 'react'
import SVG from 'react-inlinesvg'
import PlusSVG from '#assets/plus.svg'
import { useAppState } from '#components/AppShell/hooks/useAppState'
import { useLayers } from '#components/Map/hooks/useLayers'
import { useHasUserPermission } from '#components/Map/hooks/usePermissions'
import useWindowDimensions from '#components/Map/hooks/useWindowDimension'
export default function AddButton({
triggerAction,
@ -15,6 +17,9 @@ export default function AddButton({
const layers = useLayers()
const hasUserPermission = useHasUserPermission()
const appState = useAppState()
const { width } = useWindowDimensions()
const isMobile = width < 768
const [hideTooltips, setHideTooltips] = useState(false)
const canAddItems = () => {
let canAdd = false
@ -30,6 +35,14 @@ export default function AddButton({
return canAdd
}
const handleLayerClick = (layer: any) => {
triggerAction(layer)
// Verstecke Tooltips auf Mobile nach Layer-Auswahl
if (isMobile) {
setHideTooltips(true)
}
}
return (
<>
{canAddItems() ? (
@ -37,10 +50,23 @@ export default function AddButton({
<label
tabIndex={0}
className='tw:z-500 tw:btn tw:btn-circle tw:btn-lg tw:shadow tw:bg-base-100'
onClick={() => {
if (hideTooltips) {
setHideTooltips(false)
}
}}
onTouchEnd={() => {
if (hideTooltips) {
setHideTooltips(false)
}
}}
>
<SVG src={PlusSVG} className='tw:h-5 tw:w-5' />
</label>
<ul tabIndex={0} className='tw:dropdown-content tw:pr-1 tw:list-none'>
<ul
tabIndex={0}
className='tw:dropdown-content tw:pr-1 tw:list-none tw:space-y-3 tw:pb-3'
>
{layers.map(
(layer) =>
layer.api?.createItem &&
@ -48,16 +74,23 @@ export default function AddButton({
layer.listed && (
<li key={layer.name}>
<a>
<div className='tw:tooltip tw:tooltip-left' data-tip={layer.menuText}>
<div
className={`tw:tooltip tw:tooltip-left ${isMobile && !hideTooltips ? 'tw:tooltip-open' : ''}`}
data-tip={layer.menuText}
style={
{
'--tooltip-color': layer.menuColor || '#777',
'--tooltip-text-color': '#ffffff',
} as React.CSSProperties
}
>
<button
tabIndex={0}
className='tw:z-500 tw:border-0 tw:p-0 tw:mb-3 tw:w-10 tw:h-10 tw:cursor-pointer tw:rounded-full tw:mouse tw:drop-shadow-md tw:transition tw:ease-in tw:duration-200 tw:focus:outline-hidden tw:flex tw:items-center tw:justify-center'
className='tw:z-500 tw:border-0 tw:p-0 tw:w-10 tw:h-10 tw:cursor-pointer tw:rounded-full tw:mouse tw:drop-shadow-md tw:transition tw:ease-in tw:duration-200 tw:focus:outline-hidden tw:flex tw:items-center tw:justify-center'
style={{ backgroundColor: layer.menuColor || '#777' }}
onClick={() => {
triggerAction(layer)
}}
onClick={() => handleLayerClick(layer)}
onTouchEnd={(e) => {
triggerAction(layer)
handleLayerClick(layer)
e.preventDefault()
}}
>

View File

@ -38,8 +38,9 @@ export function LayerControl({ expandLayerControl = false }: { expandLayerContro
id={layer.name}
onChange={() => toggleVisibleLayer(layer)}
type='checkbox'
className='tw:checkbox tw:checkbox-xs tw:checkbox-success'
className='tw:checkbox tw:checkbox-xs tw:checkbox-success tw:text-white'
checked={isLayerVisible(layer)}
style={{ backgroundColor: layer.menuColor, borderColor: layer.menuColor }}
/>
<span className='tw:text-sm tw:label-text tw:mx-2 tw:cursor-pointer'>
{layer.name}

View File

@ -1,35 +0,0 @@
import type { Item } from '#types/Item'
import type { LayerProps } from '#types/LayerProps'
export const SelectPosition = ({
setSelectNewItemPosition,
selectNewItemPosition,
}: {
setSelectNewItemPosition: React.Dispatch<React.SetStateAction<Item | LayerProps | null>>
selectNewItemPosition?: Item | LayerProps | null
}) => {
return (
<div className='tw:animate-pulseGrow tw:button tw:z-1000 tw:absolute tw:right-5 tw:top-4 tw:drop-shadow-md'>
<label
className='tw:btn tw:btn-sm tw:rounded-2xl tw:btn-circle tw:btn-ghost tw:hover:bg-transparent tw:absolute tw:right-0 tw:top-0 tw:text-gray-600'
onClick={() => {
setSelectNewItemPosition(null)
}}
>
<p className='tw:text-center '></p>
</label>
<div className='tw:alert tw:bg-base-100 tw:text-base-content'>
<div>
{selectNewItemPosition && 'layer' in selectNewItemPosition && (
<span className='tw:text-lg'>
Select new position of <b>{selectNewItemPosition.name}</b> on the map!
</span>
)}
{selectNewItemPosition && 'markerIcon' in selectNewItemPosition && (
<span className='tw:text-lg'>Select position on the map!</span>
)}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,142 @@
import { useEffect, useMemo, useRef } from 'react'
import { toast } from 'react-toastify'
import { SVG } from '#components/AppShell'
import { useAppState } from '#components/AppShell/hooks/useAppState'
import type { Item } from '#types/Item'
import type { LayerProps } from '#types/LayerProps'
import type { Dispatch, SetStateAction } from 'react'
const isItemSelection = (value: Item | LayerProps): value is Item => 'layer' in value
interface SelectPositionToastProps {
selectNewItemPosition: Item | LayerProps | null
setSelectNewItemPosition: Dispatch<SetStateAction<Item | LayerProps | null>>
}
export const SelectPositionToast = ({
selectNewItemPosition,
setSelectNewItemPosition,
}: SelectPositionToastProps) => {
const toastIdRef = useRef<string | number | null>(null)
const toastId = 'select-position-toast'
const appState = useAppState()
// Escape-Key Listener
useEffect(() => {
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape' && selectNewItemPosition) {
toast.dismiss(toastId)
toastIdRef.current = null
setSelectNewItemPosition(null)
}
}
window.addEventListener('keydown', handleEscape)
return () => window.removeEventListener('keydown', handleEscape)
}, [selectNewItemPosition, setSelectNewItemPosition])
const toastContent = useMemo(() => {
if (!selectNewItemPosition) return null
const itemSelection = isItemSelection(selectNewItemPosition)
const layer: LayerProps | null = itemSelection
? (selectNewItemPosition.layer ?? null)
: selectNewItemPosition
const markerIcon = itemSelection
? (selectNewItemPosition.layer?.markerIcon ?? selectNewItemPosition.markerIcon)
: selectNewItemPosition.markerIcon
const message = itemSelection
? `Select the new position of ${selectNewItemPosition.name ?? ''} on the map!`
: 'Select the position on the map!'
const dismissToast = () => {
toast.dismiss(toastId)
toastIdRef.current = null
setSelectNewItemPosition(null)
}
const assetsApiUrl = appState.assetsApi.url
const assetsBaseUrl: string | undefined = assetsApiUrl.length > 0 ? assetsApiUrl : undefined
const resolveColor = (...candidates: (string | null | undefined)[]): string => {
for (const candidate of candidates) {
if (typeof candidate === 'string' && candidate.length > 0) {
return candidate
}
}
return '#777'
}
const itemColor =
itemSelection && typeof selectNewItemPosition.color === 'string'
? selectNewItemPosition.color
: undefined
const itemLayerColor =
itemSelection && typeof selectNewItemPosition.layer?.menuColor === 'string'
? selectNewItemPosition.layer.menuColor
: undefined
const layerMenuColor = layer?.menuColor
const baseLayerColor = typeof layerMenuColor === 'string' ? layerMenuColor : undefined
const backgroundColor = resolveColor(itemColor, itemLayerColor, baseLayerColor)
const iconSrc: string | undefined =
markerIcon?.image != null
? assetsBaseUrl
? `${assetsBaseUrl}${markerIcon.image}`
: markerIcon.image
: undefined
return (
<div>
<div className='tw:flex tw:flex-row tw:items-center'>
<div
className='tw:flex tw:items-center tw:gap-3 tw:p-2 tw:rounded-selector tw:text-white tw:mr-2'
style={{ backgroundColor }}
>
{iconSrc && <SVG src={iconSrc} className='tw:h-4 tw:w-4 tw:object-contain' />}
</div>
<div className='tw:flex tw:flex-col tw:gap-0.5'>
<span className='tw:text-sm'>{message}</span>
</div>
</div>
<button
onClick={dismissToast}
className='tw:btn tw:btn-sm tw:btn-ghost tw:btn-circle tw:absolute tw:top-0 tw:right-0'
>
</button>
</div>
)
}, [appState.assetsApi.url, selectNewItemPosition, setSelectNewItemPosition, toastId])
useEffect(() => {
if (selectNewItemPosition && toastContent) {
if (!toastIdRef.current) {
toastIdRef.current = toast(toastContent, {
toastId,
autoClose: false,
closeButton: false,
closeOnClick: false,
draggable: false,
})
} else {
toast.update(toastId, {
render: toastContent,
autoClose: false,
closeButton: false,
closeOnClick: false,
draggable: false,
})
}
}
if (!selectNewItemPosition && toastIdRef.current) {
toast.dismiss(toastIdRef.current)
toastIdRef.current = null
}
}, [selectNewItemPosition, toastContent, toastId])
return null
}

View File

@ -115,6 +115,7 @@ function UtopiaMap({
zoom={zoom}
zoomControl={showZoomControl}
maxZoom={19}
minZoom={2}
>
<UtopiaMapInner
geo={geo}

View File

@ -43,7 +43,7 @@ import { SearchControl } from './Subcomponents/Controls/SearchControl'
import { TagsControl } from './Subcomponents/Controls/TagsControl'
import { TextView } from './Subcomponents/ItemPopupComponents/TextView'
import { MapLibreLayer } from './Subcomponents/MapLibreLayer'
import { SelectPosition } from './Subcomponents/SelectPosition'
import { SelectPositionToast } from './Subcomponents/SelectPositionToast'
import type { Feature, Geometry as GeoJSONGeometry, GeoJsonObject } from 'geojson'
@ -332,12 +332,10 @@ export function UtopiaMapInner({
)}
<MapEventListener />
<AddButton triggerAction={setSelectNewItemPosition} />
{selectNewItemPosition != null && (
<SelectPosition
selectNewItemPosition={selectNewItemPosition}
setSelectNewItemPosition={setSelectNewItemPosition}
/>
)}
<SelectPositionToast
selectNewItemPosition={selectNewItemPosition}
setSelectNewItemPosition={setSelectNewItemPosition}
/>
</div>
)
}

View File

@ -8,6 +8,10 @@
opacity: 0.5;
}
.leaflet-container{
background-color: var(--color-base-300);
}
.leaflet-control-attribution a{
color: #000 !important;
}
@ -62,7 +66,6 @@ display: none !important;
.leaflet-tooltip {
border-radius: var(--radius-box);
transition: opacity 500ms;
transition-delay: 50ms;
}

View File

@ -77,4 +77,20 @@
.modal-box {
max-height: calc(100dvh - 2em);
}
}
/* Custom tooltip colors for layer-based tooltips */
.tw\:tooltip[style*='--tooltip-color']::before,
.tw\:tooltip[style*='--tooltip-color']::after {
background-color: rgba(0,0,0,0.67);
}
.tw\:tooltip[style*='--tooltip-color']::before {
color: var(--tooltip-text-color, #ffffff) !important;
}
/* Make sure tooltips sit above Leaflet panes when used inside map popups */
.tw\:tooltip::before,
.tw\:tooltip::after {
z-index: 4000;
}

14
package-lock.json generated
View File

@ -121,7 +121,7 @@
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^3.0.5",
"cypress": "^14.0.3",
"daisyui": "^5.0.6",
"daisyui": "^5.2.3",
"eslint": "^8.24.0",
"eslint-config-prettier": "^9.1.0",
"eslint-config-standard": "^17.1.0",
@ -7004,9 +7004,9 @@
"license": "MIT"
},
"node_modules/daisyui": {
"version": "5.1.29",
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.1.29.tgz",
"integrity": "sha512-4eZhqCXO7CJVNGytTZEIQYJz3fah2gPleuqp4qUD4fD8WoEQIYzKwlOewi8nPaz6NX7vvSLZ+YSjt5Z5zqacGw==",
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.2.3.tgz",
"integrity": "sha512-sldBQUIFCsSPoF4LvoHhIi9GnvBX/3aZD9NoTOvpTSX8sDjO484wQx7yEvRyREMpn4rZMvQSKKskHAHdM8+B4Q==",
"dev": true,
"license": "MIT",
"funding": {
@ -9221,9 +9221,9 @@
}
},
"node_modules/happy-dom/node_modules/@types/node": {
"version": "20.19.20",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.20.tgz",
"integrity": "sha512-2Q7WS25j4pS1cS8yw3d6buNCVJukOTeQ39bAnwR6sOJbaxvyCGebzTMypDFN82CxBLnl+lSWVdCCWbRY6y9yZQ==",
"version": "20.19.21",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.21.tgz",
"integrity": "sha512-CsGG2P3I5y48RPMfprQGfy4JPRZ6csfC3ltBZSRItG3ngggmNY/qs2uZKp4p9VbrpqNNSMzUZNFZKzgOGnd/VA==",
"dev": true,
"license": "MIT",
"dependencies": {