Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9239e6ecc5 | ||
|
|
8bb284e1d3 | ||
|
|
52fc678e44 | ||
|
|
136c269935 |
No files matched your search
@@ -33,7 +33,6 @@ body:
|
|||||||
options:
|
options:
|
||||||
# The below comment is used to insert a new version with on-release.yml
|
# The below comment is used to insert a new version with on-release.yml
|
||||||
#NEXT_VERSION#
|
#NEXT_VERSION#
|
||||||
- 1.64.0
|
|
||||||
- 1.62.0
|
- 1.62.0
|
||||||
- 1.61.0
|
- 1.61.0
|
||||||
- 1.60.1-beta.1
|
- 1.60.1-beta.1
|
||||||
|
|||||||
@@ -42,14 +42,14 @@ jobs:
|
|||||||
- name: Get changed files
|
- name: Get changed files
|
||||||
id: changed
|
id: changed
|
||||||
run: |
|
run: |
|
||||||
git diff --name-only --diff-filter=ACMR origin/${{ github.base_ref }}...HEAD -- '*.ts' '*.tsx' '*.js' '*.jsx' > /tmp/changed-files
|
FILES=$(git diff --name-only --diff-filter=ACMR origin/${{ github.base_ref }}...HEAD -- '*.ts' '*.tsx' '*.js' '*.jsx' | tr '\n' ' ')
|
||||||
echo "count=$(wc -l < /tmp/changed-files | tr -d '[:space:]')" >> $GITHUB_OUTPUT
|
echo "files=$FILES" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Auto-fix lint + format
|
- name: Auto-fix lint + format
|
||||||
if: steps.changed.outputs.count != '0'
|
if: steps.changed.outputs.files != ''
|
||||||
run: |
|
run: |
|
||||||
xargs -r -a /tmp/changed-files pnpm exec oxlint --fix || true
|
pnpm exec oxlint --fix ${{ steps.changed.outputs.files }} || true
|
||||||
xargs -r -a /tmp/changed-files pnpm exec oxfmt --write
|
pnpm exec oxfmt --write ${{ steps.changed.outputs.files }}
|
||||||
|
|
||||||
- name: Commit fixes
|
- name: Commit fixes
|
||||||
uses: stefanzweifel/git-auto-commit-action@v5
|
uses: stefanzweifel/git-auto-commit-action@v5
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ jobs:
|
|||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
if: env.SKIP_RELEASE == 'false'
|
if: env.SKIP_RELEASE == 'false'
|
||||||
with:
|
with:
|
||||||
node-version: 24.16.0
|
node-version: 24.15.0
|
||||||
package-manager-cache: false
|
package-manager-cache: false
|
||||||
- run: npm i -g pnpm
|
- run: npm i -g pnpm
|
||||||
if: env.SKIP_RELEASE == 'false'
|
if: env.SKIP_RELEASE == 'false'
|
||||||
|
|||||||
@@ -1,67 +1,3 @@
|
|||||||
## [1.64.0](https://github.com/homarr-labs/homarr/compare/v1.63.0...v1.64.0) (2026-06-05)
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* add comprehensive demo mode with mock integrations ([#5870](https://github.com/homarr-labs/homarr/issues/5870)) ([0a64679](https://github.com/homarr-labs/homarr/commit/0a6467979a5e3b3e3eae6252c31fca1d9af54dc1))
|
|
||||||
* add PeaNUT integration and UPS monitoring widget ([#5862](https://github.com/homarr-labs/homarr/issues/5862)) ([0dc2558](https://github.com/homarr-labs/homarr/commit/0dc2558fafedcbba246c2e968d00cc541ab9f84e))
|
|
||||||
* **docker:** add container logs modal and stats columns ([#4807](https://github.com/homarr-labs/homarr/issues/4807)) ([dbf021e](https://github.com/homarr-labs/homarr/commit/dbf021e04a785b44ad0e2ce45634de371d76de6d))
|
|
||||||
* media request search as first-class spotlight mode ([#5823](https://github.com/homarr-labs/homarr/issues/5823)) ([3d3e305](https://github.com/homarr-labs/homarr/commit/3d3e3059a98b36b6e64e3aad90f715e334baff3e))
|
|
||||||
* **onboarding:** enhance integration setup with Docker discovery and URL templating ([#5607](https://github.com/homarr-labs/homarr/issues/5607)) ([dd7a490](https://github.com/homarr-labs/homarr/commit/dd7a4908a36a71c1d0d1e41524eb8fd9bf53ca4d))
|
|
||||||
* **onboarding:** guided management and board tours ([#5756](https://github.com/homarr-labs/homarr/issues/5756)) ([0b663a7](https://github.com/homarr-labs/homarr/commit/0b663a7fc6c017b9fbe78c019f06d46ee95f165f))
|
|
||||||
* **widgets:** add in-place app editing in widget edit modal ([#5809](https://github.com/homarr-labs/homarr/issues/5809)) ([99346f2](https://github.com/homarr-labs/homarr/commit/99346f288bcfc701c41fecaab6f7bc0438d80563))
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* add bookmark widget option to toggle card borders ([#5854](https://github.com/homarr-labs/homarr/issues/5854)) ([41fe592](https://github.com/homarr-labs/homarr/commit/41fe5928f19207d02b04e64c2caac49d896f72ce))
|
|
||||||
* **auth:** relax password complexity requirements to suggestions-only ([#5764](https://github.com/homarr-labs/homarr/issues/5764)) ([fd10788](https://github.com/homarr-labs/homarr/commit/fd107888385557ededcf51576c6320a1960690f2))
|
|
||||||
* **db:** log when UNSAFE_ENABLE_MOCK_INTEGRATION is enabled during seeding ([efd37dc](https://github.com/homarr-labs/homarr/commit/efd37dca93be7f5f362331e9b5c2a95a87e24379))
|
|
||||||
* disable onboarding tours when demo mode is enabled ([68f24e3](https://github.com/homarr-labs/homarr/commit/68f24e34d580e3a155c37ca37c3ffd61f296bd0c))
|
|
||||||
* **docker:** restore container selection toolbar broken by Mantine v9 ([#5855](https://github.com/homarr-labs/homarr/issues/5855)) ([fe4598f](https://github.com/homarr-labs/homarr/commit/fe4598f7221a2861b8c5ae0cfd267e8c4fbf48df))
|
|
||||||
* **immich:** respect app color scheme in server-stats widget ([#5859](https://github.com/homarr-labs/homarr/issues/5859)) ([2c6b7a8](https://github.com/homarr-labs/homarr/commit/2c6b7a818e9d32b414b024d711a3b64533e61cb9))
|
|
||||||
* redirect to login after credentials logout instead of crashing on current page ([#5819](https://github.com/homarr-labs/homarr/issues/5819)) ([cc24f07](https://github.com/homarr-labs/homarr/commit/cc24f070db26337a4a2d87d68c7f142ed1118584))
|
|
||||||
* sync docs sitemap and remove stale coolify ts-expect-error ([4f5f3e1](https://github.com/homarr-labs/homarr/commit/4f5f3e14a129a94295d4b8ffb7d3702fdb4f6a74))
|
|
||||||
* **ui:** restore pre-v9 light variant colors ([#5856](https://github.com/homarr-labs/homarr/issues/5856)) ([a755692](https://github.com/homarr-labs/homarr/commit/a755692d81b80b5ff83051a3b7261c01ac10321b))
|
|
||||||
|
|
||||||
## [1.63.0](https://github.com/homarr-labs/homarr/compare/v1.62.0...v1.63.0) (2026-05-29)
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* **auth:** support custom token endpoint auth methods ([#5806](https://github.com/homarr-labs/homarr/issues/5806)) ([be61602](https://github.com/homarr-labs/homarr/commit/be61602576a8bec4bb7e4928d36e0854e926264d))
|
|
||||||
* auto-open widget settings on add with all integrations pre-selected ([#5767](https://github.com/homarr-labs/homarr/issues/5767)) ([f10bb3a](https://github.com/homarr-labs/homarr/commit/f10bb3ae23fefc9779332733706aa567cc40ba48))
|
|
||||||
* **board:** persist category collapse state in localStorage for guests ([#5714](https://github.com/homarr-labs/homarr/issues/5714)) ([66212b4](https://github.com/homarr-labs/homarr/commit/66212b4e475738e61d2ea78f3a7e72d7a610bf12)), closes [#3355](https://github.com/homarr-labs/homarr/issues/3355)
|
|
||||||
* **manage:** unified management page layout ([#5758](https://github.com/homarr-labs/homarr/issues/5758)) ([c252b4f](https://github.com/homarr-labs/homarr/commit/c252b4f9accc4523f222354c73f687714640701f))
|
|
||||||
* **users:** rework general settings ([#4915](https://github.com/homarr-labs/homarr/issues/4915)) ([2991777](https://github.com/homarr-labs/homarr/commit/2991777b0a0ae72522d0769a86a91250d618dbe6))
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* matine ui fixes for concistency ([#5733](https://github.com/homarr-labs/homarr/issues/5733)) ([4454e3f](https://github.com/homarr-labs/homarr/commit/4454e3fb7afa624ee2f1eaec015f2725e664ac46))
|
|
||||||
* parse Speedtest Tracker created_at as local time ([#5751](https://github.com/homarr-labs/homarr/issues/5751)) ([b19549b](https://github.com/homarr-labs/homarr/commit/b19549b601636bcbc2fadaa270a77a9fdb48b77a))
|
|
||||||
* prevent GitHub releases check from blocking app on API failure ([#5807](https://github.com/homarr-labs/homarr/issues/5807)) ([6cc2ac5](https://github.com/homarr-labs/homarr/commit/6cc2ac56a920563c47aaaab1b33b19a9b6d5ecee))
|
|
||||||
* **ui:** use imported theme instead of undefined createTheme ([4a67bf0](https://github.com/homarr-labs/homarr/commit/4a67bf0ef30ff8d10b06d2d3719ccab792395669))
|
|
||||||
|
|
||||||
### Performance Improvements
|
|
||||||
|
|
||||||
* **boards:** parallelize board page data fetching ([#5736](https://github.com/homarr-labs/homarr/issues/5736)) ([8f03a38](https://github.com/homarr-labs/homarr/commit/8f03a38de5b9b385da7ffcc9a963be00710acb5e))
|
|
||||||
|
|
||||||
## [1.62.0](https://github.com/homarr-labs/homarr/compare/v1.61.1...v1.62.0) (2026-05-22)
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* **seed:** add demo mode with pre-seeded user ([#5739](https://github.com/homarr-labs/homarr/issues/5739)) ([dade477](https://github.com/homarr-labs/homarr/commit/dade477b3226765eb614e66588ebee84c45cb688))
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* **deps:** update dependency better-sqlite3 to ^12.10.0 ([#5727](https://github.com/homarr-labs/homarr/issues/5727)) ([044ad9e](https://github.com/homarr-labs/homarr/commit/044ad9edf9c1dc9b50b9591dea0b5f0cce4be01a))
|
|
||||||
* **docker:** improve icon matching ([#5693](https://github.com/homarr-labs/homarr/issues/5693)) ([7cd30b6](https://github.com/homarr-labs/homarr/commit/7cd30b640cbf47e4678abda18f83c0924f039b1b))
|
|
||||||
* exclude .env files from Docker build context ([#5738](https://github.com/homarr-labs/homarr/issues/5738)) ([fa73763](https://github.com/homarr-labs/homarr/commit/fa737638a812372d904207fe7af5bd7c6f53d159))
|
|
||||||
* **tracearr:** support absolute image urls ([#5654](https://github.com/homarr-labs/homarr/issues/5654)) ([7a12721](https://github.com/homarr-labs/homarr/commit/7a1272146c597c1f9df89d74b8389f70e7cdd61a))
|
|
||||||
|
|
||||||
### Performance Improvements
|
|
||||||
|
|
||||||
* (next branch) reduce idle memory on startup ([#5637](https://github.com/homarr-labs/homarr/issues/5637)) ([5ee84f8](https://github.com/homarr-labs/homarr/commit/5ee84f8cf464c308729281cb4db572950ba85a82))
|
|
||||||
* **boards:** remove loading overlay and show board immediately ([#5735](https://github.com/homarr-labs/homarr/issues/5735)) ([df4b57b](https://github.com/homarr-labs/homarr/commit/df4b57bd414d4402522f9210b1470d0561c37274))
|
|
||||||
* **memory:** combine 3 node processes into one ([#5600](https://github.com/homarr-labs/homarr/issues/5600)) ([a1aa550](https://github.com/homarr-labs/homarr/commit/a1aa5505a365688b456090a882770e561479833e))
|
|
||||||
|
|
||||||
## [1.61.1](https://github.com/homarr-labs/homarr/compare/v1.61.0...v1.61.1) (2026-05-18)
|
## [1.61.1](https://github.com/homarr-labs/homarr/compare/v1.61.0...v1.61.1) (2026-05-18)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
FROM node:24.16.0-alpine AS base
|
FROM node:24.15.0-alpine AS base
|
||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat
|
||||||
|
|||||||
@@ -5,21 +5,9 @@ import "@homarr/common/env";
|
|||||||
import "@homarr/core/infrastructure/logs/env";
|
import "@homarr/core/infrastructure/logs/env";
|
||||||
import "@homarr/docker/env";
|
import "@homarr/docker/env";
|
||||||
|
|
||||||
import { readFileSync } from "fs";
|
|
||||||
|
|
||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
import createNextIntlPlugin from "next-intl/plugin";
|
import createNextIntlPlugin from "next-intl/plugin";
|
||||||
|
|
||||||
const homarrVersion = (() => {
|
|
||||||
for (const candidate of ["../../package.json", "./package.json"]) {
|
|
||||||
try {
|
|
||||||
const pkg = JSON.parse(readFileSync(candidate, "utf-8"));
|
|
||||||
if (pkg.name === "homarr") return pkg.version as string;
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
return "unknown";
|
|
||||||
})();
|
|
||||||
|
|
||||||
// Package path does not work... so we need to use relative path
|
// Package path does not work... so we need to use relative path
|
||||||
const withNextIntl = createNextIntlPlugin({
|
const withNextIntl = createNextIntlPlugin({
|
||||||
experimental: {
|
experimental: {
|
||||||
@@ -29,9 +17,6 @@ const withNextIntl = createNextIntlPlugin({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
env: {
|
|
||||||
HOMARR_VERSION: homarrVersion,
|
|
||||||
},
|
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
// react compiler breaks mantine-react-table, so disabled for now
|
// react compiler breaks mantine-react-table, so disabled for now
|
||||||
@@ -42,7 +27,7 @@ const nextConfig: NextConfig = {
|
|||||||
* dockerode is required in the external server packages because of https://github.com/homarr-labs/homarr/issues/612
|
* dockerode is required in the external server packages because of https://github.com/homarr-labs/homarr/issues/612
|
||||||
* isomorphic-dompurify and jsdom are required, see https://github.com/kkomelin/isomorphic-dompurify/issues/356
|
* isomorphic-dompurify and jsdom are required, see https://github.com/kkomelin/isomorphic-dompurify/issues/356
|
||||||
*/
|
*/
|
||||||
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
|
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom"],
|
||||||
experimental: {
|
experimental: {
|
||||||
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
|
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
|
||||||
turbopackFileSystemCacheForDev: true,
|
turbopackFileSystemCacheForDev: true,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"build": "pnpm with-env next build",
|
"build": "pnpm with-env next build",
|
||||||
"clean": "git clean -xdf .next .turbo node_modules",
|
"clean": "git clean -xdf .next .turbo node_modules",
|
||||||
"dev": "pnpm with-env next dev",
|
"dev": "pnpm with-env next dev",
|
||||||
"format": "oxfmt .",
|
"format": "oxfmt --check .",
|
||||||
"lint": "oxlint",
|
"lint": "oxlint",
|
||||||
"start": "pnpm with-env next start",
|
"start": "pnpm with-env next start",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
@@ -18,7 +18,6 @@
|
|||||||
"@dnd-kit/modifiers": "catalog:",
|
"@dnd-kit/modifiers": "catalog:",
|
||||||
"@dnd-kit/sortable": "catalog:",
|
"@dnd-kit/sortable": "catalog:",
|
||||||
"@dnd-kit/utilities": "catalog:",
|
"@dnd-kit/utilities": "catalog:",
|
||||||
"@gfazioli/mantine-onboarding-tour": "catalog:",
|
|
||||||
"@homarr/analytics": "workspace:^0.1.0",
|
"@homarr/analytics": "workspace:^0.1.0",
|
||||||
"@homarr/api": "workspace:^0.1.0",
|
"@homarr/api": "workspace:^0.1.0",
|
||||||
"@homarr/auth": "workspace:^0.1.0",
|
"@homarr/auth": "workspace:^0.1.0",
|
||||||
@@ -72,13 +71,9 @@
|
|||||||
"clsx": "catalog:",
|
"clsx": "catalog:",
|
||||||
"dayjs": "catalog:",
|
"dayjs": "catalog:",
|
||||||
"dotenv": "catalog:",
|
"dotenv": "catalog:",
|
||||||
"drizzle-orm": "catalog:",
|
|
||||||
"flag-icons": "catalog:",
|
"flag-icons": "catalog:",
|
||||||
"glob": "catalog:",
|
"glob": "catalog:",
|
||||||
"adm-zip": "catalog:",
|
|
||||||
"better-sqlite3": "catalog:",
|
|
||||||
"isomorphic-dompurify": "catalog:",
|
"isomorphic-dompurify": "catalog:",
|
||||||
"jszip": "catalog:",
|
|
||||||
"jotai": "catalog:",
|
"jotai": "catalog:",
|
||||||
"mantine-react-table": "catalog:",
|
"mantine-react-table": "catalog:",
|
||||||
"next": "catalog:",
|
"next": "catalog:",
|
||||||
@@ -89,7 +84,6 @@
|
|||||||
"react-error-boundary": "catalog:",
|
"react-error-boundary": "catalog:",
|
||||||
"react-simple-code-editor": "catalog:",
|
"react-simple-code-editor": "catalog:",
|
||||||
"sass": "catalog:",
|
"sass": "catalog:",
|
||||||
"sql.js": "catalog:",
|
|
||||||
"superjson": "catalog:",
|
"superjson": "catalog:",
|
||||||
"swagger-ui-react": "catalog:",
|
"swagger-ui-react": "catalog:",
|
||||||
"use-deep-compare-effect": "catalog:",
|
"use-deep-compare-effect": "catalog:",
|
||||||
@@ -98,8 +92,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/adm-zip": "catalog:",
|
|
||||||
"@types/better-sqlite3": "catalog:",
|
|
||||||
"@types/chroma-js": "catalog:",
|
"@types/chroma-js": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@types/prismjs": "catalog:",
|
"@types/prismjs": "catalog:",
|
||||||
|
|||||||
Binary file not shown.
@@ -2,14 +2,13 @@
|
|||||||
|
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core";
|
import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core";
|
||||||
import { DirectionProvider, MantineProvider, v8CssVariablesResolver } from "@mantine/core";
|
import { createTheme, DirectionProvider, MantineProvider } from "@mantine/core";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useSession } from "@homarr/auth/client";
|
import { useSession } from "@homarr/auth/client";
|
||||||
import { parseCookies, setClientCookie } from "@homarr/common";
|
import { parseCookies, setClientCookie } from "@homarr/common";
|
||||||
import { colorSchemeCookieKey } from "@homarr/definitions";
|
import { colorSchemeCookieKey } from "@homarr/definitions";
|
||||||
import { theme } from "@homarr/ui";
|
|
||||||
|
|
||||||
export const CustomMantineProvider = ({
|
export const CustomMantineProvider = ({
|
||||||
children,
|
children,
|
||||||
@@ -21,8 +20,10 @@ export const CustomMantineProvider = ({
|
|||||||
<MantineProvider
|
<MantineProvider
|
||||||
defaultColorScheme={defaultColorScheme}
|
defaultColorScheme={defaultColorScheme}
|
||||||
colorSchemeManager={manager}
|
colorSchemeManager={manager}
|
||||||
theme={theme}
|
theme={createTheme({
|
||||||
cssVariablesResolver={v8CssVariablesResolver}
|
primaryColor: "red",
|
||||||
|
autoContrast: true,
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
|
|||||||
@@ -17,13 +17,10 @@ import {
|
|||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
import type { SuperJSONResult } from "superjson";
|
import type { SuperJSONResult } from "superjson";
|
||||||
|
|
||||||
import { TRPCClientError } from "@trpc/client";
|
|
||||||
|
|
||||||
import type { AppRouter } from "@homarr/api";
|
import type { AppRouter } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
|
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
|
||||||
import { env } from "@homarr/common/env";
|
import { env } from "@homarr/common/env";
|
||||||
import { showWarningNotification } from "@homarr/notifications";
|
|
||||||
|
|
||||||
const getWebSocketProtocol = () => {
|
const getWebSocketProtocol = () => {
|
||||||
// window is not defined on server side
|
// window is not defined on server side
|
||||||
@@ -59,20 +56,6 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
|||||||
queries: {
|
queries: {
|
||||||
staleTime: 5 * 1000,
|
staleTime: 5 * 1000,
|
||||||
},
|
},
|
||||||
mutations: {
|
|
||||||
onError(error) {
|
|
||||||
if (
|
|
||||||
error instanceof TRPCClientError &&
|
|
||||||
error.data?.code === "FORBIDDEN" &&
|
|
||||||
error.message === "Mutations are disabled in demo mode"
|
|
||||||
) {
|
|
||||||
showWarningNotification({
|
|
||||||
title: "Demo mode",
|
|
||||||
message: "This action is disabled in demo mode.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export default async function InviteUsagePage(props: InviteUsagePageProps) {
|
|||||||
{t("subtitle")}
|
{t("subtitle")}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Card w={64 * 6} maw="90vw">
|
<Card withBorder w={64 * 6} maw="90vw">
|
||||||
<RegistrationForm invite={invite} />
|
<RegistrationForm invite={invite} />
|
||||||
</Card>
|
</Card>
|
||||||
<Text size="xs" c="gray.5" ta="center">
|
<Text size="xs" c="gray.5" ta="center">
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { z } from "zod/v4";
|
|||||||
|
|
||||||
import { signIn } from "@homarr/auth/client";
|
import { signIn } from "@homarr/auth/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import type { UseFormReturnType } from "@homarr/form";
|
import type { useForm } from "@homarr/form";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
@@ -162,7 +162,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
|
|||||||
|
|
||||||
interface SubmitButtonProps {
|
interface SubmitButtonProps {
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
form: UseFormReturnType<FormType>;
|
form: ReturnType<typeof useForm<FormType, (values: FormType) => FormType>>;
|
||||||
provider: "credentials" | "ldap";
|
provider: "credentials" | "ldap";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +198,7 @@ const PasswordForgottenCollapse = ({ username }: PasswordForgottenCollapseProps)
|
|||||||
{tForgotPassword("label")}
|
{tForgotPassword("label")}
|
||||||
</Anchor>
|
</Anchor>
|
||||||
|
|
||||||
<Collapse expanded={visible}>
|
<Collapse in={visible}>
|
||||||
<Card>
|
<Card>
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Text size="sm">{tForgotPassword("description")}</Text>
|
<Text size="sm">{tForgotPassword("description")}</Text>
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { Alert, Card, Center, Code, Stack, Text, Title } from "@mantine/core";
|
import { Card, Center, Stack, Text, Title } from "@mantine/core";
|
||||||
import { IconLogin } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import { env } from "@homarr/auth/env";
|
import { env } from "@homarr/auth/env";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
|
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
|
||||||
|
|
||||||
import { env as appEnv } from "~/env";
|
|
||||||
|
|
||||||
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
|
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
|
||||||
import { LoginForm } from "./_login-form";
|
import { LoginForm } from "./_login-form";
|
||||||
|
|
||||||
@@ -40,14 +37,7 @@ export default async function Login(props: LoginProps) {
|
|||||||
{t("subtitle")}
|
{t("subtitle")}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
{appEnv.DEMO_MODE && (
|
<Card withBorder w={64 * 6} maw="90vw">
|
||||||
<Alert icon={<IconLogin size={18} />} color="blue" variant="light" w={64 * 6} maw="90vw">
|
|
||||||
<Text size="sm" fw={500}>
|
|
||||||
Demo mode is enabled. Sign in with username <Code>demo</Code> and password <Code>demo</Code>
|
|
||||||
</Text>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
<Card w={64 * 6} maw="90vw">
|
|
||||||
<LoginForm
|
<LoginForm
|
||||||
providers={env.AUTH_PROVIDERS}
|
providers={env.AUTH_PROVIDERS}
|
||||||
oidcClientName={env.AUTH_OIDC_CLIENT_NAME}
|
oidcClientName={env.AUTH_OIDC_CLIENT_NAME}
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
|||||||
layout: createBoardLayout({
|
layout: createBoardLayout({
|
||||||
headerActions: <BoardContentHeaderActions />,
|
headerActions: <BoardContentHeaderActions />,
|
||||||
getInitialBoardAsync: getInitialBoard,
|
getInitialBoardAsync: getInitialBoard,
|
||||||
withTour: true,
|
|
||||||
}),
|
}),
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
page: async ({ params }: { params: Promise<TParams> }) => {
|
page: async ({ params }: { params: Promise<TParams> }) => {
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
|
import { Group, Menu, ScrollArea } from "@mantine/core";
|
||||||
import { Box, Group, Menu, ScrollArea } from "@mantine/core";
|
|
||||||
import { useHotkeys } from "@mantine/hooks";
|
import { useHotkeys } from "@mantine/hooks";
|
||||||
import {
|
import {
|
||||||
IconBox,
|
IconBox,
|
||||||
@@ -12,7 +11,6 @@ import {
|
|||||||
IconLayoutBoard,
|
IconLayoutBoard,
|
||||||
IconPencil,
|
IconPencil,
|
||||||
IconPencilOff,
|
IconPencilOff,
|
||||||
IconPlug,
|
|
||||||
IconPlus,
|
IconPlus,
|
||||||
IconReplace,
|
IconReplace,
|
||||||
IconResize,
|
IconResize,
|
||||||
@@ -38,16 +36,14 @@ import { useBoardPermissions } from "~/components/board/permissions/client";
|
|||||||
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
|
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
|
||||||
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
|
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
|
||||||
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
|
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
|
||||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
|
||||||
import { HeaderButton } from "~/components/layout/header/button";
|
import { HeaderButton } from "~/components/layout/header/button";
|
||||||
|
|
||||||
export const BoardContentHeaderActions = () => {
|
export const BoardContentHeaderActions = () => {
|
||||||
const [isEditMode] = useEditMode();
|
const [isEditMode] = useEditMode();
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const { hasChangeAccess } = useBoardPermissions(board);
|
const { hasChangeAccess } = useBoardPermissions(board);
|
||||||
const { data: demoMode, isLoading } = clientApi.info.isDemoMode.useQuery();
|
|
||||||
|
|
||||||
if (!hasChangeAccess || isLoading) {
|
if (!hasChangeAccess) {
|
||||||
return <SelectBoardsMenu />;
|
return <SelectBoardsMenu />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,15 +51,11 @@ export const BoardContentHeaderActions = () => {
|
|||||||
<>
|
<>
|
||||||
{isEditMode && <AddMenu />}
|
{isEditMode && <AddMenu />}
|
||||||
|
|
||||||
<EditModeMenu demoMode={demoMode ?? false} />
|
<EditModeMenu />
|
||||||
|
|
||||||
{!demoMode && (
|
<HeaderButton href={`/boards/${board.name}/settings`}>
|
||||||
<OnboardingTour.Target id="board-settings">
|
<IconSettings stroke={1.5} />
|
||||||
<HeaderButton href={`/boards/${board.name}/settings`}>
|
</HeaderButton>
|
||||||
<IconSettings stroke={1.5} />
|
|
||||||
</HeaderButton>
|
|
||||||
</OnboardingTour.Target>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<SelectBoardsMenu />
|
<SelectBoardsMenu />
|
||||||
</>
|
</>
|
||||||
@@ -75,7 +67,6 @@ const AddMenu = () => {
|
|||||||
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
|
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
|
||||||
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
||||||
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
|
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
|
||||||
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
|
|
||||||
const { addCategoryToEnd } = useCategoryActions();
|
const { addCategoryToEnd } = useCategoryActions();
|
||||||
const { addDynamicSection } = useDynamicSectionActions();
|
const { addDynamicSection } = useDynamicSectionActions();
|
||||||
const { createItem } = useItemActions();
|
const { createItem } = useItemActions();
|
||||||
@@ -117,12 +108,8 @@ const AddMenu = () => {
|
|||||||
});
|
});
|
||||||
}, [openAppSelectModal, createItem]);
|
}, [openAppSelectModal, createItem]);
|
||||||
|
|
||||||
const handleAddIntegration = useCallback(() => {
|
|
||||||
openIntegrationSelectModal({});
|
|
||||||
}, [openIntegrationSelectModal]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu position="bottom-end">
|
<Menu position="bottom-end" withArrow>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<HeaderButton w="auto" px={4}>
|
<HeaderButton w="auto" px={4}>
|
||||||
<Group gap={4} wrap="nowrap">
|
<Group gap={4} wrap="nowrap">
|
||||||
@@ -140,10 +127,6 @@ const AddMenu = () => {
|
|||||||
{t("app.action.add")}
|
{t("app.action.add")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Item leftSection={<IconPlug size={20} />} onClick={handleAddIntegration}>
|
|
||||||
{t("integration.action.create")}
|
|
||||||
</Menu.Item>
|
|
||||||
|
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|
||||||
<Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}>
|
<Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}>
|
||||||
@@ -158,7 +141,7 @@ const AddMenu = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const EditModeMenu = ({ demoMode }: { demoMode: boolean }) => {
|
const EditModeMenu = () => {
|
||||||
const [isEditMode, { open, close }] = useEditMode();
|
const [isEditMode, { open, close }] = useEditMode();
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const utils = clientApi.useUtils();
|
const utils = clientApi.useUtils();
|
||||||
@@ -181,28 +164,18 @@ const EditModeMenu = ({ demoMode }: { demoMode: boolean }) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const discardDemoChanges = useCallback(() => {
|
|
||||||
void utils.board.getBoardByName.invalidate({ name: board.name });
|
|
||||||
close();
|
|
||||||
}, [utils, board.name, close]);
|
|
||||||
|
|
||||||
const toggle = useCallback(() => {
|
const toggle = useCallback(() => {
|
||||||
if (isEditMode) {
|
if (isEditMode) return saveBoard(board);
|
||||||
if (demoMode) return discardDemoChanges();
|
|
||||||
return saveBoard(board);
|
|
||||||
}
|
|
||||||
open();
|
open();
|
||||||
}, [board, isEditMode, demoMode, saveBoard, open, discardDemoChanges]);
|
}, [board, isEditMode, saveBoard, open]);
|
||||||
|
|
||||||
useHotkeys([[hotkeys.toggleBoardEdit, toggle]]);
|
useHotkeys([[hotkeys.toggleBoardEdit, toggle]]);
|
||||||
usePreventLeaveWithDirty(isEditMode);
|
usePreventLeaveWithDirty(isEditMode);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OnboardingTour.Target id="board-edit-mode">
|
<HeaderButton onClick={toggle} loading={isPending}>
|
||||||
<HeaderButton onClick={toggle} loading={isPending}>
|
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
|
||||||
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
|
</HeaderButton>
|
||||||
</HeaderButton>
|
|
||||||
</OnboardingTour.Target>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -210,31 +183,27 @@ const SelectBoardsMenu = () => {
|
|||||||
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
|
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OnboardingTour.Target id="board-switcher">
|
<Menu position="bottom-end" withArrow>
|
||||||
<Box>
|
<Menu.Target>
|
||||||
<Menu position="bottom-end">
|
<HeaderButton w="auto" px={4}>
|
||||||
<Menu.Target>
|
<IconReplace stroke={1.5} />
|
||||||
<HeaderButton w="auto" px={4}>
|
</HeaderButton>
|
||||||
<IconReplace stroke={1.5} />
|
</Menu.Target>
|
||||||
</HeaderButton>
|
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
|
||||||
</Menu.Target>
|
<ScrollArea.Autosize mah={300}>
|
||||||
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
|
{boards.map((board) => (
|
||||||
<ScrollArea.Autosize mah={300}>
|
<Menu.Item
|
||||||
{boards.map((board) => (
|
key={board.id}
|
||||||
<Menu.Item
|
component={Link}
|
||||||
key={board.id}
|
href={`/boards/${board.name}`}
|
||||||
component={Link}
|
leftSection={<IconLayoutBoard size={20} />}
|
||||||
href={`/boards/${board.name}`}
|
>
|
||||||
leftSection={<IconLayoutBoard size={20} />}
|
{board.name}
|
||||||
>
|
</Menu.Item>
|
||||||
{board.name}
|
))}
|
||||||
</Menu.Item>
|
</ScrollArea.Autosize>
|
||||||
))}
|
</Menu.Dropdown>
|
||||||
</ScrollArea.Autosize>
|
</Menu>
|
||||||
</Menu.Dropdown>
|
|
||||||
</Menu>
|
|
||||||
</Box>
|
|
||||||
</OnboardingTour.Target>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -275,7 +244,7 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||||
if (env.NODE_ENV === "development") return;
|
if (env.NODE_ENV === "development") return; // Allow to reload in development
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.returnValue = true;
|
event.returnValue = true;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import type { MantineColorsTuple } from "@mantine/core";
|
import type { MantineColorsTuple } from "@mantine/core";
|
||||||
import { colorsTuple, createTheme, darken, lighten, MantineProvider, rem, v8CssVariablesResolver } from "@mantine/core";
|
import { colorsTuple, createTheme, darken, lighten, MantineProvider, rem } from "@mantine/core";
|
||||||
|
|
||||||
import { useRequiredBoard } from "@homarr/boards/context";
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
import type { ColorScheme } from "@homarr/definitions";
|
import type { ColorScheme } from "@homarr/definitions";
|
||||||
@@ -24,7 +24,6 @@ export const BoardMantineProvider = ({
|
|||||||
},
|
},
|
||||||
primaryColor: "primaryColor",
|
primaryColor: "primaryColor",
|
||||||
autoContrast: true,
|
autoContrast: true,
|
||||||
defaultRadius: "sm",
|
|
||||||
fontSizes: {
|
fontSizes: {
|
||||||
"2xl": rem(24),
|
"2xl": rem(24),
|
||||||
"3xl": rem(28),
|
"3xl": rem(28),
|
||||||
@@ -33,12 +32,7 @@ export const BoardMantineProvider = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MantineProvider
|
<MantineProvider defaultColorScheme={defaultColorScheme} theme={theme} colorSchemeManager={colorSchemeManager}>
|
||||||
defaultColorScheme={defaultColorScheme}
|
|
||||||
theme={theme}
|
|
||||||
colorSchemeManager={colorSchemeManager}
|
|
||||||
cssVariablesResolver={v8CssVariablesResolver}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export const ColorSettingsContent = ({ board }: Props) => {
|
|||||||
<Anchor onClick={toggle}>{showPreview ? t("common.preview.hide") : t("common.preview.show")}</Anchor>
|
<Anchor onClick={toggle}>{showPreview ? t("common.preview.hide") : t("common.preview.show")}</Anchor>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
<Grid.Col span={12}>
|
<Grid.Col span={12}>
|
||||||
<Collapse expanded={showPreview}>
|
<Collapse in={showPreview}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<ColorsPreview previewColor={form.values.primaryColor} />
|
<ColorsPreview previewColor={form.values.primaryColor} />
|
||||||
<ColorsPreview previewColor={form.values.secondaryColor} />
|
<ColorsPreview previewColor={form.values.secondaryColor} />
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
|
|||||||
import { MainHeader } from "~/components/layout/header";
|
import { MainHeader } from "~/components/layout/header";
|
||||||
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
|
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
|
||||||
import { ClientShell } from "~/components/layout/shell";
|
import { ClientShell } from "~/components/layout/shell";
|
||||||
import { BoardTourProvider } from "~/components/onboarding/board-tour";
|
|
||||||
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
||||||
import type { Board } from "./_types";
|
import type { Board } from "./_types";
|
||||||
import type { Params } from "./(content)/_creator";
|
import type { Params } from "./(content)/_creator";
|
||||||
@@ -21,21 +20,14 @@ import { BoardMantineProvider } from "./(content)/_theme";
|
|||||||
|
|
||||||
const logger = createLogger({ module: "createBoardLayout" });
|
const logger = createLogger({ module: "createBoardLayout" });
|
||||||
|
|
||||||
const BoardTourWrapper = ({ hasSession, children }: PropsWithChildren<{ hasSession: boolean }>) => {
|
|
||||||
if (!hasSession) return <>{children}</>;
|
|
||||||
return <BoardTourProvider>{children}</BoardTourProvider>;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface CreateBoardLayoutProps<TParams extends Params> {
|
interface CreateBoardLayoutProps<TParams extends Params> {
|
||||||
headerActions: JSX.Element;
|
headerActions: JSX.Element;
|
||||||
getInitialBoardAsync: (params: TParams) => Promise<Board>;
|
getInitialBoardAsync: (params: TParams) => Promise<Board>;
|
||||||
withTour?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createBoardLayout = <TParams extends Params>({
|
export const createBoardLayout = <TParams extends Params>({
|
||||||
headerActions,
|
headerActions,
|
||||||
getInitialBoardAsync: getInitialBoard,
|
getInitialBoardAsync: getInitialBoard,
|
||||||
withTour = false,
|
|
||||||
}: CreateBoardLayoutProps<TParams>) => {
|
}: CreateBoardLayoutProps<TParams>) => {
|
||||||
const Layout = async ({
|
const Layout = async ({
|
||||||
params,
|
params,
|
||||||
@@ -69,16 +61,14 @@ export const createBoardLayout = <TParams extends Params>({
|
|||||||
<EditModeProvider>
|
<EditModeProvider>
|
||||||
<BoardMantineProvider defaultColorScheme={colorScheme}>
|
<BoardMantineProvider defaultColorScheme={colorScheme}>
|
||||||
<CustomCss />
|
<CustomCss />
|
||||||
<BoardTourWrapper hasSession={withTour && !!session}>
|
<ClientShell hasNavigation={false}>
|
||||||
<ClientShell hasNavigation={false}>
|
<MainHeader
|
||||||
<MainHeader
|
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||||
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
actions={headerActions}
|
||||||
actions={headerActions}
|
hasNavigation={false}
|
||||||
hasNavigation={false}
|
/>
|
||||||
/>
|
<AppShellMain>{children}</AppShellMain>
|
||||||
<AppShellMain>{children}</AppShellMain>
|
</ClientShell>
|
||||||
</ClientShell>
|
|
||||||
</BoardTourWrapper>
|
|
||||||
</BoardMantineProvider>
|
</BoardMantineProvider>
|
||||||
</EditModeProvider>
|
</EditModeProvider>
|
||||||
</BoardReadyProvider>
|
</BoardReadyProvider>
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
import type { PropsWithChildren } from "react";
|
|
||||||
import { Card } from "@mantine/core";
|
|
||||||
|
|
||||||
interface InitStepCardProps {
|
|
||||||
withBorder?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const InitStepCard = ({ children, withBorder = true }: PropsWithChildren<InitStepCardProps>) => (
|
|
||||||
<Card w="100%" withBorder={withBorder}>
|
|
||||||
{children}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export const onboardingContentWidth = 64 * 16;
|
|
||||||
export const onboardingMaxWidth = "90vw";
|
|
||||||
@@ -1,27 +1,39 @@
|
|||||||
import type { MantineColor } from "@mantine/core";
|
import type { MantineColor } from "@mantine/core";
|
||||||
import { Button, Stack, Text } from "@mantine/core";
|
import { Button, Card, Stack, Text } from "@mantine/core";
|
||||||
import { IconBook2, IconLayoutDashboard, IconMailForward } from "@tabler/icons-react";
|
import { IconBook2, IconCategoryPlus, IconLayoutDashboard, IconMailForward } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { isProviderEnabled } from "@homarr/auth/server";
|
import { isProviderEnabled } from "@homarr/auth/server";
|
||||||
import { getMantineColor } from "@homarr/common";
|
import { getMantineColor } from "@homarr/common";
|
||||||
|
import { db } from "@homarr/db";
|
||||||
import { createDocumentationLink } from "@homarr/definitions";
|
import { createDocumentationLink } from "@homarr/definitions";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
import { Link } from "@homarr/ui";
|
import { Link } from "@homarr/ui";
|
||||||
import type { TablerIcon } from "@homarr/ui";
|
import type { TablerIcon } from "@homarr/ui";
|
||||||
|
|
||||||
import { InitStepCard } from "../../_components/init-step-card";
|
|
||||||
|
|
||||||
export const InitFinish = async () => {
|
export const InitFinish = async () => {
|
||||||
|
const firstBoard = await db.query.boards.findFirst({ columns: { name: true } });
|
||||||
const tFinish = await getScopedI18n("init.step.finish");
|
const tFinish = await getScopedI18n("init.step.finish");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InitStepCard>
|
<Card w={64 * 6} maw="90vw" withBorder>
|
||||||
<Stack>
|
<Stack>
|
||||||
<Text>{tFinish("description")}</Text>
|
<Text>{tFinish("description")}</Text>
|
||||||
|
|
||||||
<InternalLinkButton href="/auth/login?callbackUrl=/" iconProps={{ icon: IconLayoutDashboard, color: "blue" }}>
|
{firstBoard ? (
|
||||||
{tFinish("action.goToBoard", { name: "dashboard" })}
|
<InternalLinkButton
|
||||||
</InternalLinkButton>
|
href={`/auth/login?callbackUrl=/boards/${firstBoard.name}`}
|
||||||
|
iconProps={{ icon: IconLayoutDashboard, color: "blue" }}
|
||||||
|
>
|
||||||
|
{tFinish("action.goToBoard", { name: firstBoard.name })}
|
||||||
|
</InternalLinkButton>
|
||||||
|
) : (
|
||||||
|
<InternalLinkButton
|
||||||
|
href="/auth/login?callbackUrl=/manage/boards"
|
||||||
|
iconProps={{ icon: IconCategoryPlus, color: "blue" }}
|
||||||
|
>
|
||||||
|
{tFinish("action.createBoard")}
|
||||||
|
</InternalLinkButton>
|
||||||
|
)}
|
||||||
|
|
||||||
{isProviderEnabled("credentials") && (
|
{isProviderEnabled("credentials") && (
|
||||||
<InternalLinkButton
|
<InternalLinkButton
|
||||||
@@ -39,7 +51,7 @@ export const InitFinish = async () => {
|
|||||||
{tFinish("action.docs")}
|
{tFinish("action.docs")}
|
||||||
</ExternalLinkButton>
|
</ExternalLinkButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
</InitStepCard>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button, Stack, TextInput } from "@mantine/core";
|
import { Button, Card, Stack, TextInput } from "@mantine/core";
|
||||||
import { IconArrowRight } from "@tabler/icons-react";
|
import { IconArrowRight } from "@tabler/icons-react";
|
||||||
import type { z } from "zod/v4";
|
import type { z } from "zod/v4";
|
||||||
|
|
||||||
@@ -10,8 +10,6 @@ import { useZodForm } from "@homarr/form";
|
|||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { groupCreateSchema } from "@homarr/validation/group";
|
import { groupCreateSchema } from "@homarr/validation/group";
|
||||||
|
|
||||||
import { InitStepCard } from "../../_components/init-step-card";
|
|
||||||
|
|
||||||
export const InitGroup = () => {
|
export const InitGroup = () => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { mutateAsync } = clientApi.group.createInitialExternalGroup.useMutation({
|
const { mutateAsync } = clientApi.group.createInitialExternalGroup.useMutation({
|
||||||
@@ -36,7 +34,7 @@ export const InitGroup = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InitStepCard>
|
<Card w={64 * 6} maw="90vw" withBorder>
|
||||||
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
|
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -50,6 +48,6 @@ export const InitGroup = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
</InitStepCard>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
import { ActionIcon, Button, Group, Text } from "@mantine/core";
|
import { ActionIcon, Button, Card, Group, Text } from "@mantine/core";
|
||||||
import type { FileWithPath } from "@mantine/dropzone";
|
import type { FileWithPath } from "@mantine/dropzone";
|
||||||
import { IconPencil } from "@tabler/icons-react";
|
import { IconPencil } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { humanFileSize } from "@homarr/common";
|
import { humanFileSize } from "@homarr/common";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { InitStepCard } from "../../_components/init-step-card";
|
|
||||||
|
|
||||||
interface FileInfoCardProps {
|
interface FileInfoCardProps {
|
||||||
file: FileWithPath;
|
file: FileWithPath;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
@@ -15,7 +13,7 @@ interface FileInfoCardProps {
|
|||||||
export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => {
|
export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => {
|
||||||
const tFileInfo = useScopedI18n("init.step.import.fileInfo");
|
const tFileInfo = useScopedI18n("init.step.import.fileInfo");
|
||||||
return (
|
return (
|
||||||
<InitStepCard withBorder={false}>
|
<Card w={64 * 12 + 8} maw="90vw">
|
||||||
<Group justify="space-between" align="center" wrap="nowrap">
|
<Group justify="space-between" align="center" wrap="nowrap">
|
||||||
<Group>
|
<Group>
|
||||||
<Text fw={500} lineClamp={1} style={{ wordBreak: "break-all" }}>
|
<Text fw={500} lineClamp={1} style={{ wordBreak: "break-all" }}>
|
||||||
@@ -38,6 +36,6 @@ export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => {
|
|||||||
<IconPencil size={16} stroke={1.5} />
|
<IconPencil size={16} stroke={1.5} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Group>
|
</Group>
|
||||||
</InitStepCard>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { startTransition, useState } from "react";
|
import { startTransition, useState } from "react";
|
||||||
import { Stack } from "@mantine/core";
|
import { Card, Stack } from "@mantine/core";
|
||||||
import type { FileWithPath } from "@mantine/dropzone";
|
import type { FileWithPath } from "@mantine/dropzone";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { InitialOldmarrImport } from "@homarr/old-import/components";
|
import { InitialOldmarrImport } from "@homarr/old-import/components";
|
||||||
|
|
||||||
import { InitStepCard } from "../../_components/init-step-card";
|
|
||||||
import { FileInfoCard } from "./file-info-card";
|
import { FileInfoCard } from "./file-info-card";
|
||||||
import { ImportDropZone } from "./import-dropzone";
|
import { ImportDropZone } from "./import-dropzone";
|
||||||
|
|
||||||
@@ -21,7 +20,7 @@ export const InitImport = () => {
|
|||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return (
|
return (
|
||||||
<InitStepCard>
|
<Card w={64 * 12 + 8} maw="90vw" withBorder>
|
||||||
<ImportDropZone
|
<ImportDropZone
|
||||||
loading={isPending}
|
loading={isPending}
|
||||||
updateFile={(file) => {
|
updateFile={(file) => {
|
||||||
@@ -41,12 +40,12 @@ export const InitImport = () => {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</InitStepCard>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack w="100%" mb="sm">
|
<Stack mb="sm">
|
||||||
<FileInfoCard file={file} onRemove={() => setFile(null)} />
|
<FileInfoCard file={file} onRemove={() => setFile(null)} />
|
||||||
{analyseResult !== null && <InitialOldmarrImport file={file} analyseResult={analyseResult} />}
|
{analyseResult !== null && <InitialOldmarrImport file={file} analyseResult={analyseResult} />}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,209 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import {
|
|
||||||
Avatar,
|
|
||||||
Badge,
|
|
||||||
Card,
|
|
||||||
Collapse,
|
|
||||||
Group,
|
|
||||||
Loader,
|
|
||||||
Paper,
|
|
||||||
ScrollArea,
|
|
||||||
SimpleGrid,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
ThemeIcon,
|
|
||||||
UnstyledButton,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { IconBrandDocker, IconCheck, IconChevronDown } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import type { IntegrationKind } from "@homarr/definitions";
|
|
||||||
import { getIntegrationName } from "@homarr/definitions";
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
|
||||||
import { IntegrationAvatar } from "@homarr/ui";
|
|
||||||
|
|
||||||
export interface DiscoveredIntegration {
|
|
||||||
containerId: string;
|
|
||||||
containerName: string;
|
|
||||||
kind: IntegrationKind;
|
|
||||||
suggestedUrl: string;
|
|
||||||
publishedPort: number | null;
|
|
||||||
iconUrl: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DiscoveredApp {
|
|
||||||
containerId: string;
|
|
||||||
containerName: string;
|
|
||||||
suggestedUrl: string;
|
|
||||||
publishedPort: number | null;
|
|
||||||
iconUrl: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DockerDiscoveryIndicatorProps {
|
|
||||||
integrations: DiscoveredIntegration[];
|
|
||||||
apps: DiscoveredApp[];
|
|
||||||
selectedAppIds: Set<string>;
|
|
||||||
onToggleApp: (containerId: string) => void;
|
|
||||||
isLoading: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const APP_CARD_HEIGHT = 80;
|
|
||||||
|
|
||||||
export const DockerDiscoveryIndicator = ({
|
|
||||||
integrations,
|
|
||||||
apps,
|
|
||||||
selectedAppIds,
|
|
||||||
onToggleApp,
|
|
||||||
isLoading,
|
|
||||||
}: DockerDiscoveryIndicatorProps) => {
|
|
||||||
const hasApps = apps.length > 0;
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
|
|
||||||
const expandedOnce = useRef(false);
|
|
||||||
useEffect(() => {
|
|
||||||
if ((hasApps || integrations.length > 0) && !expandedOnce.current) {
|
|
||||||
expandedOnce.current = true;
|
|
||||||
setExpanded(true);
|
|
||||||
}
|
|
||||||
}, [hasApps, integrations.length]);
|
|
||||||
|
|
||||||
const t = useScopedI18n("init.step.integrations.docker");
|
|
||||||
const totalCount = integrations.length + apps.length;
|
|
||||||
|
|
||||||
if (!isLoading && totalCount === 0) return null;
|
|
||||||
|
|
||||||
const selectedAppCount = apps.filter((app) => selectedAppIds.has(app.containerId)).length;
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<Paper
|
|
||||||
withBorder
|
|
||||||
p="sm"
|
|
||||||
radius="md"
|
|
||||||
style={{
|
|
||||||
borderColor: "var(--mantine-color-blue-4)",
|
|
||||||
background: "var(--mantine-color-blue-light)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Group gap="sm" wrap="nowrap">
|
|
||||||
<ThemeIcon variant="light" color="blue" size="lg" radius="md">
|
|
||||||
<IconBrandDocker size={20} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Stack gap={0} style={{ flex: 1 }}>
|
|
||||||
<Text size="sm" fw={600}>
|
|
||||||
{t("scanning")}
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{t("scanningDescription")}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
<Loader size="sm" color="blue" type="dots" />
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper
|
|
||||||
withBorder
|
|
||||||
p="sm"
|
|
||||||
radius="md"
|
|
||||||
style={{
|
|
||||||
borderColor: "var(--mantine-color-blue-4)",
|
|
||||||
background: "var(--mantine-color-blue-light)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<UnstyledButton onClick={() => setExpanded((prev) => !prev)} w="100%">
|
|
||||||
<Group justify="space-between" wrap="nowrap">
|
|
||||||
<Group gap="sm" wrap="nowrap">
|
|
||||||
<ThemeIcon variant="light" color="blue" size="lg" radius="md">
|
|
||||||
<IconBrandDocker size={20} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Stack gap={0}>
|
|
||||||
<Text size="sm" fw={600}>
|
|
||||||
{t("detected", { count: String(totalCount) })}
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{t("detectedBreakdown", {
|
|
||||||
integrations: String(integrations.length),
|
|
||||||
apps: String(apps.length),
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
|
||||||
<IconChevronDown
|
|
||||||
size={16}
|
|
||||||
color="var(--mantine-color-dimmed)"
|
|
||||||
style={{
|
|
||||||
transform: expanded ? "rotate(180deg)" : undefined,
|
|
||||||
transition: "transform 200ms ease",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
</UnstyledButton>
|
|
||||||
|
|
||||||
<Collapse expanded={expanded}>
|
|
||||||
<Stack gap="xs" mt="sm">
|
|
||||||
{integrations.length > 0 && (
|
|
||||||
<Stack gap={4}>
|
|
||||||
{integrations.map((integration) => (
|
|
||||||
<Group key={integration.containerId} gap="xs" wrap="nowrap" py={2}>
|
|
||||||
<IntegrationAvatar kind={integration.kind} size="xs" />
|
|
||||||
<Text size="xs" fw={500} lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
{integration.containerName}
|
|
||||||
</Text>
|
|
||||||
<Badge size="xs" variant="light" color="blue">
|
|
||||||
{getIntegrationName(integration.kind)}
|
|
||||||
</Badge>
|
|
||||||
</Group>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasApps && (
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Text size="xs" fw={500}>
|
|
||||||
{t("appSectionLabel", { count: String(selectedAppCount) })}
|
|
||||||
</Text>
|
|
||||||
<ScrollArea.Autosize mah="30vh">
|
|
||||||
<SimpleGrid cols={{ base: 3, xs: 4, sm: 5 }} spacing="xs">
|
|
||||||
{apps.map((app) => {
|
|
||||||
const isSelected = selectedAppIds.has(app.containerId);
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
key={app.containerId}
|
|
||||||
h={APP_CARD_HEIGHT}
|
|
||||||
p="xs"
|
|
||||||
withBorder
|
|
||||||
style={{
|
|
||||||
cursor: "pointer",
|
|
||||||
borderColor: isSelected ? "var(--mantine-color-blue-6)" : undefined,
|
|
||||||
borderWidth: isSelected ? 2 : undefined,
|
|
||||||
opacity: isSelected ? 1 : 0.6,
|
|
||||||
}}
|
|
||||||
onClick={() => onToggleApp(app.containerId)}
|
|
||||||
>
|
|
||||||
<Stack justify="space-between" h="100%" gap={4} align="center">
|
|
||||||
<Group justify="space-between" w="100%" wrap="nowrap">
|
|
||||||
<Text size="xs" fw={500} lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
{app.containerName}
|
|
||||||
</Text>
|
|
||||||
{isSelected && <IconCheck size={14} color="var(--mantine-color-blue-6)" />}
|
|
||||||
</Group>
|
|
||||||
<Avatar size="sm" radius="sm" src={app.iconUrl} styles={{ image: { objectFit: "contain" } }}>
|
|
||||||
{app.containerName.at(0)?.toUpperCase()}
|
|
||||||
</Avatar>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SimpleGrid>
|
|
||||||
</ScrollArea.Autosize>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Collapse>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,483 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import {
|
|
||||||
ActionIcon,
|
|
||||||
Alert,
|
|
||||||
Avatar,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Center,
|
|
||||||
Collapse,
|
|
||||||
Group,
|
|
||||||
Loader,
|
|
||||||
Paper,
|
|
||||||
Progress,
|
|
||||||
SegmentedControl,
|
|
||||||
SimpleGrid,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
ThemeIcon,
|
|
||||||
Tooltip,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
|
||||||
import {
|
|
||||||
IconAlertTriangle,
|
|
||||||
IconArrowRight,
|
|
||||||
IconBrandDocker,
|
|
||||||
IconCheck,
|
|
||||||
IconChevronDown,
|
|
||||||
IconSettings,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
import type { IntegrationKind, UrlTemplateMode } from "@homarr/definitions";
|
|
||||||
import { buildAppUrl, buildIntegrationUrl, getIntegrationName } from "@homarr/definitions";
|
|
||||||
import { showErrorNotification } from "@homarr/notifications";
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
|
||||||
import { IntegrationAvatar } from "@homarr/ui";
|
|
||||||
|
|
||||||
import { NewIntegrationForm } from "~/app/[locale]/manage/integrations/new/_integration-new-form";
|
|
||||||
import { IntegrationMultiSelectGrid } from "~/components/integration/integration-multi-select-grid";
|
|
||||||
import { InitStepCard } from "../../_components/init-step-card";
|
|
||||||
|
|
||||||
type Phase = "select" | "configure" | "done";
|
|
||||||
|
|
||||||
interface IntegrationDraft {
|
|
||||||
kind: IntegrationKind;
|
|
||||||
initialUrl: string;
|
|
||||||
initialName?: string;
|
|
||||||
dockerPort?: number | null;
|
|
||||||
source: "manual" | "docker";
|
|
||||||
}
|
|
||||||
|
|
||||||
export const InitIntegrations = () => {
|
|
||||||
const [phase, setPhase] = useState<Phase>("select");
|
|
||||||
const [drafts, setDrafts] = useState<Map<IntegrationKind, IntegrationDraft>>(new Map());
|
|
||||||
const [selectedKinds, setSelectedKinds] = useState<IntegrationKind[]>([]);
|
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
|
||||||
const [baseHost, setBaseHost] = useState("");
|
|
||||||
const [urlMode, setUrlMode] = useState<UrlTemplateMode>("subdomain");
|
|
||||||
const t = useScopedI18n("init.step.integrations");
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { mutateAsync: setupIntegrations } = clientApi.onboard.setupIntegrations.useMutation();
|
|
||||||
const { mutateAsync: createApps } = clientApi.onboard.createAppsFromDiscovery.useMutation();
|
|
||||||
|
|
||||||
const { data: dockerData, isPending: isDockerLoading } = clientApi.onboard.discoverDockerServices.useQuery(
|
|
||||||
undefined,
|
|
||||||
{
|
|
||||||
retry: false,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const dockerIntegrations = dockerData?.status === "success" ? dockerData.integrations : [];
|
|
||||||
const dockerApps = dockerData?.status === "success" ? dockerData.apps : [];
|
|
||||||
const totalDetected = dockerIntegrations.length + dockerApps.length;
|
|
||||||
|
|
||||||
const detectedKinds = useMemo(() => new Set(dockerIntegrations.map((i) => i.kind)), [dockerIntegrations]);
|
|
||||||
|
|
||||||
const [selectedAppIds, setSelectedAppIds] = useState<Set<string>>(new Set());
|
|
||||||
const [appsExpanded, setAppsExpanded] = useState(false);
|
|
||||||
|
|
||||||
const dockerApplied = useRef(false);
|
|
||||||
useEffect(() => {
|
|
||||||
if (dockerData?.status !== "success" || dockerApplied.current) return;
|
|
||||||
dockerApplied.current = true;
|
|
||||||
|
|
||||||
const newDrafts = new Map(drafts);
|
|
||||||
const newKinds = new Set(selectedKinds);
|
|
||||||
|
|
||||||
for (const item of dockerData.integrations) {
|
|
||||||
if (!newDrafts.has(item.kind)) {
|
|
||||||
newDrafts.set(item.kind, {
|
|
||||||
kind: item.kind,
|
|
||||||
initialUrl: item.suggestedUrl,
|
|
||||||
initialName: item.containerName,
|
|
||||||
dockerPort: item.publishedPort,
|
|
||||||
source: "docker",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
newKinds.add(item.kind);
|
|
||||||
}
|
|
||||||
|
|
||||||
setDrafts(newDrafts);
|
|
||||||
setSelectedKinds(Array.from(newKinds));
|
|
||||||
setSelectedAppIds(new Set(dockerData.apps.map((app) => app.containerId)));
|
|
||||||
if (dockerData.apps.length > 0) {
|
|
||||||
setAppsExpanded(true);
|
|
||||||
}
|
|
||||||
}, [dockerData]);
|
|
||||||
|
|
||||||
const handleSelectionChange = useCallback((kinds: IntegrationKind[]) => {
|
|
||||||
setSelectedKinds(kinds);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleToggleApp = useCallback((containerId: string) => {
|
|
||||||
setSelectedAppIds((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(containerId)) {
|
|
||||||
next.delete(containerId);
|
|
||||||
} else {
|
|
||||||
next.add(containerId);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleContinueToConfig = () => {
|
|
||||||
const newDrafts = new Map(drafts);
|
|
||||||
for (const kind of selectedKinds) {
|
|
||||||
const existing = newDrafts.get(kind);
|
|
||||||
if (baseHost) {
|
|
||||||
newDrafts.set(kind, {
|
|
||||||
kind,
|
|
||||||
initialUrl: buildIntegrationUrl(kind, baseHost, urlMode, existing?.dockerPort ?? undefined),
|
|
||||||
initialName: existing?.initialName,
|
|
||||||
dockerPort: existing?.dockerPort,
|
|
||||||
source: existing?.source ?? "manual",
|
|
||||||
});
|
|
||||||
} else if (!existing) {
|
|
||||||
newDrafts.set(kind, { kind, initialUrl: "", source: "manual" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setDrafts(newDrafts);
|
|
||||||
setCurrentIndex(0);
|
|
||||||
setPhase("configure");
|
|
||||||
};
|
|
||||||
|
|
||||||
const finishSetupAsync = async () => {
|
|
||||||
setPhase("done");
|
|
||||||
try {
|
|
||||||
const appsToCreate = dockerApps.filter((app) => selectedAppIds.has(app.containerId));
|
|
||||||
if (appsToCreate.length > 0) {
|
|
||||||
await createApps(
|
|
||||||
appsToCreate.map((app) => {
|
|
||||||
const href = baseHost
|
|
||||||
? buildAppUrl(app.containerName, baseHost, urlMode, app.publishedPort ?? undefined)
|
|
||||||
: app.suggestedUrl;
|
|
||||||
return {
|
|
||||||
name: app.containerName,
|
|
||||||
href: href || null,
|
|
||||||
iconUrl: app.iconUrl,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await setupIntegrations();
|
|
||||||
router.refresh();
|
|
||||||
} catch {
|
|
||||||
showErrorNotification({
|
|
||||||
title: t("configure.error.title"),
|
|
||||||
message: t("configure.error.message"),
|
|
||||||
});
|
|
||||||
setPhase("select");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const advanceOrFinish = () => {
|
|
||||||
if (currentIndex + 1 >= selectedKinds.length) {
|
|
||||||
void finishSetupAsync();
|
|
||||||
} else {
|
|
||||||
setCurrentIndex((i) => i + 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const backToSelect = () => {
|
|
||||||
setPhase("select");
|
|
||||||
setCurrentIndex(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (phase === "done") {
|
|
||||||
return (
|
|
||||||
<InitStepCard>
|
|
||||||
<Center py="xl">
|
|
||||||
<Stack align="center" gap="md">
|
|
||||||
<Loader size="lg" />
|
|
||||||
<Text>{t("configure.finishing")}</Text>
|
|
||||||
</Stack>
|
|
||||||
</Center>
|
|
||||||
</InitStepCard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (phase === "configure") {
|
|
||||||
return (
|
|
||||||
<ConfigurePhase
|
|
||||||
selectedKinds={selectedKinds}
|
|
||||||
currentIndex={currentIndex}
|
|
||||||
drafts={drafts}
|
|
||||||
baseHost={baseHost}
|
|
||||||
setBaseHost={setBaseHost}
|
|
||||||
urlMode={urlMode}
|
|
||||||
setUrlMode={setUrlMode}
|
|
||||||
onSuccess={advanceOrFinish}
|
|
||||||
onCancel={backToSelect}
|
|
||||||
onSkip={advanceOrFinish}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedAppCount = dockerApps.filter((app) => selectedAppIds.has(app.containerId)).length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<InitStepCard>
|
|
||||||
<Stack>
|
|
||||||
<Text>{t("description")}</Text>
|
|
||||||
|
|
||||||
<Alert variant="outline" color="orange" icon={<IconAlertTriangle />} title={t("urlTemplate.label")}>
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Text size="sm" fw={500}>
|
|
||||||
{t("urlTemplate.warning")}
|
|
||||||
</Text>
|
|
||||||
<Group gap="sm" grow>
|
|
||||||
<TextInput
|
|
||||||
placeholder={t("urlTemplate.placeholder")}
|
|
||||||
value={baseHost}
|
|
||||||
onChange={(e) => setBaseHost(e.currentTarget.value)}
|
|
||||||
data-autofocus
|
|
||||||
/>
|
|
||||||
<SegmentedControl
|
|
||||||
value={urlMode}
|
|
||||||
onChange={(value) => setUrlMode(value as UrlTemplateMode)}
|
|
||||||
data={[
|
|
||||||
{ label: t("urlTemplate.mode.subdomain"), value: "subdomain" },
|
|
||||||
{ label: t("urlTemplate.mode.hostPort"), value: "hostPort" },
|
|
||||||
]}
|
|
||||||
style={{ flex: "0 0 auto" }}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
{baseHost && (
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{t("urlTemplate.preview", { example: buildIntegrationUrl("sonarr", baseHost, urlMode) })}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
{isDockerLoading && (
|
|
||||||
<Paper withBorder p="sm" radius="md">
|
|
||||||
<Group gap="sm" wrap="nowrap">
|
|
||||||
<ThemeIcon variant="light" color="blue" size="lg" radius="md">
|
|
||||||
<IconBrandDocker size={20} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text size="sm" fw={500}>
|
|
||||||
{t("docker.scanning")}
|
|
||||||
</Text>
|
|
||||||
<Loader size="sm" color="blue" type="dots" />
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isDockerLoading && totalDetected > 0 && (
|
|
||||||
<Paper withBorder p="sm" radius="md" style={{ borderColor: "var(--mantine-color-teal-4)" }}>
|
|
||||||
<Group gap="sm" wrap="nowrap">
|
|
||||||
<ThemeIcon variant="light" color="teal" size="lg" radius="md">
|
|
||||||
<IconBrandDocker size={20} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text size="sm">
|
|
||||||
{t("docker.summary", {
|
|
||||||
apps: String(totalDetected),
|
|
||||||
integrations: String(dockerIntegrations.length),
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<IntegrationMultiSelectGrid
|
|
||||||
selectedKinds={selectedKinds}
|
|
||||||
onSelectionChange={handleSelectionChange}
|
|
||||||
detectedKinds={detectedKinds}
|
|
||||||
onboarding
|
|
||||||
/>
|
|
||||||
|
|
||||||
{dockerApps.length > 0 && (
|
|
||||||
<Paper withBorder p="sm" radius="md">
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Group
|
|
||||||
justify="space-between"
|
|
||||||
wrap="nowrap"
|
|
||||||
style={{ cursor: "pointer" }}
|
|
||||||
onClick={() => setAppsExpanded((prev) => !prev)}
|
|
||||||
>
|
|
||||||
<Text size="sm" fw={500}>
|
|
||||||
{t("docker.appSectionLabel", { count: String(selectedAppCount) })}
|
|
||||||
</Text>
|
|
||||||
<IconChevronDown
|
|
||||||
size={16}
|
|
||||||
color="var(--mantine-color-dimmed)"
|
|
||||||
style={{
|
|
||||||
transform: appsExpanded ? "rotate(180deg)" : undefined,
|
|
||||||
transition: "transform 200ms ease",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
<Collapse expanded={appsExpanded}>
|
|
||||||
<SimpleGrid cols={{ base: 3, xs: 4, sm: 5 }} spacing="xs">
|
|
||||||
{dockerApps.map((app) => {
|
|
||||||
const isSelected = selectedAppIds.has(app.containerId);
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
key={app.containerId}
|
|
||||||
h={80}
|
|
||||||
p="xs"
|
|
||||||
withBorder
|
|
||||||
style={{
|
|
||||||
cursor: "pointer",
|
|
||||||
borderColor: isSelected ? "var(--mantine-color-blue-6)" : undefined,
|
|
||||||
borderWidth: isSelected ? 2 : undefined,
|
|
||||||
opacity: isSelected ? 1 : 0.6,
|
|
||||||
}}
|
|
||||||
onClick={() => handleToggleApp(app.containerId)}
|
|
||||||
>
|
|
||||||
<Stack justify="space-between" h="100%" gap={4} align="center">
|
|
||||||
<Group justify="space-between" w="100%" wrap="nowrap">
|
|
||||||
<Text size="xs" fw={500} lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
{app.containerName}
|
|
||||||
</Text>
|
|
||||||
{isSelected && <IconCheck size={14} color="var(--mantine-color-blue-6)" />}
|
|
||||||
</Group>
|
|
||||||
<Avatar size="sm" radius="sm" src={app.iconUrl} styles={{ image: { objectFit: "contain" } }}>
|
|
||||||
{app.containerName.at(0)?.toUpperCase()}
|
|
||||||
</Avatar>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SimpleGrid>
|
|
||||||
</Collapse>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Button variant="subtle" onClick={() => void finishSetupAsync()}>
|
|
||||||
{t("action.skip")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleContinueToConfig}
|
|
||||||
disabled={selectedKinds.length === 0 || undefined}
|
|
||||||
rightSection={<IconArrowRight size={16} stroke={1.5} />}
|
|
||||||
suppressHydrationWarning
|
|
||||||
>
|
|
||||||
{t("action.continue")} {selectedKinds.length > 0 && `(${selectedKinds.length})`}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</InitStepCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ConfigurePhaseProps {
|
|
||||||
selectedKinds: IntegrationKind[];
|
|
||||||
currentIndex: number;
|
|
||||||
drafts: Map<IntegrationKind, IntegrationDraft>;
|
|
||||||
baseHost: string;
|
|
||||||
setBaseHost: (value: string) => void;
|
|
||||||
urlMode: UrlTemplateMode;
|
|
||||||
setUrlMode: (value: UrlTemplateMode) => void;
|
|
||||||
onSuccess: () => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
onSkip: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ConfigurePhase = ({
|
|
||||||
selectedKinds,
|
|
||||||
currentIndex,
|
|
||||||
drafts,
|
|
||||||
baseHost,
|
|
||||||
setBaseHost,
|
|
||||||
urlMode,
|
|
||||||
setUrlMode,
|
|
||||||
onSuccess,
|
|
||||||
onCancel,
|
|
||||||
onSkip,
|
|
||||||
}: ConfigurePhaseProps) => {
|
|
||||||
const t = useScopedI18n("init.step.integrations");
|
|
||||||
const [helpersOpen, { toggle: toggleHelpers }] = useDisclosure(false);
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- currentIndex is always within bounds of selectedKinds
|
|
||||||
const currentKind = selectedKinds[currentIndex]!;
|
|
||||||
const draft = drafts.get(currentKind);
|
|
||||||
const progress = ((currentIndex + 1) / selectedKinds.length) * 100;
|
|
||||||
const computedUrl = baseHost
|
|
||||||
? buildIntegrationUrl(currentKind, baseHost, urlMode, draft?.dockerPort ?? undefined)
|
|
||||||
: draft?.initialUrl;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<InitStepCard>
|
|
||||||
<Stack>
|
|
||||||
<Group justify="space-between" align="center">
|
|
||||||
<Group gap="xs">
|
|
||||||
<IntegrationAvatar kind={currentKind} size="sm" />
|
|
||||||
<Text fw={500}>{getIntegrationName(currentKind)}</Text>
|
|
||||||
</Group>
|
|
||||||
<Tooltip label={t("configure.helpers.tooltip")}>
|
|
||||||
<ActionIcon variant={helpersOpen ? "light" : "subtle"} color="gray" onClick={toggleHelpers}>
|
|
||||||
<IconSettings size={18} stroke={1.5} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Stack gap={4}>
|
|
||||||
<Progress value={progress} size="sm" />
|
|
||||||
<Text size="xs" c="dimmed" ta="center">
|
|
||||||
{t("configure.progress", { current: String(currentIndex + 1), total: String(selectedKinds.length) })}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Collapse expanded={helpersOpen}>
|
|
||||||
<Stack
|
|
||||||
gap="xs"
|
|
||||||
p="xs"
|
|
||||||
style={{ background: "var(--mantine-color-default-hover)", borderRadius: "var(--mantine-radius-sm)" }}
|
|
||||||
>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{t("configure.helpers.description")}
|
|
||||||
</Text>
|
|
||||||
<Group gap="sm" grow>
|
|
||||||
<TextInput
|
|
||||||
size="xs"
|
|
||||||
placeholder={t("urlTemplate.placeholder")}
|
|
||||||
value={baseHost}
|
|
||||||
onChange={(e) => setBaseHost(e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
<SegmentedControl
|
|
||||||
size="xs"
|
|
||||||
value={urlMode}
|
|
||||||
onChange={(value) => setUrlMode(value as UrlTemplateMode)}
|
|
||||||
data={[
|
|
||||||
{ label: t("urlTemplate.mode.subdomain"), value: "subdomain" },
|
|
||||||
{ label: t("urlTemplate.mode.hostPort"), value: "hostPort" },
|
|
||||||
]}
|
|
||||||
style={{ flex: "0 0 auto" }}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
{baseHost && (
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{t("urlTemplate.preview", {
|
|
||||||
example: buildIntegrationUrl(currentKind, baseHost, urlMode, draft?.dockerPort ?? undefined),
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Collapse>
|
|
||||||
|
|
||||||
<NewIntegrationForm
|
|
||||||
key={currentKind}
|
|
||||||
kind={currentKind}
|
|
||||||
initialUrl={computedUrl}
|
|
||||||
initialName={draft?.initialName}
|
|
||||||
onSuccess={onSuccess}
|
|
||||||
onCancel={onCancel}
|
|
||||||
onSkip={onSkip}
|
|
||||||
isOnboarding
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</InitStepCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button, Group, Stack, Switch, Text } from "@mantine/core";
|
import { Button, Card, Group, Stack, Switch, Text } from "@mantine/core";
|
||||||
import { IconArrowRight } from "@tabler/icons-react";
|
import { IconArrowRight } from "@tabler/icons-react";
|
||||||
import type { z } from "zod/v4";
|
import type { z } from "zod/v4";
|
||||||
|
|
||||||
@@ -13,8 +13,6 @@ import type { TranslationObject } from "@homarr/translation";
|
|||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
import { settingsInitSchema } from "@homarr/validation/settings";
|
import { settingsInitSchema } from "@homarr/validation/settings";
|
||||||
|
|
||||||
import { InitStepCard } from "../../_components/init-step-card";
|
|
||||||
|
|
||||||
export const InitSettings = () => {
|
export const InitSettings = () => {
|
||||||
const tSection = useScopedI18n("management.page.settings.section");
|
const tSection = useScopedI18n("management.page.settings.section");
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
@@ -31,8 +29,8 @@ export const InitSettings = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
|
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
|
||||||
<Stack w="100%">
|
<Stack>
|
||||||
<InitStepCard>
|
<Card w={64 * 12 + 8} maw="90vw" withBorder>
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Text fw={500}>{tSection("analytics.title")}</Text>
|
<Text fw={500}>{tSection("analytics.title")}</Text>
|
||||||
|
|
||||||
@@ -40,8 +38,8 @@ export const InitSettings = () => {
|
|||||||
<AnalyticsRow kind="general" {...form.getInputProps("analytics.enableGeneral", { type: "checkbox" })} />
|
<AnalyticsRow kind="general" {...form.getInputProps("analytics.enableGeneral", { type: "checkbox" })} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
</InitStepCard>
|
</Card>
|
||||||
<InitStepCard>
|
<Card w={64 * 12 + 8} maw="90vw" withBorder>
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Text fw={500}>{tSection("crawlingAndIndexing.title")}</Text>
|
<Text fw={500}>{tSection("crawlingAndIndexing.title")}</Text>
|
||||||
|
|
||||||
@@ -64,7 +62,7 @@ export const InitSettings = () => {
|
|||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
</InitStepCard>
|
</Card>
|
||||||
|
|
||||||
<Button type="submit" loading={form.submitting} rightSection={<IconArrowRight size={16} stroke={1.5} />}>
|
<Button type="submit" loading={form.submitting} rightSection={<IconArrowRight size={16} stroke={1.5} />}>
|
||||||
{t("common.action.continue")}
|
{t("common.action.continue")}
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
import { Stack, Text } from "@mantine/core";
|
import { Card, Stack, Text } from "@mantine/core";
|
||||||
import { IconFileImport, IconPlayerPlay } from "@tabler/icons-react";
|
import { IconFileImport, IconPlayerPlay } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { getMantineColor } from "@homarr/common";
|
import { getMantineColor } from "@homarr/common";
|
||||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { InitStepCard } from "../../_components/init-step-card";
|
|
||||||
import { InitSqliteRestore } from "./sqlite-restore";
|
|
||||||
import { InitStartButton } from "./next-button";
|
import { InitStartButton } from "./next-button";
|
||||||
|
|
||||||
export const InitStart = async () => {
|
export const InitStart = async () => {
|
||||||
const tStart = await getScopedI18n("init.step.start");
|
const tStart = await getScopedI18n("init.step.start");
|
||||||
const isSqlite = dbEnv.DRIVER === "better-sqlite3";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InitStepCard>
|
<Card w={64 * 6} maw="90vw" withBorder>
|
||||||
<Stack>
|
<Stack>
|
||||||
<Text>{tStart("description")}</Text>
|
<Text>{tStart("description")}</Text>
|
||||||
|
|
||||||
@@ -30,8 +26,7 @@ export const InitStart = async () => {
|
|||||||
>
|
>
|
||||||
{tStart("action.importOldmarr")}
|
{tStart("action.importOldmarr")}
|
||||||
</InitStartButton>
|
</InitStartButton>
|
||||||
{isSqlite && <InitSqliteRestore />}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</InitStepCard>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Button, Collapse, Stack, Text } from "@mantine/core";
|
|
||||||
import { IconDatabaseImport } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import { getMantineColor } from "@homarr/common";
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
import { DatabaseRestoreFlow } from "~/components/backup";
|
|
||||||
|
|
||||||
export const InitSqliteRestore = () => {
|
|
||||||
const t = useScopedI18n("init.step.start.action");
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
|
|
||||||
if (expanded) {
|
|
||||||
return (
|
|
||||||
<Stack gap="sm">
|
|
||||||
<Button
|
|
||||||
variant="subtle"
|
|
||||||
size="xs"
|
|
||||||
onClick={() => setExpanded(false)}
|
|
||||||
>
|
|
||||||
{t("backToOptions")}
|
|
||||||
</Button>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t("restoreSqliteDescription")}
|
|
||||||
</Text>
|
|
||||||
<DatabaseRestoreFlow variant="standalone" />
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
onClick={() => setExpanded(true)}
|
|
||||||
variant="default"
|
|
||||||
leftSection={<IconDatabaseImport color={getMantineColor("orange", 6)} size={16} stroke={1.5} />}
|
|
||||||
>
|
|
||||||
{t("restoreSqlite")}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button, Stack, TextInput } from "@mantine/core";
|
import { Button, PasswordInput, Stack, TextInput } from "@mantine/core";
|
||||||
import type { z } from "zod/v4";
|
import type { z } from "zod/v4";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { signIn } from "@homarr/auth/client";
|
import { signIn } from "@homarr/auth/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { UserCreatePasswordFields } from "@homarr/forms-collection";
|
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
import { CustomPasswordInput } from "@homarr/ui";
|
||||||
import { userInitSchema } from "@homarr/validation/user";
|
import { userInitSchema } from "@homarr/validation/user";
|
||||||
|
|
||||||
export const InitUserForm = () => {
|
export const InitUserForm = () => {
|
||||||
@@ -59,10 +59,12 @@ export const InitUserForm = () => {
|
|||||||
>
|
>
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<TextInput label={t("field.username.label")} {...form.getInputProps("username")} />
|
<TextInput label={t("field.username.label")} {...form.getInputProps("username")} />
|
||||||
<UserCreatePasswordFields
|
<CustomPasswordInput
|
||||||
passwordInputProps={form.getInputProps("password")}
|
withPasswordRequirements
|
||||||
confirmPasswordInputProps={form.getInputProps("confirmPassword")}
|
label={t("field.password.label")}
|
||||||
|
{...form.getInputProps("password")}
|
||||||
/>
|
/>
|
||||||
|
<PasswordInput label={t("field.passwordConfirm.label")} {...form.getInputProps("confirmPassword")} />
|
||||||
<Button type="submit" fullWidth loading={isPending}>
|
<Button type="submit" fullWidth loading={isPending}>
|
||||||
{t("action.create")}
|
{t("action.create")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { InitStepCard } from "../../_components/init-step-card";
|
import { Card } from "@mantine/core";
|
||||||
|
|
||||||
import { InitUserForm } from "./init-user-form";
|
import { InitUserForm } from "./init-user-form";
|
||||||
|
|
||||||
export const InitUser = () => {
|
export const InitUser = () => {
|
||||||
return (
|
return (
|
||||||
<InitStepCard>
|
<Card w={64 * 6} maw="90vw" withBorder>
|
||||||
<InitUserForm />
|
<InitUserForm />
|
||||||
</InitStepCard>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -9,12 +9,10 @@ import { getScopedI18n } from "@homarr/translation/server";
|
|||||||
import { CurrentColorSchemeCombobox } from "~/components/color-scheme/current-color-scheme-combobox";
|
import { CurrentColorSchemeCombobox } from "~/components/color-scheme/current-color-scheme-combobox";
|
||||||
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
|
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
|
||||||
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
|
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
|
||||||
import { onboardingContentWidth, onboardingMaxWidth } from "./_constants";
|
|
||||||
import { BackToStart } from "./_steps/back";
|
import { BackToStart } from "./_steps/back";
|
||||||
import { InitFinish } from "./_steps/finish/init-finish";
|
import { InitFinish } from "./_steps/finish/init-finish";
|
||||||
import { InitGroup } from "./_steps/group/init-group";
|
import { InitGroup } from "./_steps/group/init-group";
|
||||||
import { InitImport } from "./_steps/import/init-import";
|
import { InitImport } from "./_steps/import/init-import";
|
||||||
import { InitIntegrations } from "./_steps/integrations/init-integrations";
|
|
||||||
import { InitSettings } from "./_steps/settings/init-settings";
|
import { InitSettings } from "./_steps/settings/init-settings";
|
||||||
import { InitStart } from "./_steps/start/init-start";
|
import { InitStart } from "./_steps/start/init-start";
|
||||||
import { InitUser } from "./_steps/user/init-user";
|
import { InitUser } from "./_steps/user/init-user";
|
||||||
@@ -25,7 +23,6 @@ const stepComponents: Record<OnboardingStep, null | (() => MaybePromise<JSX.Elem
|
|||||||
user: InitUser,
|
user: InitUser,
|
||||||
group: InitGroup,
|
group: InitGroup,
|
||||||
settings: InitSettings,
|
settings: InitSettings,
|
||||||
integrations: InitIntegrations,
|
|
||||||
finish: InitFinish,
|
finish: InitFinish,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,7 +35,7 @@ export default async function InitPage() {
|
|||||||
return (
|
return (
|
||||||
<Box mih="100dvh">
|
<Box mih="100dvh">
|
||||||
<Center>
|
<Center>
|
||||||
<Stack align="center" mt="xl" w={onboardingContentWidth} maw={onboardingMaxWidth}>
|
<Stack align="center" mt="xl">
|
||||||
<HomarrLogoWithTitle size="lg" />
|
<HomarrLogoWithTitle size="lg" />
|
||||||
<Stack gap={6} align="center">
|
<Stack gap={6} align="center">
|
||||||
<Title order={3} fw={400} ta="center">
|
<Title order={3} fw={400} ta="center">
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
|
|
||||||
import "@gfazioli/mantine-onboarding-tour/styles.css";
|
|
||||||
import "@homarr/notifications/styles.css";
|
import "@homarr/notifications/styles.css";
|
||||||
import "@homarr/spotlight/styles.css";
|
import "@homarr/spotlight/styles.css";
|
||||||
import "@homarr/ui/styles.css";
|
import "@homarr/ui/styles.css";
|
||||||
@@ -26,7 +25,6 @@ import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
|
|||||||
|
|
||||||
import { Analytics } from "~/components/layout/analytics";
|
import { Analytics } from "~/components/layout/analytics";
|
||||||
import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation";
|
import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation";
|
||||||
|
|
||||||
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
|
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
|
||||||
import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration";
|
import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration";
|
||||||
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
||||||
@@ -46,13 +44,13 @@ const fontSans = Inter({
|
|||||||
export const generateMetadata = async (): Promise<Metadata> => ({
|
export const generateMetadata = async (): Promise<Metadata> => ({
|
||||||
title: "Homarr",
|
title: "Homarr",
|
||||||
description:
|
description:
|
||||||
"A self-hosted dashboard for the *arr stack and your entire homelab. Integrates with 50+ services, real-time widgets, no config files.",
|
"Simplify the management of your server with Homarr - a sleek, modern dashboard that puts all of your apps and services at your fingertips.",
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: "Homarr Dashboard",
|
title: "Homarr Dashboard",
|
||||||
description:
|
description:
|
||||||
"A self-hosted dashboard for the *arr stack and your entire homelab. Integrates with 50+ services, real-time widgets, no config files.",
|
"Simplify the management of your server with Homarr - a sleek, modern dashboard that puts all of your apps and services at your fingertips.",
|
||||||
url: "https://homarr.dev",
|
url: "https://homarr.dev",
|
||||||
siteName: "Homarr",
|
siteName: "Homarr Documentation",
|
||||||
},
|
},
|
||||||
icons: {
|
icons: {
|
||||||
icon: "/logo/logo.png",
|
icon: "/logo/logo.png",
|
||||||
@@ -144,7 +142,7 @@ export default async function Layout(props: {
|
|||||||
</head>
|
</head>
|
||||||
<body className={["font-sans", fontSans.variable].join(" ")}>
|
<body className={["font-sans", fontSans.variable].join(" ")}>
|
||||||
<StackedProvider>
|
<StackedProvider>
|
||||||
<Notifications pauseResetOnHover="notification" />
|
<Notifications />
|
||||||
<ServiceWorkerRegistration />
|
<ServiceWorkerRegistration />
|
||||||
{props.children}
|
{props.children}
|
||||||
</StackedProvider>
|
</StackedProvider>
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export const HeroBanner = async () => {
|
|||||||
<Image
|
<Image
|
||||||
key={`grid-column-${columnIndex}-scroll-1-${index}`}
|
key={`grid-column-${columnIndex}-scroll-1-${index}`}
|
||||||
src={icon}
|
src={icon}
|
||||||
|
radius="md"
|
||||||
fit={"contain"}
|
fit={"contain"}
|
||||||
w={50}
|
w={50}
|
||||||
h={50}
|
h={50}
|
||||||
@@ -56,6 +57,7 @@ export const HeroBanner = async () => {
|
|||||||
<Image
|
<Image
|
||||||
key={`grid-column-${columnIndex}-scroll-2-${index}`}
|
key={`grid-column-${columnIndex}-scroll-2-${index}`}
|
||||||
src={icon}
|
src={icon}
|
||||||
|
radius="md"
|
||||||
fit={"contain"}
|
fit={"contain"}
|
||||||
w={50}
|
w={50}
|
||||||
h={50}
|
h={50}
|
||||||
|
|||||||
@@ -90,7 +90,9 @@ export default async function AboutPage() {
|
|||||||
<Group py="lg">
|
<Group py="lg">
|
||||||
<Image src={homarrLogoPath} width={100} height={100} alt="" />
|
<Image src={homarrLogoPath} width={100} height={100} alt="" />
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Title order={1}>Homarr</Title>
|
<Title order={1} tt="uppercase">
|
||||||
|
Homarr
|
||||||
|
</Title>
|
||||||
<Title order={2}>{t("version", { version })}</Title>
|
<Title order={2}>{t("version", { version })}</Title>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -193,9 +195,9 @@ export default async function AboutPage() {
|
|||||||
href={`https://www.npmjs.com/package/${name}`}
|
href={`https://www.npmjs.com/package/${name}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<Card radius="md" p="xs" className={classes.dependencyCard}>
|
<Card withBorder radius="md" p="xs" className={classes.dependencyCard}>
|
||||||
<Group gap="sm" wrap="nowrap">
|
<Group gap="sm" wrap="nowrap">
|
||||||
<ThemeIcon variant="light" size="lg">
|
<ThemeIcon variant="light" size="lg" radius="md">
|
||||||
<IconPackage size="1.5rem" stroke={1.5} />
|
<IconPackage size="1.5rem" stroke={1.5} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text } from "@mantine/core";
|
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
|
||||||
import { IconBox, IconPencil } from "@tabler/icons-react";
|
import { IconBox, IconPencil } from "@tabler/icons-react";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
@@ -11,9 +11,9 @@ import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
|||||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||||
import { Link, SearchInput, TablePagination } from "@homarr/ui";
|
import { Link, SearchInput, TablePagination } from "@homarr/ui";
|
||||||
|
|
||||||
import { TourTarget } from "~/components/layout/header/tour-target";
|
import { ManageContainer } from "~/components/manage/manage-container";
|
||||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
|
||||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||||
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { NoResults } from "~/components/no-results";
|
import { NoResults } from "~/components/no-results";
|
||||||
import { AppDeleteButton } from "./_app-delete-button";
|
import { AppDeleteButton } from "./_app-delete-button";
|
||||||
|
|
||||||
@@ -39,35 +39,34 @@ export default async function AppsPage(props: AppsPageProps) {
|
|||||||
const { items: apps, totalCount } = await api.app.getPaginated(searchParams);
|
const { items: apps, totalCount } = await api.app.getPaginated(searchParams);
|
||||||
const t = await getScopedI18n("app");
|
const t = await getScopedI18n("app");
|
||||||
|
|
||||||
const canCreate = session.user.permissions.includes("app-create");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ManagePageLayout
|
<ManageContainer>
|
||||||
title={t("page.list.title")}
|
<DynamicBreadcrumb />
|
||||||
primaryAction={
|
<Stack>
|
||||||
canCreate ? (
|
<Title>{t("page.list.title")}</Title>
|
||||||
<TourTarget id="manage-apps-create">
|
<Group justify="space-between" align="center">
|
||||||
|
<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} flexExpand />
|
||||||
|
{session.user.permissions.includes("app-create") && (
|
||||||
<MobileAffixButton component={Link} href="/manage/apps/new">
|
<MobileAffixButton component={Link} href="/manage/apps/new">
|
||||||
{t("page.create.title")}
|
{t("page.create.title")}
|
||||||
</MobileAffixButton>
|
</MobileAffixButton>
|
||||||
</TourTarget>
|
)}
|
||||||
) : undefined
|
</Group>
|
||||||
}
|
{apps.length === 0 && <AppNoResults />}
|
||||||
toolbar={<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} flexExpand />}
|
{apps.length > 0 && (
|
||||||
footer={<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />}
|
|
||||||
floatingPrimaryAction={canCreate}
|
|
||||||
>
|
|
||||||
{apps.length === 0 && <AppNoResults />}
|
|
||||||
{apps.length > 0 && (
|
|
||||||
<TourTarget id="manage-apps-list">
|
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
{apps.map((app) => (
|
{apps.map((app) => (
|
||||||
<AppCard key={app.id} app={app} />
|
<AppCard key={app.id} app={app} />
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
</TourTarget>
|
)}
|
||||||
)}
|
|
||||||
</ManagePageLayout>
|
{/* Added margin to not hide pagination behind affix-button */}
|
||||||
|
<Group justify="end" mb={48}>
|
||||||
|
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</ManageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +79,7 @@ const AppCard = async ({ app }: AppCardProps) => {
|
|||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card withBorder>
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<Group align="top" justify="start" wrap="nowrap" style={{ flex: "1" }}>
|
<Group align="top" justify="start" wrap="nowrap" style={{ flex: "1" }}>
|
||||||
<Avatar
|
<Avatar
|
||||||
|
|||||||
@@ -1,38 +1,43 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button, Menu } from "@mantine/core";
|
import { Affix, Button, Menu } from "@mantine/core";
|
||||||
import { IconCategoryPlus, IconChevronDown, IconFileImport } from "@tabler/icons-react";
|
import { IconCategoryPlus, IconChevronDown, IconFileImport } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { useModalAction } from "@homarr/modals";
|
import { useModalAction } from "@homarr/modals";
|
||||||
import { AddBoardModal, ImportBoardModal } from "@homarr/modals-collection";
|
import { AddBoardModal, ImportBoardModal } from "@homarr/modals-collection";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
|
|
||||||
|
|
||||||
export const CreateBoardButton = () => {
|
export const CreateBoardButton = () => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { openModal: openAddModal } = useModalAction(AddBoardModal);
|
const { openModal: openAddModal } = useModalAction(AddBoardModal);
|
||||||
const { openModal: openImportModal } = useModalAction(ImportBoardModal);
|
const { openModal: openImportModal } = useModalAction(ImportBoardModal);
|
||||||
|
|
||||||
|
const buttonGroupContent = (
|
||||||
|
<>
|
||||||
|
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openAddModal}>
|
||||||
|
{t("management.page.board.action.new.label")}
|
||||||
|
</Button>
|
||||||
|
<Menu position="bottom-end">
|
||||||
|
<Menu.Target>
|
||||||
|
<Button px="xs" ms={1}>
|
||||||
|
<IconChevronDown size="1rem" />
|
||||||
|
</Button>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<Menu.Item onClick={openImportModal} leftSection={<IconFileImport size="1rem" />}>
|
||||||
|
{t("board.action.oldImport.label")}
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ManageMobilePrimaryAction>
|
<>
|
||||||
<Button.Group>
|
<Button.Group visibleFrom="md">{buttonGroupContent}</Button.Group>
|
||||||
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openAddModal}>
|
<Affix hiddenFrom="md" position={{ bottom: 20, right: 20 }}>
|
||||||
{t("management.page.board.action.new.label")}
|
<Button.Group>{buttonGroupContent}</Button.Group>
|
||||||
</Button>
|
</Affix>
|
||||||
<Menu position="bottom-end">
|
</>
|
||||||
<Menu.Target>
|
|
||||||
<Button px="xs" ms={1}>
|
|
||||||
<IconChevronDown size="1rem" />
|
|
||||||
</Button>
|
|
||||||
</Menu.Target>
|
|
||||||
<Menu.Dropdown>
|
|
||||||
<Menu.Item onClick={openImportModal} leftSection={<IconFileImport size="1rem" />}>
|
|
||||||
{t("board.action.oldImport.label")}
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu.Dropdown>
|
|
||||||
</Menu>
|
|
||||||
</Button.Group>
|
|
||||||
</ManageMobilePrimaryAction>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -9,17 +9,12 @@ import {
|
|||||||
Group,
|
Group,
|
||||||
Menu,
|
Menu,
|
||||||
MenuTarget,
|
MenuTarget,
|
||||||
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
|
Title,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import { IconDeviceMobile, IconDotsVertical, IconHomeFilled, IconLock, IconWorld } from "@tabler/icons-react";
|
||||||
IconDeviceMobile,
|
|
||||||
IconDotsVertical,
|
|
||||||
IconHomeFilled,
|
|
||||||
IconLayoutDashboard,
|
|
||||||
IconLock,
|
|
||||||
IconWorld,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
@@ -28,9 +23,8 @@ import { getScopedI18n } from "@homarr/translation/server";
|
|||||||
import { Link, UserAvatar } from "@homarr/ui";
|
import { Link, UserAvatar } from "@homarr/ui";
|
||||||
|
|
||||||
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
|
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
|
||||||
import { TourTarget } from "~/components/layout/header/tour-target";
|
import { ManageContainer } from "~/components/manage/manage-container";
|
||||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { NoResults } from "~/components/no-results";
|
|
||||||
import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
|
import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
|
||||||
import { CreateBoardButton } from "./_components/create-board-button";
|
import { CreateBoardButton } from "./_components/create-board-button";
|
||||||
|
|
||||||
@@ -41,30 +35,23 @@ export default async function ManageBoardsPage() {
|
|||||||
const canCreateBoards = session?.user.permissions.includes("board-create");
|
const canCreateBoards = session?.user.permissions.includes("board-create");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ManagePageLayout
|
<ManageContainer>
|
||||||
title={t("title")}
|
<DynamicBreadcrumb />
|
||||||
primaryAction={
|
<Stack>
|
||||||
canCreateBoards ? (
|
<Group justify="space-between">
|
||||||
<TourTarget id="manage-boards-create">
|
<Title mb="md">{t("title")}</Title>
|
||||||
<CreateBoardButton />
|
{canCreateBoards && <CreateBoardButton />}
|
||||||
</TourTarget>
|
</Group>
|
||||||
) : undefined
|
|
||||||
}
|
<Grid mb={{ base: "xl", md: 0 }}>
|
||||||
floatingPrimaryAction={canCreateBoards}
|
{boards.map((board) => (
|
||||||
>
|
<GridCol span={{ base: 12, md: 6 }} key={board.id}>
|
||||||
{boards.length === 0 && <NoResults icon={IconLayoutDashboard} title={t("noResults.title")} />}
|
<BoardCard board={board} />
|
||||||
{boards.length > 0 && (
|
</GridCol>
|
||||||
<TourTarget id="manage-boards-list">
|
))}
|
||||||
<Grid>
|
</Grid>
|
||||||
{boards.map((board) => (
|
</Stack>
|
||||||
<GridCol span={{ base: 12, md: 6 }} key={board.id}>
|
</ManageContainer>
|
||||||
<BoardCard board={board} />
|
|
||||||
</GridCol>
|
|
||||||
))}
|
|
||||||
</Grid>
|
|
||||||
</TourTarget>
|
|
||||||
)}
|
|
||||||
</ManagePageLayout>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,14 +66,16 @@ const BoardCard = async ({ board }: BoardCardProps) => {
|
|||||||
const VisibilityIcon = board.isPublic ? IconWorld : IconLock;
|
const VisibilityIcon = board.isPublic ? IconWorld : IconLock;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card radius="lg" withBorder>
|
||||||
<CardSection p="sm" withBorder>
|
<CardSection p="sm" withBorder>
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
<Tooltip label={t(`visibility.${visibility}`)}>
|
<Tooltip label={t(`visibility.${visibility}`)}>
|
||||||
<VisibilityIcon size={20} stroke={1.5} />
|
<VisibilityIcon size={20} stroke={1.5} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Text fw={700}>{board.name}</Text>
|
<Text fw="bolder" tt="uppercase">
|
||||||
|
{board.name}
|
||||||
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Group>
|
<Group>
|
||||||
@@ -134,7 +123,6 @@ const BoardCard = async ({ board }: BoardCardProps) => {
|
|||||||
style={{ borderTop: "none", borderBottom: "none", borderRight: "none", borderRadius: 0 }}
|
style={{ borderTop: "none", borderBottom: "none", borderRight: "none", borderRadius: 0 }}
|
||||||
variant="default"
|
variant="default"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("action.settings.label")}
|
|
||||||
>
|
>
|
||||||
<IconDotsVertical size={16} stroke={1.5} />
|
<IconDotsVertical size={16} stroke={1.5} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|||||||
+3
-8
@@ -33,7 +33,7 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
|
|||||||
const KindIcon = integrationSecretIcons[secret.kind];
|
const KindIcon = integrationSecretIcons[secret.kind];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card withBorder>
|
||||||
<Stack>
|
<Stack>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Group>
|
<Group>
|
||||||
@@ -58,12 +58,7 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{isPublic ? (
|
{isPublic ? (
|
||||||
<ActionIcon
|
<ActionIcon color="gray" variant="subtle" onClick={togglePublicSecretDisplay}>
|
||||||
color="gray"
|
|
||||||
variant="subtle"
|
|
||||||
onClick={togglePublicSecretDisplay}
|
|
||||||
aria-label={t(publicSecretDisplayOpened ? "common.action.hide" : "common.action.show")}
|
|
||||||
>
|
|
||||||
<DisplayIcon size={16} stroke={1.5} />
|
<DisplayIcon size={16} stroke={1.5} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -84,7 +79,7 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
<Collapse expanded={editMode}>{children}</Collapse>
|
<Collapse in={editMode}>{children}</Collapse>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ interface FormType {
|
|||||||
interface SecretKindsSegmentedControlProps<TFormType extends FormType> {
|
interface SecretKindsSegmentedControlProps<TFormType extends FormType> {
|
||||||
defaultKinds?: IntegrationSecretKind[];
|
defaultKinds?: IntegrationSecretKind[];
|
||||||
secretKinds: IntegrationSecretKind[][];
|
secretKinds: IntegrationSecretKind[][];
|
||||||
form: UseFormReturnType<TFormType, TFormType>;
|
form: UseFormReturnType<TFormType, (values: TFormType) => TFormType>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SecretKindsSegmentedControl = <TFormType extends FormType>({
|
export const SecretKindsSegmentedControl = <TFormType extends FormType>({
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
|
|||||||
const causeArray = useMemo(() => toCauseArray(error.cause), [error.cause]);
|
const causeArray = useMemo(() => toCauseArray(error.cause), [error.cause]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card style={{ borderColor: getMantineColor("red", 8) }}>
|
<Card withBorder style={{ borderColor: getMantineColor("red", 8) }}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Text size="lg" fw={500} c="red.8">
|
<Text size="lg" fw={500} c="red.8">
|
||||||
|
|||||||
+1
-1
@@ -216,7 +216,7 @@ export const CertificateDetailsCard = ({ certificate }: CertificateDetailsProps)
|
|||||||
const tCertificateField = useScopedI18n("certificate.field");
|
const tCertificateField = useScopedI18n("certificate.field");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card withBorder>
|
||||||
<Text fw={500}>{tDetails("title")}</Text>
|
<Text fw={500}>{tDetails("title")}</Text>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ChangeEvent } from "react";
|
||||||
|
import React, { useMemo, useState } from "react";
|
||||||
|
import { Flex, Group, Menu, ScrollArea, Text, TextInput } from "@mantine/core";
|
||||||
|
import { IconSearch } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { IntegrationAvatar, Link } from "@homarr/ui";
|
||||||
|
|
||||||
|
interface IntegrationCreateDropdownContentProps {
|
||||||
|
enableMockIntegration: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IntegrationCreateDropdownContent = ({ enableMockIntegration }: IntegrationCreateDropdownContentProps) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
const filteredKinds = useMemo(() => {
|
||||||
|
return integrationKinds
|
||||||
|
.filter((kind) => enableMockIntegration || kind !== "mock")
|
||||||
|
.filter((kind) => getIntegrationName(kind).toLowerCase().includes(search.toLowerCase().trim()))
|
||||||
|
.sort((kindA, kindB) => getIntegrationName(kindA).localeCompare(getIntegrationName(kindB)));
|
||||||
|
}, [search, enableMockIntegration]);
|
||||||
|
|
||||||
|
const handleSearch = React.useCallback(
|
||||||
|
(event: ChangeEvent<HTMLInputElement>) => setSearch(event.target.value),
|
||||||
|
[setSearch],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex direction={{ base: "column-reverse", md: "column" }} gap="sm">
|
||||||
|
<TextInput
|
||||||
|
leftSection={<IconSearch stroke={1.5} size={20} />}
|
||||||
|
placeholder={t("integration.page.list.search")}
|
||||||
|
value={search}
|
||||||
|
data-autofocus
|
||||||
|
onChange={handleSearch}
|
||||||
|
variant="filled"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{filteredKinds.length > 0 ? (
|
||||||
|
<ScrollArea.Autosize mah={384}>
|
||||||
|
{filteredKinds.map((kind) => (
|
||||||
|
<Menu.Item component={Link} href={`/manage/integrations/new?kind=${kind}`} key={kind}>
|
||||||
|
<Group>
|
||||||
|
<IntegrationAvatar kind={kind} size="sm" />
|
||||||
|
<Text size="sm">{getIntegrationName(kind)}</Text>
|
||||||
|
</Group>
|
||||||
|
</Menu.Item>
|
||||||
|
))}
|
||||||
|
</ScrollArea.Autosize>
|
||||||
|
) : (
|
||||||
|
<Menu.Item disabled>{t("common.noResults")}</Menu.Item>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
||||||
-26
@@ -1,26 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
|
|
||||||
import type { IntegrationKind } from "@homarr/definitions";
|
|
||||||
|
|
||||||
import { NewIntegrationForm } from "./_integration-new-form";
|
|
||||||
|
|
||||||
interface IntegrationNewFormWrapperProps {
|
|
||||||
kind: IntegrationKind;
|
|
||||||
initialUrl?: string;
|
|
||||||
initialName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const IntegrationNewFormWrapper = ({ kind, initialUrl, initialName }: IntegrationNewFormWrapperProps) => {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NewIntegrationForm
|
|
||||||
kind={kind}
|
|
||||||
initialUrl={initialUrl}
|
|
||||||
initialName={initialName}
|
|
||||||
onSuccess={() => router.push("/manage/integrations")}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { startTransition, useState } from "react";
|
import { startTransition, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Anchor,
|
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Collapse,
|
Collapse,
|
||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconCheck, IconExternalLink, IconInfoCircle, IconKey } from "@tabler/icons-react";
|
import { IconCheck, IconInfoCircle } from "@tabler/icons-react";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
@@ -27,7 +27,6 @@ import type { IntegrationKind } from "@homarr/definitions";
|
|||||||
import {
|
import {
|
||||||
getAllSecretKindOptions,
|
getAllSecretKindOptions,
|
||||||
getIconUrl,
|
getIconUrl,
|
||||||
getIntegrationApiKeyUrl,
|
|
||||||
getIntegrationDefaultUrl,
|
getIntegrationDefaultUrl,
|
||||||
getIntegrationName,
|
getIntegrationName,
|
||||||
integrationDefs,
|
integrationDefs,
|
||||||
@@ -46,13 +45,9 @@ import { IntegrationTestConnectionError } from "../_components/test-connection/i
|
|||||||
import type { AnyMappedTestConnectionError } from "../_components/test-connection/types";
|
import type { AnyMappedTestConnectionError } from "../_components/test-connection/types";
|
||||||
|
|
||||||
interface NewIntegrationFormProps {
|
interface NewIntegrationFormProps {
|
||||||
kind: IntegrationKind;
|
searchParams: Partial<z.infer<typeof integrationCreateSchema>> & {
|
||||||
initialUrl?: string;
|
kind: IntegrationKind;
|
||||||
initialName?: string;
|
};
|
||||||
onSuccess: () => void;
|
|
||||||
onCancel?: () => void;
|
|
||||||
onSkip?: () => void;
|
|
||||||
isOnboarding?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and(
|
const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and(
|
||||||
@@ -63,48 +58,39 @@ const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const NewIntegrationForm = ({
|
export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => {
|
||||||
kind,
|
|
||||||
initialUrl,
|
|
||||||
initialName,
|
|
||||||
onSuccess,
|
|
||||||
onCancel,
|
|
||||||
onSkip,
|
|
||||||
isOnboarding = false,
|
|
||||||
}: NewIntegrationFormProps) => {
|
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const secretKinds = getAllSecretKindOptions(kind);
|
const secretKinds = getAllSecretKindOptions(searchParams.kind);
|
||||||
const hasUrlSecret = secretKinds.some((kinds) => kinds.includes("url"));
|
const hasUrlSecret = secretKinds.some((kinds) => kinds.includes("url"));
|
||||||
|
const router = useRouter();
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const canCreateApps = !isOnboarding && (session?.user.permissions.includes("app-create") ?? false);
|
const canCreateApps = session?.user.permissions.includes("app-create") ?? false;
|
||||||
|
|
||||||
let url = initialUrl ?? getIntegrationDefaultUrl(kind) ?? "";
|
let url = searchParams.url ?? getIntegrationDefaultUrl(searchParams.kind) ?? "";
|
||||||
if (hasUrlSecret) {
|
if (hasUrlSecret) {
|
||||||
|
// Placeholder Url, replaced with origin of the secret Url on submit
|
||||||
url = "http://localhost";
|
url = "http://localhost";
|
||||||
}
|
}
|
||||||
const form = useZodForm(formSchema, {
|
const form = useZodForm(formSchema, {
|
||||||
initialValues: {
|
initialValues: {
|
||||||
name: initialName ?? getIntegrationName(kind),
|
name: searchParams.name ?? getIntegrationName(searchParams.kind),
|
||||||
url,
|
url,
|
||||||
secrets: secretKinds[0].map((kind) => ({
|
secrets: secretKinds[0].map((kind) => ({
|
||||||
kind,
|
kind,
|
||||||
value: "",
|
value: "",
|
||||||
})),
|
})),
|
||||||
attemptSearchEngineCreation: !isOnboarding,
|
attemptSearchEngineCreation: true,
|
||||||
hasApp: !isOnboarding,
|
hasApp: false,
|
||||||
appHref: isOnboarding ? null : url,
|
appHref: url,
|
||||||
appId: null,
|
appId: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: createIntegrationAsync, isPending: isCreatePending } = clientApi.integration.create.useMutation({
|
const { mutateAsync: createIntegrationAsync, isPending } = clientApi.integration.create.useMutation({
|
||||||
async onSuccess() {
|
async onSuccess() {
|
||||||
await revalidatePathActionAsync("/manage/integrations");
|
await revalidatePathActionAsync("/manage/integrations");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const { mutateAsync: createOnboardingIntegrationAsync, isPending: isOnboardingCreatePending } =
|
|
||||||
clientApi.onboard.createIntegration.useMutation();
|
|
||||||
const isPending = isCreatePending || isOnboardingCreatePending;
|
|
||||||
const [error, setError] = useState<null | AnyMappedTestConnectionError>(null);
|
const [error, setError] = useState<null | AnyMappedTestConnectionError>(null);
|
||||||
|
|
||||||
const handleSubmitAsync = async ({ appId, appHref, hasApp, ...values }: FormType) => {
|
const handleSubmitAsync = async ({ appId, appHref, hasApp, ...values }: FormType) => {
|
||||||
@@ -112,39 +98,6 @@ export const NewIntegrationForm = ({
|
|||||||
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
|
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
|
||||||
: values.url;
|
: values.url;
|
||||||
|
|
||||||
const onMutationSuccess = (data: { error?: AnyMappedTestConnectionError } | undefined | void) => {
|
|
||||||
if (data && "error" in data && data.error) {
|
|
||||||
setError(data.error);
|
|
||||||
showErrorNotification({
|
|
||||||
title: t("integration.page.create.notification.error.title"),
|
|
||||||
message: t("integration.page.create.notification.error.message"),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
showSuccessNotification({
|
|
||||||
title: t("integration.page.create.notification.success.title"),
|
|
||||||
message: t("integration.page.create.notification.success.message"),
|
|
||||||
});
|
|
||||||
|
|
||||||
onSuccess();
|
|
||||||
};
|
|
||||||
|
|
||||||
const onMutationError = () => {
|
|
||||||
showErrorNotification({
|
|
||||||
title: t("integration.page.create.notification.error.title"),
|
|
||||||
message: t("integration.page.create.notification.error.message"),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isOnboarding) {
|
|
||||||
await createOnboardingIntegrationAsync(
|
|
||||||
{ kind, name: values.name, url, secrets: values.secrets },
|
|
||||||
{ onSuccess: onMutationSuccess, onError: onMutationError },
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasCustomHref = appHref !== null && appHref.trim().length >= 1;
|
const hasCustomHref = appHref !== null && appHref.trim().length >= 1;
|
||||||
|
|
||||||
const app = hasApp
|
const app = hasApp
|
||||||
@@ -153,21 +106,49 @@ export const NewIntegrationForm = ({
|
|||||||
: {
|
: {
|
||||||
name: values.name,
|
name: values.name,
|
||||||
href: hasCustomHref ? appHref : url,
|
href: hasCustomHref ? appHref : url,
|
||||||
iconUrl: getIconUrl(kind),
|
iconUrl: getIconUrl(searchParams.kind),
|
||||||
description: null,
|
description: null,
|
||||||
pingUrl: url,
|
pingUrl: url,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
await createIntegrationAsync(
|
await createIntegrationAsync(
|
||||||
{ kind, ...values, url, app },
|
{
|
||||||
{ onSuccess: onMutationSuccess, onError: onMutationError },
|
kind: searchParams.kind,
|
||||||
|
...values,
|
||||||
|
url,
|
||||||
|
app,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess(data) {
|
||||||
|
// We do it this way as we are unable to send a typesafe error through onError
|
||||||
|
if (data?.error) {
|
||||||
|
setError(data.error);
|
||||||
|
showErrorNotification({
|
||||||
|
title: t("integration.page.create.notification.error.title"),
|
||||||
|
message: t("integration.page.create.notification.error.message"),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showSuccessNotification({
|
||||||
|
title: t("integration.page.create.notification.success.title"),
|
||||||
|
message: t("integration.page.create.notification.success.message"),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push("/manage/integrations");
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showErrorNotification({
|
||||||
|
title: t("integration.page.create.notification.error.title"),
|
||||||
|
message: t("integration.page.create.notification.error.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const integrationCategories = integrationDefs[kind].category.flat();
|
const supportsSearchEngine = integrationDefs[searchParams.kind].category.flat().includes("search");
|
||||||
const supportsSearchEngine =
|
|
||||||
integrationCategories.includes("search") && !integrationCategories.includes("mediaSearch");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
|
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
|
||||||
@@ -194,39 +175,27 @@ export const NewIntegrationForm = ({
|
|||||||
<Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text>
|
<Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<ApiKeySettingsLink kind={kind} url={form.values.url} />
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Fieldset>
|
</Fieldset>
|
||||||
|
|
||||||
{error !== null && <IntegrationTestConnectionError error={error} url={form.values.url} />}
|
{error !== null && <IntegrationTestConnectionError error={error} url={form.values.url} />}
|
||||||
|
|
||||||
{!isOnboarding && supportsSearchEngine && (
|
{supportsSearchEngine && (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label={t("integration.field.attemptSearchEngineCreation.label")}
|
label={t("integration.field.attemptSearchEngineCreation.label")}
|
||||||
description={t("integration.field.attemptSearchEngineCreation.description", {
|
description={t("integration.field.attemptSearchEngineCreation.description", {
|
||||||
kind: getIntegrationName(kind),
|
kind: getIntegrationName(searchParams.kind),
|
||||||
})}
|
})}
|
||||||
{...form.getInputProps("attemptSearchEngineCreation", { type: "checkbox" })}
|
{...form.getInputProps("attemptSearchEngineCreation", { type: "checkbox" })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isOnboarding && <AppForm form={form} canCreateApps={canCreateApps} />}
|
<AppForm form={form} canCreateApps={canCreateApps} />
|
||||||
|
|
||||||
<Group justify="end" align="center">
|
<Group justify="end" align="center">
|
||||||
{onCancel ? (
|
<Button variant="default" component={Link} href="/manage/integrations">
|
||||||
<Button variant="default" onClick={onCancel}>
|
{t("common.action.backToOverview")}
|
||||||
{t("common.action.backToOverview")}
|
</Button>
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button variant="default" component={Link} href="/manage/integrations">
|
|
||||||
{t("common.action.backToOverview")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{onSkip && (
|
|
||||||
<Button variant="default" onClick={onSkip}>
|
|
||||||
{t("common.action.skip")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button type="submit" loading={isPending}>
|
<Button type="submit" loading={isPending}>
|
||||||
{t("integration.testConnection.action.create")}
|
{t("integration.testConnection.action.create")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -257,7 +226,7 @@ const AppForm = ({ form, canCreateApps }: { form: UseFormReturnType<FormType>; c
|
|||||||
description={t("integration.field.createApp.description")}
|
description={t("integration.field.createApp.description")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Collapse expanded={form.values.hasApp}>
|
<Collapse in={form.values.hasApp}>
|
||||||
<Fieldset legend={t("integration.field.app.sectionTitle")}>
|
<Fieldset legend={t("integration.field.app.sectionTitle")}>
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
{canCreateApps && (
|
{canCreateApps && (
|
||||||
@@ -297,40 +266,6 @@ const AppForm = ({ form, canCreateApps }: { form: UseFormReturnType<FormType>; c
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeUrl = (raw: string): string | null => {
|
|
||||||
try {
|
|
||||||
return new URL(raw).href;
|
|
||||||
} catch {
|
|
||||||
// Bare IP / hostname — prepend http:// and retry
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return new URL(`http://${raw}`).href;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const ApiKeySettingsLink = ({ kind, url }: { kind: IntegrationKind; url: string }) => {
|
|
||||||
const t = useI18n();
|
|
||||||
const apiKeyUrl = getIntegrationApiKeyUrl(url, kind);
|
|
||||||
if (!apiKeyUrl) return null;
|
|
||||||
|
|
||||||
const resolved = normalizeUrl(url);
|
|
||||||
if (!resolved) return null;
|
|
||||||
|
|
||||||
const fullApiKeyUrl = getIntegrationApiKeyUrl(resolved, kind);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Anchor href={fullApiKeyUrl ?? apiKeyUrl} target="_blank" rel="noopener noreferrer" size="sm">
|
|
||||||
<Group gap={4}>
|
|
||||||
<IconKey size={14} stroke={1.5} />
|
|
||||||
<Text size="sm">{t("integration.field.apiKeySettings.label")}</Text>
|
|
||||||
<IconExternalLink size={14} stroke={1.5} />
|
|
||||||
</Group>
|
|
||||||
</Anchor>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type IntegrationAppSelectProps = Modify<
|
type IntegrationAppSelectProps = Modify<
|
||||||
GetInputPropsReturnType,
|
GetInputPropsReturnType,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
|
|
||||||
import type { IntegrationKind } from "@homarr/definitions";
|
|
||||||
|
|
||||||
import { IntegrationSelectGrid } from "~/components/integration/integration-select-grid";
|
|
||||||
|
|
||||||
interface IntegrationNewSelectGridProps {
|
|
||||||
enableMockIntegration: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const IntegrationNewSelectGrid = ({ enableMockIntegration }: IntegrationNewSelectGridProps) => {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const handleSelect = (kind: IntegrationKind) => {
|
|
||||||
router.push(`/manage/integrations/new?kind=${kind}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
return <IntegrationSelectGrid onSelect={handleSelect} enableMockIntegration={enableMockIntegration} />;
|
|
||||||
};
|
|
||||||
@@ -3,21 +3,21 @@ import { Container, Group, Stack, Title } from "@mantine/core";
|
|||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
|
import type { IntegrationKind } from "@homarr/definitions";
|
||||||
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
|
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
import { IntegrationAvatar } from "@homarr/ui";
|
import { IntegrationAvatar } from "@homarr/ui";
|
||||||
|
import type { integrationCreateSchema } from "@homarr/validation/integration";
|
||||||
|
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { env } from "~/env";
|
import { NewIntegrationForm } from "./_integration-new-form";
|
||||||
import { IntegrationNewFormWrapper } from "./_integration-new-form-wrapper";
|
|
||||||
import { IntegrationNewSelectGrid } from "./_integration-new-select-grid";
|
|
||||||
|
|
||||||
interface NewIntegrationPageProps {
|
interface NewIntegrationPageProps {
|
||||||
searchParams: Promise<{
|
searchParams: Promise<
|
||||||
kind?: string;
|
Partial<z.infer<typeof integrationCreateSchema>> & {
|
||||||
url?: string;
|
kind: IntegrationKind;
|
||||||
name?: string;
|
}
|
||||||
}>;
|
>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function IntegrationsNewPage(props: NewIntegrationPageProps) {
|
export default async function IntegrationsNewPage(props: NewIntegrationPageProps) {
|
||||||
@@ -28,20 +28,8 @@ export default async function IntegrationsNewPage(props: NewIntegrationPageProps
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = z.enum(integrationKinds).safeParse(searchParams.kind);
|
const result = z.enum(integrationKinds).safeParse(searchParams.kind);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
const t = await getScopedI18n("integration");
|
notFound();
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DynamicBreadcrumb />
|
|
||||||
<Container>
|
|
||||||
<Stack>
|
|
||||||
<Title>{t("action.create")}</Title>
|
|
||||||
<IntegrationNewSelectGrid enableMockIntegration={env.UNSAFE_ENABLE_MOCK_INTEGRATION} />
|
|
||||||
</Stack>
|
|
||||||
</Container>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const tCreate = await getScopedI18n("integration.page.create");
|
const tCreate = await getScopedI18n("integration.page.create");
|
||||||
@@ -57,7 +45,7 @@ export default async function IntegrationsNewPage(props: NewIntegrationPageProps
|
|||||||
<IntegrationAvatar kind={currentKind} size="md" />
|
<IntegrationAvatar kind={currentKind} size="md" />
|
||||||
<Title>{tCreate("title", { name: getIntegrationName(currentKind) })}</Title>
|
<Title>{tCreate("title", { name: getIntegrationName(currentKind) })}</Title>
|
||||||
</Group>
|
</Group>
|
||||||
<IntegrationNewFormWrapper kind={currentKind} initialUrl={searchParams.url} initialName={searchParams.name} />
|
<NewIntegrationForm searchParams={searchParams} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</Container>
|
</Container>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
transition: background-color 150ms ease;
|
transition: transform 150ms ease;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
&[data-first="true"] {
|
&[data-first="true"] {
|
||||||
@@ -20,9 +20,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&[data-active] {
|
&[data-active] {
|
||||||
|
transform: scale(1.01);
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
background-color: var(--mantine-color-body);
|
background-color: var(--mantine-color-body);
|
||||||
border-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
border-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||||
|
box-shadow: var(--mantine-shadow-md);
|
||||||
border-radius: var(--mantine-radius-lg);
|
border-radius: var(--mantine-radius-lg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
|
import type { PropsWithChildren } from "react";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
AccordionControl,
|
AccordionControl,
|
||||||
@@ -6,9 +7,15 @@ import {
|
|||||||
AccordionPanel,
|
AccordionPanel,
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
ActionIconGroup,
|
ActionIconGroup,
|
||||||
|
Affix,
|
||||||
Anchor,
|
Anchor,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
Divider,
|
Divider,
|
||||||
Group,
|
Group,
|
||||||
|
Menu,
|
||||||
|
MenuDropdown,
|
||||||
|
MenuTarget,
|
||||||
Stack,
|
Stack,
|
||||||
Table,
|
Table,
|
||||||
TableTbody,
|
TableTbody,
|
||||||
@@ -17,8 +24,9 @@ import {
|
|||||||
TableThead,
|
TableThead,
|
||||||
TableTr,
|
TableTr,
|
||||||
Text,
|
Text,
|
||||||
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconPencil, IconPlugX } from "@tabler/icons-react";
|
import { IconChevronDown, IconChevronUp, IconPencil, IconPlugX } from "@tabler/icons-react";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
@@ -29,12 +37,13 @@ import { getIntegrationName } from "@homarr/definitions";
|
|||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
import { CountBadge, IntegrationAvatar, Link } from "@homarr/ui";
|
import { CountBadge, IntegrationAvatar, Link } from "@homarr/ui";
|
||||||
|
|
||||||
import { TourTarget } from "~/components/layout/header/tour-target";
|
import { ManageContainer } from "~/components/manage/manage-container";
|
||||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
|
||||||
import { NoResults } from "~/components/no-results";
|
import { NoResults } from "~/components/no-results";
|
||||||
|
import { env } from "~/env";
|
||||||
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
|
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
|
||||||
import { DeleteIntegrationActionButton } from "./_integration-buttons";
|
import { DeleteIntegrationActionButton } from "./_integration-buttons";
|
||||||
|
import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown";
|
||||||
import classes from "./page.module.css";
|
import classes from "./page.module.css";
|
||||||
|
|
||||||
interface IntegrationsPageProps {
|
interface IntegrationsPageProps {
|
||||||
@@ -57,26 +66,60 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
|
|||||||
const canCreateIntegrations = session.user.permissions.includes("integration-create");
|
const canCreateIntegrations = session.user.permissions.includes("integration-create");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ManagePageLayout
|
<ManageContainer>
|
||||||
title={t("page.list.title")}
|
<DynamicBreadcrumb />
|
||||||
primaryAction={
|
<Stack>
|
||||||
canCreateIntegrations ? (
|
<Group justify="space-between" align="center">
|
||||||
<TourTarget id="manage-integrations-create">
|
<Title>{t("page.list.title")}</Title>
|
||||||
<MobileAffixButton component={Link} href="/manage/integrations/new">
|
|
||||||
{t("action.create")}
|
{canCreateIntegrations && (
|
||||||
</MobileAffixButton>
|
<>
|
||||||
</TourTarget>
|
<Box>
|
||||||
) : undefined
|
<IntegrationSelectMenu>
|
||||||
}
|
<Affix hiddenFrom="md" position={{ bottom: 20, right: 20 }}>
|
||||||
floatingPrimaryAction={canCreateIntegrations}
|
<MenuTarget>
|
||||||
>
|
<Button rightSection={<IconChevronUp size={16} stroke={1.5} />}>{t("action.create")}</Button>
|
||||||
<TourTarget id="manage-integrations-list">
|
</MenuTarget>
|
||||||
|
</Affix>
|
||||||
|
</IntegrationSelectMenu>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box visibleFrom="md">
|
||||||
|
<IntegrationSelectMenu>
|
||||||
|
<MenuTarget>
|
||||||
|
<Button rightSection={<IconChevronDown size={16} stroke={1.5} />}>{t("action.create")}</Button>
|
||||||
|
</MenuTarget>
|
||||||
|
</IntegrationSelectMenu>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
<IntegrationList integrations={integrations} activeTab={searchParams.tab} />
|
<IntegrationList integrations={integrations} activeTab={searchParams.tab} />
|
||||||
</TourTarget>
|
</Stack>
|
||||||
</ManagePageLayout>
|
</ManageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const IntegrationSelectMenu = ({ children }: PropsWithChildren) => {
|
||||||
|
return (
|
||||||
|
<Menu
|
||||||
|
width={256}
|
||||||
|
trapFocus
|
||||||
|
position="bottom-end"
|
||||||
|
withinPortal
|
||||||
|
shadow="md"
|
||||||
|
keepMounted={false}
|
||||||
|
withInitialFocusPlaceholder={false}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<MenuDropdown>
|
||||||
|
<IntegrationCreateDropdownContent enableMockIntegration={env.UNSAFE_ENABLE_MOCK_INTEGRATION} />
|
||||||
|
</MenuDropdown>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
interface IntegrationListProps {
|
interface IntegrationListProps {
|
||||||
integrations: RouterOutputs["integration"]["all"];
|
integrations: RouterOutputs["integration"]["all"];
|
||||||
activeTab?: IntegrationKind;
|
activeTab?: IntegrationKind;
|
||||||
@@ -109,12 +152,12 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ActiveTabAccordion defaultValue={activeTab} radius="lg" classNames={classes}>
|
<ActiveTabAccordion defaultValue={activeTab} radius="lg" classNames={classes}>
|
||||||
{entries.map(([kind, kindIntegrations], index) => (
|
{entries.map(([kind, integrations], index) => (
|
||||||
<AccordionItem key={kind} value={kind} data-first={index === 0} data-last={index === entries.length - 1}>
|
<AccordionItem key={kind} value={kind} data-first={index === 0} data-last={index === entries.length - 1}>
|
||||||
<AccordionControl icon={<IntegrationAvatar size="sm" kind={kind} radius="sm" />}>
|
<AccordionControl icon={<IntegrationAvatar size="sm" kind={kind} radius="sm" />}>
|
||||||
<Group>
|
<Group>
|
||||||
<Text>{getIntegrationName(kind)}</Text>
|
<Text>{getIntegrationName(kind)}</Text>
|
||||||
<CountBadge count={kindIntegrations.length} />
|
<CountBadge count={integrations.length} />
|
||||||
</Group>
|
</Group>
|
||||||
</AccordionControl>
|
</AccordionControl>
|
||||||
<AccordionPanel>
|
<AccordionPanel>
|
||||||
@@ -127,7 +170,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
|||||||
</TableTr>
|
</TableTr>
|
||||||
</TableThead>
|
</TableThead>
|
||||||
<TableTbody>
|
<TableTbody>
|
||||||
{kindIntegrations.map((integration) => (
|
{integrations.map((integration) => (
|
||||||
<TableTr key={integration.id}>
|
<TableTr key={integration.id}>
|
||||||
<TableTd>{integration.name}</TableTd>
|
<TableTd>{integration.name}</TableTd>
|
||||||
<TableTd>
|
<TableTd>
|
||||||
@@ -148,7 +191,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
|||||||
>
|
>
|
||||||
<IconPencil size={16} stroke={1.5} />
|
<IconPencil size={16} stroke={1.5} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<DeleteIntegrationActionButton integration={integration} count={kindIntegrations.length} />
|
<DeleteIntegrationActionButton integration={integration} count={integrations.length} />
|
||||||
</ActionIconGroup>
|
</ActionIconGroup>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
@@ -159,9 +202,9 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
|||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
<Stack gap="xs" hiddenFrom="md">
|
<Stack gap="xs" hiddenFrom="md">
|
||||||
{kindIntegrations.map((integration, integrationIndex) => (
|
{integrations.map((integration, index) => (
|
||||||
<Fragment key={integration.id}>
|
<Fragment key={integration.id}>
|
||||||
{integrationIndex !== 0 && <Divider />}
|
{index !== 0 && <Divider />}
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Group justify="space-between" align="center" wrap="nowrap">
|
<Group justify="space-between" align="center" wrap="nowrap">
|
||||||
<Text>{integration.name}</Text>
|
<Text>{integration.name}</Text>
|
||||||
@@ -176,7 +219,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
|||||||
>
|
>
|
||||||
<IconPencil size={16} stroke={1.5} />
|
<IconPencil size={16} stroke={1.5} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<DeleteIntegrationActionButton integration={integration} count={kindIntegrations.length} />
|
<DeleteIntegrationActionButton integration={integration} count={integrations.length} />
|
||||||
</ActionIconGroup>
|
</ActionIconGroup>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
IconBrandTablerFilled,
|
IconBrandTablerFilled,
|
||||||
IconCertificate,
|
IconCertificate,
|
||||||
IconClipboardListFilled,
|
IconClipboardListFilled,
|
||||||
IconDatabaseExport,
|
|
||||||
IconDirectionsFilled,
|
IconDirectionsFilled,
|
||||||
IconGitFork,
|
IconGitFork,
|
||||||
IconHelpSquareRoundedFilled,
|
IconHelpSquareRoundedFilled,
|
||||||
@@ -29,7 +28,6 @@ import {
|
|||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { isProviderEnabled } from "@homarr/auth/server";
|
import { isProviderEnabled } from "@homarr/auth/server";
|
||||||
import { createDocumentationLink } from "@homarr/definitions";
|
import { createDocumentationLink } from "@homarr/definitions";
|
||||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
|
||||||
import { env } from "@homarr/docker/env";
|
import { env } from "@homarr/docker/env";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
@@ -38,7 +36,6 @@ import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
|||||||
import type { NavigationLink } from "~/components/layout/navigation";
|
import type { NavigationLink } from "~/components/layout/navigation";
|
||||||
import { MainNavigation } from "~/components/layout/navigation";
|
import { MainNavigation } from "~/components/layout/navigation";
|
||||||
import { ClientShell } from "~/components/layout/shell";
|
import { ClientShell } from "~/components/layout/shell";
|
||||||
import { ManageTourProvider } from "~/components/onboarding/manage-tour";
|
|
||||||
|
|
||||||
export default async function ManageLayout({ children }: PropsWithChildren) {
|
export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||||
const t = await getScopedI18n("management.navbar");
|
const t = await getScopedI18n("management.navbar");
|
||||||
@@ -48,13 +45,11 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
label: t("items.home"),
|
label: t("items.home"),
|
||||||
icon: IconHomeFilled,
|
icon: IconHomeFilled,
|
||||||
href: "/manage",
|
href: "/manage",
|
||||||
"data-onboarding-tour-id": "manage-welcome",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: IconLayoutDashboardFilled,
|
icon: IconLayoutDashboardFilled,
|
||||||
href: "/manage/boards",
|
href: "/manage/boards",
|
||||||
label: t("items.boards"),
|
label: t("items.boards"),
|
||||||
"data-onboarding-tour-id": "manage-boards",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: IconBox,
|
icon: IconBox,
|
||||||
@@ -64,14 +59,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
iconProps: {
|
iconProps: {
|
||||||
strokeWidth: 2.5,
|
strokeWidth: 2.5,
|
||||||
},
|
},
|
||||||
"data-onboarding-tour-id": "manage-apps",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: IconAffiliateFilled,
|
icon: IconAffiliateFilled,
|
||||||
href: "/manage/integrations",
|
href: "/manage/integrations",
|
||||||
label: t("items.integrations"),
|
label: t("items.integrations"),
|
||||||
hidden: !session,
|
hidden: !session,
|
||||||
"data-onboarding-tour-id": "manage-integrations",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: IconSearch,
|
icon: IconSearch,
|
||||||
@@ -81,20 +74,17 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
iconProps: {
|
iconProps: {
|
||||||
strokeWidth: 2.5,
|
strokeWidth: 2.5,
|
||||||
},
|
},
|
||||||
"data-onboarding-tour-id": "manage-search-engines",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: IconPhotoFilled,
|
icon: IconPhotoFilled,
|
||||||
href: "/manage/medias",
|
href: "/manage/medias",
|
||||||
label: t("items.medias"),
|
label: t("items.medias"),
|
||||||
hidden: !session,
|
hidden: !session,
|
||||||
"data-onboarding-tour-id": "manage-medias",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: IconUserFilled,
|
icon: IconUserFilled,
|
||||||
label: t("items.users.label"),
|
label: t("items.users.label"),
|
||||||
hidden: !session?.user.permissions.includes("admin"),
|
hidden: !session?.user.permissions.includes("admin"),
|
||||||
"data-onboarding-tour-id": "manage-users",
|
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
label: t("items.users.items.manage"),
|
label: t("items.users.items.manage"),
|
||||||
@@ -156,12 +146,6 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
href: "/manage/tools/tasks",
|
href: "/manage/tools/tasks",
|
||||||
hidden: !session?.user.permissions.includes("admin"),
|
hidden: !session?.user.permissions.includes("admin"),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: t("items.tools.items.backup"),
|
|
||||||
icon: IconDatabaseExport,
|
|
||||||
href: "/manage/tools/backup",
|
|
||||||
hidden: !session?.user.permissions.includes("admin") || dbEnv.DRIVER !== "better-sqlite3",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -169,7 +153,6 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
href: "/manage/settings",
|
href: "/manage/settings",
|
||||||
icon: IconSettingsFilled,
|
icon: IconSettingsFilled,
|
||||||
hidden: !session?.user.permissions.includes("admin"),
|
hidden: !session?.user.permissions.includes("admin"),
|
||||||
"data-onboarding-tour-id": "manage-settings",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("items.help.label"),
|
label: t("items.help.label"),
|
||||||
@@ -208,17 +191,11 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const isAdmin = session?.user.permissions.includes("admin") ?? false;
|
return (
|
||||||
|
|
||||||
const shell = (
|
|
||||||
<ClientShell hasNavigation>
|
<ClientShell hasNavigation>
|
||||||
<MainHeader></MainHeader>
|
<MainHeader></MainHeader>
|
||||||
<MainNavigation links={navigationLinks}></MainNavigation>
|
<MainNavigation links={navigationLinks}></MainNavigation>
|
||||||
<AppShellMain>{children}</AppShellMain>
|
<AppShellMain>{children}</AppShellMain>
|
||||||
</ClientShell>
|
</ClientShell>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!session) return shell;
|
|
||||||
|
|
||||||
return <ManageTourProvider isAdmin={isAdmin}>{shell}</ManageTourProvider>;
|
|
||||||
}
|
}
|
||||||
@@ -4,15 +4,17 @@ import {
|
|||||||
Anchor,
|
Anchor,
|
||||||
Group,
|
Group,
|
||||||
Image,
|
Image,
|
||||||
|
Stack,
|
||||||
Table,
|
Table,
|
||||||
TableTbody,
|
TableTbody,
|
||||||
TableTd,
|
TableTd,
|
||||||
TableTh,
|
TableTh,
|
||||||
TableThead,
|
TableThead,
|
||||||
TableTr,
|
TableTr,
|
||||||
|
Title,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconExternalLink, IconPhoto } from "@tabler/icons-react";
|
import { IconExternalLink } from "@tabler/icons-react";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
@@ -24,9 +26,8 @@ import { createLocalImageUrl } from "@homarr/icons/local";
|
|||||||
import { getI18n } from "@homarr/translation/server";
|
import { getI18n } from "@homarr/translation/server";
|
||||||
import { Link, SearchInput, TablePagination, UserAvatar } from "@homarr/ui";
|
import { Link, SearchInput, TablePagination, UserAvatar } from "@homarr/ui";
|
||||||
|
|
||||||
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
|
import { ManageContainer } from "~/components/manage/manage-container";
|
||||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { NoResults } from "~/components/no-results";
|
|
||||||
import { CopyMedia } from "./_actions/copy-media";
|
import { CopyMedia } from "./_actions/copy-media";
|
||||||
import { DeleteMedia } from "./_actions/delete-media";
|
import { DeleteMedia } from "./_actions/delete-media";
|
||||||
import { IncludeFromAllUsersSwitch } from "./_actions/show-all";
|
import { IncludeFromAllUsersSwitch } from "./_actions/show-all";
|
||||||
@@ -47,7 +48,7 @@ interface MediaListPageProps {
|
|||||||
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
|
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function MediaListPage(props: MediaListPageProps) {
|
export default async function GroupsListPage(props: MediaListPageProps) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -57,31 +58,22 @@ export default async function MediaListPage(props: MediaListPageProps) {
|
|||||||
const t = await getI18n();
|
const t = await getI18n();
|
||||||
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
||||||
const { items: medias, totalCount } = await api.media.getPaginated(searchParams);
|
const { items: medias, totalCount } = await api.media.getPaginated(searchParams);
|
||||||
const canUpload = session.user.permissions.includes("media-upload");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ManagePageLayout
|
<ManageContainer>
|
||||||
title={t("media.plural")}
|
<DynamicBreadcrumb />
|
||||||
primaryAction={
|
<Stack>
|
||||||
canUpload ? (
|
<Title>{t("media.plural")}</Title>
|
||||||
<ManageMobilePrimaryAction>
|
<Group justify="space-between">
|
||||||
<UploadMediaButton />
|
<Group>
|
||||||
</ManageMobilePrimaryAction>
|
<SearchInput placeholder={`${t("media.search")}...`} defaultValue={searchParams.search} />
|
||||||
) : undefined
|
{session.user.permissions.includes("media-view-all") && (
|
||||||
}
|
<IncludeFromAllUsersSwitch defaultChecked={searchParams.includeFromAllUsers} />
|
||||||
toolbar={
|
)}
|
||||||
<Group>
|
</Group>
|
||||||
<SearchInput placeholder={`${t("media.search")}...`} defaultValue={searchParams.search} />
|
|
||||||
{session.user.permissions.includes("media-view-all") && (
|
{session.user.permissions.includes("media-upload") && <UploadMediaButton />}
|
||||||
<IncludeFromAllUsersSwitch defaultChecked={searchParams.includeFromAllUsers} />
|
|
||||||
)}
|
|
||||||
</Group>
|
</Group>
|
||||||
}
|
|
||||||
footer={<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />}
|
|
||||||
floatingPrimaryAction={canUpload}
|
|
||||||
>
|
|
||||||
{medias.length === 0 && <NoResults icon={IconPhoto} title={t("media.noResults.title")} />}
|
|
||||||
{medias.length > 0 && (
|
|
||||||
<Table striped highlightOnHover>
|
<Table striped highlightOnHover>
|
||||||
<TableThead>
|
<TableThead>
|
||||||
<TableTr>
|
<TableTr>
|
||||||
@@ -98,8 +90,13 @@ export default async function MediaListPage(props: MediaListPageProps) {
|
|||||||
))}
|
))}
|
||||||
</TableTbody>
|
</TableTbody>
|
||||||
</Table>
|
</Table>
|
||||||
)}
|
|
||||||
</ManagePageLayout>
|
{/* Added margin to not hide pagination behind affix-button */}
|
||||||
|
<Group justify="end" mb={48}>
|
||||||
|
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</ManageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ export default async function NotFound() {
|
|||||||
return (
|
return (
|
||||||
<Center h="100%">
|
<Center h="100%">
|
||||||
<Stack align="center">
|
<Stack align="center">
|
||||||
<Title order={1}>{t("title")}</Title>
|
<Title order={1} tt="uppercase">
|
||||||
|
{t("title")}
|
||||||
|
</Title>
|
||||||
<Text>{t("text")}</Text>
|
<Text>{t("text")}</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Center>
|
</Center>
|
||||||
|
|||||||
@@ -29,20 +29,20 @@ export default async function ManagementPage() {
|
|||||||
<Space h="md" />
|
<Space h="md" />
|
||||||
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
|
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
|
||||||
{statistics.map((statistic) => (
|
{statistics.map((statistic) => (
|
||||||
<Card component={Link} href={statistic.path} key={statistic.path}>
|
<Card component={Link} href={statistic.path} key={statistic.path} radius="lg">
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<Group wrap="nowrap">
|
<Group wrap="nowrap">
|
||||||
<Text size="xl" fw={700}>
|
<Text size="2.4rem" fw="bolder">
|
||||||
{statistic.count}
|
{statistic.count}
|
||||||
</Text>
|
</Text>
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Text c="dimmed" size="xs">
|
<Text c="red" size="xs">
|
||||||
{t(`statisticLabel.${statistic.subtitleKey}`)}
|
{t(`statisticLabel.${statistic.subtitleKey}`)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text fw={600}>{t(`statistic.${statistic.titleKey}`)}</Text>
|
<Text fw="bold">{t(`statistic.${statistic.titleKey}`)}</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
<IconArrowRight size={16} stroke={1.5} color="var(--mantine-color-dimmed)" />
|
<IconArrowRight />
|
||||||
</Group>
|
</Group>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text } from "@mantine/core";
|
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
|
||||||
import { IconPencil, IconSearch } from "@tabler/icons-react";
|
import { IconPencil, IconSearch } from "@tabler/icons-react";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
@@ -10,8 +10,9 @@ import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
|||||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||||
import { Link, SearchInput, TablePagination } from "@homarr/ui";
|
import { Link, SearchInput, TablePagination } from "@homarr/ui";
|
||||||
|
|
||||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
import { ManageContainer } from "~/components/manage/manage-container";
|
||||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||||
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { NoResults } from "~/components/no-results";
|
import { NoResults } from "~/components/no-results";
|
||||||
import { SearchEngineDeleteButton } from "./_search-engine-delete-button";
|
import { SearchEngineDeleteButton } from "./_search-engine-delete-button";
|
||||||
|
|
||||||
@@ -37,31 +38,34 @@ export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
|
|||||||
|
|
||||||
const tEngine = await getScopedI18n("search.engine");
|
const tEngine = await getScopedI18n("search.engine");
|
||||||
|
|
||||||
const canCreate = session.user.permissions.includes("search-engine-create");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ManagePageLayout
|
<ManageContainer>
|
||||||
title={tEngine("page.list.title")}
|
<DynamicBreadcrumb />
|
||||||
primaryAction={
|
<Stack>
|
||||||
canCreate ? (
|
<Title>{tEngine("page.list.title")}</Title>
|
||||||
<MobileAffixButton component={Link} href="/manage/search-engines/new">
|
<Group justify="space-between" align="center">
|
||||||
{tEngine("page.create.title")}
|
<SearchInput placeholder={`${tEngine("search")}...`} defaultValue={searchParams.search} flexExpand />
|
||||||
</MobileAffixButton>
|
{session.user.permissions.includes("search-engine-create") && (
|
||||||
) : undefined
|
<MobileAffixButton component={Link} href="/manage/search-engines/new">
|
||||||
}
|
{tEngine("page.create.title")}
|
||||||
toolbar={<SearchInput placeholder={`${tEngine("search")}...`} defaultValue={searchParams.search} flexExpand />}
|
</MobileAffixButton>
|
||||||
footer={<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />}
|
)}
|
||||||
floatingPrimaryAction={canCreate}
|
</Group>
|
||||||
>
|
{searchEngines.length === 0 && <SearchEngineNoResults />}
|
||||||
{searchEngines.length === 0 && <SearchEngineNoResults />}
|
{searchEngines.length > 0 && (
|
||||||
{searchEngines.length > 0 && (
|
<Stack gap="sm">
|
||||||
<Stack gap="sm">
|
{searchEngines.map((searchEngine) => (
|
||||||
{searchEngines.map((searchEngine) => (
|
<SearchEngineCard key={searchEngine.id} searchEngine={searchEngine} />
|
||||||
<SearchEngineCard key={searchEngine.id} searchEngine={searchEngine} />
|
))}
|
||||||
))}
|
</Stack>
|
||||||
</Stack>
|
)}
|
||||||
)}
|
|
||||||
</ManagePageLayout>
|
{/* Added margin to not hide pagination behind affix-button */}
|
||||||
|
<Group justify="end" mb={48}>
|
||||||
|
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</ManageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +78,7 @@ const SearchEngineCard = async ({ searchEngine }: SearchEngineCardProps) => {
|
|||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card withBorder>
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<Group align="top" justify="start" wrap="nowrap" style={{ flex: 1 }}>
|
<Group align="top" justify="start" wrap="nowrap" style={{ flex: 1 }}>
|
||||||
<Avatar
|
<Avatar
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => {
|
|||||||
<>
|
<>
|
||||||
<Title order={2}>{t("title")}</Title>
|
<Title order={2}>{t("title")}</Title>
|
||||||
|
|
||||||
<Card pos="relative">
|
<Card pos="relative" withBorder>
|
||||||
<LoadingOverlay visible={isPending} />
|
<LoadingOverlay visible={isPending} zIndex={1000} overlayProps={{ radius: "sm", blur: 2 }} />
|
||||||
<Stack>
|
<Stack>
|
||||||
<SwitchSetting form={form} formKey="enableGeneral" title={t("general.title")} text={t("general.text")} />
|
<SwitchSetting form={form} formKey="enableGeneral" title={t("general.title")} text={t("general.text")} />
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
+2
-2
@@ -41,11 +41,11 @@ export const CrawlingAndIndexingSettings = ({ initialData }: CrawlingAndIndexing
|
|||||||
<>
|
<>
|
||||||
<Title order={2}>{t("title")}</Title>
|
<Title order={2}>{t("title")}</Title>
|
||||||
|
|
||||||
<Card pos="relative">
|
<Card pos="relative" withBorder>
|
||||||
<Text c={"dimmed"} mb={"lg"}>
|
<Text c={"dimmed"} mb={"lg"}>
|
||||||
{t("warning")}
|
{t("warning")}
|
||||||
</Text>
|
</Text>
|
||||||
<LoadingOverlay visible={isPending} />
|
<LoadingOverlay visible={isPending} zIndex={1000} overlayProps={{ radius: "sm", blur: 2 }} />
|
||||||
<Stack>
|
<Stack>
|
||||||
<SwitchSetting form={form} formKey="noIndex" title={t("noIndex.title")} text={t("noIndex.text")} />
|
<SwitchSetting form={form} formKey="noIndex" title={t("noIndex.title")} text={t("noIndex.text")} />
|
||||||
<SwitchSetting form={form} formKey="noFollow" title={t("noFollow.title")} text={t("noFollow.text")} />
|
<SwitchSetting form={form} formKey="noFollow" title={t("noFollow.title")} text={t("noFollow.text")} />
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export const SwitchSetting = <TFormValue extends Record<string, boolean>>({
|
|||||||
disabled,
|
disabled,
|
||||||
}: {
|
}: {
|
||||||
form: Modify<
|
form: Modify<
|
||||||
UseFormReturnType<TFormValue, TFormValue>,
|
UseFormReturnType<TFormValue, () => TFormValue>,
|
||||||
{
|
{
|
||||||
setFieldValue: (key: keyof TFormValue, value: (previous: boolean) => boolean) => void;
|
setFieldValue: (key: keyof TFormValue, value: (previous: boolean) => boolean) => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Alert, Button, Card, Group, Stack, Text, Title } from "@mantine/core";
|
|
||||||
import { IconAlertTriangle, IconDatabaseExport, IconDownload } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
import { DangerousActionConfirmation } from "~/components/backup/dangerous-action-confirmation";
|
|
||||||
|
|
||||||
export const BackupExportCard = () => {
|
|
||||||
const t = useScopedI18n("management.page.tool.backup.export");
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [showConfirm, setShowConfirm] = useState(false);
|
|
||||||
|
|
||||||
const handleExport = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/backup/export");
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorMessage = await response.json().then(
|
|
||||||
(data) => data.error,
|
|
||||||
() => `Server returned ${response.status}`,
|
|
||||||
);
|
|
||||||
throw new Error(errorMessage ?? t("error"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await response.blob();
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const filename = response.headers.get("Content-Disposition")?.match(/filename="(.+)"/)?.[1] ?? "homarr-backup.zip";
|
|
||||||
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = filename;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
setShowConfirm(false);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : t("error"));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card withBorder>
|
|
||||||
<Stack gap="sm">
|
|
||||||
<Group gap="sm">
|
|
||||||
<IconDatabaseExport size={24} />
|
|
||||||
<Title order={4}>{t("title")}</Title>
|
|
||||||
</Group>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t("description")}
|
|
||||||
</Text>
|
|
||||||
{error && (
|
|
||||||
<Alert color="red" icon={<IconAlertTriangle size={16} />}>
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showConfirm ? (
|
|
||||||
<DangerousActionConfirmation
|
|
||||||
title={t("confirm.title")}
|
|
||||||
warningTitle={t("confirm.warningTitle")}
|
|
||||||
warningBody={t("confirm.warningBody")}
|
|
||||||
typePrompt={t("confirm.typePrompt", { phrase: "I understand" })}
|
|
||||||
submitLabel={t("confirm.submit")}
|
|
||||||
submitIcon={<IconDownload size={16} />}
|
|
||||||
cancelLabel={t("confirm.cancel")}
|
|
||||||
onConfirm={handleExport}
|
|
||||||
onCancel={() => setShowConfirm(false)}
|
|
||||||
disabled={loading}
|
|
||||||
color="orange"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Group>
|
|
||||||
<Button leftSection={<IconDownload size={16} />} loading={loading} onClick={() => setShowConfirm(true)}>
|
|
||||||
{t("button")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Card, Group, Stack, Text, Title } from "@mantine/core";
|
|
||||||
import { IconDatabaseImport } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
import { DatabaseRestoreFlow } from "~/components/backup";
|
|
||||||
|
|
||||||
export const BackupImportCard = () => {
|
|
||||||
const t = useScopedI18n("management.page.tool.backup.import");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card withBorder>
|
|
||||||
<Stack gap="md">
|
|
||||||
<Group gap="sm">
|
|
||||||
<IconDatabaseImport size={24} />
|
|
||||||
<Title order={4}>{t("title")}</Title>
|
|
||||||
</Group>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t("description")}
|
|
||||||
</Text>
|
|
||||||
<DatabaseRestoreFlow />
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { notFound } from "next/navigation";
|
|
||||||
import { Stack, Title } from "@mantine/core";
|
|
||||||
|
|
||||||
import { auth } from "@homarr/auth/next";
|
|
||||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
|
||||||
|
|
||||||
import { createMetaTitle } from "~/metadata";
|
|
||||||
import { BackupExportCard } from "./_components/backup-export-card";
|
|
||||||
import { BackupImportCard } from "./_components/backup-import-card";
|
|
||||||
|
|
||||||
export async function generateMetadata() {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user.permissions.includes("admin") || dbEnv.DRIVER !== "better-sqlite3") {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const t = await getScopedI18n("management.page.tool.backup");
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: createMetaTitle(t("metaTitle")),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function BackupPage() {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user.permissions.includes("admin") || dbEnv.DRIVER !== "better-sqlite3") {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
const t = await getScopedI18n("management.page.tool.backup");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack>
|
|
||||||
<Title>{t("title")}</Title>
|
|
||||||
<BackupExportCard />
|
|
||||||
<BackupImportCard />
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -79,7 +79,7 @@ export default async function CertificatesPage({ params }: CertificatesPageProps
|
|||||||
|
|
||||||
<SimpleGrid cols={{ sm: 1, lg: 2, xl: 3 }} spacing="lg">
|
<SimpleGrid cols={{ sm: 1, lg: 2, xl: 3 }} spacing="lg">
|
||||||
{x509Certificates.map((cert) => (
|
{x509Certificates.map((cert) => (
|
||||||
<Card key={cert.fileName}>
|
<Card key={cert.fileName} withBorder>
|
||||||
<Group wrap="nowrap">
|
<Group wrap="nowrap">
|
||||||
{cert.isError ? (
|
{cert.isError ? (
|
||||||
<IconAlertTriangle
|
<IconAlertTriangle
|
||||||
|
|||||||
@@ -1,26 +1,23 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import type { MantineColor } from "@mantine/core";
|
import type { MantineColor } from "@mantine/core";
|
||||||
import { ActionIcon, Avatar, Badge, Box, Button, Group, Menu, Text, Tooltip } from "@mantine/core";
|
import { Avatar, Badge, Box, Button, Group, Text } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
IconCategoryPlus,
|
IconCategoryPlus,
|
||||||
IconDots,
|
|
||||||
IconFileText,
|
|
||||||
IconPlayerPlay,
|
IconPlayerPlay,
|
||||||
IconPlayerStop,
|
IconPlayerStop,
|
||||||
IconRefresh,
|
IconRefresh,
|
||||||
IconRotateClockwise,
|
IconRotateClockwise,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import type { MRT_ColumnDef, MRT_Row } from "mantine-react-table";
|
import type { MRT_ColumnDef } from "mantine-react-table";
|
||||||
import { MantineReactTable } from "mantine-react-table";
|
import { MantineReactTable } from "mantine-react-table";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { humanFileSize, useTimeAgo } from "@homarr/common";
|
import { useTimeAgo } from "@homarr/common";
|
||||||
import type { ContainerState } from "@homarr/docker";
|
import type { ContainerState } from "@homarr/docker";
|
||||||
import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared";
|
import { containerStateColorMap } from "@homarr/docker/shared";
|
||||||
import { useModalAction } from "@homarr/modals";
|
import { useModalAction } from "@homarr/modals";
|
||||||
import { AddDockerAppToHomarr } from "@homarr/modals-collection";
|
import { AddDockerAppToHomarr } from "@homarr/modals-collection";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
@@ -30,30 +27,25 @@ import type { TablerIcon } from "@homarr/ui";
|
|||||||
import { OverflowBadge } from "@homarr/ui";
|
import { OverflowBadge } from "@homarr/ui";
|
||||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||||
|
|
||||||
type DockerContainer = RouterOutputs["docker"]["getContainers"]["containers"][number];
|
const createColumns = (
|
||||||
|
t: TranslationFunction,
|
||||||
const createContainerLogsPath = (container: Pick<DockerContainer, "id" | "name">) =>
|
): MRT_ColumnDef<RouterOutputs["docker"]["getContainers"]["containers"][number]>[] => [
|
||||||
`/manage/tools/docker/logs/${container.id}?name=${encodeURIComponent(container.name)}`;
|
|
||||||
|
|
||||||
const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[] => [
|
|
||||||
{
|
{
|
||||||
accessorKey: "name",
|
accessorKey: "name",
|
||||||
header: t("docker.field.name.label"),
|
header: t("docker.field.name.label"),
|
||||||
Cell({ renderedCellValue, row }) {
|
Cell({ renderedCellValue, row }) {
|
||||||
return (
|
return (
|
||||||
<Group gap="xs" wrap="nowrap">
|
<Group gap="xs">
|
||||||
<Avatar
|
<Avatar
|
||||||
variant="outline"
|
variant="outline"
|
||||||
radius="sm"
|
radius="lg"
|
||||||
size="md"
|
size="md"
|
||||||
styles={{ image: { objectFit: "contain" } }}
|
styles={{ image: { objectFit: "contain" } }}
|
||||||
src={row.original.iconUrl}
|
src={row.original.iconUrl}
|
||||||
>
|
>
|
||||||
{row.original.name.at(0)?.toUpperCase()}
|
{row.original.name.at(0)?.toUpperCase()}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Text truncate="end" maw={500}>
|
<Text>{renderedCellValue}</Text>
|
||||||
{renderedCellValue}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -84,11 +76,11 @@ const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[]
|
|||||||
maxSize: 200,
|
maxSize: 200,
|
||||||
Cell({ renderedCellValue, cell }) {
|
Cell({ renderedCellValue, cell }) {
|
||||||
return (
|
return (
|
||||||
<Tooltip label={cell.row.original.image} multiline maw={400}>
|
<Box maw={200}>
|
||||||
<Box maw={200}>
|
<Text truncate="end" title={cell.row.original.image}>
|
||||||
<Text truncate="end">{renderedCellValue}</Text>
|
{renderedCellValue}
|
||||||
</Box>
|
</Text>
|
||||||
</Tooltip>
|
</Box>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -103,79 +95,18 @@ const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[]
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "cpuUsage",
|
|
||||||
accessorKey: "cpuUsage",
|
|
||||||
size: 120,
|
|
||||||
header: t("docker.field.stats.cpu.label"),
|
|
||||||
enableHiding: true,
|
|
||||||
sortingFn: (rowA, rowB) => {
|
|
||||||
const cpuUsageA = safeValue(rowA.original.cpuUsage);
|
|
||||||
const cpuUsageB = safeValue(rowB.original.cpuUsage);
|
|
||||||
|
|
||||||
return cpuUsageA - cpuUsageB;
|
|
||||||
},
|
|
||||||
Cell({ row }) {
|
|
||||||
const cpuUsage = safeValue(row.original.cpuUsage);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Text size="sm" c={cpuUsageColor(cpuUsage, row.original.state)}>
|
|
||||||
{cpuUsage.toFixed(2)}%
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "memoryUsage",
|
|
||||||
accessorKey: "memoryUsage",
|
|
||||||
size: 140,
|
|
||||||
header: t("docker.field.stats.memory.label"),
|
|
||||||
enableHiding: true,
|
|
||||||
sortingFn: (rowA, rowB) => {
|
|
||||||
const memoryUsageA = safeValue(rowA.original.memoryUsage);
|
|
||||||
const memoryUsageB = safeValue(rowB.original.memoryUsage);
|
|
||||||
|
|
||||||
return memoryUsageA - memoryUsageB;
|
|
||||||
},
|
|
||||||
Cell({ row }) {
|
|
||||||
const bytesUsage = safeValue(row.original.memoryUsage);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Text size="sm" c={memoryUsageColor(bytesUsage, row.original.state)}>
|
|
||||||
{humanFileSize(bytesUsage)}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["getContainers"]) {
|
export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"]) {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const tDocker = useScopedI18n("docker");
|
const tDocker = useScopedI18n("docker");
|
||||||
const { data } = clientApi.docker.getContainers.useQuery(undefined, {
|
const { data } = clientApi.docker.getContainers.useQuery(undefined, {
|
||||||
initialData: { containers, timestamp },
|
initialData,
|
||||||
refetchOnMount: false,
|
refetchOnMount: false,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
});
|
});
|
||||||
const relativeTime = useTimeAgo(data.timestamp);
|
const relativeTime = useTimeAgo(data.timestamp);
|
||||||
const utils = clientApi.useUtils();
|
|
||||||
const { mutate, isPending } = clientApi.docker.invalidate.useMutation({
|
|
||||||
async onSuccess() {
|
|
||||||
await utils.docker.getContainers.invalidate();
|
|
||||||
showSuccessNotification({
|
|
||||||
title: tDocker("action.refresh.notification.success.title"),
|
|
||||||
message: tDocker("action.refresh.notification.success.message"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError() {
|
|
||||||
showErrorNotification({
|
|
||||||
title: tDocker("action.refresh.notification.error.title"),
|
|
||||||
message: tDocker("action.refresh.notification.error.message"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const table = useTranslatedMantineReactTable({
|
const table = useTranslatedMantineReactTable({
|
||||||
data: data.containers,
|
data: data.containers,
|
||||||
enableDensityToggle: false,
|
enableDensityToggle: false,
|
||||||
@@ -183,8 +114,6 @@ export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["
|
|||||||
enableColumnFilters: false,
|
enableColumnFilters: false,
|
||||||
enablePagination: false,
|
enablePagination: false,
|
||||||
enableRowSelection: true,
|
enableRowSelection: true,
|
||||||
enableRowActions: true,
|
|
||||||
positionActionsColumn: "last",
|
|
||||||
positionToolbarAlertBanner: "top",
|
positionToolbarAlertBanner: "top",
|
||||||
enableTableFooter: false,
|
enableTableFooter: false,
|
||||||
enableBottomToolbar: false,
|
enableBottomToolbar: false,
|
||||||
@@ -195,15 +124,36 @@ export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["
|
|||||||
autoFocus: true,
|
autoFocus: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
initialState: { density: "xs", showGlobalFilter: true, columnVisibility: { cpuUsage: false, memoryUsage: false } },
|
initialState: { density: "xs", showGlobalFilter: true },
|
||||||
mantineTableBodyCellProps: { style: { padding: "4px 8px" } },
|
renderTopToolbarCustomActions: () => {
|
||||||
mantineTableHeadCellProps: { style: { padding: "4px 8px" } },
|
const utils = clientApi.useUtils();
|
||||||
renderRowActions: ({ row }: { row: MRT_Row<DockerContainer> }) => <ContainerRowMenu container={row.original} />,
|
const { mutate, isPending } = clientApi.docker.invalidate.useMutation({
|
||||||
renderTopToolbarCustomActions: () => (
|
async onSuccess() {
|
||||||
<Button variant="default" rightSection={<IconRefresh size="1rem" />} onClick={() => mutate()} loading={isPending}>
|
await utils.docker.getContainers.invalidate();
|
||||||
{tDocker("action.refresh.label")}
|
showSuccessNotification({
|
||||||
</Button>
|
title: tDocker("action.refresh.notification.success.title"),
|
||||||
),
|
message: tDocker("action.refresh.notification.success.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
showErrorNotification({
|
||||||
|
title: tDocker("action.refresh.notification.error.title"),
|
||||||
|
message: tDocker("action.refresh.notification.error.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
rightSection={<IconRefresh size="1rem" />}
|
||||||
|
onClick={() => mutate()}
|
||||||
|
loading={isPending}
|
||||||
|
>
|
||||||
|
{tDocker("action.refresh.label")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
renderToolbarAlertBannerContent: ({ groupedAlert, table }) => {
|
renderToolbarAlertBannerContent: ({ groupedAlert, table }) => {
|
||||||
const dockerContainers = table.getSelectedRowModel().rows.map((row) => row.original);
|
const dockerContainers = table.getSelectedRowModel().rows.map((row) => row.original);
|
||||||
return (
|
return (
|
||||||
@@ -231,104 +181,28 @@ export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const containerActions: { action: "start" | "stop" | "restart" | "remove"; icon: TablerIcon; color: MantineColor }[] = [
|
|
||||||
{ action: "start", icon: IconPlayerPlay, color: "green" },
|
|
||||||
{ action: "stop", icon: IconPlayerStop, color: "red" },
|
|
||||||
{ action: "restart", icon: IconRotateClockwise, color: "orange" },
|
|
||||||
{ action: "remove", icon: IconTrash, color: "red" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const ContainerRowMenu = ({ container }: { container: DockerContainer }) => {
|
|
||||||
const t = useScopedI18n("docker.action");
|
|
||||||
const router = useRouter();
|
|
||||||
const utils = clientApi.useUtils();
|
|
||||||
const { openModal } = useModalAction(AddDockerAppToHomarr);
|
|
||||||
|
|
||||||
const useContainerAction = (action: "start" | "stop" | "restart" | "remove") =>
|
|
||||||
clientApi.docker[`${action}All`].useMutation({
|
|
||||||
async onSettled() {
|
|
||||||
await utils.docker.getContainers.invalidate();
|
|
||||||
},
|
|
||||||
onSuccess() {
|
|
||||||
showSuccessNotification({
|
|
||||||
title: t(`${action}.notification.success.title`),
|
|
||||||
message: t(`${action}.notification.success.message`),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError() {
|
|
||||||
showErrorNotification({
|
|
||||||
title: t(`${action}.notification.error.title`),
|
|
||||||
message: t(`${action}.notification.error.message`),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const mutations = {
|
|
||||||
start: useContainerAction("start"),
|
|
||||||
stop: useContainerAction("stop"),
|
|
||||||
restart: useContainerAction("restart"),
|
|
||||||
remove: useContainerAction("remove"),
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Menu withinPortal position="bottom-end" shadow="sm">
|
|
||||||
<Menu.Target>
|
|
||||||
<ActionIcon variant="subtle" color="gray" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<IconDots size="1rem" />
|
|
||||||
</ActionIcon>
|
|
||||||
</Menu.Target>
|
|
||||||
<Menu.Dropdown>
|
|
||||||
<Menu.Item
|
|
||||||
leftSection={<IconFileText size="1rem" />}
|
|
||||||
onClick={() => router.push(createContainerLogsPath(container))}
|
|
||||||
>
|
|
||||||
{t("logs.label")}
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Divider />
|
|
||||||
{containerActions.map(({ action, icon: Icon, color }) => (
|
|
||||||
<Menu.Item
|
|
||||||
key={action}
|
|
||||||
leftSection={<Icon size="1rem" />}
|
|
||||||
color={color}
|
|
||||||
onClick={() => mutations[action].mutate({ ids: [container.id] })}
|
|
||||||
>
|
|
||||||
{t(`${action}.label`)}
|
|
||||||
</Menu.Item>
|
|
||||||
))}
|
|
||||||
<Menu.Divider />
|
|
||||||
<Menu.Item
|
|
||||||
leftSection={<IconCategoryPlus size="1rem" />}
|
|
||||||
color="red"
|
|
||||||
onClick={() => openModal({ selectedContainers: [container] })}
|
|
||||||
>
|
|
||||||
{t("addToHomarr.label")}
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu.Dropdown>
|
|
||||||
</Menu>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ContainerActionBarProps {
|
interface ContainerActionBarProps {
|
||||||
selectedContainers: DockerContainer[];
|
selectedContainers: RouterOutputs["docker"]["getContainers"]["containers"];
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContainerActionBar = ({ selectedContainers }: ContainerActionBarProps) => {
|
const ContainerActionBar = ({ selectedContainers }: ContainerActionBarProps) => {
|
||||||
const t = useScopedI18n("docker.action");
|
const t = useScopedI18n("docker.action");
|
||||||
const { openModal } = useModalAction(AddDockerAppToHomarr);
|
const { openModal } = useModalAction(AddDockerAppToHomarr);
|
||||||
|
const handleClick = () => {
|
||||||
|
openModal({
|
||||||
|
selectedContainers,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const selectedIds = selectedContainers.map((container) => container.id);
|
const selectedIds = selectedContainers.map((container) => container.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
{containerActions.map(({ action, icon, color }) => (
|
<ContainerActionBarButton icon={IconPlayerPlay} color="green" action="start" selectedIds={selectedIds} />
|
||||||
<ContainerActionBarButton key={action} icon={icon} color={color} action={action} selectedIds={selectedIds} />
|
<ContainerActionBarButton icon={IconPlayerStop} color="red" action="stop" selectedIds={selectedIds} />
|
||||||
))}
|
<ContainerActionBarButton icon={IconRotateClockwise} color="orange" action="restart" selectedIds={selectedIds} />
|
||||||
<Button
|
<ContainerActionBarButton icon={IconTrash} color="red" action="remove" selectedIds={selectedIds} />
|
||||||
leftSection={<IconCategoryPlus />}
|
<Button leftSection={<IconCategoryPlus />} color={"red"} onClick={handleClick} variant="light" radius="md">
|
||||||
color="red"
|
|
||||||
onClick={() => openModal({ selectedContainers })}
|
|
||||||
variant="light"
|
|
||||||
>
|
|
||||||
{t("addToHomarr.label")}
|
{t("addToHomarr.label")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -379,6 +253,7 @@ const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
|
|||||||
onClick={handleClickAsync}
|
onClick={handleClickAsync}
|
||||||
loading={isPending}
|
loading={isPending}
|
||||||
variant="light"
|
variant="light"
|
||||||
|
radius="md"
|
||||||
>
|
>
|
||||||
{t(`${props.action}.label`)}
|
{t(`${props.action}.label`)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
|
||||||
|
|
||||||
export const ClientSideDockerLogsTerminal = dynamic(
|
|
||||||
() => import("./terminal").then(({ DockerLogsTerminal }) => DockerLogsTerminal),
|
|
||||||
{
|
|
||||||
ssr: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { notFound } from "next/navigation";
|
|
||||||
import { Box, Group } from "@mantine/core";
|
|
||||||
|
|
||||||
import { api } from "@homarr/api/server";
|
|
||||||
import { auth } from "@homarr/auth/next";
|
|
||||||
import { env } from "@homarr/docker/env";
|
|
||||||
|
|
||||||
import "@xterm/xterm/css/xterm.css";
|
|
||||||
|
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
|
||||||
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
|
|
||||||
import { ClientSideDockerLogsTerminal } from "./client";
|
|
||||||
|
|
||||||
interface DockerContainerLogsPageProps {
|
|
||||||
params: Promise<{ containerId: string }>;
|
|
||||||
searchParams: Promise<{ name?: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function DockerContainerLogsPage({ params, searchParams }: DockerContainerLogsPageProps) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!(session?.user.permissions.includes("admin") && env.ENABLE_DOCKER)) {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
const { containerId } = await params;
|
|
||||||
const { name } = await searchParams;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api.docker.logs({ id: containerId, tail: 1 });
|
|
||||||
} catch {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Group justify="space-between" align="center" wrap="nowrap">
|
|
||||||
<DynamicBreadcrumb
|
|
||||||
dynamicMappings={new Map([[containerId, name ?? containerId]])}
|
|
||||||
nonInteractable={[containerId]}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
|
|
||||||
<ClientSideDockerLogsTerminal containerId={containerId} />
|
|
||||||
</Box>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
.outerTerminal > div {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef } from "react";
|
|
||||||
import { Box } from "@mantine/core";
|
|
||||||
import { CanvasAddon } from "@xterm/addon-canvas";
|
|
||||||
import { FitAddon } from "@xterm/addon-fit";
|
|
||||||
import { Terminal } from "@xterm/xterm";
|
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
|
|
||||||
import classes from "./terminal.module.css";
|
|
||||||
|
|
||||||
interface DockerLogsTerminalProps {
|
|
||||||
containerId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DockerLogsTerminal = ({ containerId }: DockerLogsTerminalProps) => {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const terminalRef = useRef<Terminal | null>(null);
|
|
||||||
const pendingLogsRef = useRef<string[]>([]);
|
|
||||||
const isTerminalReadyRef = useRef(false);
|
|
||||||
|
|
||||||
const writeToTerminal = useCallback((data: string) => {
|
|
||||||
if (!isTerminalReadyRef.current || !terminalRef.current) {
|
|
||||||
pendingLogsRef.current.push(data);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
terminalRef.current.write(data);
|
|
||||||
terminalRef.current.refresh(0, terminalRef.current.rows - 1);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
clientApi.docker.subscribeLogs.useSubscription(
|
|
||||||
{ id: containerId, tail: 200 },
|
|
||||||
{
|
|
||||||
onData: writeToTerminal,
|
|
||||||
onError(err) {
|
|
||||||
writeToTerminal(`\r\n\x1b[31m${err.message}\x1b[0m`);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!ref.current) {
|
|
||||||
return () => undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const canvasAddon = new CanvasAddon();
|
|
||||||
|
|
||||||
terminalRef.current = new Terminal({
|
|
||||||
cursorBlink: false,
|
|
||||||
disableStdin: true,
|
|
||||||
convertEol: true,
|
|
||||||
fontSize: 14,
|
|
||||||
});
|
|
||||||
terminalRef.current.open(ref.current);
|
|
||||||
terminalRef.current.loadAddon(canvasAddon);
|
|
||||||
|
|
||||||
isTerminalReadyRef.current = true;
|
|
||||||
const terminal = terminalRef.current;
|
|
||||||
pendingLogsRef.current.forEach((data) => terminal.write(data));
|
|
||||||
pendingLogsRef.current = [];
|
|
||||||
terminal.refresh(0, terminal.rows - 1);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
const fitAddon = new FitAddon();
|
|
||||||
terminalRef.current?.loadAddon(fitAddon);
|
|
||||||
fitAddon.fit();
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isTerminalReadyRef.current = false;
|
|
||||||
pendingLogsRef.current = [];
|
|
||||||
canvasAddon.dispose();
|
|
||||||
terminalRef.current?.dispose();
|
|
||||||
terminalRef.current = null;
|
|
||||||
};
|
|
||||||
}, [writeToTerminal]);
|
|
||||||
|
|
||||||
return <Box ref={ref} className={classes.outerTerminal} h="100%" />;
|
|
||||||
};
|
|
||||||
+19
-7
@@ -4,16 +4,28 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: var(--mantine-spacing-xs);
|
padding: var(--mantine-spacing-xs);
|
||||||
padding-left: calc(var(--mantine-spacing-xs) * 2);
|
padding-left: calc(var(--mantine-spacing-xs) * 2);
|
||||||
border-left: 1px solid var(--mantine-color-blue-6);
|
|
||||||
background-color: light-dark(
|
@mixin hover {
|
||||||
var(--mantine-color-blue-0),
|
box-shadow: var(--mantine-shadow-md);
|
||||||
color-mix(in oklch, var(--mantine-color-blue-9), transparent 85%)
|
}
|
||||||
);
|
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 6px;
|
||||||
|
background-image: linear-gradient(0, var(--mantine-color-blue-4), var(--mantine-color-blue-9));
|
||||||
|
}
|
||||||
|
|
||||||
@mixin rtl {
|
@mixin rtl {
|
||||||
padding-left: var(--mantine-spacing-xs);
|
padding-left: var(--mantine-spacing-xs);
|
||||||
padding-right: calc(var(--mantine-spacing-xs) * 2);
|
padding-right: calc(var(--mantine-spacing-xs) * 2);
|
||||||
border-left: none;
|
|
||||||
border-right: 1px solid var(--mantine-color-blue-6);
|
&::before {
|
||||||
|
left: unset;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
@@ -23,6 +23,7 @@ export function HeaderCard(props: HeaderCardProps) {
|
|||||||
<Flex align="center" justify={isRTL ? "space-between" : "flex-start"} gap="md" direction={"row"}>
|
<Flex align="center" justify={isRTL ? "space-between" : "flex-start"} gap="md" direction={"row"}>
|
||||||
<ThemeIcon
|
<ThemeIcon
|
||||||
size="xl"
|
size="xl"
|
||||||
|
radius="md"
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
gradient={{
|
gradient={{
|
||||||
deg: 0,
|
deg: 0,
|
||||||
|
|||||||
+2
-4
@@ -8,8 +8,6 @@ import { useCurrentLocale, useI18n } from "@homarr/translation/client";
|
|||||||
import { ResourceIcon } from "~/app/[locale]/manage/tools/kubernetes/cluster-dashboard/resource-gauge/resource-icon";
|
import { ResourceIcon } from "~/app/[locale]/manage/tools/kubernetes/cluster-dashboard/resource-gauge/resource-icon";
|
||||||
import classes from "./resource-gauge.module.css";
|
import classes from "./resource-gauge.module.css";
|
||||||
|
|
||||||
const KUBERNETES_BRAND_COLOR = "#326ce5";
|
|
||||||
|
|
||||||
interface KubernetesResourceGaugeProps {
|
interface KubernetesResourceGaugeProps {
|
||||||
kubernetesCapacity: KubernetesCapacity;
|
kubernetesCapacity: KubernetesCapacity;
|
||||||
}
|
}
|
||||||
@@ -20,8 +18,8 @@ export function ResourceGauge(props: KubernetesResourceGaugeProps) {
|
|||||||
const isRTL = Boolean(isLocaleRTL(currentLocale));
|
const isRTL = Boolean(isLocaleRTL(currentLocale));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper className={classes.paper} mt={20}>
|
<Paper radius="md" withBorder className={classes.paper} mt={20}>
|
||||||
<ThemeIcon className={classes.icon} size={60} radius={60} bg={KUBERNETES_BRAND_COLOR}>
|
<ThemeIcon className={classes.icon} size={60} radius={60} bg={"#326ce5"}>
|
||||||
<ResourceIcon type={props.kubernetesCapacity.type} />
|
<ResourceIcon type={props.kubernetesCapacity.type} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -17,7 +17,12 @@ interface ResourceTileProps {
|
|||||||
export function ResourceTile(props: ResourceTileProps) {
|
export function ResourceTile(props: ResourceTileProps) {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
return (
|
return (
|
||||||
<Card component={Link} href={`/manage/tools/kubernetes/${props.label}`} className={classes.cardContainer}>
|
<Card
|
||||||
|
withBorder
|
||||||
|
component={Link}
|
||||||
|
href={`/manage/tools/kubernetes/${props.label}`}
|
||||||
|
className={classes.cardContainer}
|
||||||
|
>
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<Image src={`/images/kubernetes/${props.label}.svg`} alt={props.label} width={64} height={64} />
|
<Image src={`/images/kubernetes/${props.label}.svg`} alt={props.label} width={64} height={64} />
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Group, Slider, Text } from "@mantine/core";
|
|
||||||
import { IconTextSize } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import { useLogContext } from "./log-context";
|
|
||||||
|
|
||||||
export const LogFontSizeSlider = () => {
|
|
||||||
const { fontSize, setFontSize } = useLogContext();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Group gap="xs" wrap="nowrap" w={160}>
|
|
||||||
<IconTextSize size={18} style={{ flexShrink: 0 }} />
|
|
||||||
<Slider
|
|
||||||
min={10}
|
|
||||||
max={24}
|
|
||||||
step={1}
|
|
||||||
value={fontSize}
|
|
||||||
onChange={setFontSize}
|
|
||||||
label={(val) => `${val}px`}
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
size="xs"
|
|
||||||
/>
|
|
||||||
<Text size="xs" c="dimmed" w={32} ta="right">
|
|
||||||
{fontSize}px
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -2,14 +2,12 @@
|
|||||||
|
|
||||||
import { Select } from "@mantine/core";
|
import { Select } from "@mantine/core";
|
||||||
|
|
||||||
import { logLevelConfiguration, logLevels } from "@homarr/core/infrastructure/logs/constants";
|
|
||||||
import type { LogLevel } from "@homarr/core/infrastructure/logs/constants";
|
import type { LogLevel } from "@homarr/core/infrastructure/logs/constants";
|
||||||
|
import { logLevelConfiguration, logLevels } from "@homarr/core/infrastructure/logs/constants";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { useLogContext } from "./log-context";
|
import { useLogContext } from "./log-context";
|
||||||
|
|
||||||
const levelByValue = Object.fromEntries(logLevels.map((logLevel) => [logLevel, logLevel])) as Record<string, LogLevel>;
|
|
||||||
|
|
||||||
export const LogLevelSelection = () => {
|
export const LogLevelSelection = () => {
|
||||||
const { level, setLevel } = useLogContext();
|
const { level, setLevel } = useLogContext();
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
@@ -21,12 +19,7 @@ export const LogLevelSelection = () => {
|
|||||||
label: `${logLevelConfiguration[level].prefix} ${t(`log.level.option.${level}`)}`,
|
label: `${logLevelConfiguration[level].prefix} ${t(`log.level.option.${level}`)}`,
|
||||||
}))}
|
}))}
|
||||||
value={level}
|
value={level}
|
||||||
onChange={(value) => {
|
onChange={(value) => setLevel(value as LogLevel)}
|
||||||
const nextLevel = value && levelByValue[value];
|
|
||||||
if (nextLevel) {
|
|
||||||
setLevel(nextLevel);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
checkIconPosition="right"
|
checkIconPosition="right"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,23 +6,10 @@ import { createContext, useContext, useMemo, useState } from "react";
|
|||||||
import type { LogLevel } from "@homarr/core/infrastructure/logs/constants";
|
import type { LogLevel } from "@homarr/core/infrastructure/logs/constants";
|
||||||
import { logLevels } from "@homarr/core/infrastructure/logs/constants";
|
import { logLevels } from "@homarr/core/infrastructure/logs/constants";
|
||||||
|
|
||||||
const FONT_SIZE_KEY = "homarr-log-font-size";
|
|
||||||
const DEFAULT_FONT_SIZE = 14;
|
|
||||||
|
|
||||||
const readStoredFontSize = () => {
|
|
||||||
if (typeof window === "undefined") return DEFAULT_FONT_SIZE;
|
|
||||||
const stored = localStorage.getItem(FONT_SIZE_KEY);
|
|
||||||
if (!stored) return DEFAULT_FONT_SIZE;
|
|
||||||
const parsed = Number(stored);
|
|
||||||
return Number.isFinite(parsed) ? Math.min(Math.max(parsed, 10), 24) : DEFAULT_FONT_SIZE;
|
|
||||||
};
|
|
||||||
|
|
||||||
const LogContext = createContext<{
|
const LogContext = createContext<{
|
||||||
level: LogLevel;
|
level: LogLevel;
|
||||||
setLevel: (level: LogLevel) => void;
|
setLevel: (level: LogLevel) => void;
|
||||||
activeLevels: LogLevel[];
|
activeLevels: LogLevel[];
|
||||||
fontSize: number;
|
|
||||||
setFontSize: (size: number) => void;
|
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
interface LogContextProviderProps extends PropsWithChildren {
|
interface LogContextProviderProps extends PropsWithChildren {
|
||||||
@@ -31,19 +18,9 @@ interface LogContextProviderProps extends PropsWithChildren {
|
|||||||
|
|
||||||
export const LogContextProvider = ({ defaultLevel, children }: LogContextProviderProps) => {
|
export const LogContextProvider = ({ defaultLevel, children }: LogContextProviderProps) => {
|
||||||
const [level, setLevel] = useState(defaultLevel);
|
const [level, setLevel] = useState(defaultLevel);
|
||||||
const [fontSize, setFontSizeRaw] = useState(readStoredFontSize);
|
|
||||||
const activeLevels = useMemo(() => logLevels.slice(0, logLevels.indexOf(level) + 1), [level]);
|
const activeLevels = useMemo(() => logLevels.slice(0, logLevels.indexOf(level) + 1), [level]);
|
||||||
|
|
||||||
const setFontSize = (size: number) => {
|
return <LogContext.Provider value={{ level, setLevel, activeLevels }}>{children}</LogContext.Provider>;
|
||||||
setFontSizeRaw(size);
|
|
||||||
localStorage.setItem(FONT_SIZE_KEY, String(size));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<LogContext.Provider value={{ level, setLevel, activeLevels, fontSize, setFontSize }}>
|
|
||||||
{children}
|
|
||||||
</LogContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useLogContext = () => {
|
export const useLogContext = () => {
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
|||||||
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
|
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
|
||||||
import { createMetaTitle } from "~/metadata";
|
import { createMetaTitle } from "~/metadata";
|
||||||
import { ClientSideTerminalComponent } from "./client";
|
import { ClientSideTerminalComponent } from "./client";
|
||||||
import { LogFontSizeSlider } from "./font-size-slider";
|
|
||||||
import { LogLevelSelection } from "./level-selection";
|
import { LogLevelSelection } from "./level-selection";
|
||||||
import { LogContextProvider } from "./log-context";
|
import { LogContextProvider } from "./log-context";
|
||||||
|
|
||||||
@@ -39,10 +38,7 @@ export default async function LogsManagementPage() {
|
|||||||
<LogContextProvider defaultLevel={logsEnv.LEVEL}>
|
<LogContextProvider defaultLevel={logsEnv.LEVEL}>
|
||||||
<Group justify="space-between" align="center" wrap="nowrap">
|
<Group justify="space-between" align="center" wrap="nowrap">
|
||||||
<DynamicBreadcrumb />
|
<DynamicBreadcrumb />
|
||||||
<Group gap="md" wrap="nowrap">
|
<LogLevelSelection />
|
||||||
<LogFontSizeSlider />
|
|
||||||
<LogLevelSelection />
|
|
||||||
</Group>
|
|
||||||
</Group>
|
</Group>
|
||||||
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
|
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
|
||||||
<ClientSideTerminalComponent />
|
<ClientSideTerminalComponent />
|
||||||
|
|||||||
@@ -13,11 +13,9 @@ import classes from "./terminal.module.css";
|
|||||||
|
|
||||||
export const TerminalComponent = () => {
|
export const TerminalComponent = () => {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const { activeLevels, fontSize } = useLogContext();
|
const { activeLevels } = useLogContext();
|
||||||
|
|
||||||
const terminalRef = useRef<Terminal>(null);
|
const terminalRef = useRef<Terminal>(null);
|
||||||
const fitAddonRef = useRef<FitAddon>(null);
|
|
||||||
|
|
||||||
clientApi.log.subscribe.useSubscription(
|
clientApi.log.subscribe.useSubscription(
|
||||||
{
|
{
|
||||||
levels: activeLevels,
|
levels: activeLevels,
|
||||||
@@ -28,6 +26,7 @@ export const TerminalComponent = () => {
|
|||||||
terminalRef.current?.refresh(0, terminalRef.current.rows - 1);
|
terminalRef.current?.refresh(0, terminalRef.current.rows - 1);
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
|
// This makes sense as logging might cause an infinite loop
|
||||||
alert(err);
|
alert(err);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -44,30 +43,22 @@ export const TerminalComponent = () => {
|
|||||||
cursorBlink: false,
|
cursorBlink: false,
|
||||||
disableStdin: true,
|
disableStdin: true,
|
||||||
convertEol: true,
|
convertEol: true,
|
||||||
fontSize,
|
|
||||||
});
|
});
|
||||||
terminalRef.current.open(ref.current);
|
terminalRef.current.open(ref.current);
|
||||||
terminalRef.current.loadAddon(canvasAddon);
|
terminalRef.current.loadAddon(canvasAddon);
|
||||||
|
|
||||||
|
// This is a hack to make sure the terminal is rendered before we try to fit it
|
||||||
|
// You can blame @Meierschlumpf for this
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const fitAddon = new FitAddon();
|
const fitAddon = new FitAddon();
|
||||||
fitAddonRef.current = fitAddon;
|
|
||||||
terminalRef.current?.loadAddon(fitAddon);
|
terminalRef.current?.loadAddon(fitAddon);
|
||||||
fitAddon.fit();
|
fitAddon.fit();
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
canvasAddon.dispose();
|
|
||||||
terminalRef.current?.dispose();
|
terminalRef.current?.dispose();
|
||||||
terminalRef.current = null;
|
canvasAddon.dispose();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!terminalRef.current) return;
|
|
||||||
terminalRef.current.options.fontSize = fontSize;
|
|
||||||
fitAddonRef.current?.fit();
|
|
||||||
}, [fontSize]);
|
|
||||||
|
|
||||||
return <Box ref={ref} id="terminal" className={classes.outerTerminal} h="100%"></Box>;
|
return <Box ref={ref} id="terminal" className={classes.outerTerminal} h="100%"></Box>;
|
||||||
};
|
};
|
||||||
+85
@@ -0,0 +1,85 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button, Group, Stack } from "@mantine/core";
|
||||||
|
import type { z } from "zod/v4";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
|
import { useZodForm } from "@homarr/form";
|
||||||
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { userChangeHomeBoardsSchema } from "@homarr/validation/user";
|
||||||
|
|
||||||
|
import type { Board } from "~/app/[locale]/boards/_types";
|
||||||
|
import { BoardSelect } from "~/components/board/board-select";
|
||||||
|
|
||||||
|
interface ChangeHomeBoardFormProps {
|
||||||
|
user: RouterOutputs["user"]["getById"];
|
||||||
|
boardsData: Pick<Board, "id" | "name" | "logoImageUrl">[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormProps) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const { mutate, isPending } = clientApi.user.changeHomeBoards.useMutation({
|
||||||
|
async onSettled() {
|
||||||
|
await revalidatePathActionAsync(`/manage/users/${user.id}`);
|
||||||
|
},
|
||||||
|
onSuccess(_, variables) {
|
||||||
|
form.setInitialValues({
|
||||||
|
homeBoardId: variables.homeBoardId,
|
||||||
|
mobileHomeBoardId: variables.mobileHomeBoardId,
|
||||||
|
});
|
||||||
|
showSuccessNotification({
|
||||||
|
message: t("user.action.changeHomeBoard.notification.success.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
showErrorNotification({
|
||||||
|
message: t("user.action.changeHomeBoard.notification.error.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const form = useZodForm(userChangeHomeBoardsSchema, {
|
||||||
|
initialValues: {
|
||||||
|
homeBoardId: user.homeBoardId,
|
||||||
|
mobileHomeBoardId: user.mobileHomeBoardId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (values: FormType) => {
|
||||||
|
mutate({
|
||||||
|
userId: user.id,
|
||||||
|
...values,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
|
<Stack gap="md">
|
||||||
|
<BoardSelect
|
||||||
|
label={t("management.page.user.setting.general.item.board.type.general")}
|
||||||
|
clearable
|
||||||
|
boards={boardsData}
|
||||||
|
w="100%"
|
||||||
|
{...form.getInputProps("homeBoardId")}
|
||||||
|
/>
|
||||||
|
<BoardSelect
|
||||||
|
label={t("management.page.user.setting.general.item.board.type.mobile")}
|
||||||
|
clearable
|
||||||
|
boards={boardsData}
|
||||||
|
w="100%"
|
||||||
|
{...form.getInputProps("mobileHomeBoardId")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group justify="end">
|
||||||
|
<Button type="submit" loading={isPending}>
|
||||||
|
{t("common.action.save")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormType = z.infer<typeof userChangeHomeBoardsSchema>;
|
||||||
+84
@@ -0,0 +1,84 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button, Group, Select, Stack, Switch } from "@mantine/core";
|
||||||
|
import type { z } from "zod/v4";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
|
import { useZodForm } from "@homarr/form";
|
||||||
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { userChangeSearchPreferencesSchema } from "@homarr/validation/user";
|
||||||
|
|
||||||
|
interface ChangeSearchPreferencesFormProps {
|
||||||
|
user: RouterOutputs["user"]["getById"];
|
||||||
|
searchEnginesData: { value: string; label: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChangeSearchPreferencesForm = ({ user, searchEnginesData }: ChangeSearchPreferencesFormProps) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const { mutate, isPending } = clientApi.user.changeSearchPreferences.useMutation({
|
||||||
|
async onSettled() {
|
||||||
|
await revalidatePathActionAsync(`/manage/users/${user.id}`);
|
||||||
|
},
|
||||||
|
onSuccess(_, variables) {
|
||||||
|
form.setInitialValues({
|
||||||
|
defaultSearchEngineId: variables.defaultSearchEngineId,
|
||||||
|
openInNewTab: variables.openInNewTab,
|
||||||
|
ddgBangsEnabled: variables.ddgBangsEnabled,
|
||||||
|
});
|
||||||
|
showSuccessNotification({
|
||||||
|
message: t("user.action.changeSearchPreferences.notification.success.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
showErrorNotification({
|
||||||
|
message: t("user.action.changeSearchPreferences.notification.error.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const form = useZodForm(userChangeSearchPreferencesSchema, {
|
||||||
|
initialValues: {
|
||||||
|
defaultSearchEngineId: user.defaultSearchEngineId,
|
||||||
|
openInNewTab: user.openSearchInNewTab,
|
||||||
|
ddgBangsEnabled: user.ddgBangs,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (values: FormType) => {
|
||||||
|
mutate({
|
||||||
|
userId: user.id,
|
||||||
|
...values,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Select
|
||||||
|
label={t("user.field.defaultSearchEngine.label")}
|
||||||
|
w="100%"
|
||||||
|
data={searchEnginesData}
|
||||||
|
{...form.getInputProps("defaultSearchEngineId")}
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
label={t("user.field.openSearchInNewTab.label")}
|
||||||
|
{...form.getInputProps("openInNewTab", { type: "checkbox" })}
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
label={t("user.field.ddgBangs.label")}
|
||||||
|
{...form.getInputProps("ddgBangsEnabled", { type: "checkbox" })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group justify="end">
|
||||||
|
<Button type="submit" loading={isPending}>
|
||||||
|
{t("common.action.save")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormType = z.infer<typeof userChangeSearchPreferencesSchema>;
|
||||||
+83
@@ -0,0 +1,83 @@
|
|||||||
|
"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";
|
||||||
|
import type { z } from "zod/v4";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
|
import { useZodForm } from "@homarr/form";
|
||||||
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { userFirstDayOfWeekSchema } from "@homarr/validation/user";
|
||||||
|
|
||||||
|
dayjs.extend(localeData);
|
||||||
|
|
||||||
|
interface FirstDayOfWeekProps {
|
||||||
|
user: RouterOutputs["user"]["getById"];
|
||||||
|
}
|
||||||
|
|
||||||
|
const weekDays = dayjs.weekdays(false);
|
||||||
|
|
||||||
|
export const FirstDayOfWeek = ({ user }: FirstDayOfWeekProps) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const { mutate, isPending } = clientApi.user.changeFirstDayOfWeek.useMutation({
|
||||||
|
async onSettled() {
|
||||||
|
await revalidatePathActionAsync(`/manage/users/${user.id}`);
|
||||||
|
},
|
||||||
|
onSuccess(_, variables) {
|
||||||
|
form.setInitialValues({
|
||||||
|
firstDayOfWeek: variables.firstDayOfWeek as DayOfWeek,
|
||||||
|
});
|
||||||
|
showSuccessNotification({
|
||||||
|
message: t("user.action.changeFirstDayOfWeek.notification.success.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
showErrorNotification({
|
||||||
|
message: t("user.action.changeFirstDayOfWeek.notification.error.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const form = useZodForm(userFirstDayOfWeekSchema, {
|
||||||
|
initialValues: {
|
||||||
|
firstDayOfWeek: user.firstDayOfWeek as DayOfWeek,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (values: FormType) => {
|
||||||
|
mutate({
|
||||||
|
id: user.id,
|
||||||
|
...values,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputProps = form.getInputProps("firstDayOfWeek");
|
||||||
|
const onChange = inputProps.onChange as (value: number) => void;
|
||||||
|
const value = (inputProps.value as number).toString();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Radio.Group {...inputProps} value={value} onChange={(value) => onChange(parseInt(value))}>
|
||||||
|
<Group mt="xs">
|
||||||
|
<Radio value="1" label={weekDays[1]} />
|
||||||
|
<Radio value="6" label={weekDays[6]} />
|
||||||
|
<Radio value="0" label={weekDays[0]} />
|
||||||
|
</Group>
|
||||||
|
</Radio.Group>
|
||||||
|
|
||||||
|
<Group justify="end">
|
||||||
|
<Button type="submit" loading={isPending}>
|
||||||
|
{t("common.action.save")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormType = z.infer<typeof userFirstDayOfWeekSchema>;
|
||||||
-332
@@ -1,332 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef } from "react";
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Divider,
|
|
||||||
Group,
|
|
||||||
Radio,
|
|
||||||
Select,
|
|
||||||
SimpleGrid,
|
|
||||||
Stack,
|
|
||||||
Switch,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
Title,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import type { DayOfWeek } from "@mantine/dates";
|
|
||||||
import dayjs from "dayjs";
|
|
||||||
import localeData from "dayjs/plugin/localeData";
|
|
||||||
import { z } from "zod/v4";
|
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
|
||||||
import { env } from "@homarr/common/env";
|
|
||||||
import { useZodForm } from "@homarr/form";
|
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
|
||||||
import {
|
|
||||||
userChangeHomeBoardsSchema,
|
|
||||||
userChangeSearchPreferencesSchema,
|
|
||||||
userEditProfileSchema,
|
|
||||||
userFirstDayOfWeekSchema,
|
|
||||||
userPingIconsEnabledSchema,
|
|
||||||
} from "@homarr/validation/user";
|
|
||||||
|
|
||||||
import type { Board } from "~/app/[locale]/boards/_types";
|
|
||||||
import { BoardSelect } from "~/components/board/board-select";
|
|
||||||
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
|
|
||||||
|
|
||||||
dayjs.extend(localeData);
|
|
||||||
|
|
||||||
const userGeneralSettingsSchema = z.object({
|
|
||||||
name: userEditProfileSchema.shape.name,
|
|
||||||
email: userEditProfileSchema.shape.email,
|
|
||||||
homeBoardId: userChangeHomeBoardsSchema.shape.homeBoardId,
|
|
||||||
mobileHomeBoardId: userChangeHomeBoardsSchema.shape.mobileHomeBoardId,
|
|
||||||
defaultSearchEngineId: userChangeSearchPreferencesSchema.shape.defaultSearchEngineId,
|
|
||||||
openInNewTab: userChangeSearchPreferencesSchema.shape.openInNewTab,
|
|
||||||
ddgBangsEnabled: userChangeSearchPreferencesSchema.shape.ddgBangsEnabled,
|
|
||||||
firstDayOfWeek: userFirstDayOfWeekSchema.shape.firstDayOfWeek,
|
|
||||||
pingIconsEnabled: userPingIconsEnabledSchema.shape.pingIconsEnabled,
|
|
||||||
});
|
|
||||||
|
|
||||||
type FormValues = z.infer<typeof userGeneralSettingsSchema>;
|
|
||||||
|
|
||||||
const FIRST_DAY_OPTIONS: { value: DayOfWeek; labelKey: number }[] = [
|
|
||||||
{ value: 1, labelKey: 1 },
|
|
||||||
{ value: 6, labelKey: 6 },
|
|
||||||
{ value: 0, labelKey: 0 },
|
|
||||||
];
|
|
||||||
|
|
||||||
interface UserGeneralSettingsFormProps {
|
|
||||||
user: RouterOutputs["user"]["getById"];
|
|
||||||
boardsData: Pick<Board, "id" | "name" | "logoImageUrl">[];
|
|
||||||
searchEnginesData: { value: string; label: string }[];
|
|
||||||
showLanguageSelector?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildInitialValues = (user: RouterOutputs["user"]["getById"]): FormValues => ({
|
|
||||||
name: user.name ?? "",
|
|
||||||
email: user.email ?? "",
|
|
||||||
homeBoardId: user.homeBoardId,
|
|
||||||
mobileHomeBoardId: user.mobileHomeBoardId,
|
|
||||||
defaultSearchEngineId: user.defaultSearchEngineId,
|
|
||||||
openInNewTab: user.openSearchInNewTab,
|
|
||||||
ddgBangsEnabled: user.ddgBangs,
|
|
||||||
firstDayOfWeek: user.firstDayOfWeek as DayOfWeek,
|
|
||||||
pingIconsEnabled: user.pingIconsEnabled,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const UserGeneralSettingsForm = ({
|
|
||||||
user,
|
|
||||||
boardsData,
|
|
||||||
searchEnginesData,
|
|
||||||
showLanguageSelector = false,
|
|
||||||
}: UserGeneralSettingsFormProps) => {
|
|
||||||
const t = useI18n();
|
|
||||||
const tGeneral = useScopedI18n("management.page.user.setting.general");
|
|
||||||
const isCredentialsUser = user.provider === "credentials";
|
|
||||||
|
|
||||||
const editProfileMutation = clientApi.user.editProfile.useMutation();
|
|
||||||
const changeHomeBoardsMutation = clientApi.user.changeHomeBoards.useMutation();
|
|
||||||
const changeSearchPreferencesMutation = clientApi.user.changeSearchPreferences.useMutation();
|
|
||||||
const changeFirstDayOfWeekMutation = clientApi.user.changeFirstDayOfWeek.useMutation();
|
|
||||||
const changePingIconsEnabledMutation = clientApi.user.changePingIconsEnabled.useMutation();
|
|
||||||
|
|
||||||
const initialValues = buildInitialValues(user);
|
|
||||||
const initialValuesRef = useRef(initialValues);
|
|
||||||
|
|
||||||
const form = useZodForm(userGeneralSettingsSchema, {
|
|
||||||
initialValues,
|
|
||||||
});
|
|
||||||
|
|
||||||
const isDirtyRef = useRef(false);
|
|
||||||
isDirtyRef.current = form.isDirty();
|
|
||||||
|
|
||||||
const weekDays = useMemo(() => dayjs.weekdays(false), []);
|
|
||||||
|
|
||||||
const mutations = [
|
|
||||||
editProfileMutation,
|
|
||||||
changeHomeBoardsMutation,
|
|
||||||
changeSearchPreferencesMutation,
|
|
||||||
changeFirstDayOfWeekMutation,
|
|
||||||
changePingIconsEnabledMutation,
|
|
||||||
];
|
|
||||||
const isPending = mutations.some((m) => m.isPending);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = (e: BeforeUnloadEvent) => {
|
|
||||||
if (env.NODE_ENV === "development") return;
|
|
||||||
if (isDirtyRef.current) e.preventDefault();
|
|
||||||
};
|
|
||||||
window.addEventListener("beforeunload", handler);
|
|
||||||
return () => window.removeEventListener("beforeunload", handler);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSubmitAsync = async (values: FormValues) => {
|
|
||||||
const parsed = userGeneralSettingsSchema.safeParse(values);
|
|
||||||
if (!parsed.success) return;
|
|
||||||
|
|
||||||
const defaults = initialValuesRef.current;
|
|
||||||
const changed = <K extends keyof FormValues>(...fields: K[]) =>
|
|
||||||
fields.some((f) => (f === "email" ? (values[f] || null) !== (defaults[f] || null) : values[f] !== defaults[f]));
|
|
||||||
|
|
||||||
const saveActions: { when: boolean; action: () => Promise<unknown> }[] = [
|
|
||||||
{
|
|
||||||
when: isCredentialsUser && changed("name", "email"),
|
|
||||||
action: () =>
|
|
||||||
editProfileMutation.mutateAsync({ id: user.id, name: parsed.data.name, email: parsed.data.email ?? "" }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
when: changed("homeBoardId", "mobileHomeBoardId"),
|
|
||||||
action: () =>
|
|
||||||
changeHomeBoardsMutation.mutateAsync({
|
|
||||||
userId: user.id,
|
|
||||||
homeBoardId: parsed.data.homeBoardId,
|
|
||||||
mobileHomeBoardId: parsed.data.mobileHomeBoardId,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
when: changed("defaultSearchEngineId", "openInNewTab", "ddgBangsEnabled"),
|
|
||||||
action: () =>
|
|
||||||
changeSearchPreferencesMutation.mutateAsync({
|
|
||||||
userId: user.id,
|
|
||||||
defaultSearchEngineId: parsed.data.defaultSearchEngineId,
|
|
||||||
openInNewTab: parsed.data.openInNewTab,
|
|
||||||
ddgBangsEnabled: parsed.data.ddgBangsEnabled,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
when: changed("firstDayOfWeek"),
|
|
||||||
action: () =>
|
|
||||||
changeFirstDayOfWeekMutation.mutateAsync({ id: user.id, firstDayOfWeek: parsed.data.firstDayOfWeek }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
when: changed("pingIconsEnabled"),
|
|
||||||
action: () =>
|
|
||||||
changePingIconsEnabledMutation.mutateAsync({ id: user.id, pingIconsEnabled: parsed.data.pingIconsEnabled }),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const promises = saveActions.filter((s) => s.when).map((s) => s.action());
|
|
||||||
if (promises.length === 0) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await Promise.all(promises);
|
|
||||||
const newValues = { ...parsed.data, email: parsed.data.email ?? "" };
|
|
||||||
initialValuesRef.current = newValues;
|
|
||||||
form.setInitialValues(newValues);
|
|
||||||
form.resetDirty();
|
|
||||||
await revalidatePathActionAsync(`/manage/users/${user.id}`);
|
|
||||||
showSuccessNotification({
|
|
||||||
title: t("common.notification.update.success"),
|
|
||||||
message: t("common.notification.update.success"),
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
showErrorNotification({
|
|
||||||
title: t("common.notification.update.error"),
|
|
||||||
message: t("common.notification.update.error"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDiscard = () => {
|
|
||||||
form.setValues(initialValuesRef.current);
|
|
||||||
form.resetDirty();
|
|
||||||
};
|
|
||||||
|
|
||||||
const firstDayOfWeekInputProps = form.getInputProps("firstDayOfWeek");
|
|
||||||
const firstDayOfWeekOnChange = firstDayOfWeekInputProps.onChange as (value: number) => void;
|
|
||||||
const firstDayOfWeekValue = (firstDayOfWeekInputProps.value as number).toString();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
|
|
||||||
<Stack gap="lg">
|
|
||||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg" verticalSpacing="lg">
|
|
||||||
<Card withBorder bg="transparent">
|
|
||||||
<Stack gap="md">
|
|
||||||
<Stack gap={2}>
|
|
||||||
<Title order={3}>{t("user.name")}</Title>
|
|
||||||
{!isCredentialsUser && (
|
|
||||||
<Text c="dimmed" size="sm">
|
|
||||||
{t("management.page.user.fieldsDisabledExternalProvider")}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
<Divider />
|
|
||||||
<TextInput
|
|
||||||
disabled={!isCredentialsUser}
|
|
||||||
label={t("user.field.username.label")}
|
|
||||||
withAsterisk
|
|
||||||
{...form.getInputProps("name")}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
disabled={!isCredentialsUser}
|
|
||||||
label={t("user.field.email.label")}
|
|
||||||
{...form.getInputProps("email")}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card withBorder bg="transparent">
|
|
||||||
<Stack gap="md">
|
|
||||||
<Stack gap={2}>
|
|
||||||
<Title order={3}>{tGeneral("item.board.title")}</Title>
|
|
||||||
</Stack>
|
|
||||||
<Divider />
|
|
||||||
<BoardSelect
|
|
||||||
label={tGeneral("item.board.type.general")}
|
|
||||||
clearable
|
|
||||||
boards={boardsData}
|
|
||||||
w="100%"
|
|
||||||
withinPortal
|
|
||||||
{...form.getInputProps("homeBoardId")}
|
|
||||||
/>
|
|
||||||
<BoardSelect
|
|
||||||
label={tGeneral("item.board.type.mobile")}
|
|
||||||
clearable
|
|
||||||
boards={boardsData}
|
|
||||||
w="100%"
|
|
||||||
withinPortal
|
|
||||||
{...form.getInputProps("mobileHomeBoardId")}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card withBorder bg="transparent">
|
|
||||||
<Stack gap="md">
|
|
||||||
<Stack gap={2}>
|
|
||||||
<Title order={3}>{tGeneral("item.search")}</Title>
|
|
||||||
</Stack>
|
|
||||||
<Divider />
|
|
||||||
<Select
|
|
||||||
label={t("user.field.defaultSearchEngine.label")}
|
|
||||||
w="100%"
|
|
||||||
data={searchEnginesData}
|
|
||||||
comboboxProps={{ withinPortal: true }}
|
|
||||||
{...form.getInputProps("defaultSearchEngineId")}
|
|
||||||
/>
|
|
||||||
<Switch
|
|
||||||
label={t("user.field.openSearchInNewTab.label")}
|
|
||||||
{...form.getInputProps("openInNewTab", { type: "checkbox" })}
|
|
||||||
/>
|
|
||||||
<Switch
|
|
||||||
label={t("user.field.ddgBangs.label")}
|
|
||||||
{...form.getInputProps("ddgBangsEnabled", { type: "checkbox" })}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card withBorder bg="transparent">
|
|
||||||
<Stack gap="md">
|
|
||||||
<Stack gap={2}>
|
|
||||||
<Title order={3}>{tGeneral("item.language")}</Title>
|
|
||||||
</Stack>
|
|
||||||
<Divider />
|
|
||||||
{showLanguageSelector && <CurrentLanguageCombobox withinPortal />}
|
|
||||||
<Title order={4}>{tGeneral("item.firstDayOfWeek")}</Title>
|
|
||||||
<Radio.Group
|
|
||||||
{...firstDayOfWeekInputProps}
|
|
||||||
value={firstDayOfWeekValue}
|
|
||||||
onChange={(nextValue: string) => firstDayOfWeekOnChange(parseInt(nextValue, 10))}
|
|
||||||
>
|
|
||||||
<Group mt="xs" wrap="wrap">
|
|
||||||
{FIRST_DAY_OPTIONS.map(({ value: dayValue, labelKey }) => (
|
|
||||||
<Radio key={dayValue} value={dayValue.toString()} label={weekDays[labelKey]} />
|
|
||||||
))}
|
|
||||||
</Group>
|
|
||||||
</Radio.Group>
|
|
||||||
<Divider my="xs" />
|
|
||||||
<Title order={4}>{tGeneral("item.accessibility")}</Title>
|
|
||||||
<Switch
|
|
||||||
label={t("user.field.pingIconsEnabled.label")}
|
|
||||||
{...form.getInputProps("pingIconsEnabled", { type: "checkbox" })}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
</SimpleGrid>
|
|
||||||
|
|
||||||
<div style={{ position: "sticky", bottom: 20 }}>
|
|
||||||
{form.isDirty() && (
|
|
||||||
<Card withBorder>
|
|
||||||
<Group justify="space-between" wrap="wrap">
|
|
||||||
<Text fw={500}>{t("common.unsavedChanges")}</Text>
|
|
||||||
<Group>
|
|
||||||
<Button disabled={isPending} variant="default" onClick={handleDiscard}>
|
|
||||||
{t("common.action.discard")}
|
|
||||||
</Button>
|
|
||||||
<Button loading={isPending} type="submit" disabled={!form.isValid()}>
|
|
||||||
{t("common.action.saveChanges")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button, Group, Stack, Switch } from "@mantine/core";
|
||||||
|
import type { z } from "zod/v4";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
|
import { useZodForm } from "@homarr/form";
|
||||||
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { userPingIconsEnabledSchema } from "@homarr/validation/user";
|
||||||
|
|
||||||
|
interface PingIconsEnabledProps {
|
||||||
|
user: RouterOutputs["user"]["getById"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PingIconsEnabled = ({ user }: PingIconsEnabledProps) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const { mutate, isPending } = clientApi.user.changePingIconsEnabled.useMutation({
|
||||||
|
async onSettled() {
|
||||||
|
await revalidatePathActionAsync(`/manage/users/${user.id}`);
|
||||||
|
},
|
||||||
|
onSuccess(_, variables) {
|
||||||
|
form.setInitialValues({
|
||||||
|
pingIconsEnabled: variables.pingIconsEnabled,
|
||||||
|
});
|
||||||
|
showSuccessNotification({
|
||||||
|
message: t("user.action.changePingIconsEnabled.notification.success.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
showErrorNotification({
|
||||||
|
message: t("user.action.changePingIconsEnabled.notification.error.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const form = useZodForm(userPingIconsEnabledSchema, {
|
||||||
|
initialValues: {
|
||||||
|
pingIconsEnabled: user.pingIconsEnabled,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (values: FormType) => {
|
||||||
|
mutate({
|
||||||
|
id: user.id,
|
||||||
|
...values,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Switch
|
||||||
|
{...form.getInputProps("pingIconsEnabled", { type: "checkbox" })}
|
||||||
|
label={t("user.field.pingIconsEnabled.label")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group justify="end">
|
||||||
|
<Button type="submit" loading={isPending}>
|
||||||
|
{t("common.action.save")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormType = z.infer<typeof userPingIconsEnabledSchema>;
|
||||||
+4
-5
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { Box, Button, FileButton, Menu, UnstyledButton } from "@mantine/core";
|
import { Box, Button, FileButton, Menu, UnstyledButton } from "@mantine/core";
|
||||||
import { useDisclosure, useMediaQuery } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { IconPencil, IconPhotoEdit, IconPhotoX } from "@tabler/icons-react";
|
import { IconPencil, IconPhotoEdit, IconPhotoX } from "@tabler/icons-react";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
@@ -25,7 +25,6 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const [opened, { toggle }] = useDisclosure(false);
|
const [opened, { toggle }] = useDisclosure(false);
|
||||||
const isMobile = useMediaQuery("(max-width: 48em)");
|
|
||||||
const { openConfirmModal } = useConfirmModal();
|
const { openConfirmModal } = useConfirmModal();
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const tManageAvatar = useScopedI18n("user.action.manageAvatar");
|
const tManageAvatar = useScopedI18n("user.action.manageAvatar");
|
||||||
@@ -95,11 +94,11 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
|
|||||||
}, [mutate, user.id, openConfirmModal, tManageAvatar]);
|
}, [mutate, user.id, openConfirmModal, tManageAvatar]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box pos="relative" display="flex" style={{ justifyContent: isMobile ? "center" : undefined }}>
|
<Box pos="relative">
|
||||||
<Menu opened={opened} keepMounted onChange={toggle} position="bottom-start" withArrow withinPortal>
|
<Menu opened={opened} keepMounted onChange={toggle} position="bottom-start" withArrow>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<UnstyledButton onClick={toggle}>
|
<UnstyledButton onClick={toggle}>
|
||||||
<UserAvatar user={user} size={isMobile ? 120 : 200} />
|
<UserAvatar user={user} size={200} />
|
||||||
<Button
|
<Button
|
||||||
component="div"
|
component="div"
|
||||||
pos="absolute"
|
pos="absolute"
|
||||||
|
|||||||
+94
@@ -0,0 +1,94 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { Button, Group, Stack, TextInput } from "@mantine/core";
|
||||||
|
|
||||||
|
import type { RouterInputs, RouterOutputs } from "@homarr/api";
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
|
import { useZodForm } from "@homarr/form";
|
||||||
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { userEditProfileSchema } from "@homarr/validation/user";
|
||||||
|
|
||||||
|
interface UserProfileFormProps {
|
||||||
|
user: RouterOutputs["user"]["getById"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UserProfileForm = ({ user }: UserProfileFormProps) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const { mutate, isPending } = clientApi.user.editProfile.useMutation({
|
||||||
|
async onSettled() {
|
||||||
|
await revalidatePathActionAsync("/manage/users");
|
||||||
|
},
|
||||||
|
onSuccess(_, variables) {
|
||||||
|
// Reset form initial values to reset dirty state
|
||||||
|
form.setInitialValues({
|
||||||
|
name: variables.name,
|
||||||
|
email: variables.email ?? "",
|
||||||
|
});
|
||||||
|
showSuccessNotification({
|
||||||
|
title: t("common.notification.update.success"),
|
||||||
|
message: t("user.action.editProfile.notification.success.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
const message =
|
||||||
|
error.data?.code === "CONFLICT"
|
||||||
|
? t("user.error.usernameTaken")
|
||||||
|
: t("user.action.editProfile.notification.error.message");
|
||||||
|
showErrorNotification({
|
||||||
|
title: t("common.notification.update.error"),
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const form = useZodForm(userEditProfileSchema.omit({ id: true }), {
|
||||||
|
initialValues: {
|
||||||
|
name: user.name ?? "",
|
||||||
|
email: user.email ?? "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only credentials users can edit their profile
|
||||||
|
const isProviderCredentials = user.provider === "credentials";
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
(values: FormType) => {
|
||||||
|
if (!isProviderCredentials) return;
|
||||||
|
mutate({
|
||||||
|
...values,
|
||||||
|
id: user.id,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[isProviderCredentials, mutate, user.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
|
<Stack>
|
||||||
|
<TextInput
|
||||||
|
disabled={!isProviderCredentials}
|
||||||
|
label={t("user.field.username.label")}
|
||||||
|
withAsterisk
|
||||||
|
{...form.getInputProps("name")}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
disabled={!isProviderCredentials}
|
||||||
|
label={t("user.field.email.label")}
|
||||||
|
{...form.getInputProps("email")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isProviderCredentials && (
|
||||||
|
<Group justify="end">
|
||||||
|
<Button type="submit" disabled={!form.isDirty()} loading={isPending}>
|
||||||
|
{t("common.action.saveChanges")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormType = Omit<RouterInputs["user"]["editProfile"], "id">;
|
||||||
-53
@@ -1,53 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Badge, Button, Group, Stack, Text } from "@mantine/core";
|
|
||||||
import { IconRefresh } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
import { showSuccessNotification } from "@homarr/notifications";
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
export const ResetTours = () => {
|
|
||||||
const t = useScopedI18n("management.page.user.setting.general.item.onboardingTours");
|
|
||||||
const utils = clientApi.useUtils();
|
|
||||||
const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
|
|
||||||
const { mutate: resetTours, isPending } = clientApi.user.resetTours.useMutation({
|
|
||||||
onSuccess() {
|
|
||||||
showSuccessNotification({
|
|
||||||
title: t("resetSuccess.title"),
|
|
||||||
message: t("resetSuccess.message"),
|
|
||||||
});
|
|
||||||
void utils.user.getTourStatus.invalidate();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const tours = [
|
|
||||||
{ key: "management" as const, completed: tourStatus?.completedManageTour ?? false },
|
|
||||||
{ key: "dashboard" as const, completed: tourStatus?.completedBoardTour ?? false },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Group gap="xs">
|
|
||||||
{tours.map(({ key, completed }) => (
|
|
||||||
<Badge key={key} color={completed ? "green" : "gray"} variant="light">
|
|
||||||
{t(`name.${key}`)}: {completed ? t("status.completed") : t("status.notStarted")}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</Group>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t("description")}
|
|
||||||
</Text>
|
|
||||||
<Button
|
|
||||||
leftSection={<IconRefresh size={16} />}
|
|
||||||
variant="light"
|
|
||||||
size="sm"
|
|
||||||
w="fit-content"
|
|
||||||
onClick={() => resetTours()}
|
|
||||||
loading={isPending}
|
|
||||||
>
|
|
||||||
{t("reset")}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -6,14 +6,18 @@ import { api } from "@homarr/api/server";
|
|||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
|
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
|
||||||
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
|
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
|
||||||
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
||||||
import { createMetaTitle } from "~/metadata";
|
import { createMetaTitle } from "~/metadata";
|
||||||
import { canAccessUserEditPage } from "../access";
|
import { canAccessUserEditPage } from "../access";
|
||||||
|
import { ChangeHomeBoardForm } from "./_components/_change-home-board";
|
||||||
|
import { ChangeSearchPreferencesForm } from "./_components/_change-search-preferences";
|
||||||
import { DeleteUserButton } from "./_components/_delete-user-button";
|
import { DeleteUserButton } from "./_components/_delete-user-button";
|
||||||
import { UserGeneralSettingsForm } from "./_components/_general-settings-form";
|
import { FirstDayOfWeek } from "./_components/_first-day-of-week";
|
||||||
|
import { PingIconsEnabled } from "./_components/_ping-icons-enabled";
|
||||||
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
|
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
|
||||||
import { ResetTours } from "./_components/_reset-tours";
|
import { UserProfileForm } from "./_components/_profile-form";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@@ -59,7 +63,7 @@ export default async function EditUserPage(props: Props) {
|
|||||||
|
|
||||||
const boards = await api.board.getAllBoards();
|
const boards = await api.board.getAllBoards();
|
||||||
const searchEngines = await api.searchEngine.getSelectable();
|
const searchEngines = await api.searchEngine.getSelectable();
|
||||||
const isSelf = session?.user.id === user.id;
|
|
||||||
const isCredentialsUser = user.provider === "credentials";
|
const isCredentialsUser = user.provider === "credentials";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -69,32 +73,51 @@ export default async function EditUserPage(props: Props) {
|
|||||||
{t("management.page.user.fieldsDisabledExternalProvider")}
|
{t("management.page.user.fieldsDisabledExternalProvider")}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Title>{tGeneral("title")}</Title>
|
<Title>{tGeneral("title")}</Title>
|
||||||
<Group gap="xl" align="flex-start" wrap="wrap">
|
<Group gap="xl">
|
||||||
<Box flex={1} miw={{ base: "100%", md: 540 }}>
|
<Box flex={1}>
|
||||||
<UserGeneralSettingsForm
|
<UserProfileForm user={user} />
|
||||||
user={user}
|
|
||||||
boardsData={boards.map((board) => ({
|
|
||||||
id: board.id,
|
|
||||||
name: board.name,
|
|
||||||
logoImageUrl: board.logoImageUrl,
|
|
||||||
}))}
|
|
||||||
searchEnginesData={searchEngines}
|
|
||||||
showLanguageSelector={isSelf}
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
<Box w={{ base: "100%", lg: 260 }}>
|
<Box w={{ base: "100%", lg: 200 }}>
|
||||||
<UserProfileAvatarForm user={user} />
|
<UserProfileAvatarForm user={user} />
|
||||||
</Box>
|
</Box>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{session?.user.id === user.id && (
|
{session?.user.id === user.id && (
|
||||||
<Stack mb="lg">
|
<Stack mb="lg">
|
||||||
<Title order={2}>{tGeneral("item.onboardingTours.title")}</Title>
|
<Title order={2}>{tGeneral("item.language")}</Title>
|
||||||
<ResetTours />
|
<CurrentLanguageCombobox />
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Stack mb="lg">
|
||||||
|
<Title order={2}>{tGeneral("item.board.title")}</Title>
|
||||||
|
<ChangeHomeBoardForm
|
||||||
|
user={user}
|
||||||
|
boardsData={boards.map((board) => ({
|
||||||
|
id: board.id,
|
||||||
|
name: board.name,
|
||||||
|
logoImageUrl: board.logoImageUrl,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack mb="lg">
|
||||||
|
<Title order={2}>{tGeneral("item.search")}</Title>
|
||||||
|
<ChangeSearchPreferencesForm user={user} searchEnginesData={searchEngines} />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack mb="lg">
|
||||||
|
<Title order={2}>{tGeneral("item.firstDayOfWeek")}</Title>
|
||||||
|
<FirstDayOfWeek user={user} />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack mb="lg">
|
||||||
|
<Title order={2}>{tGeneral("item.accessibility")}</Title>
|
||||||
|
<PingIconsEnabled user={user} />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
<DangerZoneRoot>
|
<DangerZoneRoot>
|
||||||
<DangerZoneItem
|
<DangerZoneItem
|
||||||
label={t("user.action.delete.label")}
|
label={t("user.action.delete.label")}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { UserAvatar } from "@homarr/ui";
|
|||||||
|
|
||||||
import { ManageContainer } from "~/components/manage/manage-container";
|
import { ManageContainer } from "~/components/manage/manage-container";
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { catchTrpcNotFound, catchTrpcUnauthorized } from "~/errors/trpc-catch-error";
|
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
||||||
import { NavigationLink } from "../groups/[id]/_navigation";
|
import { NavigationLink } from "../groups/[id]/_navigation";
|
||||||
import { canAccessUserEditPage } from "./access";
|
import { canAccessUserEditPage } from "./access";
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ export default async function Layout(props: PropsWithChildren<LayoutProps>) {
|
|||||||
const session = await auth();
|
const session = await auth();
|
||||||
const t = await getI18n();
|
const t = await getI18n();
|
||||||
const tUser = await getScopedI18n("management.page.user");
|
const tUser = await getScopedI18n("management.page.user");
|
||||||
const user = await api.user.getById({ userId: params.userId }).catch(catchTrpcNotFound).catch(catchTrpcUnauthorized);
|
const user = await api.user.getById({ userId: params.userId }).catch(catchTrpcNotFound);
|
||||||
|
|
||||||
if (!canAccessUserEditPage(session, user.id)) {
|
if (!canAccessUserEditPage(session, user.id)) {
|
||||||
notFound();
|
notFound();
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { api } from "@homarr/api/server";
|
|||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { catchTrpcNotFound, catchTrpcUnauthorized } from "~/errors/trpc-catch-error";
|
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
||||||
import { canAccessUserEditPage } from "../access";
|
import { canAccessUserEditPage } from "../access";
|
||||||
import { ChangePasswordForm } from "./_components/_change-password-form";
|
import { ChangePasswordForm } from "./_components/_change-password-form";
|
||||||
|
|
||||||
@@ -23,8 +23,7 @@ export default async function UserSecurityPage(props: Props) {
|
|||||||
.getById({
|
.getById({
|
||||||
userId: params.userId,
|
userId: params.userId,
|
||||||
})
|
})
|
||||||
.catch(catchTrpcNotFound)
|
.catch(catchTrpcNotFound);
|
||||||
.catch(catchTrpcUnauthorized);
|
|
||||||
|
|
||||||
if (!canAccessUserEditPage(session, user.id)) {
|
if (!canAccessUserEditPage(session, user.id)) {
|
||||||
notFound();
|
notFound();
|
||||||
|
|||||||
@@ -1,22 +1,24 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Anchor, Group, Text, Tooltip } from "@mantine/core";
|
import { Anchor, Button, Group, Text, Title, Tooltip } from "@mantine/core";
|
||||||
import { IconCheck } from "@tabler/icons-react";
|
import { IconCheck } from "@tabler/icons-react";
|
||||||
import type { MRT_ColumnDef } from "mantine-react-table";
|
import type { MRT_ColumnDef } from "mantine-react-table";
|
||||||
import { MantineReactTable } from "mantine-react-table";
|
import { MantineReactTable } from "mantine-react-table";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
import { Link, UserAvatar } from "@homarr/ui";
|
import { Link, UserAvatar } from "@homarr/ui";
|
||||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||||
|
|
||||||
interface UserListComponentProps {
|
interface UserListComponentProps {
|
||||||
initialUserList: RouterOutputs["user"]["getAll"];
|
initialUserList: RouterOutputs["user"]["getAll"];
|
||||||
|
credentialsProviderEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UserListComponent = ({ initialUserList }: UserListComponentProps) => {
|
export const UserListComponent = ({ initialUserList, credentialsProviderEnabled }: UserListComponentProps) => {
|
||||||
|
const tUserList = useScopedI18n("management.page.user.list");
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { data, isLoading } = clientApi.user.getAll.useQuery(undefined, {
|
const { data, isLoading } = clientApi.user.getAll.useQuery(undefined, {
|
||||||
initialData: initialUserList,
|
initialData: initialUserList,
|
||||||
@@ -67,10 +69,21 @@ export const UserListComponent = ({ initialUserList }: UserListComponentProps) =
|
|||||||
enableFullScreenToggle: false,
|
enableFullScreenToggle: false,
|
||||||
layoutMode: "grid-no-grow",
|
layoutMode: "grid-no-grow",
|
||||||
getRowId: (row) => row.id,
|
getRowId: (row) => row.id,
|
||||||
|
renderTopToolbarCustomActions: () =>
|
||||||
|
credentialsProviderEnabled ? (
|
||||||
|
<Button component={Link} href="/manage/users/create">
|
||||||
|
{t("management.page.user.create.title")}
|
||||||
|
</Button>
|
||||||
|
) : null,
|
||||||
state: {
|
state: {
|
||||||
isLoading,
|
isLoading,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return <MantineReactTable table={table} />;
|
return (
|
||||||
|
<>
|
||||||
|
<Title mb="md">{tUserList("title")}</Title>
|
||||||
|
<MantineReactTable table={table} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
+32
-12
@@ -1,7 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { startTransition, useCallback, useState } from "react";
|
import { startTransition, useCallback, useState } from "react";
|
||||||
import { Badge, Button, Card, Group, Stack, Stepper, Table, Text, TextInput, Title, Tooltip } from "@mantine/core";
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
PasswordInput,
|
||||||
|
Stack,
|
||||||
|
Stepper,
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
import { useListState } from "@mantine/hooks";
|
import { useListState } from "@mantine/hooks";
|
||||||
import { IconPlus, IconUserCheck } from "@tabler/icons-react";
|
import { IconPlus, IconUserCheck } from "@tabler/icons-react";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
@@ -11,11 +24,10 @@ import type { GroupPermissionKey } from "@homarr/definitions";
|
|||||||
import { everyoneGroup, groupPermissions } from "@homarr/definitions";
|
import { everyoneGroup, groupPermissions } from "@homarr/definitions";
|
||||||
import type { IsValid } from "@homarr/form";
|
import type { IsValid } from "@homarr/form";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { UserCreatePasswordFields } from "@homarr/forms-collection";
|
|
||||||
import { useModalAction } from "@homarr/modals";
|
import { useModalAction } from "@homarr/modals";
|
||||||
import { showErrorNotification } from "@homarr/notifications";
|
import { showErrorNotification } from "@homarr/notifications";
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
import { UserAvatar } from "@homarr/ui";
|
import { CustomPasswordInput, UserAvatar } from "@homarr/ui";
|
||||||
import { createCustomErrorParams } from "@homarr/validation/form/i18n";
|
import { createCustomErrorParams } from "@homarr/validation/form/i18n";
|
||||||
import { userPasswordSchema } from "@homarr/validation/user";
|
import { userPasswordSchema } from "@homarr/validation/user";
|
||||||
|
|
||||||
@@ -137,7 +149,7 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
|
|||||||
color={!generalForm.isValid() ? "red" : undefined}
|
color={!generalForm.isValid() ? "red" : undefined}
|
||||||
>
|
>
|
||||||
<form>
|
<form>
|
||||||
<Card p="xl">
|
<Card p="xl" shadow="md" withBorder>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<TextInput
|
<TextInput
|
||||||
label={tUserField("username.label")}
|
label={tUserField("username.label")}
|
||||||
@@ -153,19 +165,27 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
|
|||||||
</Stepper.Step>
|
</Stepper.Step>
|
||||||
<Stepper.Step label={t("step.security.label")} allowStepSelect={false} allowStepClick={false}>
|
<Stepper.Step label={t("step.security.label")} allowStepSelect={false} allowStepClick={false}>
|
||||||
<form>
|
<form>
|
||||||
<Card p="xl">
|
<Card p="xl" shadow="md" withBorder>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<UserCreatePasswordFields
|
<CustomPasswordInput
|
||||||
|
withPasswordRequirements
|
||||||
|
label={tUserField("password.label")}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
passwordInputProps={securityForm.getInputProps("password")}
|
withAsterisk
|
||||||
confirmPasswordInputProps={securityForm.getInputProps("confirmPassword")}
|
{...securityForm.getInputProps("password")}
|
||||||
|
/>
|
||||||
|
<PasswordInput
|
||||||
|
label={tUserField("passwordConfirm.label")}
|
||||||
|
variant="filled"
|
||||||
|
withAsterisk
|
||||||
|
{...securityForm.getInputProps("confirmPassword")}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
</form>
|
</form>
|
||||||
</Stepper.Step>
|
</Stepper.Step>
|
||||||
<Stepper.Step label={t("step.groups.label")} allowStepSelect={false} allowStepClick={false}>
|
<Stepper.Step label={t("step.groups.label")} allowStepSelect={false} allowStepClick={false}>
|
||||||
<Card p="xl">
|
<Card p="xl" shadow="md" withBorder>
|
||||||
<GroupsForm
|
<GroupsForm
|
||||||
initialGroups={initialGroups}
|
initialGroups={initialGroups}
|
||||||
addGroup={(groupId) =>
|
addGroup={(groupId) =>
|
||||||
@@ -178,20 +198,20 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
|
|||||||
</Card>
|
</Card>
|
||||||
</Stepper.Step>
|
</Stepper.Step>
|
||||||
<Stepper.Step label={t("step.review.label")} allowStepSelect={false} allowStepClick={false}>
|
<Stepper.Step label={t("step.review.label")} allowStepSelect={false} allowStepClick={false}>
|
||||||
<Card p="xl">
|
<Card p="xl" shadow="md" withBorder>
|
||||||
<Stack maw={300} align="center" mx="auto">
|
<Stack maw={300} align="center" mx="auto">
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
size="xl"
|
size="xl"
|
||||||
user={{ name: generalForm.values.username, email: generalForm.values.email ?? null, image: null }}
|
user={{ name: generalForm.values.username, email: generalForm.values.email ?? null, image: null }}
|
||||||
/>
|
/>
|
||||||
<Text fw={700} size="xl">
|
<Text tt="uppercase" fw="bolder" size="xl">
|
||||||
{generalForm.values.username}
|
{generalForm.values.username}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
</Stepper.Step>
|
</Stepper.Step>
|
||||||
<Stepper.Completed>
|
<Stepper.Completed>
|
||||||
<Card p="xl">
|
<Card p="xl" shadow="md" withBorder>
|
||||||
<Stack align="center" maw={300} mx="auto">
|
<Stack align="center" maw={300} mx="auto">
|
||||||
<IconUserCheck size="3rem" />
|
<IconUserCheck size="3rem" />
|
||||||
<Title order={2}>{t("step.completed.title")}</Title>
|
<Title order={2}>{t("step.completed.title")}</Title>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const StepperNavigationComponent = ({
|
|||||||
}: StepperNavigationComponentProps) => {
|
}: StepperNavigationComponentProps) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card shadow="md" withBorder>
|
||||||
{!isComplete ? (
|
{!isComplete ? (
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
+1
-1
@@ -101,7 +101,7 @@ export const SaveAffix = ({ groupId }: SaveAffixProps) => {
|
|||||||
<div style={{ position: "sticky", bottom: 20 }}>
|
<div style={{ position: "sticky", bottom: 20 }}>
|
||||||
<Transition transition="slide-up" mounted={form.isDirty()}>
|
<Transition transition="slide-up" mounted={form.isDirty()}>
|
||||||
{(transitionStyles) => (
|
{(transitionStyles) => (
|
||||||
<Card style={transitionStyles}>
|
<Card style={transitionStyles} withBorder>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text fw={500}>{tForm("unsavedChanges")}</Text>
|
<Text fw={500}>{tForm("unsavedChanges")}</Text>
|
||||||
<Group>
|
<Group>
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ const PermissionCard = async ({ group, isDanger }: PermissionCardProps) => {
|
|||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
p="md"
|
p="md"
|
||||||
|
withBorder
|
||||||
style={{
|
style={{
|
||||||
borderColor: isDanger ? "var(--mantine-color-red-8)" : undefined,
|
borderColor: isDanger ? "var(--mantine-color-red-8)" : undefined,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { TextInput } from "@mantine/core";
|
import { Group, TextInput } from "@mantine/core";
|
||||||
import { IconSearch } from "@tabler/icons-react";
|
import { IconSearch } from "@tabler/icons-react";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
@@ -33,20 +33,29 @@ export const GroupsList = ({ groups }: GroupsListProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TextInput
|
<Group justify="space-between">
|
||||||
leftSection={<IconSearch size={20} stroke={1.5} />}
|
<TextInput
|
||||||
value={search}
|
leftSection={<IconSearch size={20} stroke={1.5} />}
|
||||||
onChange={(event) => setSearch(event.currentTarget.value)}
|
value={search}
|
||||||
placeholder={`${t("group.search")}...`}
|
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||||
/>
|
placeholder={`${t("group.search")}...`}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<AddGroup />
|
||||||
|
</Group>
|
||||||
|
|
||||||
<GroupsTable groups={filteredGroups} initialGroupIds={initialGroupIds} hasFilter={search.length !== 0} />
|
<GroupsTable groups={filteredGroups} initialGroupIds={initialGroupIds} hasFilter={search.length !== 0} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AddGroupButton = () => {
|
const AddGroup = () => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { openModal } = useModalAction(AddGroupModal);
|
const { openModal } = useModalAction(AddGroupModal);
|
||||||
|
|
||||||
return <MobileAffixButton onClick={() => openModal()}>{t("group.action.create.label")}</MobileAffixButton>;
|
const handleAddGroup = useCallback(() => {
|
||||||
|
openModal();
|
||||||
|
}, [openModal]);
|
||||||
|
|
||||||
|
return <MobileAffixButton onClick={handleAddGroup}>{t("group.action.create.label")}</MobileAffixButton>;
|
||||||
};
|
};
|
||||||
@@ -259,7 +259,7 @@ const SaveAffix = ({ visible, isPending, onDiscard, onSave }: SaveAffixProps) =>
|
|||||||
<div style={{ position: "sticky", bottom: 20 }}>
|
<div style={{ position: "sticky", bottom: 20 }}>
|
||||||
<Transition transition="slide-up" mounted={visible}>
|
<Transition transition="slide-up" mounted={visible}>
|
||||||
{(transitionStyles) => (
|
{(transitionStyles) => (
|
||||||
<Card style={transitionStyles}>
|
<Card style={transitionStyles} withBorder>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text fw={500}>{t("common.unsavedChanges")}</Text>
|
<Text fw={500}>{t("common.unsavedChanges")}</Text>
|
||||||
<Group>
|
<Group>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { Card, Group, Stack, Text, ThemeIcon, UnstyledButton } from "@mantine/core";
|
import { Card, Group, Stack, Text, ThemeIcon, Title, UnstyledButton } from "@mantine/core";
|
||||||
import { IconChevronRight, IconUsersGroup } from "@tabler/icons-react";
|
import { IconChevronRight, IconUsersGroup } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
@@ -8,8 +8,9 @@ import { everyoneGroup } from "@homarr/definitions";
|
|||||||
import { getI18n } from "@homarr/translation/server";
|
import { getI18n } from "@homarr/translation/server";
|
||||||
import { Link } from "@homarr/ui";
|
import { Link } from "@homarr/ui";
|
||||||
|
|
||||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
import { ManageContainer } from "~/components/manage/manage-container";
|
||||||
import { AddGroupButton, GroupsList } from "./_client";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
|
import { GroupsList } from "./_client";
|
||||||
import classes from "./groups.module.css";
|
import classes from "./groups.module.css";
|
||||||
|
|
||||||
export default async function GroupsListPage() {
|
export default async function GroupsListPage() {
|
||||||
@@ -25,8 +26,11 @@ export default async function GroupsListPage() {
|
|||||||
const groupsWithoutEveryone = groups.filter((group) => group.name !== everyoneGroup);
|
const groupsWithoutEveryone = groups.filter((group) => group.name !== everyoneGroup);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ManagePageLayout title={t("group.title")} primaryAction={<AddGroupButton />} floatingPrimaryAction>
|
<ManageContainer size="xl">
|
||||||
|
<DynamicBreadcrumb />
|
||||||
<Stack>
|
<Stack>
|
||||||
|
<Title>{t("group.title")}</Title>
|
||||||
|
|
||||||
{dbEveryoneGroup && (
|
{dbEveryoneGroup && (
|
||||||
<UnstyledButton component={Link} href={`/manage/users/groups/${dbEveryoneGroup.id}`}>
|
<UnstyledButton component={Link} href={`/manage/users/groups/${dbEveryoneGroup.id}`}>
|
||||||
<Card className={classes.everyoneGroup}>
|
<Card className={classes.everyoneGroup}>
|
||||||
@@ -49,6 +53,6 @@ export default async function GroupsListPage() {
|
|||||||
|
|
||||||
<GroupsList groups={groupsWithoutEveryone} />
|
<GroupsList groups={groupsWithoutEveryone} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</ManagePageLayout>
|
</ManageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
-20
@@ -1,20 +0,0 @@
|
|||||||
"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 (
|
|
||||||
<Button miw={MANAGE_ACTION_BUTTON_MIN_WIDTH} onClick={() => openModal()}>
|
|
||||||
{t("action.new.title")}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { ActionIcon } from "@mantine/core";
|
import { ActionIcon, Button, Title } from "@mantine/core";
|
||||||
import { IconTrash } from "@tabler/icons-react";
|
import { IconTrash } from "@tabler/icons-react";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import relativeTime from "dayjs/plugin/relativeTime";
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
@@ -10,7 +10,8 @@ import { MantineReactTable } from "mantine-react-table";
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useConfirmModal } from "@homarr/modals";
|
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||||
|
import { InviteCreateModal } from "@homarr/modals-collection";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||||
|
|
||||||
@@ -64,6 +65,7 @@ export const InviteListComponent = ({ initialInvites }: InviteListComponentProps
|
|||||||
enableFullScreenToggle: false,
|
enableFullScreenToggle: false,
|
||||||
layoutMode: "grid-no-grow",
|
layoutMode: "grid-no-grow",
|
||||||
getRowId: (row) => row.id,
|
getRowId: (row) => row.id,
|
||||||
|
renderTopToolbarCustomActions: RenderTopToolbarCustomActions,
|
||||||
state: {
|
state: {
|
||||||
isLoading,
|
isLoading,
|
||||||
},
|
},
|
||||||
@@ -72,7 +74,22 @@ export const InviteListComponent = ({ initialInvites }: InviteListComponentProps
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return <MantineReactTable table={table} />;
|
return (
|
||||||
|
<>
|
||||||
|
<Title mb="md">{t("title")}</Title>
|
||||||
|
<MantineReactTable table={table} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const RenderTopToolbarCustomActions = () => {
|
||||||
|
const t = useScopedI18n("management.page.user.invite");
|
||||||
|
const { openModal } = useModalAction(InviteCreateModal);
|
||||||
|
const handleNewInvite = useCallback(() => {
|
||||||
|
openModal();
|
||||||
|
}, [openModal]);
|
||||||
|
|
||||||
|
return <Button onClick={handleNewInvite}>{t("action.new.title")}</Button>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const RenderRowActions = ({ row }: { row: MRT_Row<RouterOutputs["invite"]["getAll"][number]> }) => {
|
const RenderRowActions = ({ row }: { row: MRT_Row<RouterOutputs["invite"]["getAll"][number]> }) => {
|
||||||
|
|||||||
@@ -3,11 +3,8 @@ import { notFound } from "next/navigation";
|
|||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { isProviderEnabled } from "@homarr/auth/server";
|
import { isProviderEnabled } from "@homarr/auth/server";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
|
||||||
|
|
||||||
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
|
||||||
import { InviteCreateButton } from "./_components/invite-create-button";
|
|
||||||
import { InviteListComponent } from "./_components/invite-list";
|
import { InviteListComponent } from "./_components/invite-list";
|
||||||
|
|
||||||
export default async function InvitesOverviewPage() {
|
export default async function InvitesOverviewPage() {
|
||||||
@@ -20,20 +17,11 @@ export default async function InvitesOverviewPage() {
|
|||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const t = await getScopedI18n("management.page.user.invite");
|
|
||||||
const initialInvites = await api.invite.getAll();
|
const initialInvites = await api.invite.getAll();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ManagePageLayout
|
<>
|
||||||
title={t("title")}
|
<DynamicBreadcrumb />
|
||||||
primaryAction={
|
|
||||||
<ManageMobilePrimaryAction>
|
|
||||||
<InviteCreateButton />
|
|
||||||
</ManageMobilePrimaryAction>
|
|
||||||
}
|
|
||||||
floatingPrimaryAction
|
|
||||||
>
|
|
||||||
<InviteListComponent initialInvites={initialInvites} />
|
<InviteListComponent initialInvites={initialInvites} />
|
||||||
</ManagePageLayout>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4,11 +4,8 @@ import { api } from "@homarr/api/server";
|
|||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { isProviderEnabled } from "@homarr/auth/server";
|
import { isProviderEnabled } from "@homarr/auth/server";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
import { Link } from "@homarr/ui";
|
|
||||||
|
|
||||||
import { TourTarget } from "~/components/layout/header/tour-target";
|
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 { createMetaTitle } from "~/metadata";
|
||||||
import { UserListComponent } from "./_components/user-list";
|
import { UserListComponent } from "./_components/user-list";
|
||||||
|
|
||||||
@@ -30,27 +27,13 @@ export default async function UsersPage() {
|
|||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const t = await getScopedI18n("management.page.user");
|
|
||||||
const userList = await api.user.getAll();
|
const userList = await api.user.getAll();
|
||||||
const credentialsProviderEnabled = isProviderEnabled("credentials");
|
const credentialsProviderEnabled = isProviderEnabled("credentials");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ManagePageLayout
|
<>
|
||||||
title={t("list.title")}
|
<DynamicBreadcrumb />
|
||||||
primaryAction={
|
<UserListComponent initialUserList={userList} credentialsProviderEnabled={credentialsProviderEnabled} />
|
||||||
credentialsProviderEnabled ? (
|
</>
|
||||||
<TourTarget id="manage-users-create">
|
|
||||||
<MobileAffixButton component={Link} href="/manage/users/create">
|
|
||||||
{t("create.title")}
|
|
||||||
</MobileAffixButton>
|
|
||||||
</TourTarget>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
floatingPrimaryAction={credentialsProviderEnabled}
|
|
||||||
>
|
|
||||||
<TourTarget id="manage-users-list">
|
|
||||||
<UserListComponent initialUserList={userList} />
|
|
||||||
</TourTarget>
|
|
||||||
</ManagePageLayout>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -99,7 +99,7 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card w={dimensions.width} h={dimensions.height} p={dimensions.height >= 96 ? undefined : 4}>
|
<Card withBorder w={dimensions.width} h={dimensions.height} p={dimensions.height >= 96 ? undefined : 4}>
|
||||||
<QueryErrorResetBoundary>
|
<QueryErrorResetBoundary>
|
||||||
{({ reset }) => (
|
{({ reset }) => (
|
||||||
<ErrorBoundary
|
<ErrorBoundary
|
||||||
@@ -123,35 +123,17 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
|||||||
</QueryErrorResetBoundary>
|
</QueryErrorResetBoundary>
|
||||||
</Card>
|
</Card>
|
||||||
<Affix bottom={12} right={72}>
|
<Affix bottom={12} right={72}>
|
||||||
<ActionIcon
|
<ActionIcon size={48} variant="default" radius="xl" onClick={handleOpenEditWidgetModal}>
|
||||||
size={48}
|
|
||||||
variant="default"
|
|
||||||
radius="xl"
|
|
||||||
onClick={handleOpenEditWidgetModal}
|
|
||||||
aria-label={t("common.action.edit")}
|
|
||||||
>
|
|
||||||
<IconPencil size={24} />
|
<IconPencil size={24} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Affix>
|
</Affix>
|
||||||
<Affix bottom={12} right={72 + 60}>
|
<Affix bottom={12} right={72 + 60}>
|
||||||
<ActionIcon
|
<ActionIcon size={48} variant="default" radius="xl" onClick={toggleEditMode}>
|
||||||
size={48}
|
|
||||||
variant="default"
|
|
||||||
radius="xl"
|
|
||||||
onClick={toggleEditMode}
|
|
||||||
aria-label={editMode ? t("widgetPreview.toggle.disabled") : t("widgetPreview.toggle.enabled")}
|
|
||||||
>
|
|
||||||
{editMode ? <IconToggleLeft size={24} /> : <IconToggleRight size={24} />}
|
{editMode ? <IconToggleLeft size={24} /> : <IconToggleRight size={24} />}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Affix>
|
</Affix>
|
||||||
<Affix bottom={12} right={72 + 120}>
|
<Affix bottom={12} right={72 + 120}>
|
||||||
<ActionIcon
|
<ActionIcon size={48} variant="default" radius="xl" onClick={openDimensionsModal}>
|
||||||
size={48}
|
|
||||||
variant="default"
|
|
||||||
radius="xl"
|
|
||||||
onClick={openDimensionsModal}
|
|
||||||
aria-label={t("widgetPreview.dimensions.title")}
|
|
||||||
>
|
|
||||||
<IconDimensions size={24} />
|
<IconDimensions size={24} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Affix>
|
</Affix>
|
||||||
|
|||||||
Loaded 100 of 413 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user