Skip to content
Snippets Groups Projects
Commit 5ac36230 authored by Moritz Stückler's avatar Moritz Stückler :cowboy:
Browse files

Feat/add poi form

parent 804bcc17
No related branches found
No related tags found
No related merge requests found
Showing
with 875 additions and 186 deletions
......@@ -9,7 +9,7 @@ before_script:
- apk add --no-cache lftp openssh
- mkdir -p ~/.ssh
- echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
- echo "$FRONTEND_ENV" >> .env
- echo "$FRONTEND_ENV" > .env
npm build:
stage: build
......
This diff is collapsed.
......@@ -6,6 +6,7 @@
"lint": "prettier --check \"src/**/*.{js,jsx,ts,tsx}\""
},
"dependencies": {
"@tailwindcss/forms": "^0.3.2",
"graphql": "^15.5.0",
"graphql-request": "^3.4.0",
"heroicons-react": "1.3.0",
......@@ -13,10 +14,13 @@
"react": "^17.0.0",
"react-dom": "^17.0.0",
"react-leaflet": "^3.1.0",
"react-router-dom": "^5.2.0",
"swr": "^0.5.5",
"tailwindcss": "^2.0.3"
"tailwindcss": "^2.0.3",
"zustand": "^3.4.1"
},
"devDependencies": {
"@jadex/snowpack-plugin-tailwindcss-jit": "^0.2.0",
"@snowpack/plugin-dotenv": "^2.1.0",
"@snowpack/plugin-postcss": "^1.1.0",
"@snowpack/plugin-react-refresh": "^2.4.0",
......@@ -24,6 +28,7 @@
"@types/leaflet": "^1.7.0",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"@types/react-router-dom": "^5.1.7",
"@types/snowpack-env": "^2.3.2",
"autoprefixer": "^10.2.4",
"postcss": "^8.2.6",
......
......@@ -10,10 +10,11 @@ module.exports = {
'@snowpack/plugin-typescript',
'@snowpack/plugin-postcss',
'@snowpack/plugin-dotenv',
'@jadex/snowpack-plugin-tailwindcss-jit',
],
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: */
......
import React, { useEffect, useState } from 'react';
import useSWR from 'swr';
import SidebarListView from './Sidebar/SidebarListView';
import type { PointOfInterest } from './types/PointOfInterest';
import SidebarSingleView from './Sidebar/SidebarSingleView';
import Map from './Map/Map';
import Modal from './Modal';
import { ExclamationOutline as AlertIcon } from 'heroicons-react';
function App() {
const [poiData, setPoiData] = useState<PointOfInterest[]>([]);
const [selectedPoi, setSelectedPoi] = useState<null | PointOfInterest>(null);
const [hoveredPoiId, setHoveredPoiId] = useState<null | number>(null);
const [showErrorModal, setShowErrorModal] = useState<boolean>(false);
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
address
lat
lng
image
category
}
}
`,
);
useEffect(() => {
data && console.log('Fetched new data', data);
data?.pois && setPoiData(data.pois);
}, [data]);
useEffect(() => {
if (error) {
console.error('Error while fetching', error);
setShowErrorModal(true);
}
}, [error]);
return (
<>
{showErrorModal && (
<Modal
title="API nicht erreichbar"
text="Kann die API nicht erreichen. Bitte später erneut probieren."
icon={<AlertIcon className="h-6 w-6 text-red-600" />}
/>
)}
<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
hoveredPoiId={hoveredPoiId}
onMouseEnter={handlePoiHoverOn}
onMouseLeave={handlePoiHoverOff}
className="sidebar"
values={poiData}
onClick={handlePoiClick}
/>
)}
</div>
</>
);
}
export default App;
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;
hoveredPoiId?: number | null;
}
const SidebarListView: React.FC<Props> = ({
values,
onMouseEnter,
onMouseLeave,
onClick,
hoveredPoiId,
...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}
hovered={hoveredPoiId === poi.id}
/>
))}
</SidebarContainer>
)
);
};
export default SidebarListView;
import React from 'react';
import { Route, Switch } from 'react-router-dom';
import { useStore } from '../hooks';
import ErrorModal from './ErrorModal';
import Map from './Map/Map';
import SidebarCreateView from './Sidebar/SidebarCreateView';
import SidebarListView from './Sidebar/SidebarListView';
import SidebarSingleView from './Sidebar/SidebarSingleView';
const App = () => {
const selectedPoi = useStore((state) => state.selectedPoi);
return (
<>
<ErrorModal />
<div className={'flex md:flex-row-reverse flex-col h-full'}>
<Route path="/add" children={({ match }) => <Map hideAllPois={!!match} />} />
<Switch>
<Route exact path="/add">
<SidebarCreateView />
</Route>
<Route>{selectedPoi ? <SidebarSingleView /> : <SidebarListView />}</Route>
</Switch>
</div>
</>
);
};
export default App;
import { ExclamationOutline as AlertIcon } from 'heroicons-react';
import React from 'react';
import { useStore } from '../hooks';
import Modal from './Modal';
interface Props {}
const ErrorModal: React.FC<Props> = () => {
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 { 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 { divIcon, DivIconOptions } from 'leaflet';
import React, { useMemo } from 'react';
import { MapContainer, Marker, TileLayer } from 'react-leaflet';
import { usePoiData, useStore } from '../../hooks';
import MapViewController from './MapViewController';
interface Props {
values: PointOfInterest[];
onSelect: (id: number) => void;
selectedEntry?: PointOfInterest | null;
hoveredPoiId?: number | null;
onMouseEnter?: (id: number) => void;
onMouseLeave?: () => void;
hideAllPois?: boolean;
}
const DEFAULT_CENTER: LatLngExpression = [53.550359, 9.986701];
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],
};
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],
};
export const Map: React.FC<Props> = ({ hideAllPois = false }) => {
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;
const { data } = usePoiData();
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 selectedLatlng: LatLngExpression | undefined = selectedPoi ? [selectedPoi?.lat, selectedPoi?.lng] : undefined;
return (
<MapContainer
......@@ -50,25 +49,25 @@ export const Map: React.FC<Props> = (props) => {
/>
<MapViewController center={selectedLatlng ?? DEFAULT_CENTER} zoom={13} />
{/* Single marker when POI is selected */}
{!!(props.selectedEntry && selectedLatlng) && <Marker icon={largeIcon} position={selectedLatlng} />}
{!!(selectedPoi && selectedLatlng && !hideAllPois) && <Marker icon={largeIcon} position={selectedLatlng} />}
{/* Multiple markers, when no POI is selected */}
{!props.selectedEntry &&
props.values &&
props.values.map((poi) => {
{!selectedPoi &&
!hideAllPois &&
data?.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}
icon={hoveredPoi?.id === poi.id ? largeIcon : icon}
opacity={hoveredPoi?.id === poi.id ? 1 : 0.7}
key={poi.id}
position={poiLatLng}
eventHandlers={{
click: () => props.onSelect(poi.id),
click: () => setSelectedPoi(poi),
mouseover: () => {
props.onMouseEnter && props.onMouseEnter(poi.id);
setHoveredPoi(poi);
},
mouseout: () => {
props.onMouseLeave && props.onMouseLeave();
setHoveredPoi(null);
},
}}
/>
......
File moved
import { GraphQLClient, request, gql } from 'graphql-request';
import React, { useEffect, useState } from 'react';
interface Props {}
const AddPoiForm: React.FC<Props> = () => {
const [formData, setFormData] = useState({
lat: 123,
lng: 123,
name: '',
address: '',
description: '',
website: '',
category: '',
image: '',
tags: '',
});
useEffect(() => {
console.log('Form data', formData);
}, [formData]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setFormData({ ...formData, [e.target.name]: e.target.value });
};
// const mutation = gql`
// mutation createPoiMutation(
// $name: String!
// $email: String!
// $lat: Float!
// $lng: Float!
// $website: String
// $description: String
// $address: String!
// $category: String!
// ) {
// createPoi(
// poi: {
// name: $name
// email: $email
// lat: $lat
// lng: $lng
// website: $website
// description: $description
// address: $address
// category: $category
// image: "ababababa"
// }
// )
// }
// `;
// const variables = {
// name: 'Inception',
// email: 2010,
// file: document.querySelector('input#avatar').files[0]
// };
// const data = await request(import.meta.env.SNOWPACK_PUBLIC_API_URL, mutation, formData);
return (
<form className="flex flex-col">
<p className="leading-relaxed mb-5 text-gray-600">Bitte auf der Karte einen Pin setzen.</p>
<label className="block mb-4">
<span className="form-label">Name</span>
<input
type="text"
name="name"
value={formData.name}
className="form-input"
placeholder="Musterspace"
onChange={handleInputChange}
/>
</label>
<label className="block mb-4">
<span className="form-label">Adresse</span>
<input
type="text"
name="address"
value={formData.address}
className="form-input"
placeholder=""
onChange={handleInputChange}
/>
</label>
<label className="block mb-4">
<span className="form-label">Webseite</span>
<input
type="text"
name="website"
value={formData.website}
className="form-input"
placeholder=""
onChange={handleInputChange}
/>
</label>
<label className="block mb-4">
<span className="form-label">Beschreibung</span>
<textarea
name="description"
value={formData.description}
className="form-input"
rows={3}
onChange={handleInputChange}
></textarea>
</label>
<label className="block mb-6">
<span className="form-label">Bild</span>
<input
className="block w-full focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
type="file"
id="avatar"
name="avatar"
accept="image/png, image/jpeg"
></input>
</label>
<button
type="submit"
className="text-white bg-indigo-500 border-0 py-2 px-6 focus:outline-none hover:bg-indigo-600 rounded-lg text-lg"
>
Hinzufügen
</button>
</form>
);
};
export default AddPoiForm;
import React from 'react';
import React, { SyntheticEvent } from 'react';
import type { PointOfInterest } from 'src/types/PointOfInterest';
interface Props {
value: PointOfInterest;
hovered?: boolean;
onMouseEnter: (event: SyntheticEvent) => void;
onMouseLeave: (event: SyntheticEvent) => void;
onClick: (event: SyntheticEvent) => void;
}
const SidebarListElement: React.FC<Props> = ({ value, hovered, ...restProps }) => {
const ListElement: React.FC<Props> = ({ value, hovered, ...restProps }) => {
return (
value && (
<div
......@@ -26,4 +29,4 @@ const SidebarListElement: React.FC<Props> = ({ value, hovered, ...restProps }) =
);
};
export default SidebarListElement;
export default ListElement;
import React, { CSSProperties } from 'react';
import React from 'react';
interface Props {
style?: CSSProperties;
className?: string;
}
const SidebarContainer: React.FC<Props> = ({ style, className, children }) => {
const SidebarContainer: React.FC<Props> = ({ className, children }) => {
return (
<div
style={style}
className={`flex flex-col shadow-2xl border-t-2 md:border-r-2 md:border-t-0 border-black border-opacity-20 ${
<aside
className={`sidebar box-border flex flex-col shadow-2xl border-t-2 md:border-r-2 md:border-t-0 border-black border-opacity-20 ${
className ?? ''
}`}
>
{children}
</div>
</aside>
);
};
......
import { X as CloseIcon } from 'heroicons-react';
import React from 'react';
import { useHistory } from 'react-router-dom';
import AddPoiForm from './AddPoiForm';
import SidebarContainer from './SidebarContainer';
interface Props {}
const SidebarCreateView: React.FC<Props> = () => {
const history = useHistory();
return (
<SidebarContainer className="p-5">
<CloseIcon
size={32}
className={`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('/')}
/>
<h1 className="text-xl font-medium title-font text-gray-900 my-2">Ort anlegen:</h1>
<AddPoiForm />
</SidebarContainer>
);
};
export default SidebarCreateView;
import React from 'react';
import { usePoiData, useStore } from '../../hooks';
import ListElement from './ListElement';
import SidebarContainer from './SidebarContainer';
interface Props {}
const SidebarListView: React.FC<Props> = () => {
const { data } = usePoiData();
const hoveredPoi = useStore((state) => state.hoveredPoi);
const setHoveredPoi = useStore((state) => state.setHoveredPoi);
const setSelectedPoi = useStore((state) => state.setSelectedPoi);
return (
<SidebarContainer>
<h1 className="text-xl font-medium title-font m-4 text-gray-900 mb-2">{data?.length} Orte:</h1>
{data?.map((poi) => (
<ListElement
key={poi.id}
onMouseEnter={() => setHoveredPoi(poi)}
onMouseLeave={() => setHoveredPoi(null)}
onClick={() => setSelectedPoi(poi)}
value={poi}
hovered={hoveredPoi?.id === poi.id}
/>
))}
</SidebarContainer>
);
};
export default SidebarListView;
import React, { CSSProperties } from 'react';
import { HomeOutline as HomeIcon, LocationMarkerOutline as AddressIcon, X as CloseIcon } from 'heroicons-react';
import React from 'react';
import { useStore } from '../../hooks';
import SidebarContainer from './SidebarContainer';
import type { PointOfInterest } from '../types/PointOfInterest';
import { X as CloseIcon, HomeOutline as HomeIcon, LocationMarkerOutline as AddressIcon } from 'heroicons-react';
import Tag from './Tag';
interface Props {
style?: CSSProperties;
value: PointOfInterest;
onClose?: () => void;
className?: string;
}
interface Props {}
const SidebarSingleView: React.FC<Props> = () => {
const selectedPoi = useStore((state) => state.selectedPoi);
const setSelectedPoi = useStore((state) => state.setSelectedPoi);
const strippedUrl = selectedPoi?.website?.replace(/(^\w+:|^)\/\//, '');
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.image ? '' : 'pl-5 pt-5'}`}>
<SidebarContainer className={`relative p-0`}>
<div className={`${selectedPoi?.image ? '' : 'pl-5 pt-5 '}`}>
<CloseIcon
size={32}
className={`${
value.image ? 'absolute left-5 top-5 ' : ''
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={onClose}
onClick={() => setSelectedPoi(null)}
/>
</div>
{value.image && (
<img className="lg:h-48 md:h-36 w-full object-cover object-center" src={value.image} alt="blog" />
{selectedPoi?.image && (
<img className="lg:h-48 md:h-36 w-full object-cover object-center" src={selectedPoi?.image} alt="blog" />
)}
<div className="p-6">
<h2 className="tracking-widest uppercase text-xs title-font font-medium text-gray-400 mb-1">
{value.category}
{selectedPoi?.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 && (
<h1 className="title-font text-lg font-medium text-gray-900 mb-3">{selectedPoi?.name}</h1>
<p className="leading-relaxed mb-6">{selectedPoi?.description}</p>
{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={value.website}>
<a className={'text-sm text-gray-500 hover:underline'} href={selectedPoi?.website}>
{strippedUrl}
</a>
</div>
)}
{value.address && (
{selectedPoi?.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 className="text-sm text-gray-500">{selectedPoi?.address}</div>
</div>
)}
{!!selectedPoi?.tags?.length && (
<div className={'flex items-center mt-3'}>
{selectedPoi?.tags.map((tag) => (
<Tag key={tag.id} color={tag.color}>
{tag.displayName}
</Tag>
))}
</div>
)}
</div>
......
import React from 'react';
interface Props {
children: string | JSX.Element;
color?: string;
}
const Tag: React.FC<Props> = ({ children, color }) => {
return (
<span
className="inline-flex items-center px-3 py-0.5 rounded-full text-sm font-medium bg-indigo-100 text-indigo-800 mr-2"
style={color ? { backgroundColor: color } : {}}
>
{children}
</span>
);
};
export default Tag;
import { request } from 'graphql-request';
import React from 'react';
import { SWRConfig } from 'swr';
import { useStore } from '../hooks';
interface Props {}
const SwrWrapper: React.FC<Props> = ({ children }) => {
const setError = useStore((state) => state.setError);
return (
<SWRConfig
value={{
fetcher: (query: string) => request(import.meta.env.SNOWPACK_PUBLIC_API_URL, query),
onError: (error) => {
setError({
title: 'API nicht erreichbar',
message: 'Kann die API nicht erreichen. Bitte später erneut probieren.',
icon: 'alert',
});
console.error('Error while fetching', error);
},
}}
>
{children}
</SWRConfig>
);
};
export default SwrWrapper;
export * from './usePoiData';
export * from './useStore';
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment