├── .editorconfig ├── .env.example ├── .eslintrc.json ├── .github ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug-report.yml │ ├── config.yml │ └── feature-request.yml ├── autolabeler.yml ├── dependabot.yml ├── labels.yml └── workflows │ ├── build.yml │ ├── dependabot-automerge.yml │ ├── labels.yml │ ├── lint.yml │ ├── lock.yml │ └── stale.yml ├── .gitignore ├── .mdl.rb ├── .mdlrc ├── .prettierignore ├── .prettierrc.json ├── .vscode └── settings.json ├── .yamllint.yml ├── LICENSE ├── README.md ├── mlc_config.json ├── next.config.js ├── package.json ├── prisma └── schema.prisma ├── public ├── banner.png ├── banner.svg ├── favicon.ico ├── favicon.png ├── favicon.svg ├── icon-circle.svg ├── icon-rect.svg └── icon.svg ├── scripts └── pullHomeAssistantFrontendHelpers.js ├── src ├── app │ ├── api │ │ └── auth │ │ │ └── [...nextauth] │ │ │ └── route.ts │ ├── dashboards │ │ ├── [dashboardId] │ │ │ ├── edit │ │ │ │ └── page.tsx │ │ │ ├── page.tsx │ │ │ └── sections │ │ │ │ ├── [sectionId] │ │ │ │ ├── edit │ │ │ │ │ └── page.tsx │ │ │ │ └── widgets │ │ │ │ │ ├── [widgetId] │ │ │ │ │ └── edit │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── new │ │ │ │ │ └── page.tsx │ │ │ │ └── new │ │ │ │ └── page.tsx │ │ ├── new │ │ │ └── page.tsx │ │ └── page.tsx │ ├── error.tsx │ ├── globals.css │ ├── layout.tsx │ ├── loading.tsx │ ├── page.module.css │ └── page.tsx ├── components │ ├── AccessDenied.tsx │ ├── Drawer.tsx │ ├── dashboard │ │ ├── editors │ │ │ ├── Dashboard.tsx │ │ │ ├── Section.tsx │ │ │ ├── Widget.tsx │ │ │ └── widgets │ │ │ │ ├── Base.tsx │ │ │ │ ├── Frame.tsx │ │ │ │ ├── HomeAssistant.tsx │ │ │ │ ├── Image.tsx │ │ │ │ └── Markdown.tsx │ │ ├── new │ │ │ ├── Dashboard.tsx │ │ │ ├── Section.tsx │ │ │ └── Widget.tsx │ │ └── views │ │ │ ├── Dashboard.tsx │ │ │ ├── Heading.tsx │ │ │ ├── Section.tsx │ │ │ ├── Widget.tsx │ │ │ └── widgets │ │ │ ├── Base.tsx │ │ │ ├── Checklist.tsx │ │ │ ├── Frame.tsx │ │ │ ├── HomeAssistant.tsx │ │ │ ├── Image.tsx │ │ │ ├── Markdown.tsx │ │ │ └── expanded │ │ │ └── homeAssistant │ │ │ ├── AlarmControlPanel.tsx │ │ │ ├── Cover.tsx │ │ │ └── Light.tsx │ └── skeletons │ │ └── Dashboard.tsx ├── providers │ ├── AuthProvider.tsx │ ├── HomeAssistantProvider.tsx │ └── MUIProvider.tsx ├── types │ ├── dashboard.type.ts │ ├── section.type.ts │ └── widget.type.ts └── utils │ ├── homeAssistant │ ├── alarmControlPanel.ts │ ├── const.ts │ ├── cover.ts │ ├── icons.ts │ └── index.ts │ ├── prisma.ts │ ├── serverActions │ ├── dashboard.ts │ ├── homeAssistant.ts │ ├── section.ts │ └── widget.ts │ └── theme.ts ├── tsconfig.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | ; EditorConfig helps developers define and maintain consistent 2 | ; coding styles between different editors and IDEs. 3 | 4 | ; For more visit http://editorconfig.org. 5 | root = true 6 | 7 | ; Choose between lf or rf on "end_of_line" property 8 | [*] 9 | indent_style = space 10 | indent_size = 2 11 | end_of_line = lf 12 | charset = utf-8 13 | trim_trailing_whitespace = true 14 | insert_final_newline = true 15 | 16 | [*.{js,css,scss}] 17 | indent_size = 2 18 | 19 | [*.html] 20 | indent_style = space 21 | 22 | [*.{py,html,md}] 23 | indent_size = 4 24 | 25 | [*.md] 26 | trim_trailing_whitespace = true 27 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL="file:./dev.db" 2 | NEXTAUTH_URL="http://localhost:3000" 3 | NEXTAUTH_SECRET="abc123" 4 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | .github/* @timmo001 2 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | aidan@timmo.dev. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | --- 2 | github: timmo001 3 | ko_fi: timmo001 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | name: Report an issue/bug 2 | description: Report an issue/bug. 3 | body: 4 | - type: textarea 5 | validations: 6 | required: true 7 | attributes: 8 | label: Description 9 | description: >- 10 | Provide a clear and concise description of what the problem is. 11 | - type: markdown 12 | attributes: 13 | value: | 14 | # Additional Details 15 | - type: textarea 16 | attributes: 17 | label: Anything in the logs or a references that might be useful? 18 | description: For example, error message, or stack traces. 19 | render: txt 20 | - type: textarea 21 | attributes: 22 | label: Additional information 23 | description: > 24 | If you have any additional information for us, use the field below. 25 | Please note, you can attach screenshots or screen recordings here, by 26 | dragging and dropping files in the field below. 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: I have a question or need support 4 | url: https://github.com/timmo001/home-panel/discussions 5 | about: Please use the discussions area for getting help or asking questions. 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yml: -------------------------------------------------------------------------------- 1 | name: Request a new feature 2 | description: Request a new feature or enhancement. 3 | body: 4 | - type: textarea 5 | validations: 6 | required: true 7 | attributes: 8 | label: Description 9 | description: >- 10 | Details of the feature and what should be added. 11 | - type: textarea 12 | attributes: 13 | label: Additional information 14 | description: > 15 | If you have any additional information for us, use the field below. 16 | Please note, you can attach screenshots or screen recordings here, by 17 | dragging and dropping files in the field below. 18 | -------------------------------------------------------------------------------- /.github/autolabeler.yml: -------------------------------------------------------------------------------- 1 | --- 2 | "Type: Documentation": ["*.md", "*.j2"] 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: daily 8 | - package-ecosystem: "npm" 9 | directory: "/" 10 | open-pull-requests-limit: 20 11 | schedule: 12 | interval: "daily" 13 | -------------------------------------------------------------------------------- /.github/labels.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: "breaking-change" 3 | color: ee0701 4 | description: "A breaking change for existing users." 5 | - name: "bugfix" 6 | color: ee0701 7 | description: "Inconsistencies or issues which will cause a problem for users or implementors." 8 | - name: "documentation" 9 | color: 0052cc 10 | description: "Solely about the documentation of the project." 11 | - name: "enhancement" 12 | color: 1d76db 13 | description: "Enhancement of the code, not introducing new features." 14 | - name: "refactor" 15 | color: 1d76db 16 | description: "Improvement of existing code, not introducing new features." 17 | - name: "performance" 18 | color: 1d76db 19 | description: "Improving performance, not introducing new features." 20 | - name: "new-feature" 21 | color: 0e8a16 22 | description: "New features or options." 23 | - name: "maintenance" 24 | color: 2af79e 25 | description: "Generic maintenance tasks." 26 | - name: "ci" 27 | color: 1d76db 28 | description: "Work that improves the continue integration." 29 | - name: "dependencies" 30 | color: 1d76db 31 | description: "Upgrade or downgrade of project dependencies." 32 | 33 | - name: "in-progress" 34 | color: fbca04 35 | description: "Issue is currently being resolved by a developer." 36 | - name: "stale" 37 | color: fef2c0 38 | description: "There has not been activity on this issue or PR for quite some time." 39 | - name: "no-stale" 40 | color: fef2c0 41 | description: "This issue or PR is exempted from the stable bot." 42 | 43 | - name: "security" 44 | color: ee0701 45 | description: "Marks a security issue that needs to be resolved asap." 46 | - name: "incomplete" 47 | color: fef2c0 48 | description: "Marks a PR or issue that is missing information." 49 | - name: "invalid" 50 | color: fef2c0 51 | description: "Marks a PR or issue that is missing information." 52 | 53 | - name: "beginner-friendly" 54 | color: 0e8a16 55 | description: "Good first issue for people wanting to contribute to the project." 56 | - name: "help-wanted" 57 | color: 0e8a16 58 | description: "We need some extra helping hands or expertise in order to resolve this." 59 | 60 | - name: "hacktoberfest" 61 | description: "Issues/PRs are participating in the Hacktoberfest." 62 | color: fbca04 63 | - name: "hacktoberfest-accepted" 64 | description: "Issues/PRs are participating in the Hacktoberfest." 65 | color: fbca04 66 | 67 | - name: "priority-critical" 68 | color: ee0701 69 | description: "This should be dealt with ASAP. Not fixing this issue would be a serious error." 70 | - name: "priority-high" 71 | color: b60205 72 | description: "After critical issues are fixed, these should be dealt with before any further issues." 73 | - name: "priority-medium" 74 | color: 0e8a16 75 | description: "This issue may be useful, and needs some attention." 76 | - name: "priority-low" 77 | color: e4ea8a 78 | description: "Nice addition, maybe... someday..." 79 | 80 | - name: "major" 81 | color: b60205 82 | description: "This PR causes a major version bump in the version number." 83 | - name: "minor" 84 | color: 0e8a16 85 | description: "This PR causes a minor version bump in the version number." 86 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | # yamllint disable-line rule:truthy 4 | on: 5 | push: 6 | branches: 7 | - master 8 | pull_request: 9 | types: 10 | - opened 11 | - reopened 12 | - synchronize 13 | workflow_dispatch: 14 | 15 | concurrency: 16 | group: build-${{ github.head_ref || github.ref }} 17 | cancel-in-progress: true 18 | 19 | jobs: 20 | build-nodejs: 21 | uses: timmo001/workflows/.github/workflows/build-node-linux.yml@master 22 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-automerge.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Dependabot - Auto-merge 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | pull_request_target: 7 | 8 | permissions: 9 | pull-requests: write 10 | contents: write 11 | 12 | jobs: 13 | dependabot-automerge: 14 | uses: timmo001/workflows/.github/workflows/dependabot-automerge-any.yml@master 15 | -------------------------------------------------------------------------------- /.github/workflows/labels.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Sync labels 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | push: 7 | branches: 8 | - master 9 | paths: 10 | - .github/labels.yml 11 | schedule: 12 | - cron: "0 5 * * *" 13 | workflow_dispatch: 14 | 15 | jobs: 16 | labels: 17 | uses: timmo001/workflows/.github/workflows/labels.yml@master 18 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | # yamllint disable-line rule:truthy 4 | on: 5 | push: 6 | branches: 7 | - master 8 | pull_request: 9 | types: 10 | - opened 11 | - reopened 12 | - synchronize 13 | workflow_dispatch: 14 | 15 | concurrency: 16 | group: lint-${{ github.head_ref || github.ref }} 17 | cancel-in-progress: true 18 | 19 | jobs: 20 | lint-eslint: 21 | uses: timmo001/workflows/.github/workflows/lint-eslint.yml@master 22 | lint-jsonlint: 23 | uses: timmo001/workflows/.github/workflows/lint-jsonlint.yml@master 24 | lint-markdown-links: 25 | uses: timmo001/workflows/.github/workflows/lint-markdown-links.yml@master 26 | lint-markdownlint: 27 | uses: timmo001/workflows/.github/workflows/lint-markdownlint.yml@master 28 | # lint-prettier: 29 | # uses: timmo001/workflows/.github/workflows/lint-prettier.yml@master 30 | lint-yamllint: 31 | uses: timmo001/workflows/.github/workflows/lint-yamllint.yml@master 32 | -------------------------------------------------------------------------------- /.github/workflows/lock.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Lock 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | schedule: 7 | - cron: "0 9 * * *" 8 | workflow_dispatch: 9 | 10 | jobs: 11 | lock: 12 | name: 🔒 Lock closed issues and PRs 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: dessant/lock-threads@v5.0.1 16 | with: 17 | github-token: ${{ github.token }} 18 | issue-lock-inactive-days: "30" 19 | issue-lock-reason: "" 20 | pr-lock-inactive-days: "1" 21 | pr-lock-reason: "" 22 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Stale 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | schedule: 7 | - cron: "0 8 * * *" 8 | workflow_dispatch: 9 | 10 | jobs: 11 | stale: 12 | name: 🧹 Clean up stale issues and PRs 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: 🚀 Run stale 16 | uses: actions/stale@v9.0.0 17 | with: 18 | repo-token: ${{ secrets.GITHUB_TOKEN }} 19 | days-before-stale: 30 20 | days-before-close: 7 21 | remove-stale-when-updated: true 22 | stale-issue-label: "stale" 23 | exempt-issue-labels: "no-stale,help-wanted" 24 | stale-issue-message: > 25 | There hasn't been any activity on this issue recently, so we 26 | clean up some of the older and inactive issues. 27 | 28 | Please make sure to update to the latest version and 29 | check if that solves the issue. Let us know if that works for you 30 | by leaving a comment 👍 31 | 32 | This issue has now been marked as stale and will be closed if no 33 | further activity occurs. Thanks! 34 | stale-pr-label: "stale" 35 | exempt-pr-labels: "no-stale" 36 | stale-pr-message: > 37 | There hasn't been any activity on this pull request recently. This 38 | pull request has been automatically marked as stale because of that 39 | and will be closed if no further activity occurs within 7 days. 40 | Thank you for your contributions. 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | # Env 39 | .env 40 | !.env.example 41 | 42 | # Prisma 43 | *.db* 44 | prisma/migrations 45 | !schema.prisma 46 | -------------------------------------------------------------------------------- /.mdl.rb: -------------------------------------------------------------------------------- 1 | all 2 | rule 'MD013', :tables => false 3 | exclude_rule 'MD002' 4 | exclude_rule 'MD013' 5 | exclude_rule 'MD024' 6 | exclude_rule 'MD032' 7 | exclude_rule 'MD033' 8 | exclude_rule 'MD034' 9 | exclude_rule 'MD036' 10 | exclude_rule 'MD041' 11 | -------------------------------------------------------------------------------- /.mdlrc: -------------------------------------------------------------------------------- 1 | style '.mdl.rb' 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | .output/ -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules\\typescript\\lib", 3 | "typescript.enablePromptUseWorkspaceTsdk": true 4 | } -------------------------------------------------------------------------------- /.yamllint.yml: -------------------------------------------------------------------------------- 1 | --- 2 | extends: default 3 | 4 | rules: 5 | line-length: 6 | ignore: | 7 | .gitlab-ci.yml 8 | .github/ 9 | .yarn/ 10 | level: warning 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-2023 Aidan Timson 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Home Panel 2 | 3 | A web frontend for controlling the home. Integrates with [Home Assistant](https://www.home-assistant.io) as an additional frontend. 4 | 5 | ![banner](public/banner.png) 6 | 7 | ## Features 8 | 9 | - Supports [Home Assistant](https://www.home-assistant.io) entities, cameras, news feeds, iframes and more. 10 | - Fully customizable interface. 11 | - Custom theme support. 12 | - Full in-application configuration UI. 13 | 14 | ## Documentation / Setup 15 | 16 | Setup and configuration for the app is available [here](https://home-panel.timmo.dev/docs/setup) 17 | 18 | ## Links 19 | 20 | [Discussions / Support](https://github.com/timmo001/home-panel/discussions) 21 | 22 | [Code of Conduct](.github/CODE_OF_CONDUCT.md) 23 | 24 | [License](LICENSE) 25 | -------------------------------------------------------------------------------- /mlc_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignorePatterns": [ 3 | { 4 | "pattern": "^aidan@timmo" 5 | }, 6 | { 7 | "pattern": "https://home-panel.timmo.dev" 8 | } 9 | ], 10 | "retryOn429": true 11 | } 12 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | experimental: { 4 | appDir: true, 5 | serverActions: true, 6 | }, 7 | images: { 8 | remotePatterns: [ 9 | { 10 | protocol: "https", 11 | hostname: "avatars.githubusercontent.com", 12 | }, 13 | ], 14 | }, 15 | }; 16 | 17 | module.exports = nextConfig; 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "home-panel", 3 | "description": "Home Panel", 4 | "private": true, 5 | "author": { 6 | "name": "Aidan Timson", 7 | "email": "aidan@timmo.dev" 8 | }, 9 | "license": "MIT", 10 | "version": "3.0.0", 11 | "scripts": { 12 | "dev": "next dev", 13 | "build": "next build", 14 | "start": "next start", 15 | "lint": "next lint" 16 | }, 17 | "dependencies": { 18 | "@emotion/react": "11.11.4", 19 | "@emotion/styled": "11.11.5", 20 | "@mdi/js": "7.4.47", 21 | "@mdi/react": "1.6.1", 22 | "@mui/icons-material": "5.15.15", 23 | "@mui/lab": "5.0.0-alpha.170", 24 | "@mui/material": "5.15.15", 25 | "@next-auth/prisma-adapter": "1.0.7", 26 | "@prisma/client": "5.12.1", 27 | "@types/node": "20.12.7", 28 | "@types/react": "18.2.77", 29 | "@types/react-dom": "18.2.25", 30 | "encoding": "0.1.13", 31 | "eslint": "8.56.0", 32 | "eslint-config-next": "14.2.0", 33 | "home-assistant-js-websocket": "9.2.1", 34 | "materialdesign-js": "1.0.0", 35 | "moment": "2.30.1", 36 | "mui-chips-input": "2.1.4", 37 | "next": "14.2.0", 38 | "next-auth": "4.24.7", 39 | "prisma": "5.12.1", 40 | "react": "18.2.0", 41 | "react-dom": "18.2.0", 42 | "react-moment": "1.1.3", 43 | "tss-react": "4.9.6", 44 | "typescript": "5.4.5", 45 | "use-long-press": "3.2.0" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "sqlite" 10 | url = env("DATABASE_URL") 11 | } 12 | 13 | model User { 14 | id String @id @default(cuid()) 15 | username String @unique 16 | password String 17 | name String? 18 | image String? 19 | dashboards Dashboard[] 20 | 21 | @@index([username], name: "username_unique") 22 | } 23 | 24 | model Dashboard { 25 | id String @id @default(cuid()) 26 | position Int @default(10) 27 | name String 28 | description String? 29 | userId String 30 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 31 | headerItems HeaderItem[] 32 | homeAssistant HomeAssistant[] 33 | sections Section[] 34 | 35 | @@index([id, userId], name: "id_userId_unique") 36 | @@index([userId, position], name: "userId_position_unique") 37 | } 38 | 39 | model HeaderItem { 40 | id String @id @default(cuid()) 41 | position Int @default(10) 42 | type String 43 | dashboardId String? 44 | dashboard Dashboard? @relation(fields: [dashboardId], references: [id]) 45 | } 46 | 47 | model Section { 48 | id String @id @default(cuid()) 49 | position Int @default(10) 50 | title String 51 | subtitle String? 52 | width String 53 | dashboardId String 54 | dashboard Dashboard @relation(fields: [dashboardId], references: [id], onDelete: Cascade) 55 | widgets Widget[] 56 | 57 | @@index([id, dashboardId], name: "id_dashboardId_unique") 58 | @@index([dashboardId, position], name: "dashboardId_position_unique") 59 | } 60 | 61 | model Widget { 62 | id String @id @default(cuid()) 63 | position Int @default(10) 64 | type String 65 | title String? 66 | width String? 67 | sectionId String 68 | section Section @relation(fields: [sectionId], references: [id], onDelete: Cascade) 69 | frame WidgetFrame[] 70 | homeAssistant WidgetHomeAssistant[] 71 | image WidgetImage[] 72 | markdown WidgetMarkdown[] 73 | checklist WidgetChecklist[] 74 | 75 | @@index([id, sectionId], name: "id_sectionId_unique") 76 | @@index([sectionId, position], name: "sectionId_position_unique") 77 | } 78 | 79 | model WidgetChecklist { 80 | widgetId String @id 81 | widget Widget @relation(fields: [widgetId], references: [id], onDelete: Cascade) 82 | items WidgetChecklistItem[] 83 | } 84 | 85 | model WidgetChecklistItem { 86 | id String @id @default(cuid()) 87 | position Int @default(10) 88 | content String 89 | checked Boolean @default(false) 90 | checklist WidgetChecklist @relation(fields: [checklistWidgetId], references: [widgetId]) 91 | checklistWidgetId String 92 | } 93 | 94 | model WidgetFrame { 95 | widgetId String @id 96 | widget Widget @relation(fields: [widgetId], references: [id], onDelete: Cascade) 97 | url String 98 | height String? 99 | } 100 | 101 | model WidgetHomeAssistant { 102 | widgetId String @id 103 | widget Widget @relation(fields: [widgetId], references: [id], onDelete: Cascade) 104 | entityId String 105 | showName Boolean? 106 | showIcon Boolean? 107 | showState Boolean? 108 | iconColor String? 109 | iconSize String? 110 | secondaryInfo String? 111 | } 112 | 113 | model WidgetImage { 114 | widgetId String @id 115 | widget Widget @relation(fields: [widgetId], references: [id], onDelete: Cascade) 116 | url String 117 | } 118 | 119 | model WidgetMarkdown { 120 | widgetId String @id 121 | widget Widget @relation(fields: [widgetId], references: [id], onDelete: Cascade) 122 | content String? 123 | } 124 | 125 | model HomeAssistant { 126 | id String @id @default(cuid()) 127 | name String? 128 | url String 129 | accessToken String? 130 | refreshToken String? 131 | clientId String? 132 | expires BigInt? 133 | expiresIn Int? 134 | dashboardId String @unique 135 | dashboard Dashboard @relation(fields: [dashboardId], references: [id], onDelete: Cascade) 136 | 137 | @@index([id, dashboardId], name: "ha_id_dashboardId_unique") 138 | } 139 | -------------------------------------------------------------------------------- /public/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmo001/home-panel/90ab5f05ec66e05ed7449f2c1283b62a9a8542a1/public/banner.png -------------------------------------------------------------------------------- /public/banner.svg: -------------------------------------------------------------------------------- 1 | HomePanel -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmo001/home-panel/90ab5f05ec66e05ed7449f2c1283b62a9a8542a1/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmo001/home-panel/90ab5f05ec66e05ed7449f2c1283b62a9a8542a1/public/favicon.png -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/icon-circle.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/icon-rect.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /scripts/pullHomeAssistantFrontendHelpers.js: -------------------------------------------------------------------------------- 1 | const { join } = require("path"); 2 | const { writeFile } = require("fs"); 3 | 4 | const homeAssistantConstantsUrl = 5 | "https://raw.githubusercontent.com/home-assistant/frontend/dev/src/common/const.ts"; 6 | 7 | console.log("Pulling Home Assistant Frontend Constants from GitHub..."); 8 | 9 | fetch(homeAssistantConstantsUrl, { method: "GET" }) 10 | .then((response) => { 11 | response.text().then((text) => { 12 | writeFile( 13 | join(__dirname, "../src/utils/homeAssistant/const.ts"), 14 | `// Sourced from ${homeAssistantConstantsUrl}\n\n${text}`, 15 | (error) => { 16 | if (error) { 17 | console.error( 18 | "Error writing Home Assistant Frontend Constants to file: " + 19 | error 20 | ); 21 | } else { 22 | console.log( 23 | "Home Assistant Frontend Constants successfully pulled from GitHub!" 24 | ); 25 | } 26 | } 27 | ); 28 | }); 29 | }) 30 | .catch((error) => { 31 | console.error( 32 | "Error pulling Home Assistant Frontend Constants from GitHub: " + error 33 | ); 34 | }); 35 | -------------------------------------------------------------------------------- /src/app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | import NextAuth from "next-auth"; 2 | 3 | import { authOptions } from "@/utils/prisma"; 4 | 5 | const handler = NextAuth(authOptions); 6 | export { handler as GET, handler as POST }; 7 | -------------------------------------------------------------------------------- /src/app/dashboards/[dashboardId]/edit/page.tsx: -------------------------------------------------------------------------------- 1 | import type { 2 | Dashboard as DashboardModel, 3 | HeaderItem as HeaderItemModel, 4 | HomeAssistant as HomeAssistantModel, 5 | User as UserModel, 6 | } from "@prisma/client"; 7 | import type { Metadata } from "next"; 8 | import { getServerSession } from "next-auth/next"; 9 | import { notFound } from "next/navigation"; 10 | 11 | import { AccessDenied } from "@/components/AccessDenied"; 12 | import { EditDashboard } from "@/components/dashboard/editors/Dashboard"; 13 | import { HeaderItemType } from "@/types/dashboard.type"; 14 | import { authOptions, prisma } from "@/utils/prisma"; 15 | 16 | export const metadata: Metadata = { 17 | title: "Edit Dashboard | Home Panel", 18 | description: "Edit Dashboard - Home Panel", 19 | }; 20 | 21 | export const revalidate = 0; 22 | 23 | export default async function Page({ 24 | params, 25 | }: { 26 | params: { dashboardId: string }; 27 | }): Promise { 28 | console.log("Edit Dashboard:", params); 29 | 30 | const session = await getServerSession(authOptions); 31 | if (!session) return ; 32 | 33 | const user: UserModel = await prisma.user.findUniqueOrThrow({ 34 | where: { 35 | username: session.user!.email!, 36 | }, 37 | }); 38 | 39 | let dashboardConfig: DashboardModel | null = 40 | await prisma.dashboard.findUnique({ 41 | where: { 42 | id: params.dashboardId, 43 | }, 44 | }); 45 | 46 | if (!dashboardConfig) return notFound(); 47 | 48 | let homeAssistantConfig: HomeAssistantModel | null = 49 | await prisma.homeAssistant.findUnique({ 50 | where: { 51 | dashboardId: params.dashboardId, 52 | }, 53 | }); 54 | 55 | if (!homeAssistantConfig) 56 | homeAssistantConfig = await prisma.homeAssistant.create({ 57 | data: { 58 | dashboard: { 59 | connect: { 60 | id: params.dashboardId, 61 | }, 62 | }, 63 | url: "http://homeassistant.local:8123", 64 | }, 65 | }); 66 | 67 | const headerItemsConfig: Array = 68 | await prisma.headerItem.findMany({ 69 | where: { 70 | dashboardId: params.dashboardId, 71 | }, 72 | }); 73 | 74 | if (headerItemsConfig.length === 0) { 75 | headerItemsConfig.push( 76 | await prisma.headerItem.create({ 77 | data: { 78 | dashboard: { 79 | connect: { 80 | id: params.dashboardId, 81 | }, 82 | }, 83 | type: HeaderItemType.Spacer, 84 | position: 0, 85 | }, 86 | }) 87 | ); 88 | headerItemsConfig.push( 89 | await prisma.headerItem.create({ 90 | data: { 91 | dashboard: { 92 | connect: { 93 | id: params.dashboardId, 94 | }, 95 | }, 96 | type: HeaderItemType.DateTime, 97 | position: 10, 98 | }, 99 | }) 100 | ); 101 | headerItemsConfig.push( 102 | await prisma.headerItem.create({ 103 | data: { 104 | dashboard: { 105 | connect: { 106 | id: params.dashboardId, 107 | }, 108 | }, 109 | type: HeaderItemType.Spacer, 110 | position: 20, 111 | }, 112 | }) 113 | ); 114 | } 115 | 116 | return ( 117 | 122 | ); 123 | } 124 | -------------------------------------------------------------------------------- /src/app/dashboards/[dashboardId]/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { notFound } from "next/navigation"; 3 | 4 | import type { DashboardModel } from "@/types/dashboard.type"; 5 | import type { SectionModel } from "@/types/section.type"; 6 | import { Dashboard } from "@/components/dashboard/views/Dashboard"; 7 | import { prisma } from "@/utils/prisma"; 8 | import { Section } from "@/components/dashboard/views/Section"; 9 | import { widgetGetData } from "@/utils/serverActions/widget"; 10 | 11 | export const metadata: Metadata = { 12 | title: "Dashboard | Home Panel", 13 | description: "Dashboard - Home Panel", 14 | }; 15 | 16 | export const revalidate = 0; 17 | 18 | export default async function Page({ 19 | params, 20 | }: { 21 | params: { dashboardId: string }; 22 | }): Promise { 23 | console.log("Dashboard:", params); 24 | 25 | /** 26 | * The dashboard object retrieved from the database. 27 | * Contains all the sections and widgets associated with the dashboard. 28 | */ 29 | let dashboard: DashboardModel | null = (await prisma.dashboard.findUnique({ 30 | where: { 31 | id: params.dashboardId, 32 | }, 33 | include: { 34 | headerItems: { 35 | orderBy: { position: "asc" }, 36 | }, 37 | sections: { 38 | include: { 39 | widgets: { 40 | orderBy: { position: "asc" }, 41 | }, 42 | }, 43 | orderBy: { position: "asc" }, 44 | }, 45 | }, 46 | })) as DashboardModel | null; 47 | 48 | if (!dashboard) return notFound(); 49 | 50 | // Fetch data for all widgets 51 | for (const section of dashboard.sections) { 52 | for (const widget of section.widgets) { 53 | widget.data = await widgetGetData(widget.id, widget.type); 54 | } 55 | } 56 | 57 | return ( 58 | 59 | {dashboard.sections.map((section: SectionModel) => ( 60 |
61 | ))} 62 | 63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /src/app/dashboards/[dashboardId]/sections/[sectionId]/edit/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Section } from "@prisma/client"; 2 | import type { Metadata } from "next"; 3 | import { notFound } from "next/navigation"; 4 | 5 | import { EditSection } from "@/components/dashboard/editors/Section"; 6 | import { prisma } from "@/utils/prisma"; 7 | 8 | export const metadata: Metadata = { 9 | title: "Edit Section | Home Panel", 10 | description: "Edit Section - Home Panel", 11 | }; 12 | 13 | export const revalidate = 0; 14 | 15 | export default async function Page({ 16 | params, 17 | }: { 18 | params: { dashboardId: string; sectionId: string }; 19 | }): Promise { 20 | console.log("Edit Section:", params); 21 | 22 | let data: Section | null = await prisma.section.findUnique({ 23 | where: { 24 | id: params.sectionId, 25 | }, 26 | }); 27 | 28 | if (!data) return notFound(); 29 | 30 | return ; 31 | } 32 | -------------------------------------------------------------------------------- /src/app/dashboards/[dashboardId]/sections/[sectionId]/widgets/[widgetId]/edit/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { notFound } from "next/navigation"; 3 | 4 | import type { SectionModel } from "@/types/section.type"; 5 | import type { WidgetModel } from "@/types/widget.type"; 6 | import { EditWidget } from "@/components/dashboard/editors/Widget"; 7 | import { HomeAssistantProvider } from "@/providers/HomeAssistantProvider"; 8 | import { prisma } from "@/utils/prisma"; 9 | import { widgetGetData } from "@/utils/serverActions/widget"; 10 | 11 | export const metadata: Metadata = { 12 | title: "Edit Widget | Home Panel", 13 | description: "Edit Widget - Home Panel", 14 | }; 15 | 16 | export const revalidate = 0; 17 | 18 | export default async function Page({ 19 | params, 20 | }: { 21 | params: { dashboardId: string; sectionId: string; widgetId: string }; 22 | }): Promise { 23 | console.log("Edit Widget:", params); 24 | 25 | const section: SectionModel | null = (await prisma.section.findUnique({ 26 | include: { 27 | widgets: { 28 | where: { 29 | id: params.widgetId, 30 | }, 31 | }, 32 | }, 33 | where: { 34 | id: params.sectionId, 35 | }, 36 | })) as SectionModel | null; 37 | 38 | if (!section) return notFound(); 39 | 40 | for (const widget of section.widgets) { 41 | widget.data = await widgetGetData(widget.id, widget.type); 42 | } 43 | 44 | return ( 45 | 46 | 47 | 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /src/app/dashboards/[dashboardId]/sections/[sectionId]/widgets/new/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { getServerSession } from "next-auth/next"; 3 | 4 | import { AccessDenied } from "@/components/AccessDenied"; 5 | import { authOptions } from "@/utils/prisma"; 6 | import { WidgetNew } from "@/components/dashboard/new/Widget"; 7 | 8 | export const metadata: Metadata = { 9 | title: "New Widget | Home Panel", 10 | description: "New Widget - Home Panel", 11 | }; 12 | 13 | export const revalidate = 0; 14 | 15 | export default async function Page({ 16 | params, 17 | }: { 18 | params: { dashboardId: string; sectionId: string }; 19 | }): Promise { 20 | const session = await getServerSession(authOptions); 21 | if (!session) return ; 22 | return ( 23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/app/dashboards/[dashboardId]/sections/new/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { getServerSession } from "next-auth/next"; 3 | 4 | import { AccessDenied } from "@/components/AccessDenied"; 5 | import { authOptions } from "@/utils/prisma"; 6 | import { SectionNew } from "@/components/dashboard/new/Section"; 7 | 8 | export const metadata: Metadata = { 9 | title: "New Section | Home Panel", 10 | description: "New Section - Home Panel", 11 | }; 12 | 13 | export const revalidate = 0; 14 | 15 | export default async function Page({ 16 | params, 17 | }: { 18 | params: { dashboardId: string }; 19 | }): Promise { 20 | const session = await getServerSession(authOptions); 21 | if (!session) return ; 22 | return ; 23 | } 24 | -------------------------------------------------------------------------------- /src/app/dashboards/new/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { getServerSession } from "next-auth/next"; 3 | 4 | import { AccessDenied } from "@/components/AccessDenied"; 5 | import { DashboardNew } from "@/components/dashboard/new/Dashboard"; 6 | import { authOptions } from "@/utils/prisma"; 7 | 8 | export const metadata: Metadata = { 9 | title: "New Dashboard | Home Panel", 10 | description: "New Dashboard - Home Panel", 11 | }; 12 | 13 | export const revalidate = false; 14 | 15 | export default async function Page(): Promise { 16 | const session = await getServerSession(authOptions); 17 | if (!session) return ; 18 | return ; 19 | } 20 | -------------------------------------------------------------------------------- /src/app/dashboards/page.tsx: -------------------------------------------------------------------------------- 1 | import type { 2 | Dashboard as DashboardModel, 3 | User as UserModel, 4 | } from "@prisma/client"; 5 | import type { Metadata } from "next"; 6 | import { getServerSession } from "next-auth/next"; 7 | import { redirect } from "next/navigation"; 8 | import { revalidatePath } from "next/cache"; 9 | 10 | import { AccessDenied } from "@/components/AccessDenied"; 11 | import { authOptions, prisma } from "@/utils/prisma"; 12 | import { WidgetType } from "@/types/widget.type"; 13 | 14 | export const metadata: Metadata = { 15 | title: "Dashboards | Home Panel", 16 | description: "Dashboards - Home Panel", 17 | }; 18 | 19 | export const revalidate = 0; 20 | 21 | export default async function Page(): Promise { 22 | const session = await getServerSession(authOptions); 23 | if (!session) return ; 24 | 25 | const user: UserModel = await prisma.user.findUniqueOrThrow({ 26 | where: { 27 | username: session.user!.email!, 28 | }, 29 | }); 30 | 31 | let dashboard: DashboardModel | null = await prisma.dashboard.findFirst(); 32 | if (!dashboard) { 33 | console.log("Creating default dashboard.."); 34 | dashboard = await prisma.dashboard.create({ 35 | data: { 36 | name: "Default", 37 | description: "Default dashboard", 38 | sections: { 39 | create: [ 40 | { 41 | title: "Example", 42 | subtitle: "Example section", 43 | width: "480px", 44 | widgets: { 45 | create: [ 46 | { 47 | title: "Example", 48 | type: WidgetType.Markdown, 49 | markdown: { 50 | create: { 51 | content: "Example widget", 52 | }, 53 | }, 54 | }, 55 | ], 56 | }, 57 | }, 58 | ], 59 | }, 60 | user: { 61 | connect: { 62 | id: user?.id, 63 | }, 64 | }, 65 | }, 66 | }); 67 | revalidatePath(`/dashboards/${dashboard.id}`); 68 | revalidatePath(`/dashboards/${dashboard.id}/edit`); 69 | return redirect(`/dashboards/${dashboard.id}/edit`); 70 | } 71 | 72 | return redirect(`/dashboards/${dashboard.id}`); 73 | } 74 | -------------------------------------------------------------------------------- /src/app/error.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useEffect } from "react"; 3 | 4 | import styles from "@/app/page.module.css"; 5 | import { Button, Stack, Typography } from "@mui/material"; 6 | 7 | export default function Error({ 8 | error, 9 | reset, 10 | }: { 11 | error: Error; 12 | reset: () => void; 13 | }): JSX.Element { 14 | useEffect(() => { 15 | console.error(error); 16 | }, [error, reset]); 17 | 18 | return ( 19 |
20 | 28 | 29 | Something went wrong! 30 | 31 | 34 | 35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --max-width: 1100px; 3 | --border-radius: 8px; 4 | --font-mono: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono", 5 | "Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro", 6 | "Fira Mono", "Droid Sans Mono", "Courier New", monospace; 7 | 8 | --foreground-rgb: 0, 0, 0; 9 | 10 | --primary-glow: conic-gradient( 11 | from 180deg at 50% 50%, 12 | #16abff33 0deg, 13 | #0885ff33 55deg, 14 | #54d6ff33 120deg, 15 | #0071ff33 160deg, 16 | transparent 360deg 17 | ); 18 | --secondary-glow: radial-gradient( 19 | rgba(255, 255, 255, 1), 20 | rgba(255, 255, 255, 0) 21 | ); 22 | 23 | --tile-start-rgb: 239, 245, 249; 24 | --tile-end-rgb: 228, 232, 233; 25 | --tile-border: conic-gradient( 26 | #00000080, 27 | #00000040, 28 | #00000030, 29 | #00000020, 30 | #00000010, 31 | #00000010, 32 | #00000080 33 | ); 34 | 35 | --callout-rgb: 238, 240, 241; 36 | --callout-border-rgb: 172, 175, 176; 37 | --card-rgb: 180, 185, 188; 38 | --card-border-rgb: 131, 134, 135; 39 | } 40 | 41 | @media (prefers-color-scheme: dark) { 42 | :root { 43 | --foreground-rgb: 255, 255, 255; 44 | 45 | --primary-glow: radial-gradient(rgba(1, 65, 255, 0.4), rgba(1, 65, 255, 0)); 46 | --secondary-glow: linear-gradient( 47 | to bottom right, 48 | rgba(1, 65, 255, 0), 49 | rgba(1, 65, 255, 0), 50 | rgba(1, 65, 255, 0.3) 51 | ); 52 | 53 | --tile-start-rgb: 2, 13, 46; 54 | --tile-end-rgb: 2, 5, 19; 55 | --tile-border: conic-gradient( 56 | #ffffff80, 57 | #ffffff40, 58 | #ffffff30, 59 | #ffffff20, 60 | #ffffff10, 61 | #ffffff10, 62 | #ffffff80 63 | ); 64 | 65 | --callout-rgb: 20, 20, 20; 66 | --callout-border-rgb: 108, 108, 108; 67 | } 68 | } 69 | 70 | ::-webkit-scrollbar { 71 | height: 0.4rem; 72 | width: 0.4rem; 73 | } 74 | 75 | ::-webkit-scrollbar-track { 76 | background: transparent; 77 | } 78 | 79 | ::-webkit-scrollbar-thumb { 80 | background: rgba(60, 60, 60, 0.8); 81 | border-radius: 0.5rem; 82 | } 83 | 84 | * { 85 | box-sizing: border-box; 86 | padding: 0; 87 | margin: 0; 88 | } 89 | 90 | html, 91 | body { 92 | min-height: 100vh; 93 | max-height: 100vh; 94 | max-width: 100vw; 95 | overflow: hidden; 96 | } 97 | 98 | body { 99 | color: rgb(var(--foreground-rgb)); 100 | } 101 | 102 | a { 103 | color: inherit; 104 | text-decoration: none; 105 | } 106 | 107 | @media (prefers-color-scheme: dark) { 108 | html { 109 | color-scheme: dark; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { 2 | Dashboard as DashboardModel, 3 | User as UserModel, 4 | } from "@prisma/client"; 5 | import type { Metadata } from "next"; 6 | import { getServerSession } from "next-auth/next"; 7 | 8 | import { AccessDenied } from "@/components/AccessDenied"; 9 | import { AuthProvider } from "@/providers/AuthProvider"; 10 | import { DrawerComponent as Drawer } from "@/components/Drawer"; 11 | import { MUIProvider } from "@/providers/MUIProvider"; 12 | import { authOptions, prisma } from "@/utils/prisma"; 13 | 14 | import "@/app/globals.css"; 15 | 16 | export const metadata: Metadata = { 17 | title: "Home Panel", 18 | description: "Home Panel", 19 | authors: [ 20 | { 21 | name: "Aidan Timson (Timmo)", 22 | url: "https://home-panel.timmo.dev", 23 | }, 24 | ], 25 | }; 26 | 27 | export default async function RootLayout({ 28 | children, 29 | }: { 30 | children: React.ReactNode; 31 | }): Promise { 32 | const session = await getServerSession(authOptions); 33 | 34 | let dashboards: Array = []; 35 | if (session?.user?.email) { 36 | const user: UserModel = await prisma.user.findUniqueOrThrow({ 37 | where: { 38 | username: session.user.email, 39 | }, 40 | }); 41 | dashboards = await prisma.dashboard.findMany({ 42 | where: { 43 | userId: user.id, 44 | }, 45 | }); 46 | } 47 | 48 | return ( 49 | 50 | 51 | 52 | 53 | 54 | {session ? children : } 55 | 56 | 57 | 58 | 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /src/app/loading.tsx: -------------------------------------------------------------------------------- 1 | import { SkeletonDashboard } from "@/components/skeletons/Dashboard"; 2 | 3 | export default async function Loading(): Promise { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/page.module.css: -------------------------------------------------------------------------------- 1 | .main { 2 | position: absolute; 3 | font-family: var(--inter-font); 4 | display: flex; 5 | flex-wrap: nowrap; 6 | flex-direction: row; 7 | align-content: flex-start; 8 | justify-content: space-between; 9 | height: 100%; 10 | width: 100%; 11 | overflow: hidden; 12 | } 13 | 14 | .main section { 15 | flex: 1 0 50%; 16 | } 17 | 18 | .main h1 { 19 | width: 100%; 20 | } 21 | 22 | .code { 23 | font-weight: 700; 24 | font-family: var(--font-mono); 25 | } 26 | 27 | .card { 28 | width: 100%; 29 | display: flex; 30 | flex-direction: row; 31 | justify-content: flex-start; 32 | align-items: flex-start; 33 | margin-bottom: 0.4rem; 34 | padding: 0.4rem 0.4rem; 35 | transition: background 200ms, border 200ms; 36 | } 37 | 38 | .card img { 39 | object-fit: cover; 40 | } 41 | 42 | .card .text { 43 | flex: 1 1 auto; 44 | margin-left: 0.6rem; 45 | display: inline-block; 46 | transition: transform 200ms; 47 | } 48 | 49 | .card h2 { 50 | font-weight: 600; 51 | margin-bottom: 0.1rem; 52 | } 53 | 54 | .card p { 55 | margin: 0; 56 | padding-left: 0.1rem; 57 | opacity: 0.6; 58 | font-size: 0.9rem; 59 | line-height: 1.5; 60 | max-width: 34ch; 61 | } 62 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | 3 | export const revalidate = 0; 4 | 5 | export default async function Page(): Promise { 6 | return redirect("/dashboards"); 7 | } 8 | -------------------------------------------------------------------------------- /src/components/AccessDenied.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { Button, Stack, Typography } from "@mui/material"; 3 | import { signIn } from "next-auth/react"; 4 | 5 | export function AccessDenied(): JSX.Element { 6 | return ( 7 | 15 | 16 | Access Denied 17 | 18 | 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/components/Drawer.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import type { Dashboard as DashboardModel } from "@prisma/client"; 3 | import { 4 | AppBar, 5 | Avatar, 6 | Divider, 7 | Drawer, 8 | IconButton, 9 | List, 10 | ListItemButton, 11 | ListItemIcon, 12 | ListItemSecondaryAction, 13 | ListItemText, 14 | Skeleton, 15 | Stack, 16 | Toolbar, 17 | Typography, 18 | } from "@mui/material"; 19 | import { 20 | AddRounded, 21 | ArrowBackRounded, 22 | DashboardRounded, 23 | MenuRounded, 24 | SettingsRounded, 25 | } from "@mui/icons-material"; 26 | import { signIn, signOut, useSession } from "next-auth/react"; 27 | import { usePathname, useRouter } from "next/navigation"; 28 | import { useEffect, useState } from "react"; 29 | import Link from "next/link"; 30 | 31 | export function DrawerComponent({ 32 | dashboards, 33 | }: { 34 | dashboards: Array; 35 | }): JSX.Element { 36 | const [drawerOpen, setDrawerOpen] = useState(false); 37 | const { data: session, status } = useSession(); 38 | const pathname = usePathname(); 39 | const router = useRouter(); 40 | 41 | const dashboardPath = 42 | pathname.startsWith("/dashboards") && 43 | pathname.split("/").slice(0, 3).join("/"); 44 | 45 | useEffect(() => { 46 | setDrawerOpen(false); 47 | }, [pathname]); 48 | 49 | console.log(pathname); 50 | 51 | return ( 52 | <> 53 | 63 | 70 | {pathname.endsWith("/edit") ? ( 71 | router.back()}> 72 | 73 | 74 | ) : ( 75 | !drawerOpen && ( 76 | setDrawerOpen(!drawerOpen)} 79 | > 80 | 81 | 82 | ) 83 | )} 84 | 85 | 86 | 93 | theme.transitions.create("width", { 94 | easing: theme.transitions.easing.sharp, 95 | duration: theme.transitions.duration.enteringScreen, 96 | }), 97 | flexShrink: 0, 98 | [`& .MuiDrawer-paper`]: { 99 | width: 260, 100 | boxSizing: "border-box", 101 | }, 102 | }} 103 | onClose={() => setDrawerOpen(false)} 104 | > 105 | 106 | setDrawerOpen(!drawerOpen)} 109 | > 110 | 111 | 112 | theme.zIndex.drawer + 1, 121 | }} 122 | > 123 | Home Panel 124 | 125 | 126 | 127 | 131 | 132 | {dashboards.map((dashboard: DashboardModel) => ( 133 | 139 | 143 | setDrawerOpen(false)} 149 | sx={{ height: "100%", flexGrow: 1 }} 150 | > 151 | 152 | 153 | 154 | 158 | 159 | 160 | 161 | setDrawerOpen(false)} 168 | sx={{ height: "100%" }} 169 | > 170 | 171 | 172 | 173 | 174 | ))} 175 | 176 | 177 | 178 | 179 | 180 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | {status === "loading" ? ( 191 | 192 | ) : ( 193 | 195 | status === "authenticated" ? signOut() : signIn() 196 | } 197 | > 198 | 199 | 205 | 206 | 214 | 215 | )} 216 | 217 | 218 | 219 | ); 220 | } 221 | -------------------------------------------------------------------------------- /src/components/dashboard/editors/Dashboard.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import type { 3 | Dashboard as DashboardModel, 4 | HeaderItem as HeaderItemModel, 5 | HomeAssistant as HomeAssistantModel, 6 | } from "@prisma/client"; 7 | import { 8 | Card, 9 | CardContent, 10 | Divider, 11 | TextField, 12 | Typography, 13 | Unstable_Grid2 as Grid2, 14 | Button, 15 | } from "@mui/material"; 16 | import { MuiChipsInput } from "mui-chips-input"; 17 | import { useMemo } from "react"; 18 | import { useRouter } from "next/navigation"; 19 | 20 | import { 21 | dashboardDelete, 22 | dashboardHeaderUpdate, 23 | dashboardUpdate, 24 | } from "@/utils/serverActions/dashboard"; 25 | import { DeleteRounded } from "@mui/icons-material"; 26 | import { homeAssistantUpdateConfig } from "@/utils/serverActions/homeAssistant"; 27 | 28 | export function EditDashboard({ 29 | dashboardConfig, 30 | headerItemsConfig, 31 | homeAssistantConfig, 32 | }: { 33 | dashboardConfig: DashboardModel; 34 | headerItemsConfig: Array; 35 | homeAssistantConfig: HomeAssistantModel; 36 | }): JSX.Element { 37 | const router = useRouter(); 38 | 39 | const headerItems = useMemo>( 40 | () => headerItemsConfig.map((item) => item.type), 41 | [headerItemsConfig] 42 | ); 43 | 44 | return ( 45 | 54 | 55 | Edit Dashboard 56 | 57 | 63 | await dashboardUpdate( 64 | dashboardConfig.id, 65 | e.target.name, 66 | e.target.value 67 | ) 68 | } 69 | /> 70 | 76 | await dashboardUpdate( 77 | dashboardConfig.id, 78 | e.target.name, 79 | e.target.value 80 | ) 81 | } 82 | /> 83 | 84 | 85 | Home Assistant 86 | 87 | 93 | await homeAssistantUpdateConfig(dashboardConfig.id, { 94 | [e.target.name]: e.target.value, 95 | }) 96 | } 97 | /> 98 | 99 | 100 | Header Items 101 | 102 | 0 ? "Double click to edit an item" : "" 109 | } 110 | onChange={async (data: Array) => { 111 | await dashboardHeaderUpdate(dashboardConfig.id, data); 112 | }} 113 | /> 114 | 115 | 127 | 128 | 129 | 130 | ); 131 | } 132 | -------------------------------------------------------------------------------- /src/components/dashboard/editors/Section.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import type { Section as SectionModel } from "@prisma/client"; 3 | import { 4 | Typography, 5 | Card, 6 | CardContent, 7 | Unstable_Grid2 as Grid2, 8 | TextField, 9 | } from "@mui/material"; 10 | 11 | import { Section } from "@/components/dashboard/views/Section"; 12 | import { sectionUpdate } from "@/utils/serverActions/section"; 13 | 14 | export function EditSection({ 15 | dashboardId, 16 | data, 17 | }: { 18 | dashboardId: string; 19 | data: SectionModel; 20 | }): JSX.Element { 21 | return ( 22 | 32 | 33 | 34 | 35 | Edit Section 36 | 37 | 43 | await sectionUpdate( 44 | dashboardId, 45 | data.id, 46 | e.target.name, 47 | e.target.value 48 | ) 49 | } 50 | /> 51 | 57 | await sectionUpdate( 58 | dashboardId, 59 | data.id, 60 | e.target.name, 61 | e.target.value 62 | ) 63 | } 64 | /> 65 | 71 | await sectionUpdate( 72 | dashboardId, 73 | data.id, 74 | e.target.name, 75 | e.target.value 76 | ) 77 | } 78 | /> 79 | 80 | 81 | 82 | 83 | 84 |
85 | 86 | 87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /src/components/dashboard/editors/Widget.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useMemo } from "react"; 3 | import { 4 | Card, 5 | CardContent, 6 | Skeleton, 7 | Typography, 8 | Unstable_Grid2 as Grid2, 9 | } from "@mui/material"; 10 | 11 | import type { SectionModel } from "@/types/section.type"; 12 | import type { WidgetModel } from "@/types/widget.type"; 13 | import { EditWidgetBase } from "@/components/dashboard/editors/widgets/Base"; 14 | import { EditWidgetFrame } from "@/components/dashboard/editors/widgets/Frame"; 15 | import { EditWidgetHomeAssistant } from "./widgets/HomeAssistant"; 16 | import { EditWidgetImage } from "@/components/dashboard/editors/widgets/Image"; 17 | import { EditWidgetMarkdown } from "@/components/dashboard/editors/widgets/Markdown"; 18 | import { Section } from "@/components/dashboard/views/Section"; 19 | import { WidgetType } from "@/types/widget.type"; 20 | 21 | export function EditWidget({ 22 | dashboardId, 23 | section, 24 | }: { 25 | dashboardId: string; 26 | section: SectionModel; 27 | }): JSX.Element { 28 | const widget: WidgetModel = section.widgets[0]; 29 | const { id, data, position, sectionId, title, type, width } = widget; 30 | 31 | const widgetView: JSX.Element = useMemo(() => { 32 | if (!data) return ; 33 | switch (type) { 34 | case WidgetType.Frame: 35 | return ( 36 | 41 | ); 42 | case WidgetType.HomeAssistant: 43 | return ( 44 | 49 | ); 50 | case WidgetType.Image: 51 | return ( 52 | 57 | ); 58 | case WidgetType.Markdown: 59 | return ( 60 | 65 | ); 66 | default: 67 | return
Unknown widget type
; 68 | } 69 | }, [dashboardId, type, sectionId, data]); 70 | 71 | return ( 72 | 82 | 83 | 84 | 85 | Edit Widget 86 | 87 | 88 | {widgetView} 89 | 90 | 91 | 92 | 93 | 94 | {data && ( 95 |
111 | )} 112 | 113 | 114 | ); 115 | } 116 | -------------------------------------------------------------------------------- /src/components/dashboard/editors/widgets/Base.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import type { Widget } from "@prisma/client"; 3 | import { Autocomplete, TextField } from "@mui/material"; 4 | 5 | import { widgetUpdate } from "@/utils/serverActions/widget"; 6 | import { WidgetType } from "@/types/widget.type"; 7 | 8 | export function EditWidgetBase({ 9 | dashboardId, 10 | widget, 11 | }: { 12 | dashboardId: string; 13 | widget: Widget; 14 | }): JSX.Element { 15 | const { id, title, type, width } = widget; 16 | return ( 17 | <> 18 | 24 | await widgetUpdate(dashboardId, id, e.target.name, e.target.value) 25 | } 26 | /> 27 | 33 | await widgetUpdate(dashboardId, id, e.target.name, e.target.value) 34 | } 35 | /> 36 | { 40 | // Split camelCase to words and capitalize first letter 41 | return option 42 | .replace(/([A-Z])/g, " $1") 43 | .replace(/^./, (str) => str.toUpperCase()); 44 | }} 45 | renderInput={(params) => ( 46 | 53 | )} 54 | onChange={async (_, value: string | null) => { 55 | if (!value) return; 56 | await widgetUpdate(dashboardId, id, "type", value); 57 | }} 58 | /> 59 | 60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /src/components/dashboard/editors/widgets/Frame.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { TextField } from "@mui/material"; 3 | import { WidgetFrame } from "@prisma/client"; 4 | 5 | import { widgetFrameUpdate } from "@/utils/serverActions/widget"; 6 | 7 | export function EditWidgetFrame({ 8 | dashboardId, 9 | sectionId, 10 | widgetData, 11 | }: { 12 | dashboardId: string; 13 | sectionId: string; 14 | widgetData: WidgetFrame; 15 | }): JSX.Element { 16 | return ( 17 | <> 18 | 24 | await widgetFrameUpdate( 25 | dashboardId, 26 | sectionId, 27 | widgetData.widgetId, 28 | e.target.name, 29 | e.target.value 30 | ) 31 | } 32 | /> 33 | 39 | await widgetFrameUpdate( 40 | dashboardId, 41 | sectionId, 42 | widgetData.widgetId, 43 | e.target.name, 44 | e.target.value 45 | ) 46 | } 47 | /> 48 | 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /src/components/dashboard/editors/widgets/HomeAssistant.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import type { HassEntity } from "home-assistant-js-websocket"; 3 | import { useMemo } from "react"; 4 | import { WidgetHomeAssistant } from "@prisma/client"; 5 | import { 6 | Autocomplete, 7 | FormControlLabel, 8 | Skeleton, 9 | Switch, 10 | TextField, 11 | } from "@mui/material"; 12 | 13 | import { widgetHomeAssistantUpdate } from "@/utils/serverActions/widget"; 14 | import { useHomeAssistant } from "@/providers/HomeAssistantProvider"; 15 | 16 | export function EditWidgetHomeAssistant({ 17 | dashboardId, 18 | sectionId, 19 | widgetData, 20 | }: { 21 | dashboardId: string; 22 | sectionId: string; 23 | widgetData: WidgetHomeAssistant; 24 | }): JSX.Element { 25 | const homeAssistant = useHomeAssistant(); 26 | 27 | const entities = useMemo | undefined>(() => { 28 | if (!homeAssistant.entities) return; 29 | return Object.values(homeAssistant.entities).sort((a, b) => 30 | a.entity_id.localeCompare(b.entity_id) 31 | ); 32 | }, [homeAssistant.entities]); 33 | 34 | const defaultEntity = useMemo(() => { 35 | if (!homeAssistant.entities) return; 36 | return homeAssistant.entities[widgetData.entityId]; 37 | }, [widgetData.entityId, homeAssistant.entities]); 38 | 39 | const entityAttributes = useMemo | undefined>(() => { 40 | if (!defaultEntity) return; 41 | return Object.keys(defaultEntity.attributes) 42 | .concat(["last_changed", "last_updated"]) 43 | .sort((a, b) => a.localeCompare(b)); 44 | }, [defaultEntity]); 45 | 46 | if (!homeAssistant.entities || !entities) 47 | return ; 48 | 49 | return ( 50 | <> 51 | { 55 | return `${option.entity_id} - ${option.attributes.friendly_name}`; 56 | }} 57 | groupBy={(option: HassEntity) => { 58 | return option.entity_id.split(".")[0]; 59 | }} 60 | renderInput={(params) => ( 61 | 68 | )} 69 | onChange={async (_, value: HassEntity | null) => { 70 | if (!value) return; 71 | await widgetHomeAssistantUpdate( 72 | dashboardId, 73 | sectionId, 74 | widgetData.widgetId, 75 | "entityId", 76 | value.entity_id 77 | ); 78 | }} 79 | /> 80 | 86 | await widgetHomeAssistantUpdate( 87 | dashboardId, 88 | sectionId, 89 | widgetData.widgetId, 90 | e.target.name, 91 | e.target.checked 92 | ) 93 | } 94 | /> 95 | } 96 | label="Show Name" 97 | /> 98 | 104 | await widgetHomeAssistantUpdate( 105 | dashboardId, 106 | sectionId, 107 | widgetData.widgetId, 108 | e.target.name, 109 | e.target.checked 110 | ) 111 | } 112 | /> 113 | } 114 | label="Show State" 115 | /> 116 | 122 | await widgetHomeAssistantUpdate( 123 | dashboardId, 124 | sectionId, 125 | widgetData.widgetId, 126 | e.target.name, 127 | e.target.checked 128 | ) 129 | } 130 | /> 131 | } 132 | label="Show Icon" 133 | /> 134 | 140 | await widgetHomeAssistantUpdate( 141 | dashboardId, 142 | sectionId, 143 | widgetData.widgetId, 144 | e.target.name, 145 | e.target.value 146 | ) 147 | } 148 | /> 149 | 155 | await widgetHomeAssistantUpdate( 156 | dashboardId, 157 | sectionId, 158 | widgetData.widgetId, 159 | e.target.name, 160 | e.target.value 161 | ) 162 | } 163 | /> 164 | ( 168 | 174 | )} 175 | onChange={async (_, value: string | null) => { 176 | if (!value) return; 177 | await widgetHomeAssistantUpdate( 178 | dashboardId, 179 | sectionId, 180 | widgetData.widgetId, 181 | "secondaryInfo", 182 | value 183 | ); 184 | }} 185 | /> 186 | 187 | ); 188 | } 189 | -------------------------------------------------------------------------------- /src/components/dashboard/editors/widgets/Image.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { TextField } from "@mui/material"; 3 | import { WidgetImage } from "@prisma/client"; 4 | 5 | import { widgetImageUpdate } from "@/utils/serverActions/widget"; 6 | 7 | export function EditWidgetImage({ 8 | dashboardId, 9 | sectionId, 10 | widgetData, 11 | }: { 12 | dashboardId: string; 13 | sectionId: string; 14 | widgetData: WidgetImage; 15 | }): JSX.Element { 16 | return ( 17 | <> 18 | 24 | await widgetImageUpdate( 25 | dashboardId, 26 | sectionId, 27 | widgetData.widgetId, 28 | e.target.name, 29 | e.target.value 30 | ) 31 | } 32 | /> 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/components/dashboard/editors/widgets/Markdown.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { TextField } from "@mui/material"; 3 | import { WidgetMarkdown } from "@prisma/client"; 4 | 5 | import { widgetMarkdownUpdate } from "@/utils/serverActions/widget"; 6 | 7 | export function EditWidgetMarkdown({ 8 | dashboardId, 9 | sectionId, 10 | widgetData, 11 | }: { 12 | dashboardId: string; 13 | sectionId: string; 14 | widgetData: WidgetMarkdown; 15 | }): JSX.Element { 16 | return ( 17 | <> 18 | 24 | await widgetMarkdownUpdate( 25 | dashboardId, 26 | sectionId, 27 | widgetData.widgetId, 28 | e.target.name, 29 | e.target.value 30 | ) 31 | } 32 | /> 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/components/dashboard/new/Dashboard.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useEffect } from "react"; 3 | import { useRouter } from "next/navigation"; 4 | import { useSession } from "next-auth/react"; 5 | 6 | import { dashboardCreate } from "@/utils/serverActions/dashboard"; 7 | import { SkeletonDashboard } from "@/components/skeletons/Dashboard"; 8 | 9 | export function DashboardNew(): JSX.Element { 10 | const session = useSession(); 11 | const router = useRouter(); 12 | 13 | useEffect(() => { 14 | (async () => { 15 | const newDashboard = await dashboardCreate(session.data!.user!.email!); 16 | router.replace(`/dashboards/${newDashboard.id}/edit`); 17 | })(); 18 | }, [router, session.data]); 19 | 20 | return ; 21 | } 22 | -------------------------------------------------------------------------------- /src/components/dashboard/new/Section.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useEffect } from "react"; 3 | import { useRouter } from "next/navigation"; 4 | 5 | import { sectionCreate } from "@/utils/serverActions/section"; 6 | import { SkeletonDashboard } from "@/components/skeletons/Dashboard"; 7 | 8 | export function SectionNew({ 9 | dashboardId, 10 | }: { 11 | dashboardId: string; 12 | }): JSX.Element { 13 | const router = useRouter(); 14 | 15 | useEffect(() => { 16 | (async () => { 17 | const newSection = await sectionCreate(dashboardId); 18 | router.replace(`/dashboards/${dashboardId}/sections/${newSection.id}/edit`); 19 | })(); 20 | }, [dashboardId, router]); 21 | 22 | return ; 23 | } 24 | -------------------------------------------------------------------------------- /src/components/dashboard/new/Widget.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useEffect } from "react"; 3 | import { useRouter } from "next/navigation"; 4 | 5 | import { widgetCreate } from "@/utils/serverActions/widget"; 6 | import { SkeletonDashboard } from "@/components/skeletons/Dashboard"; 7 | 8 | export function WidgetNew({ 9 | dashboardId, 10 | sectionId, 11 | }: { 12 | dashboardId: string; 13 | sectionId: string; 14 | }): JSX.Element { 15 | const router = useRouter(); 16 | 17 | useEffect(() => { 18 | (async () => { 19 | const newWidget = await widgetCreate(dashboardId, sectionId); 20 | router.replace( 21 | `/dashboards/${dashboardId}/sections/${sectionId}/widgets/${newWidget.id}/edit` 22 | ); 23 | })(); 24 | }, [dashboardId, router, sectionId]); 25 | 26 | return ; 27 | } 28 | -------------------------------------------------------------------------------- /src/components/dashboard/views/Dashboard.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { Unstable_Grid2 as Grid2, Stack } from "@mui/material"; 3 | 4 | import type { DashboardModel } from "@/types/dashboard.type"; 5 | import { Heading } from "@/components/dashboard/views/Heading"; 6 | import { HomeAssistantProvider } from "@/providers/HomeAssistantProvider"; 7 | 8 | export function Dashboard({ 9 | children, 10 | dashboard, 11 | }: { 12 | children: React.ReactNode; 13 | dashboard: DashboardModel; 14 | }): JSX.Element { 15 | return ( 16 | 17 | 29 | 30 | 42 | {children} 43 | 44 | 45 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /src/components/dashboard/views/Heading.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { HeaderItem as HeaderItemModel } from "@prisma/client"; 3 | import { Unstable_Grid2 as Grid2, Typography } from "@mui/material"; 4 | import Moment from "react-moment"; 5 | 6 | import type { DashboardModel } from "@/types/dashboard.type"; 7 | import { HeaderItemType } from "@/types/dashboard.type"; 8 | 9 | function HeaderItem({ item }: { item: HeaderItemModel }): JSX.Element | null { 10 | switch (item.type) { 11 | case HeaderItemType.DateTime: 12 | return ( 13 | <> 14 | 19 | 20 | 25 | 26 | 27 | 28 | 33 | 34 | 35 | 36 | ); 37 | case HeaderItemType.Date: 38 | return ( 39 | 44 | 45 | 46 | ); 47 | case HeaderItemType.Time: 48 | return ( 49 | 54 | 55 | 56 | ); 57 | default: 58 | return null; 59 | } 60 | } 61 | 62 | export function Heading({ 63 | dashboard, 64 | }: { 65 | dashboard: DashboardModel; 66 | }): JSX.Element { 67 | return ( 68 | 80 | {dashboard.headerItems.map((item: HeaderItemModel) => ( 81 | 82 | 83 | 84 | ))} 85 | 86 | ); 87 | } 88 | -------------------------------------------------------------------------------- /src/components/dashboard/views/Section.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { 3 | AddRounded, 4 | ArrowBackRounded, 5 | ArrowForwardRounded, 6 | CheckRounded, 7 | DeleteRounded, 8 | EditRounded, 9 | } from "@mui/icons-material"; 10 | import { Typography, Unstable_Grid2 as Grid2, IconButton } from "@mui/material"; 11 | import { useRouter } from "next/navigation"; 12 | import { useState } from "react"; 13 | 14 | import type { SectionModel } from "@/types/section.type"; 15 | import type { WidgetModel } from "@/types/widget.type"; 16 | import { SectionAction } from "@/types/section.type"; 17 | import { sectionDelete, sectionUpdate } from "@/utils/serverActions/section"; 18 | import { Widget } from "@/components/dashboard/views/Widget"; 19 | import Link from "next/link"; 20 | 21 | export function Section({ data }: { data: SectionModel }): JSX.Element { 22 | const [editing, setEditing] = useState(false); 23 | const router = useRouter(); 24 | 25 | async function handleInteraction(action: SectionAction): Promise { 26 | console.log("Handle interaction:", action); 27 | switch (action) { 28 | case SectionAction.Delete: 29 | console.log("Delete section"); 30 | await sectionDelete(data.dashboardId, data.id); 31 | break; 32 | case SectionAction.Edit: 33 | console.log("Edit section"); 34 | router.push(`/dashboards/${data.dashboardId}/sections/${data.id}/edit`); 35 | break; 36 | case SectionAction.MoveDown: 37 | console.log("Move section down"); 38 | await sectionUpdate( 39 | data.dashboardId, 40 | data.id, 41 | "position", 42 | data.position + 15 43 | ); 44 | break; 45 | case SectionAction.MoveUp: 46 | console.log("Move section up"); 47 | await sectionUpdate( 48 | data.dashboardId, 49 | data.id, 50 | "position", 51 | data.position - 15 52 | ); 53 | break; 54 | } 55 | } 56 | 57 | return ( 58 | <> 59 | 70 | 78 | {data.title && {data.title}} 79 | 90 | {editing && ( 91 | <> 92 | handleInteraction(SectionAction.MoveUp)} 96 | > 97 | 98 | 99 | handleInteraction(SectionAction.MoveDown)} 103 | > 104 | 105 | 106 | handleInteraction(SectionAction.Edit)} 110 | > 111 | 112 | 113 | handleInteraction(SectionAction.Delete)} 117 | > 118 | 119 | 120 | 121 | )} 122 | setEditing(!editing)} 126 | > 127 | {editing ? ( 128 | 129 | ) : ( 130 | 131 | )} 132 | 133 | 134 | 135 | 147 | {data.widgets.map((widget: WidgetModel) => ( 148 | 164 | 169 | 170 | ))} 171 | {editing && ( 172 | 173 | 176 | 177 | 178 | 179 | 180 | 181 | )} 182 | 183 | 184 | {editing && ( 185 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | )} 202 | 203 | ); 204 | } 205 | -------------------------------------------------------------------------------- /src/components/dashboard/views/Widget.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { Skeleton } from "@mui/material"; 3 | import { useCallback, useMemo, useState } from "react"; 4 | import { useRouter } from "next/navigation"; 5 | 6 | import type { WidgetActionFunction, WidgetModel } from "@/types/widget.type"; 7 | import { WidgetAction, WidgetType } from "@/types/widget.type"; 8 | import { WidgetBase } from "@/components/dashboard/views/widgets/Base"; 9 | import { WidgetChecklist } from "@/components/dashboard/views/widgets/Checklist"; 10 | import { widgetDelete, widgetUpdate } from "@/utils/serverActions/widget"; 11 | import { WidgetFrame } from "@/components/dashboard/views/widgets/Frame"; 12 | import { WidgetHomeAssistant } from "@/components/dashboard/views/widgets/HomeAssistant"; 13 | import { WidgetImage } from "@/components/dashboard/views/widgets/Image"; 14 | import { WidgetMarkdown } from "@/components/dashboard/views/widgets/Markdown"; 15 | 16 | export function Widget({ 17 | dashboardId, 18 | data, 19 | editing, 20 | }: { 21 | dashboardId: string; 22 | data: WidgetModel; 23 | editing: boolean; 24 | }): JSX.Element { 25 | const { id, position, sectionId, type } = data; 26 | const [expanded, setExpanded] = useState(false); 27 | const router = useRouter(); 28 | 29 | const handleInteraction: WidgetActionFunction = useCallback( 30 | async (action: WidgetAction): Promise => { 31 | console.log("Handle interaction:", action); 32 | 33 | switch (action) { 34 | case WidgetAction.Delete: 35 | await widgetDelete(dashboardId, id); 36 | break; 37 | case WidgetAction.Edit: 38 | router.push( 39 | `/dashboards/${dashboardId}/sections/${sectionId}/widgets/${id}/edit` 40 | ); 41 | break; 42 | case WidgetAction.MoveDown: 43 | await widgetUpdate(dashboardId, id, "position", position + 15); 44 | break; 45 | case WidgetAction.MoveUp: 46 | await widgetUpdate(dashboardId, id, "position", position - 15); 47 | break; 48 | case WidgetAction.ToggleExpanded: 49 | setExpanded(!expanded); 50 | break; 51 | } 52 | }, 53 | [dashboardId, id, position, sectionId, expanded, router] 54 | ); 55 | 56 | const widgetView: JSX.Element = useMemo(() => { 57 | if (!data) return ; 58 | switch (type) { 59 | case WidgetType.Checklist: 60 | return ( 61 | 66 | ); 67 | case WidgetType.Frame: 68 | return ; 69 | case WidgetType.HomeAssistant: 70 | return ( 71 | 77 | ); 78 | case WidgetType.Image: 79 | return ( 80 | 85 | ); 86 | case WidgetType.Markdown: 87 | return ; 88 | default: 89 | return
Unknown widget type
; 90 | } 91 | }, [ 92 | dashboardId, 93 | data, 94 | editing, 95 | expanded, 96 | handleInteraction, 97 | sectionId, 98 | type, 99 | ]); 100 | 101 | return ( 102 | 108 | {widgetView} 109 | 110 | ); 111 | } 112 | -------------------------------------------------------------------------------- /src/components/dashboard/views/widgets/Base.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import type { Widget as WidgetModel } from "@prisma/client"; 3 | import { 4 | Box, 5 | Card, 6 | CardActionArea, 7 | Typography, 8 | Unstable_Grid2 as Grid2, 9 | IconButton, 10 | Dialog, 11 | } from "@mui/material"; 12 | import { 13 | ArrowBackRounded, 14 | ArrowForwardRounded, 15 | DeleteRounded, 16 | EditRounded, 17 | } from "@mui/icons-material"; 18 | 19 | import type { WidgetActionFunction } from "@/types/widget.type"; 20 | import { WidgetAction, WidgetType } from "@/types/widget.type"; 21 | 22 | export function WidgetBase({ 23 | children, 24 | data, 25 | editing, 26 | expanded, 27 | handleInteraction, 28 | }: { 29 | children: Array | JSX.Element; 30 | data: WidgetModel; 31 | editing: boolean; 32 | expanded: boolean; 33 | handleInteraction: WidgetActionFunction; 34 | }): JSX.Element { 35 | const widget = ( 36 | 37 | {data.title && ( 38 | 39 | {data.title} 40 | 41 | )} 42 | 54 | {children} 55 | 56 | {editing && ( 57 | 63 | handleInteraction(WidgetAction.MoveUp)} 67 | > 68 | 69 | 70 | handleInteraction(WidgetAction.MoveDown)} 74 | > 75 | 76 | 77 | handleInteraction(WidgetAction.Edit)} 81 | > 82 | 83 | 84 | handleInteraction(WidgetAction.Delete)} 88 | > 89 | 90 | 91 | 92 | )} 93 | 94 | ); 95 | 96 | return ( 97 | <> 98 | {widget} 99 | {expanded && ( 100 | handleInteraction(WidgetAction.ToggleExpanded)} 104 | > 105 | {widget} 106 | 107 | )} 108 | 109 | ); 110 | } 111 | -------------------------------------------------------------------------------- /src/components/dashboard/views/widgets/Checklist.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import type { WidgetChecklistItem as WidgetChecklistItemModel } from "@prisma/client"; 3 | import { 4 | Button, 5 | Unstable_Grid2 as Grid2, 6 | IconButton, 7 | InputAdornment, 8 | TextField, 9 | } from "@mui/material"; 10 | import { 11 | DeleteOutlineRounded, 12 | CheckBoxOutlineBlankRounded, 13 | CheckBoxRounded, 14 | AddRounded, 15 | } from "@mui/icons-material"; 16 | import { useState } from "react"; 17 | 18 | import type { WidgetChecklistModel, WidgetModel } from "@/types/widget.type"; 19 | import { widgetChecklistUpdate } from "@/utils/serverActions/widget"; 20 | 21 | export function WidgetChecklist({ 22 | dashboardId, 23 | sectionId, 24 | widget, 25 | }: { 26 | dashboardId: string; 27 | sectionId: string; 28 | widget: WidgetModel; 29 | }): JSX.Element { 30 | const { id } = widget; 31 | const { items } = widget.data; 32 | 33 | return ( 34 | 35 | {items.map((item: WidgetChecklistItemModel) => ( 36 | 42 | ))} 43 | 44 | 62 | 63 | 64 | ); 65 | } 66 | 67 | function WidgetChecklistItem({ 68 | dashboardId, 69 | item, 70 | sectionId, 71 | }: { 72 | dashboardId: string; 73 | item: WidgetChecklistItemModel; 74 | sectionId: string; 75 | }): JSX.Element { 76 | const { id, checklistWidgetId, content } = item; 77 | const [checked, setChecked] = useState(item.checked); 78 | return ( 79 | 80 | 88 | { 91 | setChecked(!checked); 92 | await widgetChecklistUpdate( 93 | dashboardId, 94 | sectionId, 95 | checklistWidgetId, 96 | id, 97 | "checked", 98 | !checked 99 | ); 100 | }} 101 | edge="start" 102 | > 103 | {checked ? ( 104 | 105 | ) : ( 106 | 107 | )} 108 | 109 | 110 | ), 111 | endAdornment: ( 112 | 113 | { 116 | await widgetChecklistUpdate( 117 | dashboardId, 118 | sectionId, 119 | checklistWidgetId, 120 | id, 121 | "id", 122 | "DELETE" 123 | ); 124 | }} 125 | edge="end" 126 | > 127 | 128 | 129 | 130 | ), 131 | }} 132 | onChange={async (e) => { 133 | await widgetChecklistUpdate( 134 | dashboardId, 135 | sectionId, 136 | checklistWidgetId, 137 | id, 138 | "content", 139 | e.target.value 140 | ); 141 | }} 142 | /> 143 | 144 | ); 145 | } 146 | -------------------------------------------------------------------------------- /src/components/dashboard/views/widgets/Frame.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import type { WidgetFrame as WidgetFrameModel } from "@prisma/client"; 3 | 4 | import type { WidgetModel } from "@/types/widget.type"; 5 | 6 | export function WidgetFrame({ 7 | widget, 8 | }: { 9 | widget: WidgetModel; 10 | }): JSX.Element { 11 | const { height, url } = widget.data; 12 | return ( 13 |