Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • fcos-suite/fcos-suite-map
1 result
Show changes
Showing
with 993 additions and 76 deletions
import React, { CSSProperties } from 'react';
import SidebarContainer from './SidebarContainer';
import SidebarListElement from './SidebarListElement';
import type { PointOfInterest } from '../types/PointOfInterest';
interface Props {
style?: CSSProperties;
values: PointOfInterest[];
onClick?: (id: number) => void;
onMouseEnter?: (id: number) => void;
onMouseLeave?: () => void;
}
const SidebarListView: React.FC<Props> = ({ values, onMouseEnter, onMouseLeave, onClick, ...restProps }) => {
return (
values && (
<SidebarContainer {...restProps}>
<h1 className="text-xl font-medium title-font m-4 text-gray-900 mb-2">{values.length} Orte:</h1>
{values.map((poi) => (
<SidebarListElement
key={poi.id}
{...(onMouseLeave ? { onMouseLeave: () => onMouseLeave() } : {})}
{...(onMouseEnter ? { onMouseEnter: () => onMouseEnter(poi.id) } : {})}
{...(onClick ? { onClick: () => onClick(poi.id) } : {})}
value={poi}
/>
))}
</SidebarContainer>
)
);
};
export default SidebarListView;
import React, { CSSProperties } from 'react';
import SidebarContainer from './SidebarContainer';
import type { PointOfInterest } from '../types/PointOfInterest';
import { X as CloseIcon, HomeOutline as HomeIcon } from 'heroicons-react';
interface Props {
style?: CSSProperties;
value: PointOfInterest;
onClose?: () => void;
}
const SidebarSingleView: React.FC<Props> = ({ value, onClose, ...restProps }) => {
return (
<SidebarContainer className="relative p-0" {...restProps}>
<CloseIcon
size={32}
className="absolute left-5 top-5 p-1 text-gray-500 inline-block cursor-pointer hover:bg-gray-300 hover:bg-opacity-50 rounded-full"
onClick={onClose}
/>
<img
className="lg:h-48 md:h-36 w-full object-cover object-center"
src="https://picsum.photos/720/400"
alt="blog"
/>
<div className="p-6">
<h2 className="tracking-widest text-xs title-font font-medium text-gray-400 mb-1">{value.category}</h2>
<h1 className="title-font text-lg font-medium text-gray-900 mb-3">{value.name}</h1>
<p className="leading-relaxed mb-3">{value.description}</p>
{value.website && (
<div className={'flex items-center'}>
<HomeIcon size={20} className={'text-gray-500 mr-2'} />
<a className={'hover:underline'} href={value.website}>
{value.website.replace(/(^\w+:|^)\/\//, '')}
</a>
</div>
)}
</div>
</SidebarContainer>
);
};
export default SidebarSingleView;
import React from 'react';
import { Route, Switch } from 'react-router-dom';
import { useStore } from '../hooks';
import ErrorModal from './ErrorModal';
import Notification from './Notification';
import Map from './Map/Map';
import SidebarCreateView from './Sidebar/SidebarCreateView';
import SidebarListView from './Sidebar/SidebarListView';
import SidebarSingleView from './Sidebar/SidebarSingleView';
import PoiLoader from './PoiLoader';
const App: React.FC = () => {
const selectedPoi = useStore((state) => state.selectedPoi);
return (
<>
<ErrorModal />
<Notification />
<div className={'flex md:flex-row-reverse flex-col h-full'}>
<Route path="/add">{({ match }) => <Map createMode={!!match} />}</Route>
<Route path="/poi/:poiId">{({ match }) => <PoiLoader poiId={match?.params?.poiId as string} />}</Route>
<Route exact path="/">
<PoiLoader poiId={null} />
</Route>
<Switch>
<Route exact path="/add">
<SidebarCreateView />
</Route>
<Route>{selectedPoi ? <SidebarSingleView /> : <SidebarListView />}</Route>
</Switch>
</div>
</>
);
};
export default App;
import React from 'react';
import type { Theme } from 'react-select';
import type { NamedProps, StylesConfig } from 'react-select';
import Creatable from 'react-select/creatable';
const CreatableSelect = <OptionType, isMulti extends boolean>(props: NamedProps<OptionType, isMulti>): JSX.Element => {
const customStyles: StylesConfig<OptionType, isMulti> = {
control: (provided) => ({
...provided,
border: '0',
borderRadius: '0.5em',
boxShadow: 'none',
}),
multiValue: (provided) => ({ ...provided, borderRadius: '999px', padding: '0 3px' }),
multiValueRemove: (provided) => ({
...provided,
color: 'hsl(0, 0%, 50%)',
'&:hover': { backgroundColor: 'initial', color: 'black' },
}),
};
return (
<div>
<Creatable
theme={(theme): Theme => ({
...theme,
// @ts-expect-error: ThemeConfig type from definitely-typed is not complete
borderRadius: '0.5em',
colors: {
...theme.colors,
primary25: '#C7D2FE',
},
})}
styles={customStyles}
name="pois"
className="hover:border-opacity-40 rounded-lg w-full border-2 border-black border-opacity-20 focus-within:border-indigo-300 focus-within:ring focus-within:ring-indigo-200 focus-within:ring-opacity-50 mt-1"
{...props}
/>
</div>
);
};
export default CreatableSelect;
import { ExclamationOutline as AlertIcon } from 'heroicons-react';
import React from 'react';
import { useStore } from '../hooks';
import Modal from './Modal';
const ErrorModal: React.FC = () => {
const error = useStore((state) => state.error);
const icons: { [index: string]: JSX.Element } = {
alert: <AlertIcon className="h-6 w-6 text-red-600" />,
};
return error && <Modal title={error.title} text={error.message} icon={icons[error.icon]} />;
};
export default ErrorModal;
import React, { useEffect, useRef, useState } from 'react';
import type { PointOfInterestFormData, Tag } from '../../types/PointOfInterest';
import fetcher from '../../util/fetcher';
import { validateFile } from '../../util/file';
import TextInput from './TextInput';
import FileInput from './FileInput';
import TextAreaInput from './TextAreaInput';
import { usePoiData, useStore } from '../../hooks';
import { useHistory } from 'react-router-dom';
import CoordinateInput from './CoordinateInput';
import TagInput from './TagInput';
import { removeDuplicateObjects } from '../../util/array';
import { createPoi, createTags } from '../../graphql/mutations';
import Spinner from '../Spinner';
import type { CreatePoiMutationMutationVariables, Mutation } from '../../generated/graphql';
import SelectInput from './SelectInput';
type StringSelectOption = { label: string; value: string };
const AddPoiForm: React.FC = () => {
const [formData, setFormData] = useState<PointOfInterestFormData>({
lat: 0,
lng: 0,
email: '',
name: '',
address: '',
description: '',
website: '',
category: '',
relationStatus: '',
image: null,
tags: [],
});
const [isValid, setIsValid] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const draftPoi = useStore((state) => state.draftPoi);
const formRef = useRef<HTMLFormElement>(null);
const history = useHistory();
const { data } = usePoiData();
const [tagOptions, setTagOptions] = useState<Tag[]>([]);
const [categoryOptions, setCatgeoryOptions] = useState<StringSelectOption[]>([]);
const [relationStatusOptions, setRelationStatusOptions] = useState<StringSelectOption[]>([]);
const [selectedTags, setSelectedTags] = useState<Tag[]>([]);
const setNotification = useStore((state) => state.setNotification);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setFormData({ ...formData, [e.target.name]: e.target.value });
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e?.target?.files?.length) {
const isFileValid = validateFile(e?.target?.files[0]);
if (isFileValid) {
setFormData({ ...formData, [e.target.name]: e?.target?.files[0] });
}
}
};
const handleSubmit = async (e: React.SyntheticEvent) => {
e.preventDefault();
formRef.current?.reportValidity();
setIsLoading(true);
try {
const newTags: Tag[] = [];
let newTagIdsResponse: string[] = [];
const oldTags: Tag[] = [];
selectedTags.forEach((tag) => (tag.id === 'draft' ? newTags.push(tag) : oldTags.push(tag)));
if (newTags.length) {
const tagsRes: Mutation = await fetcher(createTags, {
tags: newTags.map(({ displayName, color }) => ({
displayName,
color,
})),
});
if (tagsRes.createTags && tagsRes.createTags.length > 0) {
newTagIdsResponse = tagsRes.createTags
.filter((newTagResponse) => newTagResponse?.id)
.map((newTagResponse) => {
return (newTagResponse as Tag).id;
});
}
}
const finalData: CreatePoiMutationMutationVariables = {
...formData,
tagIds: [...newTagIdsResponse, ...oldTags.map((oldTag) => oldTag.id)],
};
const res: Mutation = await fetcher(createPoi, finalData);
if (res.createPoi) {
setNotification({
title: 'Ort hinzugefügt',
text: 'Ort wurde erfolgreich hinzugefügt. Bitte überprüfe deine E-Mails und klicke dort auf den Link um deine Mail-Adresse zu verifizieren.',
type: 'success',
});
history.push('/');
} else {
setIsLoading(false);
throw new Error();
}
} catch (error) {
console.error(error);
setIsLoading(false);
setNotification({
title: 'Fehler beim Hinzufügen',
text: 'Ort konnte nicht hinzgefügt werden. Bitte später erneut probieren.',
type: 'alert',
});
}
};
// Aggregate all tags from all POIs and remove duplicates
useEffect(() => {
if (data) {
const tagsWithDuplicates = data.flatMap((poi) => {
return poi.tags;
});
const tags = removeDuplicateObjects(tagsWithDuplicates, 'id');
setTagOptions(tags);
const relationStatuses = removeDuplicateObjects(data, 'relationStatus')
.filter((poi) => !!poi.relationStatus)
.map((poi) => ({ label: poi.relationStatus, value: poi.relationStatus }));
setRelationStatusOptions(relationStatuses);
const categories = removeDuplicateObjects(data, 'category')
.filter((poi) => !!poi.category)
.map((poi) => ({ label: poi.category, value: poi.category }));
setCatgeoryOptions(categories);
}
}, [data]);
useEffect(() => {
// Validation of native HTML input constraints
const htmlValid = formRef.current?.checkValidity();
if (draftPoi && htmlValid) {
if (!isValid) setIsValid(true);
} else {
if (isValid) setIsValid(false);
}
}, [formData, draftPoi]);
useEffect(() => {
// Set lat/lng in Form when PIN is dropped on map
if (draftPoi) setFormData({ ...formData, lat: draftPoi[0], lng: draftPoi[1] });
}, [draftPoi]);
return (
<form className="flex-1 flex flex-col justify-between" onSubmit={handleSubmit} ref={formRef}>
<div className="flex flex-col">
<CoordinateInput
label={'Koordinaten'}
text="Bitte Pin auf Karte setzen."
value={[formData.lat, formData.lng]}
required
/>
<TagInput label={'Tags'} tags={selectedTags} options={tagOptions} onTagsChange={setSelectedTags} />
<TextInput label={'Name des Orts'} name={'name'} value={formData.name} onChange={handleInputChange} required />
<SelectInput
label={'Kategorie'}
required
placeholder={'Auswählen...'}
name={'category'}
options={categoryOptions}
onChange={(selectedOption) =>
setFormData((prev) => ({
...prev,
category: selectedOption ? (selectedOption as StringSelectOption).value : '',
}))
}
/>
<SelectInput
label={'Verhältnis zum Fab City Hamburg e.V.'}
required
name={'relationStatus'}
placeholder={'Auswählen...'}
options={relationStatusOptions}
onChange={(selectedOption) =>
setFormData((prev) => ({
...prev,
relationStatus: selectedOption ? (selectedOption as StringSelectOption).value : '',
}))
}
/>
<TextInput
label={'Anschrift'}
name={'address'}
value={formData.address}
onChange={handleInputChange}
required
/>
<TextInput
label={'E-Mail (wird nicht veröffentlicht)'}
name={'email'}
value={formData.email}
onChange={(event) => {
// event.target.setCustomValidity('Custom message');
handleInputChange(event);
}}
type="email"
required
/>
<TextInput
type="url"
label={'Webseite'}
name={'website'}
value={formData.website}
onChange={handleInputChange}
required
/>
<TextAreaInput
label={'Beschreibung'}
name={'description'}
value={formData.description}
onChange={handleInputChange}
required
/>
<FileInput value={formData.image} onChange={handleFileChange} name={'image'} label={'Bild'} required />
</div>
<button disabled={isLoading} type="submit" className="form-button">
{isLoading && <Spinner />}
Hinzufügen
</button>
</form>
);
};
export default AddPoiForm;
import React from 'react';
import { CheckCircleOutline as CheckIcon } from 'heroicons-react';
interface Props {
label: string;
value: [number | '', number | ''];
required?: boolean;
text?: string;
}
const CoordinateInput: React.FC<Props> = ({ label, text, value, required }) => {
return (
<>
{!!label && (
<span className="form-label">
{label}
{required && `*`}
</span>
)}
<label className="flex mt-1 mb-4 p-2 items-center w-full rounded-lg border-2 border-black border-opacity-20 hover:border-opacity-40 focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50">
<CheckIcon className={`${value.filter(Boolean).length ? 'text-indigo-500' : 'text-gray-400'} mr-2`} />
<input readOnly type="text" name={'lat'} value={value[0]} className="hidden" required={required}></input>
<input readOnly type="text" name={'lng'} value={value[1]} className="hidden" required={required}></input>
{!!text && <span className="form-label">{text}</span>}
</label>
</>
);
};
export default CoordinateInput;
import React from 'react';
import { PaperClipOutline as PaperClipIcon, UploadOutline as UploadIcon } from 'heroicons-react';
interface Props {
label: string;
value: File | null;
name: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
required: boolean;
}
const FileInput: React.FC<Props> = ({ name, label, value, onChange, ...inputProps }) => {
return (
<>
<span className="form-label">
{label}
{inputProps?.required && `*`}
</span>
<label
className={`mt-1 flex items-center rounded-lg border-2 border-black mb-6 w-full p-2 text-center focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 cursor-pointer${
value
? ' bg-white text-black border-opacity-20 hover:border-opacity-40 truncate text-sm text-opacity-40'
: ' bg-indigo-500 text-white border-opacity-0 hover:bg-indigo-600 text-md'
}`}
>
{value ? (
<>
<PaperClipIcon size={18} className="flex-shrink-0 mr-2" />
{value.name}
</>
) : (
<>
<UploadIcon size={18} className="flex-shrink-0 mr-2" />
{'Datei auswählen...'}
</>
)}
<input
className="hidden"
type="file"
name={name}
accept="image/png, image/jpeg"
onChange={onChange}
{...inputProps}
/>
</label>
</>
);
};
export default FileInput;
import React from 'react';
import type { NamedProps } from 'react-select';
import CreatableSelect from '../CreatableSelect';
interface Props<OptionType, isMulti extends boolean> extends NamedProps<OptionType, isMulti> {
label: string;
name: string;
required?: boolean;
}
const SelectInput = <OptionType, isMulti extends boolean>({
name,
label,
value,
onChange,
...inputProps
}: Props<OptionType, isMulti>): JSX.Element => {
return (
<label className="block mb-4">
{!!label && (
<span className="form-label">
{label}
{inputProps?.required && `*`}
</span>
)}
<CreatableSelect
name={name}
value={value}
className="p-0 form-input form-input-custom"
onChange={onChange}
{...inputProps}
/>
</label>
);
};
export default SelectInput;
import React, { useEffect, useMemo, useState } from 'react';
import type { Tag as TagType } from 'src/types/PointOfInterest';
import { generateRandomHslColor } from '../../util/color';
import Tag from '../Tag';
interface Props {
label?: string;
tags: TagType[];
options: TagType[];
onTagsChange: (tags: TagType[]) => void;
required?: boolean;
}
const TagInput: React.FC<Props> = ({ label, tags, options, onTagsChange, required }: Props) => {
// All options
const [tagOptions, setTagOptions] = useState<TagType[]>([]);
// The text input
const [draftEntry, setDraftEntry] = useState('');
const [showOptions, setShowOptions] = useState(false);
// Only show options that start with current draftEntry value
const filteredTagOptions = useMemo(
() => tagOptions.filter((tagOption) => tagOption.displayName.toLowerCase().startsWith(draftEntry.toLowerCase())),
[tagOptions, draftEntry],
);
const handleKeyboardInput = (e: React.KeyboardEvent) => {
if (draftEntry === '') {
if (e.nativeEvent.key === 'Backspace') {
const tagsClone = JSON.parse(JSON.stringify(tags));
const removedTag = tagsClone.pop();
if (removedTag) {
onTagsChange(tagsClone);
}
}
} else {
if (e.nativeEvent.key === 'Enter') {
e.preventDefault();
// Check if a Tag with that name already exists
const allTagNames = tags.map((tag) => tag.displayName.toLowerCase());
const isDuplicate = allTagNames.includes(draftEntry.toLowerCase());
if (!isDuplicate) {
onTagsChange([...tags, { displayName: draftEntry, color: generateRandomHslColor(60, 80), id: 'draft' }]);
}
setDraftEntry('');
}
}
};
const handleClickDeleteTag = (tag: TagType) => {
onTagsChange(tags.filter((filterTag) => tag.displayName.toLowerCase() !== filterTag.displayName.toLowerCase()));
};
const handleSelectTag = (tag: TagType) => {
onTagsChange([...tags, tag]);
};
useEffect(() => {
setTagOptions(
options.filter(
(tagOption) => !tags.find((tag) => tag.displayName.toLowerCase() === tagOption.displayName.toLowerCase()),
),
);
}, [tags, options]);
return (
<label className="relative block mb-4">
{!!label && (
<span className="form-label">
{label}
{required && `*`}
</span>
)}
<div
tabIndex={0}
className={`${
showOptions && filteredTagOptions.length ? 'rounded-t-lg hover:border-opacity-40 ' : 'rounded-lg '
}flex w-full border-2 border-black border-opacity-20 focus-within:border-indigo-300 focus-within:ring focus-within:ring-indigo-200 focus-within:ring-opacity-50 input-chevron mt-1`}
>
<div className="flex flex-wrap items-center flex-1 p-2">
{tags.map((selectedTag) => (
<Tag
key={`selected${selectedTag.color}`}
onClickDelete={() => handleClickDeleteTag(selectedTag)}
color={selectedTag.color}
>
{selectedTag.displayName}
</Tag>
))}
<input
onKeyDown={handleKeyboardInput}
onChange={(e) => {
const newValue = e.target.value;
setDraftEntry(newValue);
}}
onFocus={() => setShowOptions(true)}
onBlur={() => {
setShowOptions(false);
}}
type={'text'}
value={draftEntry}
className={`form-input w-16 block p-0 flex-1 rounded-lg border-0 focus:outline-none focus:ring-0 ${
tags.length ? '' : 'px-3'
}`}
/>
</div>
</div>
{/* Options Dropdown */}
{filteredTagOptions.length > 0 && (
<ul
className={`${
showOptions && filteredTagOptions.length ? 'block ' : 'hidden '
}rounded-b-lg z-10 absolute w-full bg-white border-2 border-t-0 shadow-md`}
>
{filteredTagOptions.map((option) => {
return (
<li
key={`${option.id}`}
onMouseDown={() => {
handleSelectTag(option);
}}
className="w-full p-1 hover:bg-gray-100"
>
<Tag color={option.color}>{option.displayName}</Tag>
</li>
);
})}
</ul>
)}
</label>
);
};
export default TagInput;
import React from 'react';
interface Props {
label: string;
value: string;
name: string;
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
required?: boolean;
}
const TextAreaInput: React.FC<Props> = ({ name, label, value, onChange, ...textAreaProps }) => {
return (
<label className="block mb-4">
{!!label && (
<span className="form-label">
{label}
{textAreaProps?.required && `*`}
</span>
)}
<textarea
name={name}
value={value}
className="form-textarea form-input-custom"
rows={3}
onChange={onChange}
{...textAreaProps}
></textarea>
</label>
);
};
export default TextAreaInput;
import React from 'react';
interface Props {
label: string;
value: string;
name: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
required?: boolean;
type?: string;
}
const TextInput: React.FC<Props> = ({ name, label, value, onChange, type = 'text', ...inputProps }) => {
return (
<label className="block mb-4">
{!!label && (
<span className="form-label">
{label}
{inputProps?.required && `*`}
</span>
)}
<input
type={type}
name={name}
value={value}
className="form-input form-input-custom"
onChange={onChange}
{...inputProps}
/>
</label>
);
};
export default TextInput;
import type { LatLngTuple } from 'leaflet';
import { divIcon, DivIconOptions } from 'leaflet';
import React, { useEffect, useMemo, useState } from 'react';
import { MapContainer, Marker, TileLayer } from 'react-leaflet';
import { useStore } from '../../hooks';
import MapViewController from './MapViewController';
import { useFilteredPoiData } from '../../hooks/useFilteredPoiData';
import MapLayerControl from './MapLayerControl';
import { useHistory } from 'react-router-dom';
interface Props {
createMode?: boolean;
}
const DEFAULT_CENTER: LatLngTuple = [53.550359, 9.986701];
const iconProps: DivIconOptions = {
className: 'marker-red',
// Source: https://fontawesome.com/icons/map-marker-alt
html: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path fill="currentColor" d="M172.268 501.67C26.97 291.031 0 269.413 0 192 0 85.961 85.961 0 192 0s192 85.961 192 192c0 77.413-26.97 99.031-172.268 309.67-9.535 13.774-29.93 13.773-39.464 0zM192 272c44.183 0 80-35.817 80-80s-35.817-80-80-80-80 35.817-80 80 35.817 80 80 80z"/></svg>`,
iconSize: [24, 32],
iconAnchor: [12, 32],
};
export const Map: React.FC<Props> = ({ createMode }) => {
const icon = useMemo(() => divIcon(iconProps), [iconProps]);
const largeIcon = useMemo(() => divIcon({ ...iconProps, iconSize: [30, 40], iconAnchor: [15, 40] }), [iconProps]);
const greenLargeIcon = useMemo(
() => divIcon({ ...iconProps, iconSize: [30, 40], iconAnchor: [15, 40], className: 'marker-green' }),
[iconProps],
);
const { data } = useFilteredPoiData();
const draftPoi = useStore((state) => state.draftPoi);
const [mapBounds, setMapBounds] = useState<Array<LatLngTuple>>([DEFAULT_CENTER]);
const hoveredPoi = useStore((state) => state.hoveredPoi);
const setHoveredPoi = useStore((state) => state.setHoveredPoi);
const selectedPoi = useStore((state) => state.selectedPoi);
const history = useHistory();
const selectedLatlng: LatLngTuple | undefined = selectedPoi ? [selectedPoi?.lat, selectedPoi?.lng] : undefined;
useEffect(() => {
let newBounds = [DEFAULT_CENTER];
if (selectedPoi) {
newBounds = [[selectedPoi.lat, selectedPoi.lng] as LatLngTuple];
} else if (data?.length) {
newBounds = data?.map((poi) => [poi.lat, poi.lng] as LatLngTuple) || [];
}
setMapBounds(newBounds);
}, [JSON.stringify(data), selectedPoi]);
return (
<div className="relative h-full w-full z-0">
{!createMode && !selectedPoi && <MapLayerControl />}
<MapContainer id={'mapid'} className={'h-full w-full z-0'} scrollWheelZoom={true}>
<TileLayer
attribution='&copy; <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
url="https://api.mapbox.com/styles/v1/{id}/tiles/{z}/{x}/{y}?access_token={accessToken}"
id="mapbox/streets-v11"
tileSize={512}
accessToken={import.meta.env.SNOWPACK_PUBLIC_MAPBOX_TOKEN}
zoomOffset={-1}
maxZoom={18}
/>
<MapViewController bounds={mapBounds} createPoiMode={createMode} />
{/* Single marker when creating a new POI */}
{!!(createMode && draftPoi) && <Marker icon={greenLargeIcon} position={draftPoi} />}
{/* Single marker when POI is selected */}
{!!(selectedPoi && selectedLatlng && !createMode) && <Marker icon={largeIcon} position={selectedLatlng} />}
{/* Multiple markers, when no POI is selected */}
{!selectedPoi &&
!createMode &&
data?.map((poi) => {
const poiLatLng: LatLngTuple = [poi.lat, poi.lng];
return (
<Marker
icon={hoveredPoi?.id === poi.id ? largeIcon : icon}
opacity={hoveredPoi?.id === poi.id ? 1 : 0.7}
key={poi.id}
position={poiLatLng}
eventHandlers={{
click: () => {
history.push(`/poi/${String(poi.id)}`);
},
mouseover: () => {
setHoveredPoi(poi);
},
mouseout: () => {
setHoveredPoi(null);
},
}}
/>
);
})}
</MapContainer>
</div>
);
};
export default Map;
import React, { useState } from 'react';
import { useFilteredPoiData } from '../../hooks/useFilteredPoiData';
import { usePoiData } from '../../hooks';
import { ChevronDownOutline as DownIcon } from 'heroicons-react';
const MapLayerControl: React.FC = () => {
const { data } = usePoiData();
const { filterCategories, setFilterCategories } = useFilteredPoiData();
const layers = Array.from(new Set(data?.map((poi) => poi.category)));
const [isOpen, setIsOpen] = useState(false);
const onChangeCheckbox = (state: boolean, layer: string) => {
if (state) {
setFilterCategories([...filterCategories, layer]);
} else {
setFilterCategories(filterCategories.filter((cat) => cat !== layer));
}
};
return (
<div className="border-2 border-black w-52 border-opacity-20 rounded-lg absolute right-0 bottom-0 mb-4 mr-4 bg-white p-4 z-10">
<div className={`flex justify-between cursor-pointer ${isOpen ? 'mb-2' : ''}`} onClick={() => setIsOpen(!isOpen)}>
<h3 className="text-base font-semibold text-gray-900">Kategorien:</h3>
<DownIcon
className={`w-6 h-6 text-gray-400 transform transition duration-300 ${isOpen ? 'rotate-180' : 'rotate-0'}`}
/>
</div>
{isOpen &&
layers.map((layer) => {
return (
<div key={layer} className="my-2">
<label className="inline-flex items-center">
<input
type="checkbox"
className="rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-300 focus:ring focus:ring-offset-0 focus:ring-indigo-200 focus:ring-opacity-50"
checked={!!filterCategories?.find((cat) => cat === layer)}
onChange={(e) => onChangeCheckbox(e.target.checked, layer)}
/>
<span className="ml-2">{layer}</span>
</label>
</div>
);
})}
</div>
);
};
export default MapLayerControl;
import type { LatLngTuple } from 'leaflet';
import React, { useEffect } from 'react';
import { useMap, useMapEvent } from 'react-leaflet';
import { useStore } from '../../hooks';
interface Props {
bounds: Array<LatLngTuple>;
createPoiMode?: boolean;
}
const MapViewController: React.FC<Props> = ({ bounds, createPoiMode }) => {
const setDraftPoi = useStore((state) => state.setDraftPoi);
const map = useMap();
useMapEvent('click', (event) => {
if (createPoiMode) {
setDraftPoi([event.latlng.lat, event.latlng.lng]);
}
});
useEffect(() => {
map.fitBounds(bounds, { maxZoom: 16 });
}, [bounds]);
return <></>;
};
export default MapViewController;
import React from 'react';
interface Props {
title: string;
text: string;
icon?: JSX.Element;
}
const Modal: React.FC<Props> = ({ title, text, icon }) => {
return (
<div role="dialog" aria-modal="true" className="fixed z-10 inset-0 flex flex-column items-center justify-center">
<div className="fixed inset-0 bg-gray-400 bg-opacity-50" aria-hidden="true"></div>
<div className="inline-block z-10 bg-white rounded-lg text-left overflow-hidden shadow-xl sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div className="bg-white p-4 sm:p-6">
<div className="sm:flex sm:items-start">
{icon && (
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
{icon}
</div>
)}
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
{title}
</h3>
<div className="mt-2">
<p className="text-sm text-gray-500">{text}</p>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default Modal;
import React from 'react';
import { CheckCircleOutline as CheckIcon, X as XIcon, ExclamationOutline as AlertIcon } from 'heroicons-react';
import { useStore } from '../hooks';
const Notification: React.FC = () => {
const notification = useStore((state) => state.notification);
const setNotification = useStore((state) => state.setNotification);
const icon: Record<string, JSX.Element> = {
alert: <AlertIcon className="h-6 w-6 text-red-600" />,
success: <CheckIcon className="h-6 w-6 text-green-400" />,
};
return (
notification && (
<div
aria-live="assertive"
className="z-10 fixed inset-0 flex items-end px-4 py-6 pointer-events-none sm:p-6 sm:items-start"
>
<div className="w-full flex flex-col items-center space-y-4 sm:items-end">
<div className="max-w-sm w-full bg-white shadow-lg rounded-lg pointer-events-auto ring-1 ring-black ring-opacity-5 overflow-hidden">
<div className="p-4">
<div className="flex items-start">
<div className="flex-shrink-0">{icon[notification.type]}</div>
<div className="ml-3 w-0 flex-1 pt-0.5">
<p className="text-sm font-medium text-gray-900">{notification.title}</p>
<p className="mt-1 text-sm text-gray-500">{notification.text}</p>
</div>
<div className="ml-4 flex-shrink-0 flex">
<button
className="bg-white rounded-md inline-flex text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
onClick={() => setNotification(null)}
>
<span className="sr-only">Close</span>
<XIcon className="h-5 w-5" />
</button>
</div>
</div>
</div>
</div>
</div>
</div>
)
);
};
export default Notification;
import React, { useEffect } from 'react';
import { usePoiData, useStore } from '../hooks';
interface Props {
poiId: string | null;
}
const PoiLoader: React.FC<Props> = ({ poiId }) => {
const { data } = usePoiData();
const setSelectedPoi = useStore((state) => state.setSelectedPoi);
useEffect(() => {
if (data && poiId !== undefined) {
const poi = poiId ? data.find((poi) => String(poi.id) === poiId) : null;
if (poi !== undefined) setSelectedPoi(poi);
}
}, [data, poiId]);
return <></>;
};
export default PoiLoader;
import React from 'react';
import type { Theme } from 'react-select';
import ReactSelect, { NamedProps, StylesConfig, components } from 'react-select';
import Tag from './Tag';
const Option = ({ children, data, ...rest }: { children: any; data: any }) => {
return (
// @ts-expect-error: Not typed yet
<components.Option {...rest}>
<Tag color={data.value.color}>{children}</Tag>
</components.Option>
);
};
const Select = <OptionType, isMulti extends boolean>(props: NamedProps<OptionType, isMulti>): JSX.Element => {
const customStyles: StylesConfig<OptionType, isMulti> = {
control: (provided) => ({ ...provided, border: '0', borderRadius: '0.5em' }),
multiValue: (provided, state) => ({
...provided,
// @ts-expect-error: Not typed yet
backgroundColor: state?.data?.value?.color || 'grey',
borderRadius: '999px',
padding: '0 3px',
}),
multiValueRemove: (provided) => ({
...provided,
color: 'hsl(0, 0%, 50%)',
'&:hover': { backgroundColor: 'initial', color: 'black' },
}),
};
return (
<ReactSelect
components={{
Option,
}}
theme={(theme): Theme => ({
...theme,
// @ts-expect-error: ThemeConfig type from definitely-typed is not complete
borderRadius: '0.5em',
colors: {
...theme.colors,
primary25: '#C7D2FE',
},
})}
placeholder={'Tags auswählen...'}
styles={customStyles}
name="pois"
className="hover:border-opacity-40 rounded-lg w-full border-2 border-black border-opacity-20 focus-within:border-indigo-300 focus-within:ring focus-within:ring-indigo-200 focus-within:ring-opacity-50 mt-1"
{...props}
/>
);
};
export default Select;
import React, { SyntheticEvent } from 'react';
import { X as CloseIcon } from 'heroicons-react';
interface Props {
onClick: (event: SyntheticEvent) => void;
absolute?: boolean;
}
const CloseButton: React.FC<Props> = ({ onClick, absolute = false }) => {
return (
<CloseIcon
size={32}
className={`${
absolute ? 'absolute left-5 top-5' : ''
} p-1 text-gray-600 inline-block cursor-pointer bg-gray-200 bg-opacity-30 hover:bg-opacity-80 rounded-full`}
onClick={onClick}
/>
);
};
export default CloseButton;