├── .eslintignore ├── .eslintrc ├── .github └── workflows │ ├── release.yml │ └── validation.yml ├── .gitignore ├── .prettierignore ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── hacs.json ├── nodemon.json ├── package.json ├── postcss.config.js ├── preact.config.cjs ├── src ├── components │ ├── AceEditor │ │ ├── index.css │ │ └── index.tsx │ ├── ActionConfig │ │ └── index.tsx │ ├── BindingConfig.tsx │ ├── CardEntityConfig │ │ └── index.tsx │ ├── CodeEditor │ │ └── index.tsx │ ├── CodeEditorOptions.tsx │ ├── CodemirrorEditor.tsx │ ├── ConfigCheckbox.tsx │ ├── ConfigInput.tsx │ ├── ConfigToggle.tsx │ ├── ContentEditor.tsx │ ├── DarkModeToggle │ │ └── index.tsx │ ├── EntityCombobox │ │ └── index.tsx │ ├── FloatingInput.tsx │ ├── FloatingTextarea.tsx │ ├── HaCard.tsx │ ├── HaCardConfig.tsx │ ├── HaCardConfigWrapper │ │ └── index.tsx │ ├── InputCodeEditor │ │ └── index.tsx │ ├── TextareaCodeEditor │ │ └── index.tsx │ ├── TextareaEditor.tsx │ ├── TweakPluginInput.tsx │ ├── TweakPluginToggle.tsx │ ├── TweakRangeInput.tsx │ ├── TweakToggle.tsx │ └── WithDaisyUitheme │ │ └── index.tsx ├── elements │ ├── TailwindTemplateCard.tsx │ ├── TailwindTemplateCardConfig.tsx │ └── TailwindTemplateRenderer.tsx ├── index.css ├── main.ts ├── pages │ ├── SettingsAbout.tsx │ ├── SettingsActions.tsx │ ├── SettingsBindings.tsx │ ├── SettingsCardContent.tsx │ ├── SettingsPlugins.tsx │ └── SettingsTweaks.tsx ├── store │ ├── ConfigContext.ts │ ├── ConfigProvider.tsx │ ├── ConfigReducer.ts │ └── useConfigMemo.ts ├── types │ └── index.ts ├── utils │ ├── DebounceHandler.tsx │ └── events.ts └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.node.json ├── twind.config.js ├── vite.config.ts └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": ["@typescript-eslint"], 5 | "extends": [ 6 | "eslint:recommended", 7 | "plugin:@typescript-eslint/eslint-recommended", 8 | "plugin:@typescript-eslint/recommended" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release-on-tag 2 | run-name: Build and release on tag 3 | permissions: 4 | contents: write 5 | on: 6 | push: 7 | tags: 8 | - v** 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | with: 15 | fetch-depth: 0 16 | - uses: actions/setup-node@v3 17 | with: 18 | node-version: 18 19 | - name: Build card 20 | run: yarn && yarn build 21 | - name: Check if tag is a prerelease 22 | id: check-tag 23 | run: | 24 | if [[ ${{ github.event.ref }} =~ ^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+\-.+$ ]]; then 25 | echo "prerelease=true" >> $GITHUB_OUTPUT 26 | fi 27 | 28 | - name: Get latest non-prerelease 29 | uses: cardinalby/git-get-release-action@v1 30 | id: get-latest-non-prerelease 31 | env: 32 | GITHUB_TOKEN: ${{ github.token }} 33 | with: 34 | latest: 1 35 | draft: false 36 | prerelease: false 37 | - name: Get latest release 38 | uses: cardinalby/git-get-release-action@v1 39 | id: get-latest-release 40 | env: 41 | GITHUB_TOKEN: ${{ github.token }} 42 | with: 43 | latest: 1 44 | draft: false 45 | 46 | - name: Generate CHANGELOG for release 47 | if: steps.check-tag.outputs.prerelease != true 48 | run: npx -y git-changelog-command-line -fr refs/tags/${{ steps.get-latest-non-prerelease.outputs.tag_name }} -of CHANGELOG-release.md 49 | 50 | - name: Generate CHANGELOG for prerelease 51 | if: steps.check-tag.outputs.prerelease 52 | run: npx -y git-changelog-command-line -fr refs/tags/${{ steps.get-latest-release.outputs.tag_name }} -of CHANGELOG-release.md 53 | 54 | - name: Release 55 | uses: softprops/action-gh-release@v1 56 | with: 57 | body_path: CHANGELOG-release.md 58 | append_body: true 59 | generate_release_notes: true 60 | prerelease: "${{ steps.check-tag.outputs.prerelease }}" 61 | files: | 62 | dist/* 63 | -------------------------------------------------------------------------------- /.github/workflows/validation.yml: -------------------------------------------------------------------------------- 1 | name: Validate plugin 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '0 0 * * *' 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v2" 14 | - name: HACS Action 15 | uses: "hacs/action@main" 16 | with: 17 | category: "plugin" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist-ssr 12 | *.local 13 | 14 | # Editor directories and files 15 | .vscode/* 16 | !.vscode/extensions.json 17 | .idea 18 | .DS_Store 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | 25 | dist/ -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build/ 3 | dist/ -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # tailwindcss-template-card changelog 2 | 3 | Changelog of tailwindcss-template-card. 4 | 5 | ## v2.1.0-1 (2023-06-26) 6 | 7 | ### Features 8 | 9 | - add range to set debounceChangePeriod ([60472](https://github.com/usernein/tailwindcss-template-card/commit/60472ee31bb8672) Pauxis) 10 | - change default theme to dark ([5709a](https://github.com/usernein/tailwindcss-template-card/commit/5709a0c8870d4c1) Pauxis) 11 | 12 | ### Other changes 13 | 14 | **v2.1.0-1** 15 | 16 | 17 | [b81e8](https://github.com/usernein/tailwindcss-template-card/commit/b81e8f9ef6f4979) Pauxis *2023-06-26 05:12:44* 18 | 19 | 20 | ## v2.1.0-0 (2023-06-26) 21 | 22 | ### Other changes 23 | 24 | **v2.1.0-0** 25 | 26 | 27 | [1eaa9](https://github.com/usernein/tailwindcss-template-card/commit/1eaa9a51a537884) Pauxis *2023-06-26 04:22:42* 28 | 29 | 30 | ## v2.0.4-0 (2023-06-26) 31 | 32 | ### Features 33 | 34 | - add memoizer util and debounce everything ([18ccb](https://github.com/usernein/tailwindcss-template-card/commit/18ccb7225924249) Pauxis) 35 | - add debounce utils ([6b8b6](https://github.com/usernein/tailwindcss-template-card/commit/6b8b6b238fc4d22) Pauxis) 36 | - make bindings inputs functional ([98746](https://github.com/usernein/tailwindcss-template-card/commit/98746a424f1308c) Pauxis) 37 | - customize scrollbar ([c8a21](https://github.com/usernein/tailwindcss-template-card/commit/c8a21a8bd0c0c37) Pauxis) 38 | - use real tailwindcss ([31527](https://github.com/usernein/tailwindcss-template-card/commit/31527428f190855) Pauxis) 39 | - add alert label and improve bindings latout ([92296](https://github.com/usernein/tailwindcss-template-card/commit/92296767654ace7) Pauxis) 40 | - fix base colors and imrpove bindings layout ([4679a](https://github.com/usernein/tailwindcss-template-card/commit/4679a4170c5cb2a) Pauxis) 41 | - make bindings functional ([1ba9b](https://github.com/usernein/tailwindcss-template-card/commit/1ba9bc2bbcb25a2) Pauxis) 42 | - inherit theme from hass ([00e9b](https://github.com/usernein/tailwindcss-template-card/commit/00e9bd105bcff44) Pauxis) 43 | - improve config and hide about from tabs ([6ef39](https://github.com/usernein/tailwindcss-template-card/commit/6ef39adfdd08231) Pauxis) 44 | - add padding to textarea ([32e43](https://github.com/usernein/tailwindcss-template-card/commit/32e43853abca9fd) Pauxis) 45 | - make the test for dev features case insensitive ([f6945](https://github.com/usernein/tailwindcss-template-card/commit/f69457be0bf5f3e) Pauxis) 46 | - add code mirror as hidden option ([aa5c0](https://github.com/usernein/tailwindcss-template-card/commit/aa5c0d7f3bd1435) Pauxis) 47 | - adding tabs ([caa00](https://github.com/usernein/tailwindcss-template-card/commit/caa0076512b81b0) Pauxis) 48 | 49 | ### Bug Fixes 50 | 51 | - organize types into single file ([6a8d5](https://github.com/usernein/tailwindcss-template-card/commit/6a8d5ddbb5441f6) Pauxis) 52 | - remove console.log ([54fc8](https://github.com/usernein/tailwindcss-template-card/commit/54fc88f7ab1dc0f) Pauxis) 53 | - fix color and alignment ([40e38](https://github.com/usernein/tailwindcss-template-card/commit/40e383ae194f2f7) Pauxis) 54 | - **mobile** Stop using workers ([afbd7](https://github.com/usernein/tailwindcss-template-card/commit/afbd73e6eede84b) Pauxis) 55 | 56 | ### Other changes 57 | 58 | **v2.0.4-0** 59 | 60 | 61 | [4d46a](https://github.com/usernein/tailwindcss-template-card/commit/4d46a0ec63d9972) Pauxis *2023-06-26 04:22:20* 62 | 63 | **Add debounce to inputs and improve live testing with vite** 64 | 65 | 66 | [2e696](https://github.com/usernein/tailwindcss-template-card/commit/2e6960735e40d88) Pauxis *2023-06-23 14:16:36* 67 | 68 | 69 | ## v2.0.3 (2023-06-21) 70 | 71 | ### Other changes 72 | 73 | **v2.0.3** 74 | 75 | 76 | [b553c](https://github.com/usernein/tailwindcss-template-card/commit/b553cc3ab339950) Pauxis *2023-06-21 11:03:57* 77 | 78 | 79 | ## v2.0.2 (2023-06-21) 80 | 81 | ### Bug Fixes 82 | 83 | - Fix import AceEditor ([e97d2](https://github.com/usernein/tailwindcss-template-card/commit/e97d28bd0cc40a9) Pauxis) 84 | 85 | ### Other changes 86 | 87 | **2.0.2** 88 | 89 | 90 | [a20c9](https://github.com/usernein/tailwindcss-template-card/commit/a20c98bb815ce77) Pauxis *2023-06-21 06:01:22* 91 | 92 | 93 | ## v2.0.1 (2023-06-21) 94 | 95 | ### Other changes 96 | 97 | **v2.0.1** 98 | 99 | 100 | [fc1be](https://github.com/usernein/tailwindcss-template-card/commit/fc1be1c8b409f5a) Pauxis *2023-06-21 05:54:52* 101 | 102 | 103 | ## v2.0.1-1 (2023-06-21) 104 | 105 | ### Other changes 106 | 107 | **v2.0.1-1** 108 | 109 | 110 | [4550f](https://github.com/usernein/tailwindcss-template-card/commit/4550f0d56410c04) Pauxis *2023-06-21 05:51:37* 111 | 112 | 113 | ## v2.0.1-0 (2023-06-21) 114 | 115 | ### Features 116 | 117 | - **typing** Turn template renderer into an abstract class ([d29fa](https://github.com/usernein/tailwindcss-template-card/commit/d29fa1d15524e50) Pauxis) 118 | - **typing** Reduce usage of Partial ([ddc37](https://github.com/usernein/tailwindcss-template-card/commit/ddc371224c303c1) Pauxis) 119 | - **config** Use Ace Editor and fulfill config with defaults ([d9b65](https://github.com/usernein/tailwindcss-template-card/commit/d9b65e4faeb1a6d) Pauxis) 120 | - **rendering** Make the hass setter also inject the styles ([f2c1b](https://github.com/usernein/tailwindcss-template-card/commit/f2c1b1132e2e6d9) Pauxis) 121 | - **rendering** Add out-of-box support to latest DaisyUI ([e4082](https://github.com/usernein/tailwindcss-template-card/commit/e40824269be04c5) Pauxis) 122 | - **rendering** Make hass a global variable ([ff79b](https://github.com/usernein/tailwindcss-template-card/commit/ff79bc280bde00b) Pauxis) 123 | 124 | ### Bug Fixes 125 | 126 | - Fix AceEditor issue with JSX ([c0f7c](https://github.com/usernein/tailwindcss-template-card/commit/c0f7c92fb8f3c7c) Pauxis) 127 | - **rendering** Fix conditions to inject stylesheets ([0c9cd](https://github.com/usernein/tailwindcss-template-card/commit/0c9cd3838c02685) Pauxis) 128 | - **rendering** Force card to update when the config is changed ([85d0c](https://github.com/usernein/tailwindcss-template-card/commit/85d0c1146a9a68d) Pauxis) 129 | - **rendering** Fix condition when the styles should be reinjected ([c613c](https://github.com/usernein/tailwindcss-template-card/commit/c613c9dbb777ca9) Pauxis) 130 | - **rendering** Use "auto" as default DaisyUI theme when undefined ([9e36a](https://github.com/usernein/tailwindcss-template-card/commit/9e36a4aba29c7c8) Pauxis) 131 | 132 | ### Other changes 133 | 134 | **v2.0.1-0** 135 | 136 | 137 | [47aca](https://github.com/usernein/tailwindcss-template-card/commit/47aca37aa0b42ce) Pauxis *2023-06-21 05:48:40* 138 | 139 | **v2.0.0** 140 | 141 | 142 | [c8915](https://github.com/usernein/tailwindcss-template-card/commit/c8915afebd1e985) Pauxis *2023-06-21 05:31:52* 143 | 144 | **v1.5.1-0** 145 | 146 | 147 | [8c6b3](https://github.com/usernein/tailwindcss-template-card/commit/8c6b3e8fa8b1bbe) Pauxis *2023-06-21 02:34:24* 148 | 149 | **Load ace editor's html worker inline** 150 | 151 | 152 | [b57b6](https://github.com/usernein/tailwindcss-template-card/commit/b57b69638e23541) Pauxis *2023-06-21 02:33:35* 153 | 154 | **v1.5.0** 155 | 156 | 157 | [f5c44](https://github.com/usernein/tailwindcss-template-card/commit/f5c4421fbdfb509) Pauxis *2023-06-21 02:33:03* 158 | 159 | **v1.4.1** 160 | 161 | 162 | [d6680](https://github.com/usernein/tailwindcss-template-card/commit/d668051ee5e49bb) Pauxis *2023-06-21 02:32:42* 163 | 164 | **v1.4.1-3** 165 | 166 | 167 | [b04af](https://github.com/usernein/tailwindcss-template-card/commit/b04afd6b8d47edb) Pauxis *2023-06-21 02:31:54* 168 | 169 | **v1.4.1-2** 170 | 171 | 172 | [ef146](https://github.com/usernein/tailwindcss-template-card/commit/ef1466581581690) Pauxis *2023-06-21 02:31:09* 173 | 174 | **v1.4.0** 175 | 176 | 177 | [b3542](https://github.com/usernein/tailwindcss-template-card/commit/b35420177cca9d0) Pauxis *2023-06-21 02:29:34* 178 | 179 | **v1.3.0** 180 | 181 | 182 | [99278](https://github.com/usernein/tailwindcss-template-card/commit/992786e781a6312) Pauxis *2023-06-21 02:29:34* 183 | 184 | **v1.4.1-1** 185 | 186 | 187 | [8e380](https://github.com/usernein/tailwindcss-template-card/commit/8e3801c38b97dcb) Pauxis *2023-06-21 02:29:34* 188 | 189 | **v1.2.0** 190 | 191 | 192 | [48e2a](https://github.com/usernein/tailwindcss-template-card/commit/48e2adbb93dad8d) Pauxis *2023-06-21 02:27:42* 193 | 194 | **v1.1.0** 195 | 196 | 197 | [b7953](https://github.com/usernein/tailwindcss-template-card/commit/b795364750ff7b5) Pauxis *2023-06-21 02:19:48* 198 | 199 | **v1.0.1** 200 | 201 | 202 | [86086](https://github.com/usernein/tailwindcss-template-card/commit/86086a13bf80826) Pauxis *2023-05-28 01:48:51* 203 | 204 | **v1.0.0** 205 | 206 | 207 | [62096](https://github.com/usernein/tailwindcss-template-card/commit/62096af3b59c349) Pauxis *2023-05-28 01:45:12* 208 | 209 | 210 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Cezar Pauxis 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 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TailwindCSS Template Card for Home Assistant 2 | 3 | ![GitHub](https://img.shields.io/github/license/usernein/tailwindcss-template-card) 4 | ![GitHub stars](https://img.shields.io/github/stars/usernein/tailwindcss-template-card) 5 | ![GitHub issues](https://img.shields.io/github/issues/usernein/tailwindcss-template-card) 6 | ![GitHub pull requests](https://img.shields.io/github/issues-pr/usernein/tailwindcss-template-card) 7 | 8 | The TailwindCSS Template Card is a custom card for Home Assistant that allows you to write HTML code using TailwindCSS classes and render it beautifully in the Home Assistant dashboard. This card provides flexibility in designing custom interfaces within Home Assistant, allowing you to create visually appealing and interactive elements. 9 | 10 | ![card_config](https://github.com/usernein/tailwindcss-template-card/assets/29507335/1982f343-62d9-4b5e-9b71-16889d4058b4) 11 | 12 | 13 | ## Features 14 | 15 | - **HTML Rendering**: Write HTML code directly in your Home Assistant configuration, utilizing the power of HTML and TailwindCSS. 16 | - **TailwindCSS Classes**: Use TailwindCSS classes to style your HTML elements, giving you access to a wide range of pre-built styles and responsive design options. 17 | - **Responsive Design**: Leverage the responsiveness of TailwindCSS to create layouts that adapt to different screen sizes and devices. 18 | - **Interactive Elements**: Create interactive elements with CSS within your HTML code, enhancing the user experience of your Home Assistant dashboard. 19 | - **Real-time Preview**: Write your HTML code with TailwindCSS classes and instantly see it rendered with the power of [Twind.style](https://twind.style) 20 | 21 | ## Demo 22 | 23 | https://github.com/usernein/tailwindcss-template-card/assets/29507335/752667e7-df4a-4c73-a151-e03c7fc67ba7 24 | 25 | https://github.com/usernein/tailwindcss-template-card/assets/29507335/c0c1e086-f76e-4908-beed-79e747bcc15c 26 | 27 | ## Installation 28 | 29 | ### With HACS 30 | 31 | Set `https://github.com/usernein/tailwindcss-template-card` as a custom repository in HACS. Now you should receive the TailwindCSS Template Card as new plugin to install. 32 | 33 | ### Manually 34 | 35 | 1. Copy the `tailwindcss-template-card` directory to your Home Assistant's `config/www` directory. 36 | 2. Add the following to your Home Assistant's `configuration.yaml` file: 37 | 38 | ```yaml 39 | lovelace: 40 | resources: 41 | - url: /local/tailwindcss-template-card/dist/tailwindcss-template-card.js 42 | type: module 43 | ``` 44 | 45 | 3. Restart Home Assistant to load the custom card. 46 | 47 | ## Usage 48 | 49 | To use the TailwindCSS Template Card, follow these steps: 50 | 51 | 1. Open your Home Assistant dashboard. 52 | 2. Edit the Lovelace configuration by clicking on the three dots in the upper right corner and selecting "Configure UI" or by manually editing the `ui-lovelace.yaml` file. 53 | 3. Add a new card and select the "TailwindCSS Template Card" from the available card types. 54 | 4. In the card configuration, you can define your HTML code using TailwindCSS classes to style the elements. 55 | 5. Save the configuration and enjoy your custom-designed card in your Home Assistant dashboard. 56 | 57 | ## Examples 58 | 59 | Here are some examples of how you can use the TailwindCSS Template Card to create beautiful cards in your Home Assistant dashboard: 60 | 61 | ## Example 1 62 | 63 | ![card_lite](https://github.com/usernein/tailwindcss-template-card/assets/29507335/a12fc60c-a339-4ce3-b993-5f939f7a1a3d) 64 | 65 | ```yaml 66 | type: custom:tailwindcss-template-card 67 | content: > 68 |
70 |
71 |
Title
72 |
73 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam in quam semper, vestibulum velit ut, faucibus est. Suspendisse commodo, tortor et varius pretium, est tortor mollis mauris, in fringilla felis arcu quis lacus. Aenean placerat risus sed nulla egestas, quis pellentesque tortor ultrices. Cras vel sem eu libero commodo tempus. Pellentesque mi erat, mattis id lectus nec, ullamcorper porta sapien. 74 |
75 |
76 | Click me 77 |
78 |
79 |
80 | ``` 81 | 82 | ## Example 2 83 | 84 | ![smile](https://github.com/usernein/tailwindcss-template-card/assets/29507335/324dd30a-81f1-4be1-b8e8-6447aeba4b3c) 85 | 86 | ```yaml 87 | type: custom:tailwindcss-template-card 88 | ignore_line_breaks: true 89 | content: | 90 |
91 | :) 92 |
93 | ``` 94 | 95 | ## Example 3 96 | 97 | ![colors](https://github.com/usernein/tailwindcss-template-card/assets/29507335/03ee18c1-8a0d-4ec4-bdcf-915c46fc3086) 98 | 99 | ```yaml 100 | type: custom:tailwindcss-template-card 101 | content: | 102 |
103 | {% for color in ["slate", "red", "purple", "cyan", "blue", "green", "yellow"] %} 104 |
105 | {% endfor %} 106 |
107 | ``` 108 | 109 | ## Example 4 110 | 111 | ![grid_lite](https://github.com/usernein/tailwindcss-template-card/assets/29507335/71931bf2-1d1f-491c-bf18-32feb6e8ccfa) 112 | 113 | ```yaml 114 | type: custom:tailwindcss-template-card 115 | content: | 116 |
117 | {% for n in range(1,10) %} 118 |
119 | {% for color in ["zinc", "slate", "red", "orange", "purple", "cyan", "blue", "green", "yellow"] %} 120 |
121 |
122 |
123 | {% endfor %} 124 |
125 | {% endfor %} 126 |
127 | ``` 128 | 129 | ## Example 5 130 | 131 | ![sun](https://github.com/usernein/tailwindcss-template-card/assets/29507335/068a7039-6aee-4a98-bbbc-4a047a5257ee) 132 | 133 | ```yaml 134 | type: custom:tailwindcss-template-card 135 | content: | 136 |
137 |
138 |
139 | The sun is {{ states('sun.sun') }} 140 |
Rising: {{ 'yes' if state_attr('sun.sun', 'rising') else 'no'}}
141 |
142 |
143 | ``` 144 | 145 | ## Acknowledgements 146 | 147 | I would like to extend my sincere gratitude to the following projects, libraries, and individuals for their contributions and inspiration: 148 | 149 | - [TailwindCSS](https://tailwindcss.com/) and [Home Assistant](https://home-assistant.io): obvious reasons <3 150 | - [threedy-card](https://github.com/dangreco/threedy): the card that helped me to understand the way of working with Preact in custom cards 151 | - [Home-Assistant-Lovelace-HTML-Jinja2-Template-card](https://github.com/PiotrMachowski/Home-Assistant-Lovelace-HTML-Jinja2-Template-card): the amazing card that teached me the way of rendering Jinja templates with Home Assistant 152 | - [Twind.style](https://twind.style): the reason why this card is possible 153 | - [Gourav Goyal](https://www.linkedin.com/in/gorvgoyl/): who wrote [this amazing article](https://gourav.io/blog/tailwind-in-shadow-dom) that presented me to Twind 154 | 155 | ## Contributing 156 | 157 | Contributions to the TailwindCSS Template Card for Home Assistant are welcome! If you have any ideas, suggestions, or bug reports, please open an issue on the [GitHub repository](https://github.com/usernein/tailwindcss-template-card/issues). Pull requests are also encouraged. 158 | 159 | Before making significant changes, please discuss them with the repository maintainers to ensure they align with the project's goals and direction. 160 | 161 | ## License 162 | 163 | This project is licensed under the [MIT License](LICENSE.txt). 164 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "TailwindCSS Template Card", 3 | "render_readme": true 4 | } -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "verbose": true, 3 | "ext": "ts,js,tsx,jsx,html,css", 4 | "ignore": ["**/node_modules/**", "**/dist/**"], 5 | "exec": "npm run build && npx http-server --cors='*' -c-1 ./dist" 6 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tailwindcss-template-card", 3 | "private": true, 4 | "version": "3.1.1", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "prebuild": "npm run lint", 9 | "build": "tsc && vite build", 10 | "preview": "vite preview", 11 | "changelog": "npx git-changelog-command-line -of CHANGELOG.md", 12 | "lint": "eslint . --ext .{ts,tsx,js,jsx}" 13 | }, 14 | "dependencies": { 15 | "@headlessui/react": "^1.7.16", 16 | "@twind/core": "^1.1.3", 17 | "@twind/preset-autoprefix": "^1.0.7", 18 | "@twind/preset-tailwind": "^1.1.4", 19 | "ace-builds": "^1.22.1", 20 | "axios": "^1.4.0", 21 | "clsx": "^1.2.1", 22 | "construct-style-sheets-polyfill": "^3.1.0", 23 | "custom-card-helpers": "^1.9.0", 24 | "daisyui": "^3.1.1", 25 | "html-react-parser": "^4.2.0", 26 | "lodash": "^4.17.21", 27 | "preact": "^10.13.1", 28 | "react-ace": "^10.1.0" 29 | }, 30 | "devDependencies": { 31 | "@preact/preset-vite": "^2.5.0", 32 | "@types/lodash": "^4.14.200", 33 | "@types/node": "^20.3.2", 34 | "@types/react": "17.0.30", 35 | "@typescript-eslint/eslint-plugin": "^6.2.0", 36 | "@typescript-eslint/parser": "^6.2.0", 37 | "autoprefixer": "^10.4.14", 38 | "eslint": "^8.46.0", 39 | "git-changelog-command-line": "^1.102.0", 40 | "nodemon": "^2.0.22", 41 | "postcss": "^8.4.23", 42 | "preact-cli-postcss": "^1.1.1", 43 | "react-icons": "^4.10.1", 44 | "tailwind-scrollbar": "^3.0.4", 45 | "tailwindcss": "^3.3.2", 46 | "ts-node": "^10.9.1", 47 | "typescript": "^5.0.2", 48 | "vite": "^4.3.2", 49 | "vite-plugin-checker": "^0.6.1", 50 | "vite-plugin-css-injected-by-js": "^3.1.1", 51 | "vite-plugin-eslint": "^1.8.1", 52 | "vite-tsconfig-paths": "^4.2.0" 53 | }, 54 | "resolutions": { 55 | "@types/react": "17.0.30" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /preact.config.cjs: -------------------------------------------------------------------------------- 1 | const preactCliPostCSS = require('preact-cli-postcss') 2 | 3 | export default function (config, env, helpers) { 4 | preactCliPostCSS(config, helpers) 5 | } 6 | -------------------------------------------------------------------------------- /src/components/AceEditor/index.css: -------------------------------------------------------------------------------- 1 | .ace_scrollbar { 2 | @apply scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-base-200 3 | } 4 | 5 | .ace_editor { 6 | @apply rounded-[5px] 7 | } -------------------------------------------------------------------------------- /src/components/AceEditor/index.tsx: -------------------------------------------------------------------------------- 1 | import 'ace-builds/src-noconflict/ace' 2 | import 'ace-builds/src-noconflict/mode-html' 3 | import 'ace-builds/src-noconflict/mode-javascript' 4 | import 'ace-builds/src-noconflict/mode-css' 5 | import 'ace-builds/src-noconflict/theme-github_dark' 6 | import 'ace-builds/src-noconflict/snippets/html' 7 | import 'ace-builds/src-noconflict/ext-language_tools' 8 | 9 | import Ace, { IAceOptions } from 'react-ace' 10 | 11 | import './index.css' 12 | 13 | export const AceEditor = ({ 14 | defaultValue, 15 | onChange, 16 | additionalOptions, 17 | mode = 'html' 18 | }: { 19 | defaultValue: string 20 | onChange: (defaultValue: string) => void 21 | additionalOptions?: IAceOptions 22 | mode?: string 23 | }) => { 24 | return ( 25 | 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /src/components/ActionConfig/index.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | import { Action } from '@types' 3 | import { InputCodeEditor } from '@components/InputCodeEditor' 4 | import { BiSolidTrash } from 'react-icons/bi' 5 | 6 | export function ActionConfig ({ 7 | action, 8 | isMinimized = false, 9 | maximize, 10 | onChange, 11 | onDelete 12 | }: { 13 | action: Action 14 | isMinimized?: boolean 15 | maximize?: () => void 16 | onChange: (value: Action) => void 17 | onDelete: () => void 18 | }) { 19 | const openHandler: EventListener = e => { 20 | e.preventDefault() 21 | e.stopImmediatePropagation() 22 | 23 | if (maximize) maximize() 24 | } 25 | 26 | return ( 27 |
40 |
{ 43 | e.stopImmediatePropagation() 44 | onDelete() 45 | }} 46 | > 47 | 48 |
49 | 50 |
56 |
57 | onChange({ ...action, selector: value })} 61 | mode='css' 62 | /> 63 |
64 | 79 | 85 |
86 |
87 |
93 | onChange({ ...action, call: value })} 97 | mode='javascript' 98 | emulateTextarea={true} 99 | /> 100 |
101 |
102 |
103 | ) 104 | } 105 | -------------------------------------------------------------------------------- /src/components/BindingConfig.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | import { Binding } from '@types' 3 | import { InputCodeEditor } from './InputCodeEditor' 4 | import { BiSolidTrash } from 'react-icons/bi' 5 | 6 | export function BindingConfig ({ 7 | binding, 8 | isMinimized = false, 9 | maximize, 10 | onChange, 11 | onDelete 12 | }: { 13 | binding: Binding 14 | isMinimized?: boolean 15 | maximize?: () => void 16 | onChange: (value: Binding) => void 17 | onDelete: () => void 18 | }) { 19 | const openHandler: EventListener = e => { 20 | e.preventDefault() 21 | e.stopImmediatePropagation() 22 | 23 | if (maximize) maximize() 24 | } 25 | 26 | return ( 27 |
40 |
{ 43 | e.stopImmediatePropagation() 44 | onDelete() 45 | }} 46 | > 47 | 48 |
49 | 50 |
56 |
57 | onChange({ ...binding, selector: value })} 61 | mode='css' 62 | /> 63 |
64 | 80 | 86 |
87 |
88 |
94 | onChange({ ...binding, bind: value })} 98 | mode='javascript' 99 | emulateTextarea={true} 100 | /> 101 |
102 |
103 |
104 | ) 105 | } 106 | -------------------------------------------------------------------------------- /src/components/CardEntityConfig/index.tsx: -------------------------------------------------------------------------------- 1 | import { EntityCombobox } from '@components/EntityCombobox' 2 | import { ConfigContext } from '@store/ConfigContext' 3 | import { useContext, useMemo } from 'preact/compat' 4 | 5 | export function CardEntityConfig () { 6 | const { config, updateConfig } = useContext(ConfigContext) 7 | const entity_id = useMemo(() => config.entity, [config.entity]) 8 | 9 | return ( 10 |
11 | Entity 12 | { 15 | updateConfig({ entity: v }) 16 | }} 17 | hass={window.hass} 18 | /> 19 |
20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/components/CodeEditor/index.tsx: -------------------------------------------------------------------------------- 1 | import { CodeEditorOptionsEnum } from '@types' 2 | import clsx from 'clsx' 3 | import { IAceOptions } from 'react-ace' 4 | import { useContext } from 'preact/compat' 5 | import { ConfigContext } from '@store/ConfigContext' 6 | import { AceEditor } from '@components/AceEditor' 7 | import { TextareaEditor } from '@components/TextareaEditor' 8 | import { CodemirrorEditor } from '@components/CodemirrorEditor' 9 | 10 | export function CodeEditor ({ 11 | defaultValue, 12 | onChange, 13 | additionalOptions, 14 | className, 15 | mode = 'html' 16 | }: { 17 | defaultValue: string 18 | onChange: (defaultValue: string) => void 19 | additionalOptions?: IAceOptions 20 | className?: string 21 | mode?: string 22 | }) { 23 | const { config } = useContext(ConfigContext) 24 | const { code_editor: codeEditor } = config 25 | 26 | return ( 27 |
28 | {codeEditor == CodeEditorOptionsEnum.ACE && ( 29 | 35 | )} 36 | 37 | {codeEditor == CodeEditorOptionsEnum.TEXTAREA && ( 38 | 39 | )} 40 | 41 | {codeEditor == CodeEditorOptionsEnum.CODEMIRROR_DEV && ( 42 | 43 | )} 44 |
45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /src/components/CodeEditorOptions.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | import { useContext, useMemo } from 'preact/compat' 3 | import { ConfigContext } from '@store/ConfigContext' 4 | import { CodeEditorOptionsEnum } from '@types' 5 | 6 | const CodeOption = ({ 7 | devFeature, 8 | hidden, 9 | checked, 10 | name, 11 | onChange 12 | }: { 13 | devFeature: boolean 14 | hidden: boolean 15 | checked: boolean 16 | name: string 17 | onChange: (checked: boolean) => void 18 | }) => { 19 | return ( 20 | 32 | ) 33 | } 34 | 35 | export function CodeEditorOptions ({ 36 | inHiddenMode 37 | }: { 38 | inHiddenMode?: boolean 39 | }) { 40 | const { config, updateConfig } = useContext(ConfigContext) 41 | const code_editor = useMemo(() => config.code_editor, [config.code_editor]) 42 | 43 | return ( 44 |
45 |
Code editor
46 |
47 | {Object.values(CodeEditorOptionsEnum).map(option => { 48 | const isDevFeature = /_dev/i.test(option) 49 | 50 | return ( 51 |
62 |
63 | ) 64 | } 65 | -------------------------------------------------------------------------------- /src/components/CodemirrorEditor.tsx: -------------------------------------------------------------------------------- 1 | export function CodemirrorEditor ({ 2 | defaultValue, 3 | onChange 4 | }: { 5 | defaultValue: string 6 | onChange: (defaultValue: string) => void 7 | }) { 8 | const haCodeEditor = document.createElement( 9 | 'ha-code-editor' 10 | ) as HTMLElement & { defaultValue: string } 11 | haCodeEditor.defaultValue = defaultValue 12 | haCodeEditor.addEventListener('value-changed', e => { 13 | const value = (e.target as HTMLInputElement).value 14 | onChange(value) 15 | }) 16 | 17 | return ( 18 |
{ 20 | if (ref) { 21 | ref.innerHTML = '' 22 | ref.appendChild(haCodeEditor) 23 | } 24 | }} 25 | >
26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /src/components/ConfigCheckbox.tsx: -------------------------------------------------------------------------------- 1 | import { clsx } from 'clsx' 2 | import { PropsWithChildren } from 'preact/compat' 3 | export function ConfigCheckbox ({ 4 | checked, 5 | disabled = false, 6 | onChange, 7 | children 8 | }: PropsWithChildren & { 9 | checked: boolean 10 | disabled?: boolean 11 | onChange: (checked: boolean) => void 12 | }) { 13 | return ( 14 |
21 | 31 |
32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /src/components/ConfigInput.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'preact/compat' 2 | 3 | export function ConfigInput ({ 4 | disabled = false, 5 | value, 6 | placeholder, 7 | onChange, 8 | children, 9 | debounceChangePeriod = 500 10 | }: PropsWithChildren & { 11 | disabled?: boolean 12 | value: string 13 | placeholder: string 14 | onChange: (value: string) => void 15 | debounceChangePeriod?: number 16 | }) { 17 | let timeoutPointer: NodeJS.Timeout 18 | return ( 19 |
20 | 43 |
44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /src/components/ConfigToggle.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from "preact/compat" 2 | 3 | export function ConfigToggle ({ 4 | children, 5 | checked, 6 | onChange 7 | }: PropsWithChildren & { 8 | checked: boolean 9 | onChange: (checked: boolean) => void 10 | }) { 11 | return ( 12 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /src/components/ContentEditor.tsx: -------------------------------------------------------------------------------- 1 | import { useDebouncer } from '@utils/DebounceHandler' 2 | 3 | import { IAceOptions } from 'react-ace' 4 | import { useContext } from 'preact/compat' 5 | import { ConfigContext } from '@store/ConfigContext' 6 | import { CodeEditor } from './CodeEditor' 7 | 8 | export function ContentEditor ({ 9 | additionalOptions, 10 | className, 11 | mode = 'html' 12 | }: { 13 | additionalOptions?: IAceOptions 14 | className?: string 15 | mode?: string 16 | }) { 17 | const { config } = useContext(ConfigContext) 18 | const { debounceChangePeriod, content } = config 19 | 20 | const updateConfig = useContext(ConfigContext)['updateConfig'] 21 | 22 | const debounce = useDebouncer(debounceChangePeriod) 23 | 24 | const debounceAndChange = (v: string) => { 25 | debounce(() => { 26 | updateConfig({ content: v }) 27 | }) 28 | } 29 | 30 | return ( 31 | 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /src/components/DarkModeToggle/index.tsx: -------------------------------------------------------------------------------- 1 | import { ConfigContext } from '@store/ConfigContext' 2 | import { useContext } from 'preact/compat' 3 | import { FiMoon, FiSun } from 'react-icons/fi' 4 | 5 | export function DarkModeToggle () { 6 | const { config, updateConfig } = useContext(ConfigContext) 7 | 8 | const setTheme = (scheme: 'dark' | 'light') => { 9 | const themeName = scheme === 'dark' ? 'dark - dark' : 'light - light' 10 | updateConfig({ 11 | plugins: { 12 | ...config.plugins, 13 | daisyui: { ...config.plugins.daisyui, theme: themeName } 14 | } 15 | }) 16 | } 17 | return ( 18 |
19 |
setTheme('light')}> 20 | 21 |
22 |
setTheme('dark')}> 23 | 24 |
25 |
26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /src/components/EntityCombobox/index.tsx: -------------------------------------------------------------------------------- 1 | import { Combobox } from '@headlessui/react' 2 | import { HomeAssistant } from 'custom-card-helpers' 3 | import { useState } from 'preact/hooks' 4 | 5 | import { BsChevronBarExpand } from 'react-icons/bs' 6 | 7 | export function EntityCombobox ({ 8 | hass, 9 | defaultValue, 10 | onChange 11 | }: { 12 | hass: HomeAssistant 13 | defaultValue: string 14 | onChange: (value: string) => void 15 | }) { 16 | const options = Object.keys(hass.states) 17 | const [query, setQuery] = useState('') 18 | const [selectedOption, setSelectedOption] = 19 | useState(defaultValue) 20 | 21 | const filteredOptions = options.filter(option => 22 | option.toLowerCase().includes(query.toLowerCase()) 23 | ) 24 | 25 | return ( 26 | { 29 | setSelectedOption(v) 30 | onChange(v) 31 | }} 32 | > 33 | 63 | 64 | ) 65 | } 66 | -------------------------------------------------------------------------------- /src/components/FloatingInput.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | 3 | export function FloatingInput ({ 4 | label, 5 | className, 6 | value, 7 | onChange 8 | }: { 9 | label: string 10 | value: string 11 | onChange: (value: string) => void 12 | className?: string 13 | mode?: string 14 | }) { 15 | return ( 16 |
17 | {/* onChange((e.target as HTMLInputElement).value)} 22 | class={clsx( 23 | 'bg-white/5 text-sm z-10 w-full input h-10 peer focus:outline-none ring-0 placeholder:text-[hsl(var(--er))] placeholder-shown:ring-1 placeholder-shown:ring-[hsl(var(--er))]', 24 | isMinimized ? 'text-base-content/50' : '' 25 | )} 26 | placeholder={label} 27 | autoComplete={'off'} 28 | spellcheck={false} 29 | /> */} 30 | onChange((e.target as HTMLInputElement).value)} 34 | spellCheck={false} 35 | /> 36 | 42 | 48 |
49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /src/components/FloatingTextarea.tsx: -------------------------------------------------------------------------------- 1 | import { ContentEditor } from "./ContentEditor" 2 | 3 | export function FloatingTextarea ({ 4 | label, 5 | mode = 'html' 6 | }: { 7 | label: string 8 | value: string 9 | onChange: (value: string) => void 10 | mode?: string 11 | }) { 12 | 13 | return ( 14 |
15 | {/* 23 |
24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/components/TweakPluginInput.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useMemo } from 'preact/hooks' 2 | import { ConfigContext } from '@store/ConfigContext' 3 | import { FloatingInput } from '@components/FloatingInput' 4 | import { ConfigState } from '@types' 5 | import { useDebouncer } from '@utils/DebounceHandler' 6 | 7 | export function TweakPluginInput ({ 8 | label, 9 | plugin, 10 | option 11 | }: { 12 | label: string 13 | plugin: keyof ConfigState['plugins'] 14 | option: keyof ConfigState['plugins'][typeof plugin] 15 | }) { 16 | const { config, updateConfig } = useContext(ConfigContext) 17 | const { debounceChangePeriod, plugins } = useMemo( 18 | () => config, 19 | [config.debounceChangePeriod, config.plugins] 20 | ) 21 | const debounce = useDebouncer(debounceChangePeriod) 22 | 23 | return ( 24 | { 29 | debounce(() => { 30 | updateConfig({ 31 | plugins: { 32 | ...plugins, 33 | [plugin]: { 34 | ...plugins[plugin], 35 | [option]: value 36 | } 37 | } 38 | }) 39 | }) 40 | }} 41 | /> 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /src/components/TweakPluginToggle.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'preact/hooks' 2 | import { ConfigContext } from '@store/ConfigContext' 3 | import { ConfigCheckbox } from '@components/ConfigCheckbox' 4 | import { ConfigState } from '@types' 5 | 6 | export function TweakPluginToggle ({ 7 | label, 8 | plugin, 9 | disabled 10 | }: { 11 | label: string 12 | plugin: keyof ConfigState['plugins'] 13 | disabled?: boolean 14 | }) { 15 | const { config, updateConfig } = useContext(ConfigContext) 16 | 17 | return ( 18 | { 21 | updateConfig({ 22 | plugins: { 23 | ...config.plugins, 24 | [plugin]: { 25 | ...config.plugins[plugin], 26 | enabled: checked 27 | } as ConfigState['plugins'][typeof plugin] 28 | } 29 | }) 30 | }} 31 | disabled={disabled} 32 | > 33 | {label} 34 | 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /src/components/TweakRangeInput.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'preact/compat' 2 | import { ConfigContext } from '@store/ConfigContext' 3 | import { useConfigMemo } from '@store/useConfigMemo' 4 | // import { ConfigState } from '@types' 5 | 6 | export function TweakRangeInput ( 7 | // { 8 | // label, 9 | // tweak, 10 | // min, 11 | // max, 12 | // steps 13 | // }: { 14 | // label: string 15 | // tweak: keyof ConfigState 16 | // min: number 17 | // max: number 18 | // steps: number 19 | // } 20 | ) { 21 | const { updateConfig } = useContext(ConfigContext) 22 | const { debounceChangePeriod } = useConfigMemo('debounceChangePeriod') 23 | 24 | return ( 25 |
26 |
Debounce change period: {debounceChangePeriod}ms
27 |
28 | 36 | updateConfig({ 37 | debounceChangePeriod: Number((e.target as HTMLInputElement).value) 38 | }) 39 | } 40 | /> 41 |
42 |
43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /src/components/TweakToggle.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'preact/hooks' 2 | import { ConfigContext } from '@store/ConfigContext' 3 | import { ConfigToggle } from '@components/ConfigToggle' 4 | import { ConfigState } from '@types' 5 | 6 | export function TweakToggle ({ 7 | label, 8 | tweak 9 | }: { 10 | label: string 11 | tweak: keyof ConfigState 12 | }) { 13 | const { config, updateConfig } = useContext(ConfigContext) 14 | 15 | return ( 16 | updateConfig({ [tweak]: checked })} 19 | > 20 | {label} 21 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /src/components/WithDaisyUitheme/index.tsx: -------------------------------------------------------------------------------- 1 | import { ConfigContext } from '@store/ConfigContext' 2 | import clsx from 'clsx' 3 | import { useMemo, PropsWithChildren, useContext } from 'preact/compat' 4 | 5 | export function WithDaisyUitheme ({ 6 | className, 7 | children 8 | }: PropsWithChildren<{ className?: string }>) { 9 | const { config } = useContext(ConfigContext) 10 | 11 | const daisyUiTheme = useMemo( 12 | () => config.plugins.daisyui.theme ?? 'inherit', 13 | [config.plugins.daisyui.theme] 14 | ) 15 | 16 | // split string by " - " 17 | const [scheme, theme] = daisyUiTheme.split(' - ') 18 | 19 | const attributes = ['inherit', 'auto'].includes(daisyUiTheme) 20 | ? {} 21 | : { 'data-theme': theme } 22 | return ( 23 |
24 | {children} 25 |
26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /src/elements/TailwindTemplateCard.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "preact"; 2 | import { HaCard } from "@components/HaCard"; 3 | 4 | // support shadowroot.adoptedStyleSheets in all browsers 5 | import "construct-style-sheets-polyfill"; 6 | import { TailwindTemplateRenderer } from "./TailwindTemplateRenderer"; 7 | import { initialConfigState } from "@store/ConfigReducer"; 8 | import { Action, Binding } from "@types"; 9 | import { HomeAssistant } from "custom-card-helpers"; 10 | import _ from "lodash"; 11 | 12 | console.info( 13 | `%c TailwindCSS Template Card \n%c Version ${CARD_VERSION} \n%c Star it at http://github.com/usernein/tailwindcss-template-card!`, 14 | "color: #2d2c35; font-weight: bold; background: #f5f6f9", 15 | "color: #aef3fc; font-weight: bold; background: #2d2c35", 16 | "color: #aef3fc; font-weight: bold; background: #2d2c35", 17 | ); 18 | 19 | export class TailwindTemplateCard extends TailwindTemplateRenderer { 20 | _entitiesToWatch: string[] = []; 21 | _htmlContent: string = ""; 22 | 23 | constructor() { 24 | super(); 25 | } 26 | 27 | static getConfigElement() { 28 | return document.createElement("tailwindcss-template-card-config"); 29 | } 30 | 31 | static getStubConfig() { 32 | return initialConfigState; 33 | } 34 | 35 | updateEntitiesToWatch() { 36 | this._entitiesToWatch = []; 37 | 38 | if (this._config.entity) { 39 | this._entitiesToWatch.push(this._config.entity); 40 | } 41 | 42 | if (this._config.entities && Array.isArray(this._config.entities)) { 43 | this._config.entities.forEach((entity: string) => { 44 | this._entitiesToWatch.push(entity); 45 | }); 46 | } 47 | 48 | this.watchMentionedEntities(); 49 | } 50 | 51 | watchMentionedEntities() { 52 | if (!this._hass || !this._config || this._config.content === undefined) 53 | return; 54 | 55 | Object.keys(this._hass.states).forEach((entity_id: string) => { 56 | if (!this._hass || !this._config || this._config.content === undefined) { 57 | return false; 58 | } 59 | 60 | const content = this._config.content; 61 | 62 | if ( 63 | content.includes(entity_id) || 64 | this.checkIfEntityIsUsedByBinding(entity_id) 65 | ) { 66 | this._entitiesToWatch.push(entity_id); 67 | } 68 | }); 69 | } 70 | 71 | checkIfEntityIsUsedByBinding(entity_id: string) { 72 | if (!this._hass || !this._config || this._config.content === undefined) { 73 | return null; 74 | } 75 | 76 | for (const binding of this._config.bindings) { 77 | if (binding.bind.includes(entity_id)) { 78 | return true; 79 | } 80 | } 81 | return false; 82 | } 83 | 84 | renderIfNeeded(forceUpdate?: boolean) { 85 | if (forceUpdate || this.needsRender()) { 86 | this.processAndRender(); 87 | } 88 | } 89 | 90 | needsRender() { 91 | if (!this._hass || !this._oldHass) { 92 | console.debug("needsRender: no hass"); 93 | return true; 94 | } 95 | if (!this._entitiesToWatch) { 96 | console.debug("needsRender: no entities to watch"); 97 | return true; 98 | } 99 | 100 | if (this._config.always_update) { 101 | console.debug("needsRender: always_update"); 102 | return true; 103 | } 104 | 105 | // if (!_.isEqual(this._oldConfig["bindings"], this._config["bindings"])) { 106 | // console.debug("needsRender: bindings changed"); 107 | // 108 | // return true; 109 | // } 110 | 111 | for (const entity_id of this._entitiesToWatch) { 112 | if (!this._hass.states[entity_id]) continue; 113 | if ( 114 | !_.isEqual( 115 | this._oldHass.states[entity_id], 116 | this._hass.states[entity_id], 117 | ) || 118 | !_.isEqual( 119 | this._oldHass.states[entity_id].attributes, 120 | this._hass.states[entity_id].attributes, 121 | ) 122 | ) { 123 | console.debug("needsRender: entity changed", entity_id); 124 | return true; 125 | } 126 | } 127 | 128 | return false; 129 | } 130 | 131 | getCardSize() { 132 | return 1; 133 | } 134 | 135 | processAndRender() { 136 | if (!this._hass || !this._config || this._config.content == undefined) 137 | return; 138 | 139 | let content = this._config.content; 140 | 141 | if ( 142 | undefined !== this._config.ignore_line_breaks && 143 | !this._config.ignore_line_breaks 144 | ) { 145 | content = content.replace(/\r?\n|\r/g, "
"); 146 | } 147 | 148 | if (!this._config.parse_jinja) { 149 | this._htmlContent = content; 150 | this._renderHtmlContent(); 151 | return; 152 | } 153 | 154 | this._hass.connection.subscribeMessage( 155 | (msg: { result: string }) => { 156 | this._htmlContent = msg.result; 157 | this._renderHtmlContent(); 158 | }, 159 | { 160 | type: "render_template", 161 | template: content, 162 | }, 163 | ); 164 | } 165 | 166 | _render(forceRender?: boolean) { 167 | this.updateEntitiesToWatch(); 168 | this.renderIfNeeded(forceRender); 169 | } 170 | 171 | _renderHtmlContent() { 172 | this.ensureIsReadyForRender(); 173 | 174 | this._deRender(); 175 | render( 176 | this.handleActions(e)} 180 | />, 181 | this.shadow, 182 | ); 183 | 184 | this.applyBindings(); 185 | } 186 | 187 | ensureIsReadyForRender() { 188 | if (!this._hass) { 189 | throw new Error("this._hass is invalid"); 190 | } 191 | if (this._config === undefined) { 192 | throw new Error("this.config is invalid"); 193 | } 194 | if (this._config.content === undefined) { 195 | throw new Error("this.config.content is invalid"); 196 | } 197 | if (!this.shadow) { 198 | throw new Error("this.shadow is invalid"); 199 | } 200 | } 201 | 202 | applyBindings() { 203 | if (!this._config?.bindings) return; 204 | 205 | this._config.bindings.forEach((binding: Binding) => { 206 | if (!binding.selector || !binding.bind || !binding.type) return; 207 | const matches = this.shadow.querySelectorAll(binding.selector); 208 | 209 | matches.forEach((match) => { 210 | const result = this.resolveBindValue(match, binding.bind); 211 | const target = match as HTMLElement; 212 | const targetAsInput = target as HTMLInputElement; 213 | 214 | switch (binding.type) { 215 | case "text": 216 | target.innerText = result; 217 | break; 218 | case "html": 219 | target.innerHTML = result; 220 | break; 221 | case "class": 222 | result && target.classList.add(result); 223 | break; 224 | case "checked": 225 | targetAsInput.checked = Boolean(result); 226 | break; 227 | case "value": 228 | targetAsInput.value = result; 229 | break; 230 | default: 231 | if (typeof result === "undefined" || "" === `${result}`) { 232 | target.removeAttribute(binding.type); 233 | } else { 234 | target.setAttribute(binding.type, result); 235 | } 236 | break; 237 | } 238 | }); 239 | }); 240 | } 241 | 242 | handleActions(e: Event) { 243 | if (!this._config?.actions || !e.target) return; 244 | 245 | const hass = this._hass; 246 | const config = this._config; 247 | const entity_id = config.entity; 248 | 249 | if (!hass) return; 250 | 251 | const entity = { ...hass.states[entity_id] } as { 252 | [key: string]: CallableFunction; 253 | } & HomeAssistant["states"][string]; 254 | 255 | if (entity_id) { 256 | const [domain] = entity_id.split("."); 257 | const services = hass.services[domain]; 258 | for (const service in services) { 259 | entity[service] = (data: object) => 260 | hass.callService(domain, service, { entity_id, ...data }); 261 | } 262 | } 263 | 264 | this._config.actions.forEach(({ call, selector, type }: Action) => { 265 | if (!selector || !call || !type) return; 266 | 267 | const target = e.target as HTMLElement; 268 | 269 | if (type === e.type && target.matches(selector)) { 270 | const executeCall = new Function("hass", "config", "entity", call); 271 | executeCall.call(e.target, hass, config, entity); 272 | } 273 | }); 274 | } 275 | 276 | resolveBindValue(element: Element, bind: string) { 277 | if (!this._hass) return; 278 | const entity = this._hass.states[this._config.entity]; 279 | 280 | try { 281 | const getState = new Function( 282 | "hass", 283 | "config", 284 | "entity", 285 | "state", 286 | "attr", 287 | bind, 288 | ); 289 | return getState.call( 290 | element, 291 | this._hass, 292 | this._config, 293 | entity, 294 | entity ? entity.state : undefined, 295 | entity ? entity.attributes : undefined, 296 | ); 297 | } catch (e) { 298 | console.log("BINDING --> FAILED", bind); 299 | } 300 | } 301 | } 302 | -------------------------------------------------------------------------------- /src/elements/TailwindTemplateCardConfig.tsx: -------------------------------------------------------------------------------- 1 | import { render } from 'preact' 2 | 3 | // support shadowroot.adoptedStyleSheets in all browsers 4 | import 'construct-style-sheets-polyfill' 5 | import { TailwindTemplateRenderer } from './TailwindTemplateRenderer' 6 | import { fulfillWithDefaults } from '@store/ConfigReducer' 7 | import { ConfigState } from '@types' 8 | import { CardEvents, registerCardEventHandler } from '@utils/events' 9 | // import { HaCardConfigWrapper } from '@components/HaCardConfigWrapper' 10 | import { ConfigProvider } from '@store/ConfigProvider' 11 | import { HaCardConfig } from '@components/HaCardConfig' 12 | import React from 'preact/compat' 13 | 14 | export class TailwindTemplateCardConfig extends TailwindTemplateRenderer { 15 | constructor () { 16 | super() 17 | 18 | this._force_daisyui = true 19 | this._ignore_broken_config = true 20 | this._rerender_after_set_config = false 21 | this._rerender_after_set_hass = false 22 | this._dispatch_config_setup_event = true 23 | 24 | registerCardEventHandler(CardEvents.CONFIG_CHANGED, (e: Event) => { 25 | console.log('config changed', e) 26 | const detail = (e as CustomEvent).detail 27 | const config = detail.config as Partial 28 | this.configChanged(fulfillWithDefaults(config)) 29 | }) 30 | 31 | this._render() 32 | } 33 | 34 | configChanged (newConfig: ConfigState) { 35 | const event = new CustomEvent('config-changed', { 36 | bubbles: true, 37 | composed: true, 38 | detail: { config: newConfig } 39 | }) 40 | 41 | this.dispatchEvent(event) 42 | } 43 | 44 | _render () { 45 | const MemoizedCardConfig = React.memo(HaCardConfig) 46 | render( 47 | 48 | 49 | , 50 | this.shadow 51 | ) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/elements/TailwindTemplateRenderer.tsx: -------------------------------------------------------------------------------- 1 | import { HomeAssistant } from 'custom-card-helpers' 2 | import { render } from 'preact' 3 | import config from '@/twind.config' 4 | import { twind, cssom, observe } from '@twind/core' 5 | 6 | // support shadowroot.adoptedStyleSheets in all browsers 7 | import 'construct-style-sheets-polyfill' 8 | import axios from 'axios' 9 | import { fulfillWithDefaults } from '@store/ConfigReducer' 10 | 11 | import generatedCss from '@/src/index.css?inline' 12 | import { ConfigState } from '@types' 13 | import { CardEvents, dispatchCardEvent } from '@utils/events' 14 | 15 | export abstract class TailwindTemplateRenderer extends HTMLElement { 16 | _hass: HomeAssistant | undefined 17 | _oldHass: HomeAssistant | undefined 18 | _config: ConfigState = {} as ConfigState 19 | _oldConfig: ConfigState = {} as ConfigState 20 | shadow: ShadowRoot 21 | _force_daisyui: boolean = false 22 | _ignore_broken_config = false 23 | _rerender_after_set_config = true 24 | _rerender_after_set_hass = true 25 | _dispatch_config_setup_event = false 26 | 27 | constructor () { 28 | super() 29 | 30 | this.shadow = this.attachShadow({ mode: 'open' }) 31 | } 32 | 33 | setConfig (config: Partial) { 34 | const inSetup = Object.keys(this._oldConfig).length === 0 35 | const pluginsConfigHasChanged = config.plugins !== this._oldConfig.plugins 36 | 37 | this._oldConfig = this._config 38 | this._config = fulfillWithDefaults(config) 39 | 40 | dispatchCardEvent(CardEvents.CONFIG_RECEIVED, { config }) 41 | if (this._dispatch_config_setup_event && !Object.keys(this._oldConfig).length) 42 | dispatchCardEvent(CardEvents.CONFIG_SETUP, { config }) 43 | 44 | if (pluginsConfigHasChanged || inSetup) { 45 | this.injectStylesheets(this._config) 46 | } 47 | 48 | if (!this._oldConfig || this._rerender_after_set_config) this._render(true) 49 | } 50 | 51 | async injectStylesheets ({ plugins }: ConfigState) { 52 | const adoptedStyleSheets = [] as CSSStyleSheet[] 53 | 54 | const generatedSheet = cssom(new CSSStyleSheet()) 55 | generatedSheet.target.replaceSync(generatedCss) 56 | adoptedStyleSheets.push(generatedSheet.target) 57 | 58 | const sheet = cssom(new CSSStyleSheet()) 59 | const tw = twind(config, sheet) 60 | 61 | const styles = document.querySelector('head')?.querySelectorAll('style') 62 | 63 | if (styles) { 64 | styles.forEach(elem => { 65 | if (elem.getAttribute('data-daisyui')) return 66 | const om = cssom(new CSSStyleSheet()) 67 | om.target.replaceSync(elem.innerHTML) 68 | adoptedStyleSheets.push(om.target) 69 | }) 70 | } 71 | 72 | const getDaisyUIStyle = () => { 73 | return document.querySelector('head style[data-daisyui]') 74 | } 75 | 76 | if (this._force_daisyui || plugins.daisyui.enabled) { 77 | const daisyStyle = getDaisyUIStyle() 78 | if (!daisyStyle) { 79 | const elem = document.createElement('style') 80 | elem.setAttribute('data-daisyui', 'true') 81 | elem.setAttribute('type', 'text/css') 82 | 83 | const daisyCDN = plugins.daisyui.url ?? DAISYUI_CDN_URL 84 | const res = await axios.get(daisyCDN) 85 | 86 | elem.innerHTML = res.data 87 | document.head.appendChild(elem) 88 | } 89 | 90 | const daisySheet = getDaisyUIStyle() 91 | if (daisySheet instanceof HTMLStyleElement) { 92 | if (daisySheet.sheet !== null) { 93 | const stylesheet = new CSSStyleSheet() 94 | stylesheet.replaceSync(daisySheet.innerHTML) 95 | adoptedStyleSheets.push(stylesheet) 96 | } 97 | } 98 | } 99 | 100 | adoptedStyleSheets.push(sheet.target) 101 | 102 | this.shadow.adoptedStyleSheets = adoptedStyleSheets 103 | observe(tw, this.shadow) 104 | } 105 | 106 | public set hass (hass: HomeAssistant) { 107 | this._oldHass = this._hass 108 | this._hass = hass 109 | 110 | window.hass = hass 111 | 112 | if (!this._oldHass || this._rerender_after_set_hass) this._render() 113 | } 114 | 115 | abstract _render(forceRender?: boolean): void 116 | 117 | _deRender () { 118 | this.shadow.innerHTML = '' 119 | 120 | render('', this.shadow) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import 'preact/debug' 2 | import { TailwindTemplateCard } from './elements/TailwindTemplateCard.tsx' 3 | import { TailwindTemplateCardConfig } from './elements/TailwindTemplateCardConfig.tsx' 4 | 5 | customElements.define('tailwindcss-template-card', TailwindTemplateCard) 6 | customElements.define('tailwindcss-template-card-config', TailwindTemplateCardConfig) 7 | 8 | window.customCards.push({ 9 | type: 'tailwindcss-template-card', 10 | name: 'TailwindCSS Template Card', 11 | description: 'Write HTML with TailwindCSS styles', 12 | preview: true 13 | }) 14 | -------------------------------------------------------------------------------- /src/pages/SettingsAbout.tsx: -------------------------------------------------------------------------------- 1 | export const SettingsAbout = () => { 2 | return ( 3 |
4 | ) 5 | } -------------------------------------------------------------------------------- /src/pages/SettingsActions.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useMemo, useState } from 'preact/compat' 2 | import { ConfigContext } from '@store/ConfigContext' 3 | import { ActionConfig } from '@components/ActionConfig' 4 | import { Action } from '@types' 5 | 6 | export const SettingsActions = () => { 7 | const { config, updateConfig } = useContext(ConfigContext) 8 | const actions = useMemo(() => config.actions, [config.actions]) 9 | 10 | const [maximizedAction, setMaximizedAction] = useState( 11 | null as keyof typeof actions | null 12 | ) 13 | 14 | const maximize = (actionKey: keyof typeof actions) => { 15 | setMaximizedAction(actionKey) 16 | } 17 | 18 | return ( 19 |
20 | 21 |
Actions
22 |
23 |
24 |
25 | {actions.map((action: Action, index: keyof typeof actions) => ( 26 | maximize(index)} 31 | onChange={value => { 32 | updateConfig({ 33 | actions: actions.map((v, i) => (i === index ? value : v)) 34 | }) 35 | }} 36 | onDelete={() => { 37 | updateConfig({ 38 | actions: actions.filter((_, i) => i !== index) 39 | }) 40 | }} 41 | /> 42 | ))} 43 |
44 |
45 |
46 | 56 |
57 |
58 |
59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /src/pages/SettingsBindings.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useMemo, useState } from 'preact/compat' 2 | import { ConfigContext } from '@store/ConfigContext' 3 | import { BindingConfig } from '@components/BindingConfig' 4 | import { Binding } from '@types' 5 | 6 | export const SettingsBindings = () => { 7 | const { config, updateConfig } = useContext(ConfigContext) 8 | const bindings = useMemo(() => config.bindings, [config.bindings]) 9 | 10 | const [maximizedBind, setMaximizedBind] = useState( 11 | null as keyof typeof bindings | null 12 | ) 13 | 14 | const maximize = (bindKey: keyof typeof bindings) => { 15 | setMaximizedBind(bindKey) 16 | } 17 | 18 | return ( 19 |
20 | 21 |
Bindings
22 |
23 |
24 |
25 | {bindings.map((binding: Binding, index: keyof typeof bindings) => ( 26 | maximize(index)} 31 | onChange={value => { 32 | updateConfig({ 33 | bindings: bindings.map((v, i) => (i === index ? value : v)) 34 | }) 35 | }} 36 | onDelete={() => { 37 | updateConfig({ 38 | bindings: bindings.filter((_, i) => i !== index) 39 | }) 40 | }} 41 | /> 42 | ))} 43 |
44 |
45 |
46 | 56 |
57 |
58 |
59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /src/pages/SettingsCardContent.tsx: -------------------------------------------------------------------------------- 1 | import { ContentEditor } from '@components/ContentEditor' 2 | import { SettingsBindings } from '@pages/SettingsBindings' 3 | import { SettingsActions } from './SettingsActions' 4 | 5 | export const SettingsCardContent = () => { 6 | return ( 7 |
8 |
9 | 12 | 13 | 14 |
15 |
16 | 17 | 18 |
19 |
20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/pages/SettingsPlugins.tsx: -------------------------------------------------------------------------------- 1 | import { TweakPluginInput } from '@components/TweakPluginInput' 2 | import { TweakPluginToggle } from '@components/TweakPluginToggle' 3 | import { ConfigContext } from '@store/ConfigContext' 4 | import { useContext } from 'preact/compat' 5 | 6 | export const SettingsPlugins = () => { 7 | const { config, updateConfig } = useContext(ConfigContext) 8 | 9 | return ( 10 |
11 |
12 |
Plugins
13 |
14 |
15 | 16 | 21 |
22 |
23 |
24 | 25 |
26 |
27 | Plugins settings 28 |
29 |
30 | 35 | 36 | 62 |
63 |
64 |
65 | ) 66 | } 67 | -------------------------------------------------------------------------------- /src/pages/SettingsTweaks.tsx: -------------------------------------------------------------------------------- 1 | import { TweakToggle } from '@components/TweakToggle' 2 | import { CodeEditorOptions } from '@components/CodeEditorOptions' 3 | import { TweakRangeInput } from '@components/TweakRangeInput' 4 | import { CardEntityConfig } from '@components/CardEntityConfig' 5 | 6 | export const SettingsTweaks = ({ 7 | inHiddenMode 8 | }: { 9 | inHiddenMode?: boolean 10 | }) => { 11 | return ( 12 |
13 |
14 |
15 | General 16 |
17 |
18 | 19 | 20 | 21 | 22 | 23 | 30 |
31 |
32 |
33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /src/store/ConfigContext.ts: -------------------------------------------------------------------------------- 1 | import { ConfigState } from "@types" 2 | import { createContext } from "preact" 3 | 4 | export type ConfigContextValues = { 5 | config: ConfigState 6 | updateConfig: (payload: Partial, dispatch_event?: boolean) => void 7 | } 8 | 9 | export const ConfigContext = createContext({} as ConfigContextValues) -------------------------------------------------------------------------------- /src/store/ConfigProvider.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'preact/compat' 2 | import { ConfigContext } from './ConfigContext' 3 | import { useConfigReducer } from './ConfigReducer' 4 | 5 | export const ConfigProvider = ({ children }: PropsWithChildren) => { 6 | const values = useConfigReducer() 7 | 8 | return ( 9 | {children} 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /src/store/ConfigReducer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CodeEditorOptionsEnum, 3 | ConfigActionTypes, 4 | ConfigReducerAction, 5 | ConfigState 6 | } from '@types' 7 | import { 8 | CardEvents, 9 | dispatchCardEvent, 10 | registerCardEventHandler 11 | } from '@utils/events' 12 | import { useReducer } from 'preact/hooks' 13 | 14 | export const ConfigReducer = ( 15 | state: ConfigState, 16 | action: ConfigReducerAction 17 | ) => { 18 | if (action.action_type == ConfigActionTypes.SET_CONFIG) { 19 | const newConfig = { ...state, ...action.payload } as ConfigState 20 | 21 | if (action.dispatch_event) { 22 | dispatchCardEvent(CardEvents.CONFIG_CHANGED, { config: newConfig }) 23 | } 24 | 25 | return newConfig 26 | } else { 27 | return state 28 | } 29 | } 30 | 31 | export const defaultConfigState: ConfigState = { 32 | entity: '', 33 | content: '', 34 | ignore_line_breaks: true, 35 | always_update: false, 36 | parse_jinja: true, 37 | code_editor: CodeEditorOptionsEnum.ACE, 38 | entities: [], 39 | bindings: [], 40 | actions: [], 41 | debounceChangePeriod: 100, 42 | plugins: { 43 | daisyui: { 44 | enabled: true, 45 | url: DAISYUI_CDN_URL, 46 | theme: 'dark - dark', 47 | overrideCardBackground: false 48 | }, 49 | tailwindElements: { 50 | enabled: false 51 | } 52 | } 53 | } 54 | 55 | export const fulfillWithDefaults = (config: Partial) => { 56 | return { ...defaultConfigState, ...config } as ConfigState 57 | } 58 | 59 | export const initialConfigState: ConfigState = { 60 | ...defaultConfigState, 61 | content: `
62 | {% for color in ["primary", "secondary", "accent", "info", "warning", "error", "info"] %} 63 |
64 | {% endfor %} 65 |
` 66 | } 67 | 68 | export const useConfigReducer = () => { 69 | const [state, dispatch] = useReducer(ConfigReducer, initialConfigState) 70 | 71 | const updateConfig = ( 72 | config: Partial, 73 | dispatch_event: boolean = true 74 | ) => { 75 | dispatch({ 76 | action_type: ConfigActionTypes.SET_CONFIG, 77 | dispatch_event, 78 | payload: config 79 | }) 80 | } 81 | 82 | registerCardEventHandler(CardEvents.CONFIG_RECEIVED, (e: Event) => { 83 | const config = (e as CustomEvent).detail.config as ConfigState 84 | const filledConfig = fulfillWithDefaults(config) 85 | updateConfig(filledConfig, false) 86 | }) 87 | 88 | return { 89 | config: state, 90 | updateConfig 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/store/useConfigMemo.ts: -------------------------------------------------------------------------------- 1 | import { useContext, useMemo } from 'preact/hooks' 2 | import { ConfigContext } from '@store/ConfigContext' 3 | import { ConfigState } from '@types' 4 | 5 | export const useConfigMemo = (...keys: T[]) => { 6 | const { config } = useContext(ConfigContext); 7 | return useMemo(() => { 8 | const memoizedValues = keys.reduce((acc, key) => { 9 | return { ...acc, [key]: config[key] }; 10 | }, {} as { [K in T]: ConfigState[K] }); 11 | return memoizedValues; 12 | }, keys.map((key) => config[key])); 13 | }; -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | import { HomeAssistant } from 'custom-card-helpers' 2 | 3 | declare global { 4 | interface Window { 5 | hass: HomeAssistant 6 | customCards: CustomCard[] 7 | } 8 | } 9 | 10 | export interface CustomCard { 11 | type: string 12 | name: string 13 | description: string 14 | preview: boolean 15 | } 16 | 17 | export type Binding = { 18 | bind: string 19 | selector: string 20 | type: string 21 | } 22 | 23 | export type Action = { 24 | call: string 25 | selector: string 26 | type: string 27 | } 28 | 29 | export enum ConfigActionTypes { 30 | SET_CONFIG 31 | } 32 | 33 | export type ConfigReducerAction = { 34 | action_type: ConfigActionTypes 35 | dispatch_event: boolean 36 | payload: Partial | object 37 | } 38 | 39 | export enum CodeEditorOptionsEnum { 40 | ACE = 'Ace', 41 | TEXTAREA = 'Textarea', 42 | CODEMIRROR_DEV = 'CodeMirror_dev' 43 | } 44 | 45 | type PluginOptions = { enabled: boolean; url?: string; theme?: string } 46 | 47 | type DaisyUIOptions = { overrideCardBackground: boolean } 48 | 49 | export type ConfigState = { 50 | entity: string 51 | ignore_line_breaks: boolean 52 | always_update: boolean 53 | content: string 54 | entities: string[] 55 | parse_jinja: boolean 56 | plugins: { 57 | daisyui: PluginOptions & DaisyUIOptions, 58 | tailwindElements: PluginOptions 59 | } 60 | code_editor: CodeEditorOptionsEnum 61 | bindings: Binding[] 62 | actions: Action[] 63 | debounceChangePeriod: number 64 | } 65 | 66 | export type ConfigStateValue = ConfigState[keyof ConfigState] 67 | -------------------------------------------------------------------------------- /src/utils/DebounceHandler.tsx: -------------------------------------------------------------------------------- 1 | export class DebounceHandler { 2 | debounceChangePeriod: number 3 | timeoutPointer: NodeJS.Timeout | null = null 4 | 5 | constructor (debounceChangePeriod: number) { 6 | this.debounceChangePeriod = debounceChangePeriod 7 | } 8 | 9 | run (fn: () => void) { 10 | if (this.timeoutPointer) { 11 | clearTimeout(this.timeoutPointer) 12 | } 13 | 14 | this.timeoutPointer = setTimeout(fn, this.debounceChangePeriod) 15 | } 16 | } 17 | 18 | export const useDebouncer = (debounceChangePeriod: number) => { 19 | const debouncer = new DebounceHandler(debounceChangePeriod) 20 | 21 | return (fn: () => void) => { 22 | debouncer.run(fn) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/utils/events.ts: -------------------------------------------------------------------------------- 1 | export enum CardEvents { 2 | CONFIG_RECEIVED = 'tailwindcss-template-card-config-received', 3 | CONFIG_CHANGED = 'tailwindcss-template-card-config-changed', 4 | CONFIG_SETUP = 'tailwindcss-template-card-config-setup' 5 | } 6 | 7 | export const dispatchCardEvent = ( 8 | event: CardEvents, 9 | detail: CustomEventInit['detail'] 10 | ) => { 11 | const eventInitOptions: CustomEventInit = { 12 | bubbles: true, 13 | composed: true, 14 | detail 15 | } 16 | document.dispatchEvent(new CustomEvent(event, eventInitOptions)) 17 | } 18 | 19 | export const registerCardEventHandler = ( 20 | event: CardEvents, 21 | callback: Parameters[1] 22 | ) => { 23 | document.addEventListener(event, callback) 24 | } 25 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | declare const CARD_VERSION: string 3 | declare const DAISYUI_CDN_URL: string 4 | declare const DAISYUI_THEMES: {theme: string, scheme: string}[] -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | import daisyui from 'daisyui' 3 | import tailwindScrollbar from 'tailwind-scrollbar' 4 | export default { 5 | darkMode: 'class', 6 | content: ['./src/**/*.{js,ts,jsx,tsx}'], 7 | plugins: [daisyui, tailwindScrollbar], 8 | theme: { 9 | extend: {} 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "inlineSourceMap": true, 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "module": "ESNext", 7 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "allowJs": true, 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | "jsxImportSource": "preact", 19 | 20 | /* Linting */ 21 | "strict": true, 22 | "noUnusedLocals": true, 23 | "noUnusedParameters": true, 24 | "noFallthroughCasesInSwitch": true, 25 | "baseUrl": "./src", 26 | "paths": { 27 | "@/*": ["../*"], 28 | "@components/*": ["./components/*"], 29 | "@pages/*": ["./pages/*"], 30 | "@store/*": ["./store/*"], 31 | "@elements/*": ["./elements/*"], 32 | "@types": ["./types"], 33 | "@utils/*": ["./utils/*"], 34 | } 35 | }, 36 | "include": ["src"], 37 | "references": [{ "path": "./tsconfig.node.json" }] 38 | } 39 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /twind.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from '@twind/core' 2 | import presetTailwind from '@twind/preset-tailwind' 3 | import presetAutoprefix from '@twind/preset-autoprefix' 4 | 5 | export default defineConfig({ 6 | presets: [presetAutoprefix(), presetTailwind()] 7 | }) 8 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import preact from '@preact/preset-vite' 3 | import daisyUiThemes from 'daisyui/src/theming/themes' 4 | import tsconfigPaths from 'vite-tsconfig-paths' 5 | import checker from 'vite-plugin-checker' 6 | 7 | const daisyUiFormattedThemes = Object.entries(daisyUiThemes).map(([k, v]) => ({ 8 | theme: k.match(/theme=([-\w]+)/)[1], 9 | scheme: v['color-scheme'] 10 | })) 11 | 12 | daisyUiFormattedThemes.sort((a, b) => 13 | `${a.scheme}${a.theme}`.localeCompare(`${b.scheme}${b.theme}`) 14 | ) 15 | 16 | // https://vitejs.dev/config/ 17 | export default defineConfig({ 18 | plugins: [ 19 | preact(), 20 | tsconfigPaths(), 21 | checker({ 22 | typescript: true 23 | }) 24 | ], 25 | define: { 26 | CARD_VERSION: JSON.stringify(process.env.npm_package_version), 27 | DAISYUI_CDN_URL: 28 | '"https://cdn.jsdelivr.net/npm/daisyui@latest/dist/full.css"', 29 | DAISYUI_THEMES: daisyUiFormattedThemes 30 | }, 31 | build: { 32 | rollupOptions: { 33 | input: 'src/main.ts', 34 | output: { 35 | dir: 'dist', 36 | entryFileNames: 'tailwindcss-template-card.js', 37 | manualChunks: undefined 38 | } 39 | } 40 | } 41 | }) 42 | --------------------------------------------------------------------------------