Compare commits
140
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec29d77186 | ||
|
|
b12d109f6d | ||
|
|
169fecaba1 | ||
|
|
dd2937a0d6 | ||
|
|
e030e06a16 | ||
|
|
dc7f455fe7 | ||
|
|
612b8d8c6e | ||
|
|
fe43e74c53 | ||
|
|
2cb3e95593 | ||
|
|
9e499bd30b | ||
|
|
c2185e3489 | ||
|
|
e448eeb0a6 | ||
|
|
deea531607 | ||
|
|
ba46b6f869 | ||
|
|
bcbc2d1c56 | ||
|
|
c20f7a64a0 | ||
|
|
6f47adff8a | ||
|
|
e325e1c02c | ||
|
|
12e30c392e | ||
|
|
ad3ef4c313 | ||
|
|
116d9dda04 | ||
|
|
2bc0431e4e | ||
|
|
3f364caced | ||
|
|
abdf9bc122 | ||
|
|
a0322bfd99 | ||
|
|
8f50def4c9 | ||
|
|
56b6d76f43 | ||
|
|
6e655b7ad2 | ||
|
|
4d1e4d55bc | ||
|
|
0ccf258a90 | ||
|
|
98ebabc9ca | ||
|
|
30fe9fef84 | ||
|
|
7e64d39693 | ||
|
|
d57b771a17 | ||
|
|
96c71aed6e | ||
|
|
1569862baf | ||
|
|
cd09d54163 | ||
|
|
a029abe6f3 | ||
|
|
b76fa9d722 | ||
|
|
b678d4f951 | ||
|
|
4ef668c5ea | ||
|
|
7b46d92d4f | ||
|
|
9e199b7dff | ||
|
|
114176ee17 | ||
|
|
3940d296f2 | ||
|
|
63f743175e | ||
|
|
f45fa50024 | ||
|
|
c668d0a765 | ||
|
|
e097cffe59 | ||
|
|
5a9e8edf70 | ||
|
|
048711fe05 | ||
|
|
f617c60916 | ||
|
|
c3b2215c7a | ||
|
|
eb0db3ec4e | ||
|
|
26b1c4a319 | ||
|
|
f1b1ec59ec | ||
|
|
919161798e | ||
|
|
d9f5158662 | ||
|
|
7e339c09c8 | ||
|
|
dfed804f65 | ||
|
|
b312032f02 | ||
|
|
a29cc78acd | ||
|
|
abffc866c0 | ||
|
|
dc9a8dd9c6 | ||
|
|
7664b00be0 | ||
|
|
e05deef6e2 | ||
|
|
31c2694185 | ||
|
|
7fa120c474 | ||
|
|
f30f133bfa | ||
|
|
f0891dda31 | ||
|
|
8e8f9081b0 | ||
|
|
dcaff1d91c | ||
|
|
5723295856 | ||
|
|
1abf441cdb | ||
|
|
db48615096 | ||
|
|
8166fdb8e2 | ||
|
|
ff3244d9ac | ||
|
|
9a73b13ae5 | ||
|
|
023feafafe | ||
|
|
1b6a9c5aee | ||
|
|
3fe1a19e37 | ||
|
|
669a79ef08 | ||
|
|
333dc571fa | ||
|
|
f329e2a393 | ||
|
|
a702f7dc67 | ||
|
|
8b84695079 | ||
|
|
18d1aa6fcd | ||
|
|
b18b364270 | ||
|
|
87fe03dd00 | ||
|
|
1570faa20a | ||
|
|
143db86b77 | ||
|
|
fd44ee48f4 | ||
|
|
db01301845 | ||
|
|
f0da1d81a6 | ||
|
|
333b124f67 | ||
|
|
94af21abbf | ||
|
|
fe7de2cf6d | ||
|
|
82d41abf46 | ||
|
|
95ddd03763 | ||
|
|
99114f0727 | ||
|
|
d970cb54f5 | ||
|
|
cb6f754492 | ||
|
|
8dd5987e5e | ||
|
|
627f8957e5 | ||
|
|
8e6d143edc | ||
|
|
1ce6fb0d0f | ||
|
|
0008ceffed | ||
|
|
543bfc0835 | ||
|
|
6e2bd0ce11 | ||
|
|
f5d71aebb4 | ||
|
|
0ae8a75a5e | ||
|
|
65025a5dda | ||
|
|
c2026d0962 | ||
|
|
245c2f483c | ||
|
|
781247de51 | ||
|
|
400fa3d480 | ||
|
|
2434e2e758 | ||
|
|
48d7aaaf1d | ||
|
|
687556ad96 | ||
|
|
8bd08d39ca | ||
|
|
382be496ff | ||
|
|
1172d84223 | ||
|
|
cd4762377a | ||
|
|
cab34052f9 | ||
|
|
442d45dd15 | ||
|
|
3ae96d94a2 | ||
|
|
5eb1d716de | ||
|
|
25ce74a82f | ||
|
|
5ae7bc957c | ||
|
|
35ba58a0d9 | ||
|
|
c88464498f | ||
|
|
dc0184af07 | ||
|
|
3cda718474 | ||
|
|
96158bcb5e | ||
|
|
5d30301b95 | ||
|
|
7b31684c84 | ||
|
|
4b80b16b53 | ||
|
|
08d1b6c638 | ||
|
|
60a35e2583 | ||
|
|
51aaab2f23 |
No files matched your search
+19
-4
@@ -1,15 +1,30 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
"extends": ["config:recommended"],
|
"extends": [
|
||||||
|
"config:recommended"
|
||||||
|
],
|
||||||
"packageRules": [
|
"packageRules": [
|
||||||
{
|
{
|
||||||
"matchPackagePatterns": ["^@homarr/"],
|
"matchPackagePatterns": [
|
||||||
|
"^@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": ["dev"],
|
"baseBranches": [
|
||||||
|
"dev"
|
||||||
|
],
|
||||||
"dependencyDashboard": false
|
"dependencyDashboard": false
|
||||||
}
|
}
|
||||||
@@ -66,3 +66,9 @@ 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
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
|
||||||
|
# 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
|
||||||
@@ -30,8 +30,20 @@ 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"
|
args: "Deployment of an image has been triggered: [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
|
||||||
- uses: actions/checkout@v4
|
- 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
|
||||||
@@ -44,23 +56,16 @@ 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: "Image has been tagged as ${{ steps.githubTagAction.outputs.new_tag }}"
|
args: "Built application artifacts. Building images..."
|
||||||
|
- 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
|
||||||
@@ -68,7 +73,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.githubTagAction.outputs.new_tag }}
|
type=raw,value=${{ steps.semver.outputs.next }}
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
id: buildPushAction
|
id: buildPushAction
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
@@ -86,4 +91,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: "Image built with ID ${{ steps.buildPushAction.outputs.imageid }}"
|
args: "Deployment of image has completed. Image ID is '${{ steps.buildPushAction.outputs.imageid }}'. This was a dry run."
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
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 }}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
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
|
||||||
+4
-1
@@ -51,4 +51,7 @@ yarn-error.log*
|
|||||||
db.sqlite
|
db.sqlite
|
||||||
|
|
||||||
# logs
|
# logs
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
apps/tasks/tasks.cjs
|
||||||
|
apps/websocket/wssServer.cjs
|
||||||
Vendored
+4
-2
@@ -6,9 +6,11 @@
|
|||||||
],
|
],
|
||||||
"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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+13
-3
@@ -1,4 +1,4 @@
|
|||||||
FROM node:20.12.2-alpine AS base
|
FROM node:20.14.0-alpine AS base
|
||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat
|
||||||
@@ -35,6 +35,7 @@ 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
|
||||||
@@ -42,6 +43,8 @@ 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
|
||||||
|
|
||||||
@@ -49,10 +52,15 @@ 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 .
|
||||||
@@ -63,7 +71,6 @@ 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
|
||||||
@@ -71,7 +78,10 @@ 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='/app/db/db.sqlite'
|
ENV DB_URL='/appdata/db/db.sqlite'
|
||||||
|
ENV DB_DIALECT='sqlite'
|
||||||
|
ENV DB_DRIVER='better-sqlite3'
|
||||||
|
|
||||||
CMD ["sh", "run.sh"]
|
CMD ["sh", "run.sh"]
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# 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
-14
@@ -1,6 +1,6 @@
|
|||||||
// Importing env files here to validate on build
|
// Importing env files here to validate on build
|
||||||
import "./src/env.mjs";
|
|
||||||
import "@homarr/auth/env.mjs";
|
import "@homarr/auth/env.mjs";
|
||||||
|
import "./src/env.mjs";
|
||||||
|
|
||||||
/** @type {import("next").NextConfig} */
|
/** @type {import("next").NextConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
@@ -9,20 +9,19 @@ 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 },
|
||||||
experimental: {
|
webpack: (config) => {
|
||||||
optimizePackageImports: [
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||||
"@mantine/core",
|
config.module.rules.push({
|
||||||
"@mantine/hooks",
|
test: /\.node$/,
|
||||||
"@tabler/icons-react",
|
loader: "node-loader",
|
||||||
],
|
});
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||||
|
return config;
|
||||||
},
|
},
|
||||||
transpilePackages: [
|
experimental: {
|
||||||
"@homarr/ui",
|
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
|
||||||
"@homarr/notifications",
|
},
|
||||||
"@homarr/modals",
|
transpilePackages: ["@homarr/ui", "@homarr/notifications", "@homarr/modals", "@homarr/spotlight", "@homarr/widgets"],
|
||||||
"@homarr/spotlight",
|
|
||||||
"@homarr/widgets",
|
|
||||||
],
|
|
||||||
images: {
|
images: {
|
||||||
domains: ["cdn.jsdelivr.net"],
|
domains: ["cdn.jsdelivr.net"],
|
||||||
},
|
},
|
||||||
|
|||||||
+22
-14
@@ -14,6 +14,7 @@
|
|||||||
"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",
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
"@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",
|
||||||
@@ -29,15 +31,16 @@
|
|||||||
"@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.9.0",
|
"@mantine/colors-generator": "^7.10.1",
|
||||||
"@mantine/hooks": "^7.9.0",
|
"@mantine/hooks": "^7.10.1",
|
||||||
"@mantine/modals": "^7.9.0",
|
"@mantine/modals": "^7.10.1",
|
||||||
"@mantine/tiptap": "^7.9.0",
|
"@mantine/tiptap": "^7.10.1",
|
||||||
|
"@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.34.1",
|
"@tanstack/react-query": "^5.40.0",
|
||||||
"@tanstack/react-query-devtools": "^5.34.1",
|
"@tanstack/react-query-devtools": "^5.40.0",
|
||||||
"@tanstack/react-query-next-experimental": "5.34.1",
|
"@tanstack/react-query-next-experimental": "5.40.0",
|
||||||
"@trpc/client": "11.0.0-rc.364",
|
"@trpc/client": "11.0.0-rc.382",
|
||||||
"@trpc/next": "next",
|
"@trpc/next": "next",
|
||||||
"@trpc/react-query": "next",
|
"@trpc/react-query": "next",
|
||||||
"@trpc/server": "next",
|
"@trpc/server": "next",
|
||||||
@@ -45,15 +48,19 @@
|
|||||||
"@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",
|
||||||
"glob": "^10.3.12",
|
"flag-icons": "^7.2.3",
|
||||||
"jotai": "^2.8.0",
|
"glob": "^10.4.1",
|
||||||
|
"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",
|
||||||
"sass": "^1.76.0",
|
"react-error-boundary": "^4.0.13",
|
||||||
|
"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"
|
||||||
},
|
},
|
||||||
@@ -62,13 +69,14 @@
|
|||||||
"@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.8",
|
"@types/node": "^20.12.13",
|
||||||
"@types/react": "^18.3.1",
|
"@types/react": "^18.3.3",
|
||||||
"@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.9.0",
|
"tsx": "4.11.0",
|
||||||
"typescript": "^5.4.5"
|
"typescript": "^5.4.5"
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
|
|||||||
@@ -3,10 +3,7 @@ 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 = ({
|
export const NextInternationalProvider = ({ children, locale }: PropsWithChildren<{ locale: string }>) => {
|
||||||
children,
|
|
||||||
locale,
|
|
||||||
}: PropsWithChildren<{ locale: string }>) => {
|
|
||||||
return (
|
return (
|
||||||
<I18nProviderClient locale={locale} fallback={defaultLocale}>
|
<I18nProviderClient locale={locale} fallback={defaultLocale}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -9,9 +9,6 @@ interface AuthProviderProps {
|
|||||||
session: Session | null;
|
session: Session | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AuthProvider = ({
|
export const AuthProvider = ({ children, session }: PropsWithChildren<AuthProviderProps>) => {
|
||||||
children,
|
|
||||||
session,
|
|
||||||
}: PropsWithChildren<AuthProviderProps>) => {
|
|
||||||
return <SessionProvider session={session}>{children}</SessionProvider>;
|
return <SessionProvider session={session}>{children}</SessionProvider>;
|
||||||
};
|
};
|
||||||
@@ -5,12 +5,7 @@ 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 {
|
import { createWSClient, loggerLink, unstable_httpBatchStreamLink, wsLink } from "@trpc/client";
|
||||||
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";
|
||||||
@@ -37,8 +32,7 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
|||||||
links: [
|
links: [
|
||||||
loggerLink({
|
loggerLink({
|
||||||
enabled: (opts) =>
|
enabled: (opts) =>
|
||||||
process.env.NODE_ENV === "development" ||
|
process.env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error),
|
||||||
(opts.direction === "down" && opts.result instanceof Error),
|
|
||||||
}),
|
}),
|
||||||
(args) => {
|
(args) => {
|
||||||
return ({ op, next }) => {
|
return ({ op, next }) => {
|
||||||
@@ -69,9 +63,7 @@ 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}>
|
<ReactQueryStreamedHydration transformer={superjson}>{props.children}</ReactQueryStreamedHydration>
|
||||||
{props.children}
|
|
||||||
</ReactQueryStreamedHydration>
|
|
||||||
<ReactQueryDevtools initialIsOpen={false} />
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</clientApi.Provider>
|
</clientApi.Provider>
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
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,18 +2,12 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import { Alert, Button, PasswordInput, rem, Stack, TextInput } from "@mantine/core";
|
||||||
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 { useForm, zodResolver } from "@homarr/form";
|
import { useZodForm } 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";
|
||||||
@@ -23,15 +17,14 @@ 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 = useForm<FormType>({
|
const form = useZodForm(validation.user.signIn, {
|
||||||
validate: zodResolver(validation.user.signIn),
|
|
||||||
initialValues: {
|
initialValues: {
|
||||||
name: "",
|
name: "",
|
||||||
password: "",
|
password: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = async (values: FormType) => {
|
const handleSubmitAsync = async (values: z.infer<typeof validation.user.signIn>) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
await signIn("credentials", {
|
await signIn("credentials", {
|
||||||
@@ -44,28 +37,30 @@ export const LoginForm = () => {
|
|||||||
throw response?.error;
|
throw response?.error;
|
||||||
}
|
}
|
||||||
|
|
||||||
void router.push("/");
|
showSuccessNotification({
|
||||||
|
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 handleSubmit(values))}>
|
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<TextInput
|
<TextInput label={t("field.username.label")} {...form.getInputProps("name")} />
|
||||||
label={t("field.username.label")}
|
<PasswordInput label={t("field.password.label")} {...form.getInputProps("password")} />
|
||||||
{...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")}
|
{t("action.login.label")}
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
@@ -78,5 +73,3 @@ export const LoginForm = () => {
|
|||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type FormType = z.infer<typeof validation.user.signIn>;
|
|
||||||
+2
-2
@@ -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 getInitialBoard() {
|
async getInitialBoardAsync() {
|
||||||
return await api.board.getDefaultBoard();
|
return await api.board.getHomeBoard();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
File renamed without changes.
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import definition from "./_definition";
|
import definition from "./_definition";
|
||||||
|
|
||||||
const { generateMetadata, page } = definition;
|
const { generateMetadataAsync: 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 getInitialBoard({ name }) {
|
async getInitialBoardAsync({ 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 { generateMetadata, page } = definition;
|
const { generateMetadataAsync: generateMetadata, page } = definition;
|
||||||
|
|
||||||
export default page;
|
export default page;
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,7 @@ export const updateBoardName = (name: string | null) => {
|
|||||||
boardName = name;
|
boardName = name;
|
||||||
};
|
};
|
||||||
|
|
||||||
type UpdateCallback = (
|
type UpdateCallback = (prev: RouterOutputs["board"]["getHomeBoard"]) => RouterOutputs["board"]["getHomeBoard"];
|
||||||
prev: RouterOutputs["board"]["getDefaultBoard"],
|
|
||||||
) => RouterOutputs["board"]["getDefaultBoard"];
|
|
||||||
|
|
||||||
export const useUpdateBoard = () => {
|
export const useUpdateBoard = () => {
|
||||||
const utils = clientApi.useUtils();
|
const utils = clientApi.useUtils();
|
||||||
@@ -46,9 +44,7 @@ export const ClientBoard = () => {
|
|||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const isReady = useIsBoardReady();
|
const isReady = useIsBoardReady();
|
||||||
|
|
||||||
const sortedSections = board.sections.sort(
|
const sortedSections = board.sections.sort((sectionA, sectionB) => sectionA.position - sectionB.position);
|
||||||
(sectionA, sectionB) => sectionA.position - sectionB.position,
|
|
||||||
);
|
|
||||||
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -61,24 +57,12 @@ export const ClientBoard = () => {
|
|||||||
loaderProps={{ size: "lg" }}
|
loaderProps={{ size: "lg" }}
|
||||||
h={fullHeightWithoutHeaderAndFooter}
|
h={fullHeightWithoutHeaderAndFooter}
|
||||||
/>
|
/>
|
||||||
<Stack
|
<Stack ref={ref} h="100%" style={{ visibility: isReady ? "visible" : "hidden" }}>
|
||||||
ref={ref}
|
|
||||||
h="100%"
|
|
||||||
style={{ visibility: isReady ? "visible" : "hidden" }}
|
|
||||||
>
|
|
||||||
{sortedSections.map((section) =>
|
{sortedSections.map((section) =>
|
||||||
section.kind === "empty" ? (
|
section.kind === "empty" ? (
|
||||||
<BoardEmptySection
|
<BoardEmptySection key={section.id} section={section} mainRef={ref} />
|
||||||
key={section.id}
|
|
||||||
section={section}
|
|
||||||
mainRef={ref}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<BoardCategorySection
|
<BoardCategorySection key={section.id} section={section} mainRef={ref} />
|
||||||
key={section.id}
|
|
||||||
section={section}
|
|
||||||
mainRef={ref}
|
|
||||||
/>
|
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { PropsWithChildren } from "react";
|
import type { Dispatch, PropsWithChildren, SetStateAction } from "react";
|
||||||
import {
|
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||||
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";
|
||||||
@@ -16,9 +10,11 @@ import { clientApi } from "@homarr/api/client";
|
|||||||
import { updateBoardName } from "./_client";
|
import { updateBoardName } from "./_client";
|
||||||
|
|
||||||
const BoardContext = createContext<{
|
const BoardContext = createContext<{
|
||||||
board: RouterOutputs["board"]["getDefaultBoard"];
|
board: RouterOutputs["board"]["getHomeBoard"];
|
||||||
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 = ({
|
||||||
@@ -30,11 +26,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,
|
||||||
},
|
},
|
||||||
@@ -52,18 +48,12 @@ export const BoardProvider = ({
|
|||||||
}, [pathname, utils, initialBoard.name]);
|
}, [pathname, utils, initialBoard.name]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setReadySections((previous) =>
|
setReadySections((previous) => previous.filter((id) => data.sections.some((section) => section.id === id)));
|
||||||
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) =>
|
setReadySections((previous) => (previous.includes(id) ? previous : [...previous, id]));
|
||||||
previous.includes(id) ? previous : [...previous, id],
|
|
||||||
);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -72,6 +62,8 @@ 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}
|
||||||
@@ -114,3 +106,13 @@ 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,11 +1,12 @@
|
|||||||
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";
|
||||||
@@ -14,33 +15,28 @@ 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> {
|
||||||
getInitialBoard: (params: TParams) => Promise<Board>;
|
getInitialBoardAsync: (params: TParams) => Promise<Board>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createBoardContentPage = <
|
export const createBoardContentPage = <TParams extends Record<string, unknown>>({
|
||||||
TParams extends Record<string, unknown>,
|
getInitialBoardAsync: getInitialBoard,
|
||||||
>({
|
|
||||||
getInitialBoard,
|
|
||||||
}: Props<TParams>) => {
|
}: Props<TParams>) => {
|
||||||
return {
|
return {
|
||||||
layout: createBoardLayout({
|
layout: createBoardLayout({
|
||||||
headerActions: <BoardContentHeaderActions />,
|
headerActions: <BoardContentHeaderActions />,
|
||||||
getInitialBoard,
|
getInitialBoardAsync: getInitialBoard,
|
||||||
isBoardContentPage: true,
|
isBoardContentPage: true,
|
||||||
}),
|
}),
|
||||||
page: () => {
|
page: () => {
|
||||||
return <ClientBoard />;
|
return <ClientBoard />;
|
||||||
},
|
},
|
||||||
generateMetadata: async ({
|
generateMetadataAsync: async ({ params }: { params: TParams }): Promise<Metadata> => {
|
||||||
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 ?? `${capitalize(board.name)} board | Homarr`,
|
title: board.metaTitle ?? createMetaTitle(t("board.content.metaTitle", { boardName: board.name })),
|
||||||
icons: {
|
icons: {
|
||||||
icon: board.faviconImageUrl ? board.faviconImageUrl : undefined,
|
icon: board.faviconImageUrl ? board.faviconImageUrl : undefined,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRequiredBoard } from "./_context";
|
||||||
|
|
||||||
|
export const CustomCss = () => {
|
||||||
|
const board = useRequiredBoard();
|
||||||
|
|
||||||
|
return <style>{board.customCss}</style>;
|
||||||
|
};
|
||||||
@@ -12,27 +12,22 @@ 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 {
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
showErrorNotification,
|
|
||||||
showSuccessNotification,
|
|
||||||
} from "@homarr/notifications";
|
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { revalidatePathAction } from "~/app/revalidatePathAction";
|
import { revalidatePathActionAsync } 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 { useRequiredBoard } from "./_context";
|
import { useEditMode, useRequiredBoard } from "./_context";
|
||||||
|
|
||||||
export const BoardContentHeaderActions = () => {
|
export const BoardContentHeaderActions = () => {
|
||||||
const isEditMode = useAtomValue(editModeAtom);
|
const [isEditMode] = useEditMode();
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const { hasChangeAccess } = useBoardPermissions(board);
|
const { hasChangeAccess } = useBoardPermissions(board);
|
||||||
|
|
||||||
@@ -54,8 +49,7 @@ export const BoardContentHeaderActions = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const AddMenu = () => {
|
const AddMenu = () => {
|
||||||
const { openModal: openCategoryEditModal } =
|
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
|
||||||
useModalAction(CategoryEditModal);
|
|
||||||
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
||||||
const { addCategoryToEnd } = useCategoryActions();
|
const { addCategoryToEnd } = useCategoryActions();
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
@@ -95,22 +89,14 @@ const AddMenu = () => {
|
|||||||
</HeaderButton>
|
</HeaderButton>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
|
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
|
||||||
<Menu.Item
|
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectItem}>
|
||||||
leftSection={<IconBox size={20} />}
|
|
||||||
onClick={handleSelectItem}
|
|
||||||
>
|
|
||||||
{t("item.action.create")}
|
{t("item.action.create")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item leftSection={<IconPackageImport size={20} />}>
|
<Menu.Item leftSection={<IconPackageImport size={20} />}>{t("item.action.import")}</Menu.Item>
|
||||||
{t("item.action.import")}
|
|
||||||
</Menu.Item>
|
|
||||||
|
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|
||||||
<Menu.Item
|
<Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}>
|
||||||
leftSection={<IconBoxAlignTop size={20} />}
|
|
||||||
onClick={handleAddCategory}
|
|
||||||
>
|
|
||||||
{t("section.category.action.create")}
|
{t("section.category.action.create")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
@@ -119,28 +105,27 @@ const AddMenu = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const EditModeMenu = () => {
|
const EditModeMenu = () => {
|
||||||
const [isEditMode, setEditMode] = useAtom(editModeAtom);
|
const [isEditMode, setEditMode] = useEditMode();
|
||||||
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 } =
|
const { mutate: saveBoard, isPending } = clientApi.board.saveBoard.useMutation({
|
||||||
clientApi.board.saveBoard.useMutation({
|
onSuccess() {
|
||||||
onSuccess() {
|
showSuccessNotification({
|
||||||
showSuccessNotification({
|
title: t("notification.success.title"),
|
||||||
title: t("notification.success.title"),
|
message: t("notification.success.message"),
|
||||||
message: t("notification.success.message"),
|
});
|
||||||
});
|
void utils.board.getBoardByName.invalidate({ name: board.name });
|
||||||
void utils.board.getBoardByName.invalidate({ name: board.name });
|
void revalidatePathActionAsync(`/boards/${board.name}`);
|
||||||
void revalidatePathAction(`/boards/${board.name}`);
|
setEditMode(false);
|
||||||
setEditMode(false);
|
},
|
||||||
},
|
onError() {
|
||||||
onError() {
|
showErrorNotification({
|
||||||
showErrorNotification({
|
title: t("notification.error.title"),
|
||||||
title: t("notification.error.title"),
|
message: t("notification.error.message"),
|
||||||
message: t("notification.error.message"),
|
});
|
||||||
});
|
},
|
||||||
},
|
});
|
||||||
});
|
|
||||||
|
|
||||||
const toggle = useCallback(() => {
|
const toggle = useCallback(() => {
|
||||||
if (isEditMode) return saveBoard(board);
|
if (isEditMode) return saveBoard(board);
|
||||||
@@ -149,11 +134,7 @@ const EditModeMenu = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<HeaderButton onClick={toggle} loading={isPending}>
|
<HeaderButton onClick={toggle} loading={isPending}>
|
||||||
{isEditMode ? (
|
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
|
||||||
<IconPencilOff stroke={1.5} />
|
|
||||||
) : (
|
|
||||||
<IconPencil stroke={1.5} />
|
|
||||||
)}
|
|
||||||
</HeaderButton>
|
</HeaderButton>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -22,9 +22,7 @@ export const BoardMantineProvider = ({ children }: PropsWithChildren) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const generateColors = (hex: string) => {
|
export const generateColors = (hex: string) => {
|
||||||
const lightnessForColors = [
|
const lightnessForColors = [-0.25, -0.2, -0.15, -0.1, -0.05, 0, 0.05, 0.1, 0.15, 0.2] as const;
|
||||||
-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 getInitialBoard({ name }) {
|
async getInitialBoardAsync({ name }) {
|
||||||
return await api.board.getBoardByName({ name });
|
return await api.board.getBoardByName({ name });
|
||||||
},
|
},
|
||||||
isBoardContentPage: false,
|
isBoardContentPage: false,
|
||||||
|
|||||||
@@ -44,11 +44,7 @@ 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
|
<TabItem value="inherited" count={initialPermissions.inherited.length} icon={IconUserDown} />
|
||||||
value="inherited"
|
|
||||||
count={initialPermissions.inherited.length}
|
|
||||||
icon={IconUserDown}
|
|
||||||
/>
|
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
<Tabs.Panel value="user">
|
<Tabs.Panel value="user">
|
||||||
|
|||||||
+7
-37
@@ -1,21 +1,8 @@
|
|||||||
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 {
|
import { Button, Flex, Group, Select, TableTd, TableTr, Text } from "@mantine/core";
|
||||||
Button,
|
import { IconCheck, IconEye, IconPencil, IconSettings } from "@tabler/icons-react";
|
||||||
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";
|
||||||
@@ -38,12 +25,7 @@ interface BoardAccessSelectRowProps {
|
|||||||
onCountChange: OnCountChange;
|
onCountChange: OnCountChange;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BoardAccessSelectRow = ({
|
export const BoardAccessSelectRow = ({ itemContent, permission, index, onCountChange }: BoardAccessSelectRowProps) => {
|
||||||
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();
|
||||||
@@ -61,11 +43,7 @@ export const BoardAccessSelectRow = ({
|
|||||||
<TableTr>
|
<TableTr>
|
||||||
<TableTd w={{ sm: 128, lg: 256 }}>{itemContent}</TableTd>
|
<TableTd w={{ sm: 128, lg: 256 }}>{itemContent}</TableTd>
|
||||||
<TableTd>
|
<TableTd>
|
||||||
<Flex
|
<Flex direction={{ base: "column", xs: "row" }} align={{ base: "end", xs: "center" }} wrap="nowrap">
|
||||||
direction={{ base: "column", xs: "row" }}
|
|
||||||
align={{ base: "end", xs: "center" }}
|
|
||||||
wrap="nowrap"
|
|
||||||
>
|
|
||||||
<Select
|
<Select
|
||||||
allowDeselect={false}
|
allowDeselect={false}
|
||||||
flex="1"
|
flex="1"
|
||||||
@@ -93,10 +71,7 @@ interface BoardAccessDisplayRowProps {
|
|||||||
permission: BoardPermission | "board-full";
|
permission: BoardPermission | "board-full";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BoardAccessDisplayRow = ({
|
export const BoardAccessDisplayRow = ({ itemContent, permission }: BoardAccessDisplayRowProps) => {
|
||||||
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];
|
||||||
|
|
||||||
@@ -106,10 +81,7 @@ export const BoardAccessDisplayRow = ({
|
|||||||
<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
|
<Icon size="1rem" color="var(--input-section-color, var(--mantine-color-dimmed))" />
|
||||||
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>
|
||||||
@@ -131,9 +103,7 @@ 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 && (
|
{checked && <IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />}
|
||||||
<IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />
|
|
||||||
)}
|
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -8,7 +8,6 @@ export interface BoardAccessFormType {
|
|||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const [FormProvider, useFormContext, useForm] =
|
export const [FormProvider, useFormContext, useForm] = createFormContext<BoardAccessFormType>();
|
||||||
createFormContext<BoardAccessFormType>();
|
|
||||||
|
|
||||||
export type OnCountChange = (callback: (prev: number) => number) => void;
|
export type OnCountChange = (callback: (prev: number) => number) => void;
|
||||||
@@ -1,16 +1,6 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import { Anchor, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core";
|
||||||
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";
|
||||||
@@ -24,30 +14,21 @@ 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 = ({
|
export const GroupsForm = ({ board, initialPermissions, onCountChange }: FormProps) => {
|
||||||
board,
|
const { mutate, isPending } = clientApi.board.saveGroupBoardPermissions.useMutation();
|
||||||
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(
|
new Map(initialPermissions.groupPermissions.map(({ group }) => [group.id, group])),
|
||||||
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(
|
items: initialPermissions.groupPermissions.map(({ group, permission }) => ({
|
||||||
({ group, permission }) => ({
|
itemId: group.id,
|
||||||
itemId: group.id,
|
permission,
|
||||||
permission,
|
})),
|
||||||
}),
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -92,9 +73,7 @@ export const GroupsForm = ({
|
|||||||
<Table>
|
<Table>
|
||||||
<TableThead>
|
<TableThead>
|
||||||
<TableTr>
|
<TableTr>
|
||||||
<TableTh style={{ whiteSpace: "nowrap" }}>
|
<TableTh style={{ whiteSpace: "nowrap" }}>{tPermissions("field.group.label")}</TableTh>
|
||||||
{tPermissions("field.group.label")}
|
|
||||||
</TableTh>
|
|
||||||
<TableTh>{tPermissions("field.permission.label")}</TableTh>
|
<TableTh>{tPermissions("field.permission.label")}</TableTh>
|
||||||
</TableTr>
|
</TableTr>
|
||||||
</TableThead>
|
</TableThead>
|
||||||
@@ -102,9 +81,7 @@ export const GroupsForm = ({
|
|||||||
{form.values.items.map((row, index) => (
|
{form.values.items.map((row, index) => (
|
||||||
<BoardAccessSelectRow
|
<BoardAccessSelectRow
|
||||||
key={row.itemId}
|
key={row.itemId}
|
||||||
itemContent={
|
itemContent={<GroupItemContent group={groups.get(row.itemId)!} />}
|
||||||
<GroupItemContent group={groups.get(row.itemId)!} />
|
|
||||||
}
|
|
||||||
permission={row.permission}
|
permission={row.permission}
|
||||||
index={index}
|
index={index}
|
||||||
onCountChange={onCountChange}
|
onCountChange={onCountChange}
|
||||||
@@ -114,11 +91,7 @@ export const GroupsForm = ({
|
|||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Button
|
<Button rightSection={<IconPlus size="1rem" />} variant="light" onClick={handleAddUser}>
|
||||||
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">
|
||||||
@@ -133,16 +106,10 @@ export const GroupsForm = ({
|
|||||||
|
|
||||||
export const GroupItemContent = ({ group }: { group: Group }) => {
|
export const GroupItemContent = ({ group }: { group: Group }) => {
|
||||||
return (
|
return (
|
||||||
<Anchor
|
<Anchor component={Link} href={`/manage/users/groups/${group.id}`} size="sm" style={{ whiteSpace: "nowrap" }}>
|
||||||
component={Link}
|
|
||||||
href={`/manage/users/groups/${group.id}`}
|
|
||||||
size="sm"
|
|
||||||
style={{ whiteSpace: "nowrap" }}
|
|
||||||
>
|
|
||||||
{group.name}
|
{group.name}
|
||||||
</Anchor>
|
</Anchor>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type Group =
|
type Group = RouterOutputs["board"]["getBoardPermissions"]["groupPermissions"][0]["group"];
|
||||||
RouterOutputs["board"]["getBoardPermissions"]["groupPermissions"][0]["group"];
|
|
||||||
+45
-50
@@ -16,57 +16,52 @@ interface GroupSelectFormType {
|
|||||||
groupId: string;
|
groupId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GroupSelectModal = createModal<InnerProps>(
|
export const GroupSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
|
||||||
({ actions, innerProps }) => {
|
const t = useI18n();
|
||||||
const t = useI18n();
|
const { data: groups, isPending } = clientApi.group.selectable.useQuery();
|
||||||
const { data: groups, isPending } = clientApi.group.selectable.useQuery();
|
const [loading, setLoading] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const form = useForm<GroupSelectFormType>();
|
||||||
const form = useForm<GroupSelectFormType>();
|
const handleSubmitAsync = async (values: GroupSelectFormType) => {
|
||||||
const handleSubmit = async (values: GroupSelectFormType) => {
|
const currentGroup = groups?.find((group) => group.id === values.groupId);
|
||||||
const currentGroup = groups?.find((group) => group.id === values.groupId);
|
if (!currentGroup) return;
|
||||||
if (!currentGroup) return;
|
setLoading(true);
|
||||||
setLoading(true);
|
await innerProps.onSelect({
|
||||||
await innerProps.onSelect({
|
id: currentGroup.id,
|
||||||
id: currentGroup.id,
|
name: currentGroup.name,
|
||||||
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 handleSubmit(values))}>
|
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(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(
|
?.filter((group) => !innerProps.presentGroupIds.includes(group.id))
|
||||||
(group) => !innerProps.presentGroupIds.includes(group.id),
|
.map((group) => ({ value: group.id, label: group.name }))}
|
||||||
)
|
/>
|
||||||
.map((group) => ({ value: group.id, label: group.name }))}
|
<Group justify="end">
|
||||||
/>
|
<Button variant="default" onClick={actions.closeModal}>
|
||||||
<Group justify="end">
|
{t("common.action.cancel")}
|
||||||
<Button variant="default" onClick={actions.closeModal}>
|
</Button>
|
||||||
{t("common.action.cancel")}
|
<Button type="submit" loading={loading}>
|
||||||
</Button>
|
{confirmLabel}
|
||||||
<Button type="submit" loading={loading}>
|
</Button>
|
||||||
{confirmLabel}
|
</Group>
|
||||||
</Button>
|
</Stack>
|
||||||
</Group>
|
</form>
|
||||||
</Stack>
|
);
|
||||||
</form>
|
}).withOptions({
|
||||||
);
|
defaultTitle: (t) => t("board.setting.section.access.permission.groupSelect.title"),
|
||||||
},
|
|
||||||
).withOptions({
|
|
||||||
defaultTitle: (t) =>
|
|
||||||
t("board.setting.section.access.permission.groupSelect.title"),
|
|
||||||
});
|
});
|
||||||
@@ -1,11 +1,4 @@
|
|||||||
import {
|
import { Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core";
|
||||||
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";
|
||||||
@@ -41,9 +34,7 @@ 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(
|
: getPermissionsWithChildren([permission]).includes("board-full-access")
|
||||||
"board-full-access",
|
|
||||||
)
|
|
||||||
? "board-full"
|
? "board-full"
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,6 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import { Anchor, Box, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core";
|
||||||
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";
|
||||||
@@ -21,10 +10,7 @@ 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 {
|
import { BoardAccessDisplayRow, BoardAccessSelectRow } from "./board-access-table-rows";
|
||||||
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";
|
||||||
@@ -35,18 +21,11 @@ export interface FormProps {
|
|||||||
onCountChange: OnCountChange;
|
onCountChange: OnCountChange;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UsersForm = ({
|
export const UsersForm = ({ board, initialPermissions, onCountChange }: FormProps) => {
|
||||||
board,
|
const { mutate, isPending } = clientApi.board.saveUserBoardPermissions.useMutation();
|
||||||
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(
|
new Map(initialPermissions.userPermissions.map(({ user }) => [user.id, user])),
|
||||||
initialPermissions.userPermissions.map(({ user }) => [user.id, user]),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
const { openModal } = useModalAction(UserSelectModal);
|
const { openModal } = useModalAction(UserSelectModal);
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
@@ -81,9 +60,7 @@ export const UsersForm = ({
|
|||||||
const presentUserIds = form.values.items.map(({ itemId: id }) => id);
|
const presentUserIds = form.values.items.map(({ itemId: id }) => id);
|
||||||
|
|
||||||
openModal({
|
openModal({
|
||||||
presentUserIds: board.creatorId
|
presentUserIds: board.creatorId ? presentUserIds.concat(board.creatorId) : presentUserIds,
|
||||||
? 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", [
|
||||||
@@ -111,17 +88,12 @@ export const UsersForm = ({
|
|||||||
</TableThead>
|
</TableThead>
|
||||||
<TableTbody>
|
<TableTbody>
|
||||||
{board.creator && (
|
{board.creator && (
|
||||||
<BoardAccessDisplayRow
|
<BoardAccessDisplayRow itemContent={<UserItemContent user={board.creator} />} permission="board-full" />
|
||||||
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={
|
itemContent={<UserItemContent user={users.get(row.itemId)!} />}
|
||||||
<UserItemContent user={users.get(row.itemId)!} />
|
|
||||||
}
|
|
||||||
permission={row.permission}
|
permission={row.permission}
|
||||||
index={index}
|
index={index}
|
||||||
onCountChange={onCountChange}
|
onCountChange={onCountChange}
|
||||||
@@ -131,11 +103,7 @@ export const UsersForm = ({
|
|||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Button
|
<Button rightSection={<IconPlus size="1rem" />} variant="light" onClick={handleAddUser}>
|
||||||
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">
|
||||||
@@ -154,12 +122,7 @@ const UserItemContent = ({ user }: { user: User }) => {
|
|||||||
<Box visibleFrom="xs">
|
<Box visibleFrom="xs">
|
||||||
<UserAvatar user={user} size="sm" />
|
<UserAvatar user={user} size="sm" />
|
||||||
</Box>
|
</Box>
|
||||||
<Anchor
|
<Anchor component={Link} href={`/manage/users/${user.id}`} size="sm" style={{ whiteSpace: "nowrap" }}>
|
||||||
component={Link}
|
|
||||||
href={`/manage/users/${user.id}`}
|
|
||||||
size="sm"
|
|
||||||
style={{ whiteSpace: "nowrap" }}
|
|
||||||
>
|
|
||||||
{user.name}
|
{user.name}
|
||||||
</Anchor>
|
</Anchor>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -12,11 +12,7 @@ import { UserAvatar } from "@homarr/ui";
|
|||||||
|
|
||||||
interface InnerProps {
|
interface InnerProps {
|
||||||
presentUserIds: string[];
|
presentUserIds: string[];
|
||||||
onSelect: (props: {
|
onSelect: (props: { id: string; name: string; image: string }) => void | Promise<void>;
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
image: string;
|
|
||||||
}) => void | Promise<void>;
|
|
||||||
confirmLabel?: string;
|
confirmLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,66 +20,59 @@ interface UserSelectFormType {
|
|||||||
userId: string;
|
userId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UserSelectModal = createModal<InnerProps>(
|
export const UserSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
|
||||||
({ actions, innerProps }) => {
|
const t = useI18n();
|
||||||
const t = useI18n();
|
const { data: users, isPending } = clientApi.user.selectable.useQuery();
|
||||||
const { data: users, isPending } = clientApi.user.selectable.useQuery();
|
const [loading, setLoading] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const form = useForm<UserSelectFormType>();
|
||||||
const form = useForm<UserSelectFormType>();
|
const handleSubmitAsync = async (values: UserSelectFormType) => {
|
||||||
const handleSubmit = async (values: UserSelectFormType) => {
|
const currentUser = users?.find((user) => user.id === values.userId);
|
||||||
const currentUser = users?.find((user) => user.id === values.userId);
|
if (!currentUser) return;
|
||||||
if (!currentUser) return;
|
setLoading(true);
|
||||||
setLoading(true);
|
await innerProps.onSelect({
|
||||||
await innerProps.onSelect({
|
id: currentUser.id,
|
||||||
id: currentUser.id,
|
name: currentUser.name ?? "",
|
||||||
name: currentUser.name ?? "",
|
image: currentUser.image ?? "",
|
||||||
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 handleSubmit(values))}>
|
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(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 ? (
|
isPending ? <Loader size="xs" /> : currentUser ? <UserAvatar user={currentUser} size="xs" /> : undefined
|
||||||
<Loader size="xs" />
|
}
|
||||||
) : currentUser ? (
|
nothingFoundMessage={t("user.action.select.notFound")}
|
||||||
<UserAvatar user={currentUser} size="xs" />
|
renderOption={createRenderOption(users ?? [])}
|
||||||
) : undefined
|
limit={5}
|
||||||
}
|
data={users
|
||||||
nothingFoundMessage={t("user.action.select.notFound")}
|
?.filter((user) => !innerProps.presentUserIds.includes(user.id))
|
||||||
renderOption={createRenderOption(users ?? [])}
|
.map((user) => ({ value: user.id, label: user.name ?? "" }))}
|
||||||
limit={5}
|
/>
|
||||||
data={users
|
<Group justify="end">
|
||||||
?.filter((user) => !innerProps.presentUserIds.includes(user.id))
|
<Button variant="default" onClick={actions.closeModal}>
|
||||||
.map((user) => ({ value: user.id, label: user.name ?? "" }))}
|
{t("common.action.cancel")}
|
||||||
/>
|
</Button>
|
||||||
<Group justify="end">
|
<Button type="submit" loading={loading}>
|
||||||
<Button variant="default" onClick={actions.closeModal}>
|
{confirmLabel}
|
||||||
{t("common.action.cancel")}
|
</Button>
|
||||||
</Button>
|
</Group>
|
||||||
<Button type="submit" loading={loading}>
|
</Stack>
|
||||||
{confirmLabel}
|
</form>
|
||||||
</Button>
|
);
|
||||||
</Group>
|
}).withOptions({
|
||||||
</Stack>
|
defaultTitle: (t) => t("board.setting.section.access.permission.userSelect.title"),
|
||||||
</form>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
).withOptions({
|
|
||||||
defaultTitle: (t) =>
|
|
||||||
t("board.setting.section.access.permission.userSelect.title"),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const iconProps = {
|
const iconProps = {
|
||||||
@@ -93,9 +82,7 @@ const iconProps = {
|
|||||||
size: "1rem",
|
size: "1rem",
|
||||||
};
|
};
|
||||||
|
|
||||||
const createRenderOption = (
|
const createRenderOption = (users: RouterOutputs["user"]["selectable"]): SelectProps["renderOption"] =>
|
||||||
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;
|
||||||
@@ -104,9 +91,7 @@ const createRenderOption = (
|
|||||||
<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 && (
|
{checked && <IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />}
|
||||||
<IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />
|
|
||||||
)}
|
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -2,16 +2,13 @@
|
|||||||
|
|
||||||
import { Button, Grid, Group, Stack, TextInput } from "@mantine/core";
|
import { Button, Grid, Group, Stack, TextInput } from "@mantine/core";
|
||||||
|
|
||||||
import {
|
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
|
||||||
backgroundImageAttachments,
|
import { useZodForm } from "@homarr/form";
|
||||||
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";
|
||||||
@@ -21,9 +18,8 @@ interface Props {
|
|||||||
}
|
}
|
||||||
export const BackgroundSettingsContent = ({ board }: Props) => {
|
export const BackgroundSettingsContent = ({ board }: Props) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { mutate: savePartialSettings, isPending } =
|
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
|
||||||
useSavePartialSettingsMutation(board);
|
const form = useZodForm(validation.board.savePartialSettings, {
|
||||||
const form = useForm({
|
|
||||||
initialValues: {
|
initialValues: {
|
||||||
backgroundImageUrl: board.backgroundImageUrl ?? "",
|
backgroundImageUrl: board.backgroundImageUrl ?? "",
|
||||||
backgroundImageAttachment: board.backgroundImageAttachment,
|
backgroundImageAttachment: board.backgroundImageAttachment,
|
||||||
@@ -36,14 +32,8 @@ export const BackgroundSettingsContent = ({ board }: Props) => {
|
|||||||
"backgroundImageAttachment",
|
"backgroundImageAttachment",
|
||||||
backgroundImageAttachments,
|
backgroundImageAttachments,
|
||||||
);
|
);
|
||||||
const backgroundImageSizeData = useBackgroundOptionData(
|
const backgroundImageSizeData = useBackgroundOptionData("backgroundImageSize", backgroundImageSizes);
|
||||||
"backgroundImageSize",
|
const backgroundImageRepeatData = useBackgroundOptionData("backgroundImageRepeat", backgroundImageRepeats);
|
||||||
backgroundImageSizes,
|
|
||||||
);
|
|
||||||
const backgroundImageRepeatData = useBackgroundOptionData(
|
|
||||||
"backgroundImageRepeat",
|
|
||||||
backgroundImageRepeats,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
@@ -95,13 +85,9 @@ export const BackgroundSettingsContent = ({ board }: Props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type BackgroundImageKey =
|
type BackgroundImageKey = "backgroundImageAttachment" | "backgroundImageSize" | "backgroundImageRepeat";
|
||||||
| "backgroundImageAttachment"
|
|
||||||
| "backgroundImageSize"
|
|
||||||
| "backgroundImageRepeat";
|
|
||||||
|
|
||||||
type inferOptions<TKey extends BackgroundImageKey> =
|
type inferOptions<TKey extends BackgroundImageKey> = TranslationObject["board"]["field"][TKey]["option"];
|
||||||
TranslationObject["board"]["field"][TKey]["option"];
|
|
||||||
|
|
||||||
const useBackgroundOptionData = <
|
const useBackgroundOptionData = <
|
||||||
TKey extends BackgroundImageKey,
|
TKey extends BackgroundImageKey,
|
||||||
@@ -119,9 +105,7 @@ 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(
|
description: t(`board.field.${key}.option.${value as string}.description` as never),
|
||||||
`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,8 +17,9 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
|
||||||
import { useForm } from "@homarr/form";
|
import { useZodForm } 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";
|
||||||
@@ -33,7 +34,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 = useForm({
|
const form = useZodForm(validation.board.savePartialSettings, {
|
||||||
initialValues: {
|
initialValues: {
|
||||||
primaryColor: board.primaryColor,
|
primaryColor: board.primaryColor,
|
||||||
secondaryColor: board.secondaryColor,
|
secondaryColor: board.secondaryColor,
|
||||||
@@ -43,8 +44,7 @@ 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 } =
|
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
|
||||||
useSavePartialSettingsMutation(board);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
@@ -76,11 +76,7 @@ export const ColorSettingsContent = ({ board }: Props) => {
|
|||||||
/>
|
/>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
<Grid.Col span={12}>
|
<Grid.Col span={12}>
|
||||||
<Anchor onClick={toggle}>
|
<Anchor onClick={toggle}>{showPreview ? t("common.preview.hide") : t("common.preview.show")}</Anchor>
|
||||||
{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}>
|
||||||
@@ -114,15 +110,13 @@ export const ColorSettingsContent = ({ board }: Props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface ColorsPreviewProps {
|
interface ColorsPreviewProps {
|
||||||
previewColor: string;
|
previewColor: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ColorsPreview = ({ previewColor }: ColorsPreviewProps) => {
|
const ColorsPreview = ({ previewColor }: ColorsPreviewProps) => {
|
||||||
const theme = useMantineTheme();
|
const theme = useMantineTheme();
|
||||||
|
|
||||||
const colors = hexRegex.test(previewColor)
|
const colors = previewColor && hexRegex.test(previewColor) ? generateColors(previewColor) : generateColors("#000000");
|
||||||
? generateColors(previewColor)
|
|
||||||
: generateColors("#000000");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group gap={0} wrap="nowrap">
|
<Group gap={0} wrap="nowrap">
|
||||||
|
|||||||
@@ -1,7 +1,91 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
// TODO: add some sort of store (maybe directory on GitHub)
|
import { Alert, Button, Group, Input, Stack } from "@mantine/core";
|
||||||
|
import { highlight, languages } from "prismjs";
|
||||||
|
import Editor from "react-simple-code-editor";
|
||||||
|
|
||||||
export const CustomCssSettingsContent = () => {
|
import "~/styles/prismjs.scss";
|
||||||
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,8 +20,7 @@ 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 } =
|
const { mutate: deleteBoard, isPending: isDeletePending } = clientApi.board.deleteBoard.useMutation();
|
||||||
clientApi.board.deleteBoard.useMutation();
|
|
||||||
const utils = clientApi.useUtils();
|
const utils = clientApi.useUtils();
|
||||||
const visibility = board.isPublic ? "public" : "private";
|
const visibility = board.isPublic ? "public" : "private";
|
||||||
|
|
||||||
@@ -37,12 +36,8 @@ export const DangerZoneSettingsContent = () => {
|
|||||||
|
|
||||||
const onVisibilityClick = useCallback(() => {
|
const onVisibilityClick = useCallback(() => {
|
||||||
openConfirmModal({
|
openConfirmModal({
|
||||||
title: t(
|
title: t(`section.dangerZone.action.visibility.confirm.${visibility}.title`),
|
||||||
`section.dangerZone.action.visibility.confirm.${visibility}.title`,
|
children: t(`section.dangerZone.action.visibility.confirm.${visibility}.description`),
|
||||||
),
|
|
||||||
children: t(
|
|
||||||
`section.dangerZone.action.visibility.confirm.${visibility}.description`,
|
|
||||||
),
|
|
||||||
onConfirm: () => {
|
onConfirm: () => {
|
||||||
changeVisibility(
|
changeVisibility(
|
||||||
{
|
{
|
||||||
@@ -52,7 +47,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.getDefaultBoard.invalidate();
|
void utils.board.getHomeBoard.invalidate();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -64,7 +59,7 @@ export const DangerZoneSettingsContent = () => {
|
|||||||
changeVisibility,
|
changeVisibility,
|
||||||
t,
|
t,
|
||||||
utils.board.getBoardByName,
|
utils.board.getBoardByName,
|
||||||
utils.board.getDefaultBoard,
|
utils.board.getHomeBoard,
|
||||||
visibility,
|
visibility,
|
||||||
openConfirmModal,
|
openConfirmModal,
|
||||||
]);
|
]);
|
||||||
@@ -98,12 +93,8 @@ export const DangerZoneSettingsContent = () => {
|
|||||||
<Divider />
|
<Divider />
|
||||||
<DangerZoneRow
|
<DangerZoneRow
|
||||||
label={t("section.dangerZone.action.visibility.label")}
|
label={t("section.dangerZone.action.visibility.label")}
|
||||||
description={t(
|
description={t(`section.dangerZone.action.visibility.description.${visibility}`)}
|
||||||
`section.dangerZone.action.visibility.description.${visibility}`,
|
buttonText={t(`section.dangerZone.action.visibility.button.${visibility}`)}
|
||||||
)}
|
|
||||||
buttonText={t(
|
|
||||||
`section.dangerZone.action.visibility.button.${visibility}`,
|
|
||||||
)}
|
|
||||||
onClick={onVisibilityClick}
|
onClick={onVisibilityClick}
|
||||||
isPending={isChangeVisibilityPending}
|
isPending={isChangeVisibilityPending}
|
||||||
/>
|
/>
|
||||||
@@ -127,13 +118,7 @@ interface DangerZoneRowProps {
|
|||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DangerZoneRow = ({
|
const DangerZoneRow = ({ label, description, buttonText, onClick, isPending }: DangerZoneRowProps) => {
|
||||||
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}>
|
||||||
@@ -143,12 +128,7 @@ const DangerZoneRow = ({
|
|||||||
<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
|
<Button variant="subtle" color="red" loading={isPending} onClick={onClick}>
|
||||||
variant="subtle"
|
|
||||||
color="red"
|
|
||||||
loading={isPending}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
{buttonText}
|
{buttonText}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -1,25 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import {
|
import { Button, Grid, Group, Loader, Stack, TextInput, Tooltip } from "@mantine/core";
|
||||||
Button,
|
import { useDebouncedValue, useDocumentTitle, useFavicon } from "@mantine/hooks";
|
||||||
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 { useForm } from "@homarr/form";
|
import { useZodForm } 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";
|
||||||
@@ -36,22 +26,31 @@ export const GeneralSettingsContent = ({ board }: Props) => {
|
|||||||
});
|
});
|
||||||
const { updateBoard } = useUpdateBoard();
|
const { updateBoard } = useUpdateBoard();
|
||||||
|
|
||||||
const { mutate: savePartialSettings, isPending } =
|
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
|
||||||
useSavePartialSettingsMutation(board);
|
const form = useZodForm(
|
||||||
const form = useForm({
|
validation.board.savePartialSettings
|
||||||
initialValues: {
|
.pick({
|
||||||
pageTitle: board.pageTitle ?? "",
|
pageTitle: true,
|
||||||
logoImageUrl: board.logoImageUrl ?? "",
|
logoImageUrl: true,
|
||||||
metaTitle: board.metaTitle ?? "",
|
metaTitle: true,
|
||||||
faviconImageUrl: board.faviconImageUrl ?? "",
|
faviconImageUrl: true,
|
||||||
|
})
|
||||||
|
.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);
|
||||||
@@ -94,7 +93,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="Default Board | Homarr"
|
placeholder={createMetaTitle(t("board.content.metaTitle", { boardName: board.name }))}
|
||||||
rightSection={<PendingOrInvalidIndicator {...metaTitleStatus} />}
|
rightSection={<PendingOrInvalidIndicator {...metaTitleStatus} />}
|
||||||
{...form.getInputProps("metaTitle")}
|
{...form.getInputProps("metaTitle")}
|
||||||
/>
|
/>
|
||||||
@@ -126,22 +125,12 @@ export const GeneralSettingsContent = ({ board }: Props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const PendingOrInvalidIndicator = ({
|
const PendingOrInvalidIndicator = ({ isPending, isInvalid }: { isPending: boolean; isInvalid?: boolean }) => {
|
||||||
isPending,
|
|
||||||
isInvalid,
|
|
||||||
}: {
|
|
||||||
isPending: boolean;
|
|
||||||
isInvalid?: boolean;
|
|
||||||
}) => {
|
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
|
|
||||||
if (isInvalid) {
|
if (isInvalid) {
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip multiline w={220} label={t("board.setting.section.general.unrecognizedLink")}>
|
||||||
multiline
|
|
||||||
w={220}
|
|
||||||
label={t("board.setting.section.general.unrecognizedLink")}
|
|
||||||
>
|
|
||||||
<IconAlertTriangle size="1rem" color="red" />
|
<IconAlertTriangle size="1rem" color="red" />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
@@ -183,8 +172,7 @@ 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("/") &&
|
url.includes("/") && validFaviconExtensions.some((extension) => url.endsWith(`.${extension}`));
|
||||||
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,8 +2,9 @@
|
|||||||
|
|
||||||
import { Button, Grid, Group, Input, Slider, Stack } from "@mantine/core";
|
import { Button, Grid, Group, Input, Slider, Stack } from "@mantine/core";
|
||||||
|
|
||||||
import { useForm } from "@homarr/form";
|
import { useZodForm } 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";
|
||||||
@@ -13,9 +14,8 @@ interface Props {
|
|||||||
}
|
}
|
||||||
export const LayoutSettingsContent = ({ board }: Props) => {
|
export const LayoutSettingsContent = ({ board }: Props) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { mutate: savePartialSettings, isPending } =
|
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
|
||||||
useSavePartialSettingsMutation(board);
|
const form = useZodForm(validation.board.savePartialSettings.pick({ columnCount: true }).required(), {
|
||||||
const form = useForm({
|
|
||||||
initialValues: {
|
initialValues: {
|
||||||
columnCount: board.columnCount,
|
columnCount: board.columnCount,
|
||||||
},
|
},
|
||||||
@@ -34,13 +34,7 @@ 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
|
<Slider mt="xs" min={1} max={24} step={1} {...form.getInputProps("columnCount")} />
|
||||||
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.getDefaultBoard.invalidate();
|
void utils.board.getHomeBoard.invalidate();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
.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,14 +1,6 @@
|
|||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import {
|
import { AccordionControl, AccordionItem, AccordionPanel, Container, Stack, Text, Title } from "@mantine/core";
|
||||||
AccordionControl,
|
|
||||||
AccordionItem,
|
|
||||||
AccordionPanel,
|
|
||||||
Container,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Title,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import {
|
import {
|
||||||
IconAlertTriangle,
|
IconAlertTriangle,
|
||||||
IconBrush,
|
IconBrush,
|
||||||
@@ -26,7 +18,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 { getBoardPermissions } from "~/components/board/permissions/server";
|
import { getBoardPermissionsAsync } 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";
|
||||||
@@ -45,10 +37,10 @@ interface Props {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const getBoardAndPermissions = async (params: Props["params"]) => {
|
const getBoardAndPermissionsAsync = 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 getBoardPermissions(board);
|
const { hasFullAccess } = await getBoardPermissionsAsync(board);
|
||||||
const permissions = hasFullAccess
|
const permissions = hasFullAccess
|
||||||
? await api.board.getBoardPermissions({ id: board.id })
|
? await api.board.getBoardPermissions({ id: board.id })
|
||||||
: {
|
: {
|
||||||
@@ -69,22 +61,16 @@ const getBoardAndPermissions = async (params: Props["params"]) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function BoardSettingsPage({
|
export default async function BoardSettingsPage({ params, searchParams }: Props) {
|
||||||
params,
|
const { board, permissions } = await getBoardAndPermissionsAsync(params);
|
||||||
searchParams,
|
const { hasFullAccess } = await getBoardPermissionsAsync(board);
|
||||||
}: 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
|
<ActiveTabAccordion variant="separated" defaultValue={searchParams.tab ?? "general"}>
|
||||||
variant="separated"
|
|
||||||
defaultValue={searchParams.tab ?? "general"}
|
|
||||||
>
|
|
||||||
<AccordionItemFor value="general" icon={IconSettings}>
|
<AccordionItemFor value="general" icon={IconSettings}>
|
||||||
<GeneralSettingsContent board={board} />
|
<GeneralSettingsContent board={board} />
|
||||||
</AccordionItemFor>
|
</AccordionItemFor>
|
||||||
@@ -98,22 +84,14 @@ export default async function BoardSettingsPage({
|
|||||||
<ColorSettingsContent board={board} />
|
<ColorSettingsContent board={board} />
|
||||||
</AccordionItemFor>
|
</AccordionItemFor>
|
||||||
<AccordionItemFor value="customCss" icon={IconFileTypeCss}>
|
<AccordionItemFor value="customCss" icon={IconFileTypeCss}>
|
||||||
<CustomCssSettingsContent />
|
<CustomCssSettingsContent board={board} />
|
||||||
</AccordionItemFor>
|
</AccordionItemFor>
|
||||||
{hasFullAccess && (
|
{hasFullAccess && (
|
||||||
<>
|
<>
|
||||||
<AccordionItemFor value="access" icon={IconUser}>
|
<AccordionItemFor value="access" icon={IconUser}>
|
||||||
<AccessSettingsContent
|
<AccessSettingsContent board={board} initialPermissions={permissions} />
|
||||||
board={board}
|
|
||||||
initialPermissions={permissions}
|
|
||||||
/>
|
|
||||||
</AccordionItemFor>
|
</AccordionItemFor>
|
||||||
<AccordionItemFor
|
<AccordionItemFor value="dangerZone" icon={IconAlertTriangle} danger noPadding>
|
||||||
value="dangerZone"
|
|
||||||
icon={IconAlertTriangle}
|
|
||||||
danger
|
|
||||||
noPadding
|
|
||||||
>
|
|
||||||
<DangerZoneSettingsContent />
|
<DangerZoneSettingsContent />
|
||||||
</AccordionItemFor>
|
</AccordionItemFor>
|
||||||
</>
|
</>
|
||||||
@@ -131,13 +109,7 @@ type AccordionItemForProps = PropsWithChildren<{
|
|||||||
noPadding?: boolean;
|
noPadding?: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
const AccordionItemFor = async ({
|
const AccordionItemFor = async ({ value, children, icon: Icon, danger, noPadding }: AccordionItemForProps) => {
|
||||||
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
|
||||||
@@ -158,13 +130,7 @@ const AccordionItemFor = async ({
|
|||||||
{t(`${value}.title`)}
|
{t(`${value}.title`)}
|
||||||
</Text>
|
</Text>
|
||||||
</AccordionControl>
|
</AccordionControl>
|
||||||
<AccordionPanel
|
<AccordionPanel styles={noPadding ? { content: { paddingRight: 0, paddingLeft: 0 } } : undefined}>
|
||||||
styles={
|
|
||||||
noPadding
|
|
||||||
? { content: { paddingRight: 0, paddingLeft: 0 } }
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</AccordionPanel>
|
</AccordionPanel>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|||||||
@@ -12,17 +12,18 @@ 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;
|
||||||
getInitialBoard: (params: TParams) => Promise<Board>;
|
getInitialBoardAsync: (params: TParams) => Promise<Board>;
|
||||||
isBoardContentPage: boolean;
|
isBoardContentPage: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createBoardLayout = <TParams extends Params>({
|
export const createBoardLayout = <TParams extends Params>({
|
||||||
headerActions,
|
headerActions,
|
||||||
getInitialBoard,
|
getInitialBoardAsync: getInitialBoard,
|
||||||
isBoardContentPage,
|
isBoardContentPage,
|
||||||
}: CreateBoardLayoutProps<TParams>) => {
|
}: CreateBoardLayoutProps<TParams>) => {
|
||||||
const Layout = async ({
|
const Layout = async ({
|
||||||
@@ -41,12 +42,10 @@ export const createBoardLayout = <TParams extends Params>({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GlobalItemServerDataRunner
|
<GlobalItemServerDataRunner board={initialBoard} shouldRun={isBoardContentPage}>
|
||||||
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,14 +1,11 @@
|
|||||||
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"]["getDefaultBoard"];
|
export type Board = RouterOutputs["board"]["getHomeBoard"];
|
||||||
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<
|
export type ItemOfKind<TKind extends WidgetKind> = Extract<Item, { kind: TKind }>;
|
||||||
Item,
|
|
||||||
{ kind: TKind }
|
|
||||||
>;
|
|
||||||
@@ -5,14 +5,12 @@ 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
|
return wrappers.reverse().reduce((Acc, Current): React.FunctionComponent<PropsWithChildren> => {
|
||||||
.reverse()
|
// eslint-disable-next-line react/display-name
|
||||||
.reduce((Acc, Current): React.FunctionComponent<PropsWithChildren> => {
|
return (props) => (
|
||||||
// eslint-disable-next-line react/display-name
|
<Current>
|
||||||
return (props) => (
|
<Acc {...props} />
|
||||||
<Current>
|
</Current>
|
||||||
<Acc {...props} />
|
);
|
||||||
</Current>
|
});
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
@@ -4,11 +4,8 @@ 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 { useForm, zodResolver } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import {
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
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";
|
||||||
@@ -16,12 +13,8 @@ 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 } =
|
const { mutateAsync, error, isPending } = clientApi.user.initUser.useMutation();
|
||||||
clientApi.user.initUser.useMutation();
|
const form = useZodForm(validation.user.init, {
|
||||||
const form = useForm<FormType>({
|
|
||||||
validate: zodResolver(validation.user.init),
|
|
||||||
validateInputOnBlur: true,
|
|
||||||
validateInputOnChange: true,
|
|
||||||
initialValues: {
|
initialValues: {
|
||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
@@ -29,7 +22,7 @@ export const InitUserForm = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = async (values: FormType) => {
|
const handleSubmitAsync = async (values: FormType) => {
|
||||||
await mutateAsync(values, {
|
await mutateAsync(values, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
showSuccessNotification({
|
showSuccessNotification({
|
||||||
@@ -51,23 +44,14 @@ export const InitUserForm = () => {
|
|||||||
<Stack gap="xl">
|
<Stack gap="xl">
|
||||||
<form
|
<form
|
||||||
onSubmit={form.onSubmit(
|
onSubmit={form.onSubmit(
|
||||||
(values) => void handleSubmit(values),
|
(values) => void handleSubmitAsync(values),
|
||||||
(err) => console.log(err),
|
(err) => console.log(err),
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<TextInput
|
<TextInput label={t("field.username.label")} {...form.getInputProps("username")} />
|
||||||
label={t("field.username.label")}
|
<PasswordInput label={t("field.password.label")} {...form.getInputProps("password")} />
|
||||||
{...form.getInputProps("username")}
|
<PasswordInput label={t("field.passwordConfirm.label")} {...form.getInputProps("confirmPassword")} />
|
||||||
/>
|
|
||||||
<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>
|
||||||
|
|||||||
@@ -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,6 +11,7 @@ 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";
|
||||||
@@ -48,10 +49,7 @@ export const viewport: Viewport = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Layout(props: {
|
export default function Layout(props: { children: React.ReactNode; params: { locale: string } }) {
|
||||||
children: React.ReactNode;
|
|
||||||
params: { locale: string };
|
|
||||||
}) {
|
|
||||||
const colorScheme = "dark";
|
const colorScheme = "dark";
|
||||||
|
|
||||||
const StackedProvider = composeWrappers([
|
const StackedProvider = composeWrappers([
|
||||||
@@ -61,9 +59,7 @@ export default function Layout(props: {
|
|||||||
},
|
},
|
||||||
(innerProps) => <JotaiProvider {...innerProps} />,
|
(innerProps) => <JotaiProvider {...innerProps} />,
|
||||||
(innerProps) => <TRPCReactProvider {...innerProps} />,
|
(innerProps) => <TRPCReactProvider {...innerProps} />,
|
||||||
(innerProps) => (
|
(innerProps) => <NextInternationalProvider {...innerProps} locale={props.params.locale} />,
|
||||||
<NextInternationalProvider {...innerProps} locale={props.params.locale} />
|
|
||||||
),
|
|
||||||
(innerProps) => (
|
(innerProps) => (
|
||||||
<MantineProvider
|
<MantineProvider
|
||||||
{...innerProps}
|
{...innerProps}
|
||||||
@@ -81,6 +77,7 @@ export default function Layout(props: {
|
|||||||
<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,17 +1,5 @@
|
|||||||
import { Center, Stack, Text, Title } from "@mantine/core";
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
export default function NotFound() {
|
||||||
|
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.contributorCard {
|
||||||
|
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
|
||||||
|
}
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
.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,7 +4,11 @@ import {
|
|||||||
AccordionControl,
|
AccordionControl,
|
||||||
AccordionItem,
|
AccordionItem,
|
||||||
AccordionPanel,
|
AccordionPanel,
|
||||||
|
AspectRatio,
|
||||||
|
Avatar,
|
||||||
|
Card,
|
||||||
Center,
|
Center,
|
||||||
|
Flex,
|
||||||
Group,
|
Group,
|
||||||
List,
|
List,
|
||||||
ListItem,
|
ListItem,
|
||||||
@@ -17,16 +21,18 @@ 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 "./accordion.module.css";
|
import classes from "./about.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: metaTitle,
|
title: createMetaTitle(t("metaTitle")),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,26 +55,60 @@ 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}>
|
<Title order={2}>{t("version", { version: attributes.version })}</Title>
|
||||||
{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 classNames={classes}>
|
<Accordion defaultValue="contributors" variant="filled" radius="md">
|
||||||
<AccordionItem value="contributors">
|
<AccordionItem value="contributors">
|
||||||
<AccordionControl icon={<IconUsers size="1rem" />}>
|
<AccordionControl icon={<IconUsers size="1rem" />}>
|
||||||
{t("accordion.contributors.title")}
|
<Stack gap={0}>
|
||||||
|
<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" />}>
|
||||||
{t("accordion.translators.title")}
|
<Stack gap={0}>
|
||||||
|
<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" />}>
|
||||||
@@ -102,6 +142,27 @@ 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,13 +7,10 @@ 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 {
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
showErrorNotification,
|
|
||||||
showSuccessNotification,
|
|
||||||
} from "@homarr/notifications";
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { revalidatePathAction } from "../../../revalidatePathAction";
|
import { revalidatePathActionAsync } from "../../../revalidatePathAction";
|
||||||
|
|
||||||
interface AppDeleteButtonProps {
|
interface AppDeleteButtonProps {
|
||||||
app: RouterOutputs["app"]["all"][number];
|
app: RouterOutputs["app"]["all"][number];
|
||||||
@@ -37,7 +34,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 revalidatePathAction("/manage/apps");
|
void revalidatePathActionAsync("/manage/apps");
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
showErrorNotification({
|
showErrorNotification({
|
||||||
@@ -52,13 +49,7 @@ export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
|
|||||||
}, [app, mutate, t, openConfirmModal]);
|
}, [app, mutate, t, openConfirmModal]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionIcon
|
<ActionIcon loading={isPending} variant="subtle" color="red" onClick={onClick} aria-label="Delete app">
|
||||||
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,13 +3,14 @@
|
|||||||
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 { useForm, zodResolver } from "@homarr/form";
|
import { useZodForm } 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";
|
||||||
|
|
||||||
// TODO: add icon picker
|
import { IconPicker } from "~/components/icons/picker/icon-picker";
|
||||||
|
|
||||||
type FormType = z.infer<typeof validation.app.manage>;
|
type FormType = z.infer<typeof validation.app.manage>;
|
||||||
|
|
||||||
interface AppFormProps {
|
interface AppFormProps {
|
||||||
@@ -20,29 +21,23 @@ interface AppFormProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const AppForm = (props: AppFormProps) => {
|
export const AppForm = (props: AppFormProps) => {
|
||||||
const { submitButtonTranslation, handleSubmit, initialValues, isPending } =
|
const { submitButtonTranslation, handleSubmit, initialValues, isPending } = props;
|
||||||
props;
|
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
|
|
||||||
const form = useForm({
|
const form = useZodForm(validation.app.manage, {
|
||||||
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" />
|
||||||
<TextInput
|
<IconPicker initialValue={initialValues?.iconUrl} {...form.getInputProps("iconUrl")} />
|
||||||
{...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,15 +5,12 @@ 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 {
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
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 { revalidatePathAction } from "~/app/revalidatePathAction";
|
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||||
import { AppForm } from "../../_form";
|
import { AppForm } from "../../_form";
|
||||||
|
|
||||||
interface AppEditFormProps {
|
interface AppEditFormProps {
|
||||||
@@ -30,7 +27,7 @@ export const AppEditForm = ({ app }: AppEditFormProps) => {
|
|||||||
title: t("success.title"),
|
title: t("success.title"),
|
||||||
message: t("success.message"),
|
message: t("success.message"),
|
||||||
});
|
});
|
||||||
void revalidatePathAction("/manage/apps").then(() => {
|
void revalidatePathActionAsync("/manage/apps").then(() => {
|
||||||
router.push("/manage/apps");
|
router.push("/manage/apps");
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -52,10 +49,7 @@ export const AppEditForm = ({ app }: AppEditFormProps) => {
|
|||||||
[mutate, app.id],
|
[mutate, app.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
const submitButtonTranslation = useCallback(
|
const submitButtonTranslation = useCallback((t: TranslationFunction) => t("common.action.save"), []);
|
||||||
(t: TranslationFunction) => t("common.action.save"),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppForm
|
<AppForm
|
||||||
|
|||||||
@@ -4,15 +4,12 @@ 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 {
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
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 { revalidatePathAction } from "~/app/revalidatePathAction";
|
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||||
import { AppForm } from "../_form";
|
import { AppForm } from "../_form";
|
||||||
|
|
||||||
export const AppNewForm = () => {
|
export const AppNewForm = () => {
|
||||||
@@ -25,7 +22,7 @@ export const AppNewForm = () => {
|
|||||||
title: t("success.title"),
|
title: t("success.title"),
|
||||||
message: t("success.message"),
|
message: t("success.message"),
|
||||||
});
|
});
|
||||||
void revalidatePathAction("/manage/apps").then(() => {
|
void revalidatePathActionAsync("/manage/apps").then(() => {
|
||||||
router.push("/manage/apps");
|
router.push("/manage/apps");
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -44,16 +41,9 @@ export const AppNewForm = () => {
|
|||||||
[mutate],
|
[mutate],
|
||||||
);
|
);
|
||||||
|
|
||||||
const submitButtonTranslation = useCallback(
|
const submitButtonTranslation = useCallback((t: TranslationFunction) => t("common.action.create"), []);
|
||||||
(t: TranslationFunction) => t("common.action.create"),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppForm
|
<AppForm submitButtonTranslation={submitButtonTranslation} handleSubmit={handleSubmit} isPending={isPending} />
|
||||||
submitButtonTranslation={submitButtonTranslation}
|
|
||||||
handleSubmit={handleSubmit}
|
|
||||||
isPending={isPending}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -107,9 +107,7 @@ 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">
|
<Anchor href="/manage/apps/new">{t("app.page.list.noResults.description")}</Anchor>
|
||||||
{t("app.page.list.noResults.description")}
|
|
||||||
</Anchor>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
+34
-26
@@ -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 { IconSettings, IconTrash } from "@tabler/icons-react";
|
import { IconHome, 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 { revalidatePathAction } from "~/app/revalidatePathAction";
|
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||||
|
|
||||||
const iconProps = {
|
const iconProps = {
|
||||||
@@ -21,18 +21,11 @@ const iconProps = {
|
|||||||
interface BoardCardMenuDropdownProps {
|
interface BoardCardMenuDropdownProps {
|
||||||
board: Pick<
|
board: Pick<
|
||||||
RouterOutputs["board"]["getAllBoards"][number],
|
RouterOutputs["board"]["getAllBoards"][number],
|
||||||
| "id"
|
"id" | "name" | "creator" | "userPermissions" | "groupPermissions" | "isPublic"
|
||||||
| "name"
|
|
||||||
| "creator"
|
|
||||||
| "userPermissions"
|
|
||||||
| "groupPermissions"
|
|
||||||
| "isPublic"
|
|
||||||
>;
|
>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BoardCardMenuDropdown = ({
|
export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) => {
|
||||||
board,
|
|
||||||
}: BoardCardMenuDropdownProps) => {
|
|
||||||
const t = useScopedI18n("management.page.board.action");
|
const t = useScopedI18n("management.page.board.action");
|
||||||
const tCommon = useScopedI18n("common");
|
const tCommon = useScopedI18n("common");
|
||||||
|
|
||||||
@@ -40,9 +33,15 @@ export const BoardCardMenuDropdown = ({
|
|||||||
|
|
||||||
const { openConfirmModal } = useConfirmModal();
|
const { openConfirmModal } = useConfirmModal();
|
||||||
|
|
||||||
const { mutateAsync, isPending } = clientApi.board.deleteBoard.useMutation({
|
const setHomeBoardMutation = clientApi.board.setHomeBoard.useMutation({
|
||||||
onSettled: async () => {
|
onSettled: async () => {
|
||||||
await revalidatePathAction("/manage/boards");
|
// Revalidate all as it's part of the user settings, /boards page and board manage page
|
||||||
|
await revalidatePathActionAsync("/");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const deleteBoardMutation = clientApi.board.deleteBoard.useMutation({
|
||||||
|
onSettled: async () => {
|
||||||
|
await revalidatePathActionAsync("/manage/boards");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,36 +51,45 @@ export const BoardCardMenuDropdown = ({
|
|||||||
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 mutateAsync({
|
await deleteBoardMutation.mutateAsync({
|
||||||
id: board.id,
|
id: board.id,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [board.id, board.name, mutateAsync, openConfirmModal, t]);
|
}, [board.id, board.name, deleteBoardMutation, 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
|
<>
|
||||||
component={Link}
|
<Menu.Divider />
|
||||||
href={`/boards/${board.name}/settings`}
|
<Menu.Item
|
||||||
leftSection={<IconSettings {...iconProps} />}
|
component={Link}
|
||||||
>
|
href={`/boards/${board.name}/settings`}
|
||||||
{t("settings.label")}
|
leftSection={<IconSettings {...iconProps} />}
|
||||||
</Menu.Item>
|
>
|
||||||
|
{t("settings.label")}
|
||||||
|
</Menu.Item>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{hasFullAccess && (
|
{hasFullAccess && (
|
||||||
<>
|
<>
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
<Menu.Label c="red.7">
|
<Menu.Label c="red.7">{tCommon("dangerZone")}</Menu.Label>
|
||||||
{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={isPending}
|
disabled={deleteBoardMutation.isPending}
|
||||||
>
|
>
|
||||||
{t("delete.label")}
|
{t("delete.label")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|||||||
@@ -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 { revalidatePathAction } from "~/app/revalidatePathAction";
|
import { revalidatePathActionAsync } 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 revalidatePathAction("/manage/boards");
|
await revalidatePathActionAsync("/manage/boards");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -37,11 +37,7 @@ export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => {
|
|||||||
}, [mutateAsync, boardNames, openModal]);
|
}, [mutateAsync, boardNames, openModal]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={onClick} loading={isPending}>
|
||||||
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,6 +1,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
CardSection,
|
CardSection,
|
||||||
@@ -13,13 +14,14 @@ import {
|
|||||||
Title,
|
Title,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconDotsVertical, IconLock, IconWorld } from "@tabler/icons-react";
|
import { IconDotsVertical, IconHomeFilled, IconLock, IconWorld } from "@tabler/icons-react";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
import { UserAvatar } from "@homarr/ui";
|
||||||
|
|
||||||
import { getBoardPermissions } from "~/components/board/permissions/server";
|
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
|
||||||
import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
|
import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
|
||||||
import { CreateBoardButton } from "./_components/create-board-button";
|
import { CreateBoardButton } from "./_components/create-board-button";
|
||||||
|
|
||||||
@@ -37,7 +39,7 @@ export default async function ManageBoardsPage() {
|
|||||||
|
|
||||||
<Grid>
|
<Grid>
|
||||||
{boards.map((board) => (
|
{boards.map((board) => (
|
||||||
<GridCol span={{ xs: 12, md: 4 }} key={board.id}>
|
<GridCol span={{ base: 12, md: 6, xl: 4 }} key={board.id}>
|
||||||
<BoardCard board={board} />
|
<BoardCard board={board} />
|
||||||
</GridCol>
|
</GridCol>
|
||||||
))}
|
))}
|
||||||
@@ -52,7 +54,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 getBoardPermissions(board);
|
const { hasChangeAccess: isMenuVisible } = await getBoardPermissionsAsync(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;
|
||||||
|
|
||||||
@@ -68,17 +70,29 @@ const BoardCard = async ({ board }: BoardCardProps) => {
|
|||||||
{board.name}
|
{board.name}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
<Group>
|
||||||
|
{board.isHome && (
|
||||||
|
<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">
|
||||||
|
<UserAvatar user={board.creator} size="sm" />
|
||||||
|
<Text>{board.creator?.name}</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
</CardSection>
|
</CardSection>
|
||||||
|
|
||||||
<CardSection p="sm">
|
<CardSection p="sm">
|
||||||
<Group wrap="nowrap">
|
<Group wrap="nowrap">
|
||||||
<Button
|
<Button component={Link} href={`/boards/${board.name}`} variant="default" fullWidth>
|
||||||
component={Link}
|
|
||||||
href={`/boards/${board.name}`}
|
|
||||||
variant="default"
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
{t("action.open.label")}
|
{t("action.open.label")}
|
||||||
</Button>
|
</Button>
|
||||||
{isMenuVisible && (
|
{isMenuVisible && (
|
||||||
|
|||||||
@@ -6,23 +6,17 @@ 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 {
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
showErrorNotification,
|
|
||||||
showSuccessNotification,
|
|
||||||
} from "@homarr/notifications";
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { revalidatePathAction } from "../../../revalidatePathAction";
|
import { revalidatePathActionAsync } from "../../../revalidatePathAction";
|
||||||
|
|
||||||
interface DeleteIntegrationActionButtonProps {
|
interface DeleteIntegrationActionButtonProps {
|
||||||
count: number;
|
count: number;
|
||||||
integration: { id: string; name: string };
|
integration: { id: string; name: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DeleteIntegrationActionButton = ({
|
export const DeleteIntegrationActionButton = ({ count, integration }: DeleteIntegrationActionButtonProps) => {
|
||||||
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();
|
||||||
@@ -47,9 +41,9 @@ export const DeleteIntegrationActionButton = ({
|
|||||||
message: t("notification.success.message"),
|
message: t("notification.success.message"),
|
||||||
});
|
});
|
||||||
if (count === 1) {
|
if (count === 1) {
|
||||||
router.replace("/integrations");
|
router.replace("/manage/integrations");
|
||||||
}
|
}
|
||||||
void revalidatePathAction("/integrations");
|
void revalidatePathActionAsync("/manage/integrations");
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
showErrorNotification({
|
showErrorNotification({
|
||||||
|
|||||||
@@ -2,17 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import {
|
import { ActionIcon, Avatar, Button, Card, Collapse, Group, Kbd, Stack, Text } from "@mantine/core";
|
||||||
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";
|
||||||
@@ -36,8 +26,7 @@ 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 }] =
|
const [publicSecretDisplayOpened, { toggle: togglePublicSecretDisplay }] = useDisclosure(false);
|
||||||
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];
|
||||||
@@ -50,9 +39,7 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
|
|||||||
<Avatar>
|
<Avatar>
|
||||||
<KindIcon size={16} />
|
<KindIcon size={16} />
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Text fw={500}>
|
<Text fw={500}>{t(`integration.secrets.kind.${secret.kind}.label`)}</Text>
|
||||||
{t(`integration.secrets.kind.${secret.kind}.label`)}
|
|
||||||
</Text>
|
|
||||||
{publicSecretDisplayOpened ? <Kbd>{secret.value}</Kbd> : null}
|
{publicSecretDisplayOpened ? <Kbd>{secret.value}</Kbd> : null}
|
||||||
</Group>
|
</Group>
|
||||||
<Group>
|
<Group>
|
||||||
@@ -62,11 +49,7 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
|
|||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
{isPublic ? (
|
{isPublic ? (
|
||||||
<ActionIcon
|
<ActionIcon color="gray" variant="subtle" onClick={togglePublicSecretDisplay}>
|
||||||
color="gray"
|
|
||||||
variant="subtle"
|
|
||||||
onClick={togglePublicSecretDisplay}
|
|
||||||
>
|
|
||||||
<DisplayIcon size={16} stroke={1.5} />
|
<DisplayIcon size={16} stroke={1.5} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ 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;
|
||||||
@@ -41,10 +42,7 @@ const PublicSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const PrivateSecretInput = ({
|
const PrivateSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
|
||||||
kind,
|
|
||||||
...props
|
|
||||||
}: IntegrationSecretInputProps) => {
|
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const Icon = integrationSecretIcons[kind];
|
const Icon = integrationSecretIcons[kind];
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,7 @@ 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 {
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
showErrorNotification,
|
|
||||||
showSuccessNotification,
|
|
||||||
} from "@homarr/notifications";
|
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
interface UseTestConnectionDirtyProps {
|
interface UseTestConnectionDirtyProps {
|
||||||
@@ -20,10 +17,7 @@ interface UseTestConnectionDirtyProps {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useTestConnectionDirty = ({
|
export const useTestConnectionDirty = ({ defaultDirty, initialFormValue }: UseTestConnectionDirtyProps) => {
|
||||||
defaultDirty,
|
|
||||||
initialFormValue,
|
|
||||||
}: UseTestConnectionDirtyProps) => {
|
|
||||||
const [isDirty, setIsDirty] = useState(defaultDirty);
|
const [isDirty, setIsDirty] = useState(defaultDirty);
|
||||||
const prevFormValueRef = useRef(initialFormValue);
|
const prevFormValueRef = useRef(initialFormValue);
|
||||||
|
|
||||||
@@ -36,10 +30,7 @@ export const useTestConnectionDirty = ({
|
|||||||
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(
|
.every((secretValue, index) => values.secrets[index]?.value === secretValue)
|
||||||
(secretValue, index) =>
|
|
||||||
values.secrets[index]?.value === secretValue,
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
setIsDirty(true);
|
setIsDirty(true);
|
||||||
return;
|
return;
|
||||||
@@ -62,14 +53,9 @@ interface TestConnectionProps {
|
|||||||
integration: RouterInputs["integration"]["testConnection"] & { name: string };
|
integration: RouterInputs["integration"]["testConnection"] & { name: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TestConnection = ({
|
export const TestConnection = ({ integration, removeDirty, isDirty }: TestConnectionProps) => {
|
||||||
integration,
|
|
||||||
removeDirty,
|
|
||||||
isDirty,
|
|
||||||
}: TestConnectionProps) => {
|
|
||||||
const t = useScopedI18n("integration.testConnection");
|
const t = useScopedI18n("integration.testConnection");
|
||||||
const { mutateAsync, ...mutation } =
|
const { mutateAsync, ...mutation } = clientApi.integration.testConnection.useMutation();
|
||||||
clientApi.integration.testConnection.useMutation();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group>
|
<Group>
|
||||||
@@ -125,13 +111,7 @@ interface TestConnectionIconProps {
|
|||||||
size: number;
|
size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TestConnectionIcon = ({
|
const TestConnectionIcon = ({ isDirty, isPending, isSuccess, isError, size }: TestConnectionIconProps) => {
|
||||||
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" />;
|
||||||
@@ -142,12 +122,7 @@ const TestConnectionIcon = ({
|
|||||||
export const TestConnectionNoticeAlert = () => {
|
export const TestConnectionNoticeAlert = () => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
return (
|
return (
|
||||||
<Alert
|
<Alert variant="light" color="yellow" title="Test Connection" icon={<IconInfoCircle />}>
|
||||||
variant="light"
|
|
||||||
color="yellow"
|
|
||||||
title="Test Connection"
|
|
||||||
icon={<IconInfoCircle />}
|
|
||||||
>
|
|
||||||
{t("integration.testConnection.alertNotice")}
|
{t("integration.testConnection.alertNotice")}
|
||||||
</Alert>
|
</Alert>
|
||||||
);
|
);
|
||||||
|
|||||||
+16
-44
@@ -6,28 +6,18 @@ 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 {
|
import { getAllSecretKindOptions, getDefaultSecretKinds } from "@homarr/definitions";
|
||||||
getAllSecretKindOptions,
|
import { useZodForm } from "@homarr/form";
|
||||||
getDefaultSecretKinds,
|
|
||||||
} from "@homarr/definitions";
|
|
||||||
import { useForm, zodResolver } from "@homarr/form";
|
|
||||||
import { useConfirmModal } from "@homarr/modals";
|
import { useConfirmModal } from "@homarr/modals";
|
||||||
import {
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
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 { revalidatePathAction } from "~/app/revalidatePathAction";
|
import { revalidatePathActionAsync } 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 {
|
import { TestConnection, TestConnectionNoticeAlert, useTestConnectionDirty } from "../../_integration-test-connection";
|
||||||
TestConnection,
|
|
||||||
TestConnectionNoticeAlert,
|
|
||||||
useTestConnectionDirty,
|
|
||||||
} from "../../_integration-test-connection";
|
|
||||||
|
|
||||||
interface EditIntegrationForm {
|
interface EditIntegrationForm {
|
||||||
integration: RouterOutputs["integration"]["byId"];
|
integration: RouterOutputs["integration"]["byId"];
|
||||||
@@ -45,8 +35,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
|||||||
url: integration.url,
|
url: integration.url,
|
||||||
secrets: secretsKinds.map((kind) => ({
|
secrets: secretsKinds.map((kind) => ({
|
||||||
kind,
|
kind,
|
||||||
value:
|
value: integration.secrets.find((secret) => secret.kind === kind)?.value ?? "",
|
||||||
integration.secrets.find((secret) => secret.kind === kind)?.value ?? "",
|
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
const { isDirty, onValuesChange, removeDirty } = useTestConnectionDirty({
|
const { isDirty, onValuesChange, removeDirty } = useTestConnectionDirty({
|
||||||
@@ -55,18 +44,15 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const form = useForm<FormType>({
|
const form = useZodForm(validation.integration.update.omit({ id: true }), {
|
||||||
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(
|
const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret]));
|
||||||
integration.secrets.map((secret) => [secret.kind, secret]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSubmit = async (values: FormType) => {
|
const handleSubmitAsync = async (values: FormType) => {
|
||||||
if (isDirty) return;
|
if (isDirty) return;
|
||||||
await mutateAsync(
|
await mutateAsync(
|
||||||
{
|
{
|
||||||
@@ -83,9 +69,7 @@ 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 revalidatePathAction("/integrations").then(() =>
|
void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
|
||||||
router.push("/integrations"),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
showErrorNotification({
|
showErrorNotification({
|
||||||
@@ -98,19 +82,13 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={form.onSubmit((values) => void handleSubmit(values))}>
|
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<TestConnectionNoticeAlert />
|
<TestConnectionNoticeAlert />
|
||||||
|
|
||||||
<TextInput
|
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
|
||||||
label={t("integration.field.name.label")}
|
|
||||||
{...form.getInputProps("name")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextInput
|
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
|
||||||
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">
|
||||||
@@ -121,10 +99,7 @@ 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 (
|
if ((form.values.secrets[index]?.value ?? "") === (secretsMap.get(kind)?.value ?? "")) {
|
||||||
(form.values.secrets[index]?.value ?? "") ===
|
|
||||||
(secretsMap.get(kind)?.value ?? "")
|
|
||||||
) {
|
|
||||||
return res(true);
|
return res(true);
|
||||||
}
|
}
|
||||||
openConfirmModal({
|
openConfirmModal({
|
||||||
@@ -132,10 +107,7 @@ 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(
|
form.setFieldValue(`secrets.${index}.value`, secretsMap.get(kind)!.value ?? "");
|
||||||
`secrets.${index}.value`,
|
|
||||||
secretsMap.get(kind)!.value ?? "",
|
|
||||||
);
|
|
||||||
res(true);
|
res(true);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -164,7 +136,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Group>
|
<Group>
|
||||||
<Button variant="default" component={Link} href="/integrations">
|
<Button variant="default" component={Link} href="/manage/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,9 +11,7 @@ interface EditIntegrationPageProps {
|
|||||||
params: { id: string };
|
params: { id: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function EditIntegrationPage({
|
export default async function EditIntegrationPage({ params }: EditIntegrationPageProps) {
|
||||||
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 });
|
||||||
|
|
||||||
@@ -22,9 +20,7 @@ export default async function EditIntegrationPage({
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Group align="center">
|
<Group align="center">
|
||||||
<IntegrationAvatar kind={integration.kind} size="md" />
|
<IntegrationAvatar kind={integration.kind} size="md" />
|
||||||
<Title>
|
<Title>{t("title", { name: getIntegrationName(integration.kind) })}</Title>
|
||||||
{t("title", { name: getIntegrationName(integration.kind) })}
|
|
||||||
</Title>
|
|
||||||
</Group>
|
</Group>
|
||||||
<EditIntegrationForm integration={integration} />
|
<EditIntegrationForm integration={integration} />
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { ChangeEvent, useMemo, useState } from "react";
|
import type { ChangeEvent } 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";
|
||||||
@@ -15,9 +16,7 @@ export const IntegrationCreateDropdownContent = () => {
|
|||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
const filteredKinds = useMemo(() => {
|
const filteredKinds = useMemo(() => {
|
||||||
return integrationKinds.filter((kind) =>
|
return integrationKinds.filter((kind) => kind.includes(search.toLowerCase()));
|
||||||
kind.includes(search.toLowerCase()),
|
|
||||||
);
|
|
||||||
}, [search]);
|
}, [search]);
|
||||||
|
|
||||||
const handleSearch = React.useCallback(
|
const handleSearch = React.useCallback(
|
||||||
@@ -37,11 +36,7 @@ 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
|
<Menu.Item component={Link} href={`/manage/integrations/new?kind=${kind}`} key={kind}>
|
||||||
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,38 +3,21 @@
|
|||||||
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 {
|
import { Button, Fieldset, Group, SegmentedControl, Stack, TextInput } from "@mantine/core";
|
||||||
Button,
|
|
||||||
Fieldset,
|
|
||||||
Group,
|
|
||||||
SegmentedControl,
|
|
||||||
Stack,
|
|
||||||
TextInput,
|
|
||||||
} from "@mantine/core";
|
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import type {
|
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
|
||||||
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 { useForm, zodResolver } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import {
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
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 {
|
import { TestConnection, TestConnectionNoticeAlert, useTestConnectionDirty } from "../_integration-test-connection";
|
||||||
TestConnection,
|
import { revalidatePathActionAsync } from "../../../../revalidatePathAction";
|
||||||
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>> & {
|
||||||
@@ -42,9 +25,7 @@ interface NewIntegrationFormProps {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NewIntegrationForm = ({
|
export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => {
|
||||||
searchParams,
|
|
||||||
}: NewIntegrationFormProps) => {
|
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const secretKinds = getAllSecretKindOptions(searchParams.kind);
|
const secretKinds = getAllSecretKindOptions(searchParams.kind);
|
||||||
const initialFormValues = {
|
const initialFormValues = {
|
||||||
@@ -60,14 +41,13 @@ export const NewIntegrationForm = ({
|
|||||||
initialFormValue: initialFormValues,
|
initialFormValue: initialFormValues,
|
||||||
});
|
});
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const form = useForm<FormType>({
|
const form = useZodForm(validation.integration.create.omit({ kind: true }), {
|
||||||
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 handleSubmit = async (values: FormType) => {
|
const handleSubmitAsync = async (values: FormType) => {
|
||||||
if (isDirty) return;
|
if (isDirty) return;
|
||||||
await mutateAsync(
|
await mutateAsync(
|
||||||
{
|
{
|
||||||
@@ -80,9 +60,7 @@ export const NewIntegrationForm = ({
|
|||||||
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 revalidatePathAction("/integrations").then(() =>
|
void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
|
||||||
router.push("/integrations"),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
showErrorNotification({
|
showErrorNotification({
|
||||||
@@ -95,30 +73,20 @@ export const NewIntegrationForm = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={form.onSubmit((value) => void handleSubmit(value))}>
|
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<TestConnectionNoticeAlert />
|
<TestConnectionNoticeAlert />
|
||||||
|
|
||||||
<TextInput
|
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
|
||||||
label={t("integration.field.name.label")}
|
|
||||||
{...form.getInputProps("name")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextInput
|
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
|
||||||
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 && (
|
{secretKinds.length > 1 && <SecretKindsSegmentedControl secretKinds={secretKinds} form={form} />}
|
||||||
<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`)}
|
||||||
@@ -139,7 +107,7 @@ export const NewIntegrationForm = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Group>
|
<Group>
|
||||||
<Button variant="default" component={Link} href="/integrations">
|
<Button variant="default" component={Link} href="/manage/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}>
|
||||||
@@ -157,10 +125,7 @@ interface SecretKindsSegmentedControlProps {
|
|||||||
form: UseFormReturnType<FormType, (values: FormType) => FormType>;
|
form: UseFormReturnType<FormType, (values: FormType) => FormType>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SecretKindsSegmentedControl = ({
|
const SecretKindsSegmentedControl = ({ secretKinds, form }: SecretKindsSegmentedControlProps) => {
|
||||||
secretKinds,
|
|
||||||
form,
|
|
||||||
}: SecretKindsSegmentedControlProps) => {
|
|
||||||
const t = useScopedI18n("integration.secrets");
|
const t = useScopedI18n("integration.secrets");
|
||||||
|
|
||||||
const secretKindGroups = secretKinds.map((kinds) => ({
|
const secretKindGroups = secretKinds.map((kinds) => ({
|
||||||
@@ -180,13 +145,7 @@ const SecretKindsSegmentedControl = ({
|
|||||||
[form],
|
[form],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return <SegmentedControl fullWidth data={secretKindGroups} onChange={onChange}></SegmentedControl>;
|
||||||
<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,12 +16,8 @@ interface NewIntegrationPageProps {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function IntegrationsNewPage({
|
export default async function IntegrationsNewPage({ searchParams }: NewIntegrationPageProps) {
|
||||||
searchParams,
|
const result = z.enum([integrationKinds[0]!, ...integrationKinds.slice(1)]).safeParse(searchParams.kind);
|
||||||
}: NewIntegrationPageProps) {
|
|
||||||
const result = z
|
|
||||||
.enum([integrationKinds[0]!, ...integrationKinds.slice(1)])
|
|
||||||
.safeParse(searchParams.kind);
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,9 +43,7 @@ interface IntegrationsPageProps {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function IntegrationsPage({
|
export default async function IntegrationsPage({ searchParams }: IntegrationsPageProps) {
|
||||||
searchParams,
|
|
||||||
}: IntegrationsPageProps) {
|
|
||||||
const integrations = await api.integration.all();
|
const integrations = await api.integration.all();
|
||||||
const t = await getScopedI18n("integration");
|
const t = await getScopedI18n("integration");
|
||||||
|
|
||||||
@@ -54,18 +52,9 @@ export default async function IntegrationsPage({
|
|||||||
<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
|
<Menu width={256} trapFocus position="bottom-start" withinPortal shadow="md" keepMounted={false}>
|
||||||
width={256}
|
|
||||||
trapFocus
|
|
||||||
position="bottom-start"
|
|
||||||
withinPortal
|
|
||||||
shadow="md"
|
|
||||||
keepMounted={false}
|
|
||||||
>
|
|
||||||
<MenuTarget>
|
<MenuTarget>
|
||||||
<Button rightSection={<IconChevronDown size={16} stroke={1.5} />}>
|
<Button rightSection={<IconChevronDown size={16} stroke={1.5} />}>{t("action.create")}</Button>
|
||||||
{t("action.create")}
|
|
||||||
</Button>
|
|
||||||
</MenuTarget>
|
</MenuTarget>
|
||||||
<MenuDropdown>
|
<MenuDropdown>
|
||||||
<IntegrationCreateDropdownContent />
|
<IntegrationCreateDropdownContent />
|
||||||
@@ -73,10 +62,7 @@ export default async function IntegrationsPage({
|
|||||||
</Menu>
|
</Menu>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<IntegrationList
|
<IntegrationList integrations={integrations} activeTab={searchParams.tab} />
|
||||||
integrations={integrations}
|
|
||||||
activeTab={searchParams.tab}
|
|
||||||
/>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
@@ -87,10 +73,7 @@ interface IntegrationListProps {
|
|||||||
activeTab?: IntegrationKind;
|
activeTab?: IntegrationKind;
|
||||||
}
|
}
|
||||||
|
|
||||||
const IntegrationList = async ({
|
const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps) => {
|
||||||
integrations,
|
|
||||||
activeTab,
|
|
||||||
}: IntegrationListProps) => {
|
|
||||||
const t = await getScopedI18n("integration");
|
const t = await getScopedI18n("integration");
|
||||||
|
|
||||||
if (integrations.length === 0) {
|
if (integrations.length === 0) {
|
||||||
@@ -134,12 +117,7 @@ const IntegrationList = async ({
|
|||||||
<TableTr key={integration.id}>
|
<TableTr key={integration.id}>
|
||||||
<TableTd>{integration.name}</TableTd>
|
<TableTd>{integration.name}</TableTd>
|
||||||
<TableTd>
|
<TableTd>
|
||||||
<Anchor
|
<Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm">
|
||||||
href={integration.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{integration.url}
|
{integration.url}
|
||||||
</Anchor>
|
</Anchor>
|
||||||
</TableTd>
|
</TableTd>
|
||||||
@@ -148,17 +126,14 @@ const IntegrationList = async ({
|
|||||||
<ActionIconGroup>
|
<ActionIconGroup>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
component={Link}
|
component={Link}
|
||||||
href={`/integrations/edit/${integration.id}`}
|
href={`/manage/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
|
<DeleteIntegrationActionButton integration={integration} count={integrations.length} />
|
||||||
integration={integration}
|
|
||||||
count={integrations.length}
|
|
||||||
/>
|
|
||||||
</ActionIconGroup>
|
</ActionIconGroup>
|
||||||
</Group>
|
</Group>
|
||||||
</TableTd>
|
</TableTd>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
IconMailForward,
|
IconMailForward,
|
||||||
IconPlug,
|
IconPlug,
|
||||||
IconQuestionMark,
|
IconQuestionMark,
|
||||||
|
IconSettings,
|
||||||
IconTool,
|
IconTool,
|
||||||
IconUser,
|
IconUser,
|
||||||
IconUsers,
|
IconUsers,
|
||||||
@@ -87,6 +88,11 @@ 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,
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,33 +1,94 @@
|
|||||||
import { Title } from "@mantine/core";
|
import Link from "next/link";
|
||||||
|
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 { Test } from "./test";
|
import { createMetaTitle } from "~/metadata";
|
||||||
|
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: metaTitle,
|
title: createMetaTitle(t("metaTitle")),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function ManagementPage() {
|
export default async function ManagementPage() {
|
||||||
const t = await getScopedI18n("management.title");
|
const statistics = await api.home.getStats();
|
||||||
|
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 (
|
||||||
<>
|
<>
|
||||||
<Title>{t(timeOfDay, { username: "admin" })}</Title>
|
<HeroBanner />
|
||||||
<Test />
|
<Space h="md" />
|
||||||
|
<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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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: metaTitle,
|
title: createMetaTitle(t("metaTitle")),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,12 +23,7 @@ const ClientSideTerminalComponent = dynamic(() => import("./terminal"), {
|
|||||||
|
|
||||||
export default function LogsManagementPage() {
|
export default function LogsManagementPage() {
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
|
||||||
style={{ borderRadius: 6 }}
|
|
||||||
h={fullHeightWithoutHeaderAndFooter}
|
|
||||||
p="md"
|
|
||||||
bg="black"
|
|
||||||
>
|
|
||||||
<ClientSideTerminalComponent />
|
<ClientSideTerminalComponent />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,9 +16,7 @@ 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(
|
terminalRef.current?.writeln(`${data.timestamp} ${data.level} ${data.message}`);
|
||||||
`${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) {
|
||||||
@@ -55,12 +53,5 @@ export default function TerminalComponent() {
|
|||||||
canvasAddon.dispose();
|
canvasAddon.dispose();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
return (
|
return <Box ref={ref} id="terminal" className={classes.outerTerminal} h="100%"></Box>;
|
||||||
<Box
|
|
||||||
ref={ref}
|
|
||||||
id="terminal"
|
|
||||||
className={classes.outerTerminal}
|
|
||||||
h="100%"
|
|
||||||
></Box>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
-46
@@ -1,46 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+141
@@ -0,0 +1,141 @@
|
|||||||
|
"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;
|
||||||
|
});
|
||||||
+80
@@ -0,0 +1,80 @@
|
|||||||
|
"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">;
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+94
@@ -0,0 +1,94 @@
|
|||||||
|
"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">;
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { Stack, Title } from "@mantine/core";
|
||||||
|
|
||||||
|
import { api } from "@homarr/api/server";
|
||||||
|
import { auth } from "@homarr/auth/next";
|
||||||
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
|
import { catchTrpcNotFound } from "~/errors/trpc-not-found";
|
||||||
|
import { canAccessUserEditPage } from "../access";
|
||||||
|
import { ChangePasswordForm } from "./_components/_change-password-form";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: {
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function UserSecurityPage({ params }: Props) {
|
||||||
|
const session = await auth();
|
||||||
|
const tSecurity = await getScopedI18n("management.page.user.setting.security");
|
||||||
|
const user = await api.user
|
||||||
|
.getById({
|
||||||
|
userId: params.userId,
|
||||||
|
})
|
||||||
|
.catch(catchTrpcNotFound);
|
||||||
|
|
||||||
|
if (!canAccessUserEditPage(session, user.id)) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Title>{tSecurity("title")}</Title>
|
||||||
|
|
||||||
|
<ChangePasswordForm user={user} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loaded 100 of 390 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user