├── .babelrc.json ├── .dockerignore ├── .eslintrc.yaml ├── .gitattributes ├── .github └── workflows │ ├── on_dispatch.yaml │ ├── on_push.yaml │ └── on_tag.yaml ├── .gitignore ├── .prettierignore ├── .prettierrc.yaml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── TILE_EXAMPLES.md ├── config.example.js ├── docker ├── 00-check-config.sh ├── Dockerfile ├── Dockerfile.build ├── Dockerfile.run └── README.md ├── favicon.png ├── favicon.svg ├── images ├── bg1.jpeg ├── bg2.png ├── bg3.jpg ├── bg5.jpg ├── screenshots │ ├── default.png │ ├── homekit.jpg │ ├── octopus_energy.png │ └── transparent.png ├── tile-screenshots │ ├── ALARM.png │ ├── AUTOMATION.png │ ├── CLIMATE.png │ ├── CUSTOM.png │ ├── DEVICE_TRACKER.png │ ├── FAN.png │ ├── GAUGE.png │ ├── HISTORY.png │ ├── IMAGE.png │ ├── INPUT_DATETIME.png │ ├── INPUT_SELECT.png │ ├── INPUT_SELECT_2.png │ ├── LIGHT.png │ ├── LIGHT_2.png │ ├── LIGHT_3.png │ ├── LOCK.png │ ├── MEDIA_PLAYER.png │ ├── POPUP.png │ ├── POPUP_popup.png │ ├── SCRIPT.png │ ├── SENSOR.png │ ├── SENSOR_ICON.png │ ├── SLIDER.png │ ├── SWITCH.png │ ├── TEXT_LIST.png │ ├── VACUUM.png │ ├── WEATHER.png │ ├── WEATHER_2.png │ └── WEATHER_LIST.png └── weather-icons │ ├── black │ ├── chanceflurries.svg │ ├── chancerain.svg │ ├── chancesleet.svg │ ├── chancesnow.svg │ ├── chancetstorms.svg │ ├── clear.svg │ ├── cloudy.svg │ ├── flurries.svg │ ├── fog.svg │ ├── hazy.svg │ ├── mostlycloudy.svg │ ├── mostlysunny.svg │ ├── nt_chanceflurries.svg │ ├── nt_chancerain.svg │ ├── nt_chancesleet.svg │ ├── nt_chancesnow.svg │ ├── nt_chancetstorms.svg │ ├── nt_clear.svg │ ├── nt_cloudy.svg │ ├── nt_flurries.svg │ ├── nt_fog.svg │ ├── nt_hazy.svg │ ├── nt_mostlycloudy.svg │ ├── nt_mostlysunny.svg │ ├── nt_partlycloudy.svg │ ├── nt_partlysunny.svg │ ├── nt_rain.svg │ ├── nt_sleet.svg │ ├── nt_snow.svg │ ├── nt_sunny.svg │ ├── nt_tstorms.svg │ ├── nt_unknown.svg │ ├── partlycloudy.svg │ ├── partlysunny.svg │ ├── rain.svg │ ├── sleet.svg │ ├── snow.svg │ ├── sunny.svg │ ├── tstorms.svg │ └── unknown.svg │ └── white │ ├── chanceflurries.svg │ ├── chancerain.svg │ ├── chancesleet.svg │ ├── chancesnow.svg │ ├── chancetstorms.svg │ ├── clear.svg │ ├── cloudy.svg │ ├── flurries.svg │ ├── fog.svg │ ├── hazy.svg │ ├── mostlycloudy.svg │ ├── mostlysunny.svg │ ├── nt_chanceflurries.svg │ ├── nt_chancerain.svg │ ├── nt_chancesleet.svg │ ├── nt_chancesnow.svg │ ├── nt_chancetstorms.svg │ ├── nt_clear.svg │ ├── nt_cloudy.svg │ ├── nt_flurries.svg │ ├── nt_fog.svg │ ├── nt_hazy.svg │ ├── nt_mostlycloudy.svg │ ├── nt_mostlysunny.svg │ ├── nt_partlycloudy.svg │ ├── nt_partlysunny.svg │ ├── nt_rain.svg │ ├── nt_sleet.svg │ ├── nt_snow.svg │ ├── nt_sunny.svg │ ├── nt_tstorms.svg │ ├── nt_unknown.svg │ ├── partlycloudy.svg │ ├── partlysunny.svg │ ├── rain.svg │ ├── sleet.svg │ ├── snow.svg │ ├── sunny.svg │ ├── tstorms.svg │ └── unknown.svg ├── index.html ├── index.html.ejs ├── manifest.webmanifest ├── package.json ├── post-merge-warn.js ├── renovate.json ├── rollup.config.js ├── scripts ├── ServiceWorker.js ├── app.js ├── controllers │ ├── main-utilities.js │ ├── main.js │ ├── noty.js │ └── screensaver.js ├── directives.js ├── directives │ ├── camera.js │ ├── cameraStream.js │ ├── clock.js │ ├── date.js │ ├── headerItem.html │ ├── headerItem.js │ ├── iframeTile.js │ ├── ngMax.js │ ├── ngMin.js │ ├── onScroll.js │ ├── tile.html │ └── tile.js ├── globals.js ├── globals │ ├── constants.js │ └── utils.js ├── index.js ├── init.js ├── models │ ├── api.js │ └── noty.js └── vendors │ └── color-picker.js ├── styles ├── all.less ├── color-picker.min.css ├── main.less ├── themes.less └── weather-icons.less ├── tsconfig.json ├── types ├── globals.d.ts └── shim-html.d.ts └── yarn.lock /.babelrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "useBuiltIns": "usage", 7 | "corejs": 3 8 | } 9 | ] 10 | ], 11 | "plugins": [ 12 | "angularjs-annotate" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git/ 2 | .github/ 3 | .gitignore 4 | docker/Dockerfile* 5 | docker/README.md 6 | node_modules/ 7 | -------------------------------------------------------------------------------- /.eslintrc.yaml: -------------------------------------------------------------------------------- 1 | env: 2 | browser: true 3 | es2020: true 4 | node: true 5 | parserOptions: 6 | sourceType: module 7 | extends: 'eslint:recommended' 8 | ignorePatterns: 9 | - build/ 10 | - scripts/vendors/ 11 | - locales/ 12 | - config.example.js 13 | rules: 14 | array-callback-return: error 15 | arrow-spacing: error 16 | block-scoped-var: error 17 | brace-style: error 18 | camelcase: 19 | - error 20 | - properties: never 21 | comma-dangle: 22 | - error 23 | - always-multiline 24 | comma-spacing: error 25 | comma-style: error 26 | computed-property-spacing: error 27 | curly: error 28 | dot-notation: error 29 | eol-last: error 30 | eqeqeq: error 31 | func-call-spacing: error 32 | # guard-for-in: error 33 | indent: 34 | - error 35 | - 3 36 | - SwitchCase: 1 37 | key-spacing: error 38 | keyword-spacing: error 39 | linebreak-style: error 40 | lines-between-class-members: error 41 | no-caller: error 42 | no-multi-spaces: 43 | - error 44 | - ignoreEOLComments: true 45 | no-self-compare: error 46 | no-sequences: error 47 | no-lonely-if: error 48 | no-multiple-empty-lines: error 49 | no-template-curly-in-string: error 50 | no-trailing-spaces: error 51 | no-unneeded-ternary: error 52 | no-unused-expressions: error 53 | no-useless-return: error 54 | no-unused-vars: 55 | - error 56 | - args: none 57 | ignoreRestSiblings: true 58 | varsIgnorePattern: '^_' 59 | no-var: error 60 | object-curly-spacing: 61 | - error 62 | - always 63 | one-var: 64 | - error 65 | - never 66 | one-var-declaration-per-line: error 67 | padded-blocks: 68 | - error 69 | - never 70 | prefer-const: error 71 | quotes: 72 | - error 73 | - single 74 | require-await: error 75 | semi: error 76 | space-before-blocks: error 77 | space-before-function-paren: 78 | - error 79 | space-in-parens: error 80 | space-infix-ops: error 81 | spaced-comment: error 82 | strict: error 83 | switch-colon-spacing: error 84 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.js text eol=lf 2 | *.json text eol=lf 3 | -------------------------------------------------------------------------------- /.github/workflows/on_dispatch.yaml: -------------------------------------------------------------------------------- 1 | name: addon-bump 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | addon-bump: 8 | name: Test addon bump 9 | runs-on: ubuntu-latest 10 | steps: 11 | 12 | - name: Checkout code 13 | uses: actions/checkout@v3 14 | 15 | - name: Get version 16 | id: get-version 17 | run: | 18 | version=`echo $(jq -r '.version' package.json)` 19 | echo "::set-output name=version::${version}" 20 | 21 | - name: Bump TileBoard in addon 22 | uses: octokit/request-action@v2.x 23 | with: 24 | route: POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches 25 | owner: resoai 26 | repo: TileBoard-addon 27 | workflow_id: on_tileboard_release.yaml 28 | ref: main 29 | inputs: | 30 | version: ${{ steps.get-version.outputs.version }} 31 | release_url: https://github.com/resoai/TileBoard/releases/tag/v${{ steps.get-version.outputs.version }} 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.REPO_WORKFLOW_TOKEN }} 34 | 35 | -------------------------------------------------------------------------------- /.github/workflows/on_push.yaml: -------------------------------------------------------------------------------- 1 | name: on-push 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | lint_and_build: 13 | name: Lint and Build 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v3 18 | 19 | - name: Install dependencies 20 | run: yarn install --frozen-lockfile 21 | 22 | - name: Lint 23 | run: yarn lint 24 | 25 | - name: Build 26 | run: yarn build 27 | -------------------------------------------------------------------------------- /.github/workflows/on_tag.yaml: -------------------------------------------------------------------------------- 1 | name: tagged-release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | tagged-release: 10 | name: Tagged Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | 14 | - name: Checkout code 15 | uses: actions/checkout@v3 16 | 17 | - name: Build app 18 | run: | 19 | yarn install --frozen-lockfile 20 | yarn build 21 | cp config.example.js build 22 | pushd build 23 | zip -qr "../TileBoard.zip" . 24 | 25 | - uses: marvinpinto/action-automatic-releases@latest 26 | with: 27 | repo_token: ${{ secrets.GITHUB_TOKEN }} 28 | prerelease: false 29 | files: | 30 | TileBoard.zip 31 | 32 | - name: Get version 33 | id: get-version 34 | run: | 35 | version=`echo $(jq -r '.version' package.json)` 36 | echo "::set-output name=version::${version}" 37 | 38 | - name: Bump TileBoard in addon 39 | uses: octokit/request-action@v2.x 40 | with: 41 | route: POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches 42 | owner: resoai 43 | repo: TileBoard-addon 44 | workflow_id: on_tileboard_release.yaml 45 | ref: main 46 | inputs: | 47 | version: ${{ steps.get-version.outputs.version }} 48 | release_url: https://github.com/resoai/TileBoard/releases/tag/v${{ steps.get-version.outputs.version }} 49 | env: 50 | GITHUB_TOKEN: ${{ secrets.REPO_WORKFLOW_TOKEN }} 51 | 52 | - name: Login to DockerHub 53 | uses: docker/login-action@v2 54 | with: 55 | username: ${{ secrets.DOCKERHUB_USERNAME }} 56 | password: ${{ secrets.DOCKERHUB_TOKEN }} 57 | 58 | - name: Set up Docker Buildx 59 | uses: docker/setup-buildx-action@v2 60 | 61 | - name: Docker build 62 | run: | 63 | docker buildx build \ 64 | --platform linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7 \ 65 | --pull \ 66 | -t tileboard/tileboard:latest \ 67 | -t tileboard/tileboard:${{ steps.get-version.outputs.version }} \ 68 | -f docker/Dockerfile.run \ 69 | --push \ 70 | . 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.db 2 | *.map 3 | .DS_Store 4 | /.cache/ 5 | /.idea/ 6 | /build/ 7 | /includes/ 8 | /node_modules/ 9 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build 2 | *.js 3 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | printWidth: 120 2 | tabWidth: 3 3 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to TileBoard 2 | 3 | All contributions are welcome! If you would like to make some changes, follow these steps: 4 | 5 | 1. Fork the project and clone it. 6 | 2. Install project dependencies: 7 | 8 | Note: You might need to install `yarn` globally first with `npm i -g yarn` or by follow instructions at https://classic.yarnpkg.com/en/docs/install. 9 | 10 | ```sh 11 | yarn 12 | ``` 13 | 14 | 3. Copy `config.example.js` to `build/config.js` (create `build` directory if necessary) and adjust options for your server: 15 | - Change `serverUrl` in the config to point to the Home Assistant URL. 16 | - Change `wsUrl` in the config to point to the Home Assistant API (use `wss` instead of `ws` protocol if HA is running on secure connection). 17 | 4. Configure `http.cors_allowed_origins` setting in Home Assistant to allow your server (e.g. localhost) to communicate with the HA API: 18 | 19 | ```yaml 20 | http: 21 | cors_allowed_origins: 22 | - http://:8080 23 | ``` 24 | 25 | This needs to be added in `configuration.yaml` in HA. See https://www.home-assistant.io/integrations/http/#cors_allowed_origins for more info. 26 | 27 | 5. Start the development server: 28 | 29 | ```sh 30 | yarn dev 31 | ``` 32 | 33 | This starts a local development server at address http://localhost:8080, serving the built TileBoard. 34 | 35 | Modifications to the project trigger an automatic rebuild to make it easy to iterate quickly. 36 | 37 | ## Production build 38 | 39 | To create an optimized release build of TileBoard run: 40 | 41 | ```sh 42 | yarn build 43 | ``` 44 | 45 | This creates optimized, smaller build that should be used when running TileBoard in "production". This command doesn't start development server. 46 | 47 | Release builds are also attached to GitHub releases in "assets" section so if you are not planning to make any changes then you can use those instead. 48 | 49 | ## Releases (only for maintainers) 50 | 51 | A new release can be created by running `yarn release`. A new tagged commit will be created and pushed to the remote repo, triggering automatic creation of new release with built app package attached in "assets" section. 52 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Alexey Ivanov (@resoai) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docker/00-check-config.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | NGINX_HOME=/usr/share/nginx/html 4 | 5 | if [ ! -f $NGINX_HOME/config.js ]; then 6 | echo "error: $NGINX_HOME/config.js not found" 7 | exit 1 8 | fi 9 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16 AS builder 2 | 3 | RUN mkdir -p /build 4 | WORKDIR /build 5 | 6 | COPY ./package.json yarn.lock /build/ 7 | 8 | RUN yarn install 9 | 10 | COPY ./ /build 11 | 12 | RUN yarn run build 13 | 14 | 15 | # Runtime image 16 | FROM nginx:alpine AS runtime 17 | 18 | COPY ./docker/00-check-config.sh /docker-entrypoint.d/ 19 | COPY --from=builder /build/build /usr/share/nginx/html 20 | 21 | RUN touch /usr/share/nginx/html/styles/custom.css 22 | -------------------------------------------------------------------------------- /docker/Dockerfile.build: -------------------------------------------------------------------------------- 1 | FROM node:16 AS builder 2 | 3 | RUN mkdir -p /build 4 | WORKDIR /build 5 | 6 | COPY ./package.json yarn.lock /build/ 7 | 8 | RUN yarn install 9 | 10 | COPY ./ /build 11 | 12 | RUN yarn run build 13 | 14 | 15 | # Hack for easily copying built files 16 | FROM scratch AS exporter 17 | COPY --from=builder /build/build . 18 | -------------------------------------------------------------------------------- /docker/Dockerfile.run: -------------------------------------------------------------------------------- 1 | # Runtime image 2 | FROM nginx:alpine AS runtime 3 | 4 | COPY ./docker/00-check-config.sh /docker-entrypoint.d/ 5 | COPY ./build /usr/share/nginx/html 6 | 7 | RUN touch /usr/share/nginx/html/styles/custom.css 8 | -------------------------------------------------------------------------------- /docker/README.md: -------------------------------------------------------------------------------- 1 | # The official TileBoard container 2 | 3 | A container for running [TileBoard](https://github.com/resoai/TileBoard) in a standalone web server. 4 | 5 | Runs in a minimal nginx server, serving the content on port 9000 by default. 6 | 7 | ## Running with Docker 8 | 9 | 1. Download the [sample config](https://raw.githubusercontent.com/resoai/TileBoard/master/config.example.js) file as `config.js` and configure it (see [configure](https://github.com/resoai/TileBoard/blob/master/README.md#configure) for more info). 10 | 11 | ```sh 12 | wget -O config.js https://raw.githubusercontent.com/resoai/TileBoard/master/config.example.js 13 | vim config.js 14 | ``` 15 | 16 | 2. Create a `docker-compose.yml` file with content: 17 | 18 | ```yaml 19 | version: '2' 20 | services: 21 | tileboard: 22 | image: tileboard/tileboard:latest 23 | restart: unless-stopped 24 | ports: 25 | - 9000:80 26 | volumes: 27 | - ./config.js:/usr/share/nginx/html/config.js 28 | ``` 29 | 30 | 3. Run with `docker-compose up --detach` 31 | 4. Access at http://localhost:9000 32 | 33 | ## Building 34 | 35 | ```sh 36 | docker build -t tileboard/tileboard -f docker/Dockerfile . 37 | ``` 38 | 39 | Multi-platform: 40 | 41 | ```sh 42 | rm -rf ./build/ 43 | docker buildx build -t tileboard/tileboard:build -f docker/Dockerfile.build --output build . 44 | docker buildx build \ 45 | --platform linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6 \ 46 | --pull \ 47 | -t tileboard/tileboard:latest \ 48 | -f docker/Dockerfile.run \ 49 | --push \ 50 | . 51 | ``` 52 | -------------------------------------------------------------------------------- /favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resoai/TileBoard/878a2476e85e23d77bbd306bde16af9a2fc1208b/favicon.png -------------------------------------------------------------------------------- /favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /images/bg1.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resoai/TileBoard/878a2476e85e23d77bbd306bde16af9a2fc1208b/images/bg1.jpeg -------------------------------------------------------------------------------- /images/bg2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resoai/TileBoard/878a2476e85e23d77bbd306bde16af9a2fc1208b/images/bg2.png -------------------------------------------------------------------------------- /images/bg3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resoai/TileBoard/878a2476e85e23d77bbd306bde16af9a2fc1208b/images/bg3.jpg -------------------------------------------------------------------------------- /images/bg5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resoai/TileBoard/878a2476e85e23d77bbd306bde16af9a2fc1208b/images/bg5.jpg -------------------------------------------------------------------------------- /images/screenshots/default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resoai/TileBoard/878a2476e85e23d77bbd306bde16af9a2fc1208b/images/screenshots/default.png -------------------------------------------------------------------------------- /images/screenshots/homekit.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resoai/TileBoard/878a2476e85e23d77bbd306bde16af9a2fc1208b/images/screenshots/homekit.jpg -------------------------------------------------------------------------------- /images/screenshots/octopus_energy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resoai/TileBoard/878a2476e85e23d77bbd306bde16af9a2fc1208b/images/screenshots/octopus_energy.png -------------------------------------------------------------------------------- /images/screenshots/transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resoai/TileBoard/878a2476e85e23d77bbd306bde16af9a2fc1208b/images/screenshots/transparent.png -------------------------------------------------------------------------------- /images/tile-screenshots/ALARM.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resoai/TileBoard/878a2476e85e23d77bbd306bde16af9a2fc1208b/images/tile-screenshots/ALARM.png -------------------------------------------------------------------------------- /images/tile-screenshots/AUTOMATION.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resoai/TileBoard/878a2476e85e23d77bbd306bde16af9a2fc1208b/images/tile-screenshots/AUTOMATION.png -------------------------------------------------------------------------------- /images/tile-screenshots/CLIMATE.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resoai/TileBoard/878a2476e85e23d77bbd306bde16af9a2fc1208b/images/tile-screenshots/CLIMATE.png -------------------------------------------------------------------------------- /images/tile-screenshots/CUSTOM.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resoai/TileBoard/878a2476e85e23d77bbd306bde16af9a2fc1208b/images/tile-screenshots/CUSTOM.png -------------------------------------------------------------------------------- /images/tile-screenshots/DEVICE_TRACKER.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resoai/TileBoard/878a2476e85e23d77bbd306bde16af9a2fc1208b/images/tile-screenshots/DEVICE_TRACKER.png -------------------------------------------------------------------------------- /images/tile-screenshots/FAN.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resoai/TileBoard/878a2476e85e23d77bbd306bde16af9a2fc1208b/images/tile-screenshots/FAN.png -------------------------------------------------------------------------------- /images/tile-screenshots/GAUGE.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resoai/TileBoard/878a2476e85e23d77bbd306bde16af9a2fc1208b/images/tile-screenshots/GAUGE.png -------------------------------------------------------------------------------- /images/tile-screenshots/HISTORY.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resoai/TileBoard/878a2476e85e23d77bbd306bde16af9a2fc1208b/images/tile-screenshots/HISTORY.png -------------------------------------------------------------------------------- /images/tile-screenshots/IMAGE.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resoai/TileBoard/878a2476e85e23d77bbd306bde16af9a2fc1208b/images/tile-screenshots/IMAGE.png -------------------------------------------------------------------------------- /images/tile-screenshots/INPUT_DATETIME.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resoai/TileBoard/878a2476e85e23d77bbd306bde16af9a2fc1208b/images/tile-screenshots/INPUT_DATETIME.png -------------------------------------------------------------------------------- /images/tile-screenshots/INPUT_SELECT.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resoai/TileBoard/878a2476e85e23d77bbd306bde16af9a2fc1208b/images/tile-screenshots/INPUT_SELECT.png -------------------------------------------------------------------------------- /images/tile-screenshots/INPUT_SELECT_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resoai/TileBoard/878a2476e85e23d77bbd306bde16af9a2fc1208b/images/tile-screenshots/INPUT_SELECT_2.png -------------------------------------------------------------------------------- /images/tile-screenshots/LIGHT.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resoai/TileBoard/878a2476e85e23d77bbd306bde16af9a2fc1208b/images/tile-screenshots/LIGHT.png -------------------------------------------------------------------------------- /images/tile-screenshots/LIGHT_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resoai/TileBoard/878a2476e85e23d77bbd306bde16af9a2fc1208b/images/tile-screenshots/LIGHT_2.png -------------------------------------------------------------------------------- /images/tile-screenshots/LIGHT_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resoai/TileBoard/878a2476e85e23d77bbd306bde16af9a2fc1208b/images/tile-screenshots/LIGHT_3.png -------------------------------------------------------------------------------- /images/tile-screenshots/LOCK.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resoai/TileBoard/878a2476e85e23d77bbd306bde16af9a2fc1208b/images/tile-screenshots/LOCK.png -------------------------------------------------------------------------------- /images/tile-screenshots/MEDIA_PLAYER.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resoai/TileBoard/878a2476e85e23d77bbd306bde16af9a2fc1208b/images/tile-screenshots/MEDIA_PLAYER.png -------------------------------------------------------------------------------- /images/tile-screenshots/POPUP.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resoai/TileBoard/878a2476e85e23d77bbd306bde16af9a2fc1208b/images/tile-screenshots/POPUP.png -------------------------------------------------------------------------------- /images/tile-screenshots/POPUP_popup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resoai/TileBoard/878a2476e85e23d77bbd306bde16af9a2fc1208b/images/tile-screenshots/POPUP_popup.png -------------------------------------------------------------------------------- /images/tile-screenshots/SCRIPT.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resoai/TileBoard/878a2476e85e23d77bbd306bde16af9a2fc1208b/images/tile-screenshots/SCRIPT.png -------------------------------------------------------------------------------- /images/tile-screenshots/SENSOR.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resoai/TileBoard/878a2476e85e23d77bbd306bde16af9a2fc1208b/images/tile-screenshots/SENSOR.png -------------------------------------------------------------------------------- /images/tile-screenshots/SENSOR_ICON.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resoai/TileBoard/878a2476e85e23d77bbd306bde16af9a2fc1208b/images/tile-screenshots/SENSOR_ICON.png -------------------------------------------------------------------------------- /images/tile-screenshots/SLIDER.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resoai/TileBoard/878a2476e85e23d77bbd306bde16af9a2fc1208b/images/tile-screenshots/SLIDER.png -------------------------------------------------------------------------------- /images/tile-screenshots/SWITCH.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resoai/TileBoard/878a2476e85e23d77bbd306bde16af9a2fc1208b/images/tile-screenshots/SWITCH.png -------------------------------------------------------------------------------- /images/tile-screenshots/TEXT_LIST.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resoai/TileBoard/878a2476e85e23d77bbd306bde16af9a2fc1208b/images/tile-screenshots/TEXT_LIST.png -------------------------------------------------------------------------------- /images/tile-screenshots/VACUUM.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resoai/TileBoard/878a2476e85e23d77bbd306bde16af9a2fc1208b/images/tile-screenshots/VACUUM.png -------------------------------------------------------------------------------- /images/tile-screenshots/WEATHER.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resoai/TileBoard/878a2476e85e23d77bbd306bde16af9a2fc1208b/images/tile-screenshots/WEATHER.png -------------------------------------------------------------------------------- /images/tile-screenshots/WEATHER_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resoai/TileBoard/878a2476e85e23d77bbd306bde16af9a2fc1208b/images/tile-screenshots/WEATHER_2.png -------------------------------------------------------------------------------- /images/tile-screenshots/WEATHER_LIST.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resoai/TileBoard/878a2476e85e23d77bbd306bde16af9a2fc1208b/images/tile-screenshots/WEATHER_LIST.png -------------------------------------------------------------------------------- /images/weather-icons/black/chanceflurries.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/black/chancerain.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/black/chancesleet.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/black/chancesnow.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/black/chancetstorms.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/black/clear.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/black/cloudy.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/black/flurries.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/black/fog.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/black/hazy.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/black/mostlycloudy.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/black/mostlysunny.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/black/nt_chanceflurries.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/black/nt_chancerain.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/black/nt_chancesleet.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/black/nt_chancesnow.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/black/nt_chancetstorms.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/black/nt_clear.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /images/weather-icons/black/nt_cloudy.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/black/nt_flurries.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/black/nt_fog.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/black/nt_hazy.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/black/nt_mostlycloudy.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 13 | 17 | 18 | -------------------------------------------------------------------------------- /images/weather-icons/black/nt_mostlysunny.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 13 | 17 | 18 | -------------------------------------------------------------------------------- /images/weather-icons/black/nt_partlycloudy.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 13 | 17 | 18 | -------------------------------------------------------------------------------- /images/weather-icons/black/nt_partlysunny.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 13 | 17 | 18 | -------------------------------------------------------------------------------- /images/weather-icons/black/nt_rain.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/black/nt_sleet.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/black/nt_snow.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/black/nt_sunny.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /images/weather-icons/black/nt_tstorms.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/black/nt_unknown.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/black/partlycloudy.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/black/partlysunny.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/black/rain.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/black/sleet.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/black/snow.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/black/sunny.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/black/tstorms.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/black/unknown.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/white/chanceflurries.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/white/chancerain.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/white/chancesleet.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/white/chancesnow.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/white/chancetstorms.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/white/clear.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/white/cloudy.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/white/flurries.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/white/fog.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/white/hazy.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/white/mostlycloudy.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/white/mostlysunny.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/white/nt_chanceflurries.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/white/nt_chancerain.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/white/nt_chancesleet.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/white/nt_chancesnow.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/white/nt_chancetstorms.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/white/nt_clear.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /images/weather-icons/white/nt_cloudy.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/white/nt_flurries.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/white/nt_fog.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/white/nt_hazy.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/white/nt_mostlycloudy.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 9 | 14 | 18 | 19 | -------------------------------------------------------------------------------- /images/weather-icons/white/nt_mostlysunny.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 9 | 14 | 18 | 19 | -------------------------------------------------------------------------------- /images/weather-icons/white/nt_partlycloudy.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 9 | 14 | 18 | 19 | -------------------------------------------------------------------------------- /images/weather-icons/white/nt_partlysunny.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 9 | 14 | 18 | 19 | -------------------------------------------------------------------------------- /images/weather-icons/white/nt_rain.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/white/nt_sleet.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/white/nt_snow.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/white/nt_sunny.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /images/weather-icons/white/nt_tstorms.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/white/nt_unknown.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/white/partlycloudy.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/white/partlysunny.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/white/rain.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/white/sleet.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/white/snow.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/white/sunny.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/white/tstorms.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/weather-icons/white/unknown.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 |

If you see this page then you you have most likely picked a wrong way to install TileBoard.

2 |

3 | If you are not a TileBoard developer and just want to use it then go to 4 | Releases page and download the 5 | TileBoard.zip file attached to the latest release. 6 |

7 |

8 | You can also check out How To Use instructions for more 9 | detailed instructions. 10 |

11 | -------------------------------------------------------------------------------- /manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "TileBoard", 3 | "start_url": "./index.html", 4 | "icons": [ 5 | { 6 | "src": "favicon.svg", 7 | "sizes": "16x16 24x24 32x32 48x48 64x64 192x192 256x256 512x512", 8 | "type": "image/svg+xml" 9 | }, 10 | { 11 | "src": "favicon.png", 12 | "sizes": "16x16 24x24 32x32 48x48 64x64 192x192 256x256 512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "display": "fullscreen" 17 | } 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tileboard", 3 | "version": "2.10.2", 4 | "description": "Simple yet highly customizable dashboard for Home Assistant", 5 | "scripts": { 6 | "dev": "rollup --config --watch", 7 | "build": "rollup --config --environment PRODUCTION", 8 | "build:watch": "rollup --config --environment PRODUCTION --watch", 9 | "lint": "eslint rollup.config.js scripts && prettier --check '**/*.html'", 10 | "fix": "eslint --fix rollup.config.js scripts && prettier --write '**/*.html'", 11 | "release": "standard-version" 12 | }, 13 | "standard-version": { 14 | "scripts": { 15 | "prerelease": "git pull && yarn && yarn build", 16 | "posttag": "git push --follow-tags" 17 | } 18 | }, 19 | "husky": { 20 | "hooks": { 21 | "pre-commit": "lint-staged", 22 | "post-merge": "node post-merge-warn.js" 23 | } 24 | }, 25 | "lint-staged": { 26 | "*.js": [ 27 | "eslint --cache --fix", 28 | "prettier --write '**/*.html'", 29 | "yarn lint" 30 | ] 31 | }, 32 | "repository": { 33 | "type": "git", 34 | "url": "git+https://github.com/resoai/TileBoard.git" 35 | }, 36 | "author": "Alexey", 37 | "license": "MIT", 38 | "bugs": { 39 | "url": "https://github.com/resoai/TileBoard/issues" 40 | }, 41 | "homepage": "https://github.com/resoai/TileBoard#readme", 42 | "devDependencies": { 43 | "@babel/core": "^7.18.6", 44 | "@babel/plugin-transform-runtime": "^7.18.6", 45 | "@babel/preset-env": "^7.18.6", 46 | "@rollup/plugin-babel": "^5.3.1", 47 | "@rollup/plugin-commonjs": "^22.0.1", 48 | "@rollup/plugin-node-resolve": "^13.3.0", 49 | "@types/angular": "^1.8.4", 50 | "@types/hammerjs": "^2.0.41", 51 | "@types/hls.js": "^1.0.0", 52 | "@types/lodash.mergewith": "^4.6.7", 53 | "angular-i18n": "^1.8.3", 54 | "babel-plugin-angularjs-annotate": "^0.10.0", 55 | "core-js": "3", 56 | "eslint": "^8.18.0", 57 | "husky": "^4.3.8", 58 | "less": "^4.1.3", 59 | "lint-staged": "^13.0.3", 60 | "prettier": "^2.7.1", 61 | "print-message": "^3.0.1", 62 | "rollup": "^2.75.7", 63 | "rollup-plugin-copy-glob": "^0.3.2", 64 | "rollup-plugin-copy-merge": "^0.3.2", 65 | "rollup-plugin-delete": "^2.0.0", 66 | "rollup-plugin-emit-ejs": "^3.1.0", 67 | "rollup-plugin-html": "^0.2.1", 68 | "rollup-plugin-progress": "^1.1.2", 69 | "rollup-plugin-serve": "^2.0.0", 70 | "rollup-plugin-styles": "^4.0.0", 71 | "rollup-plugin-terser": "^7.0.2", 72 | "sass": "^1.53.0", 73 | "standard-version": "^9.1.1" 74 | }, 75 | "dependencies": { 76 | "@mdi/font": "^6.9.96", 77 | "angular": "^1.8.3", 78 | "angular-chart.js": "^1.1.1", 79 | "angular-dynamic-locale": "^0.1.38", 80 | "angular-hammer": "^2.2.0", 81 | "angular-moment": "^1.3.0", 82 | "angularjs-gauge": "^2.2.0", 83 | "chart.js": "^2.9.4", 84 | "hls.js": "^1.1.5", 85 | "lodash.mergewith": "^4.6.2" 86 | }, 87 | "resolutions": { 88 | "chart.js": "2.9.4" 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /post-merge-warn.js: -------------------------------------------------------------------------------- 1 | const printMessage = require('print-message'); 2 | 3 | printMessage(['Merge detected - running "yarn" is recommended.'], { color: 'yellow' }); 4 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base", 4 | "group:allNonMajor", 5 | ":prHourlyLimit4" 6 | ], 7 | "rangeStrategy": "bump", 8 | "lockFileMaintenance": { 9 | "enabled": true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import packageJson from './package.json'; 2 | import progress from 'rollup-plugin-progress'; 3 | import mergeCopy from 'rollup-plugin-copy-merge'; 4 | import babel from '@rollup/plugin-babel'; 5 | import commonjs from '@rollup/plugin-commonjs'; 6 | import copy from 'rollup-plugin-copy-glob'; 7 | import del from 'rollup-plugin-delete'; 8 | import emitEJS from 'rollup-plugin-emit-ejs'; 9 | import html from 'rollup-plugin-html'; 10 | import resolve from '@rollup/plugin-node-resolve'; 11 | import serve from 'rollup-plugin-serve'; 12 | import styles from 'rollup-plugin-styles'; 13 | import { terser } from 'rollup-plugin-terser'; 14 | 15 | const BUNDLED_LOCALES = ['da', 'de', 'en-gb', 'en-us', 'es', 'fr', 'it', 'nl', 'pl', 'pt', 'ru']; 16 | 17 | const isProduction = process.env.PRODUCTION === 'true'; 18 | const outDir = 'build'; 19 | let outputJsName = ''; 20 | let outputCssName = ''; 21 | const appPlugins = []; 22 | 23 | if (isProduction) { 24 | outputJsName = 'app-[hash].js'; 25 | outputCssName = 'styles-[hash][extname]'; 26 | appPlugins.push(terser()); 27 | } else { 28 | outputJsName = 'app.js'; 29 | outputCssName = 'styles[extname]'; 30 | appPlugins.push( 31 | serve({ 32 | contentBase: outDir, 33 | port: 8080, 34 | }), 35 | ); 36 | } 37 | 38 | /** @type {import('rollup').RollupOptions} */ 39 | const config = { 40 | input: './scripts/index.js', 41 | output: { 42 | // Defines the output path of the extracted CSS. 43 | assetFileNames: `styles/${outputCssName}`, 44 | dir: outDir, 45 | entryFileNames: `scripts/${outputJsName}`, 46 | format: 'iife', 47 | globals: { 48 | '@babel/runtime/regenerator': 'regeneratorRuntime', 49 | }, 50 | name: 'TileBoard', 51 | sourcemap: true, 52 | }, 53 | plugins: [ 54 | // Clean up output directory before building. 55 | del({ 56 | targets: [ 57 | `${outDir}/assets/`, 58 | `${outDir}/scripts/app*`, 59 | `${outDir}/styles/styles*`, 60 | `${outDir}/locales/`, 61 | ], 62 | }), 63 | progress(), 64 | commonjs({ ignoreTryCatch: false }), 65 | resolve(), 66 | babel({ 67 | babelHelpers: 'bundled', 68 | exclude: [ 69 | 'node_modules/**', 70 | 'scripts/directives/*.html', 71 | ], 72 | }), 73 | html({ 74 | include: 'scripts/directives/*.html', 75 | }), 76 | styles({ 77 | // Extract CSS into separate file (path specified through output.assetFileNames). 78 | mode: 'extract', 79 | // Don't try to resolve CSS @imports. 80 | import: false, 81 | sourceMap: true, 82 | minimize: isProduction, 83 | url: { 84 | hash: 'assets/[name]-[hash][extname]', 85 | // The public path where assets referenced from css files are available. 86 | publicPath: '../assets/', 87 | }, 88 | }), 89 | emitEJS({ 90 | src: '.', 91 | data: { 92 | VERSION: packageJson.version, 93 | }, 94 | }), 95 | copy([ 96 | { files: './favicon.png', dest: `./${outDir}/` }, 97 | { files: './favicon.svg', dest: `./${outDir}/` }, 98 | { files: './scripts/ServiceWorker.js', dest: `./${outDir}/` }, 99 | { files: './manifest.webmanifest', dest: `./${outDir}/` }, 100 | { files: './images/*.*', dest: `./${outDir}/images/` }, 101 | ]), 102 | mergeCopy({ 103 | targets: BUNDLED_LOCALES.map(locale => { 104 | return { 105 | src: [ 106 | `./node_modules/angular-i18n/angular-locale_${locale}.js`, 107 | `./node_modules/moment/locale/${locale}.js`, 108 | ], 109 | file: `./${outDir}/locales/${locale}.js`, 110 | }; 111 | }), 112 | }), 113 | ...appPlugins, 114 | ], 115 | }; 116 | 117 | export default config; 118 | -------------------------------------------------------------------------------- /scripts/ServiceWorker.js: -------------------------------------------------------------------------------- 1 | // Installable progressive web app requires fetch handler 2 | self.addEventListener('fetch', function (event) { 3 | event.respondWith(fetch(event.request)); 4 | }); 5 | -------------------------------------------------------------------------------- /scripts/app.js: -------------------------------------------------------------------------------- 1 | import angular from 'angular'; 2 | 3 | export const App = angular.module('App', ['hmTouchEvents', 'colorpicker', 'angularjs-gauge', 'angularMoment', 'chart.js', 'tmh.dynamicLocale']); 4 | -------------------------------------------------------------------------------- /scripts/controllers/main-utilities.js: -------------------------------------------------------------------------------- 1 | import mergeWith from 'lodash.mergewith'; 2 | import { TILE_DEFAULTS, TYPES } from '../globals/constants'; 3 | 4 | const MERGED_DEFAULTS_KEY = '__merged_defaults'; 5 | 6 | export function calculateGridPageRowIndexes (page) { 7 | const rowIndexes = []; 8 | for (const group of page.groups) { 9 | const rowIndex = group.row || 0; 10 | if (!rowIndexes.includes(rowIndex)) { 11 | rowIndexes.push(rowIndex); 12 | } 13 | } 14 | 15 | if (rowIndexes.length === 0) { 16 | rowIndexes.push(0); 17 | } else { 18 | rowIndexes.sort(); 19 | } 20 | 21 | return rowIndexes; 22 | } 23 | 24 | export function mergeConfigDefaults (pages) { 25 | for (const page of pages) { 26 | for (const group of page.groups) { 27 | mergeTileListDefaults(group.items); 28 | } 29 | } 30 | return pages; 31 | } 32 | 33 | function mergeTileListDefaults (tiles) { 34 | if (!Array.isArray(tiles)) { 35 | return; 36 | } 37 | for (const [index, tile] of tiles.entries()) { 38 | tiles[index] = mergeTileDefaults(tile); 39 | } 40 | return tiles; 41 | } 42 | 43 | export function mergeTileDefaults (tile) { 44 | if (tile[MERGED_DEFAULTS_KEY]) { 45 | return tile; 46 | } 47 | let mergedTile = tile; 48 | if (mergedTile && mergedTile.type in TILE_DEFAULTS) { 49 | mergedTile = mergeTileConfigs({}, TILE_DEFAULTS[mergedTile.type], mergedTile); 50 | } 51 | switch (mergedTile.type) { 52 | case TYPES.CAMERA: 53 | case TYPES.CAMERA_STREAM: 54 | case TYPES.CAMERA_THUMBNAIL: 55 | if (mergedTile.type === TYPES.CAMERA_THUMBNAIL) { 56 | console.warn('The CAMERA_THUMBNAIL tile is deprecated. Please replace it with the CAMERA tile. Tile: ', mergedTile); 57 | mergedTile.type = TYPES.CAMERA; 58 | } 59 | if (mergedTile.fullscreen) { 60 | mergedTile.fullscreen = mergeTileDefaults(mergedTile.fullscreen); 61 | } 62 | break; 63 | case TYPES.DOOR_ENTRY: 64 | if (mergedTile.layout?.camera) { 65 | mergedTile.layout.camera = mergeTileDefaults(mergedTile.layout.camera); 66 | } 67 | if (mergedTile.layout?.tiles) { 68 | mergeTileListDefaults(mergedTile.layout.tiles); 69 | } 70 | break; 71 | } 72 | // "popup" property is only officially supported in POPUP types but in the wild it can be added to any 73 | // tile and then passed programmatically when calling "openPopup". 74 | if (mergedTile.popup?.items) { 75 | mergeTileListDefaults(mergedTile.popup.items); 76 | } 77 | mergedTile[MERGED_DEFAULTS_KEY] = true; 78 | return mergedTile; 79 | } 80 | 81 | export function mergeTileConfigs (object, ...sources) { 82 | return mergeWith(object, ...sources, mergeTileCustomizer); 83 | } 84 | 85 | function mergeTileCustomizer (objValue, srcValue, key) { 86 | if (key === 'classes') { 87 | return function (item, entity) { 88 | const objValueParsed = this.parseFieldValue(objValue, item, entity) || []; 89 | const srcValueParsed = this.parseFieldValue(srcValue, item, entity) || []; 90 | return (Array.isArray(objValueParsed) ? objValueParsed : [objValueParsed]).concat(Array.isArray(srcValueParsed) ? srcValueParsed : [srcValueParsed]); 91 | }; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /scripts/controllers/noty.js: -------------------------------------------------------------------------------- 1 | import { App } from '../app'; 2 | import { NOTIES_POSITIONS } from '../globals/constants'; 3 | import Noty from '../models/noty'; 4 | 5 | App.controller('Noty', function ($scope) { 6 | let notiesClasses = null; 7 | 8 | $scope.getNoties = function () { 9 | return Noty.noties; 10 | }; 11 | 12 | $scope.clearAll = function () { 13 | Noty.removeAll(); 14 | }; 15 | 16 | $scope.getNotiesClasses = function () { 17 | if (!notiesClasses) { 18 | notiesClasses = []; 19 | 20 | let position = NOTIES_POSITIONS.RIGHT; 21 | 22 | if (window.CONFIG && window.CONFIG.notiesPosition) { 23 | position = window.CONFIG.notiesPosition; 24 | } 25 | 26 | notiesClasses.push('-' + position); 27 | } 28 | 29 | return notiesClasses; 30 | }; 31 | 32 | Noty.onUpdate(function () { 33 | if (!$scope.$$phase) { 34 | $scope.$digest(); 35 | } 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /scripts/controllers/screensaver.js: -------------------------------------------------------------------------------- 1 | import angular from 'angular'; 2 | import { App } from '../app'; 3 | import { mergeObjects } from '../globals/utils'; 4 | 5 | App.controller('Screensaver', function ($scope) { 6 | if (!window.CONFIG) { 7 | return; 8 | } 9 | 10 | const $window = angular.element(window); 11 | let lastActivity = Date.now(); 12 | const conf = window.CONFIG.screensaver || null; 13 | 14 | if (!conf || !conf.timeout) { 15 | return; 16 | } 17 | 18 | let activeSlide = 0; 19 | const slidesTimeout = conf.slidesTimeout || 1; 20 | 21 | $scope.now = new Date(); 22 | $scope.isShown = false; 23 | $scope.conf = conf; 24 | $scope.slides = conf.slides; 25 | 26 | $scope.getSlideClasses = function (index, slide) { 27 | if (!slide._classes) { 28 | slide._classes = []; 29 | } 30 | 31 | const wasActive = activeSlide === index + 1 32 | || ($scope.slides.length === index + 1 && !activeSlide); 33 | 34 | slide._classes.length = 0; 35 | 36 | let slideClasses = slide.classes; 37 | if (slideClasses) { 38 | if (!Array.isArray(slideClasses)) { 39 | slideClasses = [slideClasses]; 40 | } 41 | slide._classes.push(...slideClasses); 42 | } 43 | 44 | if (activeSlide === index) { 45 | slide._classes.push('-active'); 46 | } 47 | 48 | if (wasActive) { 49 | slide._classes.push('-prev'); 50 | } 51 | 52 | return slide._classes; 53 | }; 54 | 55 | function setState (state) { 56 | $scope.isShown = state; 57 | 58 | // @ts-ignore 59 | if (window.setScreensaverShown) { 60 | // @ts-ignore 61 | window.setScreensaverShown(state); 62 | } 63 | 64 | if (!$scope.$$phase) { 65 | $scope.$digest(); 66 | } 67 | } 68 | 69 | $scope.hideScreensaver = function () { 70 | setState(false); 71 | }; 72 | 73 | $scope.getSlideStyle = function (slide) { 74 | if (!slide._styles) { 75 | slide._styles = { 76 | backgroundImage: 'url(' + slide.bg + ')', 77 | }; 78 | 79 | if (slide.styles) { 80 | slide._styles = mergeObjects(slide._styles, slide.styles); 81 | } 82 | } 83 | 84 | return slide._styles; 85 | }; 86 | 87 | setInterval(function () { 88 | const inactivity = Date.now() - lastActivity; 89 | 90 | const newState = conf.timeout < inactivity / 1000; 91 | 92 | if (newState !== $scope.isShown && !$scope.activeCamera) { 93 | setState(newState); 94 | } 95 | }, 1000); 96 | 97 | setInterval(function () { 98 | activeSlide += 1; 99 | 100 | if (activeSlide >= $scope.slides.length) { 101 | activeSlide = 0; 102 | } 103 | 104 | if ($scope.isShown) { 105 | $scope.now = new Date(); 106 | 107 | if (!$scope.$$phase) { 108 | $scope.$digest(); 109 | } 110 | } 111 | }, slidesTimeout * 1000); 112 | 113 | // @ts-ignore 114 | window.showScreensaver = function () { 115 | setTimeout(function () { 116 | lastActivity = 0; 117 | setState(true); 118 | }, 100); 119 | }; 120 | 121 | // @ts-ignore 122 | window.hideScreensaver = function () { 123 | setTimeout(function () { 124 | lastActivity = Date.now(); 125 | setState(false); 126 | }, 100); 127 | }; 128 | 129 | $window.bind('click keypress touchstart focus', function () { 130 | lastActivity = Date.now(); 131 | }); 132 | }); 133 | -------------------------------------------------------------------------------- /scripts/directives.js: -------------------------------------------------------------------------------- 1 | import { App } from './app'; 2 | import camera from './directives/camera'; 3 | import cameraStream from './directives/cameraStream'; 4 | import clock from './directives/clock'; 5 | import date from './directives/date'; 6 | import headerItem from './directives/headerItem'; 7 | import iframeTile from './directives/iframeTile'; 8 | import ngMax from './directives/ngMax'; 9 | import ngMin from './directives/ngMin'; 10 | import onScroll from './directives/onScroll'; 11 | import tile from './directives/tile'; 12 | 13 | App.directive('camera', camera); 14 | App.directive('cameraStream', cameraStream); 15 | App.directive('clock', clock); 16 | App.directive('date', date); 17 | App.directive('headerItem', headerItem); 18 | App.directive('iframeTile', iframeTile); 19 | App.directive('ngMax', ngMax); 20 | App.directive('ngMin', ngMin); 21 | App.directive('onScroll', onScroll); 22 | App.directive('tile', tile); 23 | -------------------------------------------------------------------------------- /scripts/directives/camera.js: -------------------------------------------------------------------------------- 1 | import { toAbsoluteServerURL } from '../globals/utils'; 2 | 3 | /** 4 | * @ngInject 5 | * 6 | * @type {angular.IDirectiveFactory} 7 | */ 8 | export default function () { 9 | return { 10 | restrict: 'AE', 11 | replace: true, 12 | scope: { 13 | item: '=item', 14 | entity: '=entity', 15 | frozen: '=frozen', 16 | }, 17 | link: function ($scope, $el, attrs) { 18 | let $i = 0; 19 | let imageUrl = null; 20 | let refresh = $scope.item.refresh || false; 21 | let current = null; 22 | let prev = null; 23 | 24 | if (typeof refresh === 'function') { 25 | refresh = refresh(); 26 | } 27 | 28 | const appendImage = function (url) { 29 | const el = document.createElement('div'); 30 | 31 | if (url) { 32 | el.style.backgroundImage = 'url("' + toAbsoluteServerURL(url) + '")'; 33 | } 34 | 35 | el.style.backgroundSize = $scope.item.bgSize || 'cover'; 36 | 37 | $el[0].appendChild(el); 38 | 39 | if (prev) { 40 | $el[0].removeChild(prev); 41 | } 42 | 43 | setTimeout(function () { 44 | el.style.opacity = 1; 45 | }, 100); 46 | 47 | prev = current; 48 | current = el; 49 | }; 50 | 51 | const getImageUrl = function () { 52 | if ($scope.item.filter) { 53 | return $scope.item.filter($scope.item, $scope.entity); 54 | } 55 | 56 | if ($scope.entity && $scope.entity.attributes.entity_picture) { 57 | return $scope.entity.attributes.entity_picture; 58 | } 59 | 60 | return null; 61 | }; 62 | 63 | const reloadImage = function () { 64 | if (!imageUrl) { 65 | return; 66 | } 67 | 68 | if ($i > 1 && $scope.frozen) { 69 | return; 70 | } 71 | 72 | let url = imageUrl; 73 | 74 | url += (url.indexOf('?') === -1 ? '?' : '&') + ('_i=' + $i++); 75 | 76 | appendImage(url); 77 | }; 78 | 79 | const setImage = function (url) { 80 | imageUrl = url; 81 | 82 | if (!imageUrl) { 83 | return; 84 | } 85 | 86 | reloadImage(); 87 | }; 88 | 89 | const updateImage = function () { 90 | const newUrl = getImageUrl(); 91 | 92 | if (imageUrl !== newUrl) { 93 | setImage(newUrl); 94 | } 95 | }; 96 | $scope.$watchGroup([ 97 | 'item', 98 | 'entity', 99 | 'entity.attributes.entity_picture', 100 | ], updateImage); 101 | 102 | if (refresh) { 103 | const interval = setInterval(reloadImage, refresh); 104 | 105 | $scope.$on('$destroy', function () { 106 | clearInterval(interval); 107 | }); 108 | } 109 | }, 110 | }; 111 | } 112 | -------------------------------------------------------------------------------- /scripts/directives/cameraStream.js: -------------------------------------------------------------------------------- 1 | import Hls from 'hls.js'; 2 | import { toAbsoluteServerURL } from '../globals/utils'; 3 | 4 | /** 5 | * @ngInject 6 | * 7 | * @type {angular.IDirectiveFactory} 8 | */ 9 | export default function (Api, $timeout) { 10 | return { 11 | restrict: 'AE', 12 | replace: true, 13 | scope: { 14 | item: '=item', 15 | entity: '=entity', 16 | frozen: '=frozen', 17 | }, 18 | link: function ($scope, $el, attrs) { 19 | // Time after which the stream will be stopped entirely (media element will be detached) 20 | // after the playback was paused due to frozen=true. 21 | const SUSPEND_TIMEOUT_MS = 5000; 22 | let suspendPromise = null; 23 | /** @type {HTMLMediaElement | null} */ 24 | let current = null; 25 | /** @type {Hls | null} */ 26 | let hls = null; 27 | 28 | $scope.$watch('frozen', frozen => { 29 | if (frozen) { 30 | onFreezed(); 31 | } else { 32 | onUnfreezed(); 33 | } 34 | }); 35 | 36 | function onFreezed () { 37 | if (current && !current.paused) { 38 | current.pause(); 39 | suspendPromise = $timeout(() => { 40 | if (hls) { 41 | hls.destroy(); 42 | hls = null; 43 | current.remove(); 44 | current = null; 45 | } 46 | }, SUSPEND_TIMEOUT_MS); 47 | } 48 | } 49 | 50 | function onUnfreezed () { 51 | $timeout.cancel(suspendPromise); 52 | if (hls) { 53 | Promise.resolve(current.play()).catch(() => {}); 54 | } else { 55 | requestStream(); 56 | } 57 | } 58 | 59 | const appendVideo = function (url) { 60 | const el = document.createElement('video'); 61 | el.style.objectFit = $scope.item.objFit || 'fill'; 62 | el.style.width = '100%'; 63 | el.style.height = '100%'; 64 | el.muted = true; 65 | 66 | if (Hls.isSupported()) { 67 | const len = $scope.item.bufferLength || 5; 68 | 69 | const config = { 70 | maxBufferLength: len, 71 | maxMaxBufferLength: len, 72 | }; 73 | 74 | if (hls) { 75 | hls.destroy(); 76 | } 77 | hls = new Hls(config); 78 | hls.on(Hls.Events.MEDIA_ATTACHED, function () { 79 | hls.loadSource(url); 80 | }); 81 | hls.on(Hls.Events.MANIFEST_PARSED, function () { 82 | Promise.resolve(el.play()).catch(() => {}); 83 | }); 84 | hls.attachMedia(el); 85 | } else { 86 | el.src = url; 87 | el.setAttribute('playsinline', 'playsinline'); 88 | el.addEventListener('loadedmetadata', function () { 89 | Promise.resolve(el.play()).catch(() => {}); 90 | }); 91 | } 92 | 93 | if (current) { 94 | $el[0].removeChild(current); 95 | } 96 | $el[0].appendChild(el); 97 | 98 | current = el; 99 | }; 100 | 101 | const requestStream = function () { 102 | if ($scope.entity.state === 'off' || $scope.frozen) { 103 | return; 104 | } 105 | 106 | Api.send( 107 | { 108 | type: 'camera/stream', 109 | entity_id: $scope.entity.entity_id, 110 | }, 111 | function (res) { 112 | if (!res.result) { 113 | return; 114 | } 115 | appendVideo(toAbsoluteServerURL(res.result.url)); 116 | }, 117 | ); 118 | }; 119 | 120 | $scope.$watch('entity', requestStream); 121 | 122 | $scope.$on('$destroy', function () { 123 | $timeout.cancel(suspendPromise); 124 | if (hls) { 125 | hls.destroy(); 126 | } 127 | }); 128 | }, 129 | }; 130 | } 131 | -------------------------------------------------------------------------------- /scripts/directives/clock.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @ngInject 3 | * 4 | * @type {angular.IDirectiveFactory} 5 | * @param {angular.IFilterService} $filter 6 | */ 7 | export default function ($filter) { 8 | return { 9 | restrict: 'E', 10 | template: ` 11 |
:
15 | `, 16 | link ($scope, $el, attrs) { 17 | const hourEl = $el[0].querySelector('.clock--h'); 18 | const minuteEl = $el[0].querySelector('.clock--m'); 19 | const postfixEl = $el[0].querySelector('.clock--postfix'); 20 | 21 | const updateTime = function () { 22 | const localeTime = $filter('date')(Date.now(), 'shortTime'); 23 | const [hour, remainder] = localeTime.split(':'); 24 | const [minute, postfix] = remainder.split(' '); 25 | 26 | hourEl.textContent = hour; 27 | minuteEl.textContent = minute; 28 | postfixEl.textContent = postfix || ''; 29 | }; 30 | 31 | updateTime(); 32 | 33 | const interval = setInterval(updateTime, 1000); 34 | 35 | $scope.$on('$destroy', function () { 36 | clearInterval(interval); 37 | }); 38 | }, 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /scripts/directives/date.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @ngInject 3 | * 4 | * @typedef {{ 5 | * format: string 6 | * date: Date 7 | * }} Scope 8 | * 9 | * @type {angular.IDirectiveFactory} 10 | * @param {angular.IIntervalService} $interval 11 | * @param {angular.ILocaleService} $locale 12 | */ 13 | export default function ($interval, $locale) { 14 | return { 15 | restrict: 'E', 16 | replace: true, 17 | scope: { 18 | format: '<', 19 | }, 20 | template: '
{{ date | date:format}}
', 21 | link ($scope, $el, attrs) { 22 | $scope.format = $scope.format || $locale.DATETIME_FORMATS.longDate; 23 | $scope.date = new Date(); 24 | 25 | $interval(function () { 26 | $scope.date = new Date(); 27 | }, 60 * 1000); 28 | }, 29 | }; 30 | } 31 | -------------------------------------------------------------------------------- /scripts/directives/headerItem.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 |
6 | 7 |
8 |
9 | 10 | 11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | 25 |
26 | 28 |
29 | 30 |
31 | 32 |
33 |
34 | 35 |
36 |
37 |
38 | -------------------------------------------------------------------------------- /scripts/directives/headerItem.js: -------------------------------------------------------------------------------- 1 | import template from './headerItem.html'; 2 | 3 | /** 4 | * @ngInject 5 | * 6 | * @type {angular.IDirectiveFactory} 7 | */ 8 | export default function () { 9 | return { 10 | restrict: 'AE', 11 | replace: false, 12 | scope: '=', 13 | template, 14 | link: function ($scope, $el, attrs) {}, 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /scripts/directives/iframeTile.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @ngInject 3 | * 4 | * @type {angular.IDirectiveFactory} 5 | */ 6 | export default function ($interval) { 7 | return { 8 | restrict: 'A', 9 | replace: false, 10 | scope: { 11 | item: '=iframeTile', 12 | }, 13 | link: function ($scope, $el, attrs) { 14 | const iframe = $el[0]; 15 | 16 | const updateIframe = function () { 17 | // eslint-disable-next-line no-self-assign 18 | iframe.src = iframe.src; 19 | }; 20 | 21 | if ($scope.item.refresh) { 22 | let time = $scope.item.refresh; 23 | 24 | if (typeof time === 'function') { 25 | time = time(); 26 | } 27 | 28 | time = Math.max(1000, time); 29 | 30 | const interval = setInterval(updateIframe, time); 31 | 32 | $scope.$on('$destroy', function () { 33 | clearInterval(interval); 34 | }); 35 | } 36 | }, 37 | }; 38 | } 39 | -------------------------------------------------------------------------------- /scripts/directives/ngMax.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @ngInject 3 | * 4 | * Custom directive to fix angularjs bug with dynamic max values being overriden to 100. 5 | * https://github.com/angular/angular.js/issues/6726 6 | * 7 | * @type {angular.IDirectiveFactory} 8 | */ 9 | export default function () { 10 | return { 11 | restrict: 'A', 12 | require: 'ngModel', 13 | link (scope, elem, attr) { 14 | elem.attr('max', attr.ngMax); 15 | }, 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /scripts/directives/ngMin.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @ngInject 3 | * 4 | * Custom directive to fix angularjs bug with dynamic max values being overriden to 100. 5 | * https://github.com/angular/angular.js/issues/6726 6 | * 7 | * @type {angular.IDirectiveFactory} 8 | */ 9 | export default function () { 10 | return { 11 | restrict: 'A', 12 | require: 'ngModel', 13 | link (scope, elem, attr) { 14 | elem.attr('min', attr.ngMin); 15 | }, 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /scripts/directives/onScroll.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @ngInject 3 | * 4 | * @type {angular.IDirectiveFactory} 5 | */ 6 | export default function () { 7 | return { 8 | restrict: 'A', 9 | scope: { 10 | onScrollModel: '=', 11 | }, 12 | link: function ($scope, $el, attrs) { 13 | let lastScrolledHorizontally = false; 14 | let lastScrolledVertically = false; 15 | 16 | const determineScroll = function () { 17 | const scrolledHorizontally = $el[0].scrollLeft !== 0; 18 | const scrolledVertically = $el[0].scrollTop !== 0; 19 | 20 | if (lastScrolledVertically !== scrolledVertically || 21 | lastScrolledHorizontally !== scrolledHorizontally) { 22 | $scope.onScrollModel.scrolledHorizontally = lastScrolledHorizontally = scrolledHorizontally; 23 | $scope.onScrollModel.scrolledVertically = lastScrolledVertically = scrolledVertically; 24 | 25 | return true; 26 | } 27 | 28 | return false; 29 | }; 30 | 31 | determineScroll(); 32 | 33 | $el.on('scroll', function () { 34 | if (determineScroll()) { 35 | $scope.$apply(); 36 | } 37 | }); 38 | }, 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /scripts/directives/tile.js: -------------------------------------------------------------------------------- 1 | import template from './tile.html'; 2 | 3 | /** 4 | * @ngInject 5 | * 6 | * @type {angular.IDirectiveFactory} 7 | */ 8 | export default function () { 9 | return { 10 | restrict: 'AE', 11 | replace: false, 12 | scope: true, 13 | template, 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /scripts/globals.js: -------------------------------------------------------------------------------- 1 | import * as Constants from './globals/constants'; 2 | import * as Utils from './globals/utils'; 3 | import Noty from './models/noty'; 4 | 5 | // Expose all constants and utils on window as those can be used by config. 6 | for (const key in Constants) { 7 | if (Object.prototype.hasOwnProperty.call(Constants, key)) { 8 | window[key] = Constants[key]; 9 | } 10 | } 11 | 12 | for (const key in Utils) { 13 | if (Object.prototype.hasOwnProperty.call(Utils, key)) { 14 | window[key] = Utils[key]; 15 | } 16 | } 17 | 18 | // @ts-ignore 19 | window.Noty = Noty; 20 | 21 | // Set up global error handler. 22 | window.onerror = function (error, file, line, char) { 23 | const text = [ 24 | error, 25 | 'File: ' + file, 26 | 'Line: ' + line + ':' + char, 27 | ].join('
'); 28 | 29 | Noty.addObject({ 30 | type: Noty.ERROR, 31 | title: 'JS error', 32 | message: text, 33 | lifetime: 12, 34 | id: error, 35 | }); 36 | }; 37 | -------------------------------------------------------------------------------- /scripts/globals/constants.js: -------------------------------------------------------------------------------- 1 | import { supportsFeature, timeAgo } from './utils'; 2 | 3 | export const CLASS_BIG = '-big-entity'; 4 | export const CLASS_SMALL = '-small-entity'; 5 | export const CLASS_MICRO = '-micro-entity'; 6 | 7 | export const GOOGLE_MAP = 'google'; 8 | export const YANDEX_MAP = 'yandex'; 9 | export const MAPBOX_MAP = 'mapbox'; 10 | 11 | export const TRANSITIONS = { 12 | ANIMATED: 'animated', 13 | ANIMATED_GPU: 'animated_gpu', 14 | SIMPLE: 'simple', // fastest 15 | }; 16 | 17 | export const ITEM_TRANSPARENT = 'transparent'; 18 | 19 | export const CUSTOM_THEMES = { 20 | TRANSPARENT: 'transparent', 21 | MATERIAL: 'material', 22 | WIN95: 'win95', 23 | WINPHONE: 'winphone', 24 | MOBILE: 'mobile', 25 | COMPACT: 'compact', 26 | HOMEKIT: 'homekit', 27 | FRESH_AIR: 'fresh-air', 28 | WHITE_PAPER: 'white-paper', 29 | }; 30 | 31 | export const TYPES = { 32 | DEVICE_TRACKER: 'device_tracker', 33 | SCRIPT: 'script', 34 | AUTOMATION: 'automation', 35 | SENSOR: 'sensor', 36 | SENSOR_ICON: 'sensor_icon', 37 | SWITCH: 'switch', 38 | LOCK: 'lock', 39 | COVER: 'cover', 40 | COVER_TOGGLE: 'cover_toggle', 41 | FAN: 'fan', 42 | INPUT_BOOLEAN: 'input_boolean', 43 | LIGHT: 'light', 44 | TEXT_LIST: 'text_list', 45 | INPUT_NUMBER: 'input_number', 46 | INPUT_SELECT: 'input_select', 47 | INPUT_DATETIME: 'input_datetime', 48 | CAMERA: 'camera', 49 | CAMERA_THUMBNAIL: 'camera_thumbnail', 50 | CAMERA_STREAM: 'camera_stream', 51 | SCENE: 'scene', 52 | SLIDER: 'slider', 53 | IFRAME: 'iframe', 54 | DOOR_ENTRY: 'door_entry', 55 | WEATHER: 'weather', 56 | CLIMATE: 'climate', 57 | MEDIA_PLAYER: 'media_player', 58 | CUSTOM: 'custom', 59 | ALARM: 'alarm', 60 | WEATHER_LIST: 'weather_list', 61 | VACUUM: 'vacuum', 62 | POPUP_IFRAME: 'popup_iframe', 63 | POPUP: 'popup', 64 | DIMMER_SWITCH: 'dimmer_switch', 65 | GAUGE: 'gauge', 66 | IMAGE: 'image', 67 | HISTORY: 'history', 68 | }; 69 | 70 | export const HEADER_ITEMS = { 71 | TIME: 'time', 72 | DATE: 'date', 73 | DATETIME: 'datetime', 74 | WEATHER: 'weather', 75 | CUSTOM_HTML: 'custom_html', 76 | }; 77 | 78 | export const SCREENSAVER_ITEMS = HEADER_ITEMS; 79 | 80 | export const FEATURES = { 81 | ALARM: { 82 | // https://github.com/home-assistant/core/blob/dev/homeassistant/components/alarm_control_panel/const.py 83 | ARM_HOME: 1, 84 | ARM_AWAY: 2, 85 | ARM_NIGHT: 4, 86 | TRIGGER: 8, 87 | ARM_CUSTOM_BYPASS: 16, 88 | }, 89 | LIGHT: { 90 | // https://github.com/home-assistant/core/blob/dev/homeassistant/components/light/__init__.py 91 | BRIGHTNESS: 1, 92 | COLOR_TEMP: 2, 93 | EFFECT: 4, 94 | FLASH: 8, 95 | COLOR: 16, 96 | TRANSITION: 32, 97 | WHITE_VALUE: 128, 98 | COLOR_MODES_BRIGHTNESS: ['brightness', 'color_temp', 'hs', 'xy', 'rgb', 'rgbw', 'rgbww', 'white'], 99 | supportsBrightness (entity) { 100 | const { attributes } = entity; 101 | let { supported_color_modes: supportedColorModes } = attributes; 102 | const { supported_features: supportedFeatures } = attributes; 103 | 104 | if (supportedColorModes === undefined) { 105 | // Backwards compatibility for supported_color_modes added in 2021.4 106 | supportedColorModes = []; 107 | 108 | if (supportedFeatures & FEATURES.LIGHT.COLOR_TEMP) { 109 | supportedColorModes.push('color_temp'); 110 | } 111 | if (supportedFeatures & FEATURES.LIGHT.COLOR) { 112 | supportedColorModes.push('hs'); 113 | } 114 | if (supportedFeatures & FEATURES.LIGHT.WHITE_VALUE) { 115 | supportedColorModes.push('rgbw'); 116 | } 117 | if (supportedFeatures & FEATURES.LIGHT.BRIGHTNESS && supportedColorModes.length === 0) { 118 | supportedColorModes = ['brightness']; 119 | } 120 | } 121 | 122 | return supportedColorModes.some(mode => FEATURES.LIGHT.COLOR_MODES_BRIGHTNESS.includes(mode)); 123 | }, 124 | }, 125 | MEDIA_PLAYER: { 126 | PAUSE: 1, 127 | SEEK: 2, 128 | VOLUME_SET: 4, 129 | VOLUME_STEP: 1024, 130 | VOLUME_MUTE: 8, 131 | PREVIOUS_TRACK: 16, 132 | NEXT_TRACK: 32, 133 | YOUTUBE: 64, 134 | TURN_ON: 128, 135 | TURN_OFF: 256, 136 | STOP: 4096, 137 | }, 138 | VACUUM: { 139 | TURN_ON: 1, 140 | TURN_OFF: 2, 141 | PAUSE: 4, 142 | STOP: 8, 143 | RETURN_HOME: 16, 144 | FAN_SPEED: 32, 145 | BATTERY: 64, 146 | STATUS: 128, 147 | SEND_COMMAND: 256, 148 | LOCATE: 512, 149 | CLEAN_SPOT: 1024, 150 | MAP: 2048, 151 | STATE: 4096, 152 | START: 8192, 153 | }, 154 | }; 155 | 156 | export const MENU_POSITIONS = { 157 | LEFT: 'left', 158 | BOTTOM: 'bottom', 159 | }; 160 | 161 | export const GROUP_ALIGNS = { 162 | VERTICALLY: 'vertically', 163 | HORIZONTALLY: 'horizontally', 164 | GRID: 'grid', 165 | }; 166 | 167 | export const NOTIES_POSITIONS = { 168 | LEFT: 'left', 169 | RIGHT: 'right', 170 | }; 171 | 172 | export const ENTITY_SIZES = { 173 | SMALL: 'small', 174 | NORMAL: 'normal', 175 | BIG: 'big', 176 | }; 177 | 178 | export const TOKEN_CACHE_KEY = '_tkn1'; 179 | 180 | export const DEFAULT_HEADER = { 181 | styles: { 182 | padding: '30px 130px 0', 183 | fontSize: '28px', 184 | }, 185 | left: [ 186 | { 187 | type: HEADER_ITEMS.DATETIME, 188 | dateFormat: 'EEEE, LLLL dd', // https://docs.angularjs.org/api/ng/filter/date 189 | styles: { 190 | margin: '0', 191 | }, 192 | }, 193 | ], 194 | right: [ 195 | { 196 | type: HEADER_ITEMS.CUSTOM_HTML, 197 | html: 'Welcome to the TileBoard', 198 | styles: { 199 | margin: '40px 0 0', 200 | }, 201 | }, 202 | /* { 203 | type: HEADER_ITEMS.WEATHER, 204 | styles: { 205 | margin: '0 0 0' 206 | }, 207 | icon: '&weather.openweathermap.state', 208 | icons: { 209 | 'clear-day': 'clear', 210 | 'clear-night': 'nt-clear', 211 | 'cloudy': 'cloudy', 212 | 'rain': 'rain', 213 | 'sleet': 'sleet', 214 | 'snow': 'snow', 215 | 'wind': 'hazy', 216 | 'fog': 'fog', 217 | 'partly-cloudy-day': 'partlycloudy', 218 | 'partly-cloudy-night': 'nt-partlycloudy' 219 | }, 220 | fields: { 221 | summary: '&sensor.dark_sky_summary.state', 222 | temperature: '&sensor.dark_sky_temperature.state', 223 | temperatureUnit: '&sensor.dark_sky_temperature.attributes.unit_of_measurement', 224 | } 225 | }*/ 226 | ], 227 | }; 228 | 229 | export const MINIMAL_CHART_OPTIONS = { 230 | layout: { 231 | padding: { 232 | left: 0, 233 | }, 234 | }, 235 | elements: { line: { 236 | fill: false, 237 | borderWidth: 3, 238 | stepped: false, 239 | cubicInterpolationMode: 'monotone', 240 | } }, 241 | legend: { display: false }, 242 | scales: { 243 | xAxes: [{ 244 | display: false, 245 | }], 246 | yAxes: [{ 247 | display: true, 248 | ticks: { 249 | mirror: true, 250 | callback (value, index, values) { 251 | if (index === values.length - 1 || index === 0) { 252 | return value; 253 | } 254 | return null; 255 | }, 256 | }, 257 | }], 258 | }, 259 | tooltips: { 260 | callbacks: { 261 | title (tooltipItem, data) { 262 | const { datasetIndex, index } = tooltipItem[0]; 263 | return timeAgo(data.datasets[datasetIndex].data[index].x); 264 | }, 265 | label (tooltipItem, data) { 266 | return tooltipItem.value; 267 | }, 268 | }, 269 | }, 270 | }; 271 | 272 | export const DEFAULT_SLIDER_OPTIONS = { 273 | max: 100, 274 | min: 0, 275 | step: 1, 276 | field: 'value', 277 | request: { 278 | domain: 'input_number', 279 | service: 'set_value', 280 | field: 'value', 281 | }, 282 | }; 283 | 284 | export const DEFAULT_LIGHT_SLIDER_OPTIONS = { 285 | max: 255, 286 | min: 0, 287 | step: 1, 288 | field: 'brightness', 289 | request: { 290 | domain: 'light', 291 | service: 'turn_on', 292 | field: 'brightness', 293 | }, 294 | }; 295 | 296 | export const DEFAULT_VOLUME_SLIDER_OPTIONS = { 297 | max: 1.0, 298 | min: 0.0, 299 | step: 0.02, 300 | field: 'volume_level', 301 | request: { 302 | domain: 'media_player', 303 | service: 'volume_set', 304 | field: 'volume_level', 305 | }, 306 | }; 307 | 308 | export const DEFAULT_POPUP_HISTORY = (item, entitiy) => ({ 309 | classes: ['-popup-landscape'], 310 | styles: {}, 311 | items: [{ 312 | type: TYPES.HISTORY, 313 | id: item.id, 314 | title: false, 315 | position: [0, 0], 316 | action: false, 317 | secondaryAction: false, 318 | classes: ['-item-fullsize'], 319 | customStyles: { 320 | width: null, 321 | height: null, 322 | top: null, 323 | left: null, 324 | }, 325 | }], 326 | }); 327 | 328 | export const DEFAULT_POPUP_IFRAME = (item, entity) => ({ 329 | classes: ['-popup-fullsize'], 330 | styles: {}, 331 | items: [{ 332 | type: TYPES.IFRAME, 333 | url: item.url, 334 | id: {}, 335 | state: false, 336 | title: false, 337 | position: [0, 0], 338 | classes: ['-item-fullsize'], 339 | customStyles: { 340 | width: null, 341 | height: null, 342 | top: null, 343 | left: null, 344 | }, 345 | }], 346 | }); 347 | 348 | export const DEFAULT_POPUP_DOOR_ENTRY = (item, entity) => ({ 349 | classes: ['-popup-fullsize'], 350 | styles: {}, 351 | items: [{ 352 | state: false, 353 | title: false, 354 | position: [0, 0], 355 | action: false, 356 | secondaryAction: false, 357 | classes: ['-item-fullsize', '-item-non-clickable', '-item-transparent'], 358 | customStyles: { 359 | width: null, 360 | height: null, 361 | top: null, 362 | left: null, 363 | }, 364 | }], 365 | }); 366 | 367 | export const TILE_DEFAULTS = { 368 | [TYPES.ALARM]: { 369 | action (item, entity) { 370 | return this.$scope.openAlarm(item, entity); 371 | }, 372 | }, 373 | [TYPES.AUTOMATION]: { 374 | action (item, entity) { 375 | return this.$scope.triggerAutomation(item, entity); 376 | }, 377 | }, 378 | [TYPES.CAMERA]: { 379 | action (item, entity) { 380 | return this.$scope.openCamera(item, entity); 381 | }, 382 | }, 383 | [TYPES.CAMERA_STREAM]: { 384 | action (item, entity) { 385 | return this.$scope.openCamera(item, entity); 386 | }, 387 | }, 388 | [TYPES.CLIMATE]: { 389 | subtitle (item, entity) { 390 | return item.useHvacMode ? entity.attributes.hvac_action : undefined; 391 | }, 392 | }, 393 | [TYPES.COVER_TOGGLE]: { 394 | action (item, entity) { 395 | return this.$scope.toggleCover(item, entity); 396 | }, 397 | }, 398 | [TYPES.DIMMER_SWITCH]: { 399 | action (item, entity) { 400 | return this.$scope.dimmerToggle(item, entity); 401 | }, 402 | }, 403 | [TYPES.DOOR_ENTRY]: { 404 | action (item, entity) { 405 | return this.$scope.openDoorEntry(item, entity); 406 | }, 407 | }, 408 | [TYPES.FAN]: { 409 | action (item, entity) { 410 | return this.$scope.toggleSwitch(item, entity); 411 | }, 412 | }, 413 | [TYPES.GAUGE]: { 414 | settings: { 415 | backgroundColor: 'rgba(0, 0, 0, 0.1)', 416 | foregroundColor: 'rgba(0, 150, 136, 1)', 417 | size (item) { 418 | return .8 * (window.CONFIG.tileSize * (item.height < item.width ? item.height : item.width)); 419 | }, 420 | duration: 1500, 421 | thick: 6, 422 | type: 'full', 423 | min: 0, 424 | max: 100, 425 | cap: 'butt', 426 | thresholds: {}, 427 | }, 428 | }, 429 | [TYPES.INPUT_BOOLEAN]: { 430 | action (item, entity) { 431 | return this.$scope.toggleSwitch(item, entity); 432 | }, 433 | }, 434 | [TYPES.INPUT_DATETIME]: { 435 | action (item, entity) { 436 | return this.$scope.openDatetime(item, entity); 437 | }, 438 | }, 439 | [TYPES.INPUT_SELECT]: { 440 | action (item, entity) { 441 | return this.$scope.toggleSelect(item, entity); 442 | }, 443 | }, 444 | [TYPES.LIGHT]: { 445 | colorpicker: (item, entity) => supportsFeature(FEATURES.LIGHT.COLOR, entity), 446 | action (item, entity) { 447 | return this.$scope.toggleSwitch(item, entity); 448 | }, 449 | secondaryAction (item, entity) { 450 | return this.$scope.openLightSliders(item, entity); 451 | }, 452 | }, 453 | [TYPES.LOCK]: { 454 | action (item, entity) { 455 | return this.$scope.toggleLock(item, entity); 456 | }, 457 | }, 458 | [TYPES.POPUP]: { 459 | action (item, entity) { 460 | return this.$scope.openPopup(item, entity, item.popup); 461 | }, 462 | }, 463 | [TYPES.POPUP_IFRAME]: { 464 | action (item, entity) { 465 | return this.$scope.openPopupIframe(item, entity); 466 | }, 467 | }, 468 | [TYPES.SCENE]: { 469 | action (item, entity) { 470 | return this.$scope.callScene(item, entity); 471 | }, 472 | }, 473 | [TYPES.SCRIPT]: { 474 | action (item, entity) { 475 | return this.$scope.callScript(item, entity); 476 | }, 477 | }, 478 | [TYPES.SENSOR]: { 479 | filter (value, item, entity) { 480 | if (entity?.attributes?.device_class === 'timestamp') { 481 | return timeAgo(value, false); 482 | } 483 | return value; 484 | }, 485 | }, 486 | [TYPES.SWITCH]: { 487 | action (item, entity) { 488 | return this.$scope.toggleSwitch(item, entity); 489 | }, 490 | }, 491 | [TYPES.VACUUM]: { 492 | action (item, entity) { 493 | return this.$scope.toggleVacuum(item, entity); 494 | }, 495 | }, 496 | [TYPES.WEATHER_LIST]: { 497 | dateFormat: 'MMM d', 498 | }, 499 | }; 500 | -------------------------------------------------------------------------------- /scripts/globals/utils.js: -------------------------------------------------------------------------------- 1 | import angular from 'angular'; 2 | import moment from 'moment'; 3 | 4 | export { moment }; 5 | 6 | export const mergeObjects = angular.merge; 7 | 8 | export const leadZero = function (num) { 9 | if (num >= 0 && num < 10) { 10 | return '0' + num; 11 | } 12 | 13 | return num; 14 | }; 15 | 16 | export const numberFilter = function (precision) { 17 | return function (value) { 18 | const num = parseFloat(value); 19 | 20 | return num && !isNaN(num) ? num.toFixed(precision) : value; 21 | }; 22 | }; 23 | 24 | export const switchPercents = function (field, max, round) { 25 | round = round || false; 26 | max = max || 100; 27 | 28 | return function (item, entity) { 29 | let value = field in entity.attributes ? entity.attributes[field] : null; 30 | 31 | value = parseFloat(value); 32 | 33 | if (isNaN(value)) { 34 | value = entity.state; 35 | 36 | if (item.states && value in item.states) { 37 | return item.states[value]; 38 | } 39 | 40 | return value; 41 | } 42 | 43 | value = Math.round((value / max * 100)); 44 | 45 | if (round) { 46 | value = Math.round(value / 10) * 10; 47 | } 48 | 49 | return value + '%'; 50 | }; 51 | }; 52 | 53 | export const playSound = function (sound) { 54 | const audio = new Audio(sound); 55 | audio.loop = false; 56 | audio.play(); 57 | }; 58 | 59 | export const timeAgo = function (time, withoutSuffix = false) { 60 | const momentInTime = moment(new Date(time)); 61 | return momentInTime.fromNow(withoutSuffix); 62 | }; 63 | 64 | export const debounce = function (func, wait, immediate) { 65 | let timeout; 66 | return function () { 67 | const context = this; 68 | const args = arguments; 69 | const later = function () { 70 | timeout = null; 71 | if (!immediate) { 72 | func.apply(context, args); 73 | } 74 | }; 75 | const callNow = immediate && !timeout; 76 | clearTimeout(timeout); 77 | timeout = setTimeout(later, wait); 78 | if (callNow) { 79 | func.apply(context, args); 80 | } 81 | }; 82 | }; 83 | 84 | export const toAbsoluteServerURL = function (path, serverUrlOverride = false) { 85 | const startsWithProtocol = path.indexOf('http') === 0; 86 | const url = startsWithProtocol ? path : (serverUrlOverride || window.SERVER_URL_OVERRIDE || window.CONFIG.serverUrl) + '/' + path; 87 | return normalizeUrlSlashes(url); 88 | }; 89 | 90 | export function normalizeUrlSlashes (url) { 91 | // Replace extra forward slashes but not in protocol. 92 | return url.replace(/([^:])\/+/g, '$1/'); 93 | } 94 | 95 | export function supportsFeature (feature, entity) { 96 | return 'supported_features' in entity.attributes 97 | && (entity.attributes.supported_features & feature) !== 0; 98 | } 99 | -------------------------------------------------------------------------------- /scripts/index.js: -------------------------------------------------------------------------------- 1 | import './globals'; 2 | import './app'; 3 | import './init'; 4 | import './models/api'; 5 | import './directives'; 6 | import './controllers/main'; 7 | import './controllers/noty'; 8 | import './controllers/screensaver'; 9 | import '../styles/all.less'; 10 | import '@mdi/font/scss/materialdesignicons.scss'; 11 | 12 | function onConfigLoadOrError (error) { 13 | if (error) { 14 | alert(`Please make sure that you have "${configName}.js" file and it is a valid javascript! 15 | If you are running TileBoard for the first time, please rename "config.example.js" to "${configName}.js"`); 16 | return; 17 | } 18 | if (!window.CONFIG) { 19 | alert(`The "${configName}.js" configuration file has loaded but window.CONFIG is not defined! 20 | Please make sure that it defines a CONFIG variable with proper configuration.`); 21 | } 22 | // Initialize the app even though we have no valid configuration so that notifications are working. 23 | // @ts-ignore 24 | window.window.initApp(); 25 | } 26 | 27 | const url = new URL(document.location.href); 28 | const configName = url.searchParams.get('config') || 'config'; 29 | 30 | const script = document.createElement('script'); 31 | script.src = `./${configName}.js?r=${Date.now()}`; 32 | script.onload = () => onConfigLoadOrError(); 33 | script.onerror = event => onConfigLoadOrError(event); 34 | document.head.appendChild(script); 35 | -------------------------------------------------------------------------------- /scripts/init.js: -------------------------------------------------------------------------------- 1 | import angular from 'angular'; 2 | import 'hammerjs'; 3 | import 'angular-hammer'; 4 | import 'angular-chart.js'; 5 | import 'angularjs-gauge'; 6 | import 'angular-moment'; 7 | import 'angular-dynamic-locale'; 8 | import './vendors/color-picker'; 9 | import { App } from './app'; 10 | 11 | // Initializes angular app manually. This is triggered from the onload event of the config.js script. 12 | // @ts-ignore 13 | window.initApp = function () { 14 | angular.element(function () { 15 | angular.bootstrap(document, [App.name]); 16 | }); 17 | 18 | App.config(function ($sceProvider, $locationProvider, ApiProvider, ChartJsProvider, tmhDynamicLocaleProvider) { 19 | $sceProvider.enabled(false); 20 | 21 | $locationProvider.html5Mode({ 22 | enabled: true, 23 | requireBase: false, 24 | }); 25 | 26 | if (!window.CONFIG) { 27 | return; 28 | } 29 | 30 | ApiProvider.setInitOptions({ 31 | wsUrl: window.WS_URL_OVERRIDE || window.CONFIG.wsUrl, 32 | authToken: window.AUTH_TOKEN_OVERRIDE || window.CONFIG.authToken, 33 | }); 34 | 35 | tmhDynamicLocaleProvider.localeLocationPattern('./locales/{{locale}}.js'); 36 | 37 | const clock24 = window.CONFIG.timeFormat === 24; 38 | 39 | ChartJsProvider.setOptions('line', { 40 | maintainAspectRatio: false, // to fit popup automatically 41 | layout: { 42 | padding: { 43 | bottom: 10, 44 | left: 10, 45 | right: 10, 46 | }, 47 | }, 48 | scales: { 49 | xAxes: [{ 50 | type: 'time', 51 | time: { 52 | displayFormats: { 53 | datetime: clock24 ? 'MMM D, YYYY, H:mm:ss' : 'MMM D, YYYY, h:mm:ss a', 54 | hour: clock24 ? 'H:mm' : 'h:mm a', 55 | millisecond: clock24 ? 'H:mm:ss.SSS' : 'h:mm:ss.SSS a', 56 | minute: clock24 ? 'H:mm' : 'h:mm a', 57 | second: clock24 ? 'H:mm:ss' : 'h:mm:ss a', 58 | }, 59 | }, 60 | }], 61 | yAxes: [ 62 | { 63 | ticks: { 64 | maxTicksLimit: 7, 65 | }, 66 | }, 67 | ], 68 | }, 69 | elements: { 70 | point: { 71 | radius: 0, // to remove points 72 | hitRadius: 5, 73 | }, 74 | line: { 75 | borderWidth: 1, 76 | stepped: true, 77 | }, 78 | }, 79 | legend: { 80 | align: 'start', 81 | display: true, 82 | }, 83 | tooltips: { 84 | intersect: false, 85 | }, 86 | hover: { 87 | intersect: false, 88 | }, 89 | }); 90 | 91 | // Workaround to add padding around legend. 92 | window.Chart.Legend.prototype.afterFit = function () { 93 | this.height += 20; 94 | }; 95 | }); 96 | }; 97 | -------------------------------------------------------------------------------- /scripts/models/api.js: -------------------------------------------------------------------------------- 1 | import angular from 'angular'; 2 | import { App } from '../app'; 3 | import { TOKEN_CACHE_KEY } from '../globals/constants'; 4 | import { toAbsoluteServerURL, normalizeUrlSlashes } from '../globals/utils'; 5 | import Noty from '../models/noty'; 6 | 7 | App.provider('Api', function () { 8 | let wsUrl; 9 | let authToken; 10 | 11 | this.setInitOptions = function (options) { 12 | wsUrl = normalizeUrlSlashes(options.wsUrl); 13 | authToken = options.authToken; 14 | }; 15 | 16 | this.$get = ['$http', '$location', '$q', function ($http, $location, $q) { 17 | const STATUS_LOADING = 1; 18 | const STATUS_OPENED = 2; 19 | const STATUS_READY = 3; 20 | const STATUS_ERROR = 4; 21 | const STATUS_CLOSED = 5; 22 | 23 | let reconnectTimeout = null; 24 | 25 | function $Api (url, token) { 26 | this._id = 1; 27 | 28 | this._url = url; 29 | 30 | this._listeners = { 31 | error: [], 32 | message: [], 33 | ready: [], 34 | unready: [], 35 | }; 36 | 37 | this._callbacks = {}; 38 | 39 | if (token) { 40 | this._configToken = token; 41 | } 42 | 43 | this._init(); 44 | 45 | window.onbeforeunload = event => { 46 | if (this.socket && this.socket.readyState < WebSocket.CLOSING) { 47 | this.socket.close(); 48 | } 49 | }; 50 | } 51 | 52 | $Api.prototype._init = function () { 53 | const self = this; 54 | 55 | if (!self._url) { 56 | console.info('Skipping API service initialization since no API URL was provided.'); 57 | return; 58 | } 59 | 60 | this._getToken().then(function (token) { 61 | if (token) { 62 | self._token = token.access_token; 63 | self._connect.call(self); 64 | 65 | if (token.expires_in) { 66 | setTimeout( 67 | self._refreshToken.bind(self), 68 | token.expires_in * 900); 69 | } 70 | } else { 71 | Noty.addObject({ 72 | type: Noty.ERROR, 73 | title: 'ACCESS TOKEN', 74 | message: 'Error while receiving access token', 75 | }); 76 | } 77 | }); 78 | }; 79 | 80 | $Api.prototype.on = function (key, callback) { 81 | const self = this; 82 | 83 | if (this._listeners[key].indexOf(callback) !== -1) { 84 | return function () {}; 85 | } 86 | 87 | this._listeners[key].push(callback); 88 | 89 | return function () { 90 | self._listeners[key] = self._listeners[key].filter(function (a) { 91 | return a !== callback; 92 | }); 93 | }; 94 | }; 95 | 96 | $Api.prototype.onError = function (callback) { 97 | return this.on('error', callback); 98 | }; 99 | $Api.prototype.onMessage = function (callback) { 100 | return this.on('message', callback); 101 | }; 102 | $Api.prototype.onReady = function (callback) { 103 | if (this.status === STATUS_READY) { 104 | try { 105 | callback({ status: STATUS_READY }); 106 | } catch (e) { 107 | // Ignore 108 | } 109 | } 110 | 111 | return this.on('ready', callback); 112 | }; 113 | $Api.prototype.onUnready = function (callback) { 114 | return this.on('unready', callback); 115 | }; 116 | 117 | $Api.prototype.send = function (data, callback, id) { 118 | id = id !== false; 119 | 120 | if (!data.id && id) { 121 | data.id = this._id++; 122 | } 123 | 124 | const wsData = JSON.stringify(data); 125 | 126 | if (callback && data.id) { 127 | this._callbacks[data.id] = callback; 128 | } 129 | 130 | return this.socket.send(wsData); 131 | }; 132 | 133 | $Api.prototype.callService = function (domain, service, data, callback) { 134 | const apiData = { 135 | type: 'call_service', 136 | domain: domain, 137 | service: service, 138 | service_data: data, 139 | }; 140 | 141 | this.send(apiData, function (res) { 142 | if (callback) { 143 | callback(res); 144 | } 145 | }); 146 | }; 147 | 148 | $Api.prototype.rest = function (requestStub) { 149 | const request = angular.copy(requestStub); 150 | request.url = toAbsoluteServerURL(request.url, window.REST_URL_OVERRIDE); 151 | request.headers = request.headers || {}; 152 | request.headers.Authorization = 'Bearer ' + this._token; 153 | return $http(request) 154 | .then(function (response) { 155 | return response.data; 156 | }) 157 | .catch(function (response) { 158 | switch (response.status) { 159 | case 401: 160 | redirectOAuth(); 161 | return; 162 | default: 163 | window.Noty.add(window.Noty.ERROR, 'Error in REST api', 'Code ' + response.status + ' retrieved for ' + request.url + '.'); 164 | return null; 165 | } 166 | }); 167 | }; 168 | 169 | $Api.prototype.getHistory = function (startDate, filterEntityId, endDate) { 170 | const request = { 171 | type: 'GET', 172 | url: '/api/history/period', 173 | }; 174 | if (startDate) { 175 | request.url += '/' + startDate; 176 | } 177 | if (endDate) { 178 | request.url += '?end_time=' + endDate; 179 | } else { 180 | request.url += '?end_time=' + new Date(Date.now()).toISOString(); 181 | } 182 | if (filterEntityId) { 183 | const entityIds = filterEntityId instanceof Array ? filterEntityId.join(',') : filterEntityId; 184 | request.url += '&filter_entity_id=' + entityIds; 185 | } 186 | return this.rest(request); 187 | }; 188 | 189 | $Api.prototype.subscribeEvents = function (events, callback) { 190 | const self = this; 191 | if (events && typeof events === 'object') { 192 | events.forEach(function (event) { 193 | self.subscribeEvent(event, callback); 194 | }); 195 | } else { 196 | this.subscribeEvent(events, callback); 197 | } 198 | }; 199 | 200 | $Api.prototype.subscribeEvent = function (event, callback) { 201 | const data = { type: 'subscribe_events' }; 202 | 203 | if (event) { 204 | data.event_type = event; 205 | } 206 | 207 | this.send(data, callback); 208 | }; 209 | 210 | $Api.prototype.getStates = function (callback) { 211 | return this.send({ type: 'get_states' }, callback); 212 | }; 213 | 214 | $Api.prototype.getPanels = function (callback) { 215 | return this.send({ type: 'get_panels' }, callback); 216 | }; 217 | 218 | $Api.prototype.getConfig = function (callback) { 219 | return this.send({ type: 'get_config' }, callback); 220 | }; 221 | 222 | $Api.prototype.getServices = function (callback) { 223 | return this.send({ type: 'get_services' }, callback); 224 | }; 225 | 226 | $Api.prototype.getUser = function (callback) { 227 | return this.send({ type: 'auth/current_user' }, callback); 228 | }; 229 | 230 | $Api.prototype.sendPing = function (callback) { 231 | return this.send({ type: 'ping' }, callback); 232 | }; 233 | 234 | $Api.prototype._connect = function () { 235 | const self = this; 236 | 237 | if (this.socket && this.socket.readyState < WebSocket.CLOSING) { 238 | return; 239 | } // opened or connecting 240 | 241 | this.status = STATUS_LOADING; 242 | this.socket = new WebSocket(this._url); 243 | 244 | this.socket.addEventListener('open', function (e) { 245 | self._setStatus(STATUS_OPENED); 246 | }); 247 | 248 | this.socket.addEventListener('close', function (e) { 249 | self._setStatus(STATUS_CLOSED); 250 | self._reconnect.call(self); 251 | }); 252 | 253 | this.socket.addEventListener('error', function (e) { 254 | self._setStatus(STATUS_ERROR); 255 | self._sendError.call(self, 'System error', e); 256 | self._reconnect.call(self, 1000); 257 | }); 258 | 259 | this.socket.addEventListener('message', function (e) { 260 | const data = JSON.parse(e.data); 261 | 262 | self._handleMessage.call(self, data); 263 | }); 264 | }; 265 | 266 | $Api.prototype.forceReconnect = function () { 267 | if (this.socket && this.socket.readyState < WebSocket.CLOSING) { 268 | this.socket.close(); 269 | } else { 270 | this._reconnect(); 271 | } 272 | }; 273 | 274 | $Api.prototype._reconnect = function (delayBeforeConnect) { 275 | delayBeforeConnect = delayBeforeConnect || 0; 276 | 277 | this._fire('unready', { status: this.status }); 278 | 279 | if (reconnectTimeout) { 280 | clearTimeout(reconnectTimeout); 281 | } 282 | 283 | reconnectTimeout = setTimeout(this._connect.bind(this), delayBeforeConnect); 284 | }; 285 | 286 | $Api.prototype._fire = function (key, data) { 287 | this._listeners[key].forEach(function (cb) { 288 | setTimeout(function () { 289 | cb(data); 290 | }, 0); 291 | }); 292 | }; 293 | 294 | $Api.prototype._handleMessage = function (data) { 295 | const self = this; 296 | if (data.type === 'auth_required') { 297 | return this._authenticate(); 298 | } 299 | if (data.type === 'auth_invalid') { 300 | return this._authInvalid(data.message); 301 | } 302 | if (data.type === 'auth_ok') { 303 | return this._ready(); 304 | } 305 | 306 | if (data.error) { 307 | return this._sendError(data.error.message, data); 308 | } 309 | 310 | if (data.type === 'result' && data.id) { 311 | if (this._callbacks[data.id]) { 312 | setTimeout(function () { 313 | self._callbacks[data.id](data); 314 | }, 0); 315 | } 316 | } 317 | 318 | if (data.type === 'pong' && data.id) { 319 | if (this._callbacks[data.id]) { 320 | setTimeout(function () { 321 | self._callbacks[data.id](data); 322 | }, 0); 323 | } 324 | } 325 | 326 | this._fire('message', data); 327 | }; 328 | 329 | $Api.prototype._authInvalid = function (message) { 330 | this._setStatus(STATUS_ERROR); 331 | this._sendError(message); 332 | this._refreshToken(); 333 | }; 334 | 335 | $Api.prototype._sendError = function (message, data) { 336 | const msg = { message: message }; 337 | 338 | if (data) { 339 | msg.data = data; 340 | } 341 | 342 | this._fire('error', msg); 343 | }; 344 | 345 | $Api.prototype._authenticate = function () { 346 | const data = { 347 | type: 'auth', 348 | access_token: this._token, 349 | }; 350 | 351 | this.send(data, null, false); 352 | }; 353 | 354 | $Api.prototype._ready = function () { 355 | this._setStatus(STATUS_READY); 356 | this._fire('ready', { status: STATUS_READY }); 357 | }; 358 | 359 | $Api.prototype._setStatus = function (status) { 360 | this.status = status; 361 | }; 362 | 363 | $Api.prototype._tokenRequest = function (data) { 364 | const request = { 365 | method: 'POST', 366 | url: toAbsoluteServerURL('/auth/token'), 367 | headers: { 368 | 'Content-Type': 'application/x-www-form-urlencoded', 369 | }, 370 | data: data + '&client_id=' + getOAuthClientId(), 371 | }; 372 | 373 | return $http(request) 374 | .then(function (response) { 375 | return response.data; 376 | }) 377 | .catch(function (response) { 378 | if (response.status >= 400 && response.status <= 499) { // authentication error 379 | redirectOAuth(); 380 | } else { 381 | return null; 382 | } 383 | }); 384 | }; 385 | 386 | $Api.prototype._refreshToken = function () { 387 | const self = this; 388 | 389 | this._getFreshToken().then(function (token) { 390 | if (token) { 391 | self._token = token.access_token; 392 | 393 | if (token.expires_in) { 394 | setTimeout( 395 | self._refreshToken.bind(self), 396 | token.expires_in * 900); 397 | } 398 | } 399 | }); 400 | }; 401 | 402 | $Api.prototype._getFreshToken = function () { 403 | const token = readToken(); 404 | 405 | const data = 'grant_type=refresh_token&refresh_token=' + token.refresh_token; 406 | 407 | return this._tokenRequest(data).then(function (data) { 408 | if (!data) { 409 | return null; 410 | } 411 | 412 | data.refresh_token = token.refresh_token; 413 | 414 | saveToken(data); 415 | 416 | return data; 417 | }); 418 | }; 419 | 420 | $Api.prototype._getTokenByCode = function (code) { 421 | const data = 'grant_type=authorization_code&code=' + code; 422 | 423 | return this._tokenRequest(data).then(function (data) { 424 | if (!data) { 425 | return null; 426 | } 427 | 428 | saveToken(data); 429 | 430 | return data; 431 | }); 432 | }; 433 | 434 | $Api.prototype._getToken = function () { 435 | if (this._configToken) { 436 | return $q.resolve({ access_token: this._configToken }); 437 | } 438 | 439 | const token = readToken(); 440 | 441 | if (token) { 442 | return this._getFreshToken(); 443 | } 444 | 445 | const params = $location.search(); 446 | 447 | if (params.oauth && params.code) { 448 | const code = params.code; 449 | 450 | // Remove oauth params to clean up the URL. 451 | $location.search('oauth', null).search('code', null); 452 | 453 | return this._getTokenByCode(code); 454 | } 455 | 456 | redirectOAuth(); 457 | return $q.resolve(null); 458 | }; 459 | 460 | function saveToken (token) { 461 | localStorage.setItem(TOKEN_CACHE_KEY, JSON.stringify(token)); 462 | } 463 | 464 | function readToken () { 465 | const token = localStorage.getItem(TOKEN_CACHE_KEY); 466 | 467 | return token ? JSON.parse(token) : null; 468 | } 469 | 470 | function removeToken () { 471 | localStorage.removeItem(TOKEN_CACHE_KEY); 472 | } 473 | 474 | function getOAuthClientId () { 475 | return encodeURIComponent(window.location.origin); 476 | } 477 | 478 | function getOAuthRedirectUrl () { 479 | let url = window.location.origin + window.location.pathname; 480 | 481 | if (window.location.search) { 482 | url += window.location.search + '&oauth=1'; 483 | } else { 484 | url += '?oauth=1'; 485 | } 486 | 487 | return encodeURIComponent(url); 488 | } 489 | 490 | function redirectOAuth () { 491 | removeToken(); 492 | 493 | window.location.href = toAbsoluteServerURL( 494 | '/auth/authorize?client_id=' + getOAuthClientId() 495 | + '&redirect_uri=' + getOAuthRedirectUrl(), 496 | ); 497 | } 498 | 499 | return new $Api(wsUrl, authToken); 500 | }]; 501 | }); 502 | -------------------------------------------------------------------------------- /scripts/models/noty.js: -------------------------------------------------------------------------------- 1 | const Noty = (function () { 2 | let updatesListeners = []; 3 | let updatesFired = false; 4 | 5 | const Noty = function (data) { 6 | this.setData(data); 7 | }; 8 | 9 | Noty.prototype.setData = function (data) { 10 | this.id = data.id || Math.random(); 11 | this.title = data.title; 12 | this.message = data.message; 13 | this.icon = data.icon; 14 | this.lifetime = data.lifetime; 15 | this.type = data.type || Noty.INFO; 16 | 17 | this._timeout = null; 18 | 19 | this.resetTimeout(); 20 | 21 | const self = this; 22 | 23 | setTimeout(function () { 24 | self.showed = true; 25 | 26 | Noty.fireUpdate(); 27 | }, 100); 28 | 29 | Noty.fireUpdate(); 30 | }; 31 | 32 | Noty.prototype.resetTimeout = function () { 33 | const self = this; 34 | 35 | this.clearTimeout(); 36 | 37 | if (this.lifetime) { 38 | this._timeout = setTimeout(function () { 39 | Noty.remove(self); 40 | Noty.fireUpdate(); 41 | }, this.lifetime * 1000); 42 | } 43 | }; 44 | 45 | Noty.prototype.getClasses = function () { 46 | if (!this._classes) { 47 | this._classes = []; 48 | } 49 | 50 | this._classes.length = 0; 51 | 52 | this._classes.push('-' + this.type); 53 | 54 | if (this.showed) { 55 | this._classes.push('-showed'); 56 | } 57 | 58 | return this._classes; 59 | }; 60 | 61 | Noty.prototype.getLifetimeStyles = function () { 62 | if (!this._lifetimeStyles) { 63 | this._lifetimeStyles = {}; 64 | 65 | if (this.lifetime) { 66 | this._lifetimeStyles.animationDuration = this.lifetime + 's'; 67 | } 68 | } 69 | 70 | return this._lifetimeStyles; 71 | }; 72 | 73 | Noty.prototype.clearTimeout = function () { 74 | if (this._timeout) { 75 | clearTimeout(this._timeout); 76 | } 77 | }; 78 | 79 | Noty.prototype.remove = function () { 80 | Noty.remove(this); 81 | }; 82 | 83 | Noty.noties = []; 84 | Noty.notiesHistory = []; 85 | 86 | Noty.INFO = 'info'; 87 | Noty.WARNING = 'warning'; 88 | Noty.ERROR = 'error'; 89 | Noty.SUCCESS = 'success'; 90 | 91 | Noty.onUpdate = function (callback) { 92 | if (updatesListeners.indexOf(callback) !== -1) { 93 | return function () {}; 94 | } 95 | 96 | updatesListeners.push(callback); 97 | 98 | return function () { 99 | updatesListeners = updatesListeners.filter(function (a) { 100 | return a !== callback; 101 | }); 102 | }; 103 | }; 104 | 105 | Noty.fireUpdate = function () { 106 | if (updatesFired) { 107 | return; 108 | } 109 | 110 | updatesFired = true; 111 | 112 | updatesListeners.forEach(function (callback) { 113 | try { 114 | setTimeout(function () { 115 | updatesFired = false; 116 | callback(); 117 | }, 0); 118 | } catch (e) { 119 | // ignore 120 | } 121 | }); 122 | 123 | setTimeout(function () { 124 | updatesFired = false; 125 | }, 0); 126 | }; 127 | 128 | Noty.add = function (type, title, message, icon, lifetime, id) { 129 | return Noty.addObject({ 130 | type: type, 131 | title: title, 132 | message: message, 133 | icon: icon, 134 | lifetime: lifetime, 135 | id: id, 136 | }); 137 | }; 138 | 139 | Noty.addObject = function (data) { 140 | if (data.id && Noty.getById(data.id)) { 141 | const oldNoty = Noty.getById(data.id); 142 | 143 | oldNoty.setData(data); 144 | 145 | return oldNoty; 146 | } 147 | 148 | const noty = new Noty(data); 149 | 150 | Noty.noties.push(noty); 151 | Noty.notiesHistory.push(noty); 152 | 153 | return noty; 154 | }; 155 | 156 | Noty.getById = function (id) { 157 | for (let i = 0; i < Noty.noties.length; i++) { 158 | if (Noty.noties[i].id === id) { 159 | return Noty.noties[i]; 160 | } 161 | } 162 | 163 | return null; 164 | }; 165 | 166 | Noty.hasSeenNoteId = function (id) { 167 | for (let i = 0; i < Noty.notiesHistory.length; i++) { 168 | if (Noty.notiesHistory[i].id === id) { 169 | return true; 170 | } 171 | } 172 | 173 | return false; 174 | }; 175 | 176 | Noty.remove = function (noty) { 177 | Noty.noties = Noty.noties.filter(function (n) { 178 | return n !== noty; 179 | }); 180 | }; 181 | 182 | Noty.removeAll = function () { 183 | Noty.noties = []; 184 | }; 185 | 186 | return Noty; 187 | }()); 188 | 189 | export default Noty; 190 | -------------------------------------------------------------------------------- /styles/all.less: -------------------------------------------------------------------------------- 1 | @import 'main'; 2 | @import 'themes'; 3 | @import 'weather-icons'; 4 | @import (less) 'color-picker.min.css'; 5 | -------------------------------------------------------------------------------- /styles/weather-icons.less: -------------------------------------------------------------------------------- 1 | /* https://github.com/manifestinteractive/weather-underground-icons */ 2 | 3 | @icon-sizes: 16, 32, 64, 128, 256; 4 | @icon-names: chanceflurries, chancerain, chancesleet, chancesnow, chancetstorms, clear, cloudy, flurries, fog, hazy, mostlycloudy, mostlysunny, partlycloudy, partlysunny, rain, sleet, snow, sunny, tstorms, unknown; 5 | @path: '../images/weather-icons/'; 6 | 7 | .wu { 8 | display: inline-block; 9 | background-position: center center; 10 | background-repeat: no-repeat; 11 | padding: 0; 12 | margin: 0; 13 | } 14 | 15 | /*! Setup Default Sizes */ 16 | each(@icon-sizes, { 17 | .wu-@{value} { width: unit(@value, px); height: unit(@value, px); } 18 | }) 19 | 20 | each(@icon-names, { 21 | .wu-@{value} { background-image: url('@{path}white/@{value}.svg') } 22 | .wu-nt-@{value} { background-image: url('@{path}white/nt_@{value}.svg') } 23 | .wu-dark-@{value} { background-image: url('@{path}black/@{value}.svg') } 24 | .wu-dark-nt-@{value} { background-image: url('@{path}black/nt_@{value}.svg') } 25 | }) 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "allowSyntheticDefaultImports": true, 5 | "baseUrl": ".", 6 | "checkJs": true, 7 | "esModuleInterop": true, 8 | "lib": ["dom", "esnext"], 9 | "module": "CommonJS", 10 | "moduleResolution": "node", 11 | "noEmit": true, 12 | "resolveJsonModule": true, 13 | "skipLibCheck": true, 14 | // "strict": true, 15 | "target": "esnext", 16 | }, 17 | } 18 | -------------------------------------------------------------------------------- /types/globals.d.ts: -------------------------------------------------------------------------------- 1 | interface Window { 2 | AUTH_TOKEN_OVERRIDE: string | null 3 | REST_URL_OVERRIDE: string | null 4 | SERVER_URL_OVERRIDE: string | null 5 | WS_URL_OVERRIDE: string | null 6 | } 7 | -------------------------------------------------------------------------------- /types/shim-html.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.html' { 2 | export default ''; 3 | } 4 | --------------------------------------------------------------------------------