Compare commits
218
Commits
v1.0.0-beta.10
...
v1.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4b19f486e | ||
|
|
32cf09a62e | ||
|
|
a1ba4372da | ||
|
|
4e153b8046 | ||
|
|
ac788650fb | ||
|
|
a9b0e26c8d | ||
|
|
132db15424 | ||
|
|
860b73fa3f | ||
|
|
e4dd19b574 | ||
|
|
1b4ed02010 | ||
|
|
47b7cbb086 | ||
|
|
7a6477a272 | ||
|
|
bff536008c | ||
|
|
8b040eddef | ||
|
|
ce87d62b35 | ||
|
|
f88cc7381b | ||
|
|
cb54de524b | ||
|
|
7d8c7b9c2d | ||
|
|
73f7d885cd | ||
|
|
470d27e091 | ||
|
|
99c8e5b609 | ||
|
|
b413e2ec7d | ||
|
|
29d4f9e003 | ||
|
|
1f54bf5b9e | ||
|
|
4d8c22a4e6 | ||
|
|
3e9e1e5e4f | ||
|
|
582b981e69 | ||
|
|
f8ec4a43cf | ||
|
|
2a8809551c | ||
|
|
9df59296ff | ||
|
|
701538fcb5 | ||
|
|
0e82f2d199 | ||
|
|
a2b5d60aa8 | ||
|
|
02dabfcd01 | ||
|
|
931f312291 | ||
|
|
175927970b | ||
|
|
c922c5b083 | ||
|
|
73bbcfeee8 | ||
|
|
7a2d97ba91 | ||
|
|
e47d497560 | ||
|
|
b9271ba14a | ||
|
|
9d85677046 | ||
|
|
b30c192b8f | ||
|
|
d4f04da709 | ||
|
|
7960a4cea1 | ||
|
|
243d7e9dd4 | ||
|
|
bdefff636b | ||
|
|
55feb9dff8 | ||
|
|
45af6442a1 | ||
|
|
72debef4b4 | ||
|
|
25635db17e | ||
|
|
1897752e38 | ||
|
|
554853ffbd | ||
|
|
e40237e4a5 | ||
|
|
fa6262cf5e | ||
|
|
49bd02e869 | ||
|
|
3c8fbe371b | ||
|
|
81a8cf4b36 | ||
|
|
b5c008b4ad | ||
|
|
343438391b | ||
|
|
8400b9fb11 | ||
|
|
fe65e73fff | ||
|
|
e023edf9d6 | ||
|
|
927913cf4e | ||
|
|
07d8103051 | ||
|
|
fe08ccf589 | ||
|
|
c55207bd59 | ||
|
|
e35811c921 | ||
|
|
e481e116ff | ||
|
|
32be09a4a7 | ||
|
|
d0a20dbfc5 | ||
|
|
68329965f7 | ||
|
|
f9fea70d39 | ||
|
|
3ed46aecbd | ||
|
|
a31c6a97e0 | ||
|
|
d1be727eb3 | ||
|
|
a1084f91e5 | ||
|
|
7622f0a4d2 | ||
|
|
34cacb3153 | ||
|
|
48cf5d5304 | ||
|
|
8c36c3e36b | ||
|
|
b10b2013af | ||
|
|
ea8acc83ea | ||
|
|
cb96508634 | ||
|
|
5e83847772 | ||
|
|
1557537763 | ||
|
|
5f993d2630 | ||
|
|
44767c46c2 | ||
|
|
aaddf0d987 | ||
|
|
7f8e6e4ea1 | ||
|
|
34a76c98e8 | ||
|
|
0f82a6f63d | ||
|
|
d4bb014a9b | ||
|
|
e01d74f4f8 | ||
|
|
ec3bda34e0 | ||
|
|
5c16fb8d6c | ||
|
|
986e013ca3 | ||
|
|
1fd3fd8dfb | ||
|
|
a03a01b964 | ||
|
|
4d94fce523 | ||
|
|
75c791801e | ||
|
|
3e67b411b6 | ||
|
|
ff1a003037 | ||
|
|
99c69ba65d | ||
|
|
f5cf92c49c | ||
|
|
05dc51b38e | ||
|
|
a53e0f7cee | ||
|
|
f3a91ca37f | ||
|
|
1e406a8a58 | ||
|
|
2ea763c231 | ||
|
|
579bb2a33e | ||
|
|
feecdd36d4 | ||
|
|
56755d1ab4 | ||
|
|
6b36ec3103 | ||
|
|
fc6dbc92df | ||
|
|
467d5e21ae | ||
|
|
b399cb6979 | ||
|
|
4d69b81676 | ||
|
|
a1fc9902a5 | ||
|
|
0649220080 | ||
|
|
e502fbc533 | ||
|
|
f8c3dab7f9 | ||
|
|
039800d94d | ||
|
|
9a7e421f68 | ||
|
|
e695fb64d3 | ||
|
|
62da953356 | ||
|
|
39171ac76a | ||
|
|
546c824888 | ||
|
|
a12dd10269 | ||
|
|
80c02ef9f2 | ||
|
|
7d1b1d8342 | ||
|
|
dc3d27e1a1 | ||
|
|
ec8ad483ca | ||
|
|
8edf922e30 | ||
|
|
86a925939c | ||
|
|
b034dcbb11 | ||
|
|
93d7d99e70 | ||
|
|
bfe5e9a8e1 | ||
|
|
e056f620da | ||
|
|
3860af31b6 | ||
|
|
a69c64d88d | ||
|
|
34a6c65ead | ||
|
|
e33e0a4d2b | ||
|
|
ffdc8d3496 | ||
|
|
64cf3d02c4 | ||
|
|
f4f02ca539 | ||
|
|
6327ca073c | ||
|
|
7807b28d24 | ||
|
|
02386ffcd8 | ||
|
|
690cc303bf | ||
|
|
9da50e518b | ||
|
|
eb424d757b | ||
|
|
65befa22ba | ||
|
|
6a68ccfee4 | ||
|
|
e46b1c49f9 | ||
|
|
64cc078b09 | ||
|
|
be4e75321a | ||
|
|
e0eab425e6 | ||
|
|
2dec74e5ff | ||
|
|
39ea2baff3 | ||
|
|
a160f16772 | ||
|
|
ccb19e06c1 | ||
|
|
bf12944768 | ||
|
|
1e9098d052 | ||
|
|
1562eae4d7 | ||
|
|
c349bf8371 | ||
|
|
1f0116d725 | ||
|
|
49d10f7ad0 | ||
|
|
d98552540a | ||
|
|
92f4f9421e | ||
|
|
53f07f87b8 | ||
|
|
812469db82 | ||
|
|
e2b211cf34 | ||
|
|
2358f6f5eb | ||
|
|
024ec9235d | ||
|
|
58fdea0245 | ||
|
|
4ead238462 | ||
|
|
6305c74f90 | ||
|
|
0780080681 | ||
|
|
aa15a705d7 | ||
|
|
cc70175125 | ||
|
|
bc5e25e9cd | ||
|
|
d43557e165 | ||
|
|
b6c96f73a5 | ||
|
|
ee8831affe | ||
|
|
6e21863d04 | ||
|
|
ca39ca66f0 | ||
|
|
cc7d53e84e | ||
|
|
99c409cbb7 | ||
|
|
1fcf9d0146 | ||
|
|
a234fc844e | ||
|
|
fac522adb9 | ||
|
|
9945425eff | ||
|
|
8cd5833281 | ||
|
|
ef7174aa36 | ||
|
|
4ce76f8a73 | ||
|
|
3e65af9ebe | ||
|
|
c6e5bec348 | ||
|
|
3e74c065f5 | ||
|
|
14ae732d0c | ||
|
|
b97883f44d | ||
|
|
741753fa74 | ||
|
|
1f0f8912b6 | ||
|
|
49d6932218 | ||
|
|
f13fefa64d | ||
|
|
cef567efdf | ||
|
|
db2b79ee7f | ||
|
|
b46d30b04c | ||
|
|
de4283b5fa | ||
|
|
a08d3e387f | ||
|
|
bd1ff01109 | ||
|
|
b24b1cc8a9 | ||
|
|
e81edc898f | ||
|
|
4d864878e3 | ||
|
|
5776ead3e3 | ||
|
|
baf32c3fe6 | ||
|
|
ea408a0fdd | ||
|
|
17f93097f6 |
No files matched your search
@@ -7,3 +7,4 @@ README.md
|
|||||||
.git
|
.git
|
||||||
dev
|
dev
|
||||||
.build
|
.build
|
||||||
|
e2e
|
||||||
@@ -28,6 +28,10 @@ DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE'
|
|||||||
# DB_PASSWORD='password'
|
# DB_PASSWORD='password'
|
||||||
# DB_NAME='name-of-database'
|
# DB_NAME='name-of-database'
|
||||||
|
|
||||||
|
# The below path can be used to store trusted certificates during development, it is not required and can be left empty.
|
||||||
|
# If it is used, please use the full path to the directory where the certificates are stored.
|
||||||
|
# LOCAL_CERTIFICATE_PATH='FULL_PATH_TO_CERTIFICATES'
|
||||||
|
|
||||||
TURBO_TELEMETRY_DISABLED=1
|
TURBO_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
# Configure logging to use winston logger
|
# Configure logging to use winston logger
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ body:
|
|||||||
label: Version
|
label: Version
|
||||||
description: What version of Homarr are you running?
|
description: What version of Homarr are you running?
|
||||||
options:
|
options:
|
||||||
|
- 1.1.0
|
||||||
|
- 1.0.1
|
||||||
|
- 1.0.0
|
||||||
- 1.0.0-beta
|
- 1.0.0-beta
|
||||||
- Other (describe in "additional information")
|
- Other (describe in "additional information")
|
||||||
default: 0
|
default: 0
|
||||||
|
|||||||
@@ -6,6 +6,11 @@
|
|||||||
matchPackagePatterns: ["^@homarr/"],
|
matchPackagePatterns: ["^@homarr/"],
|
||||||
enabled: false,
|
enabled: false,
|
||||||
},
|
},
|
||||||
|
// Disable Dockerode updates see https://github.com/apocas/dockerode/issues/787
|
||||||
|
{
|
||||||
|
matchPackagePatterns: ["^dockerode$"],
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
matchUpdateTypes: ["minor", "patch", "pin", "digest"],
|
matchUpdateTypes: ["minor", "patch", "pin", "digest"],
|
||||||
automerge: true,
|
automerge: true,
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ jobs:
|
|||||||
network: host
|
network: host
|
||||||
env:
|
env:
|
||||||
SKIP_ENV_VALIDATION: true
|
SKIP_ENV_VALIDATION: true
|
||||||
|
- name: Install playwright browsers
|
||||||
|
run: pnpm exec playwright install chromium
|
||||||
- name: Run E2E Tests
|
- name: Run E2E Tests
|
||||||
shell: bash
|
shell: bash
|
||||||
run: pnpm test:e2e
|
run: pnpm test:e2e
|
||||||
|
|||||||
@@ -94,6 +94,8 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||||
run: |
|
run: |
|
||||||
|
git config user.name "Releases Homarr"
|
||||||
|
git config user.email "175486441+homarr-releases[bot]@users.noreply.github.com"
|
||||||
git fetch origin dev
|
git fetch origin dev
|
||||||
git checkout dev
|
git checkout dev
|
||||||
git pull origin dev
|
git pull origin dev
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ jobs:
|
|||||||
token: ${{ github.token }}
|
token: ${{ github.token }}
|
||||||
branch: dev
|
branch: dev
|
||||||
- name: Create pull request
|
- name: Create pull request
|
||||||
run: 'gh pr create --title "chore(release): automatic release ${{ steps.semver.outputs.next }}" --body "**This is an automatic release**.<br/>Manual action may be required for major bumps.<br/>Detected change to be ``${{ steps.semver.outputs.bump }}``<br/>Bump version from ``${{ steps.semver.outputs.current }}`` to ``${{ steps.semver.outputs.next }}``" --base beta --head dev --label automerge'
|
run: 'gh pr create --title "chore(release): automatic release ${{ steps.semver.outputs.next }}" --body "**This is an automatic release**.<br/>Manual action may be required for major bumps.<br/>Detected change to be ``${{ steps.semver.outputs.bump }}``<br/>Bump version from ``${{ steps.semver.outputs.current }}`` to ``${{ steps.semver.outputs.next }}``" --base main --head dev --label automerge'
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||||
- name: Discord notification
|
- name: Discord notification
|
||||||
|
|||||||
@@ -54,10 +54,14 @@ yarn-error.log*
|
|||||||
*.log
|
*.log
|
||||||
|
|
||||||
apps/tasks/tasks.cjs
|
apps/tasks/tasks.cjs
|
||||||
|
apps/tasks/tasks.css
|
||||||
apps/websocket/wssServer.cjs
|
apps/websocket/wssServer.cjs
|
||||||
|
apps/websocket/wssServer.css
|
||||||
apps/nextjs/.million/
|
apps/nextjs/.million/
|
||||||
packages/cli/cli.cjs
|
packages/cli/cli.cjs
|
||||||
|
|
||||||
|
# e2e mounts
|
||||||
|
e2e/shared/tmp
|
||||||
|
|
||||||
#personal backgrounds
|
#personal backgrounds
|
||||||
apps/nextjs/public/images/background.png
|
apps/nextjs/public/images/background.png
|
||||||
+1301
File diff suppressed because it is too large.
Load diff
+2
-5
@@ -1,4 +1,4 @@
|
|||||||
FROM node:22.12.0-alpine AS base
|
FROM node:22.13.1-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
|
||||||
@@ -12,9 +12,6 @@ COPY . .
|
|||||||
|
|
||||||
RUN corepack enable pnpm && pnpm install --recursive --frozen-lockfile
|
RUN corepack enable pnpm && pnpm install --recursive --frozen-lockfile
|
||||||
|
|
||||||
# Install sharp for image optimization
|
|
||||||
RUN corepack enable pnpm && pnpm install sharp -w
|
|
||||||
|
|
||||||
# Copy static data as it is not part of the build
|
# Copy static data as it is not part of the build
|
||||||
COPY static-data ./static-data
|
COPY static-data ./static-data
|
||||||
ARG SKIP_ENV_VALIDATION='true'
|
ARG SKIP_ENV_VALIDATION='true'
|
||||||
@@ -42,7 +39,7 @@ RUN mkdir -p /var/cache/nginx && \
|
|||||||
touch /run/nginx/nginx.pid && \
|
touch /run/nginx/nginx.pid && \
|
||||||
mkdir -p /etc/nginx/templates /etc/nginx/ssl/certs
|
mkdir -p /etc/nginx/templates /etc/nginx/ssl/certs
|
||||||
|
|
||||||
COPY --from=builder /app/apps/nextjs/next.config.mjs .
|
COPY --from=builder /app/apps/nextjs/next.config.ts .
|
||||||
COPY --from=builder /app/apps/nextjs/package.json .
|
COPY --from=builder /app/apps/nextjs/package.json .
|
||||||
|
|
||||||
COPY --from=builder /app/apps/tasks/tasks.cjs ./apps/tasks/tasks.cjs
|
COPY --from=builder /app/apps/tasks/tasks.cjs ./apps/tasks/tasks.cjs
|
||||||
|
|||||||
@@ -1,24 +1,34 @@
|
|||||||
// Importing env files here to validate on build
|
// Importing env files here to validate on build
|
||||||
import "@homarr/auth/env.mjs";
|
import "@homarr/auth/env";
|
||||||
import "@homarr/db/env.mjs";
|
import "@homarr/db/env";
|
||||||
import "@homarr/common/env.mjs";
|
import "@homarr/common/env";
|
||||||
|
import "@homarr/docker/env";
|
||||||
|
|
||||||
|
import type { NextConfig } from "next";
|
||||||
import MillionLint from "@million/lint";
|
import MillionLint from "@million/lint";
|
||||||
import createNextIntlPlugin from "next-intl/plugin";
|
import createNextIntlPlugin from "next-intl/plugin";
|
||||||
|
|
||||||
import "./src/env.mjs";
|
import "./src/env.ts";
|
||||||
|
|
||||||
// Package path does not work... so we need to use relative path
|
// Package path does not work... so we need to use relative path
|
||||||
const withNextIntl = createNextIntlPlugin("../../packages/translation/src/request.ts");
|
const withNextIntl = createNextIntlPlugin("../../packages/translation/src/request.ts");
|
||||||
|
|
||||||
/** @type {import("next").NextConfig} */
|
interface WebpackConfig {
|
||||||
const nextConfig = {
|
module: {
|
||||||
|
rules: {
|
||||||
|
test: RegExp;
|
||||||
|
loader: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
/** We already do linting and typechecking as separate tasks in CI */
|
/** We already do linting and typechecking as separate tasks in CI */
|
||||||
eslint: { ignoreDuringBuilds: true },
|
eslint: { ignoreDuringBuilds: true },
|
||||||
typescript: { ignoreBuildErrors: true },
|
typescript: { ignoreBuildErrors: true },
|
||||||
webpack: (config, { isServer }) => {
|
webpack: (config: WebpackConfig, { isServer }) => {
|
||||||
if (isServer) {
|
if (isServer) {
|
||||||
config.module.rules.push({
|
config.module.rules.push({
|
||||||
test: /\.node$/,
|
test: /\.node$/,
|
||||||
@@ -38,6 +48,7 @@ const nextConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Skip transform is used because of webpack loader, without it for example 'Tooltip.Floating' will not work and show an error
|
// Skip transform is used because of webpack loader, without it for example 'Tooltip.Floating' will not work and show an error
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const withMillionLint = MillionLint.next({ rsc: true, skipTransform: true, telemetry: false });
|
const withMillionLint = MillionLint.next({ rsc: true, skipTransform: true, telemetry: false });
|
||||||
|
|
||||||
export default withNextIntl(nextConfig);
|
export default withNextIntl(nextConfig);
|
||||||
+29
-26
@@ -6,7 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "pnpm with-env next build",
|
"build": "pnpm with-env next build",
|
||||||
"clean": "git clean -xdf .next .turbo node_modules",
|
"clean": "git clean -xdf .next .turbo node_modules",
|
||||||
"dev": "pnpm with-env next dev",
|
"dev": "pnpm with-env next dev --turbopack",
|
||||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"start": "pnpm with-env next start",
|
"start": "pnpm with-env next start",
|
||||||
@@ -18,12 +18,15 @@
|
|||||||
"@homarr/analytics": "workspace:^0.1.0",
|
"@homarr/analytics": "workspace:^0.1.0",
|
||||||
"@homarr/api": "workspace:^0.1.0",
|
"@homarr/api": "workspace:^0.1.0",
|
||||||
"@homarr/auth": "workspace:^0.1.0",
|
"@homarr/auth": "workspace:^0.1.0",
|
||||||
|
"@homarr/certificates": "workspace:^0.1.0",
|
||||||
"@homarr/common": "workspace:^0.1.0",
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
"@homarr/cron-job-status": "workspace:^0.1.0",
|
"@homarr/cron-job-status": "workspace:^0.1.0",
|
||||||
"@homarr/db": "workspace:^0.1.0",
|
"@homarr/db": "workspace:^0.1.0",
|
||||||
"@homarr/definitions": "workspace:^0.1.0",
|
"@homarr/definitions": "workspace:^0.1.0",
|
||||||
|
"@homarr/docker": "workspace:^0.1.0",
|
||||||
"@homarr/form": "workspace:^0.1.0",
|
"@homarr/form": "workspace:^0.1.0",
|
||||||
"@homarr/gridstack": "^1.11.2",
|
"@homarr/gridstack": "^1.12.0",
|
||||||
|
"@homarr/icons": "workspace:^0.1.0",
|
||||||
"@homarr/integrations": "workspace:^0.1.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",
|
||||||
@@ -38,18 +41,18 @@
|
|||||||
"@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.15.2",
|
"@mantine/colors-generator": "^7.16.1",
|
||||||
"@mantine/core": "^7.15.2",
|
"@mantine/core": "^7.16.1",
|
||||||
"@mantine/dropzone": "^7.15.2",
|
"@mantine/dropzone": "^7.16.1",
|
||||||
"@mantine/hooks": "^7.15.2",
|
"@mantine/hooks": "^7.16.1",
|
||||||
"@mantine/modals": "^7.15.2",
|
"@mantine/modals": "^7.16.1",
|
||||||
"@mantine/tiptap": "^7.15.2",
|
"@mantine/tiptap": "^7.16.1",
|
||||||
"@million/lint": "1.0.14",
|
"@million/lint": "1.0.14",
|
||||||
"@t3-oss/env-nextjs": "^0.11.1",
|
"@t3-oss/env-nextjs": "^0.11.1",
|
||||||
"@tabler/icons-react": "^3.26.0",
|
"@tabler/icons-react": "^3.29.0",
|
||||||
"@tanstack/react-query": "^5.62.11",
|
"@tanstack/react-query": "^5.64.2",
|
||||||
"@tanstack/react-query-devtools": "^5.62.11",
|
"@tanstack/react-query-devtools": "^5.64.2",
|
||||||
"@tanstack/react-query-next-experimental": "5.62.11",
|
"@tanstack/react-query-next-experimental": "5.64.2",
|
||||||
"@trpc/client": "next",
|
"@trpc/client": "next",
|
||||||
"@trpc/next": "next",
|
"@trpc/next": "next",
|
||||||
"@trpc/react-query": "next",
|
"@trpc/react-query": "next",
|
||||||
@@ -61,18 +64,18 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"flag-icons": "^7.2.3",
|
"flag-icons": "^7.3.2",
|
||||||
"glob": "^11.0.0",
|
"glob": "^11.0.1",
|
||||||
"jotai": "^2.11.0",
|
"jotai": "^2.11.1",
|
||||||
"mantine-react-table": "2.0.0-beta.7",
|
"mantine-react-table": "2.0.0-beta.8",
|
||||||
"next": "^14.2.22",
|
"next": "15.1.6",
|
||||||
"postcss-preset-mantine": "^1.17.0",
|
"postcss-preset-mantine": "^1.17.0",
|
||||||
"prismjs": "^1.29.0",
|
"prismjs": "^1.29.0",
|
||||||
"react": "^19.0.0",
|
"react": "19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "19.0.0",
|
||||||
"react-error-boundary": "^5.0.0",
|
"react-error-boundary": "^5.0.0",
|
||||||
"react-simple-code-editor": "^0.14.1",
|
"react-simple-code-editor": "^0.14.1",
|
||||||
"sass": "^1.83.0",
|
"sass": "^1.83.4",
|
||||||
"superjson": "2.2.2",
|
"superjson": "2.2.2",
|
||||||
"swagger-ui-react": "^5.18.2",
|
"swagger-ui-react": "^5.18.2",
|
||||||
"use-deep-compare-effect": "^1.8.1"
|
"use-deep-compare-effect": "^1.8.1"
|
||||||
@@ -81,16 +84,16 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@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.5",
|
"@types/chroma-js": "3.1.0",
|
||||||
"@types/node": "^22.10.2",
|
"@types/node": "^22.10.10",
|
||||||
"@types/prismjs": "^1.26.5",
|
"@types/prismjs": "^1.26.5",
|
||||||
"@types/react": "^19.0.2",
|
"@types/react": "19.0.8",
|
||||||
"@types/react-dom": "^19.0.2",
|
"@types/react-dom": "19.0.3",
|
||||||
"@types/swagger-ui-react": "^4.18.3",
|
"@types/swagger-ui-react": "^4.18.3",
|
||||||
"concurrently": "^9.1.2",
|
"concurrently": "^9.1.2",
|
||||||
"eslint": "^9.17.0",
|
"eslint": "^9.18.0",
|
||||||
"node-loader": "^2.1.0",
|
"node-loader": "^2.1.0",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -18,9 +18,10 @@ import superjson from "superjson";
|
|||||||
import type { SuperJSONResult } from "superjson";
|
import type { SuperJSONResult } from "superjson";
|
||||||
|
|
||||||
import type { AppRouter } from "@homarr/api";
|
import type { AppRouter } from "@homarr/api";
|
||||||
import { clientApi, createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/client";
|
import { clientApi, getTrpcUrl } from "@homarr/api/client";
|
||||||
|
import { createHeadersCallbackForSource } from "@homarr/api/shared";
|
||||||
|
|
||||||
import { env } from "~/env.mjs";
|
import { env } from "~/env";
|
||||||
|
|
||||||
const getWebSocketProtocol = () => {
|
const getWebSocketProtocol = () => {
|
||||||
// window is not defined on server side
|
// window is not defined on server side
|
||||||
@@ -93,6 +94,7 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
|||||||
false: unstable_httpBatchStreamLink({
|
false: unstable_httpBatchStreamLink({
|
||||||
transformer: superjson,
|
transformer: superjson,
|
||||||
url: getTrpcUrl(),
|
url: getTrpcUrl(),
|
||||||
|
maxURLLength: 2083, // Suggested by tRPC: https://trpc.io/docs/client/links/httpBatchLink#setting-a-maximum-url-length
|
||||||
headers: createHeadersCallbackForSource("nextjs-react (json)"),
|
headers: createHeadersCallbackForSource("nextjs-react (json)"),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -11,15 +11,17 @@ import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
|
|||||||
import { RegistrationForm } from "./_registration-form";
|
import { RegistrationForm } from "./_registration-form";
|
||||||
|
|
||||||
interface InviteUsagePageProps {
|
interface InviteUsagePageProps {
|
||||||
params: {
|
params: Promise<{
|
||||||
id: string;
|
id: string;
|
||||||
};
|
}>;
|
||||||
searchParams: {
|
searchParams: Promise<{
|
||||||
token: string;
|
token: string;
|
||||||
};
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function InviteUsagePage({ params, searchParams }: InviteUsagePageProps) {
|
export default async function InviteUsagePage(props: InviteUsagePageProps) {
|
||||||
|
const searchParams = await props.searchParams;
|
||||||
|
const params = await props.params;
|
||||||
if (!isProviderEnabled("credentials")) notFound();
|
if (!isProviderEnabled("credentials")) notFound();
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
@@ -57,7 +59,7 @@ export default async function InviteUsagePage({ params, searchParams }: InviteUs
|
|||||||
{t("subtitle")}
|
{t("subtitle")}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Card bg="dark.8" w={64 * 6} maw="90vw">
|
<Card withBorder w={64 * 6} maw="90vw">
|
||||||
<RegistrationForm invite={invite} />
|
<RegistrationForm invite={invite} />
|
||||||
</Card>
|
</Card>
|
||||||
<Text size="xs" c="gray.5" ta="center">
|
<Text size="xs" c="gray.5" ta="center">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { Card, Center, Stack, Text, Title } from "@mantine/core";
|
import { Card, Center, Stack, Text, Title } from "@mantine/core";
|
||||||
|
|
||||||
import { env } from "@homarr/auth/env.mjs";
|
import { env } from "@homarr/auth/env";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
@@ -9,12 +9,13 @@ import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
|
|||||||
import { LoginForm } from "./_login-form";
|
import { LoginForm } from "./_login-form";
|
||||||
|
|
||||||
interface LoginProps {
|
interface LoginProps {
|
||||||
searchParams: {
|
searchParams: Promise<{
|
||||||
callbackUrl?: string;
|
callbackUrl?: string;
|
||||||
};
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function Login({ searchParams }: LoginProps) {
|
export default async function Login(props: LoginProps) {
|
||||||
|
const searchParams = await props.searchParams;
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
if (session) {
|
if (session) {
|
||||||
@@ -35,7 +36,7 @@ export default async function Login({ searchParams }: LoginProps) {
|
|||||||
{t("subtitle")}
|
{t("subtitle")}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Card bg="dark.8" w={64 * 6} maw="90vw">
|
<Card withBorder w={64 * 6} maw="90vw">
|
||||||
<LoginForm
|
<LoginForm
|
||||||
providers={env.AUTH_PROVIDERS}
|
providers={env.AUTH_PROVIDERS}
|
||||||
oidcClientName={env.AUTH_OIDC_CLIENT_NAME}
|
oidcClientName={env.AUTH_OIDC_CLIENT_NAME}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import type { MouseEvent } from "react";
|
import type { MouseEvent } from "react";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Group, Menu } from "@mantine/core";
|
import { Group, Menu } from "@mantine/core";
|
||||||
import { useHotkeys } from "@mantine/hooks";
|
import { useHotkeys } from "@mantine/hooks";
|
||||||
@@ -9,9 +10,11 @@ import {
|
|||||||
IconBox,
|
IconBox,
|
||||||
IconBoxAlignTop,
|
IconBoxAlignTop,
|
||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
|
IconLayoutBoard,
|
||||||
IconPencil,
|
IconPencil,
|
||||||
IconPencilOff,
|
IconPencilOff,
|
||||||
IconPlus,
|
IconPlus,
|
||||||
|
IconReplace,
|
||||||
IconResize,
|
IconResize,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
@@ -28,7 +31,7 @@ import { useCategoryActions } from "~/components/board/sections/category/categor
|
|||||||
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
|
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
|
||||||
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
|
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
|
||||||
import { HeaderButton } from "~/components/layout/header/button";
|
import { HeaderButton } from "~/components/layout/header/button";
|
||||||
import { env } from "~/env.mjs";
|
import { env } from "~/env";
|
||||||
import { useEditMode, useRequiredBoard } from "./_context";
|
import { useEditMode, useRequiredBoard } from "./_context";
|
||||||
|
|
||||||
export const BoardContentHeaderActions = () => {
|
export const BoardContentHeaderActions = () => {
|
||||||
@@ -49,6 +52,8 @@ export const BoardContentHeaderActions = () => {
|
|||||||
<HeaderButton href={`/boards/${board.name}/settings`}>
|
<HeaderButton href={`/boards/${board.name}/settings`}>
|
||||||
<IconSettings stroke={1.5} />
|
<IconSettings stroke={1.5} />
|
||||||
</HeaderButton>
|
</HeaderButton>
|
||||||
|
|
||||||
|
<SelectBoardsMenu />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -151,6 +156,32 @@ const EditModeMenu = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SelectBoardsMenu = () => {
|
||||||
|
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu position="bottom-end" withArrow>
|
||||||
|
<Menu.Target>
|
||||||
|
<HeaderButton w="auto" px={4}>
|
||||||
|
<IconReplace stroke={1.5} />
|
||||||
|
</HeaderButton>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
|
||||||
|
{boards.map((board) => (
|
||||||
|
<Menu.Item
|
||||||
|
key={board.id}
|
||||||
|
component={Link}
|
||||||
|
href={`/boards/${board.name}`}
|
||||||
|
leftSection={<IconLayoutBoard size={20} />}
|
||||||
|
>
|
||||||
|
{board.name}
|
||||||
|
</Menu.Item>
|
||||||
|
))}
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const usePreventLeaveWithDirty = (isDirty: boolean) => {
|
const usePreventLeaveWithDirty = (isDirty: boolean) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { openConfirmModal } = useConfirmModal();
|
const { openConfirmModal } = useConfirmModal();
|
||||||
|
|||||||
@@ -31,15 +31,15 @@ import { GeneralSettingsContent } from "./_general";
|
|||||||
import { LayoutSettingsContent } from "./_layout";
|
import { LayoutSettingsContent } from "./_layout";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: {
|
params: Promise<{
|
||||||
name: string;
|
name: string;
|
||||||
};
|
}>;
|
||||||
searchParams: {
|
searchParams: Promise<{
|
||||||
tab?: keyof TranslationObject["board"]["setting"]["section"];
|
tab?: keyof TranslationObject["board"]["setting"]["section"];
|
||||||
};
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getBoardAndPermissionsAsync = async (params: Props["params"]) => {
|
const getBoardAndPermissionsAsync = async (params: Awaited<Props["params"]>) => {
|
||||||
try {
|
try {
|
||||||
const board = await api.board.getBoardByName({ name: params.name });
|
const board = await api.board.getBoardByName({ name: params.name });
|
||||||
const { hasFullAccess } = await getBoardPermissionsAsync(board);
|
const { hasFullAccess } = await getBoardPermissionsAsync(board);
|
||||||
@@ -63,7 +63,9 @@ const getBoardAndPermissionsAsync = async (params: Props["params"]) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function BoardSettingsPage({ params, searchParams }: Props) {
|
export default async function BoardSettingsPage(props: Props) {
|
||||||
|
const searchParams = await props.searchParams;
|
||||||
|
const params = await props.params;
|
||||||
const { board, permissions } = await getBoardAndPermissionsAsync(params);
|
const { board, permissions } = await getBoardAndPermissionsAsync(params);
|
||||||
const boardSettings = await getServerSettingByKeyAsync(db, "board");
|
const boardSettings = await getServerSettingByKeyAsync(db, "board");
|
||||||
const { hasFullAccess, hasChangeAccess } = await getBoardPermissionsAsync(board);
|
const { hasFullAccess, hasChangeAccess } = await getBoardPermissionsAsync(board);
|
||||||
@@ -99,7 +101,11 @@ export default async function BoardSettingsPage({ params, searchParams }: Props)
|
|||||||
<BoardAccessSettings board={board} initialPermissions={permissions} />
|
<BoardAccessSettings board={board} initialPermissions={permissions} />
|
||||||
</AccordionItemFor>
|
</AccordionItemFor>
|
||||||
<AccordionItemFor value="dangerZone" icon={IconAlertTriangle} danger noPadding>
|
<AccordionItemFor value="dangerZone" icon={IconAlertTriangle} danger noPadding>
|
||||||
<DangerZoneSettingsContent hideVisibility={boardSettings.homeBoardId === board.id} />
|
<DangerZoneSettingsContent
|
||||||
|
hideVisibility={
|
||||||
|
boardSettings.homeBoardId === board.id || boardSettings.mobileHomeBoardId === board.id
|
||||||
|
}
|
||||||
|
/>
|
||||||
</AccordionItemFor>
|
</AccordionItemFor>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ export const createBoardLayout = <TParams extends Params>({
|
|||||||
params,
|
params,
|
||||||
children,
|
children,
|
||||||
}: PropsWithChildren<{
|
}: PropsWithChildren<{
|
||||||
params: TParams;
|
params: Promise<TParams>;
|
||||||
}>) => {
|
}>) => {
|
||||||
const initialBoard = await getInitialBoard(params).catch((error) => {
|
const initialBoard = await getInitialBoard(await params).catch((error) => {
|
||||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||||
logger.warn(error);
|
logger.warn(error);
|
||||||
notFound();
|
notFound();
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ import "~/styles/scroll-area.scss";
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { NextIntlClientProvider } from "next-intl";
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
|
|
||||||
import { env } from "@homarr/auth/env.mjs";
|
import { env } from "@homarr/auth/env";
|
||||||
import { auth } from "@homarr/auth/next";
|
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 { SpotlightProvider } from "@homarr/spotlight";
|
import { SpotlightProvider } from "@homarr/spotlight";
|
||||||
|
import type { SupportedLanguage } from "@homarr/translation";
|
||||||
import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
|
import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
|
||||||
import { getI18nMessages } from "@homarr/translation/server";
|
import { getI18nMessages } from "@homarr/translation/server";
|
||||||
|
|
||||||
@@ -63,14 +64,17 @@ export const viewport: Viewport = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function Layout(props: { children: React.ReactNode; params: { locale: string } }) {
|
export default async function Layout(props: {
|
||||||
if (!isLocaleSupported(props.params.locale)) {
|
children: React.ReactNode;
|
||||||
|
params: Promise<{ locale: SupportedLanguage }>;
|
||||||
|
}) {
|
||||||
|
if (!isLocaleSupported((await props.params).locale)) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const colorScheme = await getCurrentColorSchemeAsync();
|
const colorScheme = await getCurrentColorSchemeAsync();
|
||||||
const direction = isLocaleRTL(props.params.locale) ? "rtl" : "ltr";
|
const direction = isLocaleRTL((await props.params).locale) ? "rtl" : "ltr";
|
||||||
const i18nMessages = await getI18nMessages();
|
const i18nMessages = await getI18nMessages();
|
||||||
|
|
||||||
const StackedProvider = composeWrappers([
|
const StackedProvider = composeWrappers([
|
||||||
@@ -89,7 +93,7 @@ export default async function Layout(props: { children: React.ReactNode; params:
|
|||||||
return (
|
return (
|
||||||
// Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering
|
// Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering
|
||||||
<html
|
<html
|
||||||
lang={props.params.locale}
|
lang={(await props.params).locale}
|
||||||
dir={direction}
|
dir={direction}
|
||||||
data-mantine-color-scheme={colorScheme}
|
data-mantine-color-scheme={colorScheme}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -5,29 +5,29 @@ import { splitToNChunks } from "@homarr/common";
|
|||||||
import classes from "./hero-banner.module.css";
|
import classes from "./hero-banner.module.css";
|
||||||
|
|
||||||
const icons = [
|
const icons = [
|
||||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/homarr.svg",
|
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg",
|
||||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/sabnzbd.svg",
|
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/sabnzbd.svg",
|
||||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/deluge.svg",
|
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/deluge.svg",
|
||||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/radarr.svg",
|
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/radarr.svg",
|
||||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/sonarr.svg",
|
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/sonarr.svg",
|
||||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/lidarr.svg",
|
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/lidarr.svg",
|
||||||
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/pihole.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/homarr-labs/dashboard-icons/png/dashdot.png",
|
||||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/overseerr.svg",
|
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/overseerr.svg",
|
||||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/plex.svg",
|
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/plex.svg",
|
||||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/jellyfin.svg",
|
"https://cdn.jsdelivr.net/gh/homarr-labs/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/homeassistant.svg",
|
||||||
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/freshrss.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/homarr-labs/dashboard-icons/svg/readarr.svg",
|
||||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/transmission.svg",
|
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/transmission.svg",
|
||||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/qbittorrent.svg",
|
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/qbittorrent.svg",
|
||||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/nzbget.png",
|
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/nzbget.png",
|
||||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/openmediavault.svg",
|
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/openmediavault.svg",
|
||||||
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/docker.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/homarr-labs/dashboard-icons/svg/jellyseerr.svg",
|
||||||
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/adguardhome.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/homarr-labs/dashboard-icons/png/tdarr.png",
|
||||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/prowlarr.svg",
|
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/prowlarr.svg",
|
||||||
];
|
];
|
||||||
|
|
||||||
const countIconGroups = 3;
|
const countIconGroups = 3;
|
||||||
|
|||||||
@@ -37,16 +37,16 @@ export async function generateMetadata() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const getHost = () => {
|
const getHostAsync = async () => {
|
||||||
if (process.env.HOSTNAME) {
|
if (process.env.HOSTNAME) {
|
||||||
return `${process.env.HOSTNAME}:3000`;
|
return `${process.env.HOSTNAME}:3000`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return headers().get("host");
|
return (await headers()).get("host");
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function AboutPage() {
|
export default async function AboutPage() {
|
||||||
const baseServerUrl = `http://${getHost()}`;
|
const baseServerUrl = `http://${await getHostAsync()}`;
|
||||||
const t = await getScopedI18n("management.page.about");
|
const t = await getScopedI18n("management.page.about");
|
||||||
const attributes = await getPackageAttributesAsync();
|
const attributes = await getPackageAttributesAsync();
|
||||||
const githubContributors = (await fetch(`${baseServerUrl}/api/about/contributors/github`).then((res) =>
|
const githubContributors = (await fetch(`${baseServerUrl}/api/about/contributors/github`).then((res) =>
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
|||||||
import { AppEditForm } from "./_app-edit-form";
|
import { AppEditForm } from "./_app-edit-form";
|
||||||
|
|
||||||
interface AppEditPageProps {
|
interface AppEditPageProps {
|
||||||
params: { id: string };
|
params: Promise<{ id: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function AppEditPage({ params }: AppEditPageProps) {
|
export default async function AppEditPage(props: AppEditPageProps) {
|
||||||
|
const params = await props.params;
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
if (!session?.user.permissions.includes("app-modify-all")) {
|
if (!session?.user.permissions.includes("app-modify-all")) {
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import { IconBox, IconPencil } 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 { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
|
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
||||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||||
|
import { SearchInput, TablePagination } from "@homarr/ui";
|
||||||
|
import { z } from "@homarr/validation";
|
||||||
|
|
||||||
import { ManageContainer } from "~/components/manage/manage-container";
|
import { ManageContainer } from "~/components/manage/manage-container";
|
||||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||||
@@ -14,22 +17,35 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
|||||||
import { NoResults } from "~/components/no-results";
|
import { NoResults } from "~/components/no-results";
|
||||||
import { AppDeleteButton } from "./_app-delete-button";
|
import { AppDeleteButton } from "./_app-delete-button";
|
||||||
|
|
||||||
export default async function AppsPage() {
|
const searchParamsSchema = z.object({
|
||||||
|
search: z.string().optional(),
|
||||||
|
pageSize: z.string().regex(/\d+/).transform(Number).catch(10),
|
||||||
|
page: z.string().regex(/\d+/).transform(Number).catch(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface AppsPageProps {
|
||||||
|
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AppsPage(props: AppsPageProps) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
redirect("/auth/login");
|
redirect("/auth/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
const apps = await api.app.all();
|
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
||||||
|
|
||||||
|
const { items: apps, totalCount } = await api.app.getPaginated(searchParams);
|
||||||
const t = await getScopedI18n("app");
|
const t = await getScopedI18n("app");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ManageContainer>
|
<ManageContainer>
|
||||||
<DynamicBreadcrumb />
|
<DynamicBreadcrumb />
|
||||||
<Stack>
|
<Stack>
|
||||||
|
<Title>{t("page.list.title")}</Title>
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
<Title>{t("page.list.title")}</Title>
|
<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} />
|
||||||
{session.user.permissions.includes("app-create") && (
|
{session.user.permissions.includes("app-create") && (
|
||||||
<MobileAffixButton component={Link} href="/manage/apps/new">
|
<MobileAffixButton component={Link} href="/manage/apps/new">
|
||||||
{t("page.create.title")}
|
{t("page.create.title")}
|
||||||
@@ -44,6 +60,10 @@ export default async function AppsPage() {
|
|||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Group justify="end">
|
||||||
|
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
|
||||||
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</ManageContainer>
|
</ManageContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
+36
-2
@@ -3,12 +3,14 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Menu } from "@mantine/core";
|
import { Menu } from "@mantine/core";
|
||||||
import { IconHome, IconSettings, IconTrash } from "@tabler/icons-react";
|
import { IconCopy, IconDeviceMobile, 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 { useSession } from "@homarr/auth/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useConfirmModal } from "@homarr/modals";
|
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||||
|
import { DuplicateBoardModal } from "@homarr/modals-collection";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||||
@@ -30,8 +32,10 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
|
|||||||
const tCommon = useScopedI18n("common");
|
const tCommon = useScopedI18n("common");
|
||||||
|
|
||||||
const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board);
|
const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board);
|
||||||
|
const { data: session } = useSession();
|
||||||
|
|
||||||
const { openConfirmModal } = useConfirmModal();
|
const { openConfirmModal } = useConfirmModal();
|
||||||
|
const { openModal: openDuplicateModal } = useModalAction(DuplicateBoardModal);
|
||||||
|
|
||||||
const setHomeBoardMutation = clientApi.board.setHomeBoard.useMutation({
|
const setHomeBoardMutation = clientApi.board.setHomeBoard.useMutation({
|
||||||
onSettled: async () => {
|
onSettled: async () => {
|
||||||
@@ -39,6 +43,12 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
|
|||||||
await revalidatePathActionAsync("/");
|
await revalidatePathActionAsync("/");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const setMobileHomeBoardMutation = clientApi.board.setMobileHomeBoard.useMutation({
|
||||||
|
onSettled: async () => {
|
||||||
|
// Revalidate all as it's part of the user settings, /boards page and board manage page
|
||||||
|
await revalidatePathActionAsync("/");
|
||||||
|
},
|
||||||
|
});
|
||||||
const deleteBoardMutation = clientApi.board.deleteBoard.useMutation({
|
const deleteBoardMutation = clientApi.board.deleteBoard.useMutation({
|
||||||
onSettled: async () => {
|
onSettled: async () => {
|
||||||
await revalidatePathActionAsync("/manage/boards");
|
await revalidatePathActionAsync("/manage/boards");
|
||||||
@@ -64,11 +74,35 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
|
|||||||
await setHomeBoardMutation.mutateAsync({ id: board.id });
|
await setHomeBoardMutation.mutateAsync({ id: board.id });
|
||||||
}, [board.id, setHomeBoardMutation]);
|
}, [board.id, setHomeBoardMutation]);
|
||||||
|
|
||||||
|
const handleSetMobileHomeBoard = useCallback(async () => {
|
||||||
|
await setMobileHomeBoardMutation.mutateAsync({ id: board.id });
|
||||||
|
}, [board.id, setMobileHomeBoardMutation]);
|
||||||
|
|
||||||
|
const handleDuplicateBoard = useCallback(() => {
|
||||||
|
openDuplicateModal({
|
||||||
|
board: {
|
||||||
|
id: board.id,
|
||||||
|
name: board.name,
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await revalidatePathActionAsync("/manage/boards");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [board.id, board.name, openDuplicateModal]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<Menu.Item onClick={handleSetHomeBoard} leftSection={<IconHome {...iconProps} />}>
|
<Menu.Item onClick={handleSetHomeBoard} leftSection={<IconHome {...iconProps} />}>
|
||||||
{t("setHomeBoard.label")}
|
{t("setHomeBoard.label")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
<Menu.Item onClick={handleSetMobileHomeBoard} leftSection={<IconDeviceMobile {...iconProps} />}>
|
||||||
|
{t("setMobileHomeBoard.label")}
|
||||||
|
</Menu.Item>
|
||||||
|
{session?.user.permissions.includes("board-create") && (
|
||||||
|
<Menu.Item onClick={handleDuplicateBoard} leftSection={<IconCopy {...iconProps} />}>
|
||||||
|
{t("duplicate.label")}
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
{hasChangeAccess && (
|
{hasChangeAccess && (
|
||||||
<>
|
<>
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Affix, Button, Group, Menu } from "@mantine/core";
|
import { Affix, Button, Menu } from "@mantine/core";
|
||||||
import { IconCategoryPlus, IconChevronDown, IconFileImport } from "@tabler/icons-react";
|
import { IconCategoryPlus, IconChevronDown, IconFileImport } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { useModalAction } from "@homarr/modals";
|
import { useModalAction } from "@homarr/modals";
|
||||||
import { AddBoardModal, ImportBoardModal } from "@homarr/modals-collection";
|
import { AddBoardModal, ImportBoardModal } from "@homarr/modals-collection";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { BetaBadge } from "@homarr/ui";
|
|
||||||
|
|
||||||
export const CreateBoardButton = () => {
|
export const CreateBoardButton = () => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
@@ -26,10 +25,7 @@ export const CreateBoardButton = () => {
|
|||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<Menu.Item onClick={openImportModal} leftSection={<IconFileImport size="1rem" />}>
|
<Menu.Item onClick={openImportModal} leftSection={<IconFileImport size="1rem" />}>
|
||||||
<Group>
|
{t("board.action.oldImport.label")}
|
||||||
{t("board.action.oldImport.label")}
|
|
||||||
<BetaBadge size="xs" />
|
|
||||||
</Group>
|
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
Title,
|
Title,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconDotsVertical, IconHomeFilled, IconLock, IconWorld } from "@tabler/icons-react";
|
import { IconDeviceMobile, 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";
|
||||||
@@ -88,6 +88,14 @@ const BoardCard = async ({ board }: BoardCardProps) => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{board.isMobileHome && (
|
||||||
|
<Tooltip label={t("action.setMobileHomeBoard.badge.tooltip")}>
|
||||||
|
<Badge tt="none" color="yellow" variant="light" leftSection={<IconDeviceMobile size=".7rem" />}>
|
||||||
|
{t("action.setMobileHomeBoard.badge.label")}
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
{board.creator && (
|
{board.creator && (
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<UserAvatar user={board.creator} size="sm" />
|
<UserAvatar user={board.creator} size="sm" />
|
||||||
|
|||||||
+3
-1
@@ -1,4 +1,4 @@
|
|||||||
import { IconKey, IconPassword, IconUser } from "@tabler/icons-react";
|
import { IconGrid3x3, IconKey, IconPassword, IconServer, IconUser } from "@tabler/icons-react";
|
||||||
|
|
||||||
import type { IntegrationSecretKind } from "@homarr/definitions";
|
import type { IntegrationSecretKind } from "@homarr/definitions";
|
||||||
import type { TablerIcon } from "@homarr/ui";
|
import type { TablerIcon } from "@homarr/ui";
|
||||||
@@ -7,4 +7,6 @@ export const integrationSecretIcons = {
|
|||||||
username: IconUser,
|
username: IconUser,
|
||||||
apiKey: IconKey,
|
apiKey: IconKey,
|
||||||
password: IconPassword,
|
password: IconPassword,
|
||||||
|
realm: IconServer,
|
||||||
|
tokenId: IconGrid3x3,
|
||||||
} satisfies Record<IntegrationSecretKind, TablerIcon>;
|
} satisfies Record<IntegrationSecretKind, TablerIcon>;
|
||||||
@@ -11,10 +11,11 @@ import { IntegrationAccessSettings } from "../../_components/integration-access-
|
|||||||
import { EditIntegrationForm } from "./_integration-edit-form";
|
import { EditIntegrationForm } from "./_integration-edit-form";
|
||||||
|
|
||||||
interface EditIntegrationPageProps {
|
interface EditIntegrationPageProps {
|
||||||
params: { id: string };
|
params: Promise<{ id: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function EditIntegrationPage({ params }: EditIntegrationPageProps) {
|
export default async function EditIntegrationPage(props: EditIntegrationPageProps) {
|
||||||
|
const params = await props.params;
|
||||||
const editT = await getScopedI18n("integration.page.edit");
|
const editT = await getScopedI18n("integration.page.edit");
|
||||||
const t = await getI18n();
|
const t = await getI18n();
|
||||||
const integration = await api.integration.byId({ id: params.id }).catch(catchTrpcNotFound);
|
const integration = await api.integration.byId({ id: params.id }).catch(catchTrpcNotFound);
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ export const IntegrationCreateDropdownContent = () => {
|
|||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
const filteredKinds = useMemo(() => {
|
const filteredKinds = useMemo(() => {
|
||||||
return integrationKinds.filter((kind) => kind.includes(search.toLowerCase()));
|
return integrationKinds.filter((kind) =>
|
||||||
|
getIntegrationName(kind).toLowerCase().includes(search.toLowerCase().trim()),
|
||||||
|
);
|
||||||
}, [search]);
|
}, [search]);
|
||||||
|
|
||||||
const handleSearch = React.useCallback(
|
const handleSearch = React.useCallback(
|
||||||
@@ -29,6 +31,7 @@ export const IntegrationCreateDropdownContent = () => {
|
|||||||
leftSection={<IconSearch stroke={1.5} size={20} />}
|
leftSection={<IconSearch stroke={1.5} size={20} />}
|
||||||
placeholder={t("integration.page.list.search")}
|
placeholder={t("integration.page.list.search")}
|
||||||
value={search}
|
value={search}
|
||||||
|
data-autofocus
|
||||||
onChange={handleSearch}
|
onChange={handleSearch}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -13,12 +13,15 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
|||||||
import { NewIntegrationForm } from "./_integration-new-form";
|
import { NewIntegrationForm } from "./_integration-new-form";
|
||||||
|
|
||||||
interface NewIntegrationPageProps {
|
interface NewIntegrationPageProps {
|
||||||
searchParams: Partial<z.infer<typeof validation.integration.create>> & {
|
searchParams: Promise<
|
||||||
kind: IntegrationKind;
|
Partial<z.infer<typeof validation.integration.create>> & {
|
||||||
};
|
kind: IntegrationKind;
|
||||||
|
}
|
||||||
|
>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function IntegrationsNewPage({ searchParams }: NewIntegrationPageProps) {
|
export default async function IntegrationsNewPage(props: NewIntegrationPageProps) {
|
||||||
|
const searchParams = await props.searchParams;
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user.permissions.includes("integration-create")) {
|
if (!session?.user.permissions.includes("integration-create")) {
|
||||||
notFound();
|
notFound();
|
||||||
|
|||||||
@@ -46,12 +46,13 @@ import { DeleteIntegrationActionButton } from "./_integration-buttons";
|
|||||||
import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown";
|
import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown";
|
||||||
|
|
||||||
interface IntegrationsPageProps {
|
interface IntegrationsPageProps {
|
||||||
searchParams: {
|
searchParams: Promise<{
|
||||||
tab?: IntegrationKind;
|
tab?: IntegrationKind;
|
||||||
};
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function IntegrationsPage({ searchParams }: IntegrationsPageProps) {
|
export default async function IntegrationsPage(props: IntegrationsPageProps) {
|
||||||
|
const searchParams = await props.searchParams;
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -101,7 +102,15 @@ export default async function IntegrationsPage({ searchParams }: IntegrationsPag
|
|||||||
|
|
||||||
const IntegrationSelectMenu = ({ children }: PropsWithChildren) => {
|
const IntegrationSelectMenu = ({ children }: PropsWithChildren) => {
|
||||||
return (
|
return (
|
||||||
<Menu width={256} trapFocus position="bottom-end" withinPortal shadow="md" keepMounted={false}>
|
<Menu
|
||||||
|
width={256}
|
||||||
|
trapFocus
|
||||||
|
position="bottom-end"
|
||||||
|
withinPortal
|
||||||
|
shadow="md"
|
||||||
|
keepMounted={false}
|
||||||
|
withInitialFocusPlaceholder={false}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
<MenuDropdown>
|
<MenuDropdown>
|
||||||
<IntegrationCreateDropdownContent />
|
<IntegrationCreateDropdownContent />
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
IconBrandDiscord,
|
IconBrandDiscord,
|
||||||
IconBrandDocker,
|
IconBrandDocker,
|
||||||
IconBrandGithub,
|
IconBrandGithub,
|
||||||
|
IconCertificate,
|
||||||
IconGitFork,
|
IconGitFork,
|
||||||
IconHome,
|
IconHome,
|
||||||
IconInfoSmall,
|
IconInfoSmall,
|
||||||
@@ -119,6 +120,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
href: "/manage/tools/logs",
|
href: "/manage/tools/logs",
|
||||||
hidden: !session?.user.permissions.includes("other-view-logs"),
|
hidden: !session?.user.permissions.includes("other-view-logs"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: t("items.tools.items.certificates"),
|
||||||
|
icon: IconCertificate,
|
||||||
|
href: "/manage/tools/certificates",
|
||||||
|
hidden: !session?.user.permissions.includes("admin"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: t("items.tools.items.tasks"),
|
label: t("items.tools.items.tasks"),
|
||||||
icon: IconReport,
|
icon: IconReport,
|
||||||
@@ -146,7 +153,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
{
|
{
|
||||||
label: t("items.help.items.submitIssue"),
|
label: t("items.help.items.submitIssue"),
|
||||||
icon: IconBrandGithub,
|
icon: IconBrandGithub,
|
||||||
href: "https://github.com/ajnart/homarr/issues/new/choose",
|
href: "https://github.com/homarr-labs/homarr/issues/new/choose",
|
||||||
external: true,
|
external: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -158,7 +165,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
{
|
{
|
||||||
label: t("items.help.items.sourceCode"),
|
label: t("items.help.items.sourceCode"),
|
||||||
icon: IconGitFork,
|
icon: IconGitFork,
|
||||||
href: "https://github.com/ajnart/homarr",
|
href: "https://github.com/homarr-labs/homarr",
|
||||||
external: true,
|
external: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ export const CopyMedia = ({ media }: CopyMediaProps) => {
|
|||||||
|
|
||||||
const url = typeof window !== "undefined" ? `${window.location.origin}/api/user-medias/${media.id}` : "";
|
const url = typeof window !== "undefined" ? `${window.location.origin}/api/user-medias/${media.id}` : "";
|
||||||
|
|
||||||
|
// Clipboard only works on localhost or secure connections (https)
|
||||||
|
if (url.startsWith("http://") && !url.startsWith("http://localhost")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CopyButton value={url}>
|
<CopyButton value={url}>
|
||||||
{({ copy, copied }) => (
|
{({ copy, copied }) => (
|
||||||
|
|||||||
@@ -1,15 +1,40 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import type { JSX } from "react";
|
||||||
import { Button, FileButton } from "@mantine/core";
|
import { Button, FileButton } from "@mantine/core";
|
||||||
import { IconUpload } from "@tabler/icons-react";
|
import { IconUpload } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
|
import type { MaybePromise } from "@homarr/common/types";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { supportedMediaUploadFormats } from "@homarr/validation";
|
import { supportedMediaUploadFormats } from "@homarr/validation";
|
||||||
|
|
||||||
export const UploadMedia = () => {
|
export const UploadMediaButton = () => {
|
||||||
|
const t = useI18n();
|
||||||
|
const onSettledAsync = async () => {
|
||||||
|
await revalidatePathActionAsync("/manage/medias");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UploadMedia onSettled={onSettledAsync}>
|
||||||
|
{({ onClick, loading }) => (
|
||||||
|
<Button onClick={onClick} loading={loading} rightSection={<IconUpload size={16} stroke={1.5} />}>
|
||||||
|
{t("media.action.upload.label")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</UploadMedia>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface UploadMediaProps {
|
||||||
|
children: (props: { onClick: () => void; loading: boolean }) => JSX.Element;
|
||||||
|
onSettled?: () => MaybePromise<void>;
|
||||||
|
onSuccess?: (media: { id: string; url: string }) => MaybePromise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UploadMedia = ({ children, onSettled, onSuccess }: UploadMediaProps) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { mutateAsync, isPending } = clientApi.media.uploadMedia.useMutation();
|
const { mutateAsync, isPending } = clientApi.media.uploadMedia.useMutation();
|
||||||
|
|
||||||
@@ -18,10 +43,14 @@ export const UploadMedia = () => {
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
await mutateAsync(formData, {
|
await mutateAsync(formData, {
|
||||||
onSuccess() {
|
async onSuccess(mediaId) {
|
||||||
showSuccessNotification({
|
showSuccessNotification({
|
||||||
message: t("media.action.upload.notification.success.message"),
|
message: t("media.action.upload.notification.success.message"),
|
||||||
});
|
});
|
||||||
|
await onSuccess?.({
|
||||||
|
id: mediaId,
|
||||||
|
url: `/api/user-medias/${mediaId}`,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onError() {
|
onError() {
|
||||||
showErrorNotification({
|
showErrorNotification({
|
||||||
@@ -29,18 +58,14 @@ export const UploadMedia = () => {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
async onSettled() {
|
async onSettled() {
|
||||||
await revalidatePathActionAsync("/manage/medias");
|
await onSettled?.();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FileButton onChange={handleFileUploadAsync} accept={supportedMediaUploadFormats.join(",")}>
|
<FileButton onChange={handleFileUploadAsync} accept={supportedMediaUploadFormats.join(",")}>
|
||||||
{({ onClick }) => (
|
{({ onClick }) => children({ onClick, loading: isPending })}
|
||||||
<Button onClick={onClick} loading={isPending} rightSection={<IconUpload size={16} stroke={1.5} />}>
|
|
||||||
{t("media.action.upload.label")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</FileButton>
|
</FileButton>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,12 +1,28 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { Anchor, Group, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Title } from "@mantine/core";
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Anchor,
|
||||||
|
Group,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
TableTbody,
|
||||||
|
TableTd,
|
||||||
|
TableTh,
|
||||||
|
TableThead,
|
||||||
|
TableTr,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { IconExternalLink } 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 { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { humanFileSize } from "@homarr/common";
|
import { humanFileSize } from "@homarr/common";
|
||||||
|
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
||||||
|
import { createLocalImageUrl } from "@homarr/icons/local";
|
||||||
import { getI18n } from "@homarr/translation/server";
|
import { getI18n } from "@homarr/translation/server";
|
||||||
import { SearchInput, TablePagination, UserAvatar } from "@homarr/ui";
|
import { SearchInput, TablePagination, UserAvatar } from "@homarr/ui";
|
||||||
import { z } from "@homarr/validation";
|
import { z } from "@homarr/validation";
|
||||||
@@ -16,7 +32,7 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
|||||||
import { CopyMedia } from "./_actions/copy-media";
|
import { CopyMedia } from "./_actions/copy-media";
|
||||||
import { DeleteMedia } from "./_actions/delete-media";
|
import { DeleteMedia } from "./_actions/delete-media";
|
||||||
import { IncludeFromAllUsersSwitch } from "./_actions/show-all";
|
import { IncludeFromAllUsersSwitch } from "./_actions/show-all";
|
||||||
import { UploadMedia } from "./_actions/upload-media";
|
import { UploadMediaButton } from "./_actions/upload-media";
|
||||||
|
|
||||||
const searchParamsSchema = z.object({
|
const searchParamsSchema = z.object({
|
||||||
search: z.string().optional(),
|
search: z.string().optional(),
|
||||||
@@ -29,12 +45,8 @@ const searchParamsSchema = z.object({
|
|||||||
page: z.string().regex(/\d+/).transform(Number).catch(1),
|
page: z.string().regex(/\d+/).transform(Number).catch(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
type SearchParamsSchemaInputFromSchema<TSchema extends Record<string, unknown>> = Partial<{
|
|
||||||
[K in keyof TSchema]: Exclude<TSchema[K], undefined> extends unknown[] ? string[] : string;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
interface MediaListPageProps {
|
interface MediaListPageProps {
|
||||||
searchParams: SearchParamsSchemaInputFromSchema<z.infer<typeof searchParamsSchema>>;
|
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function GroupsListPage(props: MediaListPageProps) {
|
export default async function GroupsListPage(props: MediaListPageProps) {
|
||||||
@@ -45,7 +57,7 @@ export default async function GroupsListPage(props: MediaListPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const t = await getI18n();
|
const t = await getI18n();
|
||||||
const searchParams = searchParamsSchema.parse(props.searchParams);
|
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
||||||
const { items: medias, totalCount } = await api.media.getPaginated(searchParams);
|
const { items: medias, totalCount } = await api.media.getPaginated(searchParams);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -61,7 +73,7 @@ export default async function GroupsListPage(props: MediaListPageProps) {
|
|||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{session.user.permissions.includes("media-upload") && <UploadMedia />}
|
{session.user.permissions.includes("media-upload") && <UploadMediaButton />}
|
||||||
</Group>
|
</Group>
|
||||||
<Table striped highlightOnHover>
|
<Table striped highlightOnHover>
|
||||||
<TableThead>
|
<TableThead>
|
||||||
@@ -94,13 +106,14 @@ interface RowProps {
|
|||||||
|
|
||||||
const Row = async ({ media }: RowProps) => {
|
const Row = async ({ media }: RowProps) => {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
const t = await getI18n();
|
||||||
const canDelete = media.creatorId === session?.user.id || session?.user.permissions.includes("media-full-all");
|
const canDelete = media.creatorId === session?.user.id || session?.user.permissions.includes("media-full-all");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableTr>
|
<TableTr>
|
||||||
<TableTd w={64}>
|
<TableTd w={64}>
|
||||||
<Image
|
<Image
|
||||||
src={`/api/user-medias/${media.id}`}
|
src={createLocalImageUrl(media.id)}
|
||||||
alt={media.name}
|
alt={media.name}
|
||||||
width={64}
|
width={64}
|
||||||
height={64}
|
height={64}
|
||||||
@@ -124,6 +137,17 @@ const Row = async ({ media }: RowProps) => {
|
|||||||
<TableTd w={64}>
|
<TableTd w={64}>
|
||||||
<Group wrap="nowrap" gap="xs">
|
<Group wrap="nowrap" gap="xs">
|
||||||
<CopyMedia media={media} />
|
<CopyMedia media={media} />
|
||||||
|
<Tooltip label={t("media.action.open.label")} openDelay={500}>
|
||||||
|
<ActionIcon
|
||||||
|
component="a"
|
||||||
|
href={createLocalImageUrl(media.id)}
|
||||||
|
target="_blank"
|
||||||
|
color="gray"
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
<IconExternalLink size={16} stroke={1.5} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
{canDelete && <DeleteMedia media={media} />}
|
{canDelete && <DeleteMedia media={media} />}
|
||||||
</Group>
|
</Group>
|
||||||
</TableTd>
|
</TableTd>
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
|||||||
import { SearchEngineEditForm } from "./_search-engine-edit-form";
|
import { SearchEngineEditForm } from "./_search-engine-edit-form";
|
||||||
|
|
||||||
interface SearchEngineEditPageProps {
|
interface SearchEngineEditPageProps {
|
||||||
params: { id: string };
|
params: Promise<{ id: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function SearchEngineEditPage({ params }: SearchEngineEditPageProps) {
|
export default async function SearchEngineEditPage(props: SearchEngineEditPageProps) {
|
||||||
|
const params = await props.params;
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
if (!session?.user.permissions.includes("search-engine-modify-all")) {
|
if (!session?.user.permissions.includes("search-engine-modify-all")) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { IconPencil, IconSearch } 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 { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
|
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
||||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||||
import { SearchInput, TablePagination } from "@homarr/ui";
|
import { SearchInput, TablePagination } from "@homarr/ui";
|
||||||
import { z } from "@homarr/validation";
|
import { z } from "@homarr/validation";
|
||||||
@@ -22,12 +23,8 @@ const searchParamsSchema = z.object({
|
|||||||
page: z.string().regex(/\d+/).transform(Number).catch(1),
|
page: z.string().regex(/\d+/).transform(Number).catch(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
type SearchParamsSchemaInputFromSchema<TSchema extends Record<string, unknown>> = Partial<{
|
|
||||||
[K in keyof TSchema]: Exclude<TSchema[K], undefined> extends unknown[] ? string[] : string;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
interface SearchEnginesPageProps {
|
interface SearchEnginesPageProps {
|
||||||
searchParams: SearchParamsSchemaInputFromSchema<z.infer<typeof searchParamsSchema>>;
|
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
|
export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
|
||||||
@@ -37,7 +34,7 @@ export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
|
|||||||
redirect("/auth/login");
|
redirect("/auth/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchParams = searchParamsSchema.parse(props.searchParams);
|
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
||||||
const { items: searchEngines, totalCount } = await api.searchEngine.getPaginated(searchParams);
|
const { items: searchEngines, totalCount } = await api.searchEngine.getPaginated(searchParams);
|
||||||
|
|
||||||
const tEngine = await getScopedI18n("search.engine");
|
const tEngine = await getScopedI18n("search.engine");
|
||||||
|
|||||||
@@ -37,6 +37,25 @@ export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSett
|
|||||||
)}
|
)}
|
||||||
{...form.getInputProps("homeBoardId")}
|
{...form.getInputProps("homeBoardId")}
|
||||||
/>
|
/>
|
||||||
|
<SelectWithCustomItems
|
||||||
|
label={tBoard("homeBoard.mobileLabel")}
|
||||||
|
description={tBoard("homeBoard.description")}
|
||||||
|
data={selectableBoards.map((board) => ({
|
||||||
|
value: board.id,
|
||||||
|
label: board.name,
|
||||||
|
image: board.logoImageUrl,
|
||||||
|
}))}
|
||||||
|
SelectOption={({ label, image }: { value: string; label: string; image: string | null }) => (
|
||||||
|
<Group>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
{image ? <img width={16} height={16} src={image} alt={label} /> : <IconLayoutDashboard size={16} />}
|
||||||
|
<Text fz="sm" fw={500}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
{...form.getInputProps("mobileHomeBoardId")}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</CommonSettingsForm>
|
</CommonSettingsForm>
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Select } from "@mantine/core";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import type { ServerSettings } from "@homarr/server-settings";
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { CommonSettingsForm } from "./common-form";
|
||||||
|
|
||||||
|
export const SearchSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["search"] }) => {
|
||||||
|
const tSearch = useScopedI18n("management.page.settings.section.search");
|
||||||
|
const [selectableSearchEngines] = clientApi.searchEngine.getSelectable.useSuspenseQuery({ withIntegrations: false });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommonSettingsForm settingKey="search" defaultValues={defaultValues}>
|
||||||
|
{(form) => (
|
||||||
|
<>
|
||||||
|
<Select
|
||||||
|
label={tSearch("defaultSearchEngine.label")}
|
||||||
|
description={tSearch("defaultSearchEngine.description")}
|
||||||
|
data={selectableSearchEngines}
|
||||||
|
{...form.getInputProps("defaultSearchEngineId")}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CommonSettingsForm>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -11,6 +11,7 @@ import { AnalyticsSettings } from "./_components/analytics.settings";
|
|||||||
import { AppearanceSettingsForm } from "./_components/appearance-settings-form";
|
import { AppearanceSettingsForm } from "./_components/appearance-settings-form";
|
||||||
import { BoardSettingsForm } from "./_components/board-settings-form";
|
import { BoardSettingsForm } from "./_components/board-settings-form";
|
||||||
import { CultureSettingsForm } from "./_components/culture-settings-form";
|
import { CultureSettingsForm } from "./_components/culture-settings-form";
|
||||||
|
import { SearchSettingsForm } from "./_components/search-settings-form";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
const t = await getScopedI18n("management");
|
const t = await getScopedI18n("management");
|
||||||
@@ -41,6 +42,10 @@ export default async function SettingsPage() {
|
|||||||
<Title order={2}>{tSettings("section.board.title")}</Title>
|
<Title order={2}>{tSettings("section.board.title")}</Title>
|
||||||
<BoardSettingsForm defaultValues={serverSettings.board} />
|
<BoardSettingsForm defaultValues={serverSettings.board} />
|
||||||
</Stack>
|
</Stack>
|
||||||
|
<Stack>
|
||||||
|
<Title order={2}>{tSettings("section.search.title")}</Title>
|
||||||
|
<SearchSettingsForm defaultValues={serverSettings.search} />
|
||||||
|
</Stack>
|
||||||
<Stack>
|
<Stack>
|
||||||
<Title order={2}>{tSettings("section.appearance.title")}</Title>
|
<Title order={2}>{tSettings("section.appearance.title")}</Title>
|
||||||
<AppearanceSettingsForm defaultValues={serverSettings.appearance} />
|
<AppearanceSettingsForm defaultValues={serverSettings.appearance} />
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export default async function ApiPage() {
|
|||||||
if (!session?.user || !session.user.permissions.includes("admin")) {
|
if (!session?.user || !session.user.permissions.includes("admin")) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
const document = openApiDocument(extractBaseUrlFromHeaders(headers()));
|
const document = openApiDocument(extractBaseUrlFromHeaders(await headers()));
|
||||||
const apiKeys = await api.apiKeys.getAll();
|
const apiKeys = await api.apiKeys.getAll();
|
||||||
const t = await getScopedI18n("management.page.tool.api.tab");
|
const t = await getScopedI18n("management.page.tool.api.tab");
|
||||||
|
|
||||||
|
|||||||
+81
@@ -0,0 +1,81 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button, FileInput, Group, Stack } from "@mantine/core";
|
||||||
|
import { IconCertificate } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
|
import { useZodForm } from "@homarr/form";
|
||||||
|
import { createModal, useModalAction } from "@homarr/modals";
|
||||||
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { superRefineCertificateFile, z } from "@homarr/validation";
|
||||||
|
|
||||||
|
export const AddCertificateButton = () => {
|
||||||
|
const { openModal } = useModalAction(AddCertificateModal);
|
||||||
|
const t = useI18n();
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
openModal({});
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Button onClick={handleClick}>{t("certificate.action.create.label")}</Button>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AddCertificateModal = createModal(({ actions }) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const form = useZodForm(
|
||||||
|
z.object({
|
||||||
|
file: z.instanceof(File).nullable().superRefine(superRefineCertificateFile),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
initialValues: {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
file: null!,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const { mutateAsync } = clientApi.certificates.addCertificate.useMutation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={form.onSubmit(async (values) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
formData.set("file", values.file!);
|
||||||
|
await mutateAsync(formData, {
|
||||||
|
async onSuccess() {
|
||||||
|
showSuccessNotification({
|
||||||
|
title: t("certificate.action.create.notification.success.title"),
|
||||||
|
message: t("certificate.action.create.notification.success.message"),
|
||||||
|
});
|
||||||
|
await revalidatePathActionAsync("/manage/tools/certificates");
|
||||||
|
actions.closeModal();
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
showErrorNotification({
|
||||||
|
title: t("certificate.action.create.notification.error.title"),
|
||||||
|
message: t("certificate.action.create.notification.error.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
<FileInput leftSection={<IconCertificate size={16} />} {...form.getInputProps("file")} />
|
||||||
|
<Group justify="end">
|
||||||
|
<Button onClick={actions.closeModal} variant="subtle" color="gray">
|
||||||
|
{t("common.action.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" loading={form.submitting}>
|
||||||
|
{t("common.action.add")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}).withOptions({
|
||||||
|
defaultTitle(t) {
|
||||||
|
return t("certificate.action.create.label");
|
||||||
|
},
|
||||||
|
});
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ActionIcon } from "@mantine/core";
|
||||||
|
import { IconTrash } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
|
import { useConfirmModal } from "@homarr/modals";
|
||||||
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
interface RemoveCertificateProps {
|
||||||
|
fileName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RemoveCertificate = ({ fileName }: RemoveCertificateProps) => {
|
||||||
|
const { openConfirmModal } = useConfirmModal();
|
||||||
|
const { mutateAsync } = clientApi.certificates.removeCertificate.useMutation();
|
||||||
|
const t = useI18n();
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
openConfirmModal({
|
||||||
|
title: t("certificate.action.remove.label"),
|
||||||
|
children: t("certificate.action.remove.confirm"),
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
async onConfirm() {
|
||||||
|
await mutateAsync(
|
||||||
|
{ fileName },
|
||||||
|
{
|
||||||
|
async onSuccess() {
|
||||||
|
showSuccessNotification({
|
||||||
|
title: t("certificate.action.remove.notification.success.title"),
|
||||||
|
message: t("certificate.action.remove.notification.success.message"),
|
||||||
|
});
|
||||||
|
await revalidatePathActionAsync("/manage/tools/certificates");
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
showErrorNotification({
|
||||||
|
title: t("certificate.action.remove.notification.error.title"),
|
||||||
|
message: t("certificate.action.remove.notification.error.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActionIcon onClick={handleClick} color="red" variant="subtle">
|
||||||
|
<IconTrash color="red" size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { X509Certificate } from "node:crypto";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { Card, Group, SimpleGrid, Stack, Text, Title } from "@mantine/core";
|
||||||
|
import { IconCertificate, IconCertificateOff } from "@tabler/icons-react";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
import { auth } from "@homarr/auth/next";
|
||||||
|
import { loadCustomRootCertificatesAsync } from "@homarr/certificates/server";
|
||||||
|
import { getMantineColor } from "@homarr/common";
|
||||||
|
import type { SupportedLanguage } from "@homarr/translation";
|
||||||
|
import { getI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
|
import { NoResults } from "~/components/no-results";
|
||||||
|
import { AddCertificateButton } from "./_components/add-certificate";
|
||||||
|
import { RemoveCertificate } from "./_components/remove-certificate";
|
||||||
|
|
||||||
|
interface CertificatesPageProps {
|
||||||
|
params: Promise<{
|
||||||
|
locale: SupportedLanguage;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CertificatesPage({ params }: CertificatesPageProps) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user.permissions.includes("admin")) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { locale } = await params;
|
||||||
|
const t = await getI18n();
|
||||||
|
const certificates = await loadCustomRootCertificatesAsync();
|
||||||
|
const x509Certificates = certificates
|
||||||
|
.map((cert) => ({
|
||||||
|
...cert,
|
||||||
|
x509: new X509Certificate(cert.content),
|
||||||
|
}))
|
||||||
|
.sort((certA, certB) => certA.x509.validToDate.getTime() - certB.x509.validToDate.getTime());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DynamicBreadcrumb />
|
||||||
|
|
||||||
|
<Stack>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Title>{t("certificate.page.list.title")}</Title>
|
||||||
|
<Text>{t("certificate.page.list.description")}</Text>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<AddCertificateButton />
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{x509Certificates.length === 0 && (
|
||||||
|
<NoResults icon={IconCertificateOff} title={t("certificate.page.list.noResults.title")} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SimpleGrid cols={{ sm: 1, lg: 2, xl: 3 }} spacing="lg">
|
||||||
|
{x509Certificates.map((cert) => (
|
||||||
|
<Card key={cert.x509.fingerprint} withBorder>
|
||||||
|
<Group wrap="nowrap">
|
||||||
|
<IconCertificate
|
||||||
|
color={getMantineColor(iconColor(cert.x509.validToDate), 6)}
|
||||||
|
style={{ minWidth: 32 }}
|
||||||
|
size={32}
|
||||||
|
stroke={1.5}
|
||||||
|
/>
|
||||||
|
<Stack flex={1} gap="xs" maw="calc(100% - 48px)">
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<Text fw={500} lineClamp={1} style={{ wordBreak: "break-all" }}>
|
||||||
|
{cert.x509.subject}
|
||||||
|
</Text>
|
||||||
|
<Text c="gray.6" ta="end" size="sm">
|
||||||
|
{cert.fileName}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="sm" c="gray.6" title={cert.x509.validToDate.toISOString()}>
|
||||||
|
{t("certificate.page.list.expires", {
|
||||||
|
when: new Intl.RelativeTimeFormat(locale).format(
|
||||||
|
dayjs(cert.x509.validToDate).diff(dayjs(), "days"),
|
||||||
|
"days",
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<RemoveCertificate fileName={cert.fileName} />
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconColor = (validTo: Date) => {
|
||||||
|
const daysUntilInvalid = dayjs(validTo).diff(new Date(), "days");
|
||||||
|
if (daysUntilInvalid < 1) return "red";
|
||||||
|
if (daysUntilInvalid < 7) return "orange";
|
||||||
|
if (daysUntilInvalid < 30) return "yellow";
|
||||||
|
return "green";
|
||||||
|
};
|
||||||
@@ -2,14 +2,23 @@
|
|||||||
|
|
||||||
import type { MantineColor } from "@mantine/core";
|
import type { MantineColor } from "@mantine/core";
|
||||||
import { Avatar, Badge, Box, Button, Group, Text } from "@mantine/core";
|
import { Avatar, Badge, Box, Button, Group, Text } from "@mantine/core";
|
||||||
import { IconPlayerPlay, IconPlayerStop, IconRefresh, IconRotateClockwise, IconTrash } from "@tabler/icons-react";
|
import {
|
||||||
|
IconCategoryPlus,
|
||||||
|
IconPlayerPlay,
|
||||||
|
IconPlayerStop,
|
||||||
|
IconRefresh,
|
||||||
|
IconRotateClockwise,
|
||||||
|
IconTrash,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
import type { MRT_ColumnDef } from "mantine-react-table";
|
import type { MRT_ColumnDef } from "mantine-react-table";
|
||||||
import { MantineReactTable } from "mantine-react-table";
|
import { MantineReactTable } from "mantine-react-table";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useTimeAgo } from "@homarr/common";
|
import { useTimeAgo } from "@homarr/common";
|
||||||
import type { DockerContainerState } from "@homarr/definitions";
|
import type { ContainerState } from "@homarr/docker";
|
||||||
|
import { useModalAction } from "@homarr/modals";
|
||||||
|
import { AddDockerAppToHomarr } from "@homarr/modals-collection";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import type { TranslationFunction } from "@homarr/translation";
|
import type { TranslationFunction } from "@homarr/translation";
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
@@ -125,6 +134,7 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
renderToolbarAlertBannerContent: ({ groupedAlert, table }) => {
|
renderToolbarAlertBannerContent: ({ groupedAlert, table }) => {
|
||||||
|
const dockerContainers = table.getSelectedRowModel().rows.map((row) => row.original);
|
||||||
return (
|
return (
|
||||||
<Group gap={"sm"}>
|
<Group gap={"sm"}>
|
||||||
{groupedAlert}
|
{groupedAlert}
|
||||||
@@ -134,7 +144,7 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
|
|||||||
totalCount: table.getRowCount(),
|
totalCount: table.getRowCount(),
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
<ContainerActionBar selectedIds={table.getSelectedRowModel().rows.map((row) => row.original.id)} />
|
<ContainerActionBar selectedContainers={dockerContainers} />
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -151,16 +161,29 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ContainerActionBarProps {
|
interface ContainerActionBarProps {
|
||||||
selectedIds: string[];
|
selectedContainers: RouterOutputs["docker"]["getContainers"]["containers"];
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContainerActionBar = ({ selectedIds }: ContainerActionBarProps) => {
|
const ContainerActionBar = ({ selectedContainers }: ContainerActionBarProps) => {
|
||||||
|
const t = useScopedI18n("docker.action");
|
||||||
|
const { openModal } = useModalAction(AddDockerAppToHomarr);
|
||||||
|
const handleClick = () => {
|
||||||
|
openModal({
|
||||||
|
selectedContainers,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedIds = selectedContainers.map((container) => container.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<ContainerActionBarButton icon={IconPlayerPlay} color="green" action="start" selectedIds={selectedIds} />
|
<ContainerActionBarButton icon={IconPlayerPlay} color="green" action="start" selectedIds={selectedIds} />
|
||||||
<ContainerActionBarButton icon={IconPlayerStop} color="red" action="stop" selectedIds={selectedIds} />
|
<ContainerActionBarButton icon={IconPlayerStop} color="red" action="stop" selectedIds={selectedIds} />
|
||||||
<ContainerActionBarButton icon={IconRotateClockwise} color="orange" action="restart" selectedIds={selectedIds} />
|
<ContainerActionBarButton icon={IconRotateClockwise} color="orange" action="restart" selectedIds={selectedIds} />
|
||||||
<ContainerActionBarButton icon={IconTrash} color="red" action="remove" selectedIds={selectedIds} />
|
<ContainerActionBarButton icon={IconTrash} color="red" action="remove" selectedIds={selectedIds} />
|
||||||
|
<Button leftSection={<IconCategoryPlus />} color={"red"} onClick={handleClick} variant="light" radius="md">
|
||||||
|
{t("addToHomarr.label")}
|
||||||
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -174,9 +197,10 @@ interface ContainerActionBarButtonProps {
|
|||||||
|
|
||||||
const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
|
const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
|
||||||
const t = useScopedI18n("docker.action");
|
const t = useScopedI18n("docker.action");
|
||||||
const { mutateAsync, isPending } = clientApi.docker[`${props.action}All`].useMutation();
|
|
||||||
const utils = clientApi.useUtils();
|
const utils = clientApi.useUtils();
|
||||||
|
|
||||||
|
const { mutateAsync, isPending } = clientApi.docker[`${props.action}All`].useMutation();
|
||||||
|
|
||||||
const handleClickAsync = async () => {
|
const handleClickAsync = async () => {
|
||||||
await mutateAsync(
|
await mutateAsync(
|
||||||
{ ids: props.selectedIds },
|
{ ids: props.selectedIds },
|
||||||
@@ -222,9 +246,9 @@ const containerStates = {
|
|||||||
exited: "red",
|
exited: "red",
|
||||||
removing: "pink",
|
removing: "pink",
|
||||||
dead: "dark",
|
dead: "dark",
|
||||||
} satisfies Record<DockerContainerState, MantineColor>;
|
} satisfies Record<ContainerState, MantineColor>;
|
||||||
|
|
||||||
const ContainerStateBadge = ({ state }: { state: DockerContainerState }) => {
|
const ContainerStateBadge = ({ state }: { state: ContainerState }) => {
|
||||||
const t = useScopedI18n("docker.field.state.option");
|
const t = useScopedI18n("docker.field.state.option");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button, Group, Select, Stack } from "@mantine/core";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
|
import { useZodForm } from "@homarr/form";
|
||||||
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import type { z } from "@homarr/validation";
|
||||||
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
|
interface ChangeDefaultSearchEngineFormProps {
|
||||||
|
user: RouterOutputs["user"]["getById"];
|
||||||
|
searchEnginesData: { value: string; label: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChangeDefaultSearchEngineForm = ({ user, searchEnginesData }: ChangeDefaultSearchEngineFormProps) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const { mutate, isPending } = clientApi.user.changeDefaultSearchEngine.useMutation({
|
||||||
|
async onSettled() {
|
||||||
|
await revalidatePathActionAsync(`/manage/users/${user.id}`);
|
||||||
|
},
|
||||||
|
onSuccess(_, variables) {
|
||||||
|
form.setInitialValues({
|
||||||
|
defaultSearchEngineId: variables.defaultSearchEngineId,
|
||||||
|
});
|
||||||
|
showSuccessNotification({
|
||||||
|
message: t("user.action.changeDefaultSearchEngine.notification.success.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
showErrorNotification({
|
||||||
|
message: t("user.action.changeDefaultSearchEngine.notification.error.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const form = useZodForm(validation.user.changeDefaultSearchEngine, {
|
||||||
|
initialValues: {
|
||||||
|
defaultSearchEngineId: user.defaultSearchEngineId ?? "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (values: FormType) => {
|
||||||
|
mutate({
|
||||||
|
userId: user.id,
|
||||||
|
...values,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Select w="100%" data={searchEnginesData} {...form.getInputProps("defaultSearchEngineId")} />
|
||||||
|
|
||||||
|
<Group justify="end">
|
||||||
|
<Button type="submit" color="teal" loading={isPending}>
|
||||||
|
{t("common.action.save")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormType = z.infer<typeof validation.user.changeDefaultSearchEngine>;
|
||||||
+18
-5
@@ -18,13 +18,14 @@ interface ChangeHomeBoardFormProps {
|
|||||||
|
|
||||||
export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormProps) => {
|
export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormProps) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { mutate, isPending } = clientApi.user.changeHomeBoardId.useMutation({
|
const { mutate, isPending } = clientApi.user.changeHomeBoards.useMutation({
|
||||||
async onSettled() {
|
async onSettled() {
|
||||||
await revalidatePathActionAsync(`/manage/users/${user.id}`);
|
await revalidatePathActionAsync(`/manage/users/${user.id}`);
|
||||||
},
|
},
|
||||||
onSuccess(_, variables) {
|
onSuccess(_, variables) {
|
||||||
form.setInitialValues({
|
form.setInitialValues({
|
||||||
homeBoardId: variables.homeBoardId,
|
homeBoardId: variables.homeBoardId,
|
||||||
|
mobileHomeBoardId: variables.mobileHomeBoardId,
|
||||||
});
|
});
|
||||||
showSuccessNotification({
|
showSuccessNotification({
|
||||||
message: t("user.action.changeHomeBoard.notification.success.message"),
|
message: t("user.action.changeHomeBoard.notification.success.message"),
|
||||||
@@ -36,9 +37,10 @@ export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormPro
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const form = useZodForm(validation.user.changeHomeBoard, {
|
const form = useZodForm(validation.user.changeHomeBoards, {
|
||||||
initialValues: {
|
initialValues: {
|
||||||
homeBoardId: user.homeBoardId ?? "",
|
homeBoardId: user.homeBoardId,
|
||||||
|
mobileHomeBoardId: user.mobileHomeBoardId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,7 +54,18 @@ export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormPro
|
|||||||
return (
|
return (
|
||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Select w="100%" data={boardsData} {...form.getInputProps("homeBoardId")} />
|
<Select
|
||||||
|
label={t("management.page.user.setting.general.item.board.type.general")}
|
||||||
|
w="100%"
|
||||||
|
data={boardsData}
|
||||||
|
{...form.getInputProps("homeBoardId")}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label={t("management.page.user.setting.general.item.board.type.mobile")}
|
||||||
|
w="100%"
|
||||||
|
data={boardsData}
|
||||||
|
{...form.getInputProps("mobileHomeBoardId")}
|
||||||
|
/>
|
||||||
|
|
||||||
<Group justify="end">
|
<Group justify="end">
|
||||||
<Button type="submit" color="teal" loading={isPending}>
|
<Button type="submit" color="teal" loading={isPending}>
|
||||||
@@ -64,4 +77,4 @@ export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormPro
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type FormType = z.infer<typeof validation.user.changeHomeBoard>;
|
type FormType = z.infer<typeof validation.user.changeHomeBoards>;
|
||||||
+4
-1
@@ -51,7 +51,10 @@ export const PingIconsEnabled = ({ user }: PingIconsEnabledProps) => {
|
|||||||
return (
|
return (
|
||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Switch {...form.getInputProps("pingIconsEnabled")} label={t("user.field.pingIconsEnabled.label")} />
|
<Switch
|
||||||
|
{...form.getInputProps("pingIconsEnabled", { type: "checkbox" })}
|
||||||
|
label={t("user.field.pingIconsEnabled.label")}
|
||||||
|
/>
|
||||||
|
|
||||||
<Group justify="end">
|
<Group justify="end">
|
||||||
<Button type="submit" color="teal" loading={isPending}>
|
<Button type="submit" color="teal" loading={isPending}>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone"
|
|||||||
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
||||||
import { createMetaTitle } from "~/metadata";
|
import { createMetaTitle } from "~/metadata";
|
||||||
import { canAccessUserEditPage } from "../access";
|
import { canAccessUserEditPage } from "../access";
|
||||||
|
import { ChangeDefaultSearchEngineForm } from "./_components/_change-default-search-engine";
|
||||||
import { ChangeHomeBoardForm } from "./_components/_change-home-board";
|
import { ChangeHomeBoardForm } from "./_components/_change-home-board";
|
||||||
import { DeleteUserButton } from "./_components/_delete-user-button";
|
import { DeleteUserButton } from "./_components/_delete-user-button";
|
||||||
import { FirstDayOfWeek } from "./_components/_first-day-of-week";
|
import { FirstDayOfWeek } from "./_components/_first-day-of-week";
|
||||||
@@ -19,12 +20,13 @@ import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
|
|||||||
import { UserProfileForm } from "./_components/_profile-form";
|
import { UserProfileForm } from "./_components/_profile-form";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: {
|
params: Promise<{
|
||||||
userId: string;
|
userId: string;
|
||||||
};
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params }: Props) {
|
export async function generateMetadata(props: Props) {
|
||||||
|
const params = await props.params;
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const user = await api.user
|
const user = await api.user
|
||||||
.getById({
|
.getById({
|
||||||
@@ -43,7 +45,8 @@ export async function generateMetadata({ params }: Props) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function EditUserPage({ params }: Props) {
|
export default async function EditUserPage(props: Props) {
|
||||||
|
const params = await props.params;
|
||||||
const t = await getI18n();
|
const t = await getI18n();
|
||||||
const tGeneral = await getScopedI18n("management.page.user.setting.general");
|
const tGeneral = await getScopedI18n("management.page.user.setting.general");
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
@@ -58,6 +61,7 @@ export default async function EditUserPage({ params }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const boards = await api.board.getAllBoards();
|
const boards = await api.board.getAllBoards();
|
||||||
|
const searchEngines = await api.searchEngine.getSelectable();
|
||||||
|
|
||||||
const isCredentialsUser = user.provider === "credentials";
|
const isCredentialsUser = user.provider === "credentials";
|
||||||
|
|
||||||
@@ -79,13 +83,15 @@ export default async function EditUserPage({ params }: Props) {
|
|||||||
</Box>
|
</Box>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Stack mb="lg">
|
{session?.user.id === user.id && (
|
||||||
<Title order={2}>{tGeneral("item.language")}</Title>
|
<Stack mb="lg">
|
||||||
<CurrentLanguageCombobox />
|
<Title order={2}>{tGeneral("item.language")}</Title>
|
||||||
</Stack>
|
<CurrentLanguageCombobox />
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
<Stack mb="lg">
|
<Stack mb="lg">
|
||||||
<Title order={2}>{tGeneral("item.board")}</Title>
|
<Title order={2}>{tGeneral("item.board.title")}</Title>
|
||||||
<ChangeHomeBoardForm
|
<ChangeHomeBoardForm
|
||||||
user={user}
|
user={user}
|
||||||
boardsData={boards.map((board) => ({
|
boardsData={boards.map((board) => ({
|
||||||
@@ -95,6 +101,11 @@ export default async function EditUserPage({ params }: Props) {
|
|||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
<Stack mb="lg">
|
||||||
|
<Title order={2}>{tGeneral("item.defaultSearchEngine")}</Title>
|
||||||
|
<ChangeDefaultSearchEngineForm user={user} searchEnginesData={searchEngines} />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
<Stack mb="lg">
|
<Stack mb="lg">
|
||||||
<Title order={2}>{tGeneral("item.firstDayOfWeek")}</Title>
|
<Title order={2}>{tGeneral("item.firstDayOfWeek")}</Title>
|
||||||
<FirstDayOfWeek user={user} />
|
<FirstDayOfWeek user={user} />
|
||||||
|
|||||||
@@ -15,10 +15,14 @@ import { NavigationLink } from "../groups/[id]/_navigation";
|
|||||||
import { canAccessUserEditPage } from "./access";
|
import { canAccessUserEditPage } from "./access";
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
params: { userId: string };
|
params: Promise<{ userId: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function Layout({ children, params }: PropsWithChildren<LayoutProps>) {
|
export default async function Layout(props: PropsWithChildren<LayoutProps>) {
|
||||||
|
const params = await props.params;
|
||||||
|
|
||||||
|
const { children } = props;
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const t = await getI18n();
|
const t = await getI18n();
|
||||||
const tUser = await getScopedI18n("management.page.user");
|
const tUser = await getScopedI18n("management.page.user");
|
||||||
|
|||||||
@@ -10,12 +10,13 @@ import { canAccessUserEditPage } from "../access";
|
|||||||
import { ChangePasswordForm } from "./_components/_change-password-form";
|
import { ChangePasswordForm } from "./_components/_change-password-form";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: {
|
params: Promise<{
|
||||||
userId: string;
|
userId: string;
|
||||||
};
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function UserSecurityPage({ params }: Props) {
|
export default async function UserSecurityPage(props: Props) {
|
||||||
|
const params = await props.params;
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const tSecurity = await getScopedI18n("management.page.user.setting.security");
|
const tSecurity = await getScopedI18n("management.page.user.setting.security");
|
||||||
const user = await api.user
|
const user = await api.user
|
||||||
|
|||||||
@@ -10,10 +10,14 @@ import { ManageContainer } from "~/components/manage/manage-container";
|
|||||||
import { NavigationLink } from "./_navigation";
|
import { NavigationLink } from "./_navigation";
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
params: { id: string };
|
params: Promise<{ id: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function Layout({ children, params }: PropsWithChildren<LayoutProps>) {
|
export default async function Layout(props: PropsWithChildren<LayoutProps>) {
|
||||||
|
const params = await props.params;
|
||||||
|
|
||||||
|
const { children } = props;
|
||||||
|
|
||||||
const t = await getI18n();
|
const t = await getI18n();
|
||||||
const tGroup = await getScopedI18n("management.page.group");
|
const tGroup = await getScopedI18n("management.page.group");
|
||||||
const group = await api.group.getById({ id: params.id });
|
const group = await api.group.getById({ id: params.id });
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { IconExclamationCircle } 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 { env } from "@homarr/auth/env.mjs";
|
import { env } from "@homarr/auth/env";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { isProviderEnabled } from "@homarr/auth/server";
|
import { isProviderEnabled } from "@homarr/auth/server";
|
||||||
import { everyoneGroup } from "@homarr/definitions";
|
import { everyoneGroup } from "@homarr/definitions";
|
||||||
@@ -17,15 +17,17 @@ import { AddGroupMember } from "./_add-group-member";
|
|||||||
import { RemoveGroupMember } from "./_remove-group-member";
|
import { RemoveGroupMember } from "./_remove-group-member";
|
||||||
|
|
||||||
interface GroupsDetailPageProps {
|
interface GroupsDetailPageProps {
|
||||||
params: {
|
params: Promise<{
|
||||||
id: string;
|
id: string;
|
||||||
};
|
}>;
|
||||||
searchParams: {
|
searchParams: Promise<{
|
||||||
search: string | undefined;
|
search: string | undefined;
|
||||||
};
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function GroupsDetailPage({ params, searchParams }: GroupsDetailPageProps) {
|
export default async function GroupsDetailPage(props: GroupsDetailPageProps) {
|
||||||
|
const searchParams = await props.searchParams;
|
||||||
|
const params = await props.params;
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
if (!session?.user.permissions.includes("admin")) {
|
if (!session?.user.permissions.includes("admin")) {
|
||||||
|
|||||||
@@ -14,12 +14,13 @@ import { ReservedGroupAlert } from "./_reserved-group-alert";
|
|||||||
import { TransferGroupOwnership } from "./_transfer-group-ownership";
|
import { TransferGroupOwnership } from "./_transfer-group-ownership";
|
||||||
|
|
||||||
interface GroupsDetailPageProps {
|
interface GroupsDetailPageProps {
|
||||||
params: {
|
params: Promise<{
|
||||||
id: string;
|
id: string;
|
||||||
};
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function GroupsDetailPage({ params }: GroupsDetailPageProps) {
|
export default async function GroupsDetailPage(props: GroupsDetailPageProps) {
|
||||||
|
const params = await props.params;
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
if (!session?.user.permissions.includes("admin")) {
|
if (!session?.user.permissions.includes("admin")) {
|
||||||
|
|||||||
@@ -12,12 +12,13 @@ import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
|||||||
import { PermissionForm, PermissionSwitch, SaveAffix } from "./_group-permission-form";
|
import { PermissionForm, PermissionSwitch, SaveAffix } from "./_group-permission-form";
|
||||||
|
|
||||||
interface GroupPermissionsPageProps {
|
interface GroupPermissionsPageProps {
|
||||||
params: {
|
params: Promise<{
|
||||||
id: string;
|
id: string;
|
||||||
};
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function GroupPermissionsPage({ params }: GroupPermissionsPageProps) {
|
export default async function GroupPermissionsPage(props: GroupPermissionsPageProps) {
|
||||||
|
const params = await props.params;
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
if (!session?.user.permissions.includes("admin")) {
|
if (!session?.user.permissions.includes("admin")) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Anchor, Group, Stack, Table, TableTbody, TableTd, TableTh, TableThead,
|
|||||||
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 { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
|
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
||||||
import { getI18n } from "@homarr/translation/server";
|
import { getI18n } from "@homarr/translation/server";
|
||||||
import { SearchInput, TablePagination, UserAvatarGroup } from "@homarr/ui";
|
import { SearchInput, TablePagination, UserAvatarGroup } from "@homarr/ui";
|
||||||
import { z } from "@homarr/validation";
|
import { z } from "@homarr/validation";
|
||||||
@@ -19,12 +20,8 @@ const searchParamsSchema = z.object({
|
|||||||
page: z.string().regex(/\d+/).transform(Number).catch(1),
|
page: z.string().regex(/\d+/).transform(Number).catch(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
type SearchParamsSchemaInputFromSchema<TSchema extends Record<string, unknown>> = Partial<{
|
|
||||||
[K in keyof TSchema]: Exclude<TSchema[K], undefined> extends unknown[] ? string[] : string;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
interface GroupsListPageProps {
|
interface GroupsListPageProps {
|
||||||
searchParams: SearchParamsSchemaInputFromSchema<z.infer<typeof searchParamsSchema>>;
|
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function GroupsListPage(props: GroupsListPageProps) {
|
export default async function GroupsListPage(props: GroupsListPageProps) {
|
||||||
@@ -35,7 +32,7 @@ export default async function GroupsListPage(props: GroupsListPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const t = await getI18n();
|
const t = await getI18n();
|
||||||
const searchParams = searchParamsSchema.parse(props.searchParams);
|
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
||||||
const { items: groups, totalCount } = await api.group.getPaginated(searchParams);
|
const { items: groups, totalCount } = await api.group.getPaginated(searchParams);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,15 +5,15 @@ import { db } from "@homarr/db";
|
|||||||
import type { WidgetKind } from "@homarr/definitions";
|
import type { WidgetKind } from "@homarr/definitions";
|
||||||
import { widgetImports } from "@homarr/widgets";
|
import { widgetImports } from "@homarr/widgets";
|
||||||
|
|
||||||
import { env } from "~/env.mjs";
|
import { env } from "~/env";
|
||||||
import { WidgetPreviewPageContent } from "./_content";
|
import { WidgetPreviewPageContent } from "./_content";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: { kind: string };
|
params: Promise<{ kind: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function WidgetPreview(props: Props) {
|
export default async function WidgetPreview(props: Props) {
|
||||||
if (!(props.params.kind in widgetImports || env.NODE_ENV !== "development")) {
|
if (!((await props.params).kind in widgetImports || env.NODE_ENV !== "development")) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ export default async function WidgetPreview(props: Props) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const sort = props.params.kind as WidgetKind;
|
const sort = (await props.params).kind as WidgetKind;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Center h="100vh">
|
<Center h="100vh">
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { headers } from "next/headers";
|
|
||||||
import { userAgent } from "next/server";
|
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
|
import { userAgent } from "next/server";
|
||||||
import { createOpenApiFetchHandler } from "trpc-to-openapi";
|
import { createOpenApiFetchHandler } from "trpc-to-openapi";
|
||||||
|
|
||||||
import { appRouter, createTRPCContext } from "@homarr/api";
|
import { appRouter, createTRPCContext } from "@homarr/api";
|
||||||
import { hashPasswordAsync } from "@homarr/auth";
|
|
||||||
import type { Session } from "@homarr/auth";
|
import type { Session } from "@homarr/auth";
|
||||||
|
import { hashPasswordAsync } from "@homarr/auth";
|
||||||
import { createSessionAsync } from "@homarr/auth/server";
|
import { createSessionAsync } from "@homarr/auth/server";
|
||||||
import { db, eq } from "@homarr/db";
|
import { db, eq } from "@homarr/db";
|
||||||
import { apiKeys } from "@homarr/db/schema";
|
import { apiKeys } from "@homarr/db/schema";
|
||||||
@@ -13,7 +12,7 @@ import { logger } from "@homarr/log";
|
|||||||
|
|
||||||
const handlerAsync = async (req: NextRequest) => {
|
const handlerAsync = async (req: NextRequest) => {
|
||||||
const apiKeyHeaderValue = req.headers.get("ApiKey");
|
const apiKeyHeaderValue = req.headers.get("ApiKey");
|
||||||
const ipAddress = req.ip ?? headers().get("x-forwarded-for");
|
const ipAddress = req.headers.get("x-forwarded-for");
|
||||||
const { ua } = userAgent(req);
|
const { ua } = userAgent(req);
|
||||||
const session: Session | null = await getSessionOrDefaultFromHeadersAsync(apiKeyHeaderValue, ipAddress, ua);
|
const session: Session | null = await getSessionOrDefaultFromHeadersAsync(apiKeyHeaderValue, ipAddress, ua);
|
||||||
|
|
||||||
@@ -88,9 +87,9 @@ const getSessionOrDefaultFromHeadersAsync = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
handlerAsync as DELETE,
|
||||||
handlerAsync as GET,
|
handlerAsync as GET,
|
||||||
|
handlerAsync as PATCH,
|
||||||
handlerAsync as POST,
|
handlerAsync as POST,
|
||||||
handlerAsync as PUT,
|
handlerAsync as PUT,
|
||||||
handlerAsync as DELETE,
|
|
||||||
handlerAsync as PATCH,
|
|
||||||
};
|
};
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
import { createHandlers } from "@homarr/auth";
|
import { createHandlersAsync } from "@homarr/auth";
|
||||||
import type { SupportedAuthProvider } from "@homarr/definitions";
|
import type { SupportedAuthProvider } from "@homarr/definitions";
|
||||||
import { logger } from "@homarr/log";
|
import { logger } from "@homarr/log";
|
||||||
|
|
||||||
export const GET = async (req: NextRequest) => {
|
export const GET = async (req: NextRequest) => {
|
||||||
return await createHandlers(extractProvider(req), isSecureCookieEnabled(req)).handlers.GET(reqWithTrustedOrigin(req));
|
const { handlers } = await createHandlersAsync(extractProvider(req), isSecureCookieEnabled(req));
|
||||||
|
|
||||||
|
return await handlers.GET(reqWithTrustedOrigin(req));
|
||||||
};
|
};
|
||||||
export const POST = async (req: NextRequest) => {
|
export const POST = async (req: NextRequest) => {
|
||||||
return await createHandlers(extractProvider(req), isSecureCookieEnabled(req)).handlers.POST(
|
const { handlers } = await createHandlersAsync(extractProvider(req), isSecureCookieEnabled(req));
|
||||||
reqWithTrustedOrigin(req),
|
return await handlers.POST(reqWithTrustedOrigin(req));
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ const handler = auth(async (req) => {
|
|||||||
req,
|
req,
|
||||||
createContext: () => createTRPCContext({ session: req.auth, headers: req.headers }),
|
createContext: () => createTRPCContext({ session: req.auth, headers: req.headers }),
|
||||||
onError({ error, path, type }) {
|
onError({ error, path, type }) {
|
||||||
logger.error(`tRPC Error with ${type} on '${path}': (${error.code}) - ${error.message}`);
|
logger.error(
|
||||||
|
`tRPC Error with ${type} on '${path}': (${error.code}) - ${error.message}\n${error.stack}\n${error.cause}`,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import type { NextRequest } from "next/server";
|
|||||||
import { db, eq } from "@homarr/db";
|
import { db, eq } from "@homarr/db";
|
||||||
import { medias } from "@homarr/db/schema";
|
import { medias } from "@homarr/db/schema";
|
||||||
|
|
||||||
export async function GET(_req: NextRequest, { params }: { params: { id: string } }) {
|
export async function GET(_req: NextRequest, props: { params: Promise<{ id: string }> }) {
|
||||||
|
const params = await props.params;
|
||||||
const image = await db.query.medias.findFirst({
|
const image = await db.query.medias.findFirst({
|
||||||
where: eq(medias.id, params.id),
|
where: eq(medias.id, params.id),
|
||||||
columns: {
|
columns: {
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { Modify } from "@homarr/common/types";
|
||||||
|
import { createId } from "@homarr/db/client";
|
||||||
|
import type { WidgetKind } from "@homarr/definitions";
|
||||||
|
|
||||||
|
import type { Board, DynamicSection, EmptySection, Item } from "~/app/[locale]/boards/_types";
|
||||||
|
import { getFirstEmptyPosition } from "./empty-position";
|
||||||
|
|
||||||
|
export interface CreateItemInput {
|
||||||
|
kind: WidgetKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createItemCallback =
|
||||||
|
({ kind }: CreateItemInput) =>
|
||||||
|
(previous: Board): Board => {
|
||||||
|
const firstSection = previous.sections
|
||||||
|
.filter((section): section is EmptySection => section.kind === "empty")
|
||||||
|
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
|
||||||
|
.at(0);
|
||||||
|
|
||||||
|
if (!firstSection) return previous;
|
||||||
|
|
||||||
|
const dynamicSectionsOfFirstSection = previous.sections.filter(
|
||||||
|
(section): section is DynamicSection => section.kind === "dynamic" && section.parentSectionId === firstSection.id,
|
||||||
|
);
|
||||||
|
const elements = [...firstSection.items, ...dynamicSectionsOfFirstSection];
|
||||||
|
const emptyPosition = getFirstEmptyPosition(elements, previous.columnCount);
|
||||||
|
|
||||||
|
if (!emptyPosition) {
|
||||||
|
console.error("Your board is full");
|
||||||
|
return previous;
|
||||||
|
}
|
||||||
|
|
||||||
|
const widget = {
|
||||||
|
id: createId(),
|
||||||
|
kind,
|
||||||
|
options: {},
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
...emptyPosition,
|
||||||
|
integrationIds: [],
|
||||||
|
advancedOptions: {
|
||||||
|
customCssClasses: [],
|
||||||
|
},
|
||||||
|
} satisfies Modify<
|
||||||
|
Item,
|
||||||
|
{
|
||||||
|
kind: WidgetKind;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...previous,
|
||||||
|
sections: previous.sections.map((section) => {
|
||||||
|
// Return same section if item is not in it
|
||||||
|
if (section.id !== firstSection.id) return section;
|
||||||
|
return {
|
||||||
|
...section,
|
||||||
|
items: section.items.concat(widget),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { createId } from "@homarr/db/client";
|
||||||
|
|
||||||
|
import type { Board, DynamicSection, EmptySection } from "~/app/[locale]/boards/_types";
|
||||||
|
import { getFirstEmptyPosition } from "./empty-position";
|
||||||
|
|
||||||
|
export interface DuplicateItemInput {
|
||||||
|
itemId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const duplicateItemCallback =
|
||||||
|
({ itemId }: DuplicateItemInput) =>
|
||||||
|
(previous: Board): Board => {
|
||||||
|
const itemToDuplicate = previous.sections
|
||||||
|
.flatMap((section) => section.items.map((item) => ({ ...item, sectionId: section.id })))
|
||||||
|
.find((item) => item.id === itemId);
|
||||||
|
if (!itemToDuplicate) return previous;
|
||||||
|
|
||||||
|
const currentSection = previous.sections.find((section) => section.id === itemToDuplicate.sectionId);
|
||||||
|
if (!currentSection) return previous;
|
||||||
|
|
||||||
|
const dynamicSectionsOfCurrentSection = previous.sections.filter(
|
||||||
|
(section): section is DynamicSection =>
|
||||||
|
section.kind === "dynamic" && section.parentSectionId === currentSection.id,
|
||||||
|
);
|
||||||
|
const elements = [...currentSection.items, ...dynamicSectionsOfCurrentSection];
|
||||||
|
let sectionId = currentSection.id;
|
||||||
|
let emptyPosition = getFirstEmptyPosition(
|
||||||
|
elements,
|
||||||
|
currentSection.kind === "dynamic" ? currentSection.width : previous.columnCount,
|
||||||
|
currentSection.kind === "dynamic" ? currentSection.height : undefined,
|
||||||
|
{
|
||||||
|
width: itemToDuplicate.width,
|
||||||
|
height: itemToDuplicate.height,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!emptyPosition) {
|
||||||
|
const firstSection = previous.sections
|
||||||
|
.filter((section): section is EmptySection => section.kind === "empty")
|
||||||
|
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
|
||||||
|
.at(0);
|
||||||
|
|
||||||
|
if (!firstSection) return previous;
|
||||||
|
|
||||||
|
const dynamicSectionsOfFirstSection = previous.sections.filter(
|
||||||
|
(section): section is DynamicSection =>
|
||||||
|
section.kind === "dynamic" && section.parentSectionId === firstSection.id,
|
||||||
|
);
|
||||||
|
const elements = [...firstSection.items, ...dynamicSectionsOfFirstSection];
|
||||||
|
emptyPosition = getFirstEmptyPosition(elements, previous.columnCount, undefined, {
|
||||||
|
width: itemToDuplicate.width,
|
||||||
|
height: itemToDuplicate.height,
|
||||||
|
});
|
||||||
|
if (!emptyPosition) {
|
||||||
|
console.error("Your board is full");
|
||||||
|
return previous;
|
||||||
|
}
|
||||||
|
|
||||||
|
sectionId = firstSection.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const widget = structuredClone(itemToDuplicate);
|
||||||
|
widget.id = createId();
|
||||||
|
widget.xOffset = emptyPosition.xOffset;
|
||||||
|
widget.yOffset = emptyPosition.yOffset;
|
||||||
|
widget.sectionId = sectionId;
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
...previous,
|
||||||
|
sections: previous.sections.map((section) => {
|
||||||
|
// Return same section if item is not in it
|
||||||
|
if (section.id !== sectionId) return section;
|
||||||
|
return {
|
||||||
|
...section,
|
||||||
|
items: section.items.concat(widget),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { Item } from "~/app/[locale]/boards/_types";
|
||||||
|
|
||||||
|
export const getFirstEmptyPosition = (
|
||||||
|
elements: Pick<Item, "yOffset" | "xOffset" | "width" | "height">[],
|
||||||
|
columnCount: number,
|
||||||
|
rowCount = 9999,
|
||||||
|
size: { width: number; height: number } = { width: 1, height: 1 },
|
||||||
|
) => {
|
||||||
|
for (let yOffset = 0; yOffset < rowCount + 1 - size.height; yOffset++) {
|
||||||
|
for (let xOffset = 0; xOffset < columnCount; xOffset++) {
|
||||||
|
const isOccupied = elements.some(
|
||||||
|
(element) =>
|
||||||
|
element.yOffset < yOffset + size.height &&
|
||||||
|
element.yOffset + element.height > yOffset &&
|
||||||
|
element.xOffset < xOffset + size.width &&
|
||||||
|
element.xOffset + element.width > xOffset,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isOccupied) {
|
||||||
|
return { xOffset, yOffset };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { Board } from "~/app/[locale]/boards/_types";
|
||||||
|
import { createItemCallback } from "../create-item";
|
||||||
|
import * as emptyPosition from "../empty-position";
|
||||||
|
import { createDynamicSection, createEmptySection, createItem } from "./shared";
|
||||||
|
|
||||||
|
describe("item actions create-item", () => {
|
||||||
|
test("should add it to first section", () => {
|
||||||
|
const spy = vi.spyOn(emptyPosition, "getFirstEmptyPosition");
|
||||||
|
spy.mockReturnValue({ xOffset: 5, yOffset: 5 });
|
||||||
|
const input = {
|
||||||
|
sections: [createEmptySection("1", 2), createEmptySection("2", 0), createEmptySection("3", 1)],
|
||||||
|
columnCount: 4,
|
||||||
|
} satisfies Pick<Board, "sections" | "columnCount">;
|
||||||
|
|
||||||
|
const result = createItemCallback({
|
||||||
|
kind: "clock",
|
||||||
|
})(input as unknown as Board);
|
||||||
|
|
||||||
|
const firstSection = result.sections.find((section) => section.id === "2");
|
||||||
|
const item = firstSection?.items.at(0);
|
||||||
|
expect(item).toEqual(expect.objectContaining({ kind: "clock", xOffset: 5, yOffset: 5 }));
|
||||||
|
expect(spy).toHaveBeenCalledWith([], input.columnCount);
|
||||||
|
});
|
||||||
|
test("should correctly pass dynamic section and items to getFirstEmptyPosition", () => {
|
||||||
|
const spy = vi.spyOn(emptyPosition, "getFirstEmptyPosition");
|
||||||
|
spy.mockReturnValue({ xOffset: 5, yOffset: 5 });
|
||||||
|
const firstSection = createEmptySection("2", 0);
|
||||||
|
const expectedItem = createItem({ id: "12", xOffset: 1, yOffset: 2, width: 3, height: 2 });
|
||||||
|
firstSection.items.push(expectedItem);
|
||||||
|
const dynamicSectionInFirst = createDynamicSection({
|
||||||
|
id: "4",
|
||||||
|
parentSectionId: "2",
|
||||||
|
yOffset: 0,
|
||||||
|
xOffset: 0,
|
||||||
|
width: 2,
|
||||||
|
height: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
sections: [
|
||||||
|
createEmptySection("1", 2),
|
||||||
|
firstSection,
|
||||||
|
createEmptySection("3", 1),
|
||||||
|
dynamicSectionInFirst,
|
||||||
|
createDynamicSection({ id: "5", parentSectionId: "3", yOffset: 1 }),
|
||||||
|
],
|
||||||
|
columnCount: 4,
|
||||||
|
} satisfies Pick<Board, "sections" | "columnCount">;
|
||||||
|
|
||||||
|
const result = createItemCallback({
|
||||||
|
kind: "clock",
|
||||||
|
})(input as unknown as Board);
|
||||||
|
|
||||||
|
const firstSectionResult = result.sections.find((section) => section.id === "2");
|
||||||
|
const item = firstSectionResult?.items.find((item) => item.id !== "12");
|
||||||
|
expect(item).toEqual(expect.objectContaining({ kind: "clock", xOffset: 5, yOffset: 5 }));
|
||||||
|
expect(spy).toHaveBeenCalledWith([expectedItem, dynamicSectionInFirst], input.columnCount);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { Board } from "~/app/[locale]/boards/_types";
|
||||||
|
import { duplicateItemCallback } from "../duplicate-item";
|
||||||
|
import * as emptyPosition from "../empty-position";
|
||||||
|
import { createEmptySection, createItem } from "./shared";
|
||||||
|
|
||||||
|
describe("item actions duplicate-item", () => {
|
||||||
|
test("should copy it in the same section", () => {
|
||||||
|
const spy = vi.spyOn(emptyPosition, "getFirstEmptyPosition");
|
||||||
|
spy.mockReturnValue({ xOffset: 5, yOffset: 5 });
|
||||||
|
const currentSection = createEmptySection("2", 1);
|
||||||
|
const currentItem = createItem({
|
||||||
|
id: "1",
|
||||||
|
xOffset: 1,
|
||||||
|
yOffset: 3,
|
||||||
|
width: 3,
|
||||||
|
height: 2,
|
||||||
|
kind: "minecraftServerStatus",
|
||||||
|
integrationIds: ["1"],
|
||||||
|
options: { address: "localhost" },
|
||||||
|
advancedOptions: { customCssClasses: ["test"] },
|
||||||
|
});
|
||||||
|
const otherItem = createItem({
|
||||||
|
id: "2",
|
||||||
|
});
|
||||||
|
currentSection.items.push(currentItem, otherItem);
|
||||||
|
const input = {
|
||||||
|
columnCount: 10,
|
||||||
|
sections: [createEmptySection("1", 0), currentSection, createEmptySection("3", 2)],
|
||||||
|
} satisfies Pick<Board, "sections" | "columnCount">;
|
||||||
|
|
||||||
|
const result = duplicateItemCallback({ itemId: currentItem.id })(input as unknown as Board);
|
||||||
|
|
||||||
|
const section = result.sections.find((section) => section.id === "2");
|
||||||
|
expect(section?.items.length).toBe(3);
|
||||||
|
const duplicatedItem = section?.items.find((item) => item.id !== currentItem.id && item.id !== otherItem.id);
|
||||||
|
|
||||||
|
expect(duplicatedItem).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
kind: "minecraftServerStatus",
|
||||||
|
xOffset: 5,
|
||||||
|
yOffset: 5,
|
||||||
|
width: 3,
|
||||||
|
height: 2,
|
||||||
|
integrationIds: ["1"],
|
||||||
|
options: { address: "localhost" },
|
||||||
|
advancedOptions: { customCssClasses: ["test"] },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import type { Item } from "~/app/[locale]/boards/_types";
|
||||||
|
import { getFirstEmptyPosition } from "../empty-position";
|
||||||
|
|
||||||
|
describe("get first empty position", () => {
|
||||||
|
test.each([
|
||||||
|
[[[" ", " ", " ", " "]], [1, 1], 0, 0],
|
||||||
|
[[["a", " ", " ", " "]], [1, 1], 1, 0],
|
||||||
|
[[[" ", "a", " ", " "]], [1, 1], 0, 0],
|
||||||
|
[
|
||||||
|
[
|
||||||
|
["a", "a", " ", " "],
|
||||||
|
["a", "a", " ", " "],
|
||||||
|
],
|
||||||
|
[1, 1],
|
||||||
|
2,
|
||||||
|
0,
|
||||||
|
],
|
||||||
|
[[["a", "a", "a", "a"]], [1, 1], 0, 1],
|
||||||
|
[
|
||||||
|
[
|
||||||
|
["a", "a", "a", "a"],
|
||||||
|
["a", "a", "a", "a"],
|
||||||
|
],
|
||||||
|
[1, 1],
|
||||||
|
0,
|
||||||
|
2,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[
|
||||||
|
["a", "a", " ", "b", "b"],
|
||||||
|
["a", "a", " ", "b", "b"],
|
||||||
|
],
|
||||||
|
[1, 2],
|
||||||
|
2,
|
||||||
|
0,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[
|
||||||
|
["a", "a", " ", " ", "b", "b"],
|
||||||
|
["a", "a", " ", " ", "b", "b"],
|
||||||
|
],
|
||||||
|
[2, 2],
|
||||||
|
2,
|
||||||
|
0,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[
|
||||||
|
["a", "a", " ", "d", "b", "b"],
|
||||||
|
["a", "a", "c", "e", "b", "b"],
|
||||||
|
],
|
||||||
|
[1, 1],
|
||||||
|
2,
|
||||||
|
0,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[
|
||||||
|
["a", "a", " ", " ", "b", "b"],
|
||||||
|
["a", "a", " ", "e", "b", "b"],
|
||||||
|
],
|
||||||
|
[2, 2],
|
||||||
|
0,
|
||||||
|
2,
|
||||||
|
],
|
||||||
|
])("should return the first empty position", (layout, size, expectedX, expectedY) => {
|
||||||
|
const elements = createElementsFromLayout(layout);
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
const result = getFirstEmptyPosition(elements, layout[0]!.length, undefined, { width: size[0]!, height: size[1]! });
|
||||||
|
|
||||||
|
expect(result).toEqual({ xOffset: expectedX, yOffset: expectedY });
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
[[[" ", " "]], [1, 1], 0, 0, 1],
|
||||||
|
[[["a", " "]], [1, 1], 1, 0, 1],
|
||||||
|
[[["a", "a"]], [1, 1], undefined, undefined, 1],
|
||||||
|
[[["a", "a"]], [1, 1], 0, 1, 2],
|
||||||
|
[
|
||||||
|
[
|
||||||
|
["a", "b", " ", " "],
|
||||||
|
["a", "c", " ", "d"],
|
||||||
|
],
|
||||||
|
[2, 2],
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
3,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[
|
||||||
|
["a", "b", " ", " "],
|
||||||
|
["a", "c", " ", "d"],
|
||||||
|
],
|
||||||
|
[2, 2],
|
||||||
|
0,
|
||||||
|
2,
|
||||||
|
4,
|
||||||
|
],
|
||||||
|
[[["a", "b"]], [2, 1], 0, 1, 2],
|
||||||
|
])("should return the first empty position with limited rows", (layout, size, expectedX, expectedY, rowCount) => {
|
||||||
|
const elements = createElementsFromLayout(layout);
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
const result = getFirstEmptyPosition(elements, layout[0]!.length, rowCount, { width: size[0]!, height: size[1]! });
|
||||||
|
|
||||||
|
expect(result).toEqual(expectedX !== undefined ? { xOffset: expectedX, yOffset: expectedY } : undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const createElementsFromLayout = (layout: string[][]) => {
|
||||||
|
const elements: (Pick<Item, "xOffset" | "yOffset" | "width" | "height"> & { char: string })[] = [];
|
||||||
|
for (let yOffset = 0; yOffset < layout.length; yOffset++) {
|
||||||
|
const row = layout[yOffset];
|
||||||
|
if (!row) continue;
|
||||||
|
for (let xOffset = 0; xOffset < row.length; xOffset++) {
|
||||||
|
const item = row[xOffset];
|
||||||
|
if (item === " " || !item) continue;
|
||||||
|
|
||||||
|
const existing = elements.find((element) => element.char === item);
|
||||||
|
if (existing) {
|
||||||
|
existing.height = yOffset - existing.yOffset + 1;
|
||||||
|
existing.width = xOffset - existing.xOffset + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
elements.push({
|
||||||
|
yOffset,
|
||||||
|
xOffset,
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
char: item,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return elements;
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { DynamicSection, EmptySection, Item } from "~/app/[locale]/boards/_types";
|
||||||
|
|
||||||
|
export const createEmptySection = (id: string, yOffset: number): EmptySection => ({
|
||||||
|
id,
|
||||||
|
kind: "empty",
|
||||||
|
yOffset,
|
||||||
|
xOffset: 0,
|
||||||
|
items: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createDynamicSection = (section: Omit<Partial<DynamicSection>, "kind">): DynamicSection => ({
|
||||||
|
id: section.id ?? "0",
|
||||||
|
kind: "dynamic",
|
||||||
|
parentSectionId: section.parentSectionId ?? "0",
|
||||||
|
height: section.height ?? 1,
|
||||||
|
width: section.width ?? 1,
|
||||||
|
yOffset: section.yOffset ?? 0,
|
||||||
|
xOffset: section.xOffset ?? 0,
|
||||||
|
items: section.items ?? [],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createItem = (item: Partial<Item>): Item => ({
|
||||||
|
id: item.id ?? "0",
|
||||||
|
width: item.width ?? 1,
|
||||||
|
height: item.height ?? 1,
|
||||||
|
yOffset: item.yOffset ?? 0,
|
||||||
|
xOffset: item.xOffset ?? 0,
|
||||||
|
kind: item.kind ?? "clock",
|
||||||
|
integrationIds: item.integrationIds ?? [],
|
||||||
|
options: item.options ?? {},
|
||||||
|
advancedOptions: item.advancedOptions ?? { customCssClasses: [] },
|
||||||
|
});
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
|
||||||
import type { Modify } from "@homarr/common/types";
|
|
||||||
import { createId } from "@homarr/db/client";
|
|
||||||
import type { WidgetKind } from "@homarr/definitions";
|
|
||||||
import type { BoardItemAdvancedOptions } from "@homarr/validation";
|
import type { BoardItemAdvancedOptions } from "@homarr/validation";
|
||||||
|
|
||||||
import type { EmptySection, Item } from "~/app/[locale]/boards/_types";
|
import type { Item } from "~/app/[locale]/boards/_types";
|
||||||
import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client";
|
import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client";
|
||||||
|
import type { CreateItemInput } from "./actions/create-item";
|
||||||
|
import { createItemCallback } from "./actions/create-item";
|
||||||
|
import type { DuplicateItemInput } from "./actions/duplicate-item";
|
||||||
|
import { duplicateItemCallback } from "./actions/duplicate-item";
|
||||||
|
|
||||||
interface MoveAndResizeItem {
|
interface MoveAndResizeItem {
|
||||||
itemId: string;
|
itemId: string;
|
||||||
@@ -42,87 +43,19 @@ interface UpdateItemIntegrations {
|
|||||||
newIntegrations: string[];
|
newIntegrations: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CreateItem {
|
|
||||||
kind: WidgetKind;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DuplicateItem {
|
|
||||||
itemId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useItemActions = () => {
|
export const useItemActions = () => {
|
||||||
const { updateBoard } = useUpdateBoard();
|
const { updateBoard } = useUpdateBoard();
|
||||||
|
|
||||||
const createItem = useCallback(
|
const createItem = useCallback(
|
||||||
({ kind }: CreateItem) => {
|
(input: CreateItemInput) => {
|
||||||
updateBoard((previous) => {
|
updateBoard(createItemCallback(input));
|
||||||
const lastSection = previous.sections
|
|
||||||
.filter((section): section is EmptySection => section.kind === "empty")
|
|
||||||
.sort((sectionA, sectionB) => sectionB.yOffset - sectionA.yOffset)[0];
|
|
||||||
|
|
||||||
if (!lastSection) return previous;
|
|
||||||
|
|
||||||
const widget = {
|
|
||||||
id: createId(),
|
|
||||||
kind,
|
|
||||||
options: {},
|
|
||||||
width: 1,
|
|
||||||
height: 1,
|
|
||||||
integrationIds: [],
|
|
||||||
advancedOptions: {
|
|
||||||
customCssClasses: [],
|
|
||||||
},
|
|
||||||
} satisfies Modify<
|
|
||||||
Omit<Item, "yOffset" | "xOffset">,
|
|
||||||
{
|
|
||||||
kind: WidgetKind;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...previous,
|
|
||||||
sections: previous.sections.map((section) => {
|
|
||||||
// Return same section if item is not in it
|
|
||||||
if (section.id !== lastSection.id) return section;
|
|
||||||
return {
|
|
||||||
...section,
|
|
||||||
items: section.items.concat(widget as unknown as Item),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[updateBoard],
|
[updateBoard],
|
||||||
);
|
);
|
||||||
|
|
||||||
const duplicateItem = useCallback(
|
const duplicateItem = useCallback(
|
||||||
({ itemId }: DuplicateItem) => {
|
({ itemId }: DuplicateItemInput) => {
|
||||||
updateBoard((previous) => {
|
updateBoard(duplicateItemCallback({ itemId }));
|
||||||
const itemToDuplicate = previous.sections
|
|
||||||
.flatMap((section) => section.items)
|
|
||||||
.find((item) => item.id === itemId);
|
|
||||||
|
|
||||||
if (!itemToDuplicate) return previous;
|
|
||||||
|
|
||||||
const newItem = {
|
|
||||||
...itemToDuplicate,
|
|
||||||
id: createId(),
|
|
||||||
yOffset: undefined,
|
|
||||||
xOffset: undefined,
|
|
||||||
} satisfies Modify<Item, { yOffset?: number; xOffset?: number }>;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...previous,
|
|
||||||
sections: previous.sections.map((section) => {
|
|
||||||
// Return same section if item is not in it
|
|
||||||
if (!section.items.some((item) => item.id === itemId)) return section;
|
|
||||||
return {
|
|
||||||
...section,
|
|
||||||
items: section.items.concat(newItem as unknown as Item),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[updateBoard],
|
[updateBoard],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
|||||||
root: {
|
root: {
|
||||||
"--opacity": board.opacity / 100,
|
"--opacity": board.opacity / 100,
|
||||||
containerType: "size",
|
containerType: "size",
|
||||||
|
overflow: item.kind === "iframe" ? "hidden" : undefined,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
p={0}
|
p={0}
|
||||||
@@ -78,7 +79,12 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
|||||||
>
|
>
|
||||||
<Throw
|
<Throw
|
||||||
error={new NoIntegrationSelectedError()}
|
error={new NoIntegrationSelectedError()}
|
||||||
when={widgetSupportsIntegrations && item.integrationIds.length === 0}
|
when={
|
||||||
|
widgetSupportsIntegrations &&
|
||||||
|
item.integrationIds.length === 0 &&
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
|
(!("integrationsRequired" in definition) || definition.integrationsRequired !== false)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<BoardItemMenu offset={4} item={newItem} />
|
<BoardItemMenu offset={4} item={newItem} />
|
||||||
<Comp
|
<Comp
|
||||||
@@ -87,7 +93,14 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
|||||||
isEditMode={isEditMode}
|
isEditMode={isEditMode}
|
||||||
boardId={board.id}
|
boardId={board.id}
|
||||||
itemId={item.id}
|
itemId={item.id}
|
||||||
setOptions={updateOptions}
|
setOptions={(partialNewOptions) =>
|
||||||
|
updateOptions({
|
||||||
|
newOptions: {
|
||||||
|
...partialNewOptions.newOptions,
|
||||||
|
...options,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
{...dimensions}
|
{...dimensions}
|
||||||
/>
|
/>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|||||||
@@ -1,21 +1,67 @@
|
|||||||
import { Button, Card, Center, Grid, Stack, Text } from "@mantine/core";
|
import { useMemo, useState } from "react";
|
||||||
|
import { Button, Card, Center, Grid, Input, Stack, Text } from "@mantine/core";
|
||||||
|
import { IconSearch } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { objectEntries } from "@homarr/common";
|
import { objectEntries } from "@homarr/common";
|
||||||
import type { WidgetKind } from "@homarr/definitions";
|
import type { WidgetKind } from "@homarr/definitions";
|
||||||
import { createModal } from "@homarr/modals";
|
import { createModal } from "@homarr/modals";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import type { TablerIcon } from "@homarr/ui";
|
||||||
import { widgetImports } from "@homarr/widgets";
|
import { widgetImports } from "@homarr/widgets";
|
||||||
import type { WidgetDefinition } from "@homarr/widgets";
|
|
||||||
|
|
||||||
import { useItemActions } from "./item-actions";
|
import { useItemActions } from "./item-actions";
|
||||||
|
|
||||||
export const ItemSelectModal = createModal<void>(({ actions }) => {
|
export const ItemSelectModal = createModal<void>(({ actions }) => {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const t = useI18n();
|
||||||
|
const { createItem } = useItemActions();
|
||||||
|
|
||||||
|
const items = useMemo(
|
||||||
|
() =>
|
||||||
|
objectEntries(widgetImports)
|
||||||
|
.map(([kind, value]) => ({
|
||||||
|
kind,
|
||||||
|
icon: value.definition.icon,
|
||||||
|
name: t(`widget.${kind}.name`),
|
||||||
|
description: t(`widget.${kind}.description`),
|
||||||
|
}))
|
||||||
|
.sort((itemA, itemB) => itemA.name.localeCompare(itemB.name)),
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredItems = useMemo(
|
||||||
|
() => items.filter((item) => item.name.toLowerCase().includes(search.toLowerCase())),
|
||||||
|
[items, search],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAdd = (kind: WidgetKind) => {
|
||||||
|
createItem({ kind });
|
||||||
|
actions.closeModal();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid>
|
<Stack>
|
||||||
{objectEntries(widgetImports).map(([key, value]) => {
|
<Input
|
||||||
return <WidgetItem key={key} kind={key} definition={value.definition} closeModal={actions.closeModal} />;
|
value={search}
|
||||||
})}
|
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||||
</Grid>
|
leftSection={<IconSearch />}
|
||||||
|
placeholder={`${t("item.create.search")}...`}
|
||||||
|
data-autofocus
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
// Add item if there is only one item in the list and user presses Enter
|
||||||
|
if (event.key === "Enter" && filteredItems.length === 1) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
handleAdd(filteredItems[0]!.kind);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Grid>
|
||||||
|
{filteredItems.map((item) => (
|
||||||
|
<WidgetItem key={item.kind} item={item} onSelect={() => handleAdd(item.kind)} />
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
}).withOptions({
|
}).withOptions({
|
||||||
defaultTitle: (t) => t("item.create.title"),
|
defaultTitle: (t) => t("item.create.title"),
|
||||||
@@ -23,20 +69,18 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const WidgetItem = ({
|
const WidgetItem = ({
|
||||||
kind,
|
item,
|
||||||
definition,
|
onSelect,
|
||||||
closeModal,
|
|
||||||
}: {
|
}: {
|
||||||
kind: WidgetKind;
|
item: {
|
||||||
definition: WidgetDefinition;
|
kind: WidgetKind;
|
||||||
closeModal: () => void;
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon: TablerIcon;
|
||||||
|
};
|
||||||
|
onSelect: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { createItem } = useItemActions();
|
|
||||||
const handleAdd = (kind: WidgetKind) => {
|
|
||||||
createItem({ kind });
|
|
||||||
closeModal();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid.Col span={{ xs: 12, sm: 4, md: 3 }}>
|
<Grid.Col span={{ xs: 12, sm: 4, md: 3 }}>
|
||||||
@@ -44,25 +88,16 @@ const WidgetItem = ({
|
|||||||
<Stack justify="space-between" h="100%">
|
<Stack justify="space-between" h="100%">
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Center>
|
<Center>
|
||||||
<definition.icon />
|
<item.icon />
|
||||||
</Center>
|
</Center>
|
||||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} ta="center">
|
<Text lh={1.2} style={{ whiteSpace: "normal" }} ta="center">
|
||||||
{t(`widget.${kind}.name`)}
|
{item.name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" ta="center" c="dimmed">
|
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" ta="center" c="dimmed">
|
||||||
{t(`widget.${kind}.description`)}
|
{item.description}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Button
|
<Button onClick={onSelect} variant="light" size="xs" mt="auto" radius="md" fullWidth>
|
||||||
onClick={() => {
|
|
||||||
handleAdd(kind);
|
|
||||||
}}
|
|
||||||
variant="light"
|
|
||||||
size="xs"
|
|
||||||
mt="auto"
|
|
||||||
radius="md"
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
{t(`item.create.addToBoard`)}
|
{t(`item.create.addToBoard`)}
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import type { Board, CategorySection, DynamicSection, EmptySection, Section } from "~/app/[locale]/boards/_types";
|
||||||
|
|
||||||
|
export interface RemoveCategoryInput {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const removeCategoryCallback =
|
||||||
|
(input: RemoveCategoryInput) =>
|
||||||
|
(previous: Board): Board => {
|
||||||
|
const currentCategory = previous.sections.find(
|
||||||
|
(section): section is CategorySection => section.kind === "category" && section.id === input.id,
|
||||||
|
);
|
||||||
|
if (!currentCategory) {
|
||||||
|
return previous;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptySectionsAbove = previous.sections.filter(
|
||||||
|
(section): section is EmptySection => section.kind === "empty" && section.yOffset < currentCategory.yOffset,
|
||||||
|
);
|
||||||
|
const aboveSection = emptySectionsAbove.sort((sectionA, sectionB) => sectionB.yOffset - sectionA.yOffset).at(0);
|
||||||
|
|
||||||
|
const emptySectionsBelow = previous.sections.filter(
|
||||||
|
(section): section is EmptySection => section.kind === "empty" && section.yOffset > currentCategory.yOffset,
|
||||||
|
);
|
||||||
|
const removedSection = emptySectionsBelow.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset).at(0);
|
||||||
|
|
||||||
|
if (!aboveSection || !removedSection) {
|
||||||
|
return previous;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the yOffset for the items in the currentCategory and removedWrapper to add them with the same offset to the aboveWrapper
|
||||||
|
const aboveYOffset = Math.max(
|
||||||
|
calculateYHeightWithOffsetForItems(aboveSection),
|
||||||
|
calculateYHeightWithOffsetForDynamicSections(previous.sections, aboveSection.id),
|
||||||
|
);
|
||||||
|
const categoryYOffset = Math.max(
|
||||||
|
calculateYHeightWithOffsetForItems(currentCategory),
|
||||||
|
calculateYHeightWithOffsetForDynamicSections(previous.sections, currentCategory.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
const previousCategoryItems = currentCategory.items.map((item) => ({
|
||||||
|
...item,
|
||||||
|
yOffset: item.yOffset + aboveYOffset,
|
||||||
|
}));
|
||||||
|
const previousBelowWrapperItems = removedSection.items.map((item) => ({
|
||||||
|
...item,
|
||||||
|
yOffset: item.yOffset + aboveYOffset + categoryYOffset,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...previous,
|
||||||
|
sections: [
|
||||||
|
...previous.sections.filter((section) => section.yOffset < aboveSection.yOffset && section.kind !== "dynamic"),
|
||||||
|
{
|
||||||
|
...aboveSection,
|
||||||
|
items: [...aboveSection.items, ...previousCategoryItems, ...previousBelowWrapperItems],
|
||||||
|
},
|
||||||
|
...previous.sections
|
||||||
|
.filter(
|
||||||
|
(section): section is CategorySection | EmptySection =>
|
||||||
|
section.yOffset > removedSection.yOffset && section.kind !== "dynamic",
|
||||||
|
)
|
||||||
|
.map((section) => ({
|
||||||
|
...section,
|
||||||
|
position: section.yOffset - 2,
|
||||||
|
})),
|
||||||
|
...previous.sections
|
||||||
|
.filter((section): section is DynamicSection => section.kind === "dynamic")
|
||||||
|
.map((dynamicSection) => {
|
||||||
|
// Move dynamic sections from removed section to above section with required yOffset
|
||||||
|
if (dynamicSection.parentSectionId === removedSection.id) {
|
||||||
|
return {
|
||||||
|
...dynamicSection,
|
||||||
|
yOffset: dynamicSection.yOffset + aboveYOffset + categoryYOffset,
|
||||||
|
parentSectionId: aboveSection.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move dynamic sections from category to above section with required yOffset
|
||||||
|
if (dynamicSection.parentSectionId === currentCategory.id) {
|
||||||
|
return {
|
||||||
|
...dynamicSection,
|
||||||
|
yOffset: dynamicSection.yOffset + aboveYOffset,
|
||||||
|
parentSectionId: aboveSection.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return dynamicSection;
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateYHeightWithOffsetForDynamicSections = (sections: Section[], sectionId: string) => {
|
||||||
|
return sections.reduce((acc, section) => {
|
||||||
|
if (section.kind !== "dynamic" || section.parentSectionId !== sectionId) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
const yHeightWithOffset = section.yOffset + section.height;
|
||||||
|
if (yHeightWithOffset > acc) return yHeightWithOffset;
|
||||||
|
return acc;
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateYHeightWithOffsetForItems = (section: Section) =>
|
||||||
|
section.items.reduce((acc, item) => {
|
||||||
|
const yHeightWithOffset = item.yOffset + item.height;
|
||||||
|
if (yHeightWithOffset > acc) return yHeightWithOffset;
|
||||||
|
return acc;
|
||||||
|
}, 0);
|
||||||
+125
@@ -0,0 +1,125 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import type { DynamicSection, Item, Section } from "~/app/[locale]/boards/_types";
|
||||||
|
import { removeCategoryCallback } from "../remove-category";
|
||||||
|
|
||||||
|
describe("Remove Category", () => {
|
||||||
|
test.each([
|
||||||
|
[3, [0, 1, 2, 3, 4, 5, 6], [0, 1, 2, 5, 6], [3, 4], 2],
|
||||||
|
[5, [0, 1, 2, 3, 4, 5, 6], [0, 1, 2, 3, 4], [5, 6], 4],
|
||||||
|
[1, [0, 1, 2, 3, 4, 5, 6], [0, 3, 4, 5, 6], [1, 2], 0],
|
||||||
|
[3, [0, 3, 6, 7, 8], [0, 7, 8], [3, 6], 0],
|
||||||
|
])(
|
||||||
|
"should remove category",
|
||||||
|
(removeId, initialYOffsets, expectedYOffsets, expectedRemovals, expectedLocationOfItems) => {
|
||||||
|
const sections = createSections(initialYOffsets);
|
||||||
|
|
||||||
|
const input = removeId.toString();
|
||||||
|
|
||||||
|
const result = removeCategoryCallback({ id: input })({ sections } as never);
|
||||||
|
|
||||||
|
expect(result.sections.map((section) => parseInt(section.id, 10))).toEqual(expectedYOffsets);
|
||||||
|
expectedRemovals.forEach((expectedRemoval) => {
|
||||||
|
expect(result.sections.find((section) => section.id === expectedRemoval.toString())).toBeUndefined();
|
||||||
|
});
|
||||||
|
const aboveSection = result.sections.find((section) => section.id === expectedLocationOfItems.toString());
|
||||||
|
expect(aboveSection?.items.map((item) => parseInt(item.id, 10))).toEqual(
|
||||||
|
expect.arrayContaining(expectedRemovals),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test("should correctly move items to above empty section", () => {
|
||||||
|
const initialYOffsets = [0, 1, 2, 3, 4, 5, 6];
|
||||||
|
const sections: Section[] = createSections(initialYOffsets);
|
||||||
|
const aboveSection = sections.find((section) => section.yOffset === 2)!;
|
||||||
|
aboveSection.items = [
|
||||||
|
createItem({ id: "above-1" }),
|
||||||
|
createItem({ id: "above-2", yOffset: 3, xOffset: 2, height: 2 }),
|
||||||
|
];
|
||||||
|
const removedCategory = sections.find((section) => section.yOffset === 3)!;
|
||||||
|
removedCategory.items = [
|
||||||
|
createItem({ id: "category-1" }),
|
||||||
|
createItem({ id: "category-2", yOffset: 2, xOffset: 4, width: 4 }),
|
||||||
|
];
|
||||||
|
const removedEmptySection = sections.find((section) => section.yOffset === 4)!;
|
||||||
|
removedEmptySection.items = [
|
||||||
|
createItem({ id: "below-1", xOffset: 5 }),
|
||||||
|
createItem({ id: "below-2", yOffset: 1, xOffset: 1, height: 2 }),
|
||||||
|
];
|
||||||
|
sections.push(
|
||||||
|
createDynamicSection({
|
||||||
|
id: "7",
|
||||||
|
parentSectionId: "3",
|
||||||
|
yOffset: 7,
|
||||||
|
height: 3,
|
||||||
|
items: [createItem({ id: "dynamic-1" })],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = "3";
|
||||||
|
|
||||||
|
const result = removeCategoryCallback({ id: input })({ sections } as never);
|
||||||
|
|
||||||
|
expect(result.sections.map((section) => parseInt(section.id, 10))).toEqual([0, 1, 2, 5, 6, 7]);
|
||||||
|
const aboveSectionResult = result.sections.find((section) => section.id === "2")!;
|
||||||
|
expect(aboveSectionResult.items).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
createItem({ id: "above-1" }),
|
||||||
|
createItem({ id: "above-2", yOffset: 3, xOffset: 2, height: 2 }),
|
||||||
|
createItem({ id: "category-1", yOffset: 5 }),
|
||||||
|
createItem({ id: "category-2", yOffset: 7, xOffset: 4, width: 4 }),
|
||||||
|
createItem({ id: "below-1", yOffset: 15, xOffset: 5 }),
|
||||||
|
createItem({ id: "below-2", yOffset: 16, xOffset: 1, height: 2 }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const dynamicSection = result.sections.find((section): section is DynamicSection => section.id === "7")!;
|
||||||
|
expect(dynamicSection.yOffset).toBe(12);
|
||||||
|
expect(dynamicSection.parentSectionId).toBe("2");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const createItem = (item: Partial<{ id: string; width: number; height: number; yOffset: number; xOffset: number }>) => {
|
||||||
|
return {
|
||||||
|
id: item.id ?? "0",
|
||||||
|
kind: "app",
|
||||||
|
options: {},
|
||||||
|
advancedOptions: {
|
||||||
|
customCssClasses: [],
|
||||||
|
},
|
||||||
|
height: item.height ?? 1,
|
||||||
|
width: item.width ?? 1,
|
||||||
|
yOffset: item.yOffset ?? 0,
|
||||||
|
xOffset: item.xOffset ?? 0,
|
||||||
|
integrationIds: [],
|
||||||
|
} satisfies Item;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createDynamicSection = (
|
||||||
|
section: Partial<
|
||||||
|
Pick<DynamicSection, "id" | "height" | "width" | "yOffset" | "xOffset" | "parentSectionId" | "items">
|
||||||
|
>,
|
||||||
|
) => {
|
||||||
|
return {
|
||||||
|
id: section.id ?? "0",
|
||||||
|
kind: "dynamic",
|
||||||
|
height: section.height ?? 1,
|
||||||
|
width: section.width ?? 1,
|
||||||
|
yOffset: section.yOffset ?? 0,
|
||||||
|
xOffset: section.xOffset ?? 0,
|
||||||
|
parentSectionId: section.parentSectionId ?? "0",
|
||||||
|
items: section.items ?? [],
|
||||||
|
} satisfies DynamicSection;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createSections = (initialYOffsets: number[]) => {
|
||||||
|
return initialYOffsets.map((yOffset, index) => ({
|
||||||
|
id: yOffset.toString(),
|
||||||
|
kind: index % 2 === 0 ? "empty" : "category",
|
||||||
|
name: "Category",
|
||||||
|
yOffset,
|
||||||
|
xOffset: 0,
|
||||||
|
items: [createItem({ id: yOffset.toString() })],
|
||||||
|
})) satisfies Section[];
|
||||||
|
};
|
||||||
@@ -2,10 +2,12 @@ import { useCallback } from "react";
|
|||||||
|
|
||||||
import { createId } from "@homarr/db/client";
|
import { createId } from "@homarr/db/client";
|
||||||
|
|
||||||
import type { CategorySection, EmptySection, Section } from "~/app/[locale]/boards/_types";
|
import type { CategorySection, EmptySection } from "~/app/[locale]/boards/_types";
|
||||||
import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client";
|
import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client";
|
||||||
import type { MoveCategoryInput } from "./actions/move-category";
|
import type { MoveCategoryInput } from "./actions/move-category";
|
||||||
import { moveCategoryCallback } from "./actions/move-category";
|
import { moveCategoryCallback } from "./actions/move-category";
|
||||||
|
import type { RemoveCategoryInput } from "./actions/remove-category";
|
||||||
|
import { removeCategoryCallback } from "./actions/remove-category";
|
||||||
|
|
||||||
interface AddCategory {
|
interface AddCategory {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -17,10 +19,6 @@ interface RenameCategory {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RemoveCategory {
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useCategoryActions = () => {
|
export const useCategoryActions = () => {
|
||||||
const { updateBoard } = useUpdateBoard();
|
const { updateBoard } = useUpdateBoard();
|
||||||
|
|
||||||
@@ -132,57 +130,8 @@ export const useCategoryActions = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const removeCategory = useCallback(
|
const removeCategory = useCallback(
|
||||||
({ id: categoryId }: RemoveCategory) => {
|
(input: RemoveCategoryInput) => {
|
||||||
updateBoard((previous) => {
|
updateBoard(removeCategoryCallback(input));
|
||||||
const currentCategory = previous.sections.find(
|
|
||||||
(section): section is CategorySection => section.kind === "category" && section.id === categoryId,
|
|
||||||
);
|
|
||||||
if (!currentCategory) return previous;
|
|
||||||
|
|
||||||
const aboveWrapper = previous.sections.find(
|
|
||||||
(section): section is EmptySection =>
|
|
||||||
section.kind === "empty" && section.yOffset === currentCategory.yOffset - 1,
|
|
||||||
);
|
|
||||||
|
|
||||||
const removedWrapper = previous.sections.find(
|
|
||||||
(section): section is EmptySection =>
|
|
||||||
section.kind === "empty" && section.yOffset === currentCategory.yOffset + 1,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!aboveWrapper || !removedWrapper) return previous;
|
|
||||||
|
|
||||||
// Calculate the yOffset for the items in the currentCategory and removedWrapper to add them with the same offset to the aboveWrapper
|
|
||||||
const aboveYOffset = calculateYHeightWithOffset(aboveWrapper);
|
|
||||||
const categoryYOffset = calculateYHeightWithOffset(currentCategory);
|
|
||||||
|
|
||||||
const previousCategoryItems = currentCategory.items.map((item) => ({
|
|
||||||
...item,
|
|
||||||
yOffset: item.yOffset + aboveYOffset,
|
|
||||||
}));
|
|
||||||
const previousBelowWrapperItems = removedWrapper.items.map((item) => ({
|
|
||||||
...item,
|
|
||||||
yOffset: item.yOffset + aboveYOffset + categoryYOffset,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
...previous,
|
|
||||||
sections: [
|
|
||||||
...previous.sections.filter((section) => section.yOffset < currentCategory.yOffset - 1),
|
|
||||||
{
|
|
||||||
...aboveWrapper,
|
|
||||||
items: [...aboveWrapper.items, ...previousCategoryItems, ...previousBelowWrapperItems],
|
|
||||||
},
|
|
||||||
...previous.sections
|
|
||||||
.filter(
|
|
||||||
(section): section is CategorySection | EmptySection => section.yOffset >= currentCategory.yOffset + 2,
|
|
||||||
)
|
|
||||||
.map((section) => ({
|
|
||||||
...section,
|
|
||||||
position: section.yOffset - 2,
|
|
||||||
})),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[updateBoard],
|
[updateBoard],
|
||||||
);
|
);
|
||||||
@@ -195,10 +144,3 @@ export const useCategoryActions = () => {
|
|||||||
removeCategory,
|
removeCategory,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculateYHeightWithOffset = (section: Section) =>
|
|
||||||
section.items.reduce((acc, item) => {
|
|
||||||
const yHeightWithOffset = item.yOffset + item.height;
|
|
||||||
if (yHeightWithOffset > acc) return yHeightWithOffset;
|
|
||||||
return acc;
|
|
||||||
}, 0);
|
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import type { FocusEventHandler } from "react";
|
import type { FocusEventHandler } from "react";
|
||||||
import { startTransition, useState } from "react";
|
import { startTransition, useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
ActionIcon,
|
||||||
Box,
|
Box,
|
||||||
Card,
|
Card,
|
||||||
Combobox,
|
Combobox,
|
||||||
Flex,
|
Flex,
|
||||||
|
Group,
|
||||||
Image,
|
Image,
|
||||||
Indicator,
|
Indicator,
|
||||||
InputBase,
|
InputBase,
|
||||||
@@ -16,10 +18,13 @@ import {
|
|||||||
useCombobox,
|
useCombobox,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
|
import { IconUpload } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { useSession } from "@homarr/auth/client";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { UploadMedia } from "~/app/[locale]/manage/medias/_actions/upload-media";
|
||||||
import classes from "./icon-picker.module.css";
|
import classes from "./icon-picker.module.css";
|
||||||
|
|
||||||
interface IconPickerProps {
|
interface IconPickerProps {
|
||||||
@@ -34,6 +39,7 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
|
|||||||
const [value, setValue] = useState<string>(initialValue ?? "");
|
const [value, setValue] = useState<string>(initialValue ?? "");
|
||||||
const [search, setSearch] = useState(initialValue ?? "");
|
const [search, setSearch] = useState(initialValue ?? "");
|
||||||
const [previewUrl, setPreviewUrl] = useState<string | null>(initialValue ?? null);
|
const [previewUrl, setPreviewUrl] = useState<string | null>(initialValue ?? null);
|
||||||
|
const { data: session } = useSession();
|
||||||
|
|
||||||
const tCommon = useScopedI18n("common");
|
const tCommon = useScopedI18n("common");
|
||||||
|
|
||||||
@@ -105,40 +111,61 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
|
|||||||
return (
|
return (
|
||||||
<Combobox store={combobox} withinPortal>
|
<Combobox store={combobox} withinPortal>
|
||||||
<Combobox.Target>
|
<Combobox.Target>
|
||||||
<InputBase
|
<Group wrap="nowrap" gap="xs" w="100%" align="start">
|
||||||
rightSection={<Combobox.Chevron />}
|
<InputBase
|
||||||
leftSection={
|
flex={1}
|
||||||
previewUrl ? (
|
rightSection={<Combobox.Chevron />}
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
leftSection={
|
||||||
<img src={previewUrl} alt="" style={{ width: 20, height: 20 }} />
|
previewUrl ? (
|
||||||
) : null
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
}
|
<img src={previewUrl} alt="" style={{ width: 20, height: 20 }} />
|
||||||
value={search}
|
) : null
|
||||||
onChange={(event) => {
|
}
|
||||||
combobox.openDropdown();
|
value={search}
|
||||||
combobox.updateSelectedOptionIndex();
|
onChange={(event) => {
|
||||||
setSearch(event.currentTarget.value);
|
combobox.openDropdown();
|
||||||
setValue(event.currentTarget.value);
|
combobox.updateSelectedOptionIndex();
|
||||||
setPreviewUrl(null);
|
setSearch(event.currentTarget.value);
|
||||||
onChange(event.currentTarget.value);
|
setValue(event.currentTarget.value);
|
||||||
}}
|
setPreviewUrl(null);
|
||||||
onClick={() => combobox.openDropdown()}
|
onChange(event.currentTarget.value);
|
||||||
onFocus={(event) => {
|
}}
|
||||||
onFocus?.(event);
|
onClick={() => combobox.openDropdown()}
|
||||||
combobox.openDropdown();
|
onFocus={(event) => {
|
||||||
}}
|
onFocus?.(event);
|
||||||
onBlur={(event) => {
|
combobox.openDropdown();
|
||||||
onBlur?.(event);
|
}}
|
||||||
combobox.closeDropdown();
|
onBlur={(event) => {
|
||||||
setPreviewUrl(value);
|
onBlur?.(event);
|
||||||
setSearch(value || "");
|
combobox.closeDropdown();
|
||||||
}}
|
setPreviewUrl(value);
|
||||||
rightSectionPointerEvents="none"
|
setSearch(value || "");
|
||||||
withAsterisk
|
}}
|
||||||
error={error}
|
rightSectionPointerEvents="none"
|
||||||
label={tCommon("iconPicker.label")}
|
withAsterisk
|
||||||
placeholder={tCommon("iconPicker.header", { countIcons: data?.countIcons ?? 0 })}
|
error={error}
|
||||||
/>
|
label={tCommon("iconPicker.label")}
|
||||||
|
placeholder={tCommon("iconPicker.header", { countIcons: data?.countIcons ?? 0 })}
|
||||||
|
/>
|
||||||
|
{session?.user.permissions.includes("media-upload") && (
|
||||||
|
<UploadMedia
|
||||||
|
onSuccess={({ url }) => {
|
||||||
|
startTransition(() => {
|
||||||
|
setValue(url);
|
||||||
|
setPreviewUrl(url);
|
||||||
|
setSearch(url);
|
||||||
|
onChange(url);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{({ onClick, loading }) => (
|
||||||
|
<ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default">
|
||||||
|
<IconUpload size={16} stroke={1.5} />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
|
</UploadMedia>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
</Combobox.Target>
|
</Combobox.Target>
|
||||||
|
|
||||||
<Combobox.Dropdown>
|
<Combobox.Dropdown>
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export const UserAvatarMenu = ({ children, availableUpdates }: UserAvatarMenuPro
|
|||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|
||||||
<Menu.Item p={0} closeMenuOnClick={false}>
|
<Menu.Item p={0} closeMenuOnClick={false} component="div">
|
||||||
<CurrentLanguageCombobox />
|
<CurrentLanguageCombobox />
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
@@ -113,7 +113,7 @@ export const UserAvatarMenu = ({ children, availableUpdates }: UserAvatarMenuPro
|
|||||||
{t("logout")}
|
{t("logout")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
) : (
|
) : (
|
||||||
<Menu.Item onClick={() => router.push("/auth/login")} leftSection={<IconLogin size="1rem" />}>
|
<Menu.Item component={Link} href="/auth/login" leftSection={<IconLogin size="1rem" />}>
|
||||||
{t("login")}
|
{t("login")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { createEnv } from "@t3-oss/env-nextjs";
|
import { createEnv } from "@t3-oss/env-nextjs";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { shouldSkipEnvValidation } from "@homarr/common/env-validation";
|
||||||
|
|
||||||
export const env = createEnv({
|
export const env = createEnv({
|
||||||
shared: {
|
shared: {
|
||||||
PORT: z.coerce.number().default(3000),
|
PORT: z.coerce.number().default(3000),
|
||||||
@@ -10,11 +12,7 @@ export const env = createEnv({
|
|||||||
* Specify your server-side environment variables schema here. This way you can ensure the app isn't
|
* Specify your server-side environment variables schema here. This way you can ensure the app isn't
|
||||||
* built with invalid env vars.
|
* built with invalid env vars.
|
||||||
*/
|
*/
|
||||||
server: {
|
server: {},
|
||||||
// Comma separated list of docker hostnames that can be used to connect to query the docker endpoints (localhost:2375,host.docker.internal:2375, ...)
|
|
||||||
DOCKER_HOSTNAMES: z.string().optional(),
|
|
||||||
DOCKER_PORTS: z.number().optional(),
|
|
||||||
},
|
|
||||||
/**
|
/**
|
||||||
* Specify your client-side environment variables schema here.
|
* Specify your client-side environment variables schema here.
|
||||||
* For them to be exposed to the client, prefix them with `NEXT_PUBLIC_`.
|
* For them to be exposed to the client, prefix them with `NEXT_PUBLIC_`.
|
||||||
@@ -28,10 +26,8 @@ export const env = createEnv({
|
|||||||
runtimeEnv: {
|
runtimeEnv: {
|
||||||
PORT: process.env.PORT,
|
PORT: process.env.PORT,
|
||||||
NODE_ENV: process.env.NODE_ENV,
|
NODE_ENV: process.env.NODE_ENV,
|
||||||
DOCKER_HOSTNAMES: process.env.DOCKER_HOSTNAMES,
|
|
||||||
DOCKER_PORTS: process.env.DOCKER_PORTS,
|
|
||||||
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
|
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
|
||||||
},
|
},
|
||||||
skipValidation:
|
skipValidation: shouldSkipEnvValidation(),
|
||||||
Boolean(process.env.CI) || Boolean(process.env.SKIP_ENV_VALIDATION) || process.env.npm_lifecycle_event === "lint",
|
emptyStringAsUndefined: true,
|
||||||
});
|
});
|
||||||
@@ -4,7 +4,7 @@ import { createTRPCClient, httpLink } from "@trpc/client";
|
|||||||
import SuperJSON from "superjson";
|
import SuperJSON from "superjson";
|
||||||
|
|
||||||
import type { AppRouter } from "@homarr/api";
|
import type { AppRouter } from "@homarr/api";
|
||||||
import { createHeadersCallbackForSource } from "@homarr/api/client";
|
import { createHeadersCallbackForSource } from "@homarr/api/shared";
|
||||||
import { createI18nMiddleware } from "@homarr/translation/middleware";
|
import { createI18nMiddleware } from "@homarr/translation/middleware";
|
||||||
|
|
||||||
export async function middleware(request: NextRequest) {
|
export async function middleware(request: NextRequest) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { ColorScheme } from "@homarr/definitions";
|
|||||||
import { colorSchemeCookieKey } from "@homarr/definitions";
|
import { colorSchemeCookieKey } from "@homarr/definitions";
|
||||||
|
|
||||||
export const getCurrentColorSchemeAsync = cache(async () => {
|
export const getCurrentColorSchemeAsync = cache(async () => {
|
||||||
const cookieValue = cookies().get(colorSchemeCookieKey)?.value;
|
const cookieValue = (await cookies()).get(colorSchemeCookieKey)?.value;
|
||||||
|
|
||||||
if (cookieValue) {
|
if (cookieValue) {
|
||||||
return cookieValue as ColorScheme;
|
return cookieValue as ColorScheme;
|
||||||
|
|||||||
@@ -38,17 +38,17 @@
|
|||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"superjson": "2.2.2",
|
"superjson": "2.2.2",
|
||||||
"undici": "7.2.0"
|
"undici": "7.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@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/node": "^22.10.2",
|
"@types/node": "^22.10.10",
|
||||||
"dotenv-cli": "^8.0.0",
|
"dotenv-cli": "^8.0.0",
|
||||||
"eslint": "^9.17.0",
|
"eslint": "^9.18.0",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"tsx": "4.19.2",
|
"tsx": "4.19.2",
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,35 +1,6 @@
|
|||||||
import type { Dispatcher } from "undici";
|
import { setGlobalDispatcher } from "undici";
|
||||||
import { Agent, setGlobalDispatcher } from "undici";
|
|
||||||
|
|
||||||
import { logger } from "@homarr/log";
|
import { LoggingAgent } from "@homarr/common/server";
|
||||||
|
|
||||||
export class LoggingAgent extends Agent {
|
|
||||||
constructor(...props: ConstructorParameters<typeof Agent>) {
|
|
||||||
super(...props);
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(options: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandler): boolean {
|
|
||||||
const url = new URL(`${options.origin as string}${options.path}`);
|
|
||||||
|
|
||||||
// The below code should prevent sensitive data from being logged as
|
|
||||||
// some integrations use query parameters for auth
|
|
||||||
url.searchParams.forEach((value, key) => {
|
|
||||||
if (value === "") return; // Skip empty values
|
|
||||||
if (/^-?\d{1,12}$/.test(value)) return; // Skip small numbers
|
|
||||||
if (value === "true" || value === "false") return; // Skip boolean values
|
|
||||||
if (/^[a-zA-Z]{1,12}$/.test(value)) return; // Skip short strings
|
|
||||||
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return; // Skip dates
|
|
||||||
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)) return; // Skip date times
|
|
||||||
|
|
||||||
url.searchParams.set(key, "REDACTED");
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`Dispatching request ${url.toString().replaceAll("=&", "&")} (${Object.keys(options.headers ?? {}).length} headers)`,
|
|
||||||
);
|
|
||||||
return super.dispatch(options, handler);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const agent = new LoggingAgent();
|
const agent = new LoggingAgent();
|
||||||
setGlobalDispatcher(agent);
|
setGlobalDispatcher(agent);
|
||||||
@@ -33,9 +33,9 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@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/ws": "^8.5.13",
|
"@types/ws": "^8.5.14",
|
||||||
"eslint": "^9.17.0",
|
"eslint": "^9.18.0",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+3
-10
@@ -24,10 +24,10 @@
|
|||||||
<strong>Demo ✨</strong>
|
<strong>Demo ✨</strong>
|
||||||
</a>
|
</a>
|
||||||
•
|
•
|
||||||
<a href="https://homarr.dev/docs/getting-started/installation/">
|
<a href="https://homarr.dev/docs/category/installation-1/">
|
||||||
<strong>Install 💻</strong>
|
<strong>Install 💻</strong>
|
||||||
</a> •
|
</a> •
|
||||||
<a href="https://translate.homarr.dev/">
|
<a href="https://crowdin.com/project/homarr_labs">
|
||||||
<strong>Translations 🈺</strong>
|
<strong>Translations 🈺</strong>
|
||||||
</a> •
|
</a> •
|
||||||
<a href="https://discord.com/invite/aCsmEV5RgA">
|
<a href="https://discord.com/invite/aCsmEV5RgA">
|
||||||
@@ -35,13 +35,6 @@
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
> [!WARNING]
|
|
||||||
> This is a beta version of Homarr. You can download the old but stable version at https://github.com/ajnart/homarr/. Running this version is at your own risk.
|
|
||||||
|
|
||||||
Simplify the management of your server with Homarr - a sleek, modern dashboard that puts all of your apps and services at your fingertips. With Homarr, you can access and control everything in one location. Homarr seamlessly integrates with the apps you've added, providing you with valuable information and giving you complete control. Installation is a breeze, and Homarr supports a wide range of deployment methods.
|
|
||||||
|
|
||||||
<br/>
|
|
||||||
<br/>
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -100,7 +93,7 @@ Homarr has a [built-in collection of widgets and integrations](https://homarr.de
|
|||||||
Since we are updating Homarr very frequently, we recommend reading our official installation guides:
|
Since we are updating Homarr very frequently, we recommend reading our official installation guides:
|
||||||
|
|
||||||
<h2>
|
<h2>
|
||||||
<a href="https://homarr.dev/docs/getting-started/installation/">
|
<a href="https://homarr.dev/docs/category/installation-1/">
|
||||||
Please click here for official installation instructions
|
Please click here for official installation instructions
|
||||||
</a>
|
</a>
|
||||||
</h2>
|
</h2>
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { chromium } from "playwright";
|
||||||
|
import { GenericContainer } from "testcontainers";
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { OnboardingActions } from "./shared/actions/onboarding-actions";
|
||||||
|
import { createHomarrContainer, withLogs } from "./shared/create-homarr-container";
|
||||||
|
import { createSqliteDbFileAsync } from "./shared/e2e-db";
|
||||||
|
|
||||||
|
const defaultCredentials = {
|
||||||
|
username: "admin",
|
||||||
|
password: "password",
|
||||||
|
email: "admin@homarr.dev",
|
||||||
|
group: "lldap_admin",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ldapBase = "dc=example,dc=com";
|
||||||
|
|
||||||
|
describe("LLDAP authorization", () => {
|
||||||
|
test("Authorize with LLDAP successfully", async () => {
|
||||||
|
// Arrange
|
||||||
|
const lldapContainer = await createLldapContainer().start();
|
||||||
|
const { db, localMountPath } = await createSqliteDbFileAsync();
|
||||||
|
const homarrContainer = await createHomarrContainer({
|
||||||
|
environment: {
|
||||||
|
AUTH_PROVIDERS: "ldap",
|
||||||
|
AUTH_LDAP_URI: `ldap://host.docker.internal:${lldapContainer.getMappedPort(3890)}`,
|
||||||
|
AUTH_LDAP_BASE: ldapBase,
|
||||||
|
AUTH_LDAP_BIND_DN: `uid=${defaultCredentials.username},ou=People,${ldapBase}`,
|
||||||
|
AUTH_LDAP_BIND_PASSWORD: defaultCredentials.password,
|
||||||
|
},
|
||||||
|
mounts: {
|
||||||
|
"/appdata": localMountPath,
|
||||||
|
},
|
||||||
|
}).start();
|
||||||
|
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const context = await browser.newContext();
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
const onboardingActions = new OnboardingActions(page, db);
|
||||||
|
await onboardingActions.skipOnboardingAsync({
|
||||||
|
group: defaultCredentials.group,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}/auth/login`);
|
||||||
|
await page.getByLabel("Username").fill(defaultCredentials.username);
|
||||||
|
await page.getByLabel("Password").fill(defaultCredentials.password);
|
||||||
|
await page.locator("css=button[type='submit']").click();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await page.waitForURL(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
|
||||||
|
const users = await db.query.users.findMany({
|
||||||
|
with: {
|
||||||
|
groups: {
|
||||||
|
with: {
|
||||||
|
group: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(users).toHaveLength(1);
|
||||||
|
const user = users[0]!;
|
||||||
|
expect(user).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: defaultCredentials.username,
|
||||||
|
email: defaultCredentials.email,
|
||||||
|
provider: "ldap",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const groups = user.groups.map((g) => g.group.name);
|
||||||
|
expect(groups).toContain(defaultCredentials.group);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await browser.close();
|
||||||
|
await homarrContainer.stop();
|
||||||
|
await lldapContainer.stop();
|
||||||
|
}, 120_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
const createLldapContainer = () => {
|
||||||
|
return withLogs(
|
||||||
|
new GenericContainer("lldap/lldap:stable").withExposedPorts(3890).withEnvironment({
|
||||||
|
LLDAP_JWT_SECRET: "REPLACE_WITH_RANDOM",
|
||||||
|
LLDAP_KEY_SEED: "REPLACE_WITH_RANDOM",
|
||||||
|
LLDAP_LDAP_BASE_DN: ldapBase,
|
||||||
|
LLDAP_LDAP_USER_PASS: defaultCredentials.password,
|
||||||
|
LLDAP_LDAP_USER_EMAIL: defaultCredentials.email,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { chromium } from "playwright";
|
||||||
|
import { describe, test } from "vitest";
|
||||||
|
|
||||||
|
import { OnboardingActions } from "./shared/actions/onboarding-actions";
|
||||||
|
import { OnboardingAssertions } from "./shared/assertions/onboarding-assertions";
|
||||||
|
import { createHomarrContainer } from "./shared/create-homarr-container";
|
||||||
|
import { createSqliteDbFileAsync } from "./shared/e2e-db";
|
||||||
|
|
||||||
|
describe("Onboarding", () => {
|
||||||
|
test("Credentials onboarding should be successful", async () => {
|
||||||
|
// Arrange
|
||||||
|
const { db, localMountPath } = await createSqliteDbFileAsync();
|
||||||
|
const homarrContainer = await createHomarrContainer({
|
||||||
|
mounts: {
|
||||||
|
"/appdata": localMountPath,
|
||||||
|
},
|
||||||
|
}).start();
|
||||||
|
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const context = await browser.newContext();
|
||||||
|
const page = await context.newPage();
|
||||||
|
const actions = new OnboardingActions(page, db);
|
||||||
|
const assertions = new OnboardingAssertions(page, db);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
|
||||||
|
await actions.startOnboardingAsync("scratch");
|
||||||
|
await actions.processUserStepAsync({
|
||||||
|
username: "admin",
|
||||||
|
password: "Comp(exP4sswOrd",
|
||||||
|
confirmPassword: "Comp(exP4sswOrd",
|
||||||
|
});
|
||||||
|
await actions.processSettingsStepAsync();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await assertions.assertFinishStepVisibleAsync();
|
||||||
|
await assertions.assertUserAndAdminGroupInsertedAsync("admin");
|
||||||
|
await assertions.assertDbOnboardingStepAsync("finish");
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await browser.close();
|
||||||
|
await homarrContainer.stop();
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
test("External provider onboarding setup should be successful", async () => {
|
||||||
|
// Arrange
|
||||||
|
const { db, localMountPath } = await createSqliteDbFileAsync();
|
||||||
|
const homarrContainer = await createHomarrContainer({
|
||||||
|
environment: {
|
||||||
|
AUTH_PROVIDERS: "ldap",
|
||||||
|
AUTH_LDAP_URI: "ldap://host.docker.internal:3890",
|
||||||
|
AUTH_LDAP_BASE: "not-used",
|
||||||
|
AUTH_LDAP_BIND_DN: "not-used",
|
||||||
|
AUTH_LDAP_BIND_PASSWORD: "not-used",
|
||||||
|
},
|
||||||
|
mounts: {
|
||||||
|
"/appdata": localMountPath,
|
||||||
|
},
|
||||||
|
}).start();
|
||||||
|
const externalGroupName = "oidc-admins";
|
||||||
|
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const context = await browser.newContext();
|
||||||
|
const page = await context.newPage();
|
||||||
|
const actions = new OnboardingActions(page, db);
|
||||||
|
const assertions = new OnboardingAssertions(page, db);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
|
||||||
|
await actions.startOnboardingAsync("scratch");
|
||||||
|
await actions.processExternalGroupStepAsync({
|
||||||
|
name: externalGroupName,
|
||||||
|
});
|
||||||
|
await actions.processSettingsStepAsync();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await assertions.assertFinishStepVisibleAsync();
|
||||||
|
await assertions.assertExternalGroupInsertedAsync(externalGroupName);
|
||||||
|
await assertions.assertDbOnboardingStepAsync("finish");
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await browser.close();
|
||||||
|
await homarrContainer.stop();
|
||||||
|
}, 60_000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
|
import type { Page } from "playwright";
|
||||||
|
|
||||||
|
import * as sqliteSchema from "../../../packages/db/schema/sqlite";
|
||||||
|
import type { SqliteDatabase } from "../e2e-db";
|
||||||
|
|
||||||
|
export class OnboardingActions {
|
||||||
|
private readonly page: Page;
|
||||||
|
private readonly db: SqliteDatabase;
|
||||||
|
|
||||||
|
constructor(page: Page, db: SqliteDatabase) {
|
||||||
|
this.page = page;
|
||||||
|
this.db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async skipOnboardingAsync(input?: { group?: string }) {
|
||||||
|
await this.db.update(sqliteSchema.onboarding).set({
|
||||||
|
step: "finish",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (input?.group) {
|
||||||
|
await this.db.insert(sqliteSchema.groups).values({
|
||||||
|
id: createId(),
|
||||||
|
name: input.group,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async startOnboardingAsync(type: "scratch" | "before 1.0") {
|
||||||
|
await this.page.locator("button", { hasText: type }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async processUserStepAsync(input: { username: string; password: string; confirmPassword: string }) {
|
||||||
|
await this.page.waitForSelector("text=administrator user");
|
||||||
|
|
||||||
|
await this.page.getByLabel("Username").fill(input.username);
|
||||||
|
await this.page.getByLabel("Password", { exact: true }).fill(input.password);
|
||||||
|
await this.page.getByLabel("Confirm password").fill(input.confirmPassword);
|
||||||
|
|
||||||
|
await this.page.locator("css=button[type='submit']").click();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async processExternalGroupStepAsync(input: { name: string }) {
|
||||||
|
await this.page.waitForSelector("text=external provider");
|
||||||
|
await this.page.locator("input").fill(input.name);
|
||||||
|
await this.page.locator("css=button[type='submit']").click();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async processSettingsStepAsync() {
|
||||||
|
await this.page.waitForSelector("text=Analytics");
|
||||||
|
await this.page.locator("css=button[type='submit']").click();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { Page } from "playwright";
|
||||||
|
import { expect } from "vitest";
|
||||||
|
|
||||||
|
import * as sqliteSchema from "../../../packages/db/schema/sqlite";
|
||||||
|
import { OnboardingStep } from "../../../packages/definitions/src";
|
||||||
|
import { credentialsAdminGroup } from "../../../packages/definitions/src/group";
|
||||||
|
import type { SqliteDatabase } from "../e2e-db";
|
||||||
|
|
||||||
|
export class OnboardingAssertions {
|
||||||
|
private readonly page: Page;
|
||||||
|
private readonly db: SqliteDatabase;
|
||||||
|
|
||||||
|
constructor(page: Page, db: SqliteDatabase) {
|
||||||
|
this.page = page;
|
||||||
|
this.db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async assertDbOnboardingStepAsync(expectedStep: OnboardingStep) {
|
||||||
|
const onboarding = await this.db.query.onboarding.findFirst();
|
||||||
|
expect(onboarding?.step).toEqual(expectedStep);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async assertUserAndAdminGroupInsertedAsync(expectedUsername: string) {
|
||||||
|
const users = await this.db.query.users.findMany({
|
||||||
|
with: {
|
||||||
|
groups: {
|
||||||
|
with: {
|
||||||
|
group: {
|
||||||
|
with: {
|
||||||
|
permissions: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = users.find((u) => u.name === expectedUsername);
|
||||||
|
expect(user).toBeDefined();
|
||||||
|
|
||||||
|
const adminGroup = user!.groups.find((g) => g.group.name === credentialsAdminGroup);
|
||||||
|
expect(adminGroup).toBeDefined();
|
||||||
|
expect(adminGroup!.group.permissions).toEqual([expect.objectContaining({ permission: "admin" })]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async assertExternalGroupInsertedAsync(expectedGroupName: string) {
|
||||||
|
const group = await this.db.query.groups.findFirst({
|
||||||
|
where: eq(sqliteSchema.groups.name, expectedGroupName),
|
||||||
|
with: {
|
||||||
|
permissions: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(group).toBeDefined();
|
||||||
|
expect(group!.permissions).toEqual([expect.objectContaining({ permission: "admin" })]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async assertFinishStepVisibleAsync() {
|
||||||
|
await this.page.waitForSelector("text=completed the setup", { timeout: 5000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,43 @@
|
|||||||
import { GenericContainer, Wait } from "testcontainers";
|
import { GenericContainer, Wait } from "testcontainers";
|
||||||
|
import { Environment } from "testcontainers/build/types";
|
||||||
|
|
||||||
export const createHomarrContainer = () => {
|
export const createHomarrContainer = (
|
||||||
|
options: {
|
||||||
|
environment?: Environment;
|
||||||
|
mounts?: {
|
||||||
|
"/appdata"?: string;
|
||||||
|
"/var/run/docker.sock"?: string;
|
||||||
|
};
|
||||||
|
} = {},
|
||||||
|
) => {
|
||||||
if (!process.env.CI) {
|
if (!process.env.CI) {
|
||||||
throw new Error("This test should only be run in CI or with a homarr image named 'homarr-e2e'");
|
throw new Error("This test should only be run in CI or with a homarr image named 'homarr-e2e'");
|
||||||
}
|
}
|
||||||
|
|
||||||
return withLogs(
|
const container = new GenericContainer("homarr-e2e")
|
||||||
new GenericContainer("homarr-e2e")
|
.withExposedPorts(7575)
|
||||||
.withExposedPorts(7575)
|
.withEnvironment({
|
||||||
.withEnvironment({
|
...options.environment,
|
||||||
SECRET_ENCRYPTION_KEY: "0".repeat(64),
|
SECRET_ENCRYPTION_KEY: "0".repeat(64),
|
||||||
})
|
})
|
||||||
.withWaitStrategy(Wait.forHttp("/api/health/ready", 7575)),
|
.withBindMounts(
|
||||||
);
|
Object.entries(options.mounts ?? {})
|
||||||
|
.filter((item) => item?.[0] !== undefined)
|
||||||
|
.map(([container, local]) => ({
|
||||||
|
source: local,
|
||||||
|
target: container,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.withWaitStrategy(Wait.forHttp("/api/health/ready", 7575))
|
||||||
|
.withExtraHosts([
|
||||||
|
{
|
||||||
|
// This enabled the usage of host.docker.internal as hostname in the container
|
||||||
|
host: "host.docker.internal",
|
||||||
|
ipAddress: "host-gateway",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
return withLogs(container);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const withLogs = (container: GenericContainer) => {
|
export const withLogs = (container: GenericContainer) => {
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { mkdir } from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
import { BetterSQLite3Database, drizzle } from "drizzle-orm/better-sqlite3";
|
||||||
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||||
|
|
||||||
|
import * as sqliteSchema from "../../packages/db/schema/sqlite";
|
||||||
|
|
||||||
|
export const createSqliteDbFileAsync = async () => {
|
||||||
|
const localMountPath = path.join(__dirname, "tmp", createId());
|
||||||
|
await mkdir(path.join(localMountPath, "db"), { recursive: true });
|
||||||
|
|
||||||
|
const localDbUrl = path.join(localMountPath, "db", "db.sqlite");
|
||||||
|
|
||||||
|
const connection = new Database(localDbUrl);
|
||||||
|
const db = drizzle(connection, {
|
||||||
|
schema: sqliteSchema,
|
||||||
|
casing: "snake_case",
|
||||||
|
});
|
||||||
|
|
||||||
|
await migrate(db, {
|
||||||
|
migrationsFolder: path.join(__dirname, "..", "..", "packages", "db", "migrations", "sqlite"),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
db,
|
||||||
|
localMountPath,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SqliteDatabase = BetterSQLite3Database<typeof sqliteSchema>;
|
||||||
+21
-17
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "homarr",
|
"name": "homarr",
|
||||||
"version": "1.0.0-beta.10",
|
"version": "1.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "cross-env CI=true turbo build",
|
"build": "cross-env CI=true turbo build",
|
||||||
@@ -22,9 +22,10 @@
|
|||||||
"lint:ws": "pnpm dlx sherif@latest",
|
"lint:ws": "pnpm dlx sherif@latest",
|
||||||
"package:new": "turbo gen init",
|
"package:new": "turbo gen init",
|
||||||
"release": "semantic-release",
|
"release": "semantic-release",
|
||||||
"test": "cross-env NODE_ENV=development vitest run --exclude e2e --coverage.enabled ",
|
"start": "concurrently \"pnpm with-env node apps/tasks/tasks.cjs\" \"pnpm with-env node apps/websocket/wssServer.cjs\" \"pnpm -F nextjs start\"",
|
||||||
"test:e2e": "cross-env NODE_ENV=development vitest e2e",
|
"test": "cross-env NODE_ENV=development CI=true vitest run --exclude e2e --coverage.enabled ",
|
||||||
"test:ui": "cross-env NODE_ENV=development vitest --exclude e2e --ui --coverage.enabled",
|
"test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e",
|
||||||
|
"test:ui": "cross-env NODE_ENV=development CI=true vitest --exclude e2e --ui --coverage.enabled",
|
||||||
"typecheck": "turbo typecheck",
|
"typecheck": "turbo typecheck",
|
||||||
"with-env": "dotenv -e .env --"
|
"with-env": "dotenv -e .env --"
|
||||||
},
|
},
|
||||||
@@ -32,32 +33,35 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@semantic-release/changelog": "^6.0.3",
|
"@semantic-release/changelog": "^6.0.3",
|
||||||
"@semantic-release/commit-analyzer": "^13.0.0",
|
"@semantic-release/commit-analyzer": "^13.0.1",
|
||||||
"@semantic-release/git": "^10.0.1",
|
"@semantic-release/git": "^10.0.1",
|
||||||
"@semantic-release/github": "^11.0.1",
|
"@semantic-release/github": "^11.0.1",
|
||||||
"@semantic-release/npm": "^12.0.1",
|
"@semantic-release/npm": "^12.0.1",
|
||||||
"@semantic-release/release-notes-generator": "^14.0.2",
|
"@semantic-release/release-notes-generator": "^14.0.3",
|
||||||
"@turbo/gen": "^2.3.3",
|
"@turbo/gen": "^2.3.4",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"@vitest/coverage-v8": "^2.1.8",
|
"@vitest/coverage-v8": "^3.0.4",
|
||||||
"@vitest/ui": "^2.1.8",
|
"@vitest/ui": "^3.0.4",
|
||||||
"conventional-changelog-conventionalcommits": "^8.0.0",
|
"conventional-changelog-conventionalcommits": "^8.0.0",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"jsdom": "^25.0.1",
|
"jsdom": "^26.0.0",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"semantic-release": "^24.2.0",
|
"semantic-release": "^24.2.1",
|
||||||
"testcontainers": "^10.16.0",
|
"testcontainers": "^10.17.1",
|
||||||
"turbo": "^2.3.3",
|
"turbo": "^2.3.4",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.3",
|
||||||
"vite-tsconfig-paths": "^5.1.4",
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
"vitest": "^2.1.8"
|
"vitest": "^3.0.4"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@9.15.2",
|
"packageManager": "pnpm@9.15.4",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.12.0"
|
"node": ">=22.13.1"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"allowNonAppliedPatches": true,
|
"allowNonAppliedPatches": true,
|
||||||
|
"overrides": {
|
||||||
|
"proxmox-api>undici": "7.3.0"
|
||||||
|
},
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"pretty-print-error": "patches/pretty-print-error.patch"
|
"pretty-print-error": "patches/pretty-print-error.patch"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@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",
|
||||||
"eslint": "^9.17.0",
|
"eslint": "^9.18.0",
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,8 @@
|
|||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./client": "./src/client.ts",
|
"./client": "./src/client.ts",
|
||||||
"./server": "./src/server.ts",
|
"./server": "./src/server.ts",
|
||||||
"./websocket": "./src/websocket.ts"
|
"./websocket": "./src/websocket.ts",
|
||||||
|
"./shared": "./src/shared.ts"
|
||||||
},
|
},
|
||||||
"main": "./index.ts",
|
"main": "./index.ts",
|
||||||
"types": "./index.ts",
|
"types": "./index.ts",
|
||||||
@@ -21,12 +22,14 @@
|
|||||||
"prettier": "@homarr/prettier-config",
|
"prettier": "@homarr/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@homarr/auth": "workspace:^0.1.0",
|
"@homarr/auth": "workspace:^0.1.0",
|
||||||
|
"@homarr/certificates": "workspace:^0.1.0",
|
||||||
"@homarr/common": "workspace:^0.1.0",
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
"@homarr/cron-job-runner": "workspace:^0.1.0",
|
"@homarr/cron-job-runner": "workspace:^0.1.0",
|
||||||
"@homarr/cron-job-status": "workspace:^0.1.0",
|
"@homarr/cron-job-status": "workspace:^0.1.0",
|
||||||
"@homarr/cron-jobs": "workspace:^0.1.0",
|
"@homarr/cron-jobs": "workspace:^0.1.0",
|
||||||
"@homarr/db": "workspace:^0.1.0",
|
"@homarr/db": "workspace:^0.1.0",
|
||||||
"@homarr/definitions": "workspace:^0.1.0",
|
"@homarr/definitions": "workspace:^0.1.0",
|
||||||
|
"@homarr/docker": "workspace:^0.1.0",
|
||||||
"@homarr/icons": "workspace:^0.1.0",
|
"@homarr/icons": "workspace:^0.1.0",
|
||||||
"@homarr/integrations": "workspace:^0.1.0",
|
"@homarr/integrations": "workspace:^0.1.0",
|
||||||
"@homarr/log": "workspace:^",
|
"@homarr/log": "workspace:^",
|
||||||
@@ -40,20 +43,19 @@
|
|||||||
"@trpc/client": "next",
|
"@trpc/client": "next",
|
||||||
"@trpc/react-query": "next",
|
"@trpc/react-query": "next",
|
||||||
"@trpc/server": "next",
|
"@trpc/server": "next",
|
||||||
"dockerode": "^4.0.2",
|
|
||||||
"lodash.clonedeep": "^4.5.0",
|
"lodash.clonedeep": "^4.5.0",
|
||||||
"next": "^14.2.22",
|
"next": "15.1.6",
|
||||||
"react": "^19.0.0",
|
"react": "19.0.0",
|
||||||
|
"react-dom": "19.0.0",
|
||||||
"superjson": "2.2.2",
|
"superjson": "2.2.2",
|
||||||
"trpc-to-openapi": "^2.1.1"
|
"trpc-to-openapi": "^2.1.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@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/dockerode": "^3.3.32",
|
"eslint": "^9.18.0",
|
||||||
"eslint": "^9.17.0",
|
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import { createTRPCClient, createTRPCReact, httpLink } from "@trpc/react-query";
|
"use client";
|
||||||
|
|
||||||
|
import { createTRPCClient, httpLink } from "@trpc/client";
|
||||||
|
import { createTRPCReact } from "@trpc/react-query";
|
||||||
import SuperJSON from "superjson";
|
import SuperJSON from "superjson";
|
||||||
|
|
||||||
import type { AppRouter } from ".";
|
import type { AppRouter } from ".";
|
||||||
|
import { createHeadersCallbackForSource } from "./shared";
|
||||||
|
|
||||||
export const clientApi = createTRPCReact<AppRouter>();
|
export const clientApi = createTRPCReact<AppRouter>();
|
||||||
export const fetchApi = createTRPCClient<AppRouter>({
|
export const fetchApi = createTRPCClient<AppRouter>({
|
||||||
@@ -26,42 +30,3 @@ function getBaseUrl() {
|
|||||||
export function getTrpcUrl() {
|
export function getTrpcUrl() {
|
||||||
return `${getBaseUrl()}/api/trpc`;
|
return `${getBaseUrl()}/api/trpc`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a headers callback for a given source
|
|
||||||
* It will set the x-trpc-source header and cookies if needed
|
|
||||||
* @param source trpc source request comes from
|
|
||||||
* @returns headers callback
|
|
||||||
*/
|
|
||||||
export function createHeadersCallbackForSource(source: string) {
|
|
||||||
return async () => {
|
|
||||||
const headers = new Headers();
|
|
||||||
headers.set("x-trpc-source", source);
|
|
||||||
|
|
||||||
const cookies = await importCookiesAsync();
|
|
||||||
// We need to set cookie for ssr requests (for example with useSuspenseQuery or middleware)
|
|
||||||
if (cookies) {
|
|
||||||
headers.set("cookie", cookies);
|
|
||||||
}
|
|
||||||
|
|
||||||
return headers;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This is a workarround as cookies are not passed to the server
|
|
||||||
* when using useSuspenseQuery or middleware
|
|
||||||
* @returns cookie string on server or null on client
|
|
||||||
*/
|
|
||||||
async function importCookiesAsync() {
|
|
||||||
if (typeof window === "undefined") {
|
|
||||||
return await import("next/headers").then(({ cookies }) =>
|
|
||||||
cookies()
|
|
||||||
.getAll()
|
|
||||||
.map(({ name, value }) => `${name}=${value}`)
|
|
||||||
.join(";"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -98,56 +98,59 @@ export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(
|
|||||||
action: IntegrationAction,
|
action: IntegrationAction,
|
||||||
...kinds: AtLeastOneOf<TKind> // Ensure at least one kind is provided
|
...kinds: AtLeastOneOf<TKind> // Ensure at least one kind is provided
|
||||||
) => {
|
) => {
|
||||||
return publicProcedure
|
return publicProcedure.input(z.object({ integrationIds: z.array(z.string()) })).use(async ({ ctx, input, next }) => {
|
||||||
.input(z.object({ integrationIds: z.array(z.string()).min(1) }))
|
const dbIntegrations =
|
||||||
.use(async ({ ctx, input, next }) => {
|
input.integrationIds.length >= 1
|
||||||
const dbIntegrations = await ctx.db.query.integrations.findMany({
|
? await ctx.db.query.integrations.findMany({
|
||||||
where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, kinds)),
|
where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, kinds)),
|
||||||
with: {
|
|
||||||
secrets: true,
|
|
||||||
items: {
|
|
||||||
with: {
|
with: {
|
||||||
item: {
|
secrets: true,
|
||||||
|
items: {
|
||||||
with: {
|
with: {
|
||||||
section: {
|
item: {
|
||||||
columns: {
|
with: {
|
||||||
boardId: true,
|
section: {
|
||||||
|
columns: {
|
||||||
|
boardId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
userPermissions: true,
|
||||||
|
groupPermissions: true,
|
||||||
},
|
},
|
||||||
},
|
})
|
||||||
userPermissions: true,
|
: [];
|
||||||
groupPermissions: true,
|
|
||||||
},
|
const offset = input.integrationIds.length - dbIntegrations.length;
|
||||||
|
if (offset !== 0) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: `${offset} of the specified integrations not found or not of kinds ${kinds.join(",")}: ([${input.integrationIds.join(",")}] compared to [${dbIntegrations.map(({ id, kind }) => `${kind}:${id}`).join(",")}])`,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const offset = input.integrationIds.length - dbIntegrations.length;
|
if (dbIntegrations.length >= 1) {
|
||||||
if (offset !== 0) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "NOT_FOUND",
|
|
||||||
message: `${offset} of the specified integrations not found or not of kinds ${kinds.join(",")}: ([${input.integrationIds.join(",")}] compared to [${dbIntegrations.map(({ id, kind }) => `${kind}:${id}`).join(",")}])`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await throwIfActionIsNotAllowedAsync(action, ctx.db, dbIntegrations, ctx.session);
|
await throwIfActionIsNotAllowedAsync(action, ctx.db, dbIntegrations, ctx.session);
|
||||||
|
}
|
||||||
|
|
||||||
return next({
|
return next({
|
||||||
ctx: {
|
ctx: {
|
||||||
integrations: dbIntegrations.map(
|
integrations: dbIntegrations.map(
|
||||||
({ secrets, kind, items: _ignore1, groupPermissions: _ignore2, userPermissions: _ignore3, ...rest }) => ({
|
({ secrets, kind, items: _ignore1, groupPermissions: _ignore2, userPermissions: _ignore3, ...rest }) => ({
|
||||||
...rest,
|
...rest,
|
||||||
kind: kind as TKind,
|
kind: kind as TKind,
|
||||||
decryptedSecrets: secrets.map((secret) => ({
|
decryptedSecrets: secrets.map((secret) => ({
|
||||||
...secret,
|
...secret,
|
||||||
value: decryptSecret(secret.value),
|
value: decryptSecret(secret.value),
|
||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { apiKeysRouter } from "./router/apiKeys";
|
import { apiKeysRouter } from "./router/apiKeys";
|
||||||
import { appRouter as innerAppRouter } from "./router/app";
|
import { appRouter as innerAppRouter } from "./router/app";
|
||||||
import { boardRouter } from "./router/board";
|
import { boardRouter } from "./router/board";
|
||||||
|
import { certificateRouter } from "./router/certificates/certificate-router";
|
||||||
import { cronJobsRouter } from "./router/cron-jobs";
|
import { cronJobsRouter } from "./router/cron-jobs";
|
||||||
import { dockerRouter } from "./router/docker/docker-router";
|
import { dockerRouter } from "./router/docker/docker-router";
|
||||||
import { groupRouter } from "./router/group";
|
import { groupRouter } from "./router/group";
|
||||||
@@ -41,6 +42,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
apiKeys: apiKeysRouter,
|
apiKeys: apiKeysRouter,
|
||||||
media: mediaRouter,
|
media: mediaRouter,
|
||||||
updateChecker: updateCheckerRouter,
|
updateChecker: updateCheckerRouter,
|
||||||
|
certificates: certificateRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
|||||||
@@ -3,13 +3,36 @@ import { TRPCError } from "@trpc/server";
|
|||||||
import { asc, createId, eq, inArray, like } from "@homarr/db";
|
import { asc, createId, eq, inArray, like } from "@homarr/db";
|
||||||
import { apps } from "@homarr/db/schema";
|
import { apps } from "@homarr/db/schema";
|
||||||
import { selectAppSchema } from "@homarr/db/validationSchemas";
|
import { selectAppSchema } from "@homarr/db/validationSchemas";
|
||||||
|
import { getIconForName } from "@homarr/icons";
|
||||||
import { validation, z } from "@homarr/validation";
|
import { validation, z } from "@homarr/validation";
|
||||||
|
|
||||||
import { convertIntersectionToZodObject } from "../schema-merger";
|
import { convertIntersectionToZodObject } from "../schema-merger";
|
||||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
|
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
|
||||||
import { canUserSeeAppAsync } from "./app/app-access-control";
|
import { canUserSeeAppAsync } from "./app/app-access-control";
|
||||||
|
|
||||||
|
const defaultIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg";
|
||||||
|
|
||||||
export const appRouter = createTRPCRouter({
|
export const appRouter = createTRPCRouter({
|
||||||
|
getPaginated: protectedProcedure
|
||||||
|
.input(validation.common.paginated)
|
||||||
|
.output(z.object({ items: z.array(selectAppSchema), totalCount: z.number() }))
|
||||||
|
.meta({ openapi: { method: "GET", path: "/api/apps/paginated", tags: ["apps"], protect: true } })
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
const whereQuery = input.search ? like(apps.name, `%${input.search.trim()}%`) : undefined;
|
||||||
|
const totalCount = await ctx.db.$count(apps, whereQuery);
|
||||||
|
|
||||||
|
const dbApps = await ctx.db.query.apps.findMany({
|
||||||
|
limit: input.pageSize,
|
||||||
|
offset: (input.page - 1) * input.pageSize,
|
||||||
|
where: whereQuery,
|
||||||
|
orderBy: asc(apps.name),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: dbApps,
|
||||||
|
totalCount,
|
||||||
|
};
|
||||||
|
}),
|
||||||
all: protectedProcedure
|
all: protectedProcedure
|
||||||
.input(z.void())
|
.input(z.void())
|
||||||
.output(z.array(selectAppSchema))
|
.output(z.array(selectAppSchema))
|
||||||
@@ -98,6 +121,21 @@ export const appRouter = createTRPCRouter({
|
|||||||
href: input.href,
|
href: input.href,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
createMany: permissionRequiredProcedure
|
||||||
|
.requiresPermission("app-create")
|
||||||
|
.input(validation.app.createMany)
|
||||||
|
.output(z.void())
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
await ctx.db.insert(apps).values(
|
||||||
|
input.map((app) => ({
|
||||||
|
id: createId(),
|
||||||
|
name: app.name,
|
||||||
|
description: app.description,
|
||||||
|
iconUrl: app.iconUrl ?? getIconForName(ctx.db, app.name).sync()?.url ?? defaultIcon,
|
||||||
|
href: app.href,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}),
|
||||||
update: permissionRequiredProcedure
|
update: permissionRequiredProcedure
|
||||||
.requiresPermission("app-modify-all")
|
.requiresPermission("app-modify-all")
|
||||||
.input(convertIntersectionToZodObject(validation.app.edit))
|
.input(convertIntersectionToZodObject(validation.app.edit))
|
||||||
|
|||||||
+589
-186
@@ -2,8 +2,9 @@ import { TRPCError } from "@trpc/server";
|
|||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
|
|
||||||
import { constructBoardPermissions } from "@homarr/auth/shared";
|
import { constructBoardPermissions } from "@homarr/auth/shared";
|
||||||
import type { Database, SQL } from "@homarr/db";
|
import type { DeviceType } from "@homarr/common/server";
|
||||||
import { and, createId, eq, inArray, like, or } from "@homarr/db";
|
import type { Database, InferInsertModel, InferSelectModel, SQL } from "@homarr/db";
|
||||||
|
import { and, createId, eq, handleTransactionsAsync, inArray, like, or } from "@homarr/db";
|
||||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||||
import {
|
import {
|
||||||
boardGroupPermissions,
|
boardGroupPermissions,
|
||||||
@@ -11,7 +12,9 @@ import {
|
|||||||
boardUserPermissions,
|
boardUserPermissions,
|
||||||
groupMembers,
|
groupMembers,
|
||||||
groupPermissions,
|
groupPermissions,
|
||||||
|
integrationGroupPermissions,
|
||||||
integrationItems,
|
integrationItems,
|
||||||
|
integrationUserPermissions,
|
||||||
items,
|
items,
|
||||||
sections,
|
sections,
|
||||||
users,
|
users,
|
||||||
@@ -119,6 +122,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
return dbBoards.map((board) => ({
|
return dbBoards.map((board) => ({
|
||||||
...board,
|
...board,
|
||||||
isHome: currentUserWhenPresent?.homeBoardId === board.id,
|
isHome: currentUserWhenPresent?.homeBoardId === board.id,
|
||||||
|
isMobileHome: currentUserWhenPresent?.mobileHomeBoardId === board.id,
|
||||||
}));
|
}));
|
||||||
}),
|
}),
|
||||||
search: publicProcedure
|
search: publicProcedure
|
||||||
@@ -192,6 +196,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
logoImageUrl: board.logoImageUrl,
|
logoImageUrl: board.logoImageUrl,
|
||||||
permissions: constructBoardPermissions(board, ctx.session),
|
permissions: constructBoardPermissions(board, ctx.session),
|
||||||
isHome: currentUserWhenPresent?.homeBoardId === board.id,
|
isHome: currentUserWhenPresent?.homeBoardId === board.id,
|
||||||
|
isMobileHome: currentUserWhenPresent?.mobileHomeBoardId === board.id,
|
||||||
}));
|
}));
|
||||||
}),
|
}),
|
||||||
createBoard: permissionRequiredProcedure
|
createBoard: permissionRequiredProcedure
|
||||||
@@ -199,21 +204,180 @@ export const boardRouter = createTRPCRouter({
|
|||||||
.input(validation.board.create)
|
.input(validation.board.create)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const boardId = createId();
|
const boardId = createId();
|
||||||
await ctx.db.transaction(async (transaction) => {
|
await handleTransactionsAsync(ctx.db, {
|
||||||
await transaction.insert(boards).values({
|
async handleAsync(db, schema) {
|
||||||
id: boardId,
|
await db.transaction(async (transaction) => {
|
||||||
name: input.name,
|
await transaction.insert(schema.boards).values({
|
||||||
isPublic: input.isPublic,
|
id: boardId,
|
||||||
columnCount: input.columnCount,
|
name: input.name,
|
||||||
creatorId: ctx.session.user.id,
|
isPublic: input.isPublic,
|
||||||
});
|
columnCount: input.columnCount,
|
||||||
await transaction.insert(sections).values({
|
creatorId: ctx.session.user.id,
|
||||||
id: createId(),
|
});
|
||||||
kind: "empty",
|
await transaction.insert(schema.sections).values({
|
||||||
xOffset: 0,
|
id: createId(),
|
||||||
yOffset: 0,
|
kind: "empty",
|
||||||
boardId,
|
xOffset: 0,
|
||||||
|
yOffset: 0,
|
||||||
|
boardId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
handleSync(db) {
|
||||||
|
db.transaction((transaction) => {
|
||||||
|
transaction
|
||||||
|
.insert(boards)
|
||||||
|
.values({
|
||||||
|
id: boardId,
|
||||||
|
name: input.name,
|
||||||
|
isPublic: input.isPublic,
|
||||||
|
columnCount: input.columnCount,
|
||||||
|
creatorId: ctx.session.user.id,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
transaction
|
||||||
|
.insert(sections)
|
||||||
|
.values({
|
||||||
|
id: createId(),
|
||||||
|
kind: "empty",
|
||||||
|
xOffset: 0,
|
||||||
|
yOffset: 0,
|
||||||
|
boardId,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
duplicateBoard: permissionRequiredProcedure
|
||||||
|
.requiresPermission("board-create")
|
||||||
|
.input(validation.board.duplicate)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
|
||||||
|
await noBoardWithSimilarNameAsync(ctx.db, input.name);
|
||||||
|
|
||||||
|
const board = await ctx.db.query.boards.findFirst({
|
||||||
|
where: eq(boards.id, input.id),
|
||||||
|
with: {
|
||||||
|
sections: {
|
||||||
|
with: {
|
||||||
|
items: {
|
||||||
|
with: {
|
||||||
|
integrations: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!board) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Board not found",
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sections: boardSections, ...boardProps } = board;
|
||||||
|
|
||||||
|
const newBoardId = createId();
|
||||||
|
const sectionMap = new Map<string, string>(boardSections.map((section) => [section.id, createId()]));
|
||||||
|
const sectionsToInsert: InferInsertModel<typeof sections>[] = boardSections.map(({ items: _, ...section }) => ({
|
||||||
|
...section,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
id: sectionMap.get(section.id)!,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
parentSectionId: section.parentSectionId ? sectionMap.get(section.parentSectionId)! : null,
|
||||||
|
boardId: newBoardId,
|
||||||
|
}));
|
||||||
|
const flatItems = boardSections.flatMap((section) => section.items);
|
||||||
|
const itemMap = new Map<string, string>(flatItems.map((item) => [item.id, createId()]));
|
||||||
|
const itemsToInsert: InferInsertModel<typeof items>[] = flatItems.map(({ integrations: _, ...item }) => ({
|
||||||
|
...item,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
id: itemMap.get(item.id)!,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
sectionId: sectionMap.get(item.sectionId)!,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Creates a list with all integration ids the user has access to
|
||||||
|
const hasAccessForAll = ctx.session.user.permissions.includes("integration-use-all");
|
||||||
|
const integrationIdsWithAccess = hasAccessForAll
|
||||||
|
? []
|
||||||
|
: await ctx.db
|
||||||
|
.selectDistinct({
|
||||||
|
id: integrationGroupPermissions.integrationId,
|
||||||
|
})
|
||||||
|
.from(integrationGroupPermissions)
|
||||||
|
.leftJoin(groupMembers, eq(integrationGroupPermissions.groupId, groupMembers.groupId))
|
||||||
|
.where(eq(groupMembers.userId, ctx.session.user.id))
|
||||||
|
.union(
|
||||||
|
ctx.db
|
||||||
|
.selectDistinct({ id: integrationUserPermissions.integrationId })
|
||||||
|
.from(integrationUserPermissions)
|
||||||
|
.where(eq(integrationUserPermissions.userId, ctx.session.user.id)),
|
||||||
|
)
|
||||||
|
.then((result) => result.map((row) => row.id));
|
||||||
|
|
||||||
|
const itemIntegrationsToInsert = flatItems.flatMap((item) =>
|
||||||
|
item.integrations
|
||||||
|
// Restrict integrations to only those the user has access to
|
||||||
|
.filter(({ integrationId }) => integrationIdsWithAccess.includes(integrationId) || hasAccessForAll)
|
||||||
|
.map((integration) => ({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
itemId: itemMap.get(item.id)!,
|
||||||
|
integrationId: integration.integrationId,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
await handleTransactionsAsync(ctx.db, {
|
||||||
|
async handleAsync(db, schema) {
|
||||||
|
await db.transaction(async (transaction) => {
|
||||||
|
transaction.insert(schema.boards).values({
|
||||||
|
...boardProps,
|
||||||
|
id: newBoardId,
|
||||||
|
name: input.name,
|
||||||
|
creatorId: ctx.session.user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sectionsToInsert.length > 0) {
|
||||||
|
await transaction.insert(schema.sections).values(sectionsToInsert);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemsToInsert.length > 0) {
|
||||||
|
await transaction.insert(schema.items).values(itemsToInsert);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemIntegrationsToInsert.length > 0) {
|
||||||
|
await transaction.insert(schema.integrationItems).values(itemIntegrationsToInsert);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
handleSync(db) {
|
||||||
|
db.transaction((transaction) => {
|
||||||
|
transaction
|
||||||
|
.insert(boards)
|
||||||
|
.values({
|
||||||
|
...boardProps,
|
||||||
|
id: newBoardId,
|
||||||
|
name: input.name,
|
||||||
|
creatorId: ctx.session.user.id,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
if (sectionsToInsert.length > 0) {
|
||||||
|
transaction.insert(sections).values(sectionsToInsert).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemsToInsert.length > 0) {
|
||||||
|
transaction.insert(items).values(itemsToInsert).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemIntegrationsToInsert.length > 0) {
|
||||||
|
transaction.insert(integrationItems).values(itemIntegrationsToInsert).run();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
renameBoard: protectedProcedure.input(validation.board.rename).mutation(async ({ ctx, input }) => {
|
renameBoard: protectedProcedure.input(validation.board.rename).mutation(async ({ ctx, input }) => {
|
||||||
@@ -229,7 +393,10 @@ export const boardRouter = createTRPCRouter({
|
|||||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
|
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
|
||||||
const boardSettings = await getServerSettingByKeyAsync(ctx.db, "board");
|
const boardSettings = await getServerSettingByKeyAsync(ctx.db, "board");
|
||||||
|
|
||||||
if (input.visibility !== "public" && boardSettings.homeBoardId === input.id) {
|
if (
|
||||||
|
input.visibility !== "public" &&
|
||||||
|
(boardSettings.homeBoardId === input.id || boardSettings.mobileHomeBoardId === input.id)
|
||||||
|
) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
message: "Cannot make home board private",
|
message: "Cannot make home board private",
|
||||||
@@ -251,30 +418,30 @@ export const boardRouter = createTRPCRouter({
|
|||||||
|
|
||||||
await ctx.db.update(users).set({ homeBoardId: input.id }).where(eq(users.id, ctx.session.user.id));
|
await ctx.db.update(users).set({ homeBoardId: input.id }).where(eq(users.id, ctx.session.user.id));
|
||||||
}),
|
}),
|
||||||
|
setMobileHomeBoard: protectedProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
|
||||||
|
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
|
||||||
|
|
||||||
|
await ctx.db.update(users).set({ mobileHomeBoardId: input.id }).where(eq(users.id, ctx.session.user.id));
|
||||||
|
}),
|
||||||
getHomeBoard: publicProcedure.query(async ({ ctx }) => {
|
getHomeBoard: publicProcedure.query(async ({ ctx }) => {
|
||||||
const userId = ctx.session?.user.id;
|
const userId = ctx.session?.user.id;
|
||||||
const user = userId
|
const user = userId
|
||||||
? await ctx.db.query.users.findFirst({
|
? ((await ctx.db.query.users.findFirst({
|
||||||
where: eq(users.id, userId),
|
where: eq(users.id, userId),
|
||||||
})
|
})) ?? null)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// 1. user home board, 2. home board, 3. not found
|
const homeBoardId = await getHomeIdBoardAsync(ctx.db, user, ctx.deviceType);
|
||||||
let boardWhere: SQL<unknown> | null = null;
|
|
||||||
if (user?.homeBoardId) {
|
|
||||||
boardWhere = eq(boards.id, user.homeBoardId);
|
|
||||||
} else {
|
|
||||||
const boardSettings = await getServerSettingByKeyAsync(ctx.db, "board");
|
|
||||||
boardWhere = boardSettings.homeBoardId ? eq(boards.id, boardSettings.homeBoardId) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!boardWhere) {
|
if (!homeBoardId) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
message: "No home board found",
|
message: "No home board found",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const boardWhere = eq(boards.id, homeBoardId);
|
||||||
|
|
||||||
await throwIfActionForbiddenAsync(ctx, boardWhere, "view");
|
await throwIfActionForbiddenAsync(ctx, boardWhere, "view");
|
||||||
|
|
||||||
return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
|
return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
|
||||||
@@ -321,148 +488,313 @@ export const boardRouter = createTRPCRouter({
|
|||||||
saveBoard: protectedProcedure.input(validation.board.save).mutation(async ({ input, ctx }) => {
|
saveBoard: protectedProcedure.input(validation.board.save).mutation(async ({ input, ctx }) => {
|
||||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
|
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
|
||||||
|
|
||||||
await ctx.db.transaction(async (transaction) => {
|
const dbBoard = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
|
||||||
const dbBoard = await getFullBoardWithWhereAsync(transaction, eq(boards.id, input.id), ctx.session.user.id);
|
|
||||||
|
|
||||||
const addedSections = filterAddedItems(input.sections, dbBoard.sections);
|
await handleTransactionsAsync(ctx.db, {
|
||||||
|
async handleAsync(db, schema) {
|
||||||
|
await db.transaction(async (transaction) => {
|
||||||
|
const addedSections = filterAddedItems(input.sections, dbBoard.sections);
|
||||||
|
|
||||||
if (addedSections.length > 0) {
|
if (addedSections.length > 0) {
|
||||||
await transaction.insert(sections).values(
|
await transaction.insert(schema.sections).values(
|
||||||
addedSections.map((section) => ({
|
addedSections.map((section) => ({
|
||||||
id: section.id,
|
id: section.id,
|
||||||
kind: section.kind,
|
kind: section.kind,
|
||||||
yOffset: section.yOffset,
|
yOffset: section.yOffset,
|
||||||
xOffset: section.kind === "dynamic" ? section.xOffset : 0,
|
xOffset: section.kind === "dynamic" ? section.xOffset : 0,
|
||||||
height: "height" in section ? section.height : null,
|
height: "height" in section ? section.height : null,
|
||||||
width: "width" in section ? section.width : null,
|
width: "width" in section ? section.width : null,
|
||||||
parentSectionId: "parentSectionId" in section ? section.parentSectionId : null,
|
parentSectionId: "parentSectionId" in section ? section.parentSectionId : null,
|
||||||
name: "name" in section ? section.name : null,
|
name: "name" in section ? section.name : null,
|
||||||
boardId: dbBoard.id,
|
boardId: dbBoard.id,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputItems = input.sections.flatMap((section) =>
|
const inputItems = input.sections.flatMap((section) =>
|
||||||
section.items.map((item) => ({ ...item, sectionId: section.id })),
|
section.items.map((item) => ({ ...item, sectionId: section.id })),
|
||||||
);
|
);
|
||||||
const dbItems = dbBoard.sections.flatMap((section) =>
|
const dbItems = dbBoard.sections.flatMap((section) =>
|
||||||
section.items.map((item) => ({ ...item, sectionId: section.id })),
|
section.items.map((item) => ({ ...item, sectionId: section.id })),
|
||||||
);
|
|
||||||
|
|
||||||
const addedItems = filterAddedItems(inputItems, dbItems);
|
|
||||||
|
|
||||||
if (addedItems.length > 0) {
|
|
||||||
await transaction.insert(items).values(
|
|
||||||
addedItems.map((item) => ({
|
|
||||||
id: item.id,
|
|
||||||
kind: item.kind,
|
|
||||||
height: item.height,
|
|
||||||
width: item.width,
|
|
||||||
xOffset: item.xOffset,
|
|
||||||
yOffset: item.yOffset,
|
|
||||||
options: superjson.stringify(item.options),
|
|
||||||
advancedOptions: superjson.stringify(item.advancedOptions),
|
|
||||||
sectionId: item.sectionId,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputIntegrationRelations = inputItems.flatMap(({ integrationIds, id: itemId }) =>
|
|
||||||
integrationIds.map((integrationId) => ({
|
|
||||||
integrationId,
|
|
||||||
itemId,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
const dbIntegrationRelations = dbItems.flatMap(({ integrationIds, id: itemId }) =>
|
|
||||||
integrationIds.map((integrationId) => ({
|
|
||||||
integrationId,
|
|
||||||
itemId,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
const addedIntegrationRelations = inputIntegrationRelations.filter(
|
|
||||||
(inputRelation) =>
|
|
||||||
!dbIntegrationRelations.some(
|
|
||||||
(dbRelation) =>
|
|
||||||
dbRelation.itemId === inputRelation.itemId && dbRelation.integrationId === inputRelation.integrationId,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (addedIntegrationRelations.length > 0) {
|
|
||||||
await transaction.insert(integrationItems).values(
|
|
||||||
addedIntegrationRelations.map((relation) => ({
|
|
||||||
itemId: relation.itemId,
|
|
||||||
integrationId: relation.integrationId,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedItems = filterUpdatedItems(inputItems, dbItems);
|
|
||||||
|
|
||||||
for (const item of updatedItems) {
|
|
||||||
await transaction
|
|
||||||
.update(items)
|
|
||||||
.set({
|
|
||||||
kind: item.kind,
|
|
||||||
height: item.height,
|
|
||||||
width: item.width,
|
|
||||||
xOffset: item.xOffset,
|
|
||||||
yOffset: item.yOffset,
|
|
||||||
options: superjson.stringify(item.options),
|
|
||||||
advancedOptions: superjson.stringify(item.advancedOptions),
|
|
||||||
sectionId: item.sectionId,
|
|
||||||
})
|
|
||||||
.where(eq(items.id, item.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedSections = filterUpdatedItems(input.sections, dbBoard.sections);
|
|
||||||
|
|
||||||
for (const section of updatedSections) {
|
|
||||||
const prev = dbBoard.sections.find((dbSection) => dbSection.id === section.id);
|
|
||||||
await transaction
|
|
||||||
.update(sections)
|
|
||||||
.set({
|
|
||||||
yOffset: section.yOffset,
|
|
||||||
xOffset: section.xOffset,
|
|
||||||
height: prev?.kind === "dynamic" && "height" in section ? section.height : null,
|
|
||||||
width: prev?.kind === "dynamic" && "width" in section ? section.width : null,
|
|
||||||
parentSectionId: prev?.kind === "dynamic" && "parentSectionId" in section ? section.parentSectionId : null,
|
|
||||||
name: prev?.kind === "category" && "name" in section ? section.name : null,
|
|
||||||
})
|
|
||||||
.where(eq(sections.id, section.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
const removedIntegrationRelations = dbIntegrationRelations.filter(
|
|
||||||
(dbRelation) =>
|
|
||||||
!inputIntegrationRelations.some(
|
|
||||||
(inputRelation) =>
|
|
||||||
dbRelation.itemId === inputRelation.itemId && dbRelation.integrationId === inputRelation.integrationId,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const relation of removedIntegrationRelations) {
|
|
||||||
await transaction
|
|
||||||
.delete(integrationItems)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(integrationItems.itemId, relation.itemId),
|
|
||||||
eq(integrationItems.integrationId, relation.integrationId),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
const removedItems = filterRemovedItems(inputItems, dbItems);
|
const addedItems = filterAddedItems(inputItems, dbItems);
|
||||||
|
|
||||||
const itemIds = removedItems.map((item) => item.id);
|
if (addedItems.length > 0) {
|
||||||
if (itemIds.length > 0) {
|
await transaction.insert(schema.items).values(
|
||||||
await transaction.delete(items).where(inArray(items.id, itemIds));
|
addedItems.map((item) => ({
|
||||||
}
|
id: item.id,
|
||||||
|
kind: item.kind,
|
||||||
|
height: item.height,
|
||||||
|
width: item.width,
|
||||||
|
xOffset: item.xOffset,
|
||||||
|
yOffset: item.yOffset,
|
||||||
|
options: superjson.stringify(item.options),
|
||||||
|
advancedOptions: superjson.stringify(item.advancedOptions),
|
||||||
|
sectionId: item.sectionId,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const removedSections = filterRemovedItems(input.sections, dbBoard.sections);
|
const inputIntegrationRelations = inputItems.flatMap(({ integrationIds, id: itemId }) =>
|
||||||
const sectionIds = removedSections.map((section) => section.id);
|
integrationIds.map((integrationId) => ({
|
||||||
|
integrationId,
|
||||||
|
itemId,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const dbIntegrationRelations = dbItems.flatMap(({ integrationIds, id: itemId }) =>
|
||||||
|
integrationIds.map((integrationId) => ({
|
||||||
|
integrationId,
|
||||||
|
itemId,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const addedIntegrationRelations = inputIntegrationRelations.filter(
|
||||||
|
(inputRelation) =>
|
||||||
|
!dbIntegrationRelations.some(
|
||||||
|
(dbRelation) =>
|
||||||
|
dbRelation.itemId === inputRelation.itemId &&
|
||||||
|
dbRelation.integrationId === inputRelation.integrationId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
if (sectionIds.length > 0) {
|
if (addedIntegrationRelations.length > 0) {
|
||||||
await transaction.delete(sections).where(inArray(sections.id, sectionIds));
|
await transaction.insert(schema.integrationItems).values(
|
||||||
}
|
addedIntegrationRelations.map((relation) => ({
|
||||||
|
itemId: relation.itemId,
|
||||||
|
integrationId: relation.integrationId,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedItems = filterUpdatedItems(inputItems, dbItems);
|
||||||
|
|
||||||
|
for (const item of updatedItems) {
|
||||||
|
await transaction
|
||||||
|
.update(schema.items)
|
||||||
|
.set({
|
||||||
|
kind: item.kind,
|
||||||
|
height: item.height,
|
||||||
|
width: item.width,
|
||||||
|
xOffset: item.xOffset,
|
||||||
|
yOffset: item.yOffset,
|
||||||
|
options: superjson.stringify(item.options),
|
||||||
|
advancedOptions: superjson.stringify(item.advancedOptions),
|
||||||
|
sectionId: item.sectionId,
|
||||||
|
})
|
||||||
|
.where(eq(items.id, item.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedSections = filterUpdatedItems(input.sections, dbBoard.sections);
|
||||||
|
|
||||||
|
for (const section of updatedSections) {
|
||||||
|
const prev = dbBoard.sections.find((dbSection) => dbSection.id === section.id);
|
||||||
|
await transaction
|
||||||
|
.update(schema.sections)
|
||||||
|
.set({
|
||||||
|
yOffset: section.yOffset,
|
||||||
|
xOffset: section.xOffset,
|
||||||
|
height: prev?.kind === "dynamic" && "height" in section ? section.height : null,
|
||||||
|
width: prev?.kind === "dynamic" && "width" in section ? section.width : null,
|
||||||
|
parentSectionId:
|
||||||
|
prev?.kind === "dynamic" && "parentSectionId" in section ? section.parentSectionId : null,
|
||||||
|
name: prev?.kind === "category" && "name" in section ? section.name : null,
|
||||||
|
})
|
||||||
|
.where(eq(sections.id, section.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
const removedIntegrationRelations = dbIntegrationRelations.filter(
|
||||||
|
(dbRelation) =>
|
||||||
|
!inputIntegrationRelations.some(
|
||||||
|
(inputRelation) =>
|
||||||
|
dbRelation.itemId === inputRelation.itemId &&
|
||||||
|
dbRelation.integrationId === inputRelation.integrationId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const relation of removedIntegrationRelations) {
|
||||||
|
await transaction
|
||||||
|
.delete(schema.integrationItems)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(integrationItems.itemId, relation.itemId),
|
||||||
|
eq(integrationItems.integrationId, relation.integrationId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const removedItems = filterRemovedItems(inputItems, dbItems);
|
||||||
|
|
||||||
|
const itemIds = removedItems.map((item) => item.id);
|
||||||
|
if (itemIds.length > 0) {
|
||||||
|
await transaction.delete(schema.items).where(inArray(items.id, itemIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
const removedSections = filterRemovedItems(input.sections, dbBoard.sections);
|
||||||
|
const sectionIds = removedSections.map((section) => section.id);
|
||||||
|
|
||||||
|
if (sectionIds.length > 0) {
|
||||||
|
await transaction.delete(schema.sections).where(inArray(sections.id, sectionIds));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
handleSync(db) {
|
||||||
|
db.transaction((transaction) => {
|
||||||
|
const addedSections = filterAddedItems(input.sections, dbBoard.sections);
|
||||||
|
|
||||||
|
if (addedSections.length > 0) {
|
||||||
|
transaction
|
||||||
|
.insert(sections)
|
||||||
|
.values(
|
||||||
|
addedSections.map((section) => ({
|
||||||
|
id: section.id,
|
||||||
|
kind: section.kind,
|
||||||
|
yOffset: section.yOffset,
|
||||||
|
xOffset: section.kind === "dynamic" ? section.xOffset : 0,
|
||||||
|
height: "height" in section ? section.height : null,
|
||||||
|
width: "width" in section ? section.width : null,
|
||||||
|
parentSectionId: "parentSectionId" in section ? section.parentSectionId : null,
|
||||||
|
name: "name" in section ? section.name : null,
|
||||||
|
boardId: dbBoard.id,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputItems = input.sections.flatMap((section) =>
|
||||||
|
section.items.map((item) => ({ ...item, sectionId: section.id })),
|
||||||
|
);
|
||||||
|
const dbItems = dbBoard.sections.flatMap((section) =>
|
||||||
|
section.items.map((item) => ({ ...item, sectionId: section.id })),
|
||||||
|
);
|
||||||
|
|
||||||
|
const addedItems = filterAddedItems(inputItems, dbItems);
|
||||||
|
|
||||||
|
if (addedItems.length > 0) {
|
||||||
|
transaction
|
||||||
|
.insert(items)
|
||||||
|
.values(
|
||||||
|
addedItems.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
kind: item.kind,
|
||||||
|
height: item.height,
|
||||||
|
width: item.width,
|
||||||
|
xOffset: item.xOffset,
|
||||||
|
yOffset: item.yOffset,
|
||||||
|
options: superjson.stringify(item.options),
|
||||||
|
advancedOptions: superjson.stringify(item.advancedOptions),
|
||||||
|
sectionId: item.sectionId,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputIntegrationRelations = inputItems.flatMap(({ integrationIds, id: itemId }) =>
|
||||||
|
integrationIds.map((integrationId) => ({
|
||||||
|
integrationId,
|
||||||
|
itemId,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const dbIntegrationRelations = dbItems.flatMap(({ integrationIds, id: itemId }) =>
|
||||||
|
integrationIds.map((integrationId) => ({
|
||||||
|
integrationId,
|
||||||
|
itemId,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const addedIntegrationRelations = inputIntegrationRelations.filter(
|
||||||
|
(inputRelation) =>
|
||||||
|
!dbIntegrationRelations.some(
|
||||||
|
(dbRelation) =>
|
||||||
|
dbRelation.itemId === inputRelation.itemId &&
|
||||||
|
dbRelation.integrationId === inputRelation.integrationId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (addedIntegrationRelations.length > 0) {
|
||||||
|
transaction
|
||||||
|
.insert(integrationItems)
|
||||||
|
.values(
|
||||||
|
addedIntegrationRelations.map((relation) => ({
|
||||||
|
itemId: relation.itemId,
|
||||||
|
integrationId: relation.integrationId,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedItems = filterUpdatedItems(inputItems, dbItems);
|
||||||
|
|
||||||
|
for (const item of updatedItems) {
|
||||||
|
transaction
|
||||||
|
.update(items)
|
||||||
|
.set({
|
||||||
|
kind: item.kind,
|
||||||
|
height: item.height,
|
||||||
|
width: item.width,
|
||||||
|
xOffset: item.xOffset,
|
||||||
|
yOffset: item.yOffset,
|
||||||
|
options: superjson.stringify(item.options),
|
||||||
|
advancedOptions: superjson.stringify(item.advancedOptions),
|
||||||
|
sectionId: item.sectionId,
|
||||||
|
})
|
||||||
|
.where(eq(items.id, item.id))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedSections = filterUpdatedItems(input.sections, dbBoard.sections);
|
||||||
|
|
||||||
|
for (const section of updatedSections) {
|
||||||
|
const prev = dbBoard.sections.find((dbSection) => dbSection.id === section.id);
|
||||||
|
transaction
|
||||||
|
.update(sections)
|
||||||
|
.set({
|
||||||
|
yOffset: section.yOffset,
|
||||||
|
xOffset: section.xOffset,
|
||||||
|
height: prev?.kind === "dynamic" && "height" in section ? section.height : null,
|
||||||
|
width: prev?.kind === "dynamic" && "width" in section ? section.width : null,
|
||||||
|
parentSectionId:
|
||||||
|
prev?.kind === "dynamic" && "parentSectionId" in section ? section.parentSectionId : null,
|
||||||
|
name: prev?.kind === "category" && "name" in section ? section.name : null,
|
||||||
|
})
|
||||||
|
.where(eq(sections.id, section.id))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
const removedIntegrationRelations = dbIntegrationRelations.filter(
|
||||||
|
(dbRelation) =>
|
||||||
|
!inputIntegrationRelations.some(
|
||||||
|
(inputRelation) =>
|
||||||
|
dbRelation.itemId === inputRelation.itemId &&
|
||||||
|
dbRelation.integrationId === inputRelation.integrationId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const relation of removedIntegrationRelations) {
|
||||||
|
transaction
|
||||||
|
.delete(integrationItems)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(integrationItems.itemId, relation.itemId),
|
||||||
|
eq(integrationItems.integrationId, relation.integrationId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
const removedItems = filterRemovedItems(inputItems, dbItems);
|
||||||
|
|
||||||
|
const itemIds = removedItems.map((item) => item.id);
|
||||||
|
if (itemIds.length > 0) {
|
||||||
|
transaction.delete(items).where(inArray(items.id, itemIds)).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
const removedSections = filterRemovedItems(input.sections, dbBoard.sections);
|
||||||
|
const sectionIds = removedSections.map((section) => section.id);
|
||||||
|
|
||||||
|
if (sectionIds.length > 0) {
|
||||||
|
transaction.delete(sections).where(inArray(sections.id, sectionIds)).run();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -542,18 +874,42 @@ export const boardRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.entityId), "full");
|
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.entityId), "full");
|
||||||
|
|
||||||
await ctx.db.transaction(async (transaction) => {
|
await handleTransactionsAsync(ctx.db, {
|
||||||
await transaction.delete(boardUserPermissions).where(eq(boardUserPermissions.boardId, input.entityId));
|
async handleAsync(db, schema) {
|
||||||
if (input.permissions.length === 0) {
|
await db.transaction(async (transaction) => {
|
||||||
return;
|
await transaction
|
||||||
}
|
.delete(schema.boardUserPermissions)
|
||||||
await transaction.insert(boardUserPermissions).values(
|
.where(eq(boardUserPermissions.boardId, input.entityId));
|
||||||
input.permissions.map((permission) => ({
|
if (input.permissions.length === 0) {
|
||||||
userId: permission.principalId,
|
return;
|
||||||
permission: permission.permission,
|
}
|
||||||
boardId: input.entityId,
|
await transaction.insert(schema.boardUserPermissions).values(
|
||||||
})),
|
input.permissions.map((permission) => ({
|
||||||
);
|
userId: permission.principalId,
|
||||||
|
permission: permission.permission,
|
||||||
|
boardId: input.entityId,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
handleSync(db) {
|
||||||
|
db.transaction((transaction) => {
|
||||||
|
transaction.delete(boardUserPermissions).where(eq(boardUserPermissions.boardId, input.entityId)).run();
|
||||||
|
if (input.permissions.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
transaction
|
||||||
|
.insert(boardUserPermissions)
|
||||||
|
.values(
|
||||||
|
input.permissions.map((permission) => ({
|
||||||
|
userId: permission.principalId,
|
||||||
|
permission: permission.permission,
|
||||||
|
boardId: input.entityId,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
saveGroupBoardPermissions: protectedProcedure
|
saveGroupBoardPermissions: protectedProcedure
|
||||||
@@ -561,18 +917,42 @@ export const boardRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.entityId), "full");
|
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.entityId), "full");
|
||||||
|
|
||||||
await ctx.db.transaction(async (transaction) => {
|
await handleTransactionsAsync(ctx.db, {
|
||||||
await transaction.delete(boardGroupPermissions).where(eq(boardGroupPermissions.boardId, input.entityId));
|
async handleAsync(db, schema) {
|
||||||
if (input.permissions.length === 0) {
|
await db.transaction(async (transaction) => {
|
||||||
return;
|
await transaction
|
||||||
}
|
.delete(schema.boardGroupPermissions)
|
||||||
await transaction.insert(boardGroupPermissions).values(
|
.where(eq(boardGroupPermissions.boardId, input.entityId));
|
||||||
input.permissions.map((permission) => ({
|
if (input.permissions.length === 0) {
|
||||||
groupId: permission.principalId,
|
return;
|
||||||
permission: permission.permission,
|
}
|
||||||
boardId: input.entityId,
|
await transaction.insert(schema.boardGroupPermissions).values(
|
||||||
})),
|
input.permissions.map((permission) => ({
|
||||||
);
|
groupId: permission.principalId,
|
||||||
|
permission: permission.permission,
|
||||||
|
boardId: input.entityId,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
handleSync(db) {
|
||||||
|
db.transaction((transaction) => {
|
||||||
|
transaction.delete(boardGroupPermissions).where(eq(boardGroupPermissions.boardId, input.entityId)).run();
|
||||||
|
if (input.permissions.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
transaction
|
||||||
|
.insert(boardGroupPermissions)
|
||||||
|
.values(
|
||||||
|
input.permissions.map((permission) => ({
|
||||||
|
groupId: permission.principalId,
|
||||||
|
permission: permission.permission,
|
||||||
|
boardId: input.entityId,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
importOldmarrConfig: permissionRequiredProcedure
|
importOldmarrConfig: permissionRequiredProcedure
|
||||||
@@ -585,6 +965,29 @@ export const boardRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the home board id of the user with the given device type
|
||||||
|
* For an example of a user with deviceType = 'mobile' it would go through the following order:
|
||||||
|
* 1. user.mobileHomeBoardId
|
||||||
|
* 2. user.homeBoardId
|
||||||
|
* 3. serverSettings.mobileHomeBoardId
|
||||||
|
* 4. serverSettings.homeBoardId
|
||||||
|
* 5. show NOT_FOUND error
|
||||||
|
*/
|
||||||
|
const getHomeIdBoardAsync = async (
|
||||||
|
db: Database,
|
||||||
|
user: InferSelectModel<typeof users> | null,
|
||||||
|
deviceType: DeviceType,
|
||||||
|
) => {
|
||||||
|
const settingKey = deviceType === "mobile" ? "mobileHomeBoardId" : "homeBoardId";
|
||||||
|
if (user?.[settingKey] || user?.homeBoardId) {
|
||||||
|
return user[settingKey] ?? user.homeBoardId;
|
||||||
|
} else {
|
||||||
|
const boardSettings = await getServerSettingByKeyAsync(db, "board");
|
||||||
|
return boardSettings[settingKey] ?? boardSettings.homeBoardId;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const noBoardWithSimilarNameAsync = async (db: Database, name: string, ignoredIds: string[] = []) => {
|
const noBoardWithSimilarNameAsync = async (db: Database, name: string, ignoredIds: string[] = []) => {
|
||||||
const boards = await db.query.boards.findMany({
|
const boards = await db.query.boards.findMany({
|
||||||
columns: {
|
columns: {
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { zfd } from "zod-form-data";
|
||||||
|
|
||||||
|
import { addCustomRootCertificateAsync, removeCustomRootCertificateAsync } from "@homarr/certificates/server";
|
||||||
|
import { superRefineCertificateFile, validation } from "@homarr/validation";
|
||||||
|
|
||||||
|
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
|
||||||
|
|
||||||
|
export const certificateRouter = createTRPCRouter({
|
||||||
|
addCertificate: permissionRequiredProcedure
|
||||||
|
.requiresPermission("admin")
|
||||||
|
.input(
|
||||||
|
zfd.formData({
|
||||||
|
file: zfd.file().superRefine(superRefineCertificateFile),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const content = await input.file.text();
|
||||||
|
await addCustomRootCertificateAsync(input.file.name, content);
|
||||||
|
}),
|
||||||
|
removeCertificate: permissionRequiredProcedure
|
||||||
|
.requiresPermission("admin")
|
||||||
|
.input(z.object({ fileName: validation.certificates.validFileNameSchema }))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
await removeCustomRootCertificateAsync(input.fileName);
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -1,26 +1,24 @@
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import type Docker from "dockerode";
|
|
||||||
import type { Container } from "dockerode";
|
|
||||||
|
|
||||||
import { db, like, or } from "@homarr/db";
|
import { db, like, or } from "@homarr/db";
|
||||||
import { icons } from "@homarr/db/schema";
|
import { icons } from "@homarr/db/schema";
|
||||||
import type { DockerContainerState } from "@homarr/definitions";
|
import { DockerSingleton } from "@homarr/docker";
|
||||||
|
import type { Container, ContainerInfo, ContainerState, Docker, Port } from "@homarr/docker";
|
||||||
import { logger } from "@homarr/log";
|
import { logger } from "@homarr/log";
|
||||||
import { createCacheChannel } from "@homarr/redis";
|
import { createCacheChannel } from "@homarr/redis";
|
||||||
import { z } from "@homarr/validation";
|
import { z } from "@homarr/validation";
|
||||||
|
|
||||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
|
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
|
||||||
import { DockerSingleton } from "./docker-singleton";
|
|
||||||
|
|
||||||
const dockerCache = createCacheChannel<{
|
const dockerCache = createCacheChannel<{
|
||||||
containers: (Docker.ContainerInfo & { instance: string; iconUrl: string | null })[];
|
containers: (ContainerInfo & { instance: string; iconUrl: string | null })[];
|
||||||
}>("docker-containers", 5 * 60 * 1000);
|
}>("docker-containers", 5 * 60 * 1000);
|
||||||
|
|
||||||
export const dockerRouter = createTRPCRouter({
|
export const dockerRouter = createTRPCRouter({
|
||||||
getContainers: permissionRequiredProcedure.requiresPermission("admin").query(async () => {
|
getContainers: permissionRequiredProcedure.requiresPermission("admin").query(async () => {
|
||||||
const result = await dockerCache
|
const result = await dockerCache
|
||||||
.consumeAsync(async () => {
|
.consumeAsync(async () => {
|
||||||
const dockerInstances = DockerSingleton.getInstance();
|
const dockerInstances = DockerSingleton.getInstances();
|
||||||
const containers = await Promise.all(
|
const containers = await Promise.all(
|
||||||
// Return all the containers of all the instances into only one item
|
// Return all the containers of all the instances into only one item
|
||||||
dockerInstances.map(({ instance, host: key }) =>
|
dockerInstances.map(({ instance, host: key }) =>
|
||||||
@@ -33,8 +31,7 @@ export const dockerRouter = createTRPCRouter({
|
|||||||
),
|
),
|
||||||
).then((containers) => containers.flat());
|
).then((containers) => containers.flat());
|
||||||
|
|
||||||
const extractImage = (container: Docker.ContainerInfo) =>
|
const extractImage = (container: ContainerInfo) => container.Image.split("/").at(-1)?.split(":").at(0) ?? "";
|
||||||
container.Image.split("/").at(-1)?.split(":").at(0) ?? "";
|
|
||||||
const likeQueries = containers.map((container) => like(icons.name, `%${extractImage(container)}%`));
|
const likeQueries = containers.map((container) => like(icons.name, `%${extractImage(container)}%`));
|
||||||
const dbIcons =
|
const dbIcons =
|
||||||
likeQueries.length >= 1
|
likeQueries.length >= 1
|
||||||
@@ -151,7 +148,7 @@ const getContainerOrDefaultAsync = async (instance: Docker, id: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getContainerOrThrowAsync = async (id: string) => {
|
const getContainerOrThrowAsync = async (id: string) => {
|
||||||
const dockerInstances = DockerSingleton.getInstance();
|
const dockerInstances = DockerSingleton.getInstances();
|
||||||
const containers = await Promise.all(dockerInstances.map(({ instance }) => getContainerOrDefaultAsync(instance, id)));
|
const containers = await Promise.all(dockerInstances.map(({ instance }) => getContainerOrDefaultAsync(instance, id)));
|
||||||
const foundContainer = containers.find((container) => container) ?? null;
|
const foundContainer = containers.find((container) => container) ?? null;
|
||||||
|
|
||||||
@@ -168,21 +165,21 @@ const getContainerOrThrowAsync = async (id: string) => {
|
|||||||
interface DockerContainer {
|
interface DockerContainer {
|
||||||
name: string;
|
name: string;
|
||||||
id: string;
|
id: string;
|
||||||
state: DockerContainerState;
|
state: ContainerState;
|
||||||
image: string;
|
image: string;
|
||||||
ports: Docker.Port[];
|
ports: Port[];
|
||||||
iconUrl: string | null;
|
iconUrl: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeContainers(
|
function sanitizeContainers(
|
||||||
containers: (Docker.ContainerInfo & { instance: string; iconUrl: string | null })[],
|
containers: (ContainerInfo & { instance: string; iconUrl: string | null })[],
|
||||||
): DockerContainer[] {
|
): DockerContainer[] {
|
||||||
return containers.map((container) => {
|
return containers.map((container) => {
|
||||||
return {
|
return {
|
||||||
name: container.Names[0]?.split("/")[1] ?? "Unknown",
|
name: container.Names[0]?.split("/")[1] ?? "Unknown",
|
||||||
id: container.Id,
|
id: container.Id,
|
||||||
instance: container.instance,
|
instance: container.instance,
|
||||||
state: container.State as DockerContainerState,
|
state: container.State as ContainerState,
|
||||||
image: container.Image,
|
image: container.Image,
|
||||||
ports: container.Ports,
|
ports: container.Ports,
|
||||||
iconUrl: container.iconUrl,
|
iconUrl: container.iconUrl,
|
||||||
|
|||||||
Loaded 100 of 322 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user