more customizable item fields @ layer component and improved tag handling

This commit is contained in:
Anton Tranelis 2024-01-29 16:07:04 +01:00
parent f9ffc7b739
commit fbe19d1994
9 changed files with 182 additions and 120 deletions

View File

@ -1,35 +1,38 @@
import * as React from 'react'
import { Marker, Tooltip, useMap, useMapEvents } from 'react-leaflet'
import { Item, LayerProps } from '../../types'
import { Item, LayerProps, Tag } from '../../types'
import MarkerIconFactory from '../../Utils/MarkerIconFactory'
import { ItemViewPopup } from './Subcomponents/ItemViewPopup'
import { useItems, useSetItemsApi, useSetItemsData } from './hooks/useItems'
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { ItemFormPopup } from './Subcomponents/ItemFormPopup'
import { useFilterTags, useIsLayerVisible } from './hooks/useFilter'
import { useGetItemTags } from './hooks/useTags'
import { useAddTag, useAllTagsLoaded, useGetItemTags, useTags } from './hooks/useTags'
import { useAddMarker, useAddPopup, useLeafletRefs } from './hooks/useLeafletRefs'
import { Popup } from 'leaflet'
import { useLocation } from 'react-router-dom';
import { useAssetApi } from '../AppShell/hooks/useAssets'
import { getValue } from '../../Utils/GetValue'
import { hashTagRegex } from '../../Utils/HashTagRegex'
import { randomColor } from '../../Utils/RandomColor'
export const Layer = ( {
export const Layer = ({
data,
children,
name='places',
menuIcon='MapPinIcon',
menuText='add new place',
menuColor='#2E7D32',
markerIcon='circle-solid',
markerShape='circle',
markerDefaultColor='#777',
name = 'places',
menuIcon = 'MapPinIcon',
menuText = 'add new place',
menuColor = '#2E7D32',
markerIcon = 'circle-solid',
markerShape = 'circle',
markerDefaultColor = '#777',
api,
itemTitleField='name',
itemTextField='text',
itemNameField = 'name',
itemTextField = 'text',
itemAvatarField,
itemColorField,
itemOwnerField,
itemLatitudeField = 'position.coordinates.1',
itemLongitudeField = 'position.coordinates.0',
setItemFormPopup,
itemFormPopup,
clusterRef
@ -47,28 +50,31 @@ export const Layer = ( {
let location = useLocation();
const allTagsLoaded = useAllTagsLoaded();
const tags = useTags();
const addTag = useAddTag();
const [newTagsToAdd, setNewTagsToAdd] = useState<Tag[]>([]);
const [tagsReady, setTagsReady] = useState<boolean>(false);
const map = useMap();
const isLayerVisible = useIsLayerVisible();
const assetsApi = useAssetApi();
useEffect(() => {
data && setItemsData({data, children, name, menuIcon, menuText, menuColor, markerIcon, markerShape, markerDefaultColor, api, itemTitleField, itemTextField, itemAvatarField, itemColorField, setItemFormPopup, itemFormPopup, clusterRef});
api && setItemsApi({data, children, name, menuIcon, menuText, menuColor, markerIcon, markerShape, markerDefaultColor, api, itemTitleField, itemTextField, itemAvatarField, itemColorField, setItemFormPopup, itemFormPopup, clusterRef});
data && setItemsData({ data, children, name, menuIcon, menuText, menuColor, markerIcon, markerShape, markerDefaultColor, api, itemNameField, itemTextField, itemAvatarField, itemColorField, itemOwnerField, setItemFormPopup, itemFormPopup, clusterRef });
api && setItemsApi({ data, children, name, menuIcon, menuText, menuColor, markerIcon, markerShape, markerDefaultColor, api, itemNameField, itemTextField, itemAvatarField, itemColorField, itemOwnerField, setItemFormPopup, itemFormPopup, clusterRef });
}, [data, api])
useMapEvents({
popupopen: (e) => {
const item = Object.entries(leafletRefs).find(r => r[1].popup == e.popup)?.[1].item;
const item = Object.entries(leafletRefs).find(r => r[1].popup == e.popup)?.[1].item;
if (item?.layer?.name == name && window.location.pathname.split("/")[2] != item.id) {
window.history.pushState({}, "", `/${name}/${item.id}`)
let title = "";
if(item.name) title = item.name;
else if (item.layer?.itemTitleField) title = getValue(item, item.layer.itemTitleField);
if (item.name) title = item.name;
else if (item.layer?.itemNameField) title = getValue(item, item.layer.itemNameField);
document.title = `${document.title.split("-")[0]} - ${title}`;
}
},
@ -90,8 +96,8 @@ export const Layer = ( {
});
const item = leafletRefs[id]?.item;
let title = "";
if(item.name) title = item.name;
else if (item.layer?.itemTitleField) title = getValue(item, item.layer.itemTitleField);
if (item.name) title = item.name;
else if (item.layer?.itemNameField) title = getValue(item, item.layer.itemNameField);
document.title = `${document.title.split("-")[0]} - ${title}`;
document.querySelector('meta[property="og:title"]')?.setAttribute("content", item.name);
document.querySelector('meta[property="og:description"]')?.setAttribute("content", item.text);
@ -106,68 +112,102 @@ export const Layer = ( {
openPopup();
}, [leafletRefs, location])
useEffect(() => {
}, [allTagsLoaded])
useEffect(() => {
if(tagsReady){
newTagsToAdd.map(newtag => {
addTag(newtag);
setNewTagsToAdd(current =>
current.filter(tag => {
return tag.id !== newtag.id;
}),
)
})
}
}, [tagsReady])
return (
<>
{items &&
items.
filter(item => item.text).
filter(item => item.layer?.name === name)?.
filter(item =>
filterTags.length == 0 ? item : filterTags.every(tag => getItemTags(item).some(filterTag => filterTag.id === tag.id)))?.
filterTags.length == 0 ? item : filterTags.every(tag => getItemTags(item).some(filterTag => filterTag.id.toLocaleLowerCase() === tag.id.toLocaleLowerCase())))?.
filter(item => item.layer && isLayerVisible(item.layer)).
map((item: Item) => {
const tags = getItemTags(item);
map((item: Item) => {
if (getValue(item, itemLongitudeField) && getValue(item, itemLatitudeField)) {
if (item?.tags) {
item[itemTextField] = getValue(item, itemTextField) + '\n\n';
item.tags.map(tag => {
if(!item[itemTextField].includes(`#${tag}`))
return (item[itemTextField] = item[itemTextField] + `#${tag} `)
return item[itemTextField]
});
}
if(allTagsLoaded) {
item[itemTextField].toLocaleLowerCase().match(hashTagRegex)?.map(tag=> {
if ((!tags.find((t) => t.id.toLocaleLowerCase() === tag.slice(1).toLocaleLowerCase()))&& !newTagsToAdd.find((t) => t.id.toLocaleLowerCase() === tag.slice(1).toLocaleLowerCase())) {
const newTag = {id: tag.slice(1).toLocaleLowerCase(), color: randomColor()};
setNewTagsToAdd(current => [...current, newTag]);
}
});
!tagsReady && setTagsReady(true);
}
let color1 = markerDefaultColor;
let color2 = "RGBA(35, 31, 32, 0.2)";
if (itemColorField) color1 = getValue(item, itemColorField);
if(color1 == null) color1 = markerDefaultColor;
else if (tags && tags[0]) {
color1 = tags[0].color;
}
if (tags && tags[0] && itemColorField) color2 = tags[0].color;
else if (tags && tags[1]) {
color2 = tags[1].color;
}
return (
<Marker ref={(r) => {
if (!(item.id in leafletRefs && leafletRefs[item.id].marker == r))
r && addMarker(item, r);
}} icon={MarkerIconFactory(markerShape, color1, color2, markerIcon)} key={item.id} position={[item.position.coordinates[1], item.position.coordinates[0]]}>
{
(children && React.Children.toArray(children).some(child => React.isValidElement(child) && child.props.__TYPE === "ItemView") ?
React.Children.toArray(children).map((child) =>
React.isValidElement(child) && child.props.__TYPE === "ItemView" ?
<ItemViewPopup ref={(r) => {
const itemTtags = getItemTags(item);
const latitude = itemLatitudeField && item ? getValue(item, itemLatitudeField) : undefined;
const longitude = itemLongitudeField && item ? getValue(item, itemLongitudeField) : undefined;
let color1 = markerDefaultColor;
let color2 = "RGBA(35, 31, 32, 0.2)";
if (itemColorField) color1 = getValue(item, itemColorField);
else if (itemTtags && itemTtags[0]) {
color1 = itemTtags[0].color;
}
if (itemTtags && itemTtags[0] && itemColorField) color2 = itemTtags[0].color;
else if (itemTtags && itemTtags[1]) {
color2 = itemTtags[1].color;
}
return (
<Marker ref={(r) => {
if (!(item.id in leafletRefs && leafletRefs[item.id].marker == r))
r && addMarker(item, r);
}} icon={MarkerIconFactory(markerShape, color1, color2, markerIcon)} key={item.id} position={[latitude, longitude]}>
{
(children && React.Children.toArray(children).some(child => React.isValidElement(child) && child.props.__TYPE === "ItemView") ?
React.Children.toArray(children).map((child) =>
React.isValidElement(child) && child.props.__TYPE === "ItemView" ?
<ItemViewPopup ref={(r) => {
if (!(item.id in leafletRefs && leafletRefs[item.id].popup == r))
r && addPopup(item, r as Popup);
}} key={item.id + item.name}
item={item}
setItemFormPopup={setItemFormPopup}>
{child}
</ItemViewPopup>
: ""
)
:
<>
<ItemViewPopup key={item.id + item.name} ref={(r) => {
if (!(item.id in leafletRefs && leafletRefs[item.id].popup == r))
r && addPopup(item, r as Popup);
}} key={item.id + item.name}
title={itemTitleField && item ? getValue(item, itemTitleField) : undefined}
avatar={itemAvatarField && item && getValue(item, itemAvatarField)? assetsApi.url + getValue(item, itemAvatarField) : undefined}
owner={itemOwnerField && item ? getValue(item, itemOwnerField) : undefined}
}}
item={item}
setItemFormPopup={setItemFormPopup}>
{child}
</ItemViewPopup>
: ""
)
:
<>
<ItemViewPopup key={item.id + item.name} ref={(r) => {
if (!(item.id in leafletRefs && leafletRefs[item.id].popup == r))
r && addPopup(item, r as Popup);
}} title={itemTitleField && item ? getValue(item, itemTitleField) : undefined}
avatar={itemAvatarField && item && getValue(item, itemAvatarField)? assetsApi.url + getValue(item, itemAvatarField) : undefined}
owner={itemOwnerField && item ? getValue(item, itemOwnerField) : undefined}
item={item}
setItemFormPopup={setItemFormPopup} />
</>)
}
<Tooltip offset={[0, -38]} direction='top'>{item.name? item.name : getValue(item, itemTitleField)}</Tooltip>
</Marker>
);
setItemFormPopup={setItemFormPopup} />
</>)
}
<Tooltip offset={[0, -38]} direction='top'>{item.name ? item.name : getValue(item, itemNameField)}</Tooltip>
</Marker>
);
}
else return null;
})
}
{//{children}}

View File

@ -47,7 +47,8 @@ export const SearchControl = ({ clusterRef }) => {
};
searchGeo();
setItemsResults(items.filter(item => {
if (item.layer?.itemTitleField) item.name = getValue(item, item.layer.itemTitleField)
if (item.layer?.itemNameField) item.name = getValue(item, item.layer.itemNameField)
if (item.layer?.itemTextField) item.text = getValue(item, item.layer.itemTextField)
return item.name?.toLowerCase().includes(value.toLowerCase()) || item.text?.toLowerCase().includes(value.toLowerCase())
}))
setTagsResults(tags.filter(tag => tag.id?.toLowerCase().includes(value.toLowerCase())))

View File

@ -7,14 +7,14 @@ import { Item } from "../../../../types";
import { toast } from "react-toastify";
import { useHasUserPermission } from "../../hooks/usePermissions";
import { useAuth } from "../../../Auth";
import { getValue } from "../../../../Utils/GetValue";
import { useAssetApi } from '../../../AppShell/hooks/useAssets'
export function HeaderView({ item, title, avatar, owner, setItemFormPopup }: {
export function HeaderView({ item, setItemFormPopup }: {
item: Item,
title?: string,
avatar?: string,
owner?: string,
setItemFormPopup?: React.Dispatch<React.SetStateAction<ItemFormPopupProps | null>>
}) {
@ -26,6 +26,13 @@ export function HeaderView({ item, title, avatar, owner, setItemFormPopup }: {
const { user } = useAuth();
const assetsApi = useAssetApi();
const avatar = item.layer?.itemAvatarField && item && getValue(item, item.layer?.itemAvatarField)? assetsApi.url + getValue(item, item.layer?.itemAvatarField ) : undefined;
const title = item.layer?.itemNameField && item ? getValue(item, item.layer?.itemNameField) : undefined;
const owner = item.layer?.itemOwnerField && item ? getValue(item, item.layer?.itemOwnerField) : undefined;
const removeItemFromMap = async (event: React.MouseEvent<HTMLElement>) => {
setLoading(true);

View File

@ -6,14 +6,17 @@ import { hashTagRegex } from '../../../../Utils/HashTagRegex';
import { fixUrls, mailRegex } from '../../../../Utils/ReplaceURLs';
import Markdown from 'react-markdown'
import rehypeVideo from 'rehype-video';
import { getValue } from '../../../../Utils/GetValue';
export const TextView = ({ item }: { item?: Item }) => {
const tags = useTags();
const addFilterTag = useAddFilterTag();
const text = item?.layer?.itemTextField && item ? getValue(item, item.layer?.itemTextField) : undefined;
let replacedText;
if (item && item.text) replacedText = fixUrls(item.text);
if (item && text) replacedText = fixUrls(text);
replacedText = replacedText.replace(/(?<!\]?\()https?:\/\/[^\s\)]+(?!\))/g, (url) => {
let shortUrl = url;
@ -63,6 +66,9 @@ export const TextView = ({ item }: { item?: Item }) => {
const CustomOrderdList = ({ children }) => (
<ol className="tw-list-decimal tw-list-inside">{children}</ol>
);
const CustomHorizontalRow = ({ children }) => (
<hr className="tw-border-current">{children}</hr>
);
const CustomImage = ({ alt, src, title }) => (
<img
className="max-w-full rounded-lg shadow-md"
@ -80,22 +86,18 @@ export const TextView = ({ item }: { item?: Item }) => {
{children}
</a>
);
const CustomHashTagLink = ({ children, tag, item }) => (
const CustomHashTagLink = ({ children, tag, item }) => {
return (
<a
style={{ color: tag ? tag.color : '#faa', fontWeight: 'bold', cursor: 'pointer' }}
key={tag ? tag.id + item!.id : item.id}
onClick={(e) => {
onClick={(e) => {
e.stopPropagation();
addFilterTag(tag!);
// map.fitBounds(items)
// map.closePopup();
}}>{children}</a>
);
const isSpecialYouTubeLink = (url) => {
return /(?<=!\()[^)]+(?=\))/g.test(url);
};
)};
return (
//@ts-ignore
@ -111,15 +113,15 @@ export const TextView = ({ item }: { item?: Item }) => {
return (
<iframe className='tw-w-full'
src={youtubeEmbedUrl}
allowFullScreen
/>
<iframe className='tw-w-full'
src={youtubeEmbedUrl}
allowFullScreen
/>
);
}
if (href?.startsWith("#")) {
const tag = tags.find(t => t.id.toLowerCase() == href.slice(1).toLowerCase())
if (href?.startsWith("#")) {
const tag = tags.find(t => t.id.toLowerCase() == decodeURI(href).slice(1).toLowerCase())
return <CustomHashTagLink tag={tag} item={item}>{children}</CustomHashTagLink>;
} else {
return (
@ -130,6 +132,7 @@ export const TextView = ({ item }: { item?: Item }) => {
ul: CustomUnorderdList,
ol: CustomOrderdList,
img: CustomImage,
hr: CustomHorizontalRow,
h1: CustomH1,
h2: CustomH2,
h3: CustomH3,

View File

@ -4,7 +4,6 @@ import { Item } from '../../../types'
import { ItemFormPopupProps } from './ItemFormPopup'
import { HeaderView } from './ItemPopupComponents/HeaderView'
import { TextView } from './ItemPopupComponents/TextView'
import { useAssetApi } from '../../AppShell/hooks/useAssets'
import { timeAgo } from '../../../Utils/TimeAgo'
import { useState } from 'react'
@ -12,9 +11,6 @@ import { useState } from 'react'
export interface ItemViewPopupProps {
item: Item,
children?: React.ReactNode;
title?: string;
avatar?: string;
owner?: string,
setItemFormPopup?: React.Dispatch<React.SetStateAction<ItemFormPopupProps | null>>
}
@ -26,7 +22,7 @@ export const ItemViewPopup = React.forwardRef((props: ItemViewPopupProps, ref: a
return (
<LeafletPopup ref={ref} maxHeight={377} minWidth={275} maxWidth={275} autoPanPadding={[20, 80]}>
<div className='tw-bg-base-100 tw-text-base-content'>
<HeaderView item={props.item} title={props.title} avatar={props.avatar} owner={props.owner} setItemFormPopup={props.setItemFormPopup} />
<HeaderView item={props.item} setItemFormPopup={props.setItemFormPopup} />
<div className='tw-overflow-y-auto tw-overflow-x-hidden tw-max-h-64'>
{props.children ?

View File

@ -66,7 +66,7 @@ function useItemsManager(initialItems: Item[]): {
const setItemsApi = useCallback(async (layer: LayerProps) => {
layer.api?.createItem && addLayer(layer);
addLayer(layer);
const result = await toast.promise(
layer.api!.getItems(),
{
@ -79,9 +79,9 @@ function useItemsManager(initialItems: Item[]): {
},
}
);
if (result) {
result.map(item => {
dispatch({ type: "ADD", item: { ...item, layer: layer } });
if (result) {
result.map(item => {
dispatch({ type: "ADD", item: { ...item, layer: layer } });
})
}
}, [])

View File

@ -1,7 +1,8 @@
import { useCallback, useReducer, createContext, useContext } from "react";
import { useCallback, useReducer, createContext, useContext, useState } from "react";
import * as React from "react";
import { Item, ItemsApi, Tag } from "../../../types";
import { hashTagRegex } from "../../../Utils/HashTagRegex";
import { getValue } from "../../../Utils/GetValue";
type ActionType =
| { type: "ADD"; tag: Tag }
@ -15,7 +16,8 @@ const TagContext = createContext<UseTagManagerResult>({
removeTag: () => { },
setTagApi: () => { },
setTagData: () => { },
getItemTags: () => []
getItemTags: () => [],
allTagsLoaded: false
});
function useTagsManager(initialTags: Tag[]): {
@ -25,7 +27,11 @@ function useTagsManager(initialTags: Tag[]): {
setTagApi: (api: ItemsApi<Tag>) => void;
setTagData: (data: Tag[]) => void;
getItemTags: (item: Item) => Tag[];
allTagsLoaded: boolean
} {
const [allTagsLoaded, setallTagsLoaded] = useState<boolean>(false);
const [tags, dispatch] = useReducer((state: Tag[], action: ActionType) => {
switch (action.type) {
case "ADD":
@ -34,7 +40,7 @@ function useTagsManager(initialTags: Tag[]): {
);
if (!exist) return [
...state,
{...action.tag, id: action.tag.id.toLocaleLowerCase()}
{ ...action.tag, id: action.tag.id.toLocaleLowerCase() }
];
else return state;
@ -55,6 +61,7 @@ function useTagsManager(initialTags: Tag[]): {
tag.id = tag.id.toLocaleLowerCase();
dispatch({ type: "ADD", tag })
})
setallTagsLoaded(true);
}
}, [])
@ -70,7 +77,6 @@ function useTagsManager(initialTags: Tag[]): {
type: "ADD",
tag,
});
if (!tags.some((t) => t.id.toLocaleLowerCase() === tag.id.toLocaleLowerCase())) {
api?.createItem && api.createItem(tag);
}
@ -84,19 +90,20 @@ function useTagsManager(initialTags: Tag[]): {
api?.deleteItem && api.deleteItem(id);
}, []);
const getItemTags = useCallback((item: Item) => {
const itemTagStrings = item.text.toLocaleLowerCase().match(hashTagRegex);
const itemTags: Tag[] = [];
itemTagStrings?.map(tag => {
if (tags.find(t => t.id === tag.slice(1))) {
itemTags.push(tags.find(t => t.id === tag.slice(1))!)
}
})
return itemTags
const getItemTags = useCallback((item: Item) => {
const text = item?.layer?.itemTextField && item ? getValue(item, item.layer?.itemTextField) : undefined;
const itemTagStrings = text.toLocaleLowerCase().match(hashTagRegex);
const itemTags: Tag[] = [];
itemTagStrings?.map(tag => {
if (tags.find(t => t.id === tag.slice(1))) {
itemTags.push(tags.find(t => t.id === tag.slice(1))!)
}
})
return itemTags
}, [tags]);
return { tags, addTag, removeTag, setTagApi, setTagData, getItemTags };
return { tags, addTag, removeTag, setTagApi, setTagData, getItemTags, allTagsLoaded };
}
export const TagsProvider: React.FunctionComponent<{
@ -136,4 +143,9 @@ export const useSetTagData = (): UseTagManagerResult["setTagData"] => {
export const useGetItemTags = (): UseTagManagerResult["getItemTags"] => {
const { getItemTags } = useContext(TagContext);
return getItemTags;
}
export const useAllTagsLoaded = (): UseTagManagerResult["allTagsLoaded"] => {
const { allTagsLoaded } = useContext(TagContext);
return allTagsLoaded;
}

View File

@ -1,7 +1,7 @@
export function getValue(obj, path) {
if (obj) {
for (var i = 0, path = path.split('.'), len = path.length; i < len; i++) {
obj = obj[path[i]];
if(obj) obj = obj[path[i]];
};
return obj;
}

View File

@ -21,11 +21,14 @@ export interface LayerProps {
markerShape: string,
markerDefaultColor: string,
api?: ItemsApi<any>,
itemTitleField?: string,
itemNameField?: string,
itemTextField?: string,
itemAvatarField?: string,
itemColorField?: string,
itemOwnerField?: string,
itemTagField?: string,
itemLatitudeField?: any,
itemLongitudeField?: any,
setItemFormPopup?: React.Dispatch<React.SetStateAction<ItemFormPopupProps | null>>,
itemFormPopup?: ItemFormPopupProps | null,
clusterRef?: React.MutableRefObject<any>
@ -41,7 +44,7 @@ export class Item {
start?: string;
end?: string;
api?: ItemsApi<any>;
tags?: Tag[];
tags?: string[];
layer?: LayerProps;
[key: string]: any;
constructor(id:string,name:string,text:string,position:Geometry, layer?: LayerProps, api?: ItemsApi<any>){