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
Commits on Source (22)
Showing
with 11449 additions and 1696 deletions
......@@ -28,4 +28,4 @@ lftp deploy:
only:
- main
script:
- lftp -e "set net:timeout 5; set net:max-retries 3; set net:reconnect-interval-base 5; open sftp://$SFTP_HOST; user $SFTP_USER $SFTP_PASSWORD; mirror -X .* -X .*/ --reverse --verbose build/ live/; bye"
- lftp -e "set net:timeout 5; set net:max-retries 3; set net:reconnect-interval-base 5; open sftp://$SFTP_HOST; user $SFTP_USER $SFTP_PASSWORD; mirror -X .* -X .*/ --reverse --verbose build/ /httpdocs/map; bye"
\ No newline at end of file
This diff is collapsed.
......@@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Fab City Hamburg Map</title>
<!-- TODO: This should be imported from the leaflet npm package, not manually from the public folder. See https://github.com/snowpackjs/snowpack/discussions/1716 -->
<link rel="stylesheet" href="leaflet.css" />
<script src="leaflet.js"></script>
<link rel="stylesheet" href="/leaflet.css" />
<script src="/leaflet.js"></script>
</head>
<body class="h-full w-full">
<div id="root" class="h-full w-full"></div>
......
......@@ -5,11 +5,10 @@ module.exports = {
src: { url: '/dist' },
},
plugins: [
'@snowpack/plugin-react-refresh',
'@snowpack/plugin-dotenv',
'@snowpack/plugin-react-refresh',
'@snowpack/plugin-typescript',
'@snowpack/plugin-postcss',
'@snowpack/plugin-dotenv',
],
routes: [
/* Enable an SPA Fallback in development: */
......
......@@ -7,6 +7,7 @@ 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);
......@@ -17,6 +18,10 @@ const App: React.FC = () => {
<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 />
......
......@@ -5,7 +5,12 @@ 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' }),
control: (provided) => ({
...provided,
border: '0',
borderRadius: '0.5em',
boxShadow: 'none',
}),
multiValue: (provided) => ({ ...provided, borderRadius: '999px', padding: '0 3px' }),
multiValueRemove: (provided) => ({
...provided,
......
......@@ -15,7 +15,7 @@ import Spinner from '../Spinner';
import type { CreatePoiMutationMutationVariables, Mutation } from '../../generated/graphql';
import SelectInput from './SelectInput';
type RelationStatusOption = { label: string; value: string };
type StringSelectOption = { label: string; value: string };
const AddPoiForm: React.FC = () => {
const [formData, setFormData] = useState<PointOfInterestFormData>({
......@@ -38,7 +38,8 @@ const AddPoiForm: React.FC = () => {
const history = useHistory();
const { data } = usePoiData();
const [tagOptions, setTagOptions] = useState<Tag[]>([]);
const [relationStatusOptions, setRelationStatusOptions] = useState<RelationStatusOption[]>([]);
const [categoryOptions, setCatgeoryOptions] = useState<StringSelectOption[]>([]);
const [relationStatusOptions, setRelationStatusOptions] = useState<StringSelectOption[]>([]);
const [selectedTags, setSelectedTags] = useState<Tag[]>([]);
const setNotification = useStore((state) => state.setNotification);
......@@ -123,6 +124,10 @@ const AddPoiForm: React.FC = () => {
.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]);
......@@ -153,21 +158,29 @@ const AddPoiForm: React.FC = () => {
/>
<TagInput label={'Tags'} tags={selectedTags} options={tagOptions} onTagsChange={setSelectedTags} />
<TextInput label={'Name des Orts'} name={'name'} value={formData.name} onChange={handleInputChange} required />
<TextInput
<SelectInput
label={'Kategorie'}
name={'category'}
value={formData.category}
onChange={handleInputChange}
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 RelationStatusOption).value : '',
relationStatus: selectedOption ? (selectedOption as StringSelectOption).value : '',
}))
}
/>
......
......@@ -26,7 +26,7 @@ const SelectInput = <OptionType, isMulti extends boolean>({
<CreatableSelect
name={name}
value={value}
className="form-input form-input-custom"
className="p-0 form-input form-input-custom"
onChange={onChange}
{...inputProps}
/>
......
......@@ -80,7 +80,7 @@ const TagInput: React.FC<Props> = ({ label, tags, options, onTagsChange, require
<div className="flex flex-wrap items-center flex-1 p-2">
{tags.map((selectedTag) => (
<Tag
key={`selected${selectedTag.id}`}
key={`selected${selectedTag.color}`}
onClickDelete={() => handleClickDeleteTag(selectedTag)}
color={selectedTag.color}
>
......
import type { LatLngTuple } from 'leaflet';
import { divIcon, DivIconOptions } from 'leaflet';
import React, { useMemo } from 'react';
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;
......@@ -29,22 +30,27 @@ export const Map: React.FC<Props> = ({ createMode }) => {
);
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 setSelectedPoi = useStore((state) => state.setSelectedPoi);
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">
<MapLayerControl />
<MapContainer
id={'mapid'}
className={'h-full w-full z-0'}
center={DEFAULT_CENTER}
zoom={13}
scrollWheelZoom={true}
>
{!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}"
......@@ -54,7 +60,7 @@ export const Map: React.FC<Props> = ({ createMode }) => {
zoomOffset={-1}
maxZoom={18}
/>
<MapViewController center={selectedLatlng ?? DEFAULT_CENTER} zoom={13} createPoiMode={createMode} />
<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 */}
......@@ -71,7 +77,9 @@ export const Map: React.FC<Props> = ({ createMode }) => {
key={poi.id}
position={poiLatLng}
eventHandlers={{
click: () => setSelectedPoi(poi),
click: () => {
history.push(`/poi/${String(poi.id)}`);
},
mouseover: () => {
setHoveredPoi(poi);
},
......
import React from 'react';
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) {
......@@ -16,22 +18,29 @@ const MapLayerControl: React.FC = () => {
};
return (
<div className="border-2 border-black border-opacity-20 rounded-lg absolute right-0 bottom-0 mb-4 mr-4 bg-white p-4 z-10">
{layers.map((layer) => {
return (
<div key={layer}>
<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 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>
);
};
......
......@@ -4,12 +4,11 @@ import { useMap, useMapEvent } from 'react-leaflet';
import { useStore } from '../../hooks';
interface Props {
center: LatLngTuple;
zoom: number;
bounds: Array<LatLngTuple>;
createPoiMode?: boolean;
}
const MapViewController: React.FC<Props> = ({ center, zoom, createPoiMode }) => {
const MapViewController: React.FC<Props> = ({ bounds, createPoiMode }) => {
const setDraftPoi = useStore((state) => state.setDraftPoi);
const map = useMap();
......@@ -20,8 +19,8 @@ const MapViewController: React.FC<Props> = ({ center, zoom, createPoiMode }) =>
});
useEffect(() => {
map.setView(center, zoom);
}, [center, zoom]);
map.fitBounds(bounds, { maxZoom: 16 });
}, [bounds]);
return <></>;
};
......
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 } 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) => ({ ...provided, borderRadius: '999px', padding: '0 3px' }),
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%)',
......@@ -15,6 +31,9 @@ const Select = <OptionType, isMulti extends boolean>(props: NamedProps<OptionTyp
return (
<ReactSelect
components={{
Option,
}}
theme={(theme): Theme => ({
...theme,
// @ts-expect-error: ThemeConfig type from definitely-typed is not complete
......@@ -24,6 +43,7 @@ const Select = <OptionType, isMulti extends boolean>(props: NamedProps<OptionTyp
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"
......
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;
......@@ -16,7 +16,7 @@ const ListElement: React.FC<Props> = ({ value, hovered, ...restProps }) => {
{...restProps}
className={`border-2 border-black border-opacity-20 ${
hovered ? 'border-opacity-40' : 'hover:border-opacity-40'
} cursor-pointer rounded-lg md:overflow-hidden mx-4 my-2`}
} cursor-pointer rounded-lg mx-4 my-2`}
>
<div className="p-3">
<h2 className="tracking-widest text-xs uppercase title-font font-medium text-gray-400 mb-1">
......
import { X as CloseIcon } from 'heroicons-react';
import React from 'react';
import { useHistory } from 'react-router-dom';
import AddPoiForm from '../Form/AddPoiForm';
import CloseButton from './CloseButton';
import SidebarContainer from './SidebarContainer';
const SidebarCreateView: React.FC = () => {
const history = useHistory();
return (
<SidebarContainer className="p-5">
<CloseIcon
size={32}
className={`flex-shrink-0 left-5 top-5 p-1 text-gray-500 inline-block cursor-pointer hover:bg-gray-300 hover:bg-opacity-50 rounded-full`}
onClick={() => history.push('/')}
/>
<CloseButton onClick={() => history.push('/')} />
<h1 className="text-xl font-medium title-font text-gray-900 mt-2 mb-4">Neuen Ort anlegen:</h1>
<AddPoiForm />
</SidebarContainer>
......
import React from 'react';
import React, { useState } from 'react';
import { usePoiData, useStore } from '../../hooks';
import ListElement from './ListElement';
import SidebarContainer from './SidebarContainer';
import { removeDuplicateObjects } from '../../util/array';
import { useFilteredPoiData } from '../../hooks/useFilteredPoiData';
import type { Tag } from '../../types/PointOfInterest';
import { FilterOutline as FilterIcon } from 'heroicons-react';
import { useHistory } from 'react-router-dom';
import Select from '../Select';
const SidebarListView: React.FC = () => {
const tagsToSelectOptions = (tags?: Tag[]) => tags?.map((tag) => ({ label: tag.displayName, value: tag }));
const history = useHistory();
const { data } = usePoiData();
const { data: filteredData, filterTags, setFilterTags } = useFilteredPoiData();
const hoveredPoi = useStore((state) => state.hoveredPoi);
const setHoveredPoi = useStore((state) => state.setHoveredPoi);
const setSelectedPoi = useStore((state) => state.setSelectedPoi);
const [filterInputIsOpen, setFilterInputIsOpen] = useState(false);
const tags =
data &&
......@@ -26,21 +29,35 @@ const SidebarListView: React.FC = () => {
return (
<SidebarContainer>
<div className="p-4">
<Select
options={options}
isMulti={true}
value={filterTags && tagsToSelectOptions(filterTags)}
onChange={(selectedOptions) => setFilterTags(selectedOptions.map((opt) => opt.value))}
/>
<div className="flex-col m-4 mb-2 pb-2 border-black border-opacity-20 border-b-2">
<div className="flex justify-between items-center">
<h1 className="text-xl font-medium title-font text-gray-900">{filteredData?.length} Orte:</h1>
<FilterIcon
onClick={() => setFilterInputIsOpen(!filterInputIsOpen)}
className="text-black text-opacity-20 hover:text-opacity-60 w-5 h-5 cursor-pointer"
/>
</div>
{filterInputIsOpen && (
<div className="py-2">
<Select
options={options}
isMulti={true}
value={filterTags && tagsToSelectOptions(filterTags)}
onChange={(selectedOptions) => setFilterTags(selectedOptions.map((opt) => opt.value))}
/>
</div>
)}
</div>
<h1 className="text-xl font-medium title-font m-4 text-gray-900 mb-2">{filteredData?.length} Orte:</h1>
{filteredData?.map((poi) => (
<ListElement
key={poi.id}
onMouseEnter={() => setHoveredPoi(poi)}
onMouseLeave={() => setHoveredPoi(null)}
onClick={() => setSelectedPoi(poi)}
onClick={() => {
history.push(`/poi/${String(poi.id)}`);
}}
value={poi}
hovered={hoveredPoi?.id === poi.id}
/>
......
import { HomeOutline as HomeIcon, LocationMarkerOutline as AddressIcon, X as CloseIcon } from 'heroicons-react';
import {
HomeOutline as HomeIcon,
LocationMarkerOutline as AddressIcon,
UserGroupOutline as RealtionStatusIcon,
} from 'heroicons-react';
import React from 'react';
import { useStore } from '../../hooks';
import SidebarContainer from './SidebarContainer';
import Tag from '../Tag';
import CloseButton from './CloseButton';
import { useHistory } from 'react-router-dom';
const SidebarSingleView: React.FC = () => {
const selectedPoi = useStore((state) => state.selectedPoi);
const setSelectedPoi = useStore((state) => state.setSelectedPoi);
const strippedUrl = selectedPoi?.website?.replace(/(^\w+:|^)\/\//, '');
const history = useHistory();
return (
<SidebarContainer className={`relative p-0`}>
<div className={`${selectedPoi?.image ? '' : 'pl-5 pt-5 '}`}>
<CloseIcon
size={32}
className={`${
selectedPoi?.image ? '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={() => setSelectedPoi(null)}
<CloseButton
absolute
onClick={() => {
history.push('/');
}}
/>
</div>
{selectedPoi?.image && (
......@@ -36,7 +41,12 @@ const SidebarSingleView: React.FC = () => {
{selectedPoi?.website && (
<div className={'flex items-center'}>
<HomeIcon size={18} className={'text-gray-500 mr-2'} />
<a className={'text-sm text-gray-500 hover:underline'} href={selectedPoi?.website}>
<a
target="_blank"
rel="noopener noreferrer"
className={'text-sm text-gray-500 hover:underline'}
href={selectedPoi?.website}
>
{strippedUrl}
</a>
</div>
......@@ -47,6 +57,12 @@ const SidebarSingleView: React.FC = () => {
<div className="text-sm text-gray-500">{selectedPoi?.address}</div>
</div>
)}
{selectedPoi?.relationStatus && (
<div className={'flex items-center mt-3'}>
<RealtionStatusIcon size={18} className={'text-gray-500 mr-2'} />
<div className="text-sm text-gray-500">{selectedPoi?.relationStatus}</div>
</div>
)}
{!!selectedPoi?.tags?.length && (
<div className={'flex items-center mt-3 flex-wrap'}>
{selectedPoi?.tags.map((tag) => (
......
export function generateRandomHslColor(saturation = 100, lightness = 50): string {
const randomHue = getRandomIntInclusive(0, 360);
const color = `hsl(${randomHue},${saturation}%,${lightness}%)`;
export function generateRandomHslColor(saturation = 100, lightness = 50, distinctValues = 20): string {
const randomColorIndex = getRandomIntInclusive(0, distinctValues);
const color = `hsl(${Math.floor(randomColorIndex * (360 / distinctValues))},${saturation}%,${lightness}%)`;
return color;
}
......