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 name: l.name, // name that appear in Sidebar
color: l.menuColor,
})), })),
) )
// eslint-disable-next-line no-catch-all/no-catch-all // eslint-disable-next-line no-catch-all/no-catch-all

View File

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

View File

@ -10,6 +10,7 @@ export interface Route {
name: string name: string
submenu?: Route[] submenu?: Route[]
blank?: boolean blank?: boolean
color?: string
} }
/** /**
@ -35,7 +36,7 @@ export function SideBar({ routes, bottomRoutes }: { routes: Route[]; bottomRoute
<nav <nav
id='sidenav' id='sidenav'
className={`${appState.sideBarOpen ? 'tw:translate-x-0' : 'tw:-translate-x-full'} 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)]'} ${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: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`} 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() 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 <span
className={`${appState.sideBarSlim ? 'tw:hidden' : ''}`} className={`${appState.sideBarSlim ? 'tw:hidden' : ''}`}
data-te-sidenav-slim='false' 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.includes(route.path) && route.path.length > 1) ||
location.pathname === route.path ? ( location.pathname === route.path ? (
<span <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' aria-hidden='true'
></span> ></span>
) : null} ) : null}
@ -104,7 +111,7 @@ export function SideBar({ routes, bottomRoutes }: { routes: Route[]; bottomRoute
target={route.blank ? '_blank' : '_self'} target={route.blank ? '_blank' : '_self'}
to={route.path} to={route.path}
className={({ isActive }) => 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={() => { onClick={() => {
if (screen.width < 640 && !appState.sideBarSlim) toggleSidebarOpen() if (screen.width < 640 && !appState.sideBarSlim) toggleSidebarOpen()

View File

@ -94,7 +94,9 @@ export const UserControl = () => {
</div> </div>
</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> </Link>
<div className='tw:dropdown tw:dropdown-end'> <div className='tw:dropdown tw:dropdown-end'>
<label tabIndex={0} className='tw:btn tw:btn-ghost tw:btn-square'> <label tabIndex={0} className='tw:btn tw:btn-ghost tw:btn-square'>

View File

@ -1,12 +1,34 @@
import { useEffect } from 'react' 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') => { export const useTheme = (defaultTheme = 'default') => {
useEffect(() => { useEffect(() => {
const savedTheme = localStorage.getItem('theme') const savedThemeRaw = localStorage.getItem('theme')
const initialTheme = savedTheme ? (JSON.parse(savedTheme) as string) : defaultTheme const savedTheme = savedThemeRaw ? (JSON.parse(savedThemeRaw) as string) : undefined
if (initialTheme !== 'default') {
document.documentElement.setAttribute('data-theme', defaultTheme) const prefersDark =
localStorage.setItem('theme', JSON.stringify(initialTheme)) 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]) }, [defaultTheme])
} }

View File

@ -1,11 +1,13 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-non-null-assertion */
import { useState } from 'react'
import SVG from 'react-inlinesvg' import SVG from 'react-inlinesvg'
import PlusSVG from '#assets/plus.svg' import PlusSVG from '#assets/plus.svg'
import { useAppState } from '#components/AppShell/hooks/useAppState' import { useAppState } from '#components/AppShell/hooks/useAppState'
import { useLayers } from '#components/Map/hooks/useLayers' import { useLayers } from '#components/Map/hooks/useLayers'
import { useHasUserPermission } from '#components/Map/hooks/usePermissions' import { useHasUserPermission } from '#components/Map/hooks/usePermissions'
import useWindowDimensions from '#components/Map/hooks/useWindowDimension'
export default function AddButton({ export default function AddButton({
triggerAction, triggerAction,
@ -15,6 +17,9 @@ export default function AddButton({
const layers = useLayers() const layers = useLayers()
const hasUserPermission = useHasUserPermission() const hasUserPermission = useHasUserPermission()
const appState = useAppState() const appState = useAppState()
const { width } = useWindowDimensions()
const isMobile = width < 768
const [hideTooltips, setHideTooltips] = useState(false)
const canAddItems = () => { const canAddItems = () => {
let canAdd = false let canAdd = false
@ -30,6 +35,14 @@ export default function AddButton({
return canAdd return canAdd
} }
const handleLayerClick = (layer: any) => {
triggerAction(layer)
// Verstecke Tooltips auf Mobile nach Layer-Auswahl
if (isMobile) {
setHideTooltips(true)
}
}
return ( return (
<> <>
{canAddItems() ? ( {canAddItems() ? (
@ -37,10 +50,23 @@ export default function AddButton({
<label <label
tabIndex={0} tabIndex={0}
className='tw:z-500 tw:btn tw:btn-circle tw:btn-lg tw:shadow tw:bg-base-100' 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' /> <SVG src={PlusSVG} className='tw:h-5 tw:w-5' />
</label> </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( {layers.map(
(layer) => (layer) =>
layer.api?.createItem && layer.api?.createItem &&
@ -48,16 +74,23 @@ export default function AddButton({
layer.listed && ( layer.listed && (
<li key={layer.name}> <li key={layer.name}>
<a> <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 <button
tabIndex={0} 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' }} style={{ backgroundColor: layer.menuColor || '#777' }}
onClick={() => { onClick={() => handleLayerClick(layer)}
triggerAction(layer)
}}
onTouchEnd={(e) => { onTouchEnd={(e) => {
triggerAction(layer) handleLayerClick(layer)
e.preventDefault() e.preventDefault()
}} }}
> >

View File

@ -38,8 +38,9 @@ export function LayerControl({ expandLayerControl = false }: { expandLayerContro
id={layer.name} id={layer.name}
onChange={() => toggleVisibleLayer(layer)} onChange={() => toggleVisibleLayer(layer)}
type='checkbox' 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)} checked={isLayerVisible(layer)}
style={{ backgroundColor: layer.menuColor, borderColor: layer.menuColor }}
/> />
<span className='tw:text-sm tw:label-text tw:mx-2 tw:cursor-pointer'> <span className='tw:text-sm tw:label-text tw:mx-2 tw:cursor-pointer'>
{layer.name} {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} zoom={zoom}
zoomControl={showZoomControl} zoomControl={showZoomControl}
maxZoom={19} maxZoom={19}
minZoom={2}
> >
<UtopiaMapInner <UtopiaMapInner
geo={geo} geo={geo}

View File

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

View File

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

View File

@ -78,3 +78,19 @@
.modal-box { .modal-box {
max-height: calc(100dvh - 2em); 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", "@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^3.0.5", "@vitest/coverage-v8": "^3.0.5",
"cypress": "^14.0.3", "cypress": "^14.0.3",
"daisyui": "^5.0.6", "daisyui": "^5.2.3",
"eslint": "^8.24.0", "eslint": "^8.24.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-config-standard": "^17.1.0", "eslint-config-standard": "^17.1.0",
@ -7004,9 +7004,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/daisyui": { "node_modules/daisyui": {
"version": "5.1.29", "version": "5.2.3",
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.1.29.tgz", "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.2.3.tgz",
"integrity": "sha512-4eZhqCXO7CJVNGytTZEIQYJz3fah2gPleuqp4qUD4fD8WoEQIYzKwlOewi8nPaz6NX7vvSLZ+YSjt5Z5zqacGw==", "integrity": "sha512-sldBQUIFCsSPoF4LvoHhIi9GnvBX/3aZD9NoTOvpTSX8sDjO484wQx7yEvRyREMpn4rZMvQSKKskHAHdM8+B4Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
@ -9221,9 +9221,9 @@
} }
}, },
"node_modules/happy-dom/node_modules/@types/node": { "node_modules/happy-dom/node_modules/@types/node": {
"version": "20.19.20", "version": "20.19.21",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.20.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.21.tgz",
"integrity": "sha512-2Q7WS25j4pS1cS8yw3d6buNCVJukOTeQ39bAnwR6sOJbaxvyCGebzTMypDFN82CxBLnl+lSWVdCCWbRY6y9yZQ==", "integrity": "sha512-CsGG2P3I5y48RPMfprQGfy4JPRZ6csfC3ltBZSRItG3ngggmNY/qs2uZKp4p9VbrpqNNSMzUZNFZKzgOGnd/VA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {