Compare commits

..
Author SHA1 Message Date
Meier Lukas 7251938294 feat: add board preview 2024-05-04 22:27:04 +02:00
892 changed files with 12820 additions and 92979 deletions

No files matched your search

+1 -2
View File
@@ -5,5 +5,4 @@ npm-debug.log
README.md
.next
.git
dev
.build
dev
+2
View File
@@ -20,6 +20,8 @@ DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE'
# DB_PASSWORD='password'
# 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
# @see https://next-auth.js.org/configuration/options#secret
+15
View File
@@ -0,0 +1,15 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"packageRules": [
{
"matchPackagePatterns": ["^@homarr/"],
"enabled": false
}
],
"updateInternalDeps": true,
"rangeStrategy": "bump",
"automerge": false,
"baseBranches": ["dev"],
"dependencyDashboard": false
}
-19
View File
@@ -1,19 +0,0 @@
{
$schema: "https://docs.renovatebot.com/renovate-schema.json",
extends: ["config:recommended"],
packageRules: [
{
matchPackagePatterns: ["^@homarr/"],
enabled: false,
},
{
matchUpdateTypes: ["minor", "patch", "pin", "digest"],
automerge: true,
},
],
updateInternalDeps: true,
rangeStrategy: "bump",
automerge: false,
baseBranches: ["dev"],
dependencyDashboard: false,
}
+31
View File
@@ -0,0 +1,31 @@
name: Build apps and migration script
on:
pull_request:
branches: ["*"]
push:
branches: ["main"]
merge_group:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
env:
FORCE_COLOR: 3
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup
uses: ./tooling/github/setup
- name: Copy env
shell: bash
run: cp .env.example .env
- name: Build
run: pnpm build
+2 -20
View File
@@ -1,4 +1,4 @@
name: "[Quality] Code Analysis"
name: Code quality analysis
on:
pull_request:
@@ -8,7 +8,7 @@ on:
merge_group:
concurrency:
group: "${{ github.workflow }}-${{ github.ref }}"
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
# You can leverage Vercel Remote Caching with Turbo to speed up your builds
@@ -66,21 +66,3 @@ jobs:
- name: Test
run: pnpm test
- name: 'Report Coverage'
# 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
if: always()
uses: davelosert/vitest-coverage-report-action@v2
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup
uses: ./tooling/github/setup
- name: Copy env
shell: bash
run: cp .env.example .env
- name: Build
run: pnpm build
@@ -1,14 +0,0 @@
# https://github.com/webiny/action-conventional-commits?tab=readme-ov-file
name: "[Conventions] Semantic Commits"
on:
pull_request:
branches: [ dev ]
jobs:
build:
name: Conventional Commits
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: webiny/action-conventional-commits@v1.3.0
@@ -1,19 +0,0 @@
name: "[Conventions] Semantic PRs"
on:
pull_request_target:
types:
- opened
- edited
- synchronize
permissions:
pull-requests: read
jobs:
validate-pull-request-title:
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -1,135 +0,0 @@
name: "[Deployment] Release"
on:
push:
branches:
- main
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:
contents: write
packages: write
env:
SKIP_ENV_VALIDATION: true
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
TURBO_TELEMETRY_DISABLED: 1
concurrency: production
jobs:
deploy:
name: Deploy docker image
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20]
steps:
- name: Discord notification
if: ${{ github.events.inputs.send-notifications != false }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
with:
args: "Deployment of an image has been triggered: [run ${{ github.run_number }}](<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}>)"
- uses: actions/checkout@v4
- name: Get Next Version
id: semver
uses: ietf-tools/semver-action@v1
with:
token: ${{ github.token }}
branch: dev
- name: Discord notification
if: ${{ github.events.inputs.send-notifications != false }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
with:
args: "Semver computed next tag to be ${{ steps.semver.outputs.next }}. Current is ${{ steps.semver.outputs.current }}"
- uses: pnpm/action-setup@v2
with:
version: 8
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Discord notification
if: ${{ github.events.inputs.send-notifications != false }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
with:
args: "Built application artifacts. Building images..."
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
tags: |
type=raw,value=alpha
type=raw,value=early-adopters
# tags: |
# type=raw,value=latest
# type=raw,value=${{ steps.semver.outputs.next }}
- name: Build and push
id: buildPushAction
uses: docker/build-push-action@v6
if: ${{ github.events.inputs.push-image == 'true' || github.events.inputs.push-image == null }}
with:
platforms: linux/amd64,linux/arm64
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: .
push: false
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
network: host
env:
SKIP_ENV_VALIDATION: true
- 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.buildPushAction.outputs.imageid }}'."
- name: Discord notification
if: ${{ github.events.inputs.send-notifications != false && !(github.events.inputs.push-image == 'true' || github.events.inputs.push-image == null) }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
with:
args: "Deployment of image has completed. Image ID is '${{ steps.buildPushDryAction.outputs.imageid }}'. This was a dry run."
@@ -1,101 +0,0 @@
name: "[Deployment] Automatic Weekly Release"
on:
schedule:
- cron: "0 19 * * 5" # https://crontab.guru/#0_19_*_*_5
workflow_dispatch:
inputs:
send-notifications:
type: boolean
required: false
default: true
description: Send notifications
permissions:
contents: write
pull-requests: write
jobs:
create-and-merge-pr:
runs-on: ubuntu-latest
steps:
- name: Discord notification
if: ${{ github.events.inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
with:
args: "Automatic release has been triggered: [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get Next Version
id: semver
uses: ietf-tools/semver-action@v1
with:
token: ${{ github.token }}
branch: dev
- 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"
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
- name: Discord notification
if: ${{ github.events.inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
with:
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
if: ${{ steps.semver.outputs.bump != 'major' }}
name: automerge
uses: "pascalgn/automerge-action@v0.16.4"
env:
GITHUB_TOKEN: ${{ steps.obtainMergeToken.outputs.token }}
MERGE_METHOD: merge # we prefer merge commits for merging to master
MERGE_COMMIT_MESSAGE: "chore(release): automatic release ${{ steps.semver.outputs.next }}"
MERGE_DELETE_BRANCH: false # never set to true!
PULL_REQUEST: "${{ steps.create-pull-request.outputs.pr_number }}"
MERGE_RETRIES: 20 # 20 retries * MERGE_RETRY_SLEEP until step fails
MERGE_RETRY_SLEEP: 10000 # 10 seconds * MERGE_RETRIES until step fails
MERGE_REQUIRED_APPROVALS: 0 # do not require approvals
- name: Merged Discord notification
if: ${{ steps.automerge.outputs.mergeResult == 'merged' && github.events.inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
with:
args: "Merged PR ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}"
- name: Major Bump Discord notification
if: ${{ steps.semver.outputs.bump == 'major' && github.events.inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
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 }}"
- name: Discord Fail Notification
if: failure() && github.events.inputs.send-notifications
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
with:
args: "The automatic release workflow [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) has failed"
+89
View File
@@ -0,0 +1,89 @@
name: Docker image
on:
pull_request:
types:
- closed
branches:
- main
workflow_dispatch: {}
permissions:
contents: write
packages: write
env:
TURBO_TELEMETRY_DISABLED: 1
concurrency: production
jobs:
deploy:
name: Deploy docker image
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20]
steps:
- name: Discord notification
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
with:
args: "Deployment of an image has been triggered"
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with:
version: 8
- name: Use Node.js ${{ matrix.node-version }}
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: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Bump version and push tag
id: githubTagAction
uses: anothrNick/github-tag-action@1.67.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
WITH_V: false
DRY_RUN: true
- name: Discord notification
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
with:
args: "Image has been tagged as ${{ steps.githubTagAction.outputs.new_tag }}"
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest
type=raw,value=${{ steps.githubTagAction.outputs.new_tag }}
- name: Build and push
id: buildPushAction
uses: docker/build-push-action@v5
with:
platforms: linux/amd64,linux/arm64,linux/riscv64,linux/arm/v7,linux/arm/v6
context: .
push: false
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
network: host
- name: Discord notification
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
with:
args: "Image built with ID ${{ steps.buildPushAction.outputs.imageid }}"
@@ -1,25 +0,0 @@
name: "[Dependency Updates] Auto Approve"
on:
pull_request:
types: [opened, synchronize]
jobs:
approve-renovate-prs:
runs-on: ubuntu-latest
if: github.actor_id == 158783068 # Id of renovate bot see https://api.github.com/users/homarr-renovate%5Bbot%5D
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Obtain token
id: obtainToken
uses: tibdex/github-app-token@v2
with:
private_key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
app_id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
- name: Install GitHub CLI
run: sudo apt-get install -y gh
- name: Approve Renovate PRs
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
run: |
gh pr review ${{github.event.pull_request.number}} --approve --body "Automatically approved by GitHub Action"
+4 -13
View File
@@ -14,8 +14,8 @@ coverage
out/
next-env.d.ts
# artifacts
packages/db/migrations/*/migrate.cjs
# nest.js
apps/nestjs/dist
# nitro
.nitro/
@@ -48,16 +48,7 @@ yarn-error.log*
.turbo
# database
*.sqlite
db.sqlite
# logs
*.log
apps/tasks/tasks.cjs
apps/websocket/wssServer.cjs
apps/nextjs/.million/
packages/cli/cli.cjs
#personal backgrounds
apps/nextjs/public/images/background.png
*.log
+1 -1
View File
@@ -1 +1 @@
20.18.0
20.12.2
-13
View File
@@ -1,13 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="db:migration:mysql:generate" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="db:migration:mysql:generate" />
</scripts>
<node-interpreter value="project" />
<package-manager value="pnpm" />
<envs />
<method v="2" />
</configuration>
</component>
-13
View File
@@ -1,13 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="db:migration:sqlite:generate" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="db:migration:sqlite:generate" />
</scripts>
<node-interpreter value="project" />
<package-manager value="pnpm" />
<envs />
<method v="2" />
</configuration>
</component>
-13
View File
@@ -1,13 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="db:push" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="db:push" />
</scripts>
<node-interpreter value="project" />
<package-manager value="pnpm" />
<envs />
<method v="2" />
</configuration>
</component>
-13
View File
@@ -1,13 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="db:studio" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="db:studio" />
</scripts>
<node-interpreter value="project" />
<package-manager value="pnpm" />
<envs />
<method v="2" />
</configuration>
</component>
-13
View File
@@ -1,13 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="dev" type="js.build_tools.npm" nameIsGenerated="true">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="dev" />
</scripts>
<node-interpreter value="project" />
<package-manager value="pnpm" />
<envs />
<method v="2" />
</configuration>
</component>
-13
View File
@@ -1,13 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="docker:dev" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="docker:dev" />
</scripts>
<node-interpreter value="project" />
<package-manager value="pnpm" />
<envs />
<method v="2" />
</configuration>
</component>
-13
View File
@@ -1,13 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="format" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="format" />
</scripts>
<node-interpreter value="project" />
<package-manager value="pnpm" />
<envs />
<method v="2" />
</configuration>
</component>
-13
View File
@@ -1,13 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="format:fix" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="format:fix" />
</scripts>
<node-interpreter value="project" />
<package-manager value="pnpm" />
<envs />
<method v="2" />
</configuration>
</component>
-13
View File
@@ -1,13 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="test" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="test" />
</scripts>
<node-interpreter value="project" />
<package-manager value="pnpm" />
<envs />
<method v="2" />
</configuration>
</component>
-13
View File
@@ -1,13 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="test:ui" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="test:ui" />
</scripts>
<node-interpreter value="project" />
<package-manager value="pnpm" />
<envs />
<method v="2" />
</configuration>
</component>
+1 -2
View File
@@ -2,7 +2,6 @@
"recommendations": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"yoavbls.pretty-ts-errors",
"million.million-lint"
"yoavbls.pretty-ts-errors"
]
}
+3 -24
View File
@@ -4,32 +4,11 @@
"mode": "auto"
}
],
"eslint.experimental.useFlatConfig": true,
"typescript.tsdk": "node_modules\\typescript\\lib",
"js/ts.implicitProjectConfig.experimentalDecorators": true,
"prettier.configPath": "./tooling/prettier/index.mjs",
"cSpell.words": [
"ajnart",
"cqmin",
"gridstack",
"homarr",
"jellyfin",
"mantine",
"manuel-rw",
"Meierschlumpf",
"overseerr",
"Sabnzbd",
"SeDemal",
"Sonarr",
"superjson",
"tabler",
"trpc",
"Umami"
],
"i18n-ally.dirStructure": "auto",
"i18n-ally.enabledFrameworks": ["next-international"],
"i18n-ally.localesPaths": ["./packages/translation/src/lang/"],
"i18n-ally.enabledParsers": ["ts"],
"i18n-ally.extract.keyMaxLength": 0,
"i18n-ally.keystyle": "flat"
"homarr",
"trpc"
]
}
+18 -54
View File
@@ -1,9 +1,8 @@
FROM node:20.18.0-alpine AS base
FROM node:20.12.2-alpine AS base
FROM base AS builder
RUN apk add --no-cache libc6-compat
RUN apk update
# Set working directory
WORKDIR /app
COPY . .
@@ -12,7 +11,6 @@ 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
RUN turbo prune @homarr/cli --docker --out-dir ./cli-out
# Add lockfile and package.json's of isolated subworkspace
FROM base AS installer
@@ -24,19 +22,19 @@ WORKDIR /app
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/cli-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/pnpm-lock.yaml ./pnpm-lock.yaml
# Is used for postinstall of docs definitions
COPY --from=builder /app/packages/definitions/src/docs ./packages/definitions/src/docs
# Uses the lockfile to install the dependencies
RUN corepack enable pnpm && pnpm install --recursive --frozen-lockfile
# Install sharp for image optimization
COPY --from=builder /app/next-out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN corepack enable pnpm && pnpm install sharp -w
# Build the project
@@ -44,45 +42,17 @@ 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 --from=builder /app/cli-out/full/ .
# Copy static data as it is not part of the build
COPY static-data ./static-data
ARG SKIP_ENV_VALIDATION='true'
ARG CI='true'
ARG DISABLE_REDIS_LOGS='true'
RUN corepack enable pnpm && pnpm build
ARG SKIP_ENV_VALIDATION=true
RUN corepack enable pnpm && pnpm turbo run build
FROM base AS runner
WORKDIR /app
# gettext is required for envsubst
RUN apk add --no-cache redis nginx bash gettext
RUN mkdir /appdata
RUN mkdir /appdata/db
RUN mkdir /appdata/redis
VOLUME /appdata
RUN mkdir /secrets
VOLUME /secrets
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Enable homarr cli
COPY --from=installer --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
RUN apk add --no-cache redis
# Don't run production as root
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
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
USER nextjs
COPY --from=installer /app/apps/nextjs/next.config.mjs .
@@ -93,6 +63,7 @@ COPY --from=installer --chown=nextjs:nodejs /app/apps/websocket/wssServer.cjs ./
COPY --from=installer --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/packages/db/migrations ./db/migrations
COPY --from=installer --chown=nextjs:nodejs /app/packages/db/migrate.cjs ./db/migrate.cjs
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
@@ -100,14 +71,7 @@ COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/.next/standalone ./
COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/.next/static ./apps/nextjs/.next/static
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/generateEncryptionKey.js ./generateEncryptionKey.js
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_DIALECT='sqlite'
ENV DB_DRIVER='better-sqlite3'
ENV AUTH_PROVIDERS='credentials'
ENV DB_URL='/app/db/db.sqlite'
CMD ["sh", "run.sh"]
-2
View File
@@ -1,7 +1,5 @@
# THIS PROJECT IS STILL UNSTABLE AND WE DO NOT PROVIDE ANY SUPPORT FOR ISSUES THAT OCCURE.
## PLEASE DO NOT OPEN ANY ISSUES OR DISCUSSIONS
### EVERYTHING IS SUBJECT TO CHANGE
Please use [this](https://github.com/ajnart/homarr) version of Homarr when you want to use it
-13
View File
@@ -1,13 +0,0 @@
import baseConfig from "@homarr/eslint-config/base";
import nextjsConfig from "@homarr/eslint-config/nextjs";
import reactConfig from "@homarr/eslint-config/react";
/** @type {import('typescript-eslint').Config} */
export default [
{
ignores: [".next/**"],
},
...baseConfig,
...reactConfig,
...nextjsConfig,
];
+13 -19
View File
@@ -1,9 +1,6 @@
// Importing env files here to validate on build
import "@homarr/auth/env.mjs";
import MillionLint from "@million/lint";
import "./src/env.mjs";
import "@homarr/auth/env.mjs";
/** @type {import("next").NextConfig} */
const config = {
@@ -12,26 +9,23 @@ const config = {
/** We already do linting and typechecking as separate tasks in CI */
eslint: { ignoreDuringBuilds: true },
typescript: { ignoreBuildErrors: true },
webpack: (config, { isServer }) => {
if (isServer) {
config.module.rules.push({
test: /\.node$/,
loader: "node-loader",
});
}
return config;
},
experimental: {
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
optimizePackageImports: [
"@mantine/core",
"@mantine/hooks",
"@tabler/icons-react",
],
},
transpilePackages: ["@homarr/ui", "@homarr/notifications", "@homarr/modals", "@homarr/spotlight", "@homarr/widgets"],
transpilePackages: [
"@homarr/ui",
"@homarr/notifications",
"@homarr/modals",
"@homarr/spotlight",
"@homarr/widgets",
],
images: {
domains: ["cdn.jsdelivr.net"],
},
};
// Skip transform is used because of webpack loader, without it for example 'Tooltip.Floating' will not work and show an error
const withMillionLint = MillionLint.next({ rsc: true, skipTransform: true, telemetry: false });
export default config;
+38 -49
View File
@@ -7,72 +7,54 @@
"build": "pnpm with-env next build",
"clean": "git clean -xdf .next .turbo node_modules",
"dev": "pnpm with-env next dev",
"lint": "dotenv -v SKIP_ENV_VALIDATION=1 next lint",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"start": "pnpm with-env next start",
"typecheck": "tsc --noEmit",
"with-env": "dotenv -e ../../.env --"
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/analytics": "workspace:^0.1.0",
"@homarr/api": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/cron-job-status": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/form": "workspace:^0.1.0",
"@homarr/gridstack": "^1.0.3",
"@homarr/integrations": "workspace:^0.1.0",
"@homarr/gridstack": "^1.0.0",
"@homarr/log": "workspace:^",
"@homarr/modals": "workspace:^0.1.0",
"@homarr/modals-collection": "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/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0",
"@mantine/colors-generator": "^7.13.4",
"@mantine/core": "^7.13.4",
"@mantine/hooks": "^7.13.4",
"@mantine/modals": "^7.13.4",
"@mantine/tiptap": "^7.13.4",
"@million/lint": "1.0.11",
"@t3-oss/env-nextjs": "^0.11.1",
"@tabler/icons-react": "^3.20.0",
"@tanstack/react-query": "^5.59.16",
"@tanstack/react-query-devtools": "^5.59.16",
"@tanstack/react-query-next-experimental": "5.59.16",
"@trpc/client": "next",
"@mantine/colors-generator": "^7.9.0",
"@mantine/hooks": "^7.9.0",
"@mantine/modals": "^7.9.0",
"@mantine/tiptap": "^7.9.0",
"@t3-oss/env-nextjs": "^0.10.1",
"@tanstack/react-query": "^5.34.1",
"@tanstack/react-query-devtools": "^5.34.1",
"@tanstack/react-query-next-experimental": "5.34.1",
"@trpc/client": "11.0.0-rc.364",
"@trpc/next": "next",
"@trpc/react-query": "next",
"@trpc/server": "next",
"@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-fit": "0.10.0",
"@xterm/xterm": "^5.5.0",
"chroma-js": "^3.1.2",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"chroma-js": "^2.4.2",
"dayjs": "^1.11.11",
"dotenv": "^16.4.5",
"flag-icons": "^7.2.3",
"glob": "^11.0.0",
"jotai": "^2.10.1",
"mantine-react-table": "2.0.0-beta.7",
"next": "^14.2.16",
"postcss-preset-mantine": "^1.17.0",
"prismjs": "^1.29.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-error-boundary": "^4.1.2",
"react-simple-code-editor": "^0.14.1",
"sass": "^1.80.4",
"glob": "^10.3.12",
"jotai": "^2.8.0",
"next": "^14.2.3",
"postcss-preset-mantine": "^1.15.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"sass": "^1.76.0",
"superjson": "2.2.1",
"swagger-ui-react": "^5.17.14",
"use-deep-compare-effect": "^1.8.1"
},
"devDependencies": {
@@ -80,15 +62,22 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/chroma-js": "2.4.4",
"@types/node": "^20.17.1",
"@types/prismjs": "^1.26.5",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/swagger-ui-react": "^4.18.3",
"concurrently": "^9.0.1",
"eslint": "^9.13.0",
"node-loader": "^2.0.0",
"prettier": "^3.3.3",
"typescript": "^5.6.3"
}
"@types/node": "^20.12.8",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.0",
"concurrently": "^8.2.2",
"eslint": "^8.57.0",
"prettier": "^3.2.5",
"tsx": "4.9.0",
"typescript": "^5.4.5"
},
"eslintConfig": {
"root": true,
"extends": [
"@homarr/eslint-config/base",
"@homarr/eslint-config/nextjs",
"@homarr/eslint-config/react"
]
},
"prettier": "@homarr/prettier-config"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 497 B

-25
View File
@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="1024" height="1024" version="1.1" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<defs>
<clipPath id="a">
<circle cx="512" cy="512" r="410" fill="#989898" stroke-width="3.5906"/>
</clipPath>
<clipPath id="b">
<path d="m512 636l0-565 -330 565 330 0z" fill="#000"/>
<path d="m512 388l0 565 330-565 -330 0z" fill="#000"/>
</clipPath>
</defs>
<g id="lidarr-group-logo" transform="translate(5e-6 .26738)">
<circle id="lidarr-black-circle" cx="512" cy="512" r="410" stroke-width="1.8"/>
<path id="lidarr-honeycomb-a" d="m852.12 33.49l23.46 14.44 23.22-14.44 0-28.87 -23.22-14.44 -23.46 14.44 0 28.87zm-46.44-28.87l-23.22-14.44 -23.2 14.44 0 28.87 23.2 14.44 23.22-14.44 0-28.87 23.22-14.44 23.22 14.44m-46.44 28.87l23.22 14.44 23.22-14.44m-46.44 57.49l0 28.87 23.22 14.44 23.22-14.44 0-28.87 -23.22-14.18 -23.22 14.18 -23.22-14.18 -23.2 14.18m92.86 28.87l23.46 14.44 23.22-14.44 0-28.87 -23.22-14.18 -23.46 14.18m23.46-43.05l0 28.87m-46.68-28.87l0 28.87m69.9-72.18l23.22-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.2-14.44 23.22 14.44 0 28.87 23.22 14.44 0 28.87 -23.22 14.18 0 28.87 23.22 14.44 0 28.87 -23.22 14.44 0 28.87 23.22 14.44 0 28.87 -23.22 14.16 0 28.87 23.22 14.44 0 28.87 -23.22 14.44 0 28.87 23.22 14.44 0 28.87 -23.22 14.16 0 28.87 23.22 14.44 0 28.87 -23.22 14.45 0 28.87 23.22 14.44 0 28.87 -23.22 14.16 0 28.87 23.22 14.44 0 28.87 -23.22 14.44 0 28.87 23.22 14.44 0 28.87 -23.22 14.16 0 28.87 23.22 14.44 0 28.87 -23.22 14.44 0 28.87 23.22 14.44 0 28.87 -23.22 14.18 0 28.87 23.22 14.44 0 28.87 -23.22 14.44 -23.22-14.44 -23.2 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.46 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.2 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.46 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.2 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.2 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.46 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.22 14.44 -23.2-14.44 -23.22 14.44 -23.22-14.44 -23.46 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 0-28.87 -23.22-14.44 0-28.87 23.22-14.18 0-28.87 -23.22-14.44 0-28.87 23.22-14.44 0-28.87 -23.22-14.44 0-28.87 23.22-14.16 0-28.87 -23.22-14.44 0-28.87 23.22-14.44 0-28.87 -23.22-14.44 0-28.87 23.22-14.16 0-28.87 -23.22-14.44 0-28.87 23.22-14.45 0-28.87 -23.22-14.44 0-28.87 23.22-14.16 0-28.87 -23.22-14.44 0-28.87 23.22-14.44 0-28.87 -23.22-14.44 0-28.87 23.22-14.16 0-28.87 -23.22-14.44 0-28.87 23.22-14.44 0-28.87 -23.22-14.44 0-28.87 23.22-14.18 0-28.87 -23.22-14.44 0-28.87 23.22-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.46-14.44 23.22 14.44 23.22-14.44 23.2 14.44 23.22-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.46-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.2-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.2-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.46-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.22-14.44 23.22 14.44m-46.44 0l0 28.87 23.22 14.44 23.22-14.44m-46.44 57.49l0 28.87 23.22 14.44 23.22-14.44 0-28.87 -23.22-14.18 -23.22 14.18 -23.22-14.18 -23.22 14.18m116.08-43.05l0 28.87m-46.42-28.87l0 28.87m23.22 100.79l0 28.87 23.2 14.44 23.22-14.44 0-28.87 -23.22-14.44 -23.2 14.44 -23.22-14.44 -23.22 14.44 0 28.87 23.22 14.44 23.22-14.44m46.42-86.61l-23.22 14.44 0 28.87m-23.2-43.31l23.2 14.44m-46.42 0l0 28.87m0 57.74l0 28.87 23.22 14.16 23.2-14.16 0-28.87m46.44-86.61l0 28.87 23.22 14.44 23.46-14.44 0-28.87m-46.68 28.87l-23.22 14.44m-23.22 72.18l23.22 14.16 23.22-14.16 0-28.87 -23.22-14.44m46.44-28.87l0 28.87 23.46 14.44 23.22-14.44 0-28.87 -23.22-14.44m0 57.74l0 28.87 23.22 14.16 23.22-14.16 0-28.87 -23.22-14.44m-69.9 14.44l23.22-14.44m-185.74-201.84l0 28.87 23.22 14.44 23.22-14.44m-139.56-28.87l0 28.87 23.22 14.44 23.22-14.44 0-28.87m0 28.87l23.46 14.44 23.22-14.44m-46.68 57.49l0 28.87 23.46 14.44 23.22-14.44 0-28.87 -23.22-14.18 -23.46 14.18 -23.22-14.18 -23.22 14.18 0 28.87 23.22 14.44 23.22-14.44m-23.22-71.92l0 28.87m93.12-28.87l0 28.87m-46.44-28.87l0 28.87m-116.32-72.18l0 28.87 23.2 14.44 23.22-14.44m-92.86-28.87l0 28.87 23.22 14.44 23.22-14.44m-23.22 43.31l-23.22 14.18 0 28.87 23.22 14.44 23.22-14.44 0-28.87 -23.22-14.18zm69.64 14.18l-23.22-14.18 -23.2 14.18m-23.22-43.05l0 28.87m46.42-28.87l0 28.87m23.22 43.05l-23.22 14.44 0 28.87 23.22 14.44 23.22-14.44 0-28.87m-92.86 0l0 28.87 23.22 14.44 23.2-14.44m-46.42 86.61l23.22 14.16 23.2-14.16 0-28.87 -23.2-14.44 -23.22 14.44 0 28.87 -23.22 14.16 0 28.87 23.22 14.44 23.22-14.44 0-28.87m23.2-43.03l23.22-14.44 0-28.87m-46.42-57.74l23.2 14.44m-23.2 72.18l0-28.87m116.32-43.31l0 28.87 23.22 14.44 23.22-14.44 0-28.87 -23.22-14.44m-93.12 86.61l23.22 14.44 23.22-14.44 0-28.87 -23.22-14.44m46.68 0l-23.46 14.44m23.46 72.18l23.22 14.16 23.22-14.16 0-28.87 -23.22-14.44 -23.22 14.44 0 28.87 -23.46 14.16 0 28.87 -23.22 14.44 0 28.87 23.22 14.44 23.46-14.44 0-28.87 -23.46-14.44m-69.66-43.03l23.22 14.16 23.22-14.16 0-28.87m23.22-14.44l23.46 14.44m23.22-14.44l0-28.87m0 86.34l0 28.87 23.22 14.44 23.22-14.44 0-28.87 -23.22-14.16m-69.9 100.77l0 28.87 23.46 14.44 23.22-14.44 0-28.87 -23.22-14.44m23.22 43.31l23.22 14.44 23.22-14.44 0-28.87 -23.22-14.44 -23.22 14.44m-23.22-43.31l23.22-14.44m23.22 14.44l0 28.87m-93.12-28.87l-23.22-14.44 -23.22 14.44 0 28.87 23.22 14.44 23.22-14.44m-23.22-43.31l0-28.87m-69.64 72.18l-23.22 14.44 0 28.87 23.22 14.44 23.22-14.44 0-28.87 -23.22-14.44zm23.22 4Line truncated
<ellipse cx="512" cy="512" rx="460" ry="460" style="fill:none;stroke-width:99;stroke:#e5e5e5"/>
<ellipse cx="512" cy="512" rx="270" ry="270" style="fill:#e5e5e5;stroke-width:76;stroke:#e5e5e5"/>
<ellipse ry="410" rx="410" cy="512" cx="512" style="fill-opacity:null;fill:none;stroke-width:12;stroke:#009252"/>
<path d="m512 636l0-565 -330 565 330 0z" fill="#000"/>
<path d="m512 388l0 565 330-565 -330 0z" fill="#000"/>
<path id="lidarr-honeycomb-b" d="m852.12 33.49l23.46 14.44 23.22-14.44 0-28.87 -23.22-14.44 -23.46 14.44 0 28.87zm-46.44-28.87l-23.22-14.44 -23.2 14.44 0 28.87 23.2 14.44 23.22-14.44 0-28.87 23.22-14.44 23.22 14.44m-46.44 28.87l23.22 14.44 23.22-14.44m-46.44 57.49l0 28.87 23.22 14.44 23.22-14.44 0-28.87 -23.22-14.18 -23.22 14.18 -23.22-14.18 -23.2 14.18m92.86 28.87l23.46 14.44 23.22-14.44 0-28.87 -23.22-14.18 -23.46 14.18m23.46-43.05l0 28.87m-46.68-28.87l0 28.87m69.9-72.18l23.22-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.2-14.44 23.22 14.44 0 28.87 23.22 14.44 0 28.87 -23.22 14.18 0 28.87 23.22 14.44 0 28.87 -23.22 14.44 0 28.87 23.22 14.44 0 28.87 -23.22 14.16 0 28.87 23.22 14.44 0 28.87 -23.22 14.44 0 28.87 23.22 14.44 0 28.87 -23.22 14.16 0 28.87 23.22 14.44 0 28.87 -23.22 14.45 0 28.87 23.22 14.44 0 28.87 -23.22 14.16 0 28.87 23.22 14.44 0 28.87 -23.22 14.44 0 28.87 23.22 14.44 0 28.87 -23.22 14.16 0 28.87 23.22 14.44 0 28.87 -23.22 14.44 0 28.87 23.22 14.44 0 28.87 -23.22 14.18 0 28.87 23.22 14.44 0 28.87 -23.22 14.44 -23.22-14.44 -23.2 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.46 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.2 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.46 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.2 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.2 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.46 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.22 14.44 -23.2-14.44 -23.22 14.44 -23.22-14.44 -23.46 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 0-28.87 -23.22-14.44 0-28.87 23.22-14.18 0-28.87 -23.22-14.44 0-28.87 23.22-14.44 0-28.87 -23.22-14.44 0-28.87 23.22-14.16 0-28.87 -23.22-14.44 0-28.87 23.22-14.44 0-28.87 -23.22-14.44 0-28.87 23.22-14.16 0-28.87 -23.22-14.44 0-28.87 23.22-14.45 0-28.87 -23.22-14.44 0-28.87 23.22-14.16 0-28.87 -23.22-14.44 0-28.87 23.22-14.44 0-28.87 -23.22-14.44 0-28.87 23.22-14.16 0-28.87 -23.22-14.44 0-28.87 23.22-14.44 0-28.87 -23.22-14.44 0-28.87 23.22-14.18 0-28.87 -23.22-14.44 0-28.87 23.22-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.46-14.44 23.22 14.44 23.22-14.44 23.2 14.44 23.22-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.46-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.2-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.2-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.46-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.22-14.44 23.22 14.44m-46.44 0l0 28.87 23.22 14.44 23.22-14.44m-46.44 57.49l0 28.87 23.22 14.44 23.22-14.44 0-28.87 -23.22-14.18 -23.22 14.18 -23.22-14.18 -23.22 14.18m116.08-43.05l0 28.87m-46.42-28.87l0 28.87m23.22 100.79l0 28.87 23.2 14.44 23.22-14.44 0-28.87 -23.22-14.44 -23.2 14.44 -23.22-14.44 -23.22 14.44 0 28.87 23.22 14.44 23.22-14.44m46.42-86.61l-23.22 14.44 0 28.87m-23.2-43.31l23.2 14.44m-46.42 0l0 28.87m0 57.74l0 28.87 23.22 14.16 23.2-14.16 0-28.87m46.44-86.61l0 28.87 23.22 14.44 23.46-14.44 0-28.87m-46.68 28.87l-23.22 14.44m-23.22 72.18l23.22 14.16 23.22-14.16 0-28.87 -23.22-14.44m46.44-28.87l0 28.87 23.46 14.44 23.22-14.44 0-28.87 -23.22-14.44m0 57.74l0 28.87 23.22 14.16 23.22-14.16 0-28.87 -23.22-14.44m-69.9 14.44l23.22-14.44m-185.74-201.84l0 28.87 23.22 14.44 23.22-14.44m-139.56-28.87l0 28.87 23.22 14.44 23.22-14.44 0-28.87m0 28.87l23.46 14.44 23.22-14.44m-46.68 57.49l0 28.87 23.46 14.44 23.22-14.44 0-28.87 -23.22-14.18 -23.46 14.18 -23.22-14.18 -23.22 14.18 0 28.87 23.22 14.44 23.22-14.44m-23.22-71.92l0 28.87m93.12-28.87l0 28.87m-46.44-28.87l0 28.87m-116.32-72.18l0 28.87 23.2 14.44 23.22-14.44m-92.86-28.87l0 28.87 23.22 14.44 23.22-14.44m-23.22 43.31l-23.22 14.18 0 28.87 23.22 14.44 23.22-14.44 0-28.87 -23.22-14.18zm69.64 14.18l-23.22-14.18 -23.2 14.18m-23.22-43.05l0 28.87m46.42-28.87l0 28.87m23.22 43.05l-23.22 14.44 0 28.87 23.22 14.44 23.22-14.44 0-28.87m-92.86 0l0 28.87 23.22 14.44 23.2-14.44m-46.42 86.61l23.22 14.16 23.2-14.16 0-28.87 -23.2-14.44 -23.22 14.44 0 28.87 -23.22 14.16 0 28.87 23.22 14.44 23.22-14.44 0-28.87m23.2-43.03l23.22-14.44 0-28.87m-46.42-57.74l23.2 14.44m-23.2 72.18l0-28.87m116.32-43.31l0 28.87 23.22 14.44 23.22-14.44 0-28.87 -23.22-14.44m-93.12 86.61l23.22 14.44 23.22-14.44 0-28.87 -23.22-14.44m46.68 0l-23.46 14.44m23.46 72.18l23.22 14.16 23.22-14.16 0-28.87 -23.22-14.44 -23.22 14.44 0 28.87 -23.46 14.16 0 28.87 -23.22 14.44 0 28.87 23.22 14.44 23.46-14.44 0-28.87 -23.46-14.44m-69.66-43.03l23.22 14.16 23.22-14.16 0-28.87m23.22-14.44l23.46 14.44m23.22-14.44l0-28.87m0 86.34l0 28.87 23.22 14.44 23.22-14.44 0-28.87 -23.22-14.16m-69.9 100.77l0 28.87 23.46 14.44 23.22-14.44 0-28.87 -23.22-14.44m23.22 43.31l23.22 14.44 23.22-14.44 0-28.87 -23.22-14.44 -23.22 14.44m-23.22-43.31l23.22-14.44m23.22 14.44l0 28.87m-93.12-28.87l-23.22-14.44 -23.22 14.44 0 28.87 23.22 14.44 23.22-14.44m-23.22-43.31l0-28.87m-69.64 72.18l-23.22 14.44 0 28.87 23.22 14.44 23.22-14.44 0-28.87 -23.22-14.44zm23.22 4Line truncated
<path d="m512 636l0-565 -330 565 330 0z" style="fill:none;stroke-width:16;stroke:#009252;"/>
<path d="m512 388l0 565 330-565 -330 0z" style="fill:none;stroke-width:16;stroke:#009252;"/>
<ellipse cx="512" cy="512" rx="150" ry="150" style="fill-opacity:null;fill:#009252;stroke-opacity:null;stroke-width:null;stroke:null"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 54 KiB

@@ -1 +0,0 @@
<svg height="1024" viewBox="0 0 1024 1024" width="1024" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><path id="a" d="m0 0h1024v1024h-1024z"/><clipPath id="b"><use clip-rule="evenodd" xlink:href="#a"/></clipPath></defs><g clip-path="url(#b)"><use fill="none" xlink:href="#a"/><g transform="translate(70 21.00012)"><path d="m105.302 154.943 7.522 714.549c-60.173 7.522-105.30242-22.565-105.30242-82.737l-7.52158-594.205c0-188.03894 172.996-233.1684 278.298-157.9526l534.032 308.3846c75.216 52.651 90.259 150.431 52.651 218.125-7.521-52.651-30.086-82.737-75.216-112.823l-601.726-338.471c-45.129-30.0862-82.737-22.5646-82.737 45.13z" fill="#24292e"/><path d="m0 376.079c45.1295 15.043 90.259 7.521 127.867-15.043l616.769-361.036c37.608 52.651 30.087 105.302-15.043 135.388l-518.989 300.863c-75.216 37.608-172.9961 0-210.604-60.172z" fill="#24292e" transform="translate(60.17249 531.0214)"/><path d="m0 413.687 368.557-210.604-361.03543-203.083z" fill="#ffc230" transform="translate(240.6902 282.8092)"/></g></g></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

@@ -1 +0,0 @@
<svg version="1.1" viewBox="0 0 1000 1000" xmlns="http://www.w3.org/2000/svg"><g transform="matrix(.97895 0 0 .97895 -2.2026 -2.2026)"><g stroke="#443c3c" stroke-width="1.5"><circle cx="513" cy="513" r="510" fill="#eee"/><circle cx="513" cy="513" r="440" fill="#443c3c"/><circle cx="513" cy="513" r="387" fill="#8e2222"/></g><g stroke-width="1.5"><circle cx="513" cy="513" r="378" fill="#eee" stroke="#8e2222"/><circle cx="511.67" cy="514.33" r="265" fill="#443c3c" stroke="#443c3c"/></g><g stroke="#8e2222"><path d="m176.71 682.24-5.71-356.67c0.634-53.106 17.5-47.829 30.454-49.405 198.58 10.83 270.91 71.252 275.35 73.499 13.323 5.018 20.937 31.782 20.302 31.123 0.634 0.658 4.441 420.6 3.807 419.94 3.172 22.455-13.323 21.002-13.958 20.343-124.99-98.152-297.56-122.85-298.19-123.51-12.055-0.795-12.055-15.326-12.055-15.326zm670.08 0.82 5.711-357.54c-0.635-53.236-17.501-47.946-30.456-49.526-198.6 10.857-270.93 71.426-275.38 73.679-13.325 5.03-20.939 31.859-20.304 31.199-0.635 0.66-4.442 421.63-3.807 420.97-3.173 22.51 13.325 21.053 13.959 20.393 125-98.392 297.58-123.15 298.22-123.81 12.056-0.797 12.056-15.363 12.056-15.363z" fill="#eee" stroke-width="10"/><path d="m174.14 739.57-5.802-356.67c0.645-53.106 17.782-47.829 30.945-49.405 201.79 10.83 275.28 71.252 279.8 73.499 13.539 5.018 21.275 31.782 20.63 31.123 0.645 0.658 4.513 420.6 3.868 419.94 3.224 22.455-13.539 21.002-14.183 20.343-127-98.152-302.36-122.85-303.01-123.51-12.249-0.795-12.249-15.326-12.249-15.326zm675.22 0.49 5.803-357.54c-0.645-53.236-17.784-47.946-30.948-49.526-201.81 10.857-275.31 71.426-279.82 73.679-13.54 5.03-21.277 31.859-20.632 31.199-0.645 0.66-4.513 421.63-3.869 420.97-3.224 22.51 13.54 21.053 14.184 20.393 127.02-98.392 302.39-123.15 303.03-123.81 12.25-0.797 12.25-15.363 12.25-15.363z" fill="#8e2222" stroke-width="5"/></g></g></svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><g clip-rule="evenodd"><path fill="#eee" fill-rule="evenodd" d="M47.978 24c0 6.602-2.331 12.26-6.993 16.974a3.773 3.773 0 0 1-.52.509 20.53 20.53 0 0 1-2.435 2.047C33.988 46.51 29.318 48 24.022 48c-5.304 0-9.966-1.49-13.986-4.47a21.726 21.726 0 0 1-2.988-2.556c-3.622-3.6-5.846-7.783-6.672-12.548-.162-.93-.27-1.874-.32-2.833a38.27 38.27 0 0 1 0-3.197c0-.052.014-.104.044-.155.346-5.887 2.662-10.973 6.948-15.259C11.762 2.327 17.42 0 24.022 0c6.624 0 12.279 2.327 16.963 6.982 4.662 4.743 6.993 10.416 6.993 17.018z"/><path fill="#3a3f51" fill-rule="evenodd" d="m43.098 9.405-4.957 4.957c-2.899 2.899-3.153 5.422-3.153 9.87 0 3.97.63 7.602 3.585 10.556 2.156 2.157 4.204 4.194 4.204 4.194a27.962 27.962 0 0 1-1.792 1.992 3.773 3.773 0 0 1-.52.509 20.05 20.05 0 0 1-1.749 1.538l-3.883-3.884c-3.452-3.452-6.196-3.784-10.756-3.784-4.375 0-7.352.403-10.556 3.607a2715.831 2715.831 0 0 0-4.105 4.116 21.196 21.196 0 0 1-2.368-2.102 27.739 27.739 0 0 1-1.737-1.903s2.168-2.18 4.238-4.25c3.066-3.065 3.563-6.62 3.563-10.589 0-3.872-.636-7.485-3.452-10.301C7.705 11.975 5 9.284 5 9.284a25.954 25.954 0 0 1 2.047-2.302A29.761 29.761 0 0 1 9.04 5.201l4.504 4.503c2.877 2.877 6.565 3.618 10.533 3.618 4.087 0 7.763-.791 10.756-3.784 1.84-1.841 4.27-4.26 4.27-4.26a25.168 25.168 0 0 1 1.882 1.704c.767.782 1.471 1.59 2.113 2.423z"/><path fill="#0cf" fill-rule="evenodd" d="M17.438 25.228a6.986 6.986 0 0 1-.1-1.228c0-.155.005-.303.012-.443 0-.014.004-.029.011-.044.096-1.63.738-3.039 1.925-4.227 1.306-1.29 2.874-1.936 4.703-1.936 1.837 0 3.404.645 4.703 1.936 1.29 1.313 1.936 2.884 1.936 4.714s-.645 3.397-1.936 4.703c-.045.051-.093.1-.144.143a6.056 6.056 0 0 1-.675.565c-1.121.826-2.416 1.239-3.884 1.239s-2.759-.413-3.873-1.24a5.818 5.818 0 0 1-.83-.707c-1.003-.996-1.619-2.155-1.848-3.475z"/><path fill="none" stroke="#0cf" stroke-miterlimit="1" stroke-width=".4426" d="m34.943 13.223-3.32 3.242M6.834 7.198l9.044 9.012M34.6 34.855l6.154 6.369m.41-34.056-6.22 6.056M7.18 41.107l6.053-6.063"/><path fill="none" stroke="#0cf" stroke-miterlimit="1" stroke-width="1.5491" d="m34.943 13.223-3.75 3.806m-18.12-3.617 3.806 3.795m-3.662 17.854 3.706-3.851m13.705-.309 3.99 3.971"/></g></svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="100px" height="54px" viewBox="0 0 100 54" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>Logo tvdb</title>
<g id="Logo-tvdb" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<path d="M0,5.09590006 C0,1.81024006 2.9636,-0.441498938 6.46228,0.0733078623 L6.46228,0.0733078623 L52.10124,6.03470006 C54.15254,6.33652006 55.78724,8.54666006 55.78724,10.9536001 L55.78724,10.9536001 L55.78654,17.1835001 C51.94104,19.7605001 49.42044,24.0737001 49.42044,28.9596001 C49.42044,33.8924001 51.87974,38.1680001 55.78724,40.7361001 L55.78724,40.7361001 L55.78724,43.4756001 C55.78724,45.8825001 54.15254,48.0927001 52.10124,48.3945001 L52.10124,48.3945001 L11.60314,53.9266001 C8.10444,54.4417001 5.14084,52.1897001 5.14084,48.9040001 L5.14084,48.9040001 Z M19.68044,10.8218001 L13.66114,10.8218001 L13.66114,18.7064001 L9.84244,18.7064001 L9.84244,23.2621001 L13.66114,23.2621001 L13.66114,32.0227001 C13.4846091,37.5274601 15.6467584,39.9923503 20.6149401,40.0386142 L25.25134,40.0387001 L25.25134,35.4830001 L22.87064,35.4830001 C20.17484,35.3516001 19.59134,34.5631001 19.68074,31.0149001 L19.68074,23.2617001 L27.08014,23.2617001 L33.93424,40.0384001 L40.40294,40.0384001 L49.83694,18.7061001 L43.45734,18.7061001 L37.34794,33.3806001 L31.77694,18.7064001 L19.68044,18.7064001 L19.68044,10.8218001 Z" id="Combined-Shape" fill="#6CD591" fill-rule="nonzero"></path>
<path d="M88.60974,18.2771001 C92.51784,18.2771001 95.12314,19.2407001 97.09994,21.4310001 C98.71734,23.1831001 99.57074,25.7677001 99.57074,28.6584001 C99.57074,32.8634001 97.86394,36.1487001 94.76414,38.0323001 C92.74234,39.2590001 90.99054,39.6094001 87.03734,39.6094001 L77.24404,39.6094001 L77.24404,10.3925001 L83.26404,10.3925001 L83.26404,18.2771001 L88.60974,18.2771001 Z M83.26404,35.0537001 L87.71094,35.0537001 C91.26004,35.0537001 93.41634,32.6884001 93.41634,28.8334001 C93.41634,24.8035001 91.52964,22.8324001 87.71094,22.8324001 L83.26404,22.8324001 L83.26404,35.0537001 Z" id="Shape" fill="#FFFFFF" fill-rule="nonzero"></path>
<path d="M68.01354,10.3925001 L74.03354,10.3925001 L74.03354,39.6094001 L63.65594,39.6094001 C59.43354,39.6094001 57.41174,38.9962001 55.25524,37.1126001 C53.05394,35.1416001 51.93124,32.3384001 51.93124,28.7898001 C51.93124,25.1102001 53.14404,22.3070001 55.70494,20.2481001 C57.32204,18.9342001 59.52364,18.2771001 62.35354,18.2771001 L68.01384,18.2771001 L68.01384,10.3925001 L68.01354,10.3925001 Z M68.01354,22.8327001 L63.65594,22.8327001 C60.15224,22.8327001 58.04064,25.0667001 58.04064,28.7898001 C58.04064,32.6884001 60.19654,35.0537001 63.65594,35.0537001 L68.01354,35.0537001 L68.01354,22.8327001 Z" id="Shape" fill="#FFFFFF" fill-rule="nonzero"></path>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

@@ -1 +0,0 @@
<svg viewBox="0 0 90 90" xmlns="http://www.w3.org/2000/svg"><g fill="none"><path fill="#31BEEC" d="M90 38.197v19.137L48.942 80.999V61.864z"/><path d="M41.086 61.863V81L0 57.333V38.197l18.566 10.687c.02.016.043.03.067.04l22.453 12.94Z" fill="#0095D5"/><path fill="#AEADAE" d="m61.621 45.506-16.607 9.576-16.622-9.576 16.622-9.575z"/><path fill="#0095D5" d="M86.086 31.416 69.464 40.99 48.942 29.15V10z"/><path fill="#31BEEC" d="M41.086 10v19.15l-20.55 11.827-16.621-9.561z"/></g></svg>

Before

Width:  |  Height:  |  Size: 484 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><linearGradient id="a" x1="100%" x2="0" y1="0" y2="100%" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ff8d30"/><stop offset="1" stop-color="#e32929"/></linearGradient></defs><circle cx="50%" cy="50%" r="50%" fill="url(#a)"/><path fill="#fff" d="M246.6 200.8h18.7v110.6h-18.7zm-182.3 0H83v110.7H64.3zm91.1 123.9h18.7V367h-18.7zm-45.7-47.5h18.7v68.5h-18.7zm91.2 0h18.6v68.4h-18.6zm228.2-76.5h18.7v110.7h-18.7zM338 145.5h18.7v42.3H338zm45.7 21.2h18.7v68.2h-18.7zm-91.5 0h18.7v68.1h-18.7z"/></svg>

Before

Width:  |  Height:  |  Size: 577 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

@@ -1,74 +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";
export const CustomMantineProvider = ({ children }: PropsWithChildren) => {
const manager = useColorSchemeManager();
return (
<DirectionProvider>
<MantineProvider
defaultColorScheme="dark"
colorSchemeManager={manager}
theme={createTheme({
primaryColor: "red",
autoContrast: true,
})}
>
{children}
</MantineProvider>
</DirectionProvider>
);
};
function useColorSchemeManager(): MantineColorSchemeManager {
const key = "homarr-color-scheme";
const { data: session } = useSession();
const updateCookieValue = (value: Exclude<MantineColorScheme, "auto">) => {
setClientCookie(key, 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[key] 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,
};
}
@@ -3,7 +3,10 @@ import type { PropsWithChildren } from "react";
import { defaultLocale } from "@homarr/translation";
import { I18nProviderClient } from "@homarr/translation/client";
export const NextInternationalProvider = ({ children, locale }: PropsWithChildren<{ locale: string }>) => {
export const NextInternationalProvider = ({
children,
locale,
}: PropsWithChildren<{ locale: string }>) => {
return (
<I18nProviderClient locale={locale} fallback={defaultLocale}>
{children}
@@ -1,46 +1,17 @@
"use client";
import { createContext, useContext, useEffect } from "react";
import type { PropsWithChildren } from "react";
import dayjs from "dayjs";
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;
}
export const AuthProvider = ({ children, session, logoutUrl }: PropsWithChildren<AuthProviderProps>) => {
useLoginRedirectOnSessionExpiry(session);
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]);
export const AuthProvider = ({
children,
session,
}: PropsWithChildren<AuthProviderProps>) => {
return <SessionProvider session={session}>{children}</SessionProvider>;
};
@@ -7,10 +7,7 @@ import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
import {
createWSClient,
httpLink,
isNonJsonSerializable,
loggerLink,
splitLink,
unstable_httpBatchStreamLink,
wsLink,
} from "@trpc/client";
@@ -19,23 +16,8 @@ import superjson from "superjson";
import type { AppRouter } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { env } from "~/env.mjs";
const constructWebsocketUrl = () => {
const fallback = "ws://localhost:3001/websockets";
if (typeof window === "undefined") {
return fallback;
}
if (env.NODE_ENV === "development") {
return fallback;
}
return `ws://${window.location.hostname}:${window.location.port}/websockets`;
};
const wsClient = createWSClient({
url: constructWebsocketUrl(),
url: "ws://localhost:3001",
});
export function TRPCReactProvider(props: PropsWithChildren) {
@@ -55,31 +37,21 @@ export function TRPCReactProvider(props: PropsWithChildren) {
links: [
loggerLink({
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({
condition: ({ type }) => type === "subscription",
true: wsLink<AppRouter>({
client: wsClient,
transformer: superjson,
}),
false: splitLink({
condition: ({ input }) => isNonJsonSerializable(input),
true: httpLink({
/**
* We don't want to transform the data here as we want to use form data
*/
transformer: {
serialize(object: unknown) {
return object;
},
deserialize(data: unknown) {
return data;
},
},
url: `${getBaseUrl()}/api/trpc`,
}),
false: unstable_httpBatchStreamLink({
(args) => {
return ({ op, next }) => {
console.log("op", op.type, op.input, op.path, op.id);
if (op.type === "subscription") {
const link = wsLink<AppRouter>({
client: wsClient,
transformer: superjson,
});
return link(args)({ op, next });
}
return unstable_httpBatchStreamLink({
transformer: superjson,
url: `${getBaseUrl()}/api/trpc`,
headers() {
@@ -87,9 +59,9 @@ export function TRPCReactProvider(props: PropsWithChildren) {
headers.set("x-trpc-source", "nextjs-react");
return headers;
},
}),
}),
}),
})(args)({ op, next });
};
},
],
});
});
@@ -97,7 +69,9 @@ export function TRPCReactProvider(props: PropsWithChildren) {
return (
<clientApi.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<ReactQueryStreamedHydration transformer={superjson}>{props.children}</ReactQueryStreamedHydration>
<ReactQueryStreamedHydration transformer={superjson}>
{props.children}
</ReactQueryStreamedHydration>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</clientApi.Provider>
@@ -1,87 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { Button, PasswordInput, Stack, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { CustomPasswordInput } from "@homarr/ui";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
interface RegistrationFormProps {
invite: {
id: string;
token: string;
};
}
export const RegistrationForm = ({ invite }: RegistrationFormProps) => {
const t = useScopedI18n("user");
const router = useRouter();
const { mutate, isPending } = clientApi.user.register.useMutation();
const form = useZodForm(validation.user.registration, {
initialValues: {
username: "",
password: "",
confirmPassword: "",
},
});
const handleSubmit = (values: z.infer<typeof validation.user.registration>) => {
mutate(
{
...values,
inviteId: invite.id,
token: invite.token,
},
{
onSuccess() {
showSuccessNotification({
title: t("action.register.notification.success.title"),
message: t("action.register.notification.success.message"),
});
router.push("/auth/login");
},
onError(error) {
const message =
error.data?.code === "CONFLICT"
? t("error.usernameTaken")
: t("action.register.notification.error.message");
showErrorNotification({
title: t("action.register.notification.error.title"),
message,
});
},
},
);
};
return (
<Stack gap="xl">
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="lg">
<TextInput label={t("field.username.label")} autoComplete="off" {...form.getInputProps("username")} />
<CustomPasswordInput
withPasswordRequirements
label={t("field.password.label")}
autoComplete="new-password"
{...form.getInputProps("password")}
/>
<PasswordInput
label={t("field.passwordConfirm.label")}
autoComplete="new-password"
{...form.getInputProps("confirmPassword")}
/>
<Button type="submit" fullWidth loading={isPending}>
{t("action.register.label")}
</Button>
</Stack>
</form>
</Stack>
);
};
@@ -1,69 +0,0 @@
import { notFound } from "next/navigation";
import { Card, Center, Stack, Text, Title } from "@mantine/core";
import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server";
import { and, db, eq } from "@homarr/db";
import { invites } from "@homarr/db/schema/sqlite";
import { getScopedI18n } from "@homarr/translation/server";
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
import { RegistrationForm } from "./_registration-form";
interface InviteUsagePageProps {
params: {
id: string;
};
searchParams: {
token: string;
};
}
export default async function InviteUsagePage({ params, searchParams }: InviteUsagePageProps) {
if (!isProviderEnabled("credentials")) notFound();
const session = await auth();
if (session) notFound();
const invite = await db.query.invites.findFirst({
where: and(eq(invites.id, params.id), eq(invites.token, searchParams.token)),
columns: {
id: true,
token: true,
expirationDate: true,
},
with: {
creator: {
columns: {
name: true,
},
},
},
});
if (!invite || invite.expirationDate < new Date()) notFound();
const t = await getScopedI18n("user.page.invite");
return (
<Center>
<Stack align="center" mt="xl">
<HomarrLogoWithTitle size="lg" />
<Stack gap={6} align="center">
<Title order={3} fw={400} ta="center">
{t("title")}
</Title>
<Text size="sm" c="gray.5" ta="center">
{t("subtitle")}
</Text>
</Stack>
<Card bg="dark.8" w={64 * 6} maw="90vw">
<RegistrationForm invite={invite} />
</Card>
<Text size="xs" c="gray.5" ta="center">
{t("description", { username: invite.creator.name })}
</Text>
</Stack>
</Center>
);
}
@@ -1,185 +1,82 @@
"use client";
import type { PropsWithChildren } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Anchor, Button, Card, Code, Collapse, Divider, PasswordInput, Stack, Text, TextInput } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import {
Alert,
Button,
PasswordInput,
rem,
Stack,
TextInput,
} from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { signIn } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import type { useForm } from "@homarr/form";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useForm, zodResolver } from "@homarr/form";
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 {
providers: string[];
oidcClientName: string;
isOidcAutoLoginEnabled: boolean;
callbackUrl: string;
}
const extendedValidation = validation.user.signIn.extend({ provider: z.enum(["credentials", "ldap"]) });
export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, callbackUrl }: LoginFormProps) => {
export const LoginForm = () => {
const t = useScopedI18n("user");
const router = useRouter();
const [isPending, setIsPending] = useState(false);
const form = useZodForm(extendedValidation, {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string>();
const form = useForm<FormType>({
validate: zodResolver(validation.user.signIn),
initialValues: {
name: "",
password: "",
provider: "credentials",
},
});
const credentialInputsVisible = providers.includes("credentials") || providers.includes("ldap");
const handleSubmit = async (values: FormType) => {
setIsLoading(true);
setError(undefined);
await signIn("credentials", {
...values,
redirect: false,
callbackUrl: "/",
})
.then((response) => {
if (!response?.ok || response.error) {
throw response?.error;
}
const onSuccess = useCallback(
async (response: Awaited<ReturnType<typeof signIn>>) => {
if (response && (!response.ok || response.error)) {
// 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("/");
router.push(callbackUrl);
}
},
[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,
void router.push("/");
})
.then(onSuccess)
.catch(onError);
},
[setIsPending, onSuccess, onError, callbackUrl],
);
const isLoginInProgress = useRef(false);
useEffect(() => {
if (isOidcAutoLoginEnabled && !isPending && !isLoginInProgress.current) {
isLoginInProgress.current = true;
void signInAsync("oidc");
}
}, [signInAsync, isOidcAutoLoginEnabled, isPending]);
.catch((error: Error | string) => {
setIsLoading(false);
setError(error.toString());
});
};
return (
<Stack gap="xl">
<Stack gap="lg">
{credentialInputsVisible && (
<>
<form onSubmit={form.onSubmit((credentials) => void signInAsync(credentials.provider, credentials))}>
<Stack gap="lg">
<TextInput label={t("field.username.label")} {...form.getInputProps("name")} />
<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 })}
<form onSubmit={form.onSubmit((values) => void handleSubmit(values))}>
<Stack gap="lg">
<TextInput
label={t("field.username.label")}
{...form.getInputProps("name")}
/>
<PasswordInput
label={t("field.password.label")}
{...form.getInputProps("password")}
/>
<Button type="submit" fullWidth loading={isLoading}>
{t("action.login")}
</Button>
)}
</Stack>
</Stack>
</form>
{error && (
<Alert icon={<IconAlertTriangle size={rem(16)} />} color="red">
{error}
</Alert>
)}
</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>;
type FormType = z.infer<typeof validation.user.signIn>;
@@ -1,26 +1,11 @@
import { redirect } from "next/navigation";
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 { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
import { LoginForm } from "./_login-form";
interface LoginProps {
searchParams: {
callbackUrl?: string;
};
}
export default async function Login({ searchParams }: LoginProps) {
const session = await auth();
if (session) {
redirect(searchParams.callbackUrl ?? "/");
}
export default async function Login() {
const t = await getScopedI18n("user.page.login");
return (
@@ -36,12 +21,7 @@ export default async function Login({ searchParams }: LoginProps) {
</Text>
</Stack>
<Card bg="dark.8" w={64 * 6} maw="90vw">
<LoginForm
providers={env.AUTH_PROVIDERS}
oidcClientName={env.AUTH_OIDC_CLIENT_NAME}
isOidcAutoLoginEnabled={env.AUTH_OIDC_AUTO_LOGIN}
callbackUrl={searchParams.callbackUrl ?? "/"}
/>
<LoginForm />
</Card>
</Stack>
</Center>
@@ -3,7 +3,7 @@ import { api } from "@homarr/api/server";
import { createBoardContentPage } from "../_creator";
export default createBoardContentPage<{ locale: string }>({
async getInitialBoardAsync() {
return await api.board.getHomeBoard();
async getInitialBoard() {
return await api.board.getDefaultBoard();
},
});
@@ -1,6 +1,6 @@
import definition from "./_definition";
const { generateMetadataAsync: generateMetadata, page } = definition;
const { generateMetadata, page } = definition;
export default page;
@@ -3,7 +3,7 @@ import { api } from "@homarr/api/server";
import { createBoardContentPage } from "../_creator";
export default createBoardContentPage<{ locale: string; name: string }>({
async getInitialBoardAsync({ name }) {
async getInitialBoard({ name }) {
return await api.board.getBoardByName({ name });
},
});
@@ -1,6 +1,6 @@
import definition from "./_definition";
const { generateMetadataAsync: generateMetadata, page } = definition;
const { generateMetadata, page } = definition;
export default page;
@@ -18,7 +18,9 @@ export const updateBoardName = (name: string | null) => {
boardName = name;
};
type UpdateCallback = (prev: RouterOutputs["board"]["getHomeBoard"]) => RouterOutputs["board"]["getHomeBoard"];
type UpdateCallback = (
prev: RouterOutputs["board"]["getDefaultBoard"],
) => RouterOutputs["board"]["getDefaultBoard"];
export const useUpdateBoard = () => {
const utils = clientApi.useUtils();
@@ -44,9 +46,9 @@ export const ClientBoard = () => {
const board = useRequiredBoard();
const isReady = useIsBoardReady();
const fullWidthSortedSections = board.sections
.filter((section) => section.kind === "empty" || section.kind === "category")
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
const sortedSections = board.sections.sort(
(sectionA, sectionB) => sectionA.position - sectionB.position,
);
const ref = useRef<HTMLDivElement>(null);
@@ -59,12 +61,24 @@ export const ClientBoard = () => {
loaderProps={{ size: "lg" }}
h={fullHeightWithoutHeaderAndFooter}
/>
<Stack ref={ref} h="100%" style={{ visibility: isReady ? "visible" : "hidden" }}>
{fullWidthSortedSections.map((section) =>
<Stack
ref={ref}
h="100%"
style={{ visibility: isReady ? "visible" : "hidden" }}
>
{sortedSections.map((section) =>
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>
@@ -1,7 +1,13 @@
"use client";
import type { Dispatch, PropsWithChildren, SetStateAction } from "react";
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import type { PropsWithChildren } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
import { usePathname } from "next/navigation";
import type { RouterOutputs } from "@homarr/api";
@@ -10,11 +16,9 @@ import { clientApi } from "@homarr/api/client";
import { updateBoardName } from "./_client";
const BoardContext = createContext<{
board: RouterOutputs["board"]["getHomeBoard"];
board: RouterOutputs["board"]["getDefaultBoard"];
isReady: boolean;
markAsReady: (id: string) => void;
isEditMode: boolean;
setEditMode: Dispatch<SetStateAction<boolean>>;
} | null>(null);
export const BoardProvider = ({
@@ -26,11 +30,11 @@ export const BoardProvider = ({
const pathname = usePathname();
const utils = clientApi.useUtils();
const [readySections, setReadySections] = useState<string[]>([]);
const [isEditMode, setEditMode] = useState(false);
const { data } = clientApi.board.getBoardByName.useQuery(
{ name: initialBoard.name },
{
initialData: initialBoard,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
@@ -48,12 +52,18 @@ export const BoardProvider = ({
}, [pathname, utils, initialBoard.name]);
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]);
const markAsReady = useCallback((id: string) => {
setReadySections((previous) => (previous.includes(id) ? previous : [...previous, id]));
setReadySections((previous) =>
previous.includes(id) ? previous : [...previous, id],
);
}, []);
return (
@@ -62,8 +72,6 @@ export const BoardProvider = ({
board: data,
isReady: data.sections.length === readySections.length,
markAsReady,
isEditMode,
setEditMode,
}}
>
{children}
@@ -106,13 +114,3 @@ export const useOptionalBoard = () => {
return context?.board;
};
export const useEditMode = () => {
const context = useContext(BoardContext);
if (!context) {
throw new Error("Board is required");
}
return [context.isEditMode, context.setEditMode] as const;
};
@@ -1,15 +1,11 @@
import type { Metadata } from "next";
import { TRPCError } from "@trpc/server";
import { capitalize } from "@homarr/common";
// Placed here because gridstack styles are used for board content
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 { createMetaTitle } from "~/metadata";
import { createBoardLayout } from "../_layout-creator";
import type { Board } from "../_types";
import { ClientBoard } from "./_client";
@@ -18,41 +14,35 @@ import { BoardContentHeaderActions } from "./_header-actions";
export type Params = Record<string, unknown>;
interface Props<TParams extends Params> {
getInitialBoardAsync: (params: TParams) => Promise<Board>;
getInitialBoard: (params: TParams) => Promise<Board>;
}
export const createBoardContentPage = <TParams extends Record<string, unknown>>({
getInitialBoardAsync: getInitialBoard,
export const createBoardContentPage = <
TParams extends Record<string, unknown>,
>({
getInitialBoard,
}: Props<TParams>) => {
return {
layout: createBoardLayout({
headerActions: <BoardContentHeaderActions />,
getInitialBoardAsync: getInitialBoard,
getInitialBoard,
isBoardContentPage: true,
}),
// eslint-disable-next-line no-restricted-syntax
page: async () => {
const session = await auth();
const integrations = await getIntegrationsWithPermissionsAsync(session);
return (
<IntegrationProvider integrations={integrations}>
<ClientBoard />
</IntegrationProvider>
);
page: () => {
return <ClientBoard />;
},
generateMetadataAsync: async ({ params }: { params: TParams }): Promise<Metadata> => {
generateMetadata: async ({
params,
}: {
params: TParams;
}): Promise<Metadata> => {
try {
const board = await getInitialBoard(params);
const t = await getI18n();
return {
title: board.metaTitle ?? createMetaTitle(t("board.content.metaTitle", { boardName: board.name })),
title: board.metaTitle ?? `${capitalize(board.name)} board | Homarr`,
icons: {
icon: board.faviconImageUrl ? board.faviconImageUrl : undefined,
apple: board.faviconImageUrl ? board.faviconImageUrl : undefined,
},
appleWebApp: {
startupImage: { url: board.faviconImageUrl ? board.faviconImageUrl : "/logo/logo.png" },
},
};
} catch (error) {
@@ -1,9 +0,0 @@
"use client";
import { useRequiredBoard } from "./_context";
export const CustomCss = () => {
const board = useRequiredBoard();
return <style>{board.customCss}</style>;
};
@@ -2,34 +2,37 @@
import { useCallback } from "react";
import { Group, Menu } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks";
import {
IconBox,
IconBoxAlignTop,
IconChevronDown,
IconPackageImport,
IconPencil,
IconPencilOff,
IconPlus,
IconResize,
IconSettings,
} from "@tabler/icons-react";
import { useAtom, useAtomValue } from "jotai";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useModalAction } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import {
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { revalidatePathAction } from "~/app/revalidatePathAction";
import { editModeAtom } from "~/components/board/editMode";
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
import { useBoardPermissions } from "~/components/board/permissions/client";
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
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 { useEditMode, useRequiredBoard } from "./_context";
import { useRequiredBoard } from "./_context";
export const BoardContentHeaderActions = () => {
const [isEditMode] = useEditMode();
const isEditMode = useAtomValue(editModeAtom);
const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board);
@@ -51,10 +54,10 @@ export const BoardContentHeaderActions = () => {
};
const AddMenu = () => {
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
const { openModal: openCategoryEditModal } =
useModalAction(CategoryEditModal);
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
const { addCategoryToEnd } = useCategoryActions();
const { addDynamicSection } = useDynamicSectionActions();
const t = useI18n();
const handleAddCategory = useCallback(
@@ -92,57 +95,65 @@ const AddMenu = () => {
</HeaderButton>
</Menu.Target>
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectItem}>
<Menu.Item
leftSection={<IconBox size={20} />}
onClick={handleSelectItem}
>
{t("item.action.create")}
</Menu.Item>
<Menu.Item leftSection={<IconPackageImport size={20} />}>
{t("item.action.import")}
</Menu.Item>
<Menu.Divider />
<Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}>
<Menu.Item
leftSection={<IconBoxAlignTop size={20} />}
onClick={handleAddCategory}
>
{t("section.category.action.create")}
</Menu.Item>
<Menu.Item leftSection={<IconResize size={20} />} onClick={addDynamicSection}>
{t("section.dynamic.action.create")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
};
const EditModeMenu = () => {
const [isEditMode, setEditMode] = useEditMode();
const [isEditMode, setEditMode] = useAtom(editModeAtom);
const board = useRequiredBoard();
const utils = clientApi.useUtils();
const t = useScopedI18n("board.action.edit");
const { mutate: saveBoard, isPending } = clientApi.board.saveBoard.useMutation({
onSuccess() {
showSuccessNotification({
title: t("notification.success.title"),
message: t("notification.success.message"),
});
void utils.board.getBoardByName.invalidate({ name: board.name });
void revalidatePathActionAsync(`/boards/${board.name}`);
setEditMode(false);
},
onError() {
showErrorNotification({
title: t("notification.error.title"),
message: t("notification.error.message"),
});
},
});
const { mutate: saveBoard, isPending } =
clientApi.board.saveBoard.useMutation({
onSuccess() {
showSuccessNotification({
title: t("notification.success.title"),
message: t("notification.success.message"),
});
void utils.board.getBoardByName.invalidate({ name: board.name });
void revalidatePathAction(`/boards/${board.name}`);
setEditMode(false);
},
onError() {
showErrorNotification({
title: t("notification.error.title"),
message: t("notification.error.message"),
});
},
});
const toggle = useCallback(() => {
if (isEditMode) return saveBoard(board);
setEditMode(true);
}, [board, isEditMode, saveBoard, setEditMode]);
useHotkeys([["mod+e", toggle]]);
return (
<HeaderButton onClick={toggle} loading={isPending}>
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
{isEditMode ? (
<IconPencilOff stroke={1.5} />
) : (
<IconPencil stroke={1.5} />
)}
</HeaderButton>
);
};
@@ -22,7 +22,9 @@ export const BoardMantineProvider = ({ children }: PropsWithChildren) => {
};
export const generateColors = (hex: string) => {
const lightnessForColors = [-0.25, -0.2, -0.15, -0.1, -0.05, 0, 0.05, 0.1, 0.15, 0.2] as const;
const lightnessForColors = [
-0.25, -0.2, -0.15, -0.1, -0.05, 0, 0.05, 0.1, 0.15, 0.2,
] as const;
const rgbaColors = lightnessForColors.map((lightness) => {
if (lightness < 0) {
return lighten(hex, -lightness);
@@ -33,7 +35,6 @@ export const generateColors = (hex: string) => {
return rgbaColors.map((color) => {
return (
"#" +
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
color
.split("(")[1]!
.replaceAll(" ", "")
@@ -5,7 +5,8 @@ import { createBoardLayout } from "../_layout-creator";
export default createBoardLayout<{ locale: string; name: string }>({
headerActions: <BoardOtherHeaderActions />,
async getInitialBoardAsync({ name }) {
async getInitialBoard({ name }) {
return await api.board.getBoardByName({ name });
},
isBoardContentPage: false,
});
@@ -0,0 +1,105 @@
"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>
);
};
@@ -0,0 +1,139 @@
import { useCallback } from "react";
import type { ReactNode } from "react";
import type { SelectProps } from "@mantine/core";
import {
Button,
Flex,
Group,
Select,
TableTd,
TableTr,
Text,
} from "@mantine/core";
import {
IconCheck,
IconEye,
IconPencil,
IconSettings,
} from "@tabler/icons-react";
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 type { OnCountChange } from "./form";
import { useFormContext } from "./form";
const icons = {
"board-change": IconPencil,
"board-view": IconEye,
"board-full": IconSettings,
} satisfies Record<BoardPermission | "board-full", TablerIcon>;
interface BoardAccessSelectRowProps {
itemContent: ReactNode;
permission: BoardPermission;
index: number;
onCountChange: OnCountChange;
}
export const BoardAccessSelectRow = ({
itemContent,
permission,
index,
onCountChange,
}: BoardAccessSelectRowProps) => {
const tRoot = useI18n();
const tPermissions = useScopedI18n("board.setting.section.access.permission");
const form = useFormContext();
const Icon = icons[permission];
const handleRemove = useCallback(() => {
form.setFieldValue(
"items",
form.values.items.filter((_, i) => i !== index),
);
onCountChange((prev) => prev - 1);
}, [form, index, onCountChange]);
return (
<TableTr>
<TableTd w={{ sm: 128, lg: 256 }}>{itemContent}</TableTd>
<TableTd>
<Flex
direction={{ base: "column", xs: "row" }}
align={{ base: "end", xs: "center" }}
wrap="nowrap"
>
<Select
allowDeselect={false}
flex="1"
leftSection={<Icon size="1rem" />}
renderOption={RenderOption}
variant="unstyled"
data={boardPermissions.map((permission) => ({
value: permission,
label: tPermissions(`item.${permission}.label`),
}))}
{...form.getInputProps(`items.${index}.permission`)}
/>
<Button size="xs" variant="subtle" onClick={handleRemove}>
{tRoot("common.action.remove")}
</Button>
</Flex>
</TableTd>
</TableTr>
);
};
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 = {
stroke: 1.5,
color: "currentColor",
opacity: 0.6,
size: "1rem",
};
const RenderOption: SelectProps["renderOption"] = ({ option, checked }) => {
const Icon = icons[option.value as BoardPermission];
return (
<Group flex="1" gap="xs" wrap="nowrap">
<Icon {...iconProps} />
{option.label}
{checked && (
<IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />
)}
</Group>
);
};
@@ -0,0 +1,14 @@
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;
@@ -0,0 +1,148 @@
import { useCallback, useState } from "react";
import Link from "next/link";
import {
Anchor,
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 { BoardAccessSelectRow } from "./board-access-table-rows";
import type { BoardAccessFormType } from "./form";
import { FormProvider, useForm } from "./form";
import { GroupSelectModal } from "./group-select-modal";
import type { FormProps } from "./user-access";
export const GroupsForm = ({
board,
initialPermissions,
onCountChange,
}: FormProps) => {
const { mutate, isPending } =
clientApi.board.saveGroupBoardPermissions.useMutation();
const utils = clientApi.useUtils();
const [groups, setGroups] = useState<Map<string, Group>>(
new Map(
initialPermissions.groupPermissions.map(({ group }) => [group.id, group]),
),
);
const { openModal } = useModalAction(GroupSelectModal);
const t = useI18n();
const tPermissions = useScopedI18n("board.setting.section.access.permission");
const form = useForm({
initialValues: {
items: initialPermissions.groupPermissions.map(
({ group, permission }) => ({
itemId: group.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(() => {
openModal({
presentGroupIds: form.values.items.map(({ itemId: id }) => id),
onSelect: (group) => {
setGroups((prev) => new Map(prev).set(group.id, group));
form.setFieldValue("items", [
{
itemId: group.id,
permission: "board-view",
},
...form.values.items,
]);
onCountChange((prev) => prev + 1);
},
});
}, [form, openModal, onCountChange]);
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<FormProvider form={form}>
<Stack pt="sm">
<Table>
<TableThead>
<TableTr>
<TableTh style={{ whiteSpace: "nowrap" }}>
{tPermissions("field.group.label")}
</TableTh>
<TableTh>{tPermissions("field.permission.label")}</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{form.values.items.map((row, index) => (
<BoardAccessSelectRow
key={row.itemId}
itemContent={
<GroupItemContent group={groups.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>
);
};
export const GroupItemContent = ({ group }: { group: Group }) => {
return (
<Anchor
component={Link}
href={`/manage/users/groups/${group.id}`}
size="sm"
style={{ whiteSpace: "nowrap" }}
>
{group.name}
</Anchor>
);
};
type Group =
RouterOutputs["board"]["getBoardPermissions"]["groupPermissions"][0]["group"];
@@ -0,0 +1,72 @@
import { useState } from "react";
import { Button, Group, Loader, Select, Stack } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
interface InnerProps {
presentGroupIds: string[];
onSelect: (props: { id: string; name: string }) => void | Promise<void>;
confirmLabel?: string;
}
interface GroupSelectFormType {
groupId: string;
}
export const GroupSelectModal = createModal<InnerProps>(
({ actions, innerProps }) => {
const t = useI18n();
const { data: groups, isPending } = clientApi.group.selectable.useQuery();
const [loading, setLoading] = useState(false);
const form = useForm<GroupSelectFormType>();
const handleSubmit = async (values: GroupSelectFormType) => {
const currentGroup = groups?.find((group) => group.id === values.groupId);
if (!currentGroup) return;
setLoading(true);
await innerProps.onSelect({
id: currentGroup.id,
name: currentGroup.name,
});
setLoading(false);
actions.closeModal();
};
const confirmLabel = innerProps.confirmLabel ?? t("common.action.add");
return (
<form onSubmit={form.onSubmit((values) => void handleSubmit(values))}>
<Stack>
<Select
{...form.getInputProps("groupId")}
label={t("group.action.select.label")}
clearable
searchable
leftSection={isPending ? <Loader size="xs" /> : undefined}
nothingFoundMessage={t("group.action.select.notFound")}
limit={5}
data={groups
?.filter(
(group) => !innerProps.presentGroupIds.includes(group.id),
)
.map((group) => ({ value: group.id, label: group.name }))}
/>
<Group justify="end">
<Button variant="default" onClick={actions.closeModal}>
{t("common.action.cancel")}
</Button>
<Button type="submit" loading={loading}>
{confirmLabel}
</Button>
</Group>
</Stack>
</form>
);
},
).withOptions({
defaultTitle: (t) =>
t("board.setting.section.access.permission.groupSelect.title"),
});
@@ -0,0 +1,66 @@
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,173 @@
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}
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;
}
@@ -0,0 +1,112 @@
import { useState } from "react";
import type { SelectProps } from "@mantine/core";
import { Button, Group, Loader, Select, Stack } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui";
interface InnerProps {
presentUserIds: string[];
onSelect: (props: {
id: string;
name: string;
image: string;
}) => void | Promise<void>;
confirmLabel?: string;
}
interface UserSelectFormType {
userId: string;
}
export const UserSelectModal = createModal<InnerProps>(
({ actions, innerProps }) => {
const t = useI18n();
const { data: users, isPending } = clientApi.user.selectable.useQuery();
const [loading, setLoading] = useState(false);
const form = useForm<UserSelectFormType>();
const handleSubmit = async (values: UserSelectFormType) => {
const currentUser = users?.find((user) => user.id === values.userId);
if (!currentUser) return;
setLoading(true);
await innerProps.onSelect({
id: currentUser.id,
name: currentUser.name ?? "",
image: currentUser.image ?? "",
});
setLoading(false);
actions.closeModal();
};
const confirmLabel = innerProps.confirmLabel ?? t("common.action.add");
const currentUser = users?.find((user) => user.id === form.values.userId);
return (
<form onSubmit={form.onSubmit((values) => void handleSubmit(values))}>
<Stack>
<Select
{...form.getInputProps("userId")}
label={t("user.action.select.label")}
searchable
clearable
leftSection={
isPending ? (
<Loader size="xs" />
) : currentUser ? (
<UserAvatar user={currentUser} size="xs" />
) : undefined
}
nothingFoundMessage={t("user.action.select.notFound")}
renderOption={createRenderOption(users ?? [])}
limit={5}
data={users
?.filter((user) => !innerProps.presentUserIds.includes(user.id))
.map((user) => ({ value: user.id, label: user.name ?? "" }))}
/>
<Group justify="end">
<Button variant="default" onClick={actions.closeModal}>
{t("common.action.cancel")}
</Button>
<Button type="submit" loading={loading}>
{confirmLabel}
</Button>
</Group>
</Stack>
</form>
);
},
).withOptions({
defaultTitle: (t) =>
t("board.setting.section.access.permission.userSelect.title"),
});
const iconProps = {
stroke: 1.5,
color: "currentColor",
opacity: 0.6,
size: "1rem",
};
const createRenderOption = (
users: RouterOutputs["user"]["selectable"],
): SelectProps["renderOption"] =>
function InnerRenderRoot({ option, checked }) {
const user = users.find((user) => user.id === option.value);
if (!user) return null;
return (
<Group flex="1" gap="xs">
<UserAvatar user={user} size="xs" />
{option.label}
{checked && (
<IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />
)}
</Group>
);
};
@@ -2,13 +2,16 @@
import { Button, Grid, Group, Stack, TextInput } from "@mantine/core";
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
import { useZodForm } from "@homarr/form";
import {
backgroundImageAttachments,
backgroundImageRepeats,
backgroundImageSizes,
} from "@homarr/definitions";
import { useForm } from "@homarr/form";
import type { TranslationObject } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { SelectItemWithDescriptionBadge } from "@homarr/ui";
import { SelectWithDescriptionBadge } from "@homarr/ui";
import { validation } from "@homarr/validation";
import type { Board } from "../../_types";
import { useSavePartialSettingsMutation } from "./_shared";
@@ -18,8 +21,9 @@ interface Props {
}
export const BackgroundSettingsContent = ({ board }: Props) => {
const t = useI18n();
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
const form = useZodForm(validation.board.savePartialSettings, {
const { mutate: savePartialSettings, isPending } =
useSavePartialSettingsMutation(board);
const form = useForm({
initialValues: {
backgroundImageUrl: board.backgroundImageUrl ?? "",
backgroundImageAttachment: board.backgroundImageAttachment,
@@ -32,8 +36,14 @@ export const BackgroundSettingsContent = ({ board }: Props) => {
"backgroundImageAttachment",
backgroundImageAttachments,
);
const backgroundImageSizeData = useBackgroundOptionData("backgroundImageSize", backgroundImageSizes);
const backgroundImageRepeatData = useBackgroundOptionData("backgroundImageRepeat", backgroundImageRepeats);
const backgroundImageSizeData = useBackgroundOptionData(
"backgroundImageSize",
backgroundImageSizes,
);
const backgroundImageRepeatData = useBackgroundOptionData(
"backgroundImageRepeat",
backgroundImageRepeats,
);
return (
<form
@@ -85,9 +95,13 @@ export const BackgroundSettingsContent = ({ board }: Props) => {
);
};
type BackgroundImageKey = "backgroundImageAttachment" | "backgroundImageSize" | "backgroundImageRepeat";
type BackgroundImageKey =
| "backgroundImageAttachment"
| "backgroundImageSize"
| "backgroundImageRepeat";
type inferOptions<TKey extends BackgroundImageKey> = TranslationObject["board"]["field"][TKey]["option"];
type inferOptions<TKey extends BackgroundImageKey> =
TranslationObject["board"]["field"][TKey]["option"];
const useBackgroundOptionData = <
TKey extends BackgroundImageKey,
@@ -105,7 +119,9 @@ const useBackgroundOptionData = <
(value) =>
({
label: t(`board.field.${key}.option.${value as string}.label` as never),
description: t(`board.field.${key}.option.${value as string}.description` as never),
description: t(
`board.field.${key}.option.${value as string}.description` as never,
),
value: value as string,
badge:
data.defaultValue === value
@@ -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,
},
}}
/>
);
};
@@ -17,9 +17,8 @@ import {
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { useZodForm } from "@homarr/form";
import { useForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation";
import type { Board } from "../../_types";
import { generateColors } from "../../(content)/_theme";
@@ -34,7 +33,7 @@ const hexRegex = /^#[0-9a-fA-F]{6}$/;
const progressPercentageLabel = (value: number) => `${value}%`;
export const ColorSettingsContent = ({ board }: Props) => {
const form = useZodForm(validation.board.savePartialSettings, {
const form = useForm({
initialValues: {
primaryColor: board.primaryColor,
secondaryColor: board.secondaryColor,
@@ -44,7 +43,8 @@ export const ColorSettingsContent = ({ board }: Props) => {
const [showPreview, { toggle }] = useDisclosure(false);
const t = useI18n();
const theme = useMantineTheme();
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
const { mutate: savePartialSettings, isPending } =
useSavePartialSettingsMutation(board);
return (
<form
@@ -76,7 +76,11 @@ export const ColorSettingsContent = ({ board }: Props) => {
/>
</Grid.Col>
<Grid.Col span={12}>
<Anchor onClick={toggle}>{showPreview ? t("common.preview.hide") : t("common.preview.show")}</Anchor>
<Anchor onClick={toggle}>
{showPreview
? t("common.preview.hide")
: t("common.preview.show")}
</Anchor>
</Grid.Col>
<Grid.Col span={12}>
<Collapse in={showPreview}>
@@ -110,13 +114,15 @@ export const ColorSettingsContent = ({ board }: Props) => {
};
interface ColorsPreviewProps {
previewColor: string | undefined;
previewColor: string;
}
const ColorsPreview = ({ previewColor }: ColorsPreviewProps) => {
const theme = useMantineTheme();
const colors = previewColor && hexRegex.test(previewColor) ? generateColors(previewColor) : generateColors("#000000");
const colors = hexRegex.test(previewColor)
? generateColors(previewColor)
: generateColors("#000000");
return (
<Group gap={0} wrap="nowrap">
@@ -1,91 +1,7 @@
"use client";
import { Alert, Button, Group, Input, Stack } from "@mantine/core";
import { highlight, languages } from "prismjs";
import Editor from "react-simple-code-editor";
// TODO: add some sort of store (maybe directory on GitHub)
import "~/styles/prismjs.scss";
import { IconInfoCircle } from "@tabler/icons-react";
import { useForm } from "@homarr/form";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { Board } from "../../_types";
import { useSavePartialSettingsMutation } from "./_shared";
import classes from "./customcss.module.css";
interface Props {
board: Board;
}
export const CustomCssSettingsContent = ({ board }: Props) => {
const t = useI18n();
const customCssT = useScopedI18n("board.field.customCss");
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
const form = useForm({
initialValues: {
customCss: board.customCss ?? "",
},
});
return (
<form
onSubmit={form.onSubmit((values) => {
savePartialSettings({
id: board.id,
...values,
});
})}
>
<Stack>
<CustomCssInput {...form.getInputProps("customCss")} />
<Alert variant="light" color="cyan" title={customCssT("customClassesAlert.title")} icon={<IconInfoCircle />}>
{customCssT("customClassesAlert.description")}
</Alert>
<Group justify="end">
<Button type="submit" loading={isPending} color="teal">
{t("common.action.saveChanges")}
</Button>
</Group>
</Stack>
</form>
);
};
interface CustomCssInputProps {
value?: string;
onChange: (value: string) => void;
}
const CustomCssInput = ({ value, onChange }: CustomCssInputProps) => {
const customCssT = useScopedI18n("board.field.customCss");
return (
<Input.Wrapper
label={customCssT("label")}
labelProps={{
htmlFor: "custom-css",
}}
description={customCssT("description")}
inputWrapperOrder={["label", "description", "input", "error"]}
>
<div className={classes.codeEditorRoot}>
<Editor
textareaId="custom-css"
onValueChange={onChange}
value={value ?? ""}
highlight={(code) => highlight(code, languages.extend("css", {}), "css")}
padding={10}
style={{
fontFamily: '"Fira code", "Fira Mono", monospace',
fontSize: 12,
minHeight: 250,
}}
/>
</div>
</Input.Wrapper>
);
export const CustomCssSettingsContent = () => {
return null;
};
@@ -20,7 +20,8 @@ export const DangerZoneSettingsContent = () => {
const { openModal } = useModalAction(BoardRenameModal);
const { mutate: changeVisibility, isPending: isChangeVisibilityPending } =
clientApi.board.changeBoardVisibility.useMutation();
const { mutate: deleteBoard, isPending: isDeletePending } = clientApi.board.deleteBoard.useMutation();
const { mutate: deleteBoard, isPending: isDeletePending } =
clientApi.board.deleteBoard.useMutation();
const utils = clientApi.useUtils();
const visibility = board.isPublic ? "public" : "private";
@@ -36,8 +37,12 @@ export const DangerZoneSettingsContent = () => {
const onVisibilityClick = useCallback(() => {
openConfirmModal({
title: t(`section.dangerZone.action.visibility.confirm.${visibility}.title`),
children: t(`section.dangerZone.action.visibility.confirm.${visibility}.description`),
title: t(
`section.dangerZone.action.visibility.confirm.${visibility}.title`,
),
children: t(
`section.dangerZone.action.visibility.confirm.${visibility}.description`,
),
onConfirm: () => {
changeVisibility(
{
@@ -47,7 +52,7 @@ export const DangerZoneSettingsContent = () => {
{
onSettled() {
void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.board.getHomeBoard.invalidate();
void utils.board.getDefaultBoard.invalidate();
},
},
);
@@ -59,7 +64,7 @@ export const DangerZoneSettingsContent = () => {
changeVisibility,
t,
utils.board.getBoardByName,
utils.board.getHomeBoard,
utils.board.getDefaultBoard,
visibility,
openConfirmModal,
]);
@@ -93,8 +98,12 @@ export const DangerZoneSettingsContent = () => {
<Divider />
<DangerZoneRow
label={t("section.dangerZone.action.visibility.label")}
description={t(`section.dangerZone.action.visibility.description.${visibility}`)}
buttonText={t(`section.dangerZone.action.visibility.button.${visibility}`)}
description={t(
`section.dangerZone.action.visibility.description.${visibility}`,
)}
buttonText={t(
`section.dangerZone.action.visibility.button.${visibility}`,
)}
onClick={onVisibilityClick}
isPending={isChangeVisibilityPending}
/>
@@ -118,7 +127,13 @@ interface DangerZoneRowProps {
onClick: () => void;
}
const DangerZoneRow = ({ label, description, buttonText, onClick, isPending }: DangerZoneRowProps) => {
const DangerZoneRow = ({
label,
description,
buttonText,
onClick,
isPending,
}: DangerZoneRowProps) => {
return (
<Group justify="space-between" px="md" className={classes.dangerZoneGroup}>
<Stack gap={0}>
@@ -128,7 +143,12 @@ const DangerZoneRow = ({ label, description, buttonText, onClick, isPending }: D
<Text size="sm">{description}</Text>
</Stack>
<Group justify="end" w={{ base: "100%", xs: "auto" }}>
<Button variant="subtle" color="red" loading={isPending} onClick={onClick}>
<Button
variant="subtle"
color="red"
loading={isPending}
onClick={onClick}
>
{buttonText}
</Button>
</Group>
@@ -1,15 +1,25 @@
"use client";
import { useEffect, useRef } from "react";
import { Button, Grid, Group, Loader, Stack, TextInput, Tooltip } from "@mantine/core";
import { useDebouncedValue, useDocumentTitle, useFavicon } from "@mantine/hooks";
import {
Button,
Grid,
Group,
Loader,
Stack,
TextInput,
Tooltip,
} from "@mantine/core";
import {
useDebouncedValue,
useDocumentTitle,
useFavicon,
} from "@mantine/hooks";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useZodForm } from "@homarr/form";
import { useForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation";
import { createMetaTitle } from "~/metadata";
import type { Board } from "../../_types";
import { useUpdateBoard } from "../../(content)/_client";
import { useSavePartialSettingsMutation } from "./_shared";
@@ -26,31 +36,22 @@ export const GeneralSettingsContent = ({ board }: Props) => {
});
const { updateBoard } = useUpdateBoard();
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
const form = useZodForm(
validation.board.savePartialSettings
.pick({
pageTitle: true,
logoImageUrl: true,
metaTitle: true,
faviconImageUrl: true,
})
.required(),
{
initialValues: {
pageTitle: board.pageTitle ?? "",
logoImageUrl: board.logoImageUrl ?? "",
metaTitle: board.metaTitle ?? "",
faviconImageUrl: board.faviconImageUrl ?? "",
},
onValuesChange({ pageTitle }) {
updateBoard((previous) => ({
...previous,
pageTitle,
}));
},
const { mutate: savePartialSettings, isPending } =
useSavePartialSettingsMutation(board);
const form = useForm({
initialValues: {
pageTitle: board.pageTitle ?? "",
logoImageUrl: board.logoImageUrl ?? "",
metaTitle: board.metaTitle ?? "",
faviconImageUrl: board.faviconImageUrl ?? "",
},
);
onValuesChange({ pageTitle }) {
updateBoard((previous) => ({
...previous,
pageTitle,
}));
},
});
const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle);
const faviconStatus = useFaviconPreview(form.values.faviconImageUrl);
@@ -93,7 +94,7 @@ export const GeneralSettingsContent = ({ board }: Props) => {
<Grid.Col span={{ xs: 12, md: 6 }}>
<TextInput
label={t("board.field.metaTitle.label")}
placeholder={createMetaTitle(t("board.content.metaTitle", { boardName: board.name }))}
placeholder="Default Board | Homarr"
rightSection={<PendingOrInvalidIndicator {...metaTitleStatus} />}
{...form.getInputProps("metaTitle")}
/>
@@ -125,12 +126,22 @@ export const GeneralSettingsContent = ({ board }: Props) => {
);
};
const PendingOrInvalidIndicator = ({ isPending, isInvalid }: { isPending: boolean; isInvalid?: boolean }) => {
const PendingOrInvalidIndicator = ({
isPending,
isInvalid,
}: {
isPending: boolean;
isInvalid?: boolean;
}) => {
const t = useI18n();
if (isInvalid) {
return (
<Tooltip multiline w={220} label={t("board.setting.section.general.unrecognizedLink")}>
<Tooltip
multiline
w={220}
label={t("board.setting.section.general.unrecognizedLink")}
>
<IconAlertTriangle size="1rem" color="red" />
</Tooltip>
);
@@ -172,7 +183,8 @@ const useMetaTitlePreview = (title: string | null) => {
const validFaviconExtensions = ["ico", "png", "svg", "gif"];
const isValidUrl = (url: string) =>
url.includes("/") && validFaviconExtensions.some((extension) => url.endsWith(`.${extension}`));
url.includes("/") &&
validFaviconExtensions.some((extension) => url.endsWith(`.${extension}`));
const useFaviconPreview = (url: string | null) => {
const [faviconDebounced] = useDebouncedValue(url ?? "", 500);
@@ -2,9 +2,8 @@
import { Button, Grid, Group, Input, Slider, Stack } from "@mantine/core";
import { useZodForm } from "@homarr/form";
import { useForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation";
import type { Board } from "../../_types";
import { useSavePartialSettingsMutation } from "./_shared";
@@ -14,8 +13,9 @@ interface Props {
}
export const LayoutSettingsContent = ({ board }: Props) => {
const t = useI18n();
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
const form = useZodForm(validation.board.savePartialSettings.pick({ columnCount: true }).required(), {
const { mutate: savePartialSettings, isPending } =
useSavePartialSettingsMutation(board);
const form = useForm({
initialValues: {
columnCount: board.columnCount,
},
@@ -34,7 +34,13 @@ export const LayoutSettingsContent = ({ board }: Props) => {
<Grid>
<Grid.Col span={{ sm: 12, md: 6 }}>
<Input.Wrapper label={t("board.field.columnCount.label")}>
<Slider mt="xs" min={1} max={24} step={1} {...form.getInputProps("columnCount")} />
<Slider
mt="xs"
min={1}
max={24}
step={1}
{...form.getInputProps("columnCount")}
/>
</Input.Wrapper>
</Grid.Col>
</Grid>
@@ -7,7 +7,7 @@ export const useSavePartialSettingsMutation = (board: Board) => {
return clientApi.board.savePartialBoardSettings.useMutation({
onSettled() {
void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.board.getHomeBoard.invalidate();
void utils.board.getDefaultBoard.invalidate();
},
});
};
@@ -1,22 +0,0 @@
.codeEditorFooter {
border-bottom-left-radius: var(--mantine-radius-sm);
border-bottom-right-radius: var(--mantine-radius-sm);
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-7));
}
.codeEditorRoot {
margin-top: 4px;
border-color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4));
border-width: 1px;
border-style: solid;
border-radius: var(--mantine-radius-sm);
}
.codeEditor {
background-color: light-dark(white, var(--mantine-color-dark-6));
font-size: var(--mantine-font-size-xs);
}
.codeEditor ::placeholder {
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
}
@@ -1,6 +1,14 @@
import type { PropsWithChildren } from "react";
import { notFound } from "next/navigation";
import { AccordionControl, AccordionItem, AccordionPanel, Container, Stack, Text, Title } from "@mantine/core";
import {
AccordionControl,
AccordionItem,
AccordionPanel,
Container,
Stack,
Text,
Title,
} from "@mantine/core";
import {
IconAlertTriangle,
IconBrush,
@@ -18,10 +26,10 @@ import type { TranslationObject } from "@homarr/translation";
import { getScopedI18n } from "@homarr/translation/server";
import type { TablerIcon } from "@homarr/ui";
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
import { getBoardPermissions } from "~/components/board/permissions/server";
import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion";
import { AccessSettingsContent } from "./_access";
import { BackgroundSettingsContent } from "./_background";
import { BoardAccessSettings } from "./_board-access";
import { ColorSettingsContent } from "./_colors";
import { CustomCssSettingsContent } from "./_customCss";
import { DangerZoneSettingsContent } from "./_danger";
@@ -37,15 +45,15 @@ interface Props {
};
}
const getBoardAndPermissionsAsync = async (params: Props["params"]) => {
const getBoardAndPermissions = async (params: Props["params"]) => {
try {
const board = await api.board.getBoardByName({ name: params.name });
const { hasFullAccess } = await getBoardPermissionsAsync(board);
const { hasFullAccess } = await getBoardPermissions(board);
const permissions = hasFullAccess
? await api.board.getBoardPermissions({ id: board.id })
: {
users: [],
groups: [],
userPermissions: [],
groupPermissions: [],
inherited: [],
};
@@ -61,16 +69,22 @@ const getBoardAndPermissionsAsync = async (params: Props["params"]) => {
}
};
export default async function BoardSettingsPage({ params, searchParams }: Props) {
const { board, permissions } = await getBoardAndPermissionsAsync(params);
const { hasFullAccess } = await getBoardPermissionsAsync(board);
export default async function BoardSettingsPage({
params,
searchParams,
}: Props) {
const { board, permissions } = await getBoardAndPermissions(params);
const { hasFullAccess } = await getBoardPermissions(board);
const t = await getScopedI18n("board.setting");
return (
<Container>
<Stack>
<Title>{t("title", { boardName: capitalize(board.name) })}</Title>
<ActiveTabAccordion variant="separated" defaultValue={searchParams.tab ?? "general"}>
<ActiveTabAccordion
variant="separated"
defaultValue={searchParams.tab ?? "general"}
>
<AccordionItemFor value="general" icon={IconSettings}>
<GeneralSettingsContent board={board} />
</AccordionItemFor>
@@ -84,14 +98,22 @@ export default async function BoardSettingsPage({ params, searchParams }: Props)
<ColorSettingsContent board={board} />
</AccordionItemFor>
<AccordionItemFor value="customCss" icon={IconFileTypeCss}>
<CustomCssSettingsContent board={board} />
<CustomCssSettingsContent />
</AccordionItemFor>
{hasFullAccess && (
<>
<AccordionItemFor value="access" icon={IconUser}>
<BoardAccessSettings board={board} initialPermissions={permissions} />
<AccessSettingsContent
board={board}
initialPermissions={permissions}
/>
</AccordionItemFor>
<AccordionItemFor value="dangerZone" icon={IconAlertTriangle} danger noPadding>
<AccordionItemFor
value="dangerZone"
icon={IconAlertTriangle}
danger
noPadding
>
<DangerZoneSettingsContent />
</AccordionItemFor>
</>
@@ -109,7 +131,13 @@ type AccordionItemForProps = PropsWithChildren<{
noPadding?: boolean;
}>;
const AccordionItemFor = async ({ value, children, icon: Icon, danger, noPadding }: AccordionItemForProps) => {
const AccordionItemFor = async ({
value,
children,
icon: Icon,
danger,
noPadding,
}: AccordionItemForProps) => {
const t = await getScopedI18n("board.setting.section");
return (
<AccordionItem
@@ -130,7 +158,13 @@ const AccordionItemFor = async ({ value, children, icon: Icon, danger, noPadding
{t(`${value}.title`)}
</Text>
</AccordionControl>
<AccordionPanel styles={noPadding ? { content: { paddingRight: 0, paddingLeft: 0 } } : undefined}>
<AccordionPanel
styles={
noPadding
? { content: { paddingRight: 0, paddingLeft: 0 } }
: undefined
}
>
{children}
</AccordionPanel>
</AccordionItem>
@@ -4,6 +4,7 @@ import { AppShellMain } from "@mantine/core";
import { TRPCError } from "@trpc/server";
import { logger } from "@homarr/log";
import { GlobalItemServerDataRunner } from "@homarr/widgets";
import { MainHeader } from "~/components/layout/header";
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
@@ -11,17 +12,18 @@ import { ClientShell } from "~/components/layout/shell";
import type { Board } from "./_types";
import { BoardProvider } from "./(content)/_context";
import type { Params } from "./(content)/_creator";
import { CustomCss } from "./(content)/_custom-css";
import { BoardMantineProvider } from "./(content)/_theme";
interface CreateBoardLayoutProps<TParams extends Params> {
headerActions: JSX.Element;
getInitialBoardAsync: (params: TParams) => Promise<Board>;
getInitialBoard: (params: TParams) => Promise<Board>;
isBoardContentPage: boolean;
}
export const createBoardLayout = <TParams extends Params>({
headerActions,
getInitialBoardAsync: getInitialBoard,
getInitialBoard,
isBoardContentPage,
}: CreateBoardLayoutProps<TParams>) => {
const Layout = async ({
params,
@@ -39,19 +41,23 @@ export const createBoardLayout = <TParams extends Params>({
});
return (
<BoardProvider initialBoard={initialBoard}>
<BoardMantineProvider>
<CustomCss />
<ClientShell hasNavigation={false}>
<MainHeader
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
actions={headerActions}
hasNavigation={false}
/>
<AppShellMain>{children}</AppShellMain>
</ClientShell>
</BoardMantineProvider>
</BoardProvider>
<GlobalItemServerDataRunner
board={initialBoard}
shouldRun={isBoardContentPage}
>
<BoardProvider initialBoard={initialBoard}>
<BoardMantineProvider>
<ClientShell hasNavigation={false}>
<MainHeader
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
actions={headerActions}
hasNavigation={false}
/>
<AppShellMain>{children}</AppShellMain>
</ClientShell>
</BoardMantineProvider>
</BoardProvider>
</GlobalItemServerDataRunner>
);
};
@@ -1,12 +1,14 @@
import type { RouterOutputs } from "@homarr/api";
import type { WidgetKind } from "@homarr/definitions";
export type Board = RouterOutputs["board"]["getHomeBoard"];
export type Board = RouterOutputs["board"]["getDefaultBoard"];
export type Section = Board["sections"][number];
export type Item = Section["items"][number];
export type CategorySection = Extract<Section, { kind: "category" }>;
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 }
>;
+10 -7
View File
@@ -5,11 +5,14 @@ type PropsWithChildren = Required<React.PropsWithChildren>;
export const composeWrappers = (
wrappers: React.FunctionComponent<PropsWithChildren>[],
): React.FunctionComponent<PropsWithChildren> => {
return wrappers.reverse().reduce((Acc, Current): React.FunctionComponent<PropsWithChildren> => {
return (props) => (
<Current>
<Acc {...props} />
</Current>
);
});
return wrappers
.reverse()
.reduce((Acc, Current): React.FunctionComponent<PropsWithChildren> => {
// eslint-disable-next-line react/display-name
return (props) => (
<Current>
<Acc {...props} />
</Current>
);
});
};
@@ -4,18 +4,24 @@ import { useRouter } from "next/navigation";
import { Button, PasswordInput, Stack, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useForm, zodResolver } from "@homarr/form";
import {
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { CustomPasswordInput } from "@homarr/ui";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
export const InitUserForm = () => {
const router = useRouter();
const t = useScopedI18n("user");
const { mutateAsync, error, isPending } = clientApi.user.initUser.useMutation();
const form = useZodForm(validation.user.init, {
const { mutateAsync, error, isPending } =
clientApi.user.initUser.useMutation();
const form = useForm<FormType>({
validate: zodResolver(validation.user.init),
validateInputOnBlur: true,
validateInputOnChange: true,
initialValues: {
username: "",
password: "",
@@ -23,7 +29,7 @@ export const InitUserForm = () => {
},
});
const handleSubmitAsync = async (values: FormType) => {
const handleSubmit = async (values: FormType) => {
await mutateAsync(values, {
onSuccess: () => {
showSuccessNotification({
@@ -45,18 +51,23 @@ export const InitUserForm = () => {
<Stack gap="xl">
<form
onSubmit={form.onSubmit(
(values) => void handleSubmitAsync(values),
(values) => void handleSubmit(values),
(err) => console.log(err),
)}
>
<Stack gap="lg">
<TextInput label={t("field.username.label")} {...form.getInputProps("username")} />
<CustomPasswordInput
withPasswordRequirements
<TextInput
label={t("field.username.label")}
{...form.getInputProps("username")}
/>
<PasswordInput
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}>
{t("action.create")}
</Button>
+32 -44
View File
@@ -3,21 +3,15 @@ import { Inter } from "next/font/google";
import "@homarr/notifications/styles.css";
import "@homarr/spotlight/styles.css";
import "@homarr/ui/styles.css";
import "~/styles/scroll-area.scss";
import "@mantine/core/styles.css";
import { cookies } from "next/headers";
import { ColorSchemeScript, createTheme, MantineProvider } from "@mantine/core";
import { env } from "@homarr/auth/env.mjs";
import { auth } from "@homarr/auth/next";
import { ModalProvider } from "@homarr/modals";
import { Notifications } from "@homarr/notifications";
import { getScopedI18n } from "@homarr/translation/server";
import { Analytics } from "~/components/layout/analytics";
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
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 { TRPCReactProvider } from "./_client-providers/trpc";
@@ -28,7 +22,8 @@ const fontSans = Inter({
variable: "--font-sans",
});
export const generateMetadata = (): Metadata => ({
export const metadata: Metadata = {
metadataBase: new URL("http://localhost:3000"),
title: "Homarr",
description:
"Simplify the management of your server with Homarr - a sleek, modern dashboard that puts all of your apps and services at your fingertips.",
@@ -39,17 +34,12 @@ export const generateMetadata = (): Metadata => ({
url: "https://homarr.dev",
siteName: "Homarr Documentation",
},
icons: {
icon: "/logo/logo.png",
apple: "/logo/logo.png",
twitter: {
card: "summary_large_image",
site: "@jullerino",
creator: "@jullerino",
},
appleWebApp: {
title: "Homarr",
capable: true,
startupImage: { url: "/logo/logo.png" },
statusBarStyle: getColorScheme() === "dark" ? "black-translucent" : "default",
},
});
};
export const viewport: Viewport = {
themeColor: [
@@ -58,37 +48,39 @@ export const viewport: Viewport = {
],
};
export default async function Layout(props: { children: React.ReactNode; params: { locale: string } }) {
const session = await auth();
const colorScheme = getColorScheme();
const tCommon = await getScopedI18n("common");
const direction = tCommon("direction");
export default function Layout(props: {
children: React.ReactNode;
params: { locale: string };
}) {
const colorScheme = "dark";
const StackedProvider = composeWrappers([
(innerProps) => {
return <AuthProvider session={session} logoutUrl={env.AUTH_LOGOUT_REDIRECT_URL} {...innerProps} />;
async (innerProps) => {
const session = await auth();
return <AuthProvider session={session} {...innerProps} />;
},
(innerProps) => <JotaiProvider {...innerProps} />,
(innerProps) => <TRPCReactProvider {...innerProps} />,
(innerProps) => <NextInternationalProvider {...innerProps} locale={props.params.locale} />,
(innerProps) => <CustomMantineProvider {...innerProps} />,
(innerProps) => (
<NextInternationalProvider {...innerProps} locale={props.params.locale} />
),
(innerProps) => (
<MantineProvider
{...innerProps}
defaultColorScheme="dark"
theme={createTheme({
primaryColor: "red",
autoContrast: true,
})}
/>
),
(innerProps) => <ModalProvider {...innerProps} />,
]);
return (
// Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering
<html
lang="en"
dir={direction}
data-mantine-color-scheme={colorScheme}
style={{
backgroundColor: colorScheme === "dark" ? "#242424" : "#fff",
}}
suppressHydrationWarning
>
<html lang="en" suppressHydrationWarning>
<head>
<Analytics />
<SearchEngineOptimization />
<ColorSchemeScript defaultColorScheme={colorScheme} />
</head>
<body className={["font-sans", fontSans.variable].join(" ")}>
<StackedProvider>
@@ -99,7 +91,3 @@ export default async function Layout(props: { children: React.ReactNode; params:
</html>
);
}
const getColorScheme = () => {
return cookies().get("homarr-color-scheme")?.value ?? "dark";
};
@@ -1,5 +1,17 @@
import { notFound } from "next/navigation";
import { Center, Stack, Text, Title } from "@mantine/core";
export default function NotFound() {
return notFound();
import { getScopedI18n } from "@homarr/translation/server";
export default async function NotFound() {
const t = await getScopedI18n("management.notFound");
return (
<Center h="100%">
<Stack align="center">
<Title order={1} tt="uppercase">
{t("title")}
</Title>
<Text>{t("text")}</Text>
</Stack>
</Center>
);
}
@@ -1,36 +0,0 @@
.bannerContainer {
border-radius: 8px;
overflow: hidden;
background: linear-gradient(
130deg,
#fa52521f 0%,
var(--mantine-color-dark-6) 35%,
var(--mantine-color-dark-6) 100%
) !important;
}
.scrollContainer {
height: 100%;
transform: rotateZ(10deg);
}
@keyframes scrolling {
0% {
transform: translateY(0);
}
100% {
transform: translateY(-50.8%);
}
}
.scrollAnimationContainer {
animation: scrolling;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@media (prefers-reduced-motion: reduce) {
.scrollAnimationContainer {
animation: none !important;
}
}
@@ -1,78 +0,0 @@
import { Box, Grid, GridCol, Group, Image, Stack, Title } from "@mantine/core";
import { splitToNChunks } from "@homarr/common";
import classes from "./hero-banner.module.css";
const icons = [
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/homarr.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/sabnzbd.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/deluge.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/radarr.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/sonarr.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/lidarr.svg",
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/pihole.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/dashdot.png",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/overseerr.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/plex.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/jellyfin.svg",
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/homeassistant.svg",
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/freshrss.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/readarr.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/transmission.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/qbittorrent.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/nzbget.png",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/openmediavault.svg",
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/docker.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/jellyseerr.svg",
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/adguardhome.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/tdarr.png",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/prowlarr.svg",
];
const countIconGroups = 3;
const animationDurationInSeconds = 12;
export const HeroBanner = () => {
const arrayInChunks = splitToNChunks(icons, countIconGroups);
const gridSpan = 12 / countIconGroups;
return (
<Box className={classes.bannerContainer} p={{ base: "lg", md: "3rem" }} bg="dark.6" pos="relative">
<Stack gap={0}>
<Title fz={{ base: "h4", md: "h2" }} c="dimmed">
Welcome back to your
</Title>
<Group gap="xs" wrap="nowrap">
<Image src="/logo/logo.png" w={{ base: 32, md: 40 }} h={{ base: 32, md: 40 }} />
<Title fz={{ base: "h3", md: "h1" }}>Homarr Board</Title>
</Group>
</Stack>
<Box visibleFrom="md" className={classes.scrollContainer} w={"30%"} top={0} right={0} pos="absolute">
<Grid>
{Array(countIconGroups)
.fill(0)
.map((_, columnIndex) => (
<GridCol key={`grid-column-${columnIndex}`} span={gridSpan}>
<Stack
className={classes.scrollAnimationContainer}
style={{
animationDuration: `${animationDurationInSeconds - columnIndex}s`,
}}
>
{arrayInChunks[columnIndex]?.map((icon, index) => (
<Image key={`grid-column-${columnIndex}-scroll-1-${index}`} src={icon} radius="md" w={50} h={50} />
))}
{/* This is used for making the animation seem seamless */}
{arrayInChunks[columnIndex]?.map((icon, index) => (
<Image key={`grid-column-${columnIndex}-scroll-2-${index}`} src={icon} radius="md" w={50} h={50} />
))}
</Stack>
</GridCol>
))}
</Grid>
</Box>
</Box>
);
};
@@ -1,3 +0,0 @@
.contributorCard {
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
}
@@ -0,0 +1,36 @@
.root {
border-radius: var(--mantine-radius-sm);
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-6)
);
}
.item {
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-6)
);
border: 1px solid transparent;
position: relative;
z-index: 0;
transition: transform 150ms ease;
&[data-active] {
transform: scale(1.015);
z-index: 1;
background-color: var(--mantine-color-body);
border-color: light-dark(
var(--mantine-color-gray-2),
var(--mantine-color-dark-4)
);
box-shadow: var(--mantine-shadow-md);
border-radius: var(--mantine-radius-md);
}
}
.chevron {
&[data-rotate] {
transform: rotate(-90deg);
}
}
@@ -1,15 +1,10 @@
import { headers } from "next/headers";
import Image from "next/image";
import {
Accordion,
AccordionControl,
AccordionItem,
AccordionPanel,
AspectRatio,
Avatar,
Card,
Center,
Flex,
Group,
List,
ListItem,
@@ -18,109 +13,62 @@ import {
Title,
} from "@mantine/core";
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 { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { createMetaTitle } from "~/metadata";
import { getPackageAttributesAsync } from "~/versions/package-reader";
import type githubContributorsJson from "../../../../../../../static-data/contributors.json";
import type crowdinContributorsJson from "../../../../../../../static-data/translators.json";
import classes from "./about.module.css";
import logo from "../../../../../public/logo/logo.png";
import classes from "./accordion.module.css";
export async function generateMetadata() {
const t = await getScopedI18n("management");
const metaTitle = `${t("metaTitle")} • Homarr`;
return {
title: createMetaTitle(t("metaTitle")),
title: metaTitle,
};
}
const getHost = () => {
if (process.env.HOSTNAME) {
return `${process.env.HOSTNAME}:3000`;
}
interface PageProps {
params: {
locale: string;
};
}
return headers().get("host");
};
export default async function AboutPage() {
const baseServerUrl = `http://${getHost()}`;
export default async function AboutPage({ params: { locale } }: PageProps) {
setStaticParamsLocale(locale);
const t = await getScopedI18n("management.page.about");
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 (
<div>
<DynamicBreadcrumb />
<Center w="100%">
<Group py="lg">
<Image src={homarrLogoPath} width={100} height={100} alt="" />
<Image src={logo} width={100} height={100} alt="" />
<Stack gap={0}>
<Title order={1} tt="uppercase">
Homarr
</Title>
<Title order={2}>{t("version", { version: attributes.version })}</Title>
<Title order={2}>
{t("version", { version: attributes.version })}
</Title>
</Stack>
</Group>
</Center>
<Text mb="xl">{t("text")}</Text>
<Accordion defaultValue="contributors" variant="filled" radius="md">
<Accordion classNames={classes}>
<AccordionItem value="contributors">
<AccordionControl icon={<IconUsers size="1rem" />}>
<Stack gap={0}>
<Text>{t("accordion.contributors.title")}</Text>
<Text size="sm" c="dimmed">
{t("accordion.contributors.subtitle", {
count: githubContributors.length,
})}
</Text>
</Stack>
{t("accordion.contributors.title")}
</AccordionControl>
<AccordionPanel>
<Flex wrap="wrap" gap="xs">
{githubContributors.map((contributor) => (
<GenericContributorLinkCard
key={contributor.login}
link={`https://github.com/${contributor.login}`}
image={contributor.avatar_url}
name={contributor.login}
/>
))}
</Flex>
</AccordionPanel>
<AccordionPanel></AccordionPanel>
</AccordionItem>
<AccordionItem value="translators">
<AccordionControl icon={<IconLanguage size="1rem" />}>
<Stack gap={0}>
<Text>{t("accordion.translators.title")}</Text>
<Text size="sm" c="dimmed">
{t("accordion.translators.subtitle", {
count: crowdinContributors.length,
})}
</Text>
</Stack>
{t("accordion.translators.title")}
</AccordionControl>
<AccordionPanel>
<Flex wrap="wrap" gap="xs">
{crowdinContributors.map((translator) => (
<GenericContributorLinkCard
key={translator.username}
link={`https://crowdin.com/profile/${translator.username}`}
image={translator.avatarUrl}
name={translator.username}
/>
))}
</Flex>
</AccordionPanel>
<AccordionPanel></AccordionPanel>
</AccordionItem>
<AccordionItem value="libraries">
<AccordionControl icon={<IconLibrary size="1rem" />}>
@@ -154,23 +102,8 @@ export default async function AboutPage() {
);
}
interface GenericContributorLinkCardProps {
name: string;
link: string;
image: string;
export function generateStaticParams() {
return getStaticParams();
}
const GenericContributorLinkCard = ({ name, image, link }: GenericContributorLinkCardProps) => {
return (
<AspectRatio ratio={1}>
<Card className={classes.contributorCard} component="a" href={link} target="_blank" w={100}>
<Stack align="center">
<Avatar src={image} alt={name} size={40} display="block" />
<Text lineClamp={1} size="sm">
{name}
</Text>
</Stack>
</Card>
</AspectRatio>
);
};
export const dynamic = "force-static";
@@ -6,11 +6,15 @@ 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 {
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { revalidatePathAction } from "../../../revalidatePathAction";
interface AppDeleteButtonProps {
app: RouterOutputs["app"]["all"][number];
}
@@ -33,7 +37,7 @@ export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
title: t("notification.success.title"),
message: t("notification.success.message"),
});
void revalidatePathActionAsync("/manage/apps");
void revalidatePathAction("/manage/apps");
},
onError: () => {
showErrorNotification({
@@ -48,7 +52,13 @@ export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
}, [app, mutate, t, openConfirmModal]);
return (
<ActionIcon loading={isPending} variant="subtle" color="red" onClick={onClick} aria-label={t("title")}>
<ActionIcon
loading={isPending}
variant="subtle"
color="red"
onClick={onClick}
aria-label="Delete app"
>
<IconTrash color="red" size={16} stroke={1.5} />
</ActionIcon>
);
@@ -3,14 +3,13 @@
import Link from "next/link";
import { Button, Group, Stack, Textarea, TextInput } from "@mantine/core";
import { useZodForm } from "@homarr/form";
import { useForm, zodResolver } 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";
// TODO: add icon picker
type FormType = z.infer<typeof validation.app.manage>;
interface AppFormProps {
@@ -21,25 +20,31 @@ interface AppFormProps {
}
export const AppForm = (props: AppFormProps) => {
const { submitButtonTranslation, handleSubmit, initialValues, isPending } = props;
const { submitButtonTranslation, handleSubmit, initialValues, isPending } =
props;
const t = useI18n();
const form = useZodForm(validation.app.manage, {
const form = useForm({
initialValues: initialValues ?? {
name: "",
description: "",
iconUrl: "",
href: "",
},
validate: zodResolver(validation.app.manage),
});
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput {...form.getInputProps("name")} withAsterisk label={t("app.field.name.label")} />
<IconPicker initialValue={initialValues?.iconUrl} {...form.getInputProps("iconUrl")} />
<Textarea {...form.getInputProps("description")} label={t("app.field.description.label")} />
<TextInput {...form.getInputProps("href")} label={t("app.field.url.label")} />
<TextInput {...form.getInputProps("name")} withAsterisk label="Name" />
<TextInput
{...form.getInputProps("iconUrl")}
withAsterisk
label="Icon URL"
/>
<Textarea {...form.getInputProps("description")} label="Description" />
<TextInput {...form.getInputProps("href")} label="URL" />
<Group justify="end">
<Button variant="default" component={Link} href="/manage/apps">
@@ -5,12 +5,15 @@ 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 {
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 { revalidatePathAction } from "~/app/revalidatePathAction";
import { AppForm } from "../../_form";
interface AppEditFormProps {
@@ -27,7 +30,7 @@ export const AppEditForm = ({ app }: AppEditFormProps) => {
title: t("success.title"),
message: t("success.message"),
});
void revalidatePathActionAsync("/manage/apps").then(() => {
void revalidatePathAction("/manage/apps").then(() => {
router.push("/manage/apps");
});
},
@@ -49,7 +52,10 @@ export const AppEditForm = ({ app }: AppEditFormProps) => {
[mutate, app.id],
);
const submitButtonTranslation = useCallback((t: TranslationFunction) => t("common.action.save"), []);
const submitButtonTranslation = useCallback(
(t: TranslationFunction) => t("common.action.save"),
[],
);
return (
<AppForm
@@ -3,7 +3,6 @@ import { Container, Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import { getI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { AppEditForm } from "./_app-edit-form";
interface AppEditPageProps {
@@ -15,14 +14,11 @@ export default async function AppEditPage({ params }: AppEditPageProps) {
const t = await getI18n();
return (
<>
<DynamicBreadcrumb dynamicMappings={new Map([[params.id, app.name]])} nonInteractable={["edit"]} />
<Container>
<Stack>
<Title>{t("app.page.edit.title")}</Title>
<AppEditForm app={app} />
</Stack>
</Container>
</>
<Container>
<Stack>
<Title>{t("app.page.edit.title")}</Title>
<AppEditForm app={app} />
</Stack>
</Container>
);
}
@@ -4,12 +4,15 @@ 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 {
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 { revalidatePathAction } from "~/app/revalidatePathAction";
import { AppForm } from "../_form";
export const AppNewForm = () => {
@@ -22,7 +25,7 @@ export const AppNewForm = () => {
title: t("success.title"),
message: t("success.message"),
});
void revalidatePathActionAsync("/manage/apps").then(() => {
void revalidatePathAction("/manage/apps").then(() => {
router.push("/manage/apps");
});
},
@@ -41,9 +44,16 @@ export const AppNewForm = () => {
[mutate],
);
const submitButtonTranslation = useCallback((t: TranslationFunction) => t("common.action.create"), []);
const submitButtonTranslation = useCallback(
(t: TranslationFunction) => t("common.action.create"),
[],
);
return (
<AppForm submitButtonTranslation={submitButtonTranslation} handleSubmit={handleSubmit} isPending={isPending} />
<AppForm
submitButtonTranslation={submitButtonTranslation}
handleSubmit={handleSubmit}
isPending={isPending}
/>
);
};
@@ -1,22 +1,14 @@
import { Container, Stack, Title } from "@mantine/core";
import { getI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { AppNewForm } from "./_app-new-form";
export default async function AppNewPage() {
const t = await getI18n();
export default function AppNewPage() {
return (
<>
<DynamicBreadcrumb />
<Container>
<Stack>
<Title>{t("app.page.create.title")}</Title>
<AppNewForm />
</Stack>
</Container>
</>
<Container>
<Stack>
<Title>New app</Title>
<AppNewForm />
</Stack>
</Container>
);
}
@@ -1,30 +1,36 @@
import Link from "next/link";
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
import {
ActionIcon,
ActionIconGroup,
Anchor,
Avatar,
Button,
Card,
Container,
Group,
Stack,
Text,
Title,
} from "@mantine/core";
import { IconApps, IconPencil } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { parseAppHrefWithVariablesServer } from "@homarr/common/server";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { getI18n } from "@homarr/translation/server";
import { ManageContainer } from "~/components/manage/manage-container";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { AppDeleteButton } from "./_app-delete-button";
export default async function AppsPage() {
const apps = await api.app.all();
const t = await getScopedI18n("app");
return (
<ManageContainer>
<DynamicBreadcrumb />
<Container>
<Stack>
<Group justify="space-between" align="center">
<Title>{t("page.list.title")}</Title>
<MobileAffixButton component={Link} href="/manage/apps/new">
{t("page.create.title")}
</MobileAffixButton>
<Title>Apps</Title>
<Button component={Link} href="/manage/apps/new">
New app
</Button>
</Group>
{apps.length === 0 && <AppNoResults />}
{apps.length > 0 && (
@@ -35,7 +41,7 @@ export default async function AppsPage() {
</Stack>
)}
</Stack>
</ManageContainer>
</Container>
);
}
@@ -43,12 +49,10 @@ interface AppCardProps {
app: RouterOutputs["app"]["all"][number];
}
const AppCard = async ({ app }: AppCardProps) => {
const t = await getScopedI18n("app");
const AppCard = ({ app }: AppCardProps) => {
return (
<Card>
<Group justify="space-between" wrap="nowrap">
<Group justify="space-between">
<Group align="top" justify="start" wrap="nowrap">
<Avatar
size="sm"
@@ -61,17 +65,15 @@ const AppCard = async ({ app }: AppCardProps) => {
}}
/>
<Stack gap={0}>
<Text fw={500} lineClamp={1}>
{app.name}
</Text>
<Text fw={500}>{app.name}</Text>
{app.description && (
<Text size="sm" c="gray.6" lineClamp={4}>
<Text size="sm" c="gray.6">
{app.description}
</Text>
)}
{app.href && (
<Anchor href={parseAppHrefWithVariablesServer(app.href)} lineClamp={1} size="sm" w="min-content">
{parseAppHrefWithVariablesServer(app.href)}
<Anchor href={app.href} size="sm" w="min-content">
{app.href}
</Anchor>
)}
</Stack>
@@ -83,7 +85,7 @@ const AppCard = async ({ app }: AppCardProps) => {
href={`/manage/apps/edit/${app.id}`}
variant="subtle"
color="gray"
aria-label={t("page.edit.title")}
aria-label="Edit app"
>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
@@ -105,7 +107,9 @@ const AppNoResults = async () => {
<Text fw={500} size="lg">
{t("app.page.list.noResults.title")}
</Text>
<Anchor href="/manage/apps/new">{t("app.page.list.noResults.action")}</Anchor>
<Anchor href="/manage/apps/new">
{t("app.page.list.noResults.description")}
</Anchor>
</Stack>
</Card>
);
@@ -3,14 +3,14 @@
import { useCallback } from "react";
import Link from "next/link";
import { Menu } from "@mantine/core";
import { IconHome, IconSettings, IconTrash } from "@tabler/icons-react";
import { IconSettings, 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 { useScopedI18n } from "@homarr/translation/client";
import { revalidatePathAction } from "~/app/revalidatePathAction";
import { useBoardPermissions } from "~/components/board/permissions/client";
const iconProps = {
@@ -21,11 +21,18 @@ const iconProps = {
interface BoardCardMenuDropdownProps {
board: Pick<
RouterOutputs["board"]["getAllBoards"][number],
"id" | "name" | "creator" | "userPermissions" | "groupPermissions" | "isPublic"
| "id"
| "name"
| "creator"
| "userPermissions"
| "groupPermissions"
| "isPublic"
>;
}
export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) => {
export const BoardCardMenuDropdown = ({
board,
}: BoardCardMenuDropdownProps) => {
const t = useScopedI18n("management.page.board.action");
const tCommon = useScopedI18n("common");
@@ -33,15 +40,9 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
const { openConfirmModal } = useConfirmModal();
const setHomeBoardMutation = clientApi.board.setHomeBoard.useMutation({
const { mutateAsync, isPending } = clientApi.board.deleteBoard.useMutation({
onSettled: async () => {
// Revalidate all as it's part of the user settings, /boards page and board manage page
await revalidatePathActionAsync("/");
},
});
const deleteBoardMutation = clientApi.board.deleteBoard.useMutation({
onSettled: async () => {
await revalidatePathActionAsync("/manage/boards");
await revalidatePathAction("/manage/boards");
},
});
@@ -51,45 +52,36 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
children: t("delete.confirm.description", {
name: board.name,
}),
// eslint-disable-next-line no-restricted-syntax
onConfirm: async () => {
await deleteBoardMutation.mutateAsync({
await mutateAsync({
id: board.id,
});
},
});
}, [board.id, board.name, deleteBoardMutation, openConfirmModal, t]);
const handleSetHomeBoard = useCallback(async () => {
await setHomeBoardMutation.mutateAsync({ id: board.id });
}, [board.id, setHomeBoardMutation]);
}, [board.id, board.name, mutateAsync, openConfirmModal, t]);
return (
<Menu.Dropdown>
<Menu.Item onClick={handleSetHomeBoard} leftSection={<IconHome {...iconProps} />}>
{t("setHomeBoard.label")}
</Menu.Item>
{hasChangeAccess && (
<>
<Menu.Divider />
<Menu.Item
component={Link}
href={`/boards/${board.name}/settings`}
leftSection={<IconSettings {...iconProps} />}
>
{t("settings.label")}
</Menu.Item>
</>
<Menu.Item
component={Link}
href={`/boards/${board.name}/settings`}
leftSection={<IconSettings {...iconProps} />}
>
{t("settings.label")}
</Menu.Item>
)}
{hasFullAccess && (
<>
<Menu.Divider />
<Menu.Label c="red.7">{tCommon("dangerZone")}</Menu.Label>
<Menu.Label c="red.7">
{tCommon("menu.section.dangerZone.title")}
</Menu.Label>
<Menu.Item
c="red.7"
leftSection={<IconTrash {...iconProps} />}
onClick={handleDeletion}
disabled={deleteBoardMutation.isPending}
disabled={isPending}
>
{t("delete.label")}
</Menu.Item>
@@ -0,0 +1,83 @@
"use client";
import { Box, Card, Center, Flex, Loader, Text } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import dayjs from "dayjs";
import type { RouterOutputs } from "@homarr/api";
interface BoardPreviewProps {
board: RouterOutputs["board"]["getAllBoards"][number];
}
export const BoardPreview = ({ board }: BoardPreviewProps) => {
const { ref, width } = useElementSize();
return (
<Card
h="100%"
mah={width ? (width / 1920) * 1080 : undefined}
ref={ref}
styles={{
root: {
"--gridstack-column-count": board.columnCount,
"--gridstack-widget-width": width / board.columnCount,
},
}}
>
{width === 0 ? (
<Center h="100%">
<Loader size="sm" />
</Center>
) : (
<>
{board.sections.map((section) => (
<Section
key={section.id}
section={section}
itemWidth={width / board.columnCount}
/>
))}
</>
)}
</Card>
);
};
interface SectionProps {
section: RouterOutputs["board"]["getAllBoards"][number]["sections"][number];
itemWidth: number;
}
const Section = ({ section, itemWidth }: SectionProps) => {
const maxHeight = Math.max(
...section.items.map((item) => item.yOffset + item.height),
);
return (
<Card
withBorder={section.kind === "category"}
h={maxHeight * itemWidth}
key={section.id}
className="grid-stack"
>
{section.items.map((item) => (
<Box
className="grid-stack-item"
gs-h={item.height}
gs-w={item.width}
gs-x={item.xOffset}
gs-y={item.yOffset}
key={item.id}
p={4}
>
<Card withBorder h="100%" p={0}>
<Flex justify="center" align="center" h="100%" w="100%">
<Text fz={itemWidth / 4}>{dayjs().format("HH:mm")}</Text>
</Flex>
</Card>
</Box>
))}
</Card>
);
};
Loaded 100 of 892 files, more files were not shown because too many files have changed in this diff. Show more