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 (52)
Showing with 16136 additions and 2707 deletions
SNOWPACK_PUBLIC_MAPBOX_TOKEN=abc123
SNOWPACK_PUBLIC_API_URL=http://localhost:8000/graphql
\ No newline at end of file
module.exports = {
env: {
browser: true,
es2021: true,
},
extends: ['eslint:recommended', 'plugin:react/recommended', 'plugin:@typescript-eslint/recommended', 'prettier'],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaFeatures: {
jsx: true,
},
ecmaVersion: 12,
sourceType: 'module',
},
plugins: ['react', '@typescript-eslint'],
rules: {
'react/prop-types': 0,
},
};
image: node:current-alpine3.13
stages:
- build
- deploy
before_script:
- apk update
- apk add --no-cache lftp openssh
- mkdir -p ~/.ssh
- echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
- echo "$FRONTEND_ENV" > .env
npm build:
stage: build
only:
- main
script:
- npm ci
- npm run build
artifacts:
paths:
- build
lftp deploy:
stage: 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/ /httpdocs/map; bye"
\ No newline at end of file
14
\ No newline at end of file
overwrite: true
schema: 'http://localhost:8000/graphql'
documents: 'src/graphql/**/*.ts'
generates:
src/generated/graphql.ts:
plugins:
- 'typescript'
- 'typescript-operations'
- 'typescript-graphql-request'
config:
useTypeImports: true
[[headers]]
for = "/*"
[headers.values]
Access-Control-Allow-Origin = "*"
\ No newline at end of file
This diff is collapsed.
{
"scripts": {
"start": "snowpack dev",
"build": "snowpack build",
"build": "snowpack build && npm run copy:htaccess",
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx}\"",
"lint": "prettier --check \"src/**/*.{js,jsx,ts,tsx}\""
"prettier:lint": "prettier --check \"src/**/*.{js,jsx,ts,tsx}\"",
"eslint": "eslint \"src/**/*.{js,jsx,ts,tsx}\"",
"copy:htaccess": "cp public/.htaccess build/",
"generate": "graphql-codegen --config codegen.yml"
},
"dependencies": {
"heroicons-react": "^1.3.0",
"@tailwindcss/forms": "^0.3.2",
"graphql": "^15.5.0",
"graphql-request": "^3.4.0",
"heroicons-react": "1.3.0",
"leaflet": "^1.7.1",
"react": "^17.0.0",
"react-dom": "^17.0.0",
"react-leaflet": "^3.1.0",
"tailwindcss": "^2.0.3"
"react-leaflet": "^3.2.0",
"react-router-dom": "^5.2.0",
"react-select": "4.0.2",
"swr": "^0.5.6",
"tailwindcss": "^2.0.3",
"zustand": "^3.5.1"
},
"devDependencies": {
"@graphql-codegen/cli": "1.21.4",
"@graphql-codegen/typescript": "1.22.0",
"@graphql-codegen/typescript-graphql-request": "^3.2.0",
"@graphql-codegen/typescript-operations": "1.17.16",
"@snowpack/plugin-dotenv": "^2.1.0",
"@snowpack/plugin-postcss": "^1.1.0",
"@snowpack/plugin-postcss": "^1.3.0",
"@snowpack/plugin-react-refresh": "^2.4.0",
"@snowpack/plugin-typescript": "^1.2.0",
"@types/leaflet": "^1.7.0",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"@types/react": "^17.0.6",
"@types/react-dom": "^17.0.5",
"@types/react-router-dom": "^5.1.7",
"@types/react-select": "^4.0.15",
"@types/snowpack-env": "^2.3.2",
"@typescript-eslint/eslint-plugin": "^4.24.0",
"@typescript-eslint/parser": "^4.24.0",
"autoprefixer": "^10.2.4",
"postcss": "^8.2.6",
"eslint": "^7.26.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-react": "^7.23.2",
"postcss": "^8.3.0",
"postcss-cli": "^8.3.1",
"prettier": "^2.0.5",
"snowpack": "^3.0.1",
"prettier": "^2.3.0",
"snowpack": "^3.5.0",
"typescript": "^4.0.0"
}
}
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{HTTPS} off [OR]
RewriteCond %{HTTP_HOST} ^www\. [NC]
RewriteCond %{HTTP_HOST} ^(?:www\.)?(.+)$ [NC]
RewriteRule ^ https://%1%{REQUEST_URI} [L,NE,R=301]
RewriteCond %{REQUEST_FILENAME} -s [OR]
RewriteCond %{REQUEST_FILENAME} -l [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^.*$ - [NC,L]
RewriteRule ^(.*) /index.html [NC,L]
</IfModule>
\ No newline at end of file
......@@ -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,15 +5,14 @@ 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: */
// {"match": "routes", "src": ".*", "dest": "/index.html"},
{ match: 'routes', src: '.*', dest: '/index.html' },
],
optimize: {
/* Example: Bundle your final build: */
......@@ -23,7 +22,7 @@ module.exports = {
/* ... */
},
devOptions: {
/* ... */
tailwindConfig: './tailwind.config.js',
},
buildOptions: {
/* ... */
......
import React, { useState } from 'react';
import SidebarListView from './Sidebar/SidebarListView';
import data from './testData.json';
import type { PointOfInterest } from './types/PointOfInterest';
import SidebarSingleView from './Sidebar/SidebarSingleView';
import Map from './Map/Map';
interface AppProps {}
function App({}: AppProps) {
const [selectedPoi, setSelectedPoi] = useState<null | PointOfInterest>(null);
const [hoveredPoiId, setHoveredPoiId] = useState<null | number>(null);
const handlePoiClick = (id: number) => {
const newPoi = (data as PointOfInterest[]).find((poi) => poi.id === id);
newPoi && setSelectedPoi(newPoi);
};
const handlePoiClose = () => {
setSelectedPoi(null);
};
const handlePoiHoverOn = (poiId: number) => {
setHoveredPoiId(poiId);
};
const handlePoiHoverOff = () => {
setHoveredPoiId(null);
};
return (
<div className={'flex md:flex-row-reverse flex-col h-full'}>
<Map
onMouseEnter={handlePoiHoverOn}
onMouseLeave={handlePoiHoverOff}
hoveredPoiId={hoveredPoiId}
values={data as PointOfInterest[]}
onSelect={handlePoiClick}
selectedEntry={selectedPoi}
/>
{selectedPoi ? (
<SidebarSingleView className="sidebar" value={selectedPoi} onClose={handlePoiClose} />
) : (
<SidebarListView
onMouseEnter={handlePoiHoverOn}
onMouseLeave={handlePoiHoverOff}
className="sidebar"
values={data as PointOfInterest[]}
onClick={handlePoiClick}
/>
)}
</div>
);
}
export default App;
import { MapContainer, Marker, TileLayer } from 'react-leaflet';
import React, { useMemo } from 'react';
import { divIcon, DivIconOptions } from 'leaflet';
import type { PointOfInterest } from '../types/PointOfInterest';
import type { LatLngExpression } from 'leaflet';
import MapViewController from './MapViewController';
interface Props {
values: PointOfInterest[];
onSelect: (id: number) => void;
selectedEntry?: PointOfInterest | null;
hoveredPoiId?: number | null;
onMouseEnter?: (id: number) => void;
onMouseLeave?: () => void;
}
const DEFAULT_CENTER: LatLngExpression = [53.550359, 9.986701];
export const Map: React.FC<Props> = (props) => {
const iconProps: DivIconOptions = {
className: 'marker',
// 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],
};
const icon = useMemo(() => divIcon(iconProps), [iconProps]);
const largeIcon = useMemo(() => divIcon({ ...iconProps, iconSize: [30, 40], iconAnchor: [15, 40] }), [iconProps]);
return (
<MapContainer
id={'mapid'}
className={'h-full w-full'}
style={{ flex: 3 }}
center={DEFAULT_CENTER}
zoom={13}
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 center={props.selectedEntry?.latlng ?? DEFAULT_CENTER} zoom={13} />
{/* Single marker when POI is selected */}
{!!props.selectedEntry && <Marker icon={largeIcon} position={props.selectedEntry.latlng} />}
{/* Multiple markers, when no POI is selected */}
{!props.selectedEntry &&
props.values.map((poi) => (
<Marker
icon={props.hoveredPoiId === poi.id ? largeIcon : icon}
opacity={props.hoveredPoiId === poi.id ? 1 : 0.7}
key={poi.id}
position={poi.latlng}
eventHandlers={{
click: () => props.onSelect(poi.id),
mouseover: () => {
props.onMouseEnter && props.onMouseEnter(poi.id);
},
mouseout: () => {
props.onMouseLeave && props.onMouseLeave();
},
}}
/>
))}
</MapContainer>
);
};
export default Map;
import type { LatLngExpression } from 'leaflet';
import React, { useEffect } from 'react';
import { useMap } from 'react-leaflet';
interface Props {
center: LatLngExpression;
zoom: number;
}
const MapViewController: React.FC<Props> = ({ center, zoom }) => {
const map = useMap();
useEffect(() => {
map.setView(center, zoom);
}, [center, zoom]);
return <></>;
};
export default MapViewController;
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;
className?: string;
}
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, LocationMarkerOutline as AddressIcon } from 'heroicons-react';
interface Props {
style?: CSSProperties;
value: PointOfInterest;
onClose?: () => void;
className?: string;
}
const SidebarSingleView: React.FC<Props> = ({ value, onClose, className, ...restProps }) => {
const strippedUrl = value?.website?.replace(/(^\w+:|^)\/\//, '');
return (
<SidebarContainer className={`relative p-0 ${className || ''}`} {...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 h-24 w-full object-cover object-center" src={value.image} 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-6">{value.description}</p>
{value.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={value.website}>
{strippedUrl}
</a>
</div>
)}
{value.address && (
<div className={'flex items-center mt-3'}>
<AddressIcon size={18} className={'text-gray-500 mr-2'} />
<div className="text-sm text-gray-500">{value.address}</div>
</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;