Compare commits

..

37 Commits

Author SHA1 Message Date
shamoon
87d1ea4f2e Update services.md 2024-02-13 10:54:34 -08:00
shamoon
35af27f209 Update ical.jsx 2024-02-10 21:37:59 -08:00
SASAGAWA Kiyoshi
1c529c0e7d Fix: iCal integration fails with all-day events (#2883)
---------

Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2024-02-10 12:30:37 -08:00
Florian Hye
187291eeca Chore: add Python requirements and prettier to devcontaier (#2878) 2024-02-09 11:46:56 -08:00
Lawton Manning
eda5b0f0cf Fix: healthchecks widget does not respect fields parameter (#2875) 2024-02-09 11:28:13 -08:00
Florian Hye
3b76772f81 Fix: search opens when losing focus, prevent unnecessary search API calls (#2867)
Co-Authored-By: shamoon <4887959+shamoon@users.noreply.github.com>
2024-02-09 11:28:13 -08:00
Florian Hye
3955743590 Enhancement: initially collapsed option for layout groups 2024-02-08 10:11:35 -08:00
dependabot[bot]
74a52d9288 Chore(deps): Bump pre-commit/action from 3.0.0 to 3.0.1 (#2854)
Bumps [pre-commit/action](https://github.com/pre-commit/action) from 3.0.0 to 3.0.1.
- [Release notes](https://github.com/pre-commit/action/releases)
- [Commits](https://github.com/pre-commit/action/compare/v3.0.0...v3.0.1)

---
updated-dependencies:
- dependency-name: pre-commit/action
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-07 09:34:21 -08:00
shamoon
fc7ce0a253 Update CONTRIBUTING.md 2024-02-06 08:36:01 -08:00
shamoon
ea6192e8c6 Fix: Increase icon z-index (#2842) 2024-02-06 07:11:17 -08:00
shamoon
8eb61ef9ff Unifi widget: Show a more helpful error if specified site not found (#2839) 2024-02-05 14:46:17 -08:00
shamoon
259d0f1bb9 Update repo-maintenance.yml 2024-02-05 11:21:31 -08:00
shamoon
868335fa4f Add repo maintenance workflow 2024-02-05 01:34:27 -08:00
しぐれ
888349bd64 Documentation: fix abandoned PiAlert repo (#2819) 2024-02-02 07:22:21 -08:00
dependabot[bot]
b9b9fb04aa Chore(deps-dev): Bump tailwind-scrollbar from 2.1.0 to 3.0.5 (#2813)
Bumps [tailwind-scrollbar](https://github.com/adoxography/tailwind-scrollbar) from 2.1.0 to 3.0.5.
- [Release notes](https://github.com/adoxography/tailwind-scrollbar/releases)
- [Commits](https://github.com/adoxography/tailwind-scrollbar/compare/v2.1.0...v3.0.5)

---
updated-dependencies:
- dependency-name: tailwind-scrollbar
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-01 17:36:55 -08:00
shamoon
4af2f9c229 Revert "Chore(deps): Bump react-i18next from 11.18.6 to 12.3.1 (#2810)"
This reverts commit bdbc3cb0ba.
2024-02-01 17:36:15 -08:00
dependabot[bot]
1a724732c8 Chore(deps): Bump dockerode from 3.3.5 to 4.0.2 (#2812)
Bumps [dockerode](https://github.com/apocas/dockerode) from 3.3.5 to 4.0.2.
- [Release notes](https://github.com/apocas/dockerode/releases)
- [Commits](https://github.com/apocas/dockerode/compare/v3.3.5...v4.0.2)

---
updated-dependencies:
- dependency-name: dockerode
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-01 11:43:08 -08:00
dependabot[bot]
bdbc3cb0ba Chore(deps): Bump react-i18next from 11.18.6 to 12.3.1 (#2810)
Bumps [react-i18next](https://github.com/i18next/react-i18next) from 11.18.6 to 12.3.1.
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v11.18.6...v12.3.1)

---
updated-dependencies:
- dependency-name: react-i18next
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-01 11:31:47 -08:00
Jack Bailey
6c741c620c Fix: Format Immich totals (#2814) 2024-02-01 09:38:47 -08:00
dependabot[bot]
ee38b7c757 Chore(deps): Bump compare-versions from 5.0.3 to 6.1.0 (#2809)
Bumps [compare-versions](https://github.com/omichelsen/compare-versions) from 5.0.3 to 6.1.0.
- [Changelog](https://github.com/omichelsen/compare-versions/blob/main/CHANGELOG.md)
- [Commits](https://github.com/omichelsen/compare-versions/compare/v5.0.3...v6.1.0)

---
updated-dependencies:
- dependency-name: compare-versions
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-01 09:38:20 -08:00
shamoon
64ac19859c Fix quicklaunch failing to load without search provider 2024-02-01 01:32:47 -08:00
shamoon
20d1d8f914 Update packages, add dependabot for npm (#2803) 2024-02-01 01:05:02 -08:00
shamoon
ef39fce1de Update FUNDING.yml 2024-02-01 00:53:40 -08:00
shamoon
1ddd528bd7 Fix quick launch not opening with accented characters, decoding of characters in suggestions (#2802) 2024-02-01 00:42:22 -08:00
shamoon
578def33f5 Update quicklaunch.jsx 2024-01-31 23:44:12 -08:00
shamoon
e652d8faa4 Fix some quicklaunch size quirks 2024-01-31 23:41:48 -08:00
René-Marc Simard
61ae891a1e Documentation: specify role requirement in Kavita documentation (#2798) 2024-01-31 19:27:34 -08:00
Florian Hye
d5af7eda63 Feature: search suggestions for search and quick launch (#2775)
---------

Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2024-01-31 17:17:42 -08:00
shamoon
f0635db51d Add API key to jackett 2024-01-30 21:26:04 -08:00
NotSimone
aa882f9574 Documentation: fix gluetun typo (#2787) 2024-01-29 16:55:16 -08:00
Dan Geraghty
86740c6d7b Feature: OpenWRT service widget (#2782)
* Feat: OpenWRT widget implementation

* Update proxy.js

* fixes from review

---------

Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2024-01-29 12:33:31 -08:00
shamoon
d20cdbb9ab Update troubleshooting.md 2024-01-27 23:51:43 -08:00
shamoon
98af4ffaa3 Update troubleshooting.md 2024-01-27 23:50:36 -08:00
shamoon
0b475797da Update README.md 2024-01-27 23:46:39 -08:00
shamoon
fe1928870b Move to discussions 2024-01-27 23:38:56 -08:00
shamoon
98499cdf69 Add missing unifi fields
Co-Authored-By: JeffRandall <1891490+JeffRandall@users.noreply.github.com>
2024-01-27 15:25:34 -08:00
shamoon
7230b622a3 Update FUNDING.yml 2024-01-26 21:52:26 -08:00
49 changed files with 3723 additions and 2004 deletions

View File

@@ -3,4 +3,10 @@ FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:${VARIANT}
RUN npm install -g pnpm
RUN apt-get update \
&& apt-get -y install --no-install-recommends \
python3-pip \
&& apt-get clean -y \
&& rm -rf /var/lib/apt/lists/*
ENV PATH="${PATH}:./node_modules/.bin"

View File

@@ -1,27 +1,26 @@
{
"name": "homepage",
"build": {
"dockerfile": "Dockerfile",
"args": {
"VARIANT": "18-bullseye"
}
},
"customizations": {
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint",
"mhutchie.git-graph",
"streetsidesoftware.code-spell-checker",
],
"settings": {
"eslint.format.enable": true,
"eslint.lintTask.enable": true,
"eslint.packageManager": "pnpm"
}
}
},
"postCreateCommand": ".devcontainer/setup.sh",
"forwardPorts": [
3000
]
"name": "homepage",
"build": {
"dockerfile": "Dockerfile",
"args": {
"VARIANT": "18-bullseye",
},
},
"customizations": {
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint",
"mhutchie.git-graph",
"streetsidesoftware.code-spell-checker",
"esbenp.prettier-vscode",
],
"settings": {
"eslint.format.enable": true,
"eslint.lintTask.enable": true,
"eslint.packageManager": "pnpm",
},
},
},
"postCreateCommand": ".devcontainer/setup.sh",
"forwardPorts": [3000],
}

View File

@@ -3,6 +3,8 @@
# Install Node packages
pnpm install
python3 -m pip install -r requirements.txt
# Copy in skeleton configuration if there is no existing configuration
if [ ! -d "config/" ]; then
echo "Adding skeleton config"

49
.github/DISCUSSION_TEMPLATE/support.yml vendored Normal file
View File

@@ -0,0 +1,49 @@
body:
- type: textarea
id: description
attributes:
label: Description
description: A clear and concise description of the issue or question. If applicable, add screenshots to help explain your problem.
validations:
required: true
- type: input
id: version
attributes:
label: homepage version
placeholder: e.g. v0.4.18 (4ea2798)
validations:
required: true
- type: dropdown
id: install-method
attributes:
label: Installation method
options:
- Docker
- Unraid
- Source
- Other (please describe above)
validations:
required: true
- type: textarea
id: config
attributes:
label: Configuration
description: Please provide any relevant service, widget or otherwise related configuration here
render: yaml
- type: textarea
id: container-logs
attributes:
label: Container Logs
description: Please review and provide any logs from the container, if relevant
- type: textarea
id: browser-logs
attributes:
label: Browser Logs
description: Please review and provide any logs from the browser, if relevant
- type: textarea
id: troubleshooting
attributes:
label: Troubleshooting
description: Please include output from your [troubleshooting tests](https://gethomepage.dev/latest/more/troubleshooting/#service-widget-errors), if relevant.
validations:
required: true

3
.github/FUNDING.yml vendored
View File

@@ -1,3 +1,2 @@
github: [gethomepage, benphelps, shamoon]
ko_fi: benphelps
custom: ["https://paypal.me/phelpsben"]
open_collective: homepage

View File

@@ -1,99 +0,0 @@
name: Bug report
description: Create a report to help us improve
title: "[Bug] Concise description of the issue"
labels: ["bug, unconfirmed"]
body:
- type: markdown
attributes:
value: |
## ⚠️ Please remember: issues are for *bugs*
That is, something you believe affects every single homepage user, not just you. Otherwise, start with one of the other options below.
- type: markdown
attributes:
value: |
Have a question? 👉 [Start a new discussion](https://github.com/gethomepage/homepage/discussions/new) or [ask in chat](https://discord.gg/SaPGSzrEZC).
Before opening an issue, please double check:
- [The troubleshooting guide](https://gethomepage.dev/latest/more/troubleshooting/).
- [The homepage documentation](https://gethomepage.dev/)
- [Existing issues](https://github.com/gethomepage/homepage/search?q=&type=issues) and [discussions](https://github.com/gethomepage/homepage/search?q=&type=discussions).
- type: textarea
id: description
attributes:
label: Description
description: A clear and concise description of what the bug is. If applicable, add screenshots to help explain your problem.
placeholder: |
Currently homepage does not work when...
[Screenshot if applicable]
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps to reproduce
description: Steps to reproduce the behavior.
placeholder: |
1. Go to '...'
2. Click on '....'
3. See error
validations:
required: true
- type: input
id: version
attributes:
label: homepage version
placeholder: e.g. v0.4.18 (4ea2798)
validations:
required: true
- type: dropdown
id: install-method
attributes:
label: Installation method
options:
- Docker
- Unraid
- Source
- Other (please describe above)
validations:
required: true
- type: textarea
id: config
attributes:
label: Configuration
description: Please provide any relevant service, widget or otherwise related configuration here
render: yaml
- type: textarea
id: container-logs
attributes:
label: Container Logs
description: Please review and provide any logs from the container, if relevant
- type: textarea
id: browser-logs
attributes:
label: Browser Logs
description: Please review and provide any logs from the browser, if relevant
- type: textarea
id: troubleshooting
attributes:
label: Troubleshooting
description: Please include output from your [troubleshooting tests](https://gethomepage.dev/latest/more/troubleshooting/#service-widget-errors). If this is a service widget issue and you do not include any information here your issue will be closed. If it is not, indicate e.g. 'n/a'
validations:
required: true
- type: textarea
id: other
attributes:
label: Other
description: Include any other relevant details. E.g. service version or API version, docker version, etc.
- type: checkboxes
id: pre-flight
attributes:
label: Before submitting, I have made sure to
options:
- label: Check [the documentation](https://gethomepage.dev/)
required: true
- label: Follow [the troubleshooting guide](https://gethomepage.dev/latest/more/troubleshooting/) (please include output above if applicable).
required: true
- label: Search [existing issues](https://github.com/gethomepage/homepage/search?q=&type=issues) and [discussions](https://github.com/gethomepage/homepage/search?q=&type=discussions).
required: true

View File

@@ -2,7 +2,7 @@ blank_issues_enabled: false
contact_links:
- name: 🤔 Questions and Help
url: https://github.com/gethomepage/homepage/discussions
about: This issue tracker is for bugs only, not general support questions. Please refer to our Discussions.
about: For support or general questions.
- name: 💬 Chat
url: https://discord.gg/k4ruYNrudu
about: Want to discuss homepage with others? Check out our chat.

View File

@@ -5,7 +5,11 @@
version: 2
updates:
- package-ecosystem: "github-actions" # Maintain dependencies for GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "monthly"

View File

@@ -46,7 +46,7 @@ jobs:
python-version: 3.x
-
name: Check files
uses: pre-commit/action@v3.0.0
uses: pre-commit/action@v3.0.1
build:
name: Docker Build & Push

View File

@@ -32,7 +32,7 @@ jobs:
python-version: 3.x
-
name: Check files
uses: pre-commit/action@v3.0.0
uses: pre-commit/action@v3.0.1
test:
name: Test Build

199
.github/workflows/repo-maintenance.yml vendored Normal file
View File

@@ -0,0 +1,199 @@
name: 'Repository Maintenance'
on:
schedule:
- cron: '0 3 * * *'
workflow_dispatch:
permissions:
issues: write
pull-requests: write
discussions: write
concurrency:
group: lock
jobs:
stale:
name: 'Stale'
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
with:
days-before-stale: 7
days-before-close: 14
stale-issue-label: stale
stale-pr-label: stale
stale-issue-message: >
This issue has been automatically marked as stale because it has not had
recent activity. It will be closed if no further activity occurs. Thank you
for your contributions.
lock-threads:
name: 'Lock Old Threads'
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v5
with:
issue-inactive-days: '30'
pr-inactive-days: '30'
discussion-inactive-days: '30'
log-output: true
issue-comment: >
This issue has been automatically locked since there
has not been any recent activity after it was closed.
Please open a new discussion for related concerns.
pr-comment: >
This pull request has been automatically locked since there
has not been any recent activity after it was closed.
Please open a new discussion for related concerns.
discussion-comment: >
This discussion has been automatically locked since there
has not been any recent activity after it was closed.
Please open a new discussion for related concerns.
close-answered-discussions:
name: 'Close Answered Discussions'
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v7
with:
script: |
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
const query = `query($owner:String!, $name:String!) {
repository(owner:$owner, name:$name){
discussions(first:100, answered:true, states:[OPEN]) {
nodes {
id,
number
}
}
}
}`;
const variables = {
owner: context.repo.owner,
name: context.repo.repo,
}
const result = await github.graphql(query, variables)
console.log(`Found ${result.repository.discussions.nodes.length} open answered discussions`)
for (const discussion of result.repository.discussions.nodes) {
console.log(`Closing discussion #${discussion.number} (${discussion.id})`)
const addCommentMutation = `mutation($discussion:ID!, $body:String!) {
addDiscussionComment(input:{discussionId:$discussion, body:$body}) {
clientMutationId
}
}`;
const commentVariables = {
discussion: discussion.id,
body: 'This discussion has been automatically closed because it was marked as answered.',
}
await github.graphql(addCommentMutation, commentVariables)
const closeDiscussionMutation = `mutation($discussion:ID!, $reason:DiscussionCloseReason!) {
closeDiscussion(input:{discussionId:$discussion, reason:$reason}) {
clientMutationId
}
}`;
const closeVariables = {
discussion: discussion.id,
reason: "RESOLVED",
}
await github.graphql(closeDiscussionMutation, closeVariables)
await sleep(1000)
}
close-outdated-discussions:
name: 'Close Outdated Discussions'
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v7
with:
script: |
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
const CUTOFF_DAYS = 180;
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - CUTOFF_DAYS);
const query = `query(
$owner:String!,
$name:String!,
$supportCategory:ID!,
$generalCategory:ID!,
) {
supportDiscussions: repository(owner:$owner, name:$name){
discussions(
categoryId:$supportCategory,
last:50,
answered:false,
states:[OPEN],
) {
nodes {
id,
number,
updatedAt
}
},
},
generalDiscussions: repository(owner:$owner, name:$name){
discussions(
categoryId:$generalCategory,
last:50,
states:[OPEN],
) {
nodes {
id,
number,
updatedAt
}
}
}
}`;
const variables = {
owner: context.repo.owner,
name: context.repo.repo,
supportCategory: "DIC_kwDOH31rQM4CRErR",
generalCategory: "DIC_kwDOH31rQM4CRErQ"
}
const result = await github.graphql(query, variables);
const combinedDiscussions = [
...result.supportDiscussions.discussions.nodes,
...result.generalDiscussions.discussions.nodes,
]
console.log(`Checking ${combinedDiscussions.length} open discussions`);
for (const discussion of combinedDiscussions) {
if (new Date(discussion.updatedAt) < cutoff) {
console.log(`Closing outdated discussion #${discussion.number} (${discussion.id}), last updated at ${discussion.updatedAt}`);
const addCommentMutation = `mutation($discussion:ID!, $body:String!) {
addDiscussionComment(input:{discussionId:$discussion, body:$body}) {
clientMutationId
}
}`;
const commentVariables = {
discussion: discussion.id,
body: 'This discussion has been automatically closed due to inactivity.',
}
await github.graphql(addCommentMutation, commentVariables);
const closeDiscussionMutation = `mutation($discussion:ID!, $reason:DiscussionCloseReason!) {
closeDiscussion(input:{discussionId:$discussion, reason:$reason}) {
clientMutationId
}
}`;
const closeVariables = {
discussion: discussion.id,
reason: "OUTDATED",
}
await github.graphql(closeDiscussionMutation, closeVariables);
await sleep(1000);
}
}

View File

@@ -16,9 +16,9 @@ We use github to host code, to track issues and feature requests, as well as acc
In short, when you submit code changes, your submissions are understood to be under the same [GNU General Public License v3.0](https://choosealicense.com/licenses/gpl-3.0/) that covers the project. Feel free to contact the maintainers if that's a concern.
## Report bugs using Github's [issues](https://github.com/gethomepage/homepage/issues)
## Report bugs using Github [discussions](https://github.com/gethomepage/homepage/discussions)
We use GitHub issues to track public bugs. Report a bug by [opening a new issue](https://github.com/gethomepage/homepage/issues/new); it's that easy!
We use GitHub discussions to triage bugs. Report a bug by [opening a new discussion](https://github.com/gethomepage/homepage/discussions/new?category=support); it's that easy!
## Write bug reports with detail, background, and sample configurations

View File

@@ -164,8 +164,6 @@ mkdocs serve # or build, to build the static site
If you have any questions, suggestions, or general issues, please start a discussion on the [Discussions](https://github.com/gethomepage/homepage/discussions) page.
For bug reports, please open an issue on the [Issues](https://github.com/gethomepage/homepage/issues) page.
## Contributing & Contributors
Contributions are welcome! Please see the [CONTRIBUTING.md](CONTRIBUTING.md) file for more information.

View File

@@ -101,7 +101,7 @@ To use a local icon, first create a Docker mount to `/app/public/icons` and then
## Ping
Services may have an optional `ping` property that allows you to monitor the availability of an external host. As of v0.8.0, the ping feature attempts to use a true (ICMP) ping command on the underlying host.
Services may have an optional `ping` property that allows you to monitor the availability of an external host. As of v0.8.0, the ping feature attempts to use a true (ICMP) ping command on the underlying host. Currently, only IPv4 is supported.
```yaml
- Group A:

View File

@@ -229,6 +229,26 @@ disableCollapse: true
By default the feature is enabled.
### Initially collapsed sections
You can initially collapse sections by adding the `initiallyCollapsed` option to the layout group.
```yaml
layout:
Section A:
initiallyCollapsed: true
```
This can also be set globaly using the `groupsInitiallyCollapsed` option.
```yaml
groupsInitiallyCollapsed: true
```
The value set on a group will overwrite the global setting.
By default the feature is disabled.
### Use Equal Height Cards
You can enable equal height cards for groups of services, this will make all cards in a row the same height.
@@ -359,12 +379,14 @@ There are a few optional settings for the Quick Launch feature:
- `searchDescriptions`: which lets you control whether item descriptions are included in searches. This is off by default. When enabled, results that match the item name will be placed above those that only match the description.
- `hideInternetSearch`: disable automatically including the currently-selected web search (e.g. from the widget) as a Quick Launch option. This is false by default, enabling the feature.
- `showSearchSuggestions`: shows search suggestions for the internet search. This value will be inherited from the search widget if it is not specified. If it is not specified there either, it will default to false.
- `hideVisitURL`: disable detecting and offering an option to open URLs. This is false by default, enabling the feature.
```yaml
quicklaunch:
searchDescriptions: true
hideInternetSearch: true
showSearchSuggestions: true
hideVisitURL: true
```

View File

@@ -8,7 +8,7 @@ hide:
## Introducing the Homepage AI Bot
Thanks to the generous folks at [Glime](https://glimelab.ai), Homepage is now equipped with a pretty helpful AI-powered bot. The bot has full knowledge of our docs, GitHub issues and discussions and great at answering specific questions about setting up your Homepage. To use the bot, just hit the 'Ask AI' button on any page in our docs or check out the [#ai-support channel on Discord](https://discord.com/channels/1019316731635834932/1177885603552038993)!
Thanks to the generous folks at [Glime](https://glimelab.ai), Homepage is now equipped with a pretty clever AI-powered bot. The bot has full knowledge of our docs, GitHub issues and discussions and is great at answering specific questions about setting up your Homepage. To use the bot, just hit the 'Ask AI' button on any page in our docs, [open a GitHub discussion](https://github.com/gethomepage/homepage/discussions) or check out the [#ai-support channel on Discord](https://discord.com/channels/1019316731635834932/1177885603552038993)!
## General Troubleshooting Tips

View File

@@ -9,6 +9,7 @@ You can add a search bar to your top widget area that can search using Google, D
- search:
provider: google # google, duckduckgo, bing, baidu, brave or custom
focus: true # Optional, will set focus to the search bar on page load
showSearchSuggestions: true # Optional, will show search suggestions. Defaults to false
target: _blank # One of _self, _blank, _parent or _top
```
@@ -17,8 +18,10 @@ or for a custom search:
```yaml
- search:
provider: custom
url: https://lougle.com/?q=
url: https://www.ecosia.org/search?q=
target: _blank
suggestionUrl: https://ac.ecosia.org/autocomplete?type=list&q= # Optional
showSearchSuggestions: true # Optional
```
multiple providers is also supported via a dropdown (excluding custom):
@@ -28,4 +31,25 @@ multiple providers is also supported via a dropdown (excluding custom):
provider: [brave, google, duckduckgo]
```
The response body for the URL provided with the `suggestionUrl` option should look like this:
```json
[
"home",
[
"home depot",
"home depot near me",
"home equity loan",
"homeworkify",
"homedepot.com",
"homebase login",
"home depot credit card",
"home goods"
]
]
```
The first entry of the array contains the search query, the second one is an array of the suggestions.
In the example above, the search query was **home**.
_Added in v0.1.6, updated in 0.6.0_

View File

@@ -3,7 +3,7 @@ title: Gluetun
description: Gluetun Widget Configuration
---
Learn more about [Glueton](https://github.com/qdm12/gluetun).
Learn more about [Gluetun](https://github.com/qdm12/gluetun).
!!! note

View File

@@ -13,4 +13,5 @@ Allowed fields: `["configured", "errored"]`.
widget:
type: jackett
url: http://jackett.host.or.ip
key: jackettapikey
```

View File

@@ -5,7 +5,7 @@ description: Kavita Widget Configuration
Learn more about [Kavita](https://github.com/Kareadita/Kavita).
Uses the same username and password used to login from the web.
Uses the same admin role username and password used to login from the web.
Allowed fields: `["seriesCount", "totalFiles"]`.

View File

@@ -0,0 +1,54 @@
---
title: OpenWRT
description: OpenWRT widget configuration
---
Learn more about [OpenWRT](https://openwrt.org/).
Provides information from OpenWRT
```yaml
widget:
type: openwrt
url: http://host.or.ip
username: homepage
password: pass
interfaceName: eth0 # optional
```
## Interface
Setting `interfaceName` (e.g. eth0) will display information for that particular device, otherwise the widget will display general system info.
## Authorization
In order for homepage to access the OpenWRT RPC endpoints you will need to [create an ACL](https://openwrt.org/docs/techref/ubus#acls) and [new user](https://openwrt.org/docs/techref/ubus#authentication) in OpenWRT.
Create an ACL named `homepage.json` in `/usr/share/rpcd/acl.d/`, the following permissions will suffice:
```
{
"homepage": {
"description": "Homepage widget",
"read": {
"ubus": {
"network.interface.wan": ["status"],
"network.interface.lan": ["status"],
"network.device": ["status"]
"system": ["info"]
}
},
}
}
```
Then add a user that will use that ACL in `/etc/config/rpc`:
```config login
option username 'homepage'
option password '<password>'
list read homepage
list write '*'
```
This username and password will be used in Homepage's services.yaml to grant access.

View File

@@ -3,9 +3,9 @@ title: PiAlert
description: PiAlert Widget Configuration
---
Learn more about [PiAlert](https://github.com/pucherot/Pi.Alert).
Learn more about [PiAlert](https://github.com/jokob-sk/Pi.Alert).
Widget for [PiAlert](https://github.com/jokob-sk/Pi.Alert).
Note that [pucherot/PiAlert](https://github.com/pucherot/Pi.Alert) has been abandoned and might not work properly.
Allowed fields: `["total", "connected", "new_devices", "down_alerts"]`.

View File

@@ -11,7 +11,9 @@ You can display general connectivity status from your Unifi (Network) Controller
An optional 'site' parameter can be supplied, if it is not the widget will use the default site for the controller.
Allowed fields: `["uptime", "wan", "lan_users", "wlan_users"]`.
Allowed fields: `["uptime", "wan", "lan", "lan_users", "lan_devices", "wlan", "wlan_users", "wlan_devices"]` (maximum of four).
Note that fields unsupported by the unifi device will not be shown.
```yaml
widget:

2446
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,55 +10,55 @@
"telemetry": "next telemetry disable"
},
"dependencies": {
"@headlessui/react": "^1.7.2",
"@headlessui/react": "^1.7.18",
"@kubernetes/client-node": "^0.17.1",
"cal-parser": "^1.0.2",
"classnames": "^2.3.2",
"compare-versions": "^5.0.1",
"dockerode": "^3.3.4",
"follow-redirects": "^1.15.2",
"gamedig": "^4.3.0",
"i18next": "^21.9.2",
"classnames": "^2.5.1",
"compare-versions": "^6.1.0",
"dockerode": "^4.0.2",
"follow-redirects": "^1.15.5",
"gamedig": "^4.3.1",
"i18next": "^21.10.0",
"js-yaml": "^4.1.0",
"json-rpc-2.0": "^1.4.1",
"luxon": "^3.4.3",
"json-rpc-2.0": "^1.7.0",
"luxon": "^3.4.4",
"memory-cache": "^0.2.0",
"minecraft-ping-js": "^1.0.2",
"next": "^12.3.1",
"next-i18next": "^12.0.1",
"next": "^12.3.4",
"next-i18next": "^12.1.0",
"ping": "^0.4.4",
"pretty-bytes": "^6.0.0",
"raw-body": "^2.5.1",
"pretty-bytes": "^6.1.1",
"raw-body": "^2.5.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-i18next": "^11.18.6",
"react-icons": "^4.12.0",
"recharts": "^2.7.2",
"recharts": "^2.11.0",
"rrule": "^2.8.1",
"swr": "^1.3.0",
"systeminformation": "^5.17.12",
"tough-cookie": "^4.1.2",
"systeminformation": "^5.21.24",
"tough-cookie": "^4.1.3",
"urbackup-server-api": "^0.8.9",
"winston": "^3.8.2",
"winston": "^3.11.0",
"xml-js": "^1.6.11"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.3",
"autoprefixer": "^10.4.12",
"eslint": "^8.24.0",
"@tailwindcss/forms": "^0.5.7",
"autoprefixer": "^10.4.17",
"eslint": "^8.56.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-next": "^12.3.1",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jsx-a11y": "^6.6.1",
"eslint-config-next": "^12.3.4",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-jsx-a11y": "^6.8.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.31.8",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"postcss": "^8.4.16",
"prettier": "^3.0.3",
"tailwind-scrollbar": "^2.0.1",
"tailwindcss": "^3.1.8",
"typescript": "^4.8.3"
"postcss": "^8.4.33",
"prettier": "^3.2.4",
"tailwind-scrollbar": "^3.0.5",
"tailwindcss": "^3.4.1",
"typescript": "^4.9.5"
},
"optionalDependencies": {
"osx-temperature-sensor": "^1.0.8"

1983
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -419,7 +419,8 @@
"search": "Suchen",
"custom": "Benutzerdefiniert",
"visit": "Besuchen",
"url": "URL"
"url": "URL",
"searchsuggestion": "Vorschlag"
},
"wmo": {
"0-day": "sonnig",

View File

@@ -419,7 +419,8 @@
"search": "Search",
"custom": "Custom",
"visit": "Visit",
"url": "URL"
"url": "URL",
"searchsuggestion": "Suggestion"
},
"wmo": {
"0-day": "Sunny",
@@ -788,6 +789,14 @@
"passed": "Passed",
"failed": "Failed"
},
"openwrt": {
"uptime": "Uptime",
"cpuLoad": "CPU Load Avg (5m)",
"up": "Up",
"down": "Down",
"bytesTx": "Transmitted",
"bytesRx": "Received"
},
"uptimerobot": {
"status": "Status",
"uptime": "Uptime",

View File

@@ -1,4 +1,4 @@
import { useRef } from "react";
import { useRef, useEffect } from "react";
import classNames from "classnames";
import { Disclosure, Transition } from "@headlessui/react";
import { MdKeyboardArrowDown } from "react-icons/md";
@@ -7,8 +7,13 @@ import ErrorBoundary from "components/errorboundry";
import List from "components/bookmarks/list";
import ResolvedIcon from "components/resolvedicon";
export default function BookmarksGroup({ bookmarks, layout, disableCollapse }) {
export default function BookmarksGroup({ bookmarks, layout, disableCollapse, groupsInitiallyCollapsed }) {
const panel = useRef();
useEffect(() => {
if (layout?.initiallyCollapsed ?? groupsInitiallyCollapsed) panel.current.style.height = `0`;
}, [layout, groupsInitiallyCollapsed]);
return (
<div
key={bookmarks.name}
@@ -18,7 +23,7 @@ export default function BookmarksGroup({ bookmarks, layout, disableCollapse }) {
layout?.header === false ? "flex-1 px-1 -my-1" : "flex-1 p-1",
)}
>
<Disclosure defaultOpen>
<Disclosure defaultOpen={!(layout?.initiallyCollapsed ?? groupsInitiallyCollapsed) ?? true}>
{({ open }) => (
<>
{layout?.header !== false && (

View File

@@ -15,16 +15,19 @@ export default function QuickLaunch({
searchProvider,
}) {
const { t } = useTranslation();
const { settings } = useContext(SettingsContext);
const { searchDescriptions, hideVisitURL } = settings?.quicklaunch
? settings.quicklaunch
: { searchDescriptions: false, hideVisitURL: false };
const { searchDescriptions = false, hideVisitURL = false } = settings?.quicklaunch ?? {};
const showSearchSuggestions = !!(
settings?.quicklaunch?.showSearchSuggestions ?? searchProvider?.showSearchSuggestions
);
const searchField = useRef();
const [results, setResults] = useState([]);
const [currentItemIndex, setCurrentItemIndex] = useState(null);
const [url, setUrl] = useState(null);
const [searchSuggestions, setSearchSuggestions] = useState([]);
function openCurrentItem(newWindow) {
const result = results[currentItemIndex];
@@ -36,8 +39,9 @@ export default function QuickLaunch({
setTimeout(() => {
setSearchString("");
setCurrentItemIndex(null);
setSearchSuggestions([]);
}, 200); // delay a little for animations
}, [close, setSearchString, setCurrentItemIndex]);
}, [close, setSearchString, setCurrentItemIndex, setSearchSuggestions]);
function handleSearchChange(event) {
const rawSearchString = event.target.value.toLowerCase();
@@ -90,6 +94,8 @@ export default function QuickLaunch({
}
useEffect(() => {
const abortController = new AbortController();
if (searchString.length === 0) setResults([]);
else {
let newResults = servicesAndBookmarks.filter((r) => {
@@ -109,9 +115,43 @@ export default function QuickLaunch({
if (searchProvider) {
newResults.push({
href: searchProvider.url + encodeURIComponent(searchString),
name: `${searchProvider.name ?? t("quicklaunch.custom")} ${t("quicklaunch.search")} `,
name: `${searchProvider.name ?? t("quicklaunch.custom")} ${t("quicklaunch.search")}`,
type: "search",
});
if (showSearchSuggestions && searchProvider.suggestionUrl) {
if (searchString.trim() !== searchSuggestions[0]?.trim()) {
fetch(
`/api/search/searchSuggestion?query=${encodeURIComponent(searchString)}&providerName=${
searchProvider.name ?? "Custom"
}`,
{ signal: abortController.signal },
)
.then(async (searchSuggestionResult) => {
const newSearchSuggestions = await searchSuggestionResult.json();
if (newSearchSuggestions) {
if (newSearchSuggestions[1].length > 4) {
newSearchSuggestions[1] = newSearchSuggestions[1].splice(0, 4);
}
setSearchSuggestions(newSearchSuggestions);
}
})
.catch(() => {
// If there is an error, just ignore it. There just will be no search suggestions.
});
}
if (searchSuggestions[1]) {
newResults = newResults.concat(
searchSuggestions[1].map((suggestion) => ({
href: searchProvider.url + encodeURIComponent(suggestion),
name: suggestion,
type: "searchSuggestion",
})),
);
}
}
}
if (!hideVisitURL && url) {
@@ -128,7 +168,21 @@ export default function QuickLaunch({
setCurrentItemIndex(0);
}
}
}, [searchString, servicesAndBookmarks, searchDescriptions, hideVisitURL, searchProvider, url, t]);
return () => {
abortController.abort();
};
}, [
searchString,
servicesAndBookmarks,
searchDescriptions,
hideVisitURL,
showSearchSuggestions,
searchSuggestions,
searchProvider,
url,
t,
]);
const [hidden, setHidden] = useState(true);
useEffect(() => {
@@ -181,7 +235,7 @@ export default function QuickLaunch({
<div className="fixed inset-0 bg-gray-500 bg-opacity-50" />
<div className="fixed inset-0 z-20 overflow-y-auto">
<div className="flex min-h-full min-w-full items-start justify-center text-center">
<dialog className="mt-[10%] min-w-[80%] max-w-[90%] md:min-w-[40%] rounded-md p-0 block font-medium text-theme-700 dark:text-theme-200 dark:hover:text-theme-300 shadow-md shadow-theme-900/10 dark:shadow-theme-900/20 bg-theme-50 dark:bg-theme-800">
<dialog className="mt-[10%] min-w-[90%] max-w-[90%] md:min-w-[40%] md:max-w-[40%] rounded-md p-0 block font-medium text-theme-700 dark:text-theme-200 dark:hover:text-theme-300 shadow-md shadow-theme-900/10 dark:shadow-theme-900/20 bg-theme-50 dark:bg-theme-800">
<input
placeholder="Search"
className={classNames(
@@ -219,7 +273,17 @@ export default function QuickLaunch({
</div>
)}
<div className="flex flex-col md:flex-row text-left items-baseline mr-4 pointer-events-none">
<span className="mr-4">{r.name}</span>
{r.type !== "searchSuggestion" && <span className="mr-4">{r.name}</span>}
{r.type === "searchSuggestion" && (
<div className="flex-nowrap">
<span className="whitespace-pre">
{r.name.indexOf(searchString) === 0 ? searchString : ""}
</span>
<span className="whitespace-pre opacity-50">
{r.name.indexOf(searchString) === 0 ? r.name.substring(searchString.length) : r.name}
</span>
</div>
)}
{r.description && (
<span className="text-xs text-theme-600 text-light">
{searchDescriptions && r.priority < 2 ? highlightText(r.description) : r.description}

View File

@@ -1,4 +1,4 @@
import { useRef } from "react";
import { useRef, useEffect } from "react";
import classNames from "classnames";
import { Disclosure, Transition } from "@headlessui/react";
import { MdKeyboardArrowDown } from "react-icons/md";
@@ -6,9 +6,21 @@ import { MdKeyboardArrowDown } from "react-icons/md";
import List from "components/services/list";
import ResolvedIcon from "components/resolvedicon";
export default function ServicesGroup({ group, services, layout, fiveColumns, disableCollapse, useEqualHeights }) {
export default function ServicesGroup({
group,
services,
layout,
fiveColumns,
disableCollapse,
useEqualHeights,
groupsInitiallyCollapsed,
}) {
const panel = useRef();
useEffect(() => {
if (layout?.initiallyCollapsed ?? groupsInitiallyCollapsed) panel.current.style.height = `0`;
}, [layout, groupsInitiallyCollapsed]);
return (
<div
key={services.name}
@@ -19,7 +31,7 @@ export default function ServicesGroup({ group, services, layout, fiveColumns, di
layout?.header === false ? "flex-1 px-1 -my-1" : "flex-1 p-1",
)}
>
<Disclosure defaultOpen>
<Disclosure defaultOpen={!(layout?.initiallyCollapsed ?? groupsInitiallyCollapsed) ?? true}>
{({ open }) => (
<>
{layout?.header !== false && (

View File

@@ -48,13 +48,13 @@ export default function Item({ service, group, useEqualHeights }) {
href={service.href}
target={service.target ?? settings.target ?? "_blank"}
rel="noreferrer"
className="flex-shrink-0 flex items-center justify-center w-12 service-icon"
className="flex-shrink-0 flex items-center justify-center w-12 service-icon z-10"
aria-label={service.icon}
>
<ResolvedIcon icon={service.icon} />
</a>
) : (
<div className="flex-shrink-0 flex items-center justify-center w-12 service-icon">
<div className="flex-shrink-0 flex items-center justify-center w-12 service-icon z-10">
<ResolvedIcon icon={service.icon} />
</div>
))}

View File

@@ -1,8 +1,8 @@
import { useState, useEffect, useCallback, Fragment } from "react";
import { useState, useEffect, Fragment } from "react";
import { useTranslation } from "next-i18next";
import { FiSearch } from "react-icons/fi";
import { SiDuckduckgo, SiMicrosoftbing, SiGoogle, SiBaidu, SiBrave } from "react-icons/si";
import { Listbox, Transition } from "@headlessui/react";
import { Listbox, Transition, Combobox } from "@headlessui/react";
import classNames from "classnames";
import ContainerForm from "../widget/container_form";
@@ -12,26 +12,31 @@ export const searchProviders = {
google: {
name: "Google",
url: "https://www.google.com/search?q=",
suggestionUrl: "https://www.google.com/complete/search?client=chrome&q=",
icon: SiGoogle,
},
duckduckgo: {
name: "DuckDuckGo",
url: "https://duckduckgo.com/?q=",
suggestionUrl: "https://duckduckgo.com/ac/?type=list&q=",
icon: SiDuckduckgo,
},
bing: {
name: "Bing",
url: "https://www.bing.com/search?q=",
suggestionUrl: "https://api.bing.com/osjson.aspx?query=",
icon: SiMicrosoftbing,
},
baidu: {
name: "Baidu",
url: "https://www.baidu.com/s?wd=",
suggestionUrl: "http://suggestion.baidu.com/su?&action=opensearch&ie=utf-8&wd=",
icon: SiBaidu,
},
brave: {
name: "Brave",
url: "https://search.brave.com/search?q=",
suggestionUrl: "https://search.brave.com/api/suggest?&rich=false&q=",
icon: SiBrave,
},
custom: {
@@ -72,6 +77,7 @@ export default function Search({ options }) {
const [selectedProvider, setSelectedProvider] = useState(
searchProviders[availableProviderIds[0] ?? searchProviders.google],
);
const [searchSuggestions, setSearchSuggestions] = useState([]);
useEffect(() => {
const storedProvider = getStoredProvider();
@@ -82,22 +88,58 @@ export default function Search({ options }) {
}
}, [availableProviderIds]);
const submitCallback = useCallback(
(event) => {
const q = encodeURIComponent(query);
const { url } = selectedProvider;
if (url) {
window.open(`${url}${q}`, options.target || "_blank");
} else {
window.open(`${options.url}${q}`, options.target || "_blank");
}
useEffect(() => {
const abortController = new AbortController();
event.preventDefault();
event.target.reset();
setQuery("");
},
[options.target, options.url, query, selectedProvider],
);
if (
options.showSearchSuggestions &&
(selectedProvider.suggestionUrl || options.suggestionUrl) && // custom providers pass url via options
query.trim() !== searchSuggestions[0]
) {
fetch(`/api/search/searchSuggestion?query=${encodeURIComponent(query)}&providerName=${selectedProvider.name}`, {
signal: abortController.signal,
})
.then(async (searchSuggestionResult) => {
const newSearchSuggestions = await searchSuggestionResult.json();
if (newSearchSuggestions) {
if (newSearchSuggestions[1].length > 4) {
newSearchSuggestions[1] = newSearchSuggestions[1].splice(0, 4);
}
setSearchSuggestions(newSearchSuggestions);
}
})
.catch(() => {
// If there is an error, just ignore it. There just will be no search suggestions.
});
}
return () => {
abortController.abort();
};
}, [selectedProvider, options, query, searchSuggestions]);
let currentSuggestion;
function doSearch(value) {
const q = encodeURIComponent(value);
const { url } = selectedProvider;
if (url) {
window.open(`${url}${q}`, options.target || "_blank");
} else {
window.open(`${options.url}${q}`, options.target || "_blank");
}
setQuery("");
currentSuggestion = null;
}
const handleSearchKeyDown = (event) => {
const useSuggestion = searchSuggestions.length && currentSuggestion;
if (event.key === "Enter") {
doSearch(useSuggestion ? currentSuggestion : event.target.value);
}
};
if (!availableProviderIds) {
return null;
@@ -109,84 +151,125 @@ export default function Search({ options }) {
};
return (
<ContainerForm options={options} callback={submitCallback} additionalClassNames="grow information-widget-search">
<ContainerForm options={options} additionalClassNames="grow information-widget-search">
<Raw>
<div className="flex-col relative h-8 my-4 min-w-fit">
<div className="flex-col relative h-8 my-4 min-w-fit z-20">
<div className="flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none w-full text-theme-800 dark:text-white" />
<input
type="text"
className="
overflow-hidden w-full h-full rounded-md
text-xs text-theme-900 dark:text-white
placeholder-theme-900 dark:placeholder-white/80
bg-white/50 dark:bg-white/10
focus:ring-theme-500 dark:focus:ring-white/50
focus:border-theme-500 dark:focus:border-white/50
border border-theme-300 dark:border-theme-200/50"
placeholder={t("search.placeholder")}
onChange={(s) => setQuery(s.currentTarget.value)}
required
autoCapitalize="off"
autoCorrect="off"
autoComplete="off"
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={options.focus}
/>
<Listbox
as="div"
value={selectedProvider}
onChange={onChangeProvider}
className="relative text-left"
disabled={availableProviderIds?.length === 1}
>
<div>
<Listbox.Button
className="
absolute right-0.5 bottom-0.5 rounded-r-md px-4 py-2 border-1
text-white font-medium text-sm
bg-theme-600/40 dark:bg-white/10
focus:ring-theme-500 dark:focus:ring-white/50"
>
<selectedProvider.icon className="text-white w-3 h-3" />
<span className="sr-only">{t("search.search")}</span>
</Listbox.Button>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
<Combobox value={query}>
<Combobox.Input
type="text"
className="
overflow-hidden w-full h-full rounded-md
text-xs text-theme-900 dark:text-white
placeholder-theme-900 dark:placeholder-white/80
bg-white/50 dark:bg-white/10
focus:ring-theme-500 dark:focus:ring-white/50
focus:border-theme-500 dark:focus:border-white/50
border border-theme-300 dark:border-theme-200/50"
placeholder={t("search.placeholder")}
onChange={(event) => {
setQuery(event.target.value);
}}
required
autoCapitalize="off"
autoCorrect="off"
autoComplete="off"
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={options.focus}
onBlur={(e) => e.preventDefault()}
onKeyDown={handleSearchKeyDown}
/>
<Listbox
as="div"
value={selectedProvider}
onChange={onChangeProvider}
className="relative text-left"
disabled={availableProviderIds?.length === 1}
>
<Listbox.Options
className="absolute right-0 z-10 mt-1 origin-top-right rounded-md
bg-theme-100 dark:bg-theme-600 shadow-lg
ring-1 ring-black ring-opacity-5 focus:outline-none"
<div>
<Listbox.Button
className="
absolute right-0.5 bottom-0.5 rounded-r-md px-4 py-2 border-1
text-white font-medium text-sm
bg-theme-600/40 dark:bg-white/10
focus:ring-theme-500 dark:focus:ring-white/50"
>
<selectedProvider.icon className="text-white w-3 h-3" />
<span className="sr-only">{t("search.search")}</span>
</Listbox.Button>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<div className="flex flex-col">
{availableProviderIds.map((providerId) => {
const p = searchProviders[providerId];
return (
<Listbox.Option key={providerId} value={p} as={Fragment}>
{({ active }) => (
<li
<Listbox.Options
className="absolute right-0 z-10 mt-1 origin-top-right rounded-md
bg-theme-100 dark:bg-theme-600 shadow-lg
ring-1 ring-black ring-opacity-5 focus:outline-none"
>
<div className="flex flex-col">
{availableProviderIds.map((providerId) => {
const p = searchProviders[providerId];
return (
<Listbox.Option key={providerId} value={p} as={Fragment}>
{({ active }) => (
<li
className={classNames(
"rounded-md cursor-pointer",
active ? "bg-theme-600/10 dark:bg-white/10 dark:text-gray-900" : "dark:text-gray-100",
)}
>
<p.icon className="h-4 w-4 mx-4 my-2" />
</li>
)}
</Listbox.Option>
);
})}
</div>
</Listbox.Options>
</Transition>
</Listbox>
{searchSuggestions[1]?.length > 0 && (
<Combobox.Options className="mt-1 rounded-md bg-theme-50 dark:bg-theme-800 border border-theme-300 dark:border-theme-200/30 cursor-pointer shadow-lg">
<div className="p-1 bg-white/50 dark:bg-white/10 text-theme-900/90 dark:text-white/90 text-xs">
<Combobox.Option key={query} value={query} />
{searchSuggestions[1].map((suggestion) => (
<Combobox.Option
key={suggestion}
value={suggestion}
onClick={() => {
doSearch(suggestion);
}}
className="flex w-full"
>
{({ active }) => {
if (active) currentSuggestion = suggestion;
return (
<div
className={classNames(
"rounded-md cursor-pointer",
active ? "bg-theme-600/10 dark:bg-white/10 dark:text-gray-900" : "dark:text-gray-100",
"px-2 py-1 rounded-md w-full flex-nowrap",
active ? "bg-theme-300/20 dark:bg-white/10" : "",
)}
>
<p.icon className="h-4 w-4 mx-4 my-2" />
</li>
)}
</Listbox.Option>
);
})}
<span className="whitespace-pre">{suggestion.indexOf(query) === 0 ? query : ""}</span>
<span className="mr-4 whitespace-pre opacity-50">
{suggestion.indexOf(query) === 0 ? suggestion.substring(query.length) : suggestion}
</span>
</div>
);
}}
</Combobox.Option>
))}
</div>
</Listbox.Options>
</Transition>
</Listbox>
</Combobox.Options>
)}
</Combobox>
</div>
</Raw>
</ContainerForm>

View File

@@ -0,0 +1,23 @@
import { searchProviders } from "components/widgets/search/search";
import cachedFetch from "utils/proxy/cached-fetch";
import { widgetsFromConfig } from "utils/config/widget-helpers";
export default async function handler(req, res) {
const { query, providerName } = req.query;
const provider = Object.values(searchProviders).find(({ name }) => name === providerName);
if (provider.name === "Custom") {
const widgets = await widgetsFromConfig();
const searchWidget = widgets.find((w) => w.type === "search");
provider.url = searchWidget.options.url;
provider.suggestionUrl = searchWidget.options.suggestionUrl;
}
if (!provider.suggestionUrl) {
return res.json([query, []]); // Responde with the same array format but with no suggestions.
}
return res.send(await cachedFetch(`${provider.suggestionUrl}${encodeURIComponent(query)}`, 5));
}

View File

@@ -211,12 +211,12 @@ function Home({ initialSettings }) {
// if search provider is a list, try to retrieve from localstorage, fall back to the first
searchProvider = getStoredProvider() ?? searchProviders[searchWidget.options.provider[0]];
} else if (searchWidget.options?.provider === "custom") {
searchProvider = {
url: searchWidget.options.url,
};
searchProvider = searchWidget.options;
} else {
searchProvider = searchProviders[searchWidget.options?.provider];
}
// to pass to quicklaunch
searchProvider.showSearchSuggestions = searchWidget.options?.showSearchSuggestions;
}
const headerStyle = settings?.headerStyle || "underlined";
@@ -224,7 +224,10 @@ function Home({ initialSettings }) {
function handleKeyDown(e) {
if (e.target.tagName === "BODY" || e.target.id === "inner_wrapper") {
if (
(e.key.length === 1 && e.key.match(/(\w|\s)/g) && !(e.altKey || e.ctrlKey || e.metaKey || e.shiftKey)) ||
(e.key.length === 1 &&
e.key.match(/(\w|\s|[à-ü]|[À-Ü])/g) &&
!(e.altKey || e.ctrlKey || e.metaKey || e.shiftKey)) ||
e.key.match(/([à-ü]|[À-Ü])/g) || // accented characters may require modifier keys
(e.key === "v" && (e.ctrlKey || e.metaKey))
) {
setSearching(true);
@@ -308,6 +311,7 @@ function Home({ initialSettings }) {
fiveColumns={settings.fiveColumns}
disableCollapse={settings.disableCollapse}
useEqualHeights={settings.useEqualHeights}
groupsInitiallyCollapsed={settings.groupsInitiallyCollapsed}
/>
) : (
<BookmarksGroup
@@ -315,6 +319,7 @@ function Home({ initialSettings }) {
bookmarks={group}
layout={settings.layout?.[group.name]}
disableCollapse={settings.disableCollapse}
groupsInitiallyCollapsed={settings.groupsInitiallyCollapsed}
/>
),
)}
@@ -330,6 +335,7 @@ function Home({ initialSettings }) {
layout={settings.layout?.[group.name]}
fiveColumns={settings.fiveColumns}
disableCollapse={settings.disableCollapse}
groupsInitiallyCollapsed={settings.groupsInitiallyCollapsed}
/>
))}
</div>
@@ -342,6 +348,7 @@ function Home({ initialSettings }) {
bookmarks={group}
layout={settings.layout?.[group.name]}
disableCollapse={settings.disableCollapse}
groupsInitiallyCollapsed={settings.groupsInitiallyCollapsed}
/>
))}
</div>
@@ -358,6 +365,7 @@ function Home({ initialSettings }) {
settings.disableCollapse,
settings.useEqualHeights,
settings.cardBlur,
settings.groupsInitiallyCollapsed,
initialSettings.layout,
]);

View File

@@ -46,6 +46,10 @@ body {
width: 0.75em;
}
dialog ::-webkit-scrollbar {
display: none;
}
::-webkit-scrollbar-track {
background-color: var(--scrollbar-track);
}

View File

@@ -429,6 +429,9 @@ export function cleanServiceGroups(groups) {
// openmediavault
method,
// openwrt
interfaceName,
// opnsense, pfsense
wan,
@@ -531,6 +534,9 @@ export function cleanServiceGroups(groups) {
if (type === "openmediavault") {
if (method) cleanedService.widget.method = method;
}
if (type === "openwrt") {
if (interfaceName) cleanedService.widget.interfaceName = interfaceName;
}
if (type === "customapi") {
if (mappings) cleanedService.widget.mappings = mappings;
if (refreshInterval) cleanedService.widget.refreshInterval = refreshInterval;

View File

@@ -12,7 +12,8 @@ export default async function cachedFetch(url, duration) {
return cached;
}
const data = await fetch(url).then((res) => res.json());
// wrapping text in JSON.parse to handle utf-8 issues
const data = JSON.parse(await fetch(url).then((res) => res.text()));
cache.put(url, data, duration * 1000 * 60);
return data;
}

View File

@@ -52,15 +52,15 @@ export default function Integration({ config, params, setEvents, hideErrors, tim
}
const eventToAdd = (date, i, type) => {
const duration = event.dtend.value - event.dtstart.value;
const days = duration / (1000 * 60 * 60 * 24);
// 'dtend' is null for all-day events
const { dtstart, dtend = { value: 0 } } = event;
const days = dtend.value === 0 ? 1 : (dtend.value - dtstart.value) / (1000 * 60 * 60 * 24);
const eventDate = timezone ? DateTime.fromJSDate(date, { zone: timezone }) : DateTime.fromJSDate(date);
for (let j = 0; j < days; j += 1) {
// See https://github.com/gethomepage/homepage/issues/2753 uid is not stable
// assumption is that the event is the same if the start, end and title are all the same
const hash = simpleHash(`${event?.dtstart?.value}${event?.dtend?.value}${title}${i}${j}${type}}`);
const hash = simpleHash(`${dtstart?.value}${dtend?.value}${title}${i}${j}${type}}`);
eventsToAdd[hash] = {
title,
date: eventDate.plus({ days: j }),

View File

@@ -71,6 +71,7 @@ const components = {
opnsense: dynamic(() => import("./opnsense/component")),
overseerr: dynamic(() => import("./overseerr/component")),
openmediavault: dynamic(() => import("./openmediavault/component")),
openwrt: dynamic(() => import("./openwrt/component")),
paperlessngx: dynamic(() => import("./paperlessngx/component")),
pfsense: dynamic(() => import("./pfsense/component")),
photoprism: dynamic(() => import("./photoprism/component")),

View File

@@ -63,26 +63,22 @@ export default function Component({ service }) {
);
}
const hasUuid = widget?.uuid;
const hasUuid = !!widget?.uuid;
const { upCount, downCount } = countStatus(data);
return (
return hasUuid ? (
<Container service={service}>
{hasUuid ? (
<>
<Block label="healthchecks.status" value={t(`healthchecks.${data.status}`)} />
<Block
label="healthchecks.last_ping"
value={data.last_ping ? formatDate(data.last_ping) : t("healthchecks.never")}
/>
</>
) : (
<>
<Block label="healthchecks.up" value={upCount} />
<Block label="healthchecks.down" value={downCount} />
</>
)}
<Block label="healthchecks.status" value={t(`healthchecks.${data.status}`)} />
<Block
label="healthchecks.last_ping"
value={data.last_ping ? formatDate(data.last_ping) : t("healthchecks.never")}
/>
</Container>
) : (
<Container service={service}>
<Block label="healthchecks.up" value={upCount} />
<Block label="healthchecks.down" value={downCount} />
</Container>
);
}

View File

@@ -30,9 +30,9 @@ export default function Component({ service }) {
return (
<Container service={service}>
<Block label="immich.users" value={immichData.usageByUser.length} />
<Block label="immich.photos" value={immichData.photos} />
<Block label="immich.videos" value={immichData.videos} />
<Block label="immich.users" value={t("common.number", { value: immichData.usageByUser.length })} />
<Block label="immich.photos" value={t("common.number", { value: immichData.photos })} />
<Block label="immich.videos" value={t("common.number", { value: immichData.videos })} />
<Block
label="immich.storage"
value={

View File

@@ -0,0 +1,9 @@
import Interface from "./methods/interface";
import System from "./methods/system";
export default function Component({ service }) {
if (service.widget.interfaceName) {
return <Interface service={service} />;
}
return <System service={service} />;
}

View File

@@ -0,0 +1,37 @@
import { useTranslation } from "next-i18next";
import useWidgetAPI from "utils/proxy/use-widget-api";
import Container from "components/services/widget/container";
import Block from "components/services/widget/block";
export default function Component({ service }) {
const { t } = useTranslation();
const { data, error } = useWidgetAPI(service.widget);
if (error) {
return <Container service={service} error={error} />;
}
if (!data) {
return null;
}
const { up, bytesTx, bytesRx } = data;
return (
<Container service={service}>
<Block
label="widget.status"
value={
up ? (
<span className="text-green-500">{t("openwrt.up")}</span>
) : (
<span className="text-red-500">{t("openwrt.down")}</span>
)
}
/>
<Block label="openwrt.bytesTx" value={t("common.bytes", { value: bytesTx })} />
<Block label="openwrt.bytesRx" value={t("common.bytes", { value: bytesRx })} />
</Container>
);
}

View File

@@ -0,0 +1,27 @@
import { useTranslation } from "next-i18next";
import useWidgetAPI from "utils/proxy/use-widget-api";
import Container from "components/services/widget/container";
import Block from "components/services/widget/block";
export default function Component({ service }) {
const { t } = useTranslation();
const { data, error } = useWidgetAPI(service.widget);
if (error) {
return <Container service={service} error={error} />;
}
if (!data) {
return null;
}
const { uptime, cpuLoad } = data;
return (
<Container service={service}>
<Block label="openwrt.uptime" value={t("common.uptime", { value: uptime })} />
<Block label="openwrt.cpuLoad" value={cpuLoad} />
</Container>
);
}

View File

@@ -0,0 +1,128 @@
import { sendJsonRpcRequest } from "utils/proxy/handlers/jsonrpc";
import { formatApiCall } from "utils/proxy/api-helpers";
import getServiceWidget from "utils/config/service-helpers";
import createLogger from "utils/logger";
import widgets from "widgets/widgets";
const PROXY_NAME = "OpenWRTProxyHandler";
const logger = createLogger(PROXY_NAME);
const LOGIN_PARAMS = ["00000000000000000000000000000000", "session", "login"];
const RPC_METHOD = "call";
let authToken = "00000000000000000000000000000000";
const PARAMS = {
system: ["system", "info", {}],
device: ["network.device", "status", {}],
};
async function getWidget(req) {
const { group, service } = req.query;
if (!group || !service) {
logger.debug("Invalid or missing service '%s' or group '%s'", service, group);
return null;
}
const widget = await getServiceWidget(group, service);
if (!widget) {
logger.debug("Invalid or missing widget for service '%s' in group '%s'", service, group);
return null;
}
return widget;
}
function isUnauthorized(data) {
const json = JSON.parse(data.toString());
return json?.error?.code === -32002;
}
async function login(url, username, password) {
const response = await sendJsonRpcRequest(url, RPC_METHOD, [...LOGIN_PARAMS, { username, password }]);
if (response[0] === 200) {
const responseData = JSON.parse(response[2]);
authToken = responseData[1].ubus_rpc_session;
}
return response;
}
async function fetchInterface(url, interfaceName) {
const [, contentType, data] = await sendJsonRpcRequest(url, RPC_METHOD, [authToken, ...PARAMS.device]);
if (isUnauthorized(data)) {
return [401, contentType, data];
}
const response = JSON.parse(data.toString())[1];
const networkInterface = response[interfaceName];
if (!networkInterface) {
return [404, contentType, { error: "Interface not found" }];
}
const interfaceInfo = {
up: networkInterface.up,
bytesRx: networkInterface.statistics.rx_bytes,
bytesTx: networkInterface.statistics.tx_bytes,
};
return [200, contentType, interfaceInfo];
}
async function fetchSystem(url) {
const [, contentType, data] = await sendJsonRpcRequest(url, RPC_METHOD, [authToken, ...PARAMS.system]);
if (isUnauthorized(data)) {
return [401, contentType, data];
}
const systemResponse = JSON.parse(data.toString())[1];
const response = {
uptime: systemResponse.uptime,
cpuLoad: systemResponse.load[1],
};
return [200, contentType, response];
}
async function fetchData(url, widget) {
let response;
if (widget.interfaceName) {
response = await fetchInterface(url, widget.interfaceName);
} else {
response = await fetchSystem(url);
}
return response;
}
export default async function proxyHandler(req, res) {
const { group, service } = req.query;
if (!group || !service) {
logger.debug("Invalid or missing service '%s' or group '%s'", service, group);
return res.status(400).json({ error: "Invalid proxy service type" });
}
const widget = await getWidget(req);
if (!widget) {
logger.debug("Invalid or missing widget for service '%s' in group '%s'", service, group);
return res.status(400).json({ error: "Invalid proxy service type" });
}
const api = widgets?.[widget.type]?.api;
const url = new URL(formatApiCall(api, { ...widget }));
let [status, , data] = await fetchData(url, widget);
if (status === 401) {
const [loginStatus, , loginData] = await login(url, widget.username, widget.password);
if (loginStatus !== 200) {
return res.status(loginStatus).end(loginData);
}
[status, , data] = await fetchData(url, widget);
if (status === 401) {
return res.status(401).json({ error: "Unauthorized" });
}
}
return res.status(200).end(JSON.stringify(data));
}

View File

@@ -0,0 +1,8 @@
import proxyHandler from "./proxy";
const widget = {
api: "{url}/ubus",
proxyHandler,
};
export default widget;

View File

@@ -20,6 +20,10 @@ export default function Component({ service }) {
: statsData?.data?.find((s) => s.name === "default");
if (!defaultSite) {
if (widget.site) {
return <Container service={service} error={{ message: `Site '${widget.site}' not found` }} />;
}
return (
<Container service={service}>
<Block label="unifi.uptime" />

View File

@@ -63,6 +63,7 @@ import opendtu from "./opendtu/widget";
import opnsense from "./opnsense/widget";
import overseerr from "./overseerr/widget";
import openmediavault from "./openmediavault/widget";
import openwrt from "./openwrt/widget";
import paperlessngx from "./paperlessngx/widget";
import peanut from "./peanut/widget";
import pfsense from "./pfsense/widget";
@@ -171,6 +172,7 @@ const widgets = {
opnsense,
overseerr,
openmediavault,
openwrt,
paperlessngx,
peanut,
pfsense,