Compare commits

..
594 changed files with 10878 additions and 126426 deletions

No files matched your search

-1
View File
@@ -1 +0,0 @@
* text=auto eol=lf
+3 -25
View File
@@ -67,33 +67,11 @@ jobs:
- name: Test
run: pnpm test
- name: "Report Coverage"
- 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
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup
uses: ./tooling/github/setup
- name: Build docker image
id: build-docker-image
uses: docker/build-push-action@v6
with:
platforms: linux/amd64
context: .
push: false
tags: homarr-e2e
network: host
env:
SKIP_ENV_VALIDATION: true
- name: Run E2E Tests
shell: bash
run: pnpm test:e2e
if: always()
uses: davelosert/vitest-coverage-report-action@v2
build:
runs-on: ubuntu-latest
@@ -1,49 +0,0 @@
name: "[Crowdin] Download translations"
on:
workflow_dispatch:
schedule:
- cron: "0 0 * * *" # every day at midnight
jobs:
download-crowdin-translations:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Obtain token
id: obtainToken
uses: tibdex/github-app-token@v2
with:
private_key: ${{ secrets.CROWDIN_APP_PRIVATE_KEY }}
app_id: ${{ secrets.CROWDIN_APP_ID }}
- name: Download Crowdin translations
id: crowdin-download
uses: crowdin/github-action@v2
with:
upload_sources: false
upload_translations: false
download_translations: true
localization_branch_name: crowdin
create_pull_request: true
pull_request_title: "chore(lang): updated translations from crowdin"
pull_request_body: "New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)"
commit_message: "chore(lang): update translations from crowdin"
pull_request_base_branch_name: "dev"
github_user_name: "Crowdin Homarr"
github_user_email: "190541745+homarr-crowdin[bot]@users.noreply.github.com"
skip_untranslated_strings: true
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
- name: Enable auto-merge
if: steps.crowdin-download.outputs.pull_request_number != '' && steps.crowdin-download.outputs.pull_request_number != null
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
run: |
gh pr merge ${{steps.crowdin-download.outputs.pull_request_number}} --auto --squash --delete-branch --subject "chore(lang): updated translations from crowdin"
-28
View File
@@ -1,28 +0,0 @@
name: "[Crowdin] Upload translations"
on:
workflow_dispatch:
push:
paths:
- "packages/translation/src/lang/**"
branches: [dev]
jobs:
upload-crowdin-translations:
# Don't run this action if the downloaded translations are being pushed
if: "!contains(github.event.head_commit.message, 'chore(lang)')"
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Upload Crowdin translations
uses: crowdin/github-action@v2
with:
upload_sources: true
upload_translations: true
download_translations: false
env:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
+63 -91
View File
@@ -4,8 +4,6 @@ on:
push:
branches:
- main
- beta
- dev
workflow_dispatch:
inputs:
send-notifications:
@@ -29,101 +27,51 @@ env:
IMAGE_NAME: ${{ github.repository }}
TURBO_TELEMETRY_DISABLED: 1
concurrency:
group: ${{ github.workflow }}-${{ github.ref_name }}
concurrency: production
jobs:
release:
name: Create tag and release
runs-on: ubuntu-latest
env:
SKIP_RELEASE: ${{ github.event_name == 'workflow_dispatch' || github.ref_name == 'dev' }}
outputs:
version: ${{ steps.read-semver.outputs.version || steps.version-fallback.outputs.version }}
git_ref: ${{ steps.read-git-ref.outputs.ref || github.ref }}
steps:
- run: echo "Skipping release for workflow_dispatch event"
if: env.SKIP_RELEASE == 'true'
# The below generated version fallback represents a normalized branch name, for example "feature/branch-name" -> "feature-branch-name"
- run: echo "version="$(echo ${{github.ref_name}} | sed 's/[^a-zA-Z0-9\-]/-/g') >> "$GITHUB_OUTPUT"
id: version-fallback
if: env.SKIP_RELEASE == 'true' && github.ref_name != 'main' && github.ref_name != 'beta'
- name: Obtain token
if: env.SKIP_RELEASE == 'false'
id: obtainToken
uses: tibdex/github-app-token@v2
with:
private_key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
app_id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
- uses: actions/checkout@v4
if: env.SKIP_RELEASE == 'false'
with:
persist-credentials: false
- uses: actions/setup-node@v4
if: env.SKIP_RELEASE == 'false'
with:
node-version: 22
- run: npm i -g pnpm
if: env.SKIP_RELEASE == 'false'
- name: Install dependencies
if: env.SKIP_RELEASE == 'false'
run: |
pnpm install
- name: Run Semantic Release
if: env.SKIP_RELEASE == 'false'
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
GIT_AUTHOR_NAME: "Releases Homarr"
GIT_AUTHOR_EMAIL: "175486441+homarr-releases[bot]@users.noreply.github.com"
GIT_COMMITTER_NAME: "Releases Homarr"
GIT_COMMITTER_EMAIL: "175486441+homarr-releases[bot]@users.noreply.github.com"
run: |
pnpm release
- name: Read semver output
# We read the last tag either from the created release or from the current branch, this is to rerun the deployment job for the currently released version when it failed
if: env.SKIP_RELEASE == 'false' || github.ref_name == 'main' || github.ref_name == 'beta'
id: read-semver
run: |
git fetch --tags
echo "version=$(git describe --tags --abbrev=0)" >> "$GITHUB_OUTPUT"
- name: Read git ref
if: env.SKIP_RELEASE == 'false'
id: read-git-ref
run: |
echo "ref=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Update dev branch
if: env.SKIP_RELEASE == 'false'
continue-on-error: true # Prevent pipeline from failing when merge fails
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
run: |
git fetch origin dev
git checkout dev
git pull origin dev
git merge ${{ github.ref_name }}
git push origin dev
deploy:
name: Deploy docker image
needs: release
runs-on: ubuntu-latest
env:
NEXT_VERSION: ${{ needs.release.outputs.version }}
DEPLOY_LATEST: ${{ github.ref_name == 'main' }}
DEPLOY_BETA: ${{ github.ref_name == 'beta' }}
PUSH_IMAGE: ${{ github.event_name != 'workflow_dispatch' || github.events.inputs.push-image == true }}
strategy:
matrix:
node-version: [20]
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.release.outputs.git_ref }}
- 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 for version '${{env.NEXT_VERSION}}' has been triggered: [run ${{ github.run_number }}](<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}>)"
args: "Deployment of an image has been triggered: [run ${{ github.run_number }}](<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}>)"
- uses: actions/checkout@v4
- name: Get Next Version
id: semver
uses: ietf-tools/semver-action@v1
with:
token: ${{ github.token }}
branch: dev
- name: Discord notification
if: ${{ github.events.inputs.send-notifications != false }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
with:
args: "Semver computed next tag to be ${{ steps.semver.outputs.next }}. Current is ${{ steps.semver.outputs.current }}"
- 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:
@@ -140,24 +88,48 @@ jobs:
with:
images: "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
tags: |
${{ env.DEPLOY_LATEST == 'true' && 'type=raw,value=latest' || null }}
${{ env.DEPLOY_BETA == 'true' && 'type=raw,value=beta' || null }}
type=raw,value=${{ env.NEXT_VERSION }}
- name: Build and maybe push
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: ${{ env.PUSH_IMAGE}}
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 for branch ${{ github.ref_name }}. Image ID is '${{ steps.buildPushAction.outputs.imageid }}'. ${{ env.PUSH_IMAGE == 'true' && '' || 'This was a dry run' }}"
args: "Deployment of image has completed. Image ID is '${{ steps.buildPushAction.outputs.imageid }}'."
- name: Discord notification
if: ${{ github.events.inputs.send-notifications != false && !(github.events.inputs.push-image == 'true' || github.events.inputs.push-image == null) }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
with:
args: "Deployment of image has completed. Image ID is '${{ steps.buildPushDryAction.outputs.imageid }}'. This was a dry run."
@@ -37,7 +37,7 @@ jobs:
token: ${{ github.token }}
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 beta --head dev --label automerge'
run: "gh pr create --title \"chore(release): automatic release ${{ steps.semver.outputs.next }}\" --body \"**This is an automatic release**.<br/>Manual action may be required for major bumps.<br/>Detected change to be ``${{ steps.semver.outputs.bump }}``<br/>Bump version from ``${{ steps.semver.outputs.current }}`` to ``${{ steps.semver.outputs.next }}``\" --base main --head dev --label automerge"
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
- name: Discord notification
@@ -4,9 +4,9 @@ on:
types: [opened, synchronize]
jobs:
approve-automatic-prs:
approve-renovate-prs:
runs-on: ubuntu-latest
if: github.actor_id == 158783068 || github.actor_id == 190541745 # Id of renovate bot and crowdin bot see https://api.github.com/users/homarr-renovate%5Bbot%5D and https://api.github.com/users/homarr-crowdin%5Bbot%5D
if: github.actor_id == 158783068 # Id of renovate bot see https://api.github.com/users/homarr-renovate%5Bbot%5D
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -18,7 +18,7 @@ jobs:
app_id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
- name: Install GitHub CLI
run: sudo apt-get install -y gh
- name: Approve automatic PRs
- name: Approve Renovate PRs
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
run: |
+1 -1
View File
@@ -1 +1 @@
22.12.0
20.18.0
-51
View File
@@ -1,51 +0,0 @@
{
"branches": [
"main",
{
"name": "beta",
"prerelease": true,
"channel": "beta"
}
],
"plugins": [
[
"@semantic-release/commit-analyzer",
{
"preset": "conventionalcommits"
}
],
[
"@semantic-release/release-notes-generator",
{
"preset": "conventionalcommits"
}
],
[
"@semantic-release/changelog",
{
"changelogFile": "CHANGELOG.md"
}
],
[
"@semantic-release/npm",
{
"npmPublish": false
}
],
[
"@semantic-release/git",
{
"assets": ["package.json", "CHANGELOG.md"],
"message": "chore(release): ${nextRelease.version} [skip ci]"
}
],
[
"@semantic-release/github",
{
"successComment": false,
"failComment": false,
"releaseBodyTemplate": "<%= _.truncate(nextRelease.notes, { 'length': 124000, 'omission': '' }) %>"
}
]
]
}
+1 -2
View File
@@ -3,7 +3,6 @@
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"yoavbls.pretty-ts-errors",
"million.million-lint",
"lokalise.i18n-ally"
"million.million-lint"
]
}
-36
View File
@@ -1,36 +0,0 @@
# .vscode/i18n-ally-custom-framework.yml
# An array of strings which contain Language Ids defined by VS Code
# You can check available language ids here: https://code.visualstudio.com/docs/languages/identifiers
languageIds:
- javascript
- typescript
- javascriptreact
- typescriptreact
# An array of RegExes to find the key usage. **The key should be captured in the first match group**.
# You should unescape RegEx strings in order to fit in the YAML file
# To help with this, you can use https://www.freeformatter.com/json-escape.html
usageMatchRegex:
# The following example shows how to detect `t("your.i18n.keys")`
# the `{key}` will be placed by a proper keypath matching regex,
# you can ignore it and use your own matching rules as well
- "[^\\w\\d]t\\(['\"`]({key})['\"`]"
# A RegEx to set a custom scope range. This scope will be used as a prefix when detecting keys
# and works like how the i18next framework identifies the namespace scope from the
# useTranslation() hook.
# You should unescape RegEx strings in order to fit in the YAML file
# To help with this, you can use https://www.freeformatter.com/json-escape.html
scopeRangeRegex: "(getScopedI18n|useScopedI18n)\\(\\s*['\"](.*?)['\"]\\)"
# An array of strings containing refactor templates.
# The "$1" will be replaced by the keypath specified.
# Optional: uncomment the following two lines to use
# refactorTemplates:
# - i18n.get("$1")
# If set to true, only enables this custom framework (will disable all built-in frameworks)
monopoly: true
+5 -13
View File
@@ -14,11 +14,6 @@
"gridstack",
"homarr",
"jellyfin",
"llen",
"lpop",
"lpush",
"lrange",
"ltrim",
"mantine",
"manuel-rw",
"Meierschlumpf",
@@ -32,12 +27,9 @@
"Umami"
],
"i18n-ally.dirStructure": "auto",
"i18n-ally.displayLanguage": "en",
"i18n-ally.enabledFrameworks": [
"custom"
],
"i18n-ally.localesPaths": [
"packages/translation/src/lang",
],
"i18n-ally.keystyle": "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"
}
-1116
View File
File diff suppressed because it is too large. Load diff
+49 -17
View File
@@ -1,4 +1,4 @@
FROM node:22.12.0-alpine AS base
FROM node:20.18.0-alpine AS base
FROM base AS builder
RUN apk add --no-cache libc6-compat
@@ -6,15 +6,46 @@ RUN apk update
# Set working directory
WORKDIR /app
COPY . .
RUN npm i -g turbo
RUN turbo prune @homarr/nextjs --docker --out-dir ./next-out
RUN turbo prune @homarr/tasks --docker --out-dir ./tasks-out
RUN turbo prune @homarr/websocket --docker --out-dir ./websocket-out
RUN turbo prune @homarr/db --docker --out-dir ./migration-out
RUN turbo prune @homarr/cli --docker --out-dir ./cli-out
# Add lockfile and package.json's of isolated subworkspace
FROM base AS installer
RUN apk add --no-cache libc6-compat curl bash
RUN apk update
COPY . .
WORKDIR /app
# First install the dependencies (as they change less often)
COPY .gitignore .gitignore
COPY --from=builder /app/tasks-out/json/ .
COPY --from=builder /app/websocket-out/json/ .
COPY --from=builder /app/migration-out/json/ .
COPY --from=builder /app/cli-out/json/ .
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
RUN corepack enable pnpm && pnpm install sharp -w
# Build the project
COPY --from=builder /app/tasks-out/full/ .
COPY --from=builder /app/websocket-out/full/ .
COPY --from=builder /app/next-out/full/ .
COPY --from=builder /app/migration-out/full/ .
COPY --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'
@@ -26,8 +57,10 @@ FROM base AS runner
WORKDIR /app
# gettext is required for envsubst
RUN apk add --no-cache redis nginx bash gettext su-exec
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
@@ -38,36 +71,36 @@ RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Enable homarr cli
COPY --from=builder --chown=nextjs:nodejs /app/packages/cli/cli.cjs /app/apps/cli/cli.cjs
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
# 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
USER nextjs
COPY --from=builder /app/apps/nextjs/next.config.mjs .
COPY --from=builder /app/apps/nextjs/package.json .
COPY --from=installer /app/apps/nextjs/next.config.mjs .
COPY --from=installer /app/apps/nextjs/package.json .
COPY --from=builder --chown=nextjs:nodejs /app/apps/tasks/tasks.cjs ./apps/tasks/tasks.cjs
COPY --from=builder --chown=nextjs:nodejs /app/apps/websocket/wssServer.cjs ./apps/websocket/wssServer.cjs
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node /app/build/better_sqlite3.node
COPY --from=installer --chown=nextjs:nodejs /app/apps/tasks/tasks.cjs ./apps/tasks/tasks.cjs
COPY --from=installer --chown=nextjs:nodejs /app/apps/websocket/wssServer.cjs ./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=builder --chown=nextjs:nodejs /app/packages/db/migrations ./db/migrations
COPY --from=installer --chown=nextjs:nodejs /app/packages/db/migrations ./db/migrations
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/apps/nextjs/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/nextjs/.next/static ./apps/nextjs/.next/static
COPY --from=builder --chown=nextjs:nodejs /app/apps/nextjs/public ./apps/nextjs/public
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 scripts/entrypoint.sh ./entrypoint.sh
RUN chmod +x ./entrypoint.sh
COPY --chown=nextjs:nodejs scripts/generateRandomSecureKey.js ./generateRandomSecureKey.js
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
@@ -77,5 +110,4 @@ ENV DB_DIALECT='sqlite'
ENV DB_DRIVER='better-sqlite3'
ENV AUTH_PROVIDERS='credentials'
ENTRYPOINT [ "/app/entrypoint.sh" ]
CMD ["sh", "run.sh"]
+17 -197
View File
@@ -1,201 +1,21 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
MIT License
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
Copyright (c) 2023 Julius Marminge
1. Definitions.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright (c) 2024 Meier Lukas, Thomas Camlong and Homarr Labs
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+7
View File
@@ -0,0 +1,7 @@
# THIS PROJECT IS STILL UNSTABLE AND WE DO NOT PROVIDE ANY SUPPORT FOR ISSUES THAT OCCURE.
## PLEASE DO NOT OPEN ANY ISSUES OR DISCUSSIONS
### EVERYTHING IS SUBJECT TO CHANGE
Please use [this](https://github.com/ajnart/homarr) version of Homarr when you want to use it
-29
View File
@@ -1,29 +0,0 @@
# Security Policy
This policy is relevant if you found potential vulnerabilities in an audit.
We consider something as a vulnerability if it...
1. puts users or user data at risk
2. enables third parties to gain control or access (e.g. [RATs](https://en.wikipedia.org/wiki/Remote_desktop_software#RAT), [privilege escalation](https://en.wikipedia.org/wiki/Privilege_escalation), ...)
3. abuses the system in an unintended way (e.g. crypto mining, proxy, ...)
## Supported Versions
| Version | Supported |
| ------- | ------------------ |
| >1.0.0 | :white_check_mark: |
| <1.0.0 | :x: |
## Reporting a Vulnerability
We use [GitHub's system for reporting vulnerabilities](https://docs.github.com/en/enterprise-cloud@latest/code-security/security-advisories/working-with-repository-security-advisories/creating-a-repository-security-advisory).
Click [**here to report an advisory**](https://github.com/homarr-labs/homarr/security/advisories/new). Our team will get notified and will get back to you within 1-6 business days.
As a general guideline; please provide as much detail as possible and provide reproduction steps / documentation regarding the re-creation.
You may also provide a fork with a fix for the vulnerability.
See https://cheatsheetseries.owasp.org/cheatsheets/Vulnerability_Disclosure_Cheat_Sheet.html for guidelines regarding disclosure.
If you're unable / unwilling (or it's not safe) to disclose vulnerabilites via GitHub, please report them with the subject "Security advisory - CVEXXX" to our email homarr-labs@proton.me.
Please never disclose security vulnerabilits on your own publicly - we'd like to search for a dimplomatic solution that is also safe for our users.
In your initial contact with us, please provide details according to the [OWASP guidelines for initial reports](https://cheatsheetseries.owasp.org/cheatsheets/Vulnerability_Disclosure_Cheat_Sheet.html#initial-report).
Thank you!
We're looking forward to your report
+2 -7
View File
@@ -1,17 +1,12 @@
// Importing env files here to validate on build
import "@homarr/auth/env.mjs";
import "@homarr/db/env.mjs";
import MillionLint from "@million/lint";
import createNextIntlPlugin from "next-intl/plugin";
import "./src/env.mjs";
// Package path does not work... so we need to use relative path
const withNextIntl = createNextIntlPlugin("../../packages/translation/src/request.ts");
/** @type {import("next").NextConfig} */
const nextConfig = {
const config = {
output: "standalone",
reactStrictMode: true,
/** We already do linting and typechecking as separate tasks in CI */
@@ -39,4 +34,4 @@ const nextConfig = {
// 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 withNextIntl(nextConfig);
export default config;
+28 -30
View File
@@ -23,13 +23,12 @@
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/form": "workspace:^0.1.0",
"@homarr/gridstack": "^1.11.2",
"@homarr/gridstack": "^1.0.3",
"@homarr/integrations": "workspace:^0.1.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-import": "workspace:^0.1.0",
"@homarr/old-schema": "workspace:^0.1.0",
"@homarr/redis": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",
@@ -38,18 +37,17 @@
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0",
"@mantine/colors-generator": "^7.15.1",
"@mantine/core": "^7.15.1",
"@mantine/dropzone": "^7.15.1",
"@mantine/hooks": "^7.15.1",
"@mantine/modals": "^7.15.1",
"@mantine/tiptap": "^7.15.1",
"@million/lint": "1.0.14",
"@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.26.0",
"@tanstack/react-query": "^5.62.8",
"@tanstack/react-query-devtools": "^5.62.8",
"@tanstack/react-query-next-experimental": "5.62.8",
"@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",
"@trpc/next": "next",
"@trpc/react-query": "next",
@@ -60,37 +58,37 @@
"chroma-js": "^3.1.2",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"dotenv": "^16.4.7",
"dotenv": "^16.4.5",
"flag-icons": "^7.2.3",
"glob": "^11.0.0",
"jotai": "^2.10.4",
"jotai": "^2.10.1",
"mantine-react-table": "2.0.0-beta.7",
"next": "^14.2.21",
"next": "^14.2.16",
"postcss-preset-mantine": "^1.17.0",
"prismjs": "^1.29.0",
"react": "^19.0.0",
"react-dom": "^19.0.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.83.0",
"superjson": "2.2.2",
"swagger-ui-react": "^5.18.2",
"sass": "^1.80.4",
"superjson": "2.2.1",
"swagger-ui-react": "^5.17.14",
"use-deep-compare-effect": "^1.8.1"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/chroma-js": "2.4.5",
"@types/node": "^22.10.2",
"@types/chroma-js": "2.4.4",
"@types/node": "^20.17.1",
"@types/prismjs": "^1.26.5",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/swagger-ui-react": "^4.18.3",
"concurrently": "^9.1.0",
"eslint": "^9.17.0",
"node-loader": "^2.1.0",
"prettier": "^3.4.2",
"typescript": "^5.7.2"
"concurrently": "^9.0.1",
"eslint": "^9.13.0",
"node-loader": "^2.0.0",
"prettier": "^3.3.3",
"typescript": "^5.6.3"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 497 B

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

Before

Width:  |  Height:  |  Size: 3.9 KiB

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

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

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

Before

Width:  |  Height:  |  Size: 2.2 KiB

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

Before

Width:  |  Height:  |  Size: 4.6 KiB

@@ -1,5 +0,0 @@
import definition from "../../boards/(content)/(home)/_definition";
const { layout } = definition;
export default layout;
@@ -1,7 +0,0 @@
import definition from "../../boards/(content)/(home)/_definition";
const { generateMetadataAsync: generateMetadata, page } = definition;
export default page;
export { generateMetadata };
@@ -1,3 +0,0 @@
import HomeBoardNotFoundPage from "../boards/(content)/not-found";
export default HomeBoardNotFoundPage;
@@ -0,0 +1,14 @@
import type { PropsWithChildren } from "react";
import { AppShellMain } from "@mantine/core";
import { MainHeader } from "~/components/layout/header";
import { ClientShell } from "~/components/layout/shell";
export default function MainLayout({ children }: PropsWithChildren) {
return (
<ClientShell hasNavigation={false}>
<MainHeader />
<AppShellMain>{children}</AppShellMain>
</ClientShell>
);
}
@@ -0,0 +1,9 @@
import { Stack, Title } from "@mantine/core";
export default function HomePage() {
return (
<Stack>
<Title>Home</Title>
</Stack>
);
}
@@ -1,12 +0,0 @@
"use client";
import type { PropsWithChildren } from "react";
import { useSuspenseDayJsLocalization } from "@homarr/translation/dayjs";
export const DayJsLoader = ({ children }: PropsWithChildren) => {
// Load the dayjs localization for the current locale with suspense
useSuspenseDayJsLocalization();
return children;
};
@@ -8,18 +8,14 @@ import dayjs from "dayjs";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { parseCookies, setClientCookie } from "@homarr/common";
import type { ColorScheme } from "@homarr/definitions";
import { colorSchemeCookieKey } from "@homarr/definitions";
export const CustomMantineProvider = ({
children,
defaultColorScheme,
}: PropsWithChildren<{ defaultColorScheme: ColorScheme }>) => {
export const CustomMantineProvider = ({ children }: PropsWithChildren) => {
const manager = useColorSchemeManager();
return (
<DirectionProvider>
<MantineProvider
defaultColorScheme={defaultColorScheme}
defaultColorScheme="dark"
colorSchemeManager={manager}
theme={createTheme({
primaryColor: "red",
@@ -32,11 +28,12 @@ export const CustomMantineProvider = ({
);
};
export function useColorSchemeManager(): MantineColorSchemeManager {
function useColorSchemeManager(): MantineColorSchemeManager {
const key = "homarr-color-scheme";
const { data: session } = useSession();
const updateCookieValue = (value: Exclude<MantineColorScheme, "auto">) => {
setClientCookie(colorSchemeCookieKey, value, { expires: dayjs().add(1, "year").toDate(), path: "/" });
setClientCookie(key, value, { expires: dayjs().add(1, "year").toDate(), path: "/" });
};
const { mutate: mutateColorScheme } = clientApi.user.changeColorScheme.useMutation({
@@ -53,7 +50,7 @@ export function useColorSchemeManager(): MantineColorSchemeManager {
try {
const cookies = parseCookies(document.cookie);
return (cookies[colorSchemeCookieKey] as MantineColorScheme | undefined) ?? defaultValue;
return (cookies[key] as MantineColorScheme | undefined) ?? defaultValue;
} catch {
return defaultValue;
}
@@ -0,0 +1,12 @@
import type { PropsWithChildren } from "react";
import { defaultLocale } from "@homarr/translation";
import { I18nProviderClient } from "@homarr/translation/client";
export const NextInternationalProvider = ({ children, locale }: PropsWithChildren<{ locale: string }>) => {
return (
<I18nProviderClient locale={locale} fallback={defaultLocale}>
{children}
</I18nProviderClient>
);
};
@@ -15,24 +15,14 @@ import {
wsLink,
} from "@trpc/client";
import superjson from "superjson";
import type { SuperJSONResult } from "superjson";
import type { AppRouter } from "@homarr/api";
import { clientApi, createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/client";
import { clientApi } from "@homarr/api/client";
import { env } from "~/env.mjs";
const getWebSocketProtocol = () => {
// window is not defined on server side
if (typeof window === "undefined") {
return "ws";
}
return window.location.protocol === "https:" ? "wss" : "ws";
};
const constructWebsocketUrl = () => {
const fallback = `${getWebSocketProtocol()}://localhost:3001/websockets`;
const fallback = "ws://localhost:3001/websockets";
if (typeof window === "undefined") {
return fallback;
}
@@ -41,7 +31,7 @@ const constructWebsocketUrl = () => {
return fallback;
}
return `${getWebSocketProtocol()}://${window.location.hostname}:${window.location.port}/websockets`;
return `ws://${window.location.hostname}:${window.location.port}/websockets`;
};
const wsClient = createWSClient({
@@ -83,17 +73,20 @@ export function TRPCReactProvider(props: PropsWithChildren) {
serialize(object: unknown) {
return object;
},
deserialize(data: SuperJSONResult) {
return superjson.deserialize<unknown>(data);
deserialize(data: unknown) {
return data;
},
},
url: getTrpcUrl(),
headers: createHeadersCallbackForSource("nextjs-react (form-data)"),
url: `${getBaseUrl()}/api/trpc`,
}),
false: unstable_httpBatchStreamLink({
transformer: superjson,
url: getTrpcUrl(),
headers: createHeadersCallbackForSource("nextjs-react (json)"),
url: `${getBaseUrl()}/api/trpc`,
headers() {
const headers = new Headers();
headers.set("x-trpc-source", "nextjs-react");
return headers;
},
}),
}),
}),
@@ -110,3 +103,9 @@ export function TRPCReactProvider(props: PropsWithChildren) {
</clientApi.Provider>
);
}
function getBaseUrl() {
if (typeof window !== "undefined") return window.location.origin;
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
return `http://localhost:${process.env.PORT ?? 3000}`;
}
@@ -4,7 +4,7 @@ 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";
import { invites } from "@homarr/db/schema/sqlite";
import { getScopedI18n } from "@homarr/translation/server";
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
@@ -1,6 +1,6 @@
import { api } from "@homarr/api/server";
import { createBoardContentPage } from "../../_creator";
import { createBoardContentPage } from "../_creator";
export default createBoardContentPage<{ locale: string; name: string }>({
async getInitialBoardAsync({ name }) {
@@ -1,18 +0,0 @@
import { IconLayoutOff } from "@tabler/icons-react";
import { getScopedI18n } from "@homarr/translation/server";
import { BoardNotFound } from "~/components/board/not-found";
export default async function BoardNotFoundPage() {
const tNotFound = await getScopedI18n("board.error.notFound");
return (
<BoardNotFound
icon={IconLayoutOff}
title={tNotFound("title")}
description={tNotFound("description")}
link={{ label: tNotFound("link"), href: "/manage/boards" }}
notice={tNotFound("notice")}
/>
);
}
@@ -1,8 +1,6 @@
"use client";
import type { MouseEvent } from "react";
import { useCallback, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useCallback } from "react";
import { Group, Menu } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks";
import {
@@ -18,7 +16,7 @@ import {
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useModalAction } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
@@ -28,7 +26,6 @@ import { useCategoryActions } from "~/components/board/sections/category/categor
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 { env } from "~/env.mjs";
import { useEditMode, useRequiredBoard } from "./_context";
export const BoardContentHeaderActions = () => {
@@ -142,7 +139,6 @@ const EditModeMenu = () => {
}, [board, isEditMode, saveBoard, setEditMode]);
useHotkeys([["mod+e", toggle]]);
usePreventLeaveWithDirty(isEditMode);
return (
<HeaderButton onClick={toggle} loading={isPending}>
@@ -150,63 +146,3 @@ const EditModeMenu = () => {
</HeaderButton>
);
};
const usePreventLeaveWithDirty = (isDirty: boolean) => {
const t = useI18n();
const { openConfirmModal } = useConfirmModal();
const router = useRouter();
useEffect(() => {
const handleClick = (event: MouseEvent<HTMLElement>) => {
const target = (event.target as HTMLElement).closest("a");
if (!target) return;
if (!isDirty) return;
event.preventDefault();
openConfirmModal({
title: t("board.action.edit.confirmLeave.title"),
children: t("board.action.edit.confirmLeave.message"),
onConfirm() {
router.push(target.href);
},
confirmProps: {
children: t("common.action.discard"),
},
});
};
const handlePopState = (event: Event) => {
if (isDirty) {
window.history.pushState(null, document.title, window.location.href);
event.preventDefault();
} else {
window.history.back();
}
};
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (!isDirty) return;
if (env.NODE_ENV === "development") return; // Allow to reload in development
event.preventDefault();
event.returnValue = true;
};
document.querySelectorAll("a").forEach((link) => {
link.addEventListener("click", handleClick as never);
});
window.addEventListener("popstate", handlePopState);
window.addEventListener("beforeunload", handleBeforeUnload);
return () => {
document.querySelectorAll("a").forEach((link) => {
link.removeEventListener("click", handleClick as never);
window.removeEventListener("popstate", handlePopState);
});
window.removeEventListener("beforeunload", handleBeforeUnload);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDirty]);
};
@@ -4,17 +4,10 @@ import type { PropsWithChildren } from "react";
import type { MantineColorsTuple } from "@mantine/core";
import { createTheme, darken, lighten, MantineProvider } from "@mantine/core";
import type { ColorScheme } from "@homarr/definitions";
import { useColorSchemeManager } from "../../_client-providers/mantine";
import { useRequiredBoard } from "./_context";
export const BoardMantineProvider = ({
children,
defaultColorScheme,
}: PropsWithChildren<{ defaultColorScheme: ColorScheme }>) => {
export const BoardMantineProvider = ({ children }: PropsWithChildren) => {
const board = useRequiredBoard();
const colorSchemeManager = useColorSchemeManager();
const theme = createTheme({
colors: {
@@ -25,11 +18,7 @@ export const BoardMantineProvider = ({
autoContrast: true,
});
return (
<MantineProvider defaultColorScheme={defaultColorScheme} theme={theme} colorSchemeManager={colorSchemeManager}>
{children}
</MantineProvider>
);
return <MantineProvider theme={theme}>{children}</MantineProvider>;
};
export const generateColors = (hex: string) => {
@@ -1,47 +0,0 @@
import { IconHomeOff } from "@tabler/icons-react";
import { auth } from "@homarr/auth/next";
import { db } from "@homarr/db";
import { boards } from "@homarr/db/schema";
import { getI18n } from "@homarr/translation/server";
import type { BoardNotFoundProps } from "~/components/board/not-found";
import { BoardNotFound } from "~/components/board/not-found";
export default async function NotFoundBoardHomePage() {
const boardNotFoundProps = await getPropsAsync();
return <BoardNotFound {...boardNotFoundProps} />;
}
const getPropsAsync = async (): Promise<BoardNotFoundProps> => {
const boardCount = await db.$count(boards);
const t = await getI18n();
if (boardCount === 0) {
return {
icon: { src: "/favicon.ico", alt: "Homarr logo" },
title: t("board.error.noBoard.title"),
description: t("board.error.noBoard.description"),
link: { label: t("board.error.noBoard.link"), href: "/manage/boards" },
notice: t("board.error.noBoard.notice"),
};
}
const session = await auth();
const isAdmin = session?.user.permissions.includes("admin");
const type = isAdmin ? "admin" : session !== null ? "user" : "anonymous";
const href = {
admin: "/manage/settings",
user: `/manage/users/${session?.user.id}/general`,
anonymous: "/manage/boards",
}[type];
return {
icon: IconHomeOff,
title: t(`board.error.homeBoard.title`),
description: t(`board.error.homeBoard.${type}.description`),
link: { label: t(`board.error.homeBoard.${type}.link`), href },
notice: t(`board.error.homeBoard.${type}.notice`),
};
};
@@ -12,7 +12,7 @@ import { BoardRenameModal } from "~/components/board/modals/board-rename-modal";
import { useRequiredBoard } from "../../(content)/_context";
import classes from "./danger.module.css";
export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => {
export const DangerZoneSettingsContent = () => {
const board = useRequiredBoard();
const t = useScopedI18n("board.setting");
const router = useRouter();
@@ -90,18 +90,14 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
buttonText={t("section.dangerZone.action.rename.button")}
onClick={onRenameClick}
/>
{hideVisibility ? null : (
<>
<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}`)}
onClick={onVisibilityClick}
isPending={isChangeVisibilityPending}
/>
</>
)}
<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}`)}
onClick={onVisibilityClick}
isPending={isChangeVisibilityPending}
/>
<Divider />
<DangerZoneRow
label={t("section.dangerZone.action.delete.label")}
@@ -14,8 +14,6 @@ import { TRPCError } from "@trpc/server";
import { api } from "@homarr/api/server";
import { capitalize } from "@homarr/common";
import { db } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import type { TranslationObject } from "@homarr/translation";
import { getScopedI18n } from "@homarr/translation/server";
import type { TablerIcon } from "@homarr/ui";
@@ -65,14 +63,9 @@ const getBoardAndPermissionsAsync = async (params: Props["params"]) => {
export default async function BoardSettingsPage({ params, searchParams }: Props) {
const { board, permissions } = await getBoardAndPermissionsAsync(params);
const boardSettings = await getServerSettingByKeyAsync(db, "board");
const { hasFullAccess, hasChangeAccess } = await getBoardPermissionsAsync(board);
const { hasFullAccess } = await getBoardPermissionsAsync(board);
const t = await getScopedI18n("board.setting");
if (!hasChangeAccess) {
notFound();
}
return (
<Container>
<Stack>
@@ -99,7 +92,7 @@ export default async function BoardSettingsPage({ params, searchParams }: Props)
<BoardAccessSettings board={board} initialPermissions={permissions} />
</AccordionItemFor>
<AccordionItemFor value="dangerZone" icon={IconAlertTriangle} danger noPadding>
<DangerZoneSettingsContent hideVisibility={boardSettings.homeBoardId === board.id} />
<DangerZoneSettingsContent />
</AccordionItemFor>
</>
)}
@@ -1,4 +1,4 @@
import type { JSX, PropsWithChildren } from "react";
import type { PropsWithChildren } from "react";
import { notFound } from "next/navigation";
import { AppShellMain } from "@mantine/core";
import { TRPCError } from "@trpc/server";
@@ -8,7 +8,6 @@ import { logger } from "@homarr/log";
import { MainHeader } from "~/components/layout/header";
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
import { ClientShell } from "~/components/layout/shell";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
import type { Board } from "./_types";
import { BoardProvider } from "./(content)/_context";
import type { Params } from "./(content)/_creator";
@@ -38,11 +37,10 @@ export const createBoardLayout = <TParams extends Params>({
throw error;
});
const colorScheme = await getCurrentColorSchemeAsync();
return (
<BoardProvider initialBoard={initialBoard}>
<BoardMantineProvider defaultColorScheme={colorScheme}>
<BoardMantineProvider>
<CustomCss />
<ClientShell hasNavigation={false}>
<MainHeader
@@ -1,23 +0,0 @@
"use client";
import { Button } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useI18n } from "@homarr/translation/client";
export const BackToStart = () => {
const t = useI18n();
const { mutateAsync, isPending } = clientApi.onboard.previousStep.useMutation();
const handleBackToStartAsync = async () => {
await mutateAsync();
await revalidatePathActionAsync("/init");
};
return (
<Button loading={isPending} variant="subtle" color="gray" fullWidth onClick={handleBackToStartAsync}>
{t("init.backToStart")}
</Button>
);
};
@@ -1,87 +0,0 @@
import Link from "next/link";
import type { MantineColor } from "@mantine/core";
import { Button, Card, Stack, Text } from "@mantine/core";
import { IconBook2, IconCategoryPlus, IconLayoutDashboard, IconMailForward } from "@tabler/icons-react";
import { isProviderEnabled } from "@homarr/auth/server";
import { getMantineColor } from "@homarr/common";
import { db } from "@homarr/db";
import { createDocumentationLink } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import type { TablerIcon } from "@homarr/ui";
export const InitFinish = async () => {
const firstBoard = await db.query.boards.findFirst({ columns: { name: true } });
const tFinish = await getScopedI18n("init.step.finish");
return (
<Card w={64 * 6} maw="90vw" withBorder>
<Stack>
<Text>{tFinish("description")}</Text>
{firstBoard ? (
<InternalLinkButton
href={`/auth/login?callbackUrl=/boards/${firstBoard.name}`}
iconProps={{ icon: IconLayoutDashboard, color: "blue" }}
>
{tFinish("action.goToBoard", { name: firstBoard.name })}
</InternalLinkButton>
) : (
<InternalLinkButton
href="/auth/login?callbackUrl=/manage/boards"
iconProps={{ icon: IconCategoryPlus, color: "blue" }}
>
{tFinish("action.createBoard")}
</InternalLinkButton>
)}
{isProviderEnabled("credentials") && (
<InternalLinkButton
href="/auth/login?callbackUrl=/manage/users/invites"
iconProps={{ icon: IconMailForward, color: "pink" }}
>
{tFinish("action.inviteUser")}
</InternalLinkButton>
)}
<ExternalLinkButton
href={createDocumentationLink("/docs/getting-started/after-the-installation")}
iconProps={{ icon: IconBook2, color: "yellow" }}
>
{tFinish("action.docs")}
</ExternalLinkButton>
</Stack>
</Card>
);
};
interface LinkButtonProps {
href: string;
children: string;
iconProps: IconProps;
}
interface IconProps {
icon: TablerIcon;
color: MantineColor;
}
const Icon = ({ icon: IcomComponent, color }: IconProps) => {
return <IcomComponent color={getMantineColor(color, 6)} size={16} stroke={1.5} />;
};
const InternalLinkButton = ({ href, children, iconProps }: LinkButtonProps) => {
return (
<Button variant="default" component={Link} href={href} leftSection={<Icon {...iconProps} />}>
{children}
</Button>
);
};
const ExternalLinkButton = ({ href, children, iconProps }: LinkButtonProps) => {
return (
<Button variant="default" component="a" href={href} leftSection={<Icon {...iconProps} />}>
{children}
</Button>
);
};
@@ -1,52 +0,0 @@
"use client";
import { Button, Card, Stack, TextInput } from "@mantine/core";
import { IconArrowRight } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
export const InitGroup = () => {
const t = useI18n();
const { mutateAsync } = clientApi.group.createInitialExternalGroup.useMutation();
const form = useZodForm(validation.group.create, {
initialValues: {
name: "",
},
});
const handleSubmitAsync = async (values: z.infer<typeof validation.group.create>) => {
await mutateAsync(values, {
async onSuccess() {
await revalidatePathActionAsync("/init");
},
onError(error) {
if (error.data?.code === "CONFLICT") {
form.setErrors({ name: t("common.zod.errors.custom.groupNameTaken") });
}
},
});
};
return (
<Card w={64 * 6} maw="90vw" withBorder>
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
<Stack>
<TextInput
label={t("init.step.group.form.name.label")}
description={t("init.step.group.form.name.description")}
withAsterisk
{...form.getInputProps("name")}
/>
<Button type="submit" loading={form.submitting} rightSection={<IconArrowRight size={16} stroke={1.5} />}>
{t("common.action.continue")}
</Button>
</Stack>
</form>
</Card>
);
};
@@ -1,41 +0,0 @@
import { ActionIcon, Button, Card, Group, Text } from "@mantine/core";
import type { FileWithPath } from "@mantine/dropzone";
import { IconPencil } from "@tabler/icons-react";
import { humanFileSize } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
interface FileInfoCardProps {
file: FileWithPath;
onRemove: () => void;
}
export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => {
const tFileInfo = useScopedI18n("init.step.import.fileInfo");
return (
<Card w={64 * 12 + 8} maw="90vw">
<Group justify="space-between" align="center" wrap="nowrap">
<Group>
<Text fw={500} lineClamp={1} style={{ wordBreak: "break-all" }}>
{file.name}
</Text>
<Text visibleFrom="md" c="gray.6" size="sm">
{humanFileSize(file.size)}
</Text>
</Group>
<Button
variant="subtle"
color="gray"
rightSection={<IconPencil size={16} stroke={1.5} />}
onClick={onRemove}
visibleFrom="md"
>
{tFileInfo("action.change")}
</Button>
<ActionIcon size="sm" variant="subtle" color="gray" hiddenFrom="md" onClick={onRemove}>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
</Group>
</Card>
);
};
@@ -1,63 +0,0 @@
import { Group, rem, Text } from "@mantine/core";
import type { FileWithPath } from "@mantine/dropzone";
import { Dropzone, MIME_TYPES } from "@mantine/dropzone";
import { IconFileZip, IconUpload, IconX } from "@tabler/icons-react";
import "@mantine/dropzone/styles.css";
import { useScopedI18n } from "@homarr/translation/client";
interface ImportDropZoneProps {
loading: boolean;
updateFile: (file: FileWithPath) => void;
}
export const ImportDropZone = ({ loading, updateFile }: ImportDropZoneProps) => {
const tDropzone = useScopedI18n("init.step.import.dropzone");
return (
<Dropzone
onDrop={(files) => {
const firstFile = files[0];
if (!firstFile) return;
updateFile(firstFile);
}}
acceptColor="blue.6"
rejectColor="red.6"
accept={[MIME_TYPES.zip, "application/x-zip-compressed"]}
loading={loading}
multiple={false}
maxSize={1024 * 1024 * 1024 * 64} // 64 MB
onReject={(rejections) => {
console.error(
"Rejected files",
rejections.map(
(rejection) =>
`File: ${rejection.file.name} size=${rejection.file.size} fileType=${rejection.file.type}\n - ${rejection.errors.map((error) => error.message).join("\n - ")}`,
),
);
}}
>
<Group justify="center" gap="xl" mih={220} style={{ pointerEvents: "none" }}>
<Dropzone.Accept>
<IconUpload style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-blue-6)" }} stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-red-6)" }} stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconFileZip style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-dimmed)" }} stroke={1.5} />
</Dropzone.Idle>
<div>
<Text size="xl" inline>
{tDropzone("title")}
</Text>
<Text size="sm" c="dimmed" inline mt={7}>
{tDropzone("description")}
</Text>
</div>
</Group>
</Dropzone>
);
};
@@ -1,53 +0,0 @@
"use client";
import { startTransition, useState } from "react";
import { Card, Stack } from "@mantine/core";
import type { FileWithPath } from "@mantine/dropzone";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { InitialOldmarrImport } from "@homarr/old-import/components";
import { FileInfoCard } from "./file-info-card";
import { ImportDropZone } from "./import-dropzone";
export const InitImport = () => {
const [file, setFile] = useState<FileWithPath | null>(null);
const { isPending, mutate } = clientApi.import.analyseInitialOldmarrImport.useMutation();
const [analyseResult, setAnalyseResult] = useState<RouterOutputs["import"]["analyseInitialOldmarrImport"] | null>(
null,
);
if (!file) {
return (
<Card w={64 * 12 + 8} maw="90vw" withBorder>
<ImportDropZone
loading={isPending}
updateFile={(file) => {
const formData = new FormData();
formData.append("file", file);
mutate(formData, {
onSuccess: (result) => {
startTransition(() => {
setAnalyseResult(result);
setFile(file);
});
},
onError: (error) => {
console.error(error);
},
});
}}
/>
</Card>
);
}
return (
<Stack mb="sm">
<FileInfoCard file={file} onRemove={() => setFile(null)} />
{analyseResult !== null && <InitialOldmarrImport file={file} analyseResult={analyseResult} />}
</Stack>
);
};
@@ -1,156 +0,0 @@
"use client";
import { startTransition } from "react";
import { Button, Card, Group, Stack, Switch, Text } from "@mantine/core";
import { IconArrowRight } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import type { CheckboxProps } from "@homarr/form/types";
import { defaultServerSettings } from "@homarr/server-settings";
import type { TranslationObject } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
export const InitSettings = () => {
const tSection = useScopedI18n("management.page.settings.section");
const t = useI18n();
const { mutateAsync } = clientApi.serverSettings.initSettings.useMutation();
const form = useZodForm(validation.settings.init, { initialValues: defaultServerSettings });
form.watch("analytics.enableGeneral", ({ value }) => {
if (!value) {
startTransition(() => {
form.setFieldValue("analytics.enableWidgetData", false);
form.setFieldValue("analytics.enableIntegrationData", false);
form.setFieldValue("analytics.enableUserData", false);
});
}
});
const handleSubmitAsync = async (values: z.infer<typeof validation.settings.init>) => {
await mutateAsync(values, {
async onSuccess() {
await revalidatePathActionAsync("/init");
},
});
};
return (
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
<Stack>
<Card w={64 * 12 + 8} maw="90vw" withBorder>
<Stack gap="sm">
<Text fw={500}>{tSection("analytics.title")}</Text>
<Stack gap="xs">
<AnalyticsRow kind="general" {...form.getInputProps("analytics.enableGeneral", { type: "checkbox" })} />
<Stack gap="xs" ps="md" w="100%">
<AnalyticsRow
kind="integrationData"
disabled={!form.values.analytics.enableGeneral}
{...form.getInputProps("analytics.enableWidgetData", { type: "checkbox" })}
/>
<AnalyticsRow
kind="widgetData"
disabled={!form.values.analytics.enableGeneral}
{...form.getInputProps("analytics.enableIntegrationData", { type: "checkbox" })}
/>
<AnalyticsRow
kind="usersData"
disabled={!form.values.analytics.enableGeneral}
{...form.getInputProps("analytics.enableUserData", { type: "checkbox" })}
/>
</Stack>
</Stack>
</Stack>
</Card>
<Card w={64 * 12 + 8} maw="90vw" withBorder>
<Stack gap="sm">
<Text fw={500}>{tSection("crawlingAndIndexing.title")}</Text>
<Stack gap="xs">
<CrawlingRow
kind="noIndex"
{...form.getInputProps("crawlingAndIndexing.noIndex", { type: "checkbox" })}
/>
<CrawlingRow
kind="noFollow"
{...form.getInputProps("crawlingAndIndexing.noFollow", { type: "checkbox" })}
/>
<CrawlingRow
kind="noTranslate"
{...form.getInputProps("crawlingAndIndexing.noTranslate", { type: "checkbox" })}
/>
<CrawlingRow
kind="noSiteLinksSearchBox"
{...form.getInputProps("crawlingAndIndexing.noSiteLinksSearchBox", { type: "checkbox" })}
/>
</Stack>
</Stack>
</Card>
<Button type="submit" loading={form.submitting} rightSection={<IconArrowRight size={16} stroke={1.5} />}>
{t("common.action.continue")}
</Button>
</Stack>
</form>
);
};
interface AnalyticsRowProps {
kind: Exclude<keyof TranslationObject["management"]["page"]["settings"]["section"]["analytics"], "title">;
disabled?: boolean;
}
const AnalyticsRow = ({ kind, ...props }: AnalyticsRowProps & CheckboxProps) => {
const tSection = useI18n("management.page.settings.section");
return (
<SettingRow title={tSection(`analytics.${kind}.title`)} text={tSection(`analytics.${kind}.text`)} {...props} />
);
};
interface CrawlingRowProps {
kind: Exclude<
keyof TranslationObject["management"]["page"]["settings"]["section"]["crawlingAndIndexing"],
"title" | "warning"
>;
}
const CrawlingRow = ({ kind, ...inputProps }: CrawlingRowProps & CheckboxProps) => {
const tSection = useI18n("management.page.settings.section");
return (
<SettingRow
title={tSection(`crawlingAndIndexing.${kind}.title`)}
text={tSection(`crawlingAndIndexing.${kind}.text`)}
{...inputProps}
/>
);
};
const SettingRow = ({
title,
text,
disabled,
...inputProps
}: { title: string; text: string; disabled?: boolean } & CheckboxProps) => {
return (
<Group wrap="nowrap" align="center">
<Stack gap={0} style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{title}
</Text>
<Text size="xs" c="gray.5">
{text}
</Text>
</Stack>
<Switch disabled={disabled} {...inputProps} />
</Group>
);
};
@@ -1,32 +0,0 @@
import { Card, Stack, Text } from "@mantine/core";
import { IconFileImport, IconPlayerPlay } from "@tabler/icons-react";
import { getMantineColor } from "@homarr/common";
import { getScopedI18n } from "@homarr/translation/server";
import { InitStartButton } from "./next-button";
export const InitStart = async () => {
const tStart = await getScopedI18n("init.step.start");
return (
<Card w={64 * 6} maw="90vw" withBorder>
<Stack>
<Text>{tStart("description")}</Text>
<InitStartButton
preferredStep={undefined}
icon={<IconPlayerPlay color={getMantineColor("green", 6)} size={16} stroke={1.5} />}
>
{tStart("action.scratch")}
</InitStartButton>
<InitStartButton
preferredStep="import"
icon={<IconFileImport color={getMantineColor("cyan", 6)} size={16} stroke={1.5} />}
>
{tStart("action.importOldmarr")}
</InitStartButton>
</Stack>
</Card>
);
};
@@ -1,28 +0,0 @@
"use client";
import type { PropsWithChildren, ReactNode } from "react";
import { Button } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import type { OnboardingStep } from "@homarr/definitions";
interface InitStartButtonProps {
icon: ReactNode;
preferredStep: OnboardingStep | undefined;
}
export const InitStartButton = ({ preferredStep, icon, children }: PropsWithChildren<InitStartButtonProps>) => {
const { mutateAsync } = clientApi.onboard.nextStep.useMutation();
const handleClickAsync = async () => {
await mutateAsync({ preferredStep });
await revalidatePathActionAsync("/init");
};
return (
<Button onClick={handleClickAsync} variant="default" leftSection={icon}>
{children}
</Button>
);
};
@@ -1,11 +0,0 @@
import { Card } from "@mantine/core";
import { InitUserForm } from "./init-user-form";
export const InitUser = () => {
return (
<Card w={64 * 6} maw="90vw" withBorder>
<InitUserForm />
</Card>
);
};
@@ -1,56 +0,0 @@
import type { JSX } from "react";
import { Box, Center, Stack, Text, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import type { MaybePromise } from "@homarr/common/types";
import type { OnboardingStep } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { CurrentColorSchemeCombobox } from "~/components/color-scheme/current-color-scheme-combobox";
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
import { BackToStart } from "./_steps/back";
import { InitFinish } from "./_steps/finish/init-finish";
import { InitGroup } from "./_steps/group/init-group";
import { InitImport } from "./_steps/import/init-import";
import { InitSettings } from "./_steps/settings/init-settings";
import { InitStart } from "./_steps/start/init-start";
import { InitUser } from "./_steps/user/init-user";
const stepComponents: Record<OnboardingStep, null | (() => MaybePromise<JSX.Element>)> = {
start: InitStart,
import: InitImport,
user: InitUser,
group: InitGroup,
settings: InitSettings,
finish: InitFinish,
};
export default async function InitPage() {
const t = await getScopedI18n("init.step");
const currentStep = await api.onboard.currentStep();
const CurrentComponent = stepComponents[currentStep.current];
return (
<Box mih="100dvh">
<Center>
<Stack align="center" mt="xl">
<HomarrLogoWithTitle size="lg" />
<Stack gap={6} align="center">
<Title order={3} fw={400} ta="center">
{t(`${currentStep.current}.title`)}
</Title>
<Text size="sm" c="gray.5" ta="center">
{t(`${currentStep.current}.subtitle`)}
</Text>
</Stack>
<CurrentLanguageCombobox width="100%" />
<CurrentColorSchemeCombobox w="100%" />
{CurrentComponent && <CurrentComponent />}
{currentStep.previous === "start" && <BackToStart />}
</Stack>
</Center>
</Box>
);
}
@@ -1,9 +1,9 @@
"use client";
import { useRouter } from "next/navigation";
import { Button, PasswordInput, Stack, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
@@ -12,9 +12,9 @@ import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
export const InitUserForm = () => {
const router = useRouter();
const t = useScopedI18n("user");
const tUser = useScopedI18n("init.step.user");
const { mutateAsync, isPending } = clientApi.user.initUser.useMutation();
const { mutateAsync, error, isPending } = clientApi.user.initUser.useMutation();
const form = useZodForm(validation.user.init, {
initialValues: {
username: "",
@@ -25,17 +25,17 @@ export const InitUserForm = () => {
const handleSubmitAsync = async (values: FormType) => {
await mutateAsync(values, {
async onSuccess() {
onSuccess: () => {
showSuccessNotification({
title: tUser("notification.success.title"),
message: tUser("notification.success.message"),
title: "User created",
message: "You can now log in",
});
await revalidatePathActionAsync("/init");
router.push("/auth/login");
},
onError: (error) => {
onError: () => {
showErrorNotification({
title: tUser("notification.error.title"),
message: error.message,
title: "User creation failed",
message: error?.message ?? "Unknown error",
});
},
});
@@ -0,0 +1,41 @@
import { notFound } from "next/navigation";
import { Card, Center, Stack, Text, Title } from "@mantine/core";
import { db } from "@homarr/db";
import { getScopedI18n } from "@homarr/translation/server";
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
import { InitUserForm } from "./_init-user-form";
export default async function InitUser() {
const firstUser = await db.query.users.findFirst({
columns: {
id: true,
},
});
if (firstUser) {
notFound();
}
const t = await getScopedI18n("user.page.init");
return (
<Center>
<Stack align="center" mt="xl">
<HomarrLogoWithTitle size="lg" />
<Stack gap={6} align="center">
<Title order={3} fw={400} ta="center">
{t("title")}
</Title>
<Text size="sm" c="gray.5" ta="center">
{t("subtitle")}
</Text>
</Stack>
<Card bg="dark.8" w={64 * 6} maw="90vw">
<InitUserForm />
</Card>
</Stack>
</Center>
);
}
+15 -22
View File
@@ -6,23 +6,19 @@ import "@homarr/spotlight/styles.css";
import "@homarr/ui/styles.css";
import "~/styles/scroll-area.scss";
import { notFound } from "next/navigation";
import { NextIntlClientProvider } from "next-intl";
import { cookies } from "next/headers";
import { env } from "@homarr/auth/env.mjs";
import { auth } from "@homarr/auth/next";
import { ModalProvider } from "@homarr/modals";
import { Notifications } from "@homarr/notifications";
import { SpotlightProvider } from "@homarr/spotlight";
import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
import { getI18nMessages } from "@homarr/translation/server";
import { getScopedI18n } from "@homarr/translation/server";
import { Analytics } from "~/components/layout/analytics";
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
import { DayJsLoader } from "./_client-providers/dayjs-loader";
import { JotaiProvider } from "./_client-providers/jotai";
import { CustomMantineProvider } from "./_client-providers/mantine";
import { NextInternationalProvider } from "./_client-providers/next-international";
import { AuthProvider } from "./_client-providers/session";
import { TRPCReactProvider } from "./_client-providers/trpc";
import { composeWrappers } from "./compose";
@@ -32,8 +28,7 @@ const fontSans = Inter({
variable: "--font-sans",
});
// eslint-disable-next-line no-restricted-syntax
export const generateMetadata = async (): Promise<Metadata> => ({
export const generateMetadata = (): Metadata => ({
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.",
@@ -52,7 +47,7 @@ export const generateMetadata = async (): Promise<Metadata> => ({
title: "Homarr",
capable: true,
startupImage: { url: "/logo/logo.png" },
statusBarStyle: (await getCurrentColorSchemeAsync()) === "dark" ? "black-translucent" : "default",
statusBarStyle: getColorScheme() === "dark" ? "black-translucent" : "default",
},
});
@@ -64,14 +59,10 @@ export const viewport: Viewport = {
};
export default async function Layout(props: { children: React.ReactNode; params: { locale: string } }) {
if (!isLocaleSupported(props.params.locale)) {
notFound();
}
const session = await auth();
const colorScheme = await getCurrentColorSchemeAsync();
const direction = isLocaleRTL(props.params.locale) ? "rtl" : "ltr";
const i18nMessages = await getI18nMessages();
const colorScheme = getColorScheme();
const tCommon = await getScopedI18n("common");
const direction = tCommon("direction");
const StackedProvider = composeWrappers([
(innerProps) => {
@@ -79,17 +70,15 @@ export default async function Layout(props: { children: React.ReactNode; params:
},
(innerProps) => <JotaiProvider {...innerProps} />,
(innerProps) => <TRPCReactProvider {...innerProps} />,
(innerProps) => <DayJsLoader {...innerProps} />,
(innerProps) => <NextIntlClientProvider {...innerProps} messages={i18nMessages} />,
(innerProps) => <CustomMantineProvider {...innerProps} defaultColorScheme={colorScheme} />,
(innerProps) => <NextInternationalProvider {...innerProps} locale={props.params.locale} />,
(innerProps) => <CustomMantineProvider {...innerProps} />,
(innerProps) => <ModalProvider {...innerProps} />,
(innerProps) => <SpotlightProvider {...innerProps} />,
]);
return (
// Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering
<html
lang={props.params.locale}
lang="en"
dir={direction}
data-mantine-color-scheme={colorScheme}
style={{
@@ -110,3 +99,7 @@ export default async function Layout(props: { children: React.ReactNode; params:
</html>
);
}
const getColorScheme = () => {
return cookies().get("homarr-color-scheme")?.value ?? "dark";
};
@@ -1,22 +1,12 @@
.bannerContainer {
border-radius: 8px;
overflow: hidden;
@mixin dark {
background: linear-gradient(
130deg,
#fa52521f 0%,
var(--mantine-color-dark-6) 35%,
var(--mantine-color-dark-6) 100%
) !important;
}
@mixin light {
background: linear-gradient(
130deg,
#fa52521f 0%,
var(--mantine-color-gray-3) 35%,
var(--mantine-color-gray-3) 100%
) !important;
}
background: linear-gradient(
130deg,
#fa52521f 0%,
var(--mantine-color-dark-6) 35%,
var(--mantine-color-dark-6) 100%
) !important;
}
.scrollContainer {
@@ -32,9 +32,9 @@ const icons = [
const countIconGroups = 3;
const animationDurationInSeconds = 12;
const arrayInChunks = splitToNChunks(icons, countIconGroups);
export const HeroBanner = () => {
const arrayInChunks = splitToNChunks(icons, countIconGroups);
const gridSpan = 12 / countIconGroups;
return (
@@ -1,8 +1,6 @@
import { notFound } from "next/navigation";
import { Container, Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
@@ -13,11 +11,6 @@ interface AppEditPageProps {
}
export default async function AppEditPage({ params }: AppEditPageProps) {
const session = await auth();
if (!session?.user.permissions.includes("app-modify-all")) {
notFound();
}
const app = await api.app.byId({ id: params.id });
const t = await getI18n();
@@ -1,19 +1,11 @@
import { notFound } from "next/navigation";
import { Container, Stack, Title } from "@mantine/core";
import { auth } from "@homarr/auth/next";
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 session = await auth();
if (!session?.user.permissions.includes("app-create")) {
notFound();
}
const t = await getI18n();
return (
@@ -1,27 +1,18 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
import { IconBox, IconPencil } from "@tabler/icons-react";
import { IconApps, IconPencil } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { parseAppHrefWithVariablesServer } from "@homarr/common/server";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { ManageContainer } from "~/components/manage/manage-container";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NoResults } from "~/components/no-results";
import { AppDeleteButton } from "./_app-delete-button";
export default async function AppsPage() {
const session = await auth();
if (!session) {
redirect("/auth/login");
}
const apps = await api.app.all();
const t = await getScopedI18n("app");
@@ -31,11 +22,9 @@ export default async function AppsPage() {
<Stack>
<Group justify="space-between" align="center">
<Title>{t("page.list.title")}</Title>
{session.user.permissions.includes("app-create") && (
<MobileAffixButton component={Link} href="/manage/apps/new">
{t("page.create.title")}
</MobileAffixButton>
)}
<MobileAffixButton component={Link} href="/manage/apps/new">
{t("page.create.title")}
</MobileAffixButton>
</Group>
{apps.length === 0 && <AppNoResults />}
{apps.length > 0 && (
@@ -56,10 +45,9 @@ interface AppCardProps {
const AppCard = async ({ app }: AppCardProps) => {
const t = await getScopedI18n("app");
const session = await auth();
return (
<Card withBorder>
<Card>
<Group justify="space-between" wrap="nowrap">
<Group align="top" justify="start" wrap="nowrap">
<Avatar
@@ -90,18 +78,16 @@ const AppCard = async ({ app }: AppCardProps) => {
</Group>
<Group>
<ActionIconGroup>
{session?.user.permissions.includes("app-modify-all") && (
<ActionIcon
component={Link}
href={`/manage/apps/edit/${app.id}`}
variant="subtle"
color="gray"
aria-label={t("page.edit.title")}
>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
)}
{session?.user.permissions.includes("app-full-all") && <AppDeleteButton app={app} />}
<ActionIcon
component={Link}
href={`/manage/apps/edit/${app.id}`}
variant="subtle"
color="gray"
aria-label={t("page.edit.title")}
>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
<AppDeleteButton app={app} />
</ActionIconGroup>
</Group>
</Group>
@@ -111,17 +97,16 @@ const AppCard = async ({ app }: AppCardProps) => {
const AppNoResults = async () => {
const t = await getI18n();
const session = await auth();
return (
<NoResults
icon={IconBox}
title={t("app.page.list.noResults.title")}
action={{
label: t("app.page.list.noResults.action"),
href: "/manage/apps/new",
hidden: !session?.user.permissions.includes("app-create"),
}}
/>
<Card withBorder bg="transparent">
<Stack align="center" gap="sm">
<IconApps size="2rem" />
<Text fw={500} size="lg">
{t("app.page.list.noResults.title")}
</Text>
<Anchor href="/manage/apps/new">{t("app.page.list.noResults.action")}</Anchor>
</Stack>
</Card>
);
};
@@ -67,7 +67,7 @@ const BoardCard = async ({ board }: BoardCardProps) => {
const VisibilityIcon = board.isPublic ? IconWorld : IconLock;
return (
<Card withBorder>
<Card>
<CardSection p="sm" withBorder>
<Group justify="space-between" align="center">
<Group gap="sm">
@@ -1,14 +1,14 @@
"use client";
import { useState } from "react";
import { ActionIcon, Avatar, Badge, Button, Card, Collapse, Group, Kbd, Stack, Text, Tooltip } from "@mantine/core";
import { useParams } from "next/navigation";
import { ActionIcon, Avatar, Button, Card, Collapse, Group, Kbd, Stack, Text } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconEye, IconEyeOff } from "@tabler/icons-react";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import type { RouterOutputs } from "@homarr/api";
import type { IntegrationSecretKind } from "@homarr/definitions";
import { integrationSecretKindObject } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
@@ -17,14 +17,13 @@ import { integrationSecretIcons } from "./integration-secret-icons";
dayjs.extend(relativeTime);
interface SecretCardProps {
secret:
| RouterOutputs["integration"]["byId"]["secrets"][number]
| { kind: IntegrationSecretKind; value: null; updatedAt: null };
secret: RouterOutputs["integration"]["byId"]["secrets"][number];
children: React.ReactNode;
onCancel: () => Promise<boolean>;
}
export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
const params = useParams<{ locale: string }>();
const t = useI18n();
const { isPublic } = integrationSecretKindObject[secret.kind];
const [publicSecretDisplayOpened, { toggle: togglePublicSecretDisplay }] = useDisclosure(false);
@@ -33,7 +32,7 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
const KindIcon = integrationSecretIcons[secret.kind];
return (
<Card withBorder>
<Card>
<Stack>
<Group justify="space-between">
<Group>
@@ -44,19 +43,11 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
{publicSecretDisplayOpened ? <Kbd>{secret.value}</Kbd> : null}
</Group>
<Group>
{secret.updatedAt ? (
<Text c="gray.6" size="sm">
{t("integration.secrets.lastUpdated", {
date: dayjs().to(dayjs(secret.updatedAt)),
})}
</Text>
) : (
<Tooltip label={t("integration.secrets.notSet.tooltip")} position="left">
<Badge color="orange" variant="light" size="sm">
{t("integration.secrets.notSet.label")}
</Badge>
</Tooltip>
)}
<Text c="gray.6" size="sm">
{t("integration.secrets.lastUpdated", {
date: dayjs().locale(params.locale).to(dayjs(secret.updatedAt)),
})}
</Text>
{isPublic ? (
<ActionIcon color="gray" variant="subtle" onClick={togglePublicSecretDisplay}>
<DisplayIcon size={16} stroke={1.5} />
@@ -98,7 +98,8 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
{secretsKinds.map((kind, index) => (
<SecretCard
key={kind}
secret={secretsMap.get(kind) ?? { kind, value: null, updatedAt: null }}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
secret={secretsMap.get(kind)!}
onCancel={() =>
new Promise((resolve) => {
// When nothing changed, just close the secret card
@@ -6,7 +6,7 @@ import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { IntegrationAvatar } from "@homarr/ui";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
import { catchTrpcNotFound } from "~/errors/trpc-not-found";
import { IntegrationAccessSettings } from "../../_components/integration-access-settings";
import { EditIntegrationForm } from "./_integration-edit-form";
@@ -1,7 +1,6 @@
import { Fragment } from "react";
import type { PropsWithChildren } from "react";
import Link from "next/link";
import { redirect } from "next/navigation";
import {
AccordionControl,
AccordionItem,
@@ -27,7 +26,7 @@ import {
Text,
Title,
} from "@mantine/core";
import { IconChevronDown, IconChevronUp, IconPencil, IconPlugX } from "@tabler/icons-react";
import { IconChevronDown, IconChevronUp, IconPencil } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
@@ -40,7 +39,6 @@ import { CountBadge, IntegrationAvatar } from "@homarr/ui";
import { ManageContainer } from "~/components/manage/manage-container";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NoResults } from "~/components/no-results";
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
import { DeleteIntegrationActionButton } from "./_integration-buttons";
import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown";
@@ -52,16 +50,11 @@ interface IntegrationsPageProps {
}
export default async function IntegrationsPage({ searchParams }: IntegrationsPageProps) {
const session = await auth();
if (!session) {
redirect("/auth/login");
}
const integrations = await api.integration.all();
const session = await auth();
const t = await getScopedI18n("integration");
const canCreateIntegrations = session.user.permissions.includes("integration-create");
const canCreateIntegrations = session?.user.permissions.includes("integration-create") ?? false;
return (
<ManageContainer>
@@ -121,7 +114,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
const hasFullAccess = session?.user.permissions.includes("integration-full-all") ?? false;
if (integrations.length === 0) {
return <NoResults icon={IconPlugX} title={t("page.list.noResults.title")} />;
return <div>{t("page.list.empty")}</div>;
}
const grouppedIntegrations = integrations.reduce(
+1 -16
View File
@@ -12,7 +12,6 @@ import {
IconLayoutDashboard,
IconLogs,
IconMailForward,
IconPhoto,
IconPlug,
IconQuestionMark,
IconReport,
@@ -52,25 +51,16 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
icon: IconBox,
href: "/manage/apps",
label: t("items.apps"),
hidden: !session,
},
{
icon: IconPlug,
href: "/manage/integrations",
label: t("items.integrations"),
hidden: !session,
},
{
icon: IconSearch,
href: "/manage/search-engines",
label: t("items.searchEngies"),
hidden: !session,
},
{
icon: IconPhoto,
href: "/manage/medias",
label: t("items.medias"),
hidden: !session,
},
{
icon: IconUser,
@@ -98,32 +88,27 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
{
label: t("items.tools.label"),
icon: IconTool,
// As permissions always include there children permissions, we can check other-view-logs as admin includes it
hidden: !session?.user.permissions.includes("other-view-logs"),
hidden: !session?.user.permissions.includes("admin"),
items: [
{
label: t("items.tools.items.docker"),
icon: IconBrandDocker,
href: "/manage/tools/docker",
hidden: !session?.user.permissions.includes("admin"),
},
{
label: t("items.tools.items.api"),
icon: IconPlug,
href: "/manage/tools/api",
hidden: !session?.user.permissions.includes("admin"),
},
{
label: t("items.tools.items.logs"),
icon: IconLogs,
href: "/manage/tools/logs",
hidden: !session?.user.permissions.includes("other-view-logs"),
},
{
label: t("items.tools.items.tasks"),
icon: IconReport,
href: "/manage/tools/tasks",
hidden: !session?.user.permissions.includes("admin"),
},
],
},
@@ -1,32 +0,0 @@
"use client";
import { ActionIcon, CopyButton, Tooltip } from "@mantine/core";
import { IconCheck, IconCopy } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { useI18n } from "@homarr/translation/client";
interface CopyMediaProps {
media: RouterOutputs["media"]["getPaginated"]["items"][number];
}
export const CopyMedia = ({ media }: CopyMediaProps) => {
const t = useI18n();
const url =
typeof window !== "undefined"
? `${window.location.protocol}://${window.location.hostname}:${window.location.port}/api/user-medias/${media.id}`
: "";
return (
<CopyButton value={url}>
{({ copy, copied }) => (
<Tooltip label={t("media.action.copy.label")} openDelay={500}>
<ActionIcon onClick={copy} color={copied ? "teal" : "gray"} variant="subtle">
{copied ? <IconCheck size={16} stroke={1.5} /> : <IconCopy size={16} stroke={1.5} />}
</ActionIcon>
</Tooltip>
)}
</CopyButton>
);
};
@@ -1,40 +0,0 @@
"use client";
import { ActionIcon, Tooltip } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
interface DeleteMediaProps {
media: RouterOutputs["media"]["getPaginated"]["items"][number];
}
export const DeleteMedia = ({ media }: DeleteMediaProps) => {
const { openConfirmModal } = useConfirmModal();
const t = useI18n();
const { mutateAsync, isPending } = clientApi.media.deleteMedia.useMutation();
const onClick = () => {
openConfirmModal({
title: t("media.action.delete.label"),
children: t.rich("media.action.delete.description", { bName: () => <b>{media.name}</b> }),
// eslint-disable-next-line no-restricted-syntax
onConfirm: async () => {
await mutateAsync({ id: media.id });
await revalidatePathActionAsync("/manage/medias");
},
});
};
return (
<Tooltip label={t("media.action.delete.label")} openDelay={500}>
<ActionIcon color="red" variant="subtle" onClick={onClick} loading={isPending}>
<IconTrash color="red" size={16} stroke={1.5} />
</ActionIcon>
</Tooltip>
);
};
@@ -1,36 +0,0 @@
"use client";
import { useState } from "react";
import type { ChangeEvent } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Switch } from "@mantine/core";
import type { SwitchProps } from "@mantine/core";
import { useI18n } from "@homarr/translation/client";
type ShowAllSwitchProps = Pick<SwitchProps, "defaultChecked">;
export const IncludeFromAllUsersSwitch = ({ defaultChecked }: ShowAllSwitchProps) => {
const router = useRouter();
const pathName = usePathname();
const searchParams = useSearchParams();
const [checked, setChecked] = useState(defaultChecked);
const t = useI18n();
const onChange = (event: ChangeEvent<HTMLInputElement>) => {
setChecked(event.target.checked);
const params = new URLSearchParams(searchParams);
params.set("includeFromAllUsers", event.target.checked.toString());
if (params.has("page")) params.set("page", "1"); // Reset page to 1
router.replace(`${pathName}?${params.toString()}`);
};
return (
<Switch
defaultChecked={defaultChecked}
checked={checked}
label={t("management.page.media.includeFromAllUsers")}
onChange={onChange}
/>
);
};
@@ -1,46 +0,0 @@
"use client";
import { Button, FileButton } from "@mantine/core";
import { IconUpload } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { supportedMediaUploadFormats } from "@homarr/validation";
export const UploadMedia = () => {
const t = useI18n();
const { mutateAsync, isPending } = clientApi.media.uploadMedia.useMutation();
const handleFileUploadAsync = async (file: File | null) => {
if (!file) return;
const formData = new FormData();
formData.append("file", file);
await mutateAsync(formData, {
onSuccess() {
showSuccessNotification({
message: t("media.action.upload.notification.success.message"),
});
},
onError() {
showErrorNotification({
message: t("media.action.upload.notification.error.message"),
});
},
async onSettled() {
await revalidatePathActionAsync("/manage/medias");
},
});
};
return (
<FileButton onChange={handleFileUploadAsync} accept={supportedMediaUploadFormats.join(",")}>
{({ onClick }) => (
<Button onClick={onClick} loading={isPending} rightSection={<IconUpload size={16} stroke={1.5} />}>
{t("media.action.upload.label")}
</Button>
)}
</FileButton>
);
};
@@ -1,132 +0,0 @@
import Image from "next/image";
import Link from "next/link";
import { notFound } from "next/navigation";
import { Anchor, Group, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Title } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { humanFileSize } from "@homarr/common";
import { getI18n } from "@homarr/translation/server";
import { SearchInput, TablePagination, UserAvatar } from "@homarr/ui";
import { z } from "@homarr/validation";
import { ManageContainer } from "~/components/manage/manage-container";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { CopyMedia } from "./_actions/copy-media";
import { DeleteMedia } from "./_actions/delete-media";
import { IncludeFromAllUsersSwitch } from "./_actions/show-all";
import { UploadMedia } from "./_actions/upload-media";
const searchParamsSchema = z.object({
search: z.string().optional(),
includeFromAllUsers: z
.string()
.regex(/true|false/)
.catch("false")
.transform((value) => value === "true"),
pageSize: z.string().regex(/\d+/).transform(Number).catch(10),
page: z.string().regex(/\d+/).transform(Number).catch(1),
});
type SearchParamsSchemaInputFromSchema<TSchema extends Record<string, unknown>> = Partial<{
[K in keyof TSchema]: Exclude<TSchema[K], undefined> extends unknown[] ? string[] : string;
}>;
interface MediaListPageProps {
searchParams: SearchParamsSchemaInputFromSchema<z.infer<typeof searchParamsSchema>>;
}
export default async function GroupsListPage(props: MediaListPageProps) {
const session = await auth();
if (!session) {
return notFound();
}
const t = await getI18n();
const searchParams = searchParamsSchema.parse(props.searchParams);
const { items: medias, totalCount } = await api.media.getPaginated(searchParams);
return (
<ManageContainer size="xl">
<DynamicBreadcrumb />
<Stack>
<Title>{t("media.plural")}</Title>
<Group justify="space-between">
<Group>
<SearchInput placeholder={`${t("media.search")}...`} defaultValue={searchParams.search} />
{session.user.permissions.includes("media-view-all") && (
<IncludeFromAllUsersSwitch defaultChecked={searchParams.includeFromAllUsers} />
)}
</Group>
{session.user.permissions.includes("media-upload") && <UploadMedia />}
</Group>
<Table striped highlightOnHover>
<TableThead>
<TableTr>
<TableTh></TableTh>
<TableTh>{t("media.field.name")}</TableTh>
<TableTh>{t("media.field.size")}</TableTh>
<TableTh>{t("media.field.creator")}</TableTh>
<TableTh></TableTh>
</TableTr>
</TableThead>
<TableTbody>
{medias.map((media) => (
<Row key={media.id} media={media} />
))}
</TableTbody>
</Table>
<Group justify="end">
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
</Group>
</Stack>
</ManageContainer>
);
}
interface RowProps {
media: RouterOutputs["media"]["getPaginated"]["items"][number];
}
const Row = async ({ media }: RowProps) => {
const session = await auth();
const canDelete = media.creatorId === session?.user.id || session?.user.permissions.includes("media-full-all");
return (
<TableTr>
<TableTd w={64}>
<Image
src={`/api/user-medias/${media.id}`}
alt={media.name}
width={64}
height={64}
style={{ objectFit: "contain" }}
/>
</TableTd>
<TableTd>{media.name}</TableTd>
<TableTd>{humanFileSize(media.size)}</TableTd>
<TableTd>
{media.creator ? (
<Group gap="sm">
<UserAvatar user={media.creator} size="sm" />
<Anchor component={Link} href={`/manage/users/${media.creator.id}/general`} size="sm">
{media.creator.name}
</Anchor>
</Group>
) : (
"-"
)}
</TableTd>
<TableTd w={64}>
<Group wrap="nowrap" gap="xs">
<CopyMedia media={media} />
{canDelete && <DeleteMedia media={media} />}
</Group>
</TableTd>
</TableTr>
);
};
@@ -64,7 +64,6 @@ export default async function ManagementPage() {
href: "/manage/apps",
subtitle: t("statisticLabel.resources"),
title: t("statistic.app"),
hidden: !session?.user,
},
{
count: statistics.countGroups,
@@ -1,12 +1,8 @@
"use client";
import Link from "next/link";
import type { SegmentedControlItem } from "@mantine/core";
import { Button, Fieldset, Grid, Group, SegmentedControl, Stack, Textarea, TextInput } from "@mantine/core";
import { WidgetIntegrationSelect } from "node_modules/@homarr/widgets/src/widget-integration-select";
import { Button, Grid, Group, Stack, Textarea, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { searchEngineTypes } from "@homarr/definitions";
import { useZodForm } from "@homarr/form";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
@@ -29,8 +25,6 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
const { submitButtonTranslation, handleSubmit, initialValues, isPending, disableShort } = props;
const t = useI18n();
const [integrationData] = clientApi.integration.allThatSupportSearch.useSuspenseQuery();
const form = useZodForm(validation.searchEngine.manage, {
initialValues: initialValues ?? {
name: "",
@@ -38,7 +32,6 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
iconUrl: "",
urlTemplate: "",
description: "",
type: "generic",
},
});
@@ -59,40 +52,11 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
</Grid.Col>
</Grid>
<IconPicker initialValue={initialValues?.iconUrl} {...form.getInputProps("iconUrl")} />
<Fieldset legend={t("search.engine.page.edit.configControl")}>
<SegmentedControl
data={searchEngineTypes.map(
(type) =>
({
label: t(`search.engine.page.edit.searchEngineType.${type}`),
value: type,
}) satisfies SegmentedControlItem,
)}
{...form.getInputProps("type")}
fullWidth
/>
{form.values.type === "generic" && (
<TextInput
{...form.getInputProps("urlTemplate")}
withAsterisk
label={t("search.engine.field.urlTemplate.label")}
/>
)}
{form.values.type === "fromIntegration" && (
<WidgetIntegrationSelect
label="Integration"
data={integrationData}
canSelectMultiple={false}
onChange={(value) => form.setFieldValue("integrationId", value[0])}
value={form.values.integrationId !== undefined ? [form.values.integrationId] : []}
withAsterisk
/>
)}
</Fieldset>
<TextInput
{...form.getInputProps("urlTemplate")}
withAsterisk
label={t("search.engine.field.urlTemplate.label")}
/>
<Textarea {...form.getInputProps("description")} label={t("search.engine.field.description.label")} />
<Group justify="end">
@@ -1,8 +1,6 @@
import { notFound } from "next/navigation";
import { Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getI18n } from "@homarr/translation/server";
import { ManageContainer } from "~/components/manage/manage-container";
@@ -14,12 +12,6 @@ interface SearchEngineEditPageProps {
}
export default async function SearchEngineEditPage({ params }: SearchEngineEditPageProps) {
const session = await auth();
if (!session?.user.permissions.includes("search-engine-modify-all")) {
notFound();
}
const searchEngine = await api.searchEngine.byId({ id: params.id });
const t = await getI18n();
@@ -1,7 +1,5 @@
import { notFound } from "next/navigation";
import { Stack, Title } from "@mantine/core";
import { auth } from "@homarr/auth/next";
import { getI18n } from "@homarr/translation/server";
import { ManageContainer } from "~/components/manage/manage-container";
@@ -9,12 +7,6 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { SearchEngineNewForm } from "./_search-engine-new-form";
export default async function SearchEngineNewPage() {
const session = await auth();
if (!session?.user.permissions.includes("search-engine-create")) {
notFound();
}
const t = await getI18n();
return (
@@ -1,11 +1,9 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
import { IconPencil, IconSearch } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { SearchInput, TablePagination } from "@homarr/ui";
import { z } from "@homarr/validation";
@@ -13,7 +11,6 @@ import { z } from "@homarr/validation";
import { ManageContainer } from "~/components/manage/manage-container";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NoResults } from "~/components/no-results";
import { SearchEngineDeleteButton } from "./_search-engine-delete-button";
const searchParamsSchema = z.object({
@@ -31,12 +28,6 @@ interface SearchEnginesPageProps {
}
export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
const session = await auth();
if (!session) {
redirect("/auth/login");
}
const searchParams = searchParamsSchema.parse(props.searchParams);
const { items: searchEngines, totalCount } = await api.searchEngine.getPaginated(searchParams);
@@ -49,11 +40,9 @@ export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
<Title>{tEngine("page.list.title")}</Title>
<Group justify="space-between" align="center">
<SearchInput placeholder={`${tEngine("search")}...`} defaultValue={searchParams.search} />
{session.user.permissions.includes("search-engine-create") && (
<MobileAffixButton component={Link} href="/manage/search-engines/new">
{tEngine("page.create.title")}
</MobileAffixButton>
)}
<MobileAffixButton component={Link} href="/manage/search-engines/new">
{tEngine("page.create.title")}
</MobileAffixButton>
</Group>
{searchEngines.length === 0 && <SearchEngineNoResults />}
{searchEngines.length > 0 && (
@@ -78,10 +67,9 @@ interface SearchEngineCardProps {
const SearchEngineCard = async ({ searchEngine }: SearchEngineCardProps) => {
const t = await getScopedI18n("search.engine");
const session = await auth();
return (
<Card withBorder>
<Card>
<Group justify="space-between" wrap="nowrap">
<Group align="top" justify="start" wrap="nowrap" style={{ flex: 1 }}>
<Avatar
@@ -103,34 +91,23 @@ const SearchEngineCard = async ({ searchEngine }: SearchEngineCardProps) => {
{searchEngine.description}
</Text>
)}
{searchEngine.type === "generic" && searchEngine.urlTemplate !== null && (
<Anchor href={searchEngine.urlTemplate.replace("%s", "test")} lineClamp={1} size="sm">
{searchEngine.urlTemplate}
</Anchor>
)}
{searchEngine.type === "fromIntegration" && searchEngine.integrationId !== null && (
<Text c="dimmed" size="sm">
{t("page.list.interactive")}
</Text>
)}
<Anchor href={searchEngine.urlTemplate.replace("%s", "test")} lineClamp={1} size="sm">
{searchEngine.urlTemplate}
</Anchor>
</Stack>
</Group>
<Group>
<ActionIconGroup>
{session?.user.permissions.includes("search-engine-modify-all") && (
<ActionIcon
component={Link}
href={`/manage/search-engines/edit/${searchEngine.id}`}
variant="subtle"
color="gray"
aria-label={t("page.edit.title")}
>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
)}
{session?.user.permissions.includes("search-engine-full-all") && (
<SearchEngineDeleteButton searchEngine={searchEngine} />
)}
<ActionIcon
component={Link}
href={`/manage/search-engines/edit/${searchEngine.id}`}
variant="subtle"
color="gray"
aria-label={t("page.edit.title")}
>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
<SearchEngineDeleteButton searchEngine={searchEngine} />
</ActionIconGroup>
</Group>
</Group>
@@ -140,17 +117,16 @@ const SearchEngineCard = async ({ searchEngine }: SearchEngineCardProps) => {
const SearchEngineNoResults = async () => {
const t = await getI18n();
const session = await auth();
return (
<NoResults
icon={IconSearch}
title={t("search.engine.page.list.noResults.title")}
action={{
label: t("search.engine.page.list.noResults.action"),
href: "/manage/search-engines/new",
hidden: !session?.user.permissions.includes("search-engine-create"),
}}
/>
<Card withBorder bg="transparent">
<Stack align="center" gap="sm">
<IconSearch size="2rem" />
<Text fw={500} size="lg">
{t("search.engine.page.list.noResults.title")}
</Text>
<Anchor href="/manage/search-engines/new">{t("search.engine.page.list.noResults.action")}</Anchor>
</Stack>
</Card>
);
};
@@ -1,52 +0,0 @@
"use client";
import { Group, Text } from "@mantine/core";
import { IconMoon, IconSun } from "@tabler/icons-react";
import type { ColorScheme } from "@homarr/definitions";
import { colorSchemes } from "@homarr/definitions";
import type { ServerSettings } from "@homarr/server-settings";
import { useScopedI18n } from "@homarr/translation/client";
import { SelectWithCustomItems } from "@homarr/ui";
import { CommonSettingsForm } from "./common-form";
export const AppearanceSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["appearance"] }) => {
const tApperance = useScopedI18n("management.page.settings.section.appearance");
return (
<CommonSettingsForm settingKey="appearance" defaultValues={defaultValues}>
{(form) => (
<>
<SelectWithCustomItems
label={tApperance("defaultColorScheme.label")}
data={colorSchemes.map((scheme) => ({
value: scheme,
label: tApperance(`defaultColorScheme.options.${scheme}`),
}))}
{...form.getInputProps("defaultColorScheme")}
SelectOption={ApperanceCustomOption}
/>
</>
)}
</CommonSettingsForm>
);
};
const appearanceIcons = {
light: IconSun,
dark: IconMoon,
};
const ApperanceCustomOption = ({ value, label }: { value: ColorScheme; label: string }) => {
const Icon = appearanceIcons[value];
return (
<Group>
<Icon size={16} stroke={1.5} />
<Text fz="sm" fw={500}>
{label}
</Text>
</Group>
);
};
@@ -1,44 +0,0 @@
"use client";
import { Group, Text } from "@mantine/core";
import { IconLayoutDashboard } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import type { ServerSettings } from "@homarr/server-settings";
import { useScopedI18n } from "@homarr/translation/client";
import { SelectWithCustomItems } from "@homarr/ui";
import { CommonSettingsForm } from "./common-form";
export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["board"] }) => {
const tBoard = useScopedI18n("management.page.settings.section.board");
const [selectableBoards] = clientApi.board.getPublicBoards.useSuspenseQuery();
return (
<CommonSettingsForm settingKey="board" defaultValues={defaultValues}>
{(form) => (
<>
<SelectWithCustomItems
label={tBoard("homeBoard.label")}
description={tBoard("homeBoard.description")}
data={selectableBoards.map((board) => ({
value: board.id,
label: board.name,
image: board.logoImageUrl,
}))}
SelectOption={({ label, image }: { value: string; label: string; image: string | null }) => (
<Group>
{/* eslint-disable-next-line @next/next/no-img-element */}
{image ? <img width={16} height={16} src={image} alt={label} /> : <IconLayoutDashboard size={16} />}
<Text fz="sm" fw={500}>
{label}
</Text>
</Group>
)}
{...form.getInputProps("homeBoardId")}
/>
</>
)}
</CommonSettingsForm>
);
};
@@ -1,57 +0,0 @@
"use client";
import { Button, Group, Stack } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import type { ServerSettings } from "@homarr/server-settings";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
export const CommonSettingsForm = <TKey extends keyof ServerSettings>({
settingKey,
defaultValues,
children,
}: {
settingKey: TKey;
defaultValues: ServerSettings[TKey];
children: (form: ReturnType<typeof useForm<ServerSettings[TKey]>>) => React.ReactNode;
}) => {
const t = useI18n();
const tSettings = useScopedI18n("management.page.settings");
const { mutateAsync, isPending } = clientApi.serverSettings.saveSettings.useMutation({
onSuccess() {
showSuccessNotification({
message: tSettings("notification.success.message"),
});
},
onError() {
showErrorNotification({
message: tSettings("notification.error.message"),
});
},
});
const form = useForm({
initialValues: defaultValues,
});
const handleSubmitAsync = async (values: ServerSettings[TKey]) => {
await mutateAsync({
settingsKey: settingKey,
value: values,
});
};
return (
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack gap="sm">
{children(form)}
<Group justify="end">
<Button type="submit" loading={isPending}>
{t("common.action.save")}
</Button>
</Group>
</Stack>
</form>
);
};
@@ -1,26 +0,0 @@
"use client";
import type { ServerSettings } from "@homarr/server-settings";
import type { SupportedLanguage } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client";
import { LanguageCombobox } from "~/components/language/language-combobox";
import { CommonSettingsForm } from "./common-form";
export const CultureSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["culture"] }) => {
const tCulture = useScopedI18n("management.page.settings.section.culture");
return (
<CommonSettingsForm settingKey="culture" defaultValues={defaultValues}>
{(form) => (
<>
<LanguageCombobox
label={tCulture("defaultLocale.label")}
value={form.getInputProps("defaultLocale").value as SupportedLanguage}
{...form.getInputProps("defaultLocale")}
/>
</>
)}
</CommonSettingsForm>
);
};
@@ -1,16 +1,11 @@
import { notFound } from "next/navigation";
import { Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { CrawlingAndIndexingSettings } from "~/app/[locale]/manage/settings/_components/crawling-and-indexing.settings";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { AnalyticsSettings } from "./_components/analytics.settings";
import { AppearanceSettingsForm } from "./_components/appearance-settings-form";
import { BoardSettingsForm } from "./_components/board-settings-form";
import { CultureSettingsForm } from "./_components/culture-settings-form";
export async function generateMetadata() {
const t = await getScopedI18n("management");
@@ -22,33 +17,15 @@ export async function generateMetadata() {
}
export default async function SettingsPage() {
const session = await auth();
if (!session?.user.permissions.includes("admin")) {
notFound();
}
const serverSettings = await api.serverSettings.getAll();
const tSettings = await getScopedI18n("management.page.settings");
const t = await getScopedI18n("management.page.settings");
return (
<>
<DynamicBreadcrumb />
<Stack>
<Title order={1}>{tSettings("title")}</Title>
<Title order={1}>{t("title")}</Title>
<AnalyticsSettings initialData={serverSettings.analytics} />
<CrawlingAndIndexingSettings initialData={serverSettings.crawlingAndIndexing} />
<Stack>
<Title order={2}>{tSettings("section.board.title")}</Title>
<BoardSettingsForm defaultValues={serverSettings.board} />
</Stack>
<Stack>
<Title order={2}>{tSettings("section.appearance.title")}</Title>
<AppearanceSettingsForm defaultValues={serverSettings.appearance} />
</Stack>
<Stack>
<Title order={2}>{tSettings("section.culture.title")}</Title>
<CultureSettingsForm defaultValues={serverSettings.culture} />
</Stack>
</Stack>
</>
);
@@ -23,7 +23,7 @@ export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => {
const { mutate, isPending } = clientApi.apiKeys.create.useMutation({
async onSuccess(data) {
openModal({
apiKey: data.apiKey,
apiKey: data.randomToken,
});
await revalidatePathActionAsync("/manage/tools/api");
},
@@ -1,6 +1,6 @@
"use client";
import type { OpenAPIObject } from "openapi3-ts/oas31";
import type { OpenAPIV3 } from "openapi-types";
import SwaggerUI from "swagger-ui-react";
// workaround for CSS that cannot be processed by next.js, https://github.com/swagger-api/swagger-ui/issues/10045
@@ -9,7 +9,7 @@ import "../swagger-ui-overrides.css";
import "../swagger-ui.css";
interface SwaggerUIClientProps {
document: OpenAPIObject;
document: OpenAPIV3.Document;
}
export const SwaggerUIClient = ({ document }: SwaggerUIClientProps) => {
@@ -1,27 +0,0 @@
"use client";
import Link from "next/link";
import { Anchor, Center, Stack, Text } from "@mantine/core";
import { IconShipOff } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
export default function DockerErrorPage() {
const t = useI18n();
return (
<Center>
<Stack align="center">
<IconShipOff size={48} stroke={1.5} />
<Stack align="center" gap="xs">
<Text size="lg" fw={500}>
{t("docker.error.internalServerError")}
</Text>
<Anchor size="sm" component={Link} href="/manage/tools/logs">
{t("common.action.checkLogs")}
</Anchor>
</Stack>
</Stack>
</Center>
);
}
@@ -27,7 +27,7 @@ export async function generateMetadata() {
export default async function LogsManagementPage() {
const session = await auth();
if (!session?.user || !session.user.permissions.includes("other-view-logs")) {
if (!session?.user || !session.user.permissions.includes("admin")) {
notFound();
}
@@ -13,7 +13,7 @@ import classes from "./terminal.module.css";
export const TerminalComponent = () => {
const ref = useRef<HTMLDivElement>(null);
const terminalRef = useRef<Terminal>(null);
const terminalRef = useRef<Terminal>();
clientApi.log.subscribe.useSubscription(undefined, {
onData(data) {
terminalRef.current?.writeln(`${data.timestamp} ${data.level} ${data.message}`);
@@ -54,7 +54,7 @@ export const JobsList = ({ initialJobs }: JobsListProps) => {
return (
<Stack>
{jobs.map((job) => (
<Card key={job.job.name} withBorder>
<Card key={job.job.name}>
<Group justify={"space-between"} gap={"md"}>
<Stack gap={0}>
<Group>
@@ -31,9 +31,7 @@ export const DeleteUserButton = ({ user }: DeleteUserButtonProps) => {
children: t("user.action.delete.confirm", { username: user.name }),
// eslint-disable-next-line no-restricted-syntax
async onConfirm() {
await mutateUserDeletionAsync({
userId: user.id,
});
await mutateUserDeletionAsync(user.id);
},
}),
[user, mutateUserDeletionAsync, openConfirmModal, t],
@@ -1,7 +1,6 @@
"use client";
import { Button, Group, Radio, Stack } from "@mantine/core";
import type { DayOfWeek } from "@mantine/dates";
import dayjs from "dayjs";
import localeData from "dayjs/plugin/localeData";
@@ -44,7 +43,7 @@ export const FirstDayOfWeek = ({ user }: FirstDayOfWeekProps) => {
});
const form = useZodForm(validation.user.firstDayOfWeek, {
initialValues: {
firstDayOfWeek: user.firstDayOfWeek as DayOfWeek,
firstDayOfWeek: user.firstDayOfWeek,
},
});
@@ -149,9 +149,6 @@ const fileToBase64Async = async (file: File): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
// The functionality below works as expected and doesn't result in [object Object].
// eslint-disable-next-line @typescript-eslint/no-base-to-string
reader.onload = () => resolve(reader.result?.toString() ?? "");
reader.onerror = reject;
});
@@ -6,9 +6,9 @@ import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
import { LanguageCombobox } from "~/components/language/language-combobox";
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
import { catchTrpcNotFound } from "~/errors/trpc-not-found";
import { createMetaTitle } from "~/metadata";
import { canAccessUserEditPage } from "../access";
import { ChangeHomeBoardForm } from "./_components/_change-home-board";
@@ -81,7 +81,7 @@ export default async function EditUserPage({ params }: Props) {
<Stack mb="lg">
<Title order={2}>{tGeneral("item.language")}</Title>
<CurrentLanguageCombobox />
<LanguageCombobox />
</Stack>
<Stack mb="lg">
@@ -10,7 +10,7 @@ import { UserAvatar } from "@homarr/ui";
import { ManageContainer } from "~/components/manage/manage-container";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
import { catchTrpcNotFound } from "~/errors/trpc-not-found";
import { NavigationLink } from "../groups/[id]/_navigation";
import { canAccessUserEditPage } from "./access";
@@ -5,7 +5,7 @@ import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
import { catchTrpcNotFound } from "~/errors/trpc-not-found";
import { canAccessUserEditPage } from "../access";
import { ChangePasswordForm } from "./_components/_change-password-form";
@@ -1,48 +1,20 @@
"use client";
import { startTransition, useCallback, useMemo, useState } from "react";
import {
Badge,
Button,
Card,
Group,
PasswordInput,
Stack,
Stepper,
Table,
Text,
TextInput,
Title,
Tooltip,
} from "@mantine/core";
import { useListState } from "@mantine/hooks";
import { IconPlus, IconUserCheck } from "@tabler/icons-react";
import { useCallback, useMemo, useState } from "react";
import { Card, PasswordInput, Stack, Stepper, Text, TextInput, Title } from "@mantine/core";
import { IconUserCheck } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { everyoneGroup, groupPermissions } from "@homarr/definitions";
import type { GroupPermissionKey } from "@homarr/definitions";
import { useZodForm } from "@homarr/form";
import { useModalAction } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { useScopedI18n } from "@homarr/translation/client";
import { CustomPasswordInput, UserAvatar } from "@homarr/ui";
import { validation, z } from "@homarr/validation";
import { createCustomErrorParams } from "@homarr/validation/form";
import { GroupSelectModal } from "~/components/access/group-select-modal";
import { StepperNavigationComponent } from "./stepper-navigation";
interface GroupWithPermissions {
id: string;
name: string;
permissions: GroupPermissionKey[];
}
interface UserCreateStepperComponentProps {
initialGroups: GroupWithPermissions[];
}
export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperComponentProps) => {
export const UserCreateStepperComponent = () => {
const t = useScopedI18n("management.page.user.create");
const tUserField = useScopedI18n("user.field");
@@ -88,10 +60,7 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
})
.refine((data) => data.password === data.confirmPassword, {
path: ["confirmPassword"],
params: createCustomErrorParams({
key: "passwordsDoNotMatch",
params: {},
}),
params: createCustomErrorParams("passwordsDoNotMatch"),
}),
{
initialValues: {
@@ -101,18 +70,7 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
},
);
const groupsForm = useZodForm(
z.object({
groups: z.array(z.string()),
}),
{
initialValues: {
groups: initialGroups.map((group) => group.id),
},
},
);
const allForms = useMemo(() => [generalForm, securityForm, groupsForm], [generalForm, securityForm, groupsForm]);
const allForms = useMemo(() => [generalForm, securityForm], [generalForm, securityForm]);
const activeForm = allForms[active];
const isCurrentFormValid = activeForm ? activeForm.isValid : () => true;
@@ -125,11 +83,10 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
email: generalForm.values.email,
password: securityForm.values.password,
confirmPassword: securityForm.values.confirmPassword,
groupIds: groupsForm.values.groups,
});
}
nextStep();
}, [active, generalForm, securityForm, groupsForm, mutateAsync, nextStep]);
}, [active, generalForm, mutateAsync, securityForm, nextStep]);
const reset = useCallback(() => {
setActive(0);
@@ -149,7 +106,7 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
color={!generalForm.isValid() ? "red" : undefined}
>
<form>
<Card p="xl" shadow="md" withBorder>
<Card p="xl">
<Stack gap="md">
<TextInput
label={tUserField("username.label")}
@@ -165,7 +122,7 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
</Stepper.Step>
<Stepper.Step label={t("step.security.label")} allowStepSelect={false} allowStepClick={false}>
<form>
<Card p="xl" shadow="md" withBorder>
<Card p="xl">
<Stack gap="md">
<CustomPasswordInput
withPasswordRequirements
@@ -184,21 +141,16 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
</Card>
</form>
</Stepper.Step>
<Stepper.Step label={t("step.groups.label")} allowStepSelect={false} allowStepClick={false}>
<Card p="xl" shadow="md" withBorder>
<GroupsForm
initialGroups={initialGroups}
addGroup={(groupId) =>
groupsForm.setValues((value) => ({ groups: value.groups?.concat(groupId) ?? [groupId] }))
}
removeGroup={(groupId) => {
groupsForm.setValues((value) => ({ groups: value.groups?.filter((group) => group !== groupId) ?? [] }));
}}
/>
</Card>
<Stepper.Step
label={t("step.permissions.label")}
description={t("step.permissions.description")}
allowStepSelect={false}
allowStepClick={false}
>
3
</Stepper.Step>
<Stepper.Step label={t("step.review.label")} allowStepSelect={false} allowStepClick={false}>
<Card p="xl" shadow="md" withBorder>
<Card p="xl">
<Stack maw={300} align="center" mx="auto">
<UserAvatar size="xl" user={{ name: generalForm.values.username, image: null }} />
<Text tt="uppercase" fw="bolder" size="xl">
@@ -208,7 +160,7 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
</Card>
</Stepper.Step>
<Stepper.Completed>
<Card p="xl" shadow="md" withBorder>
<Card p="xl">
<Stack align="center" maw={300} mx="auto">
<IconUserCheck size="3rem" />
<Title order={2}>{t("step.completed.title")}</Title>
@@ -228,111 +180,3 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
</>
);
};
interface GroupsFormProps {
addGroup: (groupId: string) => void;
removeGroup: (groupId: string) => void;
initialGroups: GroupWithPermissions[];
}
const GroupsForm = ({ addGroup, removeGroup, initialGroups }: GroupsFormProps) => {
const t = useI18n();
const [groups, { append, filter }] = useListState<GroupWithPermissions>(initialGroups);
const { openModal } = useModalAction(GroupSelectModal);
const handleAddClick = () => {
openModal({
presentGroupIds: groups.map((group) => group.id),
withPermissions: true,
onSelect({ id, name, permissions }) {
if (!permissions) return;
startTransition(() => {
addGroup(id);
append({ id, name, permissions });
});
},
});
};
const handleGroupRemove = (id: string) => {
filter((group) => group.id !== id);
removeGroup(id);
};
return (
<form>
<Stack>
<Group justify="space-between">
<Stack gap={0}>
<Text fw={500}>{t("management.page.user.create.step.groups.title")}</Text>
<Text size="sm" c="gray.6">
{t("management.page.user.create.step.groups.description", { everyoneGroup })}
</Text>
</Stack>
<Button
variant="subtle"
color="gray"
leftSection={<IconPlus size={16} stroke={1.5} />}
onClick={handleAddClick}
>
{t("common.action.add")}
</Button>
</Group>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>{t("group.field.name")}</Table.Th>
<Table.Th>{t("permission.title")}</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{groups.map((group) => (
<Table.Tr key={group.id}>
<Table.Td>{group.name}</Table.Td>
<Table.Td w="100%">
<Group gap="xs">
{Object.entries(groupPermissions)
.flatMap(([key, values]) =>
Array.isArray(values)
? values.map((value) => ({ key, value: value as string }))
: [{ key, value: key }],
)
.filter(({ key, value }) =>
group.permissions.some(
(permission) => permission === (key === value ? key : `${key}-${value}`),
),
)
.map(({ key, value }) => (
<PermissionBadge key={`${key}-${value}`} category={key} value={value} />
))}
</Group>
</Table.Td>
<Table.Td>
{group.name !== everyoneGroup && (
<Button variant="subtle" onClick={() => handleGroupRemove(group.id)}>
{t("common.action.remove")}
</Button>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Stack>
</form>
);
};
const PermissionBadge = ({ category, value }: { category: string; value: string }) => {
const t = useI18n();
return (
<Tooltip label={t(`group.permission.${category}.item.${value}.description` as never)}>
<Badge color={category === "admin" ? "red" : "blue"} size="sm" variant="dot">
{t(`group.permission.${category}.item.${value}.label` as never)}
</Badge>
</Tooltip>
);
};
@@ -2,9 +2,6 @@ import { notFound } from "next/navigation";
import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server";
import { db, inArray } from "@homarr/db";
import { groups } from "@homarr/db/schema";
import { everyoneGroup } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
@@ -36,22 +33,10 @@ export default async function CreateUserPage() {
return notFound();
}
const initialGroups = await db.query.groups.findMany({
where: inArray(groups.name, [everyoneGroup]),
with: {
permissions: true,
},
});
return (
<>
<DynamicBreadcrumb />
<UserCreateStepperComponent
initialGroups={initialGroups.map((group) => ({
...group,
permissions: group.permissions.map(({ permission }) => permission),
}))}
/>
<UserCreateStepperComponent />
</>
);
}
@@ -2,7 +2,7 @@
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import { Button, useMatches } from "@mantine/core";
import { Button } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
@@ -61,14 +61,8 @@ export const DeleteGroup = ({ group }: DeleteGroupProps) => {
});
}, [tDelete, tRoot, openConfirmModal, group.id, group.name, mutateAsync, router]);
const fullWidth = useMatches({
xs: true,
sm: true,
md: false,
});
return (
<Button variant="subtle" color="red" onClick={handleDeletion} fullWidth={fullWidth}>
<Button variant="subtle" color="red" onClick={handleDeletion}>
{tDelete("label")}
</Button>
);
Loaded 100 of 594 files, more files were not shown because too many files have changed in this diff. Show more