Compare commits

..
Author SHA1 Message Date
Manuel 168a951ad9 feat: notification system 2024-12-05 18:12:07 +01:00
760 changed files with 18058 additions and 106916 deletions

No files matched your search

-1
View File
@@ -7,4 +7,3 @@ README.md
.git .git
dev dev
.build .build
e2e
+7 -13
View File
@@ -4,16 +4,6 @@
# This file will be committed to version control, so make sure not to have any secrets in it. # This file will be committed to version control, so make sure not to have any secrets in it.
# If you are cloning this repo, create a copy of this file named `.env` and populate it with your secrets. # If you are cloning this repo, create a copy of this file named `.env` and populate it with your secrets.
# The below secret is not used anywhere but required for Auth.js (Would encrypt JWTs and Mail hashes, both not used)
AUTH_SECRET="supersecret"
# The below secret is used to encrypt integration secrets in the database.
# It should be a 32-byte string, generated by running `openssl rand -hex 32` on Unix
# or starting the project without any (which will show a randomly generated one).
SECRET_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
LOG_LEVEL='info'
# This is how you can use the sqlite driver: # This is how you can use the sqlite driver:
DB_DRIVER='better-sqlite3' DB_DRIVER='better-sqlite3'
DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE' DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE'
@@ -30,8 +20,12 @@ DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE'
# DB_PASSWORD='password' # DB_PASSWORD='password'
# DB_NAME='name-of-database' # DB_NAME='name-of-database'
# The below path can be used to store trusted certificates during development, it is not required and can be left empty.
# If it is used, please use the full path to the directory where the certificates are stored. # You can generate the secret via 'openssl rand -base64 32' on Unix
# LOCAL_CERTIFICATE_PATH='FULL_PATH_TO_CERTIFICATES' # @see https://next-auth.js.org/configuration/options#secret
AUTH_SECRET='supersecret'
TURBO_TELEMETRY_DISABLED=1 TURBO_TELEMETRY_DISABLED=1
# Configure logging to use winston logger
NODE_OPTIONS='-r @homarr/log/override'
+17 -67
View File
@@ -1,87 +1,37 @@
name: 🐞 Bug Report name: 🐞 Bug Report
description: Report that something is broken, not working as intended or causes side-effects description: Create a bug report to help us improve
title: "bug: " title: "bug: "
labels: ["needs triage"] labels: ["🐞❔ unconfirmed bug"]
body: body:
- type: textarea
attributes:
label: Provide environment information
description: |
Run this command in your project root and paste the results in a code block:
```bash
npx envinfo --system --binaries
```
validations:
required: true
- type: textarea - type: textarea
attributes: attributes:
label: Describe the bug label: Describe the bug
description: A clear and concise description of the bug, as well as what you expected to happen when encountering it. description: A clear and concise description of the bug, as well as what you expected to happen when encountering it.
validations: validations:
required: true required: true
- type: textarea - type: input
attributes: attributes:
label: Steps to reproduce label: Link to reproduction
description: Describe how to reproduce your bug. Steps, code snippets, reproduction repos etc. description: Please provide a link to a reproduction of the bug. Issues without a reproduction repo may be ignored.
validations: validations:
required: true required: true
- type: textarea - type: textarea
attributes: attributes:
label: Impact label: To reproduce
description: How big is the impact of this bug? Does it make Homarr unusable? Is there any workaround that you're aware of? description: Describe how to reproduce your bug. Steps, code snippets, reproduction repos etc.
validations: validations:
required: true required: true
- type: textarea - type: textarea
attributes: attributes:
label: Additional information label: Additional information
description: Add any other information related to the bug here, screenshots if applicable. description: Add any other information related to the bug here, screenshots if applicable.
- type: dropdown
id: version
attributes:
label: Version
description: What version of Homarr are you running?
options:
- 1.6.0
- 1.5.0
- 1.4.0
- 1.3.1
- 1.3.0
- 1.2.0
- 1.1.0
- 1.0.1
- 1.0.0
- 1.0.0-beta
- Other (describe in "additional information")
default: 0
validations:
required: true
- type: dropdown
id: installationMethod
attributes:
label: Installation method
description: How do you run Homarr? Post docker-compose, configs or screenshots if applicable.
options:
- Docker Run
- Docker Compose
- Portainer
- Helm
- QNAP
- Saltbox
- EasyPanel
- Unraid Apps
- TrueNAS Apps
- Synology
- HomeAssistant Addon
- From Source
- Other (describe in "additional information")
default: 0
validations:
required: true
- type: dropdown
id: browser
attributes:
label: Browser
description: If relevant, what browser do you use?
options:
- Firefox
- Edge (Chromium)
- Edge (Proprietary)
- Chrome
- Safari
- Vivaldi
- Brave
- Samsung Internet
- Other (describe in "additional information")
default: 0
validations:
required: false
+12 -2
View File
@@ -2,16 +2,26 @@
# See here: https://github.com/vercel/next.js/blob/canary/.github/ISSUE_TEMPLATE/3.feature_request.yml # See here: https://github.com/vercel/next.js/blob/canary/.github/ISSUE_TEMPLATE/3.feature_request.yml
name: 🛠 Feature Request name: 🛠 Feature Request
description: Request a new feature that you would like to have implemented description: Create a feature request for the core packages
title: "feat: " title: "feat: "
labels: ["needs triage"] labels: ["✨ enhancement"]
body: body:
- type: markdown
attributes:
value: |
Thank you for taking the time to file a feature request. Please fill out this form as completely as possible.
- type: textarea - type: textarea
attributes: attributes:
label: Describe the feature you'd like to request label: Describe the feature you'd like to request
description: Please describe the feature as clear and concise as possible. Remember to add context as to why you believe this feature is needed. description: Please describe the feature as clear and concise as possible. Remember to add context as to why you believe this feature is needed.
validations: validations:
required: true required: true
- type: textarea
attributes:
label: Describe the solution you'd like to see
description: Please describe the solution you would like to see. Adding example usage is a good way to provide context.
validations:
required: true
- type: textarea - type: textarea
attributes: attributes:
label: Additional information label: Additional information
-40
View File
@@ -1,40 +0,0 @@
name: 🛠 Integration request
description: Request support for a new integration (eg. Sonarr, Radarr)
title: "feat: "
labels: ["needs triage"]
body:
- type: input
attributes:
label: Project Website
description: Post the link to the website of the application. Paste the official link.
placeholder: ex. https://sonarr.tv/
validations:
required: true
- type: textarea
attributes:
label: Describe what data should be consumed by Homarr
description: Please describe what data Homarr should fetch from the integration. Specify in what interval data should be fetched and whether the user can also perform write operations (eg. deleting a movie or adding a user).
validations:
required: true
- type: textarea
attributes:
label: Additional information
description: Add any other information related to the integration.
- type: dropdown
attributes:
label: Public API available?
description: Is there a public API available, that we can consume in Homarr?
options:
- Yes, available on a website
- Yes, available in the application itself
- No
validations:
required: true
- type: dropdown
attributes:
label: Are you willing to contribute this yourself?
options:
- Yes
- No
validations:
required: true
-30
View File
@@ -1,30 +0,0 @@
name: 🛠 Widget request
description: Request a new widget (eg. Clock, Calendar, ...)
title: "feat: "
labels: ["needs triage"]
body:
- type: input
attributes:
label: Compatible integrations
description: Post a list of the integrations that should be compatible with this widget. Divide using comma. Leave empty if no integration is needed.
placeholder: ex. Sonarr, Radarr, Lidarr, Readarr, Nextcloud
validations:
required: false
- type: textarea
attributes:
label: Describe what data should be displayed
description: Please describe what data Homarr should display. Describe how elements should be intractable and what actions the user can perform.
validations:
required: true
- type: textarea
attributes:
label: Additional information
description: Add any other information related to the widget.
- type: dropdown
attributes:
label: Are you willing to contribute this yourself?
options:
- Yes
- No
validations:
required: true
+1 -1
View File
@@ -6,7 +6,7 @@
**Thank you for your contribution. Please ensure that your pull request meets the following pull request:** **Thank you for your contribution. Please ensure that your pull request meets the following pull request:**
- [ ] Builds without warnings or errors (``pnpm build``, autofix with ``pnpm format:fix``) - [ ] Builds without warnings or errors (``pnpm buid``, autofix with ``pnpm format:fix``)
- [ ] Pull request targets ``dev`` branch - [ ] Pull request targets ``dev`` branch
- [ ] Commits follow the [conventional commits guideline](https://www.conventionalcommits.org/en/v1.0.0/) - [ ] Commits follow the [conventional commits guideline](https://www.conventionalcommits.org/en/v1.0.0/)
- [ ] No shorthand variable names are used (eg. ``x``, ``y``, ``i`` or any abbrevation) - [ ] No shorthand variable names are used (eg. ``x``, ``y``, ``i`` or any abbrevation)
-5
View File
@@ -6,11 +6,6 @@
matchPackagePatterns: ["^@homarr/"], matchPackagePatterns: ["^@homarr/"],
enabled: false, enabled: false,
}, },
// Disable Dockerode updates see https://github.com/apocas/dockerode/issues/787
{
matchPackagePatterns: ["^dockerode$"],
enabled: false,
},
{ {
matchUpdateTypes: ["minor", "patch", "pin", "digest"], matchUpdateTypes: ["minor", "patch", "pin", "digest"],
automerge: true, automerge: true,
-2
View File
@@ -91,8 +91,6 @@ jobs:
network: host network: host
env: env:
SKIP_ENV_VALIDATION: true SKIP_ENV_VALIDATION: true
- name: Install playwright browsers
run: pnpm exec playwright install chromium
- name: Run E2E Tests - name: Run E2E Tests
shell: bash shell: bash
run: pnpm test:e2e run: pnpm test:e2e
@@ -21,7 +21,6 @@ jobs:
app_id: ${{ secrets.CROWDIN_APP_ID }} app_id: ${{ secrets.CROWDIN_APP_ID }}
- name: Download Crowdin translations - name: Download Crowdin translations
id: crowdin-download
uses: crowdin/github-action@v2 uses: crowdin/github-action@v2
with: with:
upload_sources: false upload_sources: false
@@ -31,7 +30,6 @@ jobs:
create_pull_request: true create_pull_request: true
pull_request_title: "chore(lang): updated translations from crowdin" pull_request_title: "chore(lang): updated translations from crowdin"
pull_request_body: "New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)" pull_request_body: "New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)"
commit_message: "chore(lang): update translations from crowdin"
pull_request_base_branch_name: "dev" pull_request_base_branch_name: "dev"
github_user_name: "Crowdin Homarr" github_user_name: "Crowdin Homarr"
github_user_email: "190541745+homarr-crowdin[bot]@users.noreply.github.com" github_user_email: "190541745+homarr-crowdin[bot]@users.noreply.github.com"
@@ -40,10 +38,3 @@ jobs:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }} GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
- name: Enable auto-merge
if: steps.crowdin-download.outputs.pull_request_number != '' && steps.crowdin-download.outputs.pull_request_number != null
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
run: |
gh pr merge ${{steps.crowdin-download.outputs.pull_request_number}} --auto --squash --delete-branch --subject "chore(lang): updated translations from crowdin"
+49 -91
View File
@@ -4,8 +4,6 @@ on:
push: push:
branches: branches:
- main - main
- beta
- dev
workflow_dispatch: workflow_dispatch:
inputs: inputs:
send-notifications: send-notifications:
@@ -13,6 +11,11 @@ on:
required: false required: false
default: true default: true
description: Send notifications description: Send notifications
push-image:
type: boolean
required: false
default: true
description: Push Docker Image
permissions: permissions:
contents: write contents: write
@@ -24,103 +27,34 @@ env:
IMAGE_NAME: ${{ github.repository }} IMAGE_NAME: ${{ github.repository }}
TURBO_TELEMETRY_DISABLED: 1 TURBO_TELEMETRY_DISABLED: 1
concurrency: concurrency: production
group: ${{ github.workflow }}-${{ github.ref_name }}
jobs: jobs:
release:
name: Create tag and release
runs-on: ubuntu-latest
env:
SKIP_RELEASE: ${{ github.event_name == 'workflow_dispatch' || github.ref_name == 'dev' }}
outputs:
version: ${{ steps.read-semver.outputs.version || steps.version-fallback.outputs.version }}
git_ref: ${{ steps.read-git-ref.outputs.ref || github.ref }}
steps:
- run: echo "Skipping release for workflow_dispatch event"
if: env.SKIP_RELEASE == 'true'
# The below generated version fallback represents a normalized branch name, for example "feature/branch-name" -> "feature-branch-name"
- run: echo "version="$(echo ${{github.ref_name}} | sed 's/[^a-zA-Z0-9\-]/-/g') >> "$GITHUB_OUTPUT"
id: version-fallback
if: env.SKIP_RELEASE == 'true' && github.ref_name != 'main' && github.ref_name != 'beta'
- name: Obtain token
if: env.SKIP_RELEASE == 'false'
id: obtainToken
uses: tibdex/github-app-token@v2
with:
private_key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
app_id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
- uses: actions/checkout@v4
if: env.SKIP_RELEASE == 'false'
with:
persist-credentials: false
- uses: actions/setup-node@v4
if: env.SKIP_RELEASE == 'false'
with:
node-version: 22
- run: npm i -g pnpm
if: env.SKIP_RELEASE == 'false'
- name: Install dependencies
if: env.SKIP_RELEASE == 'false'
run: |
pnpm install
- name: Run Semantic Release
if: env.SKIP_RELEASE == 'false'
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
GIT_AUTHOR_NAME: "Releases Homarr"
GIT_AUTHOR_EMAIL: "175486441+homarr-releases[bot]@users.noreply.github.com"
GIT_COMMITTER_NAME: "Releases Homarr"
GIT_COMMITTER_EMAIL: "175486441+homarr-releases[bot]@users.noreply.github.com"
run: |
pnpm release
- name: Read semver output
# We read the last tag either from the created release or from the current branch, this is to rerun the deployment job for the currently released version when it failed
if: env.SKIP_RELEASE == 'false' || github.ref_name == 'main' || github.ref_name == 'beta'
id: read-semver
run: |
git fetch --tags
echo "version=$(git describe --tags --abbrev=0)" >> "$GITHUB_OUTPUT"
- name: Read git ref
if: env.SKIP_RELEASE == 'false'
id: read-git-ref
run: |
echo "ref=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Update dev branch
if: env.SKIP_RELEASE == 'false'
continue-on-error: true # Prevent pipeline from failing when merge fails
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
run: |
git config user.name "Releases Homarr"
git config user.email "175486441+homarr-releases[bot]@users.noreply.github.com"
git remote set-url origin https://x-access-token:${{ steps.obtainToken.outputs.token }}@github.com/${{ github.repository }}.git
git fetch origin dev
git checkout dev
git pull origin dev
git rebase ${{ github.ref_name }}
git push origin dev
deploy: deploy:
name: Deploy docker image name: Deploy docker image
needs: release
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
NEXT_VERSION: ${{ needs.release.outputs.version }}
DEPLOY_LATEST: ${{ github.ref_name == 'main' }}
DEPLOY_BETA: ${{ github.ref_name == 'beta' }}
steps: steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.release.outputs.git_ref }}
- name: Discord notification - name: Discord notification
if: ${{ github.events.inputs.send-notifications != false }} if: ${{ github.events.inputs.send-notifications != false }}
env: env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master uses: Ilshidur/action-discord@master
with: with:
args: "Deployment of an image for version '${{env.NEXT_VERSION}}' has been triggered: [run ${{ github.run_number }}](<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}>)" args: "Deployment of an image has been triggered: [run ${{ github.run_number }}](<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}>)"
- uses: actions/checkout@v4
- name: Get Next Version
id: semver
uses: ietf-tools/semver-action@v1
with:
token: ${{ github.token }}
branch: dev
- name: Discord notification
if: ${{ github.events.inputs.send-notifications != false }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
with:
args: "Semver computed next tag to be ${{ steps.semver.outputs.next }}. Current is ${{ steps.semver.outputs.current }}. Building images..."
- name: Log in to the Container registry - name: Log in to the Container registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
@@ -137,12 +71,15 @@ jobs:
with: with:
images: "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" images: "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
tags: | tags: |
${{ env.DEPLOY_LATEST == 'true' && 'type=raw,value=latest' || null }} type=raw,value=alpha
${{ env.DEPLOY_BETA == 'true' && 'type=raw,value=beta' || null }} type=raw,value=early-adopters
type=raw,value=${{ env.NEXT_VERSION }} # tags: |
# type=raw,value=latest
# type=raw,value=${{ steps.semver.outputs.next }}
- name: Build and push - name: Build and push
id: buildPushAction id: buildPushAction
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
if: ${{ github.events.inputs.push-image == 'true' || github.events.inputs.push-image == null }}
with: with:
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
context: . context: .
@@ -152,9 +89,30 @@ jobs:
network: host network: host
env: env:
SKIP_ENV_VALIDATION: true SKIP_ENV_VALIDATION: true
- name: Build
id: buildPushDryAction
uses: docker/build-push-action@v6
if: ${{ github.events.inputs.push-image == 'false' }}
with:
platforms: linux/amd64,linux/arm64
context: .
push: false
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
network: host
env:
SKIP_ENV_VALIDATION: true
- name: Discord notification - name: Discord notification
if: ${{ github.events.inputs.send-notifications != false && (github.events.inputs.push-image == 'true' || github.events.inputs.push-image == null) }}
env: env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master uses: Ilshidur/action-discord@master
with: with:
args: "Deployment of image has completed for branch ${{ github.ref_name }}. Image ID is '${{ steps.buildPushAction.outputs.imageid }}'." args: "Deployment of image has completed. Image ID is '${{ steps.buildPushAction.outputs.imageid }}'."
- name: Discord notification
if: ${{ github.events.inputs.send-notifications != false && !(github.events.inputs.push-image == 'true' || github.events.inputs.push-image == null) }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
with:
args: "Deployment of image has completed. Image ID is '${{ steps.buildPushDryAction.outputs.imageid }}'. This was a dry run."
@@ -37,7 +37,7 @@ jobs:
token: ${{ github.token }} token: ${{ github.token }}
branch: dev branch: dev
- name: Create pull request - name: Create pull request
run: 'gh pr create --title "chore(release): automatic release ${{ steps.semver.outputs.next }}" --body "**This is an automatic release**.<br/>Manual action may be required for major bumps.<br/>Detected change to be ``${{ steps.semver.outputs.bump }}``<br/>Bump version from ``${{ steps.semver.outputs.current }}`` to ``${{ steps.semver.outputs.next }}``" --base main --head dev --label automerge' run: "gh pr create --title \"chore(release): automatic release ${{ steps.semver.outputs.next }}\" --body \"**This is an automatic release**.<br/>Manual action may be required for major bumps.<br/>Detected change to be ``${{ steps.semver.outputs.bump }}``<br/>Bump version from ``${{ steps.semver.outputs.current }}`` to ``${{ steps.semver.outputs.next }}``\" --base main --head dev --label automerge"
env: env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
- name: Discord notification - name: Discord notification
@@ -4,9 +4,9 @@ on:
types: [opened, synchronize] types: [opened, synchronize]
jobs: jobs:
approve-automatic-prs: approve-renovate-prs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.actor_id == 158783068 || github.actor_id == 190541745 # Id of renovate bot and crowdin bot see https://api.github.com/users/homarr-renovate%5Bbot%5D and https://api.github.com/users/homarr-crowdin%5Bbot%5D if: github.actor_id == 158783068 # Id of renovate bot see https://api.github.com/users/homarr-renovate%5Bbot%5D
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -18,7 +18,7 @@ jobs:
app_id: ${{ secrets.RENOVATE_APPROVE_APP_ID }} app_id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
- name: Install GitHub CLI - name: Install GitHub CLI
run: sudo apt-get install -y gh run: sudo apt-get install -y gh
- name: Approve automatic PRs - name: Approve Renovate PRs
env: env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }} GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
run: | run: |
-4
View File
@@ -54,14 +54,10 @@ yarn-error.log*
*.log *.log
apps/tasks/tasks.cjs apps/tasks/tasks.cjs
apps/tasks/tasks.css
apps/websocket/wssServer.cjs apps/websocket/wssServer.cjs
apps/websocket/wssServer.css
apps/nextjs/.million/ apps/nextjs/.million/
packages/cli/cli.cjs packages/cli/cli.cjs
# e2e mounts
e2e/shared/tmp
#personal backgrounds #personal backgrounds
apps/nextjs/public/images/background.png apps/nextjs/public/images/background.png
+1 -1
View File
@@ -1 +1 @@
22.14.0 22.12.0
-51
View File
@@ -1,51 +0,0 @@
{
"branches": [
"main",
{
"name": "beta",
"prerelease": true,
"channel": "beta"
}
],
"plugins": [
[
"@semantic-release/commit-analyzer",
{
"preset": "conventionalcommits"
}
],
[
"@semantic-release/release-notes-generator",
{
"preset": "conventionalcommits"
}
],
[
"@semantic-release/changelog",
{
"changelogFile": "CHANGELOG.md"
}
],
[
"@semantic-release/npm",
{
"npmPublish": false
}
],
[
"@semantic-release/git",
{
"assets": ["package.json", "CHANGELOG.md"],
"message": "chore(release): ${nextRelease.version} [skip ci]"
}
],
[
"@semantic-release/github",
{
"successComment": false,
"failComment": false,
"releaseBodyTemplate": "<%= _.truncate(nextRelease.notes, { 'length': 124000, 'omission': '' }) %>"
}
]
]
}
-13
View File
@@ -1,13 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="typecheck" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="typecheck" />
</scripts>
<node-interpreter value="project" />
<package-manager value="pnpm" />
<envs />
<method v="2" />
</configuration>
</component>
+1 -6
View File
@@ -14,11 +14,6 @@
"gridstack", "gridstack",
"homarr", "homarr",
"jellyfin", "jellyfin",
"llen",
"lpop",
"lpush",
"lrange",
"ltrim",
"mantine", "mantine",
"manuel-rw", "manuel-rw",
"Meierschlumpf", "Meierschlumpf",
@@ -39,5 +34,5 @@
"i18n-ally.localesPaths": [ "i18n-ally.localesPaths": [
"packages/translation/src/lang", "packages/translation/src/lang",
], ],
"i18n-ally.keystyle": "nested", "i18n-ally.keystyle": "auto",
} }
-2622
View File
File diff suppressed because it is too large. Load diff
+34 -27
View File
@@ -1,4 +1,4 @@
FROM node:22.14.0-alpine AS base FROM node:22.12.0-alpine AS base
FROM base AS builder FROM base AS builder
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat
@@ -10,64 +10,71 @@ RUN apk add --no-cache libc6-compat curl bash
RUN apk update RUN apk update
COPY . . COPY . .
# Install working version of corepack (See https://github.com/nodejs/corepack/issues/612)
RUN npm install -g corepack@0.31.0 && corepack --version
RUN corepack enable pnpm && pnpm install --recursive --frozen-lockfile RUN corepack enable pnpm && pnpm install --recursive --frozen-lockfile
# Install sharp for image optimization
RUN corepack enable pnpm && pnpm install sharp -w
# Copy static data as it is not part of the build # Copy static data as it is not part of the build
COPY static-data ./static-data COPY static-data ./static-data
ARG SKIP_ENV_VALIDATION='true' ARG SKIP_ENV_VALIDATION='true'
ARG CI='true' ARG CI='true'
ARG DISABLE_REDIS_LOGS='true' ARG DISABLE_REDIS_LOGS='true'
# Install working version of corepack (See https://github.com/nodejs/corepack/issues/612)
RUN npm install -g corepack@0.31.0 && corepack --version
RUN corepack enable pnpm && pnpm build RUN corepack enable pnpm && pnpm build
FROM base AS runner FROM base AS runner
WORKDIR /app WORKDIR /app
# gettext is required for envsubst, openssl for generating AUTH_SECRET, su-exec for running application as non-root # gettext is required for envsubst
RUN apk add --no-cache redis nginx bash gettext su-exec openssl RUN apk add --no-cache redis nginx bash gettext
RUN mkdir /appdata RUN mkdir /appdata
VOLUME /appdata VOLUME /appdata
RUN mkdir /secrets
VOLUME /secrets
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Enable homarr cli # Enable homarr cli
COPY --from=builder /app/packages/cli/cli.cjs /app/apps/cli/cli.cjs COPY --from=builder --chown=nextjs:nodejs /app/packages/cli/cli.cjs /app/apps/cli/cli.cjs
RUN echo $'#!/bin/bash\ncd /app/apps/cli && node ./cli.cjs "$@"' > /usr/bin/homarr RUN echo $'#!/bin/bash\ncd /app/apps/cli && node ./cli.cjs "$@"' > /usr/bin/homarr
RUN chmod +x /usr/bin/homarr RUN chmod +x /usr/bin/homarr
# Don't run production as root # Don't run production as root
RUN mkdir -p /var/cache/nginx && \ RUN chown -R nextjs:nodejs /appdata
mkdir -p /var/log/nginx && \ RUN chown -R nextjs:nodejs /secrets
mkdir -p /var/lib/nginx && \ RUN mkdir -p /var/cache/nginx && chown -R nextjs:nodejs /var/cache/nginx && \
touch /run/nginx/nginx.pid && \ mkdir -p /var/log/nginx && chown -R nextjs:nodejs /var/log/nginx && \
mkdir -p /etc/nginx/templates /etc/nginx/ssl/certs mkdir -p /var/lib/nginx && chown -R nextjs:nodejs /var/lib/nginx && \
touch /run/nginx/nginx.pid && chown -R nextjs:nodejs /run/nginx/nginx.pid && \
mkdir -p /etc/nginx/templates /etc/nginx/ssl/certs && chown -R nextjs:nodejs /etc/nginx
USER nextjs
COPY --from=builder /app/apps/nextjs/next.config.ts . COPY --from=builder /app/apps/nextjs/next.config.mjs .
COPY --from=builder /app/apps/nextjs/package.json . COPY --from=builder /app/apps/nextjs/package.json .
COPY --from=builder /app/apps/tasks/tasks.cjs ./apps/tasks/tasks.cjs COPY --from=builder --chown=nextjs:nodejs /app/apps/tasks/tasks.cjs ./apps/tasks/tasks.cjs
COPY --from=builder /app/apps/websocket/wssServer.cjs ./apps/websocket/wssServer.cjs COPY --from=builder --chown=nextjs:nodejs /app/apps/websocket/wssServer.cjs ./apps/websocket/wssServer.cjs
COPY --from=builder /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node /app/build/better_sqlite3.node COPY --from=builder --chown=nextjs:nodejs /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node /app/build/better_sqlite3.node
COPY --from=builder /app/packages/db/migrations ./db/migrations COPY --from=builder --chown=nextjs:nodejs /app/packages/db/migrations ./db/migrations
# Automatically leverage output traces to reduce image size # Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing # https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder /app/apps/nextjs/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/apps/nextjs/.next/standalone ./
COPY --from=builder /app/apps/nextjs/.next/static ./apps/nextjs/.next/static COPY --from=builder --chown=nextjs:nodejs /app/apps/nextjs/.next/static ./apps/nextjs/.next/static
COPY --from=builder /app/apps/nextjs/public ./apps/nextjs/public COPY --from=builder --chown=nextjs:nodejs /app/apps/nextjs/public ./apps/nextjs/public
COPY scripts/run.sh ./run.sh COPY --chown=nextjs:nodejs scripts/run.sh ./run.sh
COPY --chmod=777 scripts/entrypoint.sh ./entrypoint.sh COPY --chown=nextjs:nodejs scripts/generateEncryptionKey.js ./generateEncryptionKey.js
COPY packages/redis/redis.conf /app/redis.conf COPY --chown=nextjs:nodejs packages/redis/redis.conf /app/redis.conf
COPY nginx.conf /etc/nginx/templates/nginx.conf COPY --chown=nextjs:nodejs nginx.conf /etc/nginx/templates/nginx.conf
ENV DB_URL='/appdata/db/db.sqlite' ENV DB_URL='/appdata/db/db.sqlite'
ENV DB_DIALECT='sqlite' ENV DB_DIALECT='sqlite'
ENV DB_DRIVER='better-sqlite3' ENV DB_DRIVER='better-sqlite3'
ENV AUTH_PROVIDERS='credentials' ENV AUTH_PROVIDERS='credentials'
ENV NODE_ENV='production'
ENTRYPOINT [ "/app/entrypoint.sh" ]
CMD ["sh", "run.sh"] CMD ["sh", "run.sh"]
+17 -197
View File
@@ -1,201 +1,21 @@
Apache License MIT License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION Copyright (c) 2023 Julius Marminge
1. Definitions. Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
"License" shall mean the terms and conditions for use, reproduction, The above copyright notice and this permission notice shall be included in all
and distribution as defined by Sections 1 through 9 of this document. copies or substantial portions of the Software.
"Licensor" shall mean the copyright owner or entity authorized by THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
the copyright owner that is granting the License. IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
"Legal Entity" shall mean the union of the acting entity and all AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
other entities that control, are controlled by, or are under common LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
control with that entity. For the purposes of this definition, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
"control" means (i) the power, direct or indirect, to cause the SOFTWARE.
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright (c) 2024 Meier Lukas, Thomas Camlong and Homarr Labs
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+7
View File
@@ -0,0 +1,7 @@
# THIS PROJECT IS STILL UNSTABLE AND WE DO NOT PROVIDE ANY SUPPORT FOR ISSUES THAT OCCURE.
## PLEASE DO NOT OPEN ANY ISSUES OR DISCUSSIONS
### EVERYTHING IS SUBJECT TO CHANGE
Please use [this](https://github.com/ajnart/homarr) version of Homarr when you want to use it
-29
View File
@@ -1,29 +0,0 @@
# Security Policy
This policy is relevant if you found potential vulnerabilities in an audit.
We consider something as a vulnerability if it...
1. puts users or user data at risk
2. enables third parties to gain control or access (e.g. [RATs](https://en.wikipedia.org/wiki/Remote_desktop_software#RAT), [privilege escalation](https://en.wikipedia.org/wiki/Privilege_escalation), ...)
3. abuses the system in an unintended way (e.g. crypto mining, proxy, ...)
## Supported Versions
| Version | Supported |
| ------- | ------------------ |
| >1.0.0 | :white_check_mark: |
| <1.0.0 | :x: |
## Reporting a Vulnerability
We use [GitHub's system for reporting vulnerabilities](https://docs.github.com/en/enterprise-cloud@latest/code-security/security-advisories/working-with-repository-security-advisories/creating-a-repository-security-advisory).
Click [**here to report an advisory**](https://github.com/homarr-labs/homarr/security/advisories/new). Our team will get notified and will get back to you within 1-6 business days.
As a general guideline; please provide as much detail as possible and provide reproduction steps / documentation regarding the re-creation.
You may also provide a fork with a fix for the vulnerability.
See https://cheatsheetseries.owasp.org/cheatsheets/Vulnerability_Disclosure_Cheat_Sheet.html for guidelines regarding disclosure.
If you're unable / unwilling (or it's not safe) to disclose vulnerabilites via GitHub, please report them with the subject "Security advisory - CVEXXX" to our email homarr-labs@proton.me.
Please never disclose security vulnerabilits on your own publicly - we'd like to search for a dimplomatic solution that is also safe for our users.
In your initial contact with us, please provide details according to the [OWASP guidelines for initial reports](https://cheatsheetseries.owasp.org/cheatsheets/Vulnerability_Disclosure_Cheat_Sheet.html#initial-report).
Thank you!
We're looking forward to your report
@@ -1,33 +1,22 @@
// Importing env files here to validate on build // Importing env files here to validate on build
import "@homarr/auth/env"; import "@homarr/auth/env.mjs";
import "@homarr/db/env";
import "@homarr/common/env";
import "@homarr/log/env";
import "@homarr/docker/env";
import type { NextConfig } from "next";
import MillionLint from "@million/lint"; import MillionLint from "@million/lint";
import createNextIntlPlugin from "next-intl/plugin"; import createNextIntlPlugin from "next-intl/plugin";
import "./src/env.mjs";
// Package path does not work... so we need to use relative path // Package path does not work... so we need to use relative path
const withNextIntl = createNextIntlPlugin("../../packages/translation/src/request.ts"); const withNextIntl = createNextIntlPlugin("../../packages/translation/src/request.ts");
interface WebpackConfig { /** @type {import("next").NextConfig} */
module: { const nextConfig = {
rules: {
test: RegExp;
loader: string;
}[];
};
}
const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
reactStrictMode: true, reactStrictMode: true,
/** We already do linting and typechecking as separate tasks in CI */ /** We already do linting and typechecking as separate tasks in CI */
eslint: { ignoreDuringBuilds: true }, eslint: { ignoreDuringBuilds: true },
typescript: { ignoreBuildErrors: true }, typescript: { ignoreBuildErrors: true },
webpack: (config: WebpackConfig, { isServer }) => { webpack: (config, { isServer }) => {
if (isServer) { if (isServer) {
config.module.rules.push({ config.module.rules.push({
test: /\.node$/, test: /\.node$/,
@@ -47,7 +36,6 @@ const nextConfig: NextConfig = {
}; };
// Skip transform is used because of webpack loader, without it for example 'Tooltip.Floating' will not work and show an error // Skip transform is used because of webpack loader, without it for example 'Tooltip.Floating' will not work and show an error
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const withMillionLint = MillionLint.next({ rsc: true, skipTransform: true, telemetry: false }); const withMillionLint = MillionLint.next({ rsc: true, skipTransform: true, telemetry: false });
export default withNextIntl(nextConfig); export default withNextIntl(nextConfig);
+34 -46
View File
@@ -6,7 +6,7 @@
"scripts": { "scripts": {
"build": "pnpm with-env next build", "build": "pnpm with-env next build",
"clean": "git clean -xdf .next .turbo node_modules", "clean": "git clean -xdf .next .turbo node_modules",
"dev": "pnpm with-env next dev --turbopack", "dev": "pnpm with-env next dev",
"format": "prettier --check . --ignore-path ../../.gitignore", "format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint", "lint": "eslint",
"start": "pnpm with-env next start", "start": "pnpm with-env next start",
@@ -15,50 +15,39 @@
}, },
"prettier": "@homarr/prettier-config", "prettier": "@homarr/prettier-config",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@homarr/analytics": "workspace:^0.1.0", "@homarr/analytics": "workspace:^0.1.0",
"@homarr/api": "workspace:^0.1.0", "@homarr/api": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0", "@homarr/auth": "workspace:^0.1.0",
"@homarr/boards": "workspace:^0.1.0",
"@homarr/certificates": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"@homarr/cron-job-status": "workspace:^0.1.0", "@homarr/cron-job-status": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0", "@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0", "@homarr/definitions": "workspace:^0.1.0",
"@homarr/docker": "workspace:^0.1.0",
"@homarr/form": "workspace:^0.1.0", "@homarr/form": "workspace:^0.1.0",
"@homarr/forms-collection": "workspace:^0.1.0", "@homarr/gridstack": "^1.11.2",
"@homarr/gridstack": "^1.12.0",
"@homarr/icons": "workspace:^0.1.0",
"@homarr/integrations": "workspace:^0.1.0", "@homarr/integrations": "workspace:^0.1.0",
"@homarr/log": "workspace:^", "@homarr/log": "workspace:^",
"@homarr/modals": "workspace:^0.1.0", "@homarr/modals": "workspace:^0.1.0",
"@homarr/modals-collection": "workspace:^0.1.0", "@homarr/modals-collection": "workspace:^0.1.0",
"@homarr/notifications": "workspace:^0.1.0", "@homarr/notifications": "workspace:^0.1.0",
"@homarr/old-import": "workspace:^0.1.0",
"@homarr/old-schema": "workspace:^0.1.0", "@homarr/old-schema": "workspace:^0.1.0",
"@homarr/redis": "workspace:^0.1.0", "@homarr/redis": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0", "@homarr/server-settings": "workspace:^0.1.0",
"@homarr/settings": "workspace:^0.1.0",
"@homarr/spotlight": "workspace:^0.1.0", "@homarr/spotlight": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0", "@homarr/widgets": "workspace:^0.1.0",
"@mantine/colors-generator": "^7.17.0", "@mantine/colors-generator": "^7.14.3",
"@mantine/core": "^7.17.0", "@mantine/core": "^7.14.3",
"@mantine/dropzone": "^7.17.0", "@mantine/hooks": "^7.14.3",
"@mantine/hooks": "^7.17.0", "@mantine/modals": "^7.14.3",
"@mantine/modals": "^7.17.0", "@mantine/tiptap": "^7.14.3",
"@mantine/tiptap": "^7.17.0", "@million/lint": "1.0.13",
"@million/lint": "1.0.14", "@t3-oss/env-nextjs": "^0.11.1",
"@tabler/icons-react": "^3.30.0", "@tabler/icons-react": "^3.24.0",
"@tanstack/react-query": "^5.66.9", "@tanstack/react-query": "^5.62.2",
"@tanstack/react-query-devtools": "^5.66.9", "@tanstack/react-query-devtools": "^5.62.2",
"@tanstack/react-query-next-experimental": "^5.66.9", "@tanstack/react-query-next-experimental": "5.62.2",
"@trpc/client": "next", "@trpc/client": "next",
"@trpc/next": "next", "@trpc/next": "next",
"@trpc/react-query": "next", "@trpc/react-query": "next",
@@ -70,37 +59,36 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"flag-icons": "^7.3.2", "flag-icons": "^7.2.3",
"glob": "^11.0.1", "glob": "^11.0.0",
"jotai": "^2.12.1", "jotai": "^2.10.3",
"mantine-react-table": "2.0.0-beta.9", "mantine-react-table": "2.0.0-beta.7",
"next": "15.1.7", "next": "^14.2.20",
"postcss-preset-mantine": "^1.17.0", "postcss-preset-mantine": "^1.17.0",
"prismjs": "^1.29.0", "prismjs": "^1.29.0",
"react": "19.0.0", "react": "^18.3.1",
"react-dom": "19.0.0", "react-dom": "^18.3.1",
"react-error-boundary": "^5.0.0", "react-error-boundary": "^4.1.2",
"react-simple-code-editor": "^0.14.1", "react-simple-code-editor": "^0.14.1",
"sass": "^1.85.0", "sass": "^1.82.0",
"superjson": "2.2.2", "superjson": "2.2.1",
"swagger-ui-react": "^5.19.0", "swagger-ui-react": "^5.18.2",
"use-deep-compare-effect": "^1.8.1", "use-deep-compare-effect": "^1.8.1"
"zod": "^3.24.2"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/chroma-js": "3.1.1", "@types/chroma-js": "2.4.4",
"@types/node": "^22.13.5", "@types/node": "^22.10.1",
"@types/prismjs": "^1.26.5", "@types/prismjs": "^1.26.5",
"@types/react": "19.0.10", "@types/react": "^18.3.13",
"@types/react-dom": "19.0.4", "@types/react-dom": "^18.3.1",
"@types/swagger-ui-react": "^5.18.0", "@types/swagger-ui-react": "^4.18.3",
"concurrently": "^9.1.2", "concurrently": "^9.1.0",
"eslint": "^9.21.0", "eslint": "^9.16.0",
"node-loader": "^2.1.0", "node-loader": "^2.1.0",
"prettier": "^3.5.2", "prettier": "^3.4.2",
"typescript": "^5.7.3" "typescript": "^5.7.2"
} }
} }
@@ -1,5 +0,0 @@
import definition from "../../boards/(content)/(home)/_definition";
const { layout } = definition;
export default layout;
@@ -1,3 +0,0 @@
import HomeBoardNotFoundPage from "../boards/(content)/not-found";
export default HomeBoardNotFoundPage;
@@ -0,0 +1,5 @@
import definition from "../boards/(content)/(home)/_definition";
const { layout } = definition;
export default layout;
@@ -1,4 +1,4 @@
import definition from "../../boards/(content)/(home)/_definition"; import definition from "../boards/(content)/(home)/_definition";
const { generateMetadataAsync: generateMetadata, page } = definition; const { generateMetadataAsync: generateMetadata, page } = definition;
@@ -15,12 +15,11 @@ import {
wsLink, wsLink,
} from "@trpc/client"; } from "@trpc/client";
import superjson from "superjson"; import superjson from "superjson";
import type { SuperJSONResult } from "superjson";
import type { AppRouter } from "@homarr/api"; import type { AppRouter } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi, createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/client";
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
import { env } from "@homarr/common/env"; import { env } from "~/env.mjs";
const getWebSocketProtocol = () => { const getWebSocketProtocol = () => {
// window is not defined on server side // window is not defined on server side
@@ -65,7 +64,7 @@ export function TRPCReactProvider(props: PropsWithChildren) {
links: [ links: [
loggerLink({ loggerLink({
enabled: (opts) => enabled: (opts) =>
env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error), process.env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error),
}), }),
splitLink({ splitLink({
condition: ({ type }) => type === "subscription", condition: ({ type }) => type === "subscription",
@@ -83,8 +82,8 @@ export function TRPCReactProvider(props: PropsWithChildren) {
serialize(object: unknown) { serialize(object: unknown) {
return object; return object;
}, },
deserialize(data: SuperJSONResult) { deserialize(data: unknown) {
return superjson.deserialize<unknown>(data); return data;
}, },
}, },
url: getTrpcUrl(), url: getTrpcUrl(),
@@ -93,7 +92,6 @@ export function TRPCReactProvider(props: PropsWithChildren) {
false: unstable_httpBatchStreamLink({ false: unstable_httpBatchStreamLink({
transformer: superjson, transformer: superjson,
url: getTrpcUrl(), url: getTrpcUrl(),
maxURLLength: 2083, // Suggested by tRPC: https://trpc.io/docs/client/links/httpBatchLink#setting-a-maximum-url-length
headers: createHeadersCallbackForSource("nextjs-react (json)"), headers: createHeadersCallbackForSource("nextjs-react (json)"),
}), }),
}), }),
@@ -2,13 +2,13 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Button, PasswordInput, Stack, TextInput } from "@mantine/core"; import { Button, PasswordInput, Stack, TextInput } from "@mantine/core";
import type { z } from "zod";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { CustomPasswordInput } from "@homarr/ui"; import { CustomPasswordInput } from "@homarr/ui";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation"; import { validation } from "@homarr/validation";
interface RegistrationFormProps { interface RegistrationFormProps {
@@ -4,24 +4,22 @@ import { Card, Center, Stack, Text, Title } from "@mantine/core";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server"; import { isProviderEnabled } from "@homarr/auth/server";
import { and, db, eq } from "@homarr/db"; import { and, db, eq } from "@homarr/db";
import { invites } from "@homarr/db/schema"; import { invites } from "@homarr/db/schema/sqlite";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo"; import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
import { RegistrationForm } from "./_registration-form"; import { RegistrationForm } from "./_registration-form";
interface InviteUsagePageProps { interface InviteUsagePageProps {
params: Promise<{ params: {
id: string; id: string;
}>; };
searchParams: Promise<{ searchParams: {
token: string; token: string;
}>; };
} }
export default async function InviteUsagePage(props: InviteUsagePageProps) { export default async function InviteUsagePage({ params, searchParams }: InviteUsagePageProps) {
const searchParams = await props.searchParams;
const params = await props.params;
if (!isProviderEnabled("credentials")) notFound(); if (!isProviderEnabled("credentials")) notFound();
const session = await auth(); const session = await auth();
@@ -59,7 +57,7 @@ export default async function InviteUsagePage(props: InviteUsagePageProps) {
{t("subtitle")} {t("subtitle")}
</Text> </Text>
</Stack> </Stack>
<Card withBorder w={64 * 6} maw="90vw"> <Card bg="dark.8" w={64 * 6} maw="90vw">
<RegistrationForm invite={invite} /> <RegistrationForm invite={invite} />
</Card> </Card>
<Text size="xs" c="gray.5" ta="center"> <Text size="xs" c="gray.5" ta="center">
@@ -5,7 +5,6 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Anchor, Button, Card, Code, Collapse, Divider, PasswordInput, Stack, Text, TextInput } from "@mantine/core"; import { Anchor, Button, Card, Code, Collapse, Divider, PasswordInput, Stack, Text, TextInput } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { z } from "zod";
import { signIn } from "@homarr/auth/client"; import { signIn } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
@@ -13,7 +12,7 @@ import type { useForm } from "@homarr/form";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation"; import { validation, z } from "@homarr/validation";
interface LoginFormProps { interface LoginFormProps {
providers: string[]; providers: string[];
@@ -1,7 +1,7 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { Card, Center, Stack, Text, Title } from "@mantine/core"; import { Card, Center, Stack, Text, Title } from "@mantine/core";
import { env } from "@homarr/auth/env"; import { env } from "@homarr/auth/env.mjs";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
@@ -9,13 +9,12 @@ import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
import { LoginForm } from "./_login-form"; import { LoginForm } from "./_login-form";
interface LoginProps { interface LoginProps {
searchParams: Promise<{ searchParams: {
callbackUrl?: string; callbackUrl?: string;
}>; };
} }
export default async function Login(props: LoginProps) { export default async function Login({ searchParams }: LoginProps) {
const searchParams = await props.searchParams;
const session = await auth(); const session = await auth();
if (session) { if (session) {
@@ -36,7 +35,7 @@ export default async function Login(props: LoginProps) {
{t("subtitle")} {t("subtitle")}
</Text> </Text>
</Stack> </Stack>
<Card withBorder w={64 * 6} maw="90vw"> <Card bg="dark.8" w={64 * 6} maw="90vw">
<LoginForm <LoginForm
providers={env.AUTH_PROVIDERS} providers={env.AUTH_PROVIDERS}
oidcClientName={env.AUTH_OIDC_CLIENT_NAME} oidcClientName={env.AUTH_OIDC_CLIENT_NAME}
@@ -1,6 +1,6 @@
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { createBoardContentPage } from "../../_creator"; import { createBoardContentPage } from "../_creator";
export default createBoardContentPage<{ locale: string; name: string }>({ export default createBoardContentPage<{ locale: string; name: string }>({
async getInitialBoardAsync({ name }) { async getInitialBoardAsync({ name }) {
@@ -1,18 +0,0 @@
import { IconLayoutOff } from "@tabler/icons-react";
import { getScopedI18n } from "@homarr/translation/server";
import { BoardNotFound } from "~/components/board/not-found";
export default async function BoardNotFoundPage() {
const tNotFound = await getScopedI18n("board.error.notFound");
return (
<BoardNotFound
icon={IconLayoutOff}
title={tNotFound("title")}
description={tNotFound("description")}
link={{ label: tNotFound("link"), href: "/manage/boards" }}
notice={tNotFound("notice")}
/>
);
}
@@ -5,13 +5,12 @@ import { Box, LoadingOverlay, Stack } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { BoardCategorySection } from "~/components/board/sections/category-section"; import { BoardCategorySection } from "~/components/board/sections/category-section";
import { BoardEmptySection } from "~/components/board/sections/empty-section"; import { BoardEmptySection } from "~/components/board/sections/empty-section";
import { BoardBackgroundVideo } from "~/components/layout/background"; import { BoardBackgroundVideo } from "~/components/layout/background";
import { fullHeightWithoutHeaderAndFooter } from "~/constants"; import { fullHeightWithoutHeaderAndFooter } from "~/constants";
import { useIsBoardReady } from "./_ready-context"; import { useIsBoardReady, useRequiredBoard } from "./_context";
let boardName: string | null = null; let boardName: string | null = null;
@@ -0,0 +1,118 @@
"use client";
import type { Dispatch, PropsWithChildren, SetStateAction } from "react";
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { updateBoardName } from "./_client";
const BoardContext = createContext<{
board: RouterOutputs["board"]["getHomeBoard"];
isReady: boolean;
markAsReady: (id: string) => void;
isEditMode: boolean;
setEditMode: Dispatch<SetStateAction<boolean>>;
} | null>(null);
export const BoardProvider = ({
children,
initialBoard,
}: PropsWithChildren<{
initialBoard: RouterOutputs["board"]["getBoardByName"];
}>) => {
const pathname = usePathname();
const utils = clientApi.useUtils();
const [readySections, setReadySections] = useState<string[]>([]);
const [isEditMode, setEditMode] = useState(false);
const { data } = clientApi.board.getBoardByName.useQuery(
{ name: initialBoard.name },
{
initialData: initialBoard,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
);
// Update the board name so it can be used within updateBoard method
updateBoardName(initialBoard.name);
// Invalidate the board when the pathname changes
// This allows to refetch the board when it might have changed - e.g. if someone else added an item
useEffect(() => {
return () => {
setReadySections([]);
void utils.board.getBoardByName.invalidate({ name: initialBoard.name });
};
}, [pathname, utils, initialBoard.name]);
useEffect(() => {
setReadySections((previous) => previous.filter((id) => data.sections.some((section) => section.id === id)));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data.sections.length, setReadySections]);
const markAsReady = useCallback((id: string) => {
setReadySections((previous) => (previous.includes(id) ? previous : [...previous, id]));
}, []);
return (
<BoardContext.Provider
value={{
board: data,
isReady: data.sections.length === readySections.length,
markAsReady,
isEditMode,
setEditMode,
}}
>
{children}
</BoardContext.Provider>
);
};
export const useMarkSectionAsReady = () => {
const context = useContext(BoardContext);
if (!context) {
throw new Error("Board is required");
}
return context.markAsReady;
};
export const useIsBoardReady = () => {
const context = useContext(BoardContext);
if (!context) {
throw new Error("Board is required");
}
return context.isReady;
};
export const useRequiredBoard = () => {
const optionalBoard = useOptionalBoard();
if (!optionalBoard) {
throw new Error("Board is required");
}
return optionalBoard;
};
export const useOptionalBoard = () => {
const context = useContext(BoardContext);
return context?.board;
};
export const useEditMode = () => {
const context = useContext(BoardContext);
if (!context) {
throw new Error("Board is required");
}
return [context.isEditMode, context.setEditMode] as const;
};
@@ -7,7 +7,6 @@ import "~/styles/gridstack.scss";
import { IntegrationProvider } from "@homarr/auth/client"; import { IntegrationProvider } from "@homarr/auth/client";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { getIntegrationsWithPermissionsAsync } from "@homarr/auth/server"; import { getIntegrationsWithPermissionsAsync } from "@homarr/auth/server";
import { isNullOrWhitespace } from "@homarr/common";
import { getI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { createMetaTitle } from "~/metadata"; import { createMetaTitle } from "~/metadata";
@@ -49,13 +48,11 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
return { return {
title: board.metaTitle ?? createMetaTitle(t("board.content.metaTitle", { boardName: board.name })), title: board.metaTitle ?? createMetaTitle(t("board.content.metaTitle", { boardName: board.name })),
icons: { icons: {
icon: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : undefined, icon: board.faviconImageUrl ? board.faviconImageUrl : undefined,
apple: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : undefined, apple: board.faviconImageUrl ? board.faviconImageUrl : undefined,
}, },
appleWebApp: { appleWebApp: {
startupImage: { startupImage: { url: board.faviconImageUrl ? board.faviconImageUrl : "/logo/logo.png" },
url: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : "/logo/logo.png",
},
}, },
}; };
} catch (error) { } catch (error) {
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "./_context";
export const CustomCss = () => { export const CustomCss = () => {
const board = useRequiredBoard(); const board = useRequiredBoard();
@@ -2,7 +2,6 @@
import type { MouseEvent } from "react"; import type { MouseEvent } from "react";
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Group, Menu } from "@mantine/core"; import { Group, Menu } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks"; import { useHotkeys } from "@mantine/hooks";
@@ -10,20 +9,15 @@ import {
IconBox, IconBox,
IconBoxAlignTop, IconBoxAlignTop,
IconChevronDown, IconChevronDown,
IconLayoutBoard,
IconPencil, IconPencil,
IconPencilOff, IconPencilOff,
IconPlus, IconPlus,
IconReplace,
IconResize, IconResize,
IconSettings, IconSettings,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env";
import { useConfirmModal, useModalAction } from "@homarr/modals"; import { useConfirmModal, useModalAction } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
@@ -34,6 +28,8 @@ import { useCategoryActions } from "~/components/board/sections/category/categor
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal"; import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions"; import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
import { HeaderButton } from "~/components/layout/header/button"; import { HeaderButton } from "~/components/layout/header/button";
import { env } from "~/env.mjs";
import { useEditMode, useRequiredBoard } from "./_context";
export const BoardContentHeaderActions = () => { export const BoardContentHeaderActions = () => {
const [isEditMode] = useEditMode(); const [isEditMode] = useEditMode();
@@ -53,8 +49,6 @@ export const BoardContentHeaderActions = () => {
<HeaderButton href={`/boards/${board.name}/settings`}> <HeaderButton href={`/boards/${board.name}/settings`}>
<IconSettings stroke={1.5} /> <IconSettings stroke={1.5} />
</HeaderButton> </HeaderButton>
<SelectBoardsMenu />
</> </>
); );
}; };
@@ -120,7 +114,7 @@ const AddMenu = () => {
}; };
const EditModeMenu = () => { const EditModeMenu = () => {
const [isEditMode, { open, close }] = useEditMode(); const [isEditMode, setEditMode] = useEditMode();
const board = useRequiredBoard(); const board = useRequiredBoard();
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
const t = useScopedI18n("board.action.edit"); const t = useScopedI18n("board.action.edit");
@@ -132,7 +126,7 @@ const EditModeMenu = () => {
}); });
void utils.board.getBoardByName.invalidate({ name: board.name }); void utils.board.getBoardByName.invalidate({ name: board.name });
void revalidatePathActionAsync(`/boards/${board.name}`); void revalidatePathActionAsync(`/boards/${board.name}`);
close(); setEditMode(false);
}, },
onError() { onError() {
showErrorNotification({ showErrorNotification({
@@ -144,8 +138,8 @@ const EditModeMenu = () => {
const toggle = useCallback(() => { const toggle = useCallback(() => {
if (isEditMode) return saveBoard(board); if (isEditMode) return saveBoard(board);
open(); setEditMode(true);
}, [board, isEditMode, saveBoard, open]); }, [board, isEditMode, saveBoard, setEditMode]);
useHotkeys([["mod+e", toggle]]); useHotkeys([["mod+e", toggle]]);
usePreventLeaveWithDirty(isEditMode); usePreventLeaveWithDirty(isEditMode);
@@ -157,32 +151,6 @@ const EditModeMenu = () => {
); );
}; };
const SelectBoardsMenu = () => {
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
return (
<Menu position="bottom-end" withArrow>
<Menu.Target>
<HeaderButton w="auto" px={4}>
<IconReplace stroke={1.5} />
</HeaderButton>
</Menu.Target>
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
{boards.map((board) => (
<Menu.Item
key={board.id}
component={Link}
href={`/boards/${board.name}`}
leftSection={<IconLayoutBoard size={20} />}
>
{board.name}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
);
};
const usePreventLeaveWithDirty = (isDirty: boolean) => { const usePreventLeaveWithDirty = (isDirty: boolean) => {
const t = useI18n(); const t = useI18n();
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
@@ -1,67 +0,0 @@
"use client";
import type { PropsWithChildren } from "react";
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
const BoardReadyContext = createContext<{
isReady: boolean;
markAsReady: (id: string) => void;
} | null>(null);
export const BoardReadyProvider = ({ children }: PropsWithChildren) => {
const pathname = usePathname();
const utils = clientApi.useUtils();
const board = useRequiredBoard();
const [readySections, setReadySections] = useState<string[]>([]);
// Reset sections required for ready state
useEffect(() => {
return () => {
setReadySections([]);
};
}, [pathname, utils]);
useEffect(() => {
setReadySections((previous) => previous.filter((id) => board.sections.some((section) => section.id === id)));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [board.sections.length, setReadySections]);
const markAsReady = useCallback((id: string) => {
setReadySections((previous) => (previous.includes(id) ? previous : [...previous, id]));
}, []);
return (
<BoardReadyContext.Provider
value={{
isReady: board.sections.length === readySections.length,
markAsReady,
}}
>
{children}
</BoardReadyContext.Provider>
);
};
export const useMarkSectionAsReady = () => {
const context = useContext(BoardReadyContext);
if (!context) {
throw new Error("BoardReadyProvider is required");
}
return context.markAsReady;
};
export const useIsBoardReady = () => {
const context = useContext(BoardReadyContext);
if (!context) {
throw new Error("BoardReadyProvider is required");
}
return context.isReady;
};
@@ -2,12 +2,12 @@
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import type { MantineColorsTuple } from "@mantine/core"; import type { MantineColorsTuple } from "@mantine/core";
import { colorsTuple, createTheme, darken, lighten, MantineProvider } from "@mantine/core"; import { createTheme, darken, lighten, MantineProvider } from "@mantine/core";
import { useRequiredBoard } from "@homarr/boards/context";
import type { ColorScheme } from "@homarr/definitions"; import type { ColorScheme } from "@homarr/definitions";
import { useColorSchemeManager } from "../../_client-providers/mantine"; import { useColorSchemeManager } from "../../_client-providers/mantine";
import { useRequiredBoard } from "./_context";
export const BoardMantineProvider = ({ export const BoardMantineProvider = ({
children, children,
@@ -20,7 +20,6 @@ export const BoardMantineProvider = ({
colors: { colors: {
primaryColor: generateColors(board.primaryColor), primaryColor: generateColors(board.primaryColor),
secondaryColor: generateColors(board.secondaryColor), secondaryColor: generateColors(board.secondaryColor),
iconColor: board.iconColor ? generateColors(board.iconColor) : colorsTuple("#000000"),
}, },
primaryColor: "primaryColor", primaryColor: "primaryColor",
autoContrast: true, autoContrast: true,
@@ -1,47 +0,0 @@
import { IconHomeOff } from "@tabler/icons-react";
import { auth } from "@homarr/auth/next";
import { db } from "@homarr/db";
import { boards } from "@homarr/db/schema";
import { getI18n } from "@homarr/translation/server";
import type { BoardNotFoundProps } from "~/components/board/not-found";
import { BoardNotFound } from "~/components/board/not-found";
export default async function NotFoundBoardHomePage() {
const boardNotFoundProps = await getPropsAsync();
return <BoardNotFound {...boardNotFoundProps} />;
}
const getPropsAsync = async (): Promise<BoardNotFoundProps> => {
const boardCount = await db.$count(boards);
const t = await getI18n();
if (boardCount === 0) {
return {
icon: { src: "/favicon.ico", alt: "Homarr logo" },
title: t("board.error.noBoard.title"),
description: t("board.error.noBoard.description"),
link: { label: t("board.error.noBoard.link"), href: "/manage/boards" },
notice: t("board.error.noBoard.notice"),
};
}
const session = await auth();
const isAdmin = session?.user.permissions.includes("admin");
const type = isAdmin ? "admin" : session !== null ? "user" : "anonymous";
const href = {
admin: "/manage/settings",
user: `/manage/users/${session?.user.id}/general`,
anonymous: "/manage/boards",
}[type];
return {
icon: IconHomeOff,
title: t(`board.error.homeBoard.title`),
description: t(`board.error.homeBoard.${type}.description`),
link: { label: t(`board.error.homeBoard.${type}.link`), href },
notice: t(`board.error.homeBoard.${type}.notice`),
};
};
@@ -1,48 +0,0 @@
"use client";
import { Button, Group, Stack, Switch } from "@mantine/core";
import { useForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import type { Board } from "../../_types";
import { useSavePartialSettingsMutation } from "./_shared";
interface Props {
board: Board;
}
export const BehaviorSettingsContent = ({ board }: Props) => {
const t = useI18n();
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
const form = useForm({
initialValues: {
disableStatus: board.disableStatus,
},
});
return (
<form
onSubmit={form.onSubmit((values) => {
savePartialSettings({
id: board.id,
...values,
});
})}
>
<Stack>
<Switch
label={t("board.field.disableStatus.label")}
description={t("board.field.disableStatus.description")}
{...form.getInputProps("disableStatus", { type: "checkbox" })}
/>
<Group justify="end">
<Button type="submit" loading={isPending} color="teal">
{t("common.action.saveChanges")}
</Button>
</Group>
</Stack>
</form>
);
};
@@ -10,7 +10,6 @@ import {
Group, Group,
InputWrapper, InputWrapper,
isLightColor, isLightColor,
Select,
Slider, Slider,
Stack, Stack,
Text, Text,
@@ -40,8 +39,6 @@ export const ColorSettingsContent = ({ board }: Props) => {
primaryColor: board.primaryColor, primaryColor: board.primaryColor,
secondaryColor: board.secondaryColor, secondaryColor: board.secondaryColor,
opacity: board.opacity, opacity: board.opacity,
iconColor: board.iconColor ?? "",
itemRadius: board.itemRadius,
}, },
}); });
const [showPreview, { toggle }] = useDisclosure(false); const [showPreview, { toggle }] = useDisclosure(false);
@@ -101,26 +98,6 @@ export const ColorSettingsContent = ({ board }: Props) => {
/> />
</InputWrapper> </InputWrapper>
</Grid.Col> </Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<ColorInput
label={t("board.field.iconColor.label")}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
{...form.getInputProps("iconColor")}
/>
<Select
label={t("board.field.itemRadius.label")}
description={t("board.field.itemRadius.description")}
data={[
{ label: t("board.field.itemRadius.option.xs"), value: "xs" },
{ label: t("board.field.itemRadius.option.sm"), value: "sm" },
{ label: t("board.field.itemRadius.option.md"), value: "md" },
{ label: t("board.field.itemRadius.option.lg"), value: "lg" },
{ label: t("board.field.itemRadius.option.xl"), value: "xl" },
]}
{...form.getInputProps("itemRadius")}
/>
</Grid.Col>
</Grid> </Grid>
<Group justify="end"> <Group justify="end">
<Button type="submit" loading={isPending} color="teal"> <Button type="submit" loading={isPending} color="teal">
@@ -5,11 +5,11 @@ import { useRouter } from "next/navigation";
import { Button, Divider, Group, Stack, Text } from "@mantine/core"; import { Button, Divider, Group, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useConfirmModal, useModalAction } from "@homarr/modals"; import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { BoardRenameModal } from "~/components/board/modals/board-rename-modal"; import { BoardRenameModal } from "~/components/board/modals/board-rename-modal";
import { useRequiredBoard } from "../../(content)/_context";
import classes from "./danger.module.css"; import classes from "./danger.module.css";
export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => { export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => {
@@ -5,13 +5,13 @@ import { Button, Grid, Group, Loader, Stack, TextInput, Tooltip } from "@mantine
import { useDebouncedValue, useDocumentTitle, useFavicon } from "@mantine/hooks"; import { useDebouncedValue, useDocumentTitle, useFavicon } from "@mantine/hooks";
import { IconAlertTriangle } from "@tabler/icons-react"; import { IconAlertTriangle } from "@tabler/icons-react";
import { useUpdateBoard } from "@homarr/boards/updater";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation"; import { validation } from "@homarr/validation";
import { createMetaTitle } from "~/metadata"; import { createMetaTitle } from "~/metadata";
import type { Board } from "../../_types"; import type { Board } from "../../_types";
import { useUpdateBoard } from "../../(content)/_client";
import { useSavePartialSettingsMutation } from "./_shared"; import { useSavePartialSettingsMutation } from "./_shared";
interface Props { interface Props {
@@ -4,7 +4,6 @@ import { AccordionControl, AccordionItem, AccordionPanel, Container, Stack, Text
import { import {
IconAlertTriangle, IconAlertTriangle,
IconBrush, IconBrush,
IconClick,
IconFileTypeCss, IconFileTypeCss,
IconLayout, IconLayout,
IconPhoto, IconPhoto,
@@ -23,25 +22,24 @@ import type { TablerIcon } from "@homarr/ui";
import { getBoardPermissionsAsync } from "~/components/board/permissions/server"; import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion"; import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion";
import { ColorSettingsContent } from "./_appereance";
import { BackgroundSettingsContent } from "./_background"; import { BackgroundSettingsContent } from "./_background";
import { BehaviorSettingsContent } from "./_behavior";
import { BoardAccessSettings } from "./_board-access"; import { BoardAccessSettings } from "./_board-access";
import { ColorSettingsContent } from "./_colors";
import { CustomCssSettingsContent } from "./_customCss"; import { CustomCssSettingsContent } from "./_customCss";
import { DangerZoneSettingsContent } from "./_danger"; import { DangerZoneSettingsContent } from "./_danger";
import { GeneralSettingsContent } from "./_general"; import { GeneralSettingsContent } from "./_general";
import { LayoutSettingsContent } from "./_layout"; import { LayoutSettingsContent } from "./_layout";
interface Props { interface Props {
params: Promise<{ params: {
name: string; name: string;
}>; };
searchParams: Promise<{ searchParams: {
tab?: keyof TranslationObject["board"]["setting"]["section"]; tab?: keyof TranslationObject["board"]["setting"]["section"];
}>; };
} }
const getBoardAndPermissionsAsync = async (params: Awaited<Props["params"]>) => { const getBoardAndPermissionsAsync = async (params: Props["params"]) => {
try { try {
const board = await api.board.getBoardByName({ name: params.name }); const board = await api.board.getBoardByName({ name: params.name });
const { hasFullAccess } = await getBoardPermissionsAsync(board); const { hasFullAccess } = await getBoardPermissionsAsync(board);
@@ -65,18 +63,12 @@ const getBoardAndPermissionsAsync = async (params: Awaited<Props["params"]>) =>
} }
}; };
export default async function BoardSettingsPage(props: Props) { export default async function BoardSettingsPage({ params, searchParams }: Props) {
const searchParams = await props.searchParams;
const params = await props.params;
const { board, permissions } = await getBoardAndPermissionsAsync(params); const { board, permissions } = await getBoardAndPermissionsAsync(params);
const boardSettings = await getServerSettingByKeyAsync(db, "board"); const boardSettings = await getServerSettingByKeyAsync(db, "board");
const { hasFullAccess, hasChangeAccess } = await getBoardPermissionsAsync(board); const { hasFullAccess } = await getBoardPermissionsAsync(board);
const t = await getScopedI18n("board.setting"); const t = await getScopedI18n("board.setting");
if (!hasChangeAccess) {
notFound();
}
return ( return (
<Container> <Container>
<Stack> <Stack>
@@ -91,26 +83,19 @@ export default async function BoardSettingsPage(props: Props) {
<AccordionItemFor value="background" icon={IconPhoto}> <AccordionItemFor value="background" icon={IconPhoto}>
<BackgroundSettingsContent board={board} /> <BackgroundSettingsContent board={board} />
</AccordionItemFor> </AccordionItemFor>
<AccordionItemFor value="appearance" icon={IconBrush}> <AccordionItemFor value="color" icon={IconBrush}>
<ColorSettingsContent board={board} /> <ColorSettingsContent board={board} />
</AccordionItemFor> </AccordionItemFor>
<AccordionItemFor value="customCss" icon={IconFileTypeCss}> <AccordionItemFor value="customCss" icon={IconFileTypeCss}>
<CustomCssSettingsContent board={board} /> <CustomCssSettingsContent board={board} />
</AccordionItemFor> </AccordionItemFor>
<AccordionItemFor value="behavior" icon={IconClick}>
<BehaviorSettingsContent board={board} />
</AccordionItemFor>
{hasFullAccess && ( {hasFullAccess && (
<> <>
<AccordionItemFor value="access" icon={IconUser}> <AccordionItemFor value="access" icon={IconUser}>
<BoardAccessSettings board={board} initialPermissions={permissions} /> <BoardAccessSettings board={board} initialPermissions={permissions} />
</AccordionItemFor> </AccordionItemFor>
<AccordionItemFor value="dangerZone" icon={IconAlertTriangle} danger noPadding> <AccordionItemFor value="dangerZone" icon={IconAlertTriangle} danger noPadding>
<DangerZoneSettingsContent <DangerZoneSettingsContent hideVisibility={boardSettings.homeBoardId === board.id} />
hideVisibility={
boardSettings.homeBoardId === board.id || boardSettings.mobileHomeBoardId === board.id
}
/>
</AccordionItemFor> </AccordionItemFor>
</> </>
)} )}
@@ -2,9 +2,8 @@
import { IconLayoutBoard } from "@tabler/icons-react"; import { IconLayoutBoard } from "@tabler/icons-react";
import { useRequiredBoard } from "@homarr/boards/context";
import { HeaderButton } from "~/components/layout/header/button"; import { HeaderButton } from "~/components/layout/header/button";
import { useRequiredBoard } from "./(content)/_context";
export const BoardOtherHeaderActions = () => { export const BoardOtherHeaderActions = () => {
const board = useRequiredBoard(); const board = useRequiredBoard();
@@ -1,11 +1,8 @@
import type { JSX, PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import { notFound, redirect } from "next/navigation"; import { notFound } from "next/navigation";
import { AppShellMain } from "@mantine/core"; import { AppShellMain } from "@mantine/core";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { auth } from "@homarr/auth/next";
import { BoardProvider } from "@homarr/boards/context";
import { EditModeProvider } from "@homarr/boards/edit-mode";
import { logger } from "@homarr/log"; import { logger } from "@homarr/log";
import { MainHeader } from "~/components/layout/header"; import { MainHeader } from "~/components/layout/header";
@@ -13,9 +10,9 @@ import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
import { ClientShell } from "~/components/layout/shell"; import { ClientShell } from "~/components/layout/shell";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme"; import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
import type { Board } from "./_types"; import type { Board } from "./_types";
import { BoardProvider } from "./(content)/_context";
import type { Params } from "./(content)/_creator"; import type { Params } from "./(content)/_creator";
import { CustomCss } from "./(content)/_custom-css"; import { CustomCss } from "./(content)/_custom-css";
import { BoardReadyProvider } from "./(content)/_ready-context";
import { BoardMantineProvider } from "./(content)/_theme"; import { BoardMantineProvider } from "./(content)/_theme";
interface CreateBoardLayoutProps<TParams extends Params> { interface CreateBoardLayoutProps<TParams extends Params> {
@@ -31,16 +28,10 @@ export const createBoardLayout = <TParams extends Params>({
params, params,
children, children,
}: PropsWithChildren<{ }: PropsWithChildren<{
params: Promise<TParams>; params: TParams;
}>) => { }>) => {
const session = await auth(); const initialBoard = await getInitialBoard(params).catch((error) => {
const initialBoard = await getInitialBoard(await params).catch((error) => {
if (error instanceof TRPCError && error.code === "NOT_FOUND") { if (error instanceof TRPCError && error.code === "NOT_FOUND") {
if (!session) {
logger.debug("No home board found for anonymous user, redirecting to login");
redirect("/auth/login");
}
logger.warn(error); logger.warn(error);
notFound(); notFound();
} }
@@ -51,21 +42,17 @@ export const createBoardLayout = <TParams extends Params>({
return ( return (
<BoardProvider initialBoard={initialBoard}> <BoardProvider initialBoard={initialBoard}>
<BoardReadyProvider> <BoardMantineProvider defaultColorScheme={colorScheme}>
<EditModeProvider> <CustomCss />
<BoardMantineProvider defaultColorScheme={colorScheme}> <ClientShell hasNavigation={false}>
<CustomCss /> <MainHeader
<ClientShell hasNavigation={false}> logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
<MainHeader actions={headerActions}
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />} hasNavigation={false}
actions={headerActions} />
hasNavigation={false} <AppShellMain>{children}</AppShellMain>
/> </ClientShell>
<AppShellMain>{children}</AppShellMain> </BoardMantineProvider>
</ClientShell>
</BoardMantineProvider>
</EditModeProvider>
</BoardReadyProvider>
</BoardProvider> </BoardProvider>
); );
}; };
@@ -1,23 +0,0 @@
"use client";
import { Button } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useI18n } from "@homarr/translation/client";
export const BackToStart = () => {
const t = useI18n();
const { mutateAsync, isPending } = clientApi.onboard.previousStep.useMutation();
const handleBackToStartAsync = async () => {
await mutateAsync();
await revalidatePathActionAsync("/init");
};
return (
<Button loading={isPending} variant="subtle" color="gray" fullWidth onClick={handleBackToStartAsync}>
{t("init.backToStart")}
</Button>
);
};
@@ -1,87 +0,0 @@
import Link from "next/link";
import type { MantineColor } from "@mantine/core";
import { Button, Card, Stack, Text } from "@mantine/core";
import { IconBook2, IconCategoryPlus, IconLayoutDashboard, IconMailForward } from "@tabler/icons-react";
import { isProviderEnabled } from "@homarr/auth/server";
import { getMantineColor } from "@homarr/common";
import { db } from "@homarr/db";
import { createDocumentationLink } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import type { TablerIcon } from "@homarr/ui";
export const InitFinish = async () => {
const firstBoard = await db.query.boards.findFirst({ columns: { name: true } });
const tFinish = await getScopedI18n("init.step.finish");
return (
<Card w={64 * 6} maw="90vw" withBorder>
<Stack>
<Text>{tFinish("description")}</Text>
{firstBoard ? (
<InternalLinkButton
href={`/auth/login?callbackUrl=/boards/${firstBoard.name}`}
iconProps={{ icon: IconLayoutDashboard, color: "blue" }}
>
{tFinish("action.goToBoard", { name: firstBoard.name })}
</InternalLinkButton>
) : (
<InternalLinkButton
href="/auth/login?callbackUrl=/manage/boards"
iconProps={{ icon: IconCategoryPlus, color: "blue" }}
>
{tFinish("action.createBoard")}
</InternalLinkButton>
)}
{isProviderEnabled("credentials") && (
<InternalLinkButton
href="/auth/login?callbackUrl=/manage/users/invites"
iconProps={{ icon: IconMailForward, color: "pink" }}
>
{tFinish("action.inviteUser")}
</InternalLinkButton>
)}
<ExternalLinkButton
href={createDocumentationLink("/docs/getting-started/after-the-installation")}
iconProps={{ icon: IconBook2, color: "yellow" }}
>
{tFinish("action.docs")}
</ExternalLinkButton>
</Stack>
</Card>
);
};
interface LinkButtonProps {
href: string;
children: string;
iconProps: IconProps;
}
interface IconProps {
icon: TablerIcon;
color: MantineColor;
}
const Icon = ({ icon: IcomComponent, color }: IconProps) => {
return <IcomComponent color={getMantineColor(color, 6)} size={16} stroke={1.5} />;
};
const InternalLinkButton = ({ href, children, iconProps }: LinkButtonProps) => {
return (
<Button variant="default" component={Link} href={href} leftSection={<Icon {...iconProps} />}>
{children}
</Button>
);
};
const ExternalLinkButton = ({ href, children, iconProps }: LinkButtonProps) => {
return (
<Button variant="default" component="a" href={href} leftSection={<Icon {...iconProps} />}>
{children}
</Button>
);
};
@@ -1,52 +0,0 @@
"use client";
import { Button, Card, Stack, TextInput } from "@mantine/core";
import { IconArrowRight } from "@tabler/icons-react";
import type { z } from "zod";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation";
export const InitGroup = () => {
const t = useI18n();
const { mutateAsync } = clientApi.group.createInitialExternalGroup.useMutation();
const form = useZodForm(validation.group.create, {
initialValues: {
name: "",
},
});
const handleSubmitAsync = async (values: z.infer<typeof validation.group.create>) => {
await mutateAsync(values, {
async onSuccess() {
await revalidatePathActionAsync("/init");
},
onError(error) {
if (error.data?.code === "CONFLICT") {
form.setErrors({ name: t("common.zod.errors.custom.groupNameTaken") });
}
},
});
};
return (
<Card w={64 * 6} maw="90vw" withBorder>
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
<Stack>
<TextInput
label={t("init.step.group.form.name.label")}
description={t("init.step.group.form.name.description")}
withAsterisk
{...form.getInputProps("name")}
/>
<Button type="submit" loading={form.submitting} rightSection={<IconArrowRight size={16} stroke={1.5} />}>
{t("common.action.continue")}
</Button>
</Stack>
</form>
</Card>
);
};
@@ -1,41 +0,0 @@
import { ActionIcon, Button, Card, Group, Text } from "@mantine/core";
import type { FileWithPath } from "@mantine/dropzone";
import { IconPencil } from "@tabler/icons-react";
import { humanFileSize } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
interface FileInfoCardProps {
file: FileWithPath;
onRemove: () => void;
}
export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => {
const tFileInfo = useScopedI18n("init.step.import.fileInfo");
return (
<Card w={64 * 12 + 8} maw="90vw">
<Group justify="space-between" align="center" wrap="nowrap">
<Group>
<Text fw={500} lineClamp={1} style={{ wordBreak: "break-all" }}>
{file.name}
</Text>
<Text visibleFrom="md" c="gray.6" size="sm">
{humanFileSize(file.size)}
</Text>
</Group>
<Button
variant="subtle"
color="gray"
rightSection={<IconPencil size={16} stroke={1.5} />}
onClick={onRemove}
visibleFrom="md"
>
{tFileInfo("action.change")}
</Button>
<ActionIcon size="sm" variant="subtle" color="gray" hiddenFrom="md" onClick={onRemove}>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
</Group>
</Card>
);
};
@@ -1,63 +0,0 @@
import { Group, rem, Text } from "@mantine/core";
import type { FileWithPath } from "@mantine/dropzone";
import { Dropzone, MIME_TYPES } from "@mantine/dropzone";
import { IconFileZip, IconUpload, IconX } from "@tabler/icons-react";
import "@mantine/dropzone/styles.css";
import { useScopedI18n } from "@homarr/translation/client";
interface ImportDropZoneProps {
loading: boolean;
updateFile: (file: FileWithPath) => void;
}
export const ImportDropZone = ({ loading, updateFile }: ImportDropZoneProps) => {
const tDropzone = useScopedI18n("init.step.import.dropzone");
return (
<Dropzone
onDrop={(files) => {
const firstFile = files[0];
if (!firstFile) return;
updateFile(firstFile);
}}
acceptColor="blue.6"
rejectColor="red.6"
accept={[MIME_TYPES.zip, "application/x-zip-compressed"]}
loading={loading}
multiple={false}
maxSize={1024 * 1024 * 1024 * 64} // 64 MB
onReject={(rejections) => {
console.error(
"Rejected files",
rejections.map(
(rejection) =>
`File: ${rejection.file.name} size=${rejection.file.size} fileType=${rejection.file.type}\n - ${rejection.errors.map((error) => error.message).join("\n - ")}`,
),
);
}}
>
<Group justify="center" gap="xl" mih={220} style={{ pointerEvents: "none" }}>
<Dropzone.Accept>
<IconUpload style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-blue-6)" }} stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-red-6)" }} stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconFileZip style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-dimmed)" }} stroke={1.5} />
</Dropzone.Idle>
<div>
<Text size="xl" inline>
{tDropzone("title")}
</Text>
<Text size="sm" c="dimmed" inline mt={7}>
{tDropzone("description")}
</Text>
</div>
</Group>
</Dropzone>
);
};
@@ -1,53 +0,0 @@
"use client";
import { startTransition, useState } from "react";
import { Card, Stack } from "@mantine/core";
import type { FileWithPath } from "@mantine/dropzone";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { InitialOldmarrImport } from "@homarr/old-import/components";
import { FileInfoCard } from "./file-info-card";
import { ImportDropZone } from "./import-dropzone";
export const InitImport = () => {
const [file, setFile] = useState<FileWithPath | null>(null);
const { isPending, mutate } = clientApi.import.analyseInitialOldmarrImport.useMutation();
const [analyseResult, setAnalyseResult] = useState<RouterOutputs["import"]["analyseInitialOldmarrImport"] | null>(
null,
);
if (!file) {
return (
<Card w={64 * 12 + 8} maw="90vw" withBorder>
<ImportDropZone
loading={isPending}
updateFile={(file) => {
const formData = new FormData();
formData.append("file", file);
mutate(formData, {
onSuccess: (result) => {
startTransition(() => {
setAnalyseResult(result);
setFile(file);
});
},
onError: (error) => {
console.error(error);
},
});
}}
/>
</Card>
);
}
return (
<Stack mb="sm">
<FileInfoCard file={file} onRemove={() => setFile(null)} />
{analyseResult !== null && <InitialOldmarrImport file={file} analyseResult={analyseResult} />}
</Stack>
);
};
@@ -1,156 +0,0 @@
"use client";
import { startTransition } from "react";
import { Button, Card, Group, Stack, Switch, Text } from "@mantine/core";
import { IconArrowRight } from "@tabler/icons-react";
import type { z } from "zod";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import type { CheckboxProps } from "@homarr/form/types";
import { defaultServerSettings } from "@homarr/server-settings";
import type { TranslationObject } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation";
export const InitSettings = () => {
const tSection = useScopedI18n("management.page.settings.section");
const t = useI18n();
const { mutateAsync } = clientApi.serverSettings.initSettings.useMutation();
const form = useZodForm(validation.settings.init, { initialValues: defaultServerSettings });
form.watch("analytics.enableGeneral", ({ value }) => {
if (!value) {
startTransition(() => {
form.setFieldValue("analytics.enableWidgetData", false);
form.setFieldValue("analytics.enableIntegrationData", false);
form.setFieldValue("analytics.enableUserData", false);
});
}
});
const handleSubmitAsync = async (values: z.infer<typeof validation.settings.init>) => {
await mutateAsync(values, {
async onSuccess() {
await revalidatePathActionAsync("/init");
},
});
};
return (
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
<Stack>
<Card w={64 * 12 + 8} maw="90vw" withBorder>
<Stack gap="sm">
<Text fw={500}>{tSection("analytics.title")}</Text>
<Stack gap="xs">
<AnalyticsRow kind="general" {...form.getInputProps("analytics.enableGeneral", { type: "checkbox" })} />
<Stack gap="xs" ps="md" w="100%">
<AnalyticsRow
kind="integrationData"
disabled={!form.values.analytics.enableGeneral}
{...form.getInputProps("analytics.enableWidgetData", { type: "checkbox" })}
/>
<AnalyticsRow
kind="widgetData"
disabled={!form.values.analytics.enableGeneral}
{...form.getInputProps("analytics.enableIntegrationData", { type: "checkbox" })}
/>
<AnalyticsRow
kind="usersData"
disabled={!form.values.analytics.enableGeneral}
{...form.getInputProps("analytics.enableUserData", { type: "checkbox" })}
/>
</Stack>
</Stack>
</Stack>
</Card>
<Card w={64 * 12 + 8} maw="90vw" withBorder>
<Stack gap="sm">
<Text fw={500}>{tSection("crawlingAndIndexing.title")}</Text>
<Stack gap="xs">
<CrawlingRow
kind="noIndex"
{...form.getInputProps("crawlingAndIndexing.noIndex", { type: "checkbox" })}
/>
<CrawlingRow
kind="noFollow"
{...form.getInputProps("crawlingAndIndexing.noFollow", { type: "checkbox" })}
/>
<CrawlingRow
kind="noTranslate"
{...form.getInputProps("crawlingAndIndexing.noTranslate", { type: "checkbox" })}
/>
<CrawlingRow
kind="noSiteLinksSearchBox"
{...form.getInputProps("crawlingAndIndexing.noSiteLinksSearchBox", { type: "checkbox" })}
/>
</Stack>
</Stack>
</Card>
<Button type="submit" loading={form.submitting} rightSection={<IconArrowRight size={16} stroke={1.5} />}>
{t("common.action.continue")}
</Button>
</Stack>
</form>
);
};
interface AnalyticsRowProps {
kind: Exclude<keyof TranslationObject["management"]["page"]["settings"]["section"]["analytics"], "title">;
disabled?: boolean;
}
const AnalyticsRow = ({ kind, ...props }: AnalyticsRowProps & CheckboxProps) => {
const tSection = useI18n("management.page.settings.section");
return (
<SettingRow title={tSection(`analytics.${kind}.title`)} text={tSection(`analytics.${kind}.text`)} {...props} />
);
};
interface CrawlingRowProps {
kind: Exclude<
keyof TranslationObject["management"]["page"]["settings"]["section"]["crawlingAndIndexing"],
"title" | "warning"
>;
}
const CrawlingRow = ({ kind, ...inputProps }: CrawlingRowProps & CheckboxProps) => {
const tSection = useI18n("management.page.settings.section");
return (
<SettingRow
title={tSection(`crawlingAndIndexing.${kind}.title`)}
text={tSection(`crawlingAndIndexing.${kind}.text`)}
{...inputProps}
/>
);
};
const SettingRow = ({
title,
text,
disabled,
...inputProps
}: { title: string; text: string; disabled?: boolean } & CheckboxProps) => {
return (
<Group wrap="nowrap" align="center">
<Stack gap={0} style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{title}
</Text>
<Text size="xs" c="gray.5">
{text}
</Text>
</Stack>
<Switch disabled={disabled} {...inputProps} />
</Group>
);
};
@@ -1,32 +0,0 @@
import { Card, Stack, Text } from "@mantine/core";
import { IconFileImport, IconPlayerPlay } from "@tabler/icons-react";
import { getMantineColor } from "@homarr/common";
import { getScopedI18n } from "@homarr/translation/server";
import { InitStartButton } from "./next-button";
export const InitStart = async () => {
const tStart = await getScopedI18n("init.step.start");
return (
<Card w={64 * 6} maw="90vw" withBorder>
<Stack>
<Text>{tStart("description")}</Text>
<InitStartButton
preferredStep={undefined}
icon={<IconPlayerPlay color={getMantineColor("green", 6)} size={16} stroke={1.5} />}
>
{tStart("action.scratch")}
</InitStartButton>
<InitStartButton
preferredStep="import"
icon={<IconFileImport color={getMantineColor("cyan", 6)} size={16} stroke={1.5} />}
>
{tStart("action.importOldmarr")}
</InitStartButton>
</Stack>
</Card>
);
};
@@ -1,28 +0,0 @@
"use client";
import type { PropsWithChildren, ReactNode } from "react";
import { Button } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import type { OnboardingStep } from "@homarr/definitions";
interface InitStartButtonProps {
icon: ReactNode;
preferredStep: OnboardingStep | undefined;
}
export const InitStartButton = ({ preferredStep, icon, children }: PropsWithChildren<InitStartButtonProps>) => {
const { mutateAsync } = clientApi.onboard.nextStep.useMutation();
const handleClickAsync = async () => {
await mutateAsync({ preferredStep });
await revalidatePathActionAsync("/init");
};
return (
<Button onClick={handleClickAsync} variant="default" leftSection={icon}>
{children}
</Button>
);
};
@@ -1,11 +0,0 @@
import { Card } from "@mantine/core";
import { InitUserForm } from "./init-user-form";
export const InitUser = () => {
return (
<Card w={64 * 6} maw="90vw" withBorder>
<InitUserForm />
</Card>
);
};
@@ -1,56 +0,0 @@
import type { JSX } from "react";
import { Box, Center, Stack, Text, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import type { MaybePromise } from "@homarr/common/types";
import type { OnboardingStep } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { CurrentColorSchemeCombobox } from "~/components/color-scheme/current-color-scheme-combobox";
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
import { BackToStart } from "./_steps/back";
import { InitFinish } from "./_steps/finish/init-finish";
import { InitGroup } from "./_steps/group/init-group";
import { InitImport } from "./_steps/import/init-import";
import { InitSettings } from "./_steps/settings/init-settings";
import { InitStart } from "./_steps/start/init-start";
import { InitUser } from "./_steps/user/init-user";
const stepComponents: Record<OnboardingStep, null | (() => MaybePromise<JSX.Element>)> = {
start: InitStart,
import: InitImport,
user: InitUser,
group: InitGroup,
settings: InitSettings,
finish: InitFinish,
};
export default async function InitPage() {
const t = await getScopedI18n("init.step");
const currentStep = await api.onboard.currentStep();
const CurrentComponent = stepComponents[currentStep.current];
return (
<Box mih="100dvh">
<Center>
<Stack align="center" mt="xl">
<HomarrLogoWithTitle size="lg" />
<Stack gap={6} align="center">
<Title order={3} fw={400} ta="center">
{t(`${currentStep.current}.title`)}
</Title>
<Text size="sm" c="gray.5" ta="center">
{t(`${currentStep.current}.subtitle`)}
</Text>
</Stack>
<CurrentLanguageCombobox width="100%" />
<CurrentColorSchemeCombobox w="100%" />
{CurrentComponent && <CurrentComponent />}
{currentStep.previous === "start" && <BackToStart />}
</Stack>
</Center>
</Box>
);
}
@@ -1,20 +1,20 @@
"use client"; "use client";
import { useRouter } from "next/navigation";
import { Button, PasswordInput, Stack, TextInput } from "@mantine/core"; import { Button, PasswordInput, Stack, TextInput } from "@mantine/core";
import type { z } from "zod";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { CustomPasswordInput } from "@homarr/ui"; import { CustomPasswordInput } from "@homarr/ui";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation"; import { validation } from "@homarr/validation";
export const InitUserForm = () => { export const InitUserForm = () => {
const router = useRouter();
const t = useScopedI18n("user"); const t = useScopedI18n("user");
const tUser = useScopedI18n("init.step.user"); const { mutateAsync, error, isPending } = clientApi.user.initUser.useMutation();
const { mutateAsync, isPending } = clientApi.user.initUser.useMutation();
const form = useZodForm(validation.user.init, { const form = useZodForm(validation.user.init, {
initialValues: { initialValues: {
username: "", username: "",
@@ -25,17 +25,17 @@ export const InitUserForm = () => {
const handleSubmitAsync = async (values: FormType) => { const handleSubmitAsync = async (values: FormType) => {
await mutateAsync(values, { await mutateAsync(values, {
async onSuccess() { onSuccess: () => {
showSuccessNotification({ showSuccessNotification({
title: tUser("notification.success.title"), title: "User created",
message: tUser("notification.success.message"), message: "You can now log in",
}); });
await revalidatePathActionAsync("/init"); router.push("/auth/login");
}, },
onError: (error) => { onError: () => {
showErrorNotification({ showErrorNotification({
title: tUser("notification.error.title"), title: "User creation failed",
message: error.message, message: error?.message ?? "Unknown error",
}); });
}, },
}); });
@@ -0,0 +1,41 @@
import { notFound } from "next/navigation";
import { Card, Center, Stack, Text, Title } from "@mantine/core";
import { db } from "@homarr/db";
import { getScopedI18n } from "@homarr/translation/server";
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
import { InitUserForm } from "./_init-user-form";
export default async function InitUser() {
const firstUser = await db.query.users.findFirst({
columns: {
id: true,
},
});
if (firstUser) {
notFound();
}
const t = await getScopedI18n("user.page.init");
return (
<Center>
<Stack align="center" mt="xl">
<HomarrLogoWithTitle size="lg" />
<Stack gap={6} align="center">
<Title order={3} fw={400} ta="center">
{t("title")}
</Title>
<Text size="sm" c="gray.5" ta="center">
{t("subtitle")}
</Text>
</Stack>
<Card bg="dark.8" w={64 * 6} maw="90vw">
<InitUserForm />
</Card>
</Stack>
</Center>
);
}
+5 -32
View File
@@ -9,16 +9,10 @@ import "~/styles/scroll-area.scss";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { NextIntlClientProvider } from "next-intl"; import { NextIntlClientProvider } from "next-intl";
import { api } from "@homarr/api/server"; import { env } from "@homarr/auth/env.mjs";
import { env } from "@homarr/auth/env";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { db } from "@homarr/db";
import { getServerSettingsAsync } from "@homarr/db/queries";
import { ModalProvider } from "@homarr/modals"; import { ModalProvider } from "@homarr/modals";
import { Notifications } from "@homarr/notifications"; import { Notifications } from "@homarr/notifications";
import { SettingsProvider } from "@homarr/settings";
import { SpotlightProvider } from "@homarr/spotlight";
import type { SupportedLanguage } from "@homarr/translation";
import { isLocaleRTL, isLocaleSupported } from "@homarr/translation"; import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
import { getI18nMessages } from "@homarr/translation/server"; import { getI18nMessages } from "@homarr/translation/server";
@@ -68,53 +62,32 @@ export const viewport: Viewport = {
], ],
}; };
export default async function Layout(props: { export default async function Layout(props: { children: React.ReactNode; params: { locale: string } }) {
children: React.ReactNode; if (!isLocaleSupported(props.params.locale)) {
params: Promise<{ locale: SupportedLanguage }>;
}) {
if (!isLocaleSupported((await props.params).locale)) {
notFound(); notFound();
} }
const session = await auth(); const session = await auth();
const user = session ? await api.user.getById({ userId: session.user.id }).catch(() => null) : null;
const serverSettings = await getServerSettingsAsync(db);
const colorScheme = await getCurrentColorSchemeAsync(); const colorScheme = await getCurrentColorSchemeAsync();
const direction = isLocaleRTL((await props.params).locale) ? "rtl" : "ltr"; const direction = isLocaleRTL(props.params.locale) ? "rtl" : "ltr";
const i18nMessages = await getI18nMessages(); const i18nMessages = await getI18nMessages();
const StackedProvider = composeWrappers([ const StackedProvider = composeWrappers([
(innerProps) => { (innerProps) => {
return <AuthProvider session={session} logoutUrl={env.AUTH_LOGOUT_REDIRECT_URL} {...innerProps} />; return <AuthProvider session={session} logoutUrl={env.AUTH_LOGOUT_REDIRECT_URL} {...innerProps} />;
}, },
(innerProps) => (
<SettingsProvider
user={user}
serverSettings={{
board: {
homeBoardId: serverSettings.board.homeBoardId,
mobileHomeBoardId: serverSettings.board.mobileHomeBoardId,
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
forceDisableStatus: serverSettings.board.forceDisableStatus,
},
search: { defaultSearchEngineId: serverSettings.search.defaultSearchEngineId },
}}
{...innerProps}
/>
),
(innerProps) => <JotaiProvider {...innerProps} />, (innerProps) => <JotaiProvider {...innerProps} />,
(innerProps) => <TRPCReactProvider {...innerProps} />, (innerProps) => <TRPCReactProvider {...innerProps} />,
(innerProps) => <DayJsLoader {...innerProps} />, (innerProps) => <DayJsLoader {...innerProps} />,
(innerProps) => <NextIntlClientProvider {...innerProps} messages={i18nMessages} />, (innerProps) => <NextIntlClientProvider {...innerProps} messages={i18nMessages} />,
(innerProps) => <CustomMantineProvider {...innerProps} defaultColorScheme={colorScheme} />, (innerProps) => <CustomMantineProvider {...innerProps} defaultColorScheme={colorScheme} />,
(innerProps) => <ModalProvider {...innerProps} />, (innerProps) => <ModalProvider {...innerProps} />,
(innerProps) => <SpotlightProvider {...innerProps} />,
]); ]);
return ( return (
// Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering // Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering
<html <html
lang={(await props.params).locale} lang={props.params.locale}
dir={direction} dir={direction}
data-mantine-color-scheme={colorScheme} data-mantine-color-scheme={colorScheme}
style={{ style={{
@@ -1,22 +1,12 @@
.bannerContainer { .bannerContainer {
border-radius: 16px; border-radius: 8px;
overflow: hidden; overflow: hidden;
@mixin dark { background: linear-gradient(
background: linear-gradient( 130deg,
130deg, #fa52521f 0%,
#fa52521f 0%, var(--mantine-color-dark-6) 35%,
var(--mantine-color-dark-6) 35%, var(--mantine-color-dark-6) 100%
var(--mantine-color-dark-6) 100% ) !important;
) !important;
}
@mixin light {
background: linear-gradient(
130deg,
#fa52521f 0%,
var(--mantine-color-gray-3) 35%,
var(--mantine-color-gray-3) 100%
) !important;
}
} }
.scrollContainer { .scrollContainer {
@@ -5,36 +5,36 @@ import { splitToNChunks } from "@homarr/common";
import classes from "./hero-banner.module.css"; import classes from "./hero-banner.module.css";
const icons = [ const icons = [
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg", "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/homarr.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/sabnzbd.svg", "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/sabnzbd.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/deluge.svg", "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/deluge.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/radarr.svg", "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/radarr.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/sonarr.svg", "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/sonarr.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/lidarr.svg", "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/lidarr.svg",
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/pihole.svg", "https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/pihole.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/dashdot.png", "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/dashdot.png",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/overseerr.svg", "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/overseerr.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/plex.svg", "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/plex.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/jellyfin.svg", "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/jellyfin.svg",
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/homeassistant.svg", "https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/homeassistant.svg",
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/freshrss.svg", "https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/freshrss.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/readarr.svg", "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/readarr.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/transmission.svg", "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/transmission.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/qbittorrent.svg", "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/qbittorrent.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/nzbget.png", "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/nzbget.png",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/openmediavault.svg", "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/openmediavault.svg",
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/docker.svg", "https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/docker.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/jellyseerr.svg", "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/jellyseerr.svg",
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/adguardhome.svg", "https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/adguardhome.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/tdarr.png", "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/tdarr.png",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/prowlarr.svg", "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/prowlarr.svg",
]; ];
const countIconGroups = 3; const countIconGroups = 3;
const animationDurationInSeconds = 12; const animationDurationInSeconds = 12;
const arrayInChunks = splitToNChunks(icons, countIconGroups);
export const HeroBanner = () => { export const HeroBanner = () => {
const arrayInChunks = splitToNChunks(icons, countIconGroups);
const gridSpan = 12 / countIconGroups; const gridSpan = 12 / countIconGroups;
return ( return (
@@ -37,16 +37,16 @@ export async function generateMetadata() {
}; };
} }
const getHostAsync = async () => { const getHost = () => {
if (process.env.HOSTNAME) { if (process.env.HOSTNAME) {
return `${process.env.HOSTNAME}:3000`; return `${process.env.HOSTNAME}:3000`;
} }
return (await headers()).get("host"); return headers().get("host");
}; };
export default async function AboutPage() { export default async function AboutPage() {
const baseServerUrl = `http://${await getHostAsync()}`; const baseServerUrl = `http://${getHost()}`;
const t = await getScopedI18n("management.page.about"); const t = await getScopedI18n("management.page.about");
const attributes = await getPackageAttributesAsync(); const attributes = await getPackageAttributesAsync();
const githubContributors = (await fetch(`${baseServerUrl}/api/about/contributors/github`).then((res) => const githubContributors = (await fetch(`${baseServerUrl}/api/about/contributors/github`).then((res) =>
@@ -0,0 +1,55 @@
"use client";
import Link from "next/link";
import { Button, Group, Stack, Textarea, TextInput } from "@mantine/core";
import { useZodForm } from "@homarr/form";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
import { IconPicker } from "~/components/icons/picker/icon-picker";
type FormType = z.infer<typeof validation.app.manage>;
interface AppFormProps {
submitButtonTranslation: (t: TranslationFunction) => string;
initialValues?: FormType;
handleSubmit: (values: FormType) => void;
isPending: boolean;
}
export const AppForm = (props: AppFormProps) => {
const { submitButtonTranslation, handleSubmit, initialValues, isPending } = props;
const t = useI18n();
const form = useZodForm(validation.app.manage, {
initialValues: initialValues ?? {
name: "",
description: "",
iconUrl: "",
href: "",
},
});
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput {...form.getInputProps("name")} withAsterisk label={t("app.field.name.label")} />
<IconPicker initialValue={initialValues?.iconUrl} {...form.getInputProps("iconUrl")} />
<Textarea {...form.getInputProps("description")} label={t("app.field.description.label")} />
<TextInput {...form.getInputProps("href")} label={t("app.field.url.label")} />
<Group justify="end">
<Button variant="default" component={Link} href="/manage/apps">
{t("common.action.backToOverview")}
</Button>
<Button type="submit" loading={isPending}>
{submitButtonTranslation(t)}
</Button>
</Group>
</Stack>
</form>
);
};
@@ -2,30 +2,30 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { z } from "zod";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
import { AppForm } from "@homarr/forms-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import type { TranslationFunction } from "@homarr/translation";
import type { validation } from "@homarr/validation"; import { useScopedI18n } from "@homarr/translation/client";
import type { validation, z } from "@homarr/validation";
import { AppForm } from "../../_form";
interface AppEditFormProps { interface AppEditFormProps {
app: RouterOutputs["app"]["byId"]; app: RouterOutputs["app"]["byId"];
} }
export const AppEditForm = ({ app }: AppEditFormProps) => { export const AppEditForm = ({ app }: AppEditFormProps) => {
const tScoped = useScopedI18n("app.page.edit.notification"); const t = useScopedI18n("app.page.edit.notification");
const t = useI18n();
const router = useRouter(); const router = useRouter();
const { mutate, isPending } = clientApi.app.update.useMutation({ const { mutate, isPending } = clientApi.app.update.useMutation({
onSuccess: () => { onSuccess: () => {
showSuccessNotification({ showSuccessNotification({
title: tScoped("success.title"), title: t("success.title"),
message: tScoped("success.message"), message: t("success.message"),
}); });
void revalidatePathActionAsync("/manage/apps").then(() => { void revalidatePathActionAsync("/manage/apps").then(() => {
router.push("/manage/apps"); router.push("/manage/apps");
@@ -33,8 +33,8 @@ export const AppEditForm = ({ app }: AppEditFormProps) => {
}, },
onError: () => { onError: () => {
showErrorNotification({ showErrorNotification({
title: tScoped("error.title"), title: t("error.title"),
message: tScoped("error.message"), message: t("error.message"),
}); });
}, },
}); });
@@ -49,15 +49,14 @@ export const AppEditForm = ({ app }: AppEditFormProps) => {
[mutate, app.id], [mutate, app.id],
); );
const submitButtonTranslation = useCallback((t: TranslationFunction) => t("common.action.save"), []);
return ( return (
<AppForm <AppForm
buttonLabels={{ submitButtonTranslation={submitButtonTranslation}
submit: t("common.action.save"),
}}
initialValues={app} initialValues={app}
handleSubmit={handleSubmit} handleSubmit={handleSubmit}
isPending={isPending} isPending={isPending}
showBackToOverview
/> />
); );
}; };
@@ -9,11 +9,10 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { AppEditForm } from "./_app-edit-form"; import { AppEditForm } from "./_app-edit-form";
interface AppEditPageProps { interface AppEditPageProps {
params: Promise<{ id: string }>; params: { id: string };
} }
export default async function AppEditPage(props: AppEditPageProps) { export default async function AppEditPage({ params }: AppEditPageProps) {
const params = await props.params;
const session = await auth(); const session = await auth();
if (!session?.user.permissions.includes("app-modify-all")) { if (!session?.user.permissions.includes("app-modify-all")) {
@@ -0,0 +1,49 @@
"use client";
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import type { TranslationFunction } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client";
import type { validation, z } from "@homarr/validation";
import { AppForm } from "../_form";
export const AppNewForm = () => {
const t = useScopedI18n("app.page.create.notification");
const router = useRouter();
const { mutate, isPending } = clientApi.app.create.useMutation({
onSuccess: () => {
showSuccessNotification({
title: t("success.title"),
message: t("success.message"),
});
void revalidatePathActionAsync("/manage/apps").then(() => {
router.push("/manage/apps");
});
},
onError: () => {
showErrorNotification({
title: t("error.title"),
message: t("error.message"),
});
},
});
const handleSubmit = useCallback(
(values: z.infer<typeof validation.app.manage>) => {
mutate(values);
},
[mutate],
);
const submitButtonTranslation = useCallback((t: TranslationFunction) => t("common.action.create"), []);
return (
<AppForm submitButtonTranslation={submitButtonTranslation} handleSubmit={handleSubmit} isPending={isPending} />
);
};
@@ -2,10 +2,10 @@ import { notFound } from "next/navigation";
import { Container, Stack, Title } from "@mantine/core"; import { Container, Stack, Title } from "@mantine/core";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { AppNewForm } from "@homarr/forms-collection";
import { getI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { AppNewForm } from "./_app-new-form";
export default async function AppNewPage() { export default async function AppNewPage() {
const session = await auth(); const session = await auth();
@@ -22,7 +22,7 @@ export default async function AppNewPage() {
<Container> <Container>
<Stack> <Stack>
<Title>{t("app.page.create.title")}</Title> <Title>{t("app.page.create.title")}</Title>
<AppNewForm showBackToOverview showCreateAnother /> <AppNewForm />
</Stack> </Stack>
</Container> </Container>
</> </>
@@ -2,14 +2,12 @@ import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core"; import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
import { IconBox, IconPencil } from "@tabler/icons-react"; import { IconBox, IconPencil } from "@tabler/icons-react";
import { z } from "zod";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import type { inferSearchParamsFromSchema } from "@homarr/common/types"; import { parseAppHrefWithVariablesServer } from "@homarr/common/server";
import { getI18n, getScopedI18n } from "@homarr/translation/server"; import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { SearchInput, TablePagination } from "@homarr/ui";
import { ManageContainer } from "~/components/manage/manage-container"; import { ManageContainer } from "~/components/manage/manage-container";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
@@ -17,35 +15,22 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NoResults } from "~/components/no-results"; import { NoResults } from "~/components/no-results";
import { AppDeleteButton } from "./_app-delete-button"; import { AppDeleteButton } from "./_app-delete-button";
const searchParamsSchema = z.object({ export default async function AppsPage() {
search: z.string().optional(),
pageSize: z.string().regex(/\d+/).transform(Number).catch(10),
page: z.string().regex(/\d+/).transform(Number).catch(1),
});
interface AppsPageProps {
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
}
export default async function AppsPage(props: AppsPageProps) {
const session = await auth(); const session = await auth();
if (!session) { if (!session) {
redirect("/auth/login"); redirect("/auth/login");
} }
const searchParams = searchParamsSchema.parse(await props.searchParams); const apps = await api.app.all();
const { items: apps, totalCount } = await api.app.getPaginated(searchParams);
const t = await getScopedI18n("app"); const t = await getScopedI18n("app");
return ( return (
<ManageContainer> <ManageContainer>
<DynamicBreadcrumb /> <DynamicBreadcrumb />
<Stack> <Stack>
<Title>{t("page.list.title")}</Title>
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} flexExpand /> <Title>{t("page.list.title")}</Title>
{session.user.permissions.includes("app-create") && ( {session.user.permissions.includes("app-create") && (
<MobileAffixButton component={Link} href="/manage/apps/new"> <MobileAffixButton component={Link} href="/manage/apps/new">
{t("page.create.title")} {t("page.create.title")}
@@ -60,10 +45,6 @@ export default async function AppsPage(props: AppsPageProps) {
))} ))}
</Stack> </Stack>
)} )}
<Group justify="end">
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
</Group>
</Stack> </Stack>
</ManageContainer> </ManageContainer>
); );
@@ -78,9 +59,9 @@ const AppCard = async ({ app }: AppCardProps) => {
const session = await auth(); const session = await auth();
return ( return (
<Card withBorder> <Card>
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
<Group align="top" justify="start" wrap="nowrap" style={{ flex: "1" }}> <Group align="top" justify="start" wrap="nowrap">
<Avatar <Avatar
size="sm" size="sm"
src={app.iconUrl} src={app.iconUrl}
@@ -91,7 +72,7 @@ const AppCard = async ({ app }: AppCardProps) => {
}, },
}} }}
/> />
<Stack gap={0} style={{ flex: "1" }}> <Stack gap={0}>
<Text fw={500} lineClamp={1}> <Text fw={500} lineClamp={1}>
{app.name} {app.name}
</Text> </Text>
@@ -101,8 +82,8 @@ const AppCard = async ({ app }: AppCardProps) => {
</Text> </Text>
)} )}
{app.href && ( {app.href && (
<Anchor href={app.href} lineClamp={1} size="sm" style={{ wordBreak: "break-all" }}> <Anchor href={parseAppHrefWithVariablesServer(app.href)} lineClamp={1} size="sm" w="min-content">
{app.href} {parseAppHrefWithVariablesServer(app.href)}
</Anchor> </Anchor>
)} )}
</Stack> </Stack>
@@ -3,14 +3,12 @@
import { useCallback } from "react"; import { useCallback } from "react";
import Link from "next/link"; import Link from "next/link";
import { Menu } from "@mantine/core"; import { Menu } from "@mantine/core";
import { IconCopy, IconDeviceMobile, IconHome, IconSettings, IconTrash } from "@tabler/icons-react"; import { IconHome, IconSettings, IconTrash } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal, useModalAction } from "@homarr/modals"; import { useConfirmModal } from "@homarr/modals";
import { DuplicateBoardModal } from "@homarr/modals-collection";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { useBoardPermissions } from "~/components/board/permissions/client"; import { useBoardPermissions } from "~/components/board/permissions/client";
@@ -32,10 +30,8 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
const tCommon = useScopedI18n("common"); const tCommon = useScopedI18n("common");
const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board); const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board);
const { data: session } = useSession();
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
const { openModal: openDuplicateModal } = useModalAction(DuplicateBoardModal);
const setHomeBoardMutation = clientApi.board.setHomeBoard.useMutation({ const setHomeBoardMutation = clientApi.board.setHomeBoard.useMutation({
onSettled: async () => { onSettled: async () => {
@@ -43,12 +39,6 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
await revalidatePathActionAsync("/"); await revalidatePathActionAsync("/");
}, },
}); });
const setMobileHomeBoardMutation = clientApi.board.setMobileHomeBoard.useMutation({
onSettled: async () => {
// Revalidate all as it's part of the user settings, /boards page and board manage page
await revalidatePathActionAsync("/");
},
});
const deleteBoardMutation = clientApi.board.deleteBoard.useMutation({ const deleteBoardMutation = clientApi.board.deleteBoard.useMutation({
onSettled: async () => { onSettled: async () => {
await revalidatePathActionAsync("/manage/boards"); await revalidatePathActionAsync("/manage/boards");
@@ -74,35 +64,11 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
await setHomeBoardMutation.mutateAsync({ id: board.id }); await setHomeBoardMutation.mutateAsync({ id: board.id });
}, [board.id, setHomeBoardMutation]); }, [board.id, setHomeBoardMutation]);
const handleSetMobileHomeBoard = useCallback(async () => {
await setMobileHomeBoardMutation.mutateAsync({ id: board.id });
}, [board.id, setMobileHomeBoardMutation]);
const handleDuplicateBoard = useCallback(() => {
openDuplicateModal({
board: {
id: board.id,
name: board.name,
},
onSuccess: async () => {
await revalidatePathActionAsync("/manage/boards");
},
});
}, [board.id, board.name, openDuplicateModal]);
return ( return (
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Item onClick={handleSetHomeBoard} leftSection={<IconHome {...iconProps} />}> <Menu.Item onClick={handleSetHomeBoard} leftSection={<IconHome {...iconProps} />}>
{t("setHomeBoard.label")} {t("setHomeBoard.label")}
</Menu.Item> </Menu.Item>
<Menu.Item onClick={handleSetMobileHomeBoard} leftSection={<IconDeviceMobile {...iconProps} />}>
{t("setMobileHomeBoard.label")}
</Menu.Item>
{session?.user.permissions.includes("board-create") && (
<Menu.Item onClick={handleDuplicateBoard} leftSection={<IconCopy {...iconProps} />}>
{t("duplicate.label")}
</Menu.Item>
)}
{hasChangeAccess && ( {hasChangeAccess && (
<> <>
<Menu.Divider /> <Menu.Divider />
@@ -1,11 +1,12 @@
"use client"; "use client";
import { Affix, Button, Menu } from "@mantine/core"; import { Affix, Button, Group, Menu } from "@mantine/core";
import { IconCategoryPlus, IconChevronDown, IconFileImport } from "@tabler/icons-react"; import { IconCategoryPlus, IconChevronDown, IconFileImport } from "@tabler/icons-react";
import { useModalAction } from "@homarr/modals"; import { useModalAction } from "@homarr/modals";
import { AddBoardModal, ImportBoardModal } from "@homarr/modals-collection"; import { AddBoardModal, ImportBoardModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { BetaBadge } from "@homarr/ui";
export const CreateBoardButton = () => { export const CreateBoardButton = () => {
const t = useI18n(); const t = useI18n();
@@ -25,7 +26,10 @@ export const CreateBoardButton = () => {
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Item onClick={openImportModal} leftSection={<IconFileImport size="1rem" />}> <Menu.Item onClick={openImportModal} leftSection={<IconFileImport size="1rem" />}>
{t("board.action.oldImport.label")} <Group>
{t("board.action.oldImport.label")}
<BetaBadge size="xs" />
</Group>
</Menu.Item> </Menu.Item>
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
@@ -15,7 +15,7 @@ import {
Title, Title,
Tooltip, Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { IconDeviceMobile, IconDotsVertical, IconHomeFilled, IconLock, IconWorld } from "@tabler/icons-react"; import { IconDotsVertical, IconHomeFilled, IconLock, IconWorld } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
@@ -67,7 +67,7 @@ const BoardCard = async ({ board }: BoardCardProps) => {
const VisibilityIcon = board.isPublic ? IconWorld : IconLock; const VisibilityIcon = board.isPublic ? IconWorld : IconLock;
return ( return (
<Card radius="lg" withBorder> <Card>
<CardSection p="sm" withBorder> <CardSection p="sm" withBorder>
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<Group gap="sm"> <Group gap="sm">
@@ -88,14 +88,6 @@ const BoardCard = async ({ board }: BoardCardProps) => {
</Tooltip> </Tooltip>
)} )}
{board.isMobileHome && (
<Tooltip label={t("action.setMobileHomeBoard.badge.tooltip")}>
<Badge tt="none" color="yellow" variant="light" leftSection={<IconDeviceMobile size=".7rem" />}>
{t("action.setMobileHomeBoard.badge.label")}
</Badge>
</Tooltip>
)}
{board.creator && ( {board.creator && (
<Group gap="xs"> <Group gap="xs">
<UserAvatar user={board.creator} size="sm" /> <UserAvatar user={board.creator} size="sm" />
@@ -106,25 +98,15 @@ const BoardCard = async ({ board }: BoardCardProps) => {
</Group> </Group>
</CardSection> </CardSection>
<CardSection> <CardSection p="sm">
<Group gap={0} wrap="nowrap"> <Group wrap="nowrap">
<Button <Button component={Link} href={`/boards/${board.name}`} variant="default" fullWidth>
style={{ border: "none", borderRadius: 0 }}
component={Link}
href={`/boards/${board.name}`}
variant="default"
fullWidth
>
{t("action.open.label")} {t("action.open.label")}
</Button> </Button>
{isMenuVisible && ( {isMenuVisible && (
<Menu position="bottom-end"> <Menu position="bottom-end">
<MenuTarget> <MenuTarget>
<ActionIcon <ActionIcon variant="default" size="lg">
style={{ borderTop: "none", borderBottom: "none", borderRight: "none", borderRadius: 0 }}
variant="default"
size="lg"
>
<IconDotsVertical size={16} stroke={1.5} /> <IconDotsVertical size={16} stroke={1.5} />
</ActionIcon> </ActionIcon>
</MenuTarget> </MenuTarget>
@@ -1,14 +1,13 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { ActionIcon, Avatar, Badge, Button, Card, Collapse, Group, Kbd, Stack, Text, Tooltip } from "@mantine/core"; import { ActionIcon, Avatar, Button, Card, Collapse, Group, Kbd, Stack, Text } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { IconEye, IconEyeOff } from "@tabler/icons-react"; import { IconEye, IconEyeOff } from "@tabler/icons-react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime"; import relativeTime from "dayjs/plugin/relativeTime";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import type { IntegrationSecretKind } from "@homarr/definitions";
import { integrationSecretKindObject } from "@homarr/definitions"; import { integrationSecretKindObject } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
@@ -17,9 +16,7 @@ import { integrationSecretIcons } from "./integration-secret-icons";
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
interface SecretCardProps { interface SecretCardProps {
secret: secret: RouterOutputs["integration"]["byId"]["secrets"][number];
| RouterOutputs["integration"]["byId"]["secrets"][number]
| { kind: IntegrationSecretKind; value: null; updatedAt: null };
children: React.ReactNode; children: React.ReactNode;
onCancel: () => Promise<boolean>; onCancel: () => Promise<boolean>;
} }
@@ -33,7 +30,7 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
const KindIcon = integrationSecretIcons[secret.kind]; const KindIcon = integrationSecretIcons[secret.kind];
return ( return (
<Card withBorder> <Card>
<Stack> <Stack>
<Group justify="space-between"> <Group justify="space-between">
<Group> <Group>
@@ -44,19 +41,11 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
{publicSecretDisplayOpened ? <Kbd>{secret.value}</Kbd> : null} {publicSecretDisplayOpened ? <Kbd>{secret.value}</Kbd> : null}
</Group> </Group>
<Group> <Group>
{secret.updatedAt ? ( <Text c="gray.6" size="sm">
<Text c="gray.6" size="sm"> {t("integration.secrets.lastUpdated", {
{t("integration.secrets.lastUpdated", { date: dayjs().to(dayjs(secret.updatedAt)),
date: dayjs().to(dayjs(secret.updatedAt)), })}
})} </Text>
</Text>
) : (
<Tooltip label={t("integration.secrets.notSet.tooltip")} position="left">
<Badge color="orange" variant="light" size="sm">
{t("integration.secrets.notSet.label")}
</Badge>
</Tooltip>
)}
{isPublic ? ( {isPublic ? (
<ActionIcon color="gray" variant="subtle" onClick={togglePublicSecretDisplay}> <ActionIcon color="gray" variant="subtle" onClick={togglePublicSecretDisplay}>
<DisplayIcon size={16} stroke={1.5} /> <DisplayIcon size={16} stroke={1.5} />
@@ -1,4 +1,4 @@
import { IconGrid3x3, IconKey, IconPassword, IconServer, IconUser } from "@tabler/icons-react"; import { IconKey, IconPassword, IconUser } from "@tabler/icons-react";
import type { IntegrationSecretKind } from "@homarr/definitions"; import type { IntegrationSecretKind } from "@homarr/definitions";
import type { TablerIcon } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui";
@@ -7,6 +7,4 @@ export const integrationSecretIcons = {
username: IconUser, username: IconUser,
apiKey: IconKey, apiKey: IconKey,
password: IconPassword, password: IconPassword,
realm: IconServer,
tokenId: IconGrid3x3,
} satisfies Record<IntegrationSecretKind, TablerIcon>; } satisfies Record<IntegrationSecretKind, TablerIcon>;
@@ -3,7 +3,6 @@
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Button, Fieldset, Group, Stack, TextInput } from "@mantine/core"; import { Button, Fieldset, Group, Stack, TextInput } from "@mantine/core";
import type { z } from "zod";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
@@ -14,6 +13,7 @@ import { convertIntegrationTestConnectionError } from "@homarr/integrations/clie
import { useConfirmModal } from "@homarr/modals"; import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation"; import { validation } from "@homarr/validation";
import { SecretCard } from "../../_components/secrets/integration-secret-card"; import { SecretCard } from "../../_components/secrets/integration-secret-card";
@@ -70,9 +70,9 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
if (testConnectionError) { if (testConnectionError) {
showErrorNotification({ showErrorNotification({
title: t(`integration.testConnection.notification.${testConnectionError.key}.title`), title: t(`integration.testConnection.notification.${testConnectionError.key}.title`),
message: message: testConnectionError.message
testConnectionError.message ?? ? testConnectionError.message
t(`integration.testConnection.notification.${testConnectionError.key}.message`), : t(`integration.testConnection.notification.${testConnectionError.key}.message`),
}); });
return; return;
} }
@@ -98,7 +98,8 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
{secretsKinds.map((kind, index) => ( {secretsKinds.map((kind, index) => (
<SecretCard <SecretCard
key={kind} key={kind}
secret={secretsMap.get(kind) ?? { kind, value: null, updatedAt: null }} // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
secret={secretsMap.get(kind)!}
onCancel={() => onCancel={() =>
new Promise((resolve) => { new Promise((resolve) => {
// When nothing changed, just close the secret card // When nothing changed, just close the secret card
@@ -11,11 +11,10 @@ import { IntegrationAccessSettings } from "../../_components/integration-access-
import { EditIntegrationForm } from "./_integration-edit-form"; import { EditIntegrationForm } from "./_integration-edit-form";
interface EditIntegrationPageProps { interface EditIntegrationPageProps {
params: Promise<{ id: string }>; params: { id: string };
} }
export default async function EditIntegrationPage(props: EditIntegrationPageProps) { export default async function EditIntegrationPage({ params }: EditIntegrationPageProps) {
const params = await props.params;
const editT = await getScopedI18n("integration.page.edit"); const editT = await getScopedI18n("integration.page.edit");
const t = await getI18n(); const t = await getI18n();
const integration = await api.integration.byId({ id: params.id }).catch(catchTrpcNotFound); const integration = await api.integration.byId({ id: params.id }).catch(catchTrpcNotFound);
@@ -15,9 +15,7 @@ export const IntegrationCreateDropdownContent = () => {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const filteredKinds = useMemo(() => { const filteredKinds = useMemo(() => {
return integrationKinds.filter((kind) => return integrationKinds.filter((kind) => kind.includes(search.toLowerCase()));
getIntegrationName(kind).toLowerCase().includes(search.toLowerCase().trim()),
);
}, [search]); }, [search]);
const handleSearch = React.useCallback( const handleSearch = React.useCallback(
@@ -31,9 +29,7 @@ export const IntegrationCreateDropdownContent = () => {
leftSection={<IconSearch stroke={1.5} size={20} />} leftSection={<IconSearch stroke={1.5} size={20} />}
placeholder={t("integration.page.list.search")} placeholder={t("integration.page.list.search")}
value={search} value={search}
data-autofocus
onChange={handleSearch} onChange={handleSearch}
variant="filled"
/> />
{filteredKinds.length > 0 ? ( {filteredKinds.length > 0 ? (
@@ -3,19 +3,19 @@
import { useCallback } from "react"; import { useCallback } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Alert, Button, Checkbox, Fieldset, Group, SegmentedControl, Stack, Text, TextInput } from "@mantine/core"; import { Alert, Button, Fieldset, Group, SegmentedControl, Stack, Text, TextInput } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react"; import { IconInfoCircle } from "@tabler/icons-react";
import type { z } from "zod";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions"; import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
import { getAllSecretKindOptions, getIntegrationName, integrationDefs } from "@homarr/definitions"; import { getAllSecretKindOptions, getIntegrationName } from "@homarr/definitions";
import type { UseFormReturnType } from "@homarr/form"; import type { UseFormReturnType } from "@homarr/form";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { convertIntegrationTestConnectionError } from "@homarr/integrations/client"; import { convertIntegrationTestConnectionError } from "@homarr/integrations/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation"; import { validation } from "@homarr/validation";
import { IntegrationSecretInput } from "../_components/secrets/integration-secret-inputs"; import { IntegrationSecretInput } from "../_components/secrets/integration-secret-inputs";
@@ -38,7 +38,6 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
kind, kind,
value: "", value: "",
})), })),
attemptSearchEngineCreation: true,
}, },
}); });
const { mutateAsync, isPending } = clientApi.integration.create.useMutation(); const { mutateAsync, isPending } = clientApi.integration.create.useMutation();
@@ -63,9 +62,9 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
if (testConnectionError) { if (testConnectionError) {
showErrorNotification({ showErrorNotification({
title: t(`integration.testConnection.notification.${testConnectionError.key}.title`), title: t(`integration.testConnection.notification.${testConnectionError.key}.title`),
message: message: testConnectionError.message
testConnectionError.message ?? ? testConnectionError.message
t(`integration.testConnection.notification.${testConnectionError.key}.message`), : t(`integration.testConnection.notification.${testConnectionError.key}.message`),
}); });
return; return;
} }
@@ -79,8 +78,6 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
); );
}; };
const supportsSearchEngine = integrationDefs[searchParams.kind].category.flat().includes("search");
return ( return (
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}> <form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
<Stack> <Stack>
@@ -107,16 +104,6 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
</Stack> </Stack>
</Fieldset> </Fieldset>
{supportsSearchEngine && (
<Checkbox
label={t("integration.field.attemptSearchEngineCreation.label")}
description={t("integration.field.attemptSearchEngineCreation.description", {
kind: getIntegrationName(searchParams.kind),
})}
{...form.getInputProps("attemptSearchEngineCreation", { type: "checkbox" })}
/>
)}
<Group justify="end" align="center"> <Group justify="end" align="center">
<Button variant="default" component={Link} href="/manage/integrations"> <Button variant="default" component={Link} href="/manage/integrations">
{t("common.action.backToOverview")} {t("common.action.backToOverview")}
@@ -1,6 +1,5 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { Container, Group, Stack, Title } from "@mantine/core"; import { Container, Group, Stack, Title } from "@mantine/core";
import { z } from "zod";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import type { IntegrationKind } from "@homarr/definitions"; import type { IntegrationKind } from "@homarr/definitions";
@@ -8,20 +7,18 @@ import { getIntegrationName, integrationKinds } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { IntegrationAvatar } from "@homarr/ui"; import { IntegrationAvatar } from "@homarr/ui";
import type { validation } from "@homarr/validation"; import type { validation } from "@homarr/validation";
import { z } from "@homarr/validation";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NewIntegrationForm } from "./_integration-new-form"; import { NewIntegrationForm } from "./_integration-new-form";
interface NewIntegrationPageProps { interface NewIntegrationPageProps {
searchParams: Promise< searchParams: Partial<z.infer<typeof validation.integration.create>> & {
Partial<z.infer<typeof validation.integration.create>> & { kind: IntegrationKind;
kind: IntegrationKind; };
}
>;
} }
export default async function IntegrationsNewPage(props: NewIntegrationPageProps) { export default async function IntegrationsNewPage({ searchParams }: NewIntegrationPageProps) {
const searchParams = await props.searchParams;
const session = await auth(); const session = await auth();
if (!session?.user.permissions.includes("integration-create")) { if (!session?.user.permissions.includes("integration-create")) {
notFound(); notFound();
@@ -1,36 +0,0 @@
.root {
border-radius: var(--mantine-radius-lg);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
.item {
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
border: 1px solid transparent;
position: relative;
z-index: 0;
transition: transform 150ms ease;
overflow: hidden;
&[data-first="true"] {
border-radius: var(--mantine-radius-lg) var(--mantine-radius-lg) 0 0;
}
&[data-last="true"] {
border-radius: 0 0 var(--mantine-radius-lg) var(--mantine-radius-lg);
}
&[data-active] {
transform: scale(1.01);
z-index: 1;
background-color: var(--mantine-color-body);
border-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
box-shadow: var(--mantine-shadow-md);
border-radius: var(--mantine-radius-lg);
}
}
.chevron {
&[data-rotate] {
transform: rotate(180deg);
}
}
@@ -44,16 +44,14 @@ import { NoResults } from "~/components/no-results";
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion"; import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
import { DeleteIntegrationActionButton } from "./_integration-buttons"; import { DeleteIntegrationActionButton } from "./_integration-buttons";
import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown"; import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown";
import classes from "./page.module.css";
interface IntegrationsPageProps { interface IntegrationsPageProps {
searchParams: Promise<{ searchParams: {
tab?: IntegrationKind; tab?: IntegrationKind;
}>; };
} }
export default async function IntegrationsPage(props: IntegrationsPageProps) { export default async function IntegrationsPage({ searchParams }: IntegrationsPageProps) {
const searchParams = await props.searchParams;
const session = await auth(); const session = await auth();
if (!session) { if (!session) {
@@ -103,15 +101,7 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
const IntegrationSelectMenu = ({ children }: PropsWithChildren) => { const IntegrationSelectMenu = ({ children }: PropsWithChildren) => {
return ( return (
<Menu <Menu width={256} trapFocus position="bottom-end" withinPortal shadow="md" keepMounted={false}>
width={256}
trapFocus
position="bottom-end"
withinPortal
shadow="md"
keepMounted={false}
withInitialFocusPlaceholder={false}
>
{children} {children}
<MenuDropdown> <MenuDropdown>
<IntegrationCreateDropdownContent /> <IntegrationCreateDropdownContent />
@@ -134,7 +124,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
return <NoResults icon={IconPlugX} title={t("page.list.noResults.title")} />; return <NoResults icon={IconPlugX} title={t("page.list.noResults.title")} />;
} }
const groupedIntegrations = integrations.reduce( const grouppedIntegrations = integrations.reduce(
(acc, integration) => { (acc, integration) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!acc[integration.kind]) { if (!acc[integration.kind]) {
@@ -148,13 +138,11 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
{} as Record<IntegrationKind, RouterOutputs["integration"]["all"]>, {} as Record<IntegrationKind, RouterOutputs["integration"]["all"]>,
); );
const entries = objectEntries(groupedIntegrations);
return ( return (
<ActiveTabAccordion defaultValue={activeTab} radius="lg" classNames={classes}> <ActiveTabAccordion defaultValue={activeTab} variant="separated">
{entries.map(([kind, integrations], index) => ( {objectEntries(grouppedIntegrations).map(([kind, integrations]) => (
<AccordionItem key={kind} value={kind} data-first={index === 0} data-last={index === entries.length - 1}> <AccordionItem key={kind} value={kind}>
<AccordionControl icon={<IntegrationAvatar size="sm" kind={kind} radius="sm" />}> <AccordionControl icon={<IntegrationAvatar size="sm" kind={kind} />}>
<Group> <Group>
<Text>{getIntegrationName(kind)}</Text> <Text>{getIntegrationName(kind)}</Text>
<CountBadge count={integrations.length} /> <CountBadge count={integrations.length} />
+25 -39
View File
@@ -1,26 +1,25 @@
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import { AppShellMain } from "@mantine/core"; import { AppShellMain } from "@mantine/core";
import { import {
IconAffiliateFilled,
IconBook2, IconBook2,
IconBox, IconBox,
IconBrandDiscord, IconBrandDiscord,
IconBrandDocker, IconBrandDocker,
IconBrandGithub, IconBrandGithub,
IconBrandTablerFilled,
IconCertificate,
IconClipboardListFilled,
IconDirectionsFilled,
IconGitFork, IconGitFork,
IconHelpSquareRoundedFilled, IconHome,
IconHomeFilled, IconInfoSmall,
IconLayoutDashboardFilled, IconLayoutDashboard,
IconLogs,
IconMailForward, IconMailForward,
IconPhotoFilled, IconPhoto,
IconPointerFilled, IconPlug,
IconQuestionMark,
IconReport,
IconSearch, IconSearch,
IconSettingsFilled, IconSettings,
IconUserFilled, IconTool,
IconUser,
IconUsers, IconUsers,
IconUsersGroup, IconUsersGroup,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
@@ -31,7 +30,6 @@ import { createDocumentationLink } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { MainHeader } from "~/components/layout/header"; import { MainHeader } from "~/components/layout/header";
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
import type { NavigationLink } from "~/components/layout/navigation"; import type { NavigationLink } from "~/components/layout/navigation";
import { MainNavigation } from "~/components/layout/navigation"; import { MainNavigation } from "~/components/layout/navigation";
import { ClientShell } from "~/components/layout/shell"; import { ClientShell } from "~/components/layout/shell";
@@ -42,11 +40,11 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
const navigationLinks: NavigationLink[] = [ const navigationLinks: NavigationLink[] = [
{ {
label: t("items.home"), label: t("items.home"),
icon: IconHomeFilled, icon: IconHome,
href: "/manage", href: "/manage",
}, },
{ {
icon: IconLayoutDashboardFilled, icon: IconLayoutDashboard,
href: "/manage/boards", href: "/manage/boards",
label: t("items.boards"), label: t("items.boards"),
}, },
@@ -55,12 +53,9 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
href: "/manage/apps", href: "/manage/apps",
label: t("items.apps"), label: t("items.apps"),
hidden: !session, hidden: !session,
iconProps: {
strokeWidth: 2.5,
},
}, },
{ {
icon: IconAffiliateFilled, icon: IconPlug,
href: "/manage/integrations", href: "/manage/integrations",
label: t("items.integrations"), label: t("items.integrations"),
hidden: !session, hidden: !session,
@@ -70,18 +65,15 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
href: "/manage/search-engines", href: "/manage/search-engines",
label: t("items.searchEngies"), label: t("items.searchEngies"),
hidden: !session, hidden: !session,
iconProps: {
strokeWidth: 2.5,
},
}, },
{ {
icon: IconPhotoFilled, icon: IconPhoto,
href: "/manage/medias", href: "/manage/medias",
label: t("items.medias"), label: t("items.medias"),
hidden: !session, hidden: !session,
}, },
{ {
icon: IconUserFilled, icon: IconUser,
label: t("items.users.label"), label: t("items.users.label"),
hidden: !session?.user.permissions.includes("admin"), hidden: !session?.user.permissions.includes("admin"),
items: [ items: [
@@ -105,7 +97,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
}, },
{ {
label: t("items.tools.label"), label: t("items.tools.label"),
icon: IconPointerFilled, icon: IconTool,
// As permissions always include there children permissions, we can check other-view-logs as admin includes it // As permissions always include there children permissions, we can check other-view-logs as admin includes it
hidden: !session?.user.permissions.includes("other-view-logs"), hidden: !session?.user.permissions.includes("other-view-logs"),
items: [ items: [
@@ -117,25 +109,19 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
}, },
{ {
label: t("items.tools.items.api"), label: t("items.tools.items.api"),
icon: IconDirectionsFilled, icon: IconPlug,
href: "/manage/tools/api", href: "/manage/tools/api",
hidden: !session?.user.permissions.includes("admin"), hidden: !session?.user.permissions.includes("admin"),
}, },
{ {
label: t("items.tools.items.logs"), label: t("items.tools.items.logs"),
icon: IconBrandTablerFilled, icon: IconLogs,
href: "/manage/tools/logs", href: "/manage/tools/logs",
hidden: !session?.user.permissions.includes("other-view-logs"), hidden: !session?.user.permissions.includes("other-view-logs"),
}, },
{
label: t("items.tools.items.certificates"),
icon: IconCertificate,
href: "/manage/tools/certificates",
hidden: !session?.user.permissions.includes("admin"),
},
{ {
label: t("items.tools.items.tasks"), label: t("items.tools.items.tasks"),
icon: IconClipboardListFilled, icon: IconReport,
href: "/manage/tools/tasks", href: "/manage/tools/tasks",
hidden: !session?.user.permissions.includes("admin"), hidden: !session?.user.permissions.includes("admin"),
}, },
@@ -144,12 +130,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
{ {
label: t("items.settings"), label: t("items.settings"),
href: "/manage/settings", href: "/manage/settings",
icon: IconSettingsFilled, icon: IconSettings,
hidden: !session?.user.permissions.includes("admin"), hidden: !session?.user.permissions.includes("admin"),
}, },
{ {
label: t("items.help.label"), label: t("items.help.label"),
icon: IconHelpSquareRoundedFilled, icon: IconQuestionMark,
items: [ items: [
{ {
label: t("items.help.items.documentation"), label: t("items.help.items.documentation"),
@@ -160,7 +146,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
{ {
label: t("items.help.items.submitIssue"), label: t("items.help.items.submitIssue"),
icon: IconBrandGithub, icon: IconBrandGithub,
href: "https://github.com/homarr-labs/homarr/issues/new/choose", href: "https://github.com/ajnart/homarr/issues/new/choose",
external: true, external: true,
}, },
{ {
@@ -172,14 +158,14 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
{ {
label: t("items.help.items.sourceCode"), label: t("items.help.items.sourceCode"),
icon: IconGitFork, icon: IconGitFork,
href: "https://github.com/homarr-labs/homarr", href: "https://github.com/ajnart/homarr",
external: true, external: true,
}, },
], ],
}, },
{ {
label: t("items.about"), label: t("items.about"),
icon: homarrLogoPath, icon: IconInfoSmall,
href: "/manage/about", href: "/manage/about",
}, },
]; ];
@@ -13,12 +13,10 @@ interface CopyMediaProps {
export const CopyMedia = ({ media }: CopyMediaProps) => { export const CopyMedia = ({ media }: CopyMediaProps) => {
const t = useI18n(); const t = useI18n();
const url = typeof window !== "undefined" ? `${window.location.origin}/api/user-medias/${media.id}` : ""; const url =
typeof window !== "undefined"
// Clipboard only works on localhost or secure connections (https) ? `${window.location.protocol}://${window.location.hostname}:${window.location.port}/api/user-medias/${media.id}`
if (url.startsWith("http://") && !url.startsWith("http://localhost")) { : "";
return null;
}
return ( return (
<CopyButton value={url}> <CopyButton value={url}>
@@ -1,25 +1,46 @@
"use client"; "use client";
import { Button } from "@mantine/core"; import { Button, FileButton } from "@mantine/core";
import { IconUpload } from "@tabler/icons-react"; import { IconUpload } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
import { UploadMedia } from "@homarr/forms-collection"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { supportedMediaUploadFormats } from "@homarr/validation";
export const UploadMediaButton = () => { export const UploadMedia = () => {
const t = useI18n(); const t = useI18n();
const onSettledAsync = async () => { const { mutateAsync, isPending } = clientApi.media.uploadMedia.useMutation();
await revalidatePathActionAsync("/manage/medias");
const handleFileUploadAsync = async (file: File | null) => {
if (!file) return;
const formData = new FormData();
formData.append("file", file);
await mutateAsync(formData, {
onSuccess() {
showSuccessNotification({
message: t("media.action.upload.notification.success.message"),
});
},
onError() {
showErrorNotification({
message: t("media.action.upload.notification.error.message"),
});
},
async onSettled() {
await revalidatePathActionAsync("/manage/medias");
},
});
}; };
return ( return (
<UploadMedia onSettled={onSettledAsync}> <FileButton onChange={handleFileUploadAsync} accept={supportedMediaUploadFormats.join(",")}>
{({ onClick, loading }) => ( {({ onClick }) => (
<Button onClick={onClick} loading={loading} rightSection={<IconUpload size={16} stroke={1.5} />}> <Button onClick={onClick} loading={isPending} rightSection={<IconUpload size={16} stroke={1.5} />}>
{t("media.action.upload.label")} {t("media.action.upload.label")}
</Button> </Button>
)} )}
</UploadMedia> </FileButton>
); );
}; };
@@ -1,38 +1,22 @@
import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { import { Anchor, Group, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Title } from "@mantine/core";
ActionIcon,
Anchor,
Group,
Image,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Title,
Tooltip,
} from "@mantine/core";
import { IconExternalLink } from "@tabler/icons-react";
import { z } from "zod";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { humanFileSize } from "@homarr/common"; import { humanFileSize } from "@homarr/common";
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { createLocalImageUrl } from "@homarr/icons/local";
import { getI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { SearchInput, TablePagination, UserAvatar } from "@homarr/ui"; import { SearchInput, TablePagination, UserAvatar } from "@homarr/ui";
import { z } from "@homarr/validation";
import { ManageContainer } from "~/components/manage/manage-container"; import { ManageContainer } from "~/components/manage/manage-container";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { CopyMedia } from "./_actions/copy-media"; import { CopyMedia } from "./_actions/copy-media";
import { DeleteMedia } from "./_actions/delete-media"; import { DeleteMedia } from "./_actions/delete-media";
import { IncludeFromAllUsersSwitch } from "./_actions/show-all"; import { IncludeFromAllUsersSwitch } from "./_actions/show-all";
import { UploadMediaButton } from "./_actions/upload-media"; import { UploadMedia } from "./_actions/upload-media";
const searchParamsSchema = z.object({ const searchParamsSchema = z.object({
search: z.string().optional(), search: z.string().optional(),
@@ -45,8 +29,12 @@ const searchParamsSchema = z.object({
page: z.string().regex(/\d+/).transform(Number).catch(1), page: z.string().regex(/\d+/).transform(Number).catch(1),
}); });
type SearchParamsSchemaInputFromSchema<TSchema extends Record<string, unknown>> = Partial<{
[K in keyof TSchema]: Exclude<TSchema[K], undefined> extends unknown[] ? string[] : string;
}>;
interface MediaListPageProps { interface MediaListPageProps {
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>; searchParams: SearchParamsSchemaInputFromSchema<z.infer<typeof searchParamsSchema>>;
} }
export default async function GroupsListPage(props: MediaListPageProps) { export default async function GroupsListPage(props: MediaListPageProps) {
@@ -57,11 +45,11 @@ export default async function GroupsListPage(props: MediaListPageProps) {
} }
const t = await getI18n(); const t = await getI18n();
const searchParams = searchParamsSchema.parse(await props.searchParams); const searchParams = searchParamsSchema.parse(props.searchParams);
const { items: medias, totalCount } = await api.media.getPaginated(searchParams); const { items: medias, totalCount } = await api.media.getPaginated(searchParams);
return ( return (
<ManageContainer> <ManageContainer size="xl">
<DynamicBreadcrumb /> <DynamicBreadcrumb />
<Stack> <Stack>
<Title>{t("media.plural")}</Title> <Title>{t("media.plural")}</Title>
@@ -73,7 +61,7 @@ export default async function GroupsListPage(props: MediaListPageProps) {
)} )}
</Group> </Group>
{session.user.permissions.includes("media-upload") && <UploadMediaButton />} {session.user.permissions.includes("media-upload") && <UploadMedia />}
</Group> </Group>
<Table striped highlightOnHover> <Table striped highlightOnHover>
<TableThead> <TableThead>
@@ -106,19 +94,17 @@ interface RowProps {
const Row = async ({ media }: RowProps) => { const Row = async ({ media }: RowProps) => {
const session = await auth(); const session = await auth();
const t = await getI18n();
const canDelete = media.creatorId === session?.user.id || session?.user.permissions.includes("media-full-all"); const canDelete = media.creatorId === session?.user.id || session?.user.permissions.includes("media-full-all");
return ( return (
<TableTr> <TableTr>
<TableTd w={64}> <TableTd w={64}>
<Image <Image
// Switched to mantine image because next/image doesn't support svgs src={`/api/user-medias/${media.id}`}
src={createLocalImageUrl(media.id)}
alt={media.name} alt={media.name}
w={64} width={64}
h={64} height={64}
fit="contain" style={{ objectFit: "contain" }}
/> />
</TableTd> </TableTd>
<TableTd>{media.name}</TableTd> <TableTd>{media.name}</TableTd>
@@ -138,17 +124,6 @@ const Row = async ({ media }: RowProps) => {
<TableTd w={64}> <TableTd w={64}>
<Group wrap="nowrap" gap="xs"> <Group wrap="nowrap" gap="xs">
<CopyMedia media={media} /> <CopyMedia media={media} />
<Tooltip label={t("media.action.open.label")} openDelay={500}>
<ActionIcon
component="a"
href={createLocalImageUrl(media.id)}
target="_blank"
color="gray"
variant="subtle"
>
<IconExternalLink size={16} stroke={1.5} />
</ActionIcon>
</Tooltip>
{canDelete && <DeleteMedia media={media} />} {canDelete && <DeleteMedia media={media} />}
</Group> </Group>
</TableTd> </TableTd>
+1 -1
View File
@@ -83,7 +83,7 @@ export default async function ManagementPage() {
{links.map( {links.map(
(link) => (link) =>
!link.hidden && ( !link.hidden && (
<Card component={Link} href={link.href} key={link.href} radius="lg"> <Card component={Link} href={link.href} key={link.href} withBorder>
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
<Group wrap="nowrap"> <Group wrap="nowrap">
<Text size="2.4rem" fw="bolder"> <Text size="2.4rem" fw="bolder">
@@ -4,16 +4,17 @@ import Link from "next/link";
import type { SegmentedControlItem } from "@mantine/core"; import type { SegmentedControlItem } from "@mantine/core";
import { Button, Fieldset, Grid, Group, SegmentedControl, Stack, Textarea, TextInput } from "@mantine/core"; import { Button, Fieldset, Grid, Group, SegmentedControl, Stack, Textarea, TextInput } from "@mantine/core";
import { WidgetIntegrationSelect } from "node_modules/@homarr/widgets/src/widget-integration-select"; import { WidgetIntegrationSelect } from "node_modules/@homarr/widgets/src/widget-integration-select";
import type { z } from "zod";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { searchEngineTypes } from "@homarr/definitions"; import { searchEngineTypes } from "@homarr/definitions";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { IconPicker } from "@homarr/forms-collection";
import type { TranslationFunction } from "@homarr/translation"; import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation"; import { validation } from "@homarr/validation";
import { IconPicker } from "~/components/icons/picker/icon-picker";
type FormType = z.infer<typeof validation.searchEngine.manage>; type FormType = z.infer<typeof validation.searchEngine.manage>;
interface SearchEngineFormProps { interface SearchEngineFormProps {
@@ -57,7 +58,7 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
/> />
</Grid.Col> </Grid.Col>
</Grid> </Grid>
<IconPicker {...form.getInputProps("iconUrl")} /> <IconPicker initialValue={initialValues?.iconUrl} {...form.getInputProps("iconUrl")} />
<Fieldset legend={t("search.engine.page.edit.configControl")}> <Fieldset legend={t("search.engine.page.edit.configControl")}>
<SegmentedControl <SegmentedControl
@@ -2,7 +2,6 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { z } from "zod";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
@@ -10,7 +9,7 @@ import { revalidatePathActionAsync } from "@homarr/common/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import type { TranslationFunction } from "@homarr/translation"; import type { TranslationFunction } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import type { validation } from "@homarr/validation"; import type { validation, z } from "@homarr/validation";
import { SearchEngineForm } from "../../_form"; import { SearchEngineForm } from "../../_form";
@@ -10,11 +10,10 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { SearchEngineEditForm } from "./_search-engine-edit-form"; import { SearchEngineEditForm } from "./_search-engine-edit-form";
interface SearchEngineEditPageProps { interface SearchEngineEditPageProps {
params: Promise<{ id: string }>; params: { id: string };
} }
export default async function SearchEngineEditPage(props: SearchEngineEditPageProps) { export default async function SearchEngineEditPage({ params }: SearchEngineEditPageProps) {
const params = await props.params;
const session = await auth(); const session = await auth();
if (!session?.user.permissions.includes("search-engine-modify-all")) { if (!session?.user.permissions.includes("search-engine-modify-all")) {
@@ -2,14 +2,13 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { z } from "zod";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import type { TranslationFunction } from "@homarr/translation"; import type { TranslationFunction } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import type { validation } from "@homarr/validation"; import type { validation, z } from "@homarr/validation";
import { SearchEngineForm } from "../_form"; import { SearchEngineForm } from "../_form";
@@ -2,14 +2,13 @@ import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core"; import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
import { IconPencil, IconSearch } from "@tabler/icons-react"; import { IconPencil, IconSearch } from "@tabler/icons-react";
import { z } from "zod";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { getI18n, getScopedI18n } from "@homarr/translation/server"; import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { SearchInput, TablePagination } from "@homarr/ui"; import { SearchInput, TablePagination } from "@homarr/ui";
import { z } from "@homarr/validation";
import { ManageContainer } from "~/components/manage/manage-container"; import { ManageContainer } from "~/components/manage/manage-container";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
@@ -23,8 +22,12 @@ const searchParamsSchema = z.object({
page: z.string().regex(/\d+/).transform(Number).catch(1), page: z.string().regex(/\d+/).transform(Number).catch(1),
}); });
type SearchParamsSchemaInputFromSchema<TSchema extends Record<string, unknown>> = Partial<{
[K in keyof TSchema]: Exclude<TSchema[K], undefined> extends unknown[] ? string[] : string;
}>;
interface SearchEnginesPageProps { interface SearchEnginesPageProps {
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>; searchParams: SearchParamsSchemaInputFromSchema<z.infer<typeof searchParamsSchema>>;
} }
export default async function SearchEnginesPage(props: SearchEnginesPageProps) { export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
@@ -34,7 +37,7 @@ export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
redirect("/auth/login"); redirect("/auth/login");
} }
const searchParams = searchParamsSchema.parse(await props.searchParams); const searchParams = searchParamsSchema.parse(props.searchParams);
const { items: searchEngines, totalCount } = await api.searchEngine.getPaginated(searchParams); const { items: searchEngines, totalCount } = await api.searchEngine.getPaginated(searchParams);
const tEngine = await getScopedI18n("search.engine"); const tEngine = await getScopedI18n("search.engine");
@@ -45,7 +48,7 @@ export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
<Stack> <Stack>
<Title>{tEngine("page.list.title")}</Title> <Title>{tEngine("page.list.title")}</Title>
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<SearchInput placeholder={`${tEngine("search")}...`} defaultValue={searchParams.search} flexExpand /> <SearchInput placeholder={`${tEngine("search")}...`} defaultValue={searchParams.search} />
{session.user.permissions.includes("search-engine-create") && ( {session.user.permissions.includes("search-engine-create") && (
<MobileAffixButton component={Link} href="/manage/search-engines/new"> <MobileAffixButton component={Link} href="/manage/search-engines/new">
{tEngine("page.create.title")} {tEngine("page.create.title")}
@@ -78,7 +81,7 @@ const SearchEngineCard = async ({ searchEngine }: SearchEngineCardProps) => {
const session = await auth(); const session = await auth();
return ( return (
<Card withBorder> <Card>
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
<Group align="top" justify="start" wrap="nowrap" style={{ flex: 1 }}> <Group align="top" justify="start" wrap="nowrap" style={{ flex: 1 }}>
<Avatar <Avatar
@@ -1,12 +1,13 @@
"use client"; "use client";
import { Switch, Text } from "@mantine/core"; import { Group, Text } from "@mantine/core";
import { IconLayoutDashboard } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import type { ServerSettings } from "@homarr/server-settings"; import type { ServerSettings } from "@homarr/server-settings";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { SelectWithCustomItems } from "@homarr/ui";
import { BoardSelect } from "~/components/board/board-select";
import { CommonSettingsForm } from "./common-form"; import { CommonSettingsForm } from "./common-form";
export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["board"] }) => { export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["board"] }) => {
@@ -17,33 +18,25 @@ export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSett
<CommonSettingsForm settingKey="board" defaultValues={defaultValues}> <CommonSettingsForm settingKey="board" defaultValues={defaultValues}>
{(form) => ( {(form) => (
<> <>
<BoardSelect <SelectWithCustomItems
label={tBoard("homeBoard.label")} label={tBoard("homeBoard.label")}
description={tBoard("homeBoard.description")} description={tBoard("homeBoard.description")}
clearable data={selectableBoards.map((board) => ({
boards={selectableBoards} value: board.id,
label: board.name,
image: board.logoImageUrl,
}))}
SelectOption={({ label, image }: { value: string; label: string; image: string | null }) => (
<Group>
{/* eslint-disable-next-line @next/next/no-img-element */}
{image ? <img width={16} height={16} src={image} alt={label} /> : <IconLayoutDashboard size={16} />}
<Text fz="sm" fw={500}>
{label}
</Text>
</Group>
)}
{...form.getInputProps("homeBoardId")} {...form.getInputProps("homeBoardId")}
/> />
<BoardSelect
label={tBoard("homeBoard.mobileLabel")}
description={tBoard("homeBoard.description")}
clearable
boards={selectableBoards}
{...form.getInputProps("mobileHomeBoardId")}
/>
<Text fw={500}>{tBoard("status.title")}</Text>
<Switch
{...form.getInputProps("enableStatusByDefault", { type: "checkbox" })}
label={tBoard("status.enableStatusByDefault.label")}
description={tBoard("status.enableStatusByDefault.description")}
/>
<Switch
{...form.getInputProps("forceDisableStatus", { type: "checkbox" })}
label={tBoard("status.forceDisableStatus.label")}
description={tBoard("status.forceDisableStatus.description")}
/>
</> </>
)} )}
</CommonSettingsForm> </CommonSettingsForm>
@@ -1,29 +0,0 @@
"use client";
import { Select } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { ServerSettings } from "@homarr/server-settings";
import { useScopedI18n } from "@homarr/translation/client";
import { CommonSettingsForm } from "./common-form";
export const SearchSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["search"] }) => {
const tSearch = useScopedI18n("management.page.settings.section.search");
const [selectableSearchEngines] = clientApi.searchEngine.getSelectable.useSuspenseQuery({ withIntegrations: false });
return (
<CommonSettingsForm settingKey="search" defaultValues={defaultValues}>
{(form) => (
<>
<Select
label={tSearch("defaultSearchEngine.label")}
description={tSearch("defaultSearchEngine.description")}
data={selectableSearchEngines}
{...form.getInputProps("defaultSearchEngineId")}
/>
</>
)}
</CommonSettingsForm>
);
};
Loaded 100 of 760 files, more files were not shown because too many files have changed in this diff. Show more