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 (36)
Showing
with 14129 additions and 4174 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,
},
};
......@@ -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
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.
......@@ -3,8 +3,10 @@
"start": "snowpack dev",
"build": "snowpack build && npm run copy:htaccess",
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx}\"",
"lint": "prettier --check \"src/**/*.{js,jsx,ts,tsx}\"",
"copy:htaccess": "cp public/.htaccess build/"
"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": {
"@tailwindcss/forms": "^0.3.2",
......@@ -14,28 +16,38 @@
"leaflet": "^1.7.1",
"react": "^17.0.0",
"react-dom": "^17.0.0",
"react-leaflet": "^3.1.0",
"react-leaflet": "^3.2.0",
"react-router-dom": "^5.2.0",
"swr": "^0.5.5",
"react-select": "4.0.2",
"swr": "^0.5.6",
"tailwindcss": "^2.0.3",
"zustand": "^3.4.1"
"zustand": "^3.5.1"
},
"devDependencies": {
"@jadex/snowpack-plugin-tailwindcss-jit": "^0.2.0",
"@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"
}
}
......@@ -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,12 +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',
'@jadex/snowpack-plugin-tailwindcss-jit',
],
routes: [
/* Enable an SPA Fallback in development: */
......@@ -24,7 +22,7 @@ module.exports = {
/* ... */
},
devOptions: {
/* ... */
tailwindConfig: './tailwind.config.js',
},
buildOptions: {
/* ... */
......
......@@ -7,8 +7,9 @@ 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 = () => {
const App: React.FC = () => {
const selectedPoi = useStore((state) => state.selectedPoi);
return (
......@@ -16,7 +17,11 @@ const App = () => {
<ErrorModal />
<Notification />
<div className={'flex md:flex-row-reverse flex-col h-full'}>
<Route path="/add" children={({ match }) => <Map createMode={!!match} />} />
<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 />
......
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;
......@@ -3,9 +3,7 @@ import React from 'react';
import { useStore } from '../hooks';
import Modal from './Modal';
interface Props {}
const ErrorModal: React.FC<Props> = () => {
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" />,
......
import { gql } from 'graphql-request';
import React, { useEffect, useRef, useState } from 'react';
import type { PointOfInterestFormData } from '../../types/PointOfInterest';
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 { CheckCircleOutline as CheckIcon } from 'heroicons-react';
import { useStore } from '../../hooks';
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';
interface Props {}
type StringSelectOption = { label: string; value: string };
const AddPoiForm: React.FC<Props> = () => {
const AddPoiForm: React.FC = () => {
const [formData, setFormData] = useState<PointOfInterestFormData>({
lat: 0,
lng: 0,
......@@ -21,14 +27,20 @@ const AddPoiForm: React.FC<Props> = () => {
description: '',
website: '',
category: '',
relationStatus: '',
image: null,
tags: '',
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>) => {
......@@ -36,50 +48,51 @@ const AddPoiForm: React.FC<Props> = () => {
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e?.target?.files?.length) setFormData({ ...formData, [e.target.name]: e?.target?.files[0] });
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();
const mutation = gql`
mutation createPoiMutation(
$name: String!
$email: String!
$lat: Float!
$lng: Float!
$website: String
$description: String
$address: String!
$category: String!
$image: Upload!
) {
createPoi(
poi: {
name: $name
email: $email
lat: $lat
lng: $lng
website: $website
description: $description
address: $address
category: $category
image: $image
}
)
}
`;
setIsLoading(true);
try {
const res = await fetcher(mutation, formData);
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 auf den Link um den Eintrag zu verifizieren.',
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('/');
......@@ -87,7 +100,8 @@ const AddPoiForm: React.FC<Props> = () => {
setIsLoading(false);
throw new Error();
}
} catch {
} catch (error) {
console.error(error);
setIsLoading(false);
setNotification({
title: 'Fehler beim Hinzufügen',
......@@ -97,8 +111,28 @@ const AddPoiForm: React.FC<Props> = () => {
}
};
// Aggregate all tags from all POIs and remove duplicates
useEffect(() => {
// Validation
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) {
......@@ -114,66 +148,80 @@ const AddPoiForm: React.FC<Props> = () => {
}, [draftPoi]);
return (
<form className="flex flex-col" onSubmit={handleSubmit} ref={formRef}>
<p className="flex items-center leading-relaxed mb-5 text-gray-600">
<CheckIcon className={`${draftPoi ? 'text-indigo-500' : 'text-gray-400'} mr-2`} />
Bitte einen Pin auf der Karte setzen.
</p>
<TextInput label={'Name des Orts'} name={'name'} value={formData.name} onChange={handleInputChange} required />
<TextInput
label={'Kategorie'}
name={'category'}
value={formData.category}
onChange={handleInputChange}
required
/>
<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={handleInputChange}
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 />
<button
disabled={!isValid || isLoading}
type="submit"
className="mt-2 flex justify-center items-center text-white bg-indigo-500 border-0 py-2 px-6 focus:outline-none hover:bg-indigo-600 rounded-lg text-lg disabled:opacity-50 disabled:cursor-default disabled:hover:bg-indigo-500"
>
{isLoading && (
<svg
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
)}
<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>
......
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;
......@@ -17,13 +17,13 @@ const FileInput: React.FC<Props> = ({ name, label, value, onChange, ...inputProp
{inputProps?.required && `*`}
</span>
<label
className={`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${
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 ? (
{value ? (
<>
<PaperClipIcon size={18} className="flex-shrink-0 mr-2" />
{value.name}
......
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;
......@@ -20,7 +20,7 @@ const TextAreaInput: React.FC<Props> = ({ name, label, value, onChange, ...textA
<textarea
name={name}
value={value}
className="form-input"
className="form-textarea form-input-custom"
rows={3}
onChange={onChange}
{...textAreaProps}
......
import React, { MutableRefObject } from 'react';
import React from 'react';
interface Props {
label: string;
......@@ -7,10 +7,9 @@ interface Props {
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
required?: boolean;
type?: string;
ref?: MutableRefObject<HTMLInputElement>;
}
const TextInput: React.FC<Props> = ({ name, label, value, onChange, ref, type = 'text', ...inputProps }) => {
const TextInput: React.FC<Props> = ({ name, label, value, onChange, type = 'text', ...inputProps }) => {
return (
<label className="block mb-4">
{!!label && (
......@@ -20,11 +19,10 @@ const TextInput: React.FC<Props> = ({ name, label, value, onChange, ref, type =
</span>
)}
<input
ref={ref}
type={type}
name={name}
value={value}
className="form-input"
className="form-input form-input-custom"
onChange={onChange}
{...inputProps}
/>
......
import type { LatLngExpression, LatLngTuple } from 'leaflet';
import type { LatLngTuple } from 'leaflet';
import { divIcon, DivIconOptions } from 'leaflet';
import React, { useMemo } from 'react';
import { MapContainer, Marker, TileLayer, useMapEvent } from 'react-leaflet';
import { usePoiData, useStore } from '../../hooks';
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;
......@@ -25,61 +28,70 @@ export const Map: React.FC<Props> = ({ createMode }) => {
() => divIcon({ ...iconProps, iconSize: [30, 40], iconAnchor: [15, 40], className: 'marker-green' }),
[iconProps],
);
const { data } = usePoiData();
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 (
<MapContainer
id={'mapid'}
className={'h-full w-full z-0'}
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} 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: () => setSelectedPoi(poi),
mouseover: () => {
setHoveredPoi(poi);
},
mouseout: () => {
setHoveredPoi(null);
},
}}
/>
);
})}
</MapContainer>
<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>
);
};
......
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;