Compare commits

..
Author SHA1 Message Date
Meier Lukas 27b04e620e wip: experiment with dropping to a div and removing item 2024-07-07 12:37:24 +02:00
799 changed files with 10626 additions and 166447 deletions

No files matched your search

+1 -2
View File
@@ -5,5 +5,4 @@ npm-debug.log
README.md README.md
.next .next
.git .git
dev dev
.build
+2
View File
@@ -20,6 +20,8 @@ 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'
# @see https://next-auth.js.org/configuration/options#nextauth_url
AUTH_URL='http://localhost:3000'
# You can generate the secret via 'openssl rand -base64 32' on Unix # You can generate the secret via 'openssl rand -base64 32' on Unix
# @see https://next-auth.js.org/configuration/options#secret # @see https://next-auth.js.org/configuration/options#secret
-1
View File
@@ -1 +0,0 @@
* text=auto eol=lf
+29 -17
View File
@@ -1,19 +1,31 @@
{ {
$schema: "https://docs.renovatebot.com/renovate-schema.json", "$schema": "https://docs.renovatebot.com/renovate-schema.json",
extends: ["config:recommended"], "extends": [
packageRules: [ "config:recommended"
{
matchPackagePatterns: ["^@homarr/"],
enabled: false,
},
{
matchUpdateTypes: ["minor", "patch", "pin", "digest"],
automerge: true,
},
], ],
updateInternalDeps: true, "packageRules": [
rangeStrategy: "bump", {
automerge: false, "matchPackagePatterns": [
baseBranches: ["dev"], "^@homarr/",
dependencyDashboard: false, "tsx" // Disabled for now as version 0.14.4 did not work with the current version of homarr. It resulted in a ERR_MODULE_NOT_FOUND error
} ],
"enabled": false
},
{
"matchUpdateTypes": [
"minor",
"patch",
"pin",
"digest"
],
"automerge": true
}
],
"updateInternalDeps": true,
"rangeStrategy": "bump",
"automerge": false,
"baseBranches": [
"dev"
],
"dependencyDashboard": false
}
+3 -25
View File
@@ -67,33 +67,11 @@ jobs:
- name: Test - name: Test
run: pnpm test run: pnpm test
- name: "Report Coverage" - name: 'Report Coverage'
# Set if: always() to also generate the report if tests are failing # Set if: always() to also generate the report if tests are failing
# Only works if you set `reportOnFailure: true` in your vite config as specified above # Only works if you set `reportOnFailure: true` in your vite config as specified above
if: always() if: always()
uses: davelosert/vitest-coverage-report-action@v2 uses: davelosert/vitest-coverage-report-action@v2
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup
uses: ./tooling/github/setup
- name: Build docker image
id: build-docker-image
uses: docker/build-push-action@v6
with:
platforms: linux/amd64
context: .
push: false
tags: homarr-e2e
network: host
env:
SKIP_ENV_VALIDATION: true
- name: Run E2E Tests
shell: bash
run: pnpm test:e2e
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -1,40 +0,0 @@
name: "[Crowdin] Download translations"
on:
workflow_dispatch:
schedule:
- cron: "0 0 * * *" # every day at midnight
jobs:
download-crowdin-translations:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Obtain token
id: obtainToken
uses: tibdex/github-app-token@v2
with:
private_key: ${{ secrets.CROWDIN_APP_PRIVATE_KEY }}
app_id: ${{ secrets.CROWDIN_APP_ID }}
- name: Download Crowdin translations
uses: crowdin/github-action@v2
with:
upload_sources: false
upload_translations: false
download_translations: true
localization_branch_name: crowdin
create_pull_request: true
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_base_branch_name: "dev"
github_user_name: "Crowdin Homarr"
github_user_email: "190541745+homarr-crowdin[bot]@users.noreply.github.com"
skip_untranslated_strings: true
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
-28
View File
@@ -1,28 +0,0 @@
name: "[Crowdin] Upload translations"
on:
workflow_dispatch:
push:
paths:
- "packages/translation/src/lang/**"
branches: [dev]
jobs:
upload-crowdin-translations:
# Don't run this action if the downloaded translations are being pushed
if: "!contains(github.event.head_commit.message, 'chore(lang)')"
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Upload Crowdin translations
uses: crowdin/github-action@v2
with:
upload_sources: true
upload_translations: true
download_translations: false
env:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
+33 -57
View File
@@ -1,30 +1,18 @@
name: "[Deployment] Release" name: "[Deployment] Release"
on: on:
push: pull_request:
types:
- closed
branches: branches:
- main - main
workflow_dispatch: workflow_dispatch: {}
inputs:
send-notifications:
type: boolean
required: false
default: true
description: Send notifications
push-image:
type: boolean
required: false
default: true
description: Push Docker Image
permissions: permissions:
contents: write contents: write
packages: write packages: write
env: env:
SKIP_ENV_VALIDATION: true
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
TURBO_TELEMETRY_DISABLED: 1 TURBO_TELEMETRY_DISABLED: 1
concurrency: production concurrency: production
@@ -33,34 +21,47 @@ jobs:
deploy: deploy:
name: Deploy docker image name: Deploy docker image
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20]
steps: steps:
- name: Discord notification - name: Discord notification
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 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 - uses: actions/checkout@v4
- name: Get Next Version - name: Get Next Version
id: semver id: semver
uses: ietf-tools/semver-action@v1 uses: ietf-tools/semver-action@v1
with: with:
token: ${{ github.token }} token: ${{ github.token }}
branch: dev branch: master
- name: Discord notification - name: Discord notification
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: "Semver computed next tag to be ${{ steps.semver.outputs.next }}. Current is ${{ steps.semver.outputs.current }}. Building images..." args: "Semver computed next tag to be ${{ steps.semver.outputs.next }}. Current is ${{ steps.semver.outputs.current }}"
- name: Log in to the Container registry - uses: pnpm/action-setup@v2
uses: docker/login-action@v3
with: with:
registry: ${{ env.REGISTRY }} version: 8
username: ${{ github.actor }} - name: Use Node.js ${{ matrix.node-version }}
password: ${{ secrets.GITHUB_TOKEN }} uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "pnpm"
- name: Install dependencies
run: pnpm install
- name: Build artifacts
run: pnpm build
- name: Discord notification
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
with:
args: "Built application artifacts. Building images..."
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
@@ -71,48 +72,23 @@ jobs:
with: with:
images: "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" images: "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
tags: | tags: |
type=raw,value=alpha type=raw,value=latest
type=raw,value=early-adopters type=raw,value=${{ steps.semver.outputs.next }}
# 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,linux/riscv64,linux/arm/v7,linux/arm/v6
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
network: host
env:
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: . context: .
push: false push: false
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
network: host 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. Image ID is '${{ steps.buildPushAction.outputs.imageid }}'." args: "Deployment of image has completed. Image ID is '${{ steps.buildPushAction.outputs.imageid }}'. This was a dry run."
- 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."
+14 -33
View File
@@ -4,12 +4,6 @@ on:
schedule: schedule:
- cron: "0 19 * * 5" # https://crontab.guru/#0_19_*_*_5 - cron: "0 19 * * 5" # https://crontab.guru/#0_19_*_*_5
workflow_dispatch: workflow_dispatch:
inputs:
send-notifications:
type: boolean
required: false
default: true
description: Send notifications
permissions: permissions:
contents: write contents: write
@@ -20,7 +14,6 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Discord notification - name: Discord notification
if: ${{ github.events.inputs.send-notifications }}
env: env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master uses: Ilshidur/action-discord@master
@@ -37,39 +30,27 @@ 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" uses: devops-infra/action-pull-request@v0.5.5
env: id: create-pull-request
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" with:
github_token: ${{ secrets.GITHUB_TOKEN }}
title: "chore(release): automatic release ${{ steps.semver.outputs.next }}"
new_string: "**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 }}``"
source_branch: dev
target_branch: main
label: automerge
- name: Discord notification - name: Discord notification
if: ${{ github.events.inputs.send-notifications }}
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: "Created a release PR ${{ steps.create-pull-request.outputs.url }} for version ${{ steps.semver.outputs.next }} (new behaviour: ${{ steps.semver.outputs.bump }})" args: "Created a release PR ${{ steps.create-pull-request.outputs.url }} for version ${{ steps.semver.outputs.next }} (new behaviour: ${{ steps.semver.outputs.bump }})"
- name: Obtain token
id: obtainApprovalToken
uses: tibdex/github-app-token@v2
with:
private_key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
app_id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
- name: Approve PR
env:
GITHUB_TOKEN: ${{ steps.obtainApprovalToken.outputs.token }}
run: |
gh pr review --approve --body "Automatically approved by GitHub Action"
- name: Obtain token
id: obtainMergeToken
uses: tibdex/github-app-token@v2
with:
private_key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
app_id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
- id: automerge - id: automerge
if: ${{ steps.semver.outputs.bump != 'major' }} if: ${{ steps.semver.outputs.bump != 'major' }}
name: automerge name: automerge
uses: "pascalgn/automerge-action@v0.16.4" uses: "pascalgn/automerge-action@v0.16.3"
env: env:
GITHUB_TOKEN: ${{ steps.obtainMergeToken.outputs.token }} GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
MERGE_METHOD: merge # we prefer merge commits for merging to master MERGE_METHOD: merge # we prefer merge commits for merging to master
MERGE_COMMIT_MESSAGE: "chore(release): automatic release ${{ steps.semver.outputs.next }}" MERGE_COMMIT_MESSAGE: "chore(release): automatic release ${{ steps.semver.outputs.next }}"
MERGE_DELETE_BRANCH: false # never set to true! MERGE_DELETE_BRANCH: false # never set to true!
@@ -79,21 +60,21 @@ jobs:
MERGE_REQUIRED_APPROVALS: 0 # do not require approvals MERGE_REQUIRED_APPROVALS: 0 # do not require approvals
- name: Merged Discord notification - name: Merged Discord notification
if: ${{ steps.automerge.outputs.mergeResult == 'merged' && github.events.inputs.send-notifications }} if: ${{ steps.automerge.outputs.mergeResult == 'merged' }}
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: "Merged PR ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}" args: "Merged PR ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}"
- name: Major Bump Discord notification - name: Major Bump Discord notification
if: ${{ steps.semver.outputs.bump == 'major' && github.events.inputs.send-notifications }} if: ${{ steps.semver.outputs.bump == 'major' }}
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: "The release PR must be manually merged because the next version is a major version: ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}" args: "The release PR must be manually merged because the next version is a major version: ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}"
- name: Discord Fail Notification - name: Discord Fail Notification
if: failure() && github.events.inputs.send-notifications if: failure()
env: env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master uses: Ilshidur/action-discord@master
@@ -6,7 +6,6 @@ on:
jobs: jobs:
approve-renovate-prs: approve-renovate-prs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
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
@@ -22,4 +21,6 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }} GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
run: | run: |
gh pr review ${{github.event.pull_request.number}} --approve --body "Automatically approved by GitHub Action" for pr in $(gh pr list --author homarr-renovate[bot] --json number --jq .[].number); do
gh pr review $pr --approve --body "Automatically approved by GitHub Action"
done
+2 -8
View File
@@ -48,16 +48,10 @@ yarn-error.log*
.turbo .turbo
# database # database
*.sqlite db.sqlite
# logs # logs
*.log *.log
apps/tasks/tasks.cjs apps/tasks/tasks.cjs
apps/websocket/wssServer.cjs apps/websocket/wssServer.cjs
apps/nextjs/.million/
packages/cli/cli.cjs
#personal backgrounds
apps/nextjs/public/images/background.png
+1 -1
View File
@@ -1 +1 @@
22.12.0 20.15.0
+1 -3
View File
@@ -2,8 +2,6 @@
"recommendations": [ "recommendations": [
"dbaeumer.vscode-eslint", "dbaeumer.vscode-eslint",
"esbenp.prettier-vscode", "esbenp.prettier-vscode",
"yoavbls.pretty-ts-errors", "yoavbls.pretty-ts-errors"
"million.million-lint",
"lokalise.i18n-ally"
] ]
} }
-36
View File
@@ -1,36 +0,0 @@
# .vscode/i18n-ally-custom-framework.yml
# An array of strings which contain Language Ids defined by VS Code
# You can check available language ids here: https://code.visualstudio.com/docs/languages/identifiers
languageIds:
- javascript
- typescript
- javascriptreact
- typescriptreact
# An array of RegExes to find the key usage. **The key should be captured in the first match group**.
# You should unescape RegEx strings in order to fit in the YAML file
# To help with this, you can use https://www.freeformatter.com/json-escape.html
usageMatchRegex:
# The following example shows how to detect `t("your.i18n.keys")`
# the `{key}` will be placed by a proper keypath matching regex,
# you can ignore it and use your own matching rules as well
- "[^\\w\\d]t\\(['\"`]({key})['\"`]"
# A RegEx to set a custom scope range. This scope will be used as a prefix when detecting keys
# and works like how the i18next framework identifies the namespace scope from the
# useTranslation() hook.
# You should unescape RegEx strings in order to fit in the YAML file
# To help with this, you can use https://www.freeformatter.com/json-escape.html
scopeRangeRegex: "(getScopedI18n|useScopedI18n)\\(\\s*['\"](.*?)['\"]\\)"
# An array of strings containing refactor templates.
# The "$1" will be replaced by the keypath specified.
# Optional: uncomment the following two lines to use
# refactorTemplates:
# - i18n.get("$1")
# If set to true, only enables this custom framework (will disable all built-in frameworks)
monopoly: true
+7 -24
View File
@@ -9,35 +9,18 @@
"js/ts.implicitProjectConfig.experimentalDecorators": true, "js/ts.implicitProjectConfig.experimentalDecorators": true,
"prettier.configPath": "./tooling/prettier/index.mjs", "prettier.configPath": "./tooling/prettier/index.mjs",
"cSpell.words": [ "cSpell.words": [
"ajnart",
"cqmin", "cqmin",
"gridstack",
"homarr", "homarr",
"jellyfin", "jellyfin",
"llen",
"lpop",
"lpush",
"lrange",
"ltrim",
"mantine",
"manuel-rw",
"Meierschlumpf",
"overseerr",
"Sabnzbd",
"SeDemal",
"Sonarr",
"superjson", "superjson",
"tabler",
"trpc", "trpc",
"Umami" "Umami",
"Sonarr"
], ],
"i18n-ally.dirStructure": "auto", "i18n-ally.dirStructure": "auto",
"i18n-ally.displayLanguage": "en", "i18n-ally.enabledFrameworks": ["next-international"],
"i18n-ally.enabledFrameworks": [ "i18n-ally.localesPaths": ["./packages/translation/src/lang/"],
"custom" "i18n-ally.enabledParsers": ["ts"],
], "i18n-ally.extract.keyMaxLength": 0,
"i18n-ally.localesPaths": [ "i18n-ally.keystyle": "flat"
"packages/translation/src/lang",
],
"i18n-ally.keystyle": "auto",
} }
+49 -42
View File
@@ -1,80 +1,87 @@
FROM node:22.12.0-alpine AS base FROM node:20.15.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
RUN apk update RUN apk update
# Set working directory # Set working directory
WORKDIR /app WORKDIR /app
COPY . .
RUN npm i -g turbo
RUN turbo prune @homarr/nextjs --docker --out-dir ./next-out
RUN turbo prune @homarr/tasks --docker --out-dir ./tasks-out
RUN turbo prune @homarr/websocket --docker --out-dir ./websocket-out
RUN turbo prune @homarr/db --docker --out-dir ./migration-out
# Add lockfile and package.json's of isolated subworkspace
FROM base AS installer
RUN apk add --no-cache libc6-compat curl bash RUN apk add --no-cache libc6-compat curl bash
RUN apk update RUN apk update
COPY . . WORKDIR /app
RUN corepack enable pnpm && pnpm install --recursive --frozen-lockfile # First install the dependencies (as they change less often)
COPY .gitignore .gitignore
COPY --from=builder /app/tasks-out/json/ .
COPY --from=builder /app/tasks-out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN corepack enable pnpm && pnpm install
COPY --from=builder /app/websocket-out/json/ .
COPY --from=builder /app/websocket-out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN corepack enable pnpm && pnpm install
COPY --from=builder /app/migration-out/json/ .
COPY --from=builder /app/migration-out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN corepack enable pnpm && pnpm install
COPY --from=builder /app/next-out/json/ .
COPY --from=builder /app/next-out/pnpm-lock.yaml ./pnpm-lock.yaml
# Install sharp for image optimization
RUN corepack enable pnpm && pnpm install sharp -w RUN corepack enable pnpm && pnpm install sharp -w
# Build the project
COPY --from=builder /app/tasks-out/full/ .
COPY --from=builder /app/websocket-out/full/ .
COPY --from=builder /app/next-out/full/ .
COPY --from=builder /app/migration-out/full/ .
# 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' RUN corepack enable pnpm && pnpm turbo run build
ARG DISABLE_REDIS_LOGS='true'
RUN corepack enable pnpm && pnpm build
FROM base AS runner FROM base AS runner
WORKDIR /app WORKDIR /app
# gettext is required for envsubst RUN apk add --no-cache redis
RUN apk add --no-cache redis nginx bash gettext
RUN mkdir /appdata RUN mkdir /appdata
RUN mkdir /appdata/db
RUN mkdir /appdata/redis
VOLUME /appdata VOLUME /appdata
RUN mkdir /secrets
VOLUME /secrets
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Enable homarr cli
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 chmod +x /usr/bin/homarr
# Don't run production as root # Don't run production as root
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
RUN chown -R nextjs:nodejs /appdata RUN chown -R nextjs:nodejs /appdata
RUN chown -R nextjs:nodejs /secrets
RUN mkdir -p /var/cache/nginx && chown -R nextjs:nodejs /var/cache/nginx && \
mkdir -p /var/log/nginx && chown -R nextjs:nodejs /var/log/nginx && \
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 USER nextjs
COPY --from=builder /app/apps/nextjs/next.config.mjs . COPY --from=installer /app/apps/nextjs/next.config.mjs .
COPY --from=builder /app/apps/nextjs/package.json . COPY --from=installer /app/apps/nextjs/package.json .
COPY --from=builder --chown=nextjs:nodejs /app/apps/tasks/tasks.cjs ./apps/tasks/tasks.cjs COPY --from=installer --chown=nextjs:nodejs /app/apps/tasks/tasks.cjs ./apps/tasks/tasks.cjs
COPY --from=builder --chown=nextjs:nodejs /app/apps/websocket/wssServer.cjs ./apps/websocket/wssServer.cjs COPY --from=installer --chown=nextjs:nodejs /app/apps/websocket/wssServer.cjs ./apps/websocket/wssServer.cjs
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node /app/build/better_sqlite3.node COPY --from=installer --chown=nextjs:nodejs /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node /app/build/better_sqlite3.node
COPY --from=builder --chown=nextjs:nodejs /app/packages/db/migrations ./db/migrations COPY --from=installer --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 --chown=nextjs:nodejs /app/apps/nextjs/.next/standalone ./ COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/nextjs/.next/static ./apps/nextjs/.next/static COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/.next/static ./apps/nextjs/.next/static
COPY --from=builder --chown=nextjs:nodejs /app/apps/nextjs/public ./apps/nextjs/public COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/public ./apps/nextjs/public
COPY --chown=nextjs:nodejs scripts/run.sh ./run.sh COPY --chown=nextjs:nodejs scripts/run.sh ./run.sh
COPY --chown=nextjs:nodejs scripts/generateEncryptionKey.js ./generateEncryptionKey.js
COPY --chown=nextjs:nodejs packages/redis/redis.conf /app/redis.conf COPY --chown=nextjs:nodejs packages/redis/redis.conf /app/redis.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'
CMD ["sh", "run.sh"] CMD ["sh", "run.sh"]
+7 -20
View File
@@ -1,29 +1,19 @@
// Importing env files here to validate on build // Importing env files here to validate on build
import "@homarr/auth/env.mjs"; import "@homarr/auth/env.mjs";
import MillionLint from "@million/lint";
import createNextIntlPlugin from "next-intl/plugin";
import "./src/env.mjs"; import "./src/env.mjs";
// Package path does not work... so we need to use relative path
const withNextIntl = createNextIntlPlugin("../../packages/translation/src/request.ts");
/** @type {import("next").NextConfig} */ /** @type {import("next").NextConfig} */
const nextConfig = { const config = {
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, { isServer }) => { webpack: (config) => {
if (isServer) { config.module.rules.push({
config.module.rules.push({ test: /\.node$/,
test: /\.node$/, loader: "node-loader",
loader: "node-loader", });
});
}
return config; return config;
}, },
experimental: { experimental: {
@@ -35,7 +25,4 @@ const nextConfig = {
}, },
}; };
// Skip transform is used because of webpack loader, without it for example 'Tooltip.Floating' will not work and show an error export default config;
const withMillionLint = MillionLint.next({ rsc: true, skipTransform: true, telemetry: false });
export default withNextIntl(nextConfig);
+35 -41
View File
@@ -7,13 +7,12 @@
"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", "dev": "pnpm with-env next dev",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint", "lint": "eslint",
"format": "prettier --check . --ignore-path ../../.gitignore",
"start": "pnpm with-env next start", "start": "pnpm with-env next start",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"with-env": "dotenv -e ../../.env --" "with-env": "dotenv -e ../../.env --"
}, },
"prettier": "@homarr/prettier-config",
"dependencies": { "dependencies": {
"@homarr/analytics": "workspace:^0.1.0", "@homarr/analytics": "workspace:^0.1.0",
"@homarr/api": "workspace:^0.1.0", "@homarr/api": "workspace:^0.1.0",
@@ -23,31 +22,27 @@
"@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/form": "workspace:^0.1.0", "@homarr/form": "workspace:^0.1.0",
"@homarr/gridstack": "^1.11.2", "@homarr/gridstack": "^1.0.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/notifications": "workspace:^0.1.0", "@homarr/notifications": "workspace:^0.1.0",
"@homarr/old-schema": "workspace:^0.1.0",
"@homarr/redis": "workspace:^0.1.0",
"@homarr/server-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.14.3", "@mantine/colors-generator": "^7.11.1",
"@mantine/core": "^7.14.3", "@mantine/core": "^7.11.1",
"@mantine/hooks": "^7.14.3", "@mantine/hooks": "^7.11.1",
"@mantine/modals": "^7.14.3", "@mantine/modals": "^7.11.1",
"@mantine/tiptap": "^7.14.3", "@mantine/tiptap": "^7.11.1",
"@million/lint": "1.0.13", "@homarr/server-settings": "workspace:^0.1.0",
"@t3-oss/env-nextjs": "^0.11.1", "@t3-oss/env-nextjs": "^0.10.1",
"@tabler/icons-react": "^3.24.0", "@tanstack/react-query": "^5.50.1",
"@tanstack/react-query": "^5.62.3", "@tanstack/react-query-devtools": "^5.50.1",
"@tanstack/react-query-devtools": "^5.62.3", "@tanstack/react-query-next-experimental": "5.50.1",
"@tanstack/react-query-next-experimental": "5.62.3", "@tabler/icons-react": "^3.9.0",
"@trpc/client": "next", "@trpc/client": "next",
"@trpc/next": "next", "@trpc/next": "next",
"@trpc/react-query": "next", "@trpc/react-query": "next",
@@ -55,24 +50,23 @@
"@xterm/addon-canvas": "^0.7.0", "@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-fit": "0.10.0", "@xterm/addon-fit": "0.10.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"chroma-js": "^3.1.2", "chroma-js": "^2.4.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dayjs": "^1.11.13", "dayjs": "^1.11.11",
"dotenv": "^16.4.7", "dotenv": "^16.4.5",
"flag-icons": "^7.2.3", "flag-icons": "^7.2.3",
"glob": "^11.0.0", "glob": "^10.4.3",
"jotai": "^2.10.3", "jotai": "^2.8.4",
"mantine-react-table": "2.0.0-beta.7", "mantine-react-table": "2.0.0-beta.5",
"next": "^14.2.20", "next": "^14.2.4",
"postcss-preset-mantine": "^1.17.0", "postcss-preset-mantine": "^1.15.0",
"prismjs": "^1.29.0", "prismjs": "^1.29.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-error-boundary": "^4.1.2", "react-error-boundary": "^4.0.13",
"react-simple-code-editor": "^0.14.1", "react-simple-code-editor": "^0.14.1",
"sass": "^1.82.0", "sass": "^1.77.6",
"superjson": "2.2.2", "superjson": "2.2.1",
"swagger-ui-react": "^5.18.2",
"use-deep-compare-effect": "^1.8.1" "use-deep-compare-effect": "^1.8.1"
}, },
"devDependencies": { "devDependencies": {
@@ -80,15 +74,15 @@
"@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": "2.4.4", "@types/chroma-js": "2.4.4",
"@types/node": "^22.10.1", "@types/node": "^20.14.10",
"@types/prismjs": "^1.26.5", "@types/prismjs": "^1.26.4",
"@types/react": "^18.3.13", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.0",
"@types/swagger-ui-react": "^4.18.3", "concurrently": "^8.2.2",
"concurrently": "^9.1.0", "eslint": "^9.6.0",
"eslint": "^9.16.0", "node-loader": "^2.0.0",
"node-loader": "^2.1.0", "prettier": "^3.3.2",
"prettier": "^3.4.2", "typescript": "^5.5.3"
"typescript": "^5.7.2" },
} "prettier": "@homarr/prettier-config"
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 497 B

-3
View File
@@ -1,3 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid meet" viewBox="0 0 575 289.83" width="575" height="289.83"><defs><path d="M575 24.91C573.44 12.15 563.97 1.98 551.91 0C499.05 0 76.18 0 23.32 0C10.11 2.17 0 14.16 0 28.61C0 51.84 0 237.64 0 260.86C0 276.86 12.37 289.83 27.64 289.83C79.63 289.83 495.6 289.83 547.59 289.83C561.65 289.83 573.26 278.82 575 264.57C575 216.64 575 48.87 575 24.91Z" id="d1pwhf9wy2"></path><path d="M69.35 58.24L114.98 58.24L114.98 233.89L69.35 233.89L69.35 58.24Z" id="g5jjnq26yS"></path><path d="M201.2 139.15C197.28 112.38 195.1 97.5 194.67 94.53C192.76 80.2 190.94 67.73 189.2 57.09C185.25 57.09 165.54 57.09 130.04 57.09L130.04 232.74L170.01 232.74L170.15 116.76L186.97 232.74L215.44 232.74L231.39 114.18L231.54 232.74L271.38 232.74L271.38 57.09L211.77 57.09L201.2 139.15Z" id="i3Prh1JpXt"></path><path d="M346.71 93.63C347.21 95.87 347.47 100.95 347.47 108.89C347.47 115.7 347.47 170.18 347.47 176.99C347.47 188.68 346.71 195.84 345.2 198.48C343.68 201.12 339.64 202.43 333.09 202.43C333.09 190.9 333.09 98.66 333.09 87.13C338.06 87.13 341.45 87.66 343.25 88.7C345.05 89.75 346.21 91.39 346.71 93.63ZM367.32 230.95C372.75 229.76 377.31 227.66 381.01 224.67C384.7 221.67 387.29 217.52 388.77 212.21C390.26 206.91 391.14 196.38 391.14 180.63C391.14 174.47 391.14 125.12 391.14 118.95C391.14 102.33 390.49 91.19 389.48 85.53C388.46 79.86 385.93 74.71 381.88 70.09C377.82 65.47 371.9 62.15 364.12 60.13C356.33 58.11 343.63 57.09 321.54 57.09C319.27 57.09 307.93 57.09 287.5 57.09L287.5 232.74L342.78 232.74C355.52 232.34 363.7 231.75 367.32 230.95Z" id="a4ov9rRGQm"></path><path d="M464.76 204.7C463.92 206.93 460.24 208.06 457.46 208.06C454.74 208.06 452.93 206.98 452.01 204.81C451.09 202.65 450.64 197.72 450.64 190C450.64 185.36 450.64 148.22 450.64 143.58C450.64 135.58 451.04 130.59 451.85 128.6C452.65 126.63 454.41 125.63 457.13 125.63C459.91 125.63 463.64 126.76 464.6 129.03C465.55 131.3 466.03 136.15 466.03 143.58C466.03 146.58 466.03 161.58 466.03 188.59C465.74 197.84 465.32 203.21 464.76 204.7ZM406.68 231.21L447.76 231.21C449.47 224.5 450.41 220.77 450.6 220.02C454.32 224.52 458.41 227.9 462.9 230.14C467.37 232.39 474.06 233.51 479.24 233.51C486.45 233.51 492.67 231.62 497.92 227.83C503.16 224.05 506.5 219.57 507.92 214.42C509.34 209.26 510.05 201.42 510.05 190.88C510.05 185.95 510.05 146.53 510.05 141.6C510.05 131 509.81 124.08 509.34 120.83C508.87 117.58 507.47 114.27 505.14 110.88C502.81 107.49 499.42 104.86 494.98 102.98C490.54 101.1 485.3 100.16 479.26 100.16C474.01 100.16 467.29 101.21 462.81 103.28C458.34 105.35 454.28 108.49 450.64 112.7C450.64 108.89 450.64 89.85 450.64 55.56L406.68 55.56L406.68 231.21Z" id="fk968BpsX"></path></defs><g><g><g><use xlink:href="#d1pwhf9wy2" opacity="1" fill="#f6c700" fill-opacity="1"></use><g><use xlink:href="#d1pwhf9wy2" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#g5jjnq26yS" opacity="1" fill="#000000" fill-opacity="1"></use><g><use xlink:href="#g5jjnq26yS" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#i3Prh1JpXt" opacity="1" fill="#000000" fill-opacity="1"></use><g><use xlink:href="#i3Prh1JpXt" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#a4ov9rRGQm" opacity="1" fill="#000000" fill-opacity="1"></use><g><use xlink:href="#a4ov9rRGQm" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#fk968BpsX" opacity="1" fill="#000000" fill-opacity="1"></use><g><use xlink:href="#fk968BpsX" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g></g></g></svg>

Before

Width:  |  Height:  |  Size: 3.9 KiB

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="708.767" height="179.332">
<path fill="#d51007" d="m158.431 165.498-8.354-22.708s-13.575 15.14-33.932 15.14c-18.013 0-30.802-15.662-30.802-40.721 0-32.106 16.182-43.591 32.107-43.591 22.969 0 30.277 14.878 36.543 33.934l8.354 26.103c8.351 25.318 24.013 45.678 69.17 45.678 32.37 0 54.295-9.918 54.295-36.02 0-21.143-12.009-32.107-34.458-37.328l-16.705-3.654c-11.484-2.61-14.877-7.309-14.877-15.14 0-8.875 7.046-14.096 18.533-14.096 12.529 0 19.315 4.699 20.36 15.923l26.102-3.133c-2.088-23.492-18.271-33.15-44.896-33.15-23.491 0-46.462 8.875-46.462 37.327 0 17.75 8.614 28.975 30.277 34.195l17.752 4.175c13.312 3.133 17.748 8.614 17.748 16.185 0 9.656-9.396 13.572-27.146 13.572-26.364 0-37.325-13.834-43.591-32.89l-8.614-26.101c-10.961-33.934-28.452-46.463-63.169-46.463-38.37 0-58.731 24.275-58.731 65.517 0 39.677 20.361 61.08 56.906 61.08 29.492 0 43.59-13.834 43.59-13.834zM46.726 153.229c-2.61.784-5.221 1.306-8.614 1.306-6.265 0-10.703-2.87-10.703-10.442V1.827H0v148.792c0 19.577 13.575 27.672 29.497 27.672 5.221 0 10.181-.785 16.446-2.349l.783-22.713zm330.185-4.176c-6.787 4.701-12.529 7.051-20.36 7.051-9.92 0-15.401-5.221-15.401-18.012V77.006h36.023V55.603H341.41V26.625l-27.669 3.394v25.583h-17.49v21.403h17.49v66.826c0 24.02 13.834 35.5 36.284 35.5 12.269 0 23.232-2.346 31.847-7.305l-4.961-22.973zm23.807 9.396c0 10.705 8.354 19.318 19.056 19.318 11.226 0 19.578-8.613 19.578-19.318 0-10.963-8.353-19.313-19.578-19.313-10.702 0-19.056 8.35-19.056 19.313zm67.009-81.443v99.195h27.409V77.006h30.803V55.603h-30.803V44.638c0-16.444 7.049-21.665 18.534-21.665 8.092 0 13.574 1.825 19.839 5.221l4.437-22.974C530.638 1.827 522.023 0 511.582 0c-22.973 0-43.855 10.963-43.855 43.593v12.01h-17.489v21.403h17.489zm167.427 2.352c-3.133-19.578-15.923-26.629-32.63-26.629-16.706 0-31.062 7.571-37.329 26.104l-3.393-23.23h-22.188v120.598h27.409v-68.129c0-23.235 12.008-32.11 24.799-32.11 13.312 0 18.795 8.875 18.795 23.232V176.2h27.147v-68.39c0-22.974 12.269-31.849 25.061-31.849 13.052 0 18.532 8.875 18.532 23.232v77.006h27.409v-86.66c0-25.843-15.14-36.81-35.24-36.81-16.965 0-32.107 7.571-38.372 26.629z"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 190.24 81.52"><defs><style>.cls-1{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" y1="40.76" x2="190.24" y2="40.76" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#90cea1"/><stop offset="0.56" stop-color="#3cbec9"/><stop offset="1" stop-color="#00b3e5"/></linearGradient></defs><title>Asset 2</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M105.67,36.06h66.9A17.67,17.67,0,0,0,190.24,18.4h0A17.67,17.67,0,0,0,172.57.73h-66.9A17.67,17.67,0,0,0,88,18.4h0A17.67,17.67,0,0,0,105.67,36.06Zm-88,45h76.9A17.67,17.67,0,0,0,112.24,63.4h0A17.67,17.67,0,0,0,94.57,45.73H17.67A17.67,17.67,0,0,0,0,63.4H0A17.67,17.67,0,0,0,17.67,81.06ZM10.41,35.42h7.8V6.92h10.1V0H.31v6.9h10.1Zm28.1,0h7.8V8.25h.1l9,27.15h6l9.3-27.15h.1V35.4h7.8V0H66.76l-8.2,23.1h-.1L50.31,0H38.51ZM152.43,55.67a15.07,15.07,0,0,0-4.52-5.52,18.57,18.57,0,0,0-6.68-3.08,33.54,33.54,0,0,0-8.07-1h-11.7v35.4h12.75a24.58,24.58,0,0,0,7.55-1.15A19.34,19.34,0,0,0,148.11,77a16.27,16.27,0,0,0,4.37-5.5,16.91,16.91,0,0,0,1.63-7.58A18.5,18.5,0,0,0,152.43,55.67ZM145,68.6A8.8,8.8,0,0,1,142.36,72a10.7,10.7,0,0,1-4,1.82,21.57,21.57,0,0,1-5,.55h-4.05v-21h4.6a17,17,0,0,1,4.67.63,11.66,11.66,0,0,1,3.88,1.87A9.14,9.14,0,0,1,145,59a9.87,9.87,0,0,1,1,4.52A11.89,11.89,0,0,1,145,68.6Zm44.63-.13a8,8,0,0,0-1.58-2.62A8.38,8.38,0,0,0,185.63,64a10.31,10.31,0,0,0-3.17-1v-.1a9.22,9.22,0,0,0,4.42-2.82,7.43,7.43,0,0,0,1.68-5,8.42,8.42,0,0,0-1.15-4.65,8.09,8.09,0,0,0-3-2.72,12.56,12.56,0,0,0-4.18-1.3,32.84,32.84,0,0,0-4.62-.33h-13.2v35.4h14.5a22.41,22.41,0,0,0,4.72-.5,13.53,13.53,0,0,0,4.28-1.65,9.42,9.42,0,0,0,3.1-3,8.52,8.52,0,0,0,1.2-4.68A9.39,9.39,0,0,0,189.66,68.47ZM170.21,52.72h5.3a10,10,0,0,1,1.85.18,6.18,6.18,0,0,1,1.7.57,3.39,3.39,0,0,1,1.22,1.13,3.22,3.22,0,0,1,.48,1.82,3.63,3.63,0,0,1-.43,1.8,3.4,3.4,0,0,1-1.12,1.2,4.92,4.92,0,0,1-1.58.65,7.51,7.51,0,0,1-1.77.2h-5.65Zm11.72,20a3.9,3.9,0,0,1-1.22,1.3,4.64,4.64,0,0,1-1.68.7,8.18,8.18,0,0,1-1.82.2h-7v-8h5.9a15.35,15.35,0,0,1,2,.15,8.47,8.47,0,0,1,2.05.55,4,4,0,0,1,1.57,1.18,3.11,3.11,0,0,1,.63,2A3.71,3.71,0,0,1,181.93,72.72Z"/></g></g></svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

-47
View File
@@ -1,47 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="64mm"
height="64mm"
viewBox="0 0 64 64"
version="1.1"
id="svg5"
inkscape:version="1.2-alpha1 (b6a15bbbed, 2022-02-23)"
sodipodi:docname="vgmdb.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="2.1089995"
inkscape:cx="96.017091"
inkscape:cy="132.29021"
inkscape:window-width="1920"
inkscape:window-height="1017"
inkscape:window-x="1912"
inkscape:window-y="456"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs2" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
style="fill:#000000;stroke-width:0.21854"
d="m 24.915135,62.650271 c 0.887879,-1.778597 2.041345,-3.66055 2.243584,-3.66055 0.373389,0 3.112623,2.34553 4.058216,3.474939 l 0.933036,1.114407 h -3.849247 -3.849246 z m 12.769897,0.133716 c 0.188983,-1.699691 0.540579,-9.438559 0.541952,-11.928786 l 0.0013,-2.561742 1.233546,-0.358603 c 1.858993,-0.540424 4.864719,-0.351614 6.306093,0.396126 2.102595,1.090761 3.373658,3.018391 3.382575,5.129841 0.0167,3.956206 -2.25508,7.722455 -5.608397,9.297806 -1.304436,0.612809 -1.766356,0.710219 -3.720191,0.784508 l -2.230831,0.08482 0.09384,-0.843972 z M 15.28167,58.692354 C 13.855849,58.372721 13.307133,58.098082 12.027965,57.063833 10.767623,56.044804 9.571224,54.160633 8.9251418,52.177293 8.4791686,50.808245 8.4074221,50.178438 8.4306037,47.836119 8.5286648,37.927793 11.975024,28.061437 18.601259,18.719217 c 1.935825,-2.729288 2.971061,-3.715223 4.909347,-4.675547 2.754154,-1.364545 6.425257,-1.245307 7.097695,0.230534 0.528157,1.159175 -0.351225,2.928281 -5.658254,11.38306 -3.70676,5.905347 -6.114927,9.18009 -8.52131,11.587702 l -1.747431,1.748322 0.748093,2.185403 c 1.052426,3.074452 2.737855,6.406887 3.730724,7.376398 l 0.831803,0.812233 1.792285,-1.012255 c 0.985756,-0.556742 3.87521,-2.58299 6.421008,-4.502779 l 4.628722,-3.490523 -0.152738,4.29269 c -0.08401,2.36098 -0.222472,4.480622 -0.3077,4.710314 -0.332428,0.895893 -8.050465,7.1666 -10.09734,8.203831 -2.185833,1.107645 -5.028822,1.564407 -6.994493,1.123754 z m 12.832756,-0.03877 c -0.369353,-0.206871 -0.546974,-0.44325 -0.453135,-0.603036 0.708434,-1.206309 4.008342,-5.172791 4.307161,-5.177197 0.225634,-0.0033 -0.01427,3.110825 -0.312679,4.058824 -0.487124,1.547514 -2.288476,2.42313 -3.541347,1.721409 z M 54.400377,47.270961 c -2.064993,-1.073365 -4.152108,-1.442529 -8.74161,-1.546198 -3.114822,-0.07036 -4.685696,-0.01346 -5.837847,0.211437 l -1.576311,0.307699 -0.101497,-0.893553 C 38.087288,44.858893 37.933571,42.73579 37.801516,40.63234 l -0.240099,-3.824455 0.497396,-0.409308 c 3.74237,-3.079615 11.651909,-7.818064 15.417757,-9.236475 2.486857,-0.936679 2.235049,-2.072647 2.235049,10.082864 0,5.829561 -0.07376,10.585246 -0.163906,10.568187 -0.09015,-0.01705 -0.606449,-0.261044 -1.147336,-0.542192 z M 23.409747,36.75325 c 0.981959,-3.570355 4.756318,-11.031164 6.826933,-13.49486 0.464503,-0.552684 0.586271,-0.604153 0.775553,-0.327811 0.553602,0.808232 1.251919,4.110924 1.621318,7.668016 l 0.126807,1.221076 -3.079512,1.795215 c -1.693731,0.987367 -3.814306,2.281815 -4.71239,2.876551 -0.898084,0.594738 -1.666906,1.081339 -1.708492,1.081339 -0.04159,0 0.02582,-0.368786 0.149783,-0.819526 z m 13.184647,-7.110817 c -0.04629,-0.145705 -0.341909,-1.543377 -0.656926,-3.10594 -0.315019,-1.562562 -0.864147,-3.967401 -1.220285,-5.344086 l -0.647526,-2.503063 1.150575,-1.176622 c 0.632814,-0.647143 2.643448,-2.301196 4.468071,-3.675673 3.438183,-2.589963 4.301443,-3.419286 4.80161,-4.6128516 0.714642,-1.7053778 -0.03157,-3.7643498 -1.67217,-4.613896 -0.898369,-0.4651992 -1.181912,-0.4997701 -3.278104,-0.3996832 -2.190425,0.1045861 -4.050576,0.523224 -8.85088,1.9919437 l -1.092702,0.334327 0.546351,-0.4412242 c 6.292255,-5.0815261 14.525967,-6.98424479 19.646997,-4.540202 2.438424,1.163752 4.050578,3.1575295 5.198539,6.4291158 l 0.605044,1.7243152 0.0047,7.3023343 0.0047,7.302333 -2.622483,0.481857 c -4.702423,0.864027 -9.663403,2.365904 -14.706862,4.452327 -1.118146,0.462565 -1.619586,0.580467 -1.67861,0.394688 z"
id="path6625" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

@@ -1,5 +0,0 @@
import definition from "../boards/(content)/(home)/_definition";
const { layout } = definition;
export default layout;
@@ -1,7 +0,0 @@
import definition from "../boards/(content)/(home)/_definition";
const { generateMetadataAsync: generateMetadata, page } = definition;
export default page;
export { generateMetadata };
@@ -0,0 +1,14 @@
import type { PropsWithChildren } from "react";
import { AppShellMain } from "@mantine/core";
import { MainHeader } from "~/components/layout/header";
import { ClientShell } from "~/components/layout/shell";
export default function MainLayout({ children }: PropsWithChildren) {
return (
<ClientShell hasNavigation={false}>
<MainHeader />
<AppShellMain>{children}</AppShellMain>
</ClientShell>
);
}
@@ -0,0 +1,9 @@
import { Stack, Title } from "@mantine/core";
export default function HomePage() {
return (
<Stack>
<Title>Home</Title>
</Stack>
);
}
@@ -1,12 +0,0 @@
"use client";
import type { PropsWithChildren } from "react";
import { useSuspenseDayJsLocalization } from "@homarr/translation/dayjs";
export const DayJsLoader = ({ children }: PropsWithChildren) => {
// Load the dayjs localization for the current locale with suspense
useSuspenseDayJsLocalization();
return children;
};
@@ -1,77 +0,0 @@
"use client";
import type { PropsWithChildren } from "react";
import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core";
import { createTheme, DirectionProvider, MantineProvider } from "@mantine/core";
import dayjs from "dayjs";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { parseCookies, setClientCookie } from "@homarr/common";
import type { ColorScheme } from "@homarr/definitions";
import { colorSchemeCookieKey } from "@homarr/definitions";
export const CustomMantineProvider = ({
children,
defaultColorScheme,
}: PropsWithChildren<{ defaultColorScheme: ColorScheme }>) => {
const manager = useColorSchemeManager();
return (
<DirectionProvider>
<MantineProvider
defaultColorScheme={defaultColorScheme}
colorSchemeManager={manager}
theme={createTheme({
primaryColor: "red",
autoContrast: true,
})}
>
{children}
</MantineProvider>
</DirectionProvider>
);
};
export function useColorSchemeManager(): MantineColorSchemeManager {
const { data: session } = useSession();
const updateCookieValue = (value: Exclude<MantineColorScheme, "auto">) => {
setClientCookie(colorSchemeCookieKey, value, { expires: dayjs().add(1, "year").toDate(), path: "/" });
};
const { mutate: mutateColorScheme } = clientApi.user.changeColorScheme.useMutation({
onSuccess: (_, variables) => {
updateCookieValue(variables.colorScheme);
},
});
return {
get: (defaultValue) => {
if (typeof window === "undefined") {
return defaultValue;
}
try {
const cookies = parseCookies(document.cookie);
return (cookies[colorSchemeCookieKey] as MantineColorScheme | undefined) ?? defaultValue;
} catch {
return defaultValue;
}
},
set: (value) => {
if (value === "auto") return;
try {
if (session) {
mutateColorScheme({ colorScheme: value });
}
updateCookieValue(value);
} catch (error) {
console.warn("[@mantine/core] Color scheme manager was unable to save color scheme.", error);
}
},
subscribe: () => undefined,
unsubscribe: () => undefined,
clear: () => undefined,
};
}
@@ -0,0 +1,12 @@
import type { PropsWithChildren } from "react";
import { defaultLocale } from "@homarr/translation";
import { I18nProviderClient } from "@homarr/translation/client";
export const NextInternationalProvider = ({ children, locale }: PropsWithChildren<{ locale: string }>) => {
return (
<I18nProviderClient locale={locale} fallback={defaultLocale}>
{children}
</I18nProviderClient>
);
};
@@ -1,46 +1,14 @@
"use client"; "use client";
import { createContext, useContext, useEffect } from "react";
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import dayjs from "dayjs";
import type { Session } from "@homarr/auth"; import type { Session } from "@homarr/auth";
import { SessionProvider, signIn } from "@homarr/auth/client"; import { SessionProvider } from "@homarr/auth/client";
interface AuthProviderProps extends AuthContextProps { interface AuthProviderProps {
session: Session | null; session: Session | null;
} }
export const AuthProvider = ({ children, session, logoutUrl }: PropsWithChildren<AuthProviderProps>) => { export const AuthProvider = ({ children, session }: PropsWithChildren<AuthProviderProps>) => {
useLoginRedirectOnSessionExpiry(session); return <SessionProvider session={session}>{children}</SessionProvider>;
return (
<SessionProvider session={session}>
<AuthContext.Provider value={{ logoutUrl }}>{children}</AuthContext.Provider>
</SessionProvider>
);
};
interface AuthContextProps {
logoutUrl: string | undefined;
}
const AuthContext = createContext<AuthContextProps | null>(null);
export const useAuthContext = () => {
const context = useContext(AuthContext);
if (!context) throw new Error("useAuthContext must be used within an AuthProvider");
return context;
};
const useLoginRedirectOnSessionExpiry = (session: Session | null) => {
useEffect(() => {
// eslint-disable-next-line @typescript-eslint/no-empty-function
if (!session) return () => {};
//setTimeout doesn't allow for a number higher than 2147483647 (2³¹-1 , or roughly 24 days)
const timeout = setTimeout(() => void signIn(), Math.min(dayjs(session.expires).diff(), 2147483647));
return () => clearTimeout(timeout);
}, [session]);
}; };
@@ -5,46 +5,14 @@ import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental"; import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
import { import { createWSClient, loggerLink, unstable_httpBatchStreamLink, wsLink } from "@trpc/client";
createWSClient,
httpLink,
isNonJsonSerializable,
loggerLink,
splitLink,
unstable_httpBatchStreamLink,
wsLink,
} from "@trpc/client";
import superjson from "superjson"; import superjson from "superjson";
import type { AppRouter } from "@homarr/api"; import type { AppRouter } from "@homarr/api";
import { clientApi, createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { env } from "~/env.mjs";
const getWebSocketProtocol = () => {
// window is not defined on server side
if (typeof window === "undefined") {
return "ws";
}
return window.location.protocol === "https:" ? "wss" : "ws";
};
const constructWebsocketUrl = () => {
const fallback = `${getWebSocketProtocol()}://localhost:3001/websockets`;
if (typeof window === "undefined") {
return fallback;
}
if (env.NODE_ENV === "development") {
return fallback;
}
return `${getWebSocketProtocol()}://${window.location.hostname}:${window.location.port}/websockets`;
};
const wsClient = createWSClient({ const wsClient = createWSClient({
url: constructWebsocketUrl(), url: "ws://localhost:3001",
}); });
export function TRPCReactProvider(props: PropsWithChildren) { export function TRPCReactProvider(props: PropsWithChildren) {
@@ -66,36 +34,28 @@ export function TRPCReactProvider(props: PropsWithChildren) {
enabled: (opts) => enabled: (opts) =>
process.env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error), process.env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error),
}), }),
splitLink({ (args) => {
condition: ({ type }) => type === "subscription", return ({ op, next }) => {
true: wsLink<AppRouter>({ console.log("op", op.type, op.input, op.path, op.id);
client: wsClient, if (op.type === "subscription") {
transformer: superjson, const link = wsLink<AppRouter>({
}), client: wsClient,
false: splitLink({ transformer: superjson,
condition: ({ input }) => isNonJsonSerializable(input), });
true: httpLink({ return link(args)({ op, next });
/** }
* We don't want to transform the data here as we want to use form data
*/ return unstable_httpBatchStreamLink({
transformer: {
serialize(object: unknown) {
return object;
},
deserialize(data: unknown) {
return data;
},
},
url: getTrpcUrl(),
headers: createHeadersCallbackForSource("nextjs-react (form-data)"),
}),
false: unstable_httpBatchStreamLink({
transformer: superjson, transformer: superjson,
url: getTrpcUrl(), url: `${getBaseUrl()}/api/trpc`,
headers: createHeadersCallbackForSource("nextjs-react (json)"), headers() {
}), const headers = new Headers();
}), headers.set("x-trpc-source", "nextjs-react");
}), return headers;
},
})(args)({ op, next });
};
},
], ],
}); });
}); });
@@ -109,3 +69,9 @@ export function TRPCReactProvider(props: PropsWithChildren) {
</clientApi.Provider> </clientApi.Provider>
); );
} }
function getBaseUrl() {
if (typeof window !== "undefined") return window.location.origin;
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
return `http://localhost:${process.env.PORT ?? 3000}`;
}
@@ -7,7 +7,6 @@ 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 type { z } from "@homarr/validation"; import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation"; import { validation } from "@homarr/validation";
@@ -65,8 +64,7 @@ export const RegistrationForm = ({ invite }: RegistrationFormProps) => {
<form onSubmit={form.onSubmit(handleSubmit)}> <form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="lg"> <Stack gap="lg">
<TextInput label={t("field.username.label")} autoComplete="off" {...form.getInputProps("username")} /> <TextInput label={t("field.username.label")} autoComplete="off" {...form.getInputProps("username")} />
<CustomPasswordInput <PasswordInput
withPasswordRequirements
label={t("field.password.label")} label={t("field.password.label")}
autoComplete="new-password" autoComplete="new-password"
{...form.getInputProps("password")} {...form.getInputProps("password")}
@@ -2,7 +2,6 @@ import { notFound } from "next/navigation";
import { Card, Center, Stack, Text, Title } from "@mantine/core"; 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 { and, db, eq } from "@homarr/db"; import { and, db, eq } from "@homarr/db";
import { invites } from "@homarr/db/schema/sqlite"; import { invites } from "@homarr/db/schema/sqlite";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
@@ -20,8 +19,6 @@ interface InviteUsagePageProps {
} }
export default async function InviteUsagePage({ params, searchParams }: InviteUsagePageProps) { export default async function InviteUsagePage({ params, searchParams }: InviteUsagePageProps) {
if (!isProviderEnabled("credentials")) notFound();
const session = await auth(); const session = await auth();
if (session) notFound(); if (session) notFound();
@@ -1,185 +1,78 @@
"use client"; "use client";
import type { PropsWithChildren } from "react"; import { useState } from "react";
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 { Alert, Button, PasswordInput, rem, Stack, TextInput } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks"; import { IconAlertTriangle } from "@tabler/icons-react";
import { signIn } from "@homarr/auth/client"; import { signIn } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
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, z } from "@homarr/validation"; import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
interface LoginFormProps { import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
providers: string[];
oidcClientName: string;
isOidcAutoLoginEnabled: boolean;
callbackUrl: string;
}
const extendedValidation = validation.user.signIn.extend({ provider: z.enum(["credentials", "ldap"]) }); export const LoginForm = () => {
export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, callbackUrl }: LoginFormProps) => {
const t = useScopedI18n("user"); const t = useScopedI18n("user");
const router = useRouter(); const router = useRouter();
const [isPending, setIsPending] = useState(false); const [isLoading, setIsLoading] = useState(false);
const form = useZodForm(extendedValidation, { const [error, setError] = useState<string>();
const form = useZodForm(validation.user.signIn, {
initialValues: { initialValues: {
name: "", name: "",
password: "", password: "",
provider: "credentials",
}, },
}); });
const credentialInputsVisible = providers.includes("credentials") || providers.includes("ldap"); const handleSubmitAsync = async (values: z.infer<typeof validation.user.signIn>) => {
setIsLoading(true);
setError(undefined);
await signIn("credentials", {
...values,
redirect: false,
callbackUrl: "/",
})
.then(async (response) => {
if (!response?.ok || response.error) {
throw response?.error;
}
const onSuccess = useCallback( showSuccessNotification({
async (response: Awaited<ReturnType<typeof signIn>>) => { title: t("action.login.notification.success.title"),
if (response && (!response.ok || response.error)) { message: t("action.login.notification.success.message"),
// eslint-disable-next-line @typescript-eslint/only-throw-error });
throw response.error;
}
showSuccessNotification({
title: t("action.login.notification.success.title"),
message: t("action.login.notification.success.message"),
});
// Redirect to the callback URL if the response is defined and comes from a credentials provider (ldap or credentials). oidc is redirected automatically.
if (response) {
await revalidatePathActionAsync("/"); await revalidatePathActionAsync("/");
router.push(callbackUrl); router.push("/");
}
},
[t, router, callbackUrl],
);
const onError = useCallback(() => {
setIsPending(false);
showErrorNotification({
title: t("action.login.notification.error.title"),
message: t("action.login.notification.error.message"),
autoClose: 10000,
});
}, [t]);
const signInAsync = useCallback(
async (provider: string, options?: Parameters<typeof signIn>[1]) => {
setIsPending(true);
await signIn(provider, {
...options,
redirect: false,
callbackUrl: new URL(callbackUrl, window.location.href).href,
}) })
.then(onSuccess) .catch((error: Error | string) => {
.catch(onError); setIsLoading(false);
}, setError(error.toString());
[setIsPending, onSuccess, onError, callbackUrl], showErrorNotification({
); title: t("action.login.notification.error.title"),
message: t("action.login.notification.error.message"),
const isLoginInProgress = useRef(false); });
});
useEffect(() => { };
if (isOidcAutoLoginEnabled && !isPending && !isLoginInProgress.current) {
isLoginInProgress.current = true;
void signInAsync("oidc");
}
}, [signInAsync, isOidcAutoLoginEnabled, isPending]);
return ( return (
<Stack gap="xl"> <Stack gap="xl">
<Stack gap="lg"> <form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
{credentialInputsVisible && ( <Stack gap="lg">
<> <TextInput label={t("field.username.label")} {...form.getInputProps("name")} />
<form onSubmit={form.onSubmit((credentials) => void signInAsync(credentials.provider, credentials))}> <PasswordInput label={t("field.password.label")} {...form.getInputProps("password")} />
<Stack gap="lg"> <Button type="submit" fullWidth loading={isLoading}>
<TextInput label={t("field.username.label")} {...form.getInputProps("name")} /> {t("action.login.label")}
<PasswordInput label={t("field.password.label")} {...form.getInputProps("password")} />
{providers.includes("credentials") && (
<Stack gap="sm">
<SubmitButton isPending={isPending} form={form} provider="credentials">
{t("action.login.label")}
</SubmitButton>
<PasswordForgottenCollapse username={form.values.name} />
</Stack>
)}
{providers.includes("ldap") && (
<SubmitButton isPending={isPending} form={form} provider="ldap">
{t("action.login.labelWith", { provider: "LDAP" })}
</SubmitButton>
)}
</Stack>
</form>
{providers.includes("oidc") && <Divider label="OIDC" labelPosition="center" />}
</>
)}
{providers.includes("oidc") && (
<Button fullWidth variant="light" onClick={async () => await signInAsync("oidc")}>
{t("action.login.labelWith", { provider: oidcClientName })}
</Button> </Button>
)} </Stack>
</Stack> </form>
{error && (
<Alert icon={<IconAlertTriangle size={rem(16)} />} color="red">
{error}
</Alert>
)}
</Stack> </Stack>
); );
}; };
interface SubmitButtonProps {
isPending: boolean;
form: ReturnType<typeof useForm<FormType, (values: FormType) => FormType>>;
provider: "credentials" | "ldap";
}
const SubmitButton = ({ isPending, form, provider, children }: PropsWithChildren<SubmitButtonProps>) => {
const isCurrentProviderActive = form.getValues().provider === provider;
return (
<Button
type="submit"
name={provider}
fullWidth
onClick={() => form.setFieldValue("provider", provider)}
loading={isPending && isCurrentProviderActive}
disabled={isPending && !isCurrentProviderActive}
>
{children}
</Button>
);
};
interface PasswordForgottenCollapseProps {
username: string;
}
const PasswordForgottenCollapse = ({ username }: PasswordForgottenCollapseProps) => {
const [visible, { toggle }] = useDisclosure(false);
const tForgotPassword = useScopedI18n("user.action.login.forgotPassword");
const commandUsername = username.trim().length >= 1 ? username.trim() : "<username>";
return (
<>
<Anchor type="button" component="button" onClick={toggle}>
{tForgotPassword("label")}
</Anchor>
<Collapse in={visible}>
<Card>
<Stack gap="xs">
<Text size="sm">{tForgotPassword("description")}</Text>
<Code>homarr reset-password -u {commandUsername}</Code>
</Stack>
</Card>
</Collapse>
</>
);
};
type FormType = z.infer<typeof extendedValidation>;
@@ -1,26 +1,11 @@
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.mjs";
import { auth } from "@homarr/auth/next";
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 { LoginForm } from "./_login-form"; import { LoginForm } from "./_login-form";
interface LoginProps { export default async function Login() {
searchParams: {
callbackUrl?: string;
};
}
export default async function Login({ searchParams }: LoginProps) {
const session = await auth();
if (session) {
redirect(searchParams.callbackUrl ?? "/");
}
const t = await getScopedI18n("user.page.login"); const t = await getScopedI18n("user.page.login");
return ( return (
@@ -36,12 +21,7 @@ export default async function Login({ searchParams }: LoginProps) {
</Text> </Text>
</Stack> </Stack>
<Card bg="dark.8" w={64 * 6} maw="90vw"> <Card bg="dark.8" w={64 * 6} maw="90vw">
<LoginForm <LoginForm />
providers={env.AUTH_PROVIDERS}
oidcClientName={env.AUTH_OIDC_CLIENT_NAME}
isOidcAutoLoginEnabled={env.AUTH_OIDC_AUTO_LOGIN}
callbackUrl={searchParams.callbackUrl ?? "/"}
/>
</Card> </Card>
</Stack> </Stack>
</Center> </Center>
@@ -1,10 +1,11 @@
"use client"; "use client";
import { useCallback, useRef } from "react"; import { useCallback, useEffect, useRef } from "react";
import { Box, LoadingOverlay, Stack } from "@mantine/core"; 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 { DDGridStack } from "@homarr/gridstack";
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";
@@ -40,15 +41,47 @@ export const useUpdateBoard = () => {
}; };
}; };
const dd = new DDGridStack();
export const ClientBoard = () => { export const ClientBoard = () => {
const board = useRequiredBoard(); const board = useRequiredBoard();
const isReady = useIsBoardReady(); const isReady = useIsBoardReady();
const { updateBoard } = useUpdateBoard();
const fullWidthSortedSections = board.sections const sortedSections = board.sections.sort((sectionA, sectionB) => sectionA.position - sectionB.position);
.filter((section) => section.kind === "empty" || section.kind === "category")
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const boxRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!boxRef.current) return;
dd.droppable(boxRef.current, {
accept: () => true,
})
.on(boxRef.current, "dropover", (event, el, helper) => {
console.log("dropover", event, el, helper);
})
.on(boxRef.current, "dropout", (event, el, helper) => {
console.log("dropout", event, el, helper);
})
.on(boxRef.current, "drop", (event, el, helper) => {
console.log("drop", event, el, helper);
const itemId = el.getAttribute("data-id");
if (!itemId) return;
// Same as removeItem
updateBoard((previous) => {
return {
...previous,
// Filter removed item out of items array
sections: previous.sections.map((section) => ({
...section,
items: section.items.filter((item) => item.id !== itemId),
})),
};
});
});
}, []);
return ( return (
<Box h="100%" pos="relative"> <Box h="100%" pos="relative">
@@ -60,11 +93,12 @@ export const ClientBoard = () => {
h={fullHeightWithoutHeaderAndFooter} h={fullHeightWithoutHeaderAndFooter}
/> />
<Stack ref={ref} h="100%" style={{ visibility: isReady ? "visible" : "hidden" }}> <Stack ref={ref} h="100%" style={{ visibility: isReady ? "visible" : "hidden" }}>
{fullWidthSortedSections.map((section) => <Box ref={boxRef} w="100%" h={80} bg="red"></Box>
{sortedSections.map((section) =>
section.kind === "empty" ? ( section.kind === "empty" ? (
<BoardEmptySection key={section.id} section={section} /> <BoardEmptySection key={section.id} section={section} mainRef={ref} />
) : ( ) : (
<BoardCategorySection key={section.id} section={section} /> <BoardCategorySection key={section.id} section={section} mainRef={ref} />
), ),
)} )}
</Stack> </Stack>
@@ -49,7 +49,6 @@ export const BoardProvider = ({
useEffect(() => { useEffect(() => {
setReadySections((previous) => previous.filter((id) => data.sections.some((section) => section.id === id))); setReadySections((previous) => previous.filter((id) => data.sections.some((section) => section.id === id)));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data.sections.length, setReadySections]); }, [data.sections.length, setReadySections]);
const markAsReady = useCallback((id: string) => { const markAsReady = useCallback((id: string) => {
@@ -4,9 +4,6 @@ import { TRPCError } from "@trpc/server";
// Placed here because gridstack styles are used for board content // Placed here because gridstack styles are used for board content
import "~/styles/gridstack.scss"; import "~/styles/gridstack.scss";
import { IntegrationProvider } from "@homarr/auth/client";
import { auth } from "@homarr/auth/next";
import { getIntegrationsWithPermissionsAsync } from "@homarr/auth/server";
import { getI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { createMetaTitle } from "~/metadata"; import { createMetaTitle } from "~/metadata";
@@ -28,17 +25,10 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
layout: createBoardLayout({ layout: createBoardLayout({
headerActions: <BoardContentHeaderActions />, headerActions: <BoardContentHeaderActions />,
getInitialBoardAsync: getInitialBoard, getInitialBoardAsync: getInitialBoard,
isBoardContentPage: true,
}), }),
// eslint-disable-next-line no-restricted-syntax page: () => {
page: async () => { return <ClientBoard />;
const session = await auth();
const integrations = await getIntegrationsWithPermissionsAsync(session);
return (
<IntegrationProvider integrations={integrations}>
<ClientBoard />
</IntegrationProvider>
);
}, },
generateMetadataAsync: async ({ params }: { params: TParams }): Promise<Metadata> => { generateMetadataAsync: async ({ params }: { params: TParams }): Promise<Metadata> => {
try { try {
@@ -49,10 +39,6 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
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: board.faviconImageUrl ? board.faviconImageUrl : undefined, icon: board.faviconImageUrl ? board.faviconImageUrl : undefined,
apple: board.faviconImageUrl ? board.faviconImageUrl : undefined,
},
appleWebApp: {
startupImage: { url: board.faviconImageUrl ? board.faviconImageUrl : "/logo/logo.png" },
}, },
}; };
} catch (error) { } catch (error) {
@@ -1,34 +1,29 @@
"use client"; "use client";
import type { MouseEvent } from "react"; import { useCallback } from "react";
import { useCallback, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Group, Menu } from "@mantine/core"; import { Group, Menu } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks";
import { import {
IconBox, IconBox,
IconBoxAlignTop, IconBoxAlignTop,
IconChevronDown, IconChevronDown,
IconPackageImport,
IconPencil, IconPencil,
IconPencilOff, IconPencilOff,
IconPlus, IconPlus,
IconResize,
IconSettings, IconSettings,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { 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";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
import { ItemSelectModal } from "~/components/board/items/item-select-modal"; import { ItemSelectModal } from "~/components/board/items/item-select-modal";
import { useBoardPermissions } from "~/components/board/permissions/client"; import { useBoardPermissions } from "~/components/board/permissions/client";
import { useCategoryActions } from "~/components/board/sections/category/category-actions"; import { useCategoryActions } from "~/components/board/sections/category/category-actions";
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 { HeaderButton } from "~/components/layout/header/button"; import { HeaderButton } from "~/components/layout/header/button";
import { env } from "~/env.mjs";
import { useEditMode, useRequiredBoard } from "./_context"; import { useEditMode, useRequiredBoard } from "./_context";
export const BoardContentHeaderActions = () => { export const BoardContentHeaderActions = () => {
@@ -57,7 +52,6 @@ const AddMenu = () => {
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal); const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal); const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
const { addCategoryToEnd } = useCategoryActions(); const { addCategoryToEnd } = useCategoryActions();
const { addDynamicSection } = useDynamicSectionActions();
const t = useI18n(); const t = useI18n();
const handleAddCategory = useCallback( const handleAddCategory = useCallback(
@@ -98,16 +92,13 @@ const AddMenu = () => {
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectItem}> <Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectItem}>
{t("item.action.create")} {t("item.action.create")}
</Menu.Item> </Menu.Item>
<Menu.Item leftSection={<IconPackageImport size={20} />}>{t("item.action.import")}</Menu.Item>
<Menu.Divider /> <Menu.Divider />
<Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}> <Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}>
{t("section.category.action.create")} {t("section.category.action.create")}
</Menu.Item> </Menu.Item>
<Menu.Item leftSection={<IconResize size={20} />} onClick={addDynamicSection}>
{t("section.dynamic.action.create")}
</Menu.Item>
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
); );
@@ -141,72 +132,9 @@ const EditModeMenu = () => {
setEditMode(true); setEditMode(true);
}, [board, isEditMode, saveBoard, setEditMode]); }, [board, isEditMode, saveBoard, setEditMode]);
useHotkeys([["mod+e", toggle]]);
usePreventLeaveWithDirty(isEditMode);
return ( return (
<HeaderButton onClick={toggle} loading={isPending}> <HeaderButton onClick={toggle} loading={isPending}>
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />} {isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
</HeaderButton> </HeaderButton>
); );
}; };
const usePreventLeaveWithDirty = (isDirty: boolean) => {
const t = useI18n();
const { openConfirmModal } = useConfirmModal();
const router = useRouter();
useEffect(() => {
const handleClick = (event: MouseEvent<HTMLElement>) => {
const target = (event.target as HTMLElement).closest("a");
if (!target) return;
if (!isDirty) return;
event.preventDefault();
openConfirmModal({
title: t("board.action.edit.confirmLeave.title"),
children: t("board.action.edit.confirmLeave.message"),
onConfirm() {
router.push(target.href);
},
confirmProps: {
children: t("common.action.discard"),
},
});
};
const handlePopState = (event: Event) => {
if (isDirty) {
window.history.pushState(null, document.title, window.location.href);
event.preventDefault();
} else {
window.history.back();
}
};
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (!isDirty) return;
if (env.NODE_ENV === "development") return; // Allow to reload in development
event.preventDefault();
event.returnValue = true;
};
document.querySelectorAll("a").forEach((link) => {
link.addEventListener("click", handleClick as never);
});
window.addEventListener("popstate", handlePopState);
window.addEventListener("beforeunload", handleBeforeUnload);
return () => {
document.querySelectorAll("a").forEach((link) => {
link.removeEventListener("click", handleClick as never);
window.removeEventListener("popstate", handlePopState);
});
window.removeEventListener("beforeunload", handleBeforeUnload);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDirty]);
};
@@ -4,17 +4,10 @@ import type { PropsWithChildren } from "react";
import type { MantineColorsTuple } from "@mantine/core"; import type { MantineColorsTuple } from "@mantine/core";
import { createTheme, darken, lighten, MantineProvider } from "@mantine/core"; import { createTheme, darken, lighten, MantineProvider } from "@mantine/core";
import type { ColorScheme } from "@homarr/definitions";
import { useColorSchemeManager } from "../../_client-providers/mantine";
import { useRequiredBoard } from "./_context"; import { useRequiredBoard } from "./_context";
export const BoardMantineProvider = ({ export const BoardMantineProvider = ({ children }: PropsWithChildren) => {
children,
defaultColorScheme,
}: PropsWithChildren<{ defaultColorScheme: ColorScheme }>) => {
const board = useRequiredBoard(); const board = useRequiredBoard();
const colorSchemeManager = useColorSchemeManager();
const theme = createTheme({ const theme = createTheme({
colors: { colors: {
@@ -25,11 +18,7 @@ export const BoardMantineProvider = ({
autoContrast: true, autoContrast: true,
}); });
return ( return <MantineProvider theme={theme}>{children}</MantineProvider>;
<MantineProvider defaultColorScheme={defaultColorScheme} theme={theme} colorSchemeManager={colorSchemeManager}>
{children}
</MantineProvider>
);
}; };
export const generateColors = (hex: string) => { export const generateColors = (hex: string) => {
@@ -8,4 +8,5 @@ export default createBoardLayout<{ locale: string; name: string }>({
async getInitialBoardAsync({ name }) { async getInitialBoardAsync({ name }) {
return await api.board.getBoardByName({ name }); return await api.board.getBoardByName({ name });
}, },
isBoardContentPage: false,
}); });
@@ -0,0 +1,101 @@
"use client";
import { useState } from "react";
import { Group, Stack, Tabs } from "@mantine/core";
import { IconUser, IconUserDown, IconUsersGroup } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { CountBadge } from "@homarr/ui";
import type { Board } from "../../_types";
import { GroupsForm } from "./_access/group-access";
import { InheritTable } from "./_access/inherit-access";
import { UsersForm } from "./_access/user-access";
interface Props {
board: Board;
initialPermissions: RouterOutputs["board"]["getBoardPermissions"];
}
export const AccessSettingsContent = ({ board, initialPermissions }: Props) => {
const { data: permissions } = clientApi.board.getBoardPermissions.useQuery(
{
id: board.id,
},
{
initialData: initialPermissions,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
);
const [counts, setCounts] = useState({
user: initialPermissions.userPermissions.length + (board.creator ? 1 : 0),
group: initialPermissions.groupPermissions.length,
});
return (
<Stack>
<Tabs color="red" defaultValue="user">
<Tabs.List grow>
<TabItem value="user" count={counts.user} icon={IconUser} />
<TabItem value="group" count={counts.group} icon={IconUsersGroup} />
<TabItem value="inherited" count={initialPermissions.inherited.length} icon={IconUserDown} />
</Tabs.List>
<Tabs.Panel value="user">
<UsersForm
board={board}
initialPermissions={permissions}
onCountChange={(callback) =>
setCounts(({ user, ...others }) => ({
user: callback(user),
...others,
}))
}
/>
</Tabs.Panel>
<Tabs.Panel value="group">
<GroupsForm
board={board}
initialPermissions={permissions}
onCountChange={(callback) =>
setCounts(({ group, ...others }) => ({
group: callback(group),
...others,
}))
}
/>
</Tabs.Panel>
<Tabs.Panel value="inherited">
<InheritTable initialPermissions={permissions} />
</Tabs.Panel>
</Tabs>
</Stack>
);
};
interface TabItemProps {
value: "user" | "group" | "inherited";
count: number;
icon: TablerIcon;
}
const TabItem = ({ value, icon: Icon, count }: TabItemProps) => {
const t = useScopedI18n("board.setting.section.access.permission");
return (
<Tabs.Tab value={value} leftSection={<Icon stroke={1.5} size={16} />}>
<Group gap="sm">
{t(`tab.${value}`)}
<CountBadge count={count} />
</Group>
</Tabs.Tab>
);
};
@@ -1,36 +1,43 @@
import type { ReactNode } from "react";
import { useCallback } from "react"; import { useCallback } from "react";
import type { ReactNode } from "react";
import type { SelectProps } from "@mantine/core"; import type { SelectProps } from "@mantine/core";
import { Button, Flex, Group, Select, TableTd, TableTr, Text } from "@mantine/core"; import { Button, Flex, Group, Select, TableTd, TableTr, Text } from "@mantine/core";
import { Icon123, IconCheck } from "@tabler/icons-react"; import { IconCheck, IconEye, IconPencil, IconSettings } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client"; import type { BoardPermission } from "@homarr/definitions";
import { boardPermissions } from "@homarr/definitions";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { useAccessContext } from "./context"; import type { OnCountChange } from "./form";
import type { HandleCountChange } from "./form";
import { useFormContext } from "./form"; import { useFormContext } from "./form";
interface AccessSelectRowProps { const icons = {
"board-change": IconPencil,
"board-view": IconEye,
"board-full": IconSettings,
} satisfies Record<BoardPermission | "board-full", TablerIcon>;
interface BoardAccessSelectRowProps {
itemContent: ReactNode; itemContent: ReactNode;
permission: string; permission: BoardPermission;
index: number; index: number;
handleCountChange: HandleCountChange; onCountChange: OnCountChange;
} }
export const AccessSelectRow = ({ itemContent, permission, index, handleCountChange }: AccessSelectRowProps) => { export const BoardAccessSelectRow = ({ itemContent, permission, index, onCountChange }: BoardAccessSelectRowProps) => {
const tRoot = useI18n(); const tRoot = useI18n();
const { icons, getSelectData } = useAccessContext(); const tPermissions = useScopedI18n("board.setting.section.access.permission");
const form = useFormContext(); const form = useFormContext();
const Icon = icons[permission];
const handleRemove = useCallback(() => { const handleRemove = useCallback(() => {
form.setFieldValue( form.setFieldValue(
"items", "items",
form.values.items.filter((_, i) => i !== index), form.values.items.filter((_, i) => i !== index),
); );
handleCountChange((prev) => prev - 1); onCountChange((prev) => prev - 1);
}, [form, index, handleCountChange]); }, [form, index, onCountChange]);
const Icon = icons[permission] ?? Icon123;
return ( return (
<TableTr> <TableTr>
@@ -43,7 +50,10 @@ export const AccessSelectRow = ({ itemContent, permission, index, handleCountCha
leftSection={<Icon size="1rem" />} leftSection={<Icon size="1rem" />}
renderOption={RenderOption} renderOption={RenderOption}
variant="unstyled" variant="unstyled"
data={getSelectData()} data={boardPermissions.map((permission) => ({
value: permission,
label: tPermissions(`item.${permission}.label`),
}))}
{...form.getInputProps(`items.${index}.permission`)} {...form.getInputProps(`items.${index}.permission`)}
/> />
@@ -56,6 +66,30 @@ export const AccessSelectRow = ({ itemContent, permission, index, handleCountCha
); );
}; };
interface BoardAccessDisplayRowProps {
itemContent: ReactNode;
permission: BoardPermission | "board-full";
}
export const BoardAccessDisplayRow = ({ itemContent, permission }: BoardAccessDisplayRowProps) => {
const tPermissions = useScopedI18n("board.setting.section.access.permission");
const Icon = icons[permission];
return (
<TableTr>
<TableTd w={{ sm: 128, lg: 256 }}>{itemContent}</TableTd>
<TableTd>
<Group gap={0}>
<Flex w={34} h={34} align="center" justify="center">
<Icon size="1rem" color="var(--input-section-color, var(--mantine-color-dimmed))" />
</Flex>
<Text size="sm">{tPermissions(`item.${permission}.label`)}</Text>
</Group>
</TableTd>
</TableTr>
);
};
const iconProps = { const iconProps = {
stroke: 1.5, stroke: 1.5,
color: "currentColor", color: "currentColor",
@@ -64,9 +98,7 @@ const iconProps = {
}; };
const RenderOption: SelectProps["renderOption"] = ({ option, checked }) => { const RenderOption: SelectProps["renderOption"] = ({ option, checked }) => {
const { icons } = useAccessContext(); const Icon = icons[option.value as BoardPermission];
const Icon = icons[option.value] ?? Icon123;
return ( return (
<Group flex="1" gap="xs" wrap="nowrap"> <Group flex="1" gap="xs" wrap="nowrap">
<Icon {...iconProps} /> <Icon {...iconProps} />
@@ -75,27 +107,3 @@ const RenderOption: SelectProps["renderOption"] = ({ option, checked }) => {
</Group> </Group>
); );
}; };
interface AccessDisplayRowProps {
itemContent: ReactNode;
permission: string;
}
export const AccessDisplayRow = ({ itemContent, permission }: AccessDisplayRowProps) => {
const { icons, translate } = useAccessContext();
const Icon = icons[permission] ?? Icon123;
return (
<TableTr>
<TableTd w={{ sm: 128, lg: 256 }}>{itemContent}</TableTd>
<TableTd>
<Group gap={0}>
<Flex w={34} h={34} align="center" justify="center">
<Icon size="1rem" color="var(--input-section-color, var(--mantine-color-dimmed))" />
</Flex>
<Text size="sm">{translate(permission)}</Text>
</Group>
</TableTd>
</TableTr>
);
};
@@ -0,0 +1,13 @@
import type { BoardPermission } from "@homarr/definitions";
import { createFormContext } from "@homarr/form";
export interface BoardAccessFormType {
items: {
itemId: string;
permission: BoardPermission;
}[];
}
export const [FormProvider, useFormContext, useForm] = createFormContext<BoardAccessFormType>();
export type OnCountChange = (callback: (prev: number) => number) => void;
@@ -1,60 +1,73 @@
import { useState } from "react"; import { useCallback, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { Anchor, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core"; import { Anchor, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core";
import { IconPlus } from "@tabler/icons-react"; import { IconPlus } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useModalAction } from "@homarr/modals"; import { useModalAction } from "@homarr/modals";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { AccessQueryData } from "./access-settings"; import { BoardAccessSelectRow } from "./board-access-table-rows";
import { AccessSelectRow } from "./access-table-rows"; import type { BoardAccessFormType } from "./form";
import { useAccessContext } from "./context";
import type { AccessFormType } from "./form";
import { FormProvider, useForm } from "./form"; import { FormProvider, useForm } from "./form";
import { GroupSelectModal } from "./group-select-modal"; import { GroupSelectModal } from "./group-select-modal";
import type { FormProps } from "./user-access-form"; import type { FormProps } from "./user-access";
export const GroupAccessForm = <TPermission extends string>({ export const GroupsForm = ({ board, initialPermissions, onCountChange }: FormProps) => {
accessQueryData, const { mutate, isPending } = clientApi.board.saveGroupBoardPermissions.useMutation();
handleCountChange, const utils = clientApi.useUtils();
handleSubmit, const [groups, setGroups] = useState<Map<string, Group>>(
isPending, new Map(initialPermissions.groupPermissions.map(({ group }) => [group.id, group])),
}: Omit<FormProps<TPermission>, "entity">) => {
const { defaultPermission } = useAccessContext();
const [groups, setGroups] = useState<Map<string, AccessQueryData<string>["groups"][number]["group"]>>(
new Map(accessQueryData.groups.map(({ group }) => [group.id, group])),
); );
const { openModal } = useModalAction(GroupSelectModal); const { openModal } = useModalAction(GroupSelectModal);
const t = useI18n(); const t = useI18n();
const tPermissions = useScopedI18n("permission"); const tPermissions = useScopedI18n("board.setting.section.access.permission");
const form = useForm({ const form = useForm({
initialValues: { initialValues: {
items: accessQueryData.groups.map(({ group, permission }) => ({ items: initialPermissions.groupPermissions.map(({ group, permission }) => ({
principalId: group.id, itemId: group.id,
permission, permission,
})), })),
}, },
}); });
const handleAddUser = () => { const handleSubmit = useCallback(
(values: BoardAccessFormType) => {
mutate(
{
id: board.id,
permissions: values.items,
},
{
onSuccess: () => {
void utils.board.getBoardPermissions.invalidate();
},
},
);
},
[board.id, mutate, utils.board.getBoardPermissions],
);
const handleAddUser = useCallback(() => {
openModal({ openModal({
presentGroupIds: form.values.items.map(({ principalId: id }) => id), presentGroupIds: form.values.items.map(({ itemId: id }) => id),
onSelect: (group) => { onSelect: (group) => {
setGroups((prev) => new Map(prev).set(group.id, group)); setGroups((prev) => new Map(prev).set(group.id, group));
form.setFieldValue("items", [ form.setFieldValue("items", [
{ {
principalId: group.id, itemId: group.id,
permission: defaultPermission, permission: "board-view",
}, },
...form.values.items, ...form.values.items,
]); ]);
handleCountChange((prev) => prev + 1); onCountChange((prev) => prev + 1);
}, },
}); });
}; }, [form, openModal, onCountChange]);
return ( return (
<form onSubmit={form.onSubmit((values) => handleSubmit(values as AccessFormType<TPermission>))}> <form onSubmit={form.onSubmit(handleSubmit)}>
<FormProvider form={form}> <FormProvider form={form}>
<Stack pt="sm"> <Stack pt="sm">
<Table> <Table>
@@ -66,13 +79,13 @@ export const GroupAccessForm = <TPermission extends string>({
</TableThead> </TableThead>
<TableTbody> <TableTbody>
{form.values.items.map((row, index) => ( {form.values.items.map((row, index) => (
<AccessSelectRow <BoardAccessSelectRow
key={row.principalId} key={row.itemId}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
itemContent={<GroupItemContent group={groups.get(row.principalId)!} />} itemContent={<GroupItemContent group={groups.get(row.itemId)!} />}
permission={row.permission} permission={row.permission}
index={index} index={index}
handleCountChange={handleCountChange} onCountChange={onCountChange}
/> />
))} ))}
</TableTbody> </TableTbody>
@@ -83,7 +96,7 @@ export const GroupAccessForm = <TPermission extends string>({
{t("common.action.add")} {t("common.action.add")}
</Button> </Button>
<Button type="submit" loading={isPending} color="teal"> <Button type="submit" loading={isPending} color="teal">
{t("permission.action.saveGroup")} {t("common.action.saveChanges")}
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
@@ -92,10 +105,12 @@ export const GroupAccessForm = <TPermission extends string>({
); );
}; };
export const GroupItemContent = ({ group }: { group: AccessQueryData<string>["groups"][number]["group"] }) => { export const GroupItemContent = ({ group }: { group: Group }) => {
return ( return (
<Anchor component={Link} href={`/manage/users/groups/${group.id}`} size="sm" style={{ whiteSpace: "nowrap" }}> <Anchor component={Link} href={`/manage/users/groups/${group.id}`} size="sm" style={{ whiteSpace: "nowrap" }}>
{group.name} {group.name}
</Anchor> </Anchor>
); );
}; };
type Group = RouterOutputs["board"]["getBoardPermissions"]["groupPermissions"][0]["group"];
@@ -2,15 +2,13 @@ import { useState } from "react";
import { Button, Group, Loader, Select, Stack } from "@mantine/core"; import { Button, Group, Loader, Select, Stack } from "@mantine/core";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import type { GroupPermissionKey } from "@homarr/definitions";
import { useForm } from "@homarr/form"; import { useForm } from "@homarr/form";
import { createModal } from "@homarr/modals"; import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
interface InnerProps { interface InnerProps {
withPermissions?: boolean;
presentGroupIds: string[]; presentGroupIds: string[];
onSelect: (props: { id: string; name: string; permissions?: GroupPermissionKey[] }) => void | Promise<void>; onSelect: (props: { id: string; name: string }) => void | Promise<void>;
confirmLabel?: string; confirmLabel?: string;
} }
@@ -20,9 +18,7 @@ interface GroupSelectFormType {
export const GroupSelectModal = createModal<InnerProps>(({ actions, innerProps }) => { export const GroupSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
const t = useI18n(); const t = useI18n();
const { data: groups, isPending } = clientApi.group.selectable.useQuery({ const { data: groups, isPending } = clientApi.group.selectable.useQuery();
withPermissions: innerProps.withPermissions,
});
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const form = useForm<GroupSelectFormType>(); const form = useForm<GroupSelectFormType>();
const handleSubmitAsync = async (values: GroupSelectFormType) => { const handleSubmitAsync = async (values: GroupSelectFormType) => {
@@ -32,7 +28,6 @@ export const GroupSelectModal = createModal<InnerProps>(({ actions, innerProps }
await innerProps.onSelect({ await innerProps.onSelect({
id: currentGroup.id, id: currentGroup.id,
name: currentGroup.name, name: currentGroup.name,
permissions: "permissions" in currentGroup ? (currentGroup.permissions as GroupPermissionKey[]) : undefined,
}); });
setLoading(false); setLoading(false);
@@ -68,5 +63,5 @@ export const GroupSelectModal = createModal<InnerProps>(({ actions, innerProps }
</form> </form>
); );
}).withOptions({ }).withOptions({
defaultTitle: (t) => t("permission.groupSelect.title"), defaultTitle: (t) => t("board.setting.section.access.permission.groupSelect.title"),
}); });
@@ -0,0 +1,57 @@
import { Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { getPermissionsWithChildren } from "@homarr/definitions";
import type { BoardPermission, GroupPermissionKey } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { BoardAccessDisplayRow } from "./board-access-table-rows";
import { GroupItemContent } from "./group-access";
export interface InheritTableProps {
initialPermissions: RouterOutputs["board"]["getBoardPermissions"];
}
const mapPermissions = {
"board-full-access": "board-full",
"board-modify-all": "board-change",
"board-view-all": "board-view",
} satisfies Partial<Record<GroupPermissionKey, BoardPermission | "board-full">>;
export const InheritTable = ({ initialPermissions }: InheritTableProps) => {
const tPermissions = useScopedI18n("board.setting.section.access.permission");
return (
<Stack pt="sm">
<Table>
<TableThead>
<TableTr>
<TableTh>{tPermissions("field.user.label")}</TableTh>
<TableTh>{tPermissions("field.permission.label")}</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{initialPermissions.inherited.map(({ group, permission }) => {
const boardPermission =
permission in mapPermissions
? mapPermissions[permission as keyof typeof mapPermissions]
: getPermissionsWithChildren([permission]).includes("board-full-access")
? "board-full"
: null;
if (!boardPermission) {
return null;
}
return (
<BoardAccessDisplayRow
key={group.id}
itemContent={<GroupItemContent group={group} />}
permission={boardPermission}
/>
);
})}
</TableTbody>
</Table>
</Stack>
);
};
@@ -0,0 +1,137 @@
import { useCallback, useState } from "react";
import Link from "next/link";
import { Anchor, Box, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useModalAction } from "@homarr/modals";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui";
import type { Board } from "../../../_types";
import { BoardAccessDisplayRow, BoardAccessSelectRow } from "./board-access-table-rows";
import type { BoardAccessFormType, OnCountChange } from "./form";
import { FormProvider, useForm } from "./form";
import { UserSelectModal } from "./user-select-modal";
export interface FormProps {
board: Pick<Board, "id" | "creatorId" | "creator">;
initialPermissions: RouterOutputs["board"]["getBoardPermissions"];
onCountChange: OnCountChange;
}
export const UsersForm = ({ board, initialPermissions, onCountChange }: FormProps) => {
const { mutate, isPending } = clientApi.board.saveUserBoardPermissions.useMutation();
const utils = clientApi.useUtils();
const [users, setUsers] = useState<Map<string, User>>(
new Map(initialPermissions.userPermissions.map(({ user }) => [user.id, user])),
);
const { openModal } = useModalAction(UserSelectModal);
const t = useI18n();
const tPermissions = useScopedI18n("board.setting.section.access.permission");
const form = useForm({
initialValues: {
items: initialPermissions.userPermissions.map(({ user, permission }) => ({
itemId: user.id,
permission,
})),
},
});
const handleSubmit = useCallback(
(values: BoardAccessFormType) => {
mutate(
{
id: board.id,
permissions: values.items,
},
{
onSuccess: () => {
void utils.board.getBoardPermissions.invalidate();
},
},
);
},
[board.id, mutate, utils.board.getBoardPermissions],
);
const handleAddUser = useCallback(() => {
const presentUserIds = form.values.items.map(({ itemId: id }) => id);
openModal({
presentUserIds: board.creatorId ? presentUserIds.concat(board.creatorId) : presentUserIds,
onSelect: (user) => {
setUsers((prev) => new Map(prev).set(user.id, user));
form.setFieldValue("items", [
{
itemId: user.id,
permission: "board-view",
},
...form.values.items,
]);
onCountChange((prev) => prev + 1);
},
});
}, [form, openModal, board.creatorId, onCountChange]);
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<FormProvider form={form}>
<Stack pt="sm">
<Table>
<TableThead>
<TableTr>
<TableTh>{tPermissions("field.user.label")}</TableTh>
<TableTh>{tPermissions("field.permission.label")}</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{board.creator && (
<BoardAccessDisplayRow itemContent={<UserItemContent user={board.creator} />} permission="board-full" />
)}
{form.values.items.map((row, index) => (
<BoardAccessSelectRow
key={row.itemId}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
itemContent={<UserItemContent user={users.get(row.itemId)!} />}
permission={row.permission}
index={index}
onCountChange={onCountChange}
/>
))}
</TableTbody>
</Table>
<Group justify="space-between">
<Button rightSection={<IconPlus size="1rem" />} variant="light" onClick={handleAddUser}>
{t("common.action.add")}
</Button>
<Button type="submit" loading={isPending} color="teal">
{t("common.action.saveChanges")}
</Button>
</Group>
</Stack>
</FormProvider>
</form>
);
};
const UserItemContent = ({ user }: { user: User }) => {
return (
<Group wrap="nowrap">
<Box visibleFrom="xs">
<UserAvatar user={user} size="sm" />
</Box>
<Anchor component={Link} href={`/manage/users/${user.id}`} size="sm" style={{ whiteSpace: "nowrap" }}>
{user.name}
</Anchor>
</Group>
);
};
interface User {
id: string;
name: string | null;
image: string | null;
}
@@ -12,7 +12,6 @@ import { UserAvatar } from "@homarr/ui";
interface InnerProps { interface InnerProps {
presentUserIds: string[]; presentUserIds: string[];
excludeExternalProviders?: boolean;
onSelect: (props: { id: string; name: string; image: string }) => void | Promise<void>; onSelect: (props: { id: string; name: string; image: string }) => void | Promise<void>;
confirmLabel?: string; confirmLabel?: string;
} }
@@ -23,9 +22,7 @@ interface UserSelectFormType {
export const UserSelectModal = createModal<InnerProps>(({ actions, innerProps }) => { export const UserSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
const t = useI18n(); const t = useI18n();
const { data: users, isPending } = clientApi.user.selectable.useQuery({ const { data: users, isPending } = clientApi.user.selectable.useQuery();
excludeExternalProviders: innerProps.excludeExternalProviders,
});
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const form = useForm<UserSelectFormType>(); const form = useForm<UserSelectFormType>();
const handleSubmitAsync = async (values: UserSelectFormType) => { const handleSubmitAsync = async (values: UserSelectFormType) => {
@@ -75,7 +72,7 @@ export const UserSelectModal = createModal<InnerProps>(({ actions, innerProps })
</form> </form>
); );
}).withOptions({ }).withOptions({
defaultTitle: (t) => t("permission.userSelect.title"), defaultTitle: (t) => t("board.setting.section.access.permission.userSelect.title"),
}); });
const iconProps = { const iconProps = {
@@ -1,69 +0,0 @@
"use client";
import { IconEye, IconPencil, IconSettings } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { boardPermissions, boardPermissionsMap } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { AccessSettings } from "~/components/access/access-settings";
import type { Board } from "../../_types";
interface Props {
board: Board;
initialPermissions: RouterOutputs["board"]["getBoardPermissions"];
}
export const BoardAccessSettings = ({ board, initialPermissions }: Props) => {
const groupMutation = clientApi.board.saveGroupBoardPermissions.useMutation();
const userMutation = clientApi.board.saveUserBoardPermissions.useMutation();
const utils = clientApi.useUtils();
const t = useI18n();
const { data: permissions } = clientApi.board.getBoardPermissions.useQuery(
{
id: board.id,
},
{
initialData: initialPermissions,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
);
return (
<AccessSettings
entity={{
id: board.id,
ownerId: board.creatorId,
owner: board.creator,
}}
query={{
invalidate: () => utils.board.getBoardPermissions.invalidate(),
data: permissions,
}}
groupsMutation={{
mutate: groupMutation.mutate,
isPending: groupMutation.isPending,
}}
usersMutation={{
mutate: userMutation.mutate,
isPending: userMutation.isPending,
}}
translate={(key) => t(`board.setting.section.access.permission.item.${key}.label`)}
permission={{
items: boardPermissions,
default: "view",
fullAccessGroupPermission: "board-full-all",
groupPermissionMapping: boardPermissionsMap,
icons: {
modify: IconPencil,
view: IconEye,
full: IconSettings,
},
}}
/>
);
};
@@ -12,7 +12,7 @@ import { BoardRenameModal } from "~/components/board/modals/board-rename-modal";
import { useRequiredBoard } from "../../(content)/_context"; 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 = () => {
const board = useRequiredBoard(); const board = useRequiredBoard();
const t = useScopedI18n("board.setting"); const t = useScopedI18n("board.setting");
const router = useRouter(); const router = useRouter();
@@ -90,18 +90,14 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
buttonText={t("section.dangerZone.action.rename.button")} buttonText={t("section.dangerZone.action.rename.button")}
onClick={onRenameClick} onClick={onRenameClick}
/> />
{hideVisibility ? null : ( <Divider />
<> <DangerZoneRow
<Divider /> label={t("section.dangerZone.action.visibility.label")}
<DangerZoneRow description={t(`section.dangerZone.action.visibility.description.${visibility}`)}
label={t("section.dangerZone.action.visibility.label")} buttonText={t(`section.dangerZone.action.visibility.button.${visibility}`)}
description={t(`section.dangerZone.action.visibility.description.${visibility}`)} onClick={onVisibilityClick}
buttonText={t(`section.dangerZone.action.visibility.button.${visibility}`)} isPending={isChangeVisibilityPending}
onClick={onVisibilityClick} />
isPending={isChangeVisibilityPending}
/>
</>
)}
<Divider /> <Divider />
<DangerZoneRow <DangerZoneRow
label={t("section.dangerZone.action.delete.label")} label={t("section.dangerZone.action.delete.label")}
@@ -14,16 +14,14 @@ import { TRPCError } from "@trpc/server";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { capitalize } from "@homarr/common"; import { capitalize } from "@homarr/common";
import { db } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import type { TranslationObject } from "@homarr/translation"; import type { TranslationObject } from "@homarr/translation";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import type { TablerIcon } from "@homarr/ui"; 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 { AccessSettingsContent } from "./_access";
import { BackgroundSettingsContent } from "./_background"; import { BackgroundSettingsContent } from "./_background";
import { BoardAccessSettings } from "./_board-access";
import { ColorSettingsContent } from "./_colors"; import { ColorSettingsContent } from "./_colors";
import { CustomCssSettingsContent } from "./_customCss"; import { CustomCssSettingsContent } from "./_customCss";
import { DangerZoneSettingsContent } from "./_danger"; import { DangerZoneSettingsContent } from "./_danger";
@@ -46,8 +44,8 @@ const getBoardAndPermissionsAsync = async (params: Props["params"]) => {
const permissions = hasFullAccess const permissions = hasFullAccess
? await api.board.getBoardPermissions({ id: board.id }) ? await api.board.getBoardPermissions({ id: board.id })
: { : {
users: [], userPermissions: [],
groups: [], groupPermissions: [],
inherited: [], inherited: [],
}; };
@@ -65,7 +63,6 @@ const getBoardAndPermissionsAsync = async (params: Props["params"]) => {
export default async function BoardSettingsPage({ params, searchParams }: Props) { export default async function BoardSettingsPage({ params, searchParams }: Props) {
const { board, permissions } = await getBoardAndPermissionsAsync(params); const { board, permissions } = await getBoardAndPermissionsAsync(params);
const boardSettings = await getServerSettingByKeyAsync(db, "board");
const { hasFullAccess } = await getBoardPermissionsAsync(board); const { hasFullAccess } = await getBoardPermissionsAsync(board);
const t = await getScopedI18n("board.setting"); const t = await getScopedI18n("board.setting");
@@ -92,10 +89,10 @@ export default async function BoardSettingsPage({ params, searchParams }: Props)
{hasFullAccess && ( {hasFullAccess && (
<> <>
<AccordionItemFor value="access" icon={IconUser}> <AccordionItemFor value="access" icon={IconUser}>
<BoardAccessSettings board={board} initialPermissions={permissions} /> <AccessSettingsContent board={board} initialPermissions={permissions} />
</AccordionItemFor> </AccordionItemFor>
<AccordionItemFor value="dangerZone" icon={IconAlertTriangle} danger noPadding> <AccordionItemFor value="dangerZone" icon={IconAlertTriangle} danger noPadding>
<DangerZoneSettingsContent hideVisibility={boardSettings.homeBoardId === board.id} /> <DangerZoneSettingsContent />
</AccordionItemFor> </AccordionItemFor>
</> </>
)} )}
@@ -4,11 +4,11 @@ import { AppShellMain } from "@mantine/core";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { logger } from "@homarr/log"; import { logger } from "@homarr/log";
import { GlobalItemServerDataRunner } from "@homarr/widgets";
import { MainHeader } from "~/components/layout/header"; import { MainHeader } from "~/components/layout/header";
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo"; 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 type { Board } from "./_types"; import type { Board } from "./_types";
import { BoardProvider } from "./(content)/_context"; import { BoardProvider } from "./(content)/_context";
import type { Params } from "./(content)/_creator"; import type { Params } from "./(content)/_creator";
@@ -18,11 +18,13 @@ import { BoardMantineProvider } from "./(content)/_theme";
interface CreateBoardLayoutProps<TParams extends Params> { interface CreateBoardLayoutProps<TParams extends Params> {
headerActions: JSX.Element; headerActions: JSX.Element;
getInitialBoardAsync: (params: TParams) => Promise<Board>; getInitialBoardAsync: (params: TParams) => Promise<Board>;
isBoardContentPage: boolean;
} }
export const createBoardLayout = <TParams extends Params>({ export const createBoardLayout = <TParams extends Params>({
headerActions, headerActions,
getInitialBoardAsync: getInitialBoard, getInitialBoardAsync: getInitialBoard,
isBoardContentPage,
}: CreateBoardLayoutProps<TParams>) => { }: CreateBoardLayoutProps<TParams>) => {
const Layout = async ({ const Layout = async ({
params, params,
@@ -38,22 +40,23 @@ export const createBoardLayout = <TParams extends Params>({
throw error; throw error;
}); });
const colorScheme = await getCurrentColorSchemeAsync();
return ( return (
<BoardProvider initialBoard={initialBoard}> <GlobalItemServerDataRunner board={initialBoard} shouldRun={isBoardContentPage}>
<BoardMantineProvider defaultColorScheme={colorScheme}> <BoardProvider initialBoard={initialBoard}>
<CustomCss /> <BoardMantineProvider>
<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>
</BoardProvider> </BoardMantineProvider>
</BoardProvider>
</GlobalItemServerDataRunner>
); );
}; };
@@ -7,6 +7,5 @@ export type Item = Section["items"][number];
export type CategorySection = Extract<Section, { kind: "category" }>; export type CategorySection = Extract<Section, { kind: "category" }>;
export type EmptySection = Extract<Section, { kind: "empty" }>; export type EmptySection = Extract<Section, { kind: "empty" }>;
export type DynamicSection = Extract<Section, { kind: "dynamic" }>;
export type ItemOfKind<TKind extends WidgetKind> = Extract<Item, { kind: TKind }>; export type ItemOfKind<TKind extends WidgetKind> = Extract<Item, { kind: TKind }>;
@@ -7,7 +7,6 @@ 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 type { z } from "@homarr/validation"; import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation"; import { validation } from "@homarr/validation";
@@ -51,11 +50,7 @@ export const InitUserForm = () => {
> >
<Stack gap="lg"> <Stack gap="lg">
<TextInput label={t("field.username.label")} {...form.getInputProps("username")} /> <TextInput label={t("field.username.label")} {...form.getInputProps("username")} />
<CustomPasswordInput <PasswordInput label={t("field.password.label")} {...form.getInputProps("password")} />
withPasswordRequirements
label={t("field.password.label")}
{...form.getInputProps("password")}
/>
<PasswordInput label={t("field.passwordConfirm.label")} {...form.getInputProps("confirmPassword")} /> <PasswordInput label={t("field.passwordConfirm.label")} {...form.getInputProps("confirmPassword")} />
<Button type="submit" fullWidth loading={isPending}> <Button type="submit" fullWidth loading={isPending}>
{t("action.create")} {t("action.create")}
+28 -50
View File
@@ -1,28 +1,19 @@
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google"; import { Inter } from "next/font/google";
import "@homarr/ui/styles.css";
import "@homarr/notifications/styles.css"; import "@homarr/notifications/styles.css";
import "@homarr/spotlight/styles.css"; import "@homarr/spotlight/styles.css";
import "@homarr/ui/styles.css";
import "~/styles/scroll-area.scss";
import { notFound } from "next/navigation"; import { ColorSchemeScript, createTheme, MantineProvider } from "@mantine/core";
import { NextIntlClientProvider } from "next-intl";
import { env } from "@homarr/auth/env.mjs";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { ModalProvider } from "@homarr/modals"; import { ModalProvider } from "@homarr/modals";
import { Notifications } from "@homarr/notifications"; import { Notifications } from "@homarr/notifications";
import { SpotlightProvider } from "@homarr/spotlight";
import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
import { getI18nMessages } from "@homarr/translation/server";
import { Analytics } from "~/components/layout/analytics"; import { Analytics } from "~/components/layout/analytics";
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
import { DayJsLoader } from "./_client-providers/dayjs-loader";
import { JotaiProvider } from "./_client-providers/jotai"; import { JotaiProvider } from "./_client-providers/jotai";
import { CustomMantineProvider } from "./_client-providers/mantine"; import { NextInternationalProvider } from "./_client-providers/next-international";
import { AuthProvider } from "./_client-providers/session"; import { AuthProvider } from "./_client-providers/session";
import { TRPCReactProvider } from "./_client-providers/trpc"; import { TRPCReactProvider } from "./_client-providers/trpc";
import { composeWrappers } from "./compose"; import { composeWrappers } from "./compose";
@@ -32,8 +23,8 @@ const fontSans = Inter({
variable: "--font-sans", variable: "--font-sans",
}); });
// eslint-disable-next-line no-restricted-syntax export const metadata: Metadata = {
export const generateMetadata = async (): Promise<Metadata> => ({ metadataBase: new URL("http://localhost:3000"),
title: "Homarr", title: "Homarr",
description: description:
"Simplify the management of your server with Homarr - a sleek, modern dashboard that puts all of your apps and services at your fingertips.", "Simplify the management of your server with Homarr - a sleek, modern dashboard that puts all of your apps and services at your fingertips.",
@@ -44,17 +35,12 @@ export const generateMetadata = async (): Promise<Metadata> => ({
url: "https://homarr.dev", url: "https://homarr.dev",
siteName: "Homarr Documentation", siteName: "Homarr Documentation",
}, },
icons: { twitter: {
icon: "/logo/logo.png", card: "summary_large_image",
apple: "/logo/logo.png", site: "@jullerino",
creator: "@jullerino",
}, },
appleWebApp: { };
title: "Homarr",
capable: true,
startupImage: { url: "/logo/logo.png" },
statusBarStyle: (await getCurrentColorSchemeAsync()) === "dark" ? "black-translucent" : "default",
},
});
export const viewport: Viewport = { export const viewport: Viewport = {
themeColor: [ themeColor: [
@@ -63,43 +49,35 @@ export const viewport: Viewport = {
], ],
}; };
export default async function Layout(props: { children: React.ReactNode; params: { locale: string } }) { export default function Layout(props: { children: React.ReactNode; params: { locale: string } }) {
if (!isLocaleSupported(props.params.locale)) { const colorScheme = "dark";
notFound();
}
const session = await auth();
const colorScheme = await getCurrentColorSchemeAsync();
const direction = isLocaleRTL(props.params.locale) ? "rtl" : "ltr";
const i18nMessages = await getI18nMessages();
const StackedProvider = composeWrappers([ const StackedProvider = composeWrappers([
(innerProps) => { async (innerProps) => {
return <AuthProvider session={session} logoutUrl={env.AUTH_LOGOUT_REDIRECT_URL} {...innerProps} />; const session = await auth();
return <AuthProvider session={session} {...innerProps} />;
}, },
(innerProps) => <JotaiProvider {...innerProps} />, (innerProps) => <JotaiProvider {...innerProps} />,
(innerProps) => <TRPCReactProvider {...innerProps} />, (innerProps) => <TRPCReactProvider {...innerProps} />,
(innerProps) => <DayJsLoader {...innerProps} />, (innerProps) => <NextInternationalProvider {...innerProps} locale={props.params.locale} />,
(innerProps) => <NextIntlClientProvider {...innerProps} messages={i18nMessages} />, (innerProps) => (
(innerProps) => <CustomMantineProvider {...innerProps} defaultColorScheme={colorScheme} />, <MantineProvider
{...innerProps}
defaultColorScheme="dark"
theme={createTheme({
primaryColor: "red",
autoContrast: true,
})}
/>
),
(innerProps) => <ModalProvider {...innerProps} />, (innerProps) => <ModalProvider {...innerProps} />,
(innerProps) => <SpotlightProvider {...innerProps} />,
]); ]);
return ( return (
// Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering <html lang="en" suppressHydrationWarning>
<html
lang={props.params.locale}
dir={direction}
data-mantine-color-scheme={colorScheme}
style={{
backgroundColor: colorScheme === "dark" ? "#242424" : "#fff",
}}
suppressHydrationWarning
>
<head> <head>
<ColorSchemeScript defaultColorScheme={colorScheme} />
<Analytics /> <Analytics />
<SearchEngineOptimization />
</head> </head>
<body className={["font-sans", fontSans.variable].join(" ")}> <body className={["font-sans", fontSans.variable].join(" ")}>
<StackedProvider> <StackedProvider>
@@ -19,7 +19,7 @@
transform: translateY(0); transform: translateY(0);
} }
100% { 100% {
transform: translateY(-50.8%); transform: translateY(-50%);
} }
} }
@@ -1,4 +1,3 @@
import { headers } from "next/headers";
import Image from "next/image"; import Image from "next/image";
import { import {
Accordion, Accordion,
@@ -18,15 +17,16 @@ import {
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
import { IconLanguage, IconLibrary, IconUsers } from "@tabler/icons-react"; import { IconLanguage, IconLibrary, IconUsers } from "@tabler/icons-react";
import { setStaticParamsLocale } from "next-international/server";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n, getStaticParams } from "@homarr/translation/server";
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo"; import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { createMetaTitle } from "~/metadata"; import { createMetaTitle } from "~/metadata";
import { getPackageAttributesAsync } from "~/versions/package-reader"; import { getPackageAttributesAsync } from "~/versions/package-reader";
import type githubContributorsJson from "../../../../../../../static-data/contributors.json"; import contributorsData from "../../../../../../../static-data/contributors.json";
import type crowdinContributorsJson from "../../../../../../../static-data/translators.json"; import translatorsData from "../../../../../../../static-data/translators.json";
import classes from "./about.module.css"; import classes from "./about.module.css";
export async function generateMetadata() { export async function generateMetadata() {
@@ -37,26 +37,16 @@ export async function generateMetadata() {
}; };
} }
const getHost = () => { interface PageProps {
if (process.env.HOSTNAME) { params: {
return `${process.env.HOSTNAME}:3000`; locale: string;
} };
}
return headers().get("host"); export default async function AboutPage({ params: { locale } }: PageProps) {
}; setStaticParamsLocale(locale);
export default async function AboutPage() {
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) =>
res.json(),
)) as typeof githubContributorsJson;
const crowdinContributors = (await fetch(`${baseServerUrl}/api/about/contributors/crowdin`).then((res) =>
res.json(),
)) as typeof crowdinContributorsJson;
return ( return (
<div> <div>
<DynamicBreadcrumb /> <DynamicBreadcrumb />
@@ -80,14 +70,14 @@ export default async function AboutPage() {
<Text>{t("accordion.contributors.title")}</Text> <Text>{t("accordion.contributors.title")}</Text>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
{t("accordion.contributors.subtitle", { {t("accordion.contributors.subtitle", {
count: githubContributors.length, count: contributorsData.length,
})} })}
</Text> </Text>
</Stack> </Stack>
</AccordionControl> </AccordionControl>
<AccordionPanel> <AccordionPanel>
<Flex wrap="wrap" gap="xs"> <Flex wrap="wrap" gap="xs">
{githubContributors.map((contributor) => ( {contributorsData.map((contributor) => (
<GenericContributorLinkCard <GenericContributorLinkCard
key={contributor.login} key={contributor.login}
link={`https://github.com/${contributor.login}`} link={`https://github.com/${contributor.login}`}
@@ -104,14 +94,14 @@ export default async function AboutPage() {
<Text>{t("accordion.translators.title")}</Text> <Text>{t("accordion.translators.title")}</Text>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
{t("accordion.translators.subtitle", { {t("accordion.translators.subtitle", {
count: crowdinContributors.length, count: translatorsData.length,
})} })}
</Text> </Text>
</Stack> </Stack>
</AccordionControl> </AccordionControl>
<AccordionPanel> <AccordionPanel>
<Flex wrap="wrap" gap="xs"> <Flex wrap="wrap" gap="xs">
{crowdinContributors.map((translator) => ( {translatorsData.map((translator) => (
<GenericContributorLinkCard <GenericContributorLinkCard
key={translator.username} key={translator.username}
link={`https://crowdin.com/profile/${translator.username}`} link={`https://crowdin.com/profile/${translator.username}`}
@@ -174,3 +164,9 @@ const GenericContributorLinkCard = ({ name, image, link }: GenericContributorLin
</AspectRatio> </AspectRatio>
); );
}; };
export function generateStaticParams() {
return getStaticParams();
}
export const dynamic = "force-static";
@@ -6,11 +6,12 @@ import { 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 { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals"; import { useConfirmModal } from "@homarr/modals";
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 { revalidatePathActionAsync } from "../../../revalidatePathAction";
interface AppDeleteButtonProps { interface AppDeleteButtonProps {
app: RouterOutputs["app"]["all"][number]; app: RouterOutputs["app"]["all"][number];
} }
@@ -5,12 +5,12 @@ import { useRouter } from "next/navigation";
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 { 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, z } from "@homarr/validation"; import type { validation, z } from "@homarr/validation";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
import { AppForm } from "../../_form"; import { AppForm } from "../../_form";
interface AppEditFormProps { interface AppEditFormProps {
@@ -1,8 +1,6 @@
import { notFound } from "next/navigation";
import { Container, Stack, Title } from "@mantine/core"; import { Container, Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
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";
@@ -13,11 +11,6 @@ interface AppEditPageProps {
} }
export default async function AppEditPage({ params }: AppEditPageProps) { export default async function AppEditPage({ params }: AppEditPageProps) {
const session = await auth();
if (!session?.user.permissions.includes("app-modify-all")) {
notFound();
}
const app = await api.app.byId({ id: params.id }); const app = await api.app.byId({ id: params.id });
const t = await getI18n(); const t = await getI18n();
@@ -4,12 +4,12 @@ import { useCallback } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/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, z } from "@homarr/validation"; import type { validation, z } from "@homarr/validation";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
import { AppForm } from "../_form"; import { AppForm } from "../_form";
export const AppNewForm = () => { export const AppNewForm = () => {
@@ -1,19 +1,11 @@
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 { 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"; import { AppNewForm } from "./_app-new-form";
export default async function AppNewPage() { export default async function AppNewPage() {
const session = await auth();
if (!session?.user.permissions.includes("app-create")) {
notFound();
}
const t = await getI18n(); const t = await getI18n();
return ( return (
@@ -1,27 +1,17 @@
import Link from "next/link"; import Link from "next/link";
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 { IconApps, IconPencil } 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";
import { auth } from "@homarr/auth/next";
import { parseAppHrefWithVariablesServer } from "@homarr/common/server";
import { getI18n, getScopedI18n } from "@homarr/translation/server"; import { getI18n, getScopedI18n } from "@homarr/translation/server";
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";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NoResults } from "~/components/no-results";
import { AppDeleteButton } from "./_app-delete-button"; import { AppDeleteButton } from "./_app-delete-button";
export default async function AppsPage() { export default async function AppsPage() {
const session = await auth();
if (!session) {
redirect("/auth/login");
}
const apps = await api.app.all(); const apps = await api.app.all();
const t = await getScopedI18n("app"); const t = await getScopedI18n("app");
@@ -31,11 +21,9 @@ export default async function AppsPage() {
<Stack> <Stack>
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<Title>{t("page.list.title")}</Title> <Title>{t("page.list.title")}</Title>
{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")} </MobileAffixButton>
</MobileAffixButton>
)}
</Group> </Group>
{apps.length === 0 && <AppNoResults />} {apps.length === 0 && <AppNoResults />}
{apps.length > 0 && ( {apps.length > 0 && (
@@ -56,7 +44,6 @@ interface AppCardProps {
const AppCard = async ({ app }: AppCardProps) => { const AppCard = async ({ app }: AppCardProps) => {
const t = await getScopedI18n("app"); const t = await getScopedI18n("app");
const session = await auth();
return ( return (
<Card> <Card>
@@ -82,26 +69,24 @@ const AppCard = async ({ app }: AppCardProps) => {
</Text> </Text>
)} )}
{app.href && ( {app.href && (
<Anchor href={parseAppHrefWithVariablesServer(app.href)} lineClamp={1} size="sm" w="min-content"> <Anchor href={app.href} lineClamp={1} size="sm" w="min-content">
{parseAppHrefWithVariablesServer(app.href)} {app.href}
</Anchor> </Anchor>
)} )}
</Stack> </Stack>
</Group> </Group>
<Group> <Group>
<ActionIconGroup> <ActionIconGroup>
{session?.user.permissions.includes("app-modify-all") && ( <ActionIcon
<ActionIcon component={Link}
component={Link} href={`/manage/apps/edit/${app.id}`}
href={`/manage/apps/edit/${app.id}`} variant="subtle"
variant="subtle" color="gray"
color="gray" aria-label={t("page.edit.title")}
aria-label={t("page.edit.title")} >
> <IconPencil size={16} stroke={1.5} />
<IconPencil size={16} stroke={1.5} /> </ActionIcon>
</ActionIcon> <AppDeleteButton app={app} />
)}
{session?.user.permissions.includes("app-full-all") && <AppDeleteButton app={app} />}
</ActionIconGroup> </ActionIconGroup>
</Group> </Group>
</Group> </Group>
@@ -111,17 +96,16 @@ const AppCard = async ({ app }: AppCardProps) => {
const AppNoResults = async () => { const AppNoResults = async () => {
const t = await getI18n(); const t = await getI18n();
const session = await auth();
return ( return (
<NoResults <Card withBorder bg="transparent">
icon={IconBox} <Stack align="center" gap="sm">
title={t("app.page.list.noResults.title")} <IconApps size="2rem" />
action={{ <Text fw={500} size="lg">
label: t("app.page.list.noResults.action"), {t("app.page.list.noResults.title")}
href: "/manage/apps/new", </Text>
hidden: !session?.user.permissions.includes("app-create"), <Anchor href="/manage/apps/new">{t("app.page.list.noResults.description")}</Anchor>
}} </Stack>
/> </Card>
); );
}; };
@@ -7,10 +7,10 @@ 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 { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals"; import { useConfirmModal } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
import { useBoardPermissions } from "~/components/board/permissions/client"; import { useBoardPermissions } from "~/components/board/permissions/client";
const iconProps = { const iconProps = {
@@ -1,47 +1,44 @@
"use client"; "use client";
import { Affix, Button, Group, Menu } from "@mantine/core"; import { useCallback } from "react";
import { IconCategoryPlus, IconChevronDown, IconFileImport } from "@tabler/icons-react"; import { IconCategoryPlus } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useModalAction } from "@homarr/modals"; import { useModalAction } from "@homarr/modals";
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 = () => { import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
import { AddBoardModal } from "~/components/manage/boards/add-board-modal";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
interface CreateBoardButtonProps {
boardNames: string[];
}
export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => {
const t = useI18n(); const t = useI18n();
const { openModal: openAddModal } = useModalAction(AddBoardModal); const { openModal } = useModalAction(AddBoardModal);
const { openModal: openImportModal } = useModalAction(ImportBoardModal);
const buttonGroupContent = ( const { mutateAsync, isPending } = clientApi.board.createBoard.useMutation({
<> onSettled: async () => {
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openAddModal}> await revalidatePathActionAsync("/manage/boards");
{t("management.page.board.action.new.label")} },
</Button> });
<Menu position="bottom-end">
<Menu.Target> const onClick = useCallback(() => {
<Button px="xs" ms={1}> openModal({
<IconChevronDown size="1rem" /> onSuccess: async (values) => {
</Button> await mutateAsync({
</Menu.Target> name: values.name,
<Menu.Dropdown> });
<Menu.Item onClick={openImportModal} leftSection={<IconFileImport size="1rem" />}> },
<Group> boardNames,
{t("board.action.oldImport.label")} });
<BetaBadge size="xs" /> }, [mutateAsync, boardNames, openModal]);
</Group>
</Menu.Item>
</Menu.Dropdown>
</Menu>
</>
);
return ( return (
<> <MobileAffixButton leftSection={<IconCategoryPlus size="1rem" />} onClick={onClick} loading={isPending}>
<Button.Group visibleFrom="md">{buttonGroupContent}</Button.Group> {t("management.page.board.action.new.label")}
<Affix hiddenFrom="md" position={{ bottom: 20, right: 20 }}> </MobileAffixButton>
<Button.Group>{buttonGroupContent}</Button.Group>
</Affix>
</>
); );
}; };
@@ -19,7 +19,6 @@ import { IconDotsVertical, IconHomeFilled, IconLock, IconWorld } from "@tabler/i
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 { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { UserAvatar } from "@homarr/ui"; import { UserAvatar } from "@homarr/ui";
@@ -31,9 +30,8 @@ import { CreateBoardButton } from "./_components/create-board-button";
export default async function ManageBoardsPage() { export default async function ManageBoardsPage() {
const t = await getScopedI18n("management.page.board"); const t = await getScopedI18n("management.page.board");
const session = await auth();
const boards = await api.board.getAllBoards(); const boards = await api.board.getAllBoards();
const canCreateBoards = session?.user.permissions.includes("board-create");
return ( return (
<ManageContainer> <ManageContainer>
@@ -41,7 +39,7 @@ export default async function ManageBoardsPage() {
<Stack> <Stack>
<Group justify="space-between"> <Group justify="space-between">
<Title mb="md">{t("title")}</Title> <Title mb="md">{t("title")}</Title>
{canCreateBoards && <CreateBoardButton />} <CreateBoardButton boardNames={boards.map((board) => board.name)} />
</Group> </Group>
<Grid mb={{ base: "xl", md: 0 }}> <Grid mb={{ base: "xl", md: 0 }}>
@@ -1,61 +0,0 @@
"use client";
import { IconPlayerPlay, IconSelector, IconSettings } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { integrationPermissions, integrationPermissionsMap } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { AccessSettings } from "~/components/access/access-settings";
interface Props {
integration: RouterOutputs["integration"]["byId"];
initialPermissions: RouterOutputs["integration"]["getIntegrationPermissions"];
}
export const IntegrationAccessSettings = ({ integration, initialPermissions }: Props) => {
const t = useI18n();
const utils = clientApi.useUtils();
const { data } = clientApi.integration.getIntegrationPermissions.useQuery(
{
id: integration.id,
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
initialData: initialPermissions,
},
);
const usersMutation = clientApi.integration.saveUserIntegrationPermissions.useMutation();
const groupsMutation = clientApi.integration.saveGroupIntegrationPermissions.useMutation();
return (
<AccessSettings
entity={{
id: integration.id,
ownerId: null,
owner: null,
}}
permission={{
items: integrationPermissions,
default: "use",
fullAccessGroupPermission: "integration-full-all",
icons: {
use: IconSelector,
interact: IconPlayerPlay,
full: IconSettings,
},
groupPermissionMapping: integrationPermissionsMap,
}}
translate={(key) => t(`integration.permission.${key}`)}
query={{
data,
invalidate: () => utils.integration.getIntegrationPermissions.invalidate(),
}}
groupsMutation={groupsMutation}
usersMutation={usersMutation}
/>
);
};
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { useParams } from "next/navigation";
import { ActionIcon, Avatar, Button, Card, Collapse, Group, Kbd, Stack, Text } 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";
@@ -22,6 +23,7 @@ interface SecretCardProps {
} }
export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => { export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
const params = useParams<{ locale: string }>();
const t = useI18n(); const t = useI18n();
const { isPublic } = integrationSecretKindObject[secret.kind]; const { isPublic } = integrationSecretKindObject[secret.kind];
const [publicSecretDisplayOpened, { toggle: togglePublicSecretDisplay }] = useDisclosure(false); const [publicSecretDisplayOpened, { toggle: togglePublicSecretDisplay }] = useDisclosure(false);
@@ -43,7 +45,7 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
<Group> <Group>
<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().locale(params.locale).to(dayjs(secret.updatedAt)),
})} })}
</Text> </Text>
{isPublic ? ( {isPublic ? (
@@ -1,8 +1,8 @@
import type { MantineSize } from "@mantine/core";
import { Avatar } from "@mantine/core"; import { Avatar } from "@mantine/core";
import type { MantineSize } from "@mantine/core";
import type { IntegrationKind } from "@homarr/definitions";
import { getIconUrl } from "@homarr/definitions"; import { getIconUrl } from "@homarr/definitions";
import type { IntegrationKind } from "@homarr/definitions";
interface IntegrationAvatarProps { interface IntegrationAvatarProps {
size: MantineSize; size: MantineSize;
@@ -5,11 +5,12 @@ import { ActionIcon } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react"; import { IconTrash } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals"; import { useConfirmModal } from "@homarr/modals";
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 { revalidatePathActionAsync } from "../../../revalidatePathAction";
interface DeleteIntegrationActionButtonProps { interface DeleteIntegrationActionButtonProps {
count: number; count: number;
integration: { id: string; name: string }; integration: { id: string; name: string };
@@ -6,7 +6,6 @@ import { Button, Fieldset, Group, Stack, TextInput } 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 { revalidatePathActionAsync } from "@homarr/common/client";
import { getAllSecretKindOptions, getDefaultSecretKinds } from "@homarr/definitions"; import { getAllSecretKindOptions, getDefaultSecretKinds } from "@homarr/definitions";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { convertIntegrationTestConnectionError } from "@homarr/integrations/client"; import { convertIntegrationTestConnectionError } from "@homarr/integrations/client";
@@ -16,6 +15,7 @@ import { useI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation"; import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation"; import { validation } from "@homarr/validation";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
import { SecretCard } from "../../_components/secrets/integration-secret-card"; import { SecretCard } from "../../_components/secrets/integration-secret-card";
import { IntegrationSecretInput } from "../../_components/secrets/integration-secret-inputs"; import { IntegrationSecretInput } from "../../_components/secrets/integration-secret-inputs";
@@ -1,13 +1,11 @@
import { Container, Fieldset, Group, Stack, Title } from "@mantine/core"; import { Container, Group, Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { getIntegrationName } from "@homarr/definitions"; import { getIntegrationName } from "@homarr/definitions";
import { getI18n, getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { IntegrationAvatar } from "@homarr/ui";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { catchTrpcNotFound } from "~/errors/trpc-catch-error"; import { IntegrationAvatar } from "../../_integration-avatar";
import { IntegrationAccessSettings } from "../../_components/integration-access-settings";
import { EditIntegrationForm } from "./_integration-edit-form"; import { EditIntegrationForm } from "./_integration-edit-form";
interface EditIntegrationPageProps { interface EditIntegrationPageProps {
@@ -15,10 +13,8 @@ interface EditIntegrationPageProps {
} }
export default async function EditIntegrationPage({ params }: EditIntegrationPageProps) { export default async function EditIntegrationPage({ params }: EditIntegrationPageProps) {
const editT = await getScopedI18n("integration.page.edit"); const t = await getScopedI18n("integration.page.edit");
const t = await getI18n(); const integration = await api.integration.byId({ id: params.id });
const integration = await api.integration.byId({ id: params.id }).catch(catchTrpcNotFound);
const integrationPermissions = await api.integration.getIntegrationPermissions({ id: integration.id });
return ( return (
<> <>
@@ -27,14 +23,9 @@ export default async function EditIntegrationPage({ params }: EditIntegrationPag
<Stack> <Stack>
<Group align="center"> <Group align="center">
<IntegrationAvatar kind={integration.kind} size="md" /> <IntegrationAvatar kind={integration.kind} size="md" />
<Title>{editT("title", { name: getIntegrationName(integration.kind) })}</Title> <Title>{t("title", { name: getIntegrationName(integration.kind) })}</Title>
</Group> </Group>
<EditIntegrationForm integration={integration} /> <EditIntegrationForm integration={integration} />
<Title order={2}>{t("permission.title")}</Title>
<Fieldset>
<IntegrationAccessSettings integration={integration} initialPermissions={integrationPermissions} />
</Fieldset>
</Stack> </Stack>
</Container> </Container>
</> </>
@@ -8,7 +8,8 @@ import { IconSearch } from "@tabler/icons-react";
import { getIntegrationName, integrationKinds } from "@homarr/definitions"; import { getIntegrationName, integrationKinds } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "@homarr/ui";
import { IntegrationAvatar } from "../_integration-avatar";
export const IntegrationCreateDropdownContent = () => { export const IntegrationCreateDropdownContent = () => {
const t = useI18n(); const t = useI18n();
@@ -3,13 +3,11 @@
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, Fieldset, Group, SegmentedControl, Stack, Text, TextInput } from "@mantine/core"; import { Button, Fieldset, Group, SegmentedControl, Stack, TextInput } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions"; import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
import { getAllSecretKindOptions, getIntegrationName } from "@homarr/definitions"; import { getAllSecretKindOptions } 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";
@@ -19,6 +17,7 @@ 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";
import { revalidatePathActionAsync } from "../../../../revalidatePathAction";
interface NewIntegrationFormProps { interface NewIntegrationFormProps {
searchParams: Partial<z.infer<typeof validation.integration.create>> & { searchParams: Partial<z.infer<typeof validation.integration.create>> & {
@@ -32,7 +31,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
const router = useRouter(); const router = useRouter();
const form = useZodForm(validation.integration.create.omit({ kind: true }), { const form = useZodForm(validation.integration.create.omit({ kind: true }), {
initialValues: { initialValues: {
name: searchParams.name ?? getIntegrationName(searchParams.kind), name: searchParams.name ?? "",
url: searchParams.url ?? "", url: searchParams.url ?? "",
secrets: secretKinds[0].map((kind) => ({ secrets: secretKinds[0].map((kind) => ({
kind, kind,
@@ -81,7 +80,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
return ( return (
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}> <form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
<Stack> <Stack>
<TextInput withAsterisk label={t("integration.field.name.label")} autoFocus {...form.getInputProps("name")} /> <TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} /> <TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
@@ -96,11 +95,6 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
{...form.getInputProps(`secrets.${index}.value`)} {...form.getInputProps(`secrets.${index}.value`)}
/> />
))} ))}
{form.values.secrets.length === 0 && (
<Alert icon={<IconInfoCircle size={"1rem"} />} color={"blue"}>
<Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text>
</Alert>
)}
</Stack> </Stack>
</Fieldset> </Fieldset>
@@ -126,20 +120,12 @@ const SecretKindsSegmentedControl = ({ secretKinds, form }: SecretKindsSegmented
const t = useScopedI18n("integration.secrets"); const t = useScopedI18n("integration.secrets");
const secretKindGroups = secretKinds.map((kinds) => ({ const secretKindGroups = secretKinds.map((kinds) => ({
label: label: kinds.map((kind) => t(`kind.${kind}.label`)).join(" & "),
kinds.length === 0 value: kinds.join("-"),
? t("noSecretsRequired.segmentTitle")
: kinds.map((kind) => t(`kind.${kind}.label`)).join(" & "),
value: kinds.length === 0 ? "empty" : kinds.join("-"),
})); }));
const onChange = useCallback( const onChange = useCallback(
(value: string) => { (value: string) => {
if (value === "empty") {
form.setFieldValue("secrets", []);
return;
}
const kinds = value.split("-") as IntegrationSecretKind[]; const kinds = value.split("-") as IntegrationSecretKind[];
const secrets = kinds.map((kind) => ({ const secrets = kinds.map((kind) => ({
kind, kind,
@@ -1,15 +1,14 @@
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 { auth } from "@homarr/auth/next";
import type { IntegrationKind } from "@homarr/definitions"; import type { IntegrationKind } from "@homarr/definitions";
import { getIntegrationName, integrationKinds } from "@homarr/definitions"; import { getIntegrationName, integrationKinds } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { IntegrationAvatar } from "@homarr/ui";
import type { validation } from "@homarr/validation"; import type { validation } from "@homarr/validation";
import { z } from "@homarr/validation"; import { z } from "@homarr/validation";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { IntegrationAvatar } from "../_integration-avatar";
import { NewIntegrationForm } from "./_integration-new-form"; import { NewIntegrationForm } from "./_integration-new-form";
interface NewIntegrationPageProps { interface NewIntegrationPageProps {
@@ -19,17 +18,13 @@ interface NewIntegrationPageProps {
} }
export default async function IntegrationsNewPage({ searchParams }: NewIntegrationPageProps) { export default async function IntegrationsNewPage({ searchParams }: NewIntegrationPageProps) {
const session = await auth(); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (!session?.user.permissions.includes("integration-create")) { const result = z.enum([integrationKinds[0]!, ...integrationKinds.slice(1)]).safeParse(searchParams.kind);
notFound();
}
const result = z.enum(integrationKinds).safeParse(searchParams.kind);
if (!result.success) { if (!result.success) {
notFound(); notFound();
} }
const tCreate = await getScopedI18n("integration.page.create"); const t = await getScopedI18n("integration.page.create");
const currentKind = result.data; const currentKind = result.data;
@@ -40,7 +35,7 @@ export default async function IntegrationsNewPage({ searchParams }: NewIntegrati
<Stack> <Stack>
<Group align="center"> <Group align="center">
<IntegrationAvatar kind={currentKind} size="md" /> <IntegrationAvatar kind={currentKind} size="md" />
<Title>{tCreate("title", { name: getIntegrationName(currentKind) })}</Title> <Title>{t("title", { name: getIntegrationName(currentKind) })}</Title>
</Group> </Group>
<NewIntegrationForm searchParams={searchParams} /> <NewIntegrationForm searchParams={searchParams} />
</Stack> </Stack>
@@ -1,7 +1,6 @@
import { Fragment } from "react"; import { Fragment } from "react";
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation";
import { import {
AccordionControl, AccordionControl,
AccordionItem, AccordionItem,
@@ -27,21 +26,20 @@ import {
Text, Text,
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
import { IconChevronDown, IconChevronUp, IconPencil, IconPlugX } from "@tabler/icons-react"; import { IconChevronDown, IconChevronUp, IconPencil } 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";
import { auth } from "@homarr/auth/next";
import { objectEntries } from "@homarr/common"; import { objectEntries } from "@homarr/common";
import type { IntegrationKind } from "@homarr/definitions"; import type { IntegrationKind } from "@homarr/definitions";
import { getIntegrationName } from "@homarr/definitions"; import { getIntegrationName } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { CountBadge, IntegrationAvatar } from "@homarr/ui"; import { CountBadge } from "@homarr/ui";
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 { NoResults } from "~/components/no-results";
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion"; import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
import { IntegrationAvatar } from "./_integration-avatar";
import { DeleteIntegrationActionButton } from "./_integration-buttons"; import { DeleteIntegrationActionButton } from "./_integration-buttons";
import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown"; import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown";
@@ -52,17 +50,9 @@ interface IntegrationsPageProps {
} }
export default async function IntegrationsPage({ searchParams }: IntegrationsPageProps) { export default async function IntegrationsPage({ searchParams }: IntegrationsPageProps) {
const session = await auth();
if (!session) {
redirect("/auth/login");
}
const integrations = await api.integration.all(); const integrations = await api.integration.all();
const t = await getScopedI18n("integration"); const t = await getScopedI18n("integration");
const canCreateIntegrations = session.user.permissions.includes("integration-create");
return ( return (
<ManageContainer> <ManageContainer>
<DynamicBreadcrumb /> <DynamicBreadcrumb />
@@ -70,27 +60,23 @@ export default async function IntegrationsPage({ searchParams }: IntegrationsPag
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<Title>{t("page.list.title")}</Title> <Title>{t("page.list.title")}</Title>
{canCreateIntegrations && ( <Box>
<> <IntegrationSelectMenu>
<Box> <Affix hiddenFrom="md" position={{ bottom: 20, right: 20 }}>
<IntegrationSelectMenu> <MenuTarget>
<Affix hiddenFrom="md" position={{ bottom: 20, right: 20 }}> <Button rightSection={<IconChevronUp size={16} stroke={1.5} />}>{t("action.create")}</Button>
<MenuTarget> </MenuTarget>
<Button rightSection={<IconChevronUp size={16} stroke={1.5} />}>{t("action.create")}</Button> </Affix>
</MenuTarget> </IntegrationSelectMenu>
</Affix> </Box>
</IntegrationSelectMenu>
</Box>
<Box visibleFrom="md"> <Box visibleFrom="md">
<IntegrationSelectMenu> <IntegrationSelectMenu>
<MenuTarget> <MenuTarget>
<Button rightSection={<IconChevronDown size={16} stroke={1.5} />}>{t("action.create")}</Button> <Button rightSection={<IconChevronDown size={16} stroke={1.5} />}>{t("action.create")}</Button>
</MenuTarget> </MenuTarget>
</IntegrationSelectMenu> </IntegrationSelectMenu>
</Box> </Box>
</>
)}
</Group> </Group>
<IntegrationList integrations={integrations} activeTab={searchParams.tab} /> <IntegrationList integrations={integrations} activeTab={searchParams.tab} />
@@ -117,11 +103,9 @@ interface IntegrationListProps {
const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps) => { const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps) => {
const t = await getScopedI18n("integration"); const t = await getScopedI18n("integration");
const session = await auth();
const hasFullAccess = session?.user.permissions.includes("integration-full-all") ?? false;
if (integrations.length === 0) { if (integrations.length === 0) {
return <NoResults icon={IconPlugX} title={t("page.list.noResults.title")} />; return <div>{t("page.list.empty")}</div>;
} }
const grouppedIntegrations = integrations.reduce( const grouppedIntegrations = integrations.reduce(
@@ -168,20 +152,18 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
</TableTd> </TableTd>
<TableTd> <TableTd>
<Group justify="end"> <Group justify="end">
{(hasFullAccess || integration.permissions.hasFullAccess) && ( <ActionIconGroup>
<ActionIconGroup> <ActionIcon
<ActionIcon component={Link}
component={Link} href={`/manage/integrations/edit/${integration.id}`}
href={`/manage/integrations/edit/${integration.id}`} variant="subtle"
variant="subtle" color="gray"
color="gray" aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })} >
> <IconPencil size={16} stroke={1.5} />
<IconPencil size={16} stroke={1.5} /> </ActionIcon>
</ActionIcon> <DeleteIntegrationActionButton integration={integration} count={integrations.length} />
<DeleteIntegrationActionButton integration={integration} count={integrations.length} /> </ActionIconGroup>
</ActionIconGroup>
)}
</Group> </Group>
</TableTd> </TableTd>
</TableTr> </TableTr>
@@ -196,21 +178,18 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
<Stack gap={0}> <Stack gap={0}>
<Group justify="space-between" align="center" wrap="nowrap"> <Group justify="space-between" align="center" wrap="nowrap">
<Text>{integration.name}</Text> <Text>{integration.name}</Text>
{hasFullAccess || <ActionIconGroup>
(integration.permissions.hasFullAccess && ( <ActionIcon
<ActionIconGroup> component={Link}
<ActionIcon href={`/manage/integrations/edit/${integration.id}`}
component={Link} variant="subtle"
href={`/manage/integrations/edit/${integration.id}`} color="gray"
variant="subtle" aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
color="gray" >
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })} <IconPencil size={16} stroke={1.5} />
> </ActionIcon>
<IconPencil size={16} stroke={1.5} /> <DeleteIntegrationActionButton integration={integration} count={integrations.length} />
</ActionIcon> </ActionIconGroup>
<DeleteIntegrationActionButton integration={integration} count={integrations.length} />
</ActionIconGroup>
))}
</Group> </Group>
<Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm"> <Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm">
{integration.url} {integration.url}
+2 -36
View File
@@ -12,11 +12,9 @@ import {
IconLayoutDashboard, IconLayoutDashboard,
IconLogs, IconLogs,
IconMailForward, IconMailForward,
IconPhoto,
IconPlug, IconPlug,
IconQuestionMark, IconQuestionMark,
IconReport, IconReport,
IconSearch,
IconSettings, IconSettings,
IconTool, IconTool,
IconUser, IconUser,
@@ -24,9 +22,6 @@ import {
IconUsersGroup, IconUsersGroup,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server";
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";
@@ -36,7 +31,6 @@ import { ClientShell } from "~/components/layout/shell";
export default async function ManageLayout({ children }: PropsWithChildren) { export default async function ManageLayout({ children }: PropsWithChildren) {
const t = await getScopedI18n("management.navbar"); const t = await getScopedI18n("management.navbar");
const session = await auth();
const navigationLinks: NavigationLink[] = [ const navigationLinks: NavigationLink[] = [
{ {
label: t("items.home"), label: t("items.home"),
@@ -52,30 +46,15 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
icon: IconBox, icon: IconBox,
href: "/manage/apps", href: "/manage/apps",
label: t("items.apps"), label: t("items.apps"),
hidden: !session,
}, },
{ {
icon: IconPlug, icon: IconPlug,
href: "/manage/integrations", href: "/manage/integrations",
label: t("items.integrations"), label: t("items.integrations"),
hidden: !session,
},
{
icon: IconSearch,
href: "/manage/search-engines",
label: t("items.searchEngies"),
hidden: !session,
},
{
icon: IconPhoto,
href: "/manage/medias",
label: t("items.medias"),
hidden: !session,
}, },
{ {
icon: IconUser, icon: IconUser,
label: t("items.users.label"), label: t("items.users.label"),
hidden: !session?.user.permissions.includes("admin"),
items: [ items: [
{ {
label: t("items.users.items.manage"), label: t("items.users.items.manage"),
@@ -86,7 +65,6 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
label: t("items.users.items.invites"), label: t("items.users.items.invites"),
icon: IconMailForward, icon: IconMailForward,
href: "/manage/users/invites", href: "/manage/users/invites",
hidden: !isProviderEnabled("credentials"),
}, },
{ {
label: t("items.users.items.groups"), label: t("items.users.items.groups"),
@@ -98,32 +76,21 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
{ {
label: t("items.tools.label"), label: t("items.tools.label"),
icon: IconTool, icon: IconTool,
// 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"),
items: [ items: [
{ {
label: t("items.tools.items.docker"), label: t("items.tools.items.docker"),
icon: IconBrandDocker, icon: IconBrandDocker,
href: "/manage/tools/docker", href: "/manage/tools/docker",
hidden: !session?.user.permissions.includes("admin"),
},
{
label: t("items.tools.items.api"),
icon: IconPlug,
href: "/manage/tools/api",
hidden: !session?.user.permissions.includes("admin"),
}, },
{ {
label: t("items.tools.items.logs"), label: t("items.tools.items.logs"),
icon: IconLogs, icon: IconLogs,
href: "/manage/tools/logs", href: "/manage/tools/logs",
hidden: !session?.user.permissions.includes("other-view-logs"),
}, },
{ {
label: t("items.tools.items.tasks"), label: t("items.tools.items.tasks"),
icon: IconReport, icon: IconReport,
href: "/manage/tools/tasks", href: "/manage/tools/tasks",
hidden: !session?.user.permissions.includes("admin"),
}, },
], ],
}, },
@@ -131,7 +98,6 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
label: t("items.settings"), label: t("items.settings"),
href: "/manage/settings", href: "/manage/settings",
icon: IconSettings, icon: IconSettings,
hidden: !session?.user.permissions.includes("admin"),
}, },
{ {
label: t("items.help.label"), label: t("items.help.label"),
@@ -140,7 +106,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
{ {
label: t("items.help.items.documentation"), label: t("items.help.items.documentation"),
icon: IconBook2, icon: IconBook2,
href: createDocumentationLink("/docs/getting-started"), href: "https://homarr.dev/docs/getting-started/prerequisites",
external: true, external: true,
}, },
{ {
@@ -150,7 +116,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
external: true, external: true,
}, },
{ {
label: t("items.help.items.discord"), label: t("items.tools.items.docker"),
icon: IconBrandDiscord, icon: IconBrandDiscord,
href: "https://discord.com/invite/aCsmEV5RgA", href: "https://discord.com/invite/aCsmEV5RgA",
external: true, external: true,
@@ -1,32 +0,0 @@
"use client";
import { ActionIcon, CopyButton, Tooltip } from "@mantine/core";
import { IconCheck, IconCopy } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { useI18n } from "@homarr/translation/client";
interface CopyMediaProps {
media: RouterOutputs["media"]["getPaginated"]["items"][number];
}
export const CopyMedia = ({ media }: CopyMediaProps) => {
const t = useI18n();
const url =
typeof window !== "undefined"
? `${window.location.protocol}://${window.location.hostname}:${window.location.port}/api/user-medias/${media.id}`
: "";
return (
<CopyButton value={url}>
{({ copy, copied }) => (
<Tooltip label={t("media.action.copy.label")} openDelay={500}>
<ActionIcon onClick={copy} color={copied ? "teal" : "gray"} variant="subtle">
{copied ? <IconCheck size={16} stroke={1.5} /> : <IconCopy size={16} stroke={1.5} />}
</ActionIcon>
</Tooltip>
)}
</CopyButton>
);
};
@@ -1,40 +0,0 @@
"use client";
import { ActionIcon, Tooltip } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
interface DeleteMediaProps {
media: RouterOutputs["media"]["getPaginated"]["items"][number];
}
export const DeleteMedia = ({ media }: DeleteMediaProps) => {
const { openConfirmModal } = useConfirmModal();
const t = useI18n();
const { mutateAsync, isPending } = clientApi.media.deleteMedia.useMutation();
const onClick = () => {
openConfirmModal({
title: t("media.action.delete.label"),
children: t.rich("media.action.delete.description", { bName: () => <b>{media.name}</b> }),
// eslint-disable-next-line no-restricted-syntax
onConfirm: async () => {
await mutateAsync({ id: media.id });
await revalidatePathActionAsync("/manage/medias");
},
});
};
return (
<Tooltip label={t("media.action.delete.label")} openDelay={500}>
<ActionIcon color="red" variant="subtle" onClick={onClick} loading={isPending}>
<IconTrash color="red" size={16} stroke={1.5} />
</ActionIcon>
</Tooltip>
);
};
@@ -1,36 +0,0 @@
"use client";
import { useState } from "react";
import type { ChangeEvent } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Switch } from "@mantine/core";
import type { SwitchProps } from "@mantine/core";
import { useI18n } from "@homarr/translation/client";
type ShowAllSwitchProps = Pick<SwitchProps, "defaultChecked">;
export const IncludeFromAllUsersSwitch = ({ defaultChecked }: ShowAllSwitchProps) => {
const router = useRouter();
const pathName = usePathname();
const searchParams = useSearchParams();
const [checked, setChecked] = useState(defaultChecked);
const t = useI18n();
const onChange = (event: ChangeEvent<HTMLInputElement>) => {
setChecked(event.target.checked);
const params = new URLSearchParams(searchParams);
params.set("includeFromAllUsers", event.target.checked.toString());
if (params.has("page")) params.set("page", "1"); // Reset page to 1
router.replace(`${pathName}?${params.toString()}`);
};
return (
<Switch
defaultChecked={defaultChecked}
checked={checked}
label={t("management.page.media.includeFromAllUsers")}
onChange={onChange}
/>
);
};
@@ -1,46 +0,0 @@
"use client";
import { Button, FileButton } from "@mantine/core";
import { IconUpload } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { supportedMediaUploadFormats } from "@homarr/validation";
export const UploadMedia = () => {
const t = useI18n();
const { mutateAsync, isPending } = clientApi.media.uploadMedia.useMutation();
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 (
<FileButton onChange={handleFileUploadAsync} accept={supportedMediaUploadFormats.join(",")}>
{({ onClick }) => (
<Button onClick={onClick} loading={isPending} rightSection={<IconUpload size={16} stroke={1.5} />}>
{t("media.action.upload.label")}
</Button>
)}
</FileButton>
);
};
@@ -1,132 +0,0 @@
import Image from "next/image";
import Link from "next/link";
import { notFound } from "next/navigation";
import { Anchor, Group, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Title } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { humanFileSize } from "@homarr/common";
import { getI18n } from "@homarr/translation/server";
import { SearchInput, TablePagination, UserAvatar } from "@homarr/ui";
import { z } from "@homarr/validation";
import { ManageContainer } from "~/components/manage/manage-container";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { CopyMedia } from "./_actions/copy-media";
import { DeleteMedia } from "./_actions/delete-media";
import { IncludeFromAllUsersSwitch } from "./_actions/show-all";
import { UploadMedia } from "./_actions/upload-media";
const searchParamsSchema = z.object({
search: z.string().optional(),
includeFromAllUsers: z
.string()
.regex(/true|false/)
.catch("false")
.transform((value) => value === "true"),
pageSize: z.string().regex(/\d+/).transform(Number).catch(10),
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 {
searchParams: SearchParamsSchemaInputFromSchema<z.infer<typeof searchParamsSchema>>;
}
export default async function GroupsListPage(props: MediaListPageProps) {
const session = await auth();
if (!session) {
return notFound();
}
const t = await getI18n();
const searchParams = searchParamsSchema.parse(props.searchParams);
const { items: medias, totalCount } = await api.media.getPaginated(searchParams);
return (
<ManageContainer size="xl">
<DynamicBreadcrumb />
<Stack>
<Title>{t("media.plural")}</Title>
<Group justify="space-between">
<Group>
<SearchInput placeholder={`${t("media.search")}...`} defaultValue={searchParams.search} />
{session.user.permissions.includes("media-view-all") && (
<IncludeFromAllUsersSwitch defaultChecked={searchParams.includeFromAllUsers} />
)}
</Group>
{session.user.permissions.includes("media-upload") && <UploadMedia />}
</Group>
<Table striped highlightOnHover>
<TableThead>
<TableTr>
<TableTh></TableTh>
<TableTh>{t("media.field.name")}</TableTh>
<TableTh>{t("media.field.size")}</TableTh>
<TableTh>{t("media.field.creator")}</TableTh>
<TableTh></TableTh>
</TableTr>
</TableThead>
<TableTbody>
{medias.map((media) => (
<Row key={media.id} media={media} />
))}
</TableTbody>
</Table>
<Group justify="end">
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
</Group>
</Stack>
</ManageContainer>
);
}
interface RowProps {
media: RouterOutputs["media"]["getPaginated"]["items"][number];
}
const Row = async ({ media }: RowProps) => {
const session = await auth();
const canDelete = media.creatorId === session?.user.id || session?.user.permissions.includes("media-full-all");
return (
<TableTr>
<TableTd w={64}>
<Image
src={`/api/user-medias/${media.id}`}
alt={media.name}
width={64}
height={64}
style={{ objectFit: "contain" }}
/>
</TableTd>
<TableTd>{media.name}</TableTd>
<TableTd>{humanFileSize(media.size)}</TableTd>
<TableTd>
{media.creator ? (
<Group gap="sm">
<UserAvatar user={media.creator} size="sm" />
<Anchor component={Link} href={`/manage/users/${media.creator.id}/general`} size="sm">
{media.creator.name}
</Anchor>
</Group>
) : (
"-"
)}
</TableTd>
<TableTd w={64}>
<Group wrap="nowrap" gap="xs">
<CopyMedia media={media} />
{canDelete && <DeleteMedia media={media} />}
</Group>
</TableTd>
</TableTr>
);
};
+26 -37
View File
@@ -3,8 +3,6 @@ import { Card, Group, SimpleGrid, Space, Stack, Text } from "@mantine/core";
import { IconArrowRight } from "@tabler/icons-react"; import { IconArrowRight } from "@tabler/icons-react";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
@@ -16,7 +14,6 @@ interface LinkProps {
subtitle: string; subtitle: string;
count: number; count: number;
href: string; href: string;
hidden?: boolean;
} }
export async function generateMetadata() { export async function generateMetadata() {
@@ -29,7 +26,6 @@ export async function generateMetadata() {
export default async function ManagementPage() { export default async function ManagementPage() {
const statistics = await api.home.getStats(); const statistics = await api.home.getStats();
const session = await auth();
const t = await getScopedI18n("management.page.home"); const t = await getScopedI18n("management.page.home");
const links: LinkProps[] = [ const links: LinkProps[] = [
@@ -37,41 +33,37 @@ export default async function ManagementPage() {
count: statistics.countBoards, count: statistics.countBoards,
href: "/manage/boards", href: "/manage/boards",
subtitle: t("statisticLabel.boards"), subtitle: t("statisticLabel.boards"),
title: t("statistic.board"), title: t("statistic.countBoards"),
}, },
{ {
count: statistics.countUsers, count: statistics.countUsers,
href: "/manage/users", href: "/manage/boards",
subtitle: t("statisticLabel.authentication"), subtitle: t("statisticLabel.authentication"),
title: t("statistic.user"), title: t("statistic.createUser"),
hidden: !session?.user.permissions.includes("admin"),
}, },
{ {
count: statistics.countInvites, count: statistics.countInvites,
href: "/manage/users/invites", href: "/manage/boards",
subtitle: t("statisticLabel.authentication"), subtitle: t("statisticLabel.authentication"),
title: t("statistic.invite"), title: t("statistic.createInvite"),
hidden: !isProviderEnabled("credentials") || !session?.user.permissions.includes("admin"),
}, },
{ {
count: statistics.countIntegrations, count: statistics.countIntegrations,
href: "/manage/integrations", href: "/manage/integrations",
subtitle: t("statisticLabel.resources"), subtitle: t("statisticLabel.resources"),
title: t("statistic.integration"), title: t("statistic.addIntegration"),
}, },
{ {
count: statistics.countApps, count: statistics.countApps,
href: "/manage/apps", href: "/manage/apps",
subtitle: t("statisticLabel.resources"), subtitle: t("statisticLabel.resources"),
title: t("statistic.app"), title: t("statistic.addApp"),
hidden: !session?.user,
}, },
{ {
count: statistics.countGroups, count: statistics.countGroups,
href: "/manage/users/groups", href: "/manage/users/groups",
subtitle: t("statisticLabel.authorization"), subtitle: t("statisticLabel.authorization"),
title: t("statistic.group"), title: t("statistic.manageRoles"),
hidden: !session?.user.permissions.includes("admin"),
}, },
]; ];
return ( return (
@@ -80,27 +72,24 @@ export default async function ManagementPage() {
<HeroBanner /> <HeroBanner />
<Space h="md" /> <Space h="md" />
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}> <SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
{links.map( {links.map((link, index) => (
(link) => <Card component={Link} href={link.href} key={`link-${index}`} withBorder>
!link.hidden && ( <Group justify="space-between" wrap="nowrap">
<Card component={Link} href={link.href} key={link.href} withBorder> <Group wrap="nowrap">
<Group justify="space-between" wrap="nowrap"> <Text size="2.4rem" fw="bolder">
<Group wrap="nowrap"> {link.count}
<Text size="2.4rem" fw="bolder"> </Text>
{link.count} <Stack gap={0}>
</Text> <Text c="red" size="xs">
<Stack gap={0}> {link.subtitle}
<Text c="red" size="xs"> </Text>
{link.subtitle} <Text fw="bold">{link.title}</Text>
</Text> </Stack>
<Text fw="bold">{link.title}</Text> </Group>
</Stack> <IconArrowRight />
</Group> </Group>
<IconArrowRight /> </Card>
</Group> ))}
</Card>
),
)}
</SimpleGrid> </SimpleGrid>
</> </>
); );
@@ -1,109 +0,0 @@
"use client";
import Link from "next/link";
import type { SegmentedControlItem } 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 { clientApi } from "@homarr/api/client";
import { searchEngineTypes } from "@homarr/definitions";
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.searchEngine.manage>;
interface SearchEngineFormProps {
submitButtonTranslation: (t: TranslationFunction) => string;
initialValues?: FormType;
handleSubmit: (values: FormType) => void;
isPending: boolean;
disableShort?: boolean;
}
export const SearchEngineForm = (props: SearchEngineFormProps) => {
const { submitButtonTranslation, handleSubmit, initialValues, isPending, disableShort } = props;
const t = useI18n();
const [integrationData] = clientApi.integration.allThatSupportSearch.useSuspenseQuery();
const form = useZodForm(validation.searchEngine.manage, {
initialValues: initialValues ?? {
name: "",
short: "",
iconUrl: "",
urlTemplate: "",
description: "",
type: "generic",
},
});
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<Grid>
<Grid.Col span={{ base: 12, md: 8, lg: 9, xl: 10 }}>
<TextInput {...form.getInputProps("name")} withAsterisk label={t("search.engine.field.name.label")} />
</Grid.Col>
<Grid.Col span={{ base: 12, md: 4, lg: 3, xl: 2 }}>
<TextInput
{...form.getInputProps("short")}
disabled={disableShort}
withAsterisk
label={t("search.engine.field.short.label")}
/>
</Grid.Col>
</Grid>
<IconPicker initialValue={initialValues?.iconUrl} {...form.getInputProps("iconUrl")} />
<Fieldset legend={t("search.engine.page.edit.configControl")}>
<SegmentedControl
data={searchEngineTypes.map(
(type) =>
({
label: t(`search.engine.page.edit.searchEngineType.${type}`),
value: type,
}) satisfies SegmentedControlItem,
)}
{...form.getInputProps("type")}
fullWidth
/>
{form.values.type === "generic" && (
<TextInput
{...form.getInputProps("urlTemplate")}
withAsterisk
label={t("search.engine.field.urlTemplate.label")}
/>
)}
{form.values.type === "fromIntegration" && (
<WidgetIntegrationSelect
label="Integration"
data={integrationData}
canSelectMultiple={false}
onChange={(value) => form.setFieldValue("integrationId", value[0])}
value={form.values.integrationId !== undefined ? [form.values.integrationId] : []}
withAsterisk
/>
)}
</Fieldset>
<Textarea {...form.getInputProps("description")} label={t("search.engine.field.description.label")} />
<Group justify="end">
<Button variant="default" component={Link} href="/manage/search-engines">
{t("common.action.backToOverview")}
</Button>
<Button type="submit" loading={isPending}>
{submitButtonTranslation(t)}
</Button>
</Group>
</Stack>
</form>
);
};
@@ -1,55 +0,0 @@
"use client";
import { useCallback } from "react";
import { ActionIcon } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
interface SearchEngineDeleteButtonProps {
searchEngine: RouterOutputs["searchEngine"]["getPaginated"]["items"][number];
}
export const SearchEngineDeleteButton = ({ searchEngine }: SearchEngineDeleteButtonProps) => {
const t = useScopedI18n("search.engine.page.delete");
const { openConfirmModal } = useConfirmModal();
const { mutate, isPending } = clientApi.searchEngine.delete.useMutation();
const onClick = useCallback(() => {
openConfirmModal({
title: t("title"),
children: t("message", searchEngine),
onConfirm: () => {
mutate(
{ id: searchEngine.id },
{
onSuccess: () => {
showSuccessNotification({
title: t("notification.success.title"),
message: t("notification.success.message"),
});
void revalidatePathActionAsync("/manage/search-engines");
},
onError: () => {
showErrorNotification({
title: t("notification.error.title"),
message: t("notification.error.message"),
});
},
},
);
},
});
}, [searchEngine, mutate, t, openConfirmModal]);
return (
<ActionIcon loading={isPending} variant="subtle" color="red" onClick={onClick} aria-label={t("title")}>
<IconTrash color="red" size={16} stroke={1.5} />
</ActionIcon>
);
};
@@ -1,63 +0,0 @@
"use client";
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import type { RouterOutputs } from "@homarr/api";
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 { SearchEngineForm } from "../../_form";
interface SearchEngineEditFormProps {
searchEngine: RouterOutputs["searchEngine"]["byId"];
}
export const SearchEngineEditForm = ({ searchEngine }: SearchEngineEditFormProps) => {
const t = useScopedI18n("search.engine.page.edit.notification");
const router = useRouter();
const { mutate, isPending } = clientApi.searchEngine.update.useMutation({
onSuccess: () => {
showSuccessNotification({
title: t("success.title"),
message: t("success.message"),
});
void revalidatePathActionAsync("/manage/search-engines").then(() => {
router.push("/manage/search-engines");
});
},
onError: () => {
showErrorNotification({
title: t("error.title"),
message: t("error.message"),
});
},
});
const handleSubmit = useCallback(
(values: z.infer<typeof validation.searchEngine.manage>) => {
mutate({
id: searchEngine.id,
...values,
});
},
[mutate, searchEngine.id],
);
const submitButtonTranslation = useCallback((t: TranslationFunction) => t("common.action.save"), []);
return (
<SearchEngineForm
submitButtonTranslation={submitButtonTranslation}
initialValues={searchEngine}
handleSubmit={handleSubmit}
isPending={isPending}
disableShort
/>
);
};
@@ -1,35 +0,0 @@
import { notFound } from "next/navigation";
import { Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getI18n } from "@homarr/translation/server";
import { ManageContainer } from "~/components/manage/manage-container";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { SearchEngineEditForm } from "./_search-engine-edit-form";
interface SearchEngineEditPageProps {
params: { id: string };
}
export default async function SearchEngineEditPage({ params }: SearchEngineEditPageProps) {
const session = await auth();
if (!session?.user.permissions.includes("search-engine-modify-all")) {
notFound();
}
const searchEngine = await api.searchEngine.byId({ id: params.id });
const t = await getI18n();
return (
<ManageContainer>
<DynamicBreadcrumb dynamicMappings={new Map([[params.id, searchEngine.name]])} nonInteractable={["edit"]} />
<Stack>
<Title>{t("search.engine.page.edit.title")}</Title>
<SearchEngineEditForm searchEngine={searchEngine} />
</Stack>
</ManageContainer>
);
}
@@ -1,52 +0,0 @@
"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 { SearchEngineForm } from "../_form";
export const SearchEngineNewForm = () => {
const t = useScopedI18n("search.engine.page.create.notification");
const router = useRouter();
const { mutate, isPending } = clientApi.searchEngine.create.useMutation({
onSuccess: async () => {
showSuccessNotification({
title: t("success.title"),
message: t("success.message"),
});
await revalidatePathActionAsync("/manage/search-engines");
router.push("/manage/search-engines");
},
onError: () => {
showErrorNotification({
title: t("error.title"),
message: t("error.message"),
});
},
});
const handleSubmit = useCallback(
(values: z.infer<typeof validation.searchEngine.manage>) => {
mutate(values);
},
[mutate],
);
const submitButtonTranslation = useCallback((t: TranslationFunction) => t("common.action.create"), []);
return (
<SearchEngineForm
submitButtonTranslation={submitButtonTranslation}
handleSubmit={handleSubmit}
isPending={isPending}
/>
);
};
@@ -1,29 +0,0 @@
import { notFound } from "next/navigation";
import { Stack, Title } from "@mantine/core";
import { auth } from "@homarr/auth/next";
import { getI18n } from "@homarr/translation/server";
import { ManageContainer } from "~/components/manage/manage-container";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { SearchEngineNewForm } from "./_search-engine-new-form";
export default async function SearchEngineNewPage() {
const session = await auth();
if (!session?.user.permissions.includes("search-engine-create")) {
notFound();
}
const t = await getI18n();
return (
<ManageContainer>
<DynamicBreadcrumb />
<Stack>
<Title>{t("search.engine.page.create.title")}</Title>
<SearchEngineNewForm />
</Stack>
</ManageContainer>
);
}
@@ -1,156 +0,0 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
import { IconPencil, IconSearch } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { SearchInput, TablePagination } from "@homarr/ui";
import { z } from "@homarr/validation";
import { ManageContainer } from "~/components/manage/manage-container";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NoResults } from "~/components/no-results";
import { SearchEngineDeleteButton } from "./_search-engine-delete-button";
const searchParamsSchema = z.object({
search: z.string().optional(),
pageSize: z.string().regex(/\d+/).transform(Number).catch(10),
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 {
searchParams: SearchParamsSchemaInputFromSchema<z.infer<typeof searchParamsSchema>>;
}
export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
const session = await auth();
if (!session) {
redirect("/auth/login");
}
const searchParams = searchParamsSchema.parse(props.searchParams);
const { items: searchEngines, totalCount } = await api.searchEngine.getPaginated(searchParams);
const tEngine = await getScopedI18n("search.engine");
return (
<ManageContainer>
<DynamicBreadcrumb />
<Stack>
<Title>{tEngine("page.list.title")}</Title>
<Group justify="space-between" align="center">
<SearchInput placeholder={`${tEngine("search")}...`} defaultValue={searchParams.search} />
{session.user.permissions.includes("search-engine-create") && (
<MobileAffixButton component={Link} href="/manage/search-engines/new">
{tEngine("page.create.title")}
</MobileAffixButton>
)}
</Group>
{searchEngines.length === 0 && <SearchEngineNoResults />}
{searchEngines.length > 0 && (
<Stack gap="sm">
{searchEngines.map((searchEngine) => (
<SearchEngineCard key={searchEngine.id} searchEngine={searchEngine} />
))}
</Stack>
)}
<Group justify="end">
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
</Group>
</Stack>
</ManageContainer>
);
}
interface SearchEngineCardProps {
searchEngine: RouterOutputs["searchEngine"]["getPaginated"]["items"][number];
}
const SearchEngineCard = async ({ searchEngine }: SearchEngineCardProps) => {
const t = await getScopedI18n("search.engine");
const session = await auth();
return (
<Card>
<Group justify="space-between" wrap="nowrap">
<Group align="top" justify="start" wrap="nowrap" style={{ flex: 1 }}>
<Avatar
size="sm"
src={searchEngine.iconUrl}
radius={0}
styles={{
image: {
objectFit: "contain",
},
}}
/>
<Stack gap={0}>
<Text fw={500} lineClamp={1}>
{searchEngine.name}
</Text>
{searchEngine.description && (
<Text size="sm" c="gray.6" lineClamp={4}>
{searchEngine.description}
</Text>
)}
{searchEngine.type === "generic" && searchEngine.urlTemplate !== null && (
<Anchor href={searchEngine.urlTemplate.replace("%s", "test")} lineClamp={1} size="sm">
{searchEngine.urlTemplate}
</Anchor>
)}
{searchEngine.type === "fromIntegration" && searchEngine.integrationId !== null && (
<Text c="dimmed" size="sm">
{t("page.list.interactive")}
</Text>
)}
</Stack>
</Group>
<Group>
<ActionIconGroup>
{session?.user.permissions.includes("search-engine-modify-all") && (
<ActionIcon
component={Link}
href={`/manage/search-engines/edit/${searchEngine.id}`}
variant="subtle"
color="gray"
aria-label={t("page.edit.title")}
>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
)}
{session?.user.permissions.includes("search-engine-full-all") && (
<SearchEngineDeleteButton searchEngine={searchEngine} />
)}
</ActionIconGroup>
</Group>
</Group>
</Card>
);
};
const SearchEngineNoResults = async () => {
const t = await getI18n();
const session = await auth();
return (
<NoResults
icon={IconSearch}
title={t("search.engine.page.list.noResults.title")}
action={{
label: t("search.engine.page.list.noResults.action"),
href: "/manage/search-engines/new",
hidden: !session?.user.permissions.includes("search-engine-create"),
}}
/>
);
};
@@ -1,15 +1,17 @@
"use client"; "use client";
import type { ReactNode } from "react";
import React from "react"; import React from "react";
import { Card, LoadingOverlay, Stack, Title } from "@mantine/core"; import type { MantineSpacing } from "@mantine/core";
import { Card, Group, LoadingOverlay, Stack, Switch, Text, Title, UnstyledButton } from "@mantine/core";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client"; import type { UseFormReturnType } from "@homarr/form";
import { useForm } from "@homarr/form"; import { useForm } from "@homarr/form";
import type { defaultServerSettings } from "@homarr/server-settings"; import type { defaultServerSettings } from "@homarr/server-settings";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { SwitchSetting } from "~/app/[locale]/manage/settings/_components/setting-switch"; import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
interface AnalyticsSettingsProps { interface AnalyticsSettingsProps {
initialData: typeof defaultServerSettings.analytics; initialData: typeof defaultServerSettings.analytics;
@@ -60,7 +62,6 @@ export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => {
ms="xl" ms="xl"
title={t("integrationData.title")} title={t("integrationData.title")}
text={t("integrationData.text")} text={t("integrationData.text")}
disabled={!form.values.enableGeneral}
/> />
<SwitchSetting <SwitchSetting
form={form} form={form}
@@ -68,7 +69,6 @@ export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => {
ms="xl" ms="xl"
title={t("widgetData.title")} title={t("widgetData.title")}
text={t("widgetData.text")} text={t("widgetData.text")}
disabled={!form.values.enableGeneral}
/> />
<SwitchSetting <SwitchSetting
form={form} form={form}
@@ -76,10 +76,45 @@ export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => {
ms="xl" ms="xl"
title={t("usersData.title")} title={t("usersData.title")}
text={t("usersData.text")} text={t("usersData.text")}
disabled={!form.values.enableGeneral}
/> />
</Stack> </Stack>
</Card> </Card>
</> </>
); );
}; };
const SwitchSetting = ({
form,
ms,
title,
text,
formKey,
}: {
form: UseFormReturnType<typeof defaultServerSettings.analytics>;
formKey: keyof typeof defaultServerSettings.analytics;
ms?: MantineSpacing;
title: string;
text: ReactNode;
}) => {
const disabled = formKey !== "enableGeneral" && !form.values.enableGeneral;
const handleClick = React.useCallback(() => {
if (disabled) {
return;
}
form.setFieldValue(formKey, !form.values[formKey]);
}, [form, formKey, disabled]);
return (
<Group ms={ms} justify="space-between" gap="lg" align="center" wrap="nowrap">
<UnstyledButton style={{ flexGrow: 1 }} onClick={handleClick}>
<Stack gap={0}>
<Text fw="bold">{title}</Text>
<Text c="gray.5" fz={{ base: "xs", md: "sm" }}>
{text}
</Text>
</Stack>
</UnstyledButton>
<Switch disabled={disabled} onClick={handleClick} checked={form.values[formKey] && !disabled} />
</Group>
);
};
@@ -1,52 +0,0 @@
"use client";
import { Group, Text } from "@mantine/core";
import { IconMoon, IconSun } from "@tabler/icons-react";
import type { ColorScheme } from "@homarr/definitions";
import { colorSchemes } from "@homarr/definitions";
import type { ServerSettings } from "@homarr/server-settings";
import { useScopedI18n } from "@homarr/translation/client";
import { SelectWithCustomItems } from "@homarr/ui";
import { CommonSettingsForm } from "./common-form";
export const AppearanceSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["appearance"] }) => {
const tApperance = useScopedI18n("management.page.settings.section.appearance");
return (
<CommonSettingsForm settingKey="appearance" defaultValues={defaultValues}>
{(form) => (
<>
<SelectWithCustomItems
label={tApperance("defaultColorScheme.label")}
data={colorSchemes.map((scheme) => ({
value: scheme,
label: tApperance(`defaultColorScheme.options.${scheme}`),
}))}
{...form.getInputProps("defaultColorScheme")}
SelectOption={ApperanceCustomOption}
/>
</>
)}
</CommonSettingsForm>
);
};
const appearanceIcons = {
light: IconSun,
dark: IconMoon,
};
const ApperanceCustomOption = ({ value, label }: { value: ColorScheme; label: string }) => {
const Icon = appearanceIcons[value];
return (
<Group>
<Icon size={16} stroke={1.5} />
<Text fz="sm" fw={500}>
{label}
</Text>
</Group>
);
};
@@ -1,44 +0,0 @@
"use client";
import { Group, Text } from "@mantine/core";
import { IconLayoutDashboard } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import type { ServerSettings } from "@homarr/server-settings";
import { useScopedI18n } from "@homarr/translation/client";
import { SelectWithCustomItems } from "@homarr/ui";
import { CommonSettingsForm } from "./common-form";
export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["board"] }) => {
const tBoard = useScopedI18n("management.page.settings.section.board");
const [selectableBoards] = clientApi.board.getPublicBoards.useSuspenseQuery();
return (
<CommonSettingsForm settingKey="board" defaultValues={defaultValues}>
{(form) => (
<>
<SelectWithCustomItems
label={tBoard("homeBoard.label")}
description={tBoard("homeBoard.description")}
data={selectableBoards.map((board) => ({
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")}
/>
</>
)}
</CommonSettingsForm>
);
};
@@ -1,57 +0,0 @@
"use client";
import { Button, Group, Stack } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import type { ServerSettings } from "@homarr/server-settings";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
export const CommonSettingsForm = <TKey extends keyof ServerSettings>({
settingKey,
defaultValues,
children,
}: {
settingKey: TKey;
defaultValues: ServerSettings[TKey];
children: (form: ReturnType<typeof useForm<ServerSettings[TKey]>>) => React.ReactNode;
}) => {
const t = useI18n();
const tSettings = useScopedI18n("management.page.settings");
const { mutateAsync, isPending } = clientApi.serverSettings.saveSettings.useMutation({
onSuccess() {
showSuccessNotification({
message: tSettings("notification.success.message"),
});
},
onError() {
showErrorNotification({
message: tSettings("notification.error.message"),
});
},
});
const form = useForm({
initialValues: defaultValues,
});
const handleSubmitAsync = async (values: ServerSettings[TKey]) => {
await mutateAsync({
settingsKey: settingKey,
value: values,
});
};
return (
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack gap="sm">
{children(form)}
<Group justify="end">
<Button type="submit" loading={isPending}>
{t("common.action.save")}
</Button>
</Group>
</Stack>
</form>
);
};
@@ -1,68 +0,0 @@
"use client";
import React from "react";
import { Card, LoadingOverlay, Stack, Text, Title } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useForm } from "@homarr/form";
import type { defaultServerSettings } from "@homarr/server-settings";
import { useScopedI18n } from "@homarr/translation/client";
import { SwitchSetting } from "~/app/[locale]/manage/settings/_components/setting-switch";
interface CrawlingAndIndexingSettingsProps {
initialData: typeof defaultServerSettings.crawlingAndIndexing;
}
export const CrawlingAndIndexingSettings = ({ initialData }: CrawlingAndIndexingSettingsProps) => {
const t = useScopedI18n("management.page.settings.section.crawlingAndIndexing");
const form = useForm({
initialValues: initialData,
onValuesChange: (updatedValues, _) => {
if (!form.isValid()) {
return;
}
void mutateAsync({
settingsKey: "crawlingAndIndexing",
value: updatedValues,
});
},
});
const { mutateAsync, isPending } = clientApi.serverSettings.saveSettings.useMutation({
onSettled: async () => {
await revalidatePathActionAsync("/manage/settings");
},
});
return (
<>
<Title order={2}>{t("title")}</Title>
<Card pos="relative" withBorder>
<Text c={"dimmed"} mb={"lg"}>
{t("warning")}
</Text>
<LoadingOverlay visible={isPending} zIndex={1000} overlayProps={{ radius: "sm", blur: 2 }} />
<Stack>
<SwitchSetting form={form} formKey="noIndex" title={t("noIndex.title")} text={t("noIndex.text")} />
<SwitchSetting form={form} formKey="noFollow" title={t("noFollow.title")} text={t("noFollow.text")} />
<SwitchSetting
form={form}
formKey="noTranslate"
title={t("noTranslate.title")}
text={t("noTranslate.text")}
/>
<SwitchSetting
form={form}
formKey="noSiteLinksSearchBox"
title={t("noSiteLinksSearchBox.title")}
text={t("noSiteLinksSearchBox.text")}
/>
</Stack>
</Card>
</>
);
};
Loaded 100 of 799 files, more files were not shown because too many files have changed in this diff. Show more