diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index d4a0bc671..f913aaf92 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -30,7 +30,7 @@ jobs: tags: ${{ steps.meta.outputs.tags }} steps: - uses: actions/checkout@v3 - + - run: cp openapi.yaml data/output - name: Log in to the Container registry uses: docker/login-action@v2 with: diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index cf70cbe8f..bc775df0e 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -59,8 +59,7 @@ representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported to the community leaders responsible for enforcement at -app@tum.de. +reported to the community leaders responsible for enforcement at navigatum@tum.de. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the diff --git a/map/README.md b/map/README.md index cb2b897b6..8e30aa21f 100644 --- a/map/README.md +++ b/map/README.md @@ -57,7 +57,7 @@ docker run --rm -it -v $(pwd)/map:/data -p 7770:80 maptiler/tileserver-gl ### Edit the style For editing the style we use [Maputnik](https://github.com/maputnik/editor). -It is a web-based editor for Mapbox styles. +It is a web-based editor for Maplibre styles. You can use it to edit the style and see the changes live. Sadly, it is not fully compatible with tileserver-gl. diff --git a/openapi.yaml b/openapi.yaml index 1065a7751..5cd76838b 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -246,7 +246,13 @@ paths: time_ms: 23 '400': description: Invalid Request - content: {} + content: + text/plain: + schema: + type: string + example: Invalid Request + enum: + - Invalid Request '404': description: '`search_query` is empty. Since searching for nothing is nonsensical, we dont support this.' content: @@ -258,7 +264,13 @@ paths: - Not found '414': description: The uri you are trying to request is unreasonably long. Search querys dont have thousands of chars.. - content: {} + content: + text/plain: + schema: + type: string + example: Uri too long + enum: + - Uri too long tags: - core '/api/get/{id}': @@ -732,14 +744,26 @@ paths: description: | Too many requests. We are rate-limiting everyone's requests, please try again later. - content: {} + content: + text/plain: + schema: + type: string + example: Too many requests + enum: + - Too many requests '503': description: | Service unavailable. We have not configured a GitHub Access Token or a JWT Key. This could be because we are experiencing technical difficulties or intentional if we experience abuse of these endpoints. Please try again later. - content: {} + content: + text/plain: + schema: + type: string + example: Service unavailable + enum: + - Service unavailable tags: - feedback /api/feedback/feedback: @@ -770,7 +794,13 @@ paths: example: 'https://github.com/TUM-Dev/navigatum/issues/9' '400': description: If not all fields in the body are present as defined above - content: {} + content: + text/plain: + schema: + type: string + example: Not all fields in the body are present as defined in the documentation + enum: + - Not all fields in the body are present as defined in the documentation '403': description: | Forbidden. Causes are (delivered via the body): @@ -792,24 +822,48 @@ paths: description: | Unprocessable Entity Subject or body missing or too short. - content: {} + content: + text/plain: + schema: + type: string + example: Unprocessable Entity + enum: + - Unprocessable Entity '451': description: | Unavailable for legal reasons. Using this endpoint without accepting the privacy policy is not allowed. For us to post to GitHub, this has to be true - content: {} + content: + text/plain: + schema: + type: string + example: Unavailable for legal reasons + enum: + - Unavailable for legal reasons '500': description: | Internal Server Error. We have a problem communicating with GitHubs servers. Please try again later. - content: {} + content: + text/plain: + schema: + type: string + example: Internal Server Error + enum: + - Internal Server Error '503': description: | Service unavailable. We have not configured a GitHub Access Token. This could be because we are experiencing technical difficulties or intentional. Please try again later. - content: {} + content: + text/plain: + schema: + type: string + example: Service unavailable + enum: + - Service unavailable tags: - feedback '/cdn/{size}/{id}_{counter}.webp': @@ -883,7 +937,13 @@ paths: Bad Request. The request was malformed. Please check your request and try again. - content: {} + content: + text/plain: + schema: + type: string + example: Bad Request + enum: + - Bad Request '404': description: Requested Resource Not Found content: @@ -895,7 +955,13 @@ paths: - Not found '414': description: 'The uri you are trying to request is unreasonably long. neither ids, nor any other parameter has more than 30 chars..' - content: {} + content: + text/plain: + schema: + type: string + example: Uri too long + enum: + - Uri too long tags: - cdn '/cdn/maps/{source}/{id}.webp': @@ -946,7 +1012,13 @@ paths: Bad Request. The request was malformed. Please check your request and try again. - content: {} + content: + text/plain: + schema: + type: string + example: Bad Request + enum: + - Bad Request '404': description: Requested Resource Not Found content: @@ -958,7 +1030,13 @@ paths: - Not found '414': description: 'The uri you are trying to request is unreasonably long. neither ids, nor any other parameter has more than 30 chars..' - content: {} + content: + text/plain: + schema: + type: string + example: Uri too long + enum: + - Uri too long tags: - cdn '/api/legacy_redirect/{arch_name}': @@ -1006,7 +1084,13 @@ paths: type: string '301': description: Permanent redirect to the roomfinder - content: {} + content: + text/plain: + schema: + type: string + example: Permanent Redirect + enum: + - Permanent Redirect '404': description: Requested Resource Not Found content: @@ -1036,7 +1120,13 @@ paths: source code: https://github.com/TUM-Dev/navigatum/tree/8a0fb71819ac88c8af35683cfb46291f0d0c9b0a '503': description: Service Unavailable - content: {} + content: + text/plain: + schema: + type: string + example: Service Unavailable + enum: + - Service Unavailable tags: - health /api/feedback/status: @@ -1078,7 +1168,13 @@ paths: source code: https://github.com/TUM-Dev/navigatum/tree/8a0fb71819ac88c8af35683cfb46291f0d0c9b0a '503': description: Service Unavailable - content: {} + content: + text/plain: + schema: + type: string + example: Service Unavailable + enum: + - Service Unavailable tags: - health /cdn/health: @@ -1098,7 +1194,13 @@ paths: - healthy '503': description: Service Unavailable - content: {} + content: + text/plain: + schema: + type: string + example: Service Unavailable + enum: + - Service Unavailable tags: - health /health: @@ -1118,7 +1220,13 @@ paths: - healthy '503': description: Service Unavailable - content: {} + content: + text/plain: + schema: + type: string + example: Service Unavailable + enum: + - Service Unavailable tags: - health components: diff --git a/resources/scripts/regen.sh b/resources/scripts/regen.sh index 0182c408d..38443983e 100755 --- a/resources/scripts/regen.sh +++ b/resources/scripts/regen.sh @@ -6,6 +6,7 @@ echo "regenerating the data for /data" ( cd ./data || exit python compile.py +rsync ../openapi.yaml output/openapi.yaml ) @@ -23,8 +24,7 @@ python load_api_data_to_db.py echo "regenerating the data for /webclient" ( cd ./webclient || exit -rm -fr cdn -mkdir cdn +mkdir -p cdn rsync -r --exclude '*.yaml' ../data/sources/img/ cdn/ cp -r ../data/external/results/maps/roomfinder/* cdn/maps/roomfinder ) diff --git a/webclient/.dockerignore b/webclient/.dockerignore index b3204ffac..1992b6ab8 100644 --- a/webclient/.dockerignore +++ b/webclient/.dockerignore @@ -1,2 +1,4 @@ -**/node_modules -**/build +node_modules/* +dist/* +cdn/* +.vscode/* diff --git a/webclient/.editorconfig b/webclient/.editorconfig new file mode 100644 index 000000000..555cc8c43 --- /dev/null +++ b/webclient/.editorconfig @@ -0,0 +1,12 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true +max_line_length = 120 +indent_size = 2 +indent_style = space \ No newline at end of file diff --git a/webclient/.eslintrc.cjs b/webclient/.eslintrc.cjs new file mode 100644 index 000000000..0116d96e9 --- /dev/null +++ b/webclient/.eslintrc.cjs @@ -0,0 +1,15 @@ +/* eslint-env node */ +require("@rushstack/eslint-patch/modern-module-resolution"); + +module.exports = { + root: true, + extends: [ + "plugin:vue/vue3-essential", + "eslint:recommended", + "@vue/eslint-config-typescript/recommended", + "@vue/eslint-config-prettier", + ], + parserOptions: { + ecmaVersion: "latest", + }, +}; diff --git a/webclient/.gitignore b/webclient/.gitignore index 453d67fad..bae9cc004 100644 --- a/webclient/.gitignore +++ b/webclient/.gitignore @@ -1,5 +1,30 @@ -build -cdn -node_modules +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + package-lock.json -yarn.lock +node_modules +.DS_Store +dist +dist-ssr +coverage +*.local + +/cypress/videos/ +/cypress/screenshots/ + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +/cdn/ diff --git a/webclient/.vscode/extensions.json b/webclient/.vscode/extensions.json new file mode 100644 index 000000000..c0a6e5a48 --- /dev/null +++ b/webclient/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"] +} diff --git a/webclient/Dockerfile b/webclient/Dockerfile index 865a1cbdd..a3b5885d1 100644 --- a/webclient/Dockerfile +++ b/webclient/Dockerfile @@ -1,17 +1,15 @@ FROM node:latest as build-stage WORKDIR /app COPY package*.json ./ -RUN npm install -COPY config.js config.js -COPY gulpfile.js gulpfile.js -COPY src src -RUN node_modules/gulp/bin/gulp.js --gulpfile ./gulpfile.js release && rm -fr ./build/tmp +RUN npm install && \ + apt-get update && \ + apt-get install rsync -y -# compress data (only using gzip, because brotli on ngnix is a royal pain) -RUN gzip --force --keep --recursive ./build +COPY . . +RUN sh build.sh FROM nginx:latest as production-stage COPY nginx.conf /etc/nginx/nginx.conf RUN mkdir /app && apt update && apt upgrade -y -COPY --from=build-stage /app/build /app +COPY --from=build-stage /dist /app EXPOSE 80 diff --git a/webclient/README.md b/webclient/README.md index 1bb0b0236..3a66faf05 100644 --- a/webclient/README.md +++ b/webclient/README.md @@ -9,129 +9,109 @@ This folder contains the JavaScript based webclient for NavigaTUM. For getting started, there are some system dependencys which you will need. Please follow the [system dependencys docs](/resources/documentation/Dependencys.md) before trying to run this part of our project. -### Images and maps +### Recommended IDE Setup -The frontend uses images and maps from the data, that are intended to be served -statically via a CDN and not provided by the API. +[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin). +Most modern IDEs (like PyCharm) should work as well and have a Plugin. -For a local environment, create a `cdn/` subdirectory in `weblclient/` and copy the relevant files -into it: +## Dependencies -```bash -mkdir cdn -rsync -r --exclude '*.yaml' ../data/sources/img/ cdn/ -mkdir -p cdn/maps/roomfinder -cp -r ../data/external/results/maps/roomfinder/* cdn/maps/roomfinder/ -``` +### Prerequisites -### Building +For getting started, there are some system dependencys which you will need. +Please follow the [system dependencys docs](/resources/documentation/Dependencys.md) before trying to run this part of our project. -Install all npm packages: +### Installing Dependency's ```bash npm install ``` -And run Gulp to build the client. The build files will be written to `build/`. - -```bash -# Run development build -gulp -# or run release build (will not work locally, because it uses a -# different configuration and no hash based navigation) -gulp release +## Run + +Ensure that _NavigaTUM-server_ is running in the background: + +- either via following the [guide to local development](../server/README.md), or +- via [docker](https://docs.docker.com/) + _docker isolates the network, but we want these two containers to communicate to each other without being as brittle as IPs._ + _Naming the `navigatum-mieli-search` container `search` makes us able to connect to it via <`http://search:7700`> from the server_ + ```bash + docker network create navigatum-net + docker run -it --rm -p 7700:7700 --name search --network navigatum-net ghcr.io/tum-dev/navigatum-mieli-search:main + docker run -it --rm -p 8080:8080 --network navigatum-net -e MIELI_SEARCH_ADDR=search ghcr.io/tum-dev/navigatum-server:main + ``` + +By default, the webclient will connect to the server on `http://localhost:8080`. +If you want to connect to the public API instead, change `VITE_APP_URL` in [`env/.env`](./env/.env) to `https://nav.tum.de`. + +```sh +npm run dev ``` -### Testing +### Type-Check, Compile and Minify for Production -If you do a development build you can use a simple webserver to test the build. - -Ensure that _NavigaTUM-server_ is running in the background. By default, the webclient will connect to the server on `http://localhost:8080`. -If you want to connect to the public API instead, change `api_prefix` in `config.js` to `https://nav.tum.de/api/` and rebuild. +```sh +npm run build +``` -Now run: +### Linting with [ESLint](https://eslint.org/) -```bash -python -m http.server +```sh +npm run lint ``` -and open in your browser. +### Update the API's type definitions -Note that local builds served this way do not support the language and theme setting. -You can choose a different base HTML instead. +```sh +npx openapi-typescript openapi.yaml --output webclient/src/codegen/api.ts --export-type --immutable-types +``` ## Build files & Serving release build -Gulp creates a lot of index HTML files in the build process. -Each of those files are similar but differ in some aspects. -If you serve the release build with a webserver (such as Apache or Nginx) you need -to select the correct files based on the request URL and headers. +We create a lot of index HTML files in the build process. +Each of those files are similar but differ in some aspects. +If you serve the release build with a webserver (such as Nginx) you need to select the correct files based on the request URL and headers. ```plain -index-view---.html - ↑ ↑ ↑ - │ │ └── The page language. Either "de" or "en" at the - │ │ moment. It should be selected based on the - │ │ "lang" Cookie or else the "Accept-Language" header. - │ └── The page theme. Either "light" or "dark" at the moment. - │ It should be selected based on the "theme" Cookie and is - │ "light" by default. - └── The first loaded view (see architecture below). It does technically - not matter which view is selected here, but this allows to efficiently - preload resources and optimize the order of resources during initial - pageload. +-.html + ↑ ↑ + │ └── The page language. Either "de" or "en" at the moment. + │ It should be selected based on the "lang" Cookie or else the "Accept-Language" header. + └── The page theme. Either "light" or "dark" at the moment. + It should be selected based on the "theme" Cookie ("light" by default). ``` -When running locally on a development build you can use the language and theme of -your choice as well as any view. +The language-selector is working in development and this differentialtion is only happening in the build. +For the theme we can not do so for some reason (If you know of a better way, hit us up). +To test a different theme, you can change `$theme` [here](./src/assets/variables.scss). Values are `light` and `dark`. ## Architecture -The NavigaTUM webclient is made as a single-page application based on [Vue.js](https://vuejs.org/) and [Vue Router](https://router.vuejs.org/). The CSS framework is [Spectre.css](https://picturepan2.github.io/spectre/). It is made up of a core codebase, _views_ and _modules_: - -- The core codebase provides the routing functionality, as well as helper functions (e.g. to retrieve data). All of this is bundles in the `navigatum` object in JS. -- _Views_ (taking over the terminology from vue-router) are the pages displayed in NavigaTUM. -- _Modules_ provide extra functionality that is not critical or used by multiple views (e.g. the interactive map). +The NavigaTUM webclient is made as a single-page application based on [Vue.js](https://vuejs.org/) and [Vue Router](https://router.vuejs.org/). +For state management we use [pinia](https://pinia.vuejs.org/) and our CSS framework is [Spectre.css](https://picturepan2.github.io/spectre/). -### Directory structure +### Directory structure (only the important parts) -```bash +```plain webclient -├── build/ # 🠔 Build files will be written here +├── public/ # 🠔 Static assets such as icons, which cannot get inlined ├── src/ -│ ├── assets/ # 🠔 Static assets such as icons -│ ├── md/ # 🠔 Static pages written in markdown. Served at `/about/`. -│ ├── modules/ -│ │ ├── autocomplete.js # 🠔 Autocompletion for search -│ │ └── interactive-map.js # 🠔 Interactive map based on Mapbox -│ ├── views/ # 🠔 See below -│ ├── core.js # 🠔 Core JS code (and JS entrypoint) -│ ├── feedback.js # 🠔 JS for the feedback form (separated from the rest of -│ │ # the code to work even when the core JS fails). -│ ├── history-states.js # 🠔 Preseve state on back-/forward navigation -│ ├── i18n.yaml # 🠔 Translation strings for the core code -│ ├── index.html # 🠔 index.html template -│ ├── init-call.js # 🠔 Special helper-script for init on page-load -│ ├── legacy.js # 🠔 Special helper-script to automatically include some -│ │ # polyfills for older browsers. -│ ├── main.scss # 🠔 Sass CSS code for all non-view parts -│ ├── spectre-all.scss # 🠔 Include-script for Spectre.CSS -│ └── variables.scss # 🠔 Sass CSS variable definitions (also defines themes) -├── config.js # 🠔 Build configuration -├── gulpfile.js # 🠔 Gulp configuration -└── package.json # 🠔 Node package definition and dependencies -``` - -'Views' (pages) are located in `src/views` where each view has its own subdirectory called `view-`: - -```bash -view-example -├── i18n-example.yaml # 🠔 Translation strings for each language -├── view-example.inc # 🠔 The HTML Template of the view -├── view-example.js # 🠔 The JS Sources of the view -└── view-example.scss # 🠔 The Sass CSS Sources of the view +│ ├── codegen/ # 🠔 code generated via openapi.yaml for typechecking reasons +│ ├── assets/ # 🠔 Static assets such as icons +│ │ ├── md/ # 🠔 Static pages written in markdown. Served at `/about/`. +│ │ ├── variables.scss # 🠔 Include-script for Spectre.CSS +│ │ ├── main.scss # 🠔 Sass CSS code for all non-view parts +│ │ ├── spectre-all.scss # 🠔 Include-script for Spectre.CSS +│ │ └── logo.svg # 🠔 Our Logo +│ ├── components/ # 🠔 Vue components, which are used in views. +│ ├── views/ # 🠔 The views are parts of App.vue, which are loaded dynamically based on our routes. +│ ├── router.ts # 🠔 The views are parts of App.vue, which are loaded dynamically based on our routes. +│ ├── App.vue # 🠔 Main view +│ └── main.ts # 🠔 Inialization of Vue.js. This is the entrypoint of our app, from which App.vue and associated Views/Components are loaded +├── vite.config.ts # 🠔 Build configuration +├── gulpfile.js # 🠔 Gulp configuration +└── package.json # 🠔 Node package definition and dependencies ``` -Note that new views are automatically included in the build, but new JS files -in the `src/` directory are not. If you add a new JS file there you need to include -it in `gulpfile.js`. +Note that new views are automatically included in the build, but they are not routed. +To add a new view, you need to add a new route in `src/router.ts`. diff --git a/webclient/build.sh b/webclient/build.sh new file mode 100755 index 000000000..5c156f36e --- /dev/null +++ b/webclient/build.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env sh + +set -e # fail on first error + +mkdir -p ../dist +rm -fr ../dist + +for LANG in en de +do + for THEME in light dark + do + # make sure we are really only building the right theme and language + sed -i "s/\$theme: .*/\$theme: \"${THEME}\";/" src/assets/variables.scss + sed -i "s/locale: .*/locale: \"${LANG}\",/" src/main.ts + sed -i "/fallbackLocale: .*/d" src/main.ts + sed -i "s/messages: .*/messages: { ${LANG} },/" src/main.ts + + echo "Building ${LANG}-${THEME}" + npm run build-only + mv dist/index.html dist/${LANG}-${THEME}.html + rsync -r dist/* ../dist + done +done + + +# compress data (only using gzip, because brotli on ngnix is a royal pain) +gzip --force --keep --recursive ../dist diff --git a/webclient/config.js b/webclient/config.js deleted file mode 100644 index 5dce38257..000000000 --- a/webclient/config.js +++ /dev/null @@ -1,18 +0,0 @@ -export const configRelease = { - /* --- Site configuration --- */ - // Prefix for resource loading, e.g. "/app/" if the page is - // running at "example.com/app/". - // Setting it to "" makes paths relative. This only works for - // hash-based navigation in development builds. - app_prefix: "/", - // Prefix for 'cdn' content, e.g. images. - cdn_prefix: "https://nav.tum.de/cdn/", - // Prefix for API requests - api_prefix: "https://nav.tum.de/api/", -}; - -export const configLocal = { - app_prefix: "", - cdn_prefix: "/cdn/", - api_prefix: "http://localhost:8080/api/", -}; diff --git a/webclient/env.d.ts b/webclient/env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/webclient/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/webclient/env/.env b/webclient/env/.env new file mode 100644 index 000000000..a3c486b51 --- /dev/null +++ b/webclient/env/.env @@ -0,0 +1 @@ +VITE_APP_URL=http://localhost:8000 diff --git a/webclient/env/.env.production b/webclient/env/.env.production new file mode 100644 index 000000000..9b2347fa6 --- /dev/null +++ b/webclient/env/.env.production @@ -0,0 +1 @@ +VITE_APP_URL=https://nav.tum.de diff --git a/webclient/gulpfile.js b/webclient/gulpfile.js deleted file mode 100644 index 19a83a2ad..000000000 --- a/webclient/gulpfile.js +++ /dev/null @@ -1,717 +0,0 @@ -/* eslint func-names: ["error", "always"] */ - -import gulp from "gulp"; -import addsrc from "gulp-add-src"; -import babel from "gulp-babel"; -import concat from "gulp-concat"; -import csso from "gulp-csso"; -import first from "gulp-first"; -import htmlmin from "gulp-htmlmin"; -import i18n from "gulp-html-i18n"; -import i18nCompile from "gulp-i18n-compile"; -import gulpif from "gulp-if"; -import inject from "gulp-inject"; -import injectStr from "gulp-inject-string"; -import injectHtml from "gulp-inject-stringified-html"; -import markdown, { marked } from "gulp-markdown"; -import postcss from "gulp-postcss"; -import preprocess from "gulp-preprocess"; -import purgecss from "gulp-purgecss"; -import rename from "gulp-rename"; -import revAll from "gulp-rev-all"; -import _sass from "gulp-sass"; -import sitemap from "gulp-sitemap"; -import splitFiles from "gulp-split-files"; -import uglify from "gulp-uglify"; -import yaml from "gulp-yaml"; - -import browserify from "browserify"; -import del from "delete"; -import fs from "fs"; -import merge from "merge-stream"; -import dartSass from "sass"; -import path from "path"; -import postcssRemoveDeclaration from "postcss-remove-declaration"; -import postcssPrependSelector from "postcss-prepend-selector"; -import source from "vinyl-source-stream"; - -import { configRelease, configLocal } from "./config.js"; // eslint-disable-line import/extensions - -// from https://github.com/gulpjs/gulp/blob/master/docs/recipes/running-task-steps-per-folder.md -function getFolders(dir) { - return fs - .readdirSync(dir) - .filter((file) => fs.statSync(path.join(dir, file)).isDirectory()); -} - -// Selected at the bottom of the script -let config; - -const sass = _sass(dartSass); // Select Sass compiler - -const htmlminOptions = { - caseSensitive: false, - collapseBooleanAttributes: true, - collapseInlineTagWhitespace: false, - collapseWhitespace: true, - conservativeCollapse: false, - html5: true, - includeAutoGeneratedTags: false, - keepClosingSlash: false, - minifyCSS: true, - minifyJS: true, - preserveLineBreaks: false, - preventAttributesEscaping: false, - // processConditionalComments: true, - removeAttributeQuotes: true, - removeComments: true, - removeEmptyAttributes: true, - removeOptionalTags: false, //! - removeScriptTypeAttributes: true, - removeStyleLinkTypeAttributes: true, - sortAttributes: true, - // sortClassName: true -}; - -const i18nOptions = { - langDir: "build/tmp/locale", -}; - -const babelTargets = { - browsers: ["last 2 versions", "not dead", "> 0.2%"], -}; - -// --- Preparations --- -function cleanBuild(cb) { - del(["build/**"], cb); -} - -// --- Main CSS Pipeline --- -function compileMainScss() { - return merge( - ["light", "dark"].map((theme) => - gulp - .src("src/main.scss") - .pipe(injectStr.prepend(`$theme: "${theme}";\n`)) - .pipe(sass().on("error", sass.logError)) - .pipe(rename(`main-${theme}.css`)) - .pipe(gulp.dest("build/tmp")) - ) - ); -} - -function compileSpectreScss() { - return merge( - ["light", "dark"].map((theme) => - gulp - .src("src/spectre-all.scss") - .pipe(injectStr.prepend(`$theme: "${theme}";\n`)) - .pipe(sass().on("error", sass.logError)) - .pipe( - purgecss({ - content: ["src/index.html", "src/views/*/*.inc"], - }) - ) - // .pipe(csso()) - .pipe(rename(`spectre-all-purged-${theme}.css`)) - .pipe(gulp.dest("build/tmp")) - ) - ); -} - -/* function mergeMainCss() { - return merge(["light", "dark"].map((theme) => - gulp - .src([ - `build/tmp/main-${theme}.css`, - `build/tmp/spectre-all-purged-${theme}.css`, - ]) - .pipe(concat(`app-main-merged-${theme}.css`)) - .pipe(gulp.dest("build/css")) - .pipe(csso()) - .pipe(rename(`app-main-merged-${theme}.min.css`)) - .pipe(gulp.dest("build/css")) - ) - ); -} */ -gulp.task( - "main_css", - gulp.series(compileMainScss, compileSpectreScss /* , mergeMainCss */) -); - -// --- Main JS Pipeline --- -function buildAppCoreJS() { - return gulp - .src(["src/core.js", "src/detect-webp.js"]) - .pipe(concat("app-core.js")) - .pipe( - preprocess({ - context: { - app_prefix: config.app_prefix, - cdn_prefix: config.cdn_prefix, - api_prefix: config.api_prefix, - target: config.target, - }, - includeBase: "src/js", - }) - ) - .pipe(gulp.dest("build/js")); -} - -function buildAppRestJS() { - return gulp - .src([ - "src/modules/interactive-map.js", - "src/modules/autocomplete.js", - // History states are here because usually this module is not needed - // very soon, and if it is still missing this is not a real issue. - "src/history-states.js", - ]) - .pipe(concat("app-rest.js")) - .pipe( - preprocess({ - context: { - app_prefix: config.app_prefix, - cdn_prefix: config.cdn_prefix, - api_prefix: config.api_prefix, - target: config.target, - }, - includeBase: "src/js", - }) - ) - .pipe(gulp.dest("build/js")); -} - -function buildFeedbackJS() { - return gulp - .src("src/feedback.js") - .pipe(i18n(i18nOptions)) - .pipe( - preprocess({ - context: { - app_prefix: config.app_prefix, - cdn_prefix: config.cdn_prefix, - api_prefix: config.api_prefix, - target: config.target, - }, - includeBase: "src/js", - }) - ) - .pipe(gulpif(config.target === "release", uglify())) - .pipe(gulpif(config.target === "release", rename({ suffix: ".min" }))) - .pipe(gulp.dest("build/js")); -} - -function buildOutdatedBrowserJS() { - return gulp - .src("src/outdated-browser.js") - .pipe(i18n(i18nOptions)) - .pipe(gulpif(config.target === "release", uglify())) - .pipe(gulpif(config.target === "release", rename({ suffix: ".min" }))) - .pipe(gulp.dest("build/js")); -} - -function copyVueJS() { - if (config.target === "release") - return gulp - .src([ - "node_modules/vue/dist/vue.min.js", - "node_modules/vue-router/dist/vue-router.min.js", - "src/init-call.js", - ]) - .pipe(concat("vue.min.js")) - .pipe(gulp.dest("build/js")); - return gulp - .src([ - "node_modules/vue/dist/vue.js", - "node_modules/vue-router/dist/vue-router.js", - "src/init-call.js", - ]) - .pipe(concat("vue.js")) - .pipe(gulp.dest("build/js")); -} - -gulp.task( - "main_js", - gulp.series(buildAppCoreJS, buildAppRestJS, buildFeedbackJS, copyVueJS) -); - -// --- Views compilation pipeline --- -gulp.task("views", (done) => { - const viewsSrcPath = "src/views"; - - const folders = getFolders(viewsSrcPath); - if (folders.length === 0) return done(); // nothing to do! - - const tasks = folders.map((folder) => { - const cssTask = merge( - ["light", "dark"].map((theme) => - gulp - .src(path.join(viewsSrcPath, folder, `/view-${folder}.scss`)) - .pipe(injectStr.prepend(`$theme: "${theme}";\n`)) - .pipe(sass().on("error", sass.logError)) - .pipe(rename(`view-${theme}.css`)) - .pipe(gulp.dest(`build/tmp/views/${folder}`)) - ) - ); - - const jsTask = gulp - .src(path.join(viewsSrcPath, folder, `/view-${folder}.js`)) - .pipe( - preprocess({ - context: { - app_prefix: config.app_prefix, - cdn_prefix: config.cdn_prefix, - api_prefix: config.api_prefix, - target: config.target, - }, - includeBase: path.join(viewsSrcPath, folder), - }) - ) - .pipe(rename("view.js")) - .pipe(gulp.dest(`build/tmp/views/${folder}`)); - - const htmlTask = gulp - .src(path.join(viewsSrcPath, folder, `/view-${folder}.inc`)) - .pipe( - preprocess({ - context: { - app_prefix: config.app_prefix, - cdn_prefix: config.cdn_prefix, - api_prefix: config.api_prefix, - target: config.target, - }, - includeBase: path.join(viewsSrcPath, folder), - }) - ) - .pipe(htmlmin(htmlminOptions)) - .pipe(gulp.dest(`build/tmp/views/${folder}`)); - - return merge(cssTask, jsTask, htmlTask); - }); - - return merge(tasks); -}); - -// --- Build pages sources --- -gulp.task("pages_src", (done) => { - const viewsBuildPath = "build/tmp/views"; - - const folders = getFolders(viewsBuildPath); - if (folders.length === 0) return done(); // nothing to do! - - const tasks = folders.map((folder) => { - const viewCSS = merge( - ["light", "dark"].map((theme) => { - // Extract used spectre classes for this view and merge with core & view css - const viewCSSCore = gulp - .src(`build/tmp/spectre-all-purged-${theme}.css`) - .pipe(concat("view-spectre-used.css")) - .pipe( - purgecss({ - content: ["src/index.html", `src/views/${folder}/*.inc`], - }) - ) - .pipe( - addsrc([ - path.join(viewsBuildPath, folder, `view-${theme}.css`), - `build/tmp/main-${theme}.css`, - ]) - ) - .pipe(concat(`view-core-merged-${theme}.css`)) - .pipe(csso()) - .pipe(rename(`view-core-merged-${theme}.min.css`)) - .pipe(gulp.dest(`build/tmp/views/${folder}`)); - - // Merge remaining views css (TODO: include spectre somewhere else?) - const viewCSSRest = gulp - .src( - [ - `build/tmp/views/*/view-${theme}.css`, - `build/tmp/spectre-all-purged-${theme}.css`, - ], - { ignore: path.join(viewsBuildPath, folder, `view-${theme}.css`) } - ) - .pipe(concat(`view-rest-merged-${theme}.css`)) - .pipe(gulp.dest(`build/tmp/views/${folder}`)); - return merge(viewCSSCore, viewCSSRest); - }) - ); - - const viewJS = gulp - .src(path.join(viewsBuildPath, folder, "view.js")) - .pipe(injectHtml()) - .pipe(rename("view-inlined.js")) - .pipe(gulp.dest(`build/tmp/views/${folder}`)); - - return merge(viewCSS, viewJS); - }); - - return merge(tasks); -}); - -// --- Build pages output --- -gulp.task("pages_out", (done) => { - const viewsBuildPath = "build/tmp/views"; - - const folders = getFolders(viewsBuildPath); - if (folders.length === 0) return done(); // nothing to do! - - const tasks = folders.map((folder) => { - const themedTasks = merge( - ["light", "dark"].map((theme) => { - const viewHtml = gulp - .src("src/index.html") - .pipe(rename(`index-view-${folder}-${theme}.html`)) - .pipe(i18n(i18nOptions)) - .pipe( - preprocess({ - context: { - view: folder, - theme: theme, - app_prefix: config.app_prefix, - cdn_prefix: config.cdn_prefix, - api_prefix: config.api_prefix, - target: config.target, - }, - includeBase: path.join(viewsBuildPath, folder), - }) - ) - .pipe( - inject( - gulp.src( - path.join( - viewsBuildPath, - folder, - `view-core-merged-${theme}.min.css` - ) - ), - { - starttag: "", - transform: (filePath, file) => file.contents.toString("utf8"), - quiet: true, - removeTags: true, - } - ) - ) - .pipe(gulpif(config.target === "release", htmlmin(htmlminOptions))) - .pipe(gulp.dest("build")); - - const copyCSS = gulp - .src( - path.join(viewsBuildPath, folder, `view-rest-merged-${theme}.css`) - ) - .pipe(csso()) - .pipe(rename(`view-${folder}-rest-${theme}.min.css`)) - .pipe(gulp.dest("build/css")); - - return merge(viewHtml, copyCSS); - }) - ); - - const copyJSCore = gulp - .src([ - "build/js/app-core.js", - path.join(viewsBuildPath, folder, "view-inlined.js"), - ]) - .pipe(concat(`app-core-for-view-${folder}.js`)) - .pipe(i18n(i18nOptions)) - .pipe( - babel({ - presets: [ - [ - "@babel/preset-env", - { - targets: babelTargets, - useBuiltIns: false, - }, - ], - ], - }) - ) - .pipe(gulpif(config.target === "release", uglify())) - .pipe(gulpif(config.target === "release", rename({ suffix: ".min" }))) - .pipe(gulp.dest("build/js")); - - const copyJSRest = gulp - .src(["build/js/app-rest.js", "build/tmp/views/*/view-inlined.js"], { - ignore: path.join(viewsBuildPath, folder, "view-inlined.js"), - }) - .pipe(concat(`app-rest-for-view-${folder}.js`)) - .pipe(i18n(i18nOptions)) - .pipe(babel()) - .pipe(gulpif(config.target === "release", uglify())) - .pipe(gulpif(config.target === "release", rename({ suffix: ".min" }))) - .pipe(gulp.dest("build/js")); - - return merge(themedTasks, copyJSCore, copyJSRest); - }); - - return merge(tasks); -}); - -// --- Legacy JS Pipeline --- -function buildWebpPolyfills() { - return gulp - .src([ - "node_modules/webp-hero/dist-cjs/polyfills.js", - "node_modules/webp-hero/dist-cjs/webp-hero.bundle.js", - ]) - .pipe(concat("webp-hero.min.js")) - .pipe(gulp.dest("build/js")); -} - -function extractPolyfills() { - return ( - gulp - .src(["src/legacy.js", "build/js/app-core.js", "build/js/app-rest.js"]) - .pipe( - preprocess({ - context: { - app_prefix: config.app_prefix, - cdn_prefix: config.cdn_prefix, - api_prefix: config.api_prefix, - target: config.target, - }, - includeBase: "src/js", - }) - ) - .pipe(concat("tmp-merged.js")) - .pipe( - babel({ - presets: [ - [ - "@babel/preset-env", - { - targets: babelTargets, - useBuiltIns: "usage", - corejs: "3.8", - }, - ], - ], - sourceType: "module", - }) - ) - .pipe(splitFiles()) - .pipe(first()) - // Add custom polyfills for missing browser (not ES) features - .pipe(addsrc("node_modules/whatwg-fetch/dist/fetch.umd.js")) - .pipe(concat("polyfills.js")) - .pipe(gulp.dest("build/tmp")) - ); -} - -function insertPolyfills() { - const bundleStream = browserify("./build/tmp/polyfills.js").bundle(); - - return bundleStream.pipe(source("polyfills.js")).pipe(gulp.dest("build/tmp")); -} - -function minifyPolyfills() { - return gulp - .src(["build/tmp/polyfills.js"]) - .pipe(gulpif(config.target === "release", uglify())) - .pipe(gulpif(config.target === "release", rename({ suffix: ".min" }))) - .pipe(gulp.dest("build/js")); -} - -gulp.task( - "legacy_js", - gulp.parallel( - buildWebpPolyfills, - buildOutdatedBrowserJS, - gulp.series(extractPolyfills, insertPolyfills, minifyPolyfills) - ) -); - -// --- I18n Pipeline --- -function i18nCompileLangfiles() { - return gulp - .src(["src/i18n.yaml", "src/views/*/i18n-*.yaml"]) - .pipe(yaml()) - .pipe(i18nCompile("[locale]/_.json", { localePlaceholder: "[locale]" })) - .pipe(gulp.dest("build/tmp/locale")); -} - -// --- Markdown Pipeline --- -const renderer = { - code: (code, infostring) => - `
${code}
`, - link: (href, title, text) => { - if (href.startsWith("http")) - return `${text}`; - return `${text}`; - }, -}; -marked.use({ renderer: renderer }); - -function compileMarkdown() { - return gulp - .src("src/md/*.md") - .pipe( - markdown({ - headerPrefix: "md-", - }) - ) - .pipe(gulp.dest("build/pages")); -} -gulp.task("markdown", compileMarkdown); - -// --- Asset Pipeline --- -function copyAssets() { - return gulp.src("src/assets/**").pipe(gulp.dest("build/assets")); -} -gulp.task("assets", copyAssets); - -// --- Revisioning Pipeline --- -function revisionAssets(done) { - if (config.target !== "release") return done(); - return gulp - .src(["build/index-*.html", "build/js/*.js", "build/assets/*"]) - .pipe( - revAll.revision({ - // Currently .js only, because important css is inlined, and postloaded - // css is deferred using preload, which revAll currently doesn't detect - includeFilesInManifest: [".js", ".webp", ".svg", ".png", ".ico"], - dontRenameFile: [".html"], - transformFilename: (file, hash) => { - const ext = path.extname(file.path); - return `cache_${hash.substr(0, 8)}.${path.basename( - file.path, - ext - )}${ext}`; - }, - }) - ) - .pipe(gulp.dest("build")); -} -gulp.task("revision_assets", revisionAssets); - -// --- Sitemap Pipeline --- -function generateSitemap() { - return gulp - .src(["src/md/*.md", "src/index.html"], { read: false }) - .pipe( - rename((pathObj) => { - if (pathObj.extname === ".md") { - pathObj.dirname = "about"; - pathObj.extname = ""; - } else { - pathObj.dirname = ""; - } - }) - ) - .pipe( - sitemap({ - siteUrl: "https://nav.tum.de/", - fileName: "sitemap-webclient.xml", - changefreq: "monthly", - }) - ) - .pipe(gulp.dest("build")); -} -gulp.task("sitemap", generateSitemap); - -// --- .well-known Pipeline --- -// see https://well-known.dev/sites/ -function copyWellKnown() { - return gulp - .src([ - "src/.well-known/gpc.json", // we don't sell or share data - "src/.well-known/security.txt", - ]) // security-advice - .pipe(gulp.dest("build/.well-known")); -} -function copyWellKnownRoot() { - return gulp - .src([ - "src/.well-known/robots.txt", // disallow potentially costly api requests - "src/.well-known/googlebef9161f1176c5e0.html", - ]) // google search console - .pipe(gulp.dest("build")); -} -gulp.task("well_known", gulp.parallel(copyWellKnown, copyWellKnownRoot)); - -// --- map (currently maplibre) Pipeline --- -function copyMapCSS() { - return gulp - .src(["node_modules/maplibre-gl/dist/maplibre-gl.css"]) - .pipe(concat("maplibre.css")) - .pipe(gulpif(config.target === "release", csso())) - .pipe(gulpif(config.target === "release", rename({ suffix: ".min" }))) - .pipe(gulp.dest("build/css")); -} -function copyMapJS() { - return gulp - .src(["node_modules/maplibre-gl/dist/maplibre-gl.js"]) - .pipe(concat("maplibre.js")) - .pipe(gulpif(config.target === "release", uglify())) - .pipe(gulpif(config.target === "release", rename({ suffix: ".min" }))) - .pipe(gulp.dest("build/js")); -} -gulp.task("map", gulp.parallel(copyMapCSS, copyMapJS)); - -// --- api-visualiser (currently swagger-ui) Pipeline --- -function copyApiCSS() { - // swagger-ui has its own loading spinner, but it is apparently broken if we included it - const loadingCSS = { - ".swagger-ui .loading-container": "*", - ".swagger-ui .loading-container .loading": "*", - ".swagger-ui .loading-container .loading:before": "*", - ".swagger-ui .loading-container .loading::before": "*", - ".swagger-ui .loading-container .loading:after": "*", - ".swagger-ui .loading-container .loading::after": "*", - }; - return gulp - .src("node_modules/swaggerdark/SwaggerDark.css") - .pipe( - postcss([ - postcssRemoveDeclaration({ remove: loadingCSS }), - postcssPrependSelector({ selector: "body.theme-dark #swagger-ui " }), - ]) - ) - .pipe(csso()) - .pipe(addsrc.prepend("node_modules/swagger-ui-dist/swagger-ui.css")) - .pipe(postcss([postcssRemoveDeclaration({ remove: loadingCSS })])) - .pipe(concat("swagger-ui.min.css")) // swagger-ui is already minified => minifying here does not make sense - .pipe(gulp.dest("build/css")); -} -function copyApiJS() { - return gulp - .src(["node_modules/swagger-ui-dist/swagger-ui-bundle.js"]) - .pipe(concat("swagger-ui.min.js")) // swagger-ui is already minified => minifying here does not make sense - .pipe(gulp.dest("build/js")); -} -gulp.task("api", gulp.parallel(copyApiCSS, copyApiJS)); - -const _build = gulp.series( - cleanBuild, - i18nCompileLangfiles, - gulp.parallel( - "main_css", - "main_js", - "views", - "assets", - "well_known", - "map", - "api", - "markdown", - "sitemap" - ), - gulp.series("pages_src", "pages_out", "legacy_js", "revision_assets") -); - -const build = gulp.series((done) => { - config = configLocal; - config.target = "develop"; - done(); -}, _build); - -const release = gulp.series((done) => { - config = configRelease; - config.target = "release"; - done(); -}, _build); - -export default build; -export { release }; diff --git a/webclient/index.html b/webclient/index.html new file mode 100644 index 000000000..6dc284b4e --- /dev/null +++ b/webclient/index.html @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + NavigaTUM + + + + + + +
+ + + + + + + diff --git a/webclient/nginx.conf b/webclient/nginx.conf index 8db4e345d..e93116e0b 100644 --- a/webclient/nginx.conf +++ b/webclient/nginx.conf @@ -81,10 +81,11 @@ http { root /app; # metadata - location = /robots.txt { access_log off; } - location = /googlebef9161f1176c5e0.html { access_log off; } # google search console - location = /sitemap-webclient.xml { access_log off; } - location = /favicon.ico { access_log off; root /app/assets/;} + location = /robots.txt { access_log off; root /app/assets/; } + location = /googlebef9161f1176c5e0.html { access_log off; root /app/assets/; } # google search console + location = /sitemap-webclient.xml { access_log off; root /app/assets/; } + location = /favicon.ico { access_log off; root /app/; } + location = /logo-card.png { access_log off; root /app/; } # These Files are intenitonally not supported location = /adds.txt { log_not_found off; access_log off; } location = /app-ads.txt { log_not_found off; access_log off; } @@ -97,10 +98,10 @@ http { } if ($no_js_exec = 1) { - rewrite ^/(((search|about|view|campus|site|building|room)/.*)?)$ /rendertron/$1; + rewrite ^/(((api|search|about|view|campus|site|building|room)/?.*)?)$ /rendertron/$1; } # equivalent to: - #if ($uri ~ ^/((js|css|\.well-known|pages|assets)/.*|(robots.txt|googlebef9161f1176c5e0.html|adds.txt|app-ads.txt|favicon.ico|health|404.html|50x.html))$){ + #if ($uri ~ ^/((\.well-known|assets)/.*|(robots.txt|googlebef9161f1176c5e0.html|adds.txt|app-ads.txt|favicon.ico|health|404.html|50x.html))$){ # set $no_js_exec 0; #} @@ -112,48 +113,23 @@ http { proxy_pass https://nav.tum.de/rendertron/render/https://nav.tum.de$request_uri; } - location = / { + location ^~ / { # disable caching add_header Cache-Control no-cache; - expires 1s; - try_files /index-view-main-$THEME-$LANG.html /404.html; + # 360s=5min + expires 360s; + try_files /$THEME-$LANG.html /404.html; } - location ^~ /search { - # disable caching - add_header Cache-Control no-cache; - expires 1s; - try_files /index-view-search-$THEME-$LANG.html /404.html; - } - - location ^~ /api { - # disable caching - add_header Cache-Control no-cache; - expires 1s; - try_files /index-view-api-$THEME-$LANG.html /404.html; - } - - location ^~ /about/ { - # disable caching - add_header Cache-Control no-cache; - expires 1s; - try_files /index-view-md-$THEME-$LANG.html /404.html; - } - - location ~ ^/(view|campus|site|building|room)/.*$ { - # disable caching - add_header Cache-Control no-cache; - expires 1s; - try_files /index-view-view-$THEME-$LANG.html /404.html; - } - - location ~ ^/(js|assets)/cache_.*$ { + location ^~ /assets/ { access_log off; - expires 30d; + expires 360d; add_header Cache-Control "public"; + access_log off; + try_files $uri /404.html; } - location ~ ^/(js|css|assets|\.well-known|pages)/.*$ { + location ^~ /.well-known/ { access_log off; try_files $uri /404.html; } @@ -163,7 +139,7 @@ http { root /usr/share/nginx/html; } - error_page 404 /index-view-404-$THEME-$LANG.html; + error_page 404 /$THEME-$LANG.html; location = /404.html { return 404 'Requested Resource Not Found'; } diff --git a/webclient/package.json b/webclient/package.json index 36398e90c..85f55ded4 100644 --- a/webclient/package.json +++ b/webclient/package.json @@ -1,133 +1,46 @@ { "name": "navigatum", "version": "0.1.0", - "description": "", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" - }, - "type": "module", - "author": "", - "license": "ISC", - "eslintConfig": { - "extends": [ - "@paulhfischer/eslint-config-javascript" - ], - "ignorePatterns": [ - "**/googlebef9161f1176c5e0.html" - ], - "env": { - "browser": true - }, - "globals": { - "navigatum": true, - "Vue": true, - "VueRouter": true - }, - "rules": { - "prefer-destructuring": "off", - "no-plusplus": [ - "error", - { - "allowForLoopAfterthoughts": true - } - ], - "no-param-reassign": "off", - "no-underscore-dangle": "off", - "object-shorthand": [ - "error", - "never" - ], - "func-names": [ - "error", - "as-needed" - ], - "no-template-curly-in-string": "off", - "no-console": [ - "error", - { - "allow": [ - "warn", - "error" - ] - } - ] - } - }, - "prettier": { - "extends": [ - "@paulhfischer/prettier-config" - ], - "rules": { - "tab-width": 2 - } - }, - "stylelint": { - "plugins": [ - "stylelint-scss" - ], - "extends": [ - "@paulhfischer/stylelint-config" - ], - "rules": { - "color-function-notation": "legacy", - "at-rule-no-unknown": null, - "scss/at-rule-no-unknown": true, - "selector-max-id": null, - "max-nesting-depth": null, - "number-leading-zero": "never" - } - }, - "devDependencies": { - "browserify": "^17.0.0", - "core-js": "^3.16.0", - "delete": "^1.1.0", - "gulp": "^4.0.2", - "gulp-add-src": "^1.0.0", - "gulp-babel": "^8.0.0", - "gulp-concat": "^2.6.1", - "gulp-csscss": "^0.1.2", - "gulp-csso": "^4.0.1", - "gulp-first": "^1.0.7", - "gulp-html-i18n": "^0.16.0", - "gulp-htmlmin": "^5.0.1", - "gulp-i18n-compile": "^1.0.1", - "gulp-if": "^3.0.0", - "gulp-inject": "^5.0.5", - "gulp-inject-string": "^1.1.2", - "gulp-inject-stringified-html": "^2.0.0-alpha.2", - "gulp-markdown": "^7.0.0", - "gulp-postcss": "^9.0.1", - "gulp-preprocess": "^4.0.2", - "gulp-purgecss": "^5.0.0", - "gulp-rename": "^2.0.0", - "gulp-rev-all": "^4.0.0", - "gulp-sass": "^5.0.0", - "gulp-sitemap": "^8.0.0", - "gulp-split-files": "^1.2.3", - "gulp-uglify": "^3.0.2", - "gulp-yaml": "^2.0.4", - "merge-stream": "^2.0.0", - "postcss": "^8.4.16", - "postcss-prepend-selector": "^0.3.1", - "postcss-remove-declaration": "^1.0.0", - "sass": "^1.52.3", - "stylelint-scss": "^4.2.0", - "swagger-ui-dist": "^4.13.2", - "vinyl-source-stream": "^2.0.0" + "dev": "vite", + "build": "run-p type-check build-only", + "preview": "vite preview --port 4173", + "build-only": "vite build", + "type-check": "vue-tsc --noEmit", + "lint": "eslint . --ext .vue,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore" }, "dependencies": { - "@babel/core": "^7.14.8", - "@babel/preset-env": "^7.14.8", - "babel-plugin-transform-remove-strict-mode": "^0.0.2", "maplibre-gl": "^2.4.0", - "polyfill": "^0.1.0", - "regenerator": "^0.14.7", - "regenerator-runtime": "^0.13.9", + "pinia": "^2.0.28", "spectre.css": "github:Valexr/spectre#dfe3bc2c59d23cd4bfd43c690aae3655576ff708", - "swaggerdark": "github:octycs/SwaggerDark", - "vue": "^2.6.12", - "vue-router": "^3.5.1", - "webp-hero": "^0.0.2", - "whatwg-fetch": "^3.6.2" - } + "swagger-ui-dist": "^4.15.5", + "swaggerdark": "github:octycs/SwaggerDark#f02d394c8ff698cdd93e09c2188b058d2d686ca3", + "vue": "^3.2.45", + "vue-router": "^4.1.6" + }, + "devDependencies": { + "@intlify/unplugin-vue-i18n": "^0.8.1", + "@rushstack/eslint-patch": "^1.2.0", + "@types/node": "^18.11.18", + "@types/swagger-ui-dist": "^3.30.1", + "@vitejs/plugin-vue": "^4.0.0", + "@vue/eslint-config-prettier": "^7.0.0", + "@vue/eslint-config-typescript": "^11.0.2", + "@vue/tsconfig": "^0.1.3", + "@yankeeinlondon/link-builder": "^1.2.1", + "eslint": "^8.31.0", + "eslint-plugin-vue": "^9.8.0", + "isomorphic-fetch": "^3.0.0", + "npm-run-all": "^4.1.5", + "prettier": "^2.8.1", + "sass": "^1.57.1", + "stylelint-scss": "^4.3.0", + "typescript": "^4.9.4", + "vite": "^4.0.4", + "vite-plugin-md": "^0.21.5", + "vite-plugin-rewrite-all": "^1.0.1", + "vue-i18n": "^9.2.2", + "vue-tsc": "^1.0.22" + }, + "type": "module" } diff --git a/webclient/src/.well-known/googlebef9161f1176c5e0.html b/webclient/public/.well-known/googlebef9161f1176c5e0.html similarity index 100% rename from webclient/src/.well-known/googlebef9161f1176c5e0.html rename to webclient/public/.well-known/googlebef9161f1176c5e0.html diff --git a/webclient/src/.well-known/gpc.json b/webclient/public/.well-known/gpc.json similarity index 100% rename from webclient/src/.well-known/gpc.json rename to webclient/public/.well-known/gpc.json diff --git a/webclient/src/.well-known/robots.txt b/webclient/public/.well-known/robots.txt similarity index 100% rename from webclient/src/.well-known/robots.txt rename to webclient/public/.well-known/robots.txt diff --git a/webclient/src/.well-known/security.txt b/webclient/public/.well-known/security.txt similarity index 100% rename from webclient/src/.well-known/security.txt rename to webclient/public/.well-known/security.txt diff --git a/webclient/src/assets/favicon.ico b/webclient/public/favicon.ico similarity index 100% rename from webclient/src/assets/favicon.ico rename to webclient/public/favicon.ico diff --git a/webclient/shims.d.ts b/webclient/shims.d.ts new file mode 100644 index 000000000..053838e5e --- /dev/null +++ b/webclient/shims.d.ts @@ -0,0 +1,11 @@ +declare module "*.vue" { + import type { ComponentOptions } from "vue"; + const Component: ComponentOptions; + export default Component; +} + +declare module "*.md" { + import type { ComponentOptions } from "vue"; + const Component: ComponentOptions; + export default Component; +} diff --git a/webclient/src/App.vue b/webclient/src/App.vue new file mode 100644 index 000000000..33da9f1f1 --- /dev/null +++ b/webclient/src/App.vue @@ -0,0 +1,276 @@ + + + + + diff --git a/webclient/src/api_types/index.ts b/webclient/src/api_types/index.ts new file mode 100644 index 000000000..57a30707c --- /dev/null +++ b/webclient/src/api_types/index.ts @@ -0,0 +1,1174 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export type paths = { + "/api/search": { + /** + * Search entries + * @description This endpoint is designed to support search-as-you-type results. + * + * Instead of simply returning a list, the search results are returned in a way to provide a richer experience by splitting them up into sections. You might not necessarily need to implement all types of sections, or all sections features (if you just want to show a list). The order of sections is a suggested order to display them, but you may change this as you like. + * + * Some fields support highlighting the query terms and it uses \x19 and \x17 to mark the beginning/end of a highlighted sequence. + * (See [Wikipedia](https://en.wikipedia.org/wiki/C0_and_C1_control_codes#Modified_C0_control_code_sets)). + * Some text-renderers will ignore them, but in case you do not want to use them, you might want to remove them from the responses via empty `pre_highlight` and `pre_highlight` query parameters. + */ + get: operations["search"]; + }; + "/api/get/{id}": { + /** + * Get entry-details + * @description This returns the full data available for the entry (room/building). + * + * This is more data, that should be supplied once a user clicks on an entry. + * Preloading this is not an issue on our end, but keep in mind bandwith constraints on your side. + * The data can be up to 50kB (using gzip) or 200kB unzipped. + * The more about this data format is described in the NavigaTUM-data documentation + */ + get: operations["details"]; + }; + "/api/preview/{id}": { + /** + * Get a entry-preview + * @description This returns the a 1200x630px preview for the entry (room/building/..). + * + * This is usefull for implementing custom OpenGraph images for detail previews. + */ + get: operations["previews"]; + }; + "/api/source_code": { + /** + * Get link to running source-code + * @description This endpoint returns a link to the source-code of the repository at the currently running version. + * This endpoint is not required for modifications (as the license is not AGPL), but strongly encouraged. + */ + get: operations["source_code"]; + }; + "/api/feedback/source_code": { + /** + * Get link to running source-code + * @description This endpoint returns a link to the source-code of the repository at the currently running version. + * This endpoint is not required for modifications (as the license is not AGPL), but strongly encouraged. + */ + get: operations["feedback-source_code"]; + }; + "/api/feedback/get_token": { + /** + * Get a feedback-token + * @description ***Do not abuse this endpoint.*** + * + * This returns a JWT token usable for submitting feedback. + * You should request a token, ***if (and only if) a user is on a feedback page*** + * + * As a rudimentary way of rate-limiting feedback, this endpoint returns a token. + * To post feedback, you will need this token. + * + * Tokens gain validity after 5s, and are invalid after 12h of being issued. + * They are not refreshable, and are only valid for one usage. + * + * Global Rate-Limiting: + * - hourly: 20 tokens per hour + * - daily: 50 tokens per + */ + post: operations["get_token"]; + }; + "/api/feedback/feedback": { + /** + * Post feedback + * @description ***Do not abuse this endpoint.*** + * + * This posts the actual feedback to github and returns the github link. + * For this Endpoint to work, you need to generate a token via the `/api/feedback/get_token` endpoint. + * + * ***Important Note:*** Tokens are only used if we return a 201 Created response. Otherwise, they are still valid + */ + post: operations["feedback"]; + }; + "/cdn/{size}/{id}_{counter}.webp": { + /** + * Get title images + * @description This endpoint is designed to fetch the images, that are described by the `/api/get/{id}`-endpoint. + * You HAVE to get the proper attribution from that endpoint and use it. + */ + get: operations["img_cdn"]; + }; + "/cdn/maps/{source}/{id}.webp": { + /** + * Get title images + * @description This endpoint is designed to fetch the images, that are described by the `/api/get/{id}`-endpoint. + * You HAVE to get the proper attribution from that endpoint and use it. + */ + get: operations["maps_cdn"]; + }; + "/api/legacy_redirect/{arch_name}": { + /** + * Get a redirect to our roomfinder + * @description The old roomfinder still exists and adoption of our new system is not great. + * This is a redirect route which can be a direct redirect for the old room-finder. + * + * ***THIS WILL DISAPEAR IN THE FUTURE, DO NOT RELY ON IT.*** + * ***This is only here while TUM is transitioning to this system.*** + * + * After 1-2 years, we will introduce some text to nudging those, + * who still have not changed their links, as otherwise we assume this transition will never be done... + * Said nudge will include information on who to contact if updating the website is not possible and + * tell the users what link to exchange with what other link. + * Redirecting to y after a button click or something similar is probably good. + * + * THIS IS NOT A PERMANENT SOLUTION, AND WILL BE REMOVED IN THE FUTURE + * + * The reason, why this is not a dumb redirect is, that the old roomfinder has a lot of bugs and `arch_name` not being unique, nor an id is one of them. + * We dont want to have two ids for obvious reasons, this is why we dont accept this as an alias + */ + get: operations["legacy_redirect"]; + }; + "/api/health": { + /** + * API healthcheck + * @description If this endpoint does not return 200, the API is experiencing a catastrophic outage. Should never happen. + */ + get: operations["api-health"]; + }; + "/api/feedback/health": { + /** + * feedback-API healthcheck + * @description If this endpoint does not return 200, the API is experiencing a catastrophic outage. Should never happen. + */ + get: operations["feedback-health"]; + }; + "/cdn/health": { + /** + * CDN healthcheck + * @description If this endpoint does not return 200, the CDN is experiencing a catastrophic outage. Should never happen. + */ + get: operations["cdn-health"]; + }; + "/health": { + /** + * Website healthcheck + * @description If this endpoint does not return 200, the Website is experiencing a catastrophic outage. Should never happen. + */ + get: operations["web-health"]; + }; +}; + +export type webhooks = Record; + +export type components = { + schemas: { + /** @description Data for the info-card table */ + readonly Props: { + readonly computed: readonly components["schemas"]["ComputedProp"][]; + readonly links?: readonly components["schemas"]["LinkProp"][]; + /** + * @description A comment to show to an entry. + * It is used in the rare cases, where some aspect about the rooom/.. or its translation are misleading. + * An example of a room with a comment is MW1801. + */ + readonly comment?: string; + /** + * @example [ + * "https://campus.tum.de/tumonline/tvKalender.wSicht?cOrg=19691&cRes=12543&cReadonly=J", + * "https://campus.tum.de/tumonline/tvKalender.wSicht?cOrg=19691&cRes=12559&cReadonly=J" + * ] + */ + readonly calendar_url?: Record; + }; + readonly ComputedProp: { + /** @example Raumkennung */ + readonly name: string; + /** @example 5602.EG.001 */ + readonly text: string; + }; + /** @description A link with a localized link text and url */ + readonly LinkProp: { + readonly text: { + readonly de: string; + readonly en: string; + }; + readonly url: { + readonly de: string; + readonly en: string; + }; + }; + /** @description The information you need to request Images from the /cdn/{size}/{id}_{counter}.webp endpoint */ + readonly ImageInfo: { + /** + * @description The name of the image file. consists of {building_id}_{image_id}.webp, where image_id is a counter starting at 0 + * @example mi_0.webp + */ + readonly name: string; + readonly author: components["schemas"]["PossibleURLRef"]; + readonly source: components["schemas"]["PossibleURLRef"]; + readonly license: components["schemas"]["PossibleURLRef"]; + readonly meta?: components["schemas"]["ImageMetadata"]; + }; + /** + * @description Aditional data about the images. Does not have to be displayed. + * All fields are optional. + */ + readonly ImageMetadata: { + /** @description optional date description */ + readonly date?: string; + /** @description optional location description */ + readonly location?: string; + /** @description optional coordinates in lat,lon */ + readonly geo?: string; + /** + * @description optional in contrast to source this points to the image itself. + * You should not use this to request the images, as they are not scaled. + */ + readonly image_url?: string; + /** @description optional caption */ + readonly caption?: string; + /** @description optional headline */ + readonly headline?: string; + /** @description optional the event this image was taken at */ + readonly event?: string; + /** @description optional the event this image is about */ + readonly faculty?: string; + /** @description optional the building this image is about */ + readonly building?: string; + /** @description optional the department this image is about */ + readonly department?: string; + }; + /** @description Additional information you should include, if you decide to display the image for legal and attribution reasons */ + readonly PossibleURLRef: { + /** @description The text to display */ + readonly text: string; + /** @description The URL to the referenced information. Always either null or a valid URL */ + readonly url?: string | null; + }; + readonly Coordinate: { + /** + * Format: double + * @description The latitude + * @example 48.26244490906312 + */ + readonly lat: number; + /** + * Format: double + * @description The latitude + * @example 48.26244490906312 + */ + readonly lon: number; + /** + * @description The source of the Coordinates + * @example roomfinder + */ + readonly source: string; + readonly utm?: { + /** + * Format: double + * @description The easting + * @example 698288.4681410069 + */ + readonly easting?: number; + /** + * Format: double + * @description The northing + * @example 5349538.736274569 + */ + readonly northing?: number; + /** + * @description The zone + * @example U + */ + readonly zone?: string; + /** + * Format: int32 + * @description The zone number + * @example 32 + */ + readonly zone_number?: number; + }; + }; + readonly Maps: { + /** + * @description The type of the Map that should be shown by default + * @example interactive + * @enum {string} + */ + readonly default: "interactive" | "roomfinder"; + readonly roomfinder?: components["schemas"]["RoomfinderMap"]; + /** + * @description null would mean no overlay maps are displayed by default. + * For rooms you should add a warning that no floor map is available for this room + */ + readonly overlays?: { + /** + * @description The floor-id of the map, that should be shown as a default. + * null: + * - We suggest, you dont show a map by default. + * - This is only the case for buildings or other such entities and not for rooms, if we know where they are and a map exists + * + * @example 0 + */ + readonly default: number | null; + readonly available: readonly components["schemas"]["OverlayMapEntry"][]; + } | null; + }; + readonly RoomfinderMap: { + /** + * @description The id of the map, that should be shown as a default + * @example rf142 + */ + readonly default: string; + readonly available: readonly components["schemas"]["RoomfinderMapEntry"][]; + }; + readonly RoomfinderMapEntry: { + /** + * @description The human-readable name of the map + * @example FMI Übersicht + */ + readonly name: string; + /** + * @description The machine-readable name of the map + * @example rf142 + */ + readonly id: string; + /** + * @description Scale of the map. 2000 means 1:2000. + * @example 2000 + */ + readonly scale: string; + /** + * Format: int32 + * @description Map image x dimensions + * @example 461 + */ + readonly height: number; + /** + * Format: int32 + * @description Map image y dimensions + * @example 639 + */ + readonly width: number; + /** + * Format: int32 + * @description x Position on map + * @example 499 + */ + readonly x: number; + /** + * Format: int32 + * @description y Position on map image + * @example 189 + */ + readonly y: number; + }; + readonly OverlayMapEntry: { + /** + * @description The machine-readable floor-id of the map. + * Should start with 0 for the ground level (defined by the main entrance) and increase or decrease. + * It is not guaranteed that numbers are consecutive or that `1` corresponds to level `01`, because buildings sometimes have more complicated layouts. They are however always in the correct (physical) order. + * + * @example 0 + */ + readonly id: number; + /** + * @description Floor of the Map. + * Should be used for display to the user in selectors. + * Matches the floor part of the TUMonline roomcode. + * + * @example EG + */ + readonly floor: string; + /** + * @description The human-readable name of the map + * @example MI Gebäude (EG) + */ + readonly name: string; + /** + * @description The filename of the map + * @example webp/rf95.webp + */ + readonly file: string; + /** @description Coordinates are four [lon, lat] pairs, for the top left, top right, bottom right, bottom left image corners. */ + readonly coordinates: readonly (readonly number[])[]; + }; + readonly Rooms: { + /** + * @description These indicate the type of item this represents + * @example rooms + * @enum {string} + */ + readonly facet: "rooms"; + /** + * Format: int64 + * @description The estimated (not exact) number of hits for that query + */ + readonly estimatedTotalHits: number; + readonly entries: readonly components["schemas"]["RoomEntry"][]; + }; + readonly SitesBuildings: { + /** + * @description These indicate the type of item this represents + * @example sites_buildings + * @enum {string} + */ + readonly facet: "sites_buildings"; + /** + * Format: int64 + * @description The estimated (not exact) number of hits for that query + */ + readonly estimatedTotalHits: number; + readonly entries: readonly components["schemas"]["SitesBuildingsEntry"][]; + /** + * Format: int64 + * @description A recommendation how many of the entries should be displayed by default. + * The number is usually from 0-5. + * More results might be displayed when clicking "expand". + * If this field is not present, then all entries are displayed. + */ + readonly n_visible?: number; + }; + readonly RoomEntry: components["schemas"]["SitesBuildingsEntry"] & { + /** @description Subtext to show below the search (by default in bold and after the non-bold subtext). Usually contains the arch-id of the room, which is another common room id format, and supports highlighting. */ + readonly subtext_bold: string; + /** @description This is an optional feature, that is only supported for some rooms. It might be displayed instead or before the name, to show that a different room id format has matched, that was probably used. See the image below for an example. It will be cropped to a maximum length to not take too much space in UIs. Supports highlighting. */ + readonly parsed_id?: string; + }; + readonly SitesBuildingsEntry: { + /** @description The id of the room */ + readonly id: string; + /** @description the type of the room/site/building */ + readonly type: string; + /** @description Subtext to show below the search result. Usually contains the context of where this rooms is located in. Currently not highlighted. */ + readonly name: string; + /** @description Subtext to show below the search result. Usually contains the context of where this rooms is located in. Currently not highlighted. */ + readonly subtext: string; + }; + readonly TokenResponse: { + /** @description The JWT token, that can be used to generate feedback */ + readonly token: string; + }; + readonly SearchResponse: { + readonly sections: readonly (components["schemas"]["SitesBuildings"] | components["schemas"]["Rooms"])[]; + /** + * Format: int32 + * @description Time the search took in the server side, not including network delay + * Maximum as by awc timeout. other timeouts (browser, your client) may be smaller + * Expected average is 10..50 for uncached, regular requests + * + * @example 42 + */ + readonly time_ms: number; + }; + readonly DetailsResponse: components["schemas"]["BaseDetailsResponse"] & { + /** @description The id, that was requested */ + readonly id: string; + /** + * @description The type of the entry + * @enum {string} + */ + readonly type: "room" | "building" | "joined_building" | "area" | "site" | "campus"; + readonly coords: components["schemas"]["Coordinate"]; + readonly maps: components["schemas"]["Maps"]; + readonly sections?: components["schemas"]["DetailsSection"]; + }; + readonly DetailsSection: { + readonly buildings_overview?: { + readonly entries: readonly { + /** @description The id of the building */ + readonly id: string; + /** @description Main display name */ + readonly name: string; + /** + * @description What should be displayed below this Building + * @example Gebäudekomplex mit 512 Räumen + */ + readonly subtext: string; + /** + * @description The thumbnail for the building + * @example mi_0.webp + */ + readonly thumb?: string; + }[]; + /** @example 6 */ + readonly n_visible: number; + }; + readonly rooms_overview?: { + readonly usages?: readonly { + /** + * @description Category Name + * @example Büro + */ + readonly name: string; + /** + * @description How many children this category has + * @example 126 + */ + readonly count: number; + readonly children: { + /** @description The id of the building */ + readonly id: string; + /** @description Main display name */ + readonly name: string; + }; + }[]; + }; + }; + readonly RootResponse: components["schemas"]["BaseDetailsResponse"] & { + /** + * @description The id, that was requested + * @enum {string} + */ + readonly id: "root"; + /** + * @description The type of the entry + * @enum {string} + */ + readonly type: "root"; + }; + readonly BaseDetailsResponse: { + /** @description The type of the entry in a human-readable form */ + readonly type_common_name: string; + /** + * @description The name of the entry in a human-readable form + * @example 5602.EG.001 (MI HS 1, Friedrich L. Bauer Hörsaal) + */ + readonly name: string; + /** + * @description The name of the entry in the LEGACY format of the old roomfinder. + * This is only present for some rooms, and only if the room is in the legacy system. + * + * ***THIS WILL DISAPEAR IN THE FUTURE, DO NOT RELY ON IT.*** + * This is only here while TUM is transitioning to this system. + * + * @example 5602.EG.001 (MI HS 1, Friedrich L. Bauer Hörsaal) + */ + readonly arch_name?: string; + /** + * @example [ + * "root", + * "garching" + * ] + */ + readonly parents: readonly string[]; + readonly parent_names: readonly string[]; + readonly props: components["schemas"]["Props"]; + readonly imgs?: readonly components["schemas"]["ImageInfo"][]; + readonly ranking_factors: components["schemas"]["RankingFactors"]; + }; + readonly RankingFactors: { + /** + * Format: int32 + * @description How much the combined ranking is important + */ + readonly rank_combined: number; + /** + * Format: int32 + * @description How much the type is important + */ + readonly rank_type: number; + /** + * Format: int32 + * @description How much the usage is important + */ + readonly rank_usage: number; + /** + * Format: int32 + * @description Automatic boost or suppression based on entry properties: + * - numbers of buildings for a `campus`/`area`/`site`, + * - numbers of seats for a `room`, + * - number of regular rooms for a `building`/`joined_building` + */ + readonly rank_boost?: number; + /** + * Format: int32 + * @description Custom boost or suppression factor defined by us + */ + readonly rank_custom?: number; + }; + readonly TokenRequest: { + /** + * @description The JWT token, that can be used to generate feedback + * @example eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE2Njk2MzczODEsImlhdCI6MTY2OTU5NDE4MSwibmJmIjoxNjY5NTk0MTkxLCJraWQiOjE1ODU0MTUyODk5MzI0MjU0Mzg2fQ.sN0WwXzsGhjOVaqWPe-Fl5x-gwZvh28MMUM-74MoNj4 + */ + readonly token: string; + /** + * @description The category of the feedback. + * Enum attribute is softly enforced: Any value not listed below will be replaced by "other" + * + * @example bug + * @enum {string} + */ + readonly category: "general" | "bug" | "feature" | "search" | "entry" | "other"; + /** + * @description The subject/title of the feedback + * @example A catchy title + */ + readonly subject: string; + /** + * @description The body/description of the feedback + * @example A clear description what happened where and how we should improve it + */ + readonly body: string; + /** + * @description Whether the user has checked the privacy-checkbox. + * We are posting the feedback publicly on GitHub (not a EU-Company). You have to also include such a checkmark. + * For inspiration on how to do this, see our website. + * + * @example true + */ + readonly privacy_checked: boolean; + /** + * @description Whether the user has requested to delete the issue. + * If the user has requested to delete the issue, we will delete it from GitHub after processing it + * If the user has not requested to delete the issue, we will not delete it from GitHub and it will remain as a closed issue. + * + * @example true + */ + readonly delete_issue_requested: boolean; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +}; + +export type external = Record; + +export type operations = { + search: { + /** + * Search entries + * @description This endpoint is designed to support search-as-you-type results. + * + * Instead of simply returning a list, the search results are returned in a way to provide a richer experience by splitting them up into sections. You might not necessarily need to implement all types of sections, or all sections features (if you just want to show a list). The order of sections is a suggested order to display them, but you may change this as you like. + * + * Some fields support highlighting the query terms and it uses \x19 and \x17 to mark the beginning/end of a highlighted sequence. + * (See [Wikipedia](https://en.wikipedia.org/wiki/C0_and_C1_control_codes#Modified_C0_control_code_sets)). + * Some text-renderers will ignore them, but in case you do not want to use them, you might want to remove them from the responses via empty `pre_highlight` and `pre_highlight` query parameters. + */ + parameters: { + /** + * @description string you want to search for. + * Note, that the amounts returned can be controlled using the limit\* paramerters. + */ + /** + * @description Maximum number of buildings/sites to return. + * Clamped to 0..1000. If this is an problem for you, please open an issue. + */ + /** + * @description Maximum number of rooms to return. + * Clamped to 0..1000. If this is an problem for you, please open an issue. + */ + /** + * @description Overall maximum number of results. Only visible results are counted (i.e. hidden buildings are not counted). + * Clamped to 1..1000. If this is an problem for you, please open an issue. + */ + /** + * @description string to include in front of highlighted sequences. + * If this and `post_highlight` are empty, highlighting is disabled. + */ + /** + * @description string to include after the highlighted sequences. + * If this and `pre_highlight` are empty, highlighting is disabled. + */ + readonly query: { + q: string; + limit_buildings?: number; + limit_rooms?: number; + limit_all?: number; + pre_highlight?: string; + post_highlight?: string; + }; + }; + responses: { + /** @description The search-result */ + 200: { + content: { + readonly "application/json": components["schemas"]["SearchResponse"]; + }; + }; + /** @description Invalid Request */ + 400: { + content: { + readonly "text/plain": "Invalid Request"; + }; + }; + /** @description `search_query` is empty. Since searching for nothing is nonsensical, we dont support this. */ + 404: { + content: { + readonly "text/plain": "Not found"; + }; + }; + /** @description The uri you are trying to request is unreasonably long. Search querys dont have thousands of chars.. */ + 414: { + content: { + readonly "text/plain": "Uri too long"; + }; + }; + }; + }; + details: { + /** + * Get entry-details + * @description This returns the full data available for the entry (room/building). + * + * This is more data, that should be supplied once a user clicks on an entry. + * Preloading this is not an issue on our end, but keep in mind bandwith constraints on your side. + * The data can be up to 50kB (using gzip) or 200kB unzipped. + * The more about this data format is described in the NavigaTUM-data documentation + */ + parameters: { + /** @description The language you want your details to be in. If either this or the query parameter is set to en, this will be delivered. */ + readonly query?: { + lang?: "de" | "en"; + }; + /** @description string you want to search for */ + readonly path: { + id: string; + }; + }; + responses: { + /** @description More data about the requested building/room */ + 200: { + content: { + readonly "application/json": components["schemas"]["RootResponse"] | components["schemas"]["DetailsResponse"]; + }; + }; + /** @description Invalid input */ + 404: { + content: { + readonly "text/plain": "Not found"; + }; + }; + }; + }; + previews: { + /** + * Get a entry-preview + * @description This returns the a 1200x630px preview for the entry (room/building/..). + * + * This is usefull for implementing custom OpenGraph images for detail previews. + */ + parameters: { + /** @description The language you want your preview to be in. If either this or the query parameter is set to en, this will be delivered. */ + readonly query?: { + lang?: "de" | "en"; + }; + /** @description string you want to search for */ + readonly path: { + id: string; + }; + }; + responses: { + /** @description More data about the requested building/room */ + 200: { + content: { + readonly "image/png": unknown; + }; + }; + /** @description Invalid input */ + 404: { + content: { + readonly "text/plain": "Not found"; + }; + }; + }; + }; + source_code: { + /** + * Get link to running source-code + * @description This endpoint returns a link to the source-code of the repository at the currently running version. + * This endpoint is not required for modifications (as the license is not AGPL), but strongly encouraged. + */ + responses: { + /** @description The link to the source-code of the repository at the currently running version */ + 200: { + content: { + readonly "text/plain": string; + }; + }; + }; + }; + "feedback-source_code": { + /** + * Get link to running source-code + * @description This endpoint returns a link to the source-code of the repository at the currently running version. + * This endpoint is not required for modifications (as the license is not AGPL), but strongly encouraged. + */ + responses: { + /** @description The link to the source-code of the repository at the currently running version */ + 200: { + content: { + readonly "text/plain": string; + }; + }; + }; + }; + get_token: { + /** + * Get a feedback-token + * @description ***Do not abuse this endpoint.*** + * + * This returns a JWT token usable for submitting feedback. + * You should request a token, ***if (and only if) a user is on a feedback page*** + * + * As a rudimentary way of rate-limiting feedback, this endpoint returns a token. + * To post feedback, you will need this token. + * + * Tokens gain validity after 5s, and are invalid after 12h of being issued. + * They are not refreshable, and are only valid for one usage. + * + * Global Rate-Limiting: + * - hourly: 20 tokens per hour + * - daily: 50 tokens per + */ + responses: { + /** @description Returns a usable token */ + 201: { + content: { + readonly "application/json": components["schemas"]["TokenResponse"]; + }; + }; + /** + * @description Too many requests. + * We are rate-limiting everyone's requests, please try again later. + */ + 429: { + content: { + readonly "text/plain": "Too many requests"; + }; + }; + /** + * @description Service unavailable. + * We have not configured a GitHub Access Token or a JWT Key. + * This could be because we are experiencing technical difficulties or intentional if we experience abuse of these endpoints. + * Please try again later. + */ + 503: { + content: { + readonly "text/plain": "Service unavailable"; + }; + }; + }; + }; + feedback: { + /** + * Post feedback + * @description ***Do not abuse this endpoint.*** + * + * This posts the actual feedback to github and returns the github link. + * For this Endpoint to work, you need to generate a token via the `/api/feedback/get_token` endpoint. + * + * ***Important Note:*** Tokens are only used if we return a 201 Created response. Otherwise, they are still valid + */ + readonly requestBody?: { + readonly content: { + readonly "application/json": components["schemas"]["TokenRequest"]; + }; + }; + responses: { + /** + * @description The feedback has been successfully posted to GitHub. + * We return the link to the GitHub issue. + */ + 201: { + content: { + readonly "text/plain": string; + }; + }; + /** @description If not all fields in the body are present as defined above */ + 400: { + content: { + readonly "text/plain": "Not all fields in the body are present as defined in the documentation"; + }; + }; + /** + * @description Forbidden. Causes are (delivered via the body): + * + * - `Invalid token`: You have not supplied a token generated via the `gen_token`-Endpoint. + * - `Token not old enough, please wait`: Tokens are only valid after 10s. + * - `Token expired`: Tokens are only valid for 12h. + * - `Token already used`: Tokens are non reusable/refreshable single-use items. + */ + 403: { + content: { + readonly "text/plain": + | "Invalid token" + | "Token not old enough, please wait" + | "Token expired" + | "Token already used"; + }; + }; + /** + * @description Unprocessable Entity + * Subject or body missing or too short. + */ + 422: { + content: { + readonly "text/plain": "Unprocessable Entity"; + }; + }; + /** + * @description Unavailable for legal reasons. + * Using this endpoint without accepting the privacy policy is not allowed. + * For us to post to GitHub, this has to be true + */ + 451: { + content: { + readonly "text/plain": "Unavailable for legal reasons"; + }; + }; + /** + * @description Internal Server Error. + * We have a problem communicating with GitHubs servers. Please try again later. + */ + 500: { + content: { + readonly "text/plain": "Internal Server Error"; + }; + }; + /** + * @description Service unavailable. + * We have not configured a GitHub Access Token. + * This could be because we are experiencing technical difficulties or intentional. Please try again later. + */ + 503: { + content: { + readonly "text/plain": "Service unavailable"; + }; + }; + }; + }; + img_cdn: { + /** + * Get title images + * @description This endpoint is designed to fetch the images, that are described by the `/api/get/{id}`-endpoint. + * You HAVE to get the proper attribution from that endpoint and use it. + */ + parameters: { + /** + * @description size of the resource you want + * + * | name | default | + * |--------|-----------------------------------------------------------------------| + * | lg | max 4k, aspect ratio untouched | + * | md | max 1920px, aspect ratio untouched | + * | sm | max 1024px, aspect ratio untouched | + * | thumb | 256x256, cropped to fit. Usually a center-crop, but sometimes offset. | + * | header | 512x210, cropped to fit. Usually a center-crop, but sometimes offset. | + */ + /** @description id of the recource you want an image for */ + /** + * @description counter of the image you want. + * @example 0 + */ + readonly path: { + size: "lg" | "md" | "sm" | "thumb" | "header"; + id: string; + counter: number; + }; + }; + responses: { + /** @description The image you requested */ + 200: { + content: { + readonly "image/webp": string; + }; + }; + /** + * @description Bad Request. + * The request was malformed. + * Please check your request and try again. + */ + 400: { + content: { + readonly "text/plain": "Bad Request"; + }; + }; + /** @description Requested Resource Not Found */ + 404: { + content: { + readonly "text/plain": "Not found"; + }; + }; + /** @description The uri you are trying to request is unreasonably long. neither ids, nor any other parameter has more than 30 chars.. */ + 414: { + content: { + readonly "text/plain": "Uri too long"; + }; + }; + }; + }; + maps_cdn: { + /** + * Get title images + * @description This endpoint is designed to fetch the images, that are described by the `/api/get/{id}`-endpoint. + * You HAVE to get the proper attribution from that endpoint and use it. + */ + parameters: { + /** @description source of the resource you want */ + /** @description id of the map you want */ + readonly path: { + source: "overlay" | "roomfinder"; + id: string; + }; + }; + responses: { + /** @description The map you requested */ + 200: { + content: { + readonly "image/webp": string; + }; + }; + /** + * @description Bad Request. + * The request was malformed. + * Please check your request and try again. + */ + 400: { + content: { + readonly "text/plain": "Bad Request"; + }; + }; + /** @description Requested Resource Not Found */ + 404: { + content: { + readonly "text/plain": "Not found"; + }; + }; + /** @description The uri you are trying to request is unreasonably long. neither ids, nor any other parameter has more than 30 chars.. */ + 414: { + content: { + readonly "text/plain": "Uri too long"; + }; + }; + }; + }; + legacy_redirect: { + /** + * Get a redirect to our roomfinder + * @description The old roomfinder still exists and adoption of our new system is not great. + * This is a redirect route which can be a direct redirect for the old room-finder. + * + * ***THIS WILL DISAPEAR IN THE FUTURE, DO NOT RELY ON IT.*** + * ***This is only here while TUM is transitioning to this system.*** + * + * After 1-2 years, we will introduce some text to nudging those, + * who still have not changed their links, as otherwise we assume this transition will never be done... + * Said nudge will include information on who to contact if updating the website is not possible and + * tell the users what link to exchange with what other link. + * Redirecting to y after a button click or something similar is probably good. + * + * THIS IS NOT A PERMANENT SOLUTION, AND WILL BE REMOVED IN THE FUTURE + * + * The reason, why this is not a dumb redirect is, that the old roomfinder has a lot of bugs and `arch_name` not being unique, nor an id is one of them. + * We dont want to have two ids for obvious reasons, this is why we dont accept this as an alias + */ + parameters: { + /** @description Architects name of the redirect you want */ + readonly path: { + arch_name: string; + }; + }; + responses: { + /** @description There are multiple meanings for this arch_name. Please choose one */ + 200: { + content: { + readonly "text/plain": string; + }; + }; + /** @description Permanent redirect to the roomfinder */ + 301: { + content: { + readonly "text/plain": "Permanent Redirect"; + }; + }; + /** @description Requested Resource Not Found */ + 404: { + content: { + readonly "text/plain": "Not found"; + }; + }; + }; + }; + "api-health": { + /** + * API healthcheck + * @description If this endpoint does not return 200, the API is experiencing a catastrophic outage. Should never happen. + */ + responses: { + /** @description Ok */ + 200: { + content: { + readonly "text/plain": "healthy"; + }; + }; + /** @description Service Unavailable */ + 503: { + content: { + readonly "text/plain": "Service Unavailable"; + }; + }; + }; + }; + "feedback-health": { + /** + * feedback-API healthcheck + * @description If this endpoint does not return 200, the API is experiencing a catastrophic outage. Should never happen. + */ + responses: { + /** @description Ok */ + 200: { + content: { + readonly "text/plain": "healthy"; + }; + }; + /** @description Service Unavailable */ + 503: { + content: { + readonly "text/plain": "Service Unavailable"; + }; + }; + }; + }; + "cdn-health": { + /** + * CDN healthcheck + * @description If this endpoint does not return 200, the CDN is experiencing a catastrophic outage. Should never happen. + */ + responses: { + /** @description Ok */ + 200: { + content: { + readonly "text/plain": "healthy"; + }; + }; + /** @description Service Unavailable */ + 503: { + content: { + readonly "text/plain": "Service Unavailable"; + }; + }; + }; + }; + "web-health": { + /** + * Website healthcheck + * @description If this endpoint does not return 200, the Website is experiencing a catastrophic outage. Should never happen. + */ + responses: { + /** @description Ok */ + 200: { + content: { + readonly "text/plain": "healthy"; + }; + }; + /** @description Service Unavailable */ + 503: { + content: { + readonly "text/plain": "Service Unavailable"; + }; + }; + }; + }; +}; diff --git a/webclient/src/assets/logo2.min2.svg b/webclient/src/assets/logo.svg similarity index 100% rename from webclient/src/assets/logo2.min2.svg rename to webclient/src/assets/logo.svg diff --git a/webclient/src/assets/main.scss b/webclient/src/assets/main.scss new file mode 100644 index 000000000..a16b8e03a --- /dev/null +++ b/webclient/src/assets/main.scss @@ -0,0 +1,105 @@ +@import "./variables.scss"; + +/* === General === */ +html { + scroll-behavior: smooth; +} + +body { + position: relative; + + &.no-scroll { + overflow-y: hidden; + } +} + +// v-cloak is set until vue loaded +[v-cloak] { + display: none; +} + +.loading-container, +#loading-page { + display: block; + height: 100%; + width: 100%; + top: 0; + pointer-events: none; +} + +.loading-container > .loading, +#loading-page > .loading { + margin: 0 auto; + display: block; + position: static; +} + +.loading-container > .loading::after, +#loading-page > .loading::after { + border-bottom-color: transparent; + border-left-color: #ccc; +} + +#loading-page:not([v-cloak]) { + display: none; + + &.show { + display: block; + opacity: 1; + animation: loading-in .07s linear .1s; + animation-fill-mode: both; + } +} + +.img-responsive { + background-color: $image-loading-bg; +} + +@keyframes loading-in { + from { opacity: 0; } + to { opacity: 1; } +} + +// --- Menu general + +#app .menu .menu-item > a, +#app .menu .menu-item > button { // Overwrite spectre + &:focus, + &:hover { + background: $theme-accent; + color: #fff; + } +} + +#app .menu .menu-item + .menu-item { + margin-top: 0; +} + +// --- Cards +#app .card { + box-shadow: $card-shadow; + border-radius: 4px; +} + +// --- Toast buttons +.toast .btn { + background: $toast-btn-bg; + color: $light-color; + border-color: $light-color; + font-weight: bold; + + &:hover { + background: $toast-btn-bg-hover; + border-color: $light-color; + } + + &:active { + background: $toast-btn-bg-active; + border-color: $light-color; + } + + &:focus { + background: $toast-btn-bg; + border-color: $light-color; + } +} diff --git a/webclient/src/assets/map-marker_pin-shadow.webp b/webclient/src/assets/map/marker_pin-shadow.webp similarity index 100% rename from webclient/src/assets/map-marker_pin-shadow.webp rename to webclient/src/assets/map/marker_pin-shadow.webp diff --git a/webclient/src/assets/map-marker_pin.webp b/webclient/src/assets/map/marker_pin.webp similarity index 100% rename from webclient/src/assets/map-marker_pin.webp rename to webclient/src/assets/map/marker_pin.webp diff --git a/webclient/src/assets/roomfinder_cross-v2.webp b/webclient/src/assets/map/roomfinder_cross-v2.webp similarity index 100% rename from webclient/src/assets/roomfinder_cross-v2.webp rename to webclient/src/assets/map/roomfinder_cross-v2.webp diff --git a/webclient/src/md/about-us.md b/webclient/src/assets/md/about-us.md similarity index 96% rename from webclient/src/md/about-us.md rename to webclient/src/assets/md/about-us.md index 8fab1c83a..371734d16 100644 --- a/webclient/src/md/about-us.md +++ b/webclient/src/assets/md/about-us.md @@ -1,6 +1,6 @@ # About NavigaTUM -NavigaTUM is a non-official tool developed by students, that aims to help you get around at [TUM](https://tum.de). +NavigaTUM is a tool developed by students for students, to help you get around at [TUM](https://tum.de). Feel free to contribute, [the code is open source](https://github.com/TUM-Dev/navigatum) and we are open to new contributors. ## Data Sources diff --git a/webclient/src/md/datenschutz.md b/webclient/src/assets/md/datenschutz.md similarity index 100% rename from webclient/src/md/datenschutz.md rename to webclient/src/assets/md/datenschutz.md diff --git a/webclient/src/md/impressum.md b/webclient/src/assets/md/impressum.md similarity index 100% rename from webclient/src/md/impressum.md rename to webclient/src/assets/md/impressum.md diff --git a/webclient/src/md/privacy.md b/webclient/src/assets/md/privacy.md similarity index 100% rename from webclient/src/md/privacy.md rename to webclient/src/assets/md/privacy.md diff --git a/webclient/src/md/ueber-uns.md b/webclient/src/assets/md/ueber-uns.md similarity index 96% rename from webclient/src/md/ueber-uns.md rename to webclient/src/assets/md/ueber-uns.md index 1205a3cbe..10dcf9d12 100644 --- a/webclient/src/md/ueber-uns.md +++ b/webclient/src/assets/md/ueber-uns.md @@ -1,7 +1,6 @@ # Über NavigaTUM -NavigaTUM ist ein von Studierenden entwickeltes, nicht-offizielles Tool, um sich bei der -[TUM](https://tum.de) zurechtzufinden. +NavigaTUM ist ein von Studierenden für Studierende entwickeltes Tool, um sich bei der [TUM](https://tum.de) zurechtzufinden. Du kannst gerne selbst mitmachen, [der Code ist Open Source verfügbar](https://github.com/TUM-Dev/navigatum) und wir freuen uns über neue Mitwirkende. ## Datenquellen diff --git a/webclient/src/assets/spectre-all.scss b/webclient/src/assets/spectre-all.scss new file mode 100644 index 000000000..4f0b3762c --- /dev/null +++ b/webclient/src/assets/spectre-all.scss @@ -0,0 +1,10 @@ +@import "../assets/variables"; +@import "spectre.css/src/spectre"; +@import "spectre.css/src/spectre-exp"; +@import "spectre.css/src/spectre-icons"; + +// Changes +.btn:focus, +a:focus { + box-shadow: none; +} diff --git a/webclient/src/variables.scss b/webclient/src/assets/variables.scss similarity index 89% rename from webclient/src/variables.scss rename to webclient/src/assets/variables.scss index b42be6c17..bbd81ce78 100644 --- a/webclient/src/variables.scss +++ b/webclient/src/assets/variables.scss @@ -1,5 +1,8 @@ @use "sass:color"; +/* === Selector, if the theme is "light" or "dark" === */ +$theme: "light"; + /* === Shared === */ $success-color: #6cc872; $warning-color: #f49e2f; @@ -7,9 +10,11 @@ $error-color: #bb331b; $theme-accent: #0065bd; // TUM blue /* === Light mode (default) === */ -// This is not in an @if block because sass requires the variables -// to be declared outside an @if scope. -// -- Overwriting of spectre.css variables +/* + This is not in an @if block because sass requires the variables + to be declared outside an @if scope. + */ +/* -- Overwriting of spectre.css variables -- */ $primary-color: $theme-accent; $secondary-color: #64a0c8; $body-font-color: #3b4351; @@ -20,7 +25,7 @@ $border-color: #e1e1e1; $border-color-dark: color.adjust($border-color, $lightness: -10%); $border-color-light: color.adjust($border-color, $lightness: 8%); -// -- Custom vars +/* -- Custom vars -- */ $header-color: #fff; $header-shadow-color: rgba(0, 0, 0, 10%); $autocomplete-box-shadow: 0 .05rem .2rem rgba(48, 55, 66, 30%); @@ -50,7 +55,7 @@ $code-bg: #fcf2f2; /* === Dark mode === */ @if $theme == "dark" { - // -- Overwriting of spectre.css variables + /* -- Overwriting of spectre.css variables -- */ $primary-color: #59b2ff !global; $secondary-color: #213d55 !global; $body-font-color: #d2d7df !global; @@ -61,7 +66,7 @@ $code-bg: #fcf2f2; $border-color-dark: #767c84 !global; $bg-color: #222428 !global; - // -- Custom vars + /* -- Custom vars -- */ $header-color: #1b1c1e; $header-shadow-color: rgba(0, 0, 0, 40%); $autocomplete-box-shadow: 0 .05rem .2rem rgba(0, 0, 0, 60%); diff --git a/webclient/src/components/AppLanguageToggler.vue b/webclient/src/components/AppLanguageToggler.vue new file mode 100644 index 000000000..39a4bc2dd --- /dev/null +++ b/webclient/src/components/AppLanguageToggler.vue @@ -0,0 +1,29 @@ + + + diff --git a/webclient/src/components/AppSearchBar.vue b/webclient/src/components/AppSearchBar.vue new file mode 100644 index 000000000..ff9f5138e --- /dev/null +++ b/webclient/src/components/AppSearchBar.vue @@ -0,0 +1,397 @@ + + + + + diff --git a/webclient/src/components/AppThemeToggler.vue b/webclient/src/components/AppThemeToggler.vue new file mode 100644 index 000000000..8b900d664 --- /dev/null +++ b/webclient/src/components/AppThemeToggler.vue @@ -0,0 +1,46 @@ + + + diff --git a/webclient/src/components/DetailsFeaturedSection.vue b/webclient/src/components/DetailsFeaturedSection.vue new file mode 100644 index 000000000..e64e4cfd1 --- /dev/null +++ b/webclient/src/components/DetailsFeaturedSection.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/webclient/src/components/DetailsFeedbackButton.vue b/webclient/src/components/DetailsFeedbackButton.vue new file mode 100644 index 000000000..9701e32fb --- /dev/null +++ b/webclient/src/components/DetailsFeedbackButton.vue @@ -0,0 +1,103 @@ + + + + + diff --git a/webclient/src/components/DetailsInfoSection.vue b/webclient/src/components/DetailsInfoSection.vue new file mode 100644 index 000000000..861e86c7d --- /dev/null +++ b/webclient/src/components/DetailsInfoSection.vue @@ -0,0 +1,355 @@ + + + + + diff --git a/webclient/src/components/DetailsInteractiveMap.vue b/webclient/src/components/DetailsInteractiveMap.vue new file mode 100644 index 000000000..806ee8fe6 --- /dev/null +++ b/webclient/src/components/DetailsInteractiveMap.vue @@ -0,0 +1,541 @@ + + + + + diff --git a/webclient/src/components/DetailsOverviewSections.vue b/webclient/src/components/DetailsOverviewSections.vue new file mode 100644 index 000000000..38d9186c2 --- /dev/null +++ b/webclient/src/components/DetailsOverviewSections.vue @@ -0,0 +1,343 @@ + + + + + diff --git a/webclient/src/components/DetailsRoomfinderMap.vue b/webclient/src/components/DetailsRoomfinderMap.vue new file mode 100644 index 000000000..a3071318b --- /dev/null +++ b/webclient/src/components/DetailsRoomfinderMap.vue @@ -0,0 +1,240 @@ + + + + + diff --git a/webclient/src/components/DetailsSources.vue b/webclient/src/components/DetailsSources.vue new file mode 100644 index 000000000..779a10a03 --- /dev/null +++ b/webclient/src/components/DetailsSources.vue @@ -0,0 +1,66 @@ + + + + + diff --git a/webclient/src/components/FeedbackModal.vue b/webclient/src/components/FeedbackModal.vue new file mode 100644 index 000000000..7a92cd028 --- /dev/null +++ b/webclient/src/components/FeedbackModal.vue @@ -0,0 +1,361 @@ + + + + + diff --git a/webclient/src/components/ShareButton.vue b/webclient/src/components/ShareButton.vue new file mode 100644 index 000000000..029d8c298 --- /dev/null +++ b/webclient/src/components/ShareButton.vue @@ -0,0 +1,65 @@ + + + diff --git a/webclient/src/core.js b/webclient/src/core.js deleted file mode 100644 index 679378e0e..000000000 --- a/webclient/src/core.js +++ /dev/null @@ -1,480 +0,0 @@ -/* - * This is the first JS code executed for all views. - */ - -let navigatum; - -// This is a wrapper around fetch that avoids duplicate requests if the -// same resource is requested another time before the first request has -// returned. -const cachedFetch = (() => ({ - fetch: function (url, options) { - return new Promise((resolve) => { - // Add language query param to the request - const lang = document.documentElement.lang; - url += (url.includes("?") ? "&lang=" : "?lang=") + lang; - - if (url in this.cache) { - resolve(this.cache[url]); - } else if (url in this.promise_callbacks) { - this.promise_callbacks[url].push(resolve); - } else { - this.promise_callbacks[url] = [resolve]; - - if (!options.headers) options.headers = {}; - fetch(url, options) - .then((response) => { - if (!response.ok) { - if (response.status === 404) - throw new Error("${{_.core_js.error.404}}$"); - else if (response.status === 500) - throw new Error("${{_.core_js.error.500}}$"); - else if (response.status === 503) - throw new Error("${{_.core_js.error.503}}$"); - else { - const errorStatus = "${{_.core_js.error.status}}$"; - throw new Error(`${errorStatus}$${response.status}`); - } - } - navigatum.app.error.msg = null; - return options.as_text ? response.text() : response.json(); - }) - .catch((error) => { - let msg; - if (error instanceof TypeError) - msg = "${{_.core_js.error.network}}$"; - else msg = error.message; - - if (!msg) msg = "${{_.core_js.error.unknown}}$"; - - console.warn("Error on fetch:", error); - - if (navigatum && navigatum.app) navigatum.app.error.msg = msg; - - return null; - }) - .then((data) => { - if (data !== null) this.cache[url] = data; - - this.promise_callbacks[url].forEach((callback) => { - callback(data); - }); - delete this.promise_callbacks[url]; - }); - } - }); - }, - cache: {}, - promise_callbacks: {}, -}))(); - -// the following is a poor implementation of a structuredClone(item) polyfill -// (read: this is not the full implementation browsers follow, but a simplified version) - -// eslint-disable-next-line no-unused-vars -function structuredClone(item) { - // cf. StackOverflow: https://stackoverflow.com/questions/728360/how-do-i-correctly-clone-a-javascript-object - // item has to be serializable! - if (item == null || typeof item !== "object") return item; - // Arrays are currently not cloned (TODO: is this required?) - if (item instanceof Array) { - return item; - } - if (!(item instanceof Object)) - console.error( - `Items of type ${typeof item} (${item}) cant be structuredClone'd` - ); - - const copy = {}; - Object.keys(item).forEach((key) => { - if ( - key !== "__ob__" && // stuff by vue, recursive! - Object.prototype.hasOwnProperty.call(item, key) // google no-prototype-builtins for an explanation of this line - ) - copy[key] = structuredClone(item[key]); - }); - return copy; -} - -navigatum = (() => { - const apiBase = "/* @echo api_prefix */"; - const cache = {}; - - const views = {}; - const viewsResolveCallbacks = {}; - let routes; // eslint-disable-line no-unused-vars - - const router = null; // eslint-disable-line no-unused-vars - - // This is the Vue.js app - const app = null; // eslint-disable-line no-unused-vars - - function _modulePostInit(_this, name, c) { - _this.modules.initialized[name] = c; - if (name in _this.modules.loaded) delete _this.modules.loaded[name]; - - _this.module_promise_callbacks[name].forEach((callback) => { - callback(c); - }); - delete _this.module_promise_callbacks[name]; - } - - return { - apiBase: apiBase, - init: function () { - // Init Vue.js - this.router = new VueRouter({ - /* @if target="release" */ - mode: "history", - base: "/* @echo app_prefix */", - /* @endif */ - routes: this.routes, - scrollBehavior: function (to, from, savedPosition) { - if (savedPosition) { - return savedPosition; - } - // Just returning (0, 0) does not work when the new page is - // the same component and it got so small, that the current - // position is now the bottom of the new page. - // For this reason this extra call. - document.getElementById("content").scrollIntoView(); - - return { x: 0, y: 0, behavior: "smooth" }; - }, - }); - this.router.beforeEach((to, from, next) => { - this.beforeNavigate(to, from); - next(); - }); - this.router.afterEach((to, from) => { - this.afterNavigate(to, from); - }); - /* this.router.beforeResolve((to, from, next) => { - next(); - }); */ - this.app = new Vue({ - router: this.router, - el: "#app", - data: { - search: { - focused: false, - keep_focus: false, - query: "", - autocomplete: { - sections: [], - highlighted: null, - }, - }, - error: { - msg: null, - }, - modal: { - header: null, - body: null, - }, - }, - methods: { - searchFocus: function () { - this.search.focused = true; - this.search.autocomplete.highlighted = null; - }, - searchBlur: function () { - if (this.search.keep_focus) { - window.setTimeout(() => { - // This is relevant if the call is delayed and focused has - // already been disabled e.g. when clicking on an entry. - if (this.search.focused) - document.getElementById("search").focus(); - }, 0); - this.search.keep_focus = false; - } else { - this.search.focused = false; - } - }, - searchInput: function (e) { - navigatum.getModule("autocomplete").then((c) => { - c.onInput(e.srcElement.value); - }); - }, - searchKeydown: function (e) { - navigatum.getModule("autocomplete").then((c) => { - c.onKeyDown(e); - }); - }, - searchExpand: function (s) { - s.expanded = true; - }, - searchGo: function (cleanQuery) { - if (this.search.query.length === 0) return; - - navigatum.router - .push(`/search?q=${this.search.query}`) - .catch(() => { - navigatum.afterNavigate(); - }); - this.search.focused = false; - if (cleanQuery) { - this.search.query = ""; - this.search.autocomplete.sections = []; - } - document.getElementById("search").blur(); - }, - searchGoTo: function (id, cleanQuery) { - // Catch is necessary because vue-router throws an error - // if navigation is aborted for some reason (e.g. the new - // url is the same or there is a loop in redirects) - navigatum.router.push(`/view/${id}`).catch(() => { - navigatum.afterNavigate(); - }); - this.search.focused = false; - if (cleanQuery) { - this.search.query = ""; - this.search.autocomplete.sections = []; - } - document.getElementById("search").blur(); - }, - setLang: navigatum.setLang, - setTheme: navigatum.setTheme, - }, - }); - }, - cache: cache, - /* - * getData() either uses cachedFetch() to retrieve the specified data - * or loads it from its local cache if present. - */ - getData: function (id) { - return new Promise((resolve) => { - cachedFetch - .fetch(`${this.apiBase}get/${window.encodeURIComponent(id)}`, { - cache: "force-cache", - }) - .then((data) => resolve(data)); - }); - }, - setLocalStorageWithExpiry: function (key, value, ttl) { - // ttl in hours - const now = new Date(); - - const item = { - value: value, - expiry: now.getTime() + ttl * 3.6e6, - }; - localStorage.setItem(key, JSON.stringify(item)); - - // "storage" usually fires only across tabs, this way we - // force it to fire in this window as well - const e = new Event("storage"); - window.dispatchEvent(e); - }, - getLocalStorageWithExpiry: function (key, defaultValue = null) { - const itemStr = localStorage.getItem(key); - if (!itemStr) { - return defaultValue; - } - const item = JSON.parse(itemStr); - const now = new Date(); - if (now.getTime() > item.expiry) { - localStorage.removeItem(key); - return defaultValue; - } - return item.value; - }, - removeLocalStorage: function (key) { - localStorage.removeItem(key); - const e = new Event("storage"); - window.dispatchEvent(e); - }, - /* - * Views can be lazy loaded. Each view will call registerView() once it is - * availabe. If the router requests a view with getView(), it can be directly - * returned if it is already available. If not, it is retrieved and returned - * as soon as it is availabe (viewsResolveCallbacks stores the callbacks for - * this). - */ - // NOTE: This code doesn't use `this` because for some reason it doesn't work with IE - registerView: function (name, component) { - if (!(name in navigatum.views)) navigatum.views[name] = component; - if (name in viewsResolveCallbacks) { - viewsResolveCallbacks[name](component); - delete viewsResolveCallbacks[name]; - } - }, - getView: function (name) { - return (resolve, reject) => { - if (name in navigatum.views) { - resolve(navigatum.views[name]); - } else { - viewsResolveCallbacks[name] = resolve; - window.setTimeout(() => { - if (name in viewsResolveCallbacks) { - if (navigatum.app) - navigatum.app.error.msg = - "${{_.core_js.error.view_load_timeout}}$"; - reject("Load timed out"); - } - }, 15000); - } - }; - }, - views: views, - registerModule: function (name, c) { - // If there are open promise callbacks for this module, - // it initialized directly. Else it is only initialized when needed. - if (name in this.module_promise_callbacks) { - const res = c.init(); - if (!res) { - // Init without Promise - _modulePostInit(this, name, c); - } else { - // Init with Promise - res.then(() => { - _modulePostInit(this, name, c); - }); - } - } else { - this.modules.loaded[name] = c; - } - }, - // eslint-disable-next-line no-unused-vars - getModule: function (name, ...args) { - return new Promise((resolve) => { - if (name in this.modules.initialized) { - resolve(this.modules.initialized[name]); - } else { - if (name in this.module_promise_callbacks) { - this.module_promise_callbacks[name].push(resolve); - } else { - this.module_promise_callbacks[name] = [resolve]; - } - - // Init if already loaded - if (name in this.modules.loaded) { - const res = this.modules.loaded[name].init(); - if (!res) { - // Init without Promise - _modulePostInit(this, name, this.modules.loaded[name]); - } else { - // Init with Promise - // eslint-disable-next-line no-unused-vars - res.then((_) => { - _modulePostInit(this, name, this.modules.loaded[name]); - }); - } - } - } - }); - }, - modules: { - loaded: { - /* removed here after init */ - }, - initialized: {}, - }, - module_promise_callbacks: {}, - - navigationState: null, - beforeNavigate: function (to, from) { - if (navigatum.app) navigatum.app.error.msg = ""; - - if (this.navigationState === "started") return; // Prevent duplicate calls - this.navigationState = "started"; - - document.getElementById("content").classList.remove("visible"); - document.getElementById("content").style.opacity = "0"; - document.getElementById("loading-page").classList.add("show"); - if (from.name !== null && window.history.saveCurrentViewState) - window.history.saveCurrentViewState(); // Initial page load - }, - // eslint-disable-next-line no-unused-vars - afterNavigate: function (to, from) { - if (this.navigationState === null) return; - this.navigationState = null; - - navigatum.setUrl(); // sets only the og:url meta tag - - // This timeout is required because else the browser might skip to - // transition if the change is too fast (if resources are in cache) - window.setTimeout(() => { - document.getElementById("content").classList.add("visible"); - document.getElementById("content").style.opacity = ""; - document.getElementById("loading-page").classList.remove("show"); - }, 5); // await at least one frame - - window.history.lastStateIndex = null; // Reset - }, - tryReuseViewState: function () { - // Try to reuse the view state if there is one. - if ( - window.history.states && - window.history.states[window.history.stateIndex][0].viewState - ) { - // We assume instance exists, because this should only be called - // from a matched route - const instance = - navigatum.router.currentRoute.matched[0].instances.default; - - if (instance.state) - this.applyState( - window.history.states[window.history.stateIndex][0].viewState, - instance.state - ); - return true; - } - return false; - }, - applyState: function (cacheStateObj, vueStateObj) { - Object.keys(cacheStateObj).forEach((attr) => { - if (cacheStateObj[attr] instanceof Object) { - if (!(vueStateObj[attr] instanceof Object)) vueStateObj[attr] = {}; // value was null - this.applyState(cacheStateObj[attr], vueStateObj[attr]); - } else { - vueStateObj[attr] = cacheStateObj[attr]; - } - }); - }, - setTitle: function (name) { - document.title = `${name} – NavigaTUM`; - document - .querySelector('meta[property="og:title"]') - .setAttribute("content", name); - }, - setDescription: function (description) { - document - .querySelector('meta[name="description"]') - .setAttribute("content", description); - document - .querySelector('meta[property="og:description"]') - .setAttribute("content", description); - }, - setUrl: function () { - document - .querySelector('meta[property="og:url"]') - .setAttribute("content", window.location.href); - }, - // Settings are also stored in localStorage to detect when setting - // a cookie did not work. - setLang: function (lang) { - localStorage.setItem("lang", lang); - document.cookie = `lang=${lang};Max-Age=31536000;SameSite=Lax;Path=/* @echo app_prefix */`; - window.location.reload(true); - }, - setTheme: function (theme) { - localStorage.setItem("theme", theme); - document.cookie = `theme=${theme};Max-Age=31536000;SameSite=Lax;Path=/* @echo app_prefix */`; - window.location.reload(true); - }, - }; -})(); - -navigatum.routes = [ - { path: "/", component: navigatum.getView("main") }, - { - path: "/(view|campus|site|building|room)/:id", - component: navigatum.getView("view"), - }, - { path: "/search", component: navigatum.getView("search") }, - { path: "/api", component: navigatum.getView("api") }, - { path: "/about/:name", component: navigatum.getView("md") }, - { path: "/:catchAll(.*)", component: navigatum.getView("404") }, -]; diff --git a/webclient/src/detect-webp.js b/webclient/src/detect-webp.js deleted file mode 100644 index d800560d3..000000000 --- a/webclient/src/detect-webp.js +++ /dev/null @@ -1,35 +0,0 @@ -// polyfill webp support -// This file will be appended to core.js, but thus executed relatively late, -// because images are only loaded once vue finished initializing. - -let webpUnpolyfilled = true; -function ensureWebpPolyfilled() { - if (webpUnpolyfilled) { - webpUnpolyfilled = false; - console.warn( - "Your browser does not support webp images. ", - "We still support you, but we might drop this support in the future." - ); - const head = document.getElementsByTagName("head")[0]; - const webpJS = document.createElement("script"); - webpJS.src = "/* @echo app_prefix */js/webp-hero.min.js"; - head.appendChild(webpJS); - } -} -function testWebpFeature(image) { - const img = new Image(); - img.onload = () => { - if (img.width === 0 || img.height === 0) ensureWebpPolyfilled(); - }; - img.onerror = ensureWebpPolyfilled; - img.src = `data:image/webp;base64,${image}`; -} - -const webpTestImages = [ - "UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEADsD+JaQAA3AAAAAA", // lossy - "UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA==", // lossless - "UklGRkoAAABXRUJQVlA4WAoAAAAQAAAAAAAAAAAAQUxQSAwAAAARBxAR/Q9ERP8DAABWUDggGAAAABQBAJ0BKgEAAQAAAP4AAA3AAP7mtQAAAA==", // alpha -]; -webpTestImages.forEach((image) => { - if (webpUnpolyfilled) testWebpFeature(image); -}); diff --git a/webclient/src/env.d.ts b/webclient/src/env.d.ts new file mode 100644 index 000000000..d922d95a3 --- /dev/null +++ b/webclient/src/env.d.ts @@ -0,0 +1,10 @@ +/// + +interface ImportMetaEnv { + readonly VITE_APP_URL: string; + // more env variables... +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/webclient/src/feedback.js b/webclient/src/feedback.js deleted file mode 100644 index c4e9cc829..000000000 --- a/webclient/src/feedback.js +++ /dev/null @@ -1,253 +0,0 @@ -// To work even when the rest of the JS code failed, the code for the -// feedback form is mostly seperate from the rest of the codebase. -// It is only loaded when the feedback form is being opened. - -window.feedback = (() => { - let token = null; - - function _requestPage(method, url, data, onsuccess, onerror) { - const req = new XMLHttpRequest(); - req.open(method, window.encodeURI(url), true); - req.onload = () => onsuccess(req); - req.onerror = () => onerror(req); - if (data === null) { - req.send(); - } else { - req.setRequestHeader("Content-Type", "application/json"); - req.send(data); - } - } - - function _showError(msg = "", blockSend = false) { - document.getElementById("feedback-error").innerText = msg; - document.getElementById("feedback-send").disabled = blockSend; - } - - function _showLoading(isLoading) { - if (isLoading) { - document.getElementById("feedback-send").classList.add("loading"); - document.getElementById("feedback-send").disabled = true; - } else { - document.getElementById("feedback-send").classList.remove("loading"); - document.getElementById("feedback-send").disabled = false; - } - } - - function openForm(category = "general", subject = "", body = "") { - document.getElementById("feedback-category").value = category; - document.getElementById("feedback-subject").value = subject; - document.getElementById("feedback-body").value = body; - document.getElementById("feedback-privacy").checked = false; - document.getElementById("feedback-delete").checked = false; - - _showError(); - _showLoading(false); - - document.getElementById("feedback-modal").classList.add("active"); - document.body.classList.add("no-scroll"); - - // Token are renewed after 6 hours here to be sure, even though they may be valid - // for longer on the server side. - if (token === null && navigatum) { - token = navigatum.getLocalStorageWithExpiry("feedback-token", null); - } - if (token === null || Date.now() - token.creation > 1000 * 3600 * 6) { - _requestPage( - "POST", - "/* @echo api_prefix */feedback/get_token", - null, - (r) => { - if (r.status === 201) { - token = { - creation: Date.now(), - value: r.response.replace(/^"(.*)"$/, "$1"), - }; - if (navigatum) - navigatum.setLocalStorageWithExpiry("feedback-token", token, 6); - } else if (r.status === 429) { - _showError("${{_.feedback.error.429}}$", true); - } else if (r.status === 503) { - _showError("${{_.feedback.error.503}}$", true); - } else { - const unexpectedTokenError = - "${{_.feedback.error.token_unexpected_status}}$"; - _showError(`${unexpectedTokenError}${r.status}`, true); - } - }, - (r) => { - _showError("${{_.feedback.error.token_req_failed}}$", false); - console.error(r); - } - ); - } - } - - function updateFeedbackForm( - category = document.getElementById("feedback-category").value - ) { - const helptextLUT = { - general: "${{_.feedback.helptext.general}}$", - bug: "${{_.feedback.helptext.bug}}$", - features: "${{_.feedback.helptext.features}}$", - search: "${{_.feedback.helptext.search}}$", - entry: "${{_.feedback.helptext.entry}}$", - }; - document.getElementById("feedback-helptext").innerText = - helptextLUT[category]; - - const coordinatePicker = document.getElementById( - "feedback-coordinate-picker" - ); - if (category === "entry") { - coordinatePicker.classList.remove("d-none"); - } else { - coordinatePicker.classList.add("d-none"); - } - } - - function closeForm() { - document - .getElementById("feedback-coordinate-picker") - .classList.add("d-none"); - document - .getElementById("feedback-coordinate-picker-helptext") - .classList.add("d-none"); - - document.getElementById("feedback-modal").classList.remove("active"); - document - .getElementById("feedback-success-modal") - .classList.remove("active"); - - document.body.classList.remove("no-scroll"); - } - - function mayCloseForm() { - if (document.getElementById("feedback-body").value.length === 0) - closeForm(); - } - - function _showSuccess(href) { - document.getElementById("feedback-modal").classList.remove("active"); - document.getElementById("feedback-success-modal").classList.add("active"); - document.getElementById("feedback-success-url").setAttribute("href", href); - } - - function _send() { - const category = document.getElementById("feedback-category").value; - const subject = document.getElementById("feedback-subject").value; - const body = document.getElementById("feedback-body").value; - const privacy = document.getElementById("feedback-privacy").checked; - const deleteIssue = document.getElementById("feedback-delete").checked; - - _requestPage( - "POST", - "/* @echo api_prefix */feedback/feedback", - JSON.stringify({ - token: token.value, - category: category, - subject: subject, - body: body, - privacy_checked: privacy, - delete_issue_requested: deleteIssue, - }), - (r) => { - _showLoading(false); - if (r.status === 201) { - localStorage.removeItem("coordinate-feedback"); - token = null; - localStorage.removeItem("feedback-token"); - const e = new Event("storage"); - window.dispatchEvent(e); - _showSuccess(r.responseText); - } else if (r.status === 500) { - const serverError = "${{_.feedback.error.server_error}}$"; - _showError(`${serverError} (${r.responseText})`, false); - } else if (r.status === 451) { - _showError("${{_.feedback.error.privacy_not_checked}}$", false); - } else if (r.status === 403) { - localStorage.removeItem("feedback-token"); - token = null; - const invalidTokenError = "${{_.feedback.error.send_invalid_token}}$"; - _showError(`${invalidTokenError} (${r.responseText})`, false); - } else { - // we reset the token here to be sure that it is the cause of the error - localStorage.removeItem("feedback-token"); - token = null; - const unexpectedStatusError = - "${{_.feedback.error.send_unexpected_status}}$"; - _showError(`${unexpectedStatusError}${r.status}`, false); - } - }, - (r) => { - _showLoading(false); - _showError("${{_.feedback.error.send_req_failed}}$"); - console.error(r); - } - ); - } - - function sendForm() { - if (token === null) { - _showError("${{_.feedback.error.send_no_token}}$", true); - } else if (document.getElementById("feedback-subject").value.length < 3) { - _showError("${{_.feedback.error.too_short_subject}}$"); - } else if (document.getElementById("feedback-body").value.length < 10) { - _showError("${{_.feedback.error.too_short_body}}$"); - } else if (document.getElementById("feedback-privacy").checked !== true) { - _showError("${{_.feedback.error.privacy_not_checked}}$"); - } else { - _showLoading(true); - // Token may only be used after a short delay. In case that has not passed - // yet, we wait until for a short time. - if (Date.now() - token.creation < 1000 * 10) { - window.setTimeout(_send, 1000 * 10 - (Date.now() - token.creation)); - } else { - _send(); - } - } - } - - document - .getElementById("feedback-cancel") - .addEventListener("click", closeForm, false); - document - .getElementById("feedback-close") - .addEventListener("click", closeForm, false); - document - .getElementById("feedback-overlay") - .addEventListener("click", mayCloseForm, false); - - document - .getElementById("feedback-close-2") - .addEventListener("click", closeForm, false); - document - .getElementById("feedback-ok") - .addEventListener("click", closeForm, false); - document - .getElementById("feedback-overlay-2") - .addEventListener("click", closeForm, false); - - document - .getElementById("feedback-category") - .addEventListener("change", (e) => updateFeedbackForm(e.value), false); - - document - .getElementById("feedback-send") - .addEventListener("click", sendForm, false); - - /* global feedbackPreload */ - if (feedbackPreload) { - openForm( - feedbackPreload.category, - feedbackPreload.subject, - feedbackPreload.body - ); - updateFeedbackForm(feedbackPreload.category); - } - - return { - openForm: openForm, - closeForm: closeForm, - updateFeedbackForm: updateFeedbackForm, - }; -})(); diff --git a/webclient/src/history-states.js b/webclient/src/history-states.js deleted file mode 100644 index 859461073..000000000 --- a/webclient/src/history-states.js +++ /dev/null @@ -1,111 +0,0 @@ -/* - Author: Hasan Delibaş - Source: https://gist.github.com/HasanDelibas/12050fc59d675181ea973d21f882081a - Under MIT License - - modified -*/ - -// TODO: only if initialized and fallback==false - -(() => { - const stateIndexSymbol = "__state__index__"; - const stateDataSymbol = "viewState"; - - // If the page is reloaded, the state is preserved by the browser, but we have lost - // the state list. For this reason we need to create a dummy one for now. - // When navigating to that page again, we may be able to get the old state back (TODO). - window.history.states = []; - if ( - window.history.state && - window.history.state[stateIndexSymbol] !== undefined - ) { - window.history.stateIndex = window.history.state[stateIndexSymbol]; - for (let i = 0; i < window.history.stateIndex; i++) { - const state = {}; - state[stateIndexSymbol] = i; - window.history.states.push([state, "", null]); - } - window.history.states.push([window.history.state, "", null]); - } else { - window.history.stateIndex = 0; - - if (window.history.state) { - window.history.states.push([window.history.state, "", null]); - } else { - const state = {}; - state[stateIndexSymbol] = 0; - window.history.states.push([state, "", null]); - } - } - - window.history.lastStateIndex = null; - const historyPushState = window.history.pushState; - function add(data, title, url) { - if (data == null) data = {}; - if (typeof data !== "object") data = { data: data }; - data[stateIndexSymbol] = window.history.stateIndex + 1; - const addedState = [data, title, url]; - window.history.states.splice(window.history.stateIndex + 1, 0, addedState); - window.history.states.splice(window.history.stateIndex + 2); - window.history.stateIndex += 1; - } - window.history.saveCurrentViewState = () => { - if ( - navigatum.router && - navigatum.router.currentRoute.matched[0] && - navigatum.router.currentRoute.matched[0].instances.default.state - ) { - const stateIndex = - window.history.lastStateIndex === null - ? window.history.stateIndex - : window.history.lastStateIndex; - - window.history.states[stateIndex][0][stateDataSymbol] = structuredClone( - navigatum.router.currentRoute.matched[0].instances.default.state - ); - } - }; - window.history.pushState = (data, title, url = null) => { - add(data, title, url); - historyPushState.bind(window.history)(data, title, url); - }; - window.addEventListener("popstate", (e) => { - // If navigation is window.history navigation (click on back/forward), - // the 'popstate' event is emitted before 'beforeResolve()'. - // So in this case, we need to temporarily store the old state index, - // so that the saveCurrentViewState() call in beforeResolve() saves the - // state to the correct state in the window.history. - window.history.lastStateIndex = window.history.stateIndex; - - const eventObject = {}; - const newStateIndex = - e.state != null && e.state[stateIndexSymbol] !== undefined - ? e.state[stateIndexSymbol] - : 0; - eventObject.from = window.history.states[window.history.stateIndex]; - eventObject.to = - newStateIndex in window.history.states - ? window.history.states[newStateIndex] - : null; - eventObject.side = - window.history.stateIndex > newStateIndex ? "back" : "forward"; - // This happens if there is a state in the window.history, that we have lost. - // (usually happens on forward navigation from another page) - if (!(newStateIndex in window.history.states)) { - add(window.history.state, "", window.location.href); - } - // window.dispatchEvent(new CustomEvent("historyChange", {detail: eventObject} )) - window.history.stateIndex = - e.state != null && e.state[stateIndexSymbol] !== undefined - ? e.state[stateIndexSymbol] - : 0; // -1; - }); -})(); - -/* addEventListener("historyChange",function(e){ - const from = e.detail.from; // [ data , title , url ] - const to = e.detail.to; // [ data , title , url ] - const side = e.detail.side; // "back" | "forward" - console.log(e); -}) */ diff --git a/webclient/src/i18n.yaml b/webclient/src/i18n.yaml deleted file mode 100644 index 56cea3948..000000000 --- a/webclient/src/i18n.yaml +++ /dev/null @@ -1,202 +0,0 @@ -meta: - logo_alt: - de: "Das Logo von NavigaTUM." - en: "The Logo of NavigaTUM." - description: - de: "Finde Räume, Gebäude und andere Orte an der TUM mit Exzellenz. Eine moderne Alternative zum RoomFinder, entwickelt von Studierenden." - en: "Navigate around TUM with excellence. This is a website to find rooms, buildings and other places at TUM easier than the current RoomFinder, developed by students." -search: - placeholder: { de: "Suche", en: "Search" } - aria-searchlabel: { de: "Suchfeld", en: "Search-field" } - aria-actionlabel: - de: "Suche nach dem im Suchfeld eingetragenen Raum" - en: "Search for the room-query entered in the search field" - action: { de: "Go", en: "Go" } - hidden: { de: "ausgeblendet", en: "hidden" } - result: { de: "Ergebnis", en: "result" } - results: { de: "Ergebnisse", en: "results" } - approx: { de: "ca.", en: "approx." } - sections: - buildings: { de: "Gebäude / Standorte", en: "Buildings / Sites" } - of_which_visible: { de: "davon sichtbar", en: "of them visible" } - rooms: { de: "Räume", en: "Rooms" } - and: { de: "und", en: "and" } - were_found: { de: "wurden gefunden.", en: "were found." } - no_buildings_rooms_found: - de: "Keine Gebäude / Standorte oder Räume konnten gefunden werden." - en: "No buildings / locations or rooms could be found." -footer: - sourcecode: - text: { de: "Source Code", en: "Source Code" } - privacy: - text: { de: "Datenschutz", en: "Privacy" } - link: { de: "datenschutz", en: "privacy" } - imprint: - text: { de: "Impressum", en: "Imprint" } - link: { de: "impressum", en: "impressum" } - about: - text: { de: "Über uns", en: "About us" } - link: { de: "ueber-uns", en: "about-us" } - feedback: - text: { de: "Feedback senden", en: "Feedback" } - api: - text: { de: "API", en: "API" } - link: { de: "api", en: "api" } - language: { de: "Sprache", en: "Language" } - theme: { de: "Theme", en: "Theme" } - theme_light: { de: "hell", en: "light" } - theme_dark: { de: "dunkel", en: "dark" } -feedback: - title: { de: "Feedback senden", en: "Send Feedback" } - subject: { de: "Betreff", en: "Subject" } - category: { de: "Feedback-Kategorie", en: "Feedback category" } - type: - general: { de: "Allgemein", en: "General" } - bug: { de: "Fehler", en: "Bug" } - features: { de: "Features", en: "Features" } - search: { de: "Suche", en: "Search" } - entry: { de: "Eintrag", en: "Entry" } - mail: { de: "E-Mail (optional)", en: "E-Mail (optional)" } - helptext: - general: - de: "Generelles Feedback über diese Website" - en: "General Feedback about this website" - bug: - de: "Welchen Fehler hast du gefunden? Wo hast du ihn gefunden? Bitte gib eine genaue Beschreibung an." - en: "Which bug did you find? Where did you find it? Please provide a detailed description." - features: - de: "Features, die du gerne auf dieser Website haben würdest" - en: "Features you would like to see on this website" - search: - de: "Feedback zur Suche. Was war dein Suchbegriff? Was hättest du als Ergebnis erwartet?" - en: "Feedback about the search. What was your search query? What did you expect to see?" - entry: - de: "Feedback zu einem Eintrag. Wir können Räume/Gebäude/Standorte hinzufügen und alle Daten, die du siehst (Namen, Koordinaten, Adressen, ...) anpassen. Was können wir verbessern?" - en: "Feedback about an entry. We can add rooms/buildings/locations and adjust all data you see (names, coordinates, addresses, ...). What can we improve?" - coordinatepicker: - helptext: - de: "Falls du mehrere Koordinaten gleichzeitig korrigieren willst, kannst du dieses Fenster schließen und weitere Koordinaten editieren. Wir speichern deine Veränderungen für 12 Stunden bei dir lokal.
Achtung, das gilt nur für Koordinaten!" - en: "If you want to correct several coordinates at the same time, you can close this window and edit more coordinates.
We will save your changes locally for 12 hours.
Warning, that's only valid for coordinates!" - title: { de: "Koordinate auswählen", en: "Select coordinate" } - edit_coordinate_subject: - de: "Koordinate bearbeiten" - en: "Edit coordinate" - edit_coordinates_subject: - de: "Koordinaten bearbeiten" - en: "Edit coordinates" - edit_multiple_coordinates: - de: "Hallo, ich möchte diese Koordinaten beim Roomfinder editieren:" - en: "Hello, I would like to edit these coordinates in the roomfinder:" - add_coordinate: - de: "Hallo, ich möchte diese Koordinate zum Roomfinder hinzufügen:" - en: "Hello, I would like to add this coordinate to the roomfinder:" - correct_coordinate: - de: "Hallo, ich möchte diese Koordinate im Roomfinder korrigieren:" - en: "Hello, I would like to correct this coordinate in the roomfinder:" - message: { de: "Nachricht", en: "Message" } - public: - de: "Meine Feedback Daten dürfen anonym, aber öffentlich zugänglich auf der GitHub Projektseite gespeichert werden. Mit der Nutzung dieses Feedbackformulars stimmst du explizit den Nutzungsbedingungen und Datenschutzbestimmungen von GitHub sowie einer möglichen Übertragung der Daten außerhalb der Europäischen Union zu. Falls du dies ablehnst, schreibe uns bitte über app@tum.de, oder einem der anderen in unserem Impressum gelisteten Kontaktmöglichkeiten." - en: "My feedback data may to be published anonymously, but publicly accessible on the GitHub project page. By using this feedback form, you explicitly agree to GitHub's Terms of Service and Privacy Policy and a possible transfer of your data outside of the European Union. If you do not consent to this, please write us at app@tum.de, or use one of the other contact options listed in our imprint." - delete: - de: "Das zugehörige GitHub Issue löschen, sobald es gelöst wurde." - en: "Delete this GitHub issue when resolved." - send: { de: "Senden", en: "Send" } - cancel: { de: "Abbrechen", en: "Cancel" } - success: - thank_you: - de: "Vielen Dank für dein Feedback! Wir werden es schnellstmöglich bearbeiten." - en: "Thank you for giving your feedback. We will work on this as soon as possible." - response_at: - de: "Antwort auf dein Feedback findest du auf" - en: "You can see our response at" - this_issue: - de: "diesem GitHub Issue" - en: "this GitHub issue" - title: - de: "Vielen Dank!" - en: "Thank you!" - ok: - de: "OK" - en: "OK" - error: - token_req_failed: - de: "Unerwarteter Fehler beim Laden des Feedback-Formulars. Das Senden von Feedback ist gerade vermutlich nicht möglich. Bitte schreibe stattdessen eine Mail." - en: "Unexpected error when loading the feedback form. Sending feedback is currently probably not possible. Please send a mail instead." - 429: - de: "Feedback senden ist aktuell nicht möglich aufgrund von rate-limiting. Bitte versuche es später nochmal oder schreibe eine Mail." - en: "Sending feedback is currently not possible due to rate-limiting. Please try again in a while or send a mail." - 503: - de: "Das Senden von Feedback ist auf dem Server aktuell nicht konfiguriert." - en: "Sending feedback is currently not configured on the server." - token_unexpected_status: - de: "Unerwarteter Status Code beim Abrufen eines Feedback Tokens: " - en: "Unexpected status code when retrieving a feedback token: " - send_no_token: - de: "Ein unerwarteter Fehler ist aufgetreten (Kein Token). Bitte kopiere den Text und öffne das Formular nochmal." - en: "An unexpected error occured (no token). Please copy the text and re-open the form." - too_short_subject: - de: "Fehler: Betreff fehlt oder ist zu kurz" - en: "Error: Subject missing or too short" - too_short_body: - de: "Fehler: Nachricht fehlt oder ist zu kurz" - en: "Error: Message missing or too short" - server_error: - de: "Server Fehler" - en: "Server Error" - privacy_not_checked: - de: "Aus rechtlichen Gründen nicht zulässig: Du musst die Datenschutzerklärung akzeptiert haben, damit wir dein Feedback via GitHub verarbeiten können. Es gibt andere Möglichkeiten uns zu kontaktieren, welche im Impressum aufgelistet sind." - en: "Unavailable for legal reasons: You have to accept the privacy statement for us to process the feedback via GitHub. There are other means of contact listed at in our impressum." - send_invalid_token: - de: "Formular-Token ungültig (vermutlich abgelaufen). Bitte kopiere den text und öffne das Formular nochmal." - en: "Invalid form token (probably expired). Please copy the text and re-open the form." - send_unexpected_status: - de: "Unerwarteter Status Code: " - en: "Unexpected status code: " - send_req_failed: - de: "Unerwarteter Fehler beim Senden des Feedback-Formulars. Das Senden von Feedback ist gerade vermutlich nicht möglich. Bitte schreibe stattdessen eine Mail." - en: "Unexpected error when sending the feedback form. Sending feedback is currently probably not possible. Please send a mail instead." - -core_js: - error: - noscript: - js_required: - en: | - JavaScript is required to use this application, as our main functionality (search bar, map) depends on this.
- We do not think that there is a graceful fallback or any option for progressive enhancement. - de: | - JavaScript ist erforderlich, um diese Anwendung zu nutzen, da unsere Hauptfunktionen (Suchleiste, Karte) davon abhängen.
- Wir glauben nicht, dass es ein elegantes Fallback oder eine Option für eine progressive enhancement gibt. - please_enable_js: - en: "Please enable JavaScript in your browser." - de: "Bitte aktiviere JavaScript in deinem Browser." - continue_with_different_useragent: - en: | - If you want to use this application without JavaScript, you can configure your UserAgent to 'BingBot'. - However, this inherently reduces our functionality and usefulness, as this is indented for bots. - de: | - Wenn du diese Seite ohne JavaScript verwenden möchten, könnest du deinen UserAgent auf 'BingBot' einstellen. - Dies schränkt jedoch unsere Funktionalität und Nützlichkeit ein, da dies für Bots vorgesehen ist. - 404: - de: "404 Nicht gefunden" - en: "404 Not found" - 500: - de: "500 Interner Serverfehler" - en: "500 Internal Server Error" - 503: - de: "503 Dienst nicht verfügbar – Bitte probiere es in Kürze wieder" - en: "503 Service Unavailable – Please try again soon" - status: - de: "Unerwarteter Status-Code: " - en: "Unexpected status code: " - network: - de: "Netzwerkfehler, bitte überprüfe, dass du mit dem Internet verbunden bist." - en: "Network Error, please verify that you are connected to the Internet" - unknown: - de: "Unbekannter Fehler" - en: "Unknown Error" - view_load_timeout: - de: "Seite konnte nicht geladen werden: Timeout" - en: "Page could not be loaded: Timeout" - browser_outdated: - de: "Ihr aktueller Browser ist veraltet und unterstützt nicht alle Funktionen, die von dieser Anwendung benötigt werden. Bitte aktualisieren Sie Ihren Browser." - en: "Your current browser is outdated and does not support all features, needed by this application. It can be, that this causes problems. Please update your browser." diff --git a/webclient/src/index.html b/webclient/src/index.html deleted file mode 100644 index 28e9dfe04..000000000 --- a/webclient/src/index.html +++ /dev/null @@ -1,478 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - NavigaTUM - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - -
-
-
-
- {{ error.msg }} -
-
-
-
- - -
-
-
- -
-
-
- - - -
-
-
- -
-
- - -
-
-
-
-
- -
-
-
-
-
-
- -
-
-
- - - - - - - - -
-
-
-
-
- -
-
-
- - - - - - - - -
-
-
-
-
-
-
-
-
-
-
- - - - -
- - - - - - - - - - - - - - - diff --git a/webclient/src/init-call.js b/webclient/src/init-call.js deleted file mode 100644 index 455a468b1..000000000 --- a/webclient/src/init-call.js +++ /dev/null @@ -1,4 +0,0 @@ -// This code is executed after the vendor js files (e.g. vue) are loaded. -// Might be removed in the future if inlining js is not helpful (inlined js is not deferred, -// so it is not sure that this call would be called after vendor scripts if it was inlined) -navigatum.init(); diff --git a/webclient/src/legacy.js b/webclient/src/legacy.js deleted file mode 100644 index ca7ddcff5..000000000 --- a/webclient/src/legacy.js +++ /dev/null @@ -1,10 +0,0 @@ -import "regenerator-runtime/runtime"; - -/* eslint no-extend-native: "off" */ -// For some reason this polyfill is not included automatically -if (typeof String.prototype.startsWith === "undefined") { - String.prototype.startsWith = (needle) => this.indexOf(needle) === 0; -} - -/* split */ -// This comment is here to separate these polyfills and the ones provided by babel from the rest of the code concatenated to this file to make babel work diff --git a/webclient/src/locales/de.yaml b/webclient/src/locales/de.yaml new file mode 100644 index 000000000..a701e0093 --- /dev/null +++ b/webclient/src/locales/de.yaml @@ -0,0 +1,192 @@ +meta: + logo_alt: "Das Logo von NavigaTUM." + description: "Finde Räume, Gebäude und andere Orte an der TUM mit Exzellenz. Eine moderne Alternative zum RoomFinder, entwickelt von Studierenden." +search: + placeholder: "Suche" + aria-searchlabel: "Suchfeld" + aria-actionlabel: "Suche nach dem im Suchfeld eingetragenen Raum" + action: "Go" + hidden: "ausgeblendet" + result: "Ergebnis" + results: "Ergebnisse" + approx: "ca." + sections: + buildings: "Gebäude / Standorte" + of_which_visible: "davon sichtbar" + rooms: "Räume" + and: "und" + were_found: "wurden gefunden." + no_buildings_rooms_found: "Keine Gebäude / Standorte oder Räume konnten gefunden werden." +footer: + sourcecode: + text: "Source Code" + privacy: + text: "Datenschutz" + link: "datenschutz" + imprint: + text: "Impressum" + link: "impressum" + about: + text: "Über uns" + link: "ueber-uns" + feedback: + text: "Feedback senden" + api: + text: "API" + link: "api" + language: "Sprache" + theme: "Theme" + theme_light: "hell" + theme_dark: "dunkel" +feedback: + title: "Feedback senden" + subject: "Betreff" + category: "Feedback-Kategorie" + type: + general: "Allgemein" + bug: "Fehler" + features: "Features" + search: "Suche" + entry: "Eintrag" + mail: "E-Mail (optional)" + helptext: + general: "Generelles Feedback über diese Website" + bug: "Welchen Fehler hast du gefunden? Wo hast du ihn gefunden? Bitte gib eine genaue Beschreibung an." + features: "Features, die du gerne auf dieser Website haben würdest" + search: "Feedback zur Suche. Was war dein Suchbegriff? Was hättest du als Ergebnis erwartet?" + entry: "Feedback zu einem Eintrag. Wir können Räume/Gebäude/Standorte hinzufügen und alle Daten, die du siehst (Namen, Koordinaten, Adressen, ...) anpassen. Was können wir verbessern?" + coordinatepicker: + helptext: "Falls du mehrere Koordinaten gleichzeitig korrigieren willst, kannst du dieses Fenster schließen und weitere Koordinaten editieren. Wir speichern deine Veränderungen für 12 Stunden bei dir lokal.
Achtung, das gilt nur für Koordinaten!" + title: "Koordinate auswählen" + edit_coordinate_subject: "Koordinate bearbeiten" + edit_coordinates_subject: "Koordinaten bearbeiten" + edit_multiple_coordinates: "Hallo, ich möchte diese Koordinaten beim Roomfinder editieren:" + add_coordinate: "Hallo, ich möchte diese Koordinate zum Roomfinder hinzufügen:" + correct_coordinate: "Hallo, ich möchte diese Koordinate im Roomfinder korrigieren:" + message: "Nachricht" + public: "Meine Feedback Daten dürfen anonym, aber öffentlich zugänglich auf der GitHub Projektseite gespeichert werden. Mit der Nutzung dieses Feedbackformulars stimmst du explizit den Nutzungsbedingungen und Datenschutzbestimmungen von GitHub sowie einer möglichen Übertragung der Daten außerhalb der Europäischen Union zu. Falls du dies ablehnst, schreibe uns bitte über navigatum (at) tum.de, oder einem der anderen in unserem Impressum gelisteten Kontaktmöglichkeiten." + delete: "Das zugehörige GitHub Issue löschen, sobald es gelöst wurde." + send: "Senden" + cancel: "Abbrechen" + success: + thank_you: "Vielen Dank für dein Feedback! Wir werden es schnellstmöglich bearbeiten." + response_at: "Antwort auf dein Feedback findest du auf" + this_issue: "diesem GitHub Issue" + title: "Vielen Dank!" + ok: "OK" + error: + token_req_failed: "Unerwarteter Fehler beim Laden des Feedback-Formulars. Das Senden von Feedback ist gerade vermutlich nicht möglich. Bitte schreibe stattdessen eine Mail." + 429: "Feedback senden ist aktuell nicht möglich aufgrund von rate-limiting. Bitte versuche es später nochmal oder schreibe eine Mail." + 503: "Das Senden von Feedback ist auf dem Server aktuell nicht konfiguriert." + token_unexpected_status: "Unerwarteter Status Code beim Abrufen eines Feedback Tokens: " + send_no_token: "Ein unerwarteter Fehler ist aufgetreten (Kein Token). Bitte kopiere den Text und öffne das Formular nochmal." + too_short_subject: "Fehler: Betreff fehlt oder ist zu kurz" + too_short_body: "Fehler: Nachricht fehlt oder ist zu kurz" + server_error: "Server Fehler" + privacy_not_checked: "Aus rechtlichen Gründen nicht zulässig: Du musst die Datenschutzerklärung akzeptiert haben, damit wir dein Feedback via GitHub verarbeiten können. Es gibt andere Möglichkeiten uns zu kontaktieren, welche im Impressum aufgelistet sind." + send_invalid_token: "Formular-Token ungültig (vermutlich abgelaufen). Bitte kopiere den text und öffne das Formular nochmal." + send_unexpected_status: "Unerwarteter Status Code: " + send_req_failed: "Unerwarteter Fehler beim Senden des Feedback-Formulars. Das Senden von Feedback ist gerade vermutlich nicht möglich. Bitte schreibe stattdessen eine Mail." + +core_js: + error: + 404: "404 Nicht gefunden" + 500: "500 Interner Serverfehler" + 503: "503 Dienst nicht verfügbar – Bitte probiere es in Kürze wieder" + status: "Unerwarteter Status-Code: " + network: "Netzwerkfehler, bitte überprüfe, dass du mit dem Internet verbunden bist." + unknown: "Unbekannter Fehler" + view_load_timeout: "Seite konnte nicht geladen werden: Timeout" +view_404: + header: "Die angeforderte Seite wurde nicht gefunden." + description: "Dies könnte sein, weil wir einen Fehler gemacht haben." + call_to_action: "Falls du denkst, dass dies ein Fehler ist, teile es uns doch hier mit" + got_here: # "\\\\n" renders to "\n" "Ich habe diesen Fehler so gefunden:\\\\n1. ..." +view_api: + title: "NavigaTUMs API Documentation" +view_main: + sites: "Standorte" + overview_map: "Übersichtskarte" + more: "mehr" + less: "weniger" +view_md: {} +view_search: + search_for: "Suche nach" + runtime: "Laufzeit" + max_results: "bitte grenze die Suche weiter ein" + give_feedback: "Feedback zur Suche geben" +view_view: + meta: + details_for: "Details für" + header: + copy_link: "Link kopieren" + external_link: + tooltip: "Externe Links" + open_in: "Öffnen in" + other_app: "Andere App ..." + share: "Teilen" + share_link: "Teilen mit ..." + copied: "Kopiert" + calendar: "Kalender öffnen" + feedback: "Problem melden oder Änderung vorschlagen" + favorites: "Zu Favoriten hinzufügen" + map: + interactive: "Interaktive Karte" + roomfinder: "Lagepläne" + img_source: "Bildquelle" + info_title: "Informationen" + info_table: + links: "Links" + buildings_overview: + title: "Gebäude / Gebiete" + more: "mehr" + less: "weniger" + rooms_overview: + title: "Räume" + by_usage: "nach Nutzung" + any: "beliebig" + remove_selection: "Auswahl löschen" + filter: "Filter" + choose_usage: "Wähle eine Nutzung aus" + result: "Ergebnis" + results_suffix: "se" + filtered: "(gefiltert)" + sources: + title: "Quellen" + header_img: "Bild" + base: + title: "Basisdaten" + patched: "(Bei diesem Eintrag wurden automatische Korrekturen zu externen Daten angewandt)" + coords: + title: "Koordinaten" + navigatum: "NavigaTUM Mitwirkende" + roomfinder: "Roomfinder" + inferred: "Automatisch berechnet aus den zugehörigen Räumen oder Gebäuden" + msg: + correct_location: + msg: "Um die richtige Position zu setzen, ziehe den roten Marker über die Karte." + btn-done: "Fertig" + btn-cancel: "Abbrechen" + inaccurate_only_building: + msg: "Die angezeigte Position zeigt nur die Position des Gebäude(teils). Die genaue Lage innerhalb des Gebäudes ist uns nicht bekannt.
Falls du sie herausfindest, kannst du die" + btn: "Koordinate eintragen" + no_floor_overlay: "Für den angezeigten Raum gibt es leider keine Indoor Karte." + coordinate-counter: + msg-1: "Aktuell" + msg-2: "ausstehende Koordinatenänderung" + msg-2-plural: "ausstehende Koordinatenänderungen" + info: "Änderungen werden lokal \\n für 12h gespeichert" + delete: "Löschen" + delete-confirm: "Wirklich löschen?" + send: "Senden" + slideshow: + header: "Bilder-Showcase" + close: "Schließen" + image_alt: "Ein Bild welches das Gebäude zeigt" + author: "Autor" + source: "Quelle" + license: "Lizenz" + modal_roomfinder: + header: "Lageplan" + close: "Schließen" + image_alt: "Ein Bild welches den Lageplan zeigt" diff --git a/webclient/src/locales/en.yaml b/webclient/src/locales/en.yaml new file mode 100644 index 000000000..6ed57f1cb --- /dev/null +++ b/webclient/src/locales/en.yaml @@ -0,0 +1,192 @@ +meta: + logo_alt: "The Logo of NavigaTUM." + description: "Navigate around TUM with excellence. This is a website to find rooms, buildings and other places at TUM easier than the current RoomFinder, developed by students." +search: + placeholder: "Search" + aria-searchlabel: "Search-field" + aria-actionlabel: "Search for the room-query entered in the search field" + action: "Go" + hidden: "hidden" + result: "result" + results: "results" + approx: "approx." + sections: + buildings: "Buildings / Sites" + of_which_visible: "of them visible" + rooms: "Rooms" + and: "and" + were_found: "were found." + no_buildings_rooms_found: "No buildings / locations or rooms could be found." +footer: + sourcecode: + text: "Source Code" + privacy: + text: "Privacy" + link: "privacy" + imprint: + text: "Imprint" + link: "impressum" + about: + text: "About us" + link: "about-us" + feedback: + text: "Feedback" + api: + text: "API" + link: "api" + language: "Language" + theme: "Theme" + theme_light: "light" + theme_dark: "dark" +feedback: + title: "Send Feedback" + subject: "Subject" + category: "Feedback category" + type: + general: "General" + bug: "Bug" + features: "Features" + search: "Search" + entry: "Entry" + mail: "E-Mail (optional)" + helptext: + general: "General Feedback about this website" + bug: "Which bug did you find? Where did you find it? Please provide a detailed description." + features: "Features you would like to see on this website" + search: "Feedback about the search. What was your search query? What did you expect to see?" + entry: "Feedback about an entry. We can add rooms/buildings/locations and adjust all data you see (names, coordinates, addresses, ...). What can we improve?" + coordinatepicker: + helptext: "If you want to correct several coordinates at the same time, you can close this window and edit more coordinates.
We will save your changes locally for 12 hours.
Warning, that's only valid for coordinates!" + title: "Select coordinate" + edit_coordinate_subject: "Edit coordinate" + edit_coordinates_subject: "Edit coordinates" + edit_multiple_coordinates: "Hello, I would like to edit these coordinates in the roomfinder:" + add_coordinate: "Hello, I would like to add this coordinate to the roomfinder:" + correct_coordinate: "Hello, I would like to correct this coordinate in the roomfinder:" + message: "Message" + public: "My feedback data may to be published anonymously, but publicly accessible on the GitHub project page. By using this feedback form, you explicitly agree to GitHub's Terms of Service and Privacy Policy and a possible transfer of your data outside of the European Union. If you do not consent to this, please write us at navigatum (at) tum.de, or use one of the other contact options listed in our imprint." + delete: "Delete this GitHub issue when resolved." + send: "Send" + cancel: "Cancel" + success: + thank_you: "Thank you for giving your feedback. We will work on this as soon as possible." + response_at: "You can see our response at" + this_issue: "this GitHub issue" + title: "Thank you!" + ok: "OK" + error: + token_req_failed: "Unexpected error when loading the feedback form. Sending feedback is currently probably not possible. Please send a mail instead." + 429: "Sending feedback is currently not possible due to rate-limiting. Please try again in a while or send a mail." + 503: "Sending feedback is currently not configured on the server." + token_unexpected_status: "Unexpected status code when retrieving a feedback token: " + send_no_token: "An unexpected error occured (no token). Please copy the text and re-open the form." + too_short_subject: "Error: Subject missing or too short" + too_short_body: "Error: Message missing or too short" + server_error: "Server Error" + privacy_not_checked: "Unavailable for legal reasons: You have to accept the privacy statement for us to process the feedback via GitHub. There are other means of contact listed at in our impressum." + send_invalid_token: "Invalid form token (probably expired). Please copy the text and re-open the form." + send_unexpected_status: "Unexpected status code: " + send_req_failed: "Unexpected error when sending the feedback form. Sending feedback is currently probably not possible. Please send a mail instead." + +core_js: + error: + 404: "404 Not found" + 500: "500 Internal Server Error" + 503: "503 Service Unavailable – Please try again soon" + status: "Unexpected status code: " + network: "Network Error, please verify that you are connected to the Internet" + unknown: "Unknown Error" + view_load_timeout: "Page could not be loaded: Timeout" +view_404: + header: "The requested website could not to be found." + description: "This could be because we made a mistake." + call_to_action: "If you think this is a mistake, please let us know here" + got_here: # "\\\\n" renders to "\n" "I have found the error by:\\\\n1. ..." +view_api: + title: "NavigaTUMs API Documentation" +view_main: + sites: "Sites" + overview_map: "Overview Map" + more: "more" + less: "less" +view_md: {} +view_search: + search_for: "Search for" + runtime: "Runtime" + max_results: "please narrow the search further" + give_feedback: "Send feedback to search" +view_view: + meta: + details_for: "Details for" + header: + copy_link: "Copy link" + external_link: + tooltip: "External links" + open_in: "Open in" + other_app: "Other app ..." + share: "Share" + share_link: "Share with ..." + copied: "Copied" + calendar: "Open calendar" + feedback: "Report issue or suggest changes" + favorites: "Add to favorites" + map: + interactive: "Interactive Map" + roomfinder: "Site Plans" + img_source: "Image source" + info_title: "Information" + info_table: + links: "Links" + buildings_overview: + title: "Buildings / Areas" + more: "more" + less: "less" + rooms_overview: + title: "Rooms" + by_usage: "by usage" + any: "any" + remove_selection: "Remove selection" + filter: "Filter" + choose_usage: "Choose a usage" + result: "result" + results_suffix: "s" + filtered: "(filtered)" + sources: + title: "Sources" + header_img: "Image" + base: + title: "Base data" + patched: "(For this entry automatic patches were applied to external data)" + coords: + title: "Coordinates" + navigatum: "NavigaTUM Contributors" + roomfinder: "Roomfinder" + inferred: "Automatically computed based on the associated rooms or buildings" + msg: + correct_location: + msg: "To set the location, drag the red Marker around." + btn-done: "Done" + btn-cancel: "Cancel" + inaccurate_only_building: + msg: "The displayed position only shows the position of the building(part). The exact position within the building is not known to us.
If you find it out, you can help others and" + btn: "Assign a coordinate" + no_floor_overlay: "There is unfortunately no indoor map for the displayed room." + coordinate-counter: + msg-1: "Currently" + msg-2: "pending coordinate edit" + msg-2-plural: "pending coordinate edits" + info: "Changes are stored \\n locally for 12 h" + delete: "Delete" + delete-confirm: "Really delete?" + send: "Send" + slideshow: + header: "Image Showcase" + close: "Close" + image_alt: "Image showing the building" + author: "Author" + source: "Source" + license: "License" + roomfinder_modal: + header: "Site Plan" + close: "Close" + image_alt: "Image showing the Site Plan" diff --git a/webclient/src/main.scss b/webclient/src/main.scss deleted file mode 100644 index fed91a094..000000000 --- a/webclient/src/main.scss +++ /dev/null @@ -1,394 +0,0 @@ -@import "src/variables"; - -/* === General === */ -html { - scroll-behavior: smooth; -} - -body { - position: relative; - - &.no-scroll { - overflow-y: hidden; - } -} - -// v-cloak is set until vue loaded -[v-cloak] { - display: none; -} - -.loading-container, -#loading-page { - display: block; - height: 100%; - width: 100%; - top: 0; - pointer-events: none; -} - -.loading-container > .loading, -#loading-page > .loading { - margin: 0 auto; - display: block; - position: static; -} - -.loading-container > .loading::after, -#loading-page > .loading::after { - border-bottom-color: transparent; - border-left-color: #ccc; -} - -#loading-page:not([v-cloak]) { - display: none; - - &.show { - display: block; - opacity: 1; - animation: loading-in .07s linear .1s; - animation-fill-mode: both; - } -} - -.img-responsive { - background-color: $image-loading-bg; -} - -@keyframes loading-in { - from { opacity: 0; } - to { opacity: 1; } -} - -// --- Menu general - -#app .menu .menu-item > a, -#app .menu .menu-item > button { // Overwrite spectre - &:focus, - &:hover { - background: $theme-accent; - color: #fff; - } -} - -#app .menu .menu-item + .menu-item { - margin-top: 0; -} - -// --- Cards -#app .card { - box-shadow: $card-shadow; - border-radius: 4px; -} - -// --- Toast buttons -.toast .btn { - background: $toast-btn-bg; - color: $light-color; - border-color: $light-color; - font-weight: bold; - - &:hover { - background: $toast-btn-bg-hover; - border-color: $light-color; - } - - &:active { - background: $toast-btn-bg-active; - border-color: $light-color; - } - - &:focus { - background: $toast-btn-bg; - border-color: $light-color; - } -} - -/* === Navbar === */ - -#navbar { - padding: 10px 0; - box-shadow: 0 2px 3px $header-shadow-color; - - .input-group button { - // background: #0065bd; - border: 0; - } - - width: 100%; - position: fixed; - background: $header-color; - top: 0; - z-index: 2000; -} - -#logo { - height: 24px; - margin-top: 9px; -} - -// --- Autocomplete - -.form-autocomplete { - .menu { - box-shadow: $autocomplete-box-shadow; - - .menu-item { - & > a { - cursor: pointer; - - &.active { - color: #fff; - background-color: $theme-accent; - } - - em { - color: $theme-accent; - font-style: normal; - font-weight: bold; - } - - &:focus em, - &:hover em, - &.active em { - color: #fff; - } - } - } - } - - .tile-content { - max-width: 100%; - margin-bottom: -5px; - line-height: 100%; - padding-bottom: .2rem; - } - - .tile-title { - margin-right: 3px; - - i.icon-caret { - transform: rotate(-90deg); - } - } - - .tile-subtitle { - text-overflow: ellipsis; - white-space: nowrap; - max-width: 100%; - padding-right: 16px; - display: inline-block; - overflow: hidden; - vertical-align: middle; - margin-top: -5px; - - // Correction for Chrome - padding-top: 2px; - height: 1.2rem; - } -} - -.menu .search-comment { - margin: -8px -8px 0; - padding: 6px 16px; - font-size: 14px; - color: $autocomplete-comment-color; - - &.filter { - color: $autocomplete-filter-text; - background-color: $autocomplete-filter-bg; - border-bottom: 1px solid $border-light; - - > a { - display: inline; - } - } - - &.nb_results { - margin: -4px 0; - padding: 4px 8px; - - > a { - cursor: pointer; - } - } - - &.actions { - margin: -4px 0 -4px 32px; - padding: 4px 8px; - overflow-x: auto; - white-space: nowrap; - - div { - display: inline-block; - margin-right: 8px; - } - - button { - margin-top: 6px; - margin-bottom: 3px; - } - } -} - -/* === Content === */ -#content { - min-height: calc(100vh - 200px); -} - -#content-header { - margin-top: 70px; // 10px + 60px for header -} - -#content.visible { - /* For some reason (I assume because the 'visible' class is not set when vue loads), - * this class gets removed if vue adds/removes the 'search_focus' class. For this reason - * opacity on page navigation is set as style property in JS. It is only guaranteed that - * this class is there on page-load. */ - transition: opacity .07s; -} - -#content.search_focus { - opacity: .7; -} - -/* === Feedback === */ -#feedback-modal, -#feedback-success-modal { - z-index: 3000; - - .modal-container { - max-height: 95vh; - box-shadow: $feedback-box-shadow; - } - - label { - width: fit-content; - display: inline-block; - } - - .buttons { - text-align: right; - } -} - -#feedback-overlay, -#feedback-overlay-2 { - background: $feedback-overlay-bg; -} - -#feedback-error { - color: $error-color; -} - -#feedback-category { - flex: none; -} - -#feedback-body { - min-width: 100%; -} - -#feedback-privacy-label { - font-size: 11px; - line-height: 140%; -} - -#feedback-coordinate-picker { - // text-align: right; - float: right; - margin-top: .5em; -} - -#feedback-coordinate-picker-helptext { - font-size: 14px; -} - -/* === Footer === */ -footer { - padding: 8px 0 16px; - background: $footer-color; - position: relative; - left: 0; - right: 0; - top: 0; - text-align: center; - - .links { - text-align: left; - - ul { - margin: 0; - - li { - list-style: none; - margin-top: 0; - } - } - - a, - router-link, - button { - font-size: .6rem; - } - - button { - height: auto; - padding: 0; - } - - button:hover { - text-decoration: underline; - } - } - - .settings { - // margin: .8rem .8rem .8rem -.8rem; - - .setting-group { - margin-top: calc(.4rem - 1px); - } - - .btn-group { - min-width: 110px; - - .btn { - border-color: transparent; - - &:disabled { - background-color: $footer-setting-bg-disabled; - color: $footer-setting-color-disabled; - } - } - } - } -} - -// Animations -@keyframes fade-in { - from { opacity: 0; } - to { opacity: 1; } -} - -// 'xs' (mobile) -@media (max-width: 480px) { - footer { - bottom: -200px; - - .links { - ul { - margin: .8rem; - - li { - margin-top: .4rem; - } - } - - a, - router-link, - button { - font-size: .7rem; - } - } - } -} diff --git a/webclient/src/main.ts b/webclient/src/main.ts new file mode 100644 index 000000000..8f282c6c5 --- /dev/null +++ b/webclient/src/main.ts @@ -0,0 +1,29 @@ +import { createApp } from "vue"; +import { createPinia } from "pinia"; + +import App from "./App.vue"; +import router from "./router"; +import { createI18n } from "vue-i18n"; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import de from "./locales/de.yaml"; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import en from "./locales/en.yaml"; + +const i18n = createI18n({ + locale: localStorage.getItem("lang") || "de", + fallbackLocale: "en", + messages: { en, de }, + legacy: false, + missingWarning: true, + include: "yaml", +}); + +const app = createApp(App); + +app.use(createPinia()); +app.use(router); +app.use(i18n); + +app.mount("#app"); diff --git a/webclient/src/modules/FloorControl.ts b/webclient/src/modules/FloorControl.ts new file mode 100644 index 000000000..0253fb180 --- /dev/null +++ b/webclient/src/modules/FloorControl.ts @@ -0,0 +1,159 @@ +import { Evented } from "maplibre-gl"; +import type { Map, IControl } from "maplibre-gl"; +import type { components } from "@/api_types"; +type OverlayMap = components["schemas"]["OverlayMap"]; +type OverlayMapEntry = components["schemas"]["OverlayMapEntry"]; +// In reality, this extends maplibregl.Control, but this is apparently not working +export class FloorControl extends Evented implements IControl { + private readonly container: HTMLDivElement; + private readonly floor_list: HTMLDivElement; + private resize_observer: ResizeObserver | undefined; + private map: Map | undefined; + + constructor() { + super(); + + this.container = document.createElement("div"); + this.container.classList.add("maplibregl-ctrl-group"); + this.container.classList.add("maplibregl-ctrl"); + this.container.classList.add("floor-ctrl"); + + // vertical open/collapse button + const verticalOpenClose = document.createElement("button"); + verticalOpenClose.classList.add("vertical-oc"); + verticalOpenClose.innerHTML = ""; + verticalOpenClose.addEventListener("click", () => this.container.classList.toggle("closed")); + // horizontal (primarily on mobile) + const horizontalOpenClose = document.createElement("button"); + horizontalOpenClose.classList.add("horizontal-oc"); + horizontalOpenClose.innerHTML = ""; + horizontalOpenClose.addEventListener("click", () => { + this.container.classList.toggle("closed"); + }); + + this.floor_list = document.createElement("div"); + this.floor_list.id = "floor-list"; + + this.container.appendChild(horizontalOpenClose); + this.container.appendChild(this.floor_list); + this.container.appendChild(verticalOpenClose); + } + + onAdd(map: Map) { + this.map = map; + + // To change on `fullscreen` click on mobile, we need to + // observe window size changed + if (ResizeObserver) { + this.resize_observer = new ResizeObserver(() => { + this._recalculateLayout(this.floor_list.children.length); + }); + this.resize_observer.observe(document.getElementById("interactive-map")!); + } + return this.container; + } + + onRemove() { + this.container.remove(); + this.map = undefined; + } + + resetFloors() { + this.container.classList.remove("visible"); + this.fire("floor-changed", { file: null, coords: undefined }); + } + updateFloors(overlays: OverlayMap) { + // `floors` is null or a list of floors with data, + // `visibleId` is the id of the visible floor. + this.floor_list.innerHTML = ""; + + const _this = this; + const clickHandlerBuilder = function (allFloors: Array | null, i: number) { + // Because JS + return () => { + if (allFloors) { + _this._setActiveFloor(i, allFloors[i].floor); + _this.fire("floor-changed", { + file: allFloors[i].file, + coords: allFloors[i].coordinates, + }); + } else { + _this._setActiveFloor(i, "∅"); + _this.fire("floor-changed", { file: null, coords: undefined }); + } + + if (!_this.container.classList.contains("reduced")) _this.container.classList.add("closed"); + }; + }; + let btn; + let visibleI = null; + overlays.available.reverse().forEach((floor, index: number) => { + btn = document.createElement("button"); + btn.innerText = floor.floor; + btn.addEventListener("click", clickHandlerBuilder(overlays.available, index)); + this.floor_list.appendChild(btn); + + if (floor.id === overlays.default) visibleI = index; + }); + + if (visibleI === null) { + this._setActiveFloor(this.floor_list.children.length, "∅"); + this.fire("floor-changed", { file: null, coords: undefined }); + } else { + this._setActiveFloor(visibleI, overlays.available[visibleI].floor); + this.fire("floor-changed", { + file: overlays.available[visibleI].file, + coords: overlays.available[visibleI].coordinates, + }); + } + + // The last button hides all overlays + btn = document.createElement("button"); + btn.innerText = "∅"; + btn.addEventListener("click", clickHandlerBuilder(null, this.floor_list.children.length)); + this.floor_list.appendChild(btn); + + this._recalculateLayout(this.floor_list.children.length); + + this.container.classList.add("visible"); + } + + // Recalculate the layout for displaying n floor buttons + private _recalculateLayout(n: number) { + // Calculate required and available size to choose between + // vertical (default) or horizontal layout + const mapHeight = document.getElementById("interactive-map")?.clientHeight || 0; + const topCtrlHeight = document.querySelector(".maplibregl-ctrl-top-left")!.clientHeight; + const bottomCtrlHeight = document.querySelector(".maplibregl-ctrl-bottom-left")!.clientHeight; + const floorCtrlHeight = document.querySelector(".floor-ctrl")!.clientHeight; + + // The buttons have a height of 29px + const availableHeight = mapHeight - topCtrlHeight - bottomCtrlHeight + floorCtrlHeight; + const requiredHeight = 29 * n; + + // 3 or fewer buttons can always be displayed in reduced layout. + // Also, if the control takes only a small amount of space, it is always open. + if (n <= 3 || requiredHeight < availableHeight * 0.2) { + this.container.classList.remove("closed"); // reduced can never be closed + this.container.classList.remove("horizontal"); + this.container.classList.add("reduced"); + } else { + this.container.classList.remove("reduced"); + this.container.classList.add("closed"); + + // 25px = 10px reserved for top/bottom margin + 5px between control groups + // 29px = additional height from the open/collapse button + if (availableHeight - (requiredHeight + 29) > 25) this.container.classList.remove("horizontal"); + else this.container.classList.add("horizontal"); + } + } + + private _setActiveFloor(floorListI: number, name: string) { + for (let i = 0; i < this.floor_list.children.length; i++) { + if (i === floorListI) this.floor_list.children[i].classList.add("active"); + else this.floor_list.children[i].classList.remove("active"); + } + document.getElementById("vertical-oc-text")!.innerText = name; + document.getElementById("horizontal-oc-text")!.innerText = name; + } +} diff --git a/webclient/src/modules/autocomplete.js b/webclient/src/modules/autocomplete.js deleted file mode 100644 index 93375e32b..000000000 --- a/webclient/src/modules/autocomplete.js +++ /dev/null @@ -1,155 +0,0 @@ -navigatum.registerModule( - "autocomplete", - (() => { - function getVisibleElements() { - const visible = []; - - navigatum.app.search.autocomplete.sections.forEach((section) => { - section.entries.forEach((entry, index) => { - if ( - section.n_visible === undefined || - index < section.n_visible || - section.expanded - ) - visible.push(entry.id); - }); - }); - return visible; - } - - function _allowHighlighting(text) { - /// This function does still parse content only from our internal API (which should not try to pawn us in the - // first place), but for extra redundancy we sanitise this anyway. - // It is not done by Vue, as we use `v-html`-Tag to include it in the frontend. - const opt = new Option(text).innerHTML; - return opt.replaceAll("\x19", "").replaceAll("\x17", ""); - } - function extractFacets(data) { - const sections = []; - - data.sections.forEach((section) => { - const entries = []; - - section.entries.forEach((entry) => { - entries.push({ - id: entry.id, - name: _allowHighlighting(entry.name), // we explicitly dont let vue sanitise this text - type: entry.type, - subtext: entry.subtext, - subtext_bold: _allowHighlighting(entry.subtext_bold), // we explicitly dont let vue sanitise this text - parsed_id: _allowHighlighting(entry.parsed_id), // we explicitly dont let vue sanitise this text - }); - }); - - if (section.facet === "sites_buildings") { - sections.push({ - name: "${{ _.search.sections.buildings }}$", - expanded: false, - entries: entries, - estimatedTotalHits: section.estimatedTotalHits, - n_visible: section.n_visible, - }); - } else if (section.facet === "rooms") { - sections.push({ - name: "${{ _.search.sections.rooms }}$", - entries: entries, - estimatedTotalHits: section.estimatedTotalHits, - }); - } - }); - - return sections; - } - - // As a simple measure against out-of-order responses - // to the autocompletion, we count queries and make sure - // that late results will not overwrite the currently - // visible results. - let queryCounter = 0; - let latestUsedQueryId = null; - - return { - init: function () {}, - onInput: function (q) { - navigatum.app.search.autocomplete.highlighted = null; - - if (q.length === 0) { - navigatum.app.search.autocomplete.sections = []; - } else { - const queryId = queryCounter; - queryCounter += 1; - - /* global cachedFetch */ - // no-cache instructs browser, because the cachedFetch will store the reponse. - const cacheConfig = { cache: "no-cache" }; - cachedFetch - .fetch( - `${navigatum.apiBase}search?q=${window.encodeURIComponent(q)}`, - cacheConfig - ) - .then((data) => { - // Data will be cached anyway in case the user hits backspace, - // but we need to discard the data here if it arrived out of order. - if (!latestUsedQueryId || queryId > latestUsedQueryId) { - latestUsedQueryId = queryId; - - navigatum.app.search.autocomplete.sections = - extractFacets(data); - } - }); - } - }, - extractFacets: extractFacets, - onKeyDown: function (e) { - let visible; - let index; - switch (e.keyCode) { - case 27: // ESC - document.getElementById("search").blur(); - break; - - case 40: // Arrow down - visible = getVisibleElements(); - index = visible.indexOf( - navigatum.app.search.autocomplete.highlighted - ); - if (index === -1 && visible.length > 0) { - navigatum.app.search.autocomplete.highlighted = visible[0]; - } else if (index >= 0 && index < visible.length - 1) { - navigatum.app.search.autocomplete.highlighted = - visible[index + 1]; - } - e.preventDefault(); - break; - - case 38: // Arrow up - visible = getVisibleElements(); - index = visible.indexOf( - navigatum.app.search.autocomplete.highlighted - ); - if (index === 0) { - navigatum.app.search.autocomplete.highlighted = null; - } else if (index > 0) { - navigatum.app.search.autocomplete.highlighted = - visible[index - 1]; - } - e.preventDefault(); - break; - - case 13: // Enter - if (navigatum.app.search.autocomplete.highlighted !== null) { - navigatum.app.searchGoTo( - navigatum.app.search.autocomplete.highlighted, - true - ); - } else { - navigatum.app.searchGo(false); - } - break; - default: - break; - } - }, - }; - })() -); diff --git a/webclient/src/modules/autocomplete.ts b/webclient/src/modules/autocomplete.ts new file mode 100644 index 000000000..0dbd253ea --- /dev/null +++ b/webclient/src/modules/autocomplete.ts @@ -0,0 +1,62 @@ +import type { components } from "@/api_types"; +type SearchResponse = components["schemas"]["SearchResponse"]; + +function _allowHighlighting(text: string) { + /// This function does still parse content only from our internal API (which should not try to pawn us in the + // first place), but for extra redundancy we sanitise this anyway. + // It is not done by Vue, as we use `v-html`-Tag to include it in the frontend. + const opt = new Option(text).innerHTML; + return opt.replace("\x19", "").replaceAll("\x17", ""); +} + +export type SectionFacet = RoomFacet | SiteBuildingFacet; +type RoomFacet = { + name: string; + entries: EntryFacet[]; + estimatedTotalHits: number; +}; +type SiteBuildingFacet = RoomFacet & { expanded: false; n_visible: number }; +type EntryFacet = { + id: string; + name: string; + type: string; + subtext: string; + subtext_bold: string; + parsed_id: string; +}; + +export function extractFacets(data: SearchResponse, t) { + const sections: SectionFacet[] = []; + + data.sections.forEach((section) => { + const entries: EntryFacet[] = []; + + section.entries.forEach((entry) => { + entries.push({ + id: entry.id, + name: _allowHighlighting(entry.name), // we explicitly dont let vue sanitise this text + type: entry.type, + subtext: entry.subtext, + subtext_bold: _allowHighlighting(entry.subtext_bold), // we explicitly dont let vue sanitise this text + parsed_id: _allowHighlighting(entry.parsed_id), // we explicitly dont let vue sanitise this text + }); + }); + if (section.facet === "sites_buildings") { + sections.push({ + name: t("search.sections.buildings"), + expanded: false, + entries: entries, + estimatedTotalHits: section.estimatedTotalHits, + n_visible: section.n_visible, + }); + } else if (section.facet === "rooms") { + sections.push({ + name: t("search.sections.rooms"), + entries: entries, + estimatedTotalHits: section.estimatedTotalHits, + }); + } + }); + + return sections; +} diff --git a/webclient/src/modules/interactive-map.js b/webclient/src/modules/interactive-map.js deleted file mode 100644 index 8401213c9..000000000 --- a/webclient/src/modules/interactive-map.js +++ /dev/null @@ -1,404 +0,0 @@ -navigatum.registerModule( - "interactive-map", - (() => { - /* global maplibregl */ - let _map; - - function FloorControl() {} - - // Because maplibregl might not be loaded yet, we need to postpone - // the declaration of the FloorControl class - function floorControlInit() { - // Add Evented functionality from maplibregl - FloorControl.prototype = Object.create(maplibregl.Evented.prototype); - - FloorControl.prototype.onAdd = function onAdd(map) { - this.map = map; - this.container = document.createElement("div"); - this.container.classList.add("maplibregl-ctrl-group"); - this.container.classList.add("maplibregl-ctrl"); - this.container.classList.add("floor-ctrl"); - - // vertical open/collapse button - const verticalOpenClose = document.createElement("button"); - verticalOpenClose.classList.add("vertical-oc"); - verticalOpenClose.innerHTML = - ""; - verticalOpenClose.addEventListener("click", () => { - this.container.classList.toggle("closed"); - }); - // horizontal (primarily on mobile) - const horizontalOpenClose = document.createElement("button"); - horizontalOpenClose.classList.add("horizontal-oc"); - horizontalOpenClose.innerHTML = - ""; - horizontalOpenClose.addEventListener("click", () => { - this.container.classList.toggle("closed"); - }); - - this.floor_list = document.createElement("div"); - this.floor_list.id = "floor-list"; - - this.container.appendChild(horizontalOpenClose); - this.container.appendChild(this.floor_list); - this.container.appendChild(verticalOpenClose); - - // To change on `fullscreen` click on mobile, we need to - // observe window size changed - if (ResizeObserver) { - this.resize_observer = new ResizeObserver(() => { - this._recalculateLayout(this.floor_list.children.length); - }); - this.resize_observer.observe( - document.getElementById("interactive-map") - ); - } - - return this.container; - }; - - FloorControl.prototype.onRemove = function onRemove() { - this.container.parentNode.removeChild(this.container); - this.map = undefined; - }; - FloorControl.prototype.updateFloors = function updateFloors( - floors, - visibleId - ) { - // `floors` is null or a list of floors with data, - // `visibleId` is the id of the visible floor. - if (floors === null) { - this.container.classList.remove("visible"); - this.fire("floor-changed", { file: null, coords: null }); - } else { - this.floor_list.innerHTML = ""; - - const _this = this; - const clickHandlerBuilder = function (allFloors, i) { - // Because JS - return () => { - if (allFloors) { - _this._setActiveFloor(i, allFloors[i].floor); - _this.fire("floor-changed", { - file: allFloors[i].file, - coords: allFloors[i].coordinates, - }); - } else { - _this._setActiveFloor(i, "∅"); - _this.fire("floor-changed", { file: null, coords: null }); - } - - if (!_this.container.classList.contains("reduced")) - _this.container.classList.add("closed"); - }; - }; - let btn; - let visibleI = null; - floors.reverse().forEach((floor, index) => { - btn = document.createElement("button"); - btn.innerText = floor.floor; - btn.addEventListener("click", clickHandlerBuilder(floors, index)); - this.floor_list.appendChild(btn); - - if (floor.id === visibleId) visibleI = index; - }); - - if (visibleI === null) { - this._setActiveFloor(this.floor_list.children.length, "∅"); - this.fire("floor-changed", { file: null, coords: null }); - } else { - this._setActiveFloor(visibleI, floors[visibleI].floor); - this.fire("floor-changed", { - file: floors[visibleI].file, - coords: floors[visibleI].coordinates, - }); - } - - // The last button hides all overlays - btn = document.createElement("button"); - btn.innerText = "∅"; - btn.addEventListener( - "click", - clickHandlerBuilder(null, this.floor_list.children.length) - ); - this.floor_list.appendChild(btn); - - this._recalculateLayout(this.floor_list.children.length); - - this.container.classList.add("visible"); - } - }; - // Recalculate the layout for displaying n floor buttons - FloorControl.prototype._recalculateLayout = function _recalculateLayout( - n - ) { - // Calculate required and available size to choose between - // vertical (default) or horizontal layout - const mapHeight = - document.getElementById("interactive-map").clientHeight; - const topCtrlHeight = document.querySelector( - ".maplibregl-ctrl-top-left" - ).clientHeight; - const bottomCtrlHeight = document.querySelector( - ".maplibregl-ctrl-bottom-left" - ).clientHeight; - const floorCtrlHeight = - document.querySelector(".floor-ctrl").clientHeight; - - // The buttons have a height of 29px - const availableHeight = - mapHeight - topCtrlHeight - bottomCtrlHeight + floorCtrlHeight; - const requiredHeight = 29 * n; - - // 3 or less buttons can always be displayed in reduced layout. - // Also, if the control takes only a small amount of space, it is always open. - if (n <= 3 || requiredHeight < availableHeight * 0.2) { - this.container.classList.remove("closed"); // reduced can never be closed - this.container.classList.remove("horizontal"); - this.container.classList.add("reduced"); - } else { - this.container.classList.remove("reduced"); - this.container.classList.add("closed"); - - // 25px = 10px reserved for top/bottom margin + 5px between control groups - // 29px = additional height from the open/collapse button - if (availableHeight - (requiredHeight + 29) > 25) - this.container.classList.remove("horizontal"); - else this.container.classList.add("horizontal"); - } - }; - FloorControl.prototype._setActiveFloor = function _setActiveFloor( - floorListI, - name - ) { - for (let i = 0; i < this.floor_list.children.length; i++) { - if (i === floorListI) - this.floor_list.children[i].classList.add("active"); - else this.floor_list.children[i].classList.remove("active"); - } - document.getElementById("vertical-oc-text").innerText = name; - document.getElementById("horizontal-oc-text").innerText = name; - }; - } - - return { - map: undefined, - init: () => - new Promise((resolve) => { - const head = document.getElementsByTagName("head")[0]; - // Add CSS first (required by Maplibre) - const elCSS = document.createElement("link"); - elCSS.rel = "stylesheet"; - elCSS.href = - "/* @echo app_prefix */css/maplibre/* @if target='release' */.min/* @endif */.css"; - head.appendChild(elCSS); - - // JS should trigger init on load - const elJS = document.createElement("script"); - elJS.src = - "/* @echo app_prefix */js/maplibre/* @if target='release' */.min/* @endif */.js"; - elJS.onload = () => { - floorControlInit(); - resolve(); - }; - head.appendChild(elJS); - }), - createMarker: function (hueRotation = 0) { - const markerDiv = document.createElement("div"); - const markerIcon = document.createElement("span"); - markerIcon.style.backgroundImage = `url(/* @echo app_prefix */assets/map-marker_pin.webp)`; - markerIcon.style.width = `25px`; - markerIcon.style.height = `36px`; - markerIcon.style.filter = `hue-rotate(${hueRotation}deg)`; - markerIcon.style.top = `-33px`; - markerIcon.style.left = `-12px`; - markerIcon.classList.add("marker"); - markerDiv.appendChild(markerIcon); - const markerShadow = document.createElement("span"); - markerShadow.style.backgroundImage = `url(/* @echo app_prefix */assets/map-marker_pin-shadow.webp)`; - markerShadow.style.width = `38px`; - markerShadow.style.height = `24px`; - markerShadow.style.top = `-20px`; - markerShadow.style.left = `-12px`; - markerShadow.classList.add("marker"); - markerDiv.appendChild(markerShadow); - return markerDiv; - }, - initMap: function (containerId) { - const map = new maplibregl.Map({ - container: containerId, - - // create the gl context with MSAA antialiasing, so custom layers are antialiasing. - // slower, but prettier and therefore worth it for our use case - antialias: true, - - // preview of the following style is available at - // https://nav.tum.de/maps/ - style: "https://nav.tum.de/maps/styles/osm_liberty/style.json", - - center: [11.5748, 48.14], // Approx Munich - zoom: 11, // Zoomed out so that the whole city is visible - - attributionControl: false, - }); - - const nav = new maplibregl.NavigationControl(); - map.addControl(nav, "top-left"); - - // (Browser) Fullscreen is enabled only on mobile, on desktop the map - // is maximized instead. This is determined once to select the correct - // container to maximize, and then remains unchanged even if the browser - // is resized (not relevant for users but for developers). - const isMobile = - window.matchMedia && - window.matchMedia("only screen and (max-width: 480px)").matches; - - const fullscreenCtl = new maplibregl.FullscreenControl({ - container: isMobile - ? document.getElementById("interactive-map") - : document.getElementById("interactive-map-container"), - }); - // "Backup" the maplibregl default fullscreen handler - fullscreenCtl._onClickFullscreenDefault = - fullscreenCtl._onClickFullscreen; - fullscreenCtl._onClickFullscreen = () => { - if (isMobile) { - fullscreenCtl._onClickFullscreenDefault(); - } else { - if (fullscreenCtl._container.classList.contains("maximize")) { - fullscreenCtl._container.classList.remove("maximize"); - document.body.classList.remove("no-scroll"); - } else { - fullscreenCtl._container.classList.add("maximize"); - document.body.classList.add("no-scroll"); - // "instant" is not part of the spec but nonetheless implemented - // by Firefox and Chrome - window.scrollTo({ top: 0, behavior: "instant" }); - } - - fullscreenCtl._fullscreen = - fullscreenCtl._container.classList.contains("maximize"); - fullscreenCtl._changeIcon(); - fullscreenCtl._map.resize(); - } - }; - // There is a bug that the map doesn't update to the new size - // when changing between fullscreen in the mobile version. - if (isMobile && ResizeObserver) { - const fullscreenObserver = new ResizeObserver(() => { - fullscreenCtl._map.resize(); - }); - fullscreenObserver.observe(fullscreenCtl._container); - } - map.addControl(fullscreenCtl); - - const location = new maplibregl.GeolocateControl({ - positionOptions: { - enableHighAccuracy: true, - }, - trackUserLocation: true, - showUserHeading: true, - }); - map.addControl(location); - - // Each source / style change causes the map to get - // into "loading" state, so map.loaded() is not reliable - // enough to know whether just the initial loading has - // succeded. - map.on("load", () => { - map.initialLoaded = true; - - // The attributionControl is automatically open, which takes up a lot of - // space on the small map display that we have. That's why we add it ourselves - // and then toggle it. - // It's only added after loading because if we add it directly on map initialization - // for some reason it doesn't work. - const attrib = new maplibregl.AttributionControl({ compact: true }); - map.addControl(attrib); - attrib._toggleAttribution(); - }); - - const _this = this; - map.floorControl = new FloorControl(); - map.floorControl.on("floor-changed", (args) => { - _this.setOverlayImage( - args.file - ? `/* @echo cdn_prefix */maps/overlay/${args.file}` - : null, - args.coords - ); - }); - map.addControl(map.floorControl, "bottom-left"); - - _map = map; - - return map; - }, - // Set the given overlays as available overlay images. - setFloorOverlays: function (overlays, defaultOverlay) { - _map.floorControl.updateFloors(overlays, defaultOverlay); - }, - // Set the currently visible overlay image in the map, - // or hide it if imgUrl is null. - setOverlayImage: function (imgUrl, coords) { - // Even if the map is initialized, it could be that - // it hasn't loaded yet, so we need to postpone adding - // the overlay layer. - // However, the official `loaded()` function is a problem - // here, because the map is shortly in a "loading" state - // when source / style is changed, even though the initial - // loading is complete (and only the initial loading seems - // to be required to do changes here) - if (!_map.initialLoaded) { - const _this = this; - _map.on("load", () => _this.setOverlayImage(imgUrl, coords)); - return; - } - - if (imgUrl === null) { - // Hide overlay - if (_map.getLayer("overlay-layer")) - _map.setLayoutProperty("overlay-layer", "visibility", "none"); - if (_map.getLayer("overlay-bg")) - _map.setLayoutProperty("overlay-bg", "visibility", "none"); - } else { - const source = _map.getSource("overlay-src"); - if (!source) - _map.addSource("overlay-src", { - type: "image", - url: imgUrl, - coordinates: coords, - }); - else - source.updateImage({ - url: imgUrl, - coordinates: coords, - }); - - const layer = _map.getLayer("overlay-layer"); - if (!layer) { - _map.addLayer({ - id: "overlay-bg", - type: "background", - paint: { - "background-color": "#ffffff", - "background-opacity": 0.6, - }, - }); - _map.addLayer({ - id: "overlay-layer", - type: "raster", - source: "overlay-src", - paint: { - "raster-fade-duration": 0, - }, - }); - } else { - _map.setLayoutProperty("overlay-layer", "visibility", "visible"); - _map.setLayoutProperty("overlay-bg", "visibility", "visible"); - } - } - }, - }; - })() -); diff --git a/webclient/src/outdated-browser.js b/webclient/src/modules/outdatedBrowser.ts similarity index 69% rename from webclient/src/outdated-browser.js rename to webclient/src/modules/outdatedBrowser.ts index 57239a1dc..ffb0a8415 100644 --- a/webclient/src/outdated-browser.js +++ b/webclient/src/modules/outdatedBrowser.ts @@ -1,9 +1,7 @@ function extractBrowserInfo() { const ua = navigator.userAgent; - let tem = null; - let M = - ua.match(/(opera|chrome|safari|firefox|msie|trident(?=\/))\/?\s*(\d+)/i) || - []; + let tem; + let M = ua.match(/(opera|chrome|safari|firefox|msie|trident(?=\/))\/?\s*(\d+)/i) || []; if (/trident/i.test(M[1])) { tem = /\brv[ :]+(\d+)/g.exec(ua) || []; @@ -18,9 +16,8 @@ function extractBrowserInfo() { } M = M[2] ? [M[1], M[2]] : [navigator.appName, navigator.appVersion, "-?"]; - tem = ua.match(/version\/(\d+)/i); - if (tem != null) { + if ((tem = ua.match(/version\/(\d+)/i)) != null) { M.splice(1, 1, tem[1]); } @@ -37,10 +34,8 @@ const minSupportedBrowsers = { function isUnSupportedBrowser() { const browser = extractBrowserInfo(); - const browserNameIsKnown = minSupportedBrowsers[browser.name] !== undefined; - return ( - browserNameIsKnown && +browser.version < minSupportedBrowsers[browser.name] - ); + const browserNameIsKnown = minSupportedBrowsers.hasOwnProperty(browser.name); + return browserNameIsKnown && +browser.version < minSupportedBrowsers[browser.name]; } function shouldWarn() { @@ -49,8 +44,7 @@ function shouldWarn() { if (lastTime === null) return true; const currentTime = new Date().getTime(); - const daysSinceLastWarning = - (currentTime - Date(lastTime)) / (1000 * 60 * 60 * 24); + const daysSinceLastWarning = (currentTime - Date(lastTime)) / (1000 * 60 * 60 * 24); return daysSinceLastWarning > 1; } @@ -60,8 +54,5 @@ if (shouldWarn()) { error.classList.add("toast", "toast-error"); error.innerHTML = "${{_.core_js.error.browser_outdated}}$"; parent.appendChild(error); - localStorage.setItem( - "lastOutdatedBrowserWarningTime", - new Date().getTime().toString() - ); + localStorage.setItem("lastOutdatedBrowserWarningTime", new Date().getTime().toString()); } diff --git a/webclient/src/router.ts b/webclient/src/router.ts new file mode 100644 index 000000000..56ad41ec1 --- /dev/null +++ b/webclient/src/router.ts @@ -0,0 +1,48 @@ +import { createRouter, createWebHistory } from "vue-router"; +import MainView from "./views/MainView.vue"; +import NotFoundView from "./views/NotFoundView.vue"; +import SearchView from "./views/SearchView.vue"; + +const routes = [ + { path: "/", name: "main", component: MainView }, + { + path: "/:view(view|campus|site|building|room)/:id", + name: "detail", + component: () => import("./views/DetailsView.vue"), + }, + { path: "/search", name: "search", component: SearchView }, + { + path: "/api", + name: "api", + component: () => import("./views/APIView.vue"), + }, + { + path: "/about/:name", + name: "about", + component: () => import("./views/AboutView.vue"), + }, + { + path: "/:catchAll(.*)", + name: "404", + component: NotFoundView, + }, +]; + +const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes: routes, + scrollBehavior(to, from, savedPosition) { + if (savedPosition) { + return savedPosition; + } + // Just returning (0, 0) does not work when the new page is + // the same component, and it got so small, that the current + // position is now the bottom of the new page. + // For this reason this extra call. + document.getElementById("content")?.scrollIntoView(); + + return { x: 0, y: 0, behavior: "smooth" }; + }, +}); + +export default router; diff --git a/webclient/src/spectre-all.scss b/webclient/src/spectre-all.scss deleted file mode 100644 index 1b48f92c4..000000000 --- a/webclient/src/spectre-all.scss +++ /dev/null @@ -1,10 +0,0 @@ -@import "src/variables"; -@import "node_modules/spectre.css/src/spectre"; -@import "node_modules/spectre.css/src/spectre-exp"; -@import "node_modules/spectre.css/src/spectre-icons"; - -// Changes -.btn:focus, -a:focus { - box-shadow: none; -} diff --git a/webclient/src/stores/details.ts b/webclient/src/stores/details.ts new file mode 100644 index 000000000..528d517a4 --- /dev/null +++ b/webclient/src/stores/details.ts @@ -0,0 +1,79 @@ +import { defineStore } from "pinia"; +import type { components } from "@/api_types"; +type DetailsResponse = components["schemas"]["DetailsResponse"]; +type ImageInfo = components["schemas"]["ImageInfo"]; +type RoomfinderMapEntry = components["schemas"]["RoomfinderMapEntry"]; +export enum selectedMap { + roomfinder, + interactive, +} + +export const useDetailsStore = defineStore({ + id: "details", + state: () => ({ + data: null as DetailsResponse | null, + image: { + shown_image: null as ImageInfo | null, + shown_image_id: null as number | null, + slideshow_open: false, + }, + map: { + // "interactive" is default, because it should show a loading indication. + selected: selectedMap.interactive as selectedMap, + roomfinder: { + selected_id: null as string | null, // Map id + selected_index: 0 as number, // Index in the 'available' list + x: -1023 - 10, // Outside in top left corner + y: -1023 - 10, + modal_open: false, + modalX: -1023 - 10, // Outside in top left corner + modalY: -1023 - 10, + width: 400, + height: 300, + }, + }, + coord_picker: { + // The coordinate picker keeps backups of the subject and body + // in case someone writes a text and then after that clicks + // the set coordinate button in the feedback form. If we wouldn't + // make a backup, this would be lost after clicking confirm there. + backup_id: null as string | null, + subject_backup: null as string | null, + body_backup: null as string | null, + force_reopen: false, + }, + }), + actions: { + showImageSlideshow: function (i: number, openSlideshow = true) { + if (this.data?.imgs && this.data.imgs[i]) { + this.image.slideshow_open = openSlideshow; + this.image.shown_image_id = i; + this.image.shown_image = this.data.imgs[i]; + } else { + this.image.slideshow_open = false; + this.image.shown_image_id = null; + this.image.shown_image = null; + } + }, + hideImageSlideshow: function () { + this.image.slideshow_open = false; + }, + loadData: function (d: DetailsResponse) { + this.showImageSlideshow(0, false); + + // --- Maps --- + this.map.selected = d.maps.default === "interactive" ? selectedMap.interactive : selectedMap.roomfinder; + // Interactive has to be always available, but roomfinder may be unavailable + if (d.maps.roomfinder !== undefined) { + // Find default map + d.maps.roomfinder.available.forEach((availableMap: RoomfinderMapEntry, index: number) => { + if (availableMap.id === this.data?.maps.roomfinder?.default) { + this.map.roomfinder.selected_index = index; + this.map.roomfinder.selected_id = availableMap.id; + } + }); + } + this.data = d; + }, + }, +}); diff --git a/webclient/src/stores/global.ts b/webclient/src/stores/global.ts new file mode 100644 index 000000000..7de15d7bb --- /dev/null +++ b/webclient/src/stores/global.ts @@ -0,0 +1,48 @@ +import { defineStore } from "pinia"; +import type { components } from "@/api_types"; +type TokenRequest = components["schemas"]["TokenRequest"]; + +export const useGlobalStore = defineStore({ + id: "global", + state: () => ({ + search_focused: false, + error_message: null, + information_modal: { + header: null as string | null, + body: null as string | null, + }, + feedback: { + open: false, + category: "general" as TokenRequest["category"], + subject: "", + body: "", + }, + }), + actions: { + focus_search() { + this.search_focused = true; + }, + unfocus_search() { + this.search_focused = false; + }, + openFeedback(category: TokenRequest["category"] = "general", subject = "", body = "") { + this.feedback.open = true; + this.feedback.category = category; + this.feedback.subject = subject; + this.feedback.body = body; + + document.body.classList.add("no-scroll"); + }, + temprarilyCloseFeedback() { + this.feedback.open = false; + document.body.classList.remove("no-scroll"); + }, + reopenFeedback() { + this.feedback.open = false; + document.body.classList.remove("no-scroll"); + }, + showInformationModal(body: string, header: string | null = null) { + this.information_modal = { body, header }; + }, + }, +}); diff --git a/webclient/src/utils/common.ts b/webclient/src/utils/common.ts new file mode 100644 index 000000000..b6b5e32f1 --- /dev/null +++ b/webclient/src/utils/common.ts @@ -0,0 +1,39 @@ +export function setTitle(name: string) { + document.title = `${name} – NavigaTUM`; + document.querySelector('meta[property="og:title"]')?.setAttribute("content", name); +} +export function setDescription(description: string) { + document.querySelector('meta[name="description"]')?.setAttribute("content", description); + document.querySelector('meta[property="og:description"]')?.setAttribute("content", description); +} +export function setUrl() { + document.querySelector('meta[property="og:url"]')?.setAttribute("content", window.location.href); +} + +export function copyCurrentLink(copied) { + // c.f. https://stackoverflow.com/a/30810322 + const textArea = document.createElement("textarea"); + textArea.value = window.location.href; + + // Avoid scrolling to bottom + textArea.style.top = "0"; + textArea.style.left = "0"; + textArea.style.position = "fixed"; + + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + + try { + if (document.execCommand("copy")) { + copied = true; + window.setTimeout(() => { + copied = false; + }, 1000); + } + } catch (err) { + console.error("Failed to copy to clipboard", err); + } + + document.body.removeChild(textArea); +} diff --git a/webclient/src/utils/fetch.ts b/webclient/src/utils/fetch.ts new file mode 100644 index 000000000..e6184850d --- /dev/null +++ b/webclient/src/utils/fetch.ts @@ -0,0 +1,26 @@ +import { shallowRef } from "vue"; +import { useGlobalStore } from "@/stores/global"; + +export function useFetch(url: string, successHandler: (d: T) => void = () => {}, options: RequestInit = {}) { + const data = shallowRef(null); + // for some of our endpoints, we might want to have access to the lang/theme cookies + + // Add language query param to the request + const lang = document.documentElement.lang; + url += (url.indexOf("?") != -1 ? "&lang=" : "?lang=") + lang; + + const global = useGlobalStore(); + fetch(url, options) + .then((res) => { + if (res.status < 200 || res.status >= 300) throw res.statusText; + return res.json(); + }) + .then((json) => { + if (global.error_message) global.error_message = null; + data.value = json; + successHandler(json); + }) + .catch((err) => (global.error_message = err)); + + return { data }; +} diff --git a/webclient/src/utils/storage.ts b/webclient/src/utils/storage.ts new file mode 100644 index 000000000..2b2395d17 --- /dev/null +++ b/webclient/src/utils/storage.ts @@ -0,0 +1,33 @@ +export function setLocalStorageWithExpiry(key: string, value: any, ttl: number) { + // ttl in hours + const now = new Date(); + + const item = { + value: value, + expiry: now.getTime() + ttl * 3.6e6, + }; + localStorage.setItem(key, JSON.stringify(item)); + + // "storage" usually fires only across tabs, this way we + // force it to fire in this window as well + const e = new Event("storage"); + window.dispatchEvent(e); +} + +export function getLocalStorageWithExpiry(key: string, defaultValue: T | null = null): T | null { + const itemStr = localStorage.getItem(key); + if (!itemStr) return defaultValue; + const item = JSON.parse(itemStr); + const now = new Date(); + if (now.getTime() > item.expiry) { + localStorage.removeItem(key); + return defaultValue; + } + return item.value; +} + +export function removeLocalStorage(key: string) { + localStorage.removeItem(key); + const e = new Event("storage"); + window.dispatchEvent(e); +} diff --git a/webclient/src/views/404/i18n-404.yaml b/webclient/src/views/404/i18n-404.yaml deleted file mode 100644 index 3cd34d5f3..000000000 --- a/webclient/src/views/404/i18n-404.yaml +++ /dev/null @@ -1,13 +0,0 @@ -view_404: - header: - de: "Die angeforderte Seite wurde nicht gefunden." - en: "The requested website could not to be found." - description: - de: "Dies könnte sein, weil wir einen Fehler gemacht haben." - en: "This could be because we made a mistake." - call_to_action: - de: "Falls du denkst, dass dies ein Fehler ist, teile es uns doch hier mit" - en: "If you think this is a mistake, please let us know here" - got_here: # "\\\\n" renders to "\n" - de: "Ich habe diesen Fehler so gefunden:\\\\n1. ..." - en: "I have found the error by:\\\\n1. ..." diff --git a/webclient/src/views/404/view-404.inc b/webclient/src/views/404/view-404.inc deleted file mode 100644 index c92b4c1f4..000000000 --- a/webclient/src/views/404/view-404.inc +++ /dev/null @@ -1,12 +0,0 @@ -
-
${{_.core_js.error.404}}$
-
${{_.view_404.header}}$
-

${{_.view_404.description}}$

- -
diff --git a/webclient/src/views/404/view-404.js b/webclient/src/views/404/view-404.js deleted file mode 100644 index cc33b29aa..000000000 --- a/webclient/src/views/404/view-404.js +++ /dev/null @@ -1,8 +0,0 @@ -// Alread pre-request root data: -navigatum.getData("root"); - -navigatum.registerView("404", { - name: "view-404", - template: { gulp_inject: "view-404.inc" }, - data: {}, -}); diff --git a/webclient/src/views/404/view-404.scss b/webclient/src/views/404/view-404.scss deleted file mode 100644 index 020003d95..000000000 --- a/webclient/src/views/404/view-404.scss +++ /dev/null @@ -1,5 +0,0 @@ -@import "src/variables"; - -#view-404 { - /* stylelint-disable-next-line block-no-empty */ -} diff --git a/webclient/src/views/APIView.vue b/webclient/src/views/APIView.vue new file mode 100644 index 000000000..18b34fa4c --- /dev/null +++ b/webclient/src/views/APIView.vue @@ -0,0 +1,37 @@ + + + + + diff --git a/webclient/src/views/AboutView.vue b/webclient/src/views/AboutView.vue new file mode 100644 index 000000000..c999dbabb --- /dev/null +++ b/webclient/src/views/AboutView.vue @@ -0,0 +1,50 @@ + + + + + diff --git a/webclient/src/views/DetailsView.vue b/webclient/src/views/DetailsView.vue new file mode 100644 index 000000000..8b368d3f2 --- /dev/null +++ b/webclient/src/views/DetailsView.vue @@ -0,0 +1,579 @@ + + + + + diff --git a/webclient/src/views/MainView.vue b/webclient/src/views/MainView.vue new file mode 100644 index 000000000..faca175e7 --- /dev/null +++ b/webclient/src/views/MainView.vue @@ -0,0 +1,193 @@ + + + + + diff --git a/webclient/src/views/NotFoundView.vue b/webclient/src/views/NotFoundView.vue new file mode 100644 index 000000000..b79f3bb2b --- /dev/null +++ b/webclient/src/views/NotFoundView.vue @@ -0,0 +1,18 @@ + + + diff --git a/webclient/src/views/SearchView.vue b/webclient/src/views/SearchView.vue new file mode 100644 index 000000000..09490fbf5 --- /dev/null +++ b/webclient/src/views/SearchView.vue @@ -0,0 +1,218 @@ + + + + + diff --git a/webclient/src/views/api/i18n-api.yaml b/webclient/src/views/api/i18n-api.yaml deleted file mode 100644 index 697785fc8..000000000 --- a/webclient/src/views/api/i18n-api.yaml +++ /dev/null @@ -1,4 +0,0 @@ -view_api: - title: - de: "NavigaTUMs API Documentation" - en: "NavigaTUMs API Documentation" diff --git a/webclient/src/views/api/view-api.inc b/webclient/src/views/api/view-api.inc deleted file mode 100644 index 08b5b8b13..000000000 --- a/webclient/src/views/api/view-api.inc +++ /dev/null @@ -1,3 +0,0 @@ -
-
-
diff --git a/webclient/src/views/api/view-api.js b/webclient/src/views/api/view-api.js deleted file mode 100644 index a523a2a41..000000000 --- a/webclient/src/views/api/view-api.js +++ /dev/null @@ -1,48 +0,0 @@ -function apiNavigateTo(to, from, next) { - navigatum.beforeNavigate(to, from); - navigatum.setTitle("${{_.view_api.title}}$"); - next(); - - const head = document.getElementsByTagName("head")[0]; - // Add CSS first (required by swagger-ui) - const elCSS = document.createElement("link"); - elCSS.rel = "stylesheet"; - elCSS.href = "/* @echo app_prefix */css/swagger-ui.min.css"; - head.appendChild(elCSS); - - // JS should trigger init on load - const elJS = document.createElement("script"); - elJS.src = "/* @echo app_prefix */js/swagger-ui.min.js"; - elJS.onload = () => { - window.setTimeout(() => { - // we need to make sure, that swagger-ui exists, otherwise the following command will fail - // therefore waiting is effective - /* global SwaggerUIBundle */ - SwaggerUIBundle({ - url: "https://raw.githubusercontent.com/TUM-Dev/navigatum/main/openapi.yaml", - dom_id: "#swagger-ui", - presets: [ - SwaggerUIBundle.presets.apis, - // SwaggerUIStandalonePreset - ], - // layout: "StandaloneLayout", - }); - navigatum.afterNavigate(to, from); - }, 10); - }; - head.appendChild(elJS); -} - -navigatum.registerView("api", { - name: "view-api", - template: { gulp_inject: "view-api.inc" }, - data: function () { - return {}; - }, - beforeRouteEnter: function (to, from, next) { - apiNavigateTo(to, from, next); - }, - beforeRouteUpdate: function (to, from, next) { - apiNavigateTo(to, from, next); - }, -}); diff --git a/webclient/src/views/api/view-api.scss b/webclient/src/views/api/view-api.scss deleted file mode 100644 index 1997b79b2..000000000 --- a/webclient/src/views/api/view-api.scss +++ /dev/null @@ -1,17 +0,0 @@ -@import "src/variables"; - -#view-api { - .swagger-ui { - // we cannot apply loading-lg to this external dependency - .loading-container .loading { - min-height: 2rem; - } - - .loading-container .loading::after { - height: 1.6rem; - margin-left: -.8rem; - margin-top: -.8rem; - width: 1.6rem; - } - } -} diff --git a/webclient/src/views/main/i18n-main.yaml b/webclient/src/views/main/i18n-main.yaml deleted file mode 100644 index 922ad25fc..000000000 --- a/webclient/src/views/main/i18n-main.yaml +++ /dev/null @@ -1,5 +0,0 @@ -view_main: - sites: { de: "Standorte", en: "Sites" } - overview_map: { de: "Übersichtskarte", en: "Overview Map" } - more: { de: "mehr", en: "more" } - less: { de: "weniger", en: "less" } diff --git a/webclient/src/views/main/view-main.inc b/webclient/src/views/main/view-main.inc deleted file mode 100644 index f1653c09f..000000000 --- a/webclient/src/views/main/view-main.inc +++ /dev/null @@ -1,83 +0,0 @@ -
-
-
${{_.view_main.sites}}$
- -
-
-
-
-
- -
-
-
{{ site.name }}
-
-
- -
-
-
-
-
-
{{ site.name }}
-
-
-
-
- -
-
-
- -
-
-
-
{{ c.name }}
-
-
- -
-
-
- - -
-
-
-
-
diff --git a/webclient/src/views/main/view-main.js b/webclient/src/views/main/view-main.js deleted file mode 100644 index 979d39d96..000000000 --- a/webclient/src/views/main/view-main.js +++ /dev/null @@ -1,33 +0,0 @@ -// Alread pre-request root data: -navigatum.getData("root"); - -navigatum.registerView("main", { - name: "view-main", - template: { gulp_inject: "view-main.inc" }, - data: function () { - return { - root_data: null, - }; - }, - beforeRouteEnter: function (to, from, next) { - navigatum.getData("root").then((data) => next((vm) => vm.setData(data))); - }, - beforeRouteUpdate: function (to, from, next) { - // beforeRouteUpdate not used for now since data rarely changes - next(); - }, - methods: { - setData: function (data) { - if (data !== null) navigatum.setTitle(data.name); - // initalising this sets vue's renering into motion. - // Because of this, we want to set the values relevant for embeds first, as Rendertron may decide that "we are ready now" - this.root_data = data; - }, - more: function (id) { - document.getElementById(`panel-${id}`).classList.add("open"); - }, - less: function (id) { - document.getElementById(`panel-${id}`).classList.remove("open"); - }, - }, -}); diff --git a/webclient/src/views/main/view-main.scss b/webclient/src/views/main/view-main.scss deleted file mode 100644 index 41095840b..000000000 --- a/webclient/src/views/main/view-main.scss +++ /dev/null @@ -1,98 +0,0 @@ -@import "src/variables"; - -#view-main { - .panel { - border: 1px solid $card-border; - border-radius: 10px; - overflow: hidden; - box-shadow: $card-shadow-dark; - margin: 10px 0; - padding-bottom: 12px; - - .panel-header { - width: 100%; - margin-bottom: 8px; - - & > a { - text-decoration: none; - - .h6 { - text-align: left; - color: $body-font-color; - transition: color .1s; - - &:hover, - &:active { - color: $primary-color; - } - } - - button { - margin-top: -7px; - margin-bottom: -7px; - } - } - - a.btn { - margin: -8px 0; - } - - .h6 { - font-weight: bold; - } - } - - .panel-body { - & > a { - text-decoration: none; - } - - .link-more { - opacity: .5; - transition: opacity .1s; - - .tile { - display: none; - } - } - - .tile-icon { - color: $body-font-color; - margin-top: -4px; - } - - .tile-title { - padding-left: 8px; - } - } - - .btn-more, - .btn-less { - margin-top: 5px; - padding-bottom: 0; - padding-left: 0; - } - - .btn-less { - display: none; - } - } - - .panel.open { - .panel-body .link-more { - opacity: 1; - - .tile { - display: flex; - } - } - - .btn-more { - display: none; - } - - .btn-less { - display: inline-block; - } - } -} diff --git a/webclient/src/views/md/i18n-md.yaml b/webclient/src/views/md/i18n-md.yaml deleted file mode 100644 index c78983689..000000000 --- a/webclient/src/views/md/i18n-md.yaml +++ /dev/null @@ -1 +0,0 @@ -view_md: {} diff --git a/webclient/src/views/md/view-md.inc b/webclient/src/views/md/view-md.inc deleted file mode 100644 index 0c2836b32..000000000 --- a/webclient/src/views/md/view-md.inc +++ /dev/null @@ -1,8 +0,0 @@ -
- - - - -
diff --git a/webclient/src/views/md/view-md.js b/webclient/src/views/md/view-md.js deleted file mode 100644 index 43ab17835..000000000 --- a/webclient/src/views/md/view-md.js +++ /dev/null @@ -1,72 +0,0 @@ -function mdNavigateTo(to, from, next, component) { - navigatum.beforeNavigate(to, from); - - // Component is not registered on pageload because Vue might not be availabe then - if (!Vue.component("md-content")) { - // c.f. https://stackoverflow.com/questions/47530417/dynamic-router-link - Vue.component("md-content", { - props: { - content: { - type: String, - required: true, - }, - }, - render: function (h) { - return h(Vue.compile(`
${this.content}
`)); - }, - }); - } - - /* global cachedFetch */ - cachedFetch - .fetch(`/* @echo app_prefix */pages/${to.params.name}.html`, { - as_text: true, - }) - .then((resp) => { - if (component) { - next(); - navigatum.afterNavigate(to, from); - component.loadPage(resp); - } else { - next((vm) => { - navigatum.afterNavigate(to, from); - vm.loadPage(resp); - }); - } - }); -} - -navigatum.registerView("md", { - name: "view-md", - template: { gulp_inject: "view-md.inc" }, - data: function () { - return { - content: null, - }; - }, - beforeRouteEnter: function (to, from, next) { - mdNavigateTo(to, from, next, null); - }, - beforeRouteUpdate: function (to, from, next) { - mdNavigateTo(to, from, next, this); - }, - methods: { - loadPage: function (content) { - this.content = content; - - this.$nextTick(() => { - const e = document.getElementById("view-md"); - if (e === null) { - console.warn( - "Failed to update page title. Probably the page is not mounted yet or there was an error." - ); - return; - } - - const c = e.firstChild; - if (c && c.firstChild.tagName.toLowerCase() === "h1") - navigatum.setTitle(c.firstChild.innerText); - }); - }, - }, -}); diff --git a/webclient/src/views/md/view-md.scss b/webclient/src/views/md/view-md.scss deleted file mode 100644 index 9d433175c..000000000 --- a/webclient/src/views/md/view-md.scss +++ /dev/null @@ -1,27 +0,0 @@ -@import "src/variables"; - -#view-md { - padding-top: 15px; - - h1 { - font-size: 1.8rem; - } - - h2 { - font-size: 1.5rem; - } - - h1, - h2, - h3 { - font-weight: 500; - } - - code { - background: $code-bg; - } - - .code code { - background: $bg-color; - } -} diff --git a/webclient/src/views/search/i18n-search.yaml b/webclient/src/views/search/i18n-search.yaml deleted file mode 100644 index 26fb93013..000000000 --- a/webclient/src/views/search/i18n-search.yaml +++ /dev/null @@ -1,9 +0,0 @@ -view_search: - search_for: { de: "Suche nach", en: "Search for" } - runtime: { de: "Laufzeit", en: "Runtime" } - max_results: - de: "bitte grenze die Suche weiter ein" - en: "please narrow the search further" - give_feedback: - de: "Feedback zur Suche geben" - en: "Send feedback to search" diff --git a/webclient/src/views/search/view-search.inc b/webclient/src/views/search/view-search.inc deleted file mode 100644 index ef4234fb4..000000000 --- a/webclient/src/views/search/view-search.inc +++ /dev/null @@ -1,64 +0,0 @@ - diff --git a/webclient/src/views/search/view-search.js b/webclient/src/views/search/view-search.js deleted file mode 100644 index 5799775cc..000000000 --- a/webclient/src/views/search/view-search.js +++ /dev/null @@ -1,92 +0,0 @@ -function searchNavigateTo(to, from, next, component) { - navigatum.beforeNavigate(to, from); - - const params = new URLSearchParams(); - params.append("q", to.query.q); - params.append("limit_buildings", "10"); - params.append("limit_rooms", "30"); - params.append("limit_all", "30"); - - /* global cachedFetch */ - cachedFetch - .fetch(`${navigatum.apiBase}search?${params.toString()}`, { - cache: "no-cache", - }) - .then((resp) => { - if (component) { - next(); - navigatum.afterNavigate(to, from); - component.loadSearchData(to.query.q, resp); - } else { - next((vm) => { - navigatum.afterNavigate(to, from); - vm.loadSearchData(to.query.q, resp); - }); - } - }); -} - -const _searchDefaultState = {}; - -navigatum.registerView("search", { - name: "view-search", - template: { gulp_inject: "view-search.inc" }, - data: function () { - return { - search_data: null, - sections: null, - query: null, - // State is preserved when navigating in history. - // May only contain serializable objects! - state: structuredClone(_searchDefaultState), - }; - }, - beforeRouteEnter: function (to, from, next) { - searchNavigateTo(to, from, next, null); - }, - beforeRouteUpdate: function (to, from, next) { - searchNavigateTo(to, from, next, this); - }, - methods: { - genDescription: function (data) { - let sectionsDescr = ""; - let estimatedTotalHits = 0; - data.sections.forEach((section) => { - if (section.estimatedTotalHits) { - let facetStr; - if (section.facet === "sites_buildings") { - facetStr = "${{ _.search.sections.buildings }}$"; - if (section.estimatedTotalHits !== section.n_visible) { - const visibleStr = "${{ _.search.sections.of_which_visible }}$"; - facetStr = `(${section.n_visible} ${visibleStr}) ${facetStr}`; - } - } else facetStr = "${{ _.search.sections.rooms }}$"; - if (estimatedTotalHits > 0) - sectionsDescr += " ${{ _.search.sections.and }}$ "; - sectionsDescr += `${section.estimatedTotalHits} ${facetStr}`; - } - estimatedTotalHits += section.estimatedTotalHits; - }); - if (estimatedTotalHits === 0) - sectionsDescr = "${{ _.search.sections.no_buildings_rooms_found }}$"; - else sectionsDescr += " ${{ _.search.sections.were_found }}$"; - return sectionsDescr; - }, - loadSearchData: function (query, data) { - navigatum.setTitle(`\${{ _.view_search.search_for }}$ "${query}"`); - navigatum.setDescription(this.genDescription(data)); - // initalising this sets vue's renering into motion. - // Because of this, we want to set the values relevant for embeds first, as Rendertron may decide that "we are ready now" - this.search_data = data; - this.query = query; - navigatum.app.search.query = query; - // Currently borrowing this functionality from autocomplete. - // In the future it is planned that this search results page - // has a different format. - const _this = this; - navigatum.getModule("autocomplete").then((c) => { - _this.sections = c.extractFacets(data); - }); - }, - }, -}); diff --git a/webclient/src/views/search/view-search.scss b/webclient/src/views/search/view-search.scss deleted file mode 100644 index c8a01445f..000000000 --- a/webclient/src/views/search/view-search.scss +++ /dev/null @@ -1,88 +0,0 @@ -@import "src/variables"; - -#view-search { - padding-top: 25px; - - h1 { - font-size: 1.2rem; - font-weight: 500; - } - - h2 { - font-size: 1rem; - font-weight: 500; - } - - section { - margin-top: 40px; - - .search-comment { - &.nb_results { - color: $text-gray; - } - } - } - - .divider + section { - margin-top: 30px; - } - - ul.result-list { - list-style: none; - margin-left: 0; - margin-top: 0; - - li { - padding: 8px 10px 6px; - border-radius: 6px; - box-shadow: 3px 3px 4px rgba(106, 106, 106, 1%); - border: .05rem solid $search-border; - transition: border .2s; - - &:hover { - box-shadow: 3px 3px 4px rgba(106, 106, 106, 3%); - border: .05rem solid $search-border-hover; - } - - a { - text-decoration: none; - color: $body-font-color; - } - - .tile-title { - line-height: 1rem; - - i.icon-caret { - transform: rotate(-90deg); - } - } - - .tile-subtitle { - line-height: 1rem; - } - - em { - font-style: normal; - font-weight: bold; - color: $theme-accent; - } - } - } - - small { - button { // feedback-form, .. - font-size: 12px; - padding: 0; - } - - .search_meta { - display: block; - - // color: $text-gray; - - a { - color: $body-font-color; - } - } - } -} diff --git a/webclient/src/views/view/i18n-view.yaml b/webclient/src/views/view/i18n-view.yaml deleted file mode 100644 index a38261499..000000000 --- a/webclient/src/views/view/i18n-view.yaml +++ /dev/null @@ -1,117 +0,0 @@ -view_view: - meta: - details_for: { de: "Details für", en: "Details for" } - header: - copy_link: { de: "Link kopieren", en: "Copy link" } - external_link: - tooltip: { de: "Externe Links", en: "External links" } - open_in: { de: "Öffnen in", en: "Open in" } - other_app: { de: "Andere App ...", en: "Other app ..." } - share: { de: "Teilen", en: "Share" } - share_link: { de: "Teilen mit ...", en: "Share with ..." } - copied: { de: "Kopiert", en: "Copied" } - calendar: { de: "Kalender öffnen", en: "Open calendar" } - feedback: - de: "Problem melden oder Änderung vorschlagen" - en: "Report issue or suggest changes" - favorites: { de: "Zu Favoriten hinzufügen", en: "Add to favorites" } - map: - interactive: { de: "Interaktive Karte", en: "Interactive Map" } - roomfinder: { de: "Lagepläne", en: "Site Plans" } - img_source: { de: "Bildquelle", en: "Image source" } - info_title: { de: "Informationen", en: "Information" } - info_table: - links: { de: "Links", en: "Links" } - buildings_overview: - title: { de: "Gebäude / Gebiete", en: "Buildings / Areas" } - more: { de: "mehr", en: "more" } - less: { de: "weniger", en: "less" } - rooms_overview: - title: { de: "Räume", en: "Rooms" } - by_usage: { de: "nach Nutzung", en: "by usage" } - any: { de: "beliebig", en: "any" } - remove_selection: { de: "Auswahl löschen", en: "Remove selection" } - filter: { de: "Filter", en: "Filter" } - choose_usage: { de: "Wähle eine Nutzung aus", en: "Choose a usage" } - result: { de: "Ergebnis", en: "result" } - results_suffix: { de: "se", en: "s" } - filtered: { de: "gefiltert", en: "filtered" } - sources: - title: { de: "Quellen", en: "Sources" } - header_img: { de: "Bild", en: "Image" } - base: - title: { de: "Basisdaten", en: "Base data" } - patched: - de: "(Bei diesem Eintrag wurden automatische Korrekturen zu externen Daten angewandt)" - en: "(For this entry automatic patches were applied to external data)" - coords: - title: { de: "Koordinaten", en: "Coordinates" } - navigatum: { de: "NavigaTUM Mitwirkende", en: "NavigaTUM Contributors" } - roomfinder: - de: "Roomfinder" - en: "Roomfinder" - inferred: - de: "Automatisch berechnet aus den zugehörigen Räumen oder Gebäuden" - en: "Automatically computed based on the associated rooms or buildings" - msg: - correct_location: - msg: - de: "Um die richtige Position zu setzen, ziehe den roten Marker über die Karte." - en: "To set the location, drag the red Marker around." - btn-done: - de: "Fertig" - en: "Done" - btn-cancel: - de: "Abbrechen" - en: "Cancel" - inaccurate_only_building: - msg: - de: "Die angezeigte Position zeigt nur die Position des Gebäude(teils). Die genaue Lage innerhalb des Gebäudes ist uns nicht bekannt.
Falls du sie herausfindest, kannst du die" - en: "The displayed position only shows the position of the building(part). The exact position within the building is not known to us.
If you find it out, you can help others and" - btn: - de: "Koordinate eintragen" - en: "Assign a coordinate" - no_floor_overlay: - de: "Für den angezeigten Raum gibt es leider keine Indoor Karte." - en: "There is unfortunately no indoor map for the displayed room." - coordinate-counter: - msg-1: - de: "Aktuell" - en: "Currently" - msg-2: - de: "ausstehende Koordinatenänderung" - en: "pending coordinate edit" - msg-2-plural: - de: "ausstehende Koordinatenänderungen" - en: "pending coordinate edits" - info: - de: "Änderungen werden lokal \\n für 12h gespeichert" - en: "Changes are stored \\n locally for 12 h" - delete: - de: "Löschen" - en: "Delete" - delete-confirm: - de: "Wirklich löschen?" - en: "Really delete?" - send: - de: "Senden" - en: "Send" - slideshow: - header: - de: "Bilder-Showcase" - en: "Image Showcase" - close: { de: "Schließen", en: "Close" } - image_alt: - de: "Ein Bild welches das Gebäude zeigt" - en: "Image showing the building" - author: { de: "Autor", en: "Author" } - source: { de: "Quelle", en: "Source" } - license: { de: "Lizenz", en: "License" } - roomfinder-modal: - header: - de: "Lageplan" - en: "Site Plan" - close: { de: "Schließen", en: "Close" } - image_alt: - de: "Ein Bild welches den Lageplan zeigt" - en: "Image showing the Site Plan" diff --git a/webclient/src/views/view/view-view.inc b/webclient/src/views/view/view-view.inc deleted file mode 100644 index 305c5d3f6..000000000 --- a/webclient/src/views/view/view-view.inc +++ /dev/null @@ -1,972 +0,0 @@ -
- - - Header-Image, showing the building - - - -
-
-
- ${{_.view_view.msg.coordinate-counter.msg-1}}$ - {{ coord_counter.counter }} - - ${{_.view_view.msg.coordinate-counter.msg-2}}$ - - - ${{_.view_view.msg.coordinate-counter.msg-2-plural}}$ - - -
-
- - -
-
-
- - - - - -
-
-
- -
-

- {{ view_data.name }} -

-
-
-
- {{ view_data.type_common_name }} -
-
- - - - - - - - - - -
-
-
-
- - -
- -
-
-
- ${{_.view_view.msg.inaccurate_only_building.msg}}$ - -
-
- ${{_.view_view.msg.no_floor_overlay}}$ -
-
- {{ view_data.props.comment }} -
-
- -
-
-
- ${{_.view_view.msg.correct_location.msg}}$ -
-
- - -
-
-
- -
-
-
-
-
- -
- Cross showing where the room is located on the hand-drawn roomfinder map image - Hand-drawn roomfinder map image -
- ${{_.view_view.map.img_source}}$: {{ - view_data.maps.roomfinder.available[state.map.roomfinder.selected_index].source - }} -
-
-
-
- - -
- -
-
-
- - -
-
-
- - - - -
-

Informationen

- - - - - - - - - - - -
{{ prop.name }}{{ prop.text }} -
- - - - -
-
${{ _.view_view.info_table.links }}$ - -
-
- - - -
-
- - Header-Image, showing the building - -
-
${{_.view_view.info_title}}$
-
-
- - - - - - - - - - - -
{{ prop.name }}{{ prop.text }} -
- - - - -
-
-
- {{ prop.extra.header }} -
-
- {{ prop.extra.body }} -
- -
-
-
-
${{ _.view_view.info_table.links }}$ - -
- - -
- ${{_.view_view.msg.inaccurate_only_building.msg}}$ - -
-
- ${{_.view_view.msg.no_floor_overlay}}$ -
-
- {{ view_data.props.comment }} -
-
- -
-
- -
- - - - - -
-
-
-

${{_.view_view.buildings_overview.title}}$

-
- -
-
-
- -
-
-
- -
-
-
-

{{ b.name }}

- {{ b.subtext }} -
-
- -
-
-
-
-
-
- - -
-
- - -
-
-

${{_.view_view.rooms_overview.title}}$

- -
- -
-
-
-
-
- ${{_.view_view.rooms_overview.by_usage}}$: -
-
-
- -
- -
-
-
-
-
-
-
- - -
-
-
-
- -
- -
-
-
-
- -
-
-

${{_.view_view.sources.title}}$

-
-

- ${{_.view_view.sources.base.title}}$: - - {{e.name}} - - - -
${{_.view_view.sources.base.patched}}$
-

-

- ${{_.view_view.sources.header_img}}$: - {{ image.shown_image.author.text }} - • - - {{ image.shown_image.source.text }} - - - - • - - {{ image.shown_image.license.text }} - - - -

-

- ${{_.view_view.sources.coords.title}}$: - ${{_.view_view.sources.coords.navigatum}}$ - ${{_.view_view.sources.coords.roomfinder}}$ - ${{_.view_view.sources.coords.inferred}}$ -

-
-
diff --git a/webclient/src/views/view/view-view.js b/webclient/src/views/view/view-view.js deleted file mode 100644 index 7783f4269..000000000 --- a/webclient/src/views/view/view-view.js +++ /dev/null @@ -1,698 +0,0 @@ -/* global maplibregl */ -function viewNavigateTo(to, from, next, component) { - navigatum.beforeNavigate(to, from); - - navigatum.getData(to.params.id).then((data) => { - function finish() { - if (component) { - next(); - navigatum.afterNavigate(to, from); - component.loadEntryData(data); - } else { - next((vm) => { - navigatum.afterNavigate(to, from); - vm.loadEntryData(data); - }); - } - } - - if (data === null) { - finish(); - } else if (data.type === "root") { - next("/"); - } else { - // Redirect to the correct type if necessary. Technically the type information - // is not required, but it makes nicer URLs. - let urlTypeName = { - campus: "campus", - site: "site", - area: "site", // Currently also "site", maybe "group"? TODO - building: "building", - joined_building: "building", - room: "room", - virtual_room: "room", - }[data.type]; - if (urlTypeName === undefined) urlTypeName = "view"; - - if (!to.path.slice(1).startsWith(urlTypeName)) { - next(`/${urlTypeName}/${to.params.id}`); - } else { - finish(); - } - } - }); -} - -const _viewDefaultState = { - map: { - // Can also be "roomfinder". "interactive" is default, because - // it should show a loading indication. - selected: "interactive", - roomfinder: { - selected_id: null, // Map id - selected_index: null, // Index in the 'available' list - x: -1023 - 10, // Outside in top left corner - y: -1023 - 10, - modalX: -1023 - 10, - modalY: -1023 - 10, - width: 400, - height: 300, - }, - }, - buildings_overview: { - expanded: false, - }, - rooms_overview: { - expanded: false, - selected: null, - filter: "", - }, -}; - -navigatum.registerView("view", { - name: "view-view", - template: { gulp_inject: "view-view.inc" }, - data: function () { - return { - view_data: null, - image: { - shown_image: null, - shown_image_id: null, - slideshow_open: false, - }, - map: { - interactive: { - map: null, - component: null, - marker: null, - marker2: null, - }, - roomfinder: { - modalOpen: false, - }, - }, - sections: { - rooms_overview: { - combined_count: 0, - combined_list: [], - display_list: [], - _filter_index: { - selected: null, - list: [], - }, - loading: false, - }, - }, - // State is preserved when navigating in history. - // May only contain serializable objects! - state: structuredClone(_viewDefaultState), - copied: false, - // Coordinate picker states - coord_counter: { - counter: null, - to_confirm_delete: false, - }, - coord_picker: { - // The coordinate picker keeps backups of the subject and body - // in case someone writes a text and then after that clicks - // the set coordinate button in the feedback form. If we didn't - // made a backup then, this would be lost after clicking confirm there. - backup_id: null, - subject_backup: null, - body_backup: null, - force_reopen: false, - }, - browser_supports_share: "share" in navigator, - }; - }, - beforeRouteEnter: function (to, from, next) { - viewNavigateTo(to, from, next, null); - }, - beforeRouteUpdate: function (to, from, next) { - viewNavigateTo(to, from, next, this); - }, - methods: { - showImageShowcase: function (i, openSlideshow = true) { - if (this.view_data && this.view_data.imgs && this.view_data.imgs[i]) { - this.image.slideshow_open = openSlideshow; - this.image.shown_image_id = i; - this.image.shown_image = this.view_data.imgs[i]; - } else { - this.image.slideshow_open = false; - this.image.shown_image_id = null; - this.image.shown_image = null; - } - }, - hideImageShowcase: function () { - this.image.slideshow_open = false; - }, - showPropExtra: function(propExtra) { - navigatum.app.modal.header = propExtra.header; - navigatum.app.modal.body = propExtra.body; - }, - // This is called - // - on initial page load - // - when the view is loaded for the first time - // - when the view is navigated to from a different view - // - when the view is navigated to from the same view, but with a different entry - loadEntryData: function (data) { - if (data === null) return; - // --- Additional data --- - navigatum.setTitle(data.name); - navigatum.setDescription(this.genDescription(data)); - document - .querySelector('meta[property="og:image"]') - .setAttribute("content", `${navigatum.apiBase}preview/${data.id}`); - // initalising this sets vue's renering into motion. - // Because of this, we want to set the values relevant for embeds first, as Rendertron may decide that "we are ready now" - this.view_data = data; - - this.showImageShowcase(0, false); - - // --- Maps --- - if (!navigatum.tryReuseViewState()) { - // We need to reset state to default here, else it is preserved from the previous page - navigatum.applyState(structuredClone(_viewDefaultState), this.state); - - this.state.map.selected = data.maps.default; - // Interactive has to be always available, but roomfinder may be unavailable - if ("roomfinder" in data.maps) { - // Find default map - data.maps.roomfinder.available.forEach((availableMap, index) => { - if (availableMap.id === data.maps.roomfinder.default) { - this.state.map.roomfinder.selected_index = index; - this.state.map.roomfinder.selected_id = availableMap.id; - } - }); - } - } - - // Maps can only be loaded after first mount because then the elements are - // created and can be referenced by id. - if (this.is_mounted) this.loadMap(); - - // --- Sections --- - if (this.view_data.sections && this.view_data.sections.rooms_overview) { - const { usages } = this.view_data.sections.rooms_overview; - const combinedList = []; - usages.forEach((usage) => { - combinedList.push(...usage.children); - }); - this.sections.rooms_overview.combined_list = combinedList; - this.sections.rooms_overview.combined_count = combinedList.length; - this.updateRoomsOverview(); - } - }, - genDescription: function (data) { - const detailsFor = "${{_.view_view.meta.details_for}}$"; - let description = `${detailsFor} ${data.type_common_name} ${data.name}`; - if (data.props.computed) { - description += ":"; - data.props.computed.forEach((prop) => { - description += `\n- ${prop.name}: ${prop.text}`; - }); - } - return description; - }, - // --- Loading components --- - // When these methods are called, the view has already been mounted, - // so we can find elements by id. - loadMap: function () { - if (navigator.userAgent === "Rendertron") { - return; - } - if (this.state.map.selected === "interactive") this.loadInteractiveMap(); - else if (this.state.map.selected === "roomfinder") - this.loadRoomfinderMap(this.state.map.roomfinder.selected_index); - }, - addLocationPicker: function () { - // If this is called from the feedback form using the edit coordinate - // button, we temporarily save the current subject and body so it is - // not lost when being reopened - if ( - window.feedback && - document.getElementById("feedback-modal").classList.contains("active") - ) { - this.coord_picker.backup_id = this.view_data.id; - this.coord_picker.subject_backup = - document.getElementById("feedback-subject").value; - this.coord_picker.body_backup = - document.getElementById("feedback-body").value; - this.coord_picker.force_reopen = true; // reopen after confirm - - window.feedback.closeForm(); - } - - this.state.map.selected = "interactive"; - - // Verify that there isn't already a marker (could happen if you click 'assign - // a location' multiple times from the 'missing accurate location' toast) - if (this.map.interactive.marker2 === null) { - // Coordinates are either taken from the entry, or if there are already - // some in the localStorage use them - const currentEdits = navigatum.getLocalStorageWithExpiry( - "coordinate-feedback", - {} - ); - - const { coords } = currentEdits[this.view_data.id] || this.view_data; - const marker2 = new maplibregl.Marker({ - draggable: true, - color: "#ff0000", - }); - marker2 - .setLngLat([coords.lon, coords.lat]) - .addTo(this.map.interactive.map); - this.map.interactive.marker2 = marker2; - } - }, - _getFeedbackSubject: function (currentEdits) { - if (Object.keys(currentEdits).length > 1) { - return ( - `[${this.view_data.id} et.al.]: ` + - "${{_.feedback.coordinatepicker.edit_coordinates_subject}}$" - ); - } - - const subjectPrefix = `[${this.view_data.id}]: `; - const subjectMsg = - Object.keys(currentEdits).length === 0 - ? "" - : "${{_.feedback.coordinatepicker.edit_coordinate_subject}}$"; - - // The subject backup is only loaded (and supported) when a single - // entry is being edited - if ( - this.coord_picker.subject_backup && - this.coord_picker.backup_id === this.view_data.id && - this.coord_picker.subject_backup !== subjectPrefix - ) { - const backup = this.coord_picker.subject_backup; - this.coord_picker.subject_backup = null; - return backup; - } - return subjectPrefix + subjectMsg; - }, - _getFeedbackBody: function (currentEdits) { - // Look up whether there is a backup of the body and extract the section - // that is not the coordinate - let actionMsg = ""; - if ( - this.coord_picker.body_backup && - this.coord_picker.backup_id === this.view_data.id - ) { - const parts = this.coord_picker.body_backup.split("\n```"); - if (parts.length === 1) { - actionMsg = parts[0]; - } else { - actionMsg = parts[0] + parts[1].split("```").slice(1).join("\n"); - } - - this.coord_picker.body_backup = null; - } - - if (Object.keys(currentEdits).length === 0) { - // For no edits, don't show a badly formatted message - // (This is "" if there was no backup) - return actionMsg; - } - - const defaultActionMsg = - this.view_data.coords.accuracy === "building" - ? "${{_.feedback.coordinatepicker.add_coordinate}}$" - : "${{_.feedback.coordinatepicker.correct_coordinate}}$"; - actionMsg = actionMsg || defaultActionMsg; - - if (Object.keys(currentEdits).length > 1) { - // The body backup is discarded if more than a single entry - // is being edited (because then it is not supported). - actionMsg = - "${{_.feedback.coordinatepicker.edit_multiple_coordinates}}$"; - } - - let editStr = ""; - Object.entries(currentEdits).forEach(([key, value]) => { - editStr += `"${key}": { lat: ${value.coords.lat}, lon: ${value.coords.lon} }\n`; - }); - - return `${actionMsg}\n\`\`\`yaml\n${editStr}\`\`\``; - }, - openFeedbackForm: function () { - // The feedback form is opened. This may be prefilled with previously corrected coordinates. - // Maybe get the old coordinates from localstorage - const currentEdits = navigatum.getLocalStorageWithExpiry( - "coordinate-feedback", - {} - ); - const body = this._getFeedbackBody(currentEdits); - const subject = this._getFeedbackSubject(currentEdits); - - document - .getElementById("feedback-coordinate-picker") - .addEventListener("click", this.addLocationPicker); - - /* global openFeedback */ - openFeedback("entry", subject, body); - }, - confirmLocationPicker: function () { - // add the current edits to the feedback - const currentEdits = navigatum.getLocalStorageWithExpiry( - "coordinate-feedback", - {} - ); - const location = this.map.interactive.marker2.getLngLat(); - currentEdits[this.view_data.id] = { - coords: { lat: location.lat, lon: location.lng }, - }; - // save to local storage with ttl of 12h (garbage-collected on next read) - navigatum.setLocalStorageWithExpiry( - "coordinate-feedback", - currentEdits, - 12 - ); - - this.map.interactive.marker2.remove(); - this.map.interactive.marker2 = null; - - // A feedback form is only opened when this is the only (and therefore - // first coordinate). If there are more coordinates we can assume - // someone is doing batch edits. They can then use the send button in - // the coordinate counter at the top of the page. - if ( - Object.keys(currentEdits).length === 1 || - this.coord_picker.force_reopen - ) { - this.coord_picker.force_reopen = false; - this.openFeedbackForm(); - } - - // The helptext (which says thet you can edit multiple coordinates in bulk) - // is also only shown if there is one edit. - if (Object.keys(currentEdits).length === 1) { - document - .getElementById("feedback-coordinate-picker-helptext") - .classList.remove("d-none"); - } - }, - cancelLocationPicker: function () { - this.map.interactive.marker2.remove(); - this.map.interactive.marker2 = null; - - if (this.coord_picker.force_reopen) { - this.coord_picker.force_reopen = false; - this.openFeedbackForm(); - } - }, - deletePendingCoordinates: function () { - if (this.coord_counter.to_confirm_delete) { - navigatum.removeLocalStorage("coordinate-feedback"); - this.coord_counter.to_confirm_delete = false; - this.coord_picker.body_backup = null; - this.coord_picker.subject_backup = null; - this.coord_picker.backup_id = null; - } else { - this.coord_counter.to_confirm_delete = true; - } - }, - loadInteractiveMap: function (fromUi) { - const _this = this; - const fromMap = this.state.map.selected; - - this.state.map.selected = "interactive"; - - const doMapUpdate = function () { - navigatum.getModule("interactive-map").then((c) => { - _this.map.interactive.component = c; - - let { map } = _this.map.interactive; - let { marker } = _this.map.interactive; - // The map might or might not be initialized depending on the type - // of navigation. - if (document.getElementById("interactive-map")) { - if ( - document - .getElementById("interactive-map") - .classList.contains("maplibregl-map") - ) { - marker.remove(); - } else { - map = c.initMap("interactive-map"); - _this.map.interactive.map = map; - - document - .getElementById("interactive-map") - .classList.remove("loading"); - } - } - marker = new maplibregl.Marker({ element: c.createMarker() }); - _this.map.interactive.marker = marker; - const coords = _this.view_data.coords; - marker.setLngLat([coords.lon, coords.lat]).addTo(map); - - if (_this.view_data.maps && _this.view_data.maps.overlays) { - c.setFloorOverlays( - _this.view_data.maps.overlays.available, - _this.view_data.maps.overlays.default - ); - } else { - c.setFloorOverlays(null); - } - - const defaultZooms = { - joined_building: 16, - building: 17, - room: 18, - }; - if (fromMap === "interactive") { - map.flyTo({ - center: [coords.lon, coords.lat], - zoom: defaultZooms[_this.view_data.type] - ? defaultZooms[_this.view_data.type] - : 16, - speed: 1, - maxDuration: 2000, - }); - } else { - map.setZoom(16); - map.setCenter([coords.lon, coords.lat]); - } - }); - }; - - // The map element should be visible when initializing - if (!document.querySelector("#interactive-map .maplibregl-canvas")) - this.$nextTick(doMapUpdate()); - else doMapUpdate(); - - // To have an animation when the roomfinder is opened some time later, - // the cursor is set to 'zero' while the interactive map is displayed. - this.state.map.roomfinder.x = -1023 - 10; - this.state.map.roomfinder.y = -1023 - 10; - - if (fromUi) { - window.scrollTo(0, 0); - } - }, - loadRoomfinderMap: function (mapIndex, fromUi) { - const map = this.view_data.maps.roomfinder.available[mapIndex]; - this.state.map.selected = "roomfinder"; - this.state.map.roomfinder.selected_id = map.id; - this.state.map.roomfinder.selected_index = mapIndex; - - // Using the #map-container since the bounding rect is still all zero - // if we switched here from interactive map - const rect = document - .getElementById("map-container") - .getBoundingClientRect(); - // -1023px, -1023px is top left corner, 16px = 2*8px is element padding - this.state.map.roomfinder.x = - -1023 + (map.x / map.width) * (rect.width - 16); - - // We cannot use "height" here as it might be still zero before layouting - // finished, so we use the aspect ratio here. - this.state.map.roomfinder.y = - -1023 + - (map.y / map.height) * (rect.width - 16) * (map.height / map.width); - - this.state.map.roomfinder.width = map.width; - this.state.map.roomfinder.height = map.height; - - if (fromUi) { - document.getElementById("map-accordion").checked = false; - /* window.setTimeout(() => { - document.getElementById("roomfinder-map-img").scrollIntoView(false); - }, 50); */ - window.scrollTo( - 0, - rect.top + this.state.map.roomfinder.y + 1023 - window.innerHeight / 2 - ); - } - }, - loadModalRoomfinderMap: function () { - const map = - this.view_data.maps.roomfinder.available[ - this.state.map.roomfinder.selected_index - ]; - - const rect = document - .getElementById("roomfinder-modal-container") - .getBoundingClientRect(); - // -1023px, -1023px is top left corner, 16px = 2*8px is element padding - this.state.map.roomfinder.modalX = - -1023 + (map.x / map.width) * (rect.width - 65); - - // We cannot use "height" here as it might be still zero before layouting - // finished, so we use the aspect ratio here. - this.state.map.roomfinder.modalY = - -1023 + - (map.y / map.height) * (rect.width - 65) * (map.height / map.width); - }, - delayedLoadModalRoomfinderMap: function () { - setTimeout(this.loadModalRoomfinderMap, 1000); - }, - updateRoomsOverview: function (setSelected) { - const state = this.state.rooms_overview; - const data = this.view_data.sections.rooms_overview; - const local = this.sections.rooms_overview; - - if (setSelected !== undefined) state.selected = setSelected; - - if (state.selected === null) { - local.display_list = []; - } else { - const baseList = - state.selected === -1 - ? local.combined_list - : data.usages[state.selected].children; - if (state.filter === "") { - local.display_list = baseList; - } else { - // Update filter index if required - if (state.selected !== local._filter_index.selected) { - const rooms = baseList; - local._filter_index.list = []; - - rooms.forEach((room) => { - room._lower = room.name.toLowerCase(); - local._filter_index.list.push(room); - }); - local._filter_index.selected = state.selected; - } - - const filter = state.filter.toLowerCase(); - const filtered = []; - - local._filter_index.list.forEach((f) => { - if (f._lower.indexOf(filter) >= 0) filtered.push(f); - }); - local.display_list = filtered; - } - } - - // If there are a lot of rooms, updating the DOM takes a while. - // In this case we first reset the list, show a loading indicator and - // set the long list a short time later (So DOM can update and the indicator - // is visible). - if (local.display_list.length > 150) { - local.loading = true; - const tmp = local.display_list; - local.display_list = []; - // this.$nextTick doesn't work for some reason, the view freezes - // before the loading indicator is visible. - window.setTimeout(() => { - local.display_list = tmp; - local.loading = false; - }, 20); - } - }, - copy_link: function () { - // c.f. https://stackoverflow.com/a/30810322 - const textArea = document.createElement("textarea"); - textArea.value = window.location.href; - - // Avoid scrolling to bottom - textArea.style.top = "0"; - textArea.style.left = "0"; - textArea.style.position = "fixed"; - - document.body.appendChild(textArea); - textArea.focus(); - textArea.select(); - - try { - const success = document.execCommand("copy"); - if (success) { - const _this = this; - _this.copied = true; - window.setTimeout(() => { - _this.copied = false; - }, 1000); - } - } catch (err) { - console.error("Failed to copy to clipboard", err); - } - - document.body.removeChild(textArea); - }, - share_link: function () { - if (navigator.share) { - navigator.share({ - title: this.view_data.name, - text: document.title, - url: window.location.href, - }); - } - }, - }, - watch: { - "state.rooms_overview.filter": function () { - this.updateRoomsOverview(); - }, - }, - mounted: function () { - this.is_mounted = true; - if (navigator.userAgent === "Rendertron") { - return; - } - - // Update pending coordinate counter on localStorage changes - const _this = this; - const updateCoordinateCounter = function () { - const coords = navigatum.getLocalStorageWithExpiry( - "coordinate-feedback", - {} - ); - _this.coord_counter.counter = Object.keys(coords).length; - }; - window.addEventListener("storage", updateCoordinateCounter); - updateCoordinateCounter(); - window.addEventListener("resize", () => { - if (this.state.map.selected === "roomfinder") { - this.loadRoomfinderMap(this.state.map.roomfinder.selected_index, false); - this.loadModalRoomfinderMap(); - } - }); - - this.$nextTick(() => { - // Even though 'mounted' is called there is no guarantee apparently, - // that it really is mounted now. For this reason we try to poll now. - // (Not the best solution probably) - let timeoutInMs = 5; - const __this = this; - - function pollMap() { - if (document.getElementById("interactive-map") !== null) { - __this.loadMap(); - } else { - console.warn( - `'mounted' called, but page doesn't appear to be mounted yet. Retrying to load the map in ${timeoutInMs}ms` - ); - window.setTimeout(pollMap, timeoutInMs); - timeoutInMs *= 1.5; - } - } - - pollMap(); - }); - }, -}); diff --git a/webclient/src/views/view/view-view.scss b/webclient/src/views/view/view-view.scss deleted file mode 100644 index 00ad65a64..000000000 --- a/webclient/src/views/view/view-view.scss +++ /dev/null @@ -1,628 +0,0 @@ -@import "src/variables"; - -#view-view { - /* --- General --- */ - h1 { - font-size: 1.2rem; - font-weight: 500; - } - - h2 { - font-size: 1rem; - font-weight: 500; - } - - /* --- Header --- */ - .header-image-mobile { - margin: -10px -23px 10px; - - > img { - min-width: 100%; - min-height: 100px; - max-height: 200px; - object-fit: cover; - } - } - - .breadcrumb { - margin-top: 0; - font-size: 12px; - } - - .entry-header { - .title { - position: relative; - - & > div { - position: absolute; - left: -32px; - opacity: 0; - transition: opacity .2s; - } - - &:hover > div { - opacity: 1; - } - } - - .subtitle { - span { - color: $text-gray; - } - - button svg { - margin-top: 4px; - stroke: $primary-color; - } - - .column:last-child { - position: relative; - } - - .link-popover { - position: absolute; - z-index: 1000; - padding: 6px 10px; - width: 200px; - right: 36px; - background: $light-color; - box-shadow: $card-shadow-dark; - border-radius: 2px; - border: 1px solid $card-border; - visibility: hidden; - opacity: 0; - transform: translateY(-5px); - transition: opacity .05s, transform .05s; - - a, - button { - width: 100%; - margin: 4px 0; - } - - strong { - margin-top: 2px; - display: block; - - & + a, - & + button { - margin-top: 2px; - } - } - } - - button:focus + .link-popover, - .link-popover:hover { - visibility: visible; - opacity: 1; - transform: translateY(0); - } - } - - .divider { - margin-bottom: 22px; - } - } - - /* --- Pending coordinates counter --- */ - .coord-counter { - margin: 8px 0; - box-shadow: $card-shadow; - border: 1px solid $card-border; - border-radius: 4px; - background: $card-highlighted-bg; - - & .panel-body { - overflow-y: visible; - - & .msg { - margin: 15px 0; - - & em { - color: $theme-accent; - font-style: normal; - font-weight: bold; - } - - & .btn { - height: 1.3rem; - width: 1.3rem; - - &::after { - z-index: 2000; - } - } - } - - & .btns { - margin: auto 0 12px; - - .delete .default { - display: inline-block; - } - - .delete .confirm { - display: none; - } - - .delete.to-confirm { - animation: delay-btn .3s steps(1); - animation-fill-mode: both; - } - - .delete.to-confirm .default { - display: none; - } - - .delete.to-confirm .confirm { - display: inline-block; - } - } - } - } - - /* --- Map container --- */ - #map-container { - // This does not change anything (except using px instead of rem), - // but ensures that roomfinder position calculations are predictable. - padding: 0 8px; - - // The marker2 (draggable) - .maplibregl-marker + .maplibregl-marker { - animation: fade-in .1s linear .05s; - animation-fill-mode: both; - } - } - - .toast.location-picker { - animation: fade-in .1s linear .05s; - animation-fill-mode: both; - - & .btns { - margin: auto 0; - } - } - - /* --- Interactive map display --- */ - #interactive-map-container { - margin-bottom: 10px; - aspect-ratio: 4 / 3; // Not yet supported by all browsers - - > div { - padding-bottom: 75%; // 4:3 aspect ratio - border: 1px solid $border-light; - background-color: $container-loading-bg; - position: relative; - } - - &.maximize { - position: absolute; - top: -10px; - left: 0; - width: 100%; - height: calc(100vh - 60px); - z-index: 1000; - - > div { - padding-bottom: 0; - height: 100%; - } - } - } - - #interactive-map { - position: absolute; - height: 100%; - width: 100%; - } - - .marker { - position: absolute; - pointer-events: none; - padding: 0; - } - - .maplibregl-ctrl-group.floor-ctrl { - max-width: 100%; - display: none; - overflow: hidden; - - &.visible { - display: block; - } - - &.closed #floor-list { - display: none !important; - } - - & button { - &.active { - background: #ececec; - } - - & .arrow { - font-weight: normal; - font-size: .3rem; - line-height: .9rem; - vertical-align: top; - } - } - - &.reduced > .vertical-oc, - &.reduced > .horizontal-oc { - display: none !important; - } - - & > .vertical-oc, - & > .horizontal-oc { - font-weight: bold; - background: #ececec; - } - - &.closed { - & > .vertical-oc, - & > .horizontal-oc { - background: #fff; - } - - &:hover > .vertical-oc, - &:hover > .horizontal-oc { - background: #f2f2f2; - } - } - - // vertical is default layout - & > .horizontal-oc { - display: none; - } - - &.horizontal { - & > .horizontal-oc { - display: inline-block; - } - - & > .vertical-oc { - display: none; - } - - & #floor-list { - display: inline-block; - width: calc(100% - 29px); - } - - & button { - display: inline-block; - border-top: 0; - border-left: 1px solid #ddd; - - &.arrow { - font-size: .4rem; - vertical-align: bottom; - line-height: 1.1rem; - } - - & + button { - border-top: 0; - } - } - } - } - - /* --- Roomfinder display --- */ - .roomfinder-map-container { - overflow: hidden; - position: relative; - margin-bottom: 6px; - - > div { // Image source label - position: absolute; - bottom: 1px; - right: 1px; - padding: 1px 5px; - color: $body-font-color; - background-color: $container-loading-bg; - font-size: 10px; - } - } - - #roomfinder-map-cross { - position: absolute; - transition: transform .3s; - pointer-events: none; - } - - #roomfinder-map-img { - width: 100%; - display: block; - cursor: pointer; - } - - #roomfinder-map-select > label { - padding: .05rem .3rem; - } - - #roomfinder-modal-container { - padding-bottom: 4em; - } - - #roomfinder-modal-map-cross { - position: absolute; - transition: transform .3s; - pointer-events: none; - } - - #roomfinder-modal-map-img { - width: 100%; // Without this part the image doesn't fill the modal over the whole width. - } - - .accordion-body { - ul, - button, - li { - font-size: 12px; - } - - .selected { - background: $roomfinder-selected-bg; - } - } - - /* --- Information Section (mobile) --- */ - .mobile-info-section { - margin-top: 15px; - - & > .info-table { - margin-top: 16px; - } - } - - /* --- Information Card (desktop) --- */ - .card-body .toast { - margin-top: 12px; - } - - #map-container .toast { // Mobile - margin-bottom: 9px; - font-size: .7rem; - } - - /* --- Info table --- */ - .info-table { - width: 100%; - border-collapse: collapse; - - td { - vertical-align: top; - padding: 4px 0; - - &:last-child { - padding-left: 10px; - } - - .popover { - .card { - box-shadow: 0px 0px 6px rgba(106, 106, 106, 0.08); - border: .05rem solid #e1e1e1; - - .card-header { - font-weight: bold; - } - } - - svg { - margin-left: 5px; - margin-bottom: -2px; - } - } - } - - tr { - border-bottom: 1px solid $border-light; - - &:last-child { - border-bottom: 0; - } - } - - ul { - list-style-type: none; - margin: 0; - } - - li { - margin: 0 0 .4rem; - - &:last-child { - margin: 0; - } - } - } - - /* --- Sections general --- */ - section { - margin-top: 40px; - - .content { - margin-top: 15px; - } - } - - /* --- Sections --- */ - #building-overview { - a { - text-decoration: none !important; - } - - .tile { - border: .05rem solid $card-border; - padding: 8px; - border-radius: .1rem; - } - - button { - margin-top: 8px; - } - } - - .menu { - padding: 0; - box-shadow: none; - - .menu-item button { - text-align: left !important; - border: 0 transparent !important; - width: 100%; - } - - .menu-item a, - .menu-item label, - .menu-item button { - cursor: pointer; - user-select: none; - } - } - - /* --- User location dot --- */ - .maplibregl-user-location-dot, - .maplibregl-user-location-dot::before { - background-color: #3070b3; - } - - #rooms-overview { - #rooms-overview-select .menu-item { - padding: 0; - - & .icon-arrow-right { - margin-right: 4px; - } - } - - .menu-item button { - display: flex; - flex-direction: row; - box-sizing: border-box; - width: 100%; - - .menu-text { - flex-grow: 1; - flex-shrink: 1; - text-overflow: ellipsis; - overflow: hidden; - } - - .icon, - label { - flex-grow: 0; - flex-shrink: 0; - } - - .icon { - top: 5px; - } - } - - .panel-title { - font-weight: bold; - } - - .panel-body { - padding-bottom: 4px; - - .divider { - margin: 6px 0; - } - } - - .panel-footer { - color: $text-gray; - } - } - - #rooms-overview-select .panel-body { - max-height: 500px + 8px; - } - - #rooms-overview-list .panel-body { - max-height: 500px; - } - - #entry-sources { - h2 { - margin-bottom: 16px; - } - - p { - margin-bottom: 6px; - } - } - - /* --- Image slideshow / showcase --- */ - #modal-slideshow { - align-items: baseline; - - & .modal-container { - position: relative; - top: 5em; - - & .carousel-item { - // Disable the animation of Spectre, because it appears a bit irritating. - // It always run if we open the image slideshow and is wrong if we go back - // in the slideshow. - animation: none; - transform: translateX(0); - } - } - } - - /* --- Modal Roomfinder --- */ - #modal-roomfinder { - align-items: baseline; - - & .modal-container { - position: relative; - top: 5em; - } - } -} - -// 'md' ( -@media (max-width: 840px) { - #view-view { - .text-md-right { - text-align: right !important; - } - - .text-md-center { - text-align: center !important; - } - - .mt-md-3 { - margin-top: 1rem !important; - } - } -} - -// 'sm' (mobile) -@media (max-width: 600px) { - #view-view { - #rooms-overview-select .panel-body { - max-height: 260px; - } - - #rooms-overview-list .panel-body { - max-height: 275px; - } - } -} - -@keyframes delay-btn { - from { - pointer-events: none; - color: $text-gray; - } - - to { - pointer-events: all; - color: $error-color; - } -} diff --git a/webclient/tsconfig.config.json b/webclient/tsconfig.config.json new file mode 100644 index 000000000..4a3fec84b --- /dev/null +++ b/webclient/tsconfig.config.json @@ -0,0 +1,8 @@ +{ + "extends": "@vue/tsconfig/tsconfig.node.json", + "include": ["vite.config.*", "vitest.config.*", "cypress.config.*"], + "compilerOptions": { + "composite": true, + "types": ["node", "@intlify/unplugin-vue-i18n/messages"] + } +} diff --git a/webclient/tsconfig.json b/webclient/tsconfig.json new file mode 100644 index 000000000..a090ea287 --- /dev/null +++ b/webclient/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "@vue/tsconfig/tsconfig.web.json", + "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], + "compilerOptions": { + "baseUrl": ".", + "types": ["@intlify/unplugin-vue-i18n/messages"], + "paths": { + "@/*": ["./src/*"] + }, + "lib": ["esnext", "dom", "dom.iterable"], + }, + + "references": [ + { + "path": "./tsconfig.config.json" + } + ] +} diff --git a/webclient/vite.config.ts b/webclient/vite.config.ts new file mode 100644 index 000000000..328742ee3 --- /dev/null +++ b/webclient/vite.config.ts @@ -0,0 +1,56 @@ +import { fileURLToPath, URL } from "node:url"; + +import { defineConfig } from "vite"; +import Vue from "@vitejs/plugin-vue"; +import VueI18nPlugin from "@intlify/unplugin-vue-i18n/vite"; +import Markdown from "vite-plugin-md"; +import link from "@yankeeinlondon/link-builder"; +import path from "path"; +import pluginRewriteAll from "vite-plugin-rewrite-all"; + +// https://vitejs.dev/config/ +export default defineConfig({ + envDir: path.resolve(__dirname, "./env"), + appType: "spa", + server: { + port: 8000, + strictPort: true, + open: false, + proxy: { + "^/api/.+": { + target: "http://127.0.0.1:8080", + secure: false, + }, + }, + }, + build: { + rollupOptions: { + input: path.resolve(__dirname, "./index.html"), + manualChunks: { + maplibre_gl: ["maplibre-gl"], + swagger_ui_dist: ["swagger-ui-dist"], + }, + }, + }, + plugins: [ + Vue({ + include: [/\.vue$/, /\.md$/], + }), + VueI18nPlugin({ + include: path.resolve(__dirname, "./src/locales/**"), + fullInstall: false, + }), + Markdown({ + builders: [link()], + }), + //The next one is included due to https://github.com/vitejs/vite/issues/2415 + // otherwise the router won't serve the details pages, as they include dots + pluginRewriteAll(), + ], + resolve: { + alias: { + "@": fileURLToPath(new URL("./src", import.meta.url)), + vue: path.resolve(__dirname, "node_modules/vue/dist/vue.esm-bundler.js"), + }, + }, +});