From 0b80a3ade03a46d413a688864f4df6f23c90334c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Moritz=20St=C3=BCckler?= <moritz.stueckler@gmail.com> Date: Fri, 30 Apr 2021 15:59:19 +0000 Subject: [PATCH] Feat/add poi form --- .gitlab-ci.yml | 2 +- .nvmrc | 1 + package-lock.json | 482 +++++++++++++++++- package.json | 12 +- public/.htaccess | 12 + snowpack.config.js | 3 +- src/App.tsx | 97 ---- src/Map/Map.tsx | 81 --- src/Map/MapViewController.tsx | 20 - src/Sidebar/SidebarContainer.tsx | 21 - src/Sidebar/SidebarListView.tsx | 44 -- src/Sidebar/SidebarSingleView.tsx | 54 -- src/components/App.tsx | 31 ++ src/components/ErrorModal.tsx | 16 + src/components/Form/AddPoiForm.tsx | 183 +++++++ src/components/Form/FileInput.tsx | 50 ++ src/components/Form/TextAreaInput.tsx | 32 ++ src/components/Form/TextInput.tsx | 35 ++ src/components/Map/Map.tsx | 86 ++++ src/components/Map/MapViewController.tsx | 29 ++ src/{ => components}/Modal.tsx | 0 src/components/Notification.tsx | 49 ++ .../Sidebar/ListElement.tsx} | 9 +- src/components/Sidebar/SidebarContainer.tsx | 19 + src/components/Sidebar/SidebarCreateView.tsx | 24 + src/components/Sidebar/SidebarListView.tsx | 31 ++ src/components/Sidebar/SidebarSingleView.tsx | 62 +++ src/components/Sidebar/Tag.tsx | 19 + src/components/SwrWrapper.tsx | 29 ++ src/hooks/index.ts | 2 + src/hooks/usePoiData.ts | 34 ++ src/hooks/useStore.ts | 62 +++ src/index.css | 18 +- src/index.tsx | 14 +- src/testData.json | 55 -- src/types/Error.ts | 5 + src/types/Notification.ts | 5 + src/types/PointOfInterest.ts | 27 +- src/util/fetcher.ts | 6 + tailwind.config.js | 2 +- 40 files changed, 1361 insertions(+), 402 deletions(-) create mode 100644 .nvmrc create mode 100644 public/.htaccess delete mode 100644 src/App.tsx delete mode 100644 src/Map/Map.tsx delete mode 100644 src/Map/MapViewController.tsx delete mode 100644 src/Sidebar/SidebarContainer.tsx delete mode 100644 src/Sidebar/SidebarListView.tsx delete mode 100644 src/Sidebar/SidebarSingleView.tsx create mode 100644 src/components/App.tsx create mode 100644 src/components/ErrorModal.tsx create mode 100644 src/components/Form/AddPoiForm.tsx create mode 100644 src/components/Form/FileInput.tsx create mode 100644 src/components/Form/TextAreaInput.tsx create mode 100644 src/components/Form/TextInput.tsx create mode 100644 src/components/Map/Map.tsx create mode 100644 src/components/Map/MapViewController.tsx rename src/{ => components}/Modal.tsx (100%) create mode 100644 src/components/Notification.tsx rename src/{Sidebar/SidebarListElement.tsx => components/Sidebar/ListElement.tsx} (70%) create mode 100644 src/components/Sidebar/SidebarContainer.tsx create mode 100644 src/components/Sidebar/SidebarCreateView.tsx create mode 100644 src/components/Sidebar/SidebarListView.tsx create mode 100644 src/components/Sidebar/SidebarSingleView.tsx create mode 100644 src/components/Sidebar/Tag.tsx create mode 100644 src/components/SwrWrapper.tsx create mode 100644 src/hooks/index.ts create mode 100644 src/hooks/usePoiData.ts create mode 100644 src/hooks/useStore.ts delete mode 100644 src/testData.json create mode 100644 src/types/Error.ts create mode 100644 src/types/Notification.ts create mode 100644 src/util/fetcher.ts diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 6a3dd2c..2e644af 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -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 diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..da2d398 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +14 \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 4fa0eba..2ad7b7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,7 @@ "packages": { "": { "dependencies": { + "@tailwindcss/forms": "^0.3.2", "graphql": "^15.5.0", "graphql-request": "^3.4.0", "heroicons-react": "1.3.0", @@ -12,10 +13,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", @@ -23,6 +27,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", @@ -260,6 +265,14 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.13.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.13.10.tgz", + "integrity": "sha512-4QPkjJq6Ns3V/RgpEahRk+AGfL0eO6RHHtTWoNNr5mO49G6B5+X6d6THgWEAvTrznU5xYpbAlVKRYcsCgh/Akw==", + "dependencies": { + "regenerator-runtime": "^0.13.4" + } + }, "node_modules/@babel/template": { "version": "7.12.13", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.12.13.tgz", @@ -305,6 +318,21 @@ "purgecss": "^3.1.3" } }, + "node_modules/@jadex/snowpack-plugin-tailwindcss-jit": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@jadex/snowpack-plugin-tailwindcss-jit/-/snowpack-plugin-tailwindcss-jit-0.2.0.tgz", + "integrity": "sha512-iZfHqtLZzsFKAgMBVCGFJdQWWnXoLhWr7m98DJalu772SSMGXrWryW4nC/Hyk/1RDiL861BAVzXO6aZT3KLtgw==", + "dev": true, + "dependencies": { + "fs": "^0.0.1-security", + "micromatch": "^4.0.2", + "path": "^0.12.7" + }, + "peerDependencies": { + "snowpack": "^3.2.2", + "tailwindcss": "^2.1.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.4.tgz", @@ -450,6 +478,7 @@ "resolved": "https://registry.npmjs.org/@snowpack/plugin-postcss/-/plugin-postcss-1.2.2.tgz", "integrity": "sha512-XL4EyfDLcxCeo9bOm8ScNAFq9pU4bFkFq4GY6/Zokq/rxMh9lugOHTLWeT/p4IxLh4CX1o7pX8b61Eplcqlfqg==", "dev": true, + "license": "MIT", "dependencies": { "postcss-load-config": "^3.0.1", "workerpool": "^6.1.2" @@ -486,6 +515,17 @@ "typescript": "*" } }, + "node_modules/@tailwindcss/forms": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.3.2.tgz", + "integrity": "sha512-aj2/rJsGb2whAZ/BQWHWWQRSbhH0r/l1ozOByiv+ZNjBD84GMvb5dhAyfpeasFky+EJrAwX5eaqft8NQMZFWvA==", + "dependencies": { + "mini-svg-data-uri": "^1.2.3" + }, + "peerDependencies": { + "tailwindcss": ">=2.0.0" + } + }, "node_modules/@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -501,6 +541,12 @@ "integrity": "sha512-wE2v81i4C4Ol09RtsWFAqg3BUitWbHSpSlIo+bNdsCJijO9sjme+zm+73ZMCa/qMC8UEERxzGbvmr1cffo2SiQ==", "dev": true }, + "node_modules/@types/history": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.8.tgz", + "integrity": "sha512-S78QIYirQcUoo6UJZx9CSP0O2ix9IaeAXwQi26Rhr/+mg7qqPy8TzaxHSUut7eGjL8WmLccT7/MXf304WjqHcA==", + "dev": true + }, "node_modules/@types/leaflet": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.7.0.tgz", @@ -542,6 +588,27 @@ "@types/react": "*" } }, + "node_modules/@types/react-router": { + "version": "5.1.13", + "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.13.tgz", + "integrity": "sha512-ZIuaO9Yrln54X6elg8q2Ivp6iK6p4syPsefEYAhRDAoqNh48C8VYUmB9RkXjKSQAJSJV0mbIFCX7I4vZDcHrjg==", + "dev": true, + "dependencies": { + "@types/history": "*", + "@types/react": "*" + } + }, + "node_modules/@types/react-router-dom": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.1.7.tgz", + "integrity": "sha512-D5mHD6TbdV/DNHYsnwBTv+y73ei+mMjrkGrla86HthE4/PVvL1J94Bu3qABU+COXzpL23T1EZapVVpwHuBXiUg==", + "dev": true, + "dependencies": { + "@types/history": "*", + "@types/react": "*", + "@types/react-router": "*" + } + }, "node_modules/@types/scheduler": { "version": "0.16.1", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.1.tgz", @@ -1547,6 +1614,12 @@ "node": "*" } }, + "node_modules/fs": { + "version": "0.0.1-security", + "resolved": "https://registry.npmjs.org/fs/-/fs-0.0.1-security.tgz", + "integrity": "sha1-invTcYa23d84E/I4WLV+yq9eQdQ=", + "dev": true + }, "node_modules/fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", @@ -1844,6 +1917,27 @@ "react": ">=16.13" } }, + "node_modules/history": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", + "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", + "dependencies": { + "@babel/runtime": "^7.1.2", + "loose-envify": "^1.2.0", + "resolve-pathname": "^3.0.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0", + "value-equal": "^1.0.1" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "dependencies": { + "react-is": "^16.7.0" + } + }, "node_modules/hosted-git-info": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.0.2.tgz", @@ -2178,10 +2272,9 @@ } }, "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", - "dev": true + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=" }, "node_modules/isexe": { "version": "2.0.0", @@ -2451,6 +2544,24 @@ "node": ">=6" } }, + "node_modules/mini-create-react-context": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/mini-create-react-context/-/mini-create-react-context-0.4.1.tgz", + "integrity": "sha512-YWCYEmd5CQeHGSAKrYvXgmzzkrvssZcuuQDDeqkT+PziKGMgE+0MCCtcKbROzocGBG1meBLl2FotlRwf4gAzbQ==", + "dependencies": { + "@babel/runtime": "^7.12.1", + "tiny-warning": "^1.0.3" + }, + "peerDependencies": { + "prop-types": "^15.0.0", + "react": "^0.14.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/mini-svg-data-uri": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.2.3.tgz", + "integrity": "sha512-zd6KCAyXgmq6FV1mR10oKXYtvmA9vRoB6xPSTUJTbFApCtkefDnYueVR1gkof3KcdLZo1Y8mjF2DFmQMIxsHNQ==" + }, "node_modules/minimatch": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", @@ -3049,6 +3160,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/path": { + "version": "0.12.7", + "resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz", + "integrity": "sha1-1NwqUGxM4hl+tIHr/NWzbAFAsQ8=", + "dev": true, + "dependencies": { + "process": "^0.11.1", + "util": "^0.10.3" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -3071,6 +3192,14 @@ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==" }, + "node_modules/path-to-regexp": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", + "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", + "dependencies": { + "isarray": "0.0.1" + } + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -3379,6 +3508,15 @@ "node": ">= 0.8" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha1-czIwDoQBYb2j5podHZGn1LwW8YI=", + "dev": true, + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -3404,6 +3542,16 @@ "node": ">=10" } }, + "node_modules/prop-types": { + "version": "15.7.2", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", + "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.8.1" + } + }, "node_modules/psl": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", @@ -3497,6 +3645,11 @@ "react": "17.0.2" } }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, "node_modules/react-leaflet": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-3.1.0.tgz", @@ -3519,6 +3672,43 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.2.0.tgz", + "integrity": "sha512-smz1DUuFHRKdcJC0jobGo8cVbhO3x50tCL4icacOlcwDOEQPq4TMqwx3sY1TP+DvtTgz4nm3thuo7A+BK2U0Dw==", + "dependencies": { + "@babel/runtime": "^7.1.2", + "history": "^4.9.0", + "hoist-non-react-statics": "^3.1.0", + "loose-envify": "^1.3.1", + "mini-create-react-context": "^0.4.0", + "path-to-regexp": "^1.7.0", + "prop-types": "^15.6.2", + "react-is": "^16.6.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0" + }, + "peerDependencies": { + "react": ">=15" + } + }, + "node_modules/react-router-dom": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.2.0.tgz", + "integrity": "sha512-gxAmfylo2QUjcwxI63RhQ5G85Qqt4voZpUXSEqCwykV0baaOTQDR1f0PmY8AELqIyVc0NEZUj0Gov5lNGcXgsA==", + "dependencies": { + "@babel/runtime": "^7.1.2", + "history": "^4.9.0", + "loose-envify": "^1.3.1", + "prop-types": "^15.6.2", + "react-router": "5.2.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0" + }, + "peerDependencies": { + "react": ">=15" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -3556,6 +3746,12 @@ "util-deprecate": "~1.0.1" } }, + "node_modules/readable-stream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, "node_modules/readdirp": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.5.0.tgz", @@ -3581,6 +3777,11 @@ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz", "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==" }, + "node_modules/regenerator-runtime": { + "version": "0.13.7", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz", + "integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==" + }, "node_modules/request": { "version": "2.88.2", "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", @@ -3657,6 +3858,11 @@ "node": ">=4" } }, + "node_modules/resolve-pathname": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz", + "integrity": "sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==" + }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -4130,6 +4336,16 @@ "node": ">= 10" } }, + "node_modules/tiny-invariant": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.1.0.tgz", + "integrity": "sha512-ytxQvrb1cPc9WBEI/HSeYYoGD0kWnGEOR8RY6KomWLBVhqz0RgTwVO9dLrGz7dC+nN9llyI7OKAgRq8Vq4ZBSw==" + }, + "node_modules/tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" + }, "node_modules/to-fast-properties": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", @@ -4241,11 +4457,26 @@ "punycode": "^2.1.0" } }, + "node_modules/util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", + "integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==", + "dev": true, + "dependencies": { + "inherits": "2.0.3" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" }, + "node_modules/util/node_modules/inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", + "dev": true + }, "node_modules/uuid": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", @@ -4264,6 +4495,11 @@ "builtins": "^1.0.3" } }, + "node_modules/value-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz", + "integrity": "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==" + }, "node_modules/verror": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", @@ -4509,6 +4745,14 @@ "engines": { "node": ">=8" } + }, + "node_modules/zustand": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-3.4.1.tgz", + "integrity": "sha512-Kb91vSjy5vwBQ/PQ1a5GE6naS3gCxCgpkujT9zqZSO85+gnvmzgqraMW3ao1I0jR4PwHBXtLTf26r9j7EXoUiQ==", + "peerDependencies": { + "react": ">=16.8" + } } }, "dependencies": { @@ -4721,6 +4965,14 @@ "@babel/helper-plugin-utils": "^7.12.13" } }, + "@babel/runtime": { + "version": "7.13.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.13.10.tgz", + "integrity": "sha512-4QPkjJq6Ns3V/RgpEahRk+AGfL0eO6RHHtTWoNNr5mO49G6B5+X6d6THgWEAvTrznU5xYpbAlVKRYcsCgh/Akw==", + "requires": { + "regenerator-runtime": "^0.13.4" + } + }, "@babel/template": { "version": "7.12.13", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.12.13.tgz", @@ -4766,6 +5018,17 @@ "purgecss": "^3.1.3" } }, + "@jadex/snowpack-plugin-tailwindcss-jit": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@jadex/snowpack-plugin-tailwindcss-jit/-/snowpack-plugin-tailwindcss-jit-0.2.0.tgz", + "integrity": "sha512-iZfHqtLZzsFKAgMBVCGFJdQWWnXoLhWr7m98DJalu772SSMGXrWryW4nC/Hyk/1RDiL861BAVzXO6aZT3KLtgw==", + "dev": true, + "requires": { + "fs": "^0.0.1-security", + "micromatch": "^4.0.2", + "path": "^0.12.7" + } + }, "@nodelib/fs.scandir": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.4.tgz", @@ -4911,6 +5174,14 @@ "npm-run-path": "^4.0.1" } }, + "@tailwindcss/forms": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.3.2.tgz", + "integrity": "sha512-aj2/rJsGb2whAZ/BQWHWWQRSbhH0r/l1ozOByiv+ZNjBD84GMvb5dhAyfpeasFky+EJrAwX5eaqft8NQMZFWvA==", + "requires": { + "mini-svg-data-uri": "^1.2.3" + } + }, "@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -4923,6 +5194,12 @@ "integrity": "sha512-wE2v81i4C4Ol09RtsWFAqg3BUitWbHSpSlIo+bNdsCJijO9sjme+zm+73ZMCa/qMC8UEERxzGbvmr1cffo2SiQ==", "dev": true }, + "@types/history": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.8.tgz", + "integrity": "sha512-S78QIYirQcUoo6UJZx9CSP0O2ix9IaeAXwQi26Rhr/+mg7qqPy8TzaxHSUut7eGjL8WmLccT7/MXf304WjqHcA==", + "dev": true + }, "@types/leaflet": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.7.0.tgz", @@ -4964,6 +5241,27 @@ "@types/react": "*" } }, + "@types/react-router": { + "version": "5.1.13", + "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.13.tgz", + "integrity": "sha512-ZIuaO9Yrln54X6elg8q2Ivp6iK6p4syPsefEYAhRDAoqNh48C8VYUmB9RkXjKSQAJSJV0mbIFCX7I4vZDcHrjg==", + "dev": true, + "requires": { + "@types/history": "*", + "@types/react": "*" + } + }, + "@types/react-router-dom": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.1.7.tgz", + "integrity": "sha512-D5mHD6TbdV/DNHYsnwBTv+y73ei+mMjrkGrla86HthE4/PVvL1J94Bu3qABU+COXzpL23T1EZapVVpwHuBXiUg==", + "dev": true, + "requires": { + "@types/history": "*", + "@types/react": "*", + "@types/react-router": "*" + } + }, "@types/scheduler": { "version": "0.16.1", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.1.tgz", @@ -5750,6 +6048,12 @@ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.0.13.tgz", "integrity": "sha512-E1fz2Xs9ltlUp+qbiyx9wmt2n9dRzPsS11Jtdb8D2o+cC7wr9xkkKsVKJuBX0ST+LVS+LhLO+SbLJNtfWcJvXA==" }, + "fs": { + "version": "0.0.1-security", + "resolved": "https://registry.npmjs.org/fs/-/fs-0.0.1-security.tgz", + "integrity": "sha1-invTcYa23d84E/I4WLV+yq9eQdQ=", + "dev": true + }, "fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", @@ -5969,6 +6273,27 @@ "integrity": "sha512-gz1XE6/BsuVfyBM/RiDMgapPmrf2ZrDcF8GxJ26PJTPQnEbDFs9TPzpJ5IDPQLpUor/qshPbVDv20yWIwhMwyA==", "requires": {} }, + "history": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", + "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", + "requires": { + "@babel/runtime": "^7.1.2", + "loose-envify": "^1.2.0", + "resolve-pathname": "^3.0.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0", + "value-equal": "^1.0.1" + } + }, + "hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "requires": { + "react-is": "^16.7.0" + } + }, "hosted-git-info": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.0.2.tgz", @@ -6223,10 +6548,9 @@ } }, "isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", - "dev": true + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=" }, "isexe": { "version": "2.0.0", @@ -6452,6 +6776,20 @@ "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", "dev": true }, + "mini-create-react-context": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/mini-create-react-context/-/mini-create-react-context-0.4.1.tgz", + "integrity": "sha512-YWCYEmd5CQeHGSAKrYvXgmzzkrvssZcuuQDDeqkT+PziKGMgE+0MCCtcKbROzocGBG1meBLl2FotlRwf4gAzbQ==", + "requires": { + "@babel/runtime": "^7.12.1", + "tiny-warning": "^1.0.3" + } + }, + "mini-svg-data-uri": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.2.3.tgz", + "integrity": "sha512-zd6KCAyXgmq6FV1mR10oKXYtvmA9vRoB6xPSTUJTbFApCtkefDnYueVR1gkof3KcdLZo1Y8mjF2DFmQMIxsHNQ==" + }, "minimatch": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", @@ -6896,6 +7234,16 @@ "lines-and-columns": "^1.1.6" } }, + "path": { + "version": "0.12.7", + "resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz", + "integrity": "sha1-1NwqUGxM4hl+tIHr/NWzbAFAsQ8=", + "dev": true, + "requires": { + "process": "^0.11.1", + "util": "^0.10.3" + } + }, "path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -6912,6 +7260,14 @@ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==" }, + "path-to-regexp": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", + "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", + "requires": { + "isarray": "0.0.1" + } + }, "path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -7122,6 +7478,12 @@ "resolved": "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz", "integrity": "sha1-t+PqQkNaTJsnWdmeDyAesZWALuE=" }, + "process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha1-czIwDoQBYb2j5podHZGn1LwW8YI=", + "dev": true + }, "process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -7144,6 +7506,16 @@ "retry": "^0.12.0" } }, + "prop-types": { + "version": "15.7.2", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", + "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==", + "requires": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.8.1" + } + }, "psl": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", @@ -7202,6 +7574,11 @@ "scheduler": "^0.20.2" } }, + "react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, "react-leaflet": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-3.1.0.tgz", @@ -7216,6 +7593,37 @@ "integrity": "sha512-Gvzk7OZpiqKSkxsQvO/mbTN1poglhmAV7gR/DdIrRrSMXraRQQlfikRJOr3Nb9GTMPC5kof948Zy6jJZIFtDvQ==", "dev": true }, + "react-router": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.2.0.tgz", + "integrity": "sha512-smz1DUuFHRKdcJC0jobGo8cVbhO3x50tCL4icacOlcwDOEQPq4TMqwx3sY1TP+DvtTgz4nm3thuo7A+BK2U0Dw==", + "requires": { + "@babel/runtime": "^7.1.2", + "history": "^4.9.0", + "hoist-non-react-statics": "^3.1.0", + "loose-envify": "^1.3.1", + "mini-create-react-context": "^0.4.0", + "path-to-regexp": "^1.7.0", + "prop-types": "^15.6.2", + "react-is": "^16.6.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0" + } + }, + "react-router-dom": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.2.0.tgz", + "integrity": "sha512-gxAmfylo2QUjcwxI63RhQ5G85Qqt4voZpUXSEqCwykV0baaOTQDR1f0PmY8AELqIyVc0NEZUj0Gov5lNGcXgsA==", + "requires": { + "@babel/runtime": "^7.1.2", + "history": "^4.9.0", + "loose-envify": "^1.3.1", + "prop-types": "^15.6.2", + "react-router": "5.2.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0" + } + }, "read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -7248,6 +7656,14 @@ "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + } } }, "readdirp": { @@ -7274,6 +7690,11 @@ } } }, + "regenerator-runtime": { + "version": "0.13.7", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz", + "integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==" + }, "request": { "version": "2.88.2", "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", @@ -7336,6 +7757,11 @@ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true }, + "resolve-pathname": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz", + "integrity": "sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==" + }, "retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -7678,6 +8104,16 @@ "yallist": "^4.0.0" } }, + "tiny-invariant": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.1.0.tgz", + "integrity": "sha512-ytxQvrb1cPc9WBEI/HSeYYoGD0kWnGEOR8RY6KomWLBVhqz0RgTwVO9dLrGz7dC+nN9llyI7OKAgRq8Vq4ZBSw==" + }, + "tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" + }, "to-fast-properties": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", @@ -7764,6 +8200,23 @@ "punycode": "^2.1.0" } }, + "util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", + "integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==", + "dev": true, + "requires": { + "inherits": "2.0.3" + }, + "dependencies": { + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", + "dev": true + } + } + }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -7784,6 +8237,11 @@ "builtins": "^1.0.3" } }, + "value-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz", + "integrity": "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==" + }, "verror": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", @@ -7970,6 +8428,12 @@ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.7.tgz", "integrity": "sha512-FiNkvbeHzB/syOjIUxFDCnhSfzAL8R5vs40MgLFBorXACCOAEaWu0gRZl14vG8MR9AOJIZbmkjhusqBYZ3HTHw==", "dev": true + }, + "zustand": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-3.4.1.tgz", + "integrity": "sha512-Kb91vSjy5vwBQ/PQ1a5GE6naS3gCxCgpkujT9zqZSO85+gnvmzgqraMW3ao1I0jR4PwHBXtLTf26r9j7EXoUiQ==", + "requires": {} } } } diff --git a/package.json b/package.json index f829d35..2143d65 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,13 @@ { "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}\"" + "lint": "prettier --check \"src/**/*.{js,jsx,ts,tsx}\"", + "copy:htaccess": "cp public/.htaccess build/" }, "dependencies": { + "@tailwindcss/forms": "^0.3.2", "graphql": "^15.5.0", "graphql-request": "^3.4.0", "heroicons-react": "1.3.0", @@ -13,10 +15,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 +29,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", diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..edf9518 --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,12 @@ +<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 diff --git a/snowpack.config.js b/snowpack.config.js index 829a854..09245c9 100644 --- a/snowpack.config.js +++ b/snowpack.config.js @@ -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: */ diff --git a/src/App.tsx b/src/App.tsx deleted file mode 100644 index 938975d..0000000 --- a/src/App.tsx +++ /dev/null @@ -1,97 +0,0 @@ -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; diff --git a/src/Map/Map.tsx b/src/Map/Map.tsx deleted file mode 100644 index 5f571c8..0000000 --- a/src/Map/Map.tsx +++ /dev/null @@ -1,81 +0,0 @@ -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 z-0'} - style={{ flex: 3 }} - center={DEFAULT_CENTER} - zoom={13} - scrollWheelZoom={true} - > - <TileLayer - attribution='© <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; diff --git a/src/Map/MapViewController.tsx b/src/Map/MapViewController.tsx deleted file mode 100644 index 281269a..0000000 --- a/src/Map/MapViewController.tsx +++ /dev/null @@ -1,20 +0,0 @@ -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; diff --git a/src/Sidebar/SidebarContainer.tsx b/src/Sidebar/SidebarContainer.tsx deleted file mode 100644 index de02c07..0000000 --- a/src/Sidebar/SidebarContainer.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React, { CSSProperties } from 'react'; - -interface Props { - style?: CSSProperties; - className?: string; -} - -const SidebarContainer: React.FC<Props> = ({ style, 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 ${ - className ?? '' - }`} - > - {children} - </div> - ); -}; - -export default SidebarContainer; diff --git a/src/Sidebar/SidebarListView.tsx b/src/Sidebar/SidebarListView.tsx deleted file mode 100644 index 2503578..0000000 --- a/src/Sidebar/SidebarListView.tsx +++ /dev/null @@ -1,44 +0,0 @@ -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; diff --git a/src/Sidebar/SidebarSingleView.tsx b/src/Sidebar/SidebarSingleView.tsx deleted file mode 100644 index b5db3fe..0000000 --- a/src/Sidebar/SidebarSingleView.tsx +++ /dev/null @@ -1,54 +0,0 @@ -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.image ? '' : 'pl-5 pt-5'}`}> - <CloseIcon - size={32} - className={`${ - value.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} - /> - </div> - {value.image && ( - <img className="lg:h-48 md:h-36 w-full object-cover object-center" src={value.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} - </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; diff --git a/src/components/App.tsx b/src/components/App.tsx new file mode 100644 index 0000000..1d99c2f --- /dev/null +++ b/src/components/App.tsx @@ -0,0 +1,31 @@ +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'; + +const App = () => { + const selectedPoi = useStore((state) => state.selectedPoi); + + return ( + <> + <ErrorModal /> + <Notification /> + <div className={'flex md:flex-row-reverse flex-col h-full'}> + <Route path="/add" children={({ match }) => <Map createMode={!!match} />} /> + <Switch> + <Route exact path="/add"> + <SidebarCreateView /> + </Route> + <Route>{selectedPoi ? <SidebarSingleView /> : <SidebarListView />}</Route> + </Switch> + </div> + </> + ); +}; + +export default App; diff --git a/src/components/ErrorModal.tsx b/src/components/ErrorModal.tsx new file mode 100644 index 0000000..7386e08 --- /dev/null +++ b/src/components/ErrorModal.tsx @@ -0,0 +1,16 @@ +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; diff --git a/src/components/Form/AddPoiForm.tsx b/src/components/Form/AddPoiForm.tsx new file mode 100644 index 0000000..ba2e28d --- /dev/null +++ b/src/components/Form/AddPoiForm.tsx @@ -0,0 +1,183 @@ +import { gql } from 'graphql-request'; +import React, { useEffect, useRef, useState } from 'react'; +import type { PointOfInterestFormData } from '../../types/PointOfInterest'; +import fetcher from '../../util/fetcher'; +import TextInput from './TextInput'; +import FileInput from './FileInput'; +import TextAreaInput from './TextAreaInput'; +import { CheckCircleOutline as CheckIcon } from 'heroicons-react'; +import { useStore } from '../../hooks'; +import { useHistory } from 'react-router-dom'; + +interface Props {} + +const AddPoiForm: React.FC<Props> = () => { + const [formData, setFormData] = useState<PointOfInterestFormData>({ + lat: 0, + lng: 0, + email: '', + name: '', + address: '', + description: '', + website: '', + category: '', + 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 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) 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); + + 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.', + type: 'success', + }); + history.push('/'); + } else { + setIsLoading(false); + throw new Error(); + } + } catch { + setIsLoading(false); + setNotification({ + title: 'Fehler beim Hinzufügen', + text: 'Ort konnte nicht hinzgefügt werden. Bitte später erneut probieren.', + type: 'alert', + }); + } + }; + + useEffect(() => { + // Validation + 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 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> + )} + Hinzufügen + </button> + </form> + ); +}; + +export default AddPoiForm; diff --git a/src/components/Form/FileInput.tsx b/src/components/Form/FileInput.tsx new file mode 100644 index 0000000..74872d0 --- /dev/null +++ b/src/components/Form/FileInput.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { PaperClipOutline as PaperClipIcon, UploadOutline as UploadIcon } from 'heroicons-react'; + +interface Props { + label: string; + value: File | null; + name: string; + onChange: (e: React.ChangeEvent<HTMLInputElement>) => void; + required: boolean; +} + +const FileInput: React.FC<Props> = ({ name, label, value, onChange, ...inputProps }) => { + return ( + <> + <span className="form-label"> + {label} + {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${ + 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 ? ( + <> + <PaperClipIcon size={18} className="flex-shrink-0 mr-2" /> + {value.name} + </> + ) : ( + <> + <UploadIcon size={18} className="flex-shrink-0 mr-2" /> + {'Datei auswählen...'} + </> + )} + <input + className="hidden" + type="file" + name={name} + accept="image/png, image/jpeg" + onChange={onChange} + {...inputProps} + /> + </label> + </> + ); +}; + +export default FileInput; diff --git a/src/components/Form/TextAreaInput.tsx b/src/components/Form/TextAreaInput.tsx new file mode 100644 index 0000000..5df0023 --- /dev/null +++ b/src/components/Form/TextAreaInput.tsx @@ -0,0 +1,32 @@ +import React from 'react'; + +interface Props { + label: string; + value: string; + name: string; + onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void; + required?: boolean; +} + +const TextAreaInput: React.FC<Props> = ({ name, label, value, onChange, ...textAreaProps }) => { + return ( + <label className="block mb-4"> + {!!label && ( + <span className="form-label"> + {label} + {textAreaProps?.required && `*`} + </span> + )} + <textarea + name={name} + value={value} + className="form-input" + rows={3} + onChange={onChange} + {...textAreaProps} + ></textarea> + </label> + ); +}; + +export default TextAreaInput; diff --git a/src/components/Form/TextInput.tsx b/src/components/Form/TextInput.tsx new file mode 100644 index 0000000..e83f56b --- /dev/null +++ b/src/components/Form/TextInput.tsx @@ -0,0 +1,35 @@ +import React, { MutableRefObject } from 'react'; + +interface Props { + label: string; + value: string; + name: string; + 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 }) => { + return ( + <label className="block mb-4"> + {!!label && ( + <span className="form-label"> + {label} + {inputProps?.required && `*`} + </span> + )} + <input + ref={ref} + type={type} + name={name} + value={value} + className="form-input" + onChange={onChange} + {...inputProps} + /> + </label> + ); +}; + +export default TextInput; diff --git a/src/components/Map/Map.tsx b/src/components/Map/Map.tsx new file mode 100644 index 0000000..b7caad3 --- /dev/null +++ b/src/components/Map/Map.tsx @@ -0,0 +1,86 @@ +import type { LatLngExpression, 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 MapViewController from './MapViewController'; + +interface Props { + createMode?: boolean; +} + +const DEFAULT_CENTER: LatLngTuple = [53.550359, 9.986701]; +const iconProps: DivIconOptions = { + className: 'marker-red', + // 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> = ({ createMode }) => { + const icon = useMemo(() => divIcon(iconProps), [iconProps]); + const largeIcon = useMemo(() => divIcon({ ...iconProps, iconSize: [30, 40], iconAnchor: [15, 40] }), [iconProps]); + const greenLargeIcon = useMemo( + () => divIcon({ ...iconProps, iconSize: [30, 40], iconAnchor: [15, 40], className: 'marker-green' }), + [iconProps], + ); + const { data } = usePoiData(); + const draftPoi = useStore((state) => state.draftPoi); + 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: LatLngTuple | undefined = selectedPoi ? [selectedPoi?.lat, selectedPoi?.lng] : undefined; + + return ( + <MapContainer + id={'mapid'} + className={'h-full w-full z-0'} + style={{ flex: 3 }} + center={DEFAULT_CENTER} + zoom={13} + scrollWheelZoom={true} + > + <TileLayer + attribution='© <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> + ); +}; + +export default Map; diff --git a/src/components/Map/MapViewController.tsx b/src/components/Map/MapViewController.tsx new file mode 100644 index 0000000..eeebf0c --- /dev/null +++ b/src/components/Map/MapViewController.tsx @@ -0,0 +1,29 @@ +import type { LatLngTuple } from 'leaflet'; +import React, { useEffect } from 'react'; +import { useMap, useMapEvent } from 'react-leaflet'; +import { useStore } from '../../hooks'; + +interface Props { + center: LatLngTuple; + zoom: number; + createPoiMode?: boolean; +} + +const MapViewController: React.FC<Props> = ({ center, zoom, createPoiMode }) => { + const setDraftPoi = useStore((state) => state.setDraftPoi); + const map = useMap(); + + useMapEvent('click', (event) => { + if (createPoiMode) { + setDraftPoi([event.latlng.lat, event.latlng.lng]); + } + }); + + useEffect(() => { + map.setView(center, zoom); + }, [center, zoom]); + + return <></>; +}; + +export default MapViewController; diff --git a/src/Modal.tsx b/src/components/Modal.tsx similarity index 100% rename from src/Modal.tsx rename to src/components/Modal.tsx diff --git a/src/components/Notification.tsx b/src/components/Notification.tsx new file mode 100644 index 0000000..44a2d67 --- /dev/null +++ b/src/components/Notification.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { CheckCircleOutline as CheckIcon, X as XIcon, ExclamationOutline as AlertIcon } from 'heroicons-react'; +import { useStore } from '../hooks'; + +interface Props {} + +const Notification: React.FC<Props> = () => { + const notification = useStore((state) => state.notification); + const setNotification = useStore((state) => state.setNotification); + + const icon: Record<string, JSX.Element> = { + alert: <AlertIcon className="h-6 w-6 text-red-600" />, + success: <CheckIcon className="h-6 w-6 text-green-400" />, + }; + return ( + notification && ( + <div + aria-live="assertive" + className="z-10 fixed inset-0 flex items-end px-4 py-6 pointer-events-none sm:p-6 sm:items-start" + > + <div className="w-full flex flex-col items-center space-y-4 sm:items-end"> + <div className="max-w-sm w-full bg-white shadow-lg rounded-lg pointer-events-auto ring-1 ring-black ring-opacity-5 overflow-hidden"> + <div className="p-4"> + <div className="flex items-start"> + <div className="flex-shrink-0">{icon[notification.type]}</div> + <div className="ml-3 w-0 flex-1 pt-0.5"> + <p className="text-sm font-medium text-gray-900">{notification.title}</p> + <p className="mt-1 text-sm text-gray-500">{notification.text}</p> + </div> + + <div className="ml-4 flex-shrink-0 flex"> + <button + className="bg-white rounded-md inline-flex text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + onClick={() => setNotification(null)} + > + <span className="sr-only">Close</span> + <XIcon className="h-5 w-5" /> + </button> + </div> + </div> + </div> + </div> + </div> + </div> + ) + ); +}; + +export default Notification; diff --git a/src/Sidebar/SidebarListElement.tsx b/src/components/Sidebar/ListElement.tsx similarity index 70% rename from src/Sidebar/SidebarListElement.tsx rename to src/components/Sidebar/ListElement.tsx index ac5e6bd..a088ece 100644 --- a/src/Sidebar/SidebarListElement.tsx +++ b/src/components/Sidebar/ListElement.tsx @@ -1,12 +1,15 @@ -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; diff --git a/src/components/Sidebar/SidebarContainer.tsx b/src/components/Sidebar/SidebarContainer.tsx new file mode 100644 index 0000000..f00e3b1 --- /dev/null +++ b/src/components/Sidebar/SidebarContainer.tsx @@ -0,0 +1,19 @@ +import React from 'react'; + +interface Props { + className?: string; +} + +const SidebarContainer: React.FC<Props> = ({ className, children }) => { + return ( + <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} + </aside> + ); +}; + +export default SidebarContainer; diff --git a/src/components/Sidebar/SidebarCreateView.tsx b/src/components/Sidebar/SidebarCreateView.tsx new file mode 100644 index 0000000..c1408cf --- /dev/null +++ b/src/components/Sidebar/SidebarCreateView.tsx @@ -0,0 +1,24 @@ +import { X as CloseIcon } from 'heroicons-react'; +import React from 'react'; +import { useHistory } from 'react-router-dom'; +import AddPoiForm from '../Form/AddPoiForm'; +import SidebarContainer from './SidebarContainer'; + +interface Props {} + +const SidebarCreateView: React.FC<Props> = () => { + const history = useHistory(); + return ( + <SidebarContainer className="p-5"> + <CloseIcon + size={32} + className={`flex-shrink-0 left-5 top-5 p-1 text-gray-500 inline-block cursor-pointer hover:bg-gray-300 hover:bg-opacity-50 rounded-full`} + onClick={() => history.push('/')} + /> + <h1 className="text-xl font-medium title-font text-gray-900 my-2">Neuen Ort anlegen:</h1> + <AddPoiForm /> + </SidebarContainer> + ); +}; + +export default SidebarCreateView; diff --git a/src/components/Sidebar/SidebarListView.tsx b/src/components/Sidebar/SidebarListView.tsx new file mode 100644 index 0000000..5d2460d --- /dev/null +++ b/src/components/Sidebar/SidebarListView.tsx @@ -0,0 +1,31 @@ +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; diff --git a/src/components/Sidebar/SidebarSingleView.tsx b/src/components/Sidebar/SidebarSingleView.tsx new file mode 100644 index 0000000..12c3070 --- /dev/null +++ b/src/components/Sidebar/SidebarSingleView.tsx @@ -0,0 +1,62 @@ +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 Tag from './Tag'; + +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+:|^)\/\//, ''); + + return ( + <SidebarContainer className={`relative p-0`}> + <div className={`${selectedPoi?.image ? '' : 'pl-5 pt-5 '}`}> + <CloseIcon + size={32} + className={`${ + selectedPoi?.image ? 'absolute left-5 top-5 ' : '' + }p-1 text-gray-500 inline-block cursor-pointer hover:bg-gray-300 hover:bg-opacity-50 rounded-full`} + onClick={() => setSelectedPoi(null)} + /> + </div> + {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"> + {selectedPoi?.category} + </h2> + <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={selectedPoi?.website}> + {strippedUrl} + </a> + </div> + )} + {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">{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> + </SidebarContainer> + ); +}; + +export default SidebarSingleView; diff --git a/src/components/Sidebar/Tag.tsx b/src/components/Sidebar/Tag.tsx new file mode 100644 index 0000000..7b730b4 --- /dev/null +++ b/src/components/Sidebar/Tag.tsx @@ -0,0 +1,19 @@ +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; diff --git a/src/components/SwrWrapper.tsx b/src/components/SwrWrapper.tsx new file mode 100644 index 0000000..1104125 --- /dev/null +++ b/src/components/SwrWrapper.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { SWRConfig } from 'swr'; +import { useStore } from '../hooks'; +import fetcher from '../util/fetcher'; + +interface Props {} + +const SwrWrapper: React.FC<Props> = ({ children }) => { + const setError = useStore((state) => state.setError); + return ( + <SWRConfig + value={{ + fetcher, + 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; diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..df87068 --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './usePoiData'; +export * from './useStore'; diff --git a/src/hooks/usePoiData.ts b/src/hooks/usePoiData.ts new file mode 100644 index 0000000..d23d3c7 --- /dev/null +++ b/src/hooks/usePoiData.ts @@ -0,0 +1,34 @@ +import { useEffect } from 'react'; +import type { PointsOfInterestDTO } from 'src/types/PointOfInterest'; +import useSWR from 'swr'; + +export const usePoiData = () => { + const { data } = useSWR<PointsOfInterestDTO>( + `{ + pois { + id + name + description + website + address + lat + lng + image + category + tags { + id + displayName + displayName + color + } + } + } + `, + ); + + useEffect(() => { + console.log('Got data', data); + }, [data]); + + return { data: data?.pois }; +}; diff --git a/src/hooks/useStore.ts b/src/hooks/useStore.ts new file mode 100644 index 0000000..f9d126b --- /dev/null +++ b/src/hooks/useStore.ts @@ -0,0 +1,62 @@ +import type { Error } from 'src/types/Error'; +import type { PointOfInterest } from 'src/types/PointOfInterest'; +import type { Notification } from 'src/types/Notification'; +import type { LatLngTuple } from 'leaflet'; +import create, { GetState, SetState, State, StateCreator, StoreApi } from 'zustand'; + +interface Store extends State { + selectedPoi: PointOfInterest | null; + setSelectedPoi: (poi: PointOfInterest | null) => void; + hoveredPoi: PointOfInterest | null; + setHoveredPoi: (poi: PointOfInterest | null) => void; + error: Error | null; + setError: (error: Error | null) => void; + draftPoi: LatLngTuple | null; + setDraftPoi: (latLng: LatLngTuple | null) => void; + notification: Notification | null; + setNotification: (notification: Notification | null) => void; +} + +const log = (config: StateCreator<Store>) => (set: SetState<Store>, get: GetState<Store>, api: StoreApi<Store>) => + config( + (args) => { + console.group('Global state changed'); + console.log('%cAction:', 'color: #00A7F7; font-weight: 700;', args); + set(args); + console.log('%cNext State:', 'color: #47B04B; font-weight: 700;', get()); + console.groupEnd(); + }, + get, + api, + ); + +export const useStore = create<Store>( + log((set) => ({ + selectedPoi: null, + setSelectedPoi: (poi) => { + set({ + selectedPoi: poi, + }); + }, + hoveredPoi: null, + setHoveredPoi: (poi) => { + set({ + hoveredPoi: poi, + }); + }, + error: null, + setError: (error) => { + set({ error }); + }, + draftPoi: null, + setDraftPoi: (latLng) => { + set({ + draftPoi: latLng, + }); + }, + notification: null, + setNotification: (notification) => { + set({ notification }); + }, + })), +); diff --git a/src/index.css b/src/index.css index e8d6792..3d72742 100644 --- a/src/index.css +++ b/src/index.css @@ -20,10 +20,18 @@ code { font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; } -.marker { +.marker-red { color: var(--fabcity-red); } +.marker-green { + color: var(--fabcity-green); +} + +.marker-blue { + color: var(--fabcity-blue); +} + .sidebar { flex: 3; overflow-y: auto; @@ -45,3 +53,11 @@ code { display: initial; } } + +.form-input { + @apply mt-1 block 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; +} + +.form-label { + @apply leading-7 text-sm text-gray-600; +} diff --git a/src/index.tsx b/src/index.tsx index 85d91b1..96a7b15 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,15 +1,17 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import App from './App'; -import { SWRConfig } from 'swr'; -import { request } from 'graphql-request'; +import { BrowserRouter as Router } from 'react-router-dom'; +import App from './components/App'; +import SwrWrapper from './components/SwrWrapper'; import './index.css'; ReactDOM.render( <React.StrictMode> - <SWRConfig value={{ fetcher: (query: string) => request(import.meta.env.SNOWPACK_PUBLIC_API_URL, query) }}> - <App /> - </SWRConfig> + <SwrWrapper> + <Router> + <App /> + </Router> + </SwrWrapper> </React.StrictMode>, document.getElementById('root'), ); diff --git a/src/testData.json b/src/testData.json deleted file mode 100644 index d93ab54..0000000 --- a/src/testData.json +++ /dev/null @@ -1,55 +0,0 @@ -[ - { - "id": 1, - "lat": 53.550359, - "lng": 9.986701, - "name": "Welcome Werkstatt e. V.", - "description": "Eine offene Stadtteilwerkstatt in Barmbek-Süd. Hier kann mit Holz, Metall und Elektronik gearbeitet werden.", - "address": "Bachstr. 98, 22083 Hamburg", - "category": "OFFENE WERKSTATT", - "website": "https://www.welcome-werkstatt.de/", - "image": "https://picsum.photos/720/400?random=1" - }, - { - "id": 2, - "lat": 53.560359, - "lng": 9.976701, - "name": "Fabulous St. Pauli", - "description": "Photo booth fam kinfolk cold-pressed sriracha leggings jianbing microdosing tousled waistcoat.", - "address": "Mozartstr. 8, 22081 Hamburg", - "category": "OFFENE WERKSTATT", - "website": "http://www.fablab-hamburg.org/", - "image": "https://picsum.photos/720/400?random=2" - }, - { - "id": 3, - "lat": 53.540359, - "lng": 9.996701, - "name": "HoFaLab Wilhelmsburg", - "description": "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text.", - "address": "Langer-Straßen-Name. 128, 22089 Hamburg", - "category": "OFFENE WERKSTATT", - "website": "https://hofalab.de/de/home-de/", - "image": "https://picsum.photos/720/400?random=3" - }, - { - "id": 4, - "lat": 53.570359, - "lng": 9.986701, - "name": "Fab City Haus", - "description": "Eine offene Stadtteilwerkstatt in Barmbek-Süd. Hier kann mit Holz, Metall und Elektronik gearbeitet werden.", - "address": "Jungfernstieg 1, 22083 Hamburg", - "category": "OFFENE WERKSTATT", - "image": "https://picsum.photos/720/400?random=4" - }, - { - "id": 5, - "lat": 53.565359, - "lng": 9.966701, - "name": "Haus Drei e. V.", - "description": "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text.", - "address": "Hein-Hoyer-Allee 36, 22083 Hamburg", - "category": "OFFENE WERKSTATT", - "image": "https://picsum.photos/720/400?random=5" - } -] diff --git a/src/types/Error.ts b/src/types/Error.ts new file mode 100644 index 0000000..0979889 --- /dev/null +++ b/src/types/Error.ts @@ -0,0 +1,5 @@ +export interface Error { + title: string; + message: string; + icon: string; +} diff --git a/src/types/Notification.ts b/src/types/Notification.ts new file mode 100644 index 0000000..61b4753 --- /dev/null +++ b/src/types/Notification.ts @@ -0,0 +1,5 @@ +export interface Notification { + title: string; + text: string; + type: 'alert' | 'success'; +} diff --git a/src/types/PointOfInterest.ts b/src/types/PointOfInterest.ts index 81cd084..94886e8 100644 --- a/src/types/PointOfInterest.ts +++ b/src/types/PointOfInterest.ts @@ -1,13 +1,30 @@ -import type { LatLngExpression } from 'leaflet'; - -export interface PointOfInterest { - id: number; +export interface PointOfInterestBase { lat: number; lng: number; name: string; description: string; address: string; website: string; - category?: string; + category: string; +} +export interface PointOfInterest extends PointOfInterestBase { + id: number; image?: string; + tags: Tag[]; +} + +export interface PointOfInterestFormData extends PointOfInterestBase { + email: string; + image: File | null; + tags: string; +} + +export interface Tag { + id: string; + displayName: string; + color: string; +} + +export interface PointsOfInterestDTO { + pois: PointOfInterest[]; } diff --git a/src/util/fetcher.ts b/src/util/fetcher.ts new file mode 100644 index 0000000..b8a0aa8 --- /dev/null +++ b/src/util/fetcher.ts @@ -0,0 +1,6 @@ +import { request } from 'graphql-request'; + +const fetcher = (query: string, variables?: Object) => + request(import.meta.env.SNOWPACK_PUBLIC_API_URL, query, variables); + +export default fetcher; diff --git a/tailwind.config.js b/tailwind.config.js index e6c84f1..d6669e9 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -8,5 +8,5 @@ module.exports = { variants: { extend: {}, }, - plugins: [], + plugins: [require('@tailwindcss/forms')], }; -- GitLab