Compare commits

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

No files matched your search

+4 -19
View File
@@ -1,30 +1,15 @@
{ {
"$schema": "https://docs.renovatebot.com/renovate-schema.json", "$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [ "extends": ["config:recommended"],
"config:recommended"
],
"packageRules": [ "packageRules": [
{ {
"matchPackagePatterns": [ "matchPackagePatterns": ["^@homarr/"],
"^@homarr/"
],
"enabled": false "enabled": false
},
{
"matchUpdateTypes": [
"minor",
"patch",
"pin",
"digest"
],
"automerge": true
} }
], ],
"updateInternalDeps": true, "updateInternalDeps": true,
"rangeStrategy": "bump", "rangeStrategy": "bump",
"automerge": false, "automerge": false,
"baseBranches": [ "baseBranches": ["dev"],
"dev"
],
"dependencyDashboard": false "dependencyDashboard": false
} }
-6
View File
@@ -66,9 +66,3 @@ jobs:
- name: Test - name: Test
run: pnpm test run: pnpm test
- name: 'Report Coverage'
# Set if: always() to also generate the report if tests are failing
# Only works if you set `reportOnFailure: true` in your vite config as specified above
if: always()
uses: davelosert/vitest-coverage-report-action@v2
@@ -1,16 +0,0 @@
# https://github.com/webiny/action-conventional-commits?tab=readme-ov-file
name: Conventional Commits
on:
pull_request:
branches: [ dev ]
jobs:
build:
name: Conventional Commits
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: webiny/action-conventional-commits@v1.3.0
+15 -20
View File
@@ -30,20 +30,8 @@ jobs:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master uses: Ilshidur/action-discord@master
with: with:
args: "Deployment of an image has been triggered: [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})" args: "Deployment of an image has been triggered"
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Get Next Version
id: semver
uses: ietf-tools/semver-action@v1
with:
token: ${{ github.token }}
branch: master
- name: Discord notification
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
with:
args: "Semver computed next tag to be ${{ steps.semver.outputs.next }}. Current is ${{ steps.semver.outputs.current }}"
- uses: pnpm/action-setup@v2 - uses: pnpm/action-setup@v2
with: with:
version: 8 version: 8
@@ -56,16 +44,23 @@ jobs:
run: pnpm install run: pnpm install
- name: Build artifacts - name: Build artifacts
run: pnpm build run: pnpm build
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Bump version and push tag
id: githubTagAction
uses: anothrNick/github-tag-action@1.67.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
WITH_V: false
DRY_RUN: true
- name: Discord notification - name: Discord notification
env: env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master uses: Ilshidur/action-discord@master
with: with:
args: "Built application artifacts. Building images..." args: "Image has been tagged as ${{ steps.githubTagAction.outputs.new_tag }}"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
@@ -73,7 +68,7 @@ jobs:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: | tags: |
type=raw,value=latest type=raw,value=latest
type=raw,value=${{ steps.semver.outputs.next }} type=raw,value=${{ steps.githubTagAction.outputs.new_tag }}
- name: Build and push - name: Build and push
id: buildPushAction id: buildPushAction
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
@@ -91,4 +86,4 @@ jobs:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master uses: Ilshidur/action-discord@master
with: with:
args: "Deployment of image has completed. Image ID is '${{ steps.buildPushAction.outputs.imageid }}'. This was a dry run." args: "Image built with ID ${{ steps.buildPushAction.outputs.imageid }}"
@@ -1,20 +0,0 @@
name: "Lint PR"
on:
pull_request_target:
types:
- opened
- edited
- synchronize
permissions:
pull-requests: read
jobs:
main:
name: Validate PR title
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -1,23 +0,0 @@
name: Approve Renovate PRs
on:
pull_request:
types: [opened, synchronize]
jobs:
approve-renovate-prs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install GitHub CLI
run: sudo apt-get install -y gh
- name: Approve Renovate PRs
env:
GITHUB_TOKEN: ${{ secrets.RENOVATE_APPROVE_TOKEN }}
run: |
for pr in $(gh pr list --author homarr-renovate[bot] --json number --jq .[].number); do
gh pr review $pr --approve --body "Automatically approved by GitHub Action"
done
+1 -4
View File
@@ -51,7 +51,4 @@ yarn-error.log*
db.sqlite db.sqlite
# logs # logs
*.log *.log
apps/tasks/tasks.cjs
apps/websocket/wssServer.cjs
+1 -1
View File
@@ -1 +1 @@
20.14.0 20.12.2
+2 -4
View File
@@ -6,11 +6,9 @@
], ],
"typescript.tsdk": "node_modules\\typescript\\lib", "typescript.tsdk": "node_modules\\typescript\\lib",
"js/ts.implicitProjectConfig.experimentalDecorators": true, "js/ts.implicitProjectConfig.experimentalDecorators": true,
"prettier.configPath": "./tooling/prettier/index.mjs",
"cSpell.words": [ "cSpell.words": [
"superjson", "superjson",
"homarr", "homarr",
"trpc", "trpc"
"Umami"
] ]
} }
+3 -13
View File
@@ -1,4 +1,4 @@
FROM node:20.14.0-alpine AS base FROM node:20.12.2-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
@@ -35,7 +35,6 @@ RUN corepack enable pnpm && pnpm install
COPY --from=builder /app/next-out/json/ . COPY --from=builder /app/next-out/json/ .
COPY --from=builder /app/next-out/pnpm-lock.yaml ./pnpm-lock.yaml COPY --from=builder /app/next-out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN corepack enable pnpm && pnpm install sharp -w RUN corepack enable pnpm && pnpm install sharp -w
# Build the project # Build the project
@@ -43,8 +42,6 @@ COPY --from=builder /app/tasks-out/full/ .
COPY --from=builder /app/websocket-out/full/ . COPY --from=builder /app/websocket-out/full/ .
COPY --from=builder /app/next-out/full/ . COPY --from=builder /app/next-out/full/ .
COPY --from=builder /app/migration-out/full/ . COPY --from=builder /app/migration-out/full/ .
# Copy static data as it is not part of the build
COPY static-data ./static-data
ARG SKIP_ENV_VALIDATION=true ARG SKIP_ENV_VALIDATION=true
RUN corepack enable pnpm && pnpm turbo run build RUN corepack enable pnpm && pnpm turbo run build
@@ -52,15 +49,10 @@ FROM base AS runner
WORKDIR /app WORKDIR /app
RUN apk add --no-cache redis RUN apk add --no-cache redis
RUN mkdir /appdata
RUN mkdir /appdata/db
RUN mkdir /appdata/redis
VOLUME /appdata
# Don't run production as root # Don't run production as root
RUN addgroup --system --gid 1001 nodejs RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs RUN adduser --system --uid 1001 nextjs
RUN chown -R nextjs:nodejs /appdata
USER nextjs USER nextjs
COPY --from=installer /app/apps/nextjs/next.config.mjs . COPY --from=installer /app/apps/nextjs/next.config.mjs .
@@ -71,6 +63,7 @@ COPY --from=installer --chown=nextjs:nodejs /app/apps/websocket/wssServer.cjs ./
COPY --from=installer --chown=nextjs:nodejs /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node /app/build/better_sqlite3.node COPY --from=installer --chown=nextjs:nodejs /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node /app/build/better_sqlite3.node
COPY --from=installer --chown=nextjs:nodejs /app/packages/db/migrations ./db/migrations COPY --from=installer --chown=nextjs:nodejs /app/packages/db/migrations ./db/migrations
COPY --from=installer --chown=nextjs:nodejs /app/packages/db/migrate.cjs ./db/migrate.cjs
# Automatically leverage output traces to reduce image size # Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing # https://nextjs.org/docs/advanced-features/output-file-tracing
@@ -78,10 +71,7 @@ COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/.next/standalone ./
COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/.next/static ./apps/nextjs/.next/static COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/.next/static ./apps/nextjs/.next/static
COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/public ./apps/nextjs/public COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/public ./apps/nextjs/public
COPY --chown=nextjs:nodejs scripts/run.sh ./run.sh COPY --chown=nextjs:nodejs scripts/run.sh ./run.sh
COPY --chown=nextjs:nodejs packages/redis/redis.conf /app/redis.conf
ENV DB_URL='/appdata/db/db.sqlite' ENV DB_URL='/app/db/db.sqlite'
ENV DB_DIALECT='sqlite'
ENV DB_DRIVER='better-sqlite3'
CMD ["sh", "run.sh"] CMD ["sh", "run.sh"]
-2
View File
@@ -1,7 +1,5 @@
# THIS PROJECT IS STILL UNSTABLE AND WE DO NOT PROVIDE ANY SUPPORT FOR ISSUES THAT OCCURE. # THIS PROJECT IS STILL UNSTABLE AND WE DO NOT PROVIDE ANY SUPPORT FOR ISSUES THAT OCCURE.
## PLEASE DO NOT OPEN ANY ISSUES OR DISCUSSIONS ## PLEASE DO NOT OPEN ANY ISSUES OR DISCUSSIONS
### EVERYTHING IS SUBJECT TO CHANGE ### EVERYTHING IS SUBJECT TO CHANGE
Please use [this](https://github.com/ajnart/homarr) version of Homarr when you want to use it Please use [this](https://github.com/ajnart/homarr) version of Homarr when you want to use it
+13 -12
View File
@@ -1,6 +1,6 @@
// Importing env files here to validate on build // Importing env files here to validate on build
import "@homarr/auth/env.mjs";
import "./src/env.mjs"; import "./src/env.mjs";
import "@homarr/auth/env.mjs";
/** @type {import("next").NextConfig} */ /** @type {import("next").NextConfig} */
const config = { const config = {
@@ -9,19 +9,20 @@ const config = {
/** We already do linting and typechecking as separate tasks in CI */ /** We already do linting and typechecking as separate tasks in CI */
eslint: { ignoreDuringBuilds: true }, eslint: { ignoreDuringBuilds: true },
typescript: { ignoreBuildErrors: true }, typescript: { ignoreBuildErrors: true },
webpack: (config) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
config.module.rules.push({
test: /\.node$/,
loader: "node-loader",
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return config;
},
experimental: { experimental: {
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"], optimizePackageImports: [
"@mantine/core",
"@mantine/hooks",
"@tabler/icons-react",
],
}, },
transpilePackages: ["@homarr/ui", "@homarr/notifications", "@homarr/modals", "@homarr/spotlight", "@homarr/widgets"], transpilePackages: [
"@homarr/ui",
"@homarr/notifications",
"@homarr/modals",
"@homarr/spotlight",
"@homarr/widgets",
],
images: { images: {
domains: ["cdn.jsdelivr.net"], domains: ["cdn.jsdelivr.net"],
}, },
+14 -22
View File
@@ -14,7 +14,6 @@
"with-env": "dotenv -e ../../.env --" "with-env": "dotenv -e ../../.env --"
}, },
"dependencies": { "dependencies": {
"@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",
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
@@ -22,7 +21,6 @@
"@homarr/definitions": "workspace:^0.1.0", "@homarr/definitions": "workspace:^0.1.0",
"@homarr/form": "workspace:^0.1.0", "@homarr/form": "workspace:^0.1.0",
"@homarr/gridstack": "^1.0.0", "@homarr/gridstack": "^1.0.0",
"@homarr/integrations": "workspace:^0.1.0",
"@homarr/log": "workspace:^", "@homarr/log": "workspace:^",
"@homarr/modals": "workspace:^0.1.0", "@homarr/modals": "workspace:^0.1.0",
"@homarr/notifications": "workspace:^0.1.0", "@homarr/notifications": "workspace:^0.1.0",
@@ -31,16 +29,15 @@
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0", "@homarr/widgets": "workspace:^0.1.0",
"@mantine/colors-generator": "^7.10.1", "@mantine/colors-generator": "^7.9.0",
"@mantine/hooks": "^7.10.1", "@mantine/hooks": "^7.9.0",
"@mantine/modals": "^7.10.1", "@mantine/modals": "^7.9.0",
"@mantine/tiptap": "^7.10.1", "@mantine/tiptap": "^7.9.0",
"@homarr/server-settings": "workspace:^0.1.0",
"@t3-oss/env-nextjs": "^0.10.1", "@t3-oss/env-nextjs": "^0.10.1",
"@tanstack/react-query": "^5.40.0", "@tanstack/react-query": "^5.34.1",
"@tanstack/react-query-devtools": "^5.40.0", "@tanstack/react-query-devtools": "^5.34.1",
"@tanstack/react-query-next-experimental": "5.40.0", "@tanstack/react-query-next-experimental": "5.34.1",
"@trpc/client": "11.0.0-rc.382", "@trpc/client": "11.0.0-rc.364",
"@trpc/next": "next", "@trpc/next": "next",
"@trpc/react-query": "next", "@trpc/react-query": "next",
"@trpc/server": "next", "@trpc/server": "next",
@@ -48,19 +45,15 @@
"@xterm/addon-fit": "0.10.0", "@xterm/addon-fit": "0.10.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"chroma-js": "^2.4.2", "chroma-js": "^2.4.2",
"clsx": "^2.1.1",
"dayjs": "^1.11.11", "dayjs": "^1.11.11",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"flag-icons": "^7.2.3", "glob": "^10.3.12",
"glob": "^10.4.1", "jotai": "^2.8.0",
"jotai": "^2.8.2",
"mantine-react-table": "2.0.0-beta.3",
"next": "^14.2.3", "next": "^14.2.3",
"postcss-preset-mantine": "^1.15.0", "postcss-preset-mantine": "^1.15.0",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-error-boundary": "^4.0.13", "sass": "^1.76.0",
"sass": "^1.77.3",
"superjson": "2.2.1", "superjson": "2.2.1",
"use-deep-compare-effect": "^1.8.1" "use-deep-compare-effect": "^1.8.1"
}, },
@@ -69,14 +62,13 @@
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/chroma-js": "2.4.4", "@types/chroma-js": "2.4.4",
"@types/node": "^20.12.13", "@types/node": "^20.12.8",
"@types/react": "^18.3.3", "@types/react": "^18.3.1",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"node-loader": "^2.0.0",
"prettier": "^3.2.5", "prettier": "^3.2.5",
"tsx": "4.11.0", "tsx": "4.9.0",
"typescript": "^5.4.5" "typescript": "^5.4.5"
}, },
"eslintConfig": { "eslintConfig": {
@@ -3,7 +3,10 @@ import type { PropsWithChildren } from "react";
import { defaultLocale } from "@homarr/translation"; import { defaultLocale } from "@homarr/translation";
import { I18nProviderClient } from "@homarr/translation/client"; import { I18nProviderClient } from "@homarr/translation/client";
export const NextInternationalProvider = ({ children, locale }: PropsWithChildren<{ locale: string }>) => { export const NextInternationalProvider = ({
children,
locale,
}: PropsWithChildren<{ locale: string }>) => {
return ( return (
<I18nProviderClient locale={locale} fallback={defaultLocale}> <I18nProviderClient locale={locale} fallback={defaultLocale}>
{children} {children}
@@ -9,6 +9,9 @@ interface AuthProviderProps {
session: Session | null; session: Session | null;
} }
export const AuthProvider = ({ children, session }: PropsWithChildren<AuthProviderProps>) => { export const AuthProvider = ({
children,
session,
}: PropsWithChildren<AuthProviderProps>) => {
return <SessionProvider session={session}>{children}</SessionProvider>; return <SessionProvider session={session}>{children}</SessionProvider>;
}; };
@@ -5,7 +5,12 @@ import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental"; import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
import { createWSClient, loggerLink, unstable_httpBatchStreamLink, wsLink } from "@trpc/client"; import {
createWSClient,
loggerLink,
unstable_httpBatchStreamLink,
wsLink,
} from "@trpc/client";
import superjson from "superjson"; import superjson from "superjson";
import type { AppRouter } from "@homarr/api"; import type { AppRouter } from "@homarr/api";
@@ -32,7 +37,8 @@ export function TRPCReactProvider(props: PropsWithChildren) {
links: [ links: [
loggerLink({ loggerLink({
enabled: (opts) => enabled: (opts) =>
process.env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error), process.env.NODE_ENV === "development" ||
(opts.direction === "down" && opts.result instanceof Error),
}), }),
(args) => { (args) => {
return ({ op, next }) => { return ({ op, next }) => {
@@ -63,7 +69,9 @@ export function TRPCReactProvider(props: PropsWithChildren) {
return ( return (
<clientApi.Provider client={trpcClient} queryClient={queryClient}> <clientApi.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ReactQueryStreamedHydration transformer={superjson}>{props.children}</ReactQueryStreamedHydration> <ReactQueryStreamedHydration transformer={superjson}>
{props.children}
</ReactQueryStreamedHydration>
<ReactQueryDevtools initialIsOpen={false} /> <ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider> </QueryClientProvider>
</clientApi.Provider> </clientApi.Provider>
@@ -1,85 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { Button, PasswordInput, Stack, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
interface RegistrationFormProps {
invite: {
id: string;
token: string;
};
}
export const RegistrationForm = ({ invite }: RegistrationFormProps) => {
const t = useScopedI18n("user");
const router = useRouter();
const { mutate, isPending } = clientApi.user.register.useMutation();
const form = useZodForm(validation.user.registration, {
initialValues: {
username: "",
password: "",
confirmPassword: "",
},
});
const handleSubmit = (values: z.infer<typeof validation.user.registration>) => {
mutate(
{
...values,
inviteId: invite.id,
token: invite.token,
},
{
onSuccess() {
showSuccessNotification({
title: t("action.register.notification.success.title"),
message: t("action.register.notification.success.message"),
});
router.push("/auth/login");
},
onError(error) {
const message =
error.data?.code === "CONFLICT"
? t("error.usernameTaken")
: t("action.register.notification.error.message");
showErrorNotification({
title: t("action.register.notification.error.title"),
message,
});
},
},
);
};
return (
<Stack gap="xl">
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="lg">
<TextInput label={t("field.username.label")} autoComplete="off" {...form.getInputProps("username")} />
<PasswordInput
label={t("field.password.label")}
autoComplete="new-password"
{...form.getInputProps("password")}
/>
<PasswordInput
label={t("field.passwordConfirm.label")}
autoComplete="new-password"
{...form.getInputProps("confirmPassword")}
/>
<Button type="submit" fullWidth loading={isPending}>
{t("action.register.label")}
</Button>
</Stack>
</form>
</Stack>
);
};
@@ -1,66 +0,0 @@
import { notFound } from "next/navigation";
import { Card, Center, Stack, Text, Title } from "@mantine/core";
import { auth } from "@homarr/auth/next";
import { and, db, eq } from "@homarr/db";
import { invites } from "@homarr/db/schema/sqlite";
import { getScopedI18n } from "@homarr/translation/server";
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
import { RegistrationForm } from "./_registration-form";
interface InviteUsagePageProps {
params: {
id: string;
};
searchParams: {
token: string;
};
}
export default async function InviteUsagePage({ params, searchParams }: InviteUsagePageProps) {
const session = await auth();
if (session) notFound();
const invite = await db.query.invites.findFirst({
where: and(eq(invites.id, params.id), eq(invites.token, searchParams.token)),
columns: {
id: true,
token: true,
expirationDate: true,
},
with: {
creator: {
columns: {
name: true,
},
},
},
});
if (!invite || invite.expirationDate < new Date()) notFound();
const t = await getScopedI18n("user.page.invite");
return (
<Center>
<Stack align="center" mt="xl">
<HomarrLogoWithTitle size="lg" />
<Stack gap={6} align="center">
<Title order={3} fw={400} ta="center">
{t("title")}
</Title>
<Text size="sm" c="gray.5" ta="center">
{t("subtitle")}
</Text>
</Stack>
<Card bg="dark.8" w={64 * 6} maw="90vw">
<RegistrationForm invite={invite} />
</Card>
<Text size="xs" c="gray.5" ta="center">
{t("description", { username: invite.creator.name })}
</Text>
</Stack>
</Center>
);
}
@@ -2,12 +2,18 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Alert, Button, PasswordInput, rem, Stack, TextInput } from "@mantine/core"; import {
Alert,
Button,
PasswordInput,
rem,
Stack,
TextInput,
} from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react"; import { IconAlertTriangle } from "@tabler/icons-react";
import { signIn } from "@homarr/auth/client"; import { signIn } from "@homarr/auth/client";
import { useZodForm } from "@homarr/form"; import { useForm, zodResolver } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation"; import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation"; import { validation } from "@homarr/validation";
@@ -17,14 +23,15 @@ export const LoginForm = () => {
const router = useRouter(); const router = useRouter();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
const form = useZodForm(validation.user.signIn, { const form = useForm<FormType>({
validate: zodResolver(validation.user.signIn),
initialValues: { initialValues: {
name: "", name: "",
password: "", password: "",
}, },
}); });
const handleSubmitAsync = async (values: z.infer<typeof validation.user.signIn>) => { const handleSubmit = async (values: FormType) => {
setIsLoading(true); setIsLoading(true);
setError(undefined); setError(undefined);
await signIn("credentials", { await signIn("credentials", {
@@ -37,30 +44,28 @@ export const LoginForm = () => {
throw response?.error; throw response?.error;
} }
showSuccessNotification({ void router.push("/");
title: t("action.login.notification.success.title"),
message: t("action.login.notification.success.message"),
});
router.push("/");
}) })
.catch((error: Error | string) => { .catch((error: Error | string) => {
setIsLoading(false); setIsLoading(false);
setError(error.toString()); setError(error.toString());
showErrorNotification({
title: t("action.login.notification.error.title"),
message: t("action.login.notification.error.message"),
});
}); });
}; };
return ( return (
<Stack gap="xl"> <Stack gap="xl">
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}> <form onSubmit={form.onSubmit((values) => void handleSubmit(values))}>
<Stack gap="lg"> <Stack gap="lg">
<TextInput label={t("field.username.label")} {...form.getInputProps("name")} /> <TextInput
<PasswordInput label={t("field.password.label")} {...form.getInputProps("password")} /> label={t("field.username.label")}
{...form.getInputProps("name")}
/>
<PasswordInput
label={t("field.password.label")}
{...form.getInputProps("password")}
/>
<Button type="submit" fullWidth loading={isLoading}> <Button type="submit" fullWidth loading={isLoading}>
{t("action.login.label")} {t("action.login")}
</Button> </Button>
</Stack> </Stack>
</form> </form>
@@ -73,3 +78,5 @@ export const LoginForm = () => {
</Stack> </Stack>
); );
}; };
type FormType = z.infer<typeof validation.user.signIn>;
@@ -3,7 +3,7 @@ import { api } from "@homarr/api/server";
import { createBoardContentPage } from "../_creator"; import { createBoardContentPage } from "../_creator";
export default createBoardContentPage<{ locale: string }>({ export default createBoardContentPage<{ locale: string }>({
async getInitialBoardAsync() { async getInitialBoard() {
return await api.board.getHomeBoard(); return await api.board.getDefaultBoard();
}, },
}); });
@@ -1,6 +1,6 @@
import definition from "./_definition"; import definition from "./_definition";
const { generateMetadataAsync: generateMetadata, page } = definition; const { generateMetadata, page } = definition;
export default page; export default page;
@@ -3,7 +3,7 @@ import { api } from "@homarr/api/server";
import { createBoardContentPage } from "../_creator"; import { createBoardContentPage } from "../_creator";
export default createBoardContentPage<{ locale: string; name: string }>({ export default createBoardContentPage<{ locale: string; name: string }>({
async getInitialBoardAsync({ name }) { async getInitialBoard({ name }) {
return await api.board.getBoardByName({ name }); return await api.board.getBoardByName({ name });
}, },
}); });
@@ -1,6 +1,6 @@
import definition from "./_definition"; import definition from "./_definition";
const { generateMetadataAsync: generateMetadata, page } = definition; const { generateMetadata, page } = definition;
export default page; export default page;
@@ -18,7 +18,9 @@ export const updateBoardName = (name: string | null) => {
boardName = name; boardName = name;
}; };
type UpdateCallback = (prev: RouterOutputs["board"]["getHomeBoard"]) => RouterOutputs["board"]["getHomeBoard"]; type UpdateCallback = (
prev: RouterOutputs["board"]["getDefaultBoard"],
) => RouterOutputs["board"]["getDefaultBoard"];
export const useUpdateBoard = () => { export const useUpdateBoard = () => {
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
@@ -44,7 +46,9 @@ export const ClientBoard = () => {
const board = useRequiredBoard(); const board = useRequiredBoard();
const isReady = useIsBoardReady(); const isReady = useIsBoardReady();
const sortedSections = board.sections.sort((sectionA, sectionB) => sectionA.position - sectionB.position); const sortedSections = board.sections.sort(
(sectionA, sectionB) => sectionA.position - sectionB.position,
);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
@@ -57,12 +61,24 @@ export const ClientBoard = () => {
loaderProps={{ size: "lg" }} loaderProps={{ size: "lg" }}
h={fullHeightWithoutHeaderAndFooter} h={fullHeightWithoutHeaderAndFooter}
/> />
<Stack ref={ref} h="100%" style={{ visibility: isReady ? "visible" : "hidden" }}> <Stack
ref={ref}
h="100%"
style={{ visibility: isReady ? "visible" : "hidden" }}
>
{sortedSections.map((section) => {sortedSections.map((section) =>
section.kind === "empty" ? ( section.kind === "empty" ? (
<BoardEmptySection key={section.id} section={section} mainRef={ref} /> <BoardEmptySection
key={section.id}
section={section}
mainRef={ref}
/>
) : ( ) : (
<BoardCategorySection key={section.id} section={section} mainRef={ref} /> <BoardCategorySection
key={section.id}
section={section}
mainRef={ref}
/>
), ),
)} )}
</Stack> </Stack>
@@ -1,7 +1,13 @@
"use client"; "use client";
import type { Dispatch, PropsWithChildren, SetStateAction } from "react"; import type { PropsWithChildren } from "react";
import { createContext, useCallback, useContext, useEffect, useState } from "react"; import {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
@@ -10,11 +16,9 @@ import { clientApi } from "@homarr/api/client";
import { updateBoardName } from "./_client"; import { updateBoardName } from "./_client";
const BoardContext = createContext<{ const BoardContext = createContext<{
board: RouterOutputs["board"]["getHomeBoard"]; board: RouterOutputs["board"]["getDefaultBoard"];
isReady: boolean; isReady: boolean;
markAsReady: (id: string) => void; markAsReady: (id: string) => void;
isEditMode: boolean;
setEditMode: Dispatch<SetStateAction<boolean>>;
} | null>(null); } | null>(null);
export const BoardProvider = ({ export const BoardProvider = ({
@@ -26,11 +30,11 @@ export const BoardProvider = ({
const pathname = usePathname(); const pathname = usePathname();
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
const [readySections, setReadySections] = useState<string[]>([]); const [readySections, setReadySections] = useState<string[]>([]);
const [isEditMode, setEditMode] = useState(false);
const { data } = clientApi.board.getBoardByName.useQuery( const { data } = clientApi.board.getBoardByName.useQuery(
{ name: initialBoard.name }, { name: initialBoard.name },
{ {
initialData: initialBoard, initialData: initialBoard,
refetchOnMount: false,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
refetchOnReconnect: false, refetchOnReconnect: false,
}, },
@@ -48,12 +52,18 @@ export const BoardProvider = ({
}, [pathname, utils, initialBoard.name]); }, [pathname, utils, initialBoard.name]);
useEffect(() => { useEffect(() => {
setReadySections((previous) => previous.filter((id) => data.sections.some((section) => section.id === id))); setReadySections((previous) =>
previous.filter((id) =>
data.sections.some((section) => section.id === id),
),
);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [data.sections.length, setReadySections]); }, [data.sections.length, setReadySections]);
const markAsReady = useCallback((id: string) => { const markAsReady = useCallback((id: string) => {
setReadySections((previous) => (previous.includes(id) ? previous : [...previous, id])); setReadySections((previous) =>
previous.includes(id) ? previous : [...previous, id],
);
}, []); }, []);
return ( return (
@@ -62,8 +72,6 @@ export const BoardProvider = ({
board: data, board: data,
isReady: data.sections.length === readySections.length, isReady: data.sections.length === readySections.length,
markAsReady, markAsReady,
isEditMode,
setEditMode,
}} }}
> >
{children} {children}
@@ -106,13 +114,3 @@ export const useOptionalBoard = () => {
return context?.board; return context?.board;
}; };
export const useEditMode = () => {
const context = useContext(BoardContext);
if (!context) {
throw new Error("Board is required");
}
return [context.isEditMode, context.setEditMode] as const;
};
@@ -1,12 +1,11 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { capitalize } from "@homarr/common";
// Placed here because gridstack styles are used for board content // Placed here because gridstack styles are used for board content
import "~/styles/gridstack.scss"; import "~/styles/gridstack.scss";
import { getI18n } from "@homarr/translation/server";
import { createMetaTitle } from "~/metadata";
import { createBoardLayout } from "../_layout-creator"; import { createBoardLayout } from "../_layout-creator";
import type { Board } from "../_types"; import type { Board } from "../_types";
import { ClientBoard } from "./_client"; import { ClientBoard } from "./_client";
@@ -15,28 +14,33 @@ import { BoardContentHeaderActions } from "./_header-actions";
export type Params = Record<string, unknown>; export type Params = Record<string, unknown>;
interface Props<TParams extends Params> { interface Props<TParams extends Params> {
getInitialBoardAsync: (params: TParams) => Promise<Board>; getInitialBoard: (params: TParams) => Promise<Board>;
} }
export const createBoardContentPage = <TParams extends Record<string, unknown>>({ export const createBoardContentPage = <
getInitialBoardAsync: getInitialBoard, TParams extends Record<string, unknown>,
>({
getInitialBoard,
}: Props<TParams>) => { }: Props<TParams>) => {
return { return {
layout: createBoardLayout({ layout: createBoardLayout({
headerActions: <BoardContentHeaderActions />, headerActions: <BoardContentHeaderActions />,
getInitialBoardAsync: getInitialBoard, getInitialBoard,
isBoardContentPage: true, isBoardContentPage: true,
}), }),
page: () => { page: () => {
return <ClientBoard />; return <ClientBoard />;
}, },
generateMetadataAsync: async ({ params }: { params: TParams }): Promise<Metadata> => { generateMetadata: async ({
params,
}: {
params: TParams;
}): Promise<Metadata> => {
try { try {
const board = await getInitialBoard(params); const board = await getInitialBoard(params);
const t = await getI18n();
return { return {
title: board.metaTitle ?? createMetaTitle(t("board.content.metaTitle", { boardName: board.name })), title: board.metaTitle ?? `${capitalize(board.name)} board | Homarr`,
icons: { icons: {
icon: board.faviconImageUrl ? board.faviconImageUrl : undefined, icon: board.faviconImageUrl ? board.faviconImageUrl : undefined,
}, },
@@ -1,9 +0,0 @@
"use client";
import { useRequiredBoard } from "./_context";
export const CustomCss = () => {
const board = useRequiredBoard();
return <style>{board.customCss}</style>;
};
@@ -12,22 +12,27 @@ import {
IconPlus, IconPlus,
IconSettings, IconSettings,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useAtom, useAtomValue } from "jotai";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useModalAction } from "@homarr/modals"; import { useModalAction } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import {
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction"; import { revalidatePathAction } from "~/app/revalidatePathAction";
import { editModeAtom } from "~/components/board/editMode";
import { ItemSelectModal } from "~/components/board/items/item-select-modal"; import { ItemSelectModal } from "~/components/board/items/item-select-modal";
import { useBoardPermissions } from "~/components/board/permissions/client"; 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 { HeaderButton } from "~/components/layout/header/button"; import { HeaderButton } from "~/components/layout/header/button";
import { useEditMode, useRequiredBoard } from "./_context"; import { useRequiredBoard } from "./_context";
export const BoardContentHeaderActions = () => { export const BoardContentHeaderActions = () => {
const [isEditMode] = useEditMode(); const isEditMode = useAtomValue(editModeAtom);
const board = useRequiredBoard(); const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board); const { hasChangeAccess } = useBoardPermissions(board);
@@ -49,7 +54,8 @@ export const BoardContentHeaderActions = () => {
}; };
const AddMenu = () => { const AddMenu = () => {
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal); const { openModal: openCategoryEditModal } =
useModalAction(CategoryEditModal);
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal); const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
const { addCategoryToEnd } = useCategoryActions(); const { addCategoryToEnd } = useCategoryActions();
const t = useI18n(); const t = useI18n();
@@ -89,14 +95,22 @@ const AddMenu = () => {
</HeaderButton> </HeaderButton>
</Menu.Target> </Menu.Target>
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}> <Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectItem}> <Menu.Item
leftSection={<IconBox size={20} />}
onClick={handleSelectItem}
>
{t("item.action.create")} {t("item.action.create")}
</Menu.Item> </Menu.Item>
<Menu.Item leftSection={<IconPackageImport size={20} />}>{t("item.action.import")}</Menu.Item> <Menu.Item leftSection={<IconPackageImport size={20} />}>
{t("item.action.import")}
</Menu.Item>
<Menu.Divider /> <Menu.Divider />
<Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}> <Menu.Item
leftSection={<IconBoxAlignTop size={20} />}
onClick={handleAddCategory}
>
{t("section.category.action.create")} {t("section.category.action.create")}
</Menu.Item> </Menu.Item>
</Menu.Dropdown> </Menu.Dropdown>
@@ -105,27 +119,28 @@ const AddMenu = () => {
}; };
const EditModeMenu = () => { const EditModeMenu = () => {
const [isEditMode, setEditMode] = useEditMode(); const [isEditMode, setEditMode] = useAtom(editModeAtom);
const board = useRequiredBoard(); const board = useRequiredBoard();
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
const t = useScopedI18n("board.action.edit"); const t = useScopedI18n("board.action.edit");
const { mutate: saveBoard, isPending } = clientApi.board.saveBoard.useMutation({ const { mutate: saveBoard, isPending } =
onSuccess() { clientApi.board.saveBoard.useMutation({
showSuccessNotification({ onSuccess() {
title: t("notification.success.title"), showSuccessNotification({
message: t("notification.success.message"), title: t("notification.success.title"),
}); message: t("notification.success.message"),
void utils.board.getBoardByName.invalidate({ name: board.name }); });
void revalidatePathActionAsync(`/boards/${board.name}`); void utils.board.getBoardByName.invalidate({ name: board.name });
setEditMode(false); void revalidatePathAction(`/boards/${board.name}`);
}, setEditMode(false);
onError() { },
showErrorNotification({ onError() {
title: t("notification.error.title"), showErrorNotification({
message: t("notification.error.message"), title: t("notification.error.title"),
}); message: t("notification.error.message"),
}, });
}); },
});
const toggle = useCallback(() => { const toggle = useCallback(() => {
if (isEditMode) return saveBoard(board); if (isEditMode) return saveBoard(board);
@@ -134,7 +149,11 @@ const EditModeMenu = () => {
return ( return (
<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>
); );
}; };
@@ -22,7 +22,9 @@ export const BoardMantineProvider = ({ children }: PropsWithChildren) => {
}; };
export const generateColors = (hex: string) => { export const generateColors = (hex: string) => {
const lightnessForColors = [-0.25, -0.2, -0.15, -0.1, -0.05, 0, 0.05, 0.1, 0.15, 0.2] as const; const lightnessForColors = [
-0.25, -0.2, -0.15, -0.1, -0.05, 0, 0.05, 0.1, 0.15, 0.2,
] as const;
const rgbaColors = lightnessForColors.map((lightness) => { const rgbaColors = lightnessForColors.map((lightness) => {
if (lightness < 0) { if (lightness < 0) {
return lighten(hex, -lightness); return lighten(hex, -lightness);
@@ -5,7 +5,7 @@ import { createBoardLayout } from "../_layout-creator";
export default createBoardLayout<{ locale: string; name: string }>({ export default createBoardLayout<{ locale: string; name: string }>({
headerActions: <BoardOtherHeaderActions />, headerActions: <BoardOtherHeaderActions />,
async getInitialBoardAsync({ name }) { async getInitialBoard({ name }) {
return await api.board.getBoardByName({ name }); return await api.board.getBoardByName({ name });
}, },
isBoardContentPage: false, isBoardContentPage: false,
@@ -44,7 +44,11 @@ export const AccessSettingsContent = ({ board, initialPermissions }: Props) => {
<Tabs.List grow> <Tabs.List grow>
<TabItem value="user" count={counts.user} icon={IconUser} /> <TabItem value="user" count={counts.user} icon={IconUser} />
<TabItem value="group" count={counts.group} icon={IconUsersGroup} /> <TabItem value="group" count={counts.group} icon={IconUsersGroup} />
<TabItem value="inherited" count={initialPermissions.inherited.length} icon={IconUserDown} /> <TabItem
value="inherited"
count={initialPermissions.inherited.length}
icon={IconUserDown}
/>
</Tabs.List> </Tabs.List>
<Tabs.Panel value="user"> <Tabs.Panel value="user">
@@ -1,8 +1,21 @@
import { useCallback } from "react"; import { useCallback } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type { SelectProps } from "@mantine/core"; import type { SelectProps } from "@mantine/core";
import { Button, Flex, Group, Select, TableTd, TableTr, Text } from "@mantine/core"; import {
import { IconCheck, IconEye, IconPencil, IconSettings } from "@tabler/icons-react"; Button,
Flex,
Group,
Select,
TableTd,
TableTr,
Text,
} from "@mantine/core";
import {
IconCheck,
IconEye,
IconPencil,
IconSettings,
} from "@tabler/icons-react";
import type { BoardPermission } from "@homarr/definitions"; import type { BoardPermission } from "@homarr/definitions";
import { boardPermissions } from "@homarr/definitions"; import { boardPermissions } from "@homarr/definitions";
@@ -25,7 +38,12 @@ interface BoardAccessSelectRowProps {
onCountChange: OnCountChange; onCountChange: OnCountChange;
} }
export const BoardAccessSelectRow = ({ itemContent, permission, index, onCountChange }: BoardAccessSelectRowProps) => { export const BoardAccessSelectRow = ({
itemContent,
permission,
index,
onCountChange,
}: BoardAccessSelectRowProps) => {
const tRoot = useI18n(); const tRoot = useI18n();
const tPermissions = useScopedI18n("board.setting.section.access.permission"); const tPermissions = useScopedI18n("board.setting.section.access.permission");
const form = useFormContext(); const form = useFormContext();
@@ -43,7 +61,11 @@ export const BoardAccessSelectRow = ({ itemContent, permission, index, onCountCh
<TableTr> <TableTr>
<TableTd w={{ sm: 128, lg: 256 }}>{itemContent}</TableTd> <TableTd w={{ sm: 128, lg: 256 }}>{itemContent}</TableTd>
<TableTd> <TableTd>
<Flex direction={{ base: "column", xs: "row" }} align={{ base: "end", xs: "center" }} wrap="nowrap"> <Flex
direction={{ base: "column", xs: "row" }}
align={{ base: "end", xs: "center" }}
wrap="nowrap"
>
<Select <Select
allowDeselect={false} allowDeselect={false}
flex="1" flex="1"
@@ -71,7 +93,10 @@ interface BoardAccessDisplayRowProps {
permission: BoardPermission | "board-full"; permission: BoardPermission | "board-full";
} }
export const BoardAccessDisplayRow = ({ itemContent, permission }: BoardAccessDisplayRowProps) => { export const BoardAccessDisplayRow = ({
itemContent,
permission,
}: BoardAccessDisplayRowProps) => {
const tPermissions = useScopedI18n("board.setting.section.access.permission"); const tPermissions = useScopedI18n("board.setting.section.access.permission");
const Icon = icons[permission]; const Icon = icons[permission];
@@ -81,7 +106,10 @@ export const BoardAccessDisplayRow = ({ itemContent, permission }: BoardAccessDi
<TableTd> <TableTd>
<Group gap={0}> <Group gap={0}>
<Flex w={34} h={34} align="center" justify="center"> <Flex w={34} h={34} align="center" justify="center">
<Icon size="1rem" color="var(--input-section-color, var(--mantine-color-dimmed))" /> <Icon
size="1rem"
color="var(--input-section-color, var(--mantine-color-dimmed))"
/>
</Flex> </Flex>
<Text size="sm">{tPermissions(`item.${permission}.label`)}</Text> <Text size="sm">{tPermissions(`item.${permission}.label`)}</Text>
</Group> </Group>
@@ -103,7 +131,9 @@ const RenderOption: SelectProps["renderOption"] = ({ option, checked }) => {
<Group flex="1" gap="xs" wrap="nowrap"> <Group flex="1" gap="xs" wrap="nowrap">
<Icon {...iconProps} /> <Icon {...iconProps} />
{option.label} {option.label}
{checked && <IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />} {checked && (
<IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />
)}
</Group> </Group>
); );
}; };
@@ -8,6 +8,7 @@ export interface BoardAccessFormType {
}[]; }[];
} }
export const [FormProvider, useFormContext, useForm] = createFormContext<BoardAccessFormType>(); export const [FormProvider, useFormContext, useForm] =
createFormContext<BoardAccessFormType>();
export type OnCountChange = (callback: (prev: number) => number) => void; export type OnCountChange = (callback: (prev: number) => number) => void;
@@ -1,6 +1,16 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { Anchor, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core"; import {
Anchor,
Button,
Group,
Stack,
Table,
TableTbody,
TableTh,
TableThead,
TableTr,
} from "@mantine/core";
import { IconPlus } from "@tabler/icons-react"; import { IconPlus } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
@@ -14,21 +24,30 @@ import { FormProvider, useForm } from "./form";
import { GroupSelectModal } from "./group-select-modal"; import { GroupSelectModal } from "./group-select-modal";
import type { FormProps } from "./user-access"; import type { FormProps } from "./user-access";
export const GroupsForm = ({ board, initialPermissions, onCountChange }: FormProps) => { export const GroupsForm = ({
const { mutate, isPending } = clientApi.board.saveGroupBoardPermissions.useMutation(); board,
initialPermissions,
onCountChange,
}: FormProps) => {
const { mutate, isPending } =
clientApi.board.saveGroupBoardPermissions.useMutation();
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
const [groups, setGroups] = useState<Map<string, Group>>( const [groups, setGroups] = useState<Map<string, Group>>(
new Map(initialPermissions.groupPermissions.map(({ group }) => [group.id, group])), new Map(
initialPermissions.groupPermissions.map(({ group }) => [group.id, group]),
),
); );
const { openModal } = useModalAction(GroupSelectModal); const { openModal } = useModalAction(GroupSelectModal);
const t = useI18n(); const t = useI18n();
const tPermissions = useScopedI18n("board.setting.section.access.permission"); const tPermissions = useScopedI18n("board.setting.section.access.permission");
const form = useForm({ const form = useForm({
initialValues: { initialValues: {
items: initialPermissions.groupPermissions.map(({ group, permission }) => ({ items: initialPermissions.groupPermissions.map(
itemId: group.id, ({ group, permission }) => ({
permission, itemId: group.id,
})), permission,
}),
),
}, },
}); });
@@ -73,7 +92,9 @@ export const GroupsForm = ({ board, initialPermissions, onCountChange }: FormPro
<Table> <Table>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh style={{ whiteSpace: "nowrap" }}>{tPermissions("field.group.label")}</TableTh> <TableTh style={{ whiteSpace: "nowrap" }}>
{tPermissions("field.group.label")}
</TableTh>
<TableTh>{tPermissions("field.permission.label")}</TableTh> <TableTh>{tPermissions("field.permission.label")}</TableTh>
</TableTr> </TableTr>
</TableThead> </TableThead>
@@ -81,7 +102,9 @@ export const GroupsForm = ({ board, initialPermissions, onCountChange }: FormPro
{form.values.items.map((row, index) => ( {form.values.items.map((row, index) => (
<BoardAccessSelectRow <BoardAccessSelectRow
key={row.itemId} key={row.itemId}
itemContent={<GroupItemContent group={groups.get(row.itemId)!} />} itemContent={
<GroupItemContent group={groups.get(row.itemId)!} />
}
permission={row.permission} permission={row.permission}
index={index} index={index}
onCountChange={onCountChange} onCountChange={onCountChange}
@@ -91,7 +114,11 @@ export const GroupsForm = ({ board, initialPermissions, onCountChange }: FormPro
</Table> </Table>
<Group justify="space-between"> <Group justify="space-between">
<Button rightSection={<IconPlus size="1rem" />} variant="light" onClick={handleAddUser}> <Button
rightSection={<IconPlus size="1rem" />}
variant="light"
onClick={handleAddUser}
>
{t("common.action.add")} {t("common.action.add")}
</Button> </Button>
<Button type="submit" loading={isPending} color="teal"> <Button type="submit" loading={isPending} color="teal">
@@ -106,10 +133,16 @@ export const GroupsForm = ({ board, initialPermissions, onCountChange }: FormPro
export const GroupItemContent = ({ group }: { group: Group }) => { export const GroupItemContent = ({ group }: { group: Group }) => {
return ( return (
<Anchor component={Link} href={`/manage/users/groups/${group.id}`} size="sm" style={{ whiteSpace: "nowrap" }}> <Anchor
component={Link}
href={`/manage/users/groups/${group.id}`}
size="sm"
style={{ whiteSpace: "nowrap" }}
>
{group.name} {group.name}
</Anchor> </Anchor>
); );
}; };
type Group = RouterOutputs["board"]["getBoardPermissions"]["groupPermissions"][0]["group"]; type Group =
RouterOutputs["board"]["getBoardPermissions"]["groupPermissions"][0]["group"];
@@ -16,52 +16,57 @@ interface GroupSelectFormType {
groupId: string; groupId: string;
} }
export const GroupSelectModal = createModal<InnerProps>(({ actions, innerProps }) => { export const GroupSelectModal = createModal<InnerProps>(
const t = useI18n(); ({ actions, innerProps }) => {
const { data: groups, isPending } = clientApi.group.selectable.useQuery(); const t = useI18n();
const [loading, setLoading] = useState(false); const { data: groups, isPending } = clientApi.group.selectable.useQuery();
const form = useForm<GroupSelectFormType>(); const [loading, setLoading] = useState(false);
const handleSubmitAsync = async (values: GroupSelectFormType) => { const form = useForm<GroupSelectFormType>();
const currentGroup = groups?.find((group) => group.id === values.groupId); const handleSubmit = async (values: GroupSelectFormType) => {
if (!currentGroup) return; const currentGroup = groups?.find((group) => group.id === values.groupId);
setLoading(true); if (!currentGroup) return;
await innerProps.onSelect({ setLoading(true);
id: currentGroup.id, await innerProps.onSelect({
name: currentGroup.name, id: currentGroup.id,
}); name: currentGroup.name,
});
setLoading(false); setLoading(false);
actions.closeModal(); actions.closeModal();
}; };
const confirmLabel = innerProps.confirmLabel ?? t("common.action.add"); const confirmLabel = innerProps.confirmLabel ?? t("common.action.add");
return ( return (
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}> <form onSubmit={form.onSubmit((values) => void handleSubmit(values))}>
<Stack> <Stack>
<Select <Select
{...form.getInputProps("groupId")} {...form.getInputProps("groupId")}
label={t("group.action.select.label")} label={t("group.action.select.label")}
clearable clearable
searchable searchable
leftSection={isPending ? <Loader size="xs" /> : undefined} leftSection={isPending ? <Loader size="xs" /> : undefined}
nothingFoundMessage={t("group.action.select.notFound")} nothingFoundMessage={t("group.action.select.notFound")}
limit={5} limit={5}
data={groups data={groups
?.filter((group) => !innerProps.presentGroupIds.includes(group.id)) ?.filter(
.map((group) => ({ value: group.id, label: group.name }))} (group) => !innerProps.presentGroupIds.includes(group.id),
/> )
<Group justify="end"> .map((group) => ({ value: group.id, label: group.name }))}
<Button variant="default" onClick={actions.closeModal}> />
{t("common.action.cancel")} <Group justify="end">
</Button> <Button variant="default" onClick={actions.closeModal}>
<Button type="submit" loading={loading}> {t("common.action.cancel")}
{confirmLabel} </Button>
</Button> <Button type="submit" loading={loading}>
</Group> {confirmLabel}
</Stack> </Button>
</form> </Group>
); </Stack>
}).withOptions({ </form>
defaultTitle: (t) => t("board.setting.section.access.permission.groupSelect.title"), );
},
).withOptions({
defaultTitle: (t) =>
t("board.setting.section.access.permission.groupSelect.title"),
}); });
@@ -1,4 +1,11 @@
import { Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core"; import {
Stack,
Table,
TableTbody,
TableTh,
TableThead,
TableTr,
} from "@mantine/core";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { getPermissionsWithChildren } from "@homarr/definitions"; import { getPermissionsWithChildren } from "@homarr/definitions";
@@ -34,7 +41,9 @@ export const InheritTable = ({ initialPermissions }: InheritTableProps) => {
const boardPermission = const boardPermission =
permission in mapPermissions permission in mapPermissions
? mapPermissions[permission as keyof typeof mapPermissions] ? mapPermissions[permission as keyof typeof mapPermissions]
: getPermissionsWithChildren([permission]).includes("board-full-access") : getPermissionsWithChildren([permission]).includes(
"board-full-access",
)
? "board-full" ? "board-full"
: null; : null;
@@ -1,6 +1,17 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { Anchor, Box, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core"; import {
Anchor,
Box,
Button,
Group,
Stack,
Table,
TableTbody,
TableTh,
TableThead,
TableTr,
} from "@mantine/core";
import { IconPlus } from "@tabler/icons-react"; import { IconPlus } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
@@ -10,7 +21,10 @@ import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui"; import { UserAvatar } from "@homarr/ui";
import type { Board } from "../../../_types"; import type { Board } from "../../../_types";
import { BoardAccessDisplayRow, BoardAccessSelectRow } from "./board-access-table-rows"; import {
BoardAccessDisplayRow,
BoardAccessSelectRow,
} from "./board-access-table-rows";
import type { BoardAccessFormType, OnCountChange } from "./form"; import type { BoardAccessFormType, OnCountChange } from "./form";
import { FormProvider, useForm } from "./form"; import { FormProvider, useForm } from "./form";
import { UserSelectModal } from "./user-select-modal"; import { UserSelectModal } from "./user-select-modal";
@@ -21,11 +35,18 @@ export interface FormProps {
onCountChange: OnCountChange; onCountChange: OnCountChange;
} }
export const UsersForm = ({ board, initialPermissions, onCountChange }: FormProps) => { export const UsersForm = ({
const { mutate, isPending } = clientApi.board.saveUserBoardPermissions.useMutation(); board,
initialPermissions,
onCountChange,
}: FormProps) => {
const { mutate, isPending } =
clientApi.board.saveUserBoardPermissions.useMutation();
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
const [users, setUsers] = useState<Map<string, User>>( const [users, setUsers] = useState<Map<string, User>>(
new Map(initialPermissions.userPermissions.map(({ user }) => [user.id, user])), new Map(
initialPermissions.userPermissions.map(({ user }) => [user.id, user]),
),
); );
const { openModal } = useModalAction(UserSelectModal); const { openModal } = useModalAction(UserSelectModal);
const t = useI18n(); const t = useI18n();
@@ -60,7 +81,9 @@ export const UsersForm = ({ board, initialPermissions, onCountChange }: FormProp
const presentUserIds = form.values.items.map(({ itemId: id }) => id); const presentUserIds = form.values.items.map(({ itemId: id }) => id);
openModal({ openModal({
presentUserIds: board.creatorId ? presentUserIds.concat(board.creatorId) : presentUserIds, presentUserIds: board.creatorId
? presentUserIds.concat(board.creatorId)
: presentUserIds,
onSelect: (user) => { onSelect: (user) => {
setUsers((prev) => new Map(prev).set(user.id, user)); setUsers((prev) => new Map(prev).set(user.id, user));
form.setFieldValue("items", [ form.setFieldValue("items", [
@@ -88,12 +111,17 @@ export const UsersForm = ({ board, initialPermissions, onCountChange }: FormProp
</TableThead> </TableThead>
<TableTbody> <TableTbody>
{board.creator && ( {board.creator && (
<BoardAccessDisplayRow itemContent={<UserItemContent user={board.creator} />} permission="board-full" /> <BoardAccessDisplayRow
itemContent={<UserItemContent user={board.creator} />}
permission="board-full"
/>
)} )}
{form.values.items.map((row, index) => ( {form.values.items.map((row, index) => (
<BoardAccessSelectRow <BoardAccessSelectRow
key={row.itemId} key={row.itemId}
itemContent={<UserItemContent user={users.get(row.itemId)!} />} itemContent={
<UserItemContent user={users.get(row.itemId)!} />
}
permission={row.permission} permission={row.permission}
index={index} index={index}
onCountChange={onCountChange} onCountChange={onCountChange}
@@ -103,7 +131,11 @@ export const UsersForm = ({ board, initialPermissions, onCountChange }: FormProp
</Table> </Table>
<Group justify="space-between"> <Group justify="space-between">
<Button rightSection={<IconPlus size="1rem" />} variant="light" onClick={handleAddUser}> <Button
rightSection={<IconPlus size="1rem" />}
variant="light"
onClick={handleAddUser}
>
{t("common.action.add")} {t("common.action.add")}
</Button> </Button>
<Button type="submit" loading={isPending} color="teal"> <Button type="submit" loading={isPending} color="teal">
@@ -122,7 +154,12 @@ const UserItemContent = ({ user }: { user: User }) => {
<Box visibleFrom="xs"> <Box visibleFrom="xs">
<UserAvatar user={user} size="sm" /> <UserAvatar user={user} size="sm" />
</Box> </Box>
<Anchor component={Link} href={`/manage/users/${user.id}`} size="sm" style={{ whiteSpace: "nowrap" }}> <Anchor
component={Link}
href={`/manage/users/${user.id}`}
size="sm"
style={{ whiteSpace: "nowrap" }}
>
{user.name} {user.name}
</Anchor> </Anchor>
</Group> </Group>
@@ -12,7 +12,11 @@ import { UserAvatar } from "@homarr/ui";
interface InnerProps { interface InnerProps {
presentUserIds: string[]; presentUserIds: string[];
onSelect: (props: { id: string; name: string; image: string }) => void | Promise<void>; onSelect: (props: {
id: string;
name: string;
image: string;
}) => void | Promise<void>;
confirmLabel?: string; confirmLabel?: string;
} }
@@ -20,59 +24,66 @@ interface UserSelectFormType {
userId: string; userId: string;
} }
export const UserSelectModal = createModal<InnerProps>(({ actions, innerProps }) => { export const UserSelectModal = createModal<InnerProps>(
const t = useI18n(); ({ actions, innerProps }) => {
const { data: users, isPending } = clientApi.user.selectable.useQuery(); const t = useI18n();
const [loading, setLoading] = useState(false); const { data: users, isPending } = clientApi.user.selectable.useQuery();
const form = useForm<UserSelectFormType>(); const [loading, setLoading] = useState(false);
const handleSubmitAsync = async (values: UserSelectFormType) => { const form = useForm<UserSelectFormType>();
const currentUser = users?.find((user) => user.id === values.userId); const handleSubmit = async (values: UserSelectFormType) => {
if (!currentUser) return; const currentUser = users?.find((user) => user.id === values.userId);
setLoading(true); if (!currentUser) return;
await innerProps.onSelect({ setLoading(true);
id: currentUser.id, await innerProps.onSelect({
name: currentUser.name ?? "", id: currentUser.id,
image: currentUser.image ?? "", name: currentUser.name ?? "",
}); image: currentUser.image ?? "",
});
setLoading(false); setLoading(false);
actions.closeModal(); actions.closeModal();
}; };
const confirmLabel = innerProps.confirmLabel ?? t("common.action.add"); const confirmLabel = innerProps.confirmLabel ?? t("common.action.add");
const currentUser = users?.find((user) => user.id === form.values.userId); const currentUser = users?.find((user) => user.id === form.values.userId);
return ( return (
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}> <form onSubmit={form.onSubmit((values) => void handleSubmit(values))}>
<Stack> <Stack>
<Select <Select
{...form.getInputProps("userId")} {...form.getInputProps("userId")}
label={t("user.action.select.label")} label={t("user.action.select.label")}
searchable searchable
clearable clearable
leftSection={ leftSection={
isPending ? <Loader size="xs" /> : currentUser ? <UserAvatar user={currentUser} size="xs" /> : undefined isPending ? (
} <Loader size="xs" />
nothingFoundMessage={t("user.action.select.notFound")} ) : currentUser ? (
renderOption={createRenderOption(users ?? [])} <UserAvatar user={currentUser} size="xs" />
limit={5} ) : undefined
data={users }
?.filter((user) => !innerProps.presentUserIds.includes(user.id)) nothingFoundMessage={t("user.action.select.notFound")}
.map((user) => ({ value: user.id, label: user.name ?? "" }))} renderOption={createRenderOption(users ?? [])}
/> limit={5}
<Group justify="end"> data={users
<Button variant="default" onClick={actions.closeModal}> ?.filter((user) => !innerProps.presentUserIds.includes(user.id))
{t("common.action.cancel")} .map((user) => ({ value: user.id, label: user.name ?? "" }))}
</Button> />
<Button type="submit" loading={loading}> <Group justify="end">
{confirmLabel} <Button variant="default" onClick={actions.closeModal}>
</Button> {t("common.action.cancel")}
</Group> </Button>
</Stack> <Button type="submit" loading={loading}>
</form> {confirmLabel}
); </Button>
}).withOptions({ </Group>
defaultTitle: (t) => t("board.setting.section.access.permission.userSelect.title"), </Stack>
</form>
);
},
).withOptions({
defaultTitle: (t) =>
t("board.setting.section.access.permission.userSelect.title"),
}); });
const iconProps = { const iconProps = {
@@ -82,7 +93,9 @@ const iconProps = {
size: "1rem", size: "1rem",
}; };
const createRenderOption = (users: RouterOutputs["user"]["selectable"]): SelectProps["renderOption"] => const createRenderOption = (
users: RouterOutputs["user"]["selectable"],
): SelectProps["renderOption"] =>
function InnerRenderRoot({ option, checked }) { function InnerRenderRoot({ option, checked }) {
const user = users.find((user) => user.id === option.value); const user = users.find((user) => user.id === option.value);
if (!user) return null; if (!user) return null;
@@ -91,7 +104,9 @@ const createRenderOption = (users: RouterOutputs["user"]["selectable"]): SelectP
<Group flex="1" gap="xs"> <Group flex="1" gap="xs">
<UserAvatar user={user} size="xs" /> <UserAvatar user={user} size="xs" />
{option.label} {option.label}
{checked && <IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />} {checked && (
<IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />
)}
</Group> </Group>
); );
}; };
@@ -2,13 +2,16 @@
import { Button, Grid, Group, Stack, TextInput } from "@mantine/core"; import { Button, Grid, Group, Stack, TextInput } from "@mantine/core";
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions"; import {
import { useZodForm } from "@homarr/form"; backgroundImageAttachments,
backgroundImageRepeats,
backgroundImageSizes,
} from "@homarr/definitions";
import { useForm } from "@homarr/form";
import type { TranslationObject } from "@homarr/translation"; import type { TranslationObject } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import type { SelectItemWithDescriptionBadge } from "@homarr/ui"; import type { SelectItemWithDescriptionBadge } from "@homarr/ui";
import { SelectWithDescriptionBadge } from "@homarr/ui"; import { SelectWithDescriptionBadge } from "@homarr/ui";
import { validation } from "@homarr/validation";
import type { Board } from "../../_types"; import type { Board } from "../../_types";
import { useSavePartialSettingsMutation } from "./_shared"; import { useSavePartialSettingsMutation } from "./_shared";
@@ -18,8 +21,9 @@ interface Props {
} }
export const BackgroundSettingsContent = ({ board }: Props) => { export const BackgroundSettingsContent = ({ board }: Props) => {
const t = useI18n(); const t = useI18n();
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board); const { mutate: savePartialSettings, isPending } =
const form = useZodForm(validation.board.savePartialSettings, { useSavePartialSettingsMutation(board);
const form = useForm({
initialValues: { initialValues: {
backgroundImageUrl: board.backgroundImageUrl ?? "", backgroundImageUrl: board.backgroundImageUrl ?? "",
backgroundImageAttachment: board.backgroundImageAttachment, backgroundImageAttachment: board.backgroundImageAttachment,
@@ -32,8 +36,14 @@ export const BackgroundSettingsContent = ({ board }: Props) => {
"backgroundImageAttachment", "backgroundImageAttachment",
backgroundImageAttachments, backgroundImageAttachments,
); );
const backgroundImageSizeData = useBackgroundOptionData("backgroundImageSize", backgroundImageSizes); const backgroundImageSizeData = useBackgroundOptionData(
const backgroundImageRepeatData = useBackgroundOptionData("backgroundImageRepeat", backgroundImageRepeats); "backgroundImageSize",
backgroundImageSizes,
);
const backgroundImageRepeatData = useBackgroundOptionData(
"backgroundImageRepeat",
backgroundImageRepeats,
);
return ( return (
<form <form
@@ -85,9 +95,13 @@ export const BackgroundSettingsContent = ({ board }: Props) => {
); );
}; };
type BackgroundImageKey = "backgroundImageAttachment" | "backgroundImageSize" | "backgroundImageRepeat"; type BackgroundImageKey =
| "backgroundImageAttachment"
| "backgroundImageSize"
| "backgroundImageRepeat";
type inferOptions<TKey extends BackgroundImageKey> = TranslationObject["board"]["field"][TKey]["option"]; type inferOptions<TKey extends BackgroundImageKey> =
TranslationObject["board"]["field"][TKey]["option"];
const useBackgroundOptionData = < const useBackgroundOptionData = <
TKey extends BackgroundImageKey, TKey extends BackgroundImageKey,
@@ -105,7 +119,9 @@ const useBackgroundOptionData = <
(value) => (value) =>
({ ({
label: t(`board.field.${key}.option.${value as string}.label` as never), label: t(`board.field.${key}.option.${value as string}.label` as never),
description: t(`board.field.${key}.option.${value as string}.description` as never), description: t(
`board.field.${key}.option.${value as string}.description` as never,
),
value: value as string, value: value as string,
badge: badge:
data.defaultValue === value data.defaultValue === value
@@ -17,9 +17,8 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { useZodForm } from "@homarr/form"; import { useForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation";
import type { Board } from "../../_types"; import type { Board } from "../../_types";
import { generateColors } from "../../(content)/_theme"; import { generateColors } from "../../(content)/_theme";
@@ -34,7 +33,7 @@ const hexRegex = /^#[0-9a-fA-F]{6}$/;
const progressPercentageLabel = (value: number) => `${value}%`; const progressPercentageLabel = (value: number) => `${value}%`;
export const ColorSettingsContent = ({ board }: Props) => { export const ColorSettingsContent = ({ board }: Props) => {
const form = useZodForm(validation.board.savePartialSettings, { const form = useForm({
initialValues: { initialValues: {
primaryColor: board.primaryColor, primaryColor: board.primaryColor,
secondaryColor: board.secondaryColor, secondaryColor: board.secondaryColor,
@@ -44,7 +43,8 @@ export const ColorSettingsContent = ({ board }: Props) => {
const [showPreview, { toggle }] = useDisclosure(false); const [showPreview, { toggle }] = useDisclosure(false);
const t = useI18n(); const t = useI18n();
const theme = useMantineTheme(); const theme = useMantineTheme();
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board); const { mutate: savePartialSettings, isPending } =
useSavePartialSettingsMutation(board);
return ( return (
<form <form
@@ -76,7 +76,11 @@ export const ColorSettingsContent = ({ board }: Props) => {
/> />
</Grid.Col> </Grid.Col>
<Grid.Col span={12}> <Grid.Col span={12}>
<Anchor onClick={toggle}>{showPreview ? t("common.preview.hide") : t("common.preview.show")}</Anchor> <Anchor onClick={toggle}>
{showPreview
? t("common.preview.hide")
: t("common.preview.show")}
</Anchor>
</Grid.Col> </Grid.Col>
<Grid.Col span={12}> <Grid.Col span={12}>
<Collapse in={showPreview}> <Collapse in={showPreview}>
@@ -110,13 +114,15 @@ export const ColorSettingsContent = ({ board }: Props) => {
}; };
interface ColorsPreviewProps { interface ColorsPreviewProps {
previewColor: string | undefined; previewColor: string;
} }
const ColorsPreview = ({ previewColor }: ColorsPreviewProps) => { const ColorsPreview = ({ previewColor }: ColorsPreviewProps) => {
const theme = useMantineTheme(); const theme = useMantineTheme();
const colors = previewColor && hexRegex.test(previewColor) ? generateColors(previewColor) : generateColors("#000000"); const colors = hexRegex.test(previewColor)
? generateColors(previewColor)
: generateColors("#000000");
return ( return (
<Group gap={0} wrap="nowrap"> <Group gap={0} wrap="nowrap">
@@ -1,91 +1,7 @@
"use client"; "use client";
import { Alert, Button, Group, Input, Stack } from "@mantine/core"; // TODO: add some sort of store (maybe directory on GitHub)
import { highlight, languages } from "prismjs";
import Editor from "react-simple-code-editor";
import "~/styles/prismjs.scss"; export const CustomCssSettingsContent = () => {
return null;
import { IconInfoCircle } from "@tabler/icons-react";
import { useForm } from "@homarr/form";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { Board } from "../../_types";
import { useSavePartialSettingsMutation } from "./_shared";
import classes from "./customcss.module.css";
interface Props {
board: Board;
}
export const CustomCssSettingsContent = ({ board }: Props) => {
const t = useI18n();
const customCssT = useScopedI18n("board.field.customCss");
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
const form = useForm({
initialValues: {
customCss: board.customCss ?? "",
},
});
return (
<form
onSubmit={form.onSubmit((values) => {
savePartialSettings({
id: board.id,
...values,
});
})}
>
<Stack>
<CustomCssInput {...form.getInputProps("customCss")} />
<Alert variant="light" color="cyan" title={customCssT("customClassesAlert.title")} icon={<IconInfoCircle />}>
{customCssT("customClassesAlert.description")}
</Alert>
<Group justify="end">
<Button type="submit" loading={isPending} color="teal">
{t("common.action.saveChanges")}
</Button>
</Group>
</Stack>
</form>
);
};
interface CustomCssInputProps {
value?: string;
onChange: (value: string) => void;
}
const CustomCssInput = ({ value, onChange }: CustomCssInputProps) => {
const customCssT = useScopedI18n("board.field.customCss");
return (
<Input.Wrapper
label={customCssT("label")}
labelProps={{
htmlFor: "custom-css",
}}
description={customCssT("description")}
inputWrapperOrder={["label", "description", "input", "error"]}
>
<div className={classes.codeEditorRoot}>
<Editor
textareaId="custom-css"
onValueChange={onChange}
value={value ?? ""}
highlight={(code) => highlight(code, languages.extend("css", {}), "css")}
padding={10}
style={{
fontFamily: '"Fira code", "Fira Mono", monospace',
fontSize: 12,
minHeight: 250,
}}
/>
</div>
</Input.Wrapper>
);
}; };
@@ -20,7 +20,8 @@ export const DangerZoneSettingsContent = () => {
const { openModal } = useModalAction(BoardRenameModal); const { openModal } = useModalAction(BoardRenameModal);
const { mutate: changeVisibility, isPending: isChangeVisibilityPending } = const { mutate: changeVisibility, isPending: isChangeVisibilityPending } =
clientApi.board.changeBoardVisibility.useMutation(); clientApi.board.changeBoardVisibility.useMutation();
const { mutate: deleteBoard, isPending: isDeletePending } = clientApi.board.deleteBoard.useMutation(); const { mutate: deleteBoard, isPending: isDeletePending } =
clientApi.board.deleteBoard.useMutation();
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
const visibility = board.isPublic ? "public" : "private"; const visibility = board.isPublic ? "public" : "private";
@@ -36,8 +37,12 @@ export const DangerZoneSettingsContent = () => {
const onVisibilityClick = useCallback(() => { const onVisibilityClick = useCallback(() => {
openConfirmModal({ openConfirmModal({
title: t(`section.dangerZone.action.visibility.confirm.${visibility}.title`), title: t(
children: t(`section.dangerZone.action.visibility.confirm.${visibility}.description`), `section.dangerZone.action.visibility.confirm.${visibility}.title`,
),
children: t(
`section.dangerZone.action.visibility.confirm.${visibility}.description`,
),
onConfirm: () => { onConfirm: () => {
changeVisibility( changeVisibility(
{ {
@@ -47,7 +52,7 @@ export const DangerZoneSettingsContent = () => {
{ {
onSettled() { onSettled() {
void utils.board.getBoardByName.invalidate({ name: board.name }); void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.board.getHomeBoard.invalidate(); void utils.board.getDefaultBoard.invalidate();
}, },
}, },
); );
@@ -59,7 +64,7 @@ export const DangerZoneSettingsContent = () => {
changeVisibility, changeVisibility,
t, t,
utils.board.getBoardByName, utils.board.getBoardByName,
utils.board.getHomeBoard, utils.board.getDefaultBoard,
visibility, visibility,
openConfirmModal, openConfirmModal,
]); ]);
@@ -93,8 +98,12 @@ export const DangerZoneSettingsContent = () => {
<Divider /> <Divider />
<DangerZoneRow <DangerZoneRow
label={t("section.dangerZone.action.visibility.label")} label={t("section.dangerZone.action.visibility.label")}
description={t(`section.dangerZone.action.visibility.description.${visibility}`)} description={t(
buttonText={t(`section.dangerZone.action.visibility.button.${visibility}`)} `section.dangerZone.action.visibility.description.${visibility}`,
)}
buttonText={t(
`section.dangerZone.action.visibility.button.${visibility}`,
)}
onClick={onVisibilityClick} onClick={onVisibilityClick}
isPending={isChangeVisibilityPending} isPending={isChangeVisibilityPending}
/> />
@@ -118,7 +127,13 @@ interface DangerZoneRowProps {
onClick: () => void; onClick: () => void;
} }
const DangerZoneRow = ({ label, description, buttonText, onClick, isPending }: DangerZoneRowProps) => { const DangerZoneRow = ({
label,
description,
buttonText,
onClick,
isPending,
}: DangerZoneRowProps) => {
return ( return (
<Group justify="space-between" px="md" className={classes.dangerZoneGroup}> <Group justify="space-between" px="md" className={classes.dangerZoneGroup}>
<Stack gap={0}> <Stack gap={0}>
@@ -128,7 +143,12 @@ const DangerZoneRow = ({ label, description, buttonText, onClick, isPending }: D
<Text size="sm">{description}</Text> <Text size="sm">{description}</Text>
</Stack> </Stack>
<Group justify="end" w={{ base: "100%", xs: "auto" }}> <Group justify="end" w={{ base: "100%", xs: "auto" }}>
<Button variant="subtle" color="red" loading={isPending} onClick={onClick}> <Button
variant="subtle"
color="red"
loading={isPending}
onClick={onClick}
>
{buttonText} {buttonText}
</Button> </Button>
</Group> </Group>
@@ -1,15 +1,25 @@
"use client"; "use client";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { Button, Grid, Group, Loader, Stack, TextInput, Tooltip } from "@mantine/core"; import {
import { useDebouncedValue, useDocumentTitle, useFavicon } from "@mantine/hooks"; Button,
Grid,
Group,
Loader,
Stack,
TextInput,
Tooltip,
} from "@mantine/core";
import {
useDebouncedValue,
useDocumentTitle,
useFavicon,
} from "@mantine/hooks";
import { IconAlertTriangle } from "@tabler/icons-react"; import { IconAlertTriangle } from "@tabler/icons-react";
import { useZodForm } from "@homarr/form"; import { useForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation";
import { createMetaTitle } from "~/metadata";
import type { Board } from "../../_types"; import type { Board } from "../../_types";
import { useUpdateBoard } from "../../(content)/_client"; import { useUpdateBoard } from "../../(content)/_client";
import { useSavePartialSettingsMutation } from "./_shared"; import { useSavePartialSettingsMutation } from "./_shared";
@@ -26,31 +36,22 @@ export const GeneralSettingsContent = ({ board }: Props) => {
}); });
const { updateBoard } = useUpdateBoard(); const { updateBoard } = useUpdateBoard();
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board); const { mutate: savePartialSettings, isPending } =
const form = useZodForm( useSavePartialSettingsMutation(board);
validation.board.savePartialSettings const form = useForm({
.pick({ initialValues: {
pageTitle: true, pageTitle: board.pageTitle ?? "",
logoImageUrl: true, logoImageUrl: board.logoImageUrl ?? "",
metaTitle: true, metaTitle: board.metaTitle ?? "",
faviconImageUrl: true, faviconImageUrl: board.faviconImageUrl ?? "",
})
.required(),
{
initialValues: {
pageTitle: board.pageTitle ?? "",
logoImageUrl: board.logoImageUrl ?? "",
metaTitle: board.metaTitle ?? "",
faviconImageUrl: board.faviconImageUrl ?? "",
},
onValuesChange({ pageTitle }) {
updateBoard((previous) => ({
...previous,
pageTitle,
}));
},
}, },
); onValuesChange({ pageTitle }) {
updateBoard((previous) => ({
...previous,
pageTitle,
}));
},
});
const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle); const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle);
const faviconStatus = useFaviconPreview(form.values.faviconImageUrl); const faviconStatus = useFaviconPreview(form.values.faviconImageUrl);
@@ -93,7 +94,7 @@ export const GeneralSettingsContent = ({ board }: Props) => {
<Grid.Col span={{ xs: 12, md: 6 }}> <Grid.Col span={{ xs: 12, md: 6 }}>
<TextInput <TextInput
label={t("board.field.metaTitle.label")} label={t("board.field.metaTitle.label")}
placeholder={createMetaTitle(t("board.content.metaTitle", { boardName: board.name }))} placeholder="Default Board | Homarr"
rightSection={<PendingOrInvalidIndicator {...metaTitleStatus} />} rightSection={<PendingOrInvalidIndicator {...metaTitleStatus} />}
{...form.getInputProps("metaTitle")} {...form.getInputProps("metaTitle")}
/> />
@@ -125,12 +126,22 @@ export const GeneralSettingsContent = ({ board }: Props) => {
); );
}; };
const PendingOrInvalidIndicator = ({ isPending, isInvalid }: { isPending: boolean; isInvalid?: boolean }) => { const PendingOrInvalidIndicator = ({
isPending,
isInvalid,
}: {
isPending: boolean;
isInvalid?: boolean;
}) => {
const t = useI18n(); const t = useI18n();
if (isInvalid) { if (isInvalid) {
return ( return (
<Tooltip multiline w={220} label={t("board.setting.section.general.unrecognizedLink")}> <Tooltip
multiline
w={220}
label={t("board.setting.section.general.unrecognizedLink")}
>
<IconAlertTriangle size="1rem" color="red" /> <IconAlertTriangle size="1rem" color="red" />
</Tooltip> </Tooltip>
); );
@@ -172,7 +183,8 @@ const useMetaTitlePreview = (title: string | null) => {
const validFaviconExtensions = ["ico", "png", "svg", "gif"]; const validFaviconExtensions = ["ico", "png", "svg", "gif"];
const isValidUrl = (url: string) => const isValidUrl = (url: string) =>
url.includes("/") && validFaviconExtensions.some((extension) => url.endsWith(`.${extension}`)); url.includes("/") &&
validFaviconExtensions.some((extension) => url.endsWith(`.${extension}`));
const useFaviconPreview = (url: string | null) => { const useFaviconPreview = (url: string | null) => {
const [faviconDebounced] = useDebouncedValue(url ?? "", 500); const [faviconDebounced] = useDebouncedValue(url ?? "", 500);
@@ -2,9 +2,8 @@
import { Button, Grid, Group, Input, Slider, Stack } from "@mantine/core"; import { Button, Grid, Group, Input, Slider, Stack } from "@mantine/core";
import { useZodForm } from "@homarr/form"; import { useForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation";
import type { Board } from "../../_types"; import type { Board } from "../../_types";
import { useSavePartialSettingsMutation } from "./_shared"; import { useSavePartialSettingsMutation } from "./_shared";
@@ -14,8 +13,9 @@ interface Props {
} }
export const LayoutSettingsContent = ({ board }: Props) => { export const LayoutSettingsContent = ({ board }: Props) => {
const t = useI18n(); const t = useI18n();
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board); const { mutate: savePartialSettings, isPending } =
const form = useZodForm(validation.board.savePartialSettings.pick({ columnCount: true }).required(), { useSavePartialSettingsMutation(board);
const form = useForm({
initialValues: { initialValues: {
columnCount: board.columnCount, columnCount: board.columnCount,
}, },
@@ -34,7 +34,13 @@ export const LayoutSettingsContent = ({ board }: Props) => {
<Grid> <Grid>
<Grid.Col span={{ sm: 12, md: 6 }}> <Grid.Col span={{ sm: 12, md: 6 }}>
<Input.Wrapper label={t("board.field.columnCount.label")}> <Input.Wrapper label={t("board.field.columnCount.label")}>
<Slider mt="xs" min={1} max={24} step={1} {...form.getInputProps("columnCount")} /> <Slider
mt="xs"
min={1}
max={24}
step={1}
{...form.getInputProps("columnCount")}
/>
</Input.Wrapper> </Input.Wrapper>
</Grid.Col> </Grid.Col>
</Grid> </Grid>
@@ -7,7 +7,7 @@ export const useSavePartialSettingsMutation = (board: Board) => {
return clientApi.board.savePartialBoardSettings.useMutation({ return clientApi.board.savePartialBoardSettings.useMutation({
onSettled() { onSettled() {
void utils.board.getBoardByName.invalidate({ name: board.name }); void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.board.getHomeBoard.invalidate(); void utils.board.getDefaultBoard.invalidate();
}, },
}); });
}; };
@@ -1,22 +0,0 @@
.codeEditorFooter {
border-bottom-left-radius: var(--mantine-radius-sm);
border-bottom-right-radius: var(--mantine-radius-sm);
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-7));
}
.codeEditorRoot {
margin-top: 4px;
border-color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4));
border-width: 1px;
border-style: solid;
border-radius: var(--mantine-radius-sm);
}
.codeEditor {
background-color: light-dark(white, var(--mantine-color-dark-6));
font-size: var(--mantine-font-size-xs);
}
.codeEditor ::placeholder {
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
}
@@ -1,6 +1,14 @@
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { AccordionControl, AccordionItem, AccordionPanel, Container, Stack, Text, Title } from "@mantine/core"; import {
AccordionControl,
AccordionItem,
AccordionPanel,
Container,
Stack,
Text,
Title,
} from "@mantine/core";
import { import {
IconAlertTriangle, IconAlertTriangle,
IconBrush, IconBrush,
@@ -18,7 +26,7 @@ import type { TranslationObject } from "@homarr/translation";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import type { TablerIcon } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui";
import { getBoardPermissionsAsync } from "~/components/board/permissions/server"; import { getBoardPermissions } from "~/components/board/permissions/server";
import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion"; import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion";
import { AccessSettingsContent } from "./_access"; import { AccessSettingsContent } from "./_access";
import { BackgroundSettingsContent } from "./_background"; import { BackgroundSettingsContent } from "./_background";
@@ -37,10 +45,10 @@ interface Props {
}; };
} }
const getBoardAndPermissionsAsync = async (params: Props["params"]) => { const getBoardAndPermissions = async (params: Props["params"]) => {
try { try {
const board = await api.board.getBoardByName({ name: params.name }); const board = await api.board.getBoardByName({ name: params.name });
const { hasFullAccess } = await getBoardPermissionsAsync(board); const { hasFullAccess } = await getBoardPermissions(board);
const permissions = hasFullAccess const permissions = hasFullAccess
? await api.board.getBoardPermissions({ id: board.id }) ? await api.board.getBoardPermissions({ id: board.id })
: { : {
@@ -61,16 +69,22 @@ const getBoardAndPermissionsAsync = async (params: Props["params"]) => {
} }
}; };
export default async function BoardSettingsPage({ params, searchParams }: Props) { export default async function BoardSettingsPage({
const { board, permissions } = await getBoardAndPermissionsAsync(params); params,
const { hasFullAccess } = await getBoardPermissionsAsync(board); searchParams,
}: Props) {
const { board, permissions } = await getBoardAndPermissions(params);
const { hasFullAccess } = await getBoardPermissions(board);
const t = await getScopedI18n("board.setting"); const t = await getScopedI18n("board.setting");
return ( return (
<Container> <Container>
<Stack> <Stack>
<Title>{t("title", { boardName: capitalize(board.name) })}</Title> <Title>{t("title", { boardName: capitalize(board.name) })}</Title>
<ActiveTabAccordion variant="separated" defaultValue={searchParams.tab ?? "general"}> <ActiveTabAccordion
variant="separated"
defaultValue={searchParams.tab ?? "general"}
>
<AccordionItemFor value="general" icon={IconSettings}> <AccordionItemFor value="general" icon={IconSettings}>
<GeneralSettingsContent board={board} /> <GeneralSettingsContent board={board} />
</AccordionItemFor> </AccordionItemFor>
@@ -84,14 +98,22 @@ export default async function BoardSettingsPage({ params, searchParams }: Props)
<ColorSettingsContent board={board} /> <ColorSettingsContent board={board} />
</AccordionItemFor> </AccordionItemFor>
<AccordionItemFor value="customCss" icon={IconFileTypeCss}> <AccordionItemFor value="customCss" icon={IconFileTypeCss}>
<CustomCssSettingsContent board={board} /> <CustomCssSettingsContent />
</AccordionItemFor> </AccordionItemFor>
{hasFullAccess && ( {hasFullAccess && (
<> <>
<AccordionItemFor value="access" icon={IconUser}> <AccordionItemFor value="access" icon={IconUser}>
<AccessSettingsContent board={board} initialPermissions={permissions} /> <AccessSettingsContent
board={board}
initialPermissions={permissions}
/>
</AccordionItemFor> </AccordionItemFor>
<AccordionItemFor value="dangerZone" icon={IconAlertTriangle} danger noPadding> <AccordionItemFor
value="dangerZone"
icon={IconAlertTriangle}
danger
noPadding
>
<DangerZoneSettingsContent /> <DangerZoneSettingsContent />
</AccordionItemFor> </AccordionItemFor>
</> </>
@@ -109,7 +131,13 @@ type AccordionItemForProps = PropsWithChildren<{
noPadding?: boolean; noPadding?: boolean;
}>; }>;
const AccordionItemFor = async ({ value, children, icon: Icon, danger, noPadding }: AccordionItemForProps) => { const AccordionItemFor = async ({
value,
children,
icon: Icon,
danger,
noPadding,
}: AccordionItemForProps) => {
const t = await getScopedI18n("board.setting.section"); const t = await getScopedI18n("board.setting.section");
return ( return (
<AccordionItem <AccordionItem
@@ -130,7 +158,13 @@ const AccordionItemFor = async ({ value, children, icon: Icon, danger, noPadding
{t(`${value}.title`)} {t(`${value}.title`)}
</Text> </Text>
</AccordionControl> </AccordionControl>
<AccordionPanel styles={noPadding ? { content: { paddingRight: 0, paddingLeft: 0 } } : undefined}> <AccordionPanel
styles={
noPadding
? { content: { paddingRight: 0, paddingLeft: 0 } }
: undefined
}
>
{children} {children}
</AccordionPanel> </AccordionPanel>
</AccordionItem> </AccordionItem>
@@ -12,18 +12,17 @@ import { ClientShell } from "~/components/layout/shell";
import type { Board } from "./_types"; import type { Board } from "./_types";
import { BoardProvider } from "./(content)/_context"; import { BoardProvider } from "./(content)/_context";
import type { Params } from "./(content)/_creator"; import type { Params } from "./(content)/_creator";
import { CustomCss } from "./(content)/_custom-css";
import { BoardMantineProvider } from "./(content)/_theme"; import { BoardMantineProvider } from "./(content)/_theme";
interface CreateBoardLayoutProps<TParams extends Params> { interface CreateBoardLayoutProps<TParams extends Params> {
headerActions: JSX.Element; headerActions: JSX.Element;
getInitialBoardAsync: (params: TParams) => Promise<Board>; getInitialBoard: (params: TParams) => Promise<Board>;
isBoardContentPage: boolean; isBoardContentPage: boolean;
} }
export const createBoardLayout = <TParams extends Params>({ export const createBoardLayout = <TParams extends Params>({
headerActions, headerActions,
getInitialBoardAsync: getInitialBoard, getInitialBoard,
isBoardContentPage, isBoardContentPage,
}: CreateBoardLayoutProps<TParams>) => { }: CreateBoardLayoutProps<TParams>) => {
const Layout = async ({ const Layout = async ({
@@ -42,10 +41,12 @@ export const createBoardLayout = <TParams extends Params>({
}); });
return ( return (
<GlobalItemServerDataRunner board={initialBoard} shouldRun={isBoardContentPage}> <GlobalItemServerDataRunner
board={initialBoard}
shouldRun={isBoardContentPage}
>
<BoardProvider initialBoard={initialBoard}> <BoardProvider initialBoard={initialBoard}>
<BoardMantineProvider> <BoardMantineProvider>
<CustomCss />
<ClientShell hasNavigation={false}> <ClientShell hasNavigation={false}>
<MainHeader <MainHeader
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />} logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
@@ -1,11 +1,14 @@
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import type { WidgetKind } from "@homarr/definitions"; import type { WidgetKind } from "@homarr/definitions";
export type Board = RouterOutputs["board"]["getHomeBoard"]; export type Board = RouterOutputs["board"]["getDefaultBoard"];
export type Section = Board["sections"][number]; export type Section = Board["sections"][number];
export type Item = Section["items"][number]; export type Item = Section["items"][number];
export type CategorySection = Extract<Section, { kind: "category" }>; export type CategorySection = Extract<Section, { kind: "category" }>;
export type EmptySection = Extract<Section, { kind: "empty" }>; export type EmptySection = Extract<Section, { kind: "empty" }>;
export type ItemOfKind<TKind extends WidgetKind> = Extract<Item, { kind: TKind }>; export type ItemOfKind<TKind extends WidgetKind> = Extract<
Item,
{ kind: TKind }
>;
+10 -8
View File
@@ -5,12 +5,14 @@ type PropsWithChildren = Required<React.PropsWithChildren>;
export const composeWrappers = ( export const composeWrappers = (
wrappers: React.FunctionComponent<PropsWithChildren>[], wrappers: React.FunctionComponent<PropsWithChildren>[],
): React.FunctionComponent<PropsWithChildren> => { ): React.FunctionComponent<PropsWithChildren> => {
return wrappers.reverse().reduce((Acc, Current): React.FunctionComponent<PropsWithChildren> => { return wrappers
// eslint-disable-next-line react/display-name .reverse()
return (props) => ( .reduce((Acc, Current): React.FunctionComponent<PropsWithChildren> => {
<Current> // eslint-disable-next-line react/display-name
<Acc {...props} /> return (props) => (
</Current> <Current>
); <Acc {...props} />
}); </Current>
);
});
}; };
@@ -4,8 +4,11 @@ import { useRouter } from "next/navigation";
import { Button, PasswordInput, Stack, TextInput } from "@mantine/core"; import { Button, PasswordInput, Stack, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useZodForm } from "@homarr/form"; import { useForm, zodResolver } 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";
import type { z } from "@homarr/validation"; import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation"; import { validation } from "@homarr/validation";
@@ -13,8 +16,12 @@ import { validation } from "@homarr/validation";
export const InitUserForm = () => { export const InitUserForm = () => {
const router = useRouter(); const router = useRouter();
const t = useScopedI18n("user"); const t = useScopedI18n("user");
const { mutateAsync, error, isPending } = clientApi.user.initUser.useMutation(); const { mutateAsync, error, isPending } =
const form = useZodForm(validation.user.init, { clientApi.user.initUser.useMutation();
const form = useForm<FormType>({
validate: zodResolver(validation.user.init),
validateInputOnBlur: true,
validateInputOnChange: true,
initialValues: { initialValues: {
username: "", username: "",
password: "", password: "",
@@ -22,7 +29,7 @@ export const InitUserForm = () => {
}, },
}); });
const handleSubmitAsync = async (values: FormType) => { const handleSubmit = async (values: FormType) => {
await mutateAsync(values, { await mutateAsync(values, {
onSuccess: () => { onSuccess: () => {
showSuccessNotification({ showSuccessNotification({
@@ -44,14 +51,23 @@ export const InitUserForm = () => {
<Stack gap="xl"> <Stack gap="xl">
<form <form
onSubmit={form.onSubmit( onSubmit={form.onSubmit(
(values) => void handleSubmitAsync(values), (values) => void handleSubmit(values),
(err) => console.log(err), (err) => console.log(err),
)} )}
> >
<Stack gap="lg"> <Stack gap="lg">
<TextInput label={t("field.username.label")} {...form.getInputProps("username")} /> <TextInput
<PasswordInput label={t("field.password.label")} {...form.getInputProps("password")} /> label={t("field.username.label")}
<PasswordInput label={t("field.passwordConfirm.label")} {...form.getInputProps("confirmPassword")} /> {...form.getInputProps("username")}
/>
<PasswordInput
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>
+8 -5
View File
@@ -1,9 +1,9 @@
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 "@homarr/ui/styles.css";
import "@homarr/notifications/styles.css"; import "@homarr/notifications/styles.css";
import "@homarr/spotlight/styles.css"; import "@homarr/spotlight/styles.css";
import "@mantine/core/styles.css";
import { ColorSchemeScript, createTheme, MantineProvider } from "@mantine/core"; import { ColorSchemeScript, createTheme, MantineProvider } from "@mantine/core";
@@ -11,7 +11,6 @@ import { auth } from "@homarr/auth/next";
import { ModalProvider } from "@homarr/modals"; import { ModalProvider } from "@homarr/modals";
import { Notifications } from "@homarr/notifications"; import { Notifications } from "@homarr/notifications";
import { Analytics } from "~/components/layout/analytics";
import { JotaiProvider } from "./_client-providers/jotai"; import { JotaiProvider } from "./_client-providers/jotai";
import { NextInternationalProvider } from "./_client-providers/next-international"; import { NextInternationalProvider } from "./_client-providers/next-international";
import { AuthProvider } from "./_client-providers/session"; import { AuthProvider } from "./_client-providers/session";
@@ -49,7 +48,10 @@ export const viewport: Viewport = {
], ],
}; };
export default function Layout(props: { children: React.ReactNode; params: { locale: string } }) { export default function Layout(props: {
children: React.ReactNode;
params: { locale: string };
}) {
const colorScheme = "dark"; const colorScheme = "dark";
const StackedProvider = composeWrappers([ const StackedProvider = composeWrappers([
@@ -59,7 +61,9 @@ export default function Layout(props: { children: React.ReactNode; params: { loc
}, },
(innerProps) => <JotaiProvider {...innerProps} />, (innerProps) => <JotaiProvider {...innerProps} />,
(innerProps) => <TRPCReactProvider {...innerProps} />, (innerProps) => <TRPCReactProvider {...innerProps} />,
(innerProps) => <NextInternationalProvider {...innerProps} locale={props.params.locale} />, (innerProps) => (
<NextInternationalProvider {...innerProps} locale={props.params.locale} />
),
(innerProps) => ( (innerProps) => (
<MantineProvider <MantineProvider
{...innerProps} {...innerProps}
@@ -77,7 +81,6 @@ export default function Layout(props: { children: React.ReactNode; params: { loc
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<head> <head>
<ColorSchemeScript defaultColorScheme={colorScheme} /> <ColorSchemeScript defaultColorScheme={colorScheme} />
<Analytics />
</head> </head>
<body className={["font-sans", fontSans.variable].join(" ")}> <body className={["font-sans", fontSans.variable].join(" ")}>
<StackedProvider> <StackedProvider>
@@ -1,5 +1,17 @@
import { notFound } from "next/navigation"; import { Center, Stack, Text, Title } from "@mantine/core";
export default function NotFound() { import { getScopedI18n } from "@homarr/translation/server";
return notFound();
export default async function NotFound() {
const t = await getScopedI18n("management.notFound");
return (
<Center h="100%">
<Stack align="center">
<Title order={1} tt="uppercase">
{t("title")}
</Title>
<Text>{t("text")}</Text>
</Stack>
</Center>
);
} }
@@ -1,37 +0,0 @@
.bannerContainer {
padding: 3rem;
border-radius: 8px;
overflow: hidden;
background: linear-gradient(
130deg,
#fa52521f 0%,
var(--mantine-color-dark-6) 35%,
var(--mantine-color-dark-6) 100%
) !important;
}
.scrollContainer {
height: 100%;
transform: rotateZ(10deg);
}
@keyframes scrolling {
0% {
transform: translateY(0);
}
100% {
transform: translateY(-50%);
}
}
.scrollAnimationContainer {
animation: scrolling;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@media (prefers-reduced-motion: reduce) {
.scrollAnimationContainer {
animation: none !important;
}
}
@@ -1,78 +0,0 @@
import { Box, Grid, GridCol, Group, Image, Stack, Title } from "@mantine/core";
import { splitToNChunks } from "@homarr/common";
import classes from "./hero-banner.module.css";
const icons = [
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/homarr.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/sabnzbd.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/deluge.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/radarr.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/sonarr.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/lidarr.svg",
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/pihole.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/dashdot.png",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/overseerr.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/plex.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/jellyfin.svg",
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/homeassistant.svg",
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/freshrss.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/readarr.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/transmission.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/qbittorrent.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/nzbget.png",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/openmediavault.svg",
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/docker.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/jellyseerr.svg",
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/adguardhome.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/tdarr.png",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/prowlarr.svg",
];
const countIconGroups = 3;
const animationDurationInSeconds = 12;
export const HeroBanner = () => {
const arrayInChunks = splitToNChunks(icons, countIconGroups);
const gridSpan = 12 / countIconGroups;
return (
<Box className={classes.bannerContainer} bg="dark.6" pos="relative">
<Stack gap={0}>
<Title order={2} c="dimmed">
Welcome back to your
</Title>
<Group gap="xs">
<Image src="/logo/logo.png" w={40} h={40} />
<Title>Homarr Dashboard</Title>
</Group>
</Stack>
<Box className={classes.scrollContainer} w={"30%"} top={0} right={0} pos="absolute">
<Grid>
{Array(countIconGroups)
.fill(0)
.map((_, columnIndex) => (
<GridCol key={`grid-column-${columnIndex}`} span={gridSpan}>
<Stack
className={classes.scrollAnimationContainer}
style={{
animationDuration: `${animationDurationInSeconds - columnIndex}s`,
}}
>
{arrayInChunks[columnIndex]?.map((icon, index) => (
<Image key={`grid-column-${columnIndex}-scroll-1-${index}`} src={icon} radius="md" w={50} h={50} />
))}
{/* This is used for making the animation seem seamless */}
{arrayInChunks[columnIndex]?.map((icon, index) => (
<Image key={`grid-column-${columnIndex}-scroll-2-${index}`} src={icon} radius="md" w={50} h={50} />
))}
</Stack>
</GridCol>
))}
</Grid>
</Box>
</Box>
);
};
@@ -1,3 +0,0 @@
.contributorCard {
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
}
@@ -0,0 +1,36 @@
.root {
border-radius: var(--mantine-radius-sm);
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-6)
);
}
.item {
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-6)
);
border: 1px solid transparent;
position: relative;
z-index: 0;
transition: transform 150ms ease;
&[data-active] {
transform: scale(1.015);
z-index: 1;
background-color: var(--mantine-color-body);
border-color: light-dark(
var(--mantine-color-gray-2),
var(--mantine-color-dark-4)
);
box-shadow: var(--mantine-shadow-md);
border-radius: var(--mantine-radius-md);
}
}
.chevron {
&[data-rotate] {
transform: rotate(-90deg);
}
}
@@ -4,11 +4,7 @@ import {
AccordionControl, AccordionControl,
AccordionItem, AccordionItem,
AccordionPanel, AccordionPanel,
AspectRatio,
Avatar,
Card,
Center, Center,
Flex,
Group, Group,
List, List,
ListItem, ListItem,
@@ -21,18 +17,16 @@ import { setStaticParamsLocale } from "next-international/server";
import { getScopedI18n, getStaticParams } from "@homarr/translation/server"; import { getScopedI18n, getStaticParams } from "@homarr/translation/server";
import { createMetaTitle } from "~/metadata";
import { getPackageAttributesAsync } from "~/versions/package-reader"; import { getPackageAttributesAsync } from "~/versions/package-reader";
import contributorsData from "../../../../../../../static-data/contributors.json";
import translatorsData from "../../../../../../../static-data/translators.json";
import logo from "../../../../../public/logo/logo.png"; import logo from "../../../../../public/logo/logo.png";
import classes from "./about.module.css"; import classes from "./accordion.module.css";
export async function generateMetadata() { export async function generateMetadata() {
const t = await getScopedI18n("management"); const t = await getScopedI18n("management");
const metaTitle = `${t("metaTitle")} • Homarr`;
return { return {
title: createMetaTitle(t("metaTitle")), title: metaTitle,
}; };
} }
@@ -55,60 +49,26 @@ export default async function AboutPage({ params: { locale } }: PageProps) {
<Title order={1} tt="uppercase"> <Title order={1} tt="uppercase">
Homarr Homarr
</Title> </Title>
<Title order={2}>{t("version", { version: attributes.version })}</Title> <Title order={2}>
{t("version", { version: attributes.version })}
</Title>
</Stack> </Stack>
</Group> </Group>
</Center> </Center>
<Text mb="xl">{t("text")}</Text> <Text mb="xl">{t("text")}</Text>
<Accordion defaultValue="contributors" variant="filled" radius="md"> <Accordion classNames={classes}>
<AccordionItem value="contributors"> <AccordionItem value="contributors">
<AccordionControl icon={<IconUsers size="1rem" />}> <AccordionControl icon={<IconUsers size="1rem" />}>
<Stack gap={0}> {t("accordion.contributors.title")}
<Text>{t("accordion.contributors.title")}</Text>
<Text size="sm" c="dimmed">
{t("accordion.contributors.subtitle", {
count: contributorsData.length,
})}
</Text>
</Stack>
</AccordionControl> </AccordionControl>
<AccordionPanel> <AccordionPanel></AccordionPanel>
<Flex wrap="wrap" gap="xs">
{contributorsData.map((contributor) => (
<GenericContributorLinkCard
key={contributor.login}
link={`https://github.com/${contributor.login}`}
image={contributor.avatar_url}
name={contributor.login}
/>
))}
</Flex>
</AccordionPanel>
</AccordionItem> </AccordionItem>
<AccordionItem value="translators"> <AccordionItem value="translators">
<AccordionControl icon={<IconLanguage size="1rem" />}> <AccordionControl icon={<IconLanguage size="1rem" />}>
<Stack gap={0}> {t("accordion.translators.title")}
<Text>{t("accordion.translators.title")}</Text>
<Text size="sm" c="dimmed">
{t("accordion.translators.subtitle", {
count: translatorsData.length,
})}
</Text>
</Stack>
</AccordionControl> </AccordionControl>
<AccordionPanel> <AccordionPanel></AccordionPanel>
<Flex wrap="wrap" gap="xs">
{translatorsData.map((translator) => (
<GenericContributorLinkCard
key={translator.username}
link={`https://crowdin.com/profile/${translator.username}`}
image={translator.avatarUrl}
name={translator.username}
/>
))}
</Flex>
</AccordionPanel>
</AccordionItem> </AccordionItem>
<AccordionItem value="libraries"> <AccordionItem value="libraries">
<AccordionControl icon={<IconLibrary size="1rem" />}> <AccordionControl icon={<IconLibrary size="1rem" />}>
@@ -142,27 +102,6 @@ export default async function AboutPage({ params: { locale } }: PageProps) {
); );
} }
interface GenericContributorLinkCardProps {
name: string;
link: string;
image: string;
}
const GenericContributorLinkCard = ({ name, image, link }: GenericContributorLinkCardProps) => {
return (
<AspectRatio ratio={1}>
<Card className={classes.contributorCard} component="a" href={link} target="_blank" w={100}>
<Stack align="center">
<Avatar src={image} alt={name} size={40} display="block" />
<Text lineClamp={1} size="sm">
{name}
</Text>
</Stack>
</Card>
</AspectRatio>
);
};
export function generateStaticParams() { export function generateStaticParams() {
return getStaticParams(); return getStaticParams();
} }
@@ -7,10 +7,13 @@ import { IconTrash } from "@tabler/icons-react";
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 } from "@homarr/modals";
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 { revalidatePathActionAsync } from "../../../revalidatePathAction"; import { revalidatePathAction } from "../../../revalidatePathAction";
interface AppDeleteButtonProps { interface AppDeleteButtonProps {
app: RouterOutputs["app"]["all"][number]; app: RouterOutputs["app"]["all"][number];
@@ -34,7 +37,7 @@ export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
title: t("notification.success.title"), title: t("notification.success.title"),
message: t("notification.success.message"), message: t("notification.success.message"),
}); });
void revalidatePathActionAsync("/manage/apps"); void revalidatePathAction("/manage/apps");
}, },
onError: () => { onError: () => {
showErrorNotification({ showErrorNotification({
@@ -49,7 +52,13 @@ export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
}, [app, mutate, t, openConfirmModal]); }, [app, mutate, t, openConfirmModal]);
return ( return (
<ActionIcon loading={isPending} variant="subtle" color="red" onClick={onClick} aria-label="Delete app"> <ActionIcon
loading={isPending}
variant="subtle"
color="red"
onClick={onClick}
aria-label="Delete app"
>
<IconTrash color="red" size={16} stroke={1.5} /> <IconTrash color="red" size={16} stroke={1.5} />
</ActionIcon> </ActionIcon>
); );
@@ -3,14 +3,13 @@
import Link from "next/link"; import Link from "next/link";
import { Button, Group, Stack, Textarea, TextInput } from "@mantine/core"; import { Button, Group, Stack, Textarea, TextInput } from "@mantine/core";
import { useZodForm } from "@homarr/form"; import { useForm, zodResolver } from "@homarr/form";
import type { TranslationFunction } from "@homarr/translation"; import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation"; import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation"; import { validation } from "@homarr/validation";
import { IconPicker } from "~/components/icons/picker/icon-picker"; // TODO: add icon picker
type FormType = z.infer<typeof validation.app.manage>; type FormType = z.infer<typeof validation.app.manage>;
interface AppFormProps { interface AppFormProps {
@@ -21,23 +20,29 @@ interface AppFormProps {
} }
export const AppForm = (props: AppFormProps) => { export const AppForm = (props: AppFormProps) => {
const { submitButtonTranslation, handleSubmit, initialValues, isPending } = props; const { submitButtonTranslation, handleSubmit, initialValues, isPending } =
props;
const t = useI18n(); const t = useI18n();
const form = useZodForm(validation.app.manage, { const form = useForm({
initialValues: initialValues ?? { initialValues: initialValues ?? {
name: "", name: "",
description: "", description: "",
iconUrl: "", iconUrl: "",
href: "", href: "",
}, },
validate: zodResolver(validation.app.manage),
}); });
return ( return (
<form onSubmit={form.onSubmit(handleSubmit)}> <form onSubmit={form.onSubmit(handleSubmit)}>
<Stack> <Stack>
<TextInput {...form.getInputProps("name")} withAsterisk label="Name" /> <TextInput {...form.getInputProps("name")} withAsterisk label="Name" />
<IconPicker initialValue={initialValues?.iconUrl} {...form.getInputProps("iconUrl")} /> <TextInput
{...form.getInputProps("iconUrl")}
withAsterisk
label="Icon URL"
/>
<Textarea {...form.getInputProps("description")} label="Description" /> <Textarea {...form.getInputProps("description")} label="Description" />
<TextInput {...form.getInputProps("href")} label="URL" /> <TextInput {...form.getInputProps("href")} label="URL" />
@@ -5,12 +5,15 @@ import { useRouter } from "next/navigation";
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 { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import {
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import type { TranslationFunction } from "@homarr/translation"; import type { TranslationFunction } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import type { validation, z } from "@homarr/validation"; import type { validation, z } from "@homarr/validation";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction"; import { revalidatePathAction } from "~/app/revalidatePathAction";
import { AppForm } from "../../_form"; import { AppForm } from "../../_form";
interface AppEditFormProps { interface AppEditFormProps {
@@ -27,7 +30,7 @@ export const AppEditForm = ({ app }: AppEditFormProps) => {
title: t("success.title"), title: t("success.title"),
message: t("success.message"), message: t("success.message"),
}); });
void revalidatePathActionAsync("/manage/apps").then(() => { void revalidatePathAction("/manage/apps").then(() => {
router.push("/manage/apps"); router.push("/manage/apps");
}); });
}, },
@@ -49,7 +52,10 @@ export const AppEditForm = ({ app }: AppEditFormProps) => {
[mutate, app.id], [mutate, app.id],
); );
const submitButtonTranslation = useCallback((t: TranslationFunction) => t("common.action.save"), []); const submitButtonTranslation = useCallback(
(t: TranslationFunction) => t("common.action.save"),
[],
);
return ( return (
<AppForm <AppForm
@@ -4,12 +4,15 @@ import { useCallback } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import {
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import type { TranslationFunction } from "@homarr/translation"; import type { TranslationFunction } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import type { validation, z } from "@homarr/validation"; import type { validation, z } from "@homarr/validation";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction"; import { revalidatePathAction } from "~/app/revalidatePathAction";
import { AppForm } from "../_form"; import { AppForm } from "../_form";
export const AppNewForm = () => { export const AppNewForm = () => {
@@ -22,7 +25,7 @@ export const AppNewForm = () => {
title: t("success.title"), title: t("success.title"),
message: t("success.message"), message: t("success.message"),
}); });
void revalidatePathActionAsync("/manage/apps").then(() => { void revalidatePathAction("/manage/apps").then(() => {
router.push("/manage/apps"); router.push("/manage/apps");
}); });
}, },
@@ -41,9 +44,16 @@ export const AppNewForm = () => {
[mutate], [mutate],
); );
const submitButtonTranslation = useCallback((t: TranslationFunction) => t("common.action.create"), []); const submitButtonTranslation = useCallback(
(t: TranslationFunction) => t("common.action.create"),
[],
);
return ( return (
<AppForm submitButtonTranslation={submitButtonTranslation} handleSubmit={handleSubmit} isPending={isPending} /> <AppForm
submitButtonTranslation={submitButtonTranslation}
handleSubmit={handleSubmit}
isPending={isPending}
/>
); );
}; };
@@ -107,7 +107,9 @@ const AppNoResults = async () => {
<Text fw={500} size="lg"> <Text fw={500} size="lg">
{t("app.page.list.noResults.title")} {t("app.page.list.noResults.title")}
</Text> </Text>
<Anchor href="/manage/apps/new">{t("app.page.list.noResults.description")}</Anchor> <Anchor href="/manage/apps/new">
{t("app.page.list.noResults.description")}
</Anchor>
</Stack> </Stack>
</Card> </Card>
); );
@@ -3,14 +3,14 @@
import { useCallback } from "react"; import { useCallback } from "react";
import Link from "next/link"; import Link from "next/link";
import { Menu } from "@mantine/core"; import { Menu } from "@mantine/core";
import { IconHome, IconSettings, IconTrash } from "@tabler/icons-react"; import { IconSettings, IconTrash } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useConfirmModal } from "@homarr/modals"; import { useConfirmModal } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction"; import { revalidatePathAction } from "~/app/revalidatePathAction";
import { useBoardPermissions } from "~/components/board/permissions/client"; import { useBoardPermissions } from "~/components/board/permissions/client";
const iconProps = { const iconProps = {
@@ -21,11 +21,18 @@ const iconProps = {
interface BoardCardMenuDropdownProps { interface BoardCardMenuDropdownProps {
board: Pick< board: Pick<
RouterOutputs["board"]["getAllBoards"][number], RouterOutputs["board"]["getAllBoards"][number],
"id" | "name" | "creator" | "userPermissions" | "groupPermissions" | "isPublic" | "id"
| "name"
| "creator"
| "userPermissions"
| "groupPermissions"
| "isPublic"
>; >;
} }
export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) => { export const BoardCardMenuDropdown = ({
board,
}: BoardCardMenuDropdownProps) => {
const t = useScopedI18n("management.page.board.action"); const t = useScopedI18n("management.page.board.action");
const tCommon = useScopedI18n("common"); const tCommon = useScopedI18n("common");
@@ -33,15 +40,9 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
const setHomeBoardMutation = clientApi.board.setHomeBoard.useMutation({ const { mutateAsync, isPending } = clientApi.board.deleteBoard.useMutation({
onSettled: async () => { onSettled: async () => {
// Revalidate all as it's part of the user settings, /boards page and board manage page await revalidatePathAction("/manage/boards");
await revalidatePathActionAsync("/");
},
});
const deleteBoardMutation = clientApi.board.deleteBoard.useMutation({
onSettled: async () => {
await revalidatePathActionAsync("/manage/boards");
}, },
}); });
@@ -51,45 +52,36 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
children: t("delete.confirm.description", { children: t("delete.confirm.description", {
name: board.name, name: board.name,
}), }),
// eslint-disable-next-line no-restricted-syntax
onConfirm: async () => { onConfirm: async () => {
await deleteBoardMutation.mutateAsync({ await mutateAsync({
id: board.id, id: board.id,
}); });
}, },
}); });
}, [board.id, board.name, deleteBoardMutation, openConfirmModal, t]); }, [board.id, board.name, mutateAsync, openConfirmModal, t]);
const handleSetHomeBoard = useCallback(async () => {
await setHomeBoardMutation.mutateAsync({ id: board.id });
}, [board.id, setHomeBoardMutation]);
return ( return (
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Item onClick={handleSetHomeBoard} leftSection={<IconHome {...iconProps} />}>
{t("setHomeBoard.label")}
</Menu.Item>
{hasChangeAccess && ( {hasChangeAccess && (
<> <Menu.Item
<Menu.Divider /> component={Link}
<Menu.Item href={`/boards/${board.name}/settings`}
component={Link} leftSection={<IconSettings {...iconProps} />}
href={`/boards/${board.name}/settings`} >
leftSection={<IconSettings {...iconProps} />} {t("settings.label")}
> </Menu.Item>
{t("settings.label")}
</Menu.Item>
</>
)} )}
{hasFullAccess && ( {hasFullAccess && (
<> <>
<Menu.Divider /> <Menu.Divider />
<Menu.Label c="red.7">{tCommon("dangerZone")}</Menu.Label> <Menu.Label c="red.7">
{tCommon("menu.section.dangerZone.title")}
</Menu.Label>
<Menu.Item <Menu.Item
c="red.7" c="red.7"
leftSection={<IconTrash {...iconProps} />} leftSection={<IconTrash {...iconProps} />}
onClick={handleDeletion} onClick={handleDeletion}
disabled={deleteBoardMutation.isPending} disabled={isPending}
> >
{t("delete.label")} {t("delete.label")}
</Menu.Item> </Menu.Item>
@@ -0,0 +1,83 @@
"use client";
import { Box, Card, Center, Flex, Loader, Text } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import dayjs from "dayjs";
import type { RouterOutputs } from "@homarr/api";
interface BoardPreviewProps {
board: RouterOutputs["board"]["getAllBoards"][number];
}
export const BoardPreview = ({ board }: BoardPreviewProps) => {
const { ref, width } = useElementSize();
return (
<Card
h="100%"
mah={width ? (width / 1920) * 1080 : undefined}
ref={ref}
styles={{
root: {
"--gridstack-column-count": board.columnCount,
"--gridstack-widget-width": width / board.columnCount,
},
}}
>
{width === 0 ? (
<Center h="100%">
<Loader size="sm" />
</Center>
) : (
<>
{board.sections.map((section) => (
<Section
key={section.id}
section={section}
itemWidth={width / board.columnCount}
/>
))}
</>
)}
</Card>
);
};
interface SectionProps {
section: RouterOutputs["board"]["getAllBoards"][number]["sections"][number];
itemWidth: number;
}
const Section = ({ section, itemWidth }: SectionProps) => {
const maxHeight = Math.max(
...section.items.map((item) => item.yOffset + item.height),
);
return (
<Card
withBorder={section.kind === "category"}
h={maxHeight * itemWidth}
key={section.id}
className="grid-stack"
>
{section.items.map((item) => (
<Box
className="grid-stack-item"
gs-h={item.height}
gs-w={item.width}
gs-x={item.xOffset}
gs-y={item.yOffset}
key={item.id}
p={4}
>
<Card withBorder h="100%" p={0}>
<Flex justify="center" align="center" h="100%" w="100%">
<Text fz={itemWidth / 4}>{dayjs().format("HH:mm")}</Text>
</Flex>
</Card>
</Box>
))}
</Card>
);
};
@@ -8,7 +8,7 @@ import { clientApi } from "@homarr/api/client";
import { useModalAction } from "@homarr/modals"; import { useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction"; import { revalidatePathAction } from "~/app/revalidatePathAction";
import { AddBoardModal } from "~/components/manage/boards/add-board-modal"; import { AddBoardModal } from "~/components/manage/boards/add-board-modal";
interface CreateBoardButtonProps { interface CreateBoardButtonProps {
@@ -21,7 +21,7 @@ export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => {
const { mutateAsync, isPending } = clientApi.board.createBoard.useMutation({ const { mutateAsync, isPending } = clientApi.board.createBoard.useMutation({
onSettled: async () => { onSettled: async () => {
await revalidatePathActionAsync("/manage/boards"); await revalidatePathAction("/manage/boards");
}, },
}); });
@@ -37,7 +37,11 @@ export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => {
}, [mutateAsync, boardNames, openModal]); }, [mutateAsync, boardNames, openModal]);
return ( return (
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={onClick} loading={isPending}> <Button
leftSection={<IconCategoryPlus size="1rem" />}
onClick={onClick}
loading={isPending}
>
{t("management.page.board.action.new.label")} {t("management.page.board.action.new.label")}
</Button> </Button>
); );
@@ -1,7 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { import {
ActionIcon, ActionIcon,
Badge,
Button, Button,
Card, Card,
CardSection, CardSection,
@@ -14,15 +13,18 @@ import {
Title, Title,
Tooltip, Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { IconDotsVertical, IconHomeFilled, IconLock, IconWorld } from "@tabler/icons-react"; import { IconDotsVertical, IconLock, IconWorld } from "@tabler/icons-react";
import "~/styles/gridstack.scss";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { UserAvatar } from "@homarr/ui"; import { UserAvatar } from "@homarr/ui";
import { getBoardPermissionsAsync } from "~/components/board/permissions/server"; import { getBoardPermissions } from "~/components/board/permissions/server";
import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown"; import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
import { BoardPreview } from "./_components/board-preview";
import { CreateBoardButton } from "./_components/create-board-button"; import { CreateBoardButton } from "./_components/create-board-button";
export default async function ManageBoardsPage() { export default async function ManageBoardsPage() {
@@ -54,7 +56,7 @@ interface BoardCardProps {
const BoardCard = async ({ board }: BoardCardProps) => { const BoardCard = async ({ board }: BoardCardProps) => {
const t = await getScopedI18n("management.page.board"); const t = await getScopedI18n("management.page.board");
const { hasChangeAccess: isMenuVisible } = await getBoardPermissionsAsync(board); const { hasChangeAccess: isMenuVisible } = await getBoardPermissions(board);
const visibility = board.isPublic ? "public" : "private"; const visibility = board.isPublic ? "public" : "private";
const VisibilityIcon = board.isPublic ? IconWorld : IconLock; const VisibilityIcon = board.isPublic ? IconWorld : IconLock;
@@ -71,28 +73,29 @@ const BoardCard = async ({ board }: BoardCardProps) => {
</Text> </Text>
</Group> </Group>
<Group> {board.creator && (
{board.isHome && ( <Group>
<Tooltip label={t("action.setHomeBoard.badge.tooltip")}>
<Badge tt="none" color="yellow" variant="light" leftSection={<IconHomeFilled size=".7rem" />}>
{t("action.setHomeBoard.badge.label")}
</Badge>
</Tooltip>
)}
{board.creator && (
<Group gap="xs"> <Group gap="xs">
<UserAvatar user={board.creator} size="sm" /> <UserAvatar user={board.creator} size="sm" />
<Text>{board.creator?.name}</Text> <Text>{board.creator?.name}</Text>
</Group> </Group>
)} </Group>
</Group> )}
</Group> </Group>
</CardSection> </CardSection>
<CardSection p="sm" style={{ aspectRatio: 1920 / 1080 }}>
<BoardPreview board={board} />
</CardSection>
<CardSection p="sm"> <CardSection p="sm">
<Group wrap="nowrap"> <Group wrap="nowrap">
<Button component={Link} href={`/boards/${board.name}`} variant="default" fullWidth> <Button
component={Link}
href={`/boards/${board.name}`}
variant="default"
fullWidth
>
{t("action.open.label")} {t("action.open.label")}
</Button> </Button>
{isMenuVisible && ( {isMenuVisible && (
@@ -6,17 +6,23 @@ import { IconTrash } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useConfirmModal } from "@homarr/modals"; import { useConfirmModal } from "@homarr/modals";
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 { revalidatePathActionAsync } from "../../../revalidatePathAction"; import { revalidatePathAction } from "../../../revalidatePathAction";
interface DeleteIntegrationActionButtonProps { interface DeleteIntegrationActionButtonProps {
count: number; count: number;
integration: { id: string; name: string }; integration: { id: string; name: string };
} }
export const DeleteIntegrationActionButton = ({ count, integration }: DeleteIntegrationActionButtonProps) => { export const DeleteIntegrationActionButton = ({
count,
integration,
}: DeleteIntegrationActionButtonProps) => {
const t = useScopedI18n("integration.page.delete"); const t = useScopedI18n("integration.page.delete");
const router = useRouter(); const router = useRouter();
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
@@ -41,9 +47,9 @@ export const DeleteIntegrationActionButton = ({ count, integration }: DeleteInte
message: t("notification.success.message"), message: t("notification.success.message"),
}); });
if (count === 1) { if (count === 1) {
router.replace("/manage/integrations"); router.replace("/integrations");
} }
void revalidatePathActionAsync("/manage/integrations"); void revalidatePathAction("/integrations");
}, },
onError: () => { onError: () => {
showErrorNotification({ showErrorNotification({
@@ -2,7 +2,17 @@
import { useState } from "react"; import { useState } from "react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { ActionIcon, Avatar, Button, Card, Collapse, Group, Kbd, Stack, Text } from "@mantine/core"; import {
ActionIcon,
Avatar,
Button,
Card,
Collapse,
Group,
Kbd,
Stack,
Text,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { IconEye, IconEyeOff } from "@tabler/icons-react"; import { IconEye, IconEyeOff } from "@tabler/icons-react";
import dayjs from "dayjs"; import dayjs from "dayjs";
@@ -26,7 +36,8 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
const params = useParams<{ locale: string }>(); const params = useParams<{ locale: string }>();
const t = useI18n(); const t = useI18n();
const { isPublic } = integrationSecretKindObject[secret.kind]; const { isPublic } = integrationSecretKindObject[secret.kind];
const [publicSecretDisplayOpened, { toggle: togglePublicSecretDisplay }] = useDisclosure(false); const [publicSecretDisplayOpened, { toggle: togglePublicSecretDisplay }] =
useDisclosure(false);
const [editMode, setEditMode] = useState(false); const [editMode, setEditMode] = useState(false);
const DisplayIcon = publicSecretDisplayOpened ? IconEye : IconEyeOff; const DisplayIcon = publicSecretDisplayOpened ? IconEye : IconEyeOff;
const KindIcon = integrationSecretIcons[secret.kind]; const KindIcon = integrationSecretIcons[secret.kind];
@@ -39,7 +50,9 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
<Avatar> <Avatar>
<KindIcon size={16} /> <KindIcon size={16} />
</Avatar> </Avatar>
<Text fw={500}>{t(`integration.secrets.kind.${secret.kind}.label`)}</Text> <Text fw={500}>
{t(`integration.secrets.kind.${secret.kind}.label`)}
</Text>
{publicSecretDisplayOpened ? <Kbd>{secret.value}</Kbd> : null} {publicSecretDisplayOpened ? <Kbd>{secret.value}</Kbd> : null}
</Group> </Group>
<Group> <Group>
@@ -49,7 +62,11 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
})} })}
</Text> </Text>
{isPublic ? ( {isPublic ? (
<ActionIcon color="gray" variant="subtle" onClick={togglePublicSecretDisplay}> <ActionIcon
color="gray"
variant="subtle"
onClick={togglePublicSecretDisplay}
>
<DisplayIcon size={16} stroke={1.5} /> <DisplayIcon size={16} stroke={1.5} />
</ActionIcon> </ActionIcon>
) : null} ) : null}
@@ -10,7 +10,6 @@ import { useI18n } from "@homarr/translation/client";
import { integrationSecretIcons } from "./_integration-secret-icons"; import { integrationSecretIcons } from "./_integration-secret-icons";
interface IntegrationSecretInputProps { interface IntegrationSecretInputProps {
withAsterisk?: boolean;
label?: string; label?: string;
kind: IntegrationSecretKind; kind: IntegrationSecretKind;
value?: string; value?: string;
@@ -42,7 +41,10 @@ const PublicSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
); );
}; };
const PrivateSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => { const PrivateSecretInput = ({
kind,
...props
}: IntegrationSecretInputProps) => {
const t = useI18n(); const t = useI18n();
const Icon = integrationSecretIcons[kind]; const Icon = integrationSecretIcons[kind];
@@ -6,7 +6,10 @@ import { IconCheck, IconInfoCircle, IconX } from "@tabler/icons-react";
import type { RouterInputs } from "@homarr/api"; import type { RouterInputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import {
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
interface UseTestConnectionDirtyProps { interface UseTestConnectionDirtyProps {
@@ -17,7 +20,10 @@ interface UseTestConnectionDirtyProps {
}; };
} }
export const useTestConnectionDirty = ({ defaultDirty, initialFormValue }: UseTestConnectionDirtyProps) => { export const useTestConnectionDirty = ({
defaultDirty,
initialFormValue,
}: UseTestConnectionDirtyProps) => {
const [isDirty, setIsDirty] = useState(defaultDirty); const [isDirty, setIsDirty] = useState(defaultDirty);
const prevFormValueRef = useRef(initialFormValue); const prevFormValueRef = useRef(initialFormValue);
@@ -30,7 +36,10 @@ export const useTestConnectionDirty = ({ defaultDirty, initialFormValue }: UseTe
prevFormValueRef.current.url !== values.url || prevFormValueRef.current.url !== values.url ||
!prevFormValueRef.current.secrets !prevFormValueRef.current.secrets
.map((secret) => secret.value) .map((secret) => secret.value)
.every((secretValue, index) => values.secrets[index]?.value === secretValue) .every(
(secretValue, index) =>
values.secrets[index]?.value === secretValue,
)
) { ) {
setIsDirty(true); setIsDirty(true);
return; return;
@@ -53,9 +62,14 @@ interface TestConnectionProps {
integration: RouterInputs["integration"]["testConnection"] & { name: string }; integration: RouterInputs["integration"]["testConnection"] & { name: string };
} }
export const TestConnection = ({ integration, removeDirty, isDirty }: TestConnectionProps) => { export const TestConnection = ({
integration,
removeDirty,
isDirty,
}: TestConnectionProps) => {
const t = useScopedI18n("integration.testConnection"); const t = useScopedI18n("integration.testConnection");
const { mutateAsync, ...mutation } = clientApi.integration.testConnection.useMutation(); const { mutateAsync, ...mutation } =
clientApi.integration.testConnection.useMutation();
return ( return (
<Group> <Group>
@@ -111,7 +125,13 @@ interface TestConnectionIconProps {
size: number; size: number;
} }
const TestConnectionIcon = ({ isDirty, isPending, isSuccess, isError, size }: TestConnectionIconProps) => { const TestConnectionIcon = ({
isDirty,
isPending,
isSuccess,
isError,
size,
}: TestConnectionIconProps) => {
if (isPending) return <Loader color="blue" size={size} />; if (isPending) return <Loader color="blue" size={size} />;
if (isDirty) return null; if (isDirty) return null;
if (isSuccess) return <IconCheck size={size} stroke={1.5} color="green" />; if (isSuccess) return <IconCheck size={size} stroke={1.5} color="green" />;
@@ -122,7 +142,12 @@ const TestConnectionIcon = ({ isDirty, isPending, isSuccess, isError, size }: Te
export const TestConnectionNoticeAlert = () => { export const TestConnectionNoticeAlert = () => {
const t = useI18n(); const t = useI18n();
return ( return (
<Alert variant="light" color="yellow" title="Test Connection" icon={<IconInfoCircle />}> <Alert
variant="light"
color="yellow"
title="Test Connection"
icon={<IconInfoCircle />}
>
{t("integration.testConnection.alertNotice")} {t("integration.testConnection.alertNotice")}
</Alert> </Alert>
); );
@@ -6,18 +6,28 @@ import { Button, Fieldset, Group, Stack, TextInput } from "@mantine/core";
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 { getAllSecretKindOptions, getDefaultSecretKinds } from "@homarr/definitions"; import {
import { useZodForm } from "@homarr/form"; getAllSecretKindOptions,
getDefaultSecretKinds,
} from "@homarr/definitions";
import { useForm, zodResolver } from "@homarr/form";
import { useConfirmModal } from "@homarr/modals"; import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import {
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation"; import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation"; import { validation } from "@homarr/validation";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction"; import { revalidatePathAction } from "~/app/revalidatePathAction";
import { SecretCard } from "../../_integration-secret-card"; import { SecretCard } from "../../_integration-secret-card";
import { IntegrationSecretInput } from "../../_integration-secret-inputs"; import { IntegrationSecretInput } from "../../_integration-secret-inputs";
import { TestConnection, TestConnectionNoticeAlert, useTestConnectionDirty } from "../../_integration-test-connection"; import {
TestConnection,
TestConnectionNoticeAlert,
useTestConnectionDirty,
} from "../../_integration-test-connection";
interface EditIntegrationForm { interface EditIntegrationForm {
integration: RouterOutputs["integration"]["byId"]; integration: RouterOutputs["integration"]["byId"];
@@ -35,7 +45,8 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
url: integration.url, url: integration.url,
secrets: secretsKinds.map((kind) => ({ secrets: secretsKinds.map((kind) => ({
kind, kind,
value: integration.secrets.find((secret) => secret.kind === kind)?.value ?? "", value:
integration.secrets.find((secret) => secret.kind === kind)?.value ?? "",
})), })),
}; };
const { isDirty, onValuesChange, removeDirty } = useTestConnectionDirty({ const { isDirty, onValuesChange, removeDirty } = useTestConnectionDirty({
@@ -44,15 +55,18 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
}); });
const router = useRouter(); const router = useRouter();
const form = useZodForm(validation.integration.update.omit({ id: true }), { const form = useForm<FormType>({
initialValues: initialFormValues, initialValues: initialFormValues,
validate: zodResolver(validation.integration.update.omit({ id: true })),
onValuesChange, onValuesChange,
}); });
const { mutateAsync, isPending } = clientApi.integration.update.useMutation(); const { mutateAsync, isPending } = clientApi.integration.update.useMutation();
const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret])); const secretsMap = new Map(
integration.secrets.map((secret) => [secret.kind, secret]),
);
const handleSubmitAsync = async (values: FormType) => { const handleSubmit = async (values: FormType) => {
if (isDirty) return; if (isDirty) return;
await mutateAsync( await mutateAsync(
{ {
@@ -69,7 +83,9 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
title: t("integration.page.edit.notification.success.title"), title: t("integration.page.edit.notification.success.title"),
message: t("integration.page.edit.notification.success.message"), message: t("integration.page.edit.notification.success.message"),
}); });
void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations")); void revalidatePathAction("/integrations").then(() =>
router.push("/integrations"),
);
}, },
onError: () => { onError: () => {
showErrorNotification({ showErrorNotification({
@@ -82,13 +98,19 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
}; };
return ( return (
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}> <form onSubmit={form.onSubmit((values) => void handleSubmit(values))}>
<Stack> <Stack>
<TestConnectionNoticeAlert /> <TestConnectionNoticeAlert />
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} /> <TextInput
label={t("integration.field.name.label")}
{...form.getInputProps("name")}
/>
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} /> <TextInput
label={t("integration.field.url.label")}
{...form.getInputProps("url")}
/>
<Fieldset legend={t("integration.secrets.title")}> <Fieldset legend={t("integration.secrets.title")}>
<Stack gap="sm"> <Stack gap="sm">
@@ -99,7 +121,10 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
onCancel={() => onCancel={() =>
new Promise((res) => { new Promise((res) => {
// When nothing changed, just close the secret card // When nothing changed, just close the secret card
if ((form.values.secrets[index]?.value ?? "") === (secretsMap.get(kind)?.value ?? "")) { if (
(form.values.secrets[index]?.value ?? "") ===
(secretsMap.get(kind)?.value ?? "")
) {
return res(true); return res(true);
} }
openConfirmModal({ openConfirmModal({
@@ -107,7 +132,10 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
children: t("integration.secrets.reset.message"), children: t("integration.secrets.reset.message"),
onCancel: () => res(false), onCancel: () => res(false),
onConfirm: () => { onConfirm: () => {
form.setFieldValue(`secrets.${index}.value`, secretsMap.get(kind)!.value ?? ""); form.setFieldValue(
`secrets.${index}.value`,
secretsMap.get(kind)!.value ?? "",
);
res(true); res(true);
}, },
}); });
@@ -136,7 +164,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
}} }}
/> />
<Group> <Group>
<Button variant="default" component={Link} href="/manage/integrations"> <Button variant="default" component={Link} href="/integrations">
{t("common.action.backToOverview")} {t("common.action.backToOverview")}
</Button> </Button>
<Button type="submit" loading={isPending} disabled={isDirty}> <Button type="submit" loading={isPending} disabled={isDirty}>
@@ -11,7 +11,9 @@ interface EditIntegrationPageProps {
params: { id: string }; params: { id: string };
} }
export default async function EditIntegrationPage({ params }: EditIntegrationPageProps) { export default async function EditIntegrationPage({
params,
}: EditIntegrationPageProps) {
const t = await getScopedI18n("integration.page.edit"); const t = await getScopedI18n("integration.page.edit");
const integration = await api.integration.byId({ id: params.id }); const integration = await api.integration.byId({ id: params.id });
@@ -20,7 +22,9 @@ export default async function EditIntegrationPage({ params }: EditIntegrationPag
<Stack> <Stack>
<Group align="center"> <Group align="center">
<IntegrationAvatar kind={integration.kind} size="md" /> <IntegrationAvatar kind={integration.kind} size="md" />
<Title>{t("title", { name: getIntegrationName(integration.kind) })}</Title> <Title>
{t("title", { name: getIntegrationName(integration.kind) })}
</Title>
</Group> </Group>
<EditIntegrationForm integration={integration} /> <EditIntegrationForm integration={integration} />
</Stack> </Stack>
@@ -1,7 +1,6 @@
"use client"; "use client";
import type { ChangeEvent } from "react"; import React, { ChangeEvent, useMemo, useState } from "react";
import React, { useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { Group, Menu, ScrollArea, Stack, Text, TextInput } from "@mantine/core"; import { Group, Menu, ScrollArea, Stack, Text, TextInput } from "@mantine/core";
import { IconSearch } from "@tabler/icons-react"; import { IconSearch } from "@tabler/icons-react";
@@ -16,7 +15,9 @@ export const IntegrationCreateDropdownContent = () => {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const filteredKinds = useMemo(() => { const filteredKinds = useMemo(() => {
return integrationKinds.filter((kind) => kind.includes(search.toLowerCase())); return integrationKinds.filter((kind) =>
kind.includes(search.toLowerCase()),
);
}, [search]); }, [search]);
const handleSearch = React.useCallback( const handleSearch = React.useCallback(
@@ -36,7 +37,11 @@ export const IntegrationCreateDropdownContent = () => {
{filteredKinds.length > 0 ? ( {filteredKinds.length > 0 ? (
<ScrollArea.Autosize mah={384}> <ScrollArea.Autosize mah={384}>
{filteredKinds.map((kind) => ( {filteredKinds.map((kind) => (
<Menu.Item component={Link} href={`/manage/integrations/new?kind=${kind}`} key={kind}> <Menu.Item
component={Link}
href={`/integrations/new?kind=${kind}`}
key={kind}
>
<Group> <Group>
<IntegrationAvatar kind={kind} size="sm" /> <IntegrationAvatar kind={kind} size="sm" />
<Text size="sm">{getIntegrationName(kind)}</Text> <Text size="sm">{getIntegrationName(kind)}</Text>
@@ -3,21 +3,38 @@
import { useCallback } from "react"; import { useCallback } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Button, Fieldset, Group, SegmentedControl, Stack, TextInput } from "@mantine/core"; import {
Button,
Fieldset,
Group,
SegmentedControl,
Stack,
TextInput,
} from "@mantine/core";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions"; import type {
IntegrationKind,
IntegrationSecretKind,
} from "@homarr/definitions";
import { getAllSecretKindOptions } from "@homarr/definitions"; import { getAllSecretKindOptions } from "@homarr/definitions";
import type { UseFormReturnType } from "@homarr/form"; import type { UseFormReturnType } from "@homarr/form";
import { useZodForm } from "@homarr/form"; import { useForm, zodResolver } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import {
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation"; import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation"; import { validation } from "@homarr/validation";
import { IntegrationSecretInput } from "../_integration-secret-inputs"; import { IntegrationSecretInput } from "../_integration-secret-inputs";
import { TestConnection, TestConnectionNoticeAlert, useTestConnectionDirty } from "../_integration-test-connection"; import {
import { revalidatePathActionAsync } from "../../../../revalidatePathAction"; TestConnection,
TestConnectionNoticeAlert,
useTestConnectionDirty,
} from "../_integration-test-connection";
import { revalidatePathAction } from "../../../../revalidatePathAction";
interface NewIntegrationFormProps { interface NewIntegrationFormProps {
searchParams: Partial<z.infer<typeof validation.integration.create>> & { searchParams: Partial<z.infer<typeof validation.integration.create>> & {
@@ -25,7 +42,9 @@ interface NewIntegrationFormProps {
}; };
} }
export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => { export const NewIntegrationForm = ({
searchParams,
}: NewIntegrationFormProps) => {
const t = useI18n(); const t = useI18n();
const secretKinds = getAllSecretKindOptions(searchParams.kind); const secretKinds = getAllSecretKindOptions(searchParams.kind);
const initialFormValues = { const initialFormValues = {
@@ -41,13 +60,14 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
initialFormValue: initialFormValues, initialFormValue: initialFormValues,
}); });
const router = useRouter(); const router = useRouter();
const form = useZodForm(validation.integration.create.omit({ kind: true }), { const form = useForm<FormType>({
initialValues: initialFormValues, initialValues: initialFormValues,
validate: zodResolver(validation.integration.create.omit({ kind: true })),
onValuesChange, onValuesChange,
}); });
const { mutateAsync, isPending } = clientApi.integration.create.useMutation(); const { mutateAsync, isPending } = clientApi.integration.create.useMutation();
const handleSubmitAsync = async (values: FormType) => { const handleSubmit = async (values: FormType) => {
if (isDirty) return; if (isDirty) return;
await mutateAsync( await mutateAsync(
{ {
@@ -60,7 +80,9 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
title: t("integration.page.create.notification.success.title"), title: t("integration.page.create.notification.success.title"),
message: t("integration.page.create.notification.success.message"), message: t("integration.page.create.notification.success.message"),
}); });
void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations")); void revalidatePathAction("/integrations").then(() =>
router.push("/integrations"),
);
}, },
onError: () => { onError: () => {
showErrorNotification({ showErrorNotification({
@@ -73,20 +95,30 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
}; };
return ( return (
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}> <form onSubmit={form.onSubmit((value) => void handleSubmit(value))}>
<Stack> <Stack>
<TestConnectionNoticeAlert /> <TestConnectionNoticeAlert />
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} /> <TextInput
label={t("integration.field.name.label")}
{...form.getInputProps("name")}
/>
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} /> <TextInput
label={t("integration.field.url.label")}
{...form.getInputProps("url")}
/>
<Fieldset legend={t("integration.secrets.title")}> <Fieldset legend={t("integration.secrets.title")}>
<Stack gap="sm"> <Stack gap="sm">
{secretKinds.length > 1 && <SecretKindsSegmentedControl secretKinds={secretKinds} form={form} />} {secretKinds.length > 1 && (
<SecretKindsSegmentedControl
secretKinds={secretKinds}
form={form}
/>
)}
{form.values.secrets.map(({ kind }, index) => ( {form.values.secrets.map(({ kind }, index) => (
<IntegrationSecretInput <IntegrationSecretInput
withAsterisk
key={kind} key={kind}
kind={kind} kind={kind}
{...form.getInputProps(`secrets.${index}.value`)} {...form.getInputProps(`secrets.${index}.value`)}
@@ -107,7 +139,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
/> />
<Group> <Group>
<Button variant="default" component={Link} href="/manage/integrations"> <Button variant="default" component={Link} href="/integrations">
{t("common.action.backToOverview")} {t("common.action.backToOverview")}
</Button> </Button>
<Button type="submit" loading={isPending} disabled={isDirty}> <Button type="submit" loading={isPending} disabled={isDirty}>
@@ -125,7 +157,10 @@ interface SecretKindsSegmentedControlProps {
form: UseFormReturnType<FormType, (values: FormType) => FormType>; form: UseFormReturnType<FormType, (values: FormType) => FormType>;
} }
const SecretKindsSegmentedControl = ({ secretKinds, form }: SecretKindsSegmentedControlProps) => { const SecretKindsSegmentedControl = ({
secretKinds,
form,
}: SecretKindsSegmentedControlProps) => {
const t = useScopedI18n("integration.secrets"); const t = useScopedI18n("integration.secrets");
const secretKindGroups = secretKinds.map((kinds) => ({ const secretKindGroups = secretKinds.map((kinds) => ({
@@ -145,7 +180,13 @@ const SecretKindsSegmentedControl = ({ secretKinds, form }: SecretKindsSegmented
[form], [form],
); );
return <SegmentedControl fullWidth data={secretKindGroups} onChange={onChange}></SegmentedControl>; return (
<SegmentedControl
fullWidth
data={secretKindGroups}
onChange={onChange}
></SegmentedControl>
);
}; };
type FormType = Omit<z.infer<typeof validation.integration.create>, "kind">; type FormType = Omit<z.infer<typeof validation.integration.create>, "kind">;
@@ -16,8 +16,12 @@ interface NewIntegrationPageProps {
}; };
} }
export default async function IntegrationsNewPage({ searchParams }: NewIntegrationPageProps) { export default async function IntegrationsNewPage({
const result = z.enum([integrationKinds[0]!, ...integrationKinds.slice(1)]).safeParse(searchParams.kind); searchParams,
}: NewIntegrationPageProps) {
const result = z
.enum([integrationKinds[0]!, ...integrationKinds.slice(1)])
.safeParse(searchParams.kind);
if (!result.success) { if (!result.success) {
notFound(); notFound();
} }
@@ -43,7 +43,9 @@ interface IntegrationsPageProps {
}; };
} }
export default async function IntegrationsPage({ searchParams }: IntegrationsPageProps) { export default async function IntegrationsPage({
searchParams,
}: IntegrationsPageProps) {
const integrations = await api.integration.all(); const integrations = await api.integration.all();
const t = await getScopedI18n("integration"); const t = await getScopedI18n("integration");
@@ -52,9 +54,18 @@ export default async function IntegrationsPage({ searchParams }: IntegrationsPag
<Stack> <Stack>
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<Title>{t("page.list.title")}</Title> <Title>{t("page.list.title")}</Title>
<Menu width={256} trapFocus position="bottom-start" withinPortal shadow="md" keepMounted={false}> <Menu
width={256}
trapFocus
position="bottom-start"
withinPortal
shadow="md"
keepMounted={false}
>
<MenuTarget> <MenuTarget>
<Button rightSection={<IconChevronDown size={16} stroke={1.5} />}>{t("action.create")}</Button> <Button rightSection={<IconChevronDown size={16} stroke={1.5} />}>
{t("action.create")}
</Button>
</MenuTarget> </MenuTarget>
<MenuDropdown> <MenuDropdown>
<IntegrationCreateDropdownContent /> <IntegrationCreateDropdownContent />
@@ -62,7 +73,10 @@ export default async function IntegrationsPage({ searchParams }: IntegrationsPag
</Menu> </Menu>
</Group> </Group>
<IntegrationList integrations={integrations} activeTab={searchParams.tab} /> <IntegrationList
integrations={integrations}
activeTab={searchParams.tab}
/>
</Stack> </Stack>
</Container> </Container>
); );
@@ -73,7 +87,10 @@ interface IntegrationListProps {
activeTab?: IntegrationKind; activeTab?: IntegrationKind;
} }
const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps) => { const IntegrationList = async ({
integrations,
activeTab,
}: IntegrationListProps) => {
const t = await getScopedI18n("integration"); const t = await getScopedI18n("integration");
if (integrations.length === 0) { if (integrations.length === 0) {
@@ -117,7 +134,12 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
<TableTr key={integration.id}> <TableTr key={integration.id}>
<TableTd>{integration.name}</TableTd> <TableTd>{integration.name}</TableTd>
<TableTd> <TableTd>
<Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm"> <Anchor
href={integration.url}
target="_blank"
rel="noreferrer"
size="sm"
>
{integration.url} {integration.url}
</Anchor> </Anchor>
</TableTd> </TableTd>
@@ -126,14 +148,17 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
<ActionIconGroup> <ActionIconGroup>
<ActionIcon <ActionIcon
component={Link} component={Link}
href={`/manage/integrations/edit/${integration.id}`} href={`/integrations/edit/${integration.id}`}
variant="subtle" variant="subtle"
color="gray" color="gray"
aria-label="Edit integration" aria-label="Edit integration"
> >
<IconPencil size={16} stroke={1.5} /> <IconPencil size={16} stroke={1.5} />
</ActionIcon> </ActionIcon>
<DeleteIntegrationActionButton integration={integration} count={integrations.length} /> <DeleteIntegrationActionButton
integration={integration}
count={integrations.length}
/>
</ActionIconGroup> </ActionIconGroup>
</Group> </Group>
</TableTd> </TableTd>
@@ -14,7 +14,6 @@ import {
IconMailForward, IconMailForward,
IconPlug, IconPlug,
IconQuestionMark, IconQuestionMark,
IconSettings,
IconTool, IconTool,
IconUser, IconUser,
IconUsers, IconUsers,
@@ -88,11 +87,6 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
}, },
], ],
}, },
{
label: t("items.settings"),
href: "/manage/settings",
icon: IconSettings,
},
{ {
label: t("items.help.label"), label: t("items.help.label"),
icon: IconQuestionMark, icon: IconQuestionMark,
@@ -1,17 +0,0 @@
import { Center, Stack, Text, Title } from "@mantine/core";
import { getScopedI18n } from "@homarr/translation/server";
export default async function NotFound() {
const t = await getScopedI18n("management.notFound");
return (
<Center h="100%">
<Stack align="center">
<Title order={1} tt="uppercase">
{t("title")}
</Title>
<Text>{t("text")}</Text>
</Stack>
</Center>
);
}
+15 -76
View File
@@ -1,94 +1,33 @@
import Link from "next/link"; import { Title } from "@mantine/core";
import { Card, Group, SimpleGrid, Space, Stack, Text } from "@mantine/core";
import { IconArrowRight } from "@tabler/icons-react";
import { api } from "@homarr/api/server";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { createMetaTitle } from "~/metadata"; import { Test } from "./test";
import { HeroBanner } from "./_components/hero-banner";
interface LinkProps {
title: string;
subtitle: string;
count: number;
href: string;
}
export async function generateMetadata() { export async function generateMetadata() {
const t = await getScopedI18n("management"); const t = await getScopedI18n("management");
const metaTitle = `${t("metaTitle")} • Homarr`;
return { return {
title: createMetaTitle(t("metaTitle")), title: metaTitle,
}; };
} }
export default async function ManagementPage() { export default async function ManagementPage() {
const statistics = await api.home.getStats(); const t = await getScopedI18n("management.title");
const t = await getScopedI18n("management.page.home");
const dateNow = new Date();
const timeOfDay =
dateNow.getHours() < 10
? "morning"
: dateNow.getHours() < 17
? "afternoon"
: "evening";
const links: LinkProps[] = [
{
count: statistics.countBoards,
href: "/manage/boards",
subtitle: t("statisticLabel.boards"),
title: t("statistic.countBoards"),
},
{
count: statistics.countUsers,
href: "/manage/boards",
subtitle: t("statisticLabel.authentication"),
title: t("statistic.createUser"),
},
{
count: statistics.countInvites,
href: "/manage/boards",
subtitle: t("statisticLabel.authentication"),
title: t("statistic.createInvite"),
},
{
count: statistics.countIntegrations,
href: "/manage/integrations",
subtitle: t("statisticLabel.resources"),
title: t("statistic.addIntegration"),
},
{
count: statistics.countApps,
href: "/manage/apps",
subtitle: t("statisticLabel.resources"),
title: t("statistic.addApp"),
},
{
count: statistics.countGroups,
href: "/manage/users/groups",
subtitle: t("statisticLabel.authorization"),
title: t("statistic.manageRoles"),
},
];
return ( return (
<> <>
<HeroBanner /> <Title>{t(timeOfDay, { username: "admin" })}</Title>
<Space h="md" /> <Test />
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
{links.map((link, index) => (
<Card component={Link} href={link.href} key={`link-${index}`} withBorder>
<Group justify="space-between">
<Group>
<Text size="2.4rem" fw="bolder">
{link.count}
</Text>
<Stack gap={0}>
<Text c="red" size="xs">
{link.subtitle}
</Text>
<Text fw="bold">{link.title}</Text>
</Stack>
</Group>
<IconArrowRight />
</Group>
</Card>
))}
</SimpleGrid>
</> </>
); );
} }
@@ -1,118 +0,0 @@
"use client";
import type { ReactNode } from "react";
import React from "react";
import type { MantineSpacing } from "@mantine/core";
import { Card, Group, LoadingOverlay, Stack, Switch, Text, Title, UnstyledButton } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { UseFormReturnType } from "@homarr/form";
import { useForm } from "@homarr/form";
import type { defaultServerSettings } from "@homarr/server-settings";
import { useScopedI18n } from "@homarr/translation/client";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
interface AnalyticsSettingsProps {
initialData: typeof defaultServerSettings.analytics;
}
export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => {
const t = useScopedI18n("management.page.settings.section.analytics");
const form = useForm({
initialValues: initialData,
onValuesChange: (updatedValues, _) => {
if (!form.isValid()) {
return;
}
if (
!updatedValues.enableGeneral &&
(updatedValues.enableWidgetData || updatedValues.enableIntegrationData || updatedValues.enableUserData)
) {
updatedValues.enableIntegrationData = false;
updatedValues.enableUserData = false;
updatedValues.enableWidgetData = false;
}
void mutateAsync({
settingsKey: "analytics",
value: updatedValues,
});
},
});
const { mutateAsync, isPending } = clientApi.serverSettings.saveSettings.useMutation({
onSettled: async () => {
await revalidatePathActionAsync("/manage/settings");
},
});
return (
<>
<Title order={2}>{t("title")}</Title>
<Card pos="relative" withBorder>
<LoadingOverlay visible={isPending} zIndex={1000} overlayProps={{ radius: "sm", blur: 2 }} />
<Stack>
<SwitchSetting form={form} formKey="enableGeneral" title={t("general.title")} text={t("general.text")} />
<SwitchSetting
form={form}
formKey="enableIntegrationData"
ms="xl"
title={t("integrationData.title")}
text={t("integrationData.text")}
/>
<SwitchSetting
form={form}
formKey="enableWidgetData"
ms="xl"
title={t("widgetData.title")}
text={t("widgetData.text")}
/>
<SwitchSetting
form={form}
formKey="enableUserData"
ms="xl"
title={t("usersData.title")}
text={t("usersData.text")}
/>
</Stack>
</Card>
</>
);
};
const SwitchSetting = ({
form,
ms,
title,
text,
formKey,
}: {
form: UseFormReturnType<typeof defaultServerSettings.analytics>;
formKey: keyof typeof defaultServerSettings.analytics;
ms?: MantineSpacing;
title: string;
text: ReactNode;
}) => {
const disabled = formKey !== "enableGeneral" && !form.values.enableGeneral;
const handleClick = React.useCallback(() => {
if (disabled) {
return;
}
form.setFieldValue(formKey, !form.values[formKey]);
}, [form, formKey, disabled]);
return (
<Group ms={ms} justify="space-between" gap="lg" align="center" wrap="nowrap">
<UnstyledButton style={{ flexGrow: 1 }} onClick={handleClick}>
<Stack gap={0}>
<Text fw="bold">{title}</Text>
<Text c="gray.5">{text}</Text>
</Stack>
</UnstyledButton>
<Switch disabled={disabled} onClick={handleClick} checked={form.values[formKey] && !disabled} />
</Group>
);
};
@@ -1,26 +0,0 @@
import { Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import { getScopedI18n } from "@homarr/translation/server";
import { AnalyticsSettings } from "./_components/analytics.settings";
export async function generateMetadata() {
const t = await getScopedI18n("management");
const metaTitle = `${t("metaTitle")} • Homarr`;
return {
title: metaTitle,
};
}
export default async function SettingsPage() {
const serverSettings = await api.serverSettings.getAll();
const t = await getScopedI18n("management.page.settings");
return (
<Stack>
<Title order={1}>{t("title")}</Title>
<AnalyticsSettings initialData={serverSettings.analytics} />
</Stack>
);
}
@@ -0,0 +1,38 @@
"use client";
import { useCallback, useState } from "react";
import type { ChangeEvent } from "react";
import { Button, Stack, Text, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
export const Test = () => {
const [value, setValue] = useState("");
const [message, setMessage] = useState<string>("Hello, world!");
const { mutate } = clientApi.user.setMessage.useMutation();
clientApi.user.test.useSubscription(undefined, {
onData({ message }) {
setMessage(message);
},
onError(err) {
alert(err);
},
});
const onChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => setValue(event.target.value),
[setValue],
);
const onClick = useCallback(() => {
mutate(value);
setValue("");
}, [mutate, value]);
return (
<Stack>
<TextInput label="Update message" value={value} onChange={onChange} />
<Button onClick={onClick}>Update message</Button>
<Text>This message gets through subscription: {message}</Text>
</Stack>
);
};
@@ -1,154 +0,0 @@
"use client";
import type { ButtonProps, MantineColor } from "@mantine/core";
import { Avatar, Badge, Box, Button, Group, Text } from "@mantine/core";
import { IconPlayerPlay, IconPlayerStop, IconRotateClockwise, IconTrash } from "@tabler/icons-react";
import type { MRT_ColumnDef } from "mantine-react-table";
import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api";
import { useTimeAgo } from "@homarr/common";
import type { DockerContainerState } from "@homarr/definitions";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { OverflowBadge } from "@homarr/ui";
const createColumns = (
t: TranslationFunction,
): MRT_ColumnDef<RouterOutputs["docker"]["getContainers"]["containers"][number]>[] => [
{
accessorKey: "name",
header: t("docker.field.name.label"),
Cell({ renderedCellValue, row }) {
return (
<Group gap="xs">
<Avatar variant="outline" radius="lg" size="md" src={row.original.iconUrl}>
{row.original.name.at(0)?.toUpperCase()}
</Avatar>
<Text>{renderedCellValue}</Text>
</Group>
);
},
},
{
accessorKey: "state",
header: t("docker.field.state.label"),
size: 120,
Cell({ cell }) {
return <ContainerStateBadge state={cell.row.original.state} />;
},
},
{
accessorKey: "image",
header: t("docker.field.containerImage.label"),
maxSize: 200,
Cell({ renderedCellValue }) {
return (
<Box maw={200}>
<Text truncate="end">{renderedCellValue}</Text>
</Box>
);
},
},
{
accessorKey: "ports",
header: t("docker.field.ports.label"),
Cell({ cell }) {
return (
<OverflowBadge overflowCount={1} data={cell.row.original.ports.map((port) => port.PrivatePort.toString())} />
);
},
},
];
export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["getContainers"]) {
const t = useI18n();
const tDocker = useScopedI18n("docker");
const relativeTime = useTimeAgo(timestamp);
const table = useMantineReactTable({
data: containers,
enableDensityToggle: false,
enableColumnActions: false,
enableColumnFilters: false,
enablePagination: false,
enableRowSelection: true,
positionToolbarAlertBanner: "top",
enableTableFooter: false,
enableBottomToolbar: false,
positionGlobalFilter: "right",
mantineSearchTextInputProps: {
placeholder: tDocker("table.search", { count: containers.length }),
style: { minWidth: 300 },
autoFocus: true,
},
initialState: { density: "xs", showGlobalFilter: true },
renderToolbarAlertBannerContent: ({ groupedAlert, table }) => {
return (
<Group gap={"sm"}>
{groupedAlert}
<Text fw={500}>
{tDocker("table.selected", {
selectCount: table.getSelectedRowModel().rows.length,
totalCount: table.getRowCount(),
})}
</Text>
<ContainerActionBar />
</Group>
);
},
columns: createColumns(t),
});
return (
<>
<Text>{tDocker("table.updated", { when: relativeTime })}</Text>
<MantineReactTable table={table} />
</>
);
}
const ContainerActionBar = () => {
const t = useScopedI18n("docker.action");
const sharedButtonProps = {
variant: "light",
radius: "md",
} satisfies Partial<ButtonProps>;
return (
<Group gap="xs">
<Button leftSection={<IconPlayerPlay />} color="green" {...sharedButtonProps}>
{t("start")}
</Button>
<Button leftSection={<IconPlayerStop />} color="red" {...sharedButtonProps}>
{t("stop")}
</Button>
<Button leftSection={<IconRotateClockwise />} color="orange" {...sharedButtonProps}>
{t("restart")}
</Button>
<Button leftSection={<IconTrash />} color="red" {...sharedButtonProps}>
{t("remove")}
</Button>
</Group>
);
};
const containerStates = {
created: "cyan",
running: "green",
paused: "yellow",
restarting: "orange",
exited: "red",
removing: "pink",
dead: "dark",
} satisfies Record<DockerContainerState, MantineColor>;
const ContainerStateBadge = ({ state }: { state: DockerContainerState }) => {
const t = useScopedI18n("docker.field.state.option");
return (
<Badge size="lg" radius="sm" variant="light" w={120} color={containerStates[state]}>
{t(state)}
</Badge>
);
};
@@ -1,18 +0,0 @@
import { Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import { getScopedI18n } from "@homarr/translation/server";
import { DockerTable } from "./DockerTable";
export default async function DockerPage() {
const { containers, timestamp } = await api.docker.getContainers();
const tDocker = await getScopedI18n("docker");
return (
<Stack>
<Title order={1}>{tDocker("title")}</Title>
<DockerTable containers={containers} timestamp={timestamp} />
</Stack>
);
}
@@ -7,13 +7,13 @@ import "@xterm/xterm/css/xterm.css";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { fullHeightWithoutHeaderAndFooter } from "~/constants"; import { fullHeightWithoutHeaderAndFooter } from "~/constants";
import { createMetaTitle } from "~/metadata";
export async function generateMetadata() { export async function generateMetadata() {
const t = await getScopedI18n("management"); const t = await getScopedI18n("management");
const metaTitle = `${t("metaTitle")} • Homarr`;
return { return {
title: createMetaTitle(t("metaTitle")), title: metaTitle,
}; };
} }
@@ -23,7 +23,12 @@ const ClientSideTerminalComponent = dynamic(() => import("./terminal"), {
export default function LogsManagementPage() { export default function LogsManagementPage() {
return ( return (
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black"> <Box
style={{ borderRadius: 6 }}
h={fullHeightWithoutHeaderAndFooter}
p="md"
bg="black"
>
<ClientSideTerminalComponent /> <ClientSideTerminalComponent />
</Box> </Box>
); );
@@ -16,7 +16,9 @@ export default function TerminalComponent() {
const terminalRef = useRef<Terminal>(); const terminalRef = useRef<Terminal>();
clientApi.log.subscribe.useSubscription(undefined, { clientApi.log.subscribe.useSubscription(undefined, {
onData(data) { onData(data) {
terminalRef.current?.writeln(`${data.timestamp} ${data.level} ${data.message}`); terminalRef.current?.writeln(
`${data.timestamp} ${data.level} ${data.message}`,
);
terminalRef.current?.refresh(0, terminalRef.current.rows - 1); terminalRef.current?.refresh(0, terminalRef.current.rows - 1);
}, },
onError(err) { onError(err) {
@@ -53,5 +55,12 @@ export default function TerminalComponent() {
canvasAddon.dispose(); canvasAddon.dispose();
}; };
}, []); }, []);
return <Box ref={ref} id="terminal" className={classes.outerTerminal} h="100%"></Box>; return (
<Box
ref={ref}
id="terminal"
className={classes.outerTerminal}
h="100%"
></Box>
);
} }
@@ -0,0 +1,46 @@
"use client";
import React from "react";
import { useRouter } from "next/navigation";
import { Button, Divider, Group, Stack, Text } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
interface DangerZoneAccordionProps {
user: NonNullable<RouterOutputs["user"]["getById"]>;
}
export const DangerZoneAccordion = ({ user }: DangerZoneAccordionProps) => {
const t = useScopedI18n("management.page.user.edit.section.dangerZone");
const router = useRouter();
const { mutateAsync: mutateUserDeletionAsync } =
clientApi.user.delete.useMutation({
onSettled: () => {
router.push("/manage/users");
},
});
const handleDelete = React.useCallback(
async () => await mutateUserDeletionAsync(user.id),
[user, mutateUserDeletionAsync],
);
return (
<Stack>
<Divider />
<Group justify="space-between" px="md">
<Stack gap={0}>
<Text fw="bold" size="sm">
{t("action.delete.label")}
</Text>
<Text size="sm">{t("action.delete.description")}</Text>
</Stack>
<Button onClick={handleDelete} variant="subtle" color="red">
{t("action.delete.button")}
</Button>
</Group>
</Stack>
);
};
@@ -0,0 +1,59 @@
"use client";
import { Button, Stack, TextInput } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useForm, zodResolver } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation";
import { revalidatePathAction } from "~/app/revalidatePathAction";
interface ProfileAccordionProps {
user: NonNullable<RouterOutputs["user"]["getById"]>;
}
export const ProfileAccordion = ({ user }: ProfileAccordionProps) => {
const t = useI18n();
const { mutate, isPending } = clientApi.user.editProfile.useMutation({
onSettled: async () => {
await revalidatePathAction("/manage/users");
},
});
const form = useForm({
initialValues: {
name: user.name ?? "",
email: user.email ?? "",
},
validate: zodResolver(validation.user.editProfile),
validateInputOnBlur: true,
validateInputOnChange: true,
});
const handleSubmit = () => {
mutate({
userId: user.id,
form: form.values,
});
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput
label={t("user.field.username.label")}
withAsterisk
{...form.getInputProps("name")}
/>
<TextInput
label={t("user.field.email.label")}
{...form.getInputProps("email")}
/>
<Button type="submit" disabled={!form.isValid()} loading={isPending}>
{t("common.action.save")}
</Button>
</Stack>
</form>
);
};
@@ -0,0 +1,79 @@
"use client";
import { Button, PasswordInput, Stack, Title } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useForm, zodResolver } from "@homarr/form";
import { showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation";
import { revalidatePathAction } from "~/app/revalidatePathAction";
interface SecurityAccordionComponentProps {
user: NonNullable<RouterOutputs["user"]["getById"]>;
}
export const SecurityAccordionComponent = ({
user,
}: SecurityAccordionComponentProps) => {
return (
<Stack>
<ChangePasswordForm user={user} />
</Stack>
);
};
const ChangePasswordForm = ({
user,
}: {
user: NonNullable<RouterOutputs["user"]["getById"]>;
}) => {
const t = useI18n();
const { mutate, isPending } = clientApi.user.changePassword.useMutation({
onSettled: async () => {
await revalidatePathAction(`/manage/users/${user.id}`);
showSuccessNotification({
title: t(
"management.page.user.edit.section.security.changePassword.message.passwordUpdated",
),
message: "",
});
},
});
const form = useForm({
initialValues: {
userId: user.id,
password: "",
},
validate: zodResolver(validation.user.changePassword),
validateInputOnBlur: true,
validateInputOnChange: true,
});
const handleSubmit = () => {
mutate(form.values);
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<Stack gap={0}>
<Title order={5}>
{t(
"management.page.user.edit.section.security.changePassword.title",
)}
</Title>
<PasswordInput
label={t("user.field.password.label")}
{...form.getInputProps("password")}
/>
</Stack>
<Button loading={isPending} type="submit" disabled={!form.isValid()}>
{t("common.action.confirm")}
</Button>
</Stack>
</form>
);
};
@@ -1,17 +0,0 @@
import type { Session } from "@homarr/auth";
export const canAccessUserEditPage = (session: Session | null, userId: string) => {
if (!session) {
return false;
}
if (session.user.id === userId) {
return true;
}
if (session.user.permissions.includes("admin")) {
return true;
}
return false;
};
@@ -1,46 +0,0 @@
"use client";
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useConfirmModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
interface DeleteUserButtonProps {
user: RouterOutputs["user"]["getById"];
}
export const DeleteUserButton = ({ user }: DeleteUserButtonProps) => {
const t = useI18n();
const router = useRouter();
const { mutateAsync: mutateUserDeletionAsync } = clientApi.user.delete.useMutation({
async onSuccess() {
await revalidatePathActionAsync("/manage/users").then(() => router.push("/manage/users"));
},
});
const { openConfirmModal } = useConfirmModal();
const handleDelete = useCallback(
() =>
openConfirmModal({
title: t("user.action.delete.label"),
children: t("user.action.delete.confirm", { username: user.name }),
// eslint-disable-next-line no-restricted-syntax
async onConfirm() {
await mutateUserDeletionAsync(user.id);
},
}),
[user, mutateUserDeletionAsync, openConfirmModal, t],
);
return (
<Button onClick={handleDelete} variant="subtle" color="red">
{t("common.action.delete")}
</Button>
);
};
@@ -1,141 +0,0 @@
"use client";
import { useCallback } from "react";
import { Box, Button, FileButton, Menu, UnstyledButton } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconPencil, IconPhotoEdit, IconPhotoX } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
interface UserProfileAvatarForm {
user: RouterOutputs["user"]["getById"];
}
export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
const { mutate } = clientApi.user.setProfileImage.useMutation();
const [opened, { toggle }] = useDisclosure(false);
const { openConfirmModal } = useConfirmModal();
const t = useI18n();
const tManageAvatar = useScopedI18n("user.action.manageAvatar");
const handleAvatarChange = useCallback(
async (file: File | null) => {
if (!file) {
return;
}
const base64Url = await fileToBase64Async(file);
mutate(
{
userId: user.id,
image: base64Url,
},
{
async onSuccess() {
// Revalidate all as the avatar is used in multiple places
await revalidatePathActionAsync("/");
showSuccessNotification({
message: tManageAvatar("changeImage.notification.success.message"),
});
},
onError(error) {
if (error.shape?.data.code === "BAD_REQUEST") {
showErrorNotification({
title: tManageAvatar("changeImage.notification.toLarge.title"),
message: tManageAvatar("changeImage.notification.toLarge.message", { size: "256KB" }),
});
} else {
showErrorNotification({
message: tManageAvatar("changeImage.notification.error.message"),
});
}
},
},
);
},
[mutate, user.id, tManageAvatar],
);
const handleRemoveAvatar = useCallback(() => {
openConfirmModal({
title: tManageAvatar("removeImage.label"),
children: tManageAvatar("removeImage.confirm"),
onConfirm() {
mutate(
{
userId: user.id,
image: null,
},
{
async onSuccess() {
// Revalidate all as the avatar is used in multiple places
await revalidatePathActionAsync("/");
showSuccessNotification({
message: tManageAvatar("removeImage.notification.success.message"),
});
},
onError() {
showErrorNotification({
message: tManageAvatar("removeImage.notification.error.message"),
});
},
},
);
},
});
}, [mutate, user.id, openConfirmModal, tManageAvatar]);
return (
<Box pos="relative">
<Menu opened={opened} keepMounted onChange={toggle} position="bottom-start" withArrow>
<Menu.Target>
<UnstyledButton onClick={toggle}>
<UserAvatar user={user} size={200} />
<Button
component="div"
pos="absolute"
bottom={0}
left={0}
size="compact-md"
fw="normal"
variant="default"
leftSection={<IconPencil size={18} stroke={1.5} />}
>
{t("common.action.edit")}
</Button>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<FileButton onChange={handleAvatarChange} accept="image/png,image/jpeg,image/webp,image/gif">
{(props) => (
<Menu.Item {...props} leftSection={<IconPhotoEdit size={16} stroke={1.5} />}>
{tManageAvatar("changeImage.label")}
</Menu.Item>
)}
</FileButton>
{user.image && (
<Menu.Item onClick={handleRemoveAvatar} leftSection={<IconPhotoX size={16} stroke={1.5} />}>
{tManageAvatar("removeImage.label")}
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
</Box>
);
};
const fileToBase64Async = async (file: File): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result?.toString() || "");
reader.onerror = reject;
});
@@ -1,80 +0,0 @@
"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 { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
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(validation.user.editProfile.omit({ id: true }), {
initialValues: {
name: user.name ?? "",
email: user.email ?? "",
},
});
const handleSubmit = useCallback(
(values: FormType) => {
mutate({
...values,
id: user.id,
});
},
[user.id, mutate],
);
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput label={t("user.field.username.label")} withAsterisk {...form.getInputProps("name")} />
<TextInput label={t("user.field.email.label")} {...form.getInputProps("email")} />
<Group justify="end">
<Button type="submit" color="teal" disabled={!form.isDirty()} loading={isPending}>
{t("common.action.saveChanges")}
</Button>
</Group>
</Stack>
</form>
);
};
type FormType = Omit<RouterInputs["user"]["editProfile"], "id">;
@@ -1,12 +0,0 @@
import { Stack, Title } from "@mantine/core";
import { LanguageCombobox } from "~/components/language/language-combobox";
export const ProfileLanguageChange = () => {
return (
<Stack mb="lg">
<Title order={2}>Language & Region</Title>
<LanguageCombobox />
</Stack>
);
};
@@ -1,79 +0,0 @@
import { notFound } from "next/navigation";
import { Box, Group, Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
import { catchTrpcNotFound } from "~/errors/trpc-not-found";
import { createMetaTitle } from "~/metadata";
import { canAccessUserEditPage } from "../access";
import { DeleteUserButton } from "./_components/_delete-user-button";
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
import { UserProfileForm } from "./_components/_profile-form";
import { ProfileLanguageChange } from "./_components/_profile-language-change";
interface Props {
params: {
userId: string;
};
}
export async function generateMetadata({ params }: Props) {
const session = await auth();
const user = await api.user
.getById({
userId: params.userId,
})
.catch(() => null);
if (!user || !canAccessUserEditPage(session, user.id)) {
return {};
}
const t = await getScopedI18n("management.page.user.edit");
return {
title: createMetaTitle(t("metaTitle", { username: user?.name })),
};
}
export default async function EditUserPage({ params }: Props) {
const t = await getI18n();
const tGeneral = await getScopedI18n("management.page.user.setting.general");
const session = await auth();
const user = await api.user
.getById({
userId: params.userId,
})
.catch(catchTrpcNotFound);
if (!canAccessUserEditPage(session, user.id)) {
notFound();
}
return (
<Stack>
<Title>{tGeneral("title")}</Title>
<Group gap="xl">
<Box flex={1}>
<UserProfileForm user={user} />
</Box>
<Box w={{ base: "100%", lg: 200 }}>
<UserProfileAvatarForm user={user} />
</Box>
</Group>
<ProfileLanguageChange />
<DangerZoneRoot>
<DangerZoneItem
label={t("user.action.delete.label")}
description={t("user.action.delete.description")}
action={<DeleteUserButton user={user} />}
/>
</DangerZoneRoot>
</Stack>
);
}
@@ -1,69 +0,0 @@
import type { PropsWithChildren } from "react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { Button, Container, Grid, GridCol, Group, Stack, Text, Title } from "@mantine/core";
import { IconSettings, IconShieldLock } from "@tabler/icons-react";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { UserAvatar } from "@homarr/ui";
import { catchTrpcNotFound } from "~/errors/trpc-not-found";
import { NavigationLink } from "../groups/[id]/_navigation";
import { canAccessUserEditPage } from "./access";
interface LayoutProps {
params: { userId: string };
}
export default async function Layout({ children, params }: PropsWithChildren<LayoutProps>) {
const session = await auth();
const t = await getI18n();
const tUser = await getScopedI18n("management.page.user");
const user = await api.user.getById({ userId: params.userId }).catch(catchTrpcNotFound);
if (!canAccessUserEditPage(session, user.id)) {
notFound();
}
return (
<Container size="xl">
<Grid>
<GridCol span={12}>
<Group justify="space-between" align="center">
<Group>
<UserAvatar user={user} size="lg" />
<Stack gap={0}>
<Title order={3}>{user.name}</Title>
<Text c="gray.5">{t("user.name")}</Text>
</Stack>
</Group>
{session?.user.permissions.includes("admin") && (
<Button component={Link} href="/manage/users" color="gray" variant="light">
{tUser("back")}
</Button>
)}
</Group>
</GridCol>
<GridCol span={{ xs: 12, md: 4, lg: 3, xl: 2 }}>
<Stack>
<Stack gap={0}>
<NavigationLink
href={`/manage/users/${params.userId}/general`}
label={tUser("setting.general.title")}
icon={<IconSettings size="1rem" stroke={1.5} />}
/>
<NavigationLink
href={`/manage/users/${params.userId}/security`}
label={tUser("setting.security.title")}
icon={<IconShieldLock size="1rem" stroke={1.5} />}
/>
</Stack>
</Stack>
</GridCol>
<GridCol span={{ xs: 12, md: 8, lg: 9, xl: 10 }}>{children}</GridCol>
</Grid>
</Container>
);
}
@@ -0,0 +1,113 @@
import { notFound } from "next/navigation";
import {
Accordion,
AccordionControl,
AccordionItem,
AccordionPanel,
Avatar,
Group,
Stack,
Text,
Title,
} from "@mantine/core";
import {
IconAlertTriangleFilled,
IconSettingsFilled,
IconShieldLockFilled,
IconUserFilled,
} from "@tabler/icons-react";
import { api } from "@homarr/api/server";
import { getScopedI18n } from "@homarr/translation/server";
import { DangerZoneAccordion } from "./_components/dangerZone.accordion";
import { ProfileAccordion } from "./_components/profile.accordion";
import { SecurityAccordionComponent } from "./_components/security.accordion";
interface Props {
params: {
userId: string;
};
}
export async function generateMetadata({ params }: Props) {
const user = await api.user.getById({
userId: params.userId,
});
const t = await getScopedI18n("management.page.user.edit");
const metaTitle = `${t("metaTitle", { username: user?.name })} • Homarr`;
return {
title: metaTitle,
};
}
export default async function EditUserPage({ params }: Props) {
const t = await getScopedI18n("management.page.user.edit");
const user = await api.user.getById({
userId: params.userId,
});
if (!user) {
notFound();
}
return (
<Stack>
<Group mb="md">
<Avatar>{user.name?.substring(0, 2)}</Avatar>
<Title>{user.name}</Title>
</Group>
<Accordion variant="separated" defaultValue="general">
<AccordionItem value="general">
<AccordionControl icon={<IconUserFilled />}>
<Text fw="bold" size="lg">
{t("section.profile.title")}
</Text>
</AccordionControl>
<AccordionPanel>
<ProfileAccordion user={user} />
</AccordionPanel>
</AccordionItem>
<AccordionItem value="preferences">
<AccordionControl icon={<IconSettingsFilled />}>
<Text fw="bold" size="lg">
{t("section.preferences.title")}
</Text>
</AccordionControl>
<AccordionPanel></AccordionPanel>
</AccordionItem>
<AccordionItem value="security">
<AccordionControl icon={<IconShieldLockFilled />}>
<Text fw="bold" size="lg">
{t("section.security.title")}
</Text>
</AccordionControl>
<AccordionPanel>
<SecurityAccordionComponent user={user} />
</AccordionPanel>
</AccordionItem>
<AccordionItem
styles={{
item: {
borderColor: "rgba(248,81,73,0.4)",
borderWidth: 4,
},
}}
value="dangerZone"
>
<AccordionControl icon={<IconAlertTriangleFilled />}>
<Text fw="bold" size="lg">
{t("section.dangerZone.title")}
</Text>
</AccordionControl>
<AccordionPanel
styles={{ content: { paddingRight: 0, paddingLeft: 0 } }}
>
<DangerZoneAccordion user={user} />
</AccordionPanel>
</AccordionItem>
</Accordion>
</Stack>
);
}
@@ -1,94 +0,0 @@
"use client";
import { Button, Fieldset, Group, PasswordInput, Stack } from "@mantine/core";
import type { RouterInputs, RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
interface ChangePasswordFormProps {
user: RouterOutputs["user"]["getById"];
}
export const ChangePasswordForm = ({ user }: ChangePasswordFormProps) => {
const { data: session } = useSession();
const t = useI18n();
const { mutate, isPending } = clientApi.user.changePassword.useMutation({
async onSettled() {
await revalidatePathActionAsync(`/manage/users/${user.id}`);
},
onSuccess() {
showSuccessNotification({
message: t("user.action.changePassword.notification.success.message"),
});
},
onError() {
showErrorNotification({
message: t("user.action.changePassword.notification.error.message"),
});
},
});
const form = useZodForm(validation.user.changePassword, {
initialValues: {
/* Require previous password if the current user want's to change his password */
previousPassword: session?.user.id === user.id ? "" : "_",
password: "",
confirmPassword: "",
},
});
const handleSubmit = (values: FormType) => {
mutate(
{
userId: user.id,
...values,
},
{
onSettled() {
form.reset();
},
},
);
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<Fieldset legend={t("user.action.changePassword.label")}>
<Stack gap="xs">
{/* Require previous password if the current user want's to change his password */}
{session?.user.id === user.id && (
<PasswordInput
withAsterisk
label={t("user.field.previousPassword.label")}
{...form.getInputProps("previousPassword")}
/>
)}
<PasswordInput withAsterisk label={t("user.field.password.label")} {...form.getInputProps("password")} />
<PasswordInput
withAsterisk
label={t("user.field.passwordConfirm.label")}
{...form.getInputProps("confirmPassword")}
/>
<Group justify="end">
<Button type="submit" color="teal" loading={isPending}>
{t("common.action.confirm")}
</Button>
</Group>
</Stack>
</Fieldset>
</Stack>
</form>
);
};
type FormType = Omit<RouterInputs["user"]["changePassword"], "userId">;
Loaded 100 of 391 files, more files were not shown because too many files have changed in this diff. Show more