├── .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 | 
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 |
--------------------------------------------------------------------------------
/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 |
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 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/dashboard/views/widgets/HomeAssistant.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import type { WidgetHomeAssistant as WidgetHomeAssistantModel } from "@prisma/client";
3 | import { HassEntity } from "home-assistant-js-websocket";
4 | import { Icon } from "@mdi/react";
5 | import { IconButton, Typography } from "@mui/material";
6 | import { useLongPress } from "use-long-press";
7 | import { useMemo } from "react";
8 |
9 | import type { WidgetActionFunction, WidgetModel } from "@/types/widget.type";
10 | import {
11 | DEFAULT_DOMAIN_ICON,
12 | DOMAINS_TOGGLE,
13 | STATES_OFF,
14 | } from "@/utils/homeAssistant/const";
15 | import { domainIcon } from "@/utils/homeAssistant/icons";
16 | import { ExpandedHomeAssistantAlarmControlPanel } from "./expanded/homeAssistant/AlarmControlPanel";
17 | import { ExpandedHomeAssistantCover } from "@/components/dashboard/views/widgets/expanded/homeAssistant/Cover";
18 | import { ExpandedHomeAssistantLight } from "@/components/dashboard/views/widgets/expanded/homeAssistant/Light";
19 | import { primaryColorRgb } from "@/utils/theme";
20 | import { useHomeAssistant } from "@/providers/HomeAssistantProvider";
21 | import { WidgetAction } from "@/types/widget.type";
22 | import { WidgetImage } from "@/components/dashboard/views/widgets/Image";
23 |
24 | const DOMAINS_WITH_ACTIVATE_CONDITION = new Set([
25 | "alarm_control_panel",
26 | "cover",
27 | ]);
28 | const DOMAINS_WITH_MORE_INFO = new Set(["cover", "light"]);
29 |
30 | export function WidgetHomeAssistant({
31 | editing,
32 | expanded,
33 | widget,
34 | handleInteraction,
35 | }: {
36 | editing: boolean;
37 | expanded: boolean;
38 | widget: WidgetModel;
39 | handleInteraction: WidgetActionFunction;
40 | }): JSX.Element {
41 | const { id } = widget;
42 | const {
43 | entityId,
44 | iconColor,
45 | iconSize,
46 | secondaryInfo,
47 | showIcon,
48 | showName,
49 | showState,
50 | } = widget.data;
51 |
52 | const longPress = useLongPress(() =>
53 | handleInteraction(WidgetAction.ToggleExpanded)
54 | );
55 | const homeAssistant = useHomeAssistant();
56 |
57 | const entity = useMemo(() => {
58 | if (!homeAssistant.entities) return;
59 | return homeAssistant.entities[entityId];
60 | }, [entityId, homeAssistant.entities]);
61 |
62 | const domain = useMemo(() => {
63 | if (!entity) return;
64 | return entity.entity_id.split(".")[0];
65 | }, [entity]);
66 |
67 | const canTurnOnOff = useMemo(() => {
68 | if (!homeAssistant.client || !entity) return false;
69 | if (domain === "alarm_control_panel") return true;
70 | return homeAssistant.client.entityCanTurnOnOff(entity);
71 | }, [domain, entity, homeAssistant.client]);
72 |
73 | const clickable = useMemo(() => {
74 | if (!homeAssistant.client || !entity) return false;
75 | return canTurnOnOff;
76 | }, [canTurnOnOff, entity, homeAssistant.client]);
77 |
78 | const mdiIcon = useMemo(() => {
79 | if (!entity || !domain) return DEFAULT_DOMAIN_ICON;
80 | let icon = domainIcon(domain, entity, entity.state);
81 |
82 | if (entity.attributes?.icon) {
83 | const iconPath = entity.attributes.icon.replace(
84 | /[:|-](\w)/g,
85 | (_, match: string) => match.toUpperCase()
86 | );
87 |
88 | try {
89 | icon = require(`materialdesign-js/icons/${iconPath}`).default;
90 | } catch (e) {
91 | console.warn(`Could not load icon ${iconPath}:`, e);
92 | if (!icon) icon = require(`materialdesign-js/icons/mdiHelp`).default;
93 | }
94 | }
95 | return icon;
96 | }, [domain, entity]);
97 |
98 | const entityIsOn = useMemo(() => {
99 | if (!entity || !domain) return false;
100 | return DOMAINS_TOGGLE.has(domain) && !STATES_OFF.includes(entity.state);
101 | }, [domain, entity]);
102 |
103 | const icon = useMemo(
104 | () => (
105 | 0 &&
119 | Number(iconSize) < 8
120 | ? Number(iconSize)
121 | : iconSize || 4
122 | }
123 | />
124 | ),
125 | [
126 | iconColor,
127 | iconSize,
128 | entity?.attributes?.brightness,
129 | entity?.attributes?.rgb_color,
130 | entity?.state,
131 | entityIsOn,
132 | mdiIcon,
133 | ]
134 | );
135 |
136 | const state = useMemo(() => {
137 | if (!entity || !domain || !showState) return null;
138 |
139 | if (expanded && DOMAINS_WITH_MORE_INFO.has(domain)) {
140 | switch (domain) {
141 | case "alarm_control_panel":
142 | return ;
143 | case "cover":
144 | return ;
145 | case "light":
146 | return ;
147 | default:
148 | return null;
149 | }
150 | } else {
151 | switch (domain) {
152 | case "camera":
153 | return (
154 |
167 | );
168 | default:
169 | return (
170 | <>
171 | {showIcon && mdiIcon && (
172 | <>
173 | {clickable ? (
174 | {
178 | if (
179 | canTurnOnOff &&
180 | !DOMAINS_WITH_ACTIVATE_CONDITION.has(
181 | entity.entity_id.split(".")[0]
182 | )
183 | )
184 | homeAssistant.client?.entityTurnOnOff(
185 | entity,
186 | !entityIsOn
187 | );
188 | else handleInteraction(WidgetAction.ToggleExpanded);
189 | }}
190 | {...longPress()}
191 | >
192 | {icon}
193 |
194 | ) : (
195 | icon
196 | )}
197 | >
198 | )}
199 |
200 | {entity.state} {entity.attributes?.unit_of_measurement}
201 |
202 | >
203 | );
204 | }
205 | }
206 | }, [
207 | canTurnOnOff,
208 | clickable,
209 | domain,
210 | editing,
211 | entity,
212 | entityIsOn,
213 | expanded,
214 | handleInteraction,
215 | homeAssistant.client,
216 | icon,
217 | id,
218 | longPress,
219 | mdiIcon,
220 | showIcon,
221 | showState,
222 | widget,
223 | ]);
224 |
225 | return (
226 | <>
227 | {!homeAssistant.entities ? (
228 |
229 | Home Assistant is connecting / not connected.
230 |
231 | ) : entity ? (
232 | <>
233 | {showName && (
234 |
235 | {entity.attributes.friendly_name}
236 |
237 | )}
238 | {state}
239 | {secondaryInfo && (
240 |
241 | {secondaryInfo === "last_changed" ||
242 | secondaryInfo === "last_updated"
243 | ? entity[secondaryInfo]
244 | : entity.attributes[secondaryInfo]}
245 |
246 | )}
247 | >
248 | ) : (
249 |
250 | Entity '{entityId}' not found.
251 |
252 | )}
253 | >
254 | );
255 | }
256 |
--------------------------------------------------------------------------------
/src/components/dashboard/views/widgets/Image.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import type { WidgetImage as WidgetImageModel } from "@prisma/client";
3 | import { CardActionArea } from "@mui/material";
4 |
5 | import type { WidgetActionFunction, WidgetModel } from "@/types/widget.type";
6 | import { WidgetAction } from "@/types/widget.type";
7 |
8 | export function WidgetImage({
9 | editing,
10 | widget,
11 | handleInteraction,
12 | }: {
13 | editing: boolean;
14 | widget: WidgetModel;
15 | handleInteraction: WidgetActionFunction;
16 | }): JSX.Element {
17 | const { url } = widget.data;
18 | return (
19 | handleInteraction(WidgetAction.ToggleExpanded)}
22 | >
23 | {/* eslint-disable-next-line @next/next/no-img-element */}
24 |
25 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/src/components/dashboard/views/widgets/Markdown.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import type { WidgetMarkdown as WidgetMarkdownModel } from "@prisma/client";
3 | import { Typography } from "@mui/material";
4 |
5 | import type { WidgetModel } from "@/types/widget.type";
6 |
7 | export function WidgetMarkdown({
8 | widget,
9 | }: {
10 | widget: WidgetModel;
11 | }): JSX.Element {
12 | const { content } = widget.data;
13 | return {content};
14 | }
15 |
--------------------------------------------------------------------------------
/src/components/dashboard/views/widgets/expanded/homeAssistant/AlarmControlPanel.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { Icon } from "@mdi/react";
3 | import {
4 | Button,
5 | Stack,
6 | TextField,
7 | Typography,
8 | Unstable_Grid2 as Grid2,
9 | } from "@mui/material";
10 | import { useMemo, useState } from "react";
11 |
12 | import {
13 | ALARM_MODES,
14 | AlarmConfig,
15 | AlarmControlPanelEntity,
16 | } from "@/utils/homeAssistant/alarmControlPanel";
17 | import { entitySupportsFeature } from "@/utils/homeAssistant";
18 | import { useHomeAssistant } from "@/providers/HomeAssistantProvider";
19 |
20 | const keypad = [
21 | [1, 2, 3],
22 | [4, 5, 6],
23 | [7, 8, 9],
24 | [null, 0, "Clear"],
25 | ];
26 |
27 | export function ExpandedHomeAssistantAlarmControlPanel({
28 | entity,
29 | }: {
30 | entity: AlarmControlPanelEntity;
31 | }) {
32 | const [code, setCode] = useState("");
33 | const homeAssistant = useHomeAssistant();
34 |
35 | const buttons = useMemo>(
36 | () =>
37 | entity.state.startsWith("armed_")
38 | ? [ALARM_MODES["disarmed"]]
39 | : Object.values(ALARM_MODES).filter(({ feature }: AlarmConfig) =>
40 | entitySupportsFeature(entity, Number(feature))
41 | ),
42 | [entity]
43 | );
44 |
45 | return (
46 | <>
47 |
55 | {entity.state}
56 | {
62 | setCode(e.target.value);
63 | }}
64 | />
65 | {entity.attributes?.code_format === "number" && (
66 |
67 | {keypad.map((row, index: number) => (
68 |
69 | {row.map((key) => (
70 |
82 | ))}
83 |
84 | ))}
85 |
86 | )}
87 |
88 | {buttons.map(({ path, service }: AlarmConfig) => (
89 |
105 | ))}
106 |
107 |
108 | >
109 | );
110 | }
111 |
--------------------------------------------------------------------------------
/src/components/dashboard/views/widgets/expanded/homeAssistant/Cover.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { HassEntity } from "home-assistant-js-websocket";
3 | import { Icon } from "@mdi/react";
4 | import { IconButton, Typography, Unstable_Grid2 as Grid2 } from "@mui/material";
5 | import { mdiArrowBottomLeft, mdiArrowTopRight, mdiStop } from "@mdi/js";
6 | import { useMemo } from "react";
7 |
8 | import {
9 | CoverEntityFeature,
10 | canClose,
11 | canCloseTilt,
12 | canOpen,
13 | canOpenTilt,
14 | canStop,
15 | canStopTilt,
16 | } from "@/utils/homeAssistant/cover";
17 | import { domainIcon } from "@/utils/homeAssistant/icons";
18 | import { primaryColorRgb } from "@/utils/theme";
19 | import { useHomeAssistant } from "@/providers/HomeAssistantProvider";
20 | import { entitySupportsFeature } from "@/utils/homeAssistant";
21 |
22 | type CoverLayout = {
23 | type: "cross" | "vertical";
24 | items: Array;
25 | };
26 |
27 | type CoverLayoutItem = {
28 | icon: string;
29 | service: string | null;
30 | };
31 |
32 | export function ExpandedHomeAssistantCover({ entity }: { entity: HassEntity }) {
33 | const homeAssistant = useHomeAssistant();
34 |
35 | const layout = useMemo(() => {
36 | const supportsOpen = entitySupportsFeature(entity, CoverEntityFeature.OPEN);
37 | const supportsClose = entitySupportsFeature(
38 | entity,
39 | CoverEntityFeature.CLOSE
40 | );
41 | const supportsStop = entitySupportsFeature(entity, CoverEntityFeature.STOP);
42 | const supportsOpenTilt = entitySupportsFeature(
43 | entity,
44 | CoverEntityFeature.OPEN_TILT
45 | );
46 | const supportsCloseTilt = entitySupportsFeature(
47 | entity,
48 | CoverEntityFeature.CLOSE_TILT
49 | );
50 | const supportsStopTilt = entitySupportsFeature(
51 | entity,
52 | CoverEntityFeature.STOP_TILT
53 | );
54 |
55 | const items: Array = [];
56 | if (supportsOpen)
57 | items.push({
58 | icon: domainIcon("cover", entity, "open"),
59 | service: "open_cover",
60 | });
61 |
62 | if (supportsCloseTilt)
63 | items.push({
64 | icon: mdiArrowBottomLeft,
65 | service: "close_cover_tilt",
66 | });
67 |
68 | if (supportsStop || supportsStopTilt)
69 | items.push({
70 | icon: mdiStop,
71 | service: "stop",
72 | });
73 |
74 | if (supportsOpenTilt)
75 | items.push({
76 | icon: mdiArrowTopRight,
77 | service: "open_cover_tilt",
78 | });
79 |
80 | if (supportsClose)
81 | items.push({
82 | icon: domainIcon("cover", entity, "closed"),
83 | service: "close_cover",
84 | });
85 |
86 | if (
87 | (supportsOpen || supportsClose) &&
88 | (supportsOpenTilt || supportsCloseTilt)
89 | )
90 | return { type: "cross", items };
91 |
92 | if (supportsOpen || supportsClose) return { type: "vertical", items };
93 |
94 | if (supportsOpenTilt || supportsCloseTilt)
95 | return { type: "vertical", items };
96 |
97 | return { type: "vertical", items: [] };
98 | }, [entity]);
99 |
100 | const coverCanOpen = useMemo(() => canOpen(entity), [entity]);
101 |
102 | const coverCanOpenTilt = useMemo(
103 | () => canOpenTilt(entity),
104 | [entity]
105 | );
106 |
107 | const coverCanStop = useMemo(
108 | () => canStop(entity) || canStopTilt(entity),
109 | [entity]
110 | );
111 |
112 | const coverCanStopTilt = useMemo(
113 | () => canStopTilt(entity),
114 | [entity]
115 | );
116 |
117 | const coverCanClose = useMemo(() => canClose(entity), [entity]);
118 |
119 | const coverCanCloseTilt = useMemo(
120 | () => canCloseTilt(entity),
121 | [entity]
122 | );
123 |
124 | const isOn = useMemo(
125 | () =>
126 | entity.state === "open" ||
127 | entity.state === "closing" ||
128 | entity.state === "opening",
129 | [entity]
130 | );
131 |
132 | const isOff = useMemo(() => entity.state === "closed", [entity]);
133 |
134 | return (
135 | <>
136 |
143 |
144 | {entity.state}
145 | {" - "}
146 | {entity.attributes?.current_position &&
147 | `${entity.attributes.current_position} %`}
148 |
149 | {layout.items.map(({ icon, service }) => {
150 | let active = false;
151 | let disabled = false;
152 | switch (service) {
153 | case "open_cover":
154 | active = coverCanOpen;
155 | disabled = !coverCanOpen;
156 | break;
157 | case "open_cover_tilt":
158 | active = coverCanOpenTilt;
159 | disabled = !coverCanOpenTilt;
160 | break;
161 | case "stop":
162 | if (!coverCanStop && !coverCanStopTilt) service = null;
163 | else if (coverCanStop) service = "stop_cover";
164 | else if (coverCanStopTilt) service = "stop_cover_tilt";
165 | break;
166 | case "close_cover":
167 | active = coverCanClose;
168 | disabled = !coverCanClose;
169 | break;
170 | case "close_cover_tilt":
171 | active = coverCanCloseTilt;
172 | disabled = !coverCanCloseTilt;
173 | break;
174 | }
175 |
176 | return (
177 | {
181 | if (!service) return;
182 | homeAssistant.client?.callService("cover", service, {
183 | entity_id: entity.entity_id,
184 | });
185 | }}
186 | >
187 |
188 |
189 | );
190 | })}
191 |
192 | {/* {layout} */}
193 | {/* {
196 | homeAssistant.client?.callService(
197 | "cover",
198 | supportsOpenTilt ? "open_cover_tilt" : "open_cover",
199 | {
200 | entity_id: entity.entity_id,
201 | }
202 | );
203 | }}
204 | >
205 |
214 |
215 | {(supportsStop || supportsStopTilt) && (
216 | {
219 | homeAssistant.client?.callService(
220 | "cover",
221 | supportsStopTilt ? "stop_cover_tilt" : "stop_cover",
222 | {
223 | entity_id: entity.entity_id,
224 | }
225 | );
226 | }}
227 | >
228 |
229 |
230 | )}
231 | {
234 | homeAssistant.client?.callService(
235 | "cover",
236 | supportsCloseTilt ? "close_cover_tilt" : "close_cover",
237 | {
238 | entity_id: entity.entity_id,
239 | }
240 | );
241 | }}
242 | >
243 |
252 | */}
253 |
254 | >
255 | );
256 | }
257 |
--------------------------------------------------------------------------------
/src/components/dashboard/views/widgets/expanded/homeAssistant/Light.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { HassEntity } from "home-assistant-js-websocket";
3 | import {
4 | IconButton,
5 | Slider,
6 | Stack,
7 | styled,
8 | Typography,
9 | Unstable_Grid2 as Grid2,
10 | } from "@mui/material";
11 | import { Icon } from "@mdi/react";
12 | import { mdiPower } from "@mdi/js";
13 | import { useMemo } from "react";
14 | import Moment from "react-moment";
15 |
16 | import { OFF_STATES, UNAVAILABLE_STATES } from "@/utils/homeAssistant";
17 | import { ON } from "@/utils/homeAssistant";
18 | import { primaryColorRgb } from "@/utils/theme";
19 | import { useHomeAssistant } from "@/providers/HomeAssistantProvider";
20 |
21 | const LightSlider = styled(Slider)({
22 | height: 8,
23 | "& .MuiSlider-track": {
24 | border: "none",
25 | },
26 | "& .MuiSlider-thumb": {
27 | height: 24,
28 | width: 24,
29 | backgroundColor: "#fff",
30 | border: "2px solid currentColor",
31 | "&:focus, &:hover, &.Mui-active, &.Mui-focusVisible": {
32 | boxShadow: "inherit",
33 | },
34 | "&:before": {
35 | display: "none",
36 | },
37 | },
38 | });
39 |
40 | export function ExpandedHomeAssistantLight({ entity }: { entity: HassEntity }) {
41 | const homeAssistant = useHomeAssistant();
42 |
43 | const disabled = useMemo(
44 | () => UNAVAILABLE_STATES.has(entity.state),
45 | [entity.state]
46 | );
47 |
48 | const isOn = useMemo(() => entity.state === ON, [entity.state]);
49 |
50 | const isOff = useMemo(
51 | () => OFF_STATES.has(entity.state),
52 | [entity.state]
53 | );
54 |
55 | const brightness = useMemo(
56 | () => Math.round((entity.attributes.brightness / 255) * 100) || 0,
57 | [entity.attributes?.brightness]
58 | );
59 |
60 | const rgbColors = useMemo(
61 | () => entity.attributes.rgb_color || [0, 0, 0],
62 | [entity.attributes?.rgb_color]
63 | );
64 |
65 | return (
66 | <>
67 |
75 |
76 | {isOff
77 | ? entity.state.charAt(0).toUpperCase() + entity.state.slice(1)
78 | : entity.attributes?.brightness
79 | ? `${brightness}%`
80 | : entity.state.charAt(0).toUpperCase() + entity.state.slice(1)}
81 |
82 |
83 |
84 |
85 |
86 |
95 | {
98 | if (!disabled)
99 | homeAssistant.client?.entityTurnOnOff(entity, !isOn);
100 | }}
101 | >
102 |
107 |
108 |
109 |
110 |
111 | Brightness
112 |
113 | {
128 | homeAssistant.client?.callService("light", "turn_on", {
129 | entity_id: entity.entity_id,
130 | brightness: Math.round((value as number) * 2.55),
131 | });
132 | }}
133 | />
134 |
139 | Color
140 |
141 | {
148 | homeAssistant.client?.callService("light", "turn_on", {
149 | entity_id: entity.entity_id,
150 | rgb_color: [value, rgbColors[1], rgbColors[2]],
151 | });
152 | }}
153 | />
154 | {
161 | homeAssistant.client?.callService("light", "turn_on", {
162 | entity_id: entity.entity_id,
163 | rgb_color: [rgbColors[0], value, rgbColors[2]],
164 | });
165 | }}
166 | />
167 | {
174 | homeAssistant.client?.callService("light", "turn_on", {
175 | entity_id: entity.entity_id,
176 | rgb_color: [rgbColors[0], rgbColors[1], value],
177 | });
178 | }}
179 | />
180 |
181 | >
182 | );
183 | }
184 |
--------------------------------------------------------------------------------
/src/components/skeletons/Dashboard.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { Skeleton } from "@mui/material";
3 |
4 | export function SkeletonDashboard(): JSX.Element {
5 | return ;
6 | }
7 |
--------------------------------------------------------------------------------
/src/providers/AuthProvider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import type { ReactNode } from "react";
3 | import { Session } from "next-auth";
4 | import { SessionProvider } from "next-auth/react";
5 |
6 | export function AuthProvider({
7 | children,
8 | session,
9 | }: {
10 | children: ReactNode;
11 | session: Session | null;
12 | }): JSX.Element {
13 | return {children};
14 | }
15 |
--------------------------------------------------------------------------------
/src/providers/HomeAssistantProvider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import {
3 | ReactNode,
4 | createContext,
5 | useCallback,
6 | useContext,
7 | useEffect,
8 | useState,
9 | } from "react";
10 | import {
11 | HassConfig,
12 | HassEntities,
13 | HassServices,
14 | } from "home-assistant-js-websocket";
15 | import { usePathname, useRouter } from "next/navigation";
16 |
17 | import { HomeAssistant } from "@/utils/homeAssistant";
18 | import { homeAssistantGetConfig } from "@/utils/serverActions/homeAssistant";
19 |
20 | type HomeAssistantContextType = {
21 | client: HomeAssistant | null;
22 | config: HassConfig | null;
23 | entities: HassEntities | null;
24 | services: HassServices | null;
25 | };
26 |
27 | const defaultHomeAssistantContext: HomeAssistantContextType = {
28 | client: null,
29 | config: null,
30 | entities: null,
31 | services: null,
32 | };
33 |
34 | const HomeAssistantContext = createContext(
35 | defaultHomeAssistantContext
36 | );
37 |
38 | let client: HomeAssistant | null = null;
39 | export function HomeAssistantProvider({
40 | dashboardId,
41 | children,
42 | }: {
43 | dashboardId: string;
44 | children: ReactNode;
45 | }): JSX.Element {
46 | const pathname = usePathname();
47 | const router = useRouter();
48 |
49 | const [homeAssistant, setHomeAssistant] = useState(
50 | defaultHomeAssistantContext
51 | );
52 |
53 | const connectedCallback = useCallback((): void => {
54 | console.log("Connected to Home Assistant");
55 | setHomeAssistant((prevHomeAssistant: HomeAssistantContextType) => ({
56 | ...prevHomeAssistant,
57 | client,
58 | }));
59 |
60 | // Cleanup search params
61 | router.replace(pathname);
62 | }, [pathname, router, setHomeAssistant]);
63 |
64 | const configCallback = useCallback(
65 | (config: HassConfig): void => {
66 | setHomeAssistant((prevHomeAssistant: HomeAssistantContextType) => ({
67 | ...prevHomeAssistant,
68 | config,
69 | }));
70 | },
71 | [setHomeAssistant]
72 | );
73 |
74 | const entitiesCallback = useCallback(
75 | (entities: HassEntities): void => {
76 | setHomeAssistant((prevHomeAssistant: HomeAssistantContextType) => ({
77 | ...prevHomeAssistant,
78 | entities,
79 | }));
80 | },
81 | [setHomeAssistant]
82 | );
83 |
84 | const servicesCallback = useCallback(
85 | (services: HassServices): void => {
86 | setHomeAssistant((prevHomeAssistant: HomeAssistantContextType) => ({
87 | ...prevHomeAssistant,
88 | services,
89 | }));
90 | },
91 | [setHomeAssistant]
92 | );
93 |
94 | useEffect(() => {
95 | if (!dashboardId) return;
96 |
97 | console.log("Connecting to Home Assistant:", { dashboardId });
98 |
99 | client = new HomeAssistant(
100 | dashboardId,
101 | connectedCallback,
102 | configCallback,
103 | entitiesCallback,
104 | servicesCallback
105 | );
106 |
107 | (async () => {
108 | // Get home assistant config from database
109 | client.config = await homeAssistantGetConfig(dashboardId);
110 |
111 | if (!client.config) {
112 | console.warn("No config found for dashboard:", { dashboardId });
113 | return;
114 | }
115 |
116 | if (!client.config.url) {
117 | console.warn("No url found for dashboard:", { dashboardId });
118 | return;
119 | }
120 |
121 | try {
122 | await client.connect();
123 | } catch (err) {
124 | console.error(err);
125 | }
126 | })();
127 |
128 | return () => {
129 | if (client) client.disconnect();
130 | setHomeAssistant(defaultHomeAssistantContext);
131 | };
132 | }, [
133 | configCallback,
134 | connectedCallback,
135 | dashboardId,
136 | entitiesCallback,
137 | servicesCallback,
138 | ]);
139 |
140 | return (
141 |
142 | {children}
143 |
144 | );
145 | }
146 |
147 | export function useHomeAssistant(): HomeAssistantContextType {
148 | return useContext(HomeAssistantContext);
149 | }
150 |
--------------------------------------------------------------------------------
/src/providers/MUIProvider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { Box, CssBaseline, ThemeProvider } from "@mui/material";
3 | import { NextAppDirEmotionCacheProvider } from "tss-react/next/appDir";
4 | import { ReactNode } from "react";
5 |
6 | import { theme } from "@/utils/theme";
7 | import styles from "@/app/page.module.css";
8 |
9 | export function MUIProvider({
10 | children,
11 | }: {
12 | children: ReactNode;
13 | }): JSX.Element {
14 | return (
15 | <>
16 |
17 |
18 |
19 |
20 |
21 | {children}
22 |
23 |
24 |
25 |
26 | >
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/src/types/dashboard.type.ts:
--------------------------------------------------------------------------------
1 | import type { Dashboard, HeaderItem, Section } from "@prisma/client";
2 |
3 | import type { WidgetModel } from "@/types/widget.type";
4 |
5 | // Combined types for dashboard, section and widget
6 | export type DashboardModel = Dashboard & {
7 | headerItems: Array;
8 | sections: Array<
9 | Section & {
10 | widgets: Array;
11 | }
12 | >;
13 | };
14 |
15 | export type DashboardHeaderModel = Dashboard & {
16 | headerItems: Array;
17 | };
18 |
19 | export enum HeaderItemType {
20 | Date = "date",
21 | DateTime = "dateTime",
22 | Spacer = "spacer",
23 | Time = "time",
24 | }
25 |
--------------------------------------------------------------------------------
/src/types/section.type.ts:
--------------------------------------------------------------------------------
1 | import type { Section } from "@prisma/client";
2 |
3 | import { WidgetModel } from "@/types/widget.type";
4 |
5 | // Combined types for section and widget
6 | export type SectionModel = Section & {
7 | widgets: Array;
8 | };
9 |
10 | export enum SectionAction {
11 | Delete = "delete",
12 | Edit = "edit",
13 | MoveDown = "moveDown",
14 | MoveUp = "moveUp",
15 | }
16 |
--------------------------------------------------------------------------------
/src/types/widget.type.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | Section,
3 | Widget,
4 | WidgetChecklist,
5 | WidgetChecklistItem,
6 | } from "@prisma/client";
7 |
8 | // Combined types for widget and generic widget data
9 | export type WidgetModel = Widget & {
10 | data: T;
11 | };
12 |
13 | export type WidgetActionFunction = (action: WidgetAction) => void;
14 |
15 | export enum WidgetAction {
16 | Delete = "delete",
17 | Edit = "edit",
18 | MoveDown = "moveDown",
19 | MoveUp = "moveUp",
20 | ToggleExpanded = "toggleExpanded",
21 | }
22 |
23 | export enum WidgetType {
24 | Checklist = "checklist",
25 | Frame = "frame",
26 | HomeAssistant = "homeAssistant",
27 | Image = "image",
28 | Markdown = "markdown",
29 | News = "news",
30 | RSS = "rss",
31 | }
32 |
33 | export type WidgetChecklistModel = WidgetChecklist & {
34 | items: Array;
35 | };
36 |
--------------------------------------------------------------------------------
/src/utils/homeAssistant/alarmControlPanel.ts:
--------------------------------------------------------------------------------
1 | import {
2 | mdiAirplane,
3 | mdiHome,
4 | mdiLock,
5 | mdiMoonWaningCrescent,
6 | mdiShield,
7 | mdiShieldOff,
8 | } from "@mdi/js";
9 | import {
10 | HassEntityAttributeBase,
11 | HassEntityBase,
12 | } from "home-assistant-js-websocket";
13 |
14 | // Sourced from https://github.com/home-assistant/frontend/blob/dev/src/data/alarm_control_panel.ts
15 | export const FORMAT_TEXT = "text";
16 | export const FORMAT_NUMBER = "number";
17 |
18 | export const enum AlarmControlPanelEntityFeature {
19 | ARM_HOME = 1,
20 | ARM_AWAY = 2,
21 | ARM_NIGHT = 4,
22 | TRIGGER = 8,
23 | ARM_CUSTOM_BYPASS = 16,
24 | ARM_VACATION = 32,
25 | }
26 |
27 | interface AlarmControlPanelEntityAttributes extends HassEntityAttributeBase {
28 | code_format?: "text" | "number";
29 | changed_by?: string | null;
30 | code_arm_required?: boolean;
31 | }
32 |
33 | export interface AlarmControlPanelEntity extends HassEntityBase {
34 | attributes: AlarmControlPanelEntityAttributes;
35 | }
36 |
37 | export type AlarmMode =
38 | | "armed_home"
39 | | "armed_away"
40 | | "armed_night"
41 | | "armed_vacation"
42 | | "armed_custom_bypass"
43 | | "disarmed";
44 |
45 | export type AlarmConfig = {
46 | service: string;
47 | feature?: AlarmControlPanelEntityFeature;
48 | path: string;
49 | };
50 |
51 | export const ALARM_MODES: Record = {
52 | armed_home: {
53 | feature: AlarmControlPanelEntityFeature.ARM_HOME,
54 | service: "alarm_arm_home",
55 | path: mdiHome,
56 | },
57 | armed_away: {
58 | feature: AlarmControlPanelEntityFeature.ARM_AWAY,
59 | service: "alarm_arm_away",
60 | path: mdiLock,
61 | },
62 | armed_night: {
63 | feature: AlarmControlPanelEntityFeature.ARM_NIGHT,
64 | service: "alarm_arm_night",
65 | path: mdiMoonWaningCrescent,
66 | },
67 | armed_vacation: {
68 | feature: AlarmControlPanelEntityFeature.ARM_VACATION,
69 | service: "alarm_arm_vacation",
70 | path: mdiAirplane,
71 | },
72 | armed_custom_bypass: {
73 | feature: AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS,
74 | service: "alarm_arm_custom_bypass",
75 | path: mdiShield,
76 | },
77 | disarmed: {
78 | service: "alarm_disarm",
79 | path: mdiShieldOff,
80 | },
81 | };
82 |
--------------------------------------------------------------------------------
/src/utils/homeAssistant/const.ts:
--------------------------------------------------------------------------------
1 | // Sourced from https://raw.githubusercontent.com/home-assistant/frontend/dev/src/common/const.ts
2 |
3 | /** Constants to be used in the frontend. */
4 |
5 | import {
6 | mdiAirFilter,
7 | mdiAlert,
8 | mdiAngleAcute,
9 | mdiAppleSafari,
10 | mdiArrowLeftRight,
11 | mdiBell,
12 | mdiBookmark,
13 | mdiBrightness5,
14 | mdiBullhorn,
15 | mdiCalendar,
16 | mdiCalendarClock,
17 | mdiCarCoolantLevel,
18 | mdiCash,
19 | mdiClock,
20 | mdiCloudUpload,
21 | mdiCog,
22 | mdiCommentAlert,
23 | mdiCounter,
24 | mdiCurrentAc,
25 | mdiDatabase,
26 | mdiEarHearing,
27 | mdiEye,
28 | mdiFlash,
29 | mdiFlower,
30 | mdiFormatListBulleted,
31 | mdiFormTextbox,
32 | mdiGauge,
33 | mdiGestureTapButton,
34 | mdiGoogleAssistant,
35 | mdiGoogleCirclesCommunities,
36 | mdiHomeAssistant,
37 | mdiHomeAutomation,
38 | mdiImageFilterFrames,
39 | mdiLightbulb,
40 | mdiLightningBolt,
41 | mdiMailbox,
42 | mdiMapMarkerRadius,
43 | mdiMeterGas,
44 | mdiMicrophoneMessage,
45 | mdiMolecule,
46 | mdiMoleculeCo,
47 | mdiMoleculeCo2,
48 | mdiPalette,
49 | mdiProgressClock,
50 | mdiRayVertex,
51 | mdiRemote,
52 | mdiRobotVacuum,
53 | mdiScriptText,
54 | mdiSineWave,
55 | mdiSpeakerMessage,
56 | mdiSpeedometer,
57 | mdiSunWireless,
58 | mdiThermometer,
59 | mdiThermometerLines,
60 | mdiThermostat,
61 | mdiTimerOutline,
62 | mdiTransmissionTower,
63 | mdiWater,
64 | mdiWaterPercent,
65 | mdiWeatherPouring,
66 | mdiWeatherRainy,
67 | mdiWeatherWindy,
68 | mdiWeight,
69 | mdiWifi,
70 | } from "@mdi/js";
71 |
72 | // Constants should be alphabetically sorted by name.
73 | // Arrays with values should be alphabetically sorted if order doesn't matter.
74 | // Each constant should have a description what it is supposed to be used for.
75 |
76 | /** Icon to use when no icon specified for domain. */
77 | export const DEFAULT_DOMAIN_ICON = mdiBookmark;
78 |
79 | /** Icons for each domain */
80 | export const FIXED_DOMAIN_ICONS = {
81 | air_quality: mdiAirFilter,
82 | alert: mdiAlert,
83 | calendar: mdiCalendar,
84 | climate: mdiThermostat,
85 | configurator: mdiCog,
86 | conversation: mdiMicrophoneMessage,
87 | counter: mdiCounter,
88 | date: mdiCalendar,
89 | demo: mdiHomeAssistant,
90 | google_assistant: mdiGoogleAssistant,
91 | group: mdiGoogleCirclesCommunities,
92 | homeassistant: mdiHomeAssistant,
93 | homekit: mdiHomeAutomation,
94 | image_processing: mdiImageFilterFrames,
95 | input_button: mdiGestureTapButton,
96 | input_datetime: mdiCalendarClock,
97 | input_number: mdiRayVertex,
98 | input_select: mdiFormatListBulleted,
99 | input_text: mdiFormTextbox,
100 | light: mdiLightbulb,
101 | mailbox: mdiMailbox,
102 | notify: mdiCommentAlert,
103 | number: mdiRayVertex,
104 | persistent_notification: mdiBell,
105 | plant: mdiFlower,
106 | proximity: mdiAppleSafari,
107 | remote: mdiRemote,
108 | scene: mdiPalette,
109 | schedule: mdiCalendarClock,
110 | script: mdiScriptText,
111 | select: mdiFormatListBulleted,
112 | sensor: mdiEye,
113 | simple_alarm: mdiBell,
114 | siren: mdiBullhorn,
115 | stt: mdiMicrophoneMessage,
116 | text: mdiFormTextbox,
117 | timer: mdiTimerOutline,
118 | tts: mdiSpeakerMessage,
119 | updater: mdiCloudUpload,
120 | vacuum: mdiRobotVacuum,
121 | zone: mdiMapMarkerRadius,
122 | };
123 |
124 | export const FIXED_DEVICE_CLASS_ICONS = {
125 | apparent_power: mdiFlash,
126 | aqi: mdiAirFilter,
127 | atmospheric_pressure: mdiThermometerLines,
128 | // battery: mdiBattery, => not included by design since `sensorIcon()` will dynamically determine the icon
129 | carbon_dioxide: mdiMoleculeCo2,
130 | carbon_monoxide: mdiMoleculeCo,
131 | current: mdiCurrentAc,
132 | data_rate: mdiTransmissionTower,
133 | data_size: mdiDatabase,
134 | date: mdiCalendar,
135 | distance: mdiArrowLeftRight,
136 | duration: mdiProgressClock,
137 | energy: mdiLightningBolt,
138 | frequency: mdiSineWave,
139 | gas: mdiMeterGas,
140 | humidity: mdiWaterPercent,
141 | illuminance: mdiBrightness5,
142 | irradiance: mdiSunWireless,
143 | moisture: mdiWaterPercent,
144 | monetary: mdiCash,
145 | nitrogen_dioxide: mdiMolecule,
146 | nitrogen_monoxide: mdiMolecule,
147 | nitrous_oxide: mdiMolecule,
148 | ozone: mdiMolecule,
149 | pm1: mdiMolecule,
150 | pm10: mdiMolecule,
151 | pm25: mdiMolecule,
152 | power: mdiFlash,
153 | power_factor: mdiAngleAcute,
154 | precipitation: mdiWeatherRainy,
155 | precipitation_intensity: mdiWeatherPouring,
156 | pressure: mdiGauge,
157 | reactive_power: mdiFlash,
158 | signal_strength: mdiWifi,
159 | sound_pressure: mdiEarHearing,
160 | speed: mdiSpeedometer,
161 | sulphur_dioxide: mdiMolecule,
162 | temperature: mdiThermometer,
163 | timestamp: mdiClock,
164 | volatile_organic_compounds: mdiMolecule,
165 | voltage: mdiSineWave,
166 | volume: mdiCarCoolantLevel,
167 | water: mdiWater,
168 | weight: mdiWeight,
169 | wind_speed: mdiWeatherWindy,
170 | };
171 |
172 | /** Domains that have a state card. */
173 | export const DOMAINS_WITH_CARD = [
174 | "button",
175 | "climate",
176 | "cover",
177 | "configurator",
178 | "input_button",
179 | "input_select",
180 | "input_number",
181 | "input_text",
182 | "lock",
183 | "media_player",
184 | "number",
185 | "scene",
186 | "script",
187 | "select",
188 | "timer",
189 | "text",
190 | "vacuum",
191 | "water_heater",
192 | ];
193 |
194 | export const SENSOR_ENTITIES = [
195 | "sensor",
196 | "binary_sensor",
197 | "calendar",
198 | "camera",
199 | "device_tracker",
200 | "weather",
201 | ];
202 |
203 | /** Domains that render an input element instead of a text value when displayed in a row.
204 | * Those rows should then not show a cursor pointer when hovered (which would normally
205 | * be the default) unless the element itself enforces it (e.g. a button). Also those elements
206 | * should not act as a click target to open the more info dialog (the row name and state icon
207 | * still do of course) as the click should instead e.g. activate the input field or toggle
208 | * the button that this row shows.
209 | */
210 | export const DOMAINS_INPUT_ROW = [
211 | "automation",
212 | "button",
213 | "cover",
214 | "date",
215 | "fan",
216 | "group",
217 | "humidifier",
218 | "input_boolean",
219 | "input_button",
220 | "input_datetime",
221 | "input_number",
222 | "input_select",
223 | "input_text",
224 | "light",
225 | "lock",
226 | "media_player",
227 | "number",
228 | "scene",
229 | "script",
230 | "select",
231 | "switch",
232 | "text",
233 | "time",
234 | "vacuum",
235 | ];
236 |
237 | /** States that we consider "off". */
238 | export const STATES_OFF = ["closed", "locked", "off"];
239 |
240 | /** Binary States */
241 | export const BINARY_STATE_ON = "on";
242 | export const BINARY_STATE_OFF = "off";
243 |
244 | /** Domains where we allow toggle in Lovelace. */
245 | export const DOMAINS_TOGGLE = new Set([
246 | "fan",
247 | "input_boolean",
248 | "light",
249 | "switch",
250 | "group",
251 | "automation",
252 | "humidifier",
253 | ]);
254 |
255 | /** Domains that have a dynamic entity image / picture. */
256 | export const DOMAINS_WITH_DYNAMIC_PICTURE = new Set(["camera", "media_player"]);
257 |
258 | /** Temperature units. */
259 | export const UNIT_C = "°C";
260 | export const UNIT_F = "°F";
261 |
262 | /** Entity ID of the default view. */
263 | export const DEFAULT_VIEW_ENTITY_ID = "group.default_view";
264 |
--------------------------------------------------------------------------------
/src/utils/homeAssistant/cover.ts:
--------------------------------------------------------------------------------
1 | import {
2 | HassEntityAttributeBase,
3 | HassEntityBase,
4 | } from "home-assistant-js-websocket";
5 |
6 | import { UNAVAILABLE, entitySupportsFeature } from "@/utils/homeAssistant";
7 |
8 | // Sourced from https://github.com/home-assistant/frontend/blob/dev/src/data/cover.ts
9 | export const enum CoverEntityFeature {
10 | OPEN = 1,
11 | CLOSE = 2,
12 | SET_POSITION = 4,
13 | STOP = 8,
14 | OPEN_TILT = 16,
15 | CLOSE_TILT = 32,
16 | STOP_TILT = 64,
17 | SET_TILT_POSITION = 128,
18 | }
19 |
20 | export function isFullyOpen(stateObj: CoverEntity) {
21 | if (stateObj.attributes.current_position !== undefined) {
22 | return stateObj.attributes.current_position === 100;
23 | }
24 | return stateObj.state === "open";
25 | }
26 |
27 | export function isFullyClosed(stateObj: CoverEntity) {
28 | if (stateObj.attributes.current_position !== undefined) {
29 | return stateObj.attributes.current_position === 0;
30 | }
31 | return stateObj.state === "closed";
32 | }
33 |
34 | export function isFullyOpenTilt(stateObj: CoverEntity) {
35 | return stateObj.attributes.current_tilt_position === 100;
36 | }
37 |
38 | export function isFullyClosedTilt(stateObj: CoverEntity) {
39 | return stateObj.attributes.current_tilt_position === 0;
40 | }
41 |
42 | export function isOpening(stateObj: CoverEntity) {
43 | return stateObj.state === "opening";
44 | }
45 |
46 | export function isClosing(stateObj: CoverEntity) {
47 | return stateObj.state === "closing";
48 | }
49 |
50 | export function isTiltOnly(stateObj: CoverEntity) {
51 | const supportsCover =
52 | entitySupportsFeature(stateObj, CoverEntityFeature.OPEN) ||
53 | entitySupportsFeature(stateObj, CoverEntityFeature.CLOSE) ||
54 | entitySupportsFeature(stateObj, CoverEntityFeature.STOP);
55 | const supportsTilt =
56 | entitySupportsFeature(stateObj, CoverEntityFeature.OPEN_TILT) ||
57 | entitySupportsFeature(stateObj, CoverEntityFeature.CLOSE_TILT) ||
58 | entitySupportsFeature(stateObj, CoverEntityFeature.STOP_TILT);
59 | return supportsTilt && !supportsCover;
60 | }
61 |
62 | export function canOpen(stateObj: CoverEntity) {
63 | if (stateObj.state === UNAVAILABLE) {
64 | return false;
65 | }
66 | const assumedState = stateObj.attributes.assumed_state === true;
67 | return (!isFullyOpen(stateObj) && !isOpening(stateObj)) || assumedState;
68 | }
69 |
70 | export function canClose(stateObj: CoverEntity): boolean {
71 | if (stateObj.state === UNAVAILABLE) {
72 | return false;
73 | }
74 | const assumedState = stateObj.attributes.assumed_state === true;
75 | return (!isFullyClosed(stateObj) && !isClosing(stateObj)) || assumedState;
76 | }
77 |
78 | export function canStop(stateObj: CoverEntity): boolean {
79 | return stateObj.state !== UNAVAILABLE;
80 | }
81 |
82 | export function canOpenTilt(stateObj: CoverEntity): boolean {
83 | if (stateObj.state === UNAVAILABLE) {
84 | return false;
85 | }
86 | const assumedState = stateObj.attributes.assumed_state === true;
87 | return !isFullyOpenTilt(stateObj) || assumedState;
88 | }
89 |
90 | export function canCloseTilt(stateObj: CoverEntity): boolean {
91 | if (stateObj.state === UNAVAILABLE) {
92 | return false;
93 | }
94 | const assumedState = stateObj.attributes.assumed_state === true;
95 | return !isFullyClosedTilt(stateObj) || assumedState;
96 | }
97 |
98 | export function canStopTilt(stateObj: CoverEntity): boolean {
99 | return stateObj.state !== UNAVAILABLE;
100 | }
101 |
102 | interface CoverEntityAttributes extends HassEntityAttributeBase {
103 | current_position?: number;
104 | current_tilt_position?: number;
105 | }
106 |
107 | export interface CoverEntity extends HassEntityBase {
108 | attributes: CoverEntityAttributes;
109 | }
110 |
--------------------------------------------------------------------------------
/src/utils/homeAssistant/index.ts:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { HomeAssistant as HomeAssistantConfig } from "@prisma/client";
3 | import {
4 | Auth,
5 | AuthData,
6 | callService,
7 | Connection,
8 | createConnection,
9 | ERR_HASS_HOST_REQUIRED,
10 | ERR_INVALID_AUTH,
11 | getAuth,
12 | getUser,
13 | HassConfig,
14 | HassEntities,
15 | HassEntity,
16 | HassServices,
17 | HassUser,
18 | subscribeConfig,
19 | subscribeEntities,
20 | subscribeServices,
21 | } from "home-assistant-js-websocket";
22 |
23 | import { homeAssistantUpdateConfig } from "@/utils/serverActions/homeAssistant";
24 |
25 | export const UNAVAILABLE = "unavailable";
26 | export const UNKNOWN = "unknown";
27 | export const ON = "on";
28 | export const OFF = "off";
29 |
30 | export const UNAVAILABLE_STATES = new Set([UNAVAILABLE, UNKNOWN]);
31 | export const OFF_STATES = new Set([UNAVAILABLE, UNKNOWN, OFF]);
32 |
33 | export function getToggleServiceFromDomain(
34 | domain: string,
35 | turnOn: boolean = true
36 | ) {
37 | switch (domain) {
38 | case "lock":
39 | return turnOn ? "unlock" : "lock";
40 | case "cover":
41 | return turnOn ? "open_cover" : "close_cover";
42 | case "button":
43 | case "input_button":
44 | return "press";
45 | case "scene":
46 | return "turn_on";
47 | default:
48 | return turnOn ? "turn_on" : "turn_off";
49 | }
50 | }
51 |
52 | export function entitySupportsFeature(
53 | entity: HassEntity,
54 | feature: number
55 | ): boolean {
56 | return (entity.attributes?.supported_features! & feature) !== 0;
57 | }
58 |
59 | async function loadTokens(
60 | config: HomeAssistantConfig
61 | ): Promise {
62 | console.log("Load Home Assistant tokens:", config);
63 |
64 | if (
65 | !config.accessToken ||
66 | !config.refreshToken ||
67 | !config.clientId ||
68 | !config.expires ||
69 | !config.expiresIn ||
70 | !config.url
71 | )
72 | return null;
73 |
74 | return {
75 | access_token: config.accessToken,
76 | refresh_token: config.refreshToken,
77 | clientId: config.clientId,
78 | expires: Number(config.expires),
79 | expires_in: config.expiresIn,
80 | hassUrl: config.url,
81 | };
82 | }
83 |
84 | async function saveTokens(
85 | dashboardId: string,
86 | data: AuthData | null
87 | ): Promise {
88 | console.log("Save Home Assistant tokens:", data);
89 |
90 | await homeAssistantUpdateConfig(dashboardId, {
91 | accessToken: data?.access_token,
92 | refreshToken: data?.refresh_token,
93 | clientId: data?.clientId,
94 | expires: data?.expires,
95 | expiresIn: data?.expires_in,
96 | });
97 | }
98 |
99 | export class HomeAssistant {
100 | public config: HomeAssistantConfig | null = null;
101 | public connection: Connection | null = null;
102 | public dashboardId: string;
103 | public haConfig: HassConfig | null = null;
104 | public haEntities: HassEntities | null = null;
105 | public haServices: HassServices | null = null;
106 | public haUser: HassUser | null = null;
107 |
108 | private auth: Auth | null = null;
109 | private configCallback: (config: HassConfig) => void;
110 | private connectedCallback: () => void;
111 | private entitiesCallback: (entities: HassEntities) => void;
112 | private servicesCallback: (services: HassServices) => void;
113 |
114 | constructor(
115 | dashboardId: string,
116 | connectedCallback?: () => void,
117 | configCallback?: (config: HassConfig) => void,
118 | entitiesCallback?: (entities: HassEntities) => void,
119 | servicesCallback?: (services: HassServices) => void,
120 | connection?: Connection,
121 | config?: HomeAssistantConfig
122 | ) {
123 | this.dashboardId = dashboardId;
124 | this.connectedCallback = connectedCallback || (() => {});
125 | this.configCallback = configCallback || (() => {});
126 | this.entitiesCallback = entitiesCallback || (() => {});
127 | this.servicesCallback = servicesCallback || (() => {});
128 | this.connection = connection || null;
129 | this.config = config || null;
130 | }
131 |
132 | public baseUrl(): string | null {
133 | return this.config?.url || null;
134 | }
135 |
136 | public connected: boolean = this.connection !== null;
137 |
138 | async callService(
139 | domain: string,
140 | service: string,
141 | serviceData: Record
142 | ): Promise {
143 | if (!this.connection) return;
144 | console.log("Call Home Assistant service:", {
145 | domain,
146 | service,
147 | serviceData,
148 | });
149 | return await callService(this.connection, domain, service, serviceData);
150 | }
151 |
152 | async disconnect(): Promise {
153 | if (this.connection) {
154 | this.connection.close();
155 | this.connection = null;
156 | }
157 | }
158 |
159 | async connect(): Promise {
160 | if (this.connection) return;
161 | if (!this.config) throw new Error("Config not loaded");
162 |
163 | console.log("Connecting to Home Assistant:", this.config);
164 |
165 | try {
166 | // Create auth object
167 | this.auth = await getAuth({
168 | hassUrl: this.config.url,
169 | loadTokens: async (): Promise => {
170 | if (!this.config) {
171 | console.error("loadTokens - Config not loaded");
172 | return undefined;
173 | }
174 | return await loadTokens(this.config);
175 | },
176 | saveTokens: async (data: AuthData | null) =>
177 | await saveTokens(this.dashboardId, data),
178 | });
179 |
180 | // Connect to Home Assistant
181 | this.connection = await createConnection({ auth: this.auth });
182 | } catch (err) {
183 | console.warn("Failed to connect to Home Assistant:", err);
184 | if (err !== ERR_HASS_HOST_REQUIRED && err !== ERR_INVALID_AUTH) throw err;
185 |
186 | // Clear stored tokens
187 | await saveTokens(this.dashboardId, null);
188 |
189 | if (err === ERR_HASS_HOST_REQUIRED)
190 | throw new Error("No Home Assistant URL provided");
191 |
192 | console.warn("Invalid Home Assistant credentials");
193 | this.auth = await getAuth({ hassUrl: this.config.url });
194 | return;
195 | }
196 |
197 | this.connection.addEventListener("ready", () => {
198 | console.log("Home Assistant connection ready");
199 | });
200 |
201 | this.connection.addEventListener("disconnected", () => {
202 | console.log("Disconnected from Home Assistant");
203 | if (this.connection) this.connection.reconnect();
204 | });
205 |
206 | subscribeConfig(this.connection, (config: HassConfig) => {
207 | console.log("Home Assistant config updated");
208 | this.haConfig = config;
209 | this.configCallback(config);
210 | });
211 |
212 | subscribeEntities(this.connection, (entities: HassEntities) => {
213 | this.haEntities = entities;
214 | this.entitiesCallback(entities);
215 | });
216 |
217 | subscribeServices(this.connection, (services) => {
218 | this.haServices = services;
219 | this.servicesCallback(services);
220 | });
221 |
222 | getUser(this.connection).then((user: HassUser) => {
223 | console.log("Logged into Home Assistant as", user.name);
224 | this.haUser = user;
225 | this.connectedCallback();
226 | });
227 | }
228 |
229 | entityCanTurnOnOff(entity: HassEntity | undefined): boolean {
230 | if (!entity) return false;
231 | const domain = entity.entity_id.split(".")[0];
232 | const service = getToggleServiceFromDomain(domain);
233 | if (this.haServices?.[domain]?.[service]) return true;
234 | return false;
235 | }
236 |
237 | async entityTurnOnOff(entity: HassEntity, turnOn = true): Promise {
238 | if (!this.connection) return;
239 | const domain = entity.entity_id.split(".")[0];
240 |
241 | return await this.callService(
242 | domain === "group" ? "homeassistant" : domain,
243 | getToggleServiceFromDomain(domain, turnOn),
244 | {
245 | entity_id: entity.entity_id,
246 | }
247 | );
248 | }
249 | }
250 |
--------------------------------------------------------------------------------
/src/utils/prisma.ts:
--------------------------------------------------------------------------------
1 | import type { NextAuthOptions, RequestInternal, User } from "next-auth";
2 | import type { User as UserModel } from "@prisma/client";
3 | import { PrismaAdapter } from "@next-auth/prisma-adapter";
4 | import { PrismaClient } from "@prisma/client";
5 | import CredentialsProvider from "next-auth/providers/credentials";
6 |
7 | export const prisma = new PrismaClient();
8 |
9 | export const authOptions: NextAuthOptions = {
10 | adapter: PrismaAdapter(prisma),
11 | session: {
12 | strategy: "jwt",
13 | },
14 | providers: [
15 | CredentialsProvider({
16 | name: "Credentials",
17 | credentials: {
18 | username: { label: "Username", type: "text", placeholder: "jsmith" },
19 | password: { label: "Password", type: "password" },
20 | },
21 | async authorize(
22 | credentials: Record<"username" | "password", string> | undefined,
23 | _req: Pick
24 | ): Promise {
25 | if (!credentials) return null;
26 |
27 | const user: UserModel | null = await prisma.user.findUnique({
28 | where: {
29 | username: credentials.username,
30 | },
31 | });
32 |
33 | if (user) {
34 | if (user.password !== credentials.password) return null;
35 | // Any object returned will be saved in `user` property of the JWT
36 | return {
37 | id: user.id,
38 | name: user.name,
39 | email: user.username,
40 | image: user.image,
41 | };
42 | } else {
43 | // If you return null then an error will be displayed advising the user to check their details.
44 | return null;
45 | }
46 | },
47 | }),
48 | ],
49 | };
50 |
--------------------------------------------------------------------------------
/src/utils/serverActions/dashboard.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 | import type { Dashboard, HeaderItem } from "@prisma/client";
3 | import { revalidatePath } from "next/cache";
4 |
5 | import { prisma } from "@/utils/prisma";
6 | import { HeaderItemType } from "@/types/dashboard.type";
7 |
8 | export async function dashboardCreate(username: string): Promise {
9 | const user = await prisma.user.findUniqueOrThrow({
10 | where: { username },
11 | });
12 |
13 | console.log("Create dashboard:", { userId: user.id });
14 |
15 | const newData = await prisma.dashboard.create({
16 | data: {
17 | name: "Dashboard",
18 | sections: {
19 | create: [
20 | {
21 | title: "Section 01",
22 | subtitle: "Example section",
23 | width: "480px",
24 | widgets: {
25 | create: [],
26 | },
27 | },
28 | ],
29 | },
30 | headerItems: {
31 | create: {
32 | type: HeaderItemType.DateTime,
33 | },
34 | },
35 | user: {
36 | connect: {
37 | id: user.id,
38 | },
39 | },
40 | },
41 | });
42 |
43 | revalidatePath("/dashboards");
44 | revalidatePath(`/dashboards/${newData.id}`);
45 | revalidatePath(`/dashboards/${newData.id}/edit`);
46 |
47 | return newData;
48 | }
49 |
50 | export async function dashboardDelete(id: string): Promise {
51 | console.log("Delete dashboard:", { id });
52 |
53 | const data = await prisma.dashboard.delete({
54 | where: { id },
55 | });
56 |
57 | revalidatePath("/dashboards");
58 |
59 | return data;
60 | }
61 |
62 | export async function dashboardUpdate(
63 | dashboardId: string,
64 | name: string,
65 | value: any
66 | ): Promise {
67 | console.log("Update dashboard:", { dashboardId, name, value });
68 |
69 | const newData = await prisma.dashboard.update({
70 | data: {
71 | [name]: value,
72 | },
73 | where: {
74 | id: dashboardId,
75 | },
76 | });
77 |
78 | revalidatePath(`/dashboards/${dashboardId}`);
79 | revalidatePath(`/dashboards/${dashboardId}/edit`);
80 |
81 | return newData;
82 | }
83 |
84 | export async function dashboardHeaderUpdate(
85 | dashboardId: string,
86 | items: Array
87 | ): Promise> {
88 | console.log("Update dashboard header:", { dashboardId, items });
89 |
90 | await prisma.headerItem.deleteMany({
91 | where: {
92 | dashboardId: dashboardId,
93 | },
94 | });
95 |
96 | let position = 0;
97 | for (const type of items) {
98 | position += 10;
99 | await prisma.headerItem.create({
100 | data: {
101 | position,
102 | type,
103 | dashboard: {
104 | connect: {
105 | id: dashboardId,
106 | },
107 | },
108 | },
109 | });
110 | }
111 |
112 | const newData = await prisma.headerItem.findMany({
113 | where: {
114 | dashboardId: dashboardId,
115 | },
116 | });
117 |
118 | console.log("New header items:", newData);
119 |
120 | revalidatePath(`/dashboards/${dashboardId}`);
121 | revalidatePath(`/dashboards/${dashboardId}/edit`);
122 |
123 | return newData;
124 | }
125 |
--------------------------------------------------------------------------------
/src/utils/serverActions/homeAssistant.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 | import type {
3 | HomeAssistant as HomeAssistantConfig,
4 | Prisma,
5 | } from "@prisma/client";
6 |
7 | import { prisma } from "@/utils/prisma";
8 |
9 | export async function homeAssistantGetConfig(
10 | dashboardId: string
11 | ): Promise {
12 | console.log("Get Home Assistant config:", { dashboardId });
13 | if (!dashboardId) throw new Error("Dashboard ID is required");
14 |
15 | return await prisma.homeAssistant.findUniqueOrThrow({
16 | where: {
17 | dashboardId,
18 | },
19 | });
20 | }
21 |
22 | export async function homeAssistantUpdateConfig(
23 | dashboardId: string,
24 | data: Prisma.HomeAssistantUpdateInput
25 | ): Promise {
26 | console.log("Update Home Assistant config:", { dashboardId, data });
27 | if (!dashboardId) throw new Error("Dashboard ID is required");
28 |
29 | return await prisma.homeAssistant.update({
30 | data,
31 | where: {
32 | dashboardId,
33 | },
34 | });
35 | }
36 |
--------------------------------------------------------------------------------
/src/utils/serverActions/section.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 | import type { Section } from "@prisma/client";
3 | import { revalidatePath } from "next/cache";
4 |
5 | import { prisma } from "@/utils/prisma";
6 |
7 | export async function sectionCreate(dashboardId: string): Promise {
8 | console.log("Create section:", { dashboardId });
9 |
10 | const newData = await prisma.section.create({
11 | data: {
12 | title: "Section",
13 | width: "480px",
14 | dashboard: {
15 | connect: {
16 | id: dashboardId,
17 | },
18 | },
19 | },
20 | });
21 |
22 | await sectionsReorganise(dashboardId);
23 |
24 | revalidatePath(`/dashboards/${dashboardId}`);
25 | revalidatePath(`/dashboards/${dashboardId}/sections/${newData.id}/edit`);
26 |
27 | return newData;
28 | }
29 |
30 | export async function sectionDelete(
31 | dashboardId: string,
32 | id: string
33 | ): Promise {
34 | console.log("Delete section:", { id });
35 |
36 | const data = await prisma.section.delete({
37 | where: { id },
38 | });
39 |
40 | revalidatePath(`/dashboards/${dashboardId}`);
41 |
42 | return data;
43 | }
44 |
45 | export async function sectionsReorganise(dashboardId: string): Promise {
46 | console.log("Reorganise sections:", { dashboardId });
47 |
48 | const sections = await prisma.section.findMany({
49 | select: { id: true },
50 | where: { dashboardId },
51 | orderBy: { position: "asc" },
52 | });
53 |
54 | // Reorganise section positions in 10s
55 | for (let i = 0; i < sections.length; i++) {
56 | const section = sections[i];
57 | const newPosition = i * 10;
58 | console.log("Reorganise section:", {
59 | index: i,
60 | id: section.id,
61 | newPosition,
62 | });
63 | await prisma.section.update({
64 | data: { position: newPosition },
65 | where: { id: section.id },
66 | });
67 | }
68 | }
69 |
70 | export async function sectionUpdate(
71 | dashboardId: string,
72 | sectionId: string,
73 | name: string,
74 | value: any
75 | ): Promise {
76 | console.log("Update section:", dashboardId, sectionId, name, value);
77 |
78 | let newData = await prisma.section.update({
79 | data: {
80 | [name]: value,
81 | },
82 | where: {
83 | id: sectionId,
84 | },
85 | });
86 |
87 | if (name === "position") {
88 | await sectionsReorganise(newData.dashboardId);
89 | newData = await prisma.section.findUniqueOrThrow({
90 | where: { id: sectionId },
91 | });
92 | }
93 |
94 | revalidatePath(`/dashboards/${dashboardId}`);
95 | revalidatePath(`/dashboards/${dashboardId}/sections/${newData.id}/edit`);
96 |
97 | console.log("New section data:", newData);
98 |
99 | return newData;
100 | }
101 |
--------------------------------------------------------------------------------
/src/utils/serverActions/widget.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 | import type {
3 | Widget as WidgetModel,
4 | WidgetChecklistItem as WidgetChecklistItemModel,
5 | WidgetFrame as WidgetFrameModel,
6 | WidgetHomeAssistant as WidgetHomeAssistantModel,
7 | WidgetImage as WidgetImageModel,
8 | WidgetMarkdown as WidgetMarkdownModel,
9 | } from "@prisma/client";
10 | import { revalidatePath } from "next/cache";
11 |
12 | import { prisma } from "@/utils/prisma";
13 | import { WidgetType } from "@/types/widget.type";
14 |
15 | export async function widgetCreate(
16 | dashboardId: string,
17 | sectionId: string
18 | ): Promise {
19 | console.log("Create widget:", { dashboardId, sectionId });
20 |
21 | let newData = await prisma.widget.create({
22 | data: {
23 | type: WidgetType.Markdown,
24 | title: "",
25 | markdown: {
26 | create: {
27 | content: "",
28 | },
29 | },
30 | section: {
31 | connect: {
32 | id: sectionId,
33 | },
34 | },
35 | },
36 | });
37 |
38 | await widgetsReorganise(sectionId);
39 |
40 | await widgetRevalidate(dashboardId, sectionId, newData.id);
41 | newData = await prisma.widget.findUniqueOrThrow({
42 | where: { id: newData.id },
43 | });
44 |
45 | return newData;
46 | }
47 |
48 | export async function widgetDelete(
49 | dashboardId: string,
50 | id: string
51 | ): Promise {
52 | console.log("Delete widget:", { id });
53 |
54 | const data = await prisma.widget.delete({
55 | where: { id },
56 | });
57 |
58 | await widgetsReorganise(data.sectionId);
59 |
60 | revalidatePath(`/dashboards/${dashboardId}`);
61 |
62 | return data;
63 | }
64 |
65 | export async function widgetGetData(
66 | widgetId: string,
67 | type: string
68 | ): Promise {
69 | console.log("Get widget data:", { widgetId, type });
70 | let data;
71 | switch (type) {
72 | case WidgetType.Checklist:
73 | data = await prisma.widgetChecklist.findUnique({
74 | include: { items: { orderBy: { position: "asc" } } },
75 | where: { widgetId },
76 | });
77 | if (data) return data;
78 | return await prisma.widgetChecklist.create({
79 | data: {
80 | items: {
81 | create: {
82 | content: "",
83 | },
84 | },
85 | widget: { connect: { id: widgetId } },
86 | },
87 | include: { items: true },
88 | });
89 | case WidgetType.Frame:
90 | data = await prisma.widgetFrame.findUnique({
91 | where: { widgetId },
92 | });
93 | if (data) return data;
94 | return await prisma.widgetFrame.create({
95 | data: {
96 | url: "",
97 | widget: { connect: { id: widgetId } },
98 | },
99 | });
100 | case WidgetType.HomeAssistant:
101 | data = await prisma.widgetHomeAssistant.findUnique({
102 | where: { widgetId },
103 | });
104 | if (data) return data;
105 | return await prisma.widgetHomeAssistant.create({
106 | data: {
107 | entityId: "",
108 | widget: { connect: { id: widgetId } },
109 | showName: true,
110 | showIcon: true,
111 | showState: true,
112 | },
113 | });
114 | case WidgetType.Image:
115 | data = await prisma.widgetImage.findUnique({
116 | where: { widgetId },
117 | });
118 | if (data) return data;
119 | return await prisma.widgetImage.create({
120 | data: {
121 | url: "",
122 | widget: { connect: { id: widgetId } },
123 | },
124 | });
125 | case WidgetType.Markdown:
126 | data = await prisma.widgetMarkdown.findUnique({
127 | where: {
128 | widgetId,
129 | },
130 | });
131 | if (data) return data;
132 | return await prisma.widgetMarkdown.create({
133 | data: {
134 | content: "",
135 | widget: { connect: { id: widgetId } },
136 | },
137 | });
138 | default:
139 | throw new Error(`Unknown widget type: ${type}`);
140 | }
141 | }
142 |
143 | async function widgetsReorganise(sectionId: string): Promise {
144 | console.log("Reorganise widgets:", { sectionId });
145 | const ids = await prisma.widget.findMany({
146 | select: { id: true },
147 | where: { sectionId },
148 | orderBy: { position: "asc" },
149 | });
150 |
151 | // Reorganise widget positions in 10s
152 | for (let i = 0; i < ids.length; i++) {
153 | const widget = ids[i];
154 | const newPosition = i * 10;
155 | console.log("Reorganise widget:", { index: i, id: widget.id, newPosition });
156 | await prisma.widget.update({
157 | data: { position: newPosition },
158 | where: { id: widget.id },
159 | });
160 | }
161 | }
162 |
163 | async function widgetRevalidate(
164 | dashboardId: string,
165 | sectionId: string,
166 | widgetId: string
167 | ) {
168 | revalidatePath(`/dashboards/${dashboardId}`);
169 | revalidatePath(
170 | `/dashboards/${dashboardId}/sections/${sectionId}/widgets/${widgetId}/edit`
171 | );
172 | }
173 |
174 | export async function widgetUpdate(
175 | dashboardId: string,
176 | widgetId: string,
177 | name: string,
178 | value: any
179 | ): Promise {
180 | console.log("Update widget:", { dashboardId, widgetId, name, value });
181 |
182 | let newData = await prisma.widget.update({
183 | data: { [name]: value },
184 | where: { id: widgetId },
185 | });
186 |
187 | if (name === "position") {
188 | await widgetsReorganise(newData.sectionId);
189 | newData = await prisma.widget.findUniqueOrThrow({
190 | where: { id: widgetId },
191 | });
192 | }
193 |
194 | await widgetRevalidate(dashboardId, newData.sectionId, widgetId);
195 |
196 | console.log("New widget data:", newData);
197 |
198 | return newData;
199 | }
200 |
201 | export async function widgetChecklistUpdate(
202 | dashboardId: string,
203 | sectionId: string,
204 | widgetId: string,
205 | checklistItemId: string,
206 | name: keyof WidgetChecklistItemModel,
207 | value: WidgetChecklistItemModel[keyof WidgetChecklistItemModel]
208 | ): Promise {
209 | console.log("Update widget checklist:", {
210 | dashboardId,
211 | sectionId,
212 | widgetId,
213 | checklistItemId,
214 | name,
215 | value,
216 | });
217 |
218 | let newData: WidgetChecklistItemModel;
219 | if (name === "id" && value === "DELETE") {
220 | newData = await prisma.widgetChecklistItem.delete({
221 | where: {
222 | id: checklistItemId,
223 | },
224 | });
225 | } else {
226 | newData = await prisma.widgetChecklistItem.upsert({
227 | create: {
228 | content: "",
229 | position: 9999,
230 | [name]: value,
231 | checklist: {
232 | connect: {
233 | widgetId,
234 | },
235 | },
236 | },
237 | update: {
238 | [name]: value,
239 | },
240 | where: {
241 | id: checklistItemId,
242 | },
243 | });
244 | }
245 |
246 | // Sort checklist items
247 | const items = await prisma.widgetChecklistItem.findMany({
248 | select: { id: true },
249 | where: { checklist: { widgetId: widgetId } },
250 | });
251 | for (let i = 0; i < items.length; i++) {
252 | const item = items[i];
253 | await prisma.widgetChecklistItem.update({
254 | data: { position: i * 10 },
255 | where: { id: item.id },
256 | });
257 | }
258 |
259 | // Load new data with new position
260 | newData =
261 | (await prisma.widgetChecklistItem.findUnique({
262 | where: { id: newData.id },
263 | })) || newData;
264 |
265 | await widgetRevalidate(dashboardId, sectionId, widgetId);
266 |
267 | return newData;
268 | }
269 |
270 | export async function widgetFrameUpdate(
271 | dashboardId: string,
272 | sectionId: string,
273 | widgetId: string,
274 | name: string,
275 | value: any
276 | ): Promise {
277 | console.log("Update widget frame:", {
278 | dashboardId,
279 | sectionId,
280 | widgetId,
281 | name,
282 | value,
283 | });
284 |
285 | const newData = await prisma.widgetFrame.update({
286 | data: {
287 | [name]: value,
288 | },
289 | where: {
290 | widgetId,
291 | },
292 | });
293 |
294 | await widgetRevalidate(dashboardId, sectionId, widgetId);
295 |
296 | return newData;
297 | }
298 |
299 | export async function widgetHomeAssistantUpdate(
300 | dashboardId: string,
301 | sectionId: string,
302 | widgetId: string,
303 | name: string,
304 | value: any
305 | ): Promise {
306 | console.log("Update widget home assistant:", {
307 | dashboardId,
308 | sectionId,
309 | widgetId,
310 | name,
311 | value,
312 | });
313 |
314 | const newData = await prisma.widgetHomeAssistant.update({
315 | data: {
316 | [name]: value,
317 | },
318 | where: {
319 | widgetId,
320 | },
321 | });
322 |
323 | await widgetRevalidate(dashboardId, sectionId, widgetId);
324 |
325 | return newData;
326 | }
327 |
328 | export async function widgetImageUpdate(
329 | dashboardId: string,
330 | sectionId: string,
331 | widgetId: string,
332 | name: string,
333 | value: any
334 | ): Promise {
335 | console.log("Update widget image:", {
336 | dashboardId,
337 | sectionId,
338 | widgetId,
339 | name,
340 | value,
341 | });
342 |
343 | const newData = await prisma.widgetImage.update({
344 | data: {
345 | [name]: value,
346 | },
347 | where: {
348 | widgetId,
349 | },
350 | });
351 |
352 | await widgetRevalidate(dashboardId, sectionId, widgetId);
353 |
354 | return newData;
355 | }
356 |
357 | export async function widgetMarkdownUpdate(
358 | dashboardId: string,
359 | sectionId: string,
360 | widgetId: string,
361 | name: string,
362 | value: any
363 | ): Promise {
364 | console.log("Update widget markdown:", {
365 | dashboardId,
366 | sectionId,
367 | widgetId,
368 | name,
369 | value,
370 | });
371 |
372 | const newData = await prisma.widgetMarkdown.update({
373 | data: {
374 | [name]: value,
375 | },
376 | where: {
377 | widgetId,
378 | },
379 | });
380 |
381 | await widgetRevalidate(dashboardId, sectionId, widgetId);
382 |
383 | return newData;
384 | }
385 |
--------------------------------------------------------------------------------
/src/utils/theme.ts:
--------------------------------------------------------------------------------
1 | import { createTheme } from "@mui/material/styles";
2 | import { Roboto } from "next/font/google";
3 |
4 | export const roboto = Roboto({
5 | weight: ["300", "400", "500", "700"],
6 | subsets: ["latin"],
7 | display: "swap",
8 | fallback: ["Helvetica", "Arial", "sans-serif"],
9 | });
10 |
11 | export const primaryColorRgb = "126, 87, 194";
12 |
13 | // Create a theme instance.
14 | export const theme = createTheme({
15 | palette: {
16 | mode: "dark",
17 | },
18 | typography: {
19 | fontFamily: roboto.style.fontFamily,
20 | },
21 | });
22 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "plugins": [
18 | {
19 | "name": "next"
20 | }
21 | ],
22 | "paths": {
23 | "@/*": ["./src/*"]
24 | }
25 | },
26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
27 | "exclude": ["node_modules"]
28 | }
29 |
--------------------------------------------------------------------------------