Compare commits

..
407 changed files with 9733 additions and 100693 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,40 +0,0 @@
name: "[Crowdin] Download translations"
on:
workflow_dispatch:
schedule:
- cron: "0 0 * * *" # every day at midnight
jobs:
download-crowdin-translations:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Obtain token
id: obtainToken
uses: tibdex/github-app-token@v2
with:
private_key: ${{ secrets.CROWDIN_APP_PRIVATE_KEY }}
app_id: ${{ secrets.CROWDIN_APP_ID }}
- name: Download Crowdin translations
uses: crowdin/github-action@v2
with:
upload_sources: false
upload_translations: false
download_translations: true
localization_branch_name: crowdin
create_pull_request: true
pull_request_title: "chore(lang): updated translations from crowdin"
pull_request_body: "New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)"
pull_request_base_branch_name: "dev"
github_user_name: "Crowdin Homarr"
github_user_email: "190541745+homarr-crowdin[bot]@users.noreply.github.com"
skip_untranslated_strings: true
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
-28
View File
@@ -1,28 +0,0 @@
name: "[Crowdin] Upload translations"
on:
workflow_dispatch:
push:
paths:
- "packages/translation/src/lang/**"
branches: [dev]
jobs:
upload-crowdin-translations:
# Don't run this action if the downloaded translations are being pushed
if: "!contains(github.event.head_commit.message, 'chore(lang)')"
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Upload Crowdin translations
uses: crowdin/github-action@v2
with:
upload_sources: true
upload_translations: true
download_translations: false
env:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
+21 -4
View File
@@ -33,6 +33,9 @@ jobs:
deploy:
name: Deploy docker image
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20]
steps:
- name: Discord notification
if: ${{ github.events.inputs.send-notifications != false }}
@@ -54,7 +57,21 @@ jobs:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
with:
args: "Semver computed next tag to be ${{ steps.semver.outputs.next }}. Current is ${{ steps.semver.outputs.current }}. Building images..."
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:
@@ -73,9 +90,9 @@ jobs:
tags: |
type=raw,value=alpha
type=raw,value=early-adopters
# tags: |
# type=raw,value=latest
# type=raw,value=${{ steps.semver.outputs.next }}
# tags: |
# type=raw,value=latest
# type=raw,value=${{ steps.semver.outputs.next }}
- name: Build and push
id: buildPushAction
uses: docker/build-push-action@v6
+1 -1
View File
@@ -1 +1 @@
22.12.0
20.18.0
+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 -8
View File
@@ -27,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"
}
+45 -12
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'
@@ -28,6 +59,8 @@ WORKDIR /app
# gettext is required for envsubst
RUN apk add --no-cache redis nginx bash gettext
RUN mkdir /appdata
RUN mkdir /appdata/db
RUN mkdir /appdata/redis
VOLUME /appdata
RUN mkdir /secrets
VOLUME /secrets
@@ -38,7 +71,7 @@ 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
@@ -52,20 +85,20 @@ RUN mkdir -p /var/cache/nginx && chown -R nextjs:nodejs /var/cache/nginx && \
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 --chown=nextjs:nodejs scripts/generateEncryptionKey.js ./generateEncryptionKey.js
COPY --chown=nextjs:nodejs packages/redis/redis.conf /app/redis.conf
+2 -6
View File
@@ -2,15 +2,11 @@
import "@homarr/auth/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 */
@@ -38,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;
+23 -23
View File
@@ -23,7 +23,7 @@
"@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",
@@ -37,17 +37,17 @@
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0",
"@mantine/colors-generator": "^7.14.3",
"@mantine/core": "^7.14.3",
"@mantine/hooks": "^7.14.3",
"@mantine/modals": "^7.14.3",
"@mantine/tiptap": "^7.14.3",
"@million/lint": "1.0.13",
"@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.24.0",
"@tanstack/react-query": "^5.62.2",
"@tanstack/react-query-devtools": "^5.62.2",
"@tanstack/react-query-next-experimental": "5.62.2",
"@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",
@@ -58,21 +58,21 @@
"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.3",
"jotai": "^2.10.1",
"mantine-react-table": "2.0.0-beta.7",
"next": "^14.2.20",
"next": "^14.2.16",
"postcss-preset-mantine": "^1.17.0",
"prismjs": "^1.29.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-error-boundary": "^4.1.2",
"react-simple-code-editor": "^0.14.1",
"sass": "^1.82.0",
"sass": "^1.80.4",
"superjson": "2.2.1",
"swagger-ui-react": "^5.18.2",
"swagger-ui-react": "^5.17.14",
"use-deep-compare-effect": "^1.8.1"
},
"devDependencies": {
@@ -80,15 +80,15 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/chroma-js": "2.4.4",
"@types/node": "^22.10.1",
"@types/node": "^20.17.1",
"@types/prismjs": "^1.26.5",
"@types/react": "^18.3.13",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/swagger-ui-react": "^4.18.3",
"concurrently": "^9.1.0",
"eslint": "^9.16.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 };
@@ -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>
);
};
@@ -17,21 +17,12 @@ import {
import superjson 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;
}
@@ -40,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({
@@ -86,13 +77,16 @@ export function TRPCReactProvider(props: PropsWithChildren) {
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;
},
}),
}),
}),
@@ -109,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}`;
}
@@ -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) => {
@@ -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,7 +63,6 @@ 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 } = await getBoardPermissionsAsync(board);
const t = await getScopedI18n("board.setting");
@@ -95,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>
</>
)}
@@ -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
+15 -20
View File
@@ -6,22 +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 { 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";
@@ -31,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.",
@@ -51,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",
},
});
@@ -63,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) => {
@@ -78,16 +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} />,
]);
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={{
@@ -108,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,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,7 +45,6 @@ interface AppCardProps {
const AppCard = async ({ app }: AppCardProps) => {
const t = await getScopedI18n("app");
const session = await auth();
return (
<Card>
@@ -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>
);
};
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
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";
@@ -22,6 +23,7 @@ interface SecretCardProps {
}
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);
@@ -43,7 +45,7 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
<Group>
<Text c="gray.6" size="sm">
{t("integration.secrets.lastUpdated", {
date: dayjs().to(dayjs(secret.updatedAt)),
date: dayjs().locale(params.locale).to(dayjs(secret.updatedAt)),
})}
</Text>
{isPublic ? (
@@ -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,7 +67,6 @@ interface SearchEngineCardProps {
const SearchEngineCard = async ({ searchEngine }: SearchEngineCardProps) => {
const t = await getScopedI18n("search.engine");
const session = await auth();
return (
<Card>
@@ -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>
);
};
@@ -6,9 +6,6 @@ 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");
@@ -21,26 +18,14 @@ export async function generateMetadata() {
export default async function SettingsPage() {
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) => {
@@ -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();
}
@@ -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,
},
});
@@ -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);
@@ -184,18 +141,13 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
</Card>
</form>
</Stepper.Step>
<Stepper.Step label={t("step.groups.label")} allowStepSelect={false} allowStepClick={false}>
<Card p="xl">
<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">
@@ -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/sqlite";
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>
);
@@ -58,7 +58,7 @@ export const RenameGroupForm = ({ group, disabled }: RenameGroupFormProps) => {
},
);
},
[group.id, mutate, t, disabled],
[group.id, mutate, t],
);
return (
@@ -1,19 +1,17 @@
"use client";
import Link from "next/link";
import { Alert, Anchor } from "@mantine/core";
import { IconExclamationCircle } from "@tabler/icons-react";
import { createDocumentationLink } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { getI18n } from "@homarr/translation/server";
export const ReservedGroupAlert = () => {
const t = useI18n();
export const ReservedGroupAlert = async () => {
const t = await getI18n();
return (
<Alert variant="light" color="yellow" icon={<IconExclamationCircle size="1rem" stroke={1.5} />}>
{t.rich("group.reservedNotice.message", {
checkoutDocs: () => (
{t("group.reservedNotice.message", {
checkoutDocs: (
<Anchor
size="sm"
component={Link}
@@ -1,10 +1,9 @@
"use client";
import { useCallback, useState } from "react";
import { Button, useMatches } from "@mantine/core";
import { Button } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
@@ -47,7 +46,7 @@ export const TransferGroupOwnership = ({ group }: TransferGroupOwnershipProps) =
userId: id,
},
{
async onSuccess() {
onSuccess() {
setInnerOwnerId(id);
showSuccessNotification({
title: tRoot("common.notification.transfer.success"),
@@ -56,7 +55,6 @@ export const TransferGroupOwnership = ({ group }: TransferGroupOwnershipProps) =
user: name,
}),
});
await revalidatePathActionAsync(`/manage/users/groups/${group.id}`);
},
onError() {
showErrorNotification({
@@ -76,14 +74,8 @@ export const TransferGroupOwnership = ({ group }: TransferGroupOwnershipProps) =
);
}, [group.id, group.name, innerOwnerId, mutateAsync, openConfirmModal, openModal, tRoot, tTransfer]);
const fullWidth = useMatches({
xs: true,
sm: true,
md: false,
});
return (
<Button variant="subtle" color="red" onClick={handleTransfer} fullWidth={fullWidth}>
<Button variant="subtle" color="red" onClick={handleTransfer}>
{tTransfer("label")}
</Button>
);
@@ -32,7 +32,6 @@ export const AddGroupMember = ({ groupId, presentUserIds }: AddGroupMemberProps)
await revalidatePathActionAsync(`/manage/users/groups/${groupId}}/members`);
},
presentUserIds,
excludeExternalProviders: true,
},
{
title: tMembersAdd("label"),
@@ -1,9 +1,8 @@
import { Card, Group, Stack, Text, Title } from "@mantine/core";
import { Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import { everyoneGroup } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { UserAvatar } from "@homarr/ui";
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
import { DeleteGroup } from "./_delete-group";
@@ -31,30 +30,6 @@ export default async function GroupsDetailPage({ params }: GroupsDetailPageProps
<RenameGroupForm group={group} disabled={isReserved} />
<Title order={2}>{tGeneral("owner")}</Title>
<Card>
{group.owner ? (
<Group>
<UserAvatar user={{ name: group.owner.name, image: group.owner.image }} size={"lg"} />
<Stack align={"start"} gap={3}>
<Text fw={"bold"}>{group.owner.name}</Text>
<Text>{group.owner.email}</Text>
<Text c={"dimmed"} size={"sm"}>
{tGeneral("ownerOfGroup")}
</Text>
</Stack>
</Group>
) : (
<Group>
<Stack align={"start"} gap={3}>
<Text c={"dimmed"} size={"sm"}>
{tGeneral("ownerOfGroupDeleted")}
</Text>
</Stack>
</Group>
)}
</Card>
{!isReserved && (
<DangerZoneRoot>
<DangerZoneItem
@@ -45,7 +45,9 @@ export const PermissionForm = ({ children, initialPermissions }: PropsWithChildr
);
};
type FormType = Record<GroupPermissionKey, boolean>;
type FormType = {
[key in GroupPermissionKey]: boolean;
};
export const PermissionSwitch = ({ name }: { name: GroupPermissionKey }) => {
const form = useFormContext();
@@ -11,9 +11,13 @@ import { useModalAction } from "@homarr/modals";
import { showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import type { BoardItemAdvancedOptions } from "@homarr/validation";
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import {
loadWidgetDynamic,
reduceWidgetOptionsWithDefaultValues,
WidgetEditModal,
widgetImports,
} from "@homarr/widgets";
import { WidgetError } from "@homarr/widgets/errors";
import { WidgetEditModal } from "@homarr/widgets/modals";
import type { Dimensions } from "./_dimension-modal";
import { PreviewDimensionsModal } from "./_dimension-modal";
+10 -47
View File
@@ -1,26 +1,15 @@
import { headers } from "next/headers";
import { userAgent } from "next/server";
import type { NextRequest } from "next/server";
import { createOpenApiFetchHandler } from "trpc-to-openapi";
import { createOpenApiFetchHandler } from "trpc-swagger/build/index.mjs";
import { appRouter, createTRPCContext } from "@homarr/api";
import { hashPasswordAsync } from "@homarr/auth";
import type { Session } from "@homarr/auth";
import { createSessionAsync } from "@homarr/auth/server";
import { db, eq } from "@homarr/db";
import { apiKeys } from "@homarr/db/schema/sqlite";
import { logger } from "@homarr/log";
const handlerAsync = async (req: NextRequest) => {
const handlerAsync = async (req: Request) => {
const apiKeyHeaderValue = req.headers.get("ApiKey");
const ipAddress = req.ip ?? headers().get("x-forwarded-for");
const { ua } = userAgent(req);
const session: Session | null = await getSessionOrDefaultFromHeadersAsync(apiKeyHeaderValue, ipAddress, ua);
// Fallback to JSON if no content type is set
if (!req.headers.has("Content-Type")) {
req.headers.set("Content-Type", "application/json");
}
const session: Session | null = await getSessionOrDefaultFromHeadersAsync(apiKeyHeaderValue);
return createOpenApiFetchHandler({
req,
@@ -30,11 +19,7 @@ const handlerAsync = async (req: NextRequest) => {
});
};
const getSessionOrDefaultFromHeadersAsync = async (
apiKeyHeaderValue: string | null,
ipAdress: string | null,
userAgent: string,
): Promise<Session | null> => {
const getSessionOrDefaultFromHeadersAsync = async (apiKeyHeaderValue: string | null): Promise<Session | null> => {
logger.info(
`Creating OpenAPI fetch handler for user ${apiKeyHeaderValue ? "with an api key" : "without an api key"}`,
);
@@ -43,21 +28,12 @@ const getSessionOrDefaultFromHeadersAsync = async (
return null;
}
const [apiKeyId, apiKey] = apiKeyHeaderValue.split(".");
if (!apiKeyId || !apiKey) {
logger.warn(
`An attempt to authenticate over API has failed due to invalid API key format ip='${ipAdress}' userAgent='${userAgent}'`,
);
return null;
}
const apiKeyFromDb = await db.query.apiKeys.findFirst({
where: eq(apiKeys.id, apiKeyId),
where: eq(apiKeys.apiKey, apiKeyHeaderValue),
columns: {
id: true,
apiKey: true,
salt: true,
apiKey: false,
salt: false,
},
with: {
user: {
@@ -71,15 +47,8 @@ const getSessionOrDefaultFromHeadersAsync = async (
},
});
if (!apiKeyFromDb) {
logger.warn(`An attempt to authenticate over API has failed ip='${ipAdress}' userAgent='${userAgent}'`);
return null;
}
const hashedApiKey = await hashPasswordAsync(apiKey, apiKeyFromDb.salt);
if (apiKeyFromDb.apiKey !== hashedApiKey) {
logger.warn(`An attempt to authenticate over API has failed ip='${ipAdress}' userAgent='${userAgent}'`);
if (apiKeyFromDb === undefined) {
logger.warn("An attempt to authenticate over API has failed");
return null;
}
@@ -87,10 +56,4 @@ const getSessionOrDefaultFromHeadersAsync = async (
return await createSessionAsync(db, apiKeyFromDb.user);
};
export {
handlerAsync as GET,
handlerAsync as POST,
handlerAsync as PUT,
handlerAsync as DELETE,
handlerAsync as PATCH,
};
export { handlerAsync as GET, handlerAsync as POST };
@@ -5,23 +5,10 @@ import type { SupportedAuthProvider } from "@homarr/definitions";
import { logger } from "@homarr/log";
export const GET = async (req: NextRequest) => {
return await createHandlers(extractProvider(req), isSecureCookieEnabled(req)).handlers.GET(reqWithTrustedOrigin(req));
return await createHandlers(extractProvider(req)).handlers.GET(reqWithTrustedOrigin(req));
};
export const POST = async (req: NextRequest) => {
return await createHandlers(extractProvider(req), isSecureCookieEnabled(req)).handlers.POST(
reqWithTrustedOrigin(req),
);
};
/**
* wheter to use secure cookies or not, is only supported for https.
* For http it will not add the cookie as it is not considered secure.
* @param req request containing the url
* @returns true if the request is https, false otherwise
*/
const isSecureCookieEnabled = (req: NextRequest): boolean => {
const url = new URL(req.url);
return url.protocol === "https:";
return await createHandlers(extractProvider(req)).handlers.POST(reqWithTrustedOrigin(req));
};
/**
@@ -1,29 +0,0 @@
import { notFound } from "next/navigation";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { db, eq } from "@homarr/db";
import { medias } from "@homarr/db/schema/sqlite";
export async function GET(_req: NextRequest, { params }: { params: { id: string } }) {
const image = await db.query.medias.findFirst({
where: eq(medias.id, params.id),
columns: {
content: true,
contentType: true,
},
});
if (!image) {
notFound();
}
const headers = new Headers();
headers.set("Content-Type", image.contentType);
headers.set("Content-Length", image.content.length.toString());
return new NextResponse(image.content, {
status: 200,
headers,
});
}
@@ -2,15 +2,13 @@ import { useState } from "react";
import { Button, Group, Loader, Select, Stack } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { GroupPermissionKey } from "@homarr/definitions";
import { useForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
interface InnerProps {
withPermissions?: boolean;
presentGroupIds: string[];
onSelect: (props: { id: string; name: string; permissions?: GroupPermissionKey[] }) => void | Promise<void>;
onSelect: (props: { id: string; name: string }) => void | Promise<void>;
confirmLabel?: string;
}
@@ -20,9 +18,7 @@ interface GroupSelectFormType {
export const GroupSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
const t = useI18n();
const { data: groups, isPending } = clientApi.group.selectable.useQuery({
withPermissions: innerProps.withPermissions,
});
const { data: groups, isPending } = clientApi.group.selectable.useQuery();
const [loading, setLoading] = useState(false);
const form = useForm<GroupSelectFormType>();
const handleSubmitAsync = async (values: GroupSelectFormType) => {
@@ -32,7 +28,6 @@ export const GroupSelectModal = createModal<InnerProps>(({ actions, innerProps }
await innerProps.onSelect({
id: currentGroup.id,
name: currentGroup.name,
permissions: "permissions" in currentGroup ? (currentGroup.permissions as GroupPermissionKey[]) : undefined,
});
setLoading(false);
@@ -12,7 +12,6 @@ import { UserAvatar } from "@homarr/ui";
interface InnerProps {
presentUserIds: string[];
excludeExternalProviders?: boolean;
onSelect: (props: { id: string; name: string; image: string }) => void | Promise<void>;
confirmLabel?: string;
}
@@ -23,9 +22,7 @@ interface UserSelectFormType {
export const UserSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
const t = useI18n();
const { data: users, isPending } = clientApi.user.selectable.useQuery({
excludeExternalProviders: innerProps.excludeExternalProviders,
});
const { data: users, isPending } = clientApi.user.selectable.useQuery();
const [loading, setLoading] = useState(false);
const form = useForm<UserSelectFormType>();
const handleSubmitAsync = async (values: UserSelectFormType) => {
@@ -5,8 +5,7 @@ import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } f
import { clientApi } from "@homarr/api/client";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { widgetImports } from "@homarr/widgets";
import { WidgetEditModal } from "@homarr/widgets/modals";
import { WidgetEditModal, widgetImports } from "@homarr/widgets";
import type { Item } from "~/app/[locale]/boards/_types";
import { useEditMode } from "~/app/[locale]/boards/(content)/_context";
@@ -1,54 +0,0 @@
import type { RouterOutputs } from "@homarr/api";
import type { CategorySection } from "~/app/[locale]/boards/_types";
export interface MoveCategoryInput {
id: string;
direction: "up" | "down";
}
export const moveCategoryCallback =
(input: MoveCategoryInput) =>
(previous: RouterOutputs["board"]["getHomeBoard"]): RouterOutputs["board"]["getHomeBoard"] => {
const currentCategory = previous.sections.find(
(section): section is CategorySection => section.kind === "category" && section.id === input.id,
);
if (!currentCategory) {
return previous;
}
if (currentCategory.yOffset === 1 && input.direction === "up") {
return previous;
}
if (currentCategory.yOffset === previous.sections.length - 2 && input.direction === "down") {
return previous;
}
return {
...previous,
sections: previous.sections.map((section) => {
if (section.kind !== "category" && section.kind !== "empty") {
return section;
}
const offset = input.direction === "up" ? -2 : 2;
// Move category and empty section
if (section.yOffset === currentCategory.yOffset || section.yOffset === currentCategory.yOffset + 1) {
return {
...section,
yOffset: section.yOffset + offset,
};
}
if (
section.yOffset === currentCategory.yOffset + offset ||
section.yOffset === currentCategory.yOffset + offset + 1
) {
return {
...section,
yOffset: section.yOffset - offset,
};
}
return section;
}),
};
};
@@ -1,76 +0,0 @@
import { describe, expect, test } from "vitest";
import type { Section } from "~/app/[locale]/boards/_types";
import { moveCategoryCallback } from "../move-category";
describe("Move Category", () => {
test.each([
[3, [0, 3, 4, 1, 2, 5, 6]],
[5, [0, 1, 2, 5, 6, 3, 4]],
])("should move category up", (moveId, expectedOrder) => {
const sections = createSections(3);
const input = {
id: moveId.toString(),
direction: "up" as const,
};
const result = moveCategoryCallback(input)({ sections } as never);
expect(sortSections(result.sections).map((section) => parseInt(section.id, 10))).toEqual(expectedOrder);
});
test.each([
[1, [0, 3, 4, 1, 2, 5, 6]],
[3, [0, 1, 2, 5, 6, 3, 4]],
])("should move category down", (moveId, expectedOrder) => {
const sections = createSections(3);
const input = {
id: moveId.toString(),
direction: "down" as const,
};
const result = moveCategoryCallback(input)({ sections } as never);
expect(sortSections(result.sections).map((section) => parseInt(section.id, 10))).toEqual(expectedOrder);
});
test("should not move category up if it is at the top", () => {
const sections = createSections(3);
const input = {
id: "1",
direction: "up" as const,
};
const result = moveCategoryCallback(input)({ sections } as never);
expect(sortSections(result.sections).map((section) => parseInt(section.id, 10))).toEqual([0, 1, 2, 3, 4, 5, 6]);
});
test("should not move category down if it is at the bottom", () => {
const sections = createSections(3);
const input = {
id: "5",
direction: "down" as const,
};
const result = moveCategoryCallback(input)({ sections } as never);
expect(sortSections(result.sections).map((section) => parseInt(section.id, 10))).toEqual([0, 1, 2, 3, 4, 5, 6]);
});
});
const createSections = (categoryCount: number) => {
return Array.from({ length: categoryCount * 2 + 1 }, (_, index) => ({
id: index.toString(),
kind: index % 2 === 1 ? ("category" as const) : ("empty" as const),
name: `Category ${index}`,
yOffset: index,
xOffset: 0,
items: [],
})) satisfies Section[];
};
const sortSections = (sections: Section[]) => {
return sections.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
};
@@ -4,8 +4,6 @@ import { createId } from "@homarr/db/client";
import type { CategorySection, EmptySection, Section } from "~/app/[locale]/boards/_types";
import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client";
import type { MoveCategoryInput } from "./actions/move-category";
import { moveCategoryCallback } from "./actions/move-category";
interface AddCategory {
name: string;
@@ -17,6 +15,11 @@ interface RenameCategory {
name: string;
}
interface MoveCategory {
id: string;
direction: "up" | "down";
}
interface RemoveCategory {
id: string;
}
@@ -125,8 +128,52 @@ export const useCategoryActions = () => {
);
const moveCategory = useCallback(
(input: MoveCategoryInput) => {
updateBoard(moveCategoryCallback(input));
({ id, direction }: MoveCategory) => {
updateBoard((previous) => {
const currentCategory = previous.sections.find(
(section): section is CategorySection => section.kind === "category" && section.id === id,
);
if (!currentCategory) return previous;
if (currentCategory.yOffset === 1 && direction === "up") return previous;
if (currentCategory.yOffset === previous.sections.length - 2 && direction === "down") return previous;
return {
...previous,
sections: previous.sections.map((section) => {
if (section.kind !== "category" && section.kind !== "empty") return section;
const offset = direction === "up" ? -2 : 2;
// Move category and empty section
if (section.yOffset === currentCategory.yOffset || section.yOffset - 1 === currentCategory.yOffset) {
return {
...section,
yOffset: section.yOffset + offset,
};
}
if (
direction === "up" &&
(section.yOffset === currentCategory.yOffset - 2 || section.yOffset === currentCategory.yOffset - 1)
) {
return {
...section,
position: section.yOffset + 2,
};
}
if (
direction === "down" &&
(section.yOffset === currentCategory.yOffset + 2 || section.yOffset === currentCategory.yOffset + 3)
) {
return {
...section,
position: section.yOffset - 2,
};
}
return section;
}),
};
});
},
[updateBoard],
);
@@ -2,6 +2,7 @@ import { Box, Card } from "@mantine/core";
import type { DynamicSection } from "~/app/[locale]/boards/_types";
import { useRequiredBoard } from "~/app/[locale]/boards/(content)/_context";
import { DynamicSectionProvider } from "./dynamic/dynamic-context";
import { BoardDynamicSectionMenu } from "./dynamic/dynamic-menu";
import { GridStack } from "./gridstack/gridstack";
import classes from "./item.module.css";
@@ -12,6 +13,7 @@ interface Props {
export const BoardDynamicSection = ({ section }: Props) => {
const board = useRequiredBoard();
return (
<Box className="grid-stack-item-content">
<Card
@@ -27,7 +29,9 @@ export const BoardDynamicSection = ({ section }: Props) => {
}}
p={0}
>
<GridStack section={section} className="min-row" />
<DynamicSectionProvider section={section}>
<GridStack section={section} className="min-row" />
</DynamicSectionProvider>
</Card>
<BoardDynamicSectionMenu section={section} />
</Box>
@@ -0,0 +1,54 @@
"use client";
import type { PropsWithChildren } from "react";
import { createContext, useContext } from "react";
import type { DynamicSection } from "~/app/[locale]/boards/_types";
import { useRequiredBoard } from "~/app/[locale]/boards/(content)/_context";
interface DynamicSectionContextProps {
sectionIds: string[];
}
const DynamicSectionContext = createContext<DynamicSectionContextProps>({
sectionIds: [],
});
interface DynamicSectionProviderProps {
section: DynamicSection;
}
export const DynamicSectionProvider = ({ section, children }: PropsWithChildren<DynamicSectionProviderProps>) => {
const aboveIds = useAboveDynamicSectionIds();
const resetPrevious = usePlacedTopRightAndInDynamicSection({ sectionOrItem: section, previousId: aboveIds.at(-1) });
return (
<DynamicSectionContext.Provider value={{ sectionIds: resetPrevious ? [section.id] : [...aboveIds, section.id] }}>
{children}
</DynamicSectionContext.Provider>
);
};
export const useAboveDynamicSectionIds = () => {
return useContext(DynamicSectionContext).sectionIds;
};
interface UsePlacedTopRightAndInDynamicSectionProps {
sectionOrItem: { width: number; xOffset: number; yOffset: number };
previousId: string | undefined;
}
export const usePlacedTopRightAndInDynamicSection = ({
sectionOrItem,
previousId,
}: UsePlacedTopRightAndInDynamicSectionProps) => {
const board = useRequiredBoard();
const aboveSection = board.sections.find(
(section): section is DynamicSection => section.id === previousId && section.kind === "dynamic",
);
// When not on top, no above section or not reaching the end of the above section, we want to reset the previous sections
return (
sectionOrItem.yOffset >= 1 || !aboveSection || sectionOrItem.xOffset + sectionOrItem.width !== aboveSection.width
);
};
@@ -1,39 +1,144 @@
import { ActionIcon, Menu } from "@mantine/core";
import type { PropsWithChildren } from "react";
import { Fragment, useEffect, useRef } from "react";
import { ActionIcon, Group, Indicator, Menu, Popover, Stack, UnstyledButton } from "@mantine/core";
import { useHover } from "@mantine/hooks";
import { IconDotsVertical, IconTrash } from "@tabler/icons-react";
import { useConfirmModal } from "@homarr/modals";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { DynamicSection } from "~/app/[locale]/boards/_types";
import { useEditMode } from "~/app/[locale]/boards/(content)/_context";
import { useEditMode, useRequiredBoard } from "~/app/[locale]/boards/(content)/_context";
import { useDynamicSectionActions } from "./dynamic-actions";
import { useAboveDynamicSectionIds } from "./dynamic-context";
export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSection }) => {
const dropdownRef = useRef<HTMLDivElement>(null);
const [isEditMode] = useEditMode();
const aboveIds = useAboveDynamicSectionIds();
const board = useRequiredBoard();
const hasItemInTopRightCorner =
section.items.some((item) => item.xOffset + item.width === section.width && item.yOffset === 0) ||
board.sections.some(
(innerSection) =>
innerSection.kind === "dynamic" &&
innerSection.parentSectionId === section.id &&
innerSection.xOffset + innerSection.width == section.width &&
innerSection.yOffset == 0,
);
if (!isEditMode) return null;
if (aboveIds.length >= 1 && !hasItemInTopRightCorner) {
return (
<Popover width="auto" position="right" withArrow shadow="md" returnFocus>
<Popover.Target popupType="menu">
<UnstyledButton
pos="absolute"
top={4}
right={4}
style={{ zIndex: 10 }}
onKeyDown={(event) => {
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault();
document.querySelectorAll<HTMLButtonElement>("[data-section-item]")[0]?.focus();
}
}}
>
<Indicator label={aboveIds.length + 1} styles={{ indicator: { height: "1rem" } }} offset={4}>
<ActionIcon component="div" variant="default" radius={"xl"}>
<IconDotsVertical size={"1rem"} />
</ActionIcon>
</Indicator>
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown
ref={dropdownRef}
role="menu"
aria-orientation="vertical"
tabIndex={-1}
onKeyDown={(event) => {
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault();
document.querySelectorAll<HTMLButtonElement>("[data-section-item]")[0]?.focus();
}
}}
>
<div tabIndex={-1} data-autofocus data-mantine-stop-propagation style={{ outline: 0 }} />
<Stack>
{[...aboveIds, section.id].map((id, index) => (
<Fragment key={id}>
<Group hiddenFrom="md"></Group>
<DesktopButton id={id} index={index} />
</Fragment>
))}
</Stack>
</Popover.Dropdown>
</Popover>
);
}
if (aboveIds.length >= 1 && !hasItemInTopRightCorner) {
return (
<InnerMenu id={section.id}>
<ActionIcon variant="default" radius={"xl"} pos="absolute" top={4} right={4} style={{ zIndex: 10 }}>
<IconDotsVertical size={"1rem"} />
</ActionIcon>
</InnerMenu>
);
}
return null;
};
interface DesktopButtonProps {
index: number;
id: string;
}
const DesktopButton = ({ index, id }: DesktopButtonProps) => {
const { hovered, ref } = useHover<HTMLButtonElement>();
useEffect(() => {
const sectionRef = document.querySelector<HTMLDivElement>(`[data-section-id="${id}"]`);
if (!sectionRef) return;
if (hovered) {
const previousBackground = sectionRef.style.backgroundColor;
sectionRef.style.backgroundColor = "rgba(255, 0, 0, 0.1)";
return () => {
sectionRef.style.backgroundColor = previousBackground;
};
}
}, [hovered]);
return (
<InnerMenu id={id} trigger="hover">
<UnstyledButton data-section-item visibleFrom="md" ref={ref}>
Section {index + 1}
</UnstyledButton>
</InnerMenu>
);
};
const InnerMenu = ({ children, id, trigger }: PropsWithChildren<{ id: string; trigger?: "hover" }>) => {
const t = useI18n();
const tDynamic = useScopedI18n("section.dynamic");
const { removeDynamicSection } = useDynamicSectionActions();
const { openConfirmModal } = useConfirmModal();
const [isEditMode] = useEditMode();
if (!isEditMode) return null;
const openRemoveModal = () => {
openConfirmModal({
title: tDynamic("remove.title"),
children: tDynamic("remove.message"),
onConfirm: () => {
removeDynamicSection({ id: section.id });
removeDynamicSection({ id });
},
});
};
return (
<Menu withinPortal withArrow position="right-start" arrowPosition="center">
<Menu.Target>
<ActionIcon variant="default" radius={"xl"} pos="absolute" top={4} right={4} style={{ zIndex: 10 }}>
<IconDotsVertical size={"1rem"} />
</ActionIcon>
</Menu.Target>
<Menu withinPortal withArrow position="right-start" arrowPosition="center" trigger={trigger}>
<Menu.Target>{children}</Menu.Target>
<Menu.Dropdown miw={128}>
<Menu.Label c="red.6">{t("common.dangerZone")}</Menu.Label>
<Menu.Item c="red.6" leftSection={<IconTrash size={16} />} onClick={openRemoveModal}>
@@ -71,6 +71,15 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
? section.width
: board.columnCount;
useCssVariableConfiguration({
columnCount,
gridRef,
wrapperRef,
width,
height,
isDynamic: section.kind === "dynamic",
});
const itemRefKeys = Object.keys(itemRefs.current);
// define items in itemRefs for easy access and reference to items
if (itemRefKeys.length !== itemIds.length) {
@@ -86,6 +95,11 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
});
}
// Toggle the gridstack to be static or not based on the edit mode
useEffect(() => {
gridRef.current?.setStatic(!isEditMode);
}, [isEditMode]);
const onChange = useCallback(
(changedNode: GridStackNode) => {
const id = changedNode.el?.getAttribute("data-id");
@@ -244,40 +258,14 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
};
}, [isEditMode, onAdd, onChange]);
/**
* IMPORTANT: This effect has to be placed after the effect to initialize the gridstack
* because we need the gridstack object to add the listeners
* Toggle the gridstack to be static or not based on the edit mode
*/
useEffect(() => {
gridRef.current?.setStatic(!isEditMode);
}, [isEditMode]);
const sectionHeight = section.kind === "dynamic" && "height" in section ? (section.height as number) : null;
/**
* IMPORTANT: This effect has to be placed after the effect to initialize the gridstack
* because we need the gridstack object to add the listeners
* We want the amount of rows in a dynamic section to be the height of the section in the outer gridstack
*/
// We want the amount of rows in a dynamic section to be the height of the section in the outer gridstack
useEffect(() => {
if (!sectionHeight) return;
gridRef.current?.row(sectionHeight);
}, [sectionHeight]);
/**
* IMPORTANT: This effect has to be placed after the effect to initialize the gridstack
* because we need the gridstack object to add the listeners
*/
useCssVariableConfiguration({
columnCount,
gridRef,
wrapperRef,
width,
height,
isDynamic: section.kind === "dynamic",
});
return {
refs: {
items: itemRefs,
@@ -0,0 +1,27 @@
import type { GetStylesApi } from "@mantine/core";
import { createSafeContext } from "@mantine/core";
import type { SectionMenuFactory } from "./section-menu";
interface SectionMenuContext {
toggleDropdown: () => void;
closeDropdownImmediately: () => void;
closeDropdown: () => void;
openDropdown: () => void;
getItemIndex: (node: HTMLButtonElement) => number | null;
setHovered: (index: number | null) => void;
hovered: number | null;
closeOnItemClick: boolean | undefined;
loop: boolean | undefined;
trigger: "click" | "hover" | "click-hover" | undefined;
opened: boolean;
unstyled: boolean | undefined;
getStyles: GetStylesApi<SectionMenuFactory>;
menuItemTabIndex: -1 | 0 | undefined;
openedViaClick: boolean;
setOpenedViaClick: (value: boolean) => void;
}
export const [SectionMenuContextProvider, useSectionMenuContext] = createSafeContext<SectionMenuContext>(
"SectionMenu component was not found in the tree",
);
@@ -0,0 +1,48 @@
import { useRef } from "react";
import type { Factory } from "@mantine/core";
import { factory, Popover } from "@mantine/core";
import { useMergedRef } from "@mantine/hooks";
import { useSectionMenuContext } from "./section-menu-context";
export type SectionMenuDropdownStylesNames = "dropdown";
export interface SectionMenuDropdownProps {
childOpened: boolean;
children: React.ReactNode;
}
export type SectionMenuDropdownFactory = Factory<{
props: SectionMenuDropdownProps;
ref: HTMLDivElement;
stylesNames: SectionMenuDropdownStylesNames;
compound: true;
}>;
export const SectionMenuDropdown = factory<SectionMenuDropdownFactory>(({ childOpened, children }, ref) => {
const wrapperRef = useRef<HTMLDivElement>(null);
const ctx = useSectionMenuContext();
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (childOpened) return;
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault();
wrapperRef.current?.querySelectorAll<HTMLButtonElement>("[data-section-menu-item]:not(:disabled)")[0]?.focus();
}
};
return (
<Popover.Dropdown
role="menu"
aria-orientation="vertical"
ref={useMergedRef(ref, wrapperRef)}
{...ctx.getStyles("dropdown")}
tabIndex={-1}
data-section-menu-dropdown
onKeyDown={handleKeyDown}
>
<div tabIndex={-1} data-autofocus data-mantine-stop-propagation style={{ outline: 0 }} />
{children}
</Popover.Dropdown>
);
});
@@ -0,0 +1,111 @@
import { KeyboardEvent, useRef } from 'react';
import {
BoxProps,
CompoundStylesApiProps,
createEventHandler,
createScopedKeydownHandler,
polymorphicFactory,
PolymorphicFactory,
UnstyledButton,
useDirection,
useProps,
} from '@mantine/core';
import { useMergedRef } from '@mantine/hooks';
import { useSectionMenuContext } from './section-menu-context';
export type SectionMenuItemStylesNames = 'item' | 'itemLabel' | 'itemSection';
export interface SectionMenuItemProps
extends BoxProps,
CompoundStylesApiProps<SectionMenuItemFactory> {
/** Item label */
children?: React.ReactNode;
childMenu: {
opened: boolean;
close: () => void;
open: () => void;
};
}
export type SectionMenuItemFactory = PolymorphicFactory<{
props: SectionMenuItemProps;
defaultRef: HTMLButtonElement;
defaultComponent: 'button';
stylesNames: SectionMenuItemStylesNames;
compound: true;
}>;
const defaultProps: Partial<SectionMenuItemProps> = {};
export const SectionMenuItem = polymorphicFactory<SectionMenuItemFactory>((props, ref) => {
const { childMenu, classNames, className, style, styles, vars, children, ...others } = useProps(
'SectionMenuItem',
defaultProps,
props
);
const ctx = useSectionMenuContext();
const { dir } = useDirection();
const itemRef = useRef<HTMLButtonElement>();
const itemIndex = ctx.getItemIndex(itemRef.current!);
const _others: any = others;
const handleMouseLeave = createEventHandler(_others.onMouseLeave, () => ctx.setHovered(-1));
const handleMouseEnter = createEventHandler(_others.onMouseEnter, () =>
ctx.setHovered(ctx.getItemIndex(itemRef.current!))
);
const handleFocus = createEventHandler(_others.onFocus, () =>
ctx.setHovered(ctx.getItemIndex(itemRef.current!))
);
const scopedKeydownHandler = createScopedKeydownHandler({
siblingSelector: '[data-section-menu-item]',
parentSelector: '[data-section-menu-dropdown]',
activateOnFocus: false,
loop: ctx.loop,
dir,
orientation: 'vertical',
onKeyDown: _others.onKeyDown,
});
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
console.log('hi');
if (
(event.key === 'ArrowRight' && dir === 'ltr') ||
(event.key === 'ArrowLeft' && dir === 'rtl')
) {
childMenu.open();
return;
}
// We don't want to handle keydown event if child menu is opened
if (childMenu.opened) {
console.log('hey');
return;
}
console.log('hallo');
scopedKeydownHandler(event);
};
return (
<UnstyledButton
{...others}
unstyled={ctx.unstyled}
tabIndex={ctx.menuItemTabIndex}
onFocus={handleFocus}
{...ctx.getStyles('item', { className, style, styles, classNames })}
ref={useMergedRef(itemRef, ref)}
role="menuitem"
data-section-menu-item
data-hovered={ctx.hovered === itemIndex ? true : undefined}
data-mantine-stop-propagation
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onKeyDown={handleKeyDown}
>
{children}
</UnstyledButton>
);
});
@@ -0,0 +1,42 @@
import { cloneElement, forwardRef } from "react";
import { createEventHandler, isElement, Popover, useProps } from "@mantine/core";
import { useSectionMenuContext } from "./section-menu-context";
export interface SectionMenuTargetProps {
/** Target element */
children: React.ReactNode;
/** Key of the prop that should be used to get element ref */
refProp?: string;
}
const defaultProps: Partial<SectionMenuTargetProps> = {
refProp: "ref",
};
export const SectionMenuTarget = forwardRef<HTMLElement, SectionMenuTargetProps>((props, ref) => {
const { children, refProp, ...others } = useProps("SectionMenuTarget", defaultProps, props);
if (!isElement(children)) {
throw new Error(
"SectionMenu.Target component children should be an element or a component that accepts ref. Fragments, strings, numbers and other primitive values are not supported",
);
}
const ctx = useSectionMenuContext();
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access
const onClick = createEventHandler(children.props.onClick, () => {
ctx.toggleDropdown();
});
return (
<Popover.Target refProp={refProp} popupType="menu" ref={ref} {...others}>
{cloneElement(children, {
onClick,
"data-expanded": ctx.opened ? true : undefined,
})}
</Popover.Target>
);
});
@@ -0,0 +1,117 @@
import { useState } from "react";
import type { Factory, PopoverStylesNames } from "@mantine/core";
import { getContextItemIndex, Popover, useHovered, useStyles } from "@mantine/core";
import { useDidUpdate, useUncontrolled } from "@mantine/hooks";
import { SectionMenuContextProvider } from "./section-menu-context";
import classes from "./SectionMenu.module.css";
export type SectionMenuStylesNames = "item" | "itemLabel" | "itemSection" | "label" | "divider" | PopoverStylesNames;
export type SectionMenuFactory = Factory<{
props: SectionMenuProps;
ref: HTMLDivElement;
stylesNames: SectionMenuStylesNames;
}>;
export interface SectionMenuProps {
/** Menu content */
children?: React.ReactNode;
/** Controlled menu opened state */
opened?: boolean;
/** Called when Menu is opened */
onOpen?: () => void;
/** Called when Menu is closed */
onClose?: () => void;
}
export function SectionMenu(props: SectionMenuProps) {
const { children, onOpen, onClose, opened, ...others } = props;
const getStyles = useStyles<SectionMenuFactory>({
name: "SectionMenu",
classes,
props,
classNames: [],
styles: {},
unstyled: false,
});
const [hovered, { setHovered, resetHovered }] = useHovered();
const [_opened, setOpened] = useUncontrolled({
value: opened,
defaultValue: false,
finalValue: false,
onChange: () => undefined,
});
const [openedViaClick, setOpenedViaClick] = useState(false);
const close = () => {
setOpened(false);
setOpenedViaClick(false);
if (_opened) {
onClose?.();
}
};
const open = () => {
setOpened(true);
if (!_opened) {
onOpen?.();
}
};
const toggleDropdown = () => {
if (_opened) {
close();
} else {
open();
}
};
const getItemIndex = (node: HTMLButtonElement) =>
getContextItemIndex("[data-section-menu-item]", "[data-section-menu-dropdown]", node);
useDidUpdate(() => {
resetHovered();
}, [_opened]);
return (
<SectionMenuContextProvider
value={{
getStyles,
opened: _opened,
toggleDropdown,
getItemIndex,
hovered,
setHovered,
openedViaClick,
setOpenedViaClick,
closeOnItemClick: false,
closeDropdown: close,
openDropdown: open,
closeDropdownImmediately: close,
loop: true,
trigger: "click",
unstyled: false,
menuItemTabIndex: -1,
}}
>
<Popover
{...others}
opened={_opened}
onChange={toggleDropdown}
__staticSelector="SectionMenu"
clickOutsideEvents={["mousedown", "touchstart", "keydown"]}
withArrow
withinPortal={false}
returnFocus
>
{children}
</Popover>
</SectionMenuContextProvider>
);
}
@@ -1,24 +1,9 @@
import type { FocusEventHandler } from "react";
import { startTransition, useState } from "react";
import {
Box,
Card,
Combobox,
Flex,
Image,
Indicator,
InputBase,
Paper,
Skeleton,
Stack,
Text,
UnstyledButton,
useCombobox,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { useState } from "react";
import { Combobox, Group, Image, InputBase, Skeleton, Text, useCombobox } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
interface IconPickerProps {
initialValue?: string;
@@ -33,75 +18,55 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
const [search, setSearch] = useState(initialValue ?? "");
const [previewUrl, setPreviewUrl] = useState<string | null>(initialValue ?? null);
const t = useI18n();
const tCommon = useScopedI18n("common");
const [debouncedSearch] = useDebouncedValue(search, 100);
// We use not useSuspenseQuery as it would cause an above Suspense boundary to trigger and so searching for something is bad UX.
const { data } = clientApi.icon.findIcons.useQuery({
searchText: debouncedSearch,
const { data, isFetching } = clientApi.icon.findIcons.useQuery({
searchText: search,
});
const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(),
});
const totalOptions = data?.icons.reduce((acc, group) => acc + group.icons.length, 0) ?? 0;
const groups = data?.icons.map((group) => {
const notNullableData = data?.icons ?? [];
const totalOptions = notNullableData.reduce((acc, group) => acc + group.icons.length, 0);
const groups = notNullableData.map((group) => {
const options = group.icons.map((item) => (
<UnstyledButton
onClick={() => {
const value = item.url;
startTransition(() => {
setValue(value);
setPreviewUrl(value);
setSearch(value);
onChange(value);
combobox.closeDropdown();
});
}}
key={item.id}
>
<Indicator label="SVG" disabled={!item.url.endsWith(".svg")} size={16}>
<Card
p="sm"
pos="relative"
style={{
overflow: "visible",
cursor: "pointer",
}}
>
<Box w={25} h={25}>
<Image src={item.url} w={25} h={25} radius="md" />
</Box>
</Card>
</Indicator>
</UnstyledButton>
<Combobox.Option value={item.url} key={item.id}>
<Group>
<Image src={item.url} w={20} h={20} />
<Text>{item.name}</Text>
</Group>
</Combobox.Option>
));
return (
<Paper p="xs" key={group.slug} pt={2}>
<Text mb={8} size="sm" fw="bold">
{group.slug}
</Text>
<Flex gap={8} wrap={"wrap"}>
{options}
</Flex>
</Paper>
<Combobox.Group label={group.slug} key={group.id}>
{options}
</Combobox.Group>
);
});
return (
<Combobox store={combobox} withinPortal>
<Combobox
onOptionSubmit={(value) => {
setValue(value);
setPreviewUrl(value);
setSearch(value);
onChange(value);
combobox.closeDropdown();
}}
store={combobox}
withinPortal
>
<Combobox.Target>
<InputBase
rightSection={<Combobox.Chevron />}
leftSection={
previewUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={previewUrl} alt="" style={{ width: 20, height: 20 }} />
) : null
}
// eslint-disable-next-line @next/next/no-img-element
leftSection={previewUrl ? <img src={previewUrl} alt="" style={{ width: 20, height: 20 }} /> : null}
value={search}
onChange={(event) => {
combobox.openDropdown();
@@ -126,14 +91,18 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
withAsterisk
error={error}
label={tCommon("iconPicker.label")}
placeholder={tCommon("iconPicker.header", { countIcons: data?.countIcons ?? 0 })}
/>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Header>
<Text c="dimmed">{tCommon("iconPicker.header", { countIcons: data?.countIcons })}</Text>
</Combobox.Header>
<Combobox.Options mah={350} style={{ overflowY: "auto" }}>
{totalOptions > 0 ? (
<Stack gap={4}>{groups}</Stack>
groups
) : !isFetching ? (
<Combobox.Empty>{t("search.nothingFound")}</Combobox.Empty>
) : (
Array(15)
.fill(0)
@@ -1,12 +0,0 @@
"use client";
import { useChangeLocale, useCurrentLocale } from "@homarr/translation/client";
import { LanguageCombobox } from "./language-combobox";
export const CurrentLanguageCombobox = () => {
const currentLocale = useCurrentLocale();
const { changeLocale, isPending } = useChangeLocale();
return <LanguageCombobox value={currentLocale} onChange={changeLocale} isPending={isPending} />;
};
@@ -1,35 +1,31 @@
"use client";
import React from "react";
import { Combobox, Group, InputBase, Loader, ScrollArea, Text, useCombobox } from "@mantine/core";
import { Combobox, Group, InputBase, Text, useCombobox } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
import type { SupportedLanguage } from "@homarr/translation";
import { localeConfigurations, supportedLanguages } from "@homarr/translation";
import { localeAttributes, supportedLanguages } from "@homarr/translation";
import { useChangeLocale, useCurrentLocale } from "@homarr/translation/client";
import classes from "./language-combobox.module.css";
interface LanguageComboboxProps {
label?: string;
value: SupportedLanguage;
onChange: (value: SupportedLanguage) => void;
isPending?: boolean;
}
export const LanguageCombobox = ({ label, value, onChange, isPending }: LanguageComboboxProps) => {
export const LanguageCombobox = () => {
const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(),
});
const currentLocale = useCurrentLocale();
const changeLocale = useChangeLocale();
const handleOnOptionSubmit = React.useCallback(
(value: string) => {
if (!value) {
return;
}
onChange(value as SupportedLanguage);
changeLocale(value as SupportedLanguage);
combobox.closeDropdown();
},
[onChange, combobox],
[changeLocale, combobox],
);
const handleOnClick = React.useCallback(() => {
@@ -43,26 +39,22 @@ export const LanguageCombobox = ({ label, value, onChange, isPending }: Language
component="button"
type="button"
pointer
label={label}
leftSection={isPending ? <Loader size={16} /> : null}
rightSection={<Combobox.Chevron />}
rightSectionPointerEvents="none"
onClick={handleOnClick}
variant="filled"
>
<OptionItem currentLocale={value} localeKey={value} />
<OptionItem currentLocale={currentLocale} localeKey={currentLocale} />
</InputBase>
</Combobox.Target>
<Combobox.Dropdown>
<ScrollArea h={300}>
<Combobox.Options>
{supportedLanguages.map((languageKey) => (
<Combobox.Option value={languageKey} key={languageKey}>
<OptionItem currentLocale={value} localeKey={languageKey} showCheck />
</Combobox.Option>
))}
</Combobox.Options>
</ScrollArea>
<Combobox.Options>
{supportedLanguages.map((languageKey) => (
<Combobox.Option value={languageKey} key={languageKey}>
<OptionItem currentLocale={currentLocale} localeKey={languageKey} showCheck />
</Combobox.Option>
))}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
);
@@ -80,11 +72,11 @@ const OptionItem = ({
return (
<Group wrap="nowrap" justify="space-between">
<Group wrap="nowrap">
<span className={`fi fi-${localeConfigurations[localeKey].flagIcon} ${classes.flagIcon}`}></span>
<span className={`fi fi-${localeAttributes[localeKey].flagIcon} ${classes.flagIcon}`}></span>
<Group wrap="nowrap" gap="xs">
<Text>{localeConfigurations[localeKey].name}</Text>
<Text>{localeAttributes[localeKey].name}</Text>
<Text size="xs" c="dimmed" inherit>
({localeConfigurations[localeKey].translatedName})
({localeAttributes[localeKey].translatedName})
</Text>
</Group>
</Group>
@@ -1,12 +1,11 @@
import Script from "next/script";
import { UMAMI_WEBSITE_ID } from "@homarr/analytics";
import { db } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import { api } from "@homarr/api/server";
export const Analytics = async () => {
// For static pages it will not find any analytics data so we do not include the script on them
const analytics = await getServerSettingByKeyAsync(db, "analytics").catch(() => null);
const analytics = await api.serverSettings.getAnalytics().catch(() => null);
if (analytics?.enableGeneral) {
return <Script src="https://umami.homarr.dev/script.js" data-website-id={UMAMI_WEBSITE_ID} defer />;
@@ -1,28 +1,28 @@
import { db } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import SuperJSON from "superjson";
import { db, eq } from "@homarr/db";
import { serverSettings } from "@homarr/db/schema/sqlite";
import type { defaultServerSettings } from "@homarr/server-settings";
export const SearchEngineOptimization = async () => {
const crawlingAndIndexingSetting = await getServerSettingByKeyAsync(db, "crawlingAndIndexing");
const crawlingAndIndexingSetting = await db.query.serverSettings.findFirst({
where: eq(serverSettings.settingKey, "crawlingAndIndexing"),
});
const robotsAttributes: string[] = [];
if (crawlingAndIndexingSetting.noIndex) {
robotsAttributes.push("noindex");
if (!crawlingAndIndexingSetting) {
return null;
}
if (crawlingAndIndexingSetting.noFollow) {
robotsAttributes.push("nofollow");
}
const value = SuperJSON.parse<(typeof defaultServerSettings)["crawlingAndIndexing"]>(
crawlingAndIndexingSetting.value,
);
const googleAttributes: string[] = [];
const robotsAttributes = [...(value.noIndex ? ["noindex"] : []), ...(value.noIndex ? ["nofollow"] : [])];
if (crawlingAndIndexingSetting.noSiteLinksSearchBox) {
googleAttributes.push("nositelinkssearchbox");
}
if (crawlingAndIndexingSetting.noTranslate) {
googleAttributes.push("notranslate");
}
const googleAttributes = [
...(value.noSiteLinksSearchBox ? ["nositelinkssearchbox"] : []),
...(value.noTranslate ? ["notranslate"] : []),
];
return (
<>
@@ -15,7 +15,7 @@ export const DangerZoneRoot = async ({ children }: DangerZoneRootProps) => {
<Title c="red.8" order={2}>
{t("common.dangerZone")}
</Title>
<Card withBorder style={{ borderColor: "var(--mantine-color-red-8)", borderWidth: 3 }}>
<Card withBorder style={{ borderColor: "var(--mantine-color-red-8)" }}>
<Stack gap="sm">
{Array.isArray(children)
? children.map((child, index) => (
@@ -43,14 +43,14 @@ interface DangerZoneItemProps {
export const DangerZoneItem = ({ label, description, action }: DangerZoneItemProps) => {
return (
<Group justify="space-between" px="md" w={"100%"}>
<Group justify="space-between" px="md">
<Stack gap={0}>
<Text fw="bold" size="sm">
{label}
</Text>
<Text size="sm">{description}</Text>
</Stack>
<Group justify="end" w={{ xs: "100%", sm: "100%", md: "auto" }}>
<Group justify="end" w={{ base: "100%", xs: "auto" }}>
{action}
</Group>
</Group>
-26
View File
@@ -1,26 +0,0 @@
import { Anchor, Card, Stack, Text } from "@mantine/core";
import type { TablerIcon } from "@tabler/icons-react";
interface NoResultsProps {
icon: TablerIcon;
title: string;
action?: {
label: string;
href: string;
hidden?: boolean;
};
}
export const NoResults = ({ icon: Icon, title, action }: NoResultsProps) => {
return (
<Card withBorder bg="transparent">
<Stack align="center" gap="sm">
<Icon size="2rem" />
<Text fw={500} size="lg">
{title}
</Text>
{!action?.hidden && <Anchor href={action?.href}>{action?.label}</Anchor>}
</Stack>
</Card>
);
};
@@ -24,7 +24,7 @@ import { useScopedI18n } from "@homarr/translation/client";
import "flag-icons/css/flag-icons.min.css";
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
import { LanguageCombobox } from "./language/language-combobox";
interface UserAvatarMenuProps {
children: ReactNode;
@@ -61,8 +61,7 @@ export const UserAvatarMenu = ({ children }: UserAvatarMenuProps) => {
}, [logoutUrl, openModal, router]);
return (
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
<Menu width={300} withArrow withinPortal keepMounted>
<Menu width={300} withArrow withinPortal>
<Menu.Dropdown>
<Menu.Item onClick={toggleColorScheme} leftSection={<ColorSchemeIcon size="1rem" />}>
{colorSchemeText}
@@ -73,7 +72,7 @@ export const UserAvatarMenu = ({ children }: UserAvatarMenuProps) => {
<Menu.Divider />
<Menu.Item p={0} closeMenuOnClick={false}>
<CurrentLanguageCombobox />
<LanguageCombobox />
</Menu.Item>
<Menu.Divider />
{Boolean(session.data) && (
@@ -1,23 +0,0 @@
import "server-only";
import { notFound, redirect } from "next/navigation";
import { TRPCError } from "@trpc/server";
import { logger } from "@homarr/log";
export const catchTrpcNotFound = (err: unknown) => {
if (err instanceof TRPCError && err.code === "NOT_FOUND") {
notFound();
}
throw err;
};
export const catchTrpcUnauthorized = (err: unknown) => {
if (err instanceof TRPCError && err.code === "UNAUTHORIZED") {
logger.info("Somebody tried to access a protected route without being authenticated, redirecting to login page");
redirect("/auth/login");
}
throw err;
};
+12
View File
@@ -0,0 +1,12 @@
import "server-only";
import { notFound } from "next/navigation";
import { TRPCError } from "@trpc/server";
export const catchTrpcNotFound = (err: unknown) => {
if (err instanceof TRPCError && err.code === "NOT_FOUND") {
notFound();
}
throw err;
};
Loaded 100 of 407 files, more files were not shown because too many files have changed in this diff. Show more