diff --git a/.deepsource.toml b/.deepsource.toml index edc4a8907..e2f2ffcf8 100644 --- a/.deepsource.toml +++ b/.deepsource.toml @@ -3,9 +3,6 @@ version = 1 [[analyzers]] name = "javascript" - [analyzers.meta] - plugins = ["react"] - environment = ["nodejs"] - -[[transformers]] -name = "prettier" \ No newline at end of file +[analyzers.meta] +plugins = ["react"] +environment = ["nodejs"] diff --git a/.dockerignore b/.dockerignore index 4a3a71c06..d75e30111 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,4 +9,8 @@ README.md .git dev .build -e2e \ No newline at end of file +e2e +apps/nextjs/.next +.env +.env.* +!.env.example diff --git a/.env.example b/.env.example index a11cff63f..318b171e1 100644 --- a/.env.example +++ b/.env.example @@ -41,6 +41,14 @@ DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE' TURBO_TELEMETRY_DISABLED=1 +# Turborepo remote cache (optional, for faster CI/local builds) +# Uncomment and fill in to enable remote caching locally. +# CI uses GitHub secrets for these values automatically. +# TURBO_API=https://your-cache-worker.workers.dev +# TURBO_TEAM=team_your_team +# TURBO_TOKEN=your-turbo-token +# TURBO_REMOTE_CACHE_SIGNATURE_KEY=your-signature-key + # Comma-separated paths to Docker-compatible Unix sockets. # Useful for connecting to multiple runtimes (Docker + Podman) simultaneously. # When unset and no TCP hosts configured, defaults to /var/run/docker.sock. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index a5137601a..973aef4ab 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -33,6 +33,7 @@ body: options: # The below comment is used to insert a new version with on-release.yml #NEXT_VERSION# + - 1.62.0 - 1.61.0 - 1.60.1-beta.1 - 1.60.0 diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 5dc6df825..40d4ceb08 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -15,13 +15,54 @@ concurrency: env: FORCE_COLOR: 3 + SKIP_ENV_VALIDATION: true + TURBO_API: ${{ secrets.TURBO_API }} + TURBO_TEAM: ${{ secrets.TURBO_TEAM }} + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }} + TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.before || 'HEAD~1' }} + TURBO_SCM_HEAD: ${{ github.sha }} jobs: + lint-format-autofix: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.head_ref }} + + - name: Setup + uses: ./tooling/github/setup + + - name: Get changed files + id: changed + run: | + git diff --name-only --diff-filter=ACMR origin/${{ github.base_ref }}...HEAD -- '*.ts' '*.tsx' '*.js' '*.jsx' > /tmp/changed-files + echo "count=$(wc -l < /tmp/changed-files | tr -d '[:space:]')" >> $GITHUB_OUTPUT + + - name: Auto-fix lint + format + if: steps.changed.outputs.count != '0' + run: | + xargs -r -a /tmp/changed-files pnpm exec oxlint --fix || true + xargs -r -a /tmp/changed-files pnpm exec oxfmt --write + + - name: Commit fixes + uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_message: "style: auto-fix lint and format" + lint: runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v6 + with: + fetch-depth: 0 - name: Setup uses: ./tooling/github/setup @@ -31,31 +72,35 @@ jobs: run: cp .env.example .env - name: Lint - run: pnpm lint && pnpm lint:ws + run: pnpm turbo lint --affected && pnpm lint:ws format: runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v6 + with: + fetch-depth: 0 - name: Setup uses: ./tooling/github/setup - name: Format - run: pnpm format + run: pnpm turbo format --affected --continue typecheck: runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v6 + with: + fetch-depth: 0 - name: Setup uses: ./tooling/github/setup - name: Typecheck - run: pnpm typecheck + run: pnpm turbo typecheck --affected test: runs-on: ubuntu-latest @@ -78,11 +123,12 @@ jobs: e2e: runs-on: ubuntu-latest timeout-minutes: 15 - steps: - uses: actions/checkout@v6 - name: Setup uses: ./tooling/github/setup + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 - name: Build docker image id: build-docker-image uses: docker/build-push-action@v7 @@ -90,8 +136,11 @@ jobs: platforms: linux/amd64 context: . push: false + load: true tags: homarr-e2e network: host + cache-from: type=gha + cache-to: type=gha,mode=max env: SKIP_ENV_VALIDATION: true - name: Install playwright browsers @@ -105,10 +154,12 @@ jobs: timeout-minutes: 10 steps: - uses: actions/checkout@v6 + with: + fetch-depth: 0 - name: Setup uses: ./tooling/github/setup - name: Copy env shell: bash run: cp .env.example .env - name: Build - run: pnpm build + run: pnpm turbo build --affected diff --git a/.github/workflows/deployment-docker-image.yml b/.github/workflows/deployment-docker-image.yml index c43094223..98f9357db 100644 --- a/.github/workflows/deployment-docker-image.yml +++ b/.github/workflows/deployment-docker-image.yml @@ -66,7 +66,7 @@ jobs: - uses: actions/setup-node@v6 if: env.SKIP_RELEASE == 'false' with: - node-version: 24.15.0 + node-version: 24.16.0 package-manager-cache: false - run: npm i -g pnpm if: env.SKIP_RELEASE == 'false' diff --git a/.nvmrc b/.nvmrc index 5bf4400f2..b832e4001 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -24.15.0 +24.16.0 diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 000000000..88c14e2f9 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,7 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "printWidth": 120, + "endOfLine": "lf", + "sortPackageJson": true, + "ignorePatterns": ["**/*.cjs", "**/lang/**", "static-data/**", "CHANGELOG.md"] +} diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 000000000..1dbc54c92 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,37 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["eslint", "typescript", "unicorn", "oxc", "react", "import", "jsx-a11y"], + "categories": { + "correctness": "error", + "suspicious": "warn", + "pedantic": "off", + "perf": "off", + "style": "off", + "restriction": "off", + "nursery": "off" + }, + "rules": { + "eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }], + "typescript/consistent-type-imports": ["warn", { "prefer": "type-imports", "fixStyle": "separate-type-imports" }], + "typescript/no-non-null-assertion": "error", + "import/consistent-type-specifier-style": ["error", "prefer-top-level"], + "unicorn/no-new-array": "warn", + "unicorn/no-useless-spread": "warn", + "unicorn/no-useless-length-check": "warn", + "unicorn/no-useless-fallback-in-spread": "warn", + "jsx-a11y/prefer-tag-over-role": "warn", + "oxc/const-comparisons": "warn", + "react-hooks/exhaustive-deps": "warn", + "react/no-children-prop": "warn", + "jsx-a11y/no-autofocus": "warn", + "jsx-a11y/img-redundant-alt": "warn", + "react/react-in-jsx-scope": "off" + }, + "options": { + "reportUnusedDisableDirectives": "off" + }, + "ignorePatterns": ["**/*.config.js", "**/*.config.mjs", "**/*.cjs", "**/lang/*.d.json.ts", ".next/**"], + "settings": { + "react": { "version": "19" } + } +} diff --git a/.vscode/i18n-ally-custom-framework.yml b/.vscode/i18n-ally-custom-framework.yml index 1ee83bc7e..865d1ae28 100644 --- a/.vscode/i18n-ally-custom-framework.yml +++ b/.vscode/i18n-ally-custom-framework.yml @@ -31,6 +31,5 @@ scopeRangeRegex: "(?:const|let|var)\\s+t\\s*=\\s*(?:await\\s+)?(?:getScopedI18n| # refactorTemplates: # - i18n.get("$1") - # If set to true, only enables this custom framework (will disable all built-in frameworks) -monopoly: true \ No newline at end of file +monopoly: true diff --git a/.vscode/launch.json b/.vscode/launch.json index b24d8b507..f3f0886e0 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -15,9 +15,7 @@ "request": "launch", "command": "pnpm docker:dev", "cwd": "${workspaceFolder}", - "skipFiles": [ - "/**" - ] + "skipFiles": ["/**"] }, { "name": "lint fix", @@ -25,9 +23,7 @@ "request": "launch", "command": "pnpm lint:fix", "cwd": "${workspaceFolder}", - "skipFiles": [ - "/**" - ] + "skipFiles": ["/**"] }, { "name": "format fix", @@ -35,9 +31,7 @@ "request": "launch", "command": "pnpm format:fix", "cwd": "${workspaceFolder}", - "skipFiles": [ - "/**" - ] + "skipFiles": ["/**"] }, { "name": "db push", @@ -69,9 +63,7 @@ "request": "launch", "command": "pnpm test", "cwd": "${workspaceFolder}", - "skipFiles": [ - "/**" - ] + "skipFiles": ["/**"] }, { "name": "test ui", @@ -79,9 +71,7 @@ "request": "launch", "command": "pnpm test:ui", "cwd": "${workspaceFolder}", - "skipFiles": [ - "/**" - ] + "skipFiles": ["/**"] } ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index f018a27b5..aa5d827ff 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,13 +1,6 @@ { - "eslint.workingDirectories": [ - { - "mode": "auto" - } - ], - "eslint.experimental.useFlatConfig": true, "typescript.tsdk": "node_modules\\typescript\\lib", "js/ts.implicitProjectConfig.experimentalDecorators": true, - "prettier.configPath": "./tooling/prettier/index.mjs", "cSpell.words": [ "ajnart", "cqmin", @@ -34,11 +27,7 @@ ], "i18n-ally.dirStructure": "auto", "i18n-ally.displayLanguage": "en", - "i18n-ally.enabledFrameworks": [ - "custom" - ], - "i18n-ally.localesPaths": [ - "packages/translation/src/lang", - ], - "i18n-ally.keystyle": "nested", + "i18n-ally.enabledFrameworks": ["custom"], + "i18n-ally.localesPaths": ["packages/translation/src/lang"], + "i18n-ally.keystyle": "nested" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 83a872367..a99e511b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [1.61.1](https://github.com/homarr-labs/homarr/compare/v1.61.0...v1.61.1) (2026-05-18) + +### Bug Fixes + +* **downloads-widget:** increase name column default width ratio ([e9142c0](https://github.com/homarr-labs/homarr/commit/e9142c0dbcaca6cd9215b5292a2d139d96919b51)) + ## [1.61.1-beta.1](https://github.com/homarr-labs/homarr/compare/v1.61.0...v1.61.1-beta.1) (2026-05-08) ### Performance Improvements diff --git a/Dockerfile b/Dockerfile index 51b562f7d..fd852fd9e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:24.15.0-alpine AS base +FROM node:24.16.0-alpine AS base FROM base AS builder RUN apk add --no-cache libc6-compat diff --git a/apps/nextjs/eslint.config.js b/apps/nextjs/eslint.config.js deleted file mode 100644 index 3d593e34e..000000000 --- a/apps/nextjs/eslint.config.js +++ /dev/null @@ -1,30 +0,0 @@ -import baseConfig from "@homarr/eslint-config/base"; -import nextjsConfig from "@homarr/eslint-config/nextjs"; -import reactConfig from "@homarr/eslint-config/react"; - -/** @type {import('typescript-eslint').Config} */ -export default [ - { - ignores: [".next/**"], - }, - ...baseConfig, - ...reactConfig, - ...nextjsConfig, - { - files: ["public/sw.js"], - languageOptions: { - globals: { - self: "readonly", - clients: "readonly", - skipWaiting: "readonly", - importScripts: "readonly", - caches: "readonly", - fetch: "readonly", - }, - }, - rules: { - "@typescript-eslint/no-unsafe-call": "off", - "@typescript-eslint/no-unsafe-member-access": "off", - }, - }, -]; diff --git a/apps/nextjs/next.config.ts b/apps/nextjs/next.config.ts index 24b32fd21..bcce78519 100644 --- a/apps/nextjs/next.config.ts +++ b/apps/nextjs/next.config.ts @@ -6,7 +6,6 @@ import "@homarr/core/infrastructure/logs/env"; import "@homarr/docker/env"; import type { NextConfig } from "next"; -import MillionLint from "@million/lint"; import createNextIntlPlugin from "next-intl/plugin"; // Package path does not work... so we need to use relative path @@ -85,8 +84,4 @@ const nextConfig: NextConfig = { }, }; -// Skip transform is used because of webpack loader, without it for example 'Tooltip.Floating' will not work and show an error -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const withMillionLint = MillionLint.next({ rsc: true, skipTransform: true, telemetry: false }); - export default withNextIntl(nextConfig); diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index 4a7323606..ff5864f13 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -7,13 +7,12 @@ "build": "pnpm with-env next build", "clean": "git clean -xdf .next .turbo node_modules", "dev": "pnpm with-env next dev", - "format": "prettier --check . --ignore-path ../../.gitignore", - "lint": "eslint", + "format": "oxfmt --check .", + "lint": "oxlint", "start": "pnpm with-env next start", "typecheck": "tsc --noEmit", "with-env": "dotenv -e ../../.env --" }, - "prettier": "@homarr/prettier-config", "dependencies": { "@dnd-kit/core": "catalog:", "@dnd-kit/modifiers": "catalog:", @@ -56,7 +55,6 @@ "@mantine/hooks": "catalog:", "@mantine/modals": "catalog:", "@mantine/tiptap": "catalog:", - "@million/lint": "catalog:", "@tabler/icons-react": "catalog:", "@tanstack/react-query": "catalog:", "@tanstack/react-query-devtools": "catalog:", @@ -93,8 +91,6 @@ "zod": "catalog:" }, "devDependencies": { - "@homarr/eslint-config": "workspace:^0.2.0", - "@homarr/prettier-config": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0", "@types/chroma-js": "catalog:", "@types/node": "catalog:", @@ -103,9 +99,7 @@ "@types/react-dom": "catalog:", "@types/swagger-ui-react": "catalog:", "concurrently": "catalog:", - "eslint": "catalog:", "node-loader": "catalog:", - "prettier": "catalog:", "typescript": "catalog:" } } diff --git a/apps/nextjs/src/app/[locale]/_client-providers/mantine.tsx b/apps/nextjs/src/app/[locale]/_client-providers/mantine.tsx index 508074ead..1d939c919 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/mantine.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/mantine.tsx @@ -18,7 +18,15 @@ export const CustomMantineProvider = ({ const manager = useColorSchemeManager(); return ( - + {children} diff --git a/apps/nextjs/src/app/[locale]/auth/login/_login-form.tsx b/apps/nextjs/src/app/[locale]/auth/login/_login-form.tsx index 71d249cf5..8d36d1e79 100644 --- a/apps/nextjs/src/app/[locale]/auth/login/_login-form.tsx +++ b/apps/nextjs/src/app/[locale]/auth/login/_login-form.tsx @@ -9,7 +9,7 @@ import { z } from "zod/v4"; import { signIn } from "@homarr/auth/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; -import type { useForm } from "@homarr/form"; +import type { UseFormReturnType } from "@homarr/form"; import { useZodForm } from "@homarr/form"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useScopedI18n } from "@homarr/translation/client"; @@ -162,7 +162,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c interface SubmitButtonProps { isPending: boolean; - form: ReturnType FormType>>; + form: UseFormReturnType; provider: "credentials" | "ldap"; } @@ -198,7 +198,7 @@ const PasswordForgottenCollapse = ({ username }: PasswordForgottenCollapseProps) {tForgotPassword("label")} - + {tForgotPassword("description")} diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx index e9551af52..0ba502a9a 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx @@ -1,70 +1,27 @@ "use client"; -import { useCallback, useRef } from "react"; -import { Box, LoadingOverlay, Stack } from "@mantine/core"; +import { Box, Stack } from "@mantine/core"; -import type { RouterOutputs } from "@homarr/api"; -import { clientApi } from "@homarr/api/client"; import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context"; import { BoardCategorySection } from "~/components/board/sections/category-section"; import { BoardEmptySection } from "~/components/board/sections/empty-section"; import { BoardBackgroundVideo } from "~/components/layout/background"; -import { fullHeightWithoutHeaderAndFooter } from "~/constants"; -import { useIsBoardReady } from "./_ready-context"; - -let boardName: string | null = null; - -export const updateBoardName = (name: string | null) => { - boardName = name; -}; - -type UpdateCallback = (prev: RouterOutputs["board"]["getHomeBoard"]) => RouterOutputs["board"]["getHomeBoard"]; - -export const useUpdateBoard = () => { - const utils = clientApi.useUtils(); - - const updateBoard = useCallback( - (updaterWithoutUndefined: UpdateCallback) => { - if (!boardName) { - throw new Error("Board name is not set"); - } - utils.board.getBoardByName.setData({ name: boardName }, (previous) => - previous ? updaterWithoutUndefined(previous) : previous, - ); - }, - [utils], - ); - - return { - updateBoard, - }; -}; export const ClientBoard = () => { const board = useRequiredBoard(); const currentLayoutId = useCurrentLayout(); - const isReady = useIsBoardReady(); const fullWidthSortedSections = board.sections .filter((section) => section.kind === "empty" || section.kind === "category") .sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset); - const ref = useRef(null); - return ( - - + {fullWidthSortedSections.map((section) => section.kind === "empty" ? ( - // Unique keys per layout to always reinitialize the gridstack ) : ( diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx index 64c9289e3..3314dee3c 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx @@ -41,13 +41,11 @@ export const createBoardContentPage = >( }), // eslint-disable-next-line no-restricted-syntax page: async ({ params }: { params: Promise }) => { - const session = await auth(); - const integrations = await getIntegrationsWithPermissionsAsync(session); - - const board = await getInitialBoard(await params); + const resolvedParams = await params; const queryClient = getQueryClient(); - // Prefetch item data + const [board, session] = await Promise.all([getInitialBoard(resolvedParams), auth()]); + const itemsMap = board.items.reduce((acc, item) => { const existing = acc.get(item.kind); if (existing) { @@ -58,17 +56,20 @@ export const createBoardContentPage = >( return acc; }, new Map()); - for (const [kind, items] of itemsMap) { - await prefetchForKindAsync(kind, queryClient, items).catch((error) => { - logger.error( - new ErrorWithMetadata( - "Failed to prefetch widget", - { widgetKind: kind, itemCount: items.length }, - { cause: error }, - ), - ); - }); - } + const [integrations] = await Promise.all([ + getIntegrationsWithPermissionsAsync(session), + ...Array.from(itemsMap).map(([kind, items]) => + prefetchForKindAsync(kind, queryClient, items).catch((error) => { + logger.error( + new ErrorWithMetadata( + "Failed to prefetch widget", + { widgetKind: kind, itemCount: items.length }, + { cause: error }, + ), + ); + }), + ), + ]); return ( diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_ready-context.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_ready-context.tsx index 606a296ea..3d50c5a8c 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_ready-context.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_ready-context.tsx @@ -1,42 +1,19 @@ "use client"; import type { PropsWithChildren } from "react"; -import { createContext, useCallback, useContext, useEffect, useState } from "react"; -import { usePathname } from "next/navigation"; - -import { clientApi } from "@homarr/api/client"; -import { useRequiredBoard } from "@homarr/boards/context"; +import { createContext, useCallback, useContext } from "react"; const BoardReadyContext = createContext<{ - isReady: boolean; markAsReady: (id: string) => void; } | null>(null); export const BoardReadyProvider = ({ children }: PropsWithChildren) => { - const pathname = usePathname(); - const utils = clientApi.useUtils(); - const board = useRequiredBoard(); - const [readySections, setReadySections] = useState([]); - - // Reset sections required for ready state - useEffect(() => { - return () => { - setReadySections([]); - }; - }, [pathname, utils]); - - useEffect(() => { - setReadySections((previous) => previous.filter((id) => board.sections.some((section) => section.id === id))); - }, [board.sections.length, setReadySections]); - - const markAsReady = useCallback((id: string) => { - setReadySections((previous) => (previous.includes(id) ? previous : [...previous, id])); - }, []); + // eslint-disable-next-line @typescript-eslint/no-empty-function + const markAsReady = useCallback((_id: string) => {}, []); return ( @@ -54,13 +31,3 @@ export const useMarkSectionAsReady = () => { return context.markAsReady; }; - -export const useIsBoardReady = () => { - const context = useContext(BoardReadyContext); - - if (!context) { - throw new Error("BoardReadyProvider is required"); - } - - return context.isReady; -}; diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_theme.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_theme.tsx index 27f3e9152..8fa031ad0 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_theme.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_theme.tsx @@ -24,6 +24,7 @@ export const BoardMantineProvider = ({ }, primaryColor: "primaryColor", autoContrast: true, + defaultRadius: "sm", fontSizes: { "2xl": rem(24), "3xl": rem(28), diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_appereance.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_appereance.tsx index 95b3602e6..4af4af15f 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_appereance.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_appereance.tsx @@ -83,7 +83,7 @@ export const ColorSettingsContent = ({ board }: Props) => { {showPreview ? t("common.preview.hide") : t("common.preview.show")} - + diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx index 7cdd8dc7e..e88f1965b 100644 --- a/apps/nextjs/src/app/[locale]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/layout.tsx @@ -142,7 +142,7 @@ export default async function Layout(props: { - + {props.children} diff --git a/apps/nextjs/src/app/[locale]/manage/apps/page.tsx b/apps/nextjs/src/app/[locale]/manage/apps/page.tsx index 5a8ec43d2..1093524e7 100644 --- a/apps/nextjs/src/app/[locale]/manage/apps/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/apps/page.tsx @@ -1,6 +1,6 @@ import { Fragment } from "react"; import { redirect } from "next/navigation"; -import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core"; +import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text } from "@mantine/core"; import { IconBox, IconPencil } from "@tabler/icons-react"; import { z } from "zod/v4"; @@ -11,9 +11,8 @@ import type { inferSearchParamsFromSchema } from "@homarr/common/types"; import { getI18n, getScopedI18n } from "@homarr/translation/server"; import { Link, SearchInput, TablePagination } from "@homarr/ui"; -import { ManageContainer } from "~/components/manage/manage-container"; +import { ManagePageLayout } from "~/components/manage/manage-page-layout"; 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"; @@ -39,34 +38,31 @@ export default async function AppsPage(props: AppsPageProps) { const { items: apps, totalCount } = await api.app.getPaginated(searchParams); const t = await getScopedI18n("app"); - return ( - - - - {t("page.list.title")} - - - {session.user.permissions.includes("app-create") && ( - - {t("page.create.title")} - - )} - - {apps.length === 0 && } - {apps.length > 0 && ( - - {apps.map((app) => ( - - ))} - - )} + const canCreate = session.user.permissions.includes("app-create"); - {/* Added margin to not hide pagination behind affix-button */} - - - - - + return ( + + {t("page.create.title")} + + ) : undefined + } + toolbar={} + footer={} + floatingPrimaryAction={canCreate} + > + {apps.length === 0 && } + {apps.length > 0 && ( + + {apps.map((app) => ( + + ))} + + )} + ); } diff --git a/apps/nextjs/src/app/[locale]/manage/boards/_components/create-board-button.tsx b/apps/nextjs/src/app/[locale]/manage/boards/_components/create-board-button.tsx index 849db72e7..5e9642e32 100644 --- a/apps/nextjs/src/app/[locale]/manage/boards/_components/create-board-button.tsx +++ b/apps/nextjs/src/app/[locale]/manage/boards/_components/create-board-button.tsx @@ -1,43 +1,38 @@ "use client"; -import { Affix, Button, Menu } from "@mantine/core"; +import { Button, Menu } from "@mantine/core"; import { IconCategoryPlus, IconChevronDown, IconFileImport } from "@tabler/icons-react"; import { useModalAction } from "@homarr/modals"; import { AddBoardModal, ImportBoardModal } from "@homarr/modals-collection"; import { useI18n } from "@homarr/translation/client"; +import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action"; + export const CreateBoardButton = () => { const t = useI18n(); const { openModal: openAddModal } = useModalAction(AddBoardModal); const { openModal: openImportModal } = useModalAction(ImportBoardModal); - const buttonGroupContent = ( - <> - - - - - - - }> - {t("board.action.oldImport.label")} - - - - - ); - return ( - <> - {buttonGroupContent} - - {buttonGroupContent} - - + + + + + + + + + }> + {t("board.action.oldImport.label")} + + + + + ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/boards/page.tsx b/apps/nextjs/src/app/[locale]/manage/boards/page.tsx index 8340fd4ec..45bbcd2b5 100644 --- a/apps/nextjs/src/app/[locale]/manage/boards/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/boards/page.tsx @@ -9,12 +9,17 @@ import { Group, Menu, MenuTarget, - Stack, Text, - Title, Tooltip, } from "@mantine/core"; -import { IconDeviceMobile, IconDotsVertical, IconHomeFilled, IconLock, IconWorld } from "@tabler/icons-react"; +import { + IconDeviceMobile, + IconDotsVertical, + IconHomeFilled, + IconLayoutDashboard, + IconLock, + IconWorld, +} from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { api } from "@homarr/api/server"; @@ -23,8 +28,8 @@ import { getScopedI18n } from "@homarr/translation/server"; import { Link, UserAvatar } from "@homarr/ui"; import { getBoardPermissionsAsync } from "~/components/board/permissions/server"; -import { ManageContainer } from "~/components/manage/manage-container"; -import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; +import { ManagePageLayout } from "~/components/manage/manage-page-layout"; +import { NoResults } from "~/components/no-results"; import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown"; import { CreateBoardButton } from "./_components/create-board-button"; @@ -35,23 +40,22 @@ export default async function ManageBoardsPage() { const canCreateBoards = session?.user.permissions.includes("board-create"); return ( - - - - - {t("title")} - {canCreateBoards && } - - - + : undefined} + floatingPrimaryAction={canCreateBoards} + > + {boards.length === 0 && } + {boards.length > 0 && ( + {boards.map((board) => ( ))} - - + )} + ); } diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-card.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-card.tsx index 27ad484c4..0bfe34ed0 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-card.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-card.tsx @@ -84,7 +84,7 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => { - {children} + {children} ); diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-segmented-control.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-segmented-control.tsx index e524b3870..9272e47c2 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-segmented-control.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-segmented-control.tsx @@ -12,7 +12,7 @@ interface FormType { interface SecretKindsSegmentedControlProps { defaultKinds?: IntegrationSecretKind[]; secretKinds: IntegrationSecretKind[][]; - form: UseFormReturnType TFormType>; + form: UseFormReturnType; } export const SecretKindsSegmentedControl = ({ diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx index 016b9336c..b7fbf3291 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx @@ -226,7 +226,7 @@ const AppForm = ({ form, canCreateApps }: { form: UseFormReturnType; c description={t("integration.field.createApp.description")} /> - +
{canCreateApps && ( diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx index 1fe3a83e2..994d63b3c 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx @@ -7,9 +7,7 @@ import { AccordionPanel, ActionIcon, ActionIconGroup, - Affix, Anchor, - Box, Button, Divider, Group, @@ -24,9 +22,8 @@ import { TableThead, TableTr, Text, - Title, } from "@mantine/core"; -import { IconChevronDown, IconChevronUp, IconPencil, IconPlugX } from "@tabler/icons-react"; +import { IconChevronDown, IconPencil, IconPlugX } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { api } from "@homarr/api/server"; @@ -37,8 +34,8 @@ import { getIntegrationName } from "@homarr/definitions"; import { getScopedI18n } from "@homarr/translation/server"; import { CountBadge, IntegrationAvatar, Link } from "@homarr/ui"; -import { ManageContainer } from "~/components/manage/manage-container"; -import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; +import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action"; +import { ManagePageLayout } from "~/components/manage/manage-page-layout"; import { NoResults } from "~/components/no-results"; import { env } from "~/env"; import { ActiveTabAccordion } from "../../../../components/active-tab-accordion"; @@ -66,38 +63,23 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) { const canCreateIntegrations = session.user.permissions.includes("integration-create"); return ( - - - - - {t("page.list.title")} - - {canCreateIntegrations && ( - <> - - - - - - - - - - - - - - - - - - - )} - - - - - + + + + + + + + ) : undefined + } + floatingPrimaryAction={canCreateIntegrations} + > + + ); } diff --git a/apps/nextjs/src/app/[locale]/manage/medias/page.tsx b/apps/nextjs/src/app/[locale]/manage/medias/page.tsx index b14ceb531..51d4a2caf 100644 --- a/apps/nextjs/src/app/[locale]/manage/medias/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/medias/page.tsx @@ -4,17 +4,15 @@ import { Anchor, Group, Image, - Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, - Title, Tooltip, } from "@mantine/core"; -import { IconExternalLink } from "@tabler/icons-react"; +import { IconExternalLink, IconPhoto } from "@tabler/icons-react"; import { z } from "zod/v4"; import type { RouterOutputs } from "@homarr/api"; @@ -26,8 +24,9 @@ import { createLocalImageUrl } from "@homarr/icons/local"; import { getI18n } from "@homarr/translation/server"; import { Link, SearchInput, TablePagination, UserAvatar } from "@homarr/ui"; -import { ManageContainer } from "~/components/manage/manage-container"; -import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; +import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action"; +import { ManagePageLayout } from "~/components/manage/manage-page-layout"; +import { NoResults } from "~/components/no-results"; import { CopyMedia } from "./_actions/copy-media"; import { DeleteMedia } from "./_actions/delete-media"; import { IncludeFromAllUsersSwitch } from "./_actions/show-all"; @@ -48,7 +47,7 @@ interface MediaListPageProps { searchParams: Promise>; } -export default async function GroupsListPage(props: MediaListPageProps) { +export default async function MediaListPage(props: MediaListPageProps) { const session = await auth(); if (!session) { @@ -58,22 +57,31 @@ export default async function GroupsListPage(props: MediaListPageProps) { const t = await getI18n(); const searchParams = searchParamsSchema.parse(await props.searchParams); const { items: medias, totalCount } = await api.media.getPaginated(searchParams); + const canUpload = session.user.permissions.includes("media-upload"); return ( - - - - {t("media.plural")} - - - - {session.user.permissions.includes("media-view-all") && ( - - )} - - - {session.user.permissions.includes("media-upload") && } + + + + ) : undefined + } + toolbar={ + + + {session.user.permissions.includes("media-view-all") && ( + + )} + } + footer={} + floatingPrimaryAction={canUpload} + > + {medias.length === 0 && } + {medias.length > 0 && ( @@ -90,13 +98,8 @@ export default async function GroupsListPage(props: MediaListPageProps) { ))}
- - {/* Added margin to not hide pagination behind affix-button */} - - - -
-
+ )} + ); } diff --git a/apps/nextjs/src/app/[locale]/manage/search-engines/page.tsx b/apps/nextjs/src/app/[locale]/manage/search-engines/page.tsx index 0ab5b814d..fd4066d64 100644 --- a/apps/nextjs/src/app/[locale]/manage/search-engines/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/search-engines/page.tsx @@ -1,5 +1,5 @@ import { redirect } from "next/navigation"; -import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core"; +import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text } from "@mantine/core"; import { IconPencil, IconSearch } from "@tabler/icons-react"; import { z } from "zod/v4"; @@ -10,9 +10,8 @@ import type { inferSearchParamsFromSchema } from "@homarr/common/types"; import { getI18n, getScopedI18n } from "@homarr/translation/server"; import { Link, SearchInput, TablePagination } from "@homarr/ui"; -import { ManageContainer } from "~/components/manage/manage-container"; +import { ManagePageLayout } from "~/components/manage/manage-page-layout"; 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"; @@ -38,34 +37,31 @@ export default async function SearchEnginesPage(props: SearchEnginesPageProps) { const tEngine = await getScopedI18n("search.engine"); - return ( - - - - {tEngine("page.list.title")} - - - {session.user.permissions.includes("search-engine-create") && ( - - {tEngine("page.create.title")} - - )} - - {searchEngines.length === 0 && } - {searchEngines.length > 0 && ( - - {searchEngines.map((searchEngine) => ( - - ))} - - )} + const canCreate = session.user.permissions.includes("search-engine-create"); - {/* Added margin to not hide pagination behind affix-button */} - - - - - + return ( + + {tEngine("page.create.title")} + + ) : undefined + } + toolbar={} + footer={} + floatingPrimaryAction={canCreate} + > + {searchEngines.length === 0 && } + {searchEngines.length > 0 && ( + + {searchEngines.map((searchEngine) => ( + + ))} + + )} + ); } diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/setting-switch.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/setting-switch.tsx index 293a8fa96..adec086ad 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/_components/setting-switch.tsx +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/setting-switch.tsx @@ -15,7 +15,7 @@ export const SwitchSetting = >({ disabled, }: { form: Modify< - UseFormReturnType TFormValue>, + UseFormReturnType, { setFieldValue: (key: keyof TFormValue, value: (previous: boolean) => boolean) => void; } diff --git a/apps/nextjs/src/app/[locale]/manage/tools/logs/level-selection.tsx b/apps/nextjs/src/app/[locale]/manage/tools/logs/level-selection.tsx index 71846ca02..243521471 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/logs/level-selection.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/logs/level-selection.tsx @@ -2,12 +2,14 @@ import { Select } from "@mantine/core"; -import type { LogLevel } from "@homarr/core/infrastructure/logs/constants"; import { logLevelConfiguration, logLevels } from "@homarr/core/infrastructure/logs/constants"; +import type { LogLevel } from "@homarr/core/infrastructure/logs/constants"; import { useI18n } from "@homarr/translation/client"; import { useLogContext } from "./log-context"; +const levelByValue = Object.fromEntries(logLevels.map((logLevel) => [logLevel, logLevel])) as Record; + export const LogLevelSelection = () => { const { level, setLevel } = useLogContext(); const t = useI18n(); @@ -19,7 +21,12 @@ export const LogLevelSelection = () => { label: `${logLevelConfiguration[level].prefix} ${t(`log.level.option.${level}`)}`, }))} value={level} - onChange={(value) => setLevel(value as LogLevel)} + onChange={(value) => { + const nextLevel = value && levelByValue[value]; + if (nextLevel) { + setLevel(nextLevel); + } + }} checkIconPosition="right" /> ); diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_first-day-of-week.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_first-day-of-week.tsx index 71bef0737..2bf43a3bc 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_first-day-of-week.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_first-day-of-week.tsx @@ -62,7 +62,7 @@ export const FirstDayOfWeek = ({ user }: FirstDayOfWeekProps) => { return (
- onChange(parseInt(value))}> + onChange(parseInt(nextValue, 10))}> diff --git a/apps/nextjs/src/app/[locale]/manage/users/_components/user-list.tsx b/apps/nextjs/src/app/[locale]/manage/users/_components/user-list.tsx index 22f4d1b88..0e0fce5b9 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/_components/user-list.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/_components/user-list.tsx @@ -1,24 +1,22 @@ "use client"; import { useMemo } from "react"; -import { Anchor, Button, Group, Text, Title, Tooltip } from "@mantine/core"; +import { Anchor, Group, Text, Tooltip } from "@mantine/core"; import { IconCheck } from "@tabler/icons-react"; import type { MRT_ColumnDef } from "mantine-react-table"; import { MantineReactTable } from "mantine-react-table"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; -import { useI18n, useScopedI18n } from "@homarr/translation/client"; +import { useI18n } from "@homarr/translation/client"; import { Link, UserAvatar } from "@homarr/ui"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; interface UserListComponentProps { initialUserList: RouterOutputs["user"]["getAll"]; - credentialsProviderEnabled: boolean; } -export const UserListComponent = ({ initialUserList, credentialsProviderEnabled }: UserListComponentProps) => { - const tUserList = useScopedI18n("management.page.user.list"); +export const UserListComponent = ({ initialUserList }: UserListComponentProps) => { const t = useI18n(); const { data, isLoading } = clientApi.user.getAll.useQuery(undefined, { initialData: initialUserList, @@ -69,21 +67,10 @@ export const UserListComponent = ({ initialUserList, credentialsProviderEnabled enableFullScreenToggle: false, layoutMode: "grid-no-grow", getRowId: (row) => row.id, - renderTopToolbarCustomActions: () => - credentialsProviderEnabled ? ( - - ) : null, state: { isLoading, }, }); - return ( - <> - {tUserList("title")} - - - ); + return ; }; diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/_client.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/_client.tsx index 7ca382bff..353bc06ea 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/groups/_client.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/groups/_client.tsx @@ -1,7 +1,7 @@ "use client"; -import { useCallback, useMemo, useState } from "react"; -import { Group, TextInput } from "@mantine/core"; +import { useMemo, useState } from "react"; +import { TextInput } from "@mantine/core"; import { IconSearch } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; @@ -33,29 +33,20 @@ export const GroupsList = ({ groups }: GroupsListProps) => { return ( <> - - } - value={search} - onChange={(event) => setSearch(event.currentTarget.value)} - placeholder={`${t("group.search")}...`} - style={{ flex: 1 }} - /> - - - + } + value={search} + onChange={(event) => setSearch(event.currentTarget.value)} + placeholder={`${t("group.search")}...`} + /> ); }; -const AddGroup = () => { +export const AddGroupButton = () => { const t = useI18n(); const { openModal } = useModalAction(AddGroupModal); - const handleAddGroup = useCallback(() => { - openModal(); - }, [openModal]); - - return {t("group.action.create.label")}; + return openModal()}>{t("group.action.create.label")}; }; diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/page.tsx index 19ec73ff7..48f6c8f8f 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/groups/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/groups/page.tsx @@ -1,5 +1,5 @@ import { notFound } from "next/navigation"; -import { Card, Group, Stack, Text, ThemeIcon, Title, UnstyledButton } from "@mantine/core"; +import { Card, Group, Stack, Text, ThemeIcon, UnstyledButton } from "@mantine/core"; import { IconChevronRight, IconUsersGroup } from "@tabler/icons-react"; import { api } from "@homarr/api/server"; @@ -8,9 +8,8 @@ import { everyoneGroup } from "@homarr/definitions"; import { getI18n } from "@homarr/translation/server"; import { Link } from "@homarr/ui"; -import { ManageContainer } from "~/components/manage/manage-container"; -import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; -import { GroupsList } from "./_client"; +import { ManagePageLayout } from "~/components/manage/manage-page-layout"; +import { AddGroupButton, GroupsList } from "./_client"; import classes from "./groups.module.css"; export default async function GroupsListPage() { @@ -26,11 +25,8 @@ export default async function GroupsListPage() { const groupsWithoutEveryone = groups.filter((group) => group.name !== everyoneGroup); return ( - - + } floatingPrimaryAction> - {t("group.title")} - {dbEveryoneGroup && ( @@ -53,6 +49,6 @@ export default async function GroupsListPage() { - + ); } diff --git a/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-create-button.tsx b/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-create-button.tsx new file mode 100644 index 000000000..01c3f40b2 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-create-button.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { Button } from "@mantine/core"; + +import { useModalAction } from "@homarr/modals"; +import { InviteCreateModal } from "@homarr/modals-collection"; +import { useScopedI18n } from "@homarr/translation/client"; + +import { MANAGE_ACTION_BUTTON_MIN_WIDTH } from "~/components/manage/manage-page.constants"; + +export const InviteCreateButton = () => { + const t = useScopedI18n("management.page.user.invite"); + const { openModal } = useModalAction(InviteCreateModal); + + return ( + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-list.tsx b/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-list.tsx index 5fa1b5eff..4bc1eaff1 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-list.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-list.tsx @@ -1,7 +1,7 @@ "use client"; import { useCallback, useMemo } from "react"; -import { ActionIcon, Button, Title } from "@mantine/core"; +import { ActionIcon } from "@mantine/core"; import { IconTrash } from "@tabler/icons-react"; import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; @@ -10,8 +10,7 @@ import { MantineReactTable } from "mantine-react-table"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; -import { useConfirmModal, useModalAction } from "@homarr/modals"; -import { InviteCreateModal } from "@homarr/modals-collection"; +import { useConfirmModal } from "@homarr/modals"; import { useScopedI18n } from "@homarr/translation/client"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; @@ -65,7 +64,6 @@ export const InviteListComponent = ({ initialInvites }: InviteListComponentProps enableFullScreenToggle: false, layoutMode: "grid-no-grow", getRowId: (row) => row.id, - renderTopToolbarCustomActions: RenderTopToolbarCustomActions, state: { isLoading, }, @@ -74,22 +72,7 @@ export const InviteListComponent = ({ initialInvites }: InviteListComponentProps }, }); - return ( - <> - {t("title")} - - - ); -}; - -const RenderTopToolbarCustomActions = () => { - const t = useScopedI18n("management.page.user.invite"); - const { openModal } = useModalAction(InviteCreateModal); - const handleNewInvite = useCallback(() => { - openModal(); - }, [openModal]); - - return ; + return ; }; const RenderRowActions = ({ row }: { row: MRT_Row }) => { diff --git a/apps/nextjs/src/app/[locale]/manage/users/invites/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/invites/page.tsx index f392e8282..cb5c0423c 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/invites/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/invites/page.tsx @@ -3,8 +3,11 @@ import { notFound } from "next/navigation"; import { api } from "@homarr/api/server"; import { auth } from "@homarr/auth/next"; import { isProviderEnabled } from "@homarr/auth/server"; +import { getScopedI18n } from "@homarr/translation/server"; -import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; +import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action"; +import { ManagePageLayout } from "~/components/manage/manage-page-layout"; +import { InviteCreateButton } from "./_components/invite-create-button"; import { InviteListComponent } from "./_components/invite-list"; export default async function InvitesOverviewPage() { @@ -17,11 +20,20 @@ export default async function InvitesOverviewPage() { return notFound(); } + const t = await getScopedI18n("management.page.user.invite"); const initialInvites = await api.invite.getAll(); + return ( - <> - + + + + } + floatingPrimaryAction + > - + ); } diff --git a/apps/nextjs/src/app/[locale]/manage/users/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/page.tsx index 94ae5c103..4059b9972 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/page.tsx @@ -4,8 +4,10 @@ import { api } from "@homarr/api/server"; import { auth } from "@homarr/auth/next"; import { isProviderEnabled } from "@homarr/auth/server"; import { getScopedI18n } from "@homarr/translation/server"; +import { Link } from "@homarr/ui"; -import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; +import { ManagePageLayout } from "~/components/manage/manage-page-layout"; +import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; import { createMetaTitle } from "~/metadata"; import { UserListComponent } from "./_components/user-list"; @@ -27,13 +29,23 @@ export default async function UsersPage() { return notFound(); } + const t = await getScopedI18n("management.page.user"); const userList = await api.user.getAll(); const credentialsProviderEnabled = isProviderEnabled("credentials"); return ( - <> - - - + + {t("create.title")} + + ) : undefined + } + floatingPrimaryAction={credentialsProviderEnabled} + > + + ); } diff --git a/apps/nextjs/src/components/board/items/actions/create-item.ts b/apps/nextjs/src/components/board/items/actions/create-item.ts index f71e5aed0..42b619364 100644 --- a/apps/nextjs/src/components/board/items/actions/create-item.ts +++ b/apps/nextjs/src/components/board/items/actions/create-item.ts @@ -8,12 +8,14 @@ import { getFirstEmptyPosition } from "./empty-position"; import { getSectionElements } from "./section-elements"; export interface CreateItemInput { + id?: string; kind: WidgetKind; options?: Record; + integrationIds?: string[]; } export const createItemCallback = - ({ kind, options = {} }: CreateItemInput) => + ({ id, kind, options = {}, integrationIds = [] }: CreateItemInput) => (previous: Board): Board => { const firstSection = previous.sections .filter((section): section is EmptySection => section.kind === "empty") @@ -23,11 +25,11 @@ export const createItemCallback = if (!firstSection) return previous; const widget = { - id: createId(), + id: id ?? createId(), kind, options, layouts: createItemLayouts(previous, firstSection), - integrationIds: [], + integrationIds, advancedOptions: { title: null, customCssClasses: [], diff --git a/apps/nextjs/src/components/board/items/item-select-modal.tsx b/apps/nextjs/src/components/board/items/item-select-modal.tsx index 47660fbe6..23c9ddec6 100644 --- a/apps/nextjs/src/components/board/items/item-select-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx @@ -2,20 +2,26 @@ import { useMemo, useState } from "react"; import { Avatar, Button, Card, Center, Grid, Group, Input, Stack, Text, Tooltip } from "@mantine/core"; import { IconSearch } from "@tabler/icons-react"; -import { objectEntries } from "@homarr/common"; +import { clientApi } from "@homarr/api/client"; +import { createId, objectEntries } from "@homarr/common"; import { getIconUrl, getIntegrationName } from "@homarr/definitions"; import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; -import { createModal } from "@homarr/modals"; +import { createModal, useModalAction } from "@homarr/modals"; +import { useSettings } from "@homarr/settings"; import { useI18n } from "@homarr/translation/client"; import type { TablerIcon } from "@homarr/ui"; -import { widgetImports } from "@homarr/widgets"; +import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets"; +import { WidgetEditModal } from "@homarr/widgets/modals"; import { useItemActions } from "./item-actions"; export const ItemSelectModal = createModal(({ actions }) => { const [search, setSearch] = useState(""); const t = useI18n(); - const { createItem } = useItemActions(); + const { createItem, updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions(); + const { openModal: openEditModal } = useModalAction(WidgetEditModal); + const { data: integrationData } = clientApi.integration.all.useQuery(); + const settings = useSettings(); const items = useMemo( () => @@ -40,8 +46,43 @@ export const ItemSelectModal = createModal(({ actions }) => { ); const handleAdd = (kind: WidgetKind) => { - createItem({ kind }); + const definition = widgetImports[kind].definition; + const hasIntegrationSupport = "supportedIntegrations" in definition; + + const matchingIntegrations = hasIntegrationSupport + ? (integrationData ?? []).filter((integration) => + (definition.supportedIntegrations as string[]).includes(integration.kind), + ) + : []; + + const integrationIds = matchingIntegrations.map((i) => i.id); + const itemId = createId(); + const defaultOptions = reduceWidgetOptionsWithDefaultValues(kind, settings); + + createItem({ id: itemId, kind, integrationIds }); actions.closeModal(); + + openEditModal( + { + kind, + value: { + advancedOptions: { title: null, customCssClasses: [], borderColor: "" }, + options: defaultOptions, + integrationIds, + }, + onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => { + updateItemOptions({ itemId, newOptions: options }); + updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions }); + updateItemIntegrations({ itemId, newIntegrations: newIntegrationIds }); + }, + integrationData: matchingIntegrations, + integrationSupport: hasIntegrationSupport, + settings, + }, + { + title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`, + }, + ); }; return ( diff --git a/apps/nextjs/src/components/board/sections/category-section.tsx b/apps/nextjs/src/components/board/sections/category-section.tsx index 2f6a65d72..4c3958859 100644 --- a/apps/nextjs/src/components/board/sections/category-section.tsx +++ b/apps/nextjs/src/components/board/sections/category-section.tsx @@ -3,6 +3,7 @@ import { useDisclosure } from "@mantine/hooks"; import { IconChevronDown, IconChevronUp } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; +import { useSession } from "@homarr/auth/client"; import { useRequiredBoard } from "@homarr/boards/context"; import type { CategorySection } from "~/app/[locale]/boards/_types"; @@ -10,19 +11,36 @@ import { CategoryMenu } from "./category/category-menu"; import { GridStack } from "./gridstack/gridstack"; import classes from "./item.module.css"; +const localStorageKey = (sectionId: string) => `homarr-section-collapsed-${sectionId}`; + interface Props { section: CategorySection; } export const BoardCategorySection = ({ section }: Props) => { + const { status } = useSession(); const { mutate } = clientApi.section.changeCollapsed.useMutation(); const board = useRequiredBoard(); - const [opened, { toggle }] = useDisclosure(section.collapsed, { + + const initialCollapsed = + typeof window !== "undefined" + ? (localStorage.getItem(localStorageKey(section.id)) ?? String(section.collapsed)) === "true" + : section.collapsed; + + const [opened, { toggle }] = useDisclosure(initialCollapsed, { onOpen() { - mutate({ sectionId: section.id, collapsed: true }); + if (status === "authenticated") { + mutate({ sectionId: section.id, collapsed: true }); + } else if (status === "unauthenticated") { + localStorage.setItem(localStorageKey(section.id), "true"); + } }, onClose() { - mutate({ sectionId: section.id, collapsed: false }); + if (status === "authenticated") { + mutate({ sectionId: section.id, collapsed: false }); + } else if (status === "unauthenticated") { + localStorage.setItem(localStorageKey(section.id), "false"); + } }, }); @@ -38,7 +56,7 @@ export const BoardCategorySection = ({ section }: Props) => { - + diff --git a/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts b/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts index eda89a8e6..b5e468de6 100644 --- a/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts +++ b/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts @@ -1,6 +1,6 @@ -import type { RefObject } from "react"; +import type { Ref, RefObject } from "react"; import { createRef, useCallback, useEffect, useRef } from "react"; -import { useElementSize } from "@mantine/hooks"; +import { useElementSize, useMergedRef } from "@mantine/hooks"; import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context"; import { useEditMode } from "@homarr/boards/edit-mode"; @@ -13,7 +13,7 @@ import { useSectionActions } from "../section-actions"; import { initializeGridstack } from "./init-gridstack"; export interface UseGridstackRefs { - wrapper: RefObject; + wrapper: Ref; items: RefObject>>; gridstack: RefObject; } @@ -59,8 +59,9 @@ export const useGridstack = (section: Omit, itemIds: string[]) const { moveAndResizeItem, moveItemToSection } = useItemActions(); const { moveAndResizeInnerSection, moveInnerSectionToSection } = useSectionActions(); - // define reference for wrapper - is used to calculate the width of the wrapper - const { ref: wrapperRef, width, height } = useElementSize(); + const wrapperElementRef = useRef(null); + const { ref: measureWrapperRef, width, height } = useElementSize(); + const wrapperRef = useMergedRef(measureWrapperRef, wrapperElementRef); // references to the diffrent items contained in the gridstack const itemRefs = useRef>>({}); // reference of the gridstack object for modifications after initialization @@ -193,7 +194,7 @@ export const useGridstack = (section: Omit, itemIds: string[]) itemIds, refs: { items: itemRefs, - wrapper: wrapperRef, + wrapper: wrapperElementRef, gridstack: gridRef, }, sectionColumnCount: columnCount, @@ -276,7 +277,7 @@ export const useGridstack = (section: Omit, itemIds: string[]) useCssVariableConfiguration({ columnCount, gridRef, - wrapperRef, + wrapperRef: wrapperElementRef, width, height, isDynamic: section.kind === "dynamic", @@ -293,7 +294,7 @@ export const useGridstack = (section: Omit, itemIds: string[]) interface UseCssVariableConfiguration { gridRef: UseGridstackRefs["gridstack"]; - wrapperRef: UseGridstackRefs["wrapper"]; + wrapperRef: RefObject; width: number; height: number; columnCount: number; diff --git a/apps/nextjs/src/components/layout/background.tsx b/apps/nextjs/src/components/layout/background.tsx index c7011408a..70efc6b14 100644 --- a/apps/nextjs/src/components/layout/background.tsx +++ b/apps/nextjs/src/components/layout/background.tsx @@ -44,6 +44,7 @@ export const BoardBackgroundVideo = () => { muted loop playsInline + aria-hidden="true" style={{ position: "fixed", width: "100vw", diff --git a/apps/nextjs/src/components/manage/manage-mobile-primary-action.tsx b/apps/nextjs/src/components/manage/manage-mobile-primary-action.tsx new file mode 100644 index 000000000..40192e0f8 --- /dev/null +++ b/apps/nextjs/src/components/manage/manage-mobile-primary-action.tsx @@ -0,0 +1,15 @@ +import type { PropsWithChildren } from "react"; +import { Affix, Box } from "@mantine/core"; + +import { MANAGE_ACTION_BUTTON_MIN_WIDTH, MANAGE_AFFIX_POSITION } from "./manage-page.constants"; + +export const ManageMobilePrimaryAction = ({ children }: PropsWithChildren) => ( + <> + + {children} + + + {children} + + +); diff --git a/apps/nextjs/src/components/manage/manage-page-layout.tsx b/apps/nextjs/src/components/manage/manage-page-layout.tsx new file mode 100644 index 000000000..d8655c58c --- /dev/null +++ b/apps/nextjs/src/components/manage/manage-page-layout.tsx @@ -0,0 +1,44 @@ +import type { ReactNode } from "react"; +import type { MantineSize } from "@mantine/core"; +import { Group, Stack, Title } from "@mantine/core"; + +import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; +import { ManageContainer } from "./manage-container"; +import { MANAGE_FLOATING_ACTION_BOTTOM_OFFSET } from "./manage-page.constants"; + +interface ManagePageLayoutProps { + title: ReactNode; + primaryAction?: ReactNode; + toolbar?: ReactNode; + footer?: ReactNode; + floatingPrimaryAction?: boolean; + size?: MantineSize; + children: ReactNode; +} + +export const ManagePageLayout = ({ + title, + primaryAction, + toolbar, + footer, + floatingPrimaryAction, + size, + children, +}: ManagePageLayoutProps) => { + const titleNode = typeof title === "string" ? {title} : title; + + return ( + + + + + {titleNode} + {primaryAction} + + {toolbar} + {children} + {footer && {footer}} + + + ); +}; diff --git a/apps/nextjs/src/components/manage/manage-page.constants.ts b/apps/nextjs/src/components/manage/manage-page.constants.ts new file mode 100644 index 000000000..7b3a59884 --- /dev/null +++ b/apps/nextjs/src/components/manage/manage-page.constants.ts @@ -0,0 +1,5 @@ +export const MANAGE_AFFIX_POSITION = { bottom: 20, right: 20 } as const; + +export const MANAGE_FLOATING_ACTION_BOTTOM_OFFSET = 48; + +export const MANAGE_ACTION_BUTTON_MIN_WIDTH = 160; diff --git a/apps/nextjs/src/components/manage/mobile-affix-button.tsx b/apps/nextjs/src/components/manage/mobile-affix-button.tsx index abe4f017c..f711751de 100644 --- a/apps/nextjs/src/components/manage/mobile-affix-button.tsx +++ b/apps/nextjs/src/components/manage/mobile-affix-button.tsx @@ -2,13 +2,15 @@ import { forwardRef } from "react"; import type { ButtonProps } from "@mantine/core"; import { Affix, Button, createPolymorphicComponent } from "@mantine/core"; +import { MANAGE_ACTION_BUTTON_MIN_WIDTH, MANAGE_AFFIX_POSITION } from "./manage-page.constants"; + type MobileAffixButtonProps = Omit; export const MobileAffixButton = createPolymorphicComponent<"button", MobileAffixButtonProps>( forwardRef((props, ref) => ( <> -