diff --git a/.github/workflows/docker-backend.yml b/.github/workflows/docker-backend.yml new file mode 100644 index 00000000..98350507 --- /dev/null +++ b/.github/workflows/docker-backend.yml @@ -0,0 +1,99 @@ +name: Backend + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +on: + push: + branches: ["main", "development"] + tags: ["v*.*.*"] + paths: + - Server/** + pull_request: + branches: ["main", "development"] + paths: + - Server/** + +env: + # Use docker.io for Docker Hub if empty + REGISTRY: ghcr.io + # github.repository as / + IMAGE_NAME: ${{ github.repository }}-backend + +jobs: +# style: +# runs-on: ubuntu-latest +# defaults: +# run: +# working-directory: Server/ +# steps: +# - uses: actions/checkout@v2 +# - uses: actions/setup-node@v2 +# with: +# node-version: "18" +# - name: Install +# run: npm i +# - name: Generate Primsa +# run: npx prisma generate +# - name: Prettier +# run: npx prettier src/ --check +# - name: ESLint +# run: npx eslint src/** + + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + # This is used to complete the identity challenge + # with sigstore/fulcio when running outside of PRs. + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + # Workaround: https://github.com/docker/build-push-action/issues/461 + - name: Setup Docker buildx + uses: docker/setup-buildx-action@79abd3f86f79a9d68a23c75a09a9a85889262adf + + # Login against a Docker registry except on PR + # https://github.com/docker/login-action + - name: Log into registry ${{ env.REGISTRY }} + if: github.event_name != 'pull_request' + uses: docker/login-action@28218f9b04b4f3f62068d7b6ce6ca5b26e35336c + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Extract metadata (tags, labels) for Docker + # https://github.com/docker/metadata-action + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + # Build and push Docker image with Buildx (don't push on PR) + # https://github.com/docker/build-push-action + - name: Build and push Docker image + id: build-and-push + uses: docker/build-push-action@ac9327eae2b366085ac7f6a2d02df8aa8ead720a + with: + context: ./Server + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Clean container registry (backend) + uses: actions/delete-package-versions@v4 + with: + package-name: "railtrail-backend" + package-type: "container" + min-versions-to-keep: 3 + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/docker-vehicle-simulator.yml b/.github/workflows/docker-vehicle-simulator.yml new file mode 100644 index 00000000..227339b0 --- /dev/null +++ b/.github/workflows/docker-vehicle-simulator.yml @@ -0,0 +1,80 @@ +name: Vehicle Simulator + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +on: + push: + branches: ["main", "development"] + tags: ["v*.*.*"] + paths: + - vehicle-simulator/** + pull_request: + branches: ["main", "development"] + paths: + - vehicle-simulator/** + +env: + # Use docker.io for Docker Hub if empty + REGISTRY: ghcr.io + # github.repository as / + IMAGE_NAME: ${{ github.repository }}-vehicle-simulator + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + # This is used to complete the identity challenge + # with sigstore/fulcio when running outside of PRs. + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + # Workaround: https://github.com/docker/build-push-action/issues/461 + - name: Setup Docker buildx + uses: docker/setup-buildx-action@79abd3f86f79a9d68a23c75a09a9a85889262adf + + # Login against a Docker registry except on PR + # https://github.com/docker/login-action + - name: Log into registry ${{ env.REGISTRY }} + if: github.event_name != 'pull_request' + uses: docker/login-action@28218f9b04b4f3f62068d7b6ce6ca5b26e35336c + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Extract metadata (tags, labels) for Docker + # https://github.com/docker/metadata-action + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + # Build and push Docker image with Buildx (don't push on PR) + # https://github.com/docker/build-push-action + - name: Build and push Docker image + id: build-and-push + uses: docker/build-push-action@ac9327eae2b366085ac7f6a2d02df8aa8ead720a + with: + context: ./vehicle-simulator + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Clean container registry (vehicle-simulator) + uses: actions/delete-package-versions@v4 + with: + package-name: "railtrail-vehicle-simulator" + package-type: "container" + min-versions-to-keep: 3 + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/docker-website.yml b/.github/workflows/docker-website.yml new file mode 100644 index 00000000..2953613f --- /dev/null +++ b/.github/workflows/docker-website.yml @@ -0,0 +1,80 @@ +name: Website + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +on: + push: + branches: ["main", "development"] + tags: ["v*.*.*"] + paths: + - Website/** + pull_request: + branches: ["main", "development"] + paths: + - Website/** + +env: + # Use docker.io for Docker Hub if empty + REGISTRY: ghcr.io + # github.repository as / + IMAGE_NAME: ${{ github.repository }}-website + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + # This is used to complete the identity challenge + # with sigstore/fulcio when running outside of PRs. + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + # Workaround: https://github.com/docker/build-push-action/issues/461 + - name: Setup Docker buildx + uses: docker/setup-buildx-action@79abd3f86f79a9d68a23c75a09a9a85889262adf + + # Login against a Docker registry except on PR + # https://github.com/docker/login-action + - name: Log into registry ${{ env.REGISTRY }} + if: github.event_name != 'pull_request' + uses: docker/login-action@28218f9b04b4f3f62068d7b6ce6ca5b26e35336c + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Extract metadata (tags, labels) for Docker + # https://github.com/docker/metadata-action + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + # Build and push Docker image with Buildx (don't push on PR) + # https://github.com/docker/build-push-action + - name: Build and push Docker image + id: build-and-push + uses: docker/build-push-action@ac9327eae2b366085ac7f6a2d02df8aa8ead720a + with: + context: ./Website + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Clean container registry (website) + uses: actions/delete-package-versions@v4 + with: + package-name: "railtrail-website" + package-type: "container" + min-versions-to-keep: 3 + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..09c8d5d5 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +# Ignore artifacts: +build +logs +**/*.log +**/*.guard.ts \ No newline at end of file diff --git a/App/.gitignore b/App/.gitignore index 5b341582..47164614 100644 --- a/App/.gitignore +++ b/App/.gitignore @@ -254,4 +254,26 @@ google-services.json # Android Profiling *.hprof -# End of https://www.toptal.com/developers/gitignore/api/reactnative \ No newline at end of file +# End of https://www.toptal.com/developers/gitignore/api/reactnative + +## Core latex/pdflatex auxiliary files: +*.aux +*.bbl +*.bcf +*.blg +*.fdb_latexmk +*.lof +*.log +*.lot +*.loc +*.fls +*.run.xml +*.soc +*.synctex.gz +*.out +*.toc +*.fmt +*.fot +*.cb +*.cb2 +.*.lb \ No newline at end of file diff --git a/App/RailTrail/.gitignore b/App/RailTrail/.gitignore new file mode 100644 index 00000000..772ef297 --- /dev/null +++ b/App/RailTrail/.gitignore @@ -0,0 +1,17 @@ +node_modules/ +.expo/ +dist/ +npm-debug.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision +*.orig.* +web-build/ + +# macOS +.DS_Store + +# Temporary files created by Metro to check the health of the file watcher +.metro-health-check* diff --git a/App/RailTrail/App.tsx b/App/RailTrail/App.tsx new file mode 100644 index 00000000..716a05a6 --- /dev/null +++ b/App/RailTrail/App.tsx @@ -0,0 +1,28 @@ +import { RootNavigation } from "./navigation/root-navigation" +import { SafeAreaView } from "./components/safe-area-view" +import { StatusBar } from "expo-status-bar" +import { initStore } from "./redux/init" +import { Provider } from "react-redux" +import { + GestureHandlerRootView, + gestureHandlerRootHOC, +} from "react-native-gesture-handler" +import { AppRegistry } from "react-native" +import { expo } from "./app.json" + +export default function App() { + AppRegistry.registerComponent(expo.name, () => gestureHandlerRootHOC(App)) + + const { store } = initStore() + + return ( + + + + + + + + + ) +} diff --git a/App/RailTrail/README.md b/App/RailTrail/README.md new file mode 100644 index 00000000..14524dc2 --- /dev/null +++ b/App/RailTrail/README.md @@ -0,0 +1,6 @@ +``` +cd App/RailTrail/ +npm install +yarn install +npm start +``` diff --git a/App/RailTrail/api/api.ts b/App/RailTrail/api/api.ts new file mode 100644 index 00000000..9c164e47 --- /dev/null +++ b/App/RailTrail/api/api.ts @@ -0,0 +1,79 @@ +import { AxiosRequestConfig } from "axios" +import { + InitRequestInternalPosition, + InitResponse, + TrackListEntry, +} from "../types/init" +import { Backend } from "./backend" +import { UpdateRequest, UpdateResponse } from "../types/update" +import { VehicleNameRequest, VehicleNameResponse } from "../types/vehicle" + +const retrieveInitDataWithPosition = async ( + initRequest: InitRequestInternalPosition, + config?: AxiosRequestConfig +): Promise => { + const response = await Backend.put( + "/init/app", + initRequest, + config + ) + + return response.data +} + +const retrieveInitDataWithTrackId = async ( + trackId: number, + config?: AxiosRequestConfig +): Promise => { + const response = await Backend.get( + `/init/app/track/${trackId}`, + config + ) + + return response.data +} + +const retrieveUpdateData = async ( + updateRequest: UpdateRequest, + config?: AxiosRequestConfig +): Promise => { + const response = await Backend.put( + "/vehicles/app/", + updateRequest, + config + ) + + return response.data +} + +const retrieveVehicleId = async ( + vehicleNameRequest: VehicleNameRequest, + config?: AxiosRequestConfig +): Promise => { + const response = await Backend.put( + "/vehicles/app/getId", + vehicleNameRequest, + config + ) + + return response.data +} + +const retrieveTracks = async ( + config?: AxiosRequestConfig +): Promise => { + const response = await Backend.get( + "/init/app/tracks", + config + ) + + return response.data +} + +export const Api = { + retrieveInitDataWithPosition, + retrieveInitDataWithTrackId, + retrieveUpdateData, + retrieveVehicleId, + retrieveTracks, +} diff --git a/App/RailTrail/api/backend.ts b/App/RailTrail/api/backend.ts new file mode 100644 index 00000000..ab2df13d --- /dev/null +++ b/App/RailTrail/api/backend.ts @@ -0,0 +1,10 @@ +import axios from "axios" +import { backendUrl, BACKEND_TIMEOUT } from "../util/consts" + +export const defaultRequestHeaders = {} + +export const Backend = axios.create({ + baseURL: backendUrl, + timeout: BACKEND_TIMEOUT, + headers: { ...defaultRequestHeaders, "Content-Type": "application/json" }, +}) diff --git a/App/RailTrail/app.json b/App/RailTrail/app.json new file mode 100644 index 00000000..3cdd79fb --- /dev/null +++ b/App/RailTrail/app.json @@ -0,0 +1,62 @@ +{ + "expo": { + "name": "RailTrail", + "slug": "Train-Project", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/icon.png", + "userInterfaceStyle": "light", + "splash": { + "image": "./assets/splash.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff" + }, + "assetBundlePatterns": ["**/*"], + "ios": { + "bundleIdentifier": "de.cau.railtrail", + "buildNumber": "1.0.0", + "supportsTablet": true, + "infoPlist": { + "UIBackgroundModes": ["location"], + "NSLocationAlwaysAndWhenInUseUsageDescription": "This app uses the location to show the current position on a map." + } + }, + "android": { + "package": "de.cau.railtrail", + "versionCode": 1, + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon.png", + "backgroundColor": "#ffffff" + }, + "permissions": [ + "android.permission.ACCESS_COARSE_LOCATION", + "android.permission.ACCESS_FINE_LOCATION", + "android.permission.ACCESS_BACKGROUND_LOCATION", + "android.permission.FOREGROUND_SERVICE" + ], + "softwareKeyboardLayoutMode": "pan", + "config": { + "googleMaps": { + "apiKey": "" + } + } + }, + "web": { + "favicon": "./assets/favicon.png" + }, + "plugins": [ + [ + "expo-location", + { + "locationAlwaysAndWhenInUsePermission": "Allow RailTrail to use your location." + } + ], + "expo-localization" + ], + "extra": { + "eas": { + "projectId": "33c49930-36ac-4e4d-892d-dc2006fce072" + } + } + } +} diff --git a/App/RailTrail/assets/adaptive-icon.png b/App/RailTrail/assets/adaptive-icon.png new file mode 100644 index 00000000..03d6f6b6 Binary files /dev/null and b/App/RailTrail/assets/adaptive-icon.png differ diff --git a/App/RailTrail/assets/favicon.png b/App/RailTrail/assets/favicon.png new file mode 100644 index 00000000..e75f697b Binary files /dev/null and b/App/RailTrail/assets/favicon.png differ diff --git a/App/RailTrail/assets/icon.png b/App/RailTrail/assets/icon.png new file mode 100644 index 00000000..a0b1526f Binary files /dev/null and b/App/RailTrail/assets/icon.png differ diff --git a/App/RailTrail/assets/icons/lesser-level-crossing.tsx b/App/RailTrail/assets/icons/lesser-level-crossing.tsx new file mode 100644 index 00000000..0eab232e --- /dev/null +++ b/App/RailTrail/assets/icons/lesser-level-crossing.tsx @@ -0,0 +1,20 @@ +import * as React from "react" +import Svg, { SvgProps, Path } from "react-native-svg" +const LesserLevelCrossing = (props: SvgProps) => ( + + + +) +export default LesserLevelCrossing diff --git a/App/RailTrail/assets/icons/level-crossing.tsx b/App/RailTrail/assets/icons/level-crossing.tsx new file mode 100644 index 00000000..de9435cd --- /dev/null +++ b/App/RailTrail/assets/icons/level-crossing.tsx @@ -0,0 +1,24 @@ +import * as React from "react" +import Svg, { SvgProps, Path } from "react-native-svg" +const LevelCrossing = (props: SvgProps) => ( + + + + +) +export default LevelCrossing diff --git a/App/RailTrail/assets/icons/passing-position.tsx b/App/RailTrail/assets/icons/passing-position.tsx new file mode 100644 index 00000000..4d0ea40a --- /dev/null +++ b/App/RailTrail/assets/icons/passing-position.tsx @@ -0,0 +1,53 @@ +import * as React from "react" +import Svg, { SvgProps, Path, Rect } from "react-native-svg" +const PassingPosition = (props: SvgProps) => ( + + + + + + + + + + + + + +) +export default PassingPosition diff --git a/App/RailTrail/assets/icons/picnic.tsx b/App/RailTrail/assets/icons/picnic.tsx new file mode 100644 index 00000000..9b428e5b --- /dev/null +++ b/App/RailTrail/assets/icons/picnic.tsx @@ -0,0 +1,24 @@ +import * as React from "react" +import Svg, { SvgProps, Rect, Path } from "react-native-svg" +const Picnic = (props: SvgProps) => ( + + + + + +) +export default Picnic diff --git a/App/RailTrail/assets/icons/track-end.tsx b/App/RailTrail/assets/icons/track-end.tsx new file mode 100644 index 00000000..5e200ea5 --- /dev/null +++ b/App/RailTrail/assets/icons/track-end.tsx @@ -0,0 +1,50 @@ +import * as React from "react" +import Svg, { SvgProps, G, Rect, Path, Defs, ClipPath } from "react-native-svg" +const TrackEnd = (props: SvgProps) => ( + + + + + + + + + + + + + + + +) +export default TrackEnd diff --git a/App/RailTrail/assets/icons/train-background-heading.tsx b/App/RailTrail/assets/icons/train-background-heading.tsx new file mode 100644 index 00000000..bdc427a7 --- /dev/null +++ b/App/RailTrail/assets/icons/train-background-heading.tsx @@ -0,0 +1,16 @@ +import * as React from "react" +import Svg, { SvgProps, Circle, Path } from "react-native-svg" +const TrainBackgroundHeading = (props: SvgProps) => ( + + + + +) +export default TrainBackgroundHeading diff --git a/App/RailTrail/assets/icons/train-background-neutral.tsx b/App/RailTrail/assets/icons/train-background-neutral.tsx new file mode 100644 index 00000000..da33465d --- /dev/null +++ b/App/RailTrail/assets/icons/train-background-neutral.tsx @@ -0,0 +1,15 @@ +import * as React from "react" +import Svg, { SvgProps, Circle } from "react-native-svg" +const TrainBackgroundNeutral = (props: SvgProps) => ( + + + +) +export default TrainBackgroundNeutral diff --git a/App/RailTrail/assets/icons/train-forground.tsx b/App/RailTrail/assets/icons/train-forground.tsx new file mode 100644 index 00000000..f4475c08 --- /dev/null +++ b/App/RailTrail/assets/icons/train-forground.tsx @@ -0,0 +1,18 @@ +import * as React from "react" +import Svg, { SvgProps, Path } from "react-native-svg" +const TrainForeground = (props: SvgProps) => ( + + + +) +export default TrainForeground diff --git a/App/RailTrail/assets/icons/turning-point.tsx b/App/RailTrail/assets/icons/turning-point.tsx new file mode 100644 index 00000000..22c4a66e --- /dev/null +++ b/App/RailTrail/assets/icons/turning-point.tsx @@ -0,0 +1,38 @@ +import * as React from "react" +import Svg, { SvgProps, Rect, Path, Circle } from "react-native-svg" +const TurningPoint = (props: SvgProps) => ( + + + + + + +) +export default TurningPoint diff --git a/App/RailTrail/assets/icons/user-location.tsx b/App/RailTrail/assets/icons/user-location.tsx new file mode 100644 index 00000000..94c1ea1a --- /dev/null +++ b/App/RailTrail/assets/icons/user-location.tsx @@ -0,0 +1,14 @@ +import * as React from "react" +import Svg, { SvgProps, Circle } from "react-native-svg" +const UserLocation = (props: SvgProps) => ( + + + +) +export default UserLocation diff --git a/App/RailTrail/assets/splash.png b/App/RailTrail/assets/splash.png new file mode 100644 index 00000000..0e89705a Binary files /dev/null and b/App/RailTrail/assets/splash.png differ diff --git a/App/RailTrail/babel.config.js b/App/RailTrail/babel.config.js new file mode 100644 index 00000000..46a585e4 --- /dev/null +++ b/App/RailTrail/babel.config.js @@ -0,0 +1,7 @@ +module.exports = function (api) { + api.cache(true) + return { + presets: ["babel-preset-expo"], + plugins: ["react-native-reanimated/plugin"], + } +} diff --git a/App/RailTrail/components/button.tsx b/App/RailTrail/components/button.tsx new file mode 100644 index 00000000..1de1ca92 --- /dev/null +++ b/App/RailTrail/components/button.tsx @@ -0,0 +1,92 @@ +import { + View, + StyleSheet, + Pressable, + Text, + StyleProp, + ViewStyle, +} from "react-native" +import React from "react" +import { Color } from "../values/color" + +interface ExternalProps { + readonly text: string + readonly onPress: () => void + readonly isSecondary?: boolean + readonly disabled?: boolean + readonly style?: StyleProp +} + +type Props = ExternalProps + +export const Button = ({ + text, + onPress, + isSecondary, + disabled, + style, +}: Props) => ( + { + if (!disabled) onPress() + }} + style={({ pressed }) => [ + style, + pressed && !disabled ? { opacity: 0.8 } : {}, + ]} + > + + + {text} + + + +) + +const styles = StyleSheet.create({ + primary: { + borderRadius: 50, + padding: 15, + backgroundColor: Color.primary, + }, + secondary: { + borderRadius: 50, + padding: 15, + }, + disabled: { + borderRadius: 50, + padding: 15, + backgroundColor: Color.darkGray, + }, + textPrimary: { + color: Color.textLight, + fontSize: 18, + textAlign: "center", + }, + textSecondary: { + color: Color.primary, + fontSize: 18, + textAlign: "center", + }, + textDisabled: { + color: Color.darkGray, + fontSize: 18, + textAlign: "center", + }, +}) diff --git a/App/RailTrail/components/change-vehicle-id-bottom-sheet.tsx b/App/RailTrail/components/change-vehicle-id-bottom-sheet.tsx new file mode 100644 index 00000000..7cdedcae --- /dev/null +++ b/App/RailTrail/components/change-vehicle-id-bottom-sheet.tsx @@ -0,0 +1,117 @@ +import { StyleSheet, View, Text, Alert, Keyboard } from "react-native" +import { textStyles } from "../values/text-styles" +import { Color } from "../values/color" +import { memo, useEffect, useMemo, useRef, useState } from "react" +import BottomSheet, { + BottomSheetTextInput, + useBottomSheetDynamicSnapPoints, +} from "@gorhom/bottom-sheet" +import { Button } from "./button" +import { retrieveVehicleId } from "../effect-actions/api-actions" +import { useDispatch } from "react-redux" +import { TripAction } from "../redux/trip" +import { useTranslation } from "../hooks/use-translation" + +interface ExternalProps { + readonly isVisible: boolean + readonly setIsVisible: React.Dispatch> + readonly trackId: number | null +} + +type Props = ExternalProps + +export const ChangeVehicleIdBottomSheet = memo( + ({ isVisible, setIsVisible, trackId }: Props) => { + const dispatch = useDispatch() + const localizedStrings = useTranslation() + + const [text, onChangeText] = useState("") + + // ref + const bottomSheetRef = useRef(null) + + // variables + const snapPoints = useMemo(() => ["CONTENT_HEIGHT"], []) + + const { + animatedHandleHeight, + animatedSnapPoints, + animatedContentHeight, + handleContentLayout, + } = useBottomSheetDynamicSnapPoints(snapPoints) + + useEffect(() => { + if (isVisible) { + bottomSheetRef.current?.expand() + } else { + bottomSheetRef.current?.close() + } + }, [isVisible]) + + const onButtonPress = async () => { + retrieveVehicleId(text, trackId!).then((response) => { + if (response == null) { + Alert.alert( + localizedStrings.t("bottomSheetAlertVehicleIdNotFoundTitle"), + localizedStrings.t("bottomSheetAlertVehicleIdNotFoundMessage"), + [{ text: localizedStrings.t("alertOk"), onPress: () => {} }] + ) + } else { + setIsVisible(false) + Keyboard.dismiss() + dispatch(TripAction.setVehicleName(text)) + dispatch(TripAction.setVehicleId(response)) + onChangeText("") + } + }) + } + + return ( + setIsVisible(false)} + > + + + {localizedStrings.t("bottomSheetVehicleId")} + + {localizedStrings.t("bottomSheetChangeVehicleId")} + + + )} + /> + +
+ Andere Strecke auswählen: + {}} /> +
+ + + ); +} diff --git a/Website/src/app/components/form.tsx b/Website/src/app/components/form.tsx new file mode 100644 index 00000000..fba2d5f7 --- /dev/null +++ b/Website/src/app/components/form.tsx @@ -0,0 +1,19 @@ +import React from "react"; + +/** + * A component that wraps any Form, giving them a consistent appearance. + * + * @param children The actual page this layout should wrap. In JSX, these are the + * children of this element. + * @constructor + */ +export function FormWrapper({ children }: { children: React.ReactNode }) { + return ( +
+
{children}
+
+ ); +} + +// TODO: create a component for a form in a dialog to replace/refactor +// the LoginDialog and SelectionDialog components. diff --git a/Website/src/app/components/form_map.tsx b/Website/src/app/components/form_map.tsx new file mode 100644 index 00000000..8338b0ee --- /dev/null +++ b/Website/src/app/components/form_map.tsx @@ -0,0 +1,111 @@ +"use client"; +import L, { DragEndEvent, LatLng } from "leaflet"; +import "leaflet/dist/leaflet.css"; +import { FullTrack, Position } from "@/utils/api"; +import dynamic from "next/dynamic"; +import { Dispatch, useEffect, useMemo, useRef } from "react"; +import assert from "assert"; + +/** + * A form element that MUST NOT be rendered server side. + */ +function InternalPositionSelector({ + track_data, + position, + setPosition, + setModified, + zoom_level, + height +}: { + position: Position; + setPosition: Dispatch; + setModified?: Dispatch; + zoom_level: number; + track_data?: FullTrack; + height: number | string; +}) { + const mapContainerRef = useRef(null as HTMLDivElement | null); + const mapRef = useRef(undefined as L.Map | undefined); + const markerRef = useRef(undefined as L.Marker | undefined); + const markerIcon = useMemo(() => new L.Icon.Default({ imagePath: "/" }), []); + + /** handling the initialization of leaflet. MUST NOT be called twice. */ + function insertMap() { + // debugger; + // console.log(mapRef, mapRef.current); + assert(mapContainerRef.current, "Error: Ref to Map Container not populated"); + assert(mapRef.current == undefined, "Error: Trying to insert map more than once"); + mapRef.current = L.map(mapContainerRef.current); + + L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { + maxZoom: 19, + attribution: '© OpenStreetMap' + }).addTo(mapRef.current); + } + + /** + * Add a draggable marker for selecting a position + */ + function addMarker() { + assert(mapRef.current != undefined, "Error: Map not ready!"); + + markerRef.current = L.marker([0, 0], { draggable: true, icon: markerIcon }).addTo(mapRef.current); + markerRef.current?.on("dragend", (e: DragEndEvent) => { + const newPos: LatLng = e.target.getLatLng(); + setPosition({ + lat: newPos.lat, + lng: newPos.lng + }); + if (setModified) { + setModified(true); + } + }); + return () => { + console.log("removing marker again"); + markerRef.current?.remove(); + markerRef.current = undefined; + }; + } + + /** Set the zoom level of the map */ + function setMapZoom() { + assert(mapRef.current != undefined, "Error: Map not ready!"); + + mapRef.current.setZoom(zoom_level); + } + + /** Set the center of the map. The zoom level MUST be set before, otherwise leaflet will crash. */ + function setMapPosition() { + assert(mapRef.current != undefined, "Error: Map not ready!"); + assert(!Number.isNaN(mapRef.current?.getZoom()), "Error: ZoomLevel MUST be set before position is set!"); + + mapRef.current.setView(position); + markerRef.current?.setLatLng(position); + } + + /** insert the path of the track from the init data */ + function addTrackPath() { + assert(mapRef.current != undefined, "Error: Map not ready!"); + + const trackPath = L.geoJSON(track_data?.path, { style: { color: "darkblue" } }); + trackPath.addTo(mapRef.current); + + // Add a callback to remove the track path to remove the track path in case of a re-render. + return () => { + trackPath.remove(); + }; + } + + // Schedule various effects (JS run after the page is rendered) for changes to various state variables. + useEffect(insertMap, []); + useEffect(addMarker, [setPosition, setModified, markerIcon]); + useEffect(setMapZoom, [zoom_level]); + useEffect(setMapPosition, [position]); + useEffect(addTrackPath, [track_data?.path]); + + return
; +} + +const PositionSelector = dynamic(() => Promise.resolve(InternalPositionSelector), { ssr: false }); + +export default PositionSelector; diff --git a/Website/src/app/components/globals.css b/Website/src/app/components/globals.css new file mode 100644 index 00000000..89d4f907 --- /dev/null +++ b/Website/src/app/components/globals.css @@ -0,0 +1,55 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --foreground-rgb: 0, 0, 0; + --background-start-rgb: 214, 219, 220; + --background-end-rgb: 255, 255, 255; +} + +@media (prefers-color-scheme: dark) { + :root { + --foreground-rgb: 255, 255, 255; + --background-start-rgb: 0, 0, 0; + --background-end-rgb: 0, 0, 0; + } +} + +body { + color: rgb(var(--foreground-rgb)); + background: linear-gradient( + to bottom, + transparent, + rgb(var(--background-end-rgb)) + ) + rgb(var(--background-start-rgb)); +} + +@layer components { + a:not(.no-a-style) { + @apply text-blue-700; + @apply visited:text-purple-700; + @apply dark:text-blue-400; + @apply dark:visited:text-purple-400; + } + + .rotatingIconContainerContainer { + background: none; + border: none; + } + + .rotatingIconContainer { + display: flex; + align-items: center; + justify-content: center; + float: left; + height: 100%; + width: 100%; + } + + .rotatingIcon { + position: absolute; + height: 100%; + } +} diff --git a/Website/src/app/components/layout/currentUser.tsx b/Website/src/app/components/layout/currentUser.tsx new file mode 100644 index 00000000..dbd771db --- /dev/null +++ b/Website/src/app/components/layout/currentUser.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { useContext } from "react"; +import { UsernameContext } from "@/app/components/username-provider"; +import Link from "next/link"; + +/** + * A component showing the name of the currently logged-in user, with a logout link, + * or a login link if the user is not logged-in. + */ +export function CurrentUser({ className }: { className?: string }) { + const username = useContext(UsernameContext); + + return ( + <> + {username ? ( +
+ Hello {username} –{" "} + + Logout + +
+ ) : ( +
+ Login +
+ )} + + ); +} diff --git a/Website/src/app/components/layout/footer.tsx b/Website/src/app/components/layout/footer.tsx new file mode 100644 index 00000000..0f6aa5bc --- /dev/null +++ b/Website/src/app/components/layout/footer.tsx @@ -0,0 +1,21 @@ +import Link from "next/link"; + +/** + * The footer for the web page + */ +export default function Footer() { + return ( + + ); +} diff --git a/Website/src/app/components/layout/header.tsx b/Website/src/app/components/layout/header.tsx new file mode 100644 index 00000000..3b55a5ef --- /dev/null +++ b/Website/src/app/components/layout/header.tsx @@ -0,0 +1,30 @@ +import Link from "next/link"; +import { CurrentUser } from "@/app/components/layout/currentUser"; +import SmartNavigation from "@/app/components/layout/smart_navigation"; + +/** + * The header for the web page + */ +export default function Header() { + return ( +
+
+ RailTrail Verwaltung +
+ + {/* Force a line break for small devices */} +
+ + Karte + Liste + + + Datenverwaltung +
+ ); +} diff --git a/Website/src/app/components/layout/smart_navigation.tsx b/Website/src/app/components/layout/smart_navigation.tsx new file mode 100644 index 00000000..7b287e57 --- /dev/null +++ b/Website/src/app/components/layout/smart_navigation.tsx @@ -0,0 +1,39 @@ +"use client"; + +import Link, { LinkProps } from "next/link"; +import { usePathname } from "next/navigation"; +import { PropsWithChildren } from "react"; + +/** + * A navigation link that has a different style when the user is on the page it links to, or on a sub-page + * @param href The URL to navigate to + * @param className CSS classes for the enclosing div + * @param activeClassName Additional CSS classes for the enclosing div, when active + * @param linkClassName The CSS classes for the anchor tag + * @param children The contents in the anchor tag + * @param props Other options applicable to + * @constructor + */ +export default function SmartNavigation({ + href, + className = "px-2 border-2 border-transparent", + linkClassName, + children, + ...props +}: PropsWithChildren) { + // get the path of the currently open page + const currentPath = usePathname(); + + // and determine if we are currently on that path + const active = (currentPath === href || currentPath?.startsWith(href + "/")) ?? false; + + const activeClassName = "bg-neutral-500/20 !border-gray-500 rounded"; + + return ( +
+ + {children} + +
+ ); +} diff --git a/Website/src/app/components/loadmap.tsx b/Website/src/app/components/loadmap.tsx new file mode 100644 index 00000000..737e67d3 --- /dev/null +++ b/Website/src/app/components/loadmap.tsx @@ -0,0 +1,13 @@ +import { Spinner } from "@/app/components/spinner"; + +/** + * A loading page to show while the Leaflet map is loaded dynamically. + */ +export default function LoadMapScreen() { + return ( +
+ +
Loading...
+
+ ); +} diff --git a/Website/src/app/components/login.tsx b/Website/src/app/components/login.tsx new file mode 100644 index 00000000..b927e357 --- /dev/null +++ b/Website/src/app/components/login.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { FormEventHandler, PropsWithChildren, Suspense, useEffect, useRef, useState } from "react"; + +import Footer from "@/app/components/layout/footer"; +import { ErrorMessage } from "@/app/management/components/errorMessage"; + +/** + * The Login form for this web application. + * @param signup Parameter indicating whether this is a signup form (temporary, remove once disabled in the backend) + * @param setLogin Callback to set the login state to true if the login was successful. router.refresh() will be called if this is undefined. + */ +export default function Login({ setLogin }: { setLogin?: (logged_in: boolean) => void }) { + const [error, setError] = useState(undefined as undefined | string); + const router = useRouter(); + + const do_log_in: FormEventHandler = e => { + e.preventDefault(); + const data = new FormData(e.currentTarget); + const username = data.get("username"); + const password = data.get("password"); + + const loginPayload = { username, password }; + + fetch("/webapi/auth", { + body: JSON.stringify(loginPayload), + method: "POST", + headers: { + accept: "application/json", + "content-type": "application/json" + } + }).then(response => { + switch (response.status) { + case 200: + if (setLogin != undefined) { + setLogin(true); + } + // call router.refresh to refresh the header. + router.refresh(); + break; + case 401: + setError("Nutzername oder Passwort falsch"); + break; + case 502: + setError("Konnte nicht mit Datenbank kommunizieren"); + break; + default: + setError(`${response.status}: ${response.statusText}`); + } + }); + }; + + return ( +
+ + + + + + + + ); +} + +/** + * The login form wrapped in a html dialog, for easy display in a modal way. + * @param dst_url The URL where to redirect to when the login was successful or failed. + * @param setLogin function to call if login was/wasn't successful. + * @param children HTML elements to display over the login form in the dialog, for example for explanations. + */ +export function LoginDialog({ setLogin, children }: PropsWithChildren<{ setLogin?: (success: boolean) => void }>) { + const dialogRef = useRef(null as HTMLDialogElement | null); + + useEffect(() => { + if (!dialogRef.current?.open) { + dialogRef.current?.showModal(); + } + }); + + return ( + { + event.preventDefault(); + }} + className="drop-shadow-xl shadow-black bg-white dark:bg-slate-800 p-4 rounded max-w-2xl w-full dark:text-white backdrop:bg-gray-200/30 backdrop:backdrop-blur"> + {children} + + + +
+
+ ); +} diff --git a/Website/src/app/components/login_wrap.tsx b/Website/src/app/components/login_wrap.tsx new file mode 100644 index 00000000..5030a5db --- /dev/null +++ b/Website/src/app/components/login_wrap.tsx @@ -0,0 +1,42 @@ +"use client"; +import { FunctionComponent } from "react"; +import { LoginDialog } from "@/app/components/login"; +import { SelectionDialog } from "@/app/components/track_selection"; + +/** + * Component wrapping some other component with a login- and track selection dialog and keeping track of login state. + * @param logged_in initial login state + * @param track_selected track selection state + * @param map_conf parameters for the construction of the child + * @param Child The wrapped React Component. + */ +function LoginWrapper({ + logged_in, + track_selected, + childConf, + child: Child +}: { + logged_in: boolean; + track_selected: boolean; + childConf: T; + child: FunctionComponent; +}) { + return ( + <> + {!logged_in ? ( + +

Sie müssen sich einloggen!

+
+ ) : ( + !track_selected && ( + +

Bitte wählen Sie eine Strecke aus

+
+ ) + )} + + + ); +} + +export default LoginWrapper; diff --git a/Website/src/app/components/map.tsx b/Website/src/app/components/map.tsx new file mode 100644 index 00000000..3dbe9c20 --- /dev/null +++ b/Website/src/app/components/map.tsx @@ -0,0 +1,349 @@ +"use client"; +import L from "leaflet"; +import "leaflet/dist/leaflet.css"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { MapConfig } from "@/utils/types"; +import { coordinateFormatter, speedFormatter } from "@/utils/helpers"; +import assert from "assert"; +import { createPortal } from "react-dom"; +import RotatingVehicleIcon from "@/utils/rotatingIcon"; +import { PointOfInterest, POIType, POITypeIconValues } from "@/utils/api"; +import { POIIconImg } from "@/utils/common"; +import TrackerCharge from "@/app/components/tracker"; + +/** + * The side lengths of the poi icons in rem + */ +const POI_ICON_SIZES = { + tiny: 0.5, + small: 1, + medium: 2, + large: 3, + xl: 4 +} as const; + +/** + * Constructs the content of the popup for a POI, without React + * @param poi The POI to construct the popup for + * @param poi_type The type of that POI + */ +function poiPopupFactory(poi: PointOfInterest, poi_type?: POIType): HTMLDivElement { + const container = document.createElement("div"); + + const heading = container.appendChild(document.createElement("h4")); + heading.innerText = (poi_type ? poi_type.name + ": " : "") + poi.name; + heading.className = "col-span-2 basis-full text-xl text-center"; + container.appendChild(document.createElement("p")).innerText = poi.description ?? ""; + + return container; +} + +/** + * Actual Leaflet wrapper. MUST NOT be rendered server side. + */ +function Map({ + focus, + setFocus, + track_data, + initial_position, + vehicles, + points_of_interest, + poi_types, + initial_zoom_level +}: MapConfig) { + // define a reference to the leaflet map object + const mapRef = useRef(undefined as L.Map | undefined); + // and the markers on the map, so these can be reused + const markerRef = useRef([] as L.Marker[]); + // as well as a reference to the div where the map should be contained in + const mapContainerRef = useRef(null as HTMLDivElement | null); + + // We also need state for the center of the map, the vehicle in focus and the container containing the contents of an open popup + const [position, setPosition] = useState(initial_position); + const [zoomLevel, setZoomLevel] = useState(initial_zoom_level); + const [popupContainer, setPopupContainer] = useState(undefined as undefined | HTMLDivElement); + + // find the vehicle that is in focus, but only if either the vehicles, or the focus changes. + const vehicleInFocus = useMemo(() => vehicles.find(v => v.id == focus), [vehicles, focus]); + + // derive the appropriate POI Icon size from the zoom level. These are arbitrarily chosen values that seemed right to me + const poiIconSize: keyof typeof POI_ICON_SIZES = + zoomLevel < 8 ? "tiny" : zoomLevel < 12 ? "small" : zoomLevel < 14 ? "medium" : zoomLevel < 16 ? "large" : "xl"; + + const poiIconSideLength = POI_ICON_SIZES[poiIconSize]; + + // create icons for each poi type + const enriched_poi_types: (POIType & { leaf_icon: L.Icon })[] = useMemo( + () => + poi_types.map(pt => { + const icon_src = POIIconImg[pt.icon] ?? POIIconImg[POITypeIconValues.Generic]; + + // set an initial icon size, will be modified in via css + const iconSize: [number, number] = [45, 45]; + + const leaf_icon = L.icon({ iconUrl: icon_src, iconSize, className: "poi-icon transition-all" }); + + return { + ...pt, + leaf_icon + }; + }), + [poi_types] + ); + + /** handling the initialization of leaflet. MUST NOT be called twice. */ + function insertMap() { + assert(mapContainerRef.current, "Error: Ref to Map Container not populated"); + assert(mapRef.current == undefined, "Error: Trying to insert map more than once"); + mapRef.current = L.map(mapContainerRef.current, { + zoomSnap: 0.25, + wheelPxPerZoomLevel: 120 + }); + + L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { + maxZoom: 19, + attribution: '© OpenStreetMap' + }).addTo(mapRef.current); + + // create a pane for poi markers so that they are displayed below the vehicle markers. + const poiPane = mapRef.current!.createPane("poiPane"); + poiPane.style.zIndex = "550"; + poiPane.classList.add("leaflet-marker-pane"); + // as POIs don't have shadows, we don't need a poiShadowPane. + } + + /** + * Add appropriate event listeners to the map + */ + function addMapEvents() { + const map = mapRef.current; + assert(map != undefined, "Error: Map not ready!"); + + map.addEventListener("moveend", () => { + // prevent infinite loops by checking that the map actually moved + const newPos = map.getCenter(); + setPosition(oldPos => { + if (newPos.lng !== oldPos.lng || newPos.lat !== oldPos.lat) { + return { + lat: newPos.lat, + lng: newPos.lng + }; + } + return oldPos; + }); + }); + + map.addEventListener("zoomend", () => { + // React can automatically debounce this, as zoom level is just a number. + const newZoomLevel = map.getZoom(); + + setZoomLevel(newZoomLevel); + }); + } + + /** Set the zoom level of the map */ + function setMapZoom() { + assert(mapRef.current != undefined, "Error: Map not ready!"); + + mapRef.current.setZoom(zoomLevel); + } + + /** Set the center of the map. The zoom level MUST be set before, otherwise leaflet will crash. */ + function setMapPosition() { + assert(mapRef.current != undefined, "Error: Map not ready!"); + assert(!Number.isNaN(mapRef.current?.getZoom()), "Error: ZoomLevel MUST be set before position is set!"); + + mapRef.current.setView(position); + } + + /** insert the path of the track from the init data */ + function addTrackPath() { + assert(mapRef.current != undefined, "Error: Map not ready!"); + + if (track_data == undefined) { + return; + } + + // create a GeoJson map layer with the track path + const trackPath = L.geoJSON(track_data?.path, { style: { color: "darkblue" } }); + trackPath.addTo(mapRef.current); + + // fit the current map to the bounds of the track. + // honestly, this is pretty sketchy, but it should hopefully not cause problems + // As track data is never re-fetched while the user is using the map. + const bounds = trackPath.getBounds(); + mapRef.current!.fitBounds(bounds, { padding: [50, 50] }); + + // Add a callback to remove the track path to remove the track path in case of a re-render. + return () => { + trackPath.remove(); + }; + } + + /** move the vehicle markers and handle focus changes */ + function updateMarkers() { + assert(mapRef.current != undefined, "Error: Map not ready!"); + + while (markerRef.current.length > vehicles.length) { + const m = markerRef.current.pop(); + if (m) { + m.remove(); + } else { + break; + } + } + vehicles.forEach((v, i) => { + if (!v.pos) { + return; + } + if (markerRef.current[i] === undefined) { + if (mapRef.current) { + const iconBase = document.createElement("div"); + const markerIcon = new RotatingVehicleIcon(iconBase); + // place the marker initially at "null island" + markerRef.current[i] = L.marker([0, 0], { + icon: markerIcon + }).addTo(mapRef.current); + } + } + const m = markerRef.current[i]; + // update the marker position + m.setLatLng(v.pos); + // m.setPopupContent(popupContent(vehicles[i])) + // set the rotation of the icon + (m.getIcon() as RotatingVehicleIcon).setRotation(vehicles[i].heading); + + const current_popup = m.getPopup(); + // If the vehicle this marker belongs to, is currently in focus, add a pop-up + if (v.id === focus) { + // if the marker currently has no associated popup, `m.getPopup()` returns `null` or `undefined`. + if (current_popup == undefined) { + // create a div element to contain the popup content. + // We can then use a React portal to place content in there. + const popupElement = document.createElement("div"); + popupElement.className = "w-96 flex p-1.5 flex-row flex-wrap"; + m.bindPopup(popupElement, { className: "w-auto", maxWidth: undefined }); + setPopupContainer(popupElement); + // unset the focussed element on popup closing. + m.on("popupclose", () => { + // check if this popup is dismissed, or another popup is opened. + // This works, because the function closure binds v. + // If the current focus before the update was not on the vehicle this popup + // belongs to, do not update state. + // Also, this is one of the few occasions, + // where reacts "schedule state update function" feature is useful. + setFocus(focus => (focus == v.id ? undefined : focus)); + }); + } + m.openPopup(); + setPosition(v.pos); + } else { + if (current_popup != undefined) { + m.closePopup(); + m.unbindPopup(); + } + } + m.on("click", () => { + // set the vehicle as the focussed vehicle if it is clicked. + setFocus(v.id); + }); + }); + } + + /** Add points of interest to the map */ + function addPOIs() { + assert(mapRef.current != undefined, "Error: Map not ready!"); + + const poiMarkers = points_of_interest.map(poi => { + const poiType = enriched_poi_types.find(pt => pt.id == poi.typeId); + if (poiType != undefined) { + return L.marker(poi.pos, { + icon: poiType?.leaf_icon, + pane: "poiPane" + }) + .bindPopup(poiPopupFactory(poi, poiType)) + .addTo(mapRef.current!); + } else { + // return a POI with the default icon. + return L.marker(poi.pos, { + pane: "poiPane" + }) + .bindPopup(poiPopupFactory(poi, poiType)) + .addTo(mapRef.current!); + } + }); + + return () => poiMarkers.forEach(m => m.remove()); + } + + // Schedule various effects (JS run after the page is rendered) for changes to various state variables. + useEffect(insertMap, []); + useEffect(addMapEvents, [setPosition, setZoomLevel]); + useEffect(setMapZoom, [zoomLevel]); + useEffect(setMapPosition, [position]); + useEffect(addTrackPath, [track_data?.path, track_data]); + useEffect(updateMarkers, [focus, setFocus, vehicles]); + useEffect(addPOIs, [points_of_interest, enriched_poi_types]); + + // set the width and height of all poi icons using an effect to prevent re-rendering the icons + useEffect(() => { + // Iterate over all poi icons currently present + for (const poiIcon of document.querySelectorAll(".poi-icon")) { + if (poiIcon instanceof HTMLElement) { + // set the height and width using inline styles. + // this will probably make this component much more fragile than it needs to be... + poiIcon.style.width = poiIcon.style.height = `${poiIconSideLength}rem`; + + // we also need to adjust the margins, so that the icons remain centered + poiIcon.style.marginLeft = poiIcon.style.marginTop = `${-poiIconSideLength / 2}rem`; + } + } + }, [points_of_interest, enriched_poi_types, poiIconSideLength]); + + return ( + <> +
+ {/* If a vehicle is in focus, and we have a popup open, populate its contents with a portal from here. */} + {popupContainer && + createPortal( + vehicleInFocus ? ( + <> +

+ Vehicle "{vehicleInFocus?.name}" +

+
Tracker-Ladezustand:
+
+ {vehicleInFocus + ? vehicleInFocus.trackerIds.map(trackerId => ( + + )) + : "unbekannt"} +
+
Position:
+
+ {vehicleInFocus?.pos ? ( + <> + {coordinateFormatter.format(vehicleInFocus?.pos.lat)} N{" "} + {coordinateFormatter.format(vehicleInFocus?.pos.lng)} E + + ) : ( + "unbekannt" + )} +
+
Geschwindigkeit:
+
+ {vehicleInFocus?.speed != undefined && vehicleInFocus.speed !== -1 + ? speedFormatter.format(vehicleInFocus.speed) + : "unbekannt"} +
+ + ) : ( +
+ ), + popupContainer + )} + + ); +} + +export default Map; diff --git a/Website/src/app/components/reloadButton.tsx b/Website/src/app/components/reloadButton.tsx new file mode 100644 index 00000000..7da2f1bb --- /dev/null +++ b/Website/src/app/components/reloadButton.tsx @@ -0,0 +1,16 @@ +"use client"; +import { useRouter } from "next/navigation"; +import { PropsWithChildren } from "react"; + +/** + * A button that, when clicked, requests Next to re-render the page it is on. + */ +export function ReloadButton({ className, children }: PropsWithChildren<{ className?: string }>) { + const router = useRouter(); + + return ( + + ); +} diff --git a/Website/src/app/components/selectTrackButton.tsx b/Website/src/app/components/selectTrackButton.tsx new file mode 100644 index 00000000..0e2e5b48 --- /dev/null +++ b/Website/src/app/components/selectTrackButton.tsx @@ -0,0 +1,14 @@ +import Link from "next/link"; + +/** + * A link that somewhat resembles a button to select a different track. + */ +export function SelectTrackButton() { + return ( + + Andere Strecke wählen + + ); +} diff --git a/Website/src/app/components/spinner.tsx b/Website/src/app/components/spinner.tsx new file mode 100644 index 00000000..6f8f6317 --- /dev/null +++ b/Website/src/app/components/spinner.tsx @@ -0,0 +1,21 @@ +/** + * A spinning loading indicator. Will pulse instead if the user prefers reduced motion + */ +export function Spinner({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/Website/src/app/components/track_selection.tsx b/Website/src/app/components/track_selection.tsx new file mode 100644 index 00000000..12e8c6a4 --- /dev/null +++ b/Website/src/app/components/track_selection.tsx @@ -0,0 +1,125 @@ +import { Dispatch, FormEventHandler, PropsWithChildren, useEffect, useRef, useState } from "react"; + +import Footer from "@/app/components/layout/footer"; +import useSWR from "swr"; +import { getCookie, setCookie } from "cookies-next"; +import { inter } from "@/utils/common"; +import { getFetcher } from "@/utils/fetcher"; +import { useRouter } from "next/navigation"; +import { Spinner } from "@/app/components/spinner"; + +/** + * The track selection form for this web application. + */ +export default function Selection({ + completed, + setCompleted +}: { + completed: boolean; + setCompleted: Dispatch; +}) { + // @type data TrackList + const { data, error, isLoading } = useSWR("/webapi/tracks/list", getFetcher<"/webapi/tracks/list">); + // get the next page router + const router = useRouter(); + const selectedTrack = getCookie("track_id")?.toString(); + + const selectTrack: FormEventHandler = e => { + e.preventDefault(); + const data = new FormData(e.target as HTMLFormElement); + + // set the relevant cookie + setCookie("track_id", data.get("track")); + + // change the React state + setCompleted(true); + + // and reload + router.refresh(); + return; + }; + + return ( +
+ {isLoading ? ( +
+ +
Lädt...
+
+ ) : error ? ( +
{error.toString()}
+ ) : completed ? ( +
+ +
Wird gespeichert...
+
+ ) : ( + <> + + + + + )} +
+ ); +} + +/** + * The track selection form wrapped in a dialog, for easy display in a modal way. + * @param children HTML elements to display over the login form in the dialog, for example for explanations. + * @param modal Whether this is shown as part of a modal route. + */ +export function SelectionDialog({ children, modal = false }: PropsWithChildren<{ modal?: boolean }>) { + const dialogRef = useRef(null as HTMLDialogElement | null); + const router = useRouter(); + + // get a "completed" state + const [completed, setCompleted] = useState(false); + + useEffect(() => { + if (!dialogRef.current?.open) { + dialogRef.current?.showModal(); + } + }, []); + + // if this is a modal, we need to move back to the previous page using the router + useEffect(() => { + if (completed && modal) { + router.back(); + } + }, [completed, modal, router]); + + return ( + { + if (modal) { + // if this is a modal, we need to move back to the previous page using the router + router.back(); + } + event.preventDefault(); + }} + className="drop-shadow-xl shadow-black bg-white p-4 rounded max-w-2xl w-full dark:bg-slate-800 dark:text-white backdrop:bg-gray-200/30 backdrop:backdrop-blur"> + {children} + +
+
+ ); +} diff --git a/Website/src/app/components/tracker.tsx b/Website/src/app/components/tracker.tsx new file mode 100644 index 00000000..24f5a528 --- /dev/null +++ b/Website/src/app/components/tracker.tsx @@ -0,0 +1,34 @@ +import { getFetcher, TrackerIdRoute } from "@/utils/fetcher"; +import useSWR from "swr"; +import { batteryLevelFormatter } from "@/utils/helpers"; + +/** + * Component displaying the charge of a tracker with a given + * @param trackerId + * @constructor + */ +export default function TrackerCharge({ trackerId }: { trackerId: string }) { + const safeTrackerId = encodeURIComponent(trackerId); + const { data: tracker_data } = useSWR(`/webapi/tracker/read/${safeTrackerId}`, getFetcher); + + return ( + <> + {tracker_data && ( +
+
+
{tracker_data.id}
+
+ {tracker_data.id} +
+
+
+ {tracker_data.battery == undefined ? "?" : batteryLevelFormatter.format(tracker_data.battery)} +
+
+ )} + + ); +} diff --git a/Website/src/app/components/username-provider.tsx b/Website/src/app/components/username-provider.tsx new file mode 100644 index 00000000..999a2c88 --- /dev/null +++ b/Website/src/app/components/username-provider.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { createContext, PropsWithChildren } from "react"; + +/** + * A React context holding the username of the currently logged in user + */ +export const UsernameContext = createContext(undefined as undefined | string); + +/** + * Client component wrapper for the UsernameContext + */ +export default function UsernameProvider({ children, username }: PropsWithChildren<{ username: string | undefined }>) { + return {children}; +} diff --git a/Website/src/app/data_protection/page.tsx b/Website/src/app/data_protection/page.tsx new file mode 100644 index 00000000..71c354b8 --- /dev/null +++ b/Website/src/app/data_protection/page.tsx @@ -0,0 +1,21 @@ +const Page = () => ( +
+

Der Schutz Ihrer Daten ist uns sehr wichtig. Wir erheben möglicherweise folgende Daten über Sie:

+
    +
  • IP-Adresse
  • +
  • Verwendeter Webbrowser
  • +
  • Standort
  • +
  • Nutzername
  • +
  • Gewählte Strecke
  • +
+

Dazu setzen wir Cookies ein.

+

+ Bei Nutzung der Seite werden außerdem Kartendaten von{" "} + www.openstreetmap.org abgerufen um die Karte darzustellen. + Hierbei wird zwingend Ihre IP-Adresse an diesen Drittanbieter übertragen. +

+

TODO: Add some more legal text here

+
+); + +export default Page; diff --git a/Website/src/app/favicon.ico b/Website/src/app/favicon.ico new file mode 100644 index 00000000..718d6fea Binary files /dev/null and b/Website/src/app/favicon.ico differ diff --git a/Website/src/app/layout.tsx b/Website/src/app/layout.tsx new file mode 100644 index 00000000..384e4f63 --- /dev/null +++ b/Website/src/app/layout.tsx @@ -0,0 +1,31 @@ +import BaseLayout from "@/app/components/base_layout"; +import { inter, meta_info } from "@/utils/common"; +import { cookies } from "next/headers"; +import { getUsername, inlineTry } from "@/utils/helpers"; +import React from "react"; +import UsernameProvider from "@/app/components/username-provider"; + +export const metadata = meta_info; + +/** + * The Layout to use on all pages in the app-directory. + * Effectively defers to BaseLayout with minimal adjustments. + */ +export default function RootLayout({ children, modal }: { children: React.ReactNode; modal: React.ReactNode }) { + const token = cookies().get("token")?.value; + const username = token ? inlineTry(() => getUsername(token)) : undefined; + + return ( + + + + {children} + { + /* Add any modals beneath the page layout. They will need to layer themselves over the content. */ + modal + } + + + + ); +} diff --git a/Website/src/app/list/page.tsx b/Website/src/app/list/page.tsx new file mode 100644 index 00000000..45afa32a --- /dev/null +++ b/Website/src/app/list/page.tsx @@ -0,0 +1,40 @@ +import { cookies } from "next/headers"; +import { getAllVehiclesOnTrack, getTrackData } from "@/utils/data"; +import LoginWrapper from "@/app/components/login_wrap"; +import { FullTrack, Vehicle } from "@/utils/api"; +import DynamicList from "@/app/components/dynlist"; + +/** + * A page containing only the vehicle list + * @constructor + */ +export default async function Home() { + const token = cookies().get("token")?.value; + const track_id = parseInt(cookies().get("track_id")?.value ?? "", 10); + const track_selected = !isNaN(track_id); + const [track_data, server_vehicles]: [FullTrack | undefined, Vehicle[]] = !(token && track_selected) + ? [undefined, [] as Vehicle[]] + : await Promise.all([getTrackData(token, track_id), getAllVehiclesOnTrack(token, track_id)]).catch(e => { + console.error("Error fetching Map Data from the Backend:", e); + return [undefined, []]; + }); + + const listConf = { + server_vehicles, + track_data, + track_id + }; + + return ( +
+
+ +
+
+ ); +} diff --git a/Website/src/app/login/page.tsx b/Website/src/app/login/page.tsx new file mode 100644 index 00000000..a6b66e44 --- /dev/null +++ b/Website/src/app/login/page.tsx @@ -0,0 +1,28 @@ +"use client"; + +import Login from "@/app/components/login"; +import { FormWrapper } from "@/app/components/form"; +import Link from "next/link"; +import { useState } from "react"; + +/** + * The stand-alone login page + */ +export default function LoginPage() { + const [login, setLogin] = useState(false); + + return ( + + {login ? ( +
+
Erfolgreich eingeloggt.
+ + Zurück zur Hauptseite + +
+ ) : ( + + )} +
+ ); +} diff --git a/Website/src/app/management/add_track/page.tsx b/Website/src/app/management/add_track/page.tsx new file mode 100644 index 00000000..8687b414 --- /dev/null +++ b/Website/src/app/management/add_track/page.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { FormEvent, useRef, useState } from "react"; +import { UpdateTrack } from "@/utils/api"; + +/** + * The form used to add a track to the system + */ +export default function Home() { + const formRef = useRef(null as null | HTMLFormElement); + const [success, setSuccess] = useState(false); + const [error, setError] = useState(undefined as string | undefined); + + /** + * Submit the data from the form to the backend. + * @param e The form submit event. + */ + async function submit(e: FormEvent) { + e.preventDefault(); + const form = formRef.current; + if (!form) throw new Error("Form missing"); + const formData = new FormData(form); + const trackFile = formData.get("track") as File; + const path = JSON.parse(await trackFile.text()); + + const uploadRequest: UpdateTrack = { + path, + start: formData.get("trackStart") as string, + end: formData.get("trackEnd") as string + }; + + try { + const result = await fetch("/webapi/tracks/new", { + body: JSON.stringify(uploadRequest), + headers: { "Content-Type": "application/json" }, + method: "POST" + }); + + if (result.ok) { + setSuccess(true); + setError(undefined); + } else { + if (result.status == 401) setError("Authorisierungsfehler: Sind Sie angemeldet?"); + if (result.status >= 500 && result.status < 600) + setError(`Serverfehler ${result.status} ${result.statusText}`); + } + } catch (e) { + setError(`Fehler: Konnte Anfrage nicht senden: ${e}`); + } + } + + return ( + <> + {success ? ( +

+ Track erfolgreich hinzugefügt +

+ ) : ( +
+ + + + + + + {error && ( +
+ {error} +
+ )} + +
+ )} + + ); +} diff --git a/Website/src/app/management/components/errorMessage.tsx b/Website/src/app/management/components/errorMessage.tsx new file mode 100644 index 00000000..9c6610e6 --- /dev/null +++ b/Website/src/app/management/components/errorMessage.tsx @@ -0,0 +1,12 @@ +/** display an error message if there is an error */ +export function ErrorMessage({ error }: { error: string | undefined }) { + return ( + <> + {error && ( +
+ Fehler: {error} +
+ )} + + ); +} diff --git a/Website/src/app/management/components/exceptionMessage.tsx b/Website/src/app/management/components/exceptionMessage.tsx new file mode 100644 index 00000000..e01b0759 --- /dev/null +++ b/Website/src/app/management/components/exceptionMessage.tsx @@ -0,0 +1,44 @@ +import { UnauthorizedError } from "@/utils/types"; +import { ErrorMessage } from "@/app/management/components/errorMessage"; +import Link from "next/link"; +import { ReloadButton } from "@/app/components/reloadButton"; + +/** + * Display a specialized error message for server side exceptions + * @param error The relevant exception thrown. + */ +export function ExceptionMessage({ error }: { error: unknown }) { + const InternalComponent = () => { + if (error instanceof UnauthorizedError) { + return ( + <> +
+ +
+ + Erneut anmelden + + + ); + } else if (error instanceof Error) { + return ; + } else if (error instanceof Object) { + return ; + } else if (typeof error === "string") { + return ; + } + return ; + }; + + return ( +
+ + + Erneut versuchen + +
+ ); +} diff --git a/Website/src/app/management/components/iconSelection.tsx b/Website/src/app/management/components/iconSelection.tsx new file mode 100644 index 00000000..0d042ae7 --- /dev/null +++ b/Website/src/app/management/components/iconSelection.tsx @@ -0,0 +1,70 @@ +import { Options, SingleValue } from "react-select"; +import { Option } from "@/utils/types"; +import { useMemo } from "react"; +import { POIIconCommonName, POIIconImg } from "@/utils/common"; +import { POITypeIcon, POITypeIconValues } from "@/utils/api"; +import StyledSelect from "@/app/management/components/styledSelect"; + +const POI_ICONS: POITypeIcon[] = Object.values(POITypeIconValues); + +/** + * A consolidated poi icon selection component + */ +export default function IconSelection({ + currentIcon, + setIcon, + setModified, + id, + name +}: { + currentIcon: POITypeIcon | ""; + setIcon: (newIcon: POITypeIcon | "") => void; + setModified?: (modified: boolean) => void; + id: string; + name: string; +}) { + const iconOptions: Options> = useMemo( + () => + POI_ICONS.map(i => ({ + value: i, + label: ( +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {POIIconCommonName[i]} +
+
{POIIconCommonName[i]}
+
+ ) + })), + [] + ); + const defaultIcon: Option<""> = useMemo( + () => ({ + value: "", + label: ( +
+ [Bitte auswählen] +
+ ) + }), + [] + ); + + const icon: Option = useMemo( + () => iconOptions.find(v => v.value === currentIcon) ?? defaultIcon, + [currentIcon, iconOptions, defaultIcon] + ); + + /** + * Function handling the selection of a different icon + */ + function changeFunction(newValue: SingleValue>) { + if (newValue && newValue.value !== "") { + setIcon(newValue.value); + setModified ? setModified(true) : undefined; + } + } + + return ; +} diff --git a/Website/src/app/management/components/inputWithLabel.tsx b/Website/src/app/management/components/inputWithLabel.tsx new file mode 100644 index 00000000..a43f97dc --- /dev/null +++ b/Website/src/app/management/components/inputWithLabel.tsx @@ -0,0 +1,45 @@ +import { HTMLInputTypeAttribute, PropsWithChildren } from "react"; + +/** + * An input element with the corresponding label. The label contents are supplied as a child + */ +export function InputWithLabel({ + children, + id, + name, + setModified, + setValue, + value, + type = "text" +}: PropsWithChildren<{ + id: string; + name: string; + value: string; + setValue?: (value: string) => void; + setModified?: (modified: boolean) => void; + type?: HTMLInputTypeAttribute; +}>) { + return ( + <> + + { + if (setValue) { + setValue(e.target.value); + } + if (setModified) { + setModified(true); + } + }} + /> + + ); +} diff --git a/Website/src/app/management/components/managementForm.tsx b/Website/src/app/management/components/managementForm.tsx new file mode 100644 index 00000000..901a738d --- /dev/null +++ b/Website/src/app/management/components/managementForm.tsx @@ -0,0 +1,178 @@ +import { Dispatch, FormEventHandler, PropsWithChildren, useState } from "react"; +import { SuccessMessage } from "@/app/management/components/successMessage"; +import { ErrorMessage } from "@/app/management/components/errorMessage"; +import { SubmitButtons } from "@/app/management/components/submitButtons"; +import { Response } from "next/dist/compiled/@edge-runtime/primitives"; +import { type HTTP_METHOD } from "next/dist/server/web/http"; + +/** + * Adjust the form state based on the response from the backend + * @param result The response received from the backend + * @param mutate_fkt A function that indicates that data has changed, when called + * @param setSuccess A function to set the success state of the form + * @param setError A function to set the error state of the form + */ +async function handleResponse( + result: Response, + mutate_fkt: () => Promise, + setSuccess: Dispatch, + setError: Dispatch +) { + if (result.ok) { + // invalidate cached result for swr + await mutate_fkt(); + setSuccess(true); + setError(undefined); + } else if (result.status == 401) { + setError("Authorisierungsfehler: Sind Sie angemeldet?"); + } else if (result.status >= 500 && result.status < 600) { + setError(`Serverfehler ${result.status} ${result.statusText}`); + } else { + setError(`Sonstiger Fehler ${result.status} ${result.statusText}`); + } +} + +/** + * A component handling the submission for most of the management forms, as well as success/error tracking + * @param children The actual form elements + * @param delete_confirmation_msg A message to ask for confirmation for deletion with. + * @param delete_url The URL where a DELETE request shall be directed. + * @param create_update_payload The payload that needs to be sent to the create/update endpoint + * @param create_update_url The url where the above-mentioned creation/update payload shall be directed + * @param update_invalid_msg A message to show when trying to submit something invalid. MUST be `undefined` when the payload is valid. Will block create/update requests while non-nullish + * @param creating A flag indicating whether the form is used to create an object. + * @param setModified A function that, when called with `false`, clears the "dirty flag" of the form + * @param mutate_fkt A function to indicate that the data has been modified at the backend and should be re-fetched. + */ +export default function ManagementForm({ + children, + delete_confirmation_msg, + delete_url, + create_update_payload, + create_update_url, + update_invalid_msg, + creating, + setModified, + mutate_fkt +}: PropsWithChildren<{ + delete_confirmation_msg: string; + delete_url: string; + create_update_payload?: PayloadT; + update_invalid_msg?: string; + create_update_url: string; + creating: boolean; + setModified: (modified: boolean) => void; + mutate_fkt: () => Promise; +}>) { + const [success, setSuccess] = useState(false); + const [error, setError] = useState(undefined as string | undefined); + + const submit_method = creating ? "POST" : "PUT"; + + const deleteThing: FormEventHandler = async e => { + e.preventDefault(); + + // Ask the user for confirmation that they indeed want to delete the thing + const confirmation = confirm(delete_confirmation_msg); + + if (confirmation) { + try { + // send the deletion request to our proxy-API + const result = await fetch(delete_url, { + method: "DELETE" + }); + + // and set state based on the response + await handleResponse(result, mutate_fkt, setSuccess, setError); + } catch (e) { + setError(`Verbindungsfehler: ${e}`); + } + } + }; + + return ( + + { + /* Display a success message if the success flag is true */ success ? ( + + ) : ( + <> + {children} + + + + ) + } + + ); +} + +/** + * A component handling the submission for management forms, requiring however, an external submit button, and success tracking + * @param children The actual form elements + * @param submit_payload The payload that needs to be sent on submission to the endpoint + * @param submit_url The url where the above-mentioned payload shall be directed + * @param submit_invalid_msg A message to show when trying to submit something invalid. MUST be `undefined` when the payload is valid. Will block requests while non-nullish + * @param submit_method The HTTP request method used for submitting the payload. + * @param setError Function to set error state + * @param setSuccess Function to set successful submission state + * @param mutate_fkt A function to indicate that the data has been modified at the backend and should be re-fetched. + */ +export function BaseManagementForm({ + children, + submit_invalid_msg, + submit_payload, + submit_url, + submit_method, + setError, + setSuccess, + mutate_fkt +}: PropsWithChildren<{ + submit_payload?: PayloadT; + submit_invalid_msg?: string; + submit_url: string; + submit_method: HTTP_METHOD; + setError: Dispatch; + setSuccess: Dispatch; + mutate_fkt: () => Promise; +}>) { + // Form submission function + const submitThing: FormEventHandler = async e => { + e.preventDefault(); + // create the corresponding payload to send to the backend. + if (submit_invalid_msg != undefined) { + setError(submit_invalid_msg); + return; + } + + try { + // Send the payload to our own proxy-API. Create if the selected ID is empty. + const result = await fetch(submit_url, { + method: submit_method, + body: submit_payload == undefined ? undefined : JSON.stringify(submit_payload), + headers: { + accept: "application/json", + "content-type": "application/json" + } + }); + + // and set state based on the response + await handleResponse(result, mutate_fkt, setSuccess, setError); + } catch (e) { + setError(`Verbindungsfehler: ${e}`); + } + }; + + return ( +
+ {children} +
+ ); +} diff --git a/Website/src/app/management/components/referencedObjectSelect.tsx b/Website/src/app/management/components/referencedObjectSelect.tsx new file mode 100644 index 00000000..c87bc401 --- /dev/null +++ b/Website/src/app/management/components/referencedObjectSelect.tsx @@ -0,0 +1,71 @@ +import { PropsWithChildren, useMemo } from "react"; +import { Option } from "@/utils/types"; +import { Options } from "react-select"; +import StyledSelect from "@/app/management/components/styledSelect"; + +/** + * A selection element for specifying a relation to one of `objects` + * @param children The label for the selection + * @param inputId The id of the selection input + * @param name The name of the selection input + * @param value The id of the currently selected object + * @param setValue Function to set the id of the selected object + * @param setModified Function to set the form state to modified + * @param objects Objects which can be selected + * @param mappingFunction Function mapping an object to a selectable option (i.e. an Option) + * @param width The grid-width of the selection thingy. + */ +export default function ReferencedObjectSelect({ + children, + inputId, + mappingFunction, + name, + objects, + setModified, + setValue, + value, + width +}: PropsWithChildren<{ + inputId: string; + name: string; + value: ValueType; + setValue: (newValue: ValueType) => void; + setModified: (modified: true) => void; + objects: ObjectType[]; + mappingFunction: (object: ObjectType) => Option; + width?: 4 | 5; +}>) { + const defaultValue: Option<"" | ValueType> = useMemo(() => ({ value: "", label: "[Bitte auswählen]" }), []); + const options: Options> = useMemo( + () => objects.map(mappingFunction), + [objects, mappingFunction] + ); + + const currentSelection = useMemo( + () => options.find(({ value: optValue }) => optValue === value) ?? defaultValue, + [options, defaultValue, value] + ); + + return ( + <> + + { + if (e !== null && e.value !== "") { + setValue(e.value); + setModified(true); + } + }} + options={options} + width={width} + /> + + ); +} + +export type ReferencedObjectSelect = typeof ReferencedObjectSelect; diff --git a/Website/src/app/management/components/styledSelect.tsx b/Website/src/app/management/components/styledSelect.tsx new file mode 100644 index 00000000..a25a268d --- /dev/null +++ b/Website/src/app/management/components/styledSelect.tsx @@ -0,0 +1,54 @@ +import Select, { GroupBase, Props } from "react-select"; + +/** + * A react-select selection with our styles applied + * @param width The grid-width of the selection. Defaults to 5 + * @param props The props for the react-select Select component. + * @constructor + */ +export default function StyledSelect< + Option = unknown, + IsMulti extends boolean = false, + Group extends GroupBase