Compare commits

..
Author SHA1 Message Date
Thomas 4d87f53dbf docs: update red inline confirmation screenshot 2026-08-25 09:55:38 +02:00
ajnart be096c3322 docs: add PR 6688 screenshots 2026-08-24 18:12:07 +02:00
449 changed files with 0 additions and 40942 deletions

No files matched your search

-11
View File
@@ -1,11 +0,0 @@
version = 1
[[analyzers]]
name = "javascript"
[analyzers.meta]
plugins = ["react"]
environment = ["nodejs"]
[[transformers]]
name = "prettier"
-8
View File
@@ -1,8 +0,0 @@
Dockerfile
.dockerignore
node_modules
npm-debug.log
README.md
.next
.git
dev
-33
View File
@@ -1,33 +0,0 @@
# Since .env is gitignored, you can use .env.example to build a new `.env` file when you clone the repo.
# Keep this file up-to-date when you add new variables to \`.env\`.
# This file will be committed to version control, so make sure not to have any secrets in it.
# If you are cloning this repo, create a copy of this file named `.env` and populate it with your secrets.
# This is how you can use the sqlite driver:
DB_DRIVER='better-sqlite3'
DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE'
# Those are the two ways to use the mysql2 driver:
# 1. Using the URL format:
# DB_DRIVER='mysql2'
# DB_URL='mysql://user:password@host:port/database'
# 2. Using the connection options format:
# DB_DRIVER='mysql2'
# DB_HOST='localhost'
# DB_PORT='3306'
# DB_USER='username'
# DB_PASSWORD='password'
# DB_NAME='name-of-database'
# @see https://next-auth.js.org/configuration/options#nextauth_url
AUTH_URL='http://localhost:3000'
# You can generate the secret via 'openssl rand -base64 32' on Unix
# @see https://next-auth.js.org/configuration/options#secret
AUTH_SECRET='supersecret'
TURBO_TELEMETRY_DISABLED=1
# Configure logging to use winston logger
NODE_OPTIONS='-r @homarr/log/override'
-3
View File
@@ -1,3 +0,0 @@
# These are supported funding model platforms
open_collective: homarr
-37
View File
@@ -1,37 +0,0 @@
name: 🐞 Bug Report
description: Create a bug report to help us improve
title: "bug: "
labels: ["🐞❔ unconfirmed bug"]
body:
- type: textarea
attributes:
label: Provide environment information
description: |
Run this command in your project root and paste the results in a code block:
```bash
npx envinfo --system --binaries
```
validations:
required: true
- type: textarea
attributes:
label: Describe the bug
description: A clear and concise description of the bug, as well as what you expected to happen when encountering it.
validations:
required: true
- type: input
attributes:
label: Link to reproduction
description: Please provide a link to a reproduction of the bug. Issues without a reproduction repo may be ignored.
validations:
required: true
- type: textarea
attributes:
label: To reproduce
description: Describe how to reproduce your bug. Steps, code snippets, reproduction repos etc.
validations:
required: true
- type: textarea
attributes:
label: Additional information
description: Add any other information related to the bug here, screenshots if applicable.
@@ -1,28 +0,0 @@
# This template is heavily inspired by the Next.js's template:
# See here: https://github.com/vercel/next.js/blob/canary/.github/ISSUE_TEMPLATE/3.feature_request.yml
name: 🛠 Feature Request
description: Create a feature request for the core packages
title: "feat: "
labels: ["✨ enhancement"]
body:
- type: markdown
attributes:
value: |
Thank you for taking the time to file a feature request. Please fill out this form as completely as possible.
- type: textarea
attributes:
label: Describe the feature you'd like to request
description: Please describe the feature as clear and concise as possible. Remember to add context as to why you believe this feature is needed.
validations:
required: true
- type: textarea
attributes:
label: Describe the solution you'd like to see
description: Please describe the solution you would like to see. Adding example usage is a good way to provide context.
validations:
required: true
- type: textarea
attributes:
label: Additional information
description: Add any other information related to the feature here. If your feature request is related to any issues or discussions, link them here.
-13
View File
@@ -1,13 +0,0 @@
<br/>
<div align="center">
<img src="https://homarr.dev/img/logo.png" height="80" alt="" />
<h3>Homarr</h3>
</div>
**Thank you for your contribution. Please ensure that your pull request meets the following pull request:**
- [ ] Builds without warnings or errors (``pnpm buid``, autofix with ``pnpm format:fix``)
- [ ] Pull request targets ``dev`` branch
- [ ] Commits follow the [conventional commits guideline](https://www.conventionalcommits.org/en/v1.0.0/)
- [ ] No shorthand variable names are used (eg. ``x``, ``y``, ``i`` or any abbrevation)
-15
View File
@@ -1,15 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"packageRules": [
{
"matchPackagePatterns": ["^@homarr/"],
"enabled": false
}
],
"updateInternalDeps": true,
"rangeStrategy": "bump",
"automerge": false,
"baseBranches": ["dev"],
"dependencyDashboard": false
}
-31
View File
@@ -1,31 +0,0 @@
name: Build apps and migration script
on:
pull_request:
branches: ["*"]
push:
branches: ["main"]
merge_group:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
env:
FORCE_COLOR: 3
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup
uses: ./tooling/github/setup
- name: Copy env
shell: bash
run: cp .env.example .env
- name: Build
run: pnpm build
-68
View File
@@ -1,68 +0,0 @@
name: Code quality analysis
on:
pull_request:
branches: ["*"]
push:
branches: ["main"]
merge_group:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
# You can leverage Vercel Remote Caching with Turbo to speed up your builds
# @link https://turborepo.org/docs/core-concepts/remote-caching#remote-caching-on-vercel-builds
env:
FORCE_COLOR: 3
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup
uses: ./tooling/github/setup
- name: Copy env
shell: bash
run: cp .env.example .env
- name: Lint
run: pnpm lint && pnpm lint:ws
format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup
uses: ./tooling/github/setup
- name: Format
run: pnpm format
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup
uses: ./tooling/github/setup
- name: Typecheck
run: turbo typecheck
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup
uses: ./tooling/github/setup
- name: Test
run: pnpm test
-89
View File
@@ -1,89 +0,0 @@
name: Docker image
on:
pull_request:
types:
- closed
branches:
- main
workflow_dispatch: {}
permissions:
contents: write
packages: write
env:
TURBO_TELEMETRY_DISABLED: 1
concurrency: production
jobs:
deploy:
name: Deploy docker image
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20]
steps:
- name: Discord notification
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
with:
args: "Deployment of an image has been triggered"
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with:
version: 8
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "pnpm"
- name: Install dependencies
run: pnpm install
- name: Build artifacts
run: pnpm build
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Bump version and push tag
id: githubTagAction
uses: anothrNick/github-tag-action@1.67.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
WITH_V: false
DRY_RUN: true
- name: Discord notification
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
with:
args: "Image has been tagged as ${{ steps.githubTagAction.outputs.new_tag }}"
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest
type=raw,value=${{ steps.githubTagAction.outputs.new_tag }}
- name: Build and push
id: buildPushAction
uses: docker/build-push-action@v5
with:
platforms: linux/amd64,linux/arm64,linux/riscv64,linux/arm/v7,linux/arm/v6
context: .
push: false
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
network: host
- name: Discord notification
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
with:
args: "Image built with ID ${{ steps.buildPushAction.outputs.imageid }}"
-54
View File
@@ -1,54 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules
.pnp
.pnp.js
.idea/
# testing
coverage
# next.js
.next/
out/
next-env.d.ts
# nest.js
apps/nestjs/dist
# nitro
.nitro/
.output/
# production
build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
# turbo
.turbo
# database
db.sqlite
# logs
*.log
-5
View File
@@ -1,5 +0,0 @@
# Expo doesn't play nice with pnpm by default.
# The symbolic links of pnpm break the rules of Expo monorepos.
# @link https://docs.expo.dev/guides/monorepos/#common-issues
node-linker=hoisted
strict-peer-dependencies=false
-1
View File
@@ -1 +0,0 @@
20.12.2
-7
View File
@@ -1,7 +0,0 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"yoavbls.pretty-ts-errors"
]
}
-87
View File
@@ -1,87 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "dev",
"type": "node-terminal",
"request": "launch",
"command": "pnpm dev",
"cwd": "${workspaceFolder}",
"skipFiles": ["<node_internals>/**"]
},
{
"name": "docker dev",
"type": "node-terminal",
"request": "launch",
"command": "pnpm docker:dev",
"cwd": "${workspaceFolder}",
"skipFiles": [
"<node_internals>/**"
]
},
{
"name": "lint fix",
"type": "node-terminal",
"request": "launch",
"command": "pnpm lint:fix",
"cwd": "${workspaceFolder}",
"skipFiles": [
"<node_internals>/**"
]
},
{
"name": "format fix",
"type": "node-terminal",
"request": "launch",
"command": "pnpm format:fix",
"cwd": "${workspaceFolder}",
"skipFiles": [
"<node_internals>/**"
]
},
{
"name": "db push",
"type": "node-terminal",
"request": "launch",
"command": "pnpm db:push",
"cwd": "${workspaceFolder}",
"skipFiles": ["<node_internals>/**"]
},
{
"name": "db studio",
"type": "node-terminal",
"request": "launch",
"command": "pnpm db:studio",
"cwd": "${workspaceFolder}",
"skipFiles": ["<node_internals>/**"]
},
{
"name": "db migration run",
"type": "node-terminal",
"request": "launch",
"command": "pnpm db:migration:run",
"cwd": "${workspaceFolder}",
"skipFiles": ["<node_internals>/**"]
},
{
"name": "test",
"type": "node-terminal",
"request": "launch",
"command": "pnpm test",
"cwd": "${workspaceFolder}",
"skipFiles": [
"<node_internals>/**"
]
},
{
"name": "test ui",
"type": "node-terminal",
"request": "launch",
"command": "pnpm test:ui",
"cwd": "${workspaceFolder}",
"skipFiles": [
"<node_internals>/**"
]
}
]
}
-14
View File
@@ -1,14 +0,0 @@
{
"eslint.workingDirectories": [
{
"mode": "auto"
}
],
"typescript.tsdk": "node_modules\\typescript\\lib",
"js/ts.implicitProjectConfig.experimentalDecorators": true,
"cSpell.words": [
"superjson",
"homarr",
"trpc"
]
}
-1
View File
@@ -1 +0,0 @@
* @homarr-labs/maintainers
-77
View File
@@ -1,77 +0,0 @@
FROM node:20.12.2-alpine AS base
FROM base AS builder
RUN apk add --no-cache libc6-compat
RUN apk update
# Set working directory
WORKDIR /app
COPY . .
RUN npm i -g turbo
RUN turbo prune @homarr/nextjs --docker --out-dir ./next-out
RUN turbo prune @homarr/tasks --docker --out-dir ./tasks-out
RUN turbo prune @homarr/websocket --docker --out-dir ./websocket-out
RUN turbo prune @homarr/db --docker --out-dir ./migration-out
# Add lockfile and package.json's of isolated subworkspace
FROM base AS installer
RUN apk add --no-cache libc6-compat curl bash
RUN apk update
WORKDIR /app
# First install the dependencies (as they change less often)
COPY .gitignore .gitignore
COPY --from=builder /app/tasks-out/json/ .
COPY --from=builder /app/tasks-out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN corepack enable pnpm && pnpm install
COPY --from=builder /app/websocket-out/json/ .
COPY --from=builder /app/websocket-out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN corepack enable pnpm && pnpm install
COPY --from=builder /app/migration-out/json/ .
COPY --from=builder /app/migration-out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN corepack enable pnpm && pnpm install
COPY --from=builder /app/next-out/json/ .
COPY --from=builder /app/next-out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN corepack enable pnpm && pnpm install sharp -w
# Build the project
COPY --from=builder /app/tasks-out/full/ .
COPY --from=builder /app/websocket-out/full/ .
COPY --from=builder /app/next-out/full/ .
COPY --from=builder /app/migration-out/full/ .
ARG SKIP_ENV_VALIDATION=true
RUN corepack enable pnpm && pnpm turbo run build
FROM base AS runner
WORKDIR /app
RUN apk add --no-cache redis
# Don't run production as root
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
USER nextjs
COPY --from=installer /app/apps/nextjs/next.config.mjs .
COPY --from=installer /app/apps/nextjs/package.json .
COPY --from=installer --chown=nextjs:nodejs /app/apps/tasks/tasks.cjs ./apps/tasks/tasks.cjs
COPY --from=installer --chown=nextjs:nodejs /app/apps/websocket/wssServer.cjs ./apps/websocket/wssServer.cjs
COPY --from=installer --chown=nextjs:nodejs /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node /app/build/better_sqlite3.node
COPY --from=installer --chown=nextjs:nodejs /app/packages/db/migrations ./db/migrations
COPY --from=installer --chown=nextjs:nodejs /app/packages/db/migrate.cjs ./db/migrate.cjs
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/.next/standalone ./
COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/.next/static ./apps/nextjs/.next/static
COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/public ./apps/nextjs/public
COPY --chown=nextjs:nodejs scripts/run.sh ./run.sh
ENV DB_URL='/app/db/db.sqlite'
CMD ["sh", "run.sh"]
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2023 Julius Marminge
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-5
View File
@@ -1,5 +0,0 @@
# THIS PROJECT IS STILL UNSTABLE AND WE DO NOT PROVIDE ANY SUPPORT FOR ISSUES THAT OCCURE.
## PLEASE DO NOT OPEN ANY ISSUES OR DISCUSSIONS
### EVERYTHING IS SUBJECT TO CHANGE
Please use [this](https://github.com/ajnart/homarr) version of Homarr when you want to use it
-28
View File
@@ -1,28 +0,0 @@
# Create T3 App
This is a [T3 Stack](https://create.t3.gg/) project bootstrapped with `create-t3-app`.
## What's next? How do I make an app with this?
We try to keep this project as simple as possible, so you can start with just the scaffolding we set up for you, and add additional things later when they become necessary.
If you are not familiar with the different technologies used in this project, please refer to the respective docs. If you still are in the wind, please join our [Discord](https://t3.gg/discord) and ask for help.
- [Next.js](https://nextjs.org)
- [NextAuth.js](https://next-auth.js.org)
- [Drizzle](https://orm.drizzle.team)
- [Tailwind CSS](https://tailwindcss.com)
- [tRPC](https://trpc.io)
## Learn More
To learn more about the [T3 Stack](https://create.t3.gg/), take a look at the following resources:
- [Documentation](https://create.t3.gg/)
- [Learn the T3 Stack](https://create.t3.gg/en/faq#what-learning-resources-are-currently-available) — Check out these awesome tutorials
You can check out the [create-t3-app GitHub repository](https://github.com/t3-oss/create-t3-app) — your feedback and contributions are welcome!
## How do I deploy this?
Follow our deployment guides for [Vercel](https://create.t3.gg/en/deployment/vercel) and [Docker](https://create.t3.gg/en/deployment/docker) for more information.
-31
View File
@@ -1,31 +0,0 @@
// Importing env files here to validate on build
import "./src/env.mjs";
import "@homarr/auth/env.mjs";
/** @type {import("next").NextConfig} */
const config = {
output: "standalone",
reactStrictMode: true,
/** We already do linting and typechecking as separate tasks in CI */
eslint: { ignoreDuringBuilds: true },
typescript: { ignoreBuildErrors: true },
experimental: {
optimizePackageImports: [
"@mantine/core",
"@mantine/hooks",
"@tabler/icons-react",
],
},
transpilePackages: [
"@homarr/ui",
"@homarr/notifications",
"@homarr/modals",
"@homarr/spotlight",
"@homarr/widgets",
],
images: {
domains: ["cdn.jsdelivr.net"],
},
};
export default config;
-83
View File
@@ -1,83 +0,0 @@
{
"name": "@homarr/nextjs",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "pnpm with-env next build",
"clean": "git clean -xdf .next .turbo node_modules",
"dev": "pnpm with-env next dev",
"lint": "dotenv -v SKIP_ENV_VALIDATION=1 next lint",
"format": "prettier --check . --ignore-path ../../.gitignore",
"start": "pnpm with-env next start",
"typecheck": "tsc --noEmit",
"with-env": "dotenv -e ../../.env --"
},
"dependencies": {
"@homarr/api": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/form": "workspace:^0.1.0",
"@homarr/gridstack": "^1.0.0",
"@homarr/log": "workspace:^",
"@homarr/modals": "workspace:^0.1.0",
"@homarr/notifications": "workspace:^0.1.0",
"@homarr/spotlight": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0",
"@mantine/colors-generator": "^7.9.0",
"@mantine/hooks": "^7.9.0",
"@mantine/modals": "^7.9.0",
"@mantine/tiptap": "^7.9.0",
"@t3-oss/env-nextjs": "^0.10.1",
"@tanstack/react-query": "^5.34.1",
"@tanstack/react-query-devtools": "^5.34.1",
"@tanstack/react-query-next-experimental": "5.34.1",
"@trpc/client": "11.0.0-rc.364",
"@trpc/next": "next",
"@trpc/react-query": "next",
"@trpc/server": "next",
"@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-fit": "0.10.0",
"@xterm/xterm": "^5.5.0",
"chroma-js": "^2.4.2",
"dayjs": "^1.11.11",
"dotenv": "^16.4.5",
"glob": "^10.3.12",
"jotai": "^2.8.0",
"next": "^14.2.3",
"postcss-preset-mantine": "^1.15.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"sass": "^1.76.0",
"superjson": "2.2.1",
"use-deep-compare-effect": "^1.8.1"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/chroma-js": "2.4.4",
"@types/node": "^20.12.8",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.0",
"concurrently": "^8.2.2",
"eslint": "^8.57.0",
"prettier": "^3.2.5",
"tsx": "4.9.0",
"typescript": "^5.4.5"
},
"eslintConfig": {
"root": true,
"extends": [
"@homarr/eslint-config/base",
"@homarr/eslint-config/nextjs",
"@homarr/eslint-config/react"
]
},
"prettier": "@homarr/prettier-config"
}
-14
View File
@@ -1,14 +0,0 @@
module.exports = {
plugins: {
"postcss-preset-mantine": {},
"postcss-simple-vars": {
variables: {
"mantine-breakpoint-xs": "36em",
"mantine-breakpoint-sm": "48em",
"mantine-breakpoint-md": "62em",
"mantine-breakpoint-lg": "75em",
"mantine-breakpoint-xl": "88em",
},
},
},
};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

@@ -1,14 +0,0 @@
import type { PropsWithChildren } from "react";
import { AppShellMain } from "@mantine/core";
import { MainHeader } from "~/components/layout/header";
import { ClientShell } from "~/components/layout/shell";
export default function MainLayout({ children }: PropsWithChildren) {
return (
<ClientShell hasNavigation={false}>
<MainHeader />
<AppShellMain>{children}</AppShellMain>
</ClientShell>
);
}
@@ -1,9 +0,0 @@
import { Stack, Title } from "@mantine/core";
export default function HomePage() {
return (
<Stack>
<Title>Home</Title>
</Stack>
);
}
@@ -1,8 +0,0 @@
"use client";
import type { PropsWithChildren } from "react";
import { Provider } from "jotai";
export const JotaiProvider = ({ children }: PropsWithChildren) => {
return <Provider>{children}</Provider>;
};
@@ -1,15 +0,0 @@
import type { PropsWithChildren } from "react";
import { defaultLocale } from "@homarr/translation";
import { I18nProviderClient } from "@homarr/translation/client";
export const NextInternationalProvider = ({
children,
locale,
}: PropsWithChildren<{ locale: string }>) => {
return (
<I18nProviderClient locale={locale} fallback={defaultLocale}>
{children}
</I18nProviderClient>
);
};
@@ -1,17 +0,0 @@
"use client";
import type { PropsWithChildren } from "react";
import type { Session } from "@homarr/auth";
import { SessionProvider } from "@homarr/auth/client";
interface AuthProviderProps {
session: Session | null;
}
export const AuthProvider = ({
children,
session,
}: PropsWithChildren<AuthProviderProps>) => {
return <SessionProvider session={session}>{children}</SessionProvider>;
};
@@ -1,85 +0,0 @@
"use client";
import type { PropsWithChildren } from "react";
import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
import {
createWSClient,
loggerLink,
unstable_httpBatchStreamLink,
wsLink,
} from "@trpc/client";
import superjson from "superjson";
import type { AppRouter } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
const wsClient = createWSClient({
url: "ws://localhost:3001",
});
export function TRPCReactProvider(props: PropsWithChildren) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 1000,
},
},
}),
);
const [trpcClient] = useState(() => {
return clientApi.createClient({
links: [
loggerLink({
enabled: (opts) =>
process.env.NODE_ENV === "development" ||
(opts.direction === "down" && opts.result instanceof Error),
}),
(args) => {
return ({ op, next }) => {
console.log("op", op.type, op.input, op.path, op.id);
if (op.type === "subscription") {
const link = wsLink<AppRouter>({
client: wsClient,
transformer: superjson,
});
return link(args)({ op, next });
}
return unstable_httpBatchStreamLink({
transformer: superjson,
url: `${getBaseUrl()}/api/trpc`,
headers() {
const headers = new Headers();
headers.set("x-trpc-source", "nextjs-react");
return headers;
},
})(args)({ op, next });
};
},
],
});
});
return (
<clientApi.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<ReactQueryStreamedHydration transformer={superjson}>
{props.children}
</ReactQueryStreamedHydration>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</clientApi.Provider>
);
}
function getBaseUrl() {
if (typeof window !== "undefined") return window.location.origin;
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
return `http://localhost:${process.env.PORT ?? 3000}`;
}
@@ -1,82 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import {
Alert,
Button,
PasswordInput,
rem,
Stack,
TextInput,
} from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { signIn } from "@homarr/auth/client";
import { useForm, zodResolver } from "@homarr/form";
import { useScopedI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
export const LoginForm = () => {
const t = useScopedI18n("user");
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string>();
const form = useForm<FormType>({
validate: zodResolver(validation.user.signIn),
initialValues: {
name: "",
password: "",
},
});
const handleSubmit = async (values: FormType) => {
setIsLoading(true);
setError(undefined);
await signIn("credentials", {
...values,
redirect: false,
callbackUrl: "/",
})
.then((response) => {
if (!response?.ok || response.error) {
throw response?.error;
}
void router.push("/");
})
.catch((error: Error | string) => {
setIsLoading(false);
setError(error.toString());
});
};
return (
<Stack gap="xl">
<form onSubmit={form.onSubmit((values) => void handleSubmit(values))}>
<Stack gap="lg">
<TextInput
label={t("field.username.label")}
{...form.getInputProps("name")}
/>
<PasswordInput
label={t("field.password.label")}
{...form.getInputProps("password")}
/>
<Button type="submit" fullWidth loading={isLoading}>
{t("action.login")}
</Button>
</Stack>
</form>
{error && (
<Alert icon={<IconAlertTriangle size={rem(16)} />} color="red">
{error}
</Alert>
)}
</Stack>
);
};
type FormType = z.infer<typeof validation.user.signIn>;
@@ -1,29 +0,0 @@
import { Card, Center, Stack, Text, Title } from "@mantine/core";
import { getScopedI18n } from "@homarr/translation/server";
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
import { LoginForm } from "./_login-form";
export default async function Login() {
const t = await getScopedI18n("user.page.login");
return (
<Center>
<Stack align="center" mt="xl">
<HomarrLogoWithTitle size="lg" />
<Stack gap={6} align="center">
<Title order={3} fw={400} ta="center">
{t("title")}
</Title>
<Text size="sm" c="gray.5" ta="center">
{t("subtitle")}
</Text>
</Stack>
<Card bg="dark.8" w={64 * 6} maw="90vw">
<LoginForm />
</Card>
</Stack>
</Center>
);
}
@@ -1,9 +0,0 @@
import { api } from "@homarr/api/server";
import { createBoardContentPage } from "../_creator";
export default createBoardContentPage<{ locale: string }>({
async getInitialBoard() {
return await api.board.getDefaultBoard();
},
});
@@ -1,5 +0,0 @@
import definition from "./_definition";
const { layout } = definition;
export default layout;
@@ -1,7 +0,0 @@
import definition from "./_definition";
const { generateMetadata, page } = definition;
export default page;
export { generateMetadata };
@@ -1,9 +0,0 @@
import { api } from "@homarr/api/server";
import { createBoardContentPage } from "../_creator";
export default createBoardContentPage<{ locale: string; name: string }>({
async getInitialBoard({ name }) {
return await api.board.getBoardByName({ name });
},
});
@@ -1,5 +0,0 @@
import definition from "./_definition";
const { layout } = definition;
export default layout;
@@ -1,7 +0,0 @@
import definition from "./_definition";
const { generateMetadata, page } = definition;
export default page;
export { generateMetadata };
@@ -1,87 +0,0 @@
"use client";
import { useCallback, useRef } from "react";
import { Box, LoadingOverlay, Stack } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { BoardCategorySection } from "~/components/board/sections/category-section";
import { BoardEmptySection } from "~/components/board/sections/empty-section";
import { BoardBackgroundVideo } from "~/components/layout/background";
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
import { useIsBoardReady, useRequiredBoard } from "./_context";
let boardName: string | null = null;
export const updateBoardName = (name: string | null) => {
boardName = name;
};
type UpdateCallback = (
prev: RouterOutputs["board"]["getDefaultBoard"],
) => RouterOutputs["board"]["getDefaultBoard"];
export const useUpdateBoard = () => {
const utils = clientApi.useUtils();
const updateBoard = useCallback(
(updaterWithoutUndefined: UpdateCallback) => {
if (!boardName) {
throw new Error("Board name is not set");
}
utils.board.getBoardByName.setData({ name: boardName }, (previous) =>
previous ? updaterWithoutUndefined(previous) : previous,
);
},
[utils],
);
return {
updateBoard,
};
};
export const ClientBoard = () => {
const board = useRequiredBoard();
const isReady = useIsBoardReady();
const sortedSections = board.sections.sort(
(sectionA, sectionB) => sectionA.position - sectionB.position,
);
const ref = useRef<HTMLDivElement>(null);
return (
<Box h="100%" pos="relative">
<BoardBackgroundVideo />
<LoadingOverlay
visible={!isReady}
transitionProps={{ duration: 500 }}
loaderProps={{ size: "lg" }}
h={fullHeightWithoutHeaderAndFooter}
/>
<Stack
ref={ref}
h="100%"
style={{ visibility: isReady ? "visible" : "hidden" }}
>
{sortedSections.map((section) =>
section.kind === "empty" ? (
<BoardEmptySection
key={section.id}
section={section}
mainRef={ref}
/>
) : (
<BoardCategorySection
key={section.id}
section={section}
mainRef={ref}
/>
),
)}
</Stack>
</Box>
);
};
@@ -1,116 +0,0 @@
"use client";
import type { PropsWithChildren } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
import { usePathname } from "next/navigation";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { updateBoardName } from "./_client";
const BoardContext = createContext<{
board: RouterOutputs["board"]["getDefaultBoard"];
isReady: boolean;
markAsReady: (id: string) => void;
} | null>(null);
export const BoardProvider = ({
children,
initialBoard,
}: PropsWithChildren<{
initialBoard: RouterOutputs["board"]["getBoardByName"];
}>) => {
const pathname = usePathname();
const utils = clientApi.useUtils();
const [readySections, setReadySections] = useState<string[]>([]);
const { data } = clientApi.board.getBoardByName.useQuery(
{ name: initialBoard.name },
{
initialData: initialBoard,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
);
// Update the board name so it can be used within updateBoard method
updateBoardName(initialBoard.name);
// Invalidate the board when the pathname changes
// This allows to refetch the board when it might have changed - e.g. if someone else added an item
useEffect(() => {
return () => {
setReadySections([]);
void utils.board.getBoardByName.invalidate({ name: initialBoard.name });
};
}, [pathname, utils, initialBoard.name]);
useEffect(() => {
setReadySections((previous) =>
previous.filter((id) =>
data.sections.some((section) => section.id === id),
),
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data.sections.length, setReadySections]);
const markAsReady = useCallback((id: string) => {
setReadySections((previous) =>
previous.includes(id) ? previous : [...previous, id],
);
}, []);
return (
<BoardContext.Provider
value={{
board: data,
isReady: data.sections.length === readySections.length,
markAsReady,
}}
>
{children}
</BoardContext.Provider>
);
};
export const useMarkSectionAsReady = () => {
const context = useContext(BoardContext);
if (!context) {
throw new Error("Board is required");
}
return context.markAsReady;
};
export const useIsBoardReady = () => {
const context = useContext(BoardContext);
if (!context) {
throw new Error("Board is required");
}
return context.isReady;
};
export const useRequiredBoard = () => {
const optionalBoard = useOptionalBoard();
if (!optionalBoard) {
throw new Error("Board is required");
}
return optionalBoard;
};
export const useOptionalBoard = () => {
const context = useContext(BoardContext);
return context?.board;
};
@@ -1,58 +0,0 @@
import type { Metadata } from "next";
import { TRPCError } from "@trpc/server";
import { capitalize } from "@homarr/common";
// Placed here because gridstack styles are used for board content
import "~/styles/gridstack.scss";
import { createBoardLayout } from "../_layout-creator";
import type { Board } from "../_types";
import { ClientBoard } from "./_client";
import { BoardContentHeaderActions } from "./_header-actions";
export type Params = Record<string, unknown>;
interface Props<TParams extends Params> {
getInitialBoard: (params: TParams) => Promise<Board>;
}
export const createBoardContentPage = <
TParams extends Record<string, unknown>,
>({
getInitialBoard,
}: Props<TParams>) => {
return {
layout: createBoardLayout({
headerActions: <BoardContentHeaderActions />,
getInitialBoard,
isBoardContentPage: true,
}),
page: () => {
return <ClientBoard />;
},
generateMetadata: async ({
params,
}: {
params: TParams;
}): Promise<Metadata> => {
try {
const board = await getInitialBoard(params);
return {
title: board.metaTitle ?? `${capitalize(board.name)} board | Homarr`,
icons: {
icon: board.faviconImageUrl ? board.faviconImageUrl : undefined,
},
};
} catch (error) {
// Ignore not found errors and return empty metadata
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
return {};
}
throw error;
}
},
};
};
@@ -1,159 +0,0 @@
"use client";
import { useCallback } from "react";
import { Group, Menu } from "@mantine/core";
import {
IconBox,
IconBoxAlignTop,
IconChevronDown,
IconPackageImport,
IconPencil,
IconPencilOff,
IconPlus,
IconSettings,
} from "@tabler/icons-react";
import { useAtom, useAtomValue } from "jotai";
import { clientApi } from "@homarr/api/client";
import { useModalAction } from "@homarr/modals";
import {
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { revalidatePathAction } from "~/app/revalidatePathAction";
import { editModeAtom } from "~/components/board/editMode";
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
import { useBoardPermissions } from "~/components/board/permissions/client";
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
import { HeaderButton } from "~/components/layout/header/button";
import { useRequiredBoard } from "./_context";
export const BoardContentHeaderActions = () => {
const isEditMode = useAtomValue(editModeAtom);
const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board);
if (!hasChangeAccess) {
return null; // Hide actions for user without access
}
return (
<>
{isEditMode && <AddMenu />}
<EditModeMenu />
<HeaderButton href={`/boards/${board.name}/settings`}>
<IconSettings stroke={1.5} />
</HeaderButton>
</>
);
};
const AddMenu = () => {
const { openModal: openCategoryEditModal } =
useModalAction(CategoryEditModal);
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
const { addCategoryToEnd } = useCategoryActions();
const t = useI18n();
const handleAddCategory = useCallback(
() =>
openCategoryEditModal(
{
category: {
id: "new",
name: "",
},
onSuccess({ name }) {
addCategoryToEnd({ name });
},
submitLabel: t("section.category.create.submit"),
},
{
title: (t) => t("section.category.create.title"),
},
),
[addCategoryToEnd, openCategoryEditModal, t],
);
const handleSelectItem = useCallback(() => {
openItemSelectModal();
}, [openItemSelectModal]);
return (
<Menu position="bottom-end" withArrow>
<Menu.Target>
<HeaderButton w="auto" px={4}>
<Group gap={4} wrap="nowrap">
<IconPlus stroke={1.5} />
<IconChevronDown color="gray" size={16} />
</Group>
</HeaderButton>
</Menu.Target>
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
<Menu.Item
leftSection={<IconBox size={20} />}
onClick={handleSelectItem}
>
{t("item.action.create")}
</Menu.Item>
<Menu.Item leftSection={<IconPackageImport size={20} />}>
{t("item.action.import")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<IconBoxAlignTop size={20} />}
onClick={handleAddCategory}
>
{t("section.category.action.create")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
};
const EditModeMenu = () => {
const [isEditMode, setEditMode] = useAtom(editModeAtom);
const board = useRequiredBoard();
const utils = clientApi.useUtils();
const t = useScopedI18n("board.action.edit");
const { mutate: saveBoard, isPending } =
clientApi.board.saveBoard.useMutation({
onSuccess() {
showSuccessNotification({
title: t("notification.success.title"),
message: t("notification.success.message"),
});
void utils.board.getBoardByName.invalidate({ name: board.name });
void revalidatePathAction(`/boards/${board.name}`);
setEditMode(false);
},
onError() {
showErrorNotification({
title: t("notification.error.title"),
message: t("notification.error.message"),
});
},
});
const toggle = useCallback(() => {
if (isEditMode) return saveBoard(board);
setEditMode(true);
}, [board, isEditMode, saveBoard, setEditMode]);
return (
<HeaderButton onClick={toggle} loading={isPending}>
{isEditMode ? (
<IconPencilOff stroke={1.5} />
) : (
<IconPencil stroke={1.5} />
)}
</HeaderButton>
);
};
@@ -1,49 +0,0 @@
"use client";
import type { PropsWithChildren } from "react";
import type { MantineColorsTuple } from "@mantine/core";
import { createTheme, darken, lighten, MantineProvider } from "@mantine/core";
import { useRequiredBoard } from "./_context";
export const BoardMantineProvider = ({ children }: PropsWithChildren) => {
const board = useRequiredBoard();
const theme = createTheme({
colors: {
primaryColor: generateColors(board.primaryColor),
secondaryColor: generateColors(board.secondaryColor),
},
primaryColor: "primaryColor",
autoContrast: true,
});
return <MantineProvider theme={theme}>{children}</MantineProvider>;
};
export const generateColors = (hex: string) => {
const lightnessForColors = [
-0.25, -0.2, -0.15, -0.1, -0.05, 0, 0.05, 0.1, 0.15, 0.2,
] as const;
const rgbaColors = lightnessForColors.map((lightness) => {
if (lightness < 0) {
return lighten(hex, -lightness);
}
return darken(hex, lightness);
});
return rgbaColors.map((color) => {
return (
"#" +
color
.split("(")[1]!
.replaceAll(" ", "")
.replace(")", "")
.split(",")
.map((color) => parseInt(color, 10))
.slice(0, 3)
.map((color) => color.toString(16).padStart(2, "0"))
.join("")
);
}) as unknown as MantineColorsTuple;
};
@@ -1,12 +0,0 @@
import { api } from "@homarr/api/server";
import { BoardOtherHeaderActions } from "../_header-actions";
import { createBoardLayout } from "../_layout-creator";
export default createBoardLayout<{ locale: string; name: string }>({
headerActions: <BoardOtherHeaderActions />,
async getInitialBoard({ name }) {
return await api.board.getBoardByName({ name });
},
isBoardContentPage: false,
});
@@ -1,105 +0,0 @@
"use client";
import { useState } from "react";
import { Group, Stack, Tabs } from "@mantine/core";
import { IconUser, IconUserDown, IconUsersGroup } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { CountBadge } from "@homarr/ui";
import type { Board } from "../../_types";
import { GroupsForm } from "./_access/group-access";
import { InheritTable } from "./_access/inherit-access";
import { UsersForm } from "./_access/user-access";
interface Props {
board: Board;
initialPermissions: RouterOutputs["board"]["getBoardPermissions"];
}
export const AccessSettingsContent = ({ board, initialPermissions }: Props) => {
const { data: permissions } = clientApi.board.getBoardPermissions.useQuery(
{
id: board.id,
},
{
initialData: initialPermissions,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
);
const [counts, setCounts] = useState({
user: initialPermissions.userPermissions.length + (board.creator ? 1 : 0),
group: initialPermissions.groupPermissions.length,
});
return (
<Stack>
<Tabs color="red" defaultValue="user">
<Tabs.List grow>
<TabItem value="user" count={counts.user} icon={IconUser} />
<TabItem value="group" count={counts.group} icon={IconUsersGroup} />
<TabItem
value="inherited"
count={initialPermissions.inherited.length}
icon={IconUserDown}
/>
</Tabs.List>
<Tabs.Panel value="user">
<UsersForm
board={board}
initialPermissions={permissions}
onCountChange={(callback) =>
setCounts(({ user, ...others }) => ({
user: callback(user),
...others,
}))
}
/>
</Tabs.Panel>
<Tabs.Panel value="group">
<GroupsForm
board={board}
initialPermissions={permissions}
onCountChange={(callback) =>
setCounts(({ group, ...others }) => ({
group: callback(group),
...others,
}))
}
/>
</Tabs.Panel>
<Tabs.Panel value="inherited">
<InheritTable initialPermissions={permissions} />
</Tabs.Panel>
</Tabs>
</Stack>
);
};
interface TabItemProps {
value: "user" | "group" | "inherited";
count: number;
icon: TablerIcon;
}
const TabItem = ({ value, icon: Icon, count }: TabItemProps) => {
const t = useScopedI18n("board.setting.section.access.permission");
return (
<Tabs.Tab value={value} leftSection={<Icon stroke={1.5} size={16} />}>
<Group gap="sm">
{t(`tab.${value}`)}
<CountBadge count={count} />
</Group>
</Tabs.Tab>
);
};
@@ -1,139 +0,0 @@
import { useCallback } from "react";
import type { ReactNode } from "react";
import type { SelectProps } from "@mantine/core";
import {
Button,
Flex,
Group,
Select,
TableTd,
TableTr,
Text,
} from "@mantine/core";
import {
IconCheck,
IconEye,
IconPencil,
IconSettings,
} from "@tabler/icons-react";
import type { BoardPermission } from "@homarr/definitions";
import { boardPermissions } from "@homarr/definitions";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import type { OnCountChange } from "./form";
import { useFormContext } from "./form";
const icons = {
"board-change": IconPencil,
"board-view": IconEye,
"board-full": IconSettings,
} satisfies Record<BoardPermission | "board-full", TablerIcon>;
interface BoardAccessSelectRowProps {
itemContent: ReactNode;
permission: BoardPermission;
index: number;
onCountChange: OnCountChange;
}
export const BoardAccessSelectRow = ({
itemContent,
permission,
index,
onCountChange,
}: BoardAccessSelectRowProps) => {
const tRoot = useI18n();
const tPermissions = useScopedI18n("board.setting.section.access.permission");
const form = useFormContext();
const Icon = icons[permission];
const handleRemove = useCallback(() => {
form.setFieldValue(
"items",
form.values.items.filter((_, i) => i !== index),
);
onCountChange((prev) => prev - 1);
}, [form, index, onCountChange]);
return (
<TableTr>
<TableTd w={{ sm: 128, lg: 256 }}>{itemContent}</TableTd>
<TableTd>
<Flex
direction={{ base: "column", xs: "row" }}
align={{ base: "end", xs: "center" }}
wrap="nowrap"
>
<Select
allowDeselect={false}
flex="1"
leftSection={<Icon size="1rem" />}
renderOption={RenderOption}
variant="unstyled"
data={boardPermissions.map((permission) => ({
value: permission,
label: tPermissions(`item.${permission}.label`),
}))}
{...form.getInputProps(`items.${index}.permission`)}
/>
<Button size="xs" variant="subtle" onClick={handleRemove}>
{tRoot("common.action.remove")}
</Button>
</Flex>
</TableTd>
</TableTr>
);
};
interface BoardAccessDisplayRowProps {
itemContent: ReactNode;
permission: BoardPermission | "board-full";
}
export const BoardAccessDisplayRow = ({
itemContent,
permission,
}: BoardAccessDisplayRowProps) => {
const tPermissions = useScopedI18n("board.setting.section.access.permission");
const Icon = icons[permission];
return (
<TableTr>
<TableTd w={{ sm: 128, lg: 256 }}>{itemContent}</TableTd>
<TableTd>
<Group gap={0}>
<Flex w={34} h={34} align="center" justify="center">
<Icon
size="1rem"
color="var(--input-section-color, var(--mantine-color-dimmed))"
/>
</Flex>
<Text size="sm">{tPermissions(`item.${permission}.label`)}</Text>
</Group>
</TableTd>
</TableTr>
);
};
const iconProps = {
stroke: 1.5,
color: "currentColor",
opacity: 0.6,
size: "1rem",
};
const RenderOption: SelectProps["renderOption"] = ({ option, checked }) => {
const Icon = icons[option.value as BoardPermission];
return (
<Group flex="1" gap="xs" wrap="nowrap">
<Icon {...iconProps} />
{option.label}
{checked && (
<IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />
)}
</Group>
);
};
@@ -1,14 +0,0 @@
import type { BoardPermission } from "@homarr/definitions";
import { createFormContext } from "@homarr/form";
export interface BoardAccessFormType {
items: {
itemId: string;
permission: BoardPermission;
}[];
}
export const [FormProvider, useFormContext, useForm] =
createFormContext<BoardAccessFormType>();
export type OnCountChange = (callback: (prev: number) => number) => void;
@@ -1,148 +0,0 @@
import { useCallback, useState } from "react";
import Link from "next/link";
import {
Anchor,
Button,
Group,
Stack,
Table,
TableTbody,
TableTh,
TableThead,
TableTr,
} from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useModalAction } from "@homarr/modals";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { BoardAccessSelectRow } from "./board-access-table-rows";
import type { BoardAccessFormType } from "./form";
import { FormProvider, useForm } from "./form";
import { GroupSelectModal } from "./group-select-modal";
import type { FormProps } from "./user-access";
export const GroupsForm = ({
board,
initialPermissions,
onCountChange,
}: FormProps) => {
const { mutate, isPending } =
clientApi.board.saveGroupBoardPermissions.useMutation();
const utils = clientApi.useUtils();
const [groups, setGroups] = useState<Map<string, Group>>(
new Map(
initialPermissions.groupPermissions.map(({ group }) => [group.id, group]),
),
);
const { openModal } = useModalAction(GroupSelectModal);
const t = useI18n();
const tPermissions = useScopedI18n("board.setting.section.access.permission");
const form = useForm({
initialValues: {
items: initialPermissions.groupPermissions.map(
({ group, permission }) => ({
itemId: group.id,
permission,
}),
),
},
});
const handleSubmit = useCallback(
(values: BoardAccessFormType) => {
mutate(
{
id: board.id,
permissions: values.items,
},
{
onSuccess: () => {
void utils.board.getBoardPermissions.invalidate();
},
},
);
},
[board.id, mutate, utils.board.getBoardPermissions],
);
const handleAddUser = useCallback(() => {
openModal({
presentGroupIds: form.values.items.map(({ itemId: id }) => id),
onSelect: (group) => {
setGroups((prev) => new Map(prev).set(group.id, group));
form.setFieldValue("items", [
{
itemId: group.id,
permission: "board-view",
},
...form.values.items,
]);
onCountChange((prev) => prev + 1);
},
});
}, [form, openModal, onCountChange]);
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<FormProvider form={form}>
<Stack pt="sm">
<Table>
<TableThead>
<TableTr>
<TableTh style={{ whiteSpace: "nowrap" }}>
{tPermissions("field.group.label")}
</TableTh>
<TableTh>{tPermissions("field.permission.label")}</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{form.values.items.map((row, index) => (
<BoardAccessSelectRow
key={row.itemId}
itemContent={
<GroupItemContent group={groups.get(row.itemId)!} />
}
permission={row.permission}
index={index}
onCountChange={onCountChange}
/>
))}
</TableTbody>
</Table>
<Group justify="space-between">
<Button
rightSection={<IconPlus size="1rem" />}
variant="light"
onClick={handleAddUser}
>
{t("common.action.add")}
</Button>
<Button type="submit" loading={isPending} color="teal">
{t("common.action.saveChanges")}
</Button>
</Group>
</Stack>
</FormProvider>
</form>
);
};
export const GroupItemContent = ({ group }: { group: Group }) => {
return (
<Anchor
component={Link}
href={`/manage/users/groups/${group.id}`}
size="sm"
style={{ whiteSpace: "nowrap" }}
>
{group.name}
</Anchor>
);
};
type Group =
RouterOutputs["board"]["getBoardPermissions"]["groupPermissions"][0]["group"];
@@ -1,72 +0,0 @@
import { useState } from "react";
import { Button, Group, Loader, Select, Stack } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
interface InnerProps {
presentGroupIds: string[];
onSelect: (props: { id: string; name: string }) => void | Promise<void>;
confirmLabel?: string;
}
interface GroupSelectFormType {
groupId: string;
}
export const GroupSelectModal = createModal<InnerProps>(
({ actions, innerProps }) => {
const t = useI18n();
const { data: groups, isPending } = clientApi.group.selectable.useQuery();
const [loading, setLoading] = useState(false);
const form = useForm<GroupSelectFormType>();
const handleSubmit = async (values: GroupSelectFormType) => {
const currentGroup = groups?.find((group) => group.id === values.groupId);
if (!currentGroup) return;
setLoading(true);
await innerProps.onSelect({
id: currentGroup.id,
name: currentGroup.name,
});
setLoading(false);
actions.closeModal();
};
const confirmLabel = innerProps.confirmLabel ?? t("common.action.add");
return (
<form onSubmit={form.onSubmit((values) => void handleSubmit(values))}>
<Stack>
<Select
{...form.getInputProps("groupId")}
label={t("group.action.select.label")}
clearable
searchable
leftSection={isPending ? <Loader size="xs" /> : undefined}
nothingFoundMessage={t("group.action.select.notFound")}
limit={5}
data={groups
?.filter(
(group) => !innerProps.presentGroupIds.includes(group.id),
)
.map((group) => ({ value: group.id, label: group.name }))}
/>
<Group justify="end">
<Button variant="default" onClick={actions.closeModal}>
{t("common.action.cancel")}
</Button>
<Button type="submit" loading={loading}>
{confirmLabel}
</Button>
</Group>
</Stack>
</form>
);
},
).withOptions({
defaultTitle: (t) =>
t("board.setting.section.access.permission.groupSelect.title"),
});
@@ -1,66 +0,0 @@
import {
Stack,
Table,
TableTbody,
TableTh,
TableThead,
TableTr,
} from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { getPermissionsWithChildren } from "@homarr/definitions";
import type { BoardPermission, GroupPermissionKey } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { BoardAccessDisplayRow } from "./board-access-table-rows";
import { GroupItemContent } from "./group-access";
export interface InheritTableProps {
initialPermissions: RouterOutputs["board"]["getBoardPermissions"];
}
const mapPermissions = {
"board-full-access": "board-full",
"board-modify-all": "board-change",
"board-view-all": "board-view",
} satisfies Partial<Record<GroupPermissionKey, BoardPermission | "board-full">>;
export const InheritTable = ({ initialPermissions }: InheritTableProps) => {
const tPermissions = useScopedI18n("board.setting.section.access.permission");
return (
<Stack pt="sm">
<Table>
<TableThead>
<TableTr>
<TableTh>{tPermissions("field.user.label")}</TableTh>
<TableTh>{tPermissions("field.permission.label")}</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{initialPermissions.inherited.map(({ group, permission }) => {
const boardPermission =
permission in mapPermissions
? mapPermissions[permission as keyof typeof mapPermissions]
: getPermissionsWithChildren([permission]).includes(
"board-full-access",
)
? "board-full"
: null;
if (!boardPermission) {
return null;
}
return (
<BoardAccessDisplayRow
key={group.id}
itemContent={<GroupItemContent group={group} />}
permission={boardPermission}
/>
);
})}
</TableTbody>
</Table>
</Stack>
);
};
@@ -1,173 +0,0 @@
import { useCallback, useState } from "react";
import Link from "next/link";
import {
Anchor,
Box,
Button,
Group,
Stack,
Table,
TableTbody,
TableTh,
TableThead,
TableTr,
} from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useModalAction } from "@homarr/modals";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui";
import type { Board } from "../../../_types";
import {
BoardAccessDisplayRow,
BoardAccessSelectRow,
} from "./board-access-table-rows";
import type { BoardAccessFormType, OnCountChange } from "./form";
import { FormProvider, useForm } from "./form";
import { UserSelectModal } from "./user-select-modal";
export interface FormProps {
board: Pick<Board, "id" | "creatorId" | "creator">;
initialPermissions: RouterOutputs["board"]["getBoardPermissions"];
onCountChange: OnCountChange;
}
export const UsersForm = ({
board,
initialPermissions,
onCountChange,
}: FormProps) => {
const { mutate, isPending } =
clientApi.board.saveUserBoardPermissions.useMutation();
const utils = clientApi.useUtils();
const [users, setUsers] = useState<Map<string, User>>(
new Map(
initialPermissions.userPermissions.map(({ user }) => [user.id, user]),
),
);
const { openModal } = useModalAction(UserSelectModal);
const t = useI18n();
const tPermissions = useScopedI18n("board.setting.section.access.permission");
const form = useForm({
initialValues: {
items: initialPermissions.userPermissions.map(({ user, permission }) => ({
itemId: user.id,
permission,
})),
},
});
const handleSubmit = useCallback(
(values: BoardAccessFormType) => {
mutate(
{
id: board.id,
permissions: values.items,
},
{
onSuccess: () => {
void utils.board.getBoardPermissions.invalidate();
},
},
);
},
[board.id, mutate, utils.board.getBoardPermissions],
);
const handleAddUser = useCallback(() => {
const presentUserIds = form.values.items.map(({ itemId: id }) => id);
openModal({
presentUserIds: board.creatorId
? presentUserIds.concat(board.creatorId)
: presentUserIds,
onSelect: (user) => {
setUsers((prev) => new Map(prev).set(user.id, user));
form.setFieldValue("items", [
{
itemId: user.id,
permission: "board-view",
},
...form.values.items,
]);
onCountChange((prev) => prev + 1);
},
});
}, [form, openModal, board.creatorId, onCountChange]);
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<FormProvider form={form}>
<Stack pt="sm">
<Table>
<TableThead>
<TableTr>
<TableTh>{tPermissions("field.user.label")}</TableTh>
<TableTh>{tPermissions("field.permission.label")}</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{board.creator && (
<BoardAccessDisplayRow
itemContent={<UserItemContent user={board.creator} />}
permission="board-full"
/>
)}
{form.values.items.map((row, index) => (
<BoardAccessSelectRow
key={row.itemId}
itemContent={
<UserItemContent user={users.get(row.itemId)!} />
}
permission={row.permission}
index={index}
onCountChange={onCountChange}
/>
))}
</TableTbody>
</Table>
<Group justify="space-between">
<Button
rightSection={<IconPlus size="1rem" />}
variant="light"
onClick={handleAddUser}
>
{t("common.action.add")}
</Button>
<Button type="submit" loading={isPending} color="teal">
{t("common.action.saveChanges")}
</Button>
</Group>
</Stack>
</FormProvider>
</form>
);
};
const UserItemContent = ({ user }: { user: User }) => {
return (
<Group wrap="nowrap">
<Box visibleFrom="xs">
<UserAvatar user={user} size="sm" />
</Box>
<Anchor
component={Link}
href={`/manage/users/${user.id}`}
size="sm"
style={{ whiteSpace: "nowrap" }}
>
{user.name}
</Anchor>
</Group>
);
};
interface User {
id: string;
name: string | null;
image: string | null;
}
@@ -1,112 +0,0 @@
import { useState } from "react";
import type { SelectProps } from "@mantine/core";
import { Button, Group, Loader, Select, Stack } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui";
interface InnerProps {
presentUserIds: string[];
onSelect: (props: {
id: string;
name: string;
image: string;
}) => void | Promise<void>;
confirmLabel?: string;
}
interface UserSelectFormType {
userId: string;
}
export const UserSelectModal = createModal<InnerProps>(
({ actions, innerProps }) => {
const t = useI18n();
const { data: users, isPending } = clientApi.user.selectable.useQuery();
const [loading, setLoading] = useState(false);
const form = useForm<UserSelectFormType>();
const handleSubmit = async (values: UserSelectFormType) => {
const currentUser = users?.find((user) => user.id === values.userId);
if (!currentUser) return;
setLoading(true);
await innerProps.onSelect({
id: currentUser.id,
name: currentUser.name ?? "",
image: currentUser.image ?? "",
});
setLoading(false);
actions.closeModal();
};
const confirmLabel = innerProps.confirmLabel ?? t("common.action.add");
const currentUser = users?.find((user) => user.id === form.values.userId);
return (
<form onSubmit={form.onSubmit((values) => void handleSubmit(values))}>
<Stack>
<Select
{...form.getInputProps("userId")}
label={t("user.action.select.label")}
searchable
clearable
leftSection={
isPending ? (
<Loader size="xs" />
) : currentUser ? (
<UserAvatar user={currentUser} size="xs" />
) : undefined
}
nothingFoundMessage={t("user.action.select.notFound")}
renderOption={createRenderOption(users ?? [])}
limit={5}
data={users
?.filter((user) => !innerProps.presentUserIds.includes(user.id))
.map((user) => ({ value: user.id, label: user.name ?? "" }))}
/>
<Group justify="end">
<Button variant="default" onClick={actions.closeModal}>
{t("common.action.cancel")}
</Button>
<Button type="submit" loading={loading}>
{confirmLabel}
</Button>
</Group>
</Stack>
</form>
);
},
).withOptions({
defaultTitle: (t) =>
t("board.setting.section.access.permission.userSelect.title"),
});
const iconProps = {
stroke: 1.5,
color: "currentColor",
opacity: 0.6,
size: "1rem",
};
const createRenderOption = (
users: RouterOutputs["user"]["selectable"],
): SelectProps["renderOption"] =>
function InnerRenderRoot({ option, checked }) {
const user = users.find((user) => user.id === option.value);
if (!user) return null;
return (
<Group flex="1" gap="xs">
<UserAvatar user={user} size="xs" />
{option.label}
{checked && (
<IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />
)}
</Group>
);
};
@@ -1,135 +0,0 @@
"use client";
import { Button, Grid, Group, Stack, TextInput } from "@mantine/core";
import {
backgroundImageAttachments,
backgroundImageRepeats,
backgroundImageSizes,
} from "@homarr/definitions";
import { useForm } from "@homarr/form";
import type { TranslationObject } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { SelectItemWithDescriptionBadge } from "@homarr/ui";
import { SelectWithDescriptionBadge } from "@homarr/ui";
import type { Board } from "../../_types";
import { useSavePartialSettingsMutation } from "./_shared";
interface Props {
board: Board;
}
export const BackgroundSettingsContent = ({ board }: Props) => {
const t = useI18n();
const { mutate: savePartialSettings, isPending } =
useSavePartialSettingsMutation(board);
const form = useForm({
initialValues: {
backgroundImageUrl: board.backgroundImageUrl ?? "",
backgroundImageAttachment: board.backgroundImageAttachment,
backgroundImageRepeat: board.backgroundImageRepeat,
backgroundImageSize: board.backgroundImageSize,
},
});
const backgroundImageAttachmentData = useBackgroundOptionData(
"backgroundImageAttachment",
backgroundImageAttachments,
);
const backgroundImageSizeData = useBackgroundOptionData(
"backgroundImageSize",
backgroundImageSizes,
);
const backgroundImageRepeatData = useBackgroundOptionData(
"backgroundImageRepeat",
backgroundImageRepeats,
);
return (
<form
onSubmit={form.onSubmit((values) => {
savePartialSettings({
id: board.id,
...values,
});
})}
>
<Stack>
<Grid>
<Grid.Col span={12}>
<TextInput
label={t("board.field.backgroundImageUrl.label")}
{...form.getInputProps("backgroundImageUrl")}
/>
</Grid.Col>
<Grid.Col span={12}>
<SelectWithDescriptionBadge
label={t("board.field.backgroundImageAttachment.label")}
data={backgroundImageAttachmentData}
{...form.getInputProps("backgroundImageAttachment")}
/>
</Grid.Col>
<Grid.Col span={12}>
<SelectWithDescriptionBadge
label={t("board.field.backgroundImageSize.label")}
data={backgroundImageSizeData}
{...form.getInputProps("backgroundImageSize")}
/>
</Grid.Col>
<Grid.Col span={12}>
<SelectWithDescriptionBadge
label={t("board.field.backgroundImageRepeat.label")}
data={backgroundImageRepeatData}
{...form.getInputProps("backgroundImageRepeat")}
/>
</Grid.Col>
</Grid>
<Group justify="end">
<Button type="submit" loading={isPending} color="teal">
{t("common.action.saveChanges")}
</Button>
</Group>
</Stack>
</form>
);
};
type BackgroundImageKey =
| "backgroundImageAttachment"
| "backgroundImageSize"
| "backgroundImageRepeat";
type inferOptions<TKey extends BackgroundImageKey> =
TranslationObject["board"]["field"][TKey]["option"];
const useBackgroundOptionData = <
TKey extends BackgroundImageKey,
TOptions extends inferOptions<TKey> = inferOptions<TKey>,
>(
key: TKey,
data: {
values: (keyof TOptions)[];
defaultValue: keyof TOptions;
},
) => {
const t = useI18n();
return data.values.map(
(value) =>
({
label: t(`board.field.${key}.option.${value as string}.label` as never),
description: t(
`board.field.${key}.option.${value as string}.description` as never,
),
value: value as string,
badge:
data.defaultValue === value
? {
color: "blue",
label: t("common.select.badge.recommended"),
}
: undefined,
}) satisfies SelectItemWithDescriptionBadge,
);
};
@@ -1,158 +0,0 @@
"use client";
import {
Anchor,
Button,
Collapse,
ColorInput,
ColorSwatch,
Grid,
Group,
InputWrapper,
isLightColor,
Slider,
Stack,
Text,
useMantineTheme,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { useForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import type { Board } from "../../_types";
import { generateColors } from "../../(content)/_theme";
import { useSavePartialSettingsMutation } from "./_shared";
interface Props {
board: Board;
}
const hexRegex = /^#[0-9a-fA-F]{6}$/;
const progressPercentageLabel = (value: number) => `${value}%`;
export const ColorSettingsContent = ({ board }: Props) => {
const form = useForm({
initialValues: {
primaryColor: board.primaryColor,
secondaryColor: board.secondaryColor,
opacity: board.opacity,
},
});
const [showPreview, { toggle }] = useDisclosure(false);
const t = useI18n();
const theme = useMantineTheme();
const { mutate: savePartialSettings, isPending } =
useSavePartialSettingsMutation(board);
return (
<form
onSubmit={form.onSubmit((values) => {
savePartialSettings({
id: board.id,
...values,
});
})}
>
<Stack>
<Grid>
<Grid.Col span={{ sm: 12, md: 6 }}>
<Stack gap="xs">
<ColorInput
label={t("board.field.primaryColor.label")}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
{...form.getInputProps("primaryColor")}
/>
</Stack>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<ColorInput
label={t("board.field.secondaryColor.label")}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
{...form.getInputProps("secondaryColor")}
/>
</Grid.Col>
<Grid.Col span={12}>
<Anchor onClick={toggle}>
{showPreview
? t("common.preview.hide")
: t("common.preview.show")}
</Anchor>
</Grid.Col>
<Grid.Col span={12}>
<Collapse in={showPreview}>
<Stack>
<ColorsPreview previewColor={form.values.primaryColor} />
<ColorsPreview previewColor={form.values.secondaryColor} />
</Stack>
</Collapse>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<InputWrapper label={t("board.field.opacity.label")}>
<Slider
my={6}
min={0}
max={100}
step={5}
label={progressPercentageLabel}
{...form.getInputProps("opacity")}
/>
</InputWrapper>
</Grid.Col>
</Grid>
<Group justify="end">
<Button type="submit" loading={isPending} color="teal">
{t("common.action.saveChanges")}
</Button>
</Group>
</Stack>
</form>
);
};
interface ColorsPreviewProps {
previewColor: string;
}
const ColorsPreview = ({ previewColor }: ColorsPreviewProps) => {
const theme = useMantineTheme();
const colors = hexRegex.test(previewColor)
? generateColors(previewColor)
: generateColors("#000000");
return (
<Group gap={0} wrap="nowrap">
{colors.map((color, index) => (
<ColorSwatch
key={index}
color={color}
w="10%"
pb="10%"
c={isLightColor(color) ? "black" : "white"}
radius={0}
styles={{
colorOverlay: {
borderTopLeftRadius: index === 0 ? theme.radius.md : 0,
borderBottomLeftRadius: index === 0 ? theme.radius.md : 0,
borderTopRightRadius: index === 9 ? theme.radius.md : 0,
borderBottomRightRadius: index === 9 ? theme.radius.md : 0,
},
}}
>
<Stack align="center" gap={4}>
<Text visibleFrom="md" fw={500} size="lg">
{index}
</Text>
<Text visibleFrom="md" fw={500} size="xs" tt="uppercase">
{color}
</Text>
</Stack>
</ColorSwatch>
))}
</Group>
);
};
@@ -1,7 +0,0 @@
"use client";
// TODO: add some sort of store (maybe directory on GitHub)
export const CustomCssSettingsContent = () => {
return null;
};
@@ -1,157 +0,0 @@
"use client";
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import { Button, Divider, Group, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import { BoardRenameModal } from "~/components/board/modals/board-rename-modal";
import { useRequiredBoard } from "../../(content)/_context";
import classes from "./danger.module.css";
export const DangerZoneSettingsContent = () => {
const board = useRequiredBoard();
const t = useScopedI18n("board.setting");
const router = useRouter();
const { openConfirmModal } = useConfirmModal();
const { openModal } = useModalAction(BoardRenameModal);
const { mutate: changeVisibility, isPending: isChangeVisibilityPending } =
clientApi.board.changeBoardVisibility.useMutation();
const { mutate: deleteBoard, isPending: isDeletePending } =
clientApi.board.deleteBoard.useMutation();
const utils = clientApi.useUtils();
const visibility = board.isPublic ? "public" : "private";
const onRenameClick = useCallback(
() =>
openModal({
id: board.id,
previousName: board.name,
onSuccess: (name) => router.push(`/boards/${name}/settings`),
}),
[board.id, board.name, router, openModal],
);
const onVisibilityClick = useCallback(() => {
openConfirmModal({
title: t(
`section.dangerZone.action.visibility.confirm.${visibility}.title`,
),
children: t(
`section.dangerZone.action.visibility.confirm.${visibility}.description`,
),
onConfirm: () => {
changeVisibility(
{
id: board.id,
visibility: visibility === "public" ? "private" : "public",
},
{
onSettled() {
void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.board.getDefaultBoard.invalidate();
},
},
);
},
});
}, [
board.id,
board.name,
changeVisibility,
t,
utils.board.getBoardByName,
utils.board.getDefaultBoard,
visibility,
openConfirmModal,
]);
const onDeleteClick = useCallback(() => {
openConfirmModal({
title: t("section.dangerZone.action.delete.confirm.title"),
children: t("section.dangerZone.action.delete.confirm.description"),
onConfirm: () => {
deleteBoard(
{ id: board.id },
{
onSettled: () => {
router.push("/");
},
},
);
},
});
}, [board.id, deleteBoard, router, t, openConfirmModal]);
return (
<Stack gap="sm">
<Divider />
<DangerZoneRow
label={t("section.dangerZone.action.rename.label")}
description={t("section.dangerZone.action.rename.description")}
buttonText={t("section.dangerZone.action.rename.button")}
onClick={onRenameClick}
/>
<Divider />
<DangerZoneRow
label={t("section.dangerZone.action.visibility.label")}
description={t(
`section.dangerZone.action.visibility.description.${visibility}`,
)}
buttonText={t(
`section.dangerZone.action.visibility.button.${visibility}`,
)}
onClick={onVisibilityClick}
isPending={isChangeVisibilityPending}
/>
<Divider />
<DangerZoneRow
label={t("section.dangerZone.action.delete.label")}
description={t("section.dangerZone.action.delete.description")}
buttonText={t("section.dangerZone.action.delete.button")}
onClick={onDeleteClick}
isPending={isDeletePending}
/>
</Stack>
);
};
interface DangerZoneRowProps {
label: string;
description: string;
buttonText: string;
isPending?: boolean;
onClick: () => void;
}
const DangerZoneRow = ({
label,
description,
buttonText,
onClick,
isPending,
}: DangerZoneRowProps) => {
return (
<Group justify="space-between" px="md" className={classes.dangerZoneGroup}>
<Stack gap={0}>
<Text fw="bold" size="sm">
{label}
</Text>
<Text size="sm">{description}</Text>
</Stack>
<Group justify="end" w={{ base: "100%", xs: "auto" }}>
<Button
variant="subtle"
color="red"
loading={isPending}
onClick={onClick}
>
{buttonText}
</Button>
</Group>
</Group>
);
};
@@ -1,197 +0,0 @@
"use client";
import { useEffect, useRef } from "react";
import {
Button,
Grid,
Group,
Loader,
Stack,
TextInput,
Tooltip,
} from "@mantine/core";
import {
useDebouncedValue,
useDocumentTitle,
useFavicon,
} from "@mantine/hooks";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import type { Board } from "../../_types";
import { useUpdateBoard } from "../../(content)/_client";
import { useSavePartialSettingsMutation } from "./_shared";
interface Props {
board: Board;
}
export const GeneralSettingsContent = ({ board }: Props) => {
const t = useI18n();
const ref = useRef({
pageTitle: board.pageTitle,
logoImageUrl: board.logoImageUrl,
});
const { updateBoard } = useUpdateBoard();
const { mutate: savePartialSettings, isPending } =
useSavePartialSettingsMutation(board);
const form = useForm({
initialValues: {
pageTitle: board.pageTitle ?? "",
logoImageUrl: board.logoImageUrl ?? "",
metaTitle: board.metaTitle ?? "",
faviconImageUrl: board.faviconImageUrl ?? "",
},
onValuesChange({ pageTitle }) {
updateBoard((previous) => ({
...previous,
pageTitle,
}));
},
});
const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle);
const faviconStatus = useFaviconPreview(form.values.faviconImageUrl);
const logoStatus = useLogoPreview(form.values.logoImageUrl);
// Cleanup for not applied changes of the page title and logo image URL
useEffect(() => {
return () => {
updateBoard((previous) => ({
...previous,
pageTitle: ref.current.pageTitle,
logoImageUrl: ref.current.logoImageUrl,
}));
};
}, [updateBoard]);
return (
<form
onSubmit={form.onSubmit((values) => {
// Save the current values to the ref so that it does not reset if the form is submitted
ref.current = {
pageTitle: values.pageTitle,
logoImageUrl: values.logoImageUrl,
};
savePartialSettings({
id: board.id,
...values,
});
})}
>
<Stack>
<Grid>
<Grid.Col span={{ xs: 12, md: 6 }}>
<TextInput
label={t("board.field.pageTitle.label")}
placeholder="Homarr"
{...form.getInputProps("pageTitle")}
/>
</Grid.Col>
<Grid.Col span={{ xs: 12, md: 6 }}>
<TextInput
label={t("board.field.metaTitle.label")}
placeholder="Default Board | Homarr"
rightSection={<PendingOrInvalidIndicator {...metaTitleStatus} />}
{...form.getInputProps("metaTitle")}
/>
</Grid.Col>
<Grid.Col span={{ xs: 12, md: 6 }}>
<TextInput
label={t("board.field.logoImageUrl.label")}
placeholder="/logo/logo.png"
rightSection={<PendingOrInvalidIndicator {...logoStatus} />}
{...form.getInputProps("logoImageUrl")}
/>
</Grid.Col>
<Grid.Col span={{ xs: 12, md: 6 }}>
<TextInput
label={t("board.field.faviconImageUrl.label")}
placeholder="/logo/logo.png"
rightSection={<PendingOrInvalidIndicator {...faviconStatus} />}
{...form.getInputProps("faviconImageUrl")}
/>
</Grid.Col>
</Grid>
<Group justify="end">
<Button type="submit" loading={isPending} color="teal">
{t("common.action.saveChanges")}
</Button>
</Group>
</Stack>
</form>
);
};
const PendingOrInvalidIndicator = ({
isPending,
isInvalid,
}: {
isPending: boolean;
isInvalid?: boolean;
}) => {
const t = useI18n();
if (isInvalid) {
return (
<Tooltip
multiline
w={220}
label={t("board.setting.section.general.unrecognizedLink")}
>
<IconAlertTriangle size="1rem" color="red" />
</Tooltip>
);
}
if (isPending) {
return <Loader size="xs" />;
}
return null;
};
const useLogoPreview = (url: string | null) => {
const { updateBoard } = useUpdateBoard();
const [logoDebounced] = useDebouncedValue(url ?? "", 500);
useEffect(() => {
if (!logoDebounced.includes(".") && logoDebounced.length >= 1) return;
updateBoard((previous) => ({
...previous,
logoImageUrl: logoDebounced.length >= 1 ? logoDebounced : null,
}));
}, [logoDebounced, updateBoard]);
return {
isPending: (url ?? "") !== logoDebounced,
isInvalid: logoDebounced.length >= 1 && !logoDebounced.includes("."),
};
};
const useMetaTitlePreview = (title: string | null) => {
const [metaTitleDebounced] = useDebouncedValue(title ?? "", 200);
useDocumentTitle(metaTitleDebounced);
return {
isPending: (title ?? "") !== metaTitleDebounced,
};
};
const validFaviconExtensions = ["ico", "png", "svg", "gif"];
const isValidUrl = (url: string) =>
url.includes("/") &&
validFaviconExtensions.some((extension) => url.endsWith(`.${extension}`));
const useFaviconPreview = (url: string | null) => {
const [faviconDebounced] = useDebouncedValue(url ?? "", 500);
useFavicon(isValidUrl(faviconDebounced) ? faviconDebounced : "");
return {
isPending: (url ?? "") !== faviconDebounced,
isInvalid: faviconDebounced.length >= 1 && !isValidUrl(faviconDebounced),
};
};
@@ -1,55 +0,0 @@
"use client";
import { Button, Grid, Group, Input, Slider, Stack } from "@mantine/core";
import { useForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import type { Board } from "../../_types";
import { useSavePartialSettingsMutation } from "./_shared";
interface Props {
board: Board;
}
export const LayoutSettingsContent = ({ board }: Props) => {
const t = useI18n();
const { mutate: savePartialSettings, isPending } =
useSavePartialSettingsMutation(board);
const form = useForm({
initialValues: {
columnCount: board.columnCount,
},
});
return (
<form
onSubmit={form.onSubmit((values) => {
savePartialSettings({
id: board.id,
...values,
});
})}
>
<Stack>
<Grid>
<Grid.Col span={{ sm: 12, md: 6 }}>
<Input.Wrapper label={t("board.field.columnCount.label")}>
<Slider
mt="xs"
min={1}
max={24}
step={1}
{...form.getInputProps("columnCount")}
/>
</Input.Wrapper>
</Grid.Col>
</Grid>
<Group justify="end">
<Button type="submit" loading={isPending} color="teal">
{t("common.action.saveChanges")}
</Button>
</Group>
</Stack>
</form>
);
};
@@ -1,13 +0,0 @@
import { clientApi } from "@homarr/api/client";
import type { Board } from "../../_types";
export const useSavePartialSettingsMutation = (board: Board) => {
const utils = clientApi.useUtils();
return clientApi.board.savePartialBoardSettings.useMutation({
onSettled() {
void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.board.getDefaultBoard.invalidate();
},
});
};
@@ -1,5 +0,0 @@
@media (min-width: 36em) {
.dangerZoneGroup {
--group-wrap: nowrap !important;
}
}
@@ -1,172 +0,0 @@
import type { PropsWithChildren } from "react";
import { notFound } from "next/navigation";
import {
AccordionControl,
AccordionItem,
AccordionPanel,
Container,
Stack,
Text,
Title,
} from "@mantine/core";
import {
IconAlertTriangle,
IconBrush,
IconFileTypeCss,
IconLayout,
IconPhoto,
IconSettings,
IconUser,
} from "@tabler/icons-react";
import { TRPCError } from "@trpc/server";
import { api } from "@homarr/api/server";
import { capitalize } from "@homarr/common";
import type { TranslationObject } from "@homarr/translation";
import { getScopedI18n } from "@homarr/translation/server";
import type { TablerIcon } from "@homarr/ui";
import { getBoardPermissions } from "~/components/board/permissions/server";
import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion";
import { AccessSettingsContent } from "./_access";
import { BackgroundSettingsContent } from "./_background";
import { ColorSettingsContent } from "./_colors";
import { CustomCssSettingsContent } from "./_customCss";
import { DangerZoneSettingsContent } from "./_danger";
import { GeneralSettingsContent } from "./_general";
import { LayoutSettingsContent } from "./_layout";
interface Props {
params: {
name: string;
};
searchParams: {
tab?: keyof TranslationObject["board"]["setting"]["section"];
};
}
const getBoardAndPermissions = async (params: Props["params"]) => {
try {
const board = await api.board.getBoardByName({ name: params.name });
const { hasFullAccess } = await getBoardPermissions(board);
const permissions = hasFullAccess
? await api.board.getBoardPermissions({ id: board.id })
: {
userPermissions: [],
groupPermissions: [],
inherited: [],
};
return { board, permissions };
} catch (error) {
// Ignore not found errors and redirect to 404
// error is already logged in _layout-creator.tsx
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
notFound();
}
throw error;
}
};
export default async function BoardSettingsPage({
params,
searchParams,
}: Props) {
const { board, permissions } = await getBoardAndPermissions(params);
const { hasFullAccess } = await getBoardPermissions(board);
const t = await getScopedI18n("board.setting");
return (
<Container>
<Stack>
<Title>{t("title", { boardName: capitalize(board.name) })}</Title>
<ActiveTabAccordion
variant="separated"
defaultValue={searchParams.tab ?? "general"}
>
<AccordionItemFor value="general" icon={IconSettings}>
<GeneralSettingsContent board={board} />
</AccordionItemFor>
<AccordionItemFor value="layout" icon={IconLayout}>
<LayoutSettingsContent board={board} />
</AccordionItemFor>
<AccordionItemFor value="background" icon={IconPhoto}>
<BackgroundSettingsContent board={board} />
</AccordionItemFor>
<AccordionItemFor value="color" icon={IconBrush}>
<ColorSettingsContent board={board} />
</AccordionItemFor>
<AccordionItemFor value="customCss" icon={IconFileTypeCss}>
<CustomCssSettingsContent />
</AccordionItemFor>
{hasFullAccess && (
<>
<AccordionItemFor value="access" icon={IconUser}>
<AccessSettingsContent
board={board}
initialPermissions={permissions}
/>
</AccordionItemFor>
<AccordionItemFor
value="dangerZone"
icon={IconAlertTriangle}
danger
noPadding
>
<DangerZoneSettingsContent />
</AccordionItemFor>
</>
)}
</ActiveTabAccordion>
</Stack>
</Container>
);
}
type AccordionItemForProps = PropsWithChildren<{
value: keyof TranslationObject["board"]["setting"]["section"];
icon: TablerIcon;
danger?: boolean;
noPadding?: boolean;
}>;
const AccordionItemFor = async ({
value,
children,
icon: Icon,
danger,
noPadding,
}: AccordionItemForProps) => {
const t = await getScopedI18n("board.setting.section");
return (
<AccordionItem
value={value}
styles={
danger
? {
item: {
"--__item-border-color": "rgba(248,81,73,0.4)",
borderWidth: 4,
},
}
: undefined
}
>
<AccordionControl icon={<Icon />}>
<Text fw="bold" size="lg">
{t(`${value}.title`)}
</Text>
</AccordionControl>
<AccordionPanel
styles={
noPadding
? { content: { paddingRight: 0, paddingLeft: 0 } }
: undefined
}
>
{children}
</AccordionPanel>
</AccordionItem>
);
};
@@ -1,16 +0,0 @@
"use client";
import { IconLayoutBoard } from "@tabler/icons-react";
import { HeaderButton } from "~/components/layout/header/button";
import { useRequiredBoard } from "./(content)/_context";
export const BoardOtherHeaderActions = () => {
const board = useRequiredBoard();
return (
<HeaderButton href={`/boards/${board.name}`}>
<IconLayoutBoard stroke={1.5} />
</HeaderButton>
);
};
@@ -1,65 +0,0 @@
import type { PropsWithChildren } from "react";
import { notFound } from "next/navigation";
import { AppShellMain } from "@mantine/core";
import { TRPCError } from "@trpc/server";
import { logger } from "@homarr/log";
import { GlobalItemServerDataRunner } from "@homarr/widgets";
import { MainHeader } from "~/components/layout/header";
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
import { ClientShell } from "~/components/layout/shell";
import type { Board } from "./_types";
import { BoardProvider } from "./(content)/_context";
import type { Params } from "./(content)/_creator";
import { BoardMantineProvider } from "./(content)/_theme";
interface CreateBoardLayoutProps<TParams extends Params> {
headerActions: JSX.Element;
getInitialBoard: (params: TParams) => Promise<Board>;
isBoardContentPage: boolean;
}
export const createBoardLayout = <TParams extends Params>({
headerActions,
getInitialBoard,
isBoardContentPage,
}: CreateBoardLayoutProps<TParams>) => {
const Layout = async ({
params,
children,
}: PropsWithChildren<{
params: TParams;
}>) => {
const initialBoard = await getInitialBoard(params).catch((error) => {
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
logger.warn(error);
notFound();
}
throw error;
});
return (
<GlobalItemServerDataRunner
board={initialBoard}
shouldRun={isBoardContentPage}
>
<BoardProvider initialBoard={initialBoard}>
<BoardMantineProvider>
<ClientShell hasNavigation={false}>
<MainHeader
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
actions={headerActions}
hasNavigation={false}
/>
<AppShellMain>{children}</AppShellMain>
</ClientShell>
</BoardMantineProvider>
</BoardProvider>
</GlobalItemServerDataRunner>
);
};
return Layout;
};
@@ -1,14 +0,0 @@
import type { RouterOutputs } from "@homarr/api";
import type { WidgetKind } from "@homarr/definitions";
export type Board = RouterOutputs["board"]["getDefaultBoard"];
export type Section = Board["sections"][number];
export type Item = Section["items"][number];
export type CategorySection = Extract<Section, { kind: "category" }>;
export type EmptySection = Extract<Section, { kind: "empty" }>;
export type ItemOfKind<TKind extends WidgetKind> = Extract<
Item,
{ kind: TKind }
>;
-18
View File
@@ -1,18 +0,0 @@
import React from "react";
type PropsWithChildren = Required<React.PropsWithChildren>;
export const composeWrappers = (
wrappers: React.FunctionComponent<PropsWithChildren>[],
): React.FunctionComponent<PropsWithChildren> => {
return wrappers
.reverse()
.reduce((Acc, Current): React.FunctionComponent<PropsWithChildren> => {
// eslint-disable-next-line react/display-name
return (props) => (
<Current>
<Acc {...props} />
</Current>
);
});
};
@@ -1,80 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { Button, PasswordInput, Stack, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useForm, zodResolver } from "@homarr/form";
import {
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
export const InitUserForm = () => {
const router = useRouter();
const t = useScopedI18n("user");
const { mutateAsync, error, isPending } =
clientApi.user.initUser.useMutation();
const form = useForm<FormType>({
validate: zodResolver(validation.user.init),
validateInputOnBlur: true,
validateInputOnChange: true,
initialValues: {
username: "",
password: "",
confirmPassword: "",
},
});
const handleSubmit = async (values: FormType) => {
await mutateAsync(values, {
onSuccess: () => {
showSuccessNotification({
title: "User created",
message: "You can now log in",
});
router.push("/auth/login");
},
onError: () => {
showErrorNotification({
title: "User creation failed",
message: error?.message ?? "Unknown error",
});
},
});
};
return (
<Stack gap="xl">
<form
onSubmit={form.onSubmit(
(values) => void handleSubmit(values),
(err) => console.log(err),
)}
>
<Stack gap="lg">
<TextInput
label={t("field.username.label")}
{...form.getInputProps("username")}
/>
<PasswordInput
label={t("field.password.label")}
{...form.getInputProps("password")}
/>
<PasswordInput
label={t("field.passwordConfirm.label")}
{...form.getInputProps("confirmPassword")}
/>
<Button type="submit" fullWidth loading={isPending}>
{t("action.create")}
</Button>
</Stack>
</form>
</Stack>
);
};
type FormType = z.infer<typeof validation.user.init>;
@@ -1,41 +0,0 @@
import { notFound } from "next/navigation";
import { Card, Center, Stack, Text, Title } from "@mantine/core";
import { db } from "@homarr/db";
import { getScopedI18n } from "@homarr/translation/server";
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
import { InitUserForm } from "./_init-user-form";
export default async function InitUser() {
const firstUser = await db.query.users.findFirst({
columns: {
id: true,
},
});
if (firstUser) {
notFound();
}
const t = await getScopedI18n("user.page.init");
return (
<Center>
<Stack align="center" mt="xl">
<HomarrLogoWithTitle size="lg" />
<Stack gap={6} align="center">
<Title order={3} fw={400} ta="center">
{t("title")}
</Title>
<Text size="sm" c="gray.5" ta="center">
{t("subtitle")}
</Text>
</Stack>
<Card bg="dark.8" w={64 * 6} maw="90vw">
<InitUserForm />
</Card>
</Stack>
</Center>
);
}
-93
View File
@@ -1,93 +0,0 @@
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import "@homarr/notifications/styles.css";
import "@homarr/spotlight/styles.css";
import "@mantine/core/styles.css";
import { ColorSchemeScript, createTheme, MantineProvider } from "@mantine/core";
import { auth } from "@homarr/auth/next";
import { ModalProvider } from "@homarr/modals";
import { Notifications } from "@homarr/notifications";
import { JotaiProvider } from "./_client-providers/jotai";
import { NextInternationalProvider } from "./_client-providers/next-international";
import { AuthProvider } from "./_client-providers/session";
import { TRPCReactProvider } from "./_client-providers/trpc";
import { composeWrappers } from "./compose";
const fontSans = Inter({
subsets: ["latin"],
variable: "--font-sans",
});
export const metadata: Metadata = {
metadataBase: new URL("http://localhost:3000"),
title: "Homarr",
description:
"Simplify the management of your server with Homarr - a sleek, modern dashboard that puts all of your apps and services at your fingertips.",
openGraph: {
title: "Homarr Dashboard",
description:
"Simplify the management of your server with Homarr - a sleek, modern dashboard that puts all of your apps and services at your fingertips.",
url: "https://homarr.dev",
siteName: "Homarr Documentation",
},
twitter: {
card: "summary_large_image",
site: "@jullerino",
creator: "@jullerino",
},
};
export const viewport: Viewport = {
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "white" },
{ media: "(prefers-color-scheme: dark)", color: "black" },
],
};
export default function Layout(props: {
children: React.ReactNode;
params: { locale: string };
}) {
const colorScheme = "dark";
const StackedProvider = composeWrappers([
async (innerProps) => {
const session = await auth();
return <AuthProvider session={session} {...innerProps} />;
},
(innerProps) => <JotaiProvider {...innerProps} />,
(innerProps) => <TRPCReactProvider {...innerProps} />,
(innerProps) => (
<NextInternationalProvider {...innerProps} locale={props.params.locale} />
),
(innerProps) => (
<MantineProvider
{...innerProps}
defaultColorScheme="dark"
theme={createTheme({
primaryColor: "red",
autoContrast: true,
})}
/>
),
(innerProps) => <ModalProvider {...innerProps} />,
]);
return (
<html lang="en" suppressHydrationWarning>
<head>
<ColorSchemeScript defaultColorScheme={colorScheme} />
</head>
<body className={["font-sans", fontSans.variable].join(" ")}>
<StackedProvider>
<Notifications />
{props.children}
</StackedProvider>
</body>
</html>
);
}
-9
View File
@@ -1,9 +0,0 @@
import { Center, Loader } from "@mantine/core";
export default function CommonLoading() {
return (
<Center h="100vh">
<Loader />
</Center>
);
}
@@ -1,17 +0,0 @@
import { Center, Stack, Text, Title } from "@mantine/core";
import { getScopedI18n } from "@homarr/translation/server";
export default async function NotFound() {
const t = await getScopedI18n("management.notFound");
return (
<Center h="100%">
<Stack align="center">
<Title order={1} tt="uppercase">
{t("title")}
</Title>
<Text>{t("text")}</Text>
</Stack>
</Center>
);
}
@@ -1,36 +0,0 @@
.root {
border-radius: var(--mantine-radius-sm);
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-6)
);
}
.item {
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-6)
);
border: 1px solid transparent;
position: relative;
z-index: 0;
transition: transform 150ms ease;
&[data-active] {
transform: scale(1.015);
z-index: 1;
background-color: var(--mantine-color-body);
border-color: light-dark(
var(--mantine-color-gray-2),
var(--mantine-color-dark-4)
);
box-shadow: var(--mantine-shadow-md);
border-radius: var(--mantine-radius-md);
}
}
.chevron {
&[data-rotate] {
transform: rotate(-90deg);
}
}
@@ -1,109 +0,0 @@
import Image from "next/image";
import {
Accordion,
AccordionControl,
AccordionItem,
AccordionPanel,
Center,
Group,
List,
ListItem,
Stack,
Text,
Title,
} from "@mantine/core";
import { IconLanguage, IconLibrary, IconUsers } from "@tabler/icons-react";
import { setStaticParamsLocale } from "next-international/server";
import { getScopedI18n, getStaticParams } from "@homarr/translation/server";
import { getPackageAttributesAsync } from "~/versions/package-reader";
import logo from "../../../../../public/logo/logo.png";
import classes from "./accordion.module.css";
export async function generateMetadata() {
const t = await getScopedI18n("management");
const metaTitle = `${t("metaTitle")} • Homarr`;
return {
title: metaTitle,
};
}
interface PageProps {
params: {
locale: string;
};
}
export default async function AboutPage({ params: { locale } }: PageProps) {
setStaticParamsLocale(locale);
const t = await getScopedI18n("management.page.about");
const attributes = await getPackageAttributesAsync();
return (
<div>
<Center w="100%">
<Group py="lg">
<Image src={logo} width={100} height={100} alt="" />
<Stack gap={0}>
<Title order={1} tt="uppercase">
Homarr
</Title>
<Title order={2}>
{t("version", { version: attributes.version })}
</Title>
</Stack>
</Group>
</Center>
<Text mb="xl">{t("text")}</Text>
<Accordion classNames={classes}>
<AccordionItem value="contributors">
<AccordionControl icon={<IconUsers size="1rem" />}>
{t("accordion.contributors.title")}
</AccordionControl>
<AccordionPanel></AccordionPanel>
</AccordionItem>
<AccordionItem value="translators">
<AccordionControl icon={<IconLanguage size="1rem" />}>
{t("accordion.translators.title")}
</AccordionControl>
<AccordionPanel></AccordionPanel>
</AccordionItem>
<AccordionItem value="libraries">
<AccordionControl icon={<IconLibrary size="1rem" />}>
<Stack gap={0}>
<Text>{t("accordion.libraries.title")}</Text>
<Text size="sm" c="dimmed">
{t("accordion.libraries.subtitle", {
count: Object.keys(attributes.dependencies).length,
})}
</Text>
</Stack>
</AccordionControl>
<AccordionPanel>
<List>
{Object.entries(attributes.dependencies)
.sort(([key1], [key2]) => key1.localeCompare(key2))
.map(([key, value]) => (
<ListItem key={key}>
{value.includes("workspace:") ? (
<Text>{key}</Text>
) : (
<a href={`https://www.npmjs.com/package/${key}`}>{key}</a>
)}
</ListItem>
))}
</List>
</AccordionPanel>
</AccordionItem>
</Accordion>
</div>
);
}
export function generateStaticParams() {
return getStaticParams();
}
export const dynamic = "force-static";
@@ -1,65 +0,0 @@
"use client";
import { useCallback } from "react";
import { ActionIcon } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useConfirmModal } from "@homarr/modals";
import {
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { revalidatePathAction } from "../../../revalidatePathAction";
interface AppDeleteButtonProps {
app: RouterOutputs["app"]["all"][number];
}
export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
const t = useScopedI18n("app.page.delete");
const { openConfirmModal } = useConfirmModal();
const { mutate, isPending } = clientApi.app.delete.useMutation();
const onClick = useCallback(() => {
openConfirmModal({
title: t("title"),
children: t("message", app),
onConfirm: () => {
mutate(
{ id: app.id },
{
onSuccess: () => {
showSuccessNotification({
title: t("notification.success.title"),
message: t("notification.success.message"),
});
void revalidatePathAction("/manage/apps");
},
onError: () => {
showErrorNotification({
title: t("notification.error.title"),
message: t("notification.error.message"),
});
},
},
);
},
});
}, [app, mutate, t, openConfirmModal]);
return (
<ActionIcon
loading={isPending}
variant="subtle"
color="red"
onClick={onClick}
aria-label="Delete app"
>
<IconTrash color="red" size={16} stroke={1.5} />
</ActionIcon>
);
};
@@ -1,60 +0,0 @@
"use client";
import Link from "next/link";
import { Button, Group, Stack, Textarea, TextInput } from "@mantine/core";
import { useForm, zodResolver } from "@homarr/form";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
// TODO: add icon picker
type FormType = z.infer<typeof validation.app.manage>;
interface AppFormProps {
submitButtonTranslation: (t: TranslationFunction) => string;
initialValues?: FormType;
handleSubmit: (values: FormType) => void;
isPending: boolean;
}
export const AppForm = (props: AppFormProps) => {
const { submitButtonTranslation, handleSubmit, initialValues, isPending } =
props;
const t = useI18n();
const form = useForm({
initialValues: initialValues ?? {
name: "",
description: "",
iconUrl: "",
href: "",
},
validate: zodResolver(validation.app.manage),
});
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput {...form.getInputProps("name")} withAsterisk label="Name" />
<TextInput
{...form.getInputProps("iconUrl")}
withAsterisk
label="Icon URL"
/>
<Textarea {...form.getInputProps("description")} label="Description" />
<TextInput {...form.getInputProps("href")} label="URL" />
<Group justify="end">
<Button variant="default" component={Link} href="/manage/apps">
{t("common.action.backToOverview")}
</Button>
<Button type="submit" loading={isPending}>
{submitButtonTranslation(t)}
</Button>
</Group>
</Stack>
</form>
);
};
@@ -1,68 +0,0 @@
"use client";
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import {
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import type { TranslationFunction } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client";
import type { validation, z } from "@homarr/validation";
import { revalidatePathAction } from "~/app/revalidatePathAction";
import { AppForm } from "../../_form";
interface AppEditFormProps {
app: RouterOutputs["app"]["byId"];
}
export const AppEditForm = ({ app }: AppEditFormProps) => {
const t = useScopedI18n("app.page.edit.notification");
const router = useRouter();
const { mutate, isPending } = clientApi.app.update.useMutation({
onSuccess: () => {
showSuccessNotification({
title: t("success.title"),
message: t("success.message"),
});
void revalidatePathAction("/manage/apps").then(() => {
router.push("/manage/apps");
});
},
onError: () => {
showErrorNotification({
title: t("error.title"),
message: t("error.message"),
});
},
});
const handleSubmit = useCallback(
(values: z.infer<typeof validation.app.manage>) => {
mutate({
id: app.id,
...values,
});
},
[mutate, app.id],
);
const submitButtonTranslation = useCallback(
(t: TranslationFunction) => t("common.action.save"),
[],
);
return (
<AppForm
submitButtonTranslation={submitButtonTranslation}
initialValues={app}
handleSubmit={handleSubmit}
isPending={isPending}
/>
);
};
@@ -1,24 +0,0 @@
import { Container, Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import { getI18n } from "@homarr/translation/server";
import { AppEditForm } from "./_app-edit-form";
interface AppEditPageProps {
params: { id: string };
}
export default async function AppEditPage({ params }: AppEditPageProps) {
const app = await api.app.byId({ id: params.id });
const t = await getI18n();
return (
<Container>
<Stack>
<Title>{t("app.page.edit.title")}</Title>
<AppEditForm app={app} />
</Stack>
</Container>
);
}
@@ -1,59 +0,0 @@
"use client";
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import { clientApi } from "@homarr/api/client";
import {
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import type { TranslationFunction } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client";
import type { validation, z } from "@homarr/validation";
import { revalidatePathAction } from "~/app/revalidatePathAction";
import { AppForm } from "../_form";
export const AppNewForm = () => {
const t = useScopedI18n("app.page.create.notification");
const router = useRouter();
const { mutate, isPending } = clientApi.app.create.useMutation({
onSuccess: () => {
showSuccessNotification({
title: t("success.title"),
message: t("success.message"),
});
void revalidatePathAction("/manage/apps").then(() => {
router.push("/manage/apps");
});
},
onError: () => {
showErrorNotification({
title: t("error.title"),
message: t("error.message"),
});
},
});
const handleSubmit = useCallback(
(values: z.infer<typeof validation.app.manage>) => {
mutate(values);
},
[mutate],
);
const submitButtonTranslation = useCallback(
(t: TranslationFunction) => t("common.action.create"),
[],
);
return (
<AppForm
submitButtonTranslation={submitButtonTranslation}
handleSubmit={handleSubmit}
isPending={isPending}
/>
);
};
@@ -1,14 +0,0 @@
import { Container, Stack, Title } from "@mantine/core";
import { AppNewForm } from "./_app-new-form";
export default function AppNewPage() {
return (
<Container>
<Stack>
<Title>New app</Title>
<AppNewForm />
</Stack>
</Container>
);
}
@@ -1,116 +0,0 @@
import Link from "next/link";
import {
ActionIcon,
ActionIconGroup,
Anchor,
Avatar,
Button,
Card,
Container,
Group,
Stack,
Text,
Title,
} from "@mantine/core";
import { IconApps, IconPencil } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { getI18n } from "@homarr/translation/server";
import { AppDeleteButton } from "./_app-delete-button";
export default async function AppsPage() {
const apps = await api.app.all();
return (
<Container>
<Stack>
<Group justify="space-between" align="center">
<Title>Apps</Title>
<Button component={Link} href="/manage/apps/new">
New app
</Button>
</Group>
{apps.length === 0 && <AppNoResults />}
{apps.length > 0 && (
<Stack gap="sm">
{apps.map((app) => (
<AppCard key={app.id} app={app} />
))}
</Stack>
)}
</Stack>
</Container>
);
}
interface AppCardProps {
app: RouterOutputs["app"]["all"][number];
}
const AppCard = ({ app }: AppCardProps) => {
return (
<Card>
<Group justify="space-between">
<Group align="top" justify="start" wrap="nowrap">
<Avatar
size="sm"
src={app.iconUrl}
radius={0}
styles={{
image: {
objectFit: "contain",
},
}}
/>
<Stack gap={0}>
<Text fw={500}>{app.name}</Text>
{app.description && (
<Text size="sm" c="gray.6">
{app.description}
</Text>
)}
{app.href && (
<Anchor href={app.href} size="sm" w="min-content">
{app.href}
</Anchor>
)}
</Stack>
</Group>
<Group>
<ActionIconGroup>
<ActionIcon
component={Link}
href={`/manage/apps/edit/${app.id}`}
variant="subtle"
color="gray"
aria-label="Edit app"
>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
<AppDeleteButton app={app} />
</ActionIconGroup>
</Group>
</Group>
</Card>
);
};
const AppNoResults = async () => {
const t = await getI18n();
return (
<Card withBorder bg="transparent">
<Stack align="center" gap="sm">
<IconApps size="2rem" />
<Text fw={500} size="lg">
{t("app.page.list.noResults.title")}
</Text>
<Anchor href="/manage/apps/new">
{t("app.page.list.noResults.description")}
</Anchor>
</Stack>
</Card>
);
};
@@ -1,92 +0,0 @@
"use client";
import { useCallback } from "react";
import Link from "next/link";
import { Menu } from "@mantine/core";
import { IconSettings, IconTrash } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useConfirmModal } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import { revalidatePathAction } from "~/app/revalidatePathAction";
import { useBoardPermissions } from "~/components/board/permissions/client";
const iconProps = {
size: 16,
stroke: 1.5,
};
interface BoardCardMenuDropdownProps {
board: Pick<
RouterOutputs["board"]["getAllBoards"][number],
| "id"
| "name"
| "creator"
| "userPermissions"
| "groupPermissions"
| "isPublic"
>;
}
export const BoardCardMenuDropdown = ({
board,
}: BoardCardMenuDropdownProps) => {
const t = useScopedI18n("management.page.board.action");
const tCommon = useScopedI18n("common");
const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board);
const { openConfirmModal } = useConfirmModal();
const { mutateAsync, isPending } = clientApi.board.deleteBoard.useMutation({
onSettled: async () => {
await revalidatePathAction("/manage/boards");
},
});
const handleDeletion = useCallback(() => {
openConfirmModal({
title: t("delete.confirm.title"),
children: t("delete.confirm.description", {
name: board.name,
}),
onConfirm: async () => {
await mutateAsync({
id: board.id,
});
},
});
}, [board.id, board.name, mutateAsync, openConfirmModal, t]);
return (
<Menu.Dropdown>
{hasChangeAccess && (
<Menu.Item
component={Link}
href={`/boards/${board.name}/settings`}
leftSection={<IconSettings {...iconProps} />}
>
{t("settings.label")}
</Menu.Item>
)}
{hasFullAccess && (
<>
<Menu.Divider />
<Menu.Label c="red.7">
{tCommon("menu.section.dangerZone.title")}
</Menu.Label>
<Menu.Item
c="red.7"
leftSection={<IconTrash {...iconProps} />}
onClick={handleDeletion}
disabled={isPending}
>
{t("delete.label")}
</Menu.Item>
</>
)}
</Menu.Dropdown>
);
};
@@ -1,83 +0,0 @@
"use client";
import { Box, Card, Center, Flex, Loader, Text } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import dayjs from "dayjs";
import type { RouterOutputs } from "@homarr/api";
interface BoardPreviewProps {
board: RouterOutputs["board"]["getAllBoards"][number];
}
export const BoardPreview = ({ board }: BoardPreviewProps) => {
const { ref, width } = useElementSize();
return (
<Card
h="100%"
mah={width ? (width / 1920) * 1080 : undefined}
ref={ref}
styles={{
root: {
"--gridstack-column-count": board.columnCount,
"--gridstack-widget-width": width / board.columnCount,
},
}}
>
{width === 0 ? (
<Center h="100%">
<Loader size="sm" />
</Center>
) : (
<>
{board.sections.map((section) => (
<Section
key={section.id}
section={section}
itemWidth={width / board.columnCount}
/>
))}
</>
)}
</Card>
);
};
interface SectionProps {
section: RouterOutputs["board"]["getAllBoards"][number]["sections"][number];
itemWidth: number;
}
const Section = ({ section, itemWidth }: SectionProps) => {
const maxHeight = Math.max(
...section.items.map((item) => item.yOffset + item.height),
);
return (
<Card
withBorder={section.kind === "category"}
h={maxHeight * itemWidth}
key={section.id}
className="grid-stack"
>
{section.items.map((item) => (
<Box
className="grid-stack-item"
gs-h={item.height}
gs-w={item.width}
gs-x={item.xOffset}
gs-y={item.yOffset}
key={item.id}
p={4}
>
<Card withBorder h="100%" p={0}>
<Flex justify="center" align="center" h="100%" w="100%">
<Text fz={itemWidth / 4}>{dayjs().format("HH:mm")}</Text>
</Flex>
</Card>
</Box>
))}
</Card>
);
};
@@ -1,48 +0,0 @@
"use client";
import { useCallback } from "react";
import { Button } from "@mantine/core";
import { IconCategoryPlus } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { revalidatePathAction } from "~/app/revalidatePathAction";
import { AddBoardModal } from "~/components/manage/boards/add-board-modal";
interface CreateBoardButtonProps {
boardNames: string[];
}
export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => {
const t = useI18n();
const { openModal } = useModalAction(AddBoardModal);
const { mutateAsync, isPending } = clientApi.board.createBoard.useMutation({
onSettled: async () => {
await revalidatePathAction("/manage/boards");
},
});
const onClick = useCallback(() => {
openModal({
onSuccess: async (values) => {
await mutateAsync({
name: values.name,
});
},
boardNames,
});
}, [mutateAsync, boardNames, openModal]);
return (
<Button
leftSection={<IconCategoryPlus size="1rem" />}
onClick={onClick}
loading={isPending}
>
{t("management.page.board.action.new.label")}
</Button>
);
};
@@ -1,115 +0,0 @@
import Link from "next/link";
import {
ActionIcon,
Button,
Card,
CardSection,
Grid,
GridCol,
Group,
Menu,
MenuTarget,
Text,
Title,
Tooltip,
} from "@mantine/core";
import { IconDotsVertical, IconLock, IconWorld } from "@tabler/icons-react";
import "~/styles/gridstack.scss";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { getScopedI18n } from "@homarr/translation/server";
import { UserAvatar } from "@homarr/ui";
import { getBoardPermissions } from "~/components/board/permissions/server";
import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
import { BoardPreview } from "./_components/board-preview";
import { CreateBoardButton } from "./_components/create-board-button";
export default async function ManageBoardsPage() {
const t = await getScopedI18n("management.page.board");
const boards = await api.board.getAllBoards();
return (
<>
<Group justify="space-between">
<Title mb="md">{t("title")}</Title>
<CreateBoardButton boardNames={boards.map((board) => board.name)} />
</Group>
<Grid>
{boards.map((board) => (
<GridCol span={{ base: 12, md: 6, xl: 4 }} key={board.id}>
<BoardCard board={board} />
</GridCol>
))}
</Grid>
</>
);
}
interface BoardCardProps {
board: RouterOutputs["board"]["getAllBoards"][number];
}
const BoardCard = async ({ board }: BoardCardProps) => {
const t = await getScopedI18n("management.page.board");
const { hasChangeAccess: isMenuVisible } = await getBoardPermissions(board);
const visibility = board.isPublic ? "public" : "private";
const VisibilityIcon = board.isPublic ? IconWorld : IconLock;
return (
<Card>
<CardSection p="sm" withBorder>
<Group justify="space-between" align="center">
<Group gap="sm">
<Tooltip label={t(`visibility.${visibility}`)}>
<VisibilityIcon size={20} stroke={1.5} />
</Tooltip>
<Text fw="bolder" tt="uppercase">
{board.name}
</Text>
</Group>
{board.creator && (
<Group>
<Group gap="xs">
<UserAvatar user={board.creator} size="sm" />
<Text>{board.creator?.name}</Text>
</Group>
</Group>
)}
</Group>
</CardSection>
<CardSection p="sm" style={{ aspectRatio: 1920 / 1080 }}>
<BoardPreview board={board} />
</CardSection>
<CardSection p="sm">
<Group wrap="nowrap">
<Button
component={Link}
href={`/boards/${board.name}`}
variant="default"
fullWidth
>
{t("action.open.label")}
</Button>
{isMenuVisible && (
<Menu position="bottom-end">
<MenuTarget>
<ActionIcon variant="default" size="lg">
<IconDotsVertical size={16} stroke={1.5} />
</ActionIcon>
</MenuTarget>
<BoardCardMenuDropdown board={board} />
</Menu>
)}
</Group>
</CardSection>
</Card>
);
};
@@ -1,19 +0,0 @@
import { Avatar } from "@mantine/core";
import type { MantineSize } from "@mantine/core";
import { getIconUrl } from "@homarr/definitions";
import type { IntegrationKind } from "@homarr/definitions";
interface IntegrationAvatarProps {
size: MantineSize;
kind: IntegrationKind | null;
}
export const IntegrationAvatar = ({ kind, size }: IntegrationAvatarProps) => {
const url = kind ? getIconUrl(kind) : null;
if (!url) {
return null;
}
return <Avatar size={size} src={url} />;
};
@@ -1,70 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { ActionIcon } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useConfirmModal } from "@homarr/modals";
import {
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { revalidatePathAction } from "../../../revalidatePathAction";
interface DeleteIntegrationActionButtonProps {
count: number;
integration: { id: string; name: string };
}
export const DeleteIntegrationActionButton = ({
count,
integration,
}: DeleteIntegrationActionButtonProps) => {
const t = useScopedI18n("integration.page.delete");
const router = useRouter();
const { openConfirmModal } = useConfirmModal();
const { mutateAsync, isPending } = clientApi.integration.delete.useMutation();
return (
<ActionIcon
loading={isPending}
variant="subtle"
color="red"
onClick={() => {
openConfirmModal({
title: t("title"),
children: t("message", integration),
onConfirm: () => {
void mutateAsync(
{ id: integration.id },
{
onSuccess: () => {
showSuccessNotification({
title: t("notification.success.title"),
message: t("notification.success.message"),
});
if (count === 1) {
router.replace("/integrations");
}
void revalidatePathAction("/integrations");
},
onError: () => {
showErrorNotification({
title: t("notification.error.title"),
message: t("notification.error.message"),
});
},
},
);
},
});
}}
aria-label="Delete integration"
>
<IconTrash color="red" size={16} stroke={1.5} />
</ActionIcon>
);
};
@@ -1,94 +0,0 @@
"use client";
import { useState } from "react";
import { useParams } from "next/navigation";
import {
ActionIcon,
Avatar,
Button,
Card,
Collapse,
Group,
Kbd,
Stack,
Text,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconEye, IconEyeOff } from "@tabler/icons-react";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import type { RouterOutputs } from "@homarr/api";
import { integrationSecretKindObject } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { integrationSecretIcons } from "./_integration-secret-icons";
dayjs.extend(relativeTime);
interface SecretCardProps {
secret: RouterOutputs["integration"]["byId"]["secrets"][number];
children: React.ReactNode;
onCancel: () => Promise<boolean>;
}
export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
const params = useParams<{ locale: string }>();
const t = useI18n();
const { isPublic } = integrationSecretKindObject[secret.kind];
const [publicSecretDisplayOpened, { toggle: togglePublicSecretDisplay }] =
useDisclosure(false);
const [editMode, setEditMode] = useState(false);
const DisplayIcon = publicSecretDisplayOpened ? IconEye : IconEyeOff;
const KindIcon = integrationSecretIcons[secret.kind];
return (
<Card>
<Stack>
<Group justify="space-between">
<Group>
<Avatar>
<KindIcon size={16} />
</Avatar>
<Text fw={500}>
{t(`integration.secrets.kind.${secret.kind}.label`)}
</Text>
{publicSecretDisplayOpened ? <Kbd>{secret.value}</Kbd> : null}
</Group>
<Group>
<Text c="gray.6" size="sm">
{t("integration.secrets.lastUpdated", {
date: dayjs().locale(params.locale).to(dayjs(secret.updatedAt)),
})}
</Text>
{isPublic ? (
<ActionIcon
color="gray"
variant="subtle"
onClick={togglePublicSecretDisplay}
>
<DisplayIcon size={16} stroke={1.5} />
</ActionIcon>
) : null}
<Button
variant="default"
onClick={async () => {
if (!editMode) {
setEditMode(true);
return;
}
const shouldCancel = await onCancel();
if (!shouldCancel) return;
setEditMode(false);
}}
>
{editMode ? t("common.action.cancel") : t("common.action.edit")}
</Button>
</Group>
</Group>
<Collapse in={editMode}>{children}</Collapse>
</Stack>
</Card>
);
};
@@ -1,10 +0,0 @@
import { IconKey, IconPassword, IconUser } from "@tabler/icons-react";
import type { IntegrationSecretKind } from "@homarr/definitions";
import type { TablerIcon } from "@homarr/ui";
export const integrationSecretIcons = {
username: IconUser,
apiKey: IconKey,
password: IconPassword,
} satisfies Record<IntegrationSecretKind, TablerIcon>;
@@ -1,60 +0,0 @@
"use client";
import type { ChangeEventHandler, FocusEventHandler } from "react";
import { PasswordInput, TextInput } from "@mantine/core";
import { integrationSecretKindObject } from "@homarr/definitions";
import type { IntegrationSecretKind } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { integrationSecretIcons } from "./_integration-secret-icons";
interface IntegrationSecretInputProps {
label?: string;
kind: IntegrationSecretKind;
value?: string;
onChange: ChangeEventHandler<HTMLInputElement>;
onFocus?: FocusEventHandler<HTMLInputElement>;
onBlur?: FocusEventHandler<HTMLInputElement>;
error?: string;
}
export const IntegrationSecretInput = (props: IntegrationSecretInputProps) => {
const { isPublic } = integrationSecretKindObject[props.kind];
if (isPublic) return <PublicSecretInput {...props} />;
return <PrivateSecretInput {...props} />;
};
const PublicSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
const t = useI18n();
const Icon = integrationSecretIcons[kind];
return (
<TextInput
{...props}
label={props.label ?? t(`integration.secrets.kind.${kind}.label`)}
w="100%"
leftSection={<Icon size={20} stroke={1.5} />}
/>
);
};
const PrivateSecretInput = ({
kind,
...props
}: IntegrationSecretInputProps) => {
const t = useI18n();
const Icon = integrationSecretIcons[kind];
return (
<PasswordInput
{...props}
label={props.label ?? t(`integration.secrets.kind.${kind}.label`)}
description={t(`integration.secrets.secureNotice`)}
w="100%"
leftSection={<Icon size={20} stroke={1.5} />}
/>
);
};
@@ -1,154 +0,0 @@
"use client";
import { useRef, useState } from "react";
import { Alert, Anchor, Group, Loader } from "@mantine/core";
import { IconCheck, IconInfoCircle, IconX } from "@tabler/icons-react";
import type { RouterInputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import {
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
interface UseTestConnectionDirtyProps {
defaultDirty: boolean;
initialFormValue: {
url: string;
secrets: { kind: string; value: string | null }[];
};
}
export const useTestConnectionDirty = ({
defaultDirty,
initialFormValue,
}: UseTestConnectionDirtyProps) => {
const [isDirty, setIsDirty] = useState(defaultDirty);
const prevFormValueRef = useRef(initialFormValue);
return {
onValuesChange: (values: typeof initialFormValue) => {
if (isDirty) return;
// If relevant values changed, set dirty
if (
prevFormValueRef.current.url !== values.url ||
!prevFormValueRef.current.secrets
.map((secret) => secret.value)
.every(
(secretValue, index) =>
values.secrets[index]?.value === secretValue,
)
) {
setIsDirty(true);
return;
}
// If relevant values changed back to last tested, set not dirty
setIsDirty(false);
},
isDirty,
removeDirty: () => {
prevFormValueRef.current = initialFormValue;
setIsDirty(false);
},
};
};
interface TestConnectionProps {
isDirty: boolean;
removeDirty: () => void;
integration: RouterInputs["integration"]["testConnection"] & { name: string };
}
export const TestConnection = ({
integration,
removeDirty,
isDirty,
}: TestConnectionProps) => {
const t = useScopedI18n("integration.testConnection");
const { mutateAsync, ...mutation } =
clientApi.integration.testConnection.useMutation();
return (
<Group>
<Anchor
type="button"
component="button"
onClick={async () => {
await mutateAsync(integration, {
onSuccess: () => {
removeDirty();
showSuccessNotification({
title: t("notification.success.title"),
message: t("notification.success.message"),
});
},
onError: (error) => {
if (error.data?.zodError?.fieldErrors.url) {
showErrorNotification({
title: t("notification.invalidUrl.title"),
message: t("notification.invalidUrl.message"),
});
return;
}
if (error.message === "SECRETS_NOT_DEFINED") {
showErrorNotification({
title: t("notification.notAllSecretsProvided.title"),
message: t("notification.notAllSecretsProvided.message"),
});
return;
}
showErrorNotification({
title: t("notification.commonError.title"),
message: t("notification.commonError.message"),
});
},
});
}}
>
{t("action")}
</Anchor>
<TestConnectionIcon isDirty={isDirty} {...mutation} size={20} />
</Group>
);
};
interface TestConnectionIconProps {
isDirty: boolean;
isPending: boolean;
isSuccess: boolean;
isError: boolean;
size: number;
}
const TestConnectionIcon = ({
isDirty,
isPending,
isSuccess,
isError,
size,
}: TestConnectionIconProps) => {
if (isPending) return <Loader color="blue" size={size} />;
if (isDirty) return null;
if (isSuccess) return <IconCheck size={size} stroke={1.5} color="green" />;
if (isError) return <IconX size={size} stroke={1.5} color="red" />;
return null;
};
export const TestConnectionNoticeAlert = () => {
const t = useI18n();
return (
<Alert
variant="light"
color="yellow"
title="Test Connection"
icon={<IconInfoCircle />}
>
{t("integration.testConnection.alertNotice")}
</Alert>
);
};
@@ -1,180 +0,0 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Button, Fieldset, Group, Stack, TextInput } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import {
getAllSecretKindOptions,
getDefaultSecretKinds,
} from "@homarr/definitions";
import { useForm, zodResolver } from "@homarr/form";
import { useConfirmModal } from "@homarr/modals";
import {
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
import { revalidatePathAction } from "~/app/revalidatePathAction";
import { SecretCard } from "../../_integration-secret-card";
import { IntegrationSecretInput } from "../../_integration-secret-inputs";
import {
TestConnection,
TestConnectionNoticeAlert,
useTestConnectionDirty,
} from "../../_integration-test-connection";
interface EditIntegrationForm {
integration: RouterOutputs["integration"]["byId"];
}
export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
const t = useI18n();
const { openConfirmModal } = useConfirmModal();
const secretsKinds =
getAllSecretKindOptions(integration.kind).find((secretKinds) =>
integration.secrets.every((secret) => secretKinds.includes(secret.kind)),
) ?? getDefaultSecretKinds(integration.kind);
const initialFormValues = {
name: integration.name,
url: integration.url,
secrets: secretsKinds.map((kind) => ({
kind,
value:
integration.secrets.find((secret) => secret.kind === kind)?.value ?? "",
})),
};
const { isDirty, onValuesChange, removeDirty } = useTestConnectionDirty({
defaultDirty: true,
initialFormValue: initialFormValues,
});
const router = useRouter();
const form = useForm<FormType>({
initialValues: initialFormValues,
validate: zodResolver(validation.integration.update.omit({ id: true })),
onValuesChange,
});
const { mutateAsync, isPending } = clientApi.integration.update.useMutation();
const secretsMap = new Map(
integration.secrets.map((secret) => [secret.kind, secret]),
);
const handleSubmit = async (values: FormType) => {
if (isDirty) return;
await mutateAsync(
{
id: integration.id,
...values,
secrets: values.secrets.map((secret) => ({
kind: secret.kind,
value: secret.value === "" ? null : secret.value,
})),
},
{
onSuccess: () => {
showSuccessNotification({
title: t("integration.page.edit.notification.success.title"),
message: t("integration.page.edit.notification.success.message"),
});
void revalidatePathAction("/integrations").then(() =>
router.push("/integrations"),
);
},
onError: () => {
showErrorNotification({
title: t("integration.page.edit.notification.error.title"),
message: t("integration.page.edit.notification.error.message"),
});
},
},
);
};
return (
<form onSubmit={form.onSubmit((values) => void handleSubmit(values))}>
<Stack>
<TestConnectionNoticeAlert />
<TextInput
label={t("integration.field.name.label")}
{...form.getInputProps("name")}
/>
<TextInput
label={t("integration.field.url.label")}
{...form.getInputProps("url")}
/>
<Fieldset legend={t("integration.secrets.title")}>
<Stack gap="sm">
{secretsKinds.map((kind, index) => (
<SecretCard
key={kind}
secret={secretsMap.get(kind)!}
onCancel={() =>
new Promise((res) => {
// When nothing changed, just close the secret card
if (
(form.values.secrets[index]?.value ?? "") ===
(secretsMap.get(kind)?.value ?? "")
) {
return res(true);
}
openConfirmModal({
title: t("integration.secrets.reset.title"),
children: t("integration.secrets.reset.message"),
onCancel: () => res(false),
onConfirm: () => {
form.setFieldValue(
`secrets.${index}.value`,
secretsMap.get(kind)!.value ?? "",
);
res(true);
},
});
})
}
>
<IntegrationSecretInput
label={t(`integration.secrets.kind.${kind}.newLabel`)}
key={kind}
kind={kind}
{...form.getInputProps(`secrets.${index}.value`)}
/>
</SecretCard>
))}
</Stack>
</Fieldset>
<Group justify="space-between" align="center">
<TestConnection
isDirty={isDirty}
removeDirty={removeDirty}
integration={{
id: integration.id,
kind: integration.kind,
...form.values,
}}
/>
<Group>
<Button variant="default" component={Link} href="/integrations">
{t("common.action.backToOverview")}
</Button>
<Button type="submit" loading={isPending} disabled={isDirty}>
{t("common.action.save")}
</Button>
</Group>
</Group>
</Stack>
</form>
);
};
type FormType = Omit<z.infer<typeof validation.integration.update>, "id">;
@@ -1,33 +0,0 @@
import { Container, Group, Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import { getIntegrationName } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { IntegrationAvatar } from "../../_integration-avatar";
import { EditIntegrationForm } from "./_integration-edit-form";
interface EditIntegrationPageProps {
params: { id: string };
}
export default async function EditIntegrationPage({
params,
}: EditIntegrationPageProps) {
const t = await getScopedI18n("integration.page.edit");
const integration = await api.integration.byId({ id: params.id });
return (
<Container>
<Stack>
<Group align="center">
<IntegrationAvatar kind={integration.kind} size="md" />
<Title>
{t("title", { name: getIntegrationName(integration.kind) })}
</Title>
</Group>
<EditIntegrationForm integration={integration} />
</Stack>
</Container>
);
}
@@ -1,57 +0,0 @@
"use client";
import React, { ChangeEvent, useMemo, useState } from "react";
import Link from "next/link";
import { Group, Menu, ScrollArea, Stack, Text, TextInput } from "@mantine/core";
import { IconSearch } from "@tabler/icons-react";
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "../_integration-avatar";
export const IntegrationCreateDropdownContent = () => {
const t = useI18n();
const [search, setSearch] = useState("");
const filteredKinds = useMemo(() => {
return integrationKinds.filter((kind) =>
kind.includes(search.toLowerCase()),
);
}, [search]);
const handleSearch = React.useCallback(
(event: ChangeEvent<HTMLInputElement>) => setSearch(event.target.value),
[setSearch],
);
return (
<Stack>
<TextInput
leftSection={<IconSearch stroke={1.5} size={20} />}
placeholder={t("integration.page.list.search")}
value={search}
onChange={handleSearch}
/>
{filteredKinds.length > 0 ? (
<ScrollArea.Autosize mah={384}>
{filteredKinds.map((kind) => (
<Menu.Item
component={Link}
href={`/integrations/new?kind=${kind}`}
key={kind}
>
<Group>
<IntegrationAvatar kind={kind} size="sm" />
<Text size="sm">{getIntegrationName(kind)}</Text>
</Group>
</Menu.Item>
))}
</ScrollArea.Autosize>
) : (
<Menu.Item disabled>{t("common.noResults")}</Menu.Item>
)}
</Stack>
);
};
@@ -1,192 +0,0 @@
"use client";
import { useCallback } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
Button,
Fieldset,
Group,
SegmentedControl,
Stack,
TextInput,
} from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type {
IntegrationKind,
IntegrationSecretKind,
} from "@homarr/definitions";
import { getAllSecretKindOptions } from "@homarr/definitions";
import type { UseFormReturnType } from "@homarr/form";
import { useForm, zodResolver } from "@homarr/form";
import {
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
import { IntegrationSecretInput } from "../_integration-secret-inputs";
import {
TestConnection,
TestConnectionNoticeAlert,
useTestConnectionDirty,
} from "../_integration-test-connection";
import { revalidatePathAction } from "../../../../revalidatePathAction";
interface NewIntegrationFormProps {
searchParams: Partial<z.infer<typeof validation.integration.create>> & {
kind: IntegrationKind;
};
}
export const NewIntegrationForm = ({
searchParams,
}: NewIntegrationFormProps) => {
const t = useI18n();
const secretKinds = getAllSecretKindOptions(searchParams.kind);
const initialFormValues = {
name: searchParams.name ?? "",
url: searchParams.url ?? "",
secrets: secretKinds[0].map((kind) => ({
kind,
value: "",
})),
};
const { isDirty, onValuesChange, removeDirty } = useTestConnectionDirty({
defaultDirty: true,
initialFormValue: initialFormValues,
});
const router = useRouter();
const form = useForm<FormType>({
initialValues: initialFormValues,
validate: zodResolver(validation.integration.create.omit({ kind: true })),
onValuesChange,
});
const { mutateAsync, isPending } = clientApi.integration.create.useMutation();
const handleSubmit = async (values: FormType) => {
if (isDirty) return;
await mutateAsync(
{
kind: searchParams.kind,
...values,
},
{
onSuccess: () => {
showSuccessNotification({
title: t("integration.page.create.notification.success.title"),
message: t("integration.page.create.notification.success.message"),
});
void revalidatePathAction("/integrations").then(() =>
router.push("/integrations"),
);
},
onError: () => {
showErrorNotification({
title: t("integration.page.create.notification.error.title"),
message: t("integration.page.create.notification.error.message"),
});
},
},
);
};
return (
<form onSubmit={form.onSubmit((value) => void handleSubmit(value))}>
<Stack>
<TestConnectionNoticeAlert />
<TextInput
label={t("integration.field.name.label")}
{...form.getInputProps("name")}
/>
<TextInput
label={t("integration.field.url.label")}
{...form.getInputProps("url")}
/>
<Fieldset legend={t("integration.secrets.title")}>
<Stack gap="sm">
{secretKinds.length > 1 && (
<SecretKindsSegmentedControl
secretKinds={secretKinds}
form={form}
/>
)}
{form.values.secrets.map(({ kind }, index) => (
<IntegrationSecretInput
key={kind}
kind={kind}
{...form.getInputProps(`secrets.${index}.value`)}
/>
))}
</Stack>
</Fieldset>
<Group justify="space-between" align="center">
<TestConnection
isDirty={isDirty}
removeDirty={removeDirty}
integration={{
id: null,
kind: searchParams.kind,
...form.values,
}}
/>
<Group>
<Button variant="default" component={Link} href="/integrations">
{t("common.action.backToOverview")}
</Button>
<Button type="submit" loading={isPending} disabled={isDirty}>
{t("common.action.create")}
</Button>
</Group>
</Group>
</Stack>
</form>
);
};
interface SecretKindsSegmentedControlProps {
secretKinds: IntegrationSecretKind[][];
form: UseFormReturnType<FormType, (values: FormType) => FormType>;
}
const SecretKindsSegmentedControl = ({
secretKinds,
form,
}: SecretKindsSegmentedControlProps) => {
const t = useScopedI18n("integration.secrets");
const secretKindGroups = secretKinds.map((kinds) => ({
label: kinds.map((kind) => t(`kind.${kind}.label`)).join(" & "),
value: kinds.join("-"),
}));
const onChange = useCallback(
(value: string) => {
const kinds = value.split("-") as IntegrationSecretKind[];
const secrets = kinds.map((kind) => ({
kind,
value: "",
}));
form.setFieldValue("secrets", secrets);
},
[form],
);
return (
<SegmentedControl
fullWidth
data={secretKindGroups}
onChange={onChange}
></SegmentedControl>
);
};
type FormType = Omit<z.infer<typeof validation.integration.create>, "kind">;
@@ -1,44 +0,0 @@
import { notFound } from "next/navigation";
import { Container, Group, Stack, Title } from "@mantine/core";
import type { IntegrationKind } from "@homarr/definitions";
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import type { validation } from "@homarr/validation";
import { z } from "@homarr/validation";
import { IntegrationAvatar } from "../_integration-avatar";
import { NewIntegrationForm } from "./_integration-new-form";
interface NewIntegrationPageProps {
searchParams: Partial<z.infer<typeof validation.integration.create>> & {
kind: IntegrationKind;
};
}
export default async function IntegrationsNewPage({
searchParams,
}: NewIntegrationPageProps) {
const result = z
.enum([integrationKinds[0]!, ...integrationKinds.slice(1)])
.safeParse(searchParams.kind);
if (!result.success) {
notFound();
}
const t = await getScopedI18n("integration.page.create");
const currentKind = result.data;
return (
<Container>
<Stack>
<Group align="center">
<IntegrationAvatar kind={currentKind} size="md" />
<Title>{t("title", { name: getIntegrationName(currentKind) })}</Title>
</Group>
<NewIntegrationForm searchParams={searchParams} />
</Stack>
</Container>
);
}
@@ -1,174 +0,0 @@
import Link from "next/link";
import {
AccordionControl,
AccordionItem,
AccordionPanel,
ActionIcon,
ActionIconGroup,
Anchor,
Button,
Container,
Group,
Menu,
MenuDropdown,
MenuTarget,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title,
} from "@mantine/core";
import { IconChevronDown, IconPencil } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { objectEntries } from "@homarr/common";
import type { IntegrationKind } from "@homarr/definitions";
import { getIntegrationName } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { CountBadge } from "@homarr/ui";
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
import { IntegrationAvatar } from "./_integration-avatar";
import { DeleteIntegrationActionButton } from "./_integration-buttons";
import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown";
interface IntegrationsPageProps {
searchParams: {
tab?: IntegrationKind;
};
}
export default async function IntegrationsPage({
searchParams,
}: IntegrationsPageProps) {
const integrations = await api.integration.all();
const t = await getScopedI18n("integration");
return (
<Container>
<Stack>
<Group justify="space-between" align="center">
<Title>{t("page.list.title")}</Title>
<Menu
width={256}
trapFocus
position="bottom-start"
withinPortal
shadow="md"
keepMounted={false}
>
<MenuTarget>
<Button rightSection={<IconChevronDown size={16} stroke={1.5} />}>
{t("action.create")}
</Button>
</MenuTarget>
<MenuDropdown>
<IntegrationCreateDropdownContent />
</MenuDropdown>
</Menu>
</Group>
<IntegrationList
integrations={integrations}
activeTab={searchParams.tab}
/>
</Stack>
</Container>
);
}
interface IntegrationListProps {
integrations: RouterOutputs["integration"]["all"];
activeTab?: IntegrationKind;
}
const IntegrationList = async ({
integrations,
activeTab,
}: IntegrationListProps) => {
const t = await getScopedI18n("integration");
if (integrations.length === 0) {
return <div>{t("page.list.empty")}</div>;
}
const grouppedIntegrations = integrations.reduce(
(acc, integration) => {
if (!acc[integration.kind]) {
acc[integration.kind] = [];
}
acc[integration.kind].push(integration);
return acc;
},
{} as Record<IntegrationKind, RouterOutputs["integration"]["all"]>,
);
return (
<ActiveTabAccordion defaultValue={activeTab} variant="separated">
{objectEntries(grouppedIntegrations).map(([kind, integrations]) => (
<AccordionItem key={kind} value={kind}>
<AccordionControl icon={<IntegrationAvatar size="sm" kind={kind} />}>
<Group>
<Text>{getIntegrationName(kind)}</Text>
<CountBadge count={integrations.length} />
</Group>
</AccordionControl>
<AccordionPanel>
<Table>
<TableThead>
<TableTr>
<TableTh>{t("field.name.label")}</TableTh>
<TableTh>{t("field.url.label")}</TableTh>
<TableTh />
</TableTr>
</TableThead>
<TableTbody>
{integrations.map((integration) => (
<TableTr key={integration.id}>
<TableTd>{integration.name}</TableTd>
<TableTd>
<Anchor
href={integration.url}
target="_blank"
rel="noreferrer"
size="sm"
>
{integration.url}
</Anchor>
</TableTd>
<TableTd>
<Group justify="end">
<ActionIconGroup>
<ActionIcon
component={Link}
href={`/integrations/edit/${integration.id}`}
variant="subtle"
color="gray"
aria-label="Edit integration"
>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
<DeleteIntegrationActionButton
integration={integration}
count={integrations.length}
/>
</ActionIconGroup>
</Group>
</TableTd>
</TableTr>
))}
</TableTbody>
</Table>
</AccordionPanel>
</AccordionItem>
))}
</ActiveTabAccordion>
);
};
@@ -1,134 +0,0 @@
import type { PropsWithChildren } from "react";
import { AppShellMain } from "@mantine/core";
import {
IconBook2,
IconBox,
IconBrandDiscord,
IconBrandDocker,
IconBrandGithub,
IconGitFork,
IconHome,
IconInfoSmall,
IconLayoutDashboard,
IconLogs,
IconMailForward,
IconPlug,
IconQuestionMark,
IconTool,
IconUser,
IconUsers,
IconUsersGroup,
} from "@tabler/icons-react";
import { getScopedI18n } from "@homarr/translation/server";
import { MainHeader } from "~/components/layout/header";
import type { NavigationLink } from "~/components/layout/navigation";
import { MainNavigation } from "~/components/layout/navigation";
import { ClientShell } from "~/components/layout/shell";
export default async function ManageLayout({ children }: PropsWithChildren) {
const t = await getScopedI18n("management.navbar");
const navigationLinks: NavigationLink[] = [
{
label: t("items.home"),
icon: IconHome,
href: "/manage",
},
{
icon: IconLayoutDashboard,
href: "/manage/boards",
label: t("items.boards"),
},
{
icon: IconBox,
href: "/manage/apps",
label: t("items.apps"),
},
{
icon: IconPlug,
href: "/manage/integrations",
label: t("items.integrations"),
},
{
icon: IconUser,
label: t("items.users.label"),
items: [
{
label: t("items.users.items.manage"),
icon: IconUsers,
href: "/manage/users",
},
{
label: t("items.users.items.invites"),
icon: IconMailForward,
href: "/manage/users/invites",
},
{
label: t("items.users.items.groups"),
icon: IconUsersGroup,
href: "/manage/users/groups",
},
],
},
{
label: t("items.tools.label"),
icon: IconTool,
items: [
{
label: t("items.tools.items.docker"),
icon: IconBrandDocker,
href: "/manage/tools/docker",
},
{
label: t("items.tools.items.logs"),
icon: IconLogs,
href: "/manage/tools/logs",
},
],
},
{
label: t("items.help.label"),
icon: IconQuestionMark,
items: [
{
label: t("items.help.items.documentation"),
icon: IconBook2,
href: "https://homarr.dev/docs/getting-started/prerequisites",
external: true,
},
{
label: t("items.help.items.submitIssue"),
icon: IconBrandGithub,
href: "https://github.com/ajnart/homarr/issues/new/choose",
external: true,
},
{
label: t("items.tools.items.docker"),
icon: IconBrandDiscord,
href: "https://discord.com/invite/aCsmEV5RgA",
external: true,
},
{
label: t("items.help.items.sourceCode"),
icon: IconGitFork,
href: "https://github.com/ajnart/homarr",
external: true,
},
],
},
{
label: t("items.about"),
icon: IconInfoSmall,
href: "/manage/about",
},
];
return (
<ClientShell hasNavigation>
<MainHeader></MainHeader>
<MainNavigation links={navigationLinks}></MainNavigation>
<AppShellMain>{children}</AppShellMain>
</ClientShell>
);
}
@@ -1,33 +0,0 @@
import { Title } from "@mantine/core";
import { getScopedI18n } from "@homarr/translation/server";
import { Test } from "./test";
export async function generateMetadata() {
const t = await getScopedI18n("management");
const metaTitle = `${t("metaTitle")} • Homarr`;
return {
title: metaTitle,
};
}
export default async function ManagementPage() {
const t = await getScopedI18n("management.title");
const dateNow = new Date();
const timeOfDay =
dateNow.getHours() < 10
? "morning"
: dateNow.getHours() < 17
? "afternoon"
: "evening";
return (
<>
<Title>{t(timeOfDay, { username: "admin" })}</Title>
<Test />
</>
);
}
Loaded 100 of 449 files, more files were not shown because too many files have changed in this diff. Show more