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 (51)
Showing
with 16091 additions and 2888 deletions
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": { "scripts": {
"start": "snowpack dev", "start": "snowpack dev",
"build": "snowpack build", "build": "snowpack build && npm run copy:htaccess",
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx}\"", "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": { "dependencies": {
"@tailwindcss/forms": "^0.3.2",
"graphql": "^15.5.0", "graphql": "^15.5.0",
"graphql-request": "^3.4.0", "graphql-request": "^3.4.0",
"heroicons-react": "^1.3.0", "heroicons-react": "1.3.0",
"leaflet": "^1.7.1", "leaflet": "^1.7.1",
"react": "^17.0.0", "react": "^17.0.0",
"react-dom": "^17.0.0", "react-dom": "^17.0.0",
"react-leaflet": "^3.1.0", "react-leaflet": "^3.2.0",
"swr": "^0.5.5", "react-router-dom": "^5.2.0",
"tailwindcss": "^2.0.3" "react-select": "4.0.2",
"swr": "^0.5.6",
"tailwindcss": "^2.0.3",
"zustand": "^3.5.1"
}, },
"devDependencies": { "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-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-react-refresh": "^2.4.0",
"@snowpack/plugin-typescript": "^1.2.0", "@snowpack/plugin-typescript": "^1.2.0",
"@types/leaflet": "^1.7.0", "@types/leaflet": "^1.7.0",
"@types/react": "^17.0.0", "@types/react": "^17.0.6",
"@types/react-dom": "^17.0.0", "@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", "@types/snowpack-env": "^2.3.2",
"@typescript-eslint/eslint-plugin": "^4.24.0",
"@typescript-eslint/parser": "^4.24.0",
"autoprefixer": "^10.2.4", "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", "postcss-cli": "^8.3.1",
"prettier": "^2.0.5", "prettier": "^2.3.0",
"snowpack": "^3.0.1", "snowpack": "^3.5.0",
"typescript": "^4.0.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 @@ ...@@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Fab City Hamburg Map</title> <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 --> <!-- 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" /> <link rel="stylesheet" href="/leaflet.css" />
<script src="leaflet.js"></script> <script src="/leaflet.js"></script>
</head> </head>
<body class="h-full w-full"> <body class="h-full w-full">
<div id="root" class="h-full w-full"></div> <div id="root" class="h-full w-full"></div>
......
...@@ -5,15 +5,14 @@ module.exports = { ...@@ -5,15 +5,14 @@ module.exports = {
src: { url: '/dist' }, src: { url: '/dist' },
}, },
plugins: [ plugins: [
'@snowpack/plugin-react-refresh',
'@snowpack/plugin-dotenv', '@snowpack/plugin-dotenv',
'@snowpack/plugin-react-refresh',
'@snowpack/plugin-typescript', '@snowpack/plugin-typescript',
'@snowpack/plugin-postcss', '@snowpack/plugin-postcss',
'@snowpack/plugin-dotenv',
], ],
routes: [ routes: [
/* Enable an SPA Fallback in development: */ /* Enable an SPA Fallback in development: */
// {"match": "routes", "src": ".*", "dest": "/index.html"}, { match: 'routes', src: '.*', dest: '/index.html' },
], ],
optimize: { optimize: {
/* Example: Bundle your final build: */ /* Example: Bundle your final build: */
...@@ -23,7 +22,7 @@ module.exports = { ...@@ -23,7 +22,7 @@ module.exports = {
/* ... */ /* ... */
}, },
devOptions: { devOptions: {
/* ... */ tailwindConfig: './tailwind.config.js',
}, },
buildOptions: { buildOptions: {
/* ... */ /* ... */
......
import React, { useEffect, useState } from 'react';
import useSWR from 'swr';
import SidebarListView from './Sidebar/SidebarListView';
// import mockData from './testData.json';
import type { PointOfInterest } from './types/PointOfInterest';
import SidebarSingleView from './Sidebar/SidebarSingleView';
import Map from './Map/Map';
function App() {
const [poiData, setPoiData] = useState<PointOfInterest[]>([]);
const [selectedPoi, setSelectedPoi] = useState<null | PointOfInterest>(null);
const [hoveredPoiId, setHoveredPoiId] = useState<null | number>(null);
const handlePoiClick = (id: number) => {
const newPoi = poiData.find((poi) => poi.id === id);
newPoi && setSelectedPoi(newPoi);
};
const handlePoiClose = () => {
setSelectedPoi(null);
};
const handlePoiHoverOn = (poiId: number) => {
setHoveredPoiId(poiId);
};
const handlePoiHoverOff = () => {
setHoveredPoiId(null);
};
const { data, error } = useSWR(
`{
pois {
id
name
description
website
lat
lng
pathToBanner
}
}
`,
);
useEffect(() => {
data && console.log('Fetched new data', data);
data && data.pois && setPoiData(data.pois);
}, [data]);
useEffect(() => {
error && console.error('Error while fetching', error);
}, [error]);
return (
<div className={'flex md:flex-row-reverse flex-col h-full'}>
<Map
onMouseEnter={handlePoiHoverOn}
onMouseLeave={handlePoiHoverOff}
hoveredPoiId={hoveredPoiId}
values={poiData}
onSelect={handlePoiClick}
selectedEntry={selectedPoi}
/>
{selectedPoi ? (
<SidebarSingleView className="sidebar" value={selectedPoi} onClose={handlePoiClose} />
) : (
<SidebarListView
onMouseEnter={handlePoiHoverOn}
onMouseLeave={handlePoiHoverOff}
className="sidebar"
values={poiData}
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]);
const selectedLatlng: LatLngExpression | undefined = props.selectedEntry
? [props.selectedEntry?.lat, props.selectedEntry?.lng]
: undefined;
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={selectedLatlng ?? DEFAULT_CENTER} zoom={13} />
{/* Single marker when POI is selected */}
{!!(props.selectedEntry && selectedLatlng) && <Marker icon={largeIcon} position={selectedLatlng} />}
{/* Multiple markers, when no POI is selected */}
{!props.selectedEntry &&
props.values &&
props.values.map((poi) => {
const poiLatLng: LatLngExpression = [poi.lat, poi.lng];
return (
<Marker
icon={props.hoveredPoiId === poi.id ? largeIcon : icon}
opacity={props.hoveredPoiId === poi.id ? 1 : 0.7}
key={poi.id}
position={poiLatLng}
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 &&
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}>
<div className={`${value.pathToBanner ? '' : 'pl-5 pt-5'}`}>
<CloseIcon
size={32}
className={`${
value.pathToBanner ? '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}
/>
</div>
{value.pathToBanner && (
<img className="lg:h-48 md:h-36 w-full object-cover object-center" src={value.pathToBanner} alt="blog" />
)}
<div className="p-6">
<h2 className="tracking-widest text-xs title-font font-medium text-gray-400 mb-1">OFFENE WERKSTATT</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;
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;