diff --git a/.deepsource.toml b/.deepsource.toml
new file mode 100644
index 000000000..edc4a8907
--- /dev/null
+++ b/.deepsource.toml
@@ -0,0 +1,11 @@
+version = 1
+
+[[analyzers]]
+name = "javascript"
+
+ [analyzers.meta]
+ plugins = ["react"]
+ environment = ["nodejs"]
+
+[[transformers]]
+name = "prettier"
\ No newline at end of file
diff --git a/.env.example b/.env.example
index e27b97f5f..e6c6a4819 100644
--- a/.env.example
+++ b/.env.example
@@ -13,3 +13,5 @@ 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
\ No newline at end of file
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
index 043f0f9bc..2c9aab231 100644
--- a/.github/FUNDING.yml
+++ b/.github/FUNDING.yml
@@ -1,3 +1,3 @@
# These are supported funding model platforms
-github: juliusmarminge
+open_collective: homarr
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
index ae1ccf2da..c2be403e0 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.yml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -26,4 +26,3 @@ body:
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.
-
diff --git a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md
new file mode 100644
index 000000000..b551590ab
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md
@@ -0,0 +1,13 @@
+
+
+
+
Homarr
+
+
+**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)
+
diff --git a/.github/renovate.json b/.github/renovate.json
index 0942ac01d..1f5acc772 100644
--- a/.github/renovate.json
+++ b/.github/renovate.json
@@ -1,17 +1,15 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
- "extends": [
- "config:base"
- ],
+ "extends": ["config:recommended"],
"packageRules": [
{
- "matchPackagePatterns": [
- "^@homarr/"
- ],
+ "matchPackagePatterns": ["^@homarr/"],
"enabled": false
}
],
"updateInternalDeps": true,
"rangeStrategy": "bump",
- "automerge": true
-}
\ No newline at end of file
+ "automerge": false,
+ "baseBranches": ["dev"],
+ "dependencyDashboard": false
+}
diff --git a/.github/workflows/automatic-release.yml b/.github/workflows/automatic-release.yml
index 8489bfce4..b573974b5 100644
--- a/.github/workflows/automatic-release.yml
+++ b/.github/workflows/automatic-release.yml
@@ -8,6 +8,10 @@ on:
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_AUTOMATIC_RELEASE }}
+permissions:
+ contents: write
+ pull-requests: write
+
jobs:
merge:
runs-on: ubuntu-latest
@@ -17,9 +21,9 @@ jobs:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
with:
- args: 'Preparing the automatic release...'
+ args: "Preparing the automatic release..."
- uses: actions/checkout@v4
- - uses: peter-evans/create-pull-request@v5
+ - uses: peter-evans/create-pull-request@v6
id: create-pull-request
with:
base: main
@@ -38,4 +42,4 @@ jobs:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
with:
- args: 'Deployment pull request has been created at [${{ steps.create-pull-request.outputs.pull-request-number }}](${{ steps.create-pull-request.outputs.pull-request-url }})'
\ No newline at end of file
+ args: "Deployment pull request has been created at [${{ steps.create-pull-request.outputs.pull-request-number }}](${{ steps.create-pull-request.outputs.pull-request-url }})"
diff --git a/.github/workflows/ci.yml b/.github/workflows/code-quality.yml
similarity index 84%
rename from .github/workflows/ci.yml
rename to .github/workflows/code-quality.yml
index cdd48bb9c..44846a959 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/code-quality.yml
@@ -1,4 +1,4 @@
-name: CI
+name: Code quality analysis
on:
pull_request:
@@ -55,3 +55,14 @@ jobs:
- 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
diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml
new file mode 100644
index 000000000..91bc51733
--- /dev/null
+++ b/.github/workflows/docker-image.yml
@@ -0,0 +1,89 @@
+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 }}"
diff --git a/.gitignore b/.gitignore
index a9669defa..0bc0e63b9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,7 @@
node_modules
.pnp
.pnp.js
+.idea/
# testing
coverage
@@ -44,4 +45,7 @@ yarn-error.log*
.turbo
# database
-db.sqlite
\ No newline at end of file
+db.sqlite
+
+# logs
+*.log
\ No newline at end of file
diff --git a/.nvmrc b/.nvmrc
index 87ec8842b..8b0beab16 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-18.18.2
+20.11.0
diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json
index d7f597354..5b850972c 100644
--- a/apps/nextjs/package.json
+++ b/apps/nextjs/package.json
@@ -25,39 +25,41 @@
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0",
- "@mantine/hooks": "^7.4.0",
- "@mantine/modals": "^7.4.0",
- "@mantine/tiptap": "^7.4.0",
- "@t3-oss/env-nextjs": "^0.7.1",
- "@tanstack/react-query": "^5.17.1",
- "@tanstack/react-query-devtools": "^5.17.1",
- "@tanstack/react-query-next-experimental": "5.17.1",
- "@tiptap/extension-link": "^2.1.13",
- "@tiptap/react": "^2.1.13",
- "@tiptap/starter-kit": "^2.1.13",
+ "@mantine/hooks": "^7.5.2",
+ "@mantine/modals": "^7.5.2",
+ "@mantine/tiptap": "^7.5.2",
+ "@t3-oss/env-nextjs": "^0.9.2",
+ "@tanstack/react-query": "^5.20.5",
+ "@tanstack/react-query-devtools": "^5.20.5",
+ "@tanstack/react-query-next-experimental": "5.20.5",
+ "@tiptap/extension-link": "^2.2.2",
+ "@tiptap/react": "^2.2.2",
+ "@tiptap/starter-kit": "^2.2.2",
"@trpc/client": "next",
"@trpc/next": "next",
"@trpc/react-query": "next",
"@trpc/server": "next",
"dayjs": "^1.11.10",
- "jotai": "^2.6.1",
- "mantine-modal-manager": "^7.4.0",
- "next": "^14.0.4",
- "postcss-preset-mantine": "^1.12.3",
+ "@homarr/gridstack": "^1.0.0",
+ "jotai": "^2.6.4",
+ "mantine-modal-manager": "^7.5.2",
+ "next": "^14.1.0",
+ "postcss-preset-mantine": "^1.13.0",
"react": "18.2.0",
"react-dom": "18.2.0",
+ "sass": "^1.70.0",
"superjson": "2.2.1"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
- "@types/node": "^18.18.13",
- "@types/react": "^18.2.46",
- "@types/react-dom": "^18.2.18",
+ "@types/node": "^20.11.17",
+ "@types/react": "^18.2.55",
+ "@types/react-dom": "^18.2.19",
"dotenv-cli": "^7.3.0",
"eslint": "^8.56.0",
- "prettier": "^3.1.0",
+ "prettier": "^3.2.5",
"typescript": "^5.3.3"
},
"eslintConfig": {
diff --git a/apps/nextjs/src/app/[locale]/(main)/integrations/_integration-buttons.tsx b/apps/nextjs/src/app/[locale]/(main)/integrations/_integration-buttons.tsx
index 1606cc891..a0e45642f 100644
--- a/apps/nextjs/src/app/[locale]/(main)/integrations/_integration-buttons.tsx
+++ b/apps/nextjs/src/app/[locale]/(main)/integrations/_integration-buttons.tsx
@@ -2,6 +2,7 @@
import { useRouter } from "next/navigation";
+import { clientApi } from "@homarr/api/client";
import {
showErrorNotification,
showSuccessNotification,
@@ -9,7 +10,6 @@ import {
import { useScopedI18n } from "@homarr/translation/client";
import { ActionIcon, IconTrash } from "@homarr/ui";
-import { api } from "~/trpc/react";
import { revalidatePathAction } from "../../../revalidatePathAction";
import { modalEvents } from "../../modals";
@@ -24,7 +24,7 @@ export const DeleteIntegrationActionButton = ({
}: DeleteIntegrationActionButtonProps) => {
const t = useScopedI18n("integration.page.delete");
const router = useRouter();
- const { mutateAsync, isPending } = api.integration.delete.useMutation();
+ const { mutateAsync, isPending } = clientApi.integration.delete.useMutation();
return (
{
const t = useScopedI18n("integration.testConnection");
const { mutateAsync, ...mutation } =
- api.integration.testConnection.useMutation();
+ clientApi.integration.testConnection.useMutation();
return (
diff --git a/apps/nextjs/src/app/[locale]/(main)/integrations/edit/[id]/_integration-edit-form.tsx b/apps/nextjs/src/app/[locale]/(main)/integrations/edit/[id]/_integration-edit-form.tsx
index 2de518af5..1f55d6635 100644
--- a/apps/nextjs/src/app/[locale]/(main)/integrations/edit/[id]/_integration-edit-form.tsx
+++ b/apps/nextjs/src/app/[locale]/(main)/integrations/edit/[id]/_integration-edit-form.tsx
@@ -4,6 +4,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import type { RouterOutputs } from "@homarr/api";
+import { clientApi } from "@homarr/api/client";
import { getSecretKinds } from "@homarr/definitions";
import { useForm, zodResolver } from "@homarr/form";
import {
@@ -16,7 +17,6 @@ import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
import { modalEvents } from "~/app/[locale]/modals";
-import { api } from "~/trpc/react";
import { SecretCard } from "../../_integration-secret-card";
import { IntegrationSecretInput } from "../../_integration-secret-inputs";
import {
@@ -55,7 +55,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
),
onValuesChange,
});
- const { mutateAsync, isPending } = api.integration.update.useMutation();
+ const { mutateAsync, isPending } = clientApi.integration.update.useMutation();
const secretsMap = new Map(
integration.secrets.map((secret) => [secret.kind, secret]),
diff --git a/apps/nextjs/src/app/[locale]/(main)/integrations/new/_integration-new-form.tsx b/apps/nextjs/src/app/[locale]/(main)/integrations/new/_integration-new-form.tsx
index 706cc3df1..c5fad467c 100644
--- a/apps/nextjs/src/app/[locale]/(main)/integrations/new/_integration-new-form.tsx
+++ b/apps/nextjs/src/app/[locale]/(main)/integrations/new/_integration-new-form.tsx
@@ -3,6 +3,7 @@
import Link from "next/link";
import { useRouter } from "next/navigation";
+import { clientApi } from "@homarr/api/client";
import type { IntegrationKind } from "@homarr/definitions";
import { getSecretKinds } from "@homarr/definitions";
import { useForm, zodResolver } from "@homarr/form";
@@ -15,7 +16,6 @@ import { Button, Fieldset, Group, Stack, TextInput } from "@homarr/ui";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
-import { api } from "~/trpc/react";
import { IntegrationSecretInput } from "../_integration-secret-inputs";
import {
TestConnection,
@@ -53,7 +53,7 @@ export const NewIntegrationForm = ({
validate: zodResolver(validation.integration.create.omit({ kind: true })),
onValuesChange,
});
- const { mutateAsync, isPending } = api.integration.create.useMutation();
+ const { mutateAsync, isPending } = clientApi.integration.create.useMutation();
const handleSubmit = async (values: FormType) => {
if (isDirty) return;
diff --git a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx
index 731c027ae..187c5c9f1 100644
--- a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx
+++ b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx
@@ -7,8 +7,9 @@ import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experime
import { loggerLink, unstable_httpBatchStreamLink } from "@trpc/client";
import superjson from "superjson";
+import { clientApi } from "@homarr/api/client";
+
import { env } from "~/env.mjs";
-import { api } from "~/trpc/react";
const getBaseUrl = () => {
if (typeof window !== "undefined") return ""; // browser should use relative url
@@ -33,7 +34,7 @@ export function TRPCReactProvider(props: {
);
const [trpcClient] = useState(() =>
- api.createClient({
+ clientApi.createClient({
transformer: superjson,
links: [
loggerLink({
@@ -54,13 +55,13 @@ export function TRPCReactProvider(props: {
);
return (
-
+
{props.children}
-
+
);
}
diff --git a/apps/nextjs/src/app/[locale]/auth/login/page.tsx b/apps/nextjs/src/app/[locale]/auth/login/page.tsx
index e9d8ec451..4ad6bd3f8 100644
--- a/apps/nextjs/src/app/[locale]/auth/login/page.tsx
+++ b/apps/nextjs/src/app/[locale]/auth/login/page.tsx
@@ -1,7 +1,7 @@
import { getScopedI18n } from "@homarr/translation/server";
import { Card, Center, Stack, Text, Title } from "@homarr/ui";
-import { LogoWithTitle } from "~/components/layout/logo";
+import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
import { LoginForm } from "./_login-form";
export default async function Login() {
@@ -10,7 +10,7 @@ export default async function Login() {
return (
-
+
{t("title")}
diff --git a/apps/nextjs/src/app/[locale]/boards/(default)/@headeractions/page.tsx b/apps/nextjs/src/app/[locale]/boards/(default)/@headeractions/page.tsx
new file mode 100644
index 000000000..faec58434
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/boards/(default)/@headeractions/page.tsx
@@ -0,0 +1,3 @@
+import headerActions from "../../[name]/@headeractions/page";
+
+export default headerActions;
diff --git a/apps/nextjs/src/app/[locale]/boards/(default)/_definition.ts b/apps/nextjs/src/app/[locale]/boards/(default)/_definition.ts
new file mode 100644
index 000000000..cf95e7c7c
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/boards/(default)/_definition.ts
@@ -0,0 +1,8 @@
+import { api } from "~/trpc/server";
+import { createBoardPage } from "../_creator";
+
+export default createBoardPage<{ locale: string }>({
+ async getInitialBoard() {
+ return await api.board.default.query();
+ },
+});
diff --git a/apps/nextjs/src/app/[locale]/boards/(default)/layout.tsx b/apps/nextjs/src/app/[locale]/boards/(default)/layout.tsx
new file mode 100644
index 000000000..7a2eb4b2c
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/boards/(default)/layout.tsx
@@ -0,0 +1,5 @@
+import definition from "./_definition";
+
+const { layout } = definition;
+
+export default layout;
diff --git a/apps/nextjs/src/app/[locale]/boards/(default)/page.tsx b/apps/nextjs/src/app/[locale]/boards/(default)/page.tsx
new file mode 100644
index 000000000..1ff09b28b
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/boards/(default)/page.tsx
@@ -0,0 +1,7 @@
+import definition from "./_definition";
+
+const { generateMetadata, page } = definition;
+
+export default page;
+
+export { generateMetadata };
diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/@headeractions/page.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/@headeractions/page.tsx
new file mode 100644
index 000000000..ae4d5cfa1
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/boards/[name]/@headeractions/page.tsx
@@ -0,0 +1,145 @@
+"use client";
+
+import { useAtom, useAtomValue } from "jotai";
+
+import { clientApi } from "@homarr/api/client";
+import {
+ showErrorNotification,
+ showSuccessNotification,
+} from "@homarr/notifications";
+import { useI18n, useScopedI18n } from "@homarr/translation/client";
+import {
+ Group,
+ IconBox,
+ IconBoxAlignTop,
+ IconChevronDown,
+ IconPackageImport,
+ IconPencil,
+ IconPencilOff,
+ IconPlus,
+ IconSettings,
+ Menu,
+} from "@homarr/ui";
+
+import { modalEvents } from "~/app/[locale]/modals";
+import { editModeAtom } from "~/components/board/editMode";
+import { useCategoryActions } from "~/components/board/sections/category/category-actions";
+import { HeaderButton } from "~/components/layout/header/button";
+import { useRequiredBoard } from "../../_context";
+
+export default function BoardViewHeaderActions() {
+ const isEditMode = useAtomValue(editModeAtom);
+ const board = useRequiredBoard();
+
+ return (
+ <>
+ {isEditMode && }
+
+
+
+
+
+
+ >
+ );
+}
+
+const AddMenu = () => {
+ const { addCategoryToEnd } = useCategoryActions();
+ const t = useI18n();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ }
+ onClick={() =>
+ modalEvents.openManagedModal({
+ title: t("item.create.title"),
+ size: "xl",
+ modal: "itemSelectModal",
+ innerProps: {},
+ })
+ }
+ >
+ {t("item.action.create")}
+
+ }>
+ {t("item.action.import")}
+
+
+
+
+ }
+ onClick={() =>
+ modalEvents.openManagedModal({
+ title: t("section.category.create.title"),
+ modal: "categoryEditModal",
+ innerProps: {
+ submitLabel: t("section.category.create.submit"),
+ category: {
+ id: "new",
+ name: "",
+ },
+ onSuccess({ name }) {
+ addCategoryToEnd({ name });
+ },
+ },
+ })
+ }
+ >
+ {t("section.category.action.create")}
+
+
+
+ );
+};
+
+const EditModeMenu = () => {
+ const [isEditMode, setEditMode] = useAtom(editModeAtom);
+ const board = useRequiredBoard();
+ const t = useScopedI18n("board.action.edit");
+ const { mutate: saveBoard, isPending } = clientApi.board.save.useMutation({
+ onSuccess() {
+ showSuccessNotification({
+ title: t("notification.success.title"),
+ message: t("notification.success.message"),
+ });
+ setEditMode(false);
+ },
+ onError() {
+ showErrorNotification({
+ title: t("notification.error.title"),
+ message: t("notification.error.message"),
+ });
+ },
+ });
+
+ const toggle = () => {
+ if (isEditMode)
+ return saveBoard({
+ boardId: board.id,
+ ...board,
+ });
+ setEditMode(true);
+ };
+
+ return (
+
+ {isEditMode ? (
+
+ ) : (
+
+ )}
+
+ );
+};
diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/@headeractions/settings/page.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/@headeractions/settings/page.tsx
new file mode 100644
index 000000000..8651981c7
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/boards/[name]/@headeractions/settings/page.tsx
@@ -0,0 +1,16 @@
+"use client";
+
+import { IconLayoutBoard } from "@homarr/ui";
+
+import { HeaderButton } from "~/components/layout/header/button";
+import { useRequiredBoard } from "../../../_context";
+
+export default function BoardViewLayout() {
+ const board = useRequiredBoard();
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/_definition.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/_definition.tsx
new file mode 100644
index 000000000..5436994e2
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/boards/[name]/_definition.tsx
@@ -0,0 +1,8 @@
+import { api } from "~/trpc/server";
+import { createBoardPage } from "../_creator";
+
+export default createBoardPage<{ locale: string; name: string }>({
+ async getInitialBoard({ name }) {
+ return await api.board.byName.query({ name });
+ },
+});
diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/layout.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/layout.tsx
new file mode 100644
index 000000000..7a2eb4b2c
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/boards/[name]/layout.tsx
@@ -0,0 +1,5 @@
+import definition from "./_definition";
+
+const { layout } = definition;
+
+export default layout;
diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/page.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/page.tsx
new file mode 100644
index 000000000..1ff09b28b
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/boards/[name]/page.tsx
@@ -0,0 +1,7 @@
+import definition from "./_definition";
+
+const { generateMetadata, page } = definition;
+
+export default page;
+
+export { generateMetadata };
diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx
new file mode 100644
index 000000000..ae9b65e9d
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx
@@ -0,0 +1,118 @@
+"use client";
+
+import { useEffect } from "react";
+import {
+ useDebouncedValue,
+ useDocumentTitle,
+ useFavicon,
+} from "@mantine/hooks";
+
+import { clientApi } from "@homarr/api/client";
+import { useForm } from "@homarr/form";
+import { useI18n } from "@homarr/translation/client";
+import { Button, Grid, Group, Stack, TextInput } from "@homarr/ui";
+
+import { useUpdateBoard } from "../../_client";
+import type { Board } from "../../_types";
+
+interface Props {
+ board: Board;
+}
+
+export const GeneralSettingsContent = ({ board }: Props) => {
+ const t = useI18n();
+ const { updateBoard } = useUpdateBoard();
+ const { mutate: saveGeneralSettings, isPending } =
+ clientApi.board.saveGeneralSettings.useMutation();
+ const form = useForm({
+ initialValues: {
+ pageTitle: board.pageTitle,
+ logoImageUrl: board.logoImageUrl,
+ metaTitle: board.metaTitle,
+ faviconImageUrl: board.faviconImageUrl,
+ },
+ onValuesChange({ pageTitle }) {
+ updateBoard((previous) => ({
+ ...previous,
+ pageTitle,
+ }));
+ },
+ });
+
+ useMetaTitlePreview(form.values.metaTitle);
+ useFaviconPreview(form.values.faviconImageUrl);
+ useLogoPreview(form.values.logoImageUrl);
+
+ return (
+
+ );
+};
+
+const useLogoPreview = (url: string | null) => {
+ const { updateBoard } = useUpdateBoard();
+ const [logoDebounced] = useDebouncedValue(url ?? "", 500);
+
+ useEffect(() => {
+ if (!logoDebounced.includes(".")) return;
+ updateBoard((previous) => ({
+ ...previous,
+ logoImageUrl: logoDebounced,
+ }));
+ }, [logoDebounced, updateBoard]);
+};
+
+const useMetaTitlePreview = (title: string | null) => {
+ const [metaTitleDebounced] = useDebouncedValue(title ?? "", 200);
+ useDocumentTitle(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 : "");
+};
diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx
new file mode 100644
index 000000000..fcfe89938
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx
@@ -0,0 +1,133 @@
+import { capitalize } from "@homarr/common";
+import { getScopedI18n } from "@homarr/translation/server";
+import {
+ Accordion,
+ AccordionControl,
+ AccordionItem,
+ AccordionPanel,
+ Button,
+ Container,
+ Divider,
+ Group,
+ IconAlertTriangle,
+ IconBrush,
+ IconLayout,
+ IconSettings,
+ Stack,
+ Text,
+ Title,
+} from "@homarr/ui";
+
+import { api } from "~/trpc/server";
+import { GeneralSettingsContent } from "./_general";
+
+interface Props {
+ params: {
+ name: string;
+ };
+}
+
+export default async function BoardSettingsPage({ params }: Props) {
+ const board = await api.board.byName.query({ name: params.name });
+ const t = await getScopedI18n("board.setting");
+
+ return (
+
+
+ {t("title", { boardName: capitalize(board.name) })}
+
+
+ }>
+
+ {t("section.general.title")}
+
+
+
+
+
+
+
+ }>
+
+ {t("section.layout.title")}
+
+
+
+
+
+ }>
+
+ {t("section.appearance.title")}
+
+
+
+
+
+ }>
+
+ {t("section.dangerZone.title")}
+
+
+
+
+
+
+
+
+ {t("section.dangerZone.action.rename.label")}
+
+
+ {t("section.dangerZone.action.rename.description")}
+
+
+
+ {t("section.dangerZone.action.rename.button")}
+
+
+
+
+
+
+ {t("section.dangerZone.action.visibility.label")}
+
+
+ {t(
+ "section.dangerZone.action.visibility.description.private",
+ )}
+
+
+
+ {t("section.dangerZone.action.visibility.button.private")}
+
+
+
+
+
+
+ {t("section.dangerZone.action.delete.label")}
+
+
+ {t("section.dangerZone.action.delete.description")}
+
+
+
+ {t("section.dangerZone.action.delete.button")}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/nextjs/src/app/[locale]/boards/_client.tsx b/apps/nextjs/src/app/[locale]/boards/_client.tsx
new file mode 100644
index 000000000..e3b18be59
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/boards/_client.tsx
@@ -0,0 +1,79 @@
+"use client";
+
+import { useCallback, useRef } from "react";
+
+import type { RouterOutputs } from "@homarr/api";
+import { clientApi } from "@homarr/api/client";
+import { Box, LoadingOverlay, Stack } from "@homarr/ui";
+
+import { BoardCategorySection } from "~/components/board/sections/category-section";
+import { BoardEmptySection } from "~/components/board/sections/empty-section";
+import { useIsBoardReady, useRequiredBoard } from "./_context";
+import type { CategorySection, EmptySection } from "./_types";
+
+type UpdateCallback = (
+ prev: RouterOutputs["board"]["default"],
+) => RouterOutputs["board"]["default"];
+
+export const useUpdateBoard = () => {
+ const utils = clientApi.useUtils();
+
+ const updateBoard = useCallback(
+ (updaterWithoutUndefined: UpdateCallback) => {
+ utils.board.default.setData(undefined, (previous) =>
+ previous ? updaterWithoutUndefined(previous) : previous,
+ );
+ },
+ [utils],
+ );
+
+ return {
+ updateBoard,
+ };
+};
+
+export const ClientBoard = () => {
+ const board = useRequiredBoard();
+ const isReady = useIsBoardReady();
+
+ const sectionsWithoutSidebars = board.sections
+ .filter(
+ (section): section is CategorySection | EmptySection =>
+ section.kind !== "sidebar",
+ )
+ .sort((a, b) => a.position - b.position);
+
+ const ref = useRef(null);
+
+ return (
+
+
+
+ {sectionsWithoutSidebars.map((section) =>
+ section.kind === "empty" ? (
+
+ ) : (
+
+ ),
+ )}
+
+
+ );
+};
diff --git a/apps/nextjs/src/app/[locale]/boards/_context.tsx b/apps/nextjs/src/app/[locale]/boards/_context.tsx
new file mode 100644
index 000000000..e3b69f1cb
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/boards/_context.tsx
@@ -0,0 +1,80 @@
+"use client";
+
+import type { PropsWithChildren } from "react";
+import { createContext, useCallback, useContext, useState } from "react";
+
+import type { RouterOutputs } from "@homarr/api";
+import { clientApi } from "@homarr/api/client";
+
+const BoardContext = createContext<{
+ board: RouterOutputs["board"]["default"];
+ isReady: boolean;
+ markAsReady: (id: string) => void;
+} | null>(null);
+
+export const BoardProvider = ({
+ children,
+ initialBoard,
+}: PropsWithChildren<{ initialBoard: RouterOutputs["board"]["default"] }>) => {
+ const [readySections, setReadySections] = useState([]);
+ const { data } = clientApi.board.default.useQuery(undefined, {
+ initialData: initialBoard,
+ refetchOnMount: false,
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ });
+
+ const markAsReady = useCallback((id: string) => {
+ setReadySections((previous) =>
+ previous.includes(id) ? previous : [...previous, id],
+ );
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+};
+
+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;
+};
diff --git a/apps/nextjs/src/app/[locale]/boards/_creator.tsx b/apps/nextjs/src/app/[locale]/boards/_creator.tsx
new file mode 100644
index 000000000..ec2ef4f97
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/boards/_creator.tsx
@@ -0,0 +1,70 @@
+import type { PropsWithChildren, ReactNode } from "react";
+import type { Metadata } from "next";
+
+import { capitalize } from "@homarr/common";
+import { AppShellMain } from "@homarr/ui";
+
+import { MainHeader } from "~/components/layout/header";
+import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
+import { ClientShell } from "~/components/layout/shell";
+import { ClientBoard } from "./_client";
+import { BoardProvider } from "./_context";
+import type { Board } from "./_types";
+// This is placed here because it's used in the layout and the page and because it's here it's not needed to load it everywhere
+import "../../../styles/gridstack.scss";
+
+import { GlobalItemServerDataRunner } from "@homarr/widgets";
+
+type Params = Record;
+
+interface Props {
+ getInitialBoard: (params: TParams) => Promise;
+}
+
+export const createBoardPage = >({
+ getInitialBoard,
+}: Props) => {
+ return {
+ layout: async ({
+ params,
+ children,
+ headeractions,
+ }: PropsWithChildren<{ params: TParams; headeractions: ReactNode }>) => {
+ const initialBoard = await getInitialBoard(params);
+
+ return (
+
+
+
+ }
+ actions={headeractions}
+ hasNavigation={false}
+ />
+ {children}
+
+
+
+ );
+ },
+ page: () => {
+ // TODO: Add check if board is private and user is not logged in
+
+ return ;
+ },
+ generateMetadata: async ({
+ params,
+ }: {
+ params: TParams;
+ }): Promise => {
+ const board = await getInitialBoard(params);
+
+ return {
+ title: board.metaTitle ?? `${capitalize(board.name)} board | Homarr`,
+ icons: {
+ icon: board.faviconImageUrl ? board.faviconImageUrl : undefined,
+ },
+ };
+ },
+ };
+};
diff --git a/apps/nextjs/src/app/[locale]/boards/_types.ts b/apps/nextjs/src/app/[locale]/boards/_types.ts
new file mode 100644
index 000000000..0e9d0495c
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/boards/_types.ts
@@ -0,0 +1,15 @@
+import type { RouterOutputs } from "@homarr/api";
+import type { WidgetKind } from "@homarr/definitions";
+
+export type Board = RouterOutputs["board"]["default"];
+export type Section = Board["sections"][number];
+export type Item = Section["items"][number];
+
+export type CategorySection = Extract;
+export type EmptySection = Extract;
+export type SidebarSection = Extract;
+
+export type ItemOfKind = Extract<
+ Item,
+ { kind: TKind }
+>;
diff --git a/apps/nextjs/src/app/[locale]/init/user/_init-user-form.tsx b/apps/nextjs/src/app/[locale]/init/user/_init-user-form.tsx
index 913d6ee57..af52171e4 100644
--- a/apps/nextjs/src/app/[locale]/init/user/_init-user-form.tsx
+++ b/apps/nextjs/src/app/[locale]/init/user/_init-user-form.tsx
@@ -2,6 +2,7 @@
import { useRouter } from "next/navigation";
+import { clientApi } from "@homarr/api/client";
import { useForm, zodResolver } from "@homarr/form";
import {
showErrorNotification,
@@ -12,12 +13,11 @@ import { Button, PasswordInput, Stack, TextInput } from "@homarr/ui";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
-import { api } from "~/trpc/react";
-
export const InitUserForm = () => {
const router = useRouter();
const t = useScopedI18n("user");
- const { mutateAsync, error, isPending } = api.user.initUser.useMutation();
+ const { mutateAsync, error, isPending } =
+ clientApi.user.initUser.useMutation();
const form = useForm({
validate: zodResolver(validation.user.init),
validateInputOnBlur: true,
diff --git a/apps/nextjs/src/app/[locale]/init/user/page.tsx b/apps/nextjs/src/app/[locale]/init/user/page.tsx
index 63e87e249..1537c2970 100644
--- a/apps/nextjs/src/app/[locale]/init/user/page.tsx
+++ b/apps/nextjs/src/app/[locale]/init/user/page.tsx
@@ -4,7 +4,7 @@ import { db } from "@homarr/db";
import { getScopedI18n } from "@homarr/translation/server";
import { Card, Center, Stack, Text, Title } from "@homarr/ui";
-import { LogoWithTitle } from "~/components/layout/logo";
+import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
import { InitUserForm } from "./_init-user-form";
export default async function InitUser() {
@@ -23,7 +23,7 @@ export default async function InitUser() {
return (
-
+
{t("title")}
diff --git a/apps/nextjs/src/app/[locale]/manage/boards/_components/create-board-button.tsx b/apps/nextjs/src/app/[locale]/manage/boards/_components/create-board-button.tsx
new file mode 100644
index 000000000..339ae07d8
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/manage/boards/_components/create-board-button.tsx
@@ -0,0 +1,28 @@
+"use client";
+
+import React from "react";
+
+import { clientApi } from "@homarr/api/client";
+import { useI18n } from "@homarr/translation/client";
+import { Button } from "@homarr/ui";
+
+import { revalidatePathAction } from "~/app/revalidatePathAction";
+
+export const CreateBoardButton = () => {
+ const t = useI18n();
+ const { mutateAsync, isPending } = clientApi.board.create.useMutation({
+ onSettled: async () => {
+ await revalidatePathAction("/manage/boards");
+ },
+ });
+
+ const onClick = React.useCallback(async () => {
+ await mutateAsync({ name: "default" });
+ }, [mutateAsync]);
+
+ return (
+
+ {t("management.page.board.button.create")}
+
+ );
+};
diff --git a/apps/nextjs/src/app/[locale]/manage/boards/_components/delete-board-button.tsx b/apps/nextjs/src/app/[locale]/manage/boards/_components/delete-board-button.tsx
new file mode 100644
index 000000000..4d49ed17a
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/manage/boards/_components/delete-board-button.tsx
@@ -0,0 +1,34 @@
+"use client";
+
+import React from "react";
+
+import { clientApi } from "@homarr/api/client";
+import { useI18n } from "@homarr/translation/client";
+import { Button } from "@homarr/ui";
+
+import { revalidatePathAction } from "~/app/revalidatePathAction";
+
+interface Props {
+ id: string;
+}
+
+export const DeleteBoardButton = ({ id }: Props) => {
+ const t = useI18n();
+ const { mutateAsync, isPending } = clientApi.board.delete.useMutation({
+ onSettled: async () => {
+ await revalidatePathAction("/manage/boards");
+ },
+ });
+
+ const onClick = React.useCallback(async () => {
+ await mutateAsync({
+ id,
+ });
+ }, [id, mutateAsync]);
+
+ return (
+
+ {t("management.page.board.button.delete")}
+
+ );
+};
diff --git a/apps/nextjs/src/app/[locale]/manage/boards/page.tsx b/apps/nextjs/src/app/[locale]/manage/boards/page.tsx
new file mode 100644
index 000000000..a5baaa881
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/manage/boards/page.tsx
@@ -0,0 +1,38 @@
+import React from "react";
+
+import { getScopedI18n } from "@homarr/translation/server";
+import { Card, Grid, GridCol, Text, Title } from "@homarr/ui";
+
+import { api } from "~/trpc/server";
+import { CreateBoardButton } from "./_components/create-board-button";
+import { DeleteBoardButton } from "./_components/delete-board-button";
+
+export default async function ManageBoardsPage() {
+ const t = await getScopedI18n("management.page.board");
+
+ const boards = await api.board.getAll.query();
+
+ return (
+ <>
+ {t("title")}
+
+
+
+
+ {boards.map((board) => (
+
+
+ {board.name}
+
+
+ {JSON.stringify(board)}
+
+
+
+
+
+ ))}
+
+ >
+ );
+}
diff --git a/apps/nextjs/src/app/[locale]/manage/layout.tsx b/apps/nextjs/src/app/[locale]/manage/layout.tsx
index fdf281713..b7193249c 100644
--- a/apps/nextjs/src/app/[locale]/manage/layout.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/layout.tsx
@@ -101,7 +101,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
];
return (
-
+
{children}
diff --git a/apps/nextjs/src/app/[locale]/modals.tsx b/apps/nextjs/src/app/[locale]/modals.tsx
index 331305f27..7ac3e15a5 100644
--- a/apps/nextjs/src/app/[locale]/modals.tsx
+++ b/apps/nextjs/src/app/[locale]/modals.tsx
@@ -4,6 +4,11 @@ import { createModalManager } from "mantine-modal-manager";
import { WidgetEditModal } from "@homarr/widgets";
+import { ItemSelectModal } from "~/components/board/items/item-select-modal";
+import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
+
export const [ModalsManager, modalEvents] = createModalManager({
+ categoryEditModal: CategoryEditModal,
widgetEditModal: WidgetEditModal,
+ itemSelectModal: ItemSelectModal,
});
diff --git a/apps/nextjs/src/app/[locale]/widgets/[sort]/_content.tsx b/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx
similarity index 81%
rename from apps/nextjs/src/app/[locale]/widgets/[sort]/_content.tsx
rename to apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx
index de3e36f13..f1a43a538 100644
--- a/apps/nextjs/src/app/[locale]/widgets/[sort]/_content.tsx
+++ b/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx
@@ -3,9 +3,8 @@
import { useState } from "react";
import type { WidgetOptionDefinition } from "node_modules/@homarr/widgets/src/options";
-import type { IntegrationKind } from "@homarr/definitions";
+import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { ActionIcon, Affix, IconPencil } from "@homarr/ui";
-import type { WidgetSort } from "@homarr/widgets";
import {
loadWidgetDynamic,
reduceWidgetOptionsWithDefaultValues,
@@ -15,7 +14,7 @@ import {
import { modalEvents } from "../../modals";
interface WidgetPreviewPageContentProps {
- sort: WidgetSort;
+ kind: WidgetKind;
integrationData: {
id: string;
name: string;
@@ -25,10 +24,10 @@ interface WidgetPreviewPageContentProps {
}
export const WidgetPreviewPageContent = ({
- sort,
+ kind,
integrationData,
}: WidgetPreviewPageContentProps) => {
- const currentDefinition = widgetImports[sort].definition;
+ const currentDefinition = widgetImports[kind].definition;
const options = currentDefinition.options as Record<
string,
WidgetOptionDefinition
@@ -37,11 +36,11 @@ export const WidgetPreviewPageContent = ({
options: Record;
integrations: string[];
}>({
- options: reduceWidgetOptionsWithDefaultValues(options),
+ options: reduceWidgetOptionsWithDefaultValues(kind, options),
integrations: [],
});
- const Comp = loadWidgetDynamic(sort);
+ const Comp = loadWidgetDynamic(kind);
return (
<>
@@ -60,9 +59,11 @@ export const WidgetPreviewPageContent = ({
return modalEvents.openManagedModal({
modal: "widgetEditModal",
innerProps: {
- sort,
- definition: currentDefinition.options,
- state: [state, setState],
+ kind,
+ value: state,
+ onSuccessfulEdit: (value) => {
+ setState(value);
+ },
integrationData: integrationData.filter(
(integration) =>
"supportedIntegrations" in currentDefinition &&
diff --git a/apps/nextjs/src/app/[locale]/widgets/[sort]/layout.tsx b/apps/nextjs/src/app/[locale]/widgets/[kind]/layout.tsx
similarity index 94%
rename from apps/nextjs/src/app/[locale]/widgets/[sort]/layout.tsx
rename to apps/nextjs/src/app/[locale]/widgets/[kind]/layout.tsx
index beae35636..cfd213f9d 100644
--- a/apps/nextjs/src/app/[locale]/widgets/[sort]/layout.tsx
+++ b/apps/nextjs/src/app/[locale]/widgets/[kind]/layout.tsx
@@ -10,7 +10,7 @@ const getLinks = () => {
return {
href: `/widgets/${key}`,
icon: value.definition.icon,
- label: value.definition.sort,
+ label: value.definition.kind,
};
});
};
diff --git a/apps/nextjs/src/app/[locale]/widgets/[sort]/page.tsx b/apps/nextjs/src/app/[locale]/widgets/[kind]/page.tsx
similarity index 62%
rename from apps/nextjs/src/app/[locale]/widgets/[sort]/page.tsx
rename to apps/nextjs/src/app/[locale]/widgets/[kind]/page.tsx
index 75419e656..14ea3693a 100644
--- a/apps/nextjs/src/app/[locale]/widgets/[sort]/page.tsx
+++ b/apps/nextjs/src/app/[locale]/widgets/[kind]/page.tsx
@@ -1,17 +1,18 @@
-import type { PropsWithChildren } from "react";
import { notFound } from "next/navigation";
import { db } from "@homarr/db";
+import type { WidgetKind } from "@homarr/definitions";
import { Center } from "@homarr/ui";
-import type { WidgetSort } from "@homarr/widgets";
import { widgetImports } from "@homarr/widgets";
import { WidgetPreviewPageContent } from "./_content";
-type Props = PropsWithChildren<{ params: { sort: string } }>;
+interface Props {
+ params: { kind: string };
+}
export default async function WidgetPreview(props: Props) {
- if (!(props.params.sort in widgetImports)) {
+ if (!(props.params.kind in widgetImports)) {
notFound();
}
@@ -24,11 +25,11 @@ export default async function WidgetPreview(props: Props) {
},
});
- const sort = props.params.sort as WidgetSort;
+ const sort = props.params.kind as WidgetKind;
return (
-
+
);
}
diff --git a/apps/nextjs/src/components/board/editMode.ts b/apps/nextjs/src/components/board/editMode.ts
new file mode 100644
index 000000000..186b60a0f
--- /dev/null
+++ b/apps/nextjs/src/components/board/editMode.ts
@@ -0,0 +1,3 @@
+import { atom } from "jotai";
+
+export const editModeAtom = atom(false);
diff --git a/apps/nextjs/src/components/board/items/item-actions.tsx b/apps/nextjs/src/components/board/items/item-actions.tsx
new file mode 100644
index 000000000..7ad175449
--- /dev/null
+++ b/apps/nextjs/src/components/board/items/item-actions.tsx
@@ -0,0 +1,201 @@
+import { useCallback } from "react";
+
+import { createId } from "@homarr/db/client";
+import type { WidgetKind } from "@homarr/definitions";
+
+import { useUpdateBoard } from "~/app/[locale]/boards/_client";
+import type { EmptySection, Item } from "~/app/[locale]/boards/_types";
+
+interface MoveAndResizeItem {
+ itemId: string;
+ xOffset: number;
+ yOffset: number;
+ width: number;
+ height: number;
+}
+interface MoveItemToSection {
+ itemId: string;
+ sectionId: string;
+ xOffset: number;
+ yOffset: number;
+ width: number;
+ height: number;
+}
+interface RemoveItem {
+ itemId: string;
+}
+
+interface UpdateItemOptions {
+ itemId: string;
+ newOptions: Record;
+}
+
+interface CreateItem {
+ kind: WidgetKind;
+}
+
+export const useItemActions = () => {
+ const { updateBoard } = useUpdateBoard();
+
+ const createItem = useCallback(
+ ({ kind }: CreateItem) => {
+ updateBoard((previous) => {
+ const lastSection = previous.sections
+ .filter((s): s is EmptySection => s.kind === "empty")
+ .sort((a, b) => b.position - a.position)[0];
+
+ if (!lastSection) return previous;
+
+ const widget = {
+ id: createId(),
+ kind,
+ options: {},
+ width: 1,
+ height: 1,
+ integrations: [],
+ } satisfies Omit- & {
+ kind: WidgetKind;
+ };
+
+ return {
+ ...previous,
+ sections: previous.sections.map((section) => {
+ // Return same section if item is not in it
+ if (section.id !== lastSection.id) return section;
+ return {
+ ...section,
+ items: section.items.concat(widget as unknown as Item),
+ };
+ }),
+ };
+ });
+ },
+ [updateBoard],
+ );
+
+ const updateItemOptions = useCallback(
+ ({ itemId, newOptions }: UpdateItemOptions) => {
+ updateBoard((previous) => {
+ if (!previous) return previous;
+ return {
+ ...previous,
+ sections: previous.sections.map((section) => {
+ // Return same section if item is not in it
+ if (!section.items.some((item) => item.id === itemId))
+ return section;
+ return {
+ ...section,
+ items: section.items.map((item) => {
+ // Return same item if item is not the one we're moving
+ if (item.id !== itemId) return item;
+ return {
+ ...item,
+ options: newOptions,
+ };
+ }),
+ };
+ }),
+ };
+ });
+ },
+ [updateBoard],
+ );
+
+ const moveAndResizeItem = useCallback(
+ ({ itemId, ...positionProps }: MoveAndResizeItem) => {
+ updateBoard((previous) => ({
+ ...previous,
+ sections: previous.sections.map((section) => {
+ // Return same section if item is not in it
+ if (!section.items.some((item) => item.id === itemId)) return section;
+ return {
+ ...section,
+ items: section.items.map((item) => {
+ // Return same item if item is not the one we're moving
+ if (item.id !== itemId) return item;
+ return {
+ ...item,
+ ...positionProps,
+ } satisfies Item;
+ }),
+ };
+ }),
+ }));
+ },
+ [updateBoard],
+ );
+
+ const moveItemToSection = useCallback(
+ ({ itemId, sectionId, ...positionProps }: MoveItemToSection) => {
+ updateBoard((previous) => {
+ const currentSection = previous.sections.find((section) =>
+ section.items.some((item) => item.id === itemId),
+ );
+
+ // If item is in the same section (on initial loading) don't do anything
+ if (!currentSection) {
+ return previous;
+ }
+
+ const currentItem = currentSection.items.find(
+ (item) => item.id === itemId,
+ );
+ if (!currentItem) {
+ return previous;
+ }
+
+ if (currentSection.id === sectionId && currentItem.xOffset) {
+ return previous;
+ }
+
+ return {
+ ...previous,
+ sections: previous.sections.map((section) => {
+ // Return sections without item if not section where it is moved to
+ if (section.id !== sectionId)
+ return {
+ ...section,
+ items: section.items.filter((item) => item.id !== itemId),
+ };
+
+ // Return section and add item to it
+ return {
+ ...section,
+ items: section.items
+ .filter((item) => item.id !== itemId)
+ .concat({
+ ...currentItem,
+ ...positionProps,
+ }),
+ };
+ }),
+ };
+ });
+ },
+ [updateBoard],
+ );
+
+ const removeItem = useCallback(
+ ({ itemId }: RemoveItem) => {
+ updateBoard((previous) => {
+ return {
+ ...previous,
+ // Filter removed item out of items array
+ sections: previous.sections.map((section) => ({
+ ...section,
+ items: section.items.filter((item) => item.id !== itemId),
+ })),
+ };
+ });
+ },
+ [updateBoard],
+ );
+
+ return {
+ moveAndResizeItem,
+ moveItemToSection,
+ removeItem,
+ updateItemOptions,
+ createItem,
+ };
+};
diff --git a/apps/nextjs/src/components/board/items/item-select-modal.tsx b/apps/nextjs/src/components/board/items/item-select-modal.tsx
new file mode 100644
index 000000000..087c6bea8
--- /dev/null
+++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx
@@ -0,0 +1,84 @@
+import type { ManagedModal } from "mantine-modal-manager";
+
+import type { WidgetKind } from "@homarr/definitions";
+import { useI18n } from "@homarr/translation/client";
+import { Button, Card, Center, Grid, Stack, Text } from "@homarr/ui";
+
+import { objectEntries } from "../../../../../../packages/common/src";
+import { widgetImports } from "../../../../../../packages/widgets/src";
+import type { WidgetDefinition } from "../../../../../../packages/widgets/src/definition";
+import { useItemActions } from "./item-actions";
+
+export const ItemSelectModal: ManagedModal
> = ({
+ actions,
+}) => {
+ return (
+
+ {objectEntries(widgetImports).map(([key, value]) => {
+ return (
+
+ );
+ })}
+
+ );
+};
+
+const WidgetItem = ({
+ kind,
+ definition,
+ closeModal,
+}: {
+ kind: WidgetKind;
+ definition: WidgetDefinition;
+ closeModal: () => void;
+}) => {
+ const t = useI18n();
+ const { createItem } = useItemActions();
+ const handleAdd = (kind: WidgetKind) => {
+ createItem({ kind });
+ closeModal();
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ {t(`widget.${kind}.name`)}
+
+
+ {t(`widget.${kind}.description`)}
+
+
+ {
+ handleAdd(kind);
+ }}
+ variant="light"
+ size="xs"
+ mt="auto"
+ radius="md"
+ fullWidth
+ >
+ {t(`item.create.addToBoard`)}
+
+
+
+
+ );
+};
diff --git a/apps/nextjs/src/components/board/sections/category-section.tsx b/apps/nextjs/src/components/board/sections/category-section.tsx
new file mode 100644
index 000000000..3397b045d
--- /dev/null
+++ b/apps/nextjs/src/components/board/sections/category-section.tsx
@@ -0,0 +1,58 @@
+import type { RefObject } from "react";
+import { useDisclosure } from "@mantine/hooks";
+
+import {
+ Card,
+ Collapse,
+ Group,
+ IconChevronDown,
+ IconChevronUp,
+ Stack,
+ Title,
+ UnstyledButton,
+} from "@homarr/ui";
+
+import type { CategorySection } from "~/app/[locale]/boards/_types";
+import { CategoryMenu } from "./category/category-menu";
+import { SectionContent } from "./content";
+import { useGridstack } from "./gridstack/use-gridstack";
+
+interface Props {
+ section: CategorySection;
+ mainRef: RefObject;
+}
+
+export const BoardCategorySection = ({ section, mainRef }: Props) => {
+ const { refs } = useGridstack({ section, mainRef });
+ const [opened, { toggle }] = useDisclosure(false);
+
+ return (
+
+
+
+
+
+ {opened ? (
+
+ ) : (
+
+ )}
+ {section.name}
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/nextjs/src/components/board/sections/category/category-actions.ts b/apps/nextjs/src/components/board/sections/category/category-actions.ts
new file mode 100644
index 000000000..16c1b6714
--- /dev/null
+++ b/apps/nextjs/src/components/board/sections/category/category-actions.ts
@@ -0,0 +1,284 @@
+import { useCallback } from "react";
+
+import { createId } from "@homarr/db/client";
+
+import { useUpdateBoard } from "~/app/[locale]/boards/_client";
+import type {
+ CategorySection,
+ EmptySection,
+ Section,
+} from "~/app/[locale]/boards/_types";
+
+interface AddCategory {
+ name: string;
+ position: number;
+}
+
+interface RenameCategory {
+ id: string;
+ name: string;
+}
+
+interface MoveCategory {
+ id: string;
+ direction: "up" | "down";
+}
+
+interface RemoveCategory {
+ id: string;
+}
+
+export const useCategoryActions = () => {
+ const { updateBoard } = useUpdateBoard();
+
+ const addCategory = useCallback(
+ ({ name, position }: AddCategory) => {
+ if (position <= -1) {
+ return;
+ }
+ updateBoard((previous) => ({
+ ...previous,
+ sections: [
+ // Ignore sidebar sections
+ ...previous.sections.filter((section) => section.kind === "sidebar"),
+ // Place sections before the new category
+ ...previous.sections.filter(
+ (section) =>
+ (section.kind === "category" || section.kind === "empty") &&
+ section.position < position,
+ ),
+ {
+ id: createId(),
+ name,
+ kind: "category",
+ position,
+ items: [],
+ },
+ {
+ id: createId(),
+ kind: "empty",
+ position: position + 1,
+ items: [],
+ },
+ // Place sections after the new category
+ ...previous.sections
+ .filter(
+ (section): section is CategorySection | EmptySection =>
+ (section.kind === "category" || section.kind === "empty") &&
+ section.position >= position,
+ )
+ .map((section) => ({
+ ...section,
+ position: section.position + 2,
+ })),
+ ],
+ }));
+ },
+ [updateBoard],
+ );
+
+ const addCategoryToEnd = useCallback(
+ ({ name }: { name: string }) => {
+ updateBoard((previous) => {
+ const lastSection = previous.sections
+ .filter(
+ (x): x is CategorySection | EmptySection =>
+ x.kind === "empty" || x.kind === "category",
+ )
+ .sort((a, b) => b.position - a.position)
+ .at(0);
+
+ if (!lastSection) return previous;
+ const lastPosition = lastSection.position;
+
+ return {
+ ...previous,
+ sections: [
+ ...previous.sections,
+ {
+ id: createId(),
+ name,
+ kind: "category",
+ position: lastPosition + 1,
+ items: [],
+ },
+ {
+ id: createId(),
+ kind: "empty",
+ position: lastPosition + 2,
+ items: [],
+ },
+ ],
+ };
+ });
+ },
+ [updateBoard],
+ );
+
+ const renameCategory = useCallback(
+ ({ id: categoryId, name }: RenameCategory) => {
+ updateBoard((previous) => ({
+ ...previous,
+ sections: previous.sections.map((section) => {
+ if (section.kind !== "category") return section;
+ if (section.id !== categoryId) return section;
+ return {
+ ...section,
+ name,
+ };
+ }),
+ }));
+ },
+ [updateBoard],
+ );
+
+ const moveCategory = useCallback(
+ ({ id, direction }: MoveCategory) => {
+ updateBoard((previous) => {
+ const currentCategory = previous.sections.find(
+ (section): section is CategorySection =>
+ section.kind === "category" && section.id === id,
+ );
+ if (!currentCategory) return previous;
+ if (currentCategory?.position === 1 && direction === "up")
+ return previous;
+ if (
+ currentCategory?.position === previous.sections.length - 2 &&
+ direction === "down"
+ )
+ return previous;
+
+ return {
+ ...previous,
+ sections: previous.sections.map((section) => {
+ if (section.kind !== "category" && section.kind !== "empty")
+ return section;
+ const offset = direction === "up" ? -2 : 2;
+ // Move category and empty section
+ if (
+ section.position === currentCategory.position ||
+ section.position - 1 === currentCategory.position
+ ) {
+ return {
+ ...section,
+ position: section.position + offset,
+ };
+ }
+
+ if (
+ direction === "up" &&
+ (section.position === currentCategory.position - 2 ||
+ section.position === currentCategory.position - 1)
+ ) {
+ return {
+ ...section,
+ position: section.position + 2,
+ };
+ }
+
+ if (
+ direction === "down" &&
+ (section.position === currentCategory.position + 2 ||
+ section.position === currentCategory.position + 3)
+ ) {
+ return {
+ ...section,
+ position: section.position - 2,
+ };
+ }
+
+ return section;
+ }),
+ };
+ });
+ },
+ [updateBoard],
+ );
+
+ const removeCategory = useCallback(
+ ({ id: categoryId }: RemoveCategory) => {
+ updateBoard((previous) => {
+ const currentCategory = previous.sections.find(
+ (section): section is CategorySection =>
+ section.kind === "category" && section.id === categoryId,
+ );
+ if (!currentCategory) return previous;
+
+ const aboveWrapper = previous.sections.find(
+ (section): section is EmptySection =>
+ section.kind === "empty" &&
+ section.position === currentCategory.position - 1,
+ );
+
+ const removedWrapper = previous.sections.find(
+ (section): section is EmptySection =>
+ section.kind === "empty" &&
+ section.position === currentCategory.position + 1,
+ );
+
+ if (!aboveWrapper || !removedWrapper) return previous;
+
+ // Calculate the yOffset for the items in the currentCategory and removedWrapper to add them with the same offset to the aboveWrapper
+ const aboveYOffset = calculateYHeightWithOffset(aboveWrapper);
+ const categoryYOffset = calculateYHeightWithOffset(currentCategory);
+
+ const previousCategoryItems = currentCategory.items.map((item) => ({
+ ...item,
+ yOffset: item.yOffset + aboveYOffset,
+ }));
+ const previousBelowWrapperItems = removedWrapper.items.map((item) => ({
+ ...item,
+ yOffset: item.yOffset + aboveYOffset + categoryYOffset,
+ }));
+
+ return {
+ ...previous,
+ sections: [
+ ...previous.sections.filter(
+ (section) => section.kind === "sidebar",
+ ),
+ ...previous.sections.filter(
+ (section) =>
+ (section.kind === "category" || section.kind === "empty") &&
+ section.position < currentCategory.position - 1,
+ ),
+ {
+ ...aboveWrapper,
+ items: [
+ ...aboveWrapper.items,
+ ...previousCategoryItems,
+ ...previousBelowWrapperItems,
+ ],
+ },
+ ...previous.sections
+ .filter(
+ (section): section is CategorySection | EmptySection =>
+ (section.kind === "category" || section.kind === "empty") &&
+ section.position >= currentCategory.position + 2,
+ )
+ .map((section) => ({
+ ...section,
+ position: section.position - 2,
+ })),
+ ],
+ };
+ });
+ },
+ [updateBoard],
+ );
+
+ return {
+ addCategory,
+ addCategoryToEnd,
+ renameCategory,
+ moveCategory,
+ removeCategory,
+ };
+};
+
+const calculateYHeightWithOffset = (section: Section) =>
+ section.items.reduce((acc, item) => {
+ const yHeightWithOffset = item.yOffset + item.height;
+ if (yHeightWithOffset > acc) return yHeightWithOffset;
+ return acc;
+ }, 0);
diff --git a/apps/nextjs/src/components/board/sections/category/category-edit-modal.tsx b/apps/nextjs/src/components/board/sections/category/category-edit-modal.tsx
new file mode 100644
index 000000000..35f975028
--- /dev/null
+++ b/apps/nextjs/src/components/board/sections/category/category-edit-modal.tsx
@@ -0,0 +1,56 @@
+import type { ManagedModal } from "mantine-modal-manager";
+
+import { useForm } from "@homarr/form";
+import { useI18n } from "@homarr/translation/client";
+import { Button, Group, Stack, TextInput } from "@homarr/ui";
+
+interface Category {
+ id: string;
+ name: string;
+}
+
+interface InnerProps {
+ submitLabel: string;
+ category: Category;
+ onSuccess: (category: Category) => void;
+}
+
+export const CategoryEditModal: ManagedModal = ({
+ actions,
+ innerProps,
+}) => {
+ const t = useI18n();
+ const form = useForm({
+ initialValues: {
+ name: innerProps.category.name,
+ },
+ });
+
+ return (
+
+ );
+};
diff --git a/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx b/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx
new file mode 100644
index 000000000..697e99233
--- /dev/null
+++ b/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx
@@ -0,0 +1,107 @@
+import { useCallback } from "react";
+
+import { createId } from "@homarr/db/client";
+import { useI18n } from "@homarr/translation/client";
+
+import type { CategorySection } from "~/app/[locale]/boards/_types";
+import { modalEvents } from "~/app/[locale]/modals";
+import { useCategoryActions } from "./category-actions";
+
+export const useCategoryMenuActions = (category: CategorySection) => {
+ const { addCategory, moveCategory, removeCategory, renameCategory } =
+ useCategoryActions();
+ const t = useI18n();
+
+ const createCategoryAtPosition = useCallback(
+ (position: number) => {
+ modalEvents.openManagedModal({
+ title: t("section.category.create.title"),
+ modal: "categoryEditModal",
+ innerProps: {
+ category: {
+ id: createId(),
+ name: t("section.category.create.title"),
+ },
+ onSuccess: (category) => {
+ addCategory({
+ name: category.name,
+ position,
+ });
+ },
+ submitLabel: t("section.category.create.submit"),
+ },
+ });
+ },
+ [addCategory, t],
+ );
+
+ // creates a new category above the current
+ const addCategoryAbove = useCallback(() => {
+ const abovePosition = category.position;
+ createCategoryAtPosition(abovePosition);
+ }, [category.position, createCategoryAtPosition]);
+
+ // creates a new category below the current
+ const addCategoryBelow = useCallback(() => {
+ const belowPosition = category.position + 2;
+ createCategoryAtPosition(belowPosition);
+ }, [category.position, createCategoryAtPosition]);
+
+ const moveCategoryUp = useCallback(() => {
+ moveCategory({
+ id: category.id,
+ direction: "up",
+ });
+ }, [category.id, moveCategory]);
+
+ const moveCategoryDown = useCallback(() => {
+ moveCategory({
+ id: category.id,
+ direction: "down",
+ });
+ }, [category.id, moveCategory]);
+
+ // Removes the current category
+ const remove = useCallback(() => {
+ modalEvents.openConfirmModal({
+ title: t("section.category.remove.title"),
+ children: t("section.category.remove.message", {
+ name: category.name,
+ }),
+ onConfirm: () => {
+ removeCategory({
+ id: category.id,
+ });
+ },
+ confirmProps: {
+ color: "red",
+ },
+ });
+ }, [category.id, category.name, removeCategory, t]);
+
+ const edit = () => {
+ modalEvents.openManagedModal({
+ modal: "categoryEditModal",
+ title: t("section.category.edit.title"),
+ innerProps: {
+ category,
+ submitLabel: t("section.category.edit.submit"),
+ onSuccess: (category) => {
+ renameCategory({
+ id: category.id,
+ name: category.name,
+ });
+ },
+ },
+ });
+ };
+
+ return {
+ addCategoryAbove,
+ addCategoryBelow,
+ moveCategoryUp,
+ moveCategoryDown,
+ remove,
+ edit,
+ };
+};
diff --git a/apps/nextjs/src/components/board/sections/category/category-menu.tsx b/apps/nextjs/src/components/board/sections/category/category-menu.tsx
new file mode 100644
index 000000000..182ecbce8
--- /dev/null
+++ b/apps/nextjs/src/components/board/sections/category/category-menu.tsx
@@ -0,0 +1,128 @@
+"use client";
+
+import React, { useMemo } from "react";
+import { useAtomValue } from "jotai";
+
+import { useScopedI18n } from "@homarr/translation/client";
+import type { TablerIconsProps } from "@homarr/ui";
+import {
+ ActionIcon,
+ IconDotsVertical,
+ IconEdit,
+ IconRowInsertBottom,
+ IconRowInsertTop,
+ IconTransitionBottom,
+ IconTransitionTop,
+ IconTrash,
+ Menu,
+} from "@homarr/ui";
+
+import type { CategorySection } from "~/app/[locale]/boards/_types";
+import { editModeAtom } from "../../editMode";
+import { useCategoryMenuActions } from "./category-menu-actions";
+
+interface Props {
+ category: CategorySection;
+}
+
+export const CategoryMenu = ({ category }: Props) => {
+ const actions = useActions(category);
+ const t = useScopedI18n("section.category");
+
+ if (actions.length === 0) return null;
+
+ return (
+
+
+
+
+
+
+
+ {actions.map((action) => (
+
+ {"group" in action && {t(action.group)} }
+ }
+ onClick={action.onClick}
+ color={"color" in action ? action.color : undefined}
+ >
+ {t(action.label)}
+
+
+ ))}
+
+
+ );
+};
+
+const useActions = (category: CategorySection) => {
+ const isEditMode = useAtomValue(editModeAtom);
+ const editModeActions = useEditModeActions(category);
+ const nonEditModeActions = useNonEditModeActions(category);
+
+ return useMemo(
+ () => (isEditMode ? editModeActions : nonEditModeActions),
+ [isEditMode, editModeActions, nonEditModeActions],
+ );
+};
+
+const useEditModeActions = (category: CategorySection) => {
+ const {
+ addCategoryAbove,
+ addCategoryBelow,
+ moveCategoryUp,
+ moveCategoryDown,
+ edit,
+ remove,
+ } = useCategoryMenuActions(category);
+
+ return [
+ {
+ icon: IconEdit,
+ label: "action.edit",
+ onClick: edit,
+ },
+ {
+ icon: IconTrash,
+ color: "red",
+ label: "action.remove",
+ onClick: remove,
+ },
+ {
+ group: "menu.label.changePosition",
+ icon: IconTransitionTop,
+ label: "action.moveUp",
+ onClick: moveCategoryUp,
+ },
+ {
+ icon: IconTransitionBottom,
+ label: "action.moveDown",
+ onClick: moveCategoryDown,
+ },
+ {
+ group: "menu.label.create",
+ icon: IconRowInsertTop,
+ label: "action.createAbove",
+ onClick: addCategoryAbove,
+ },
+ {
+ icon: IconRowInsertBottom,
+ label: "action.createBelow",
+ onClick: addCategoryBelow,
+ },
+ ] as const satisfies ActionDefinition[];
+};
+
+// TODO: once apps are added we can use this for the open many apps action
+const useNonEditModeActions = (_category: CategorySection) => {
+ return [] as const satisfies ActionDefinition[];
+};
+
+interface ActionDefinition {
+ icon: (props: TablerIconsProps) => JSX.Element;
+ label: string;
+ onClick: () => void;
+ color?: string;
+ group?: string;
+}
diff --git a/apps/nextjs/src/components/board/sections/content.tsx b/apps/nextjs/src/components/board/sections/content.tsx
new file mode 100644
index 000000000..bc6d21af7
--- /dev/null
+++ b/apps/nextjs/src/components/board/sections/content.tsx
@@ -0,0 +1,162 @@
+/* eslint-disable react/no-unknown-property */
+// Ignored because of gridstack attributes
+
+import type { RefObject } from "react";
+import { useAtomValue } from "jotai";
+
+import { useScopedI18n } from "@homarr/translation/client";
+import {
+ ActionIcon,
+ Card,
+ IconDotsVertical,
+ IconLayoutKanban,
+ IconPencil,
+ IconTrash,
+ Menu,
+} from "@homarr/ui";
+import {
+ loadWidgetDynamic,
+ reduceWidgetOptionsWithDefaultValues,
+ useServerDataFor,
+} from "@homarr/widgets";
+
+import type { Item } from "~/app/[locale]/boards/_types";
+import { modalEvents } from "~/app/[locale]/modals";
+import { editModeAtom } from "../editMode";
+import { useItemActions } from "../items/item-actions";
+import type { UseGridstackRefs } from "./gridstack/use-gridstack";
+
+interface Props {
+ items: Item[];
+ refs: UseGridstackRefs;
+}
+
+export const SectionContent = ({ items, refs }: Props) => {
+ return (
+ <>
+ {items.map((item) => (
+ }
+ >
+
+
+
+
+ ))}
+ >
+ );
+};
+
+interface ItemProps {
+ item: Item;
+}
+
+const BoardItem = ({ item }: ItemProps) => {
+ const serverData = useServerDataFor(item.id);
+ const Comp = loadWidgetDynamic(item.kind);
+ const options = reduceWidgetOptionsWithDefaultValues(item.kind, item.options);
+ const newItem = { ...item, options };
+
+ if (!serverData?.isReady) return null;
+
+ return (
+ <>
+
+
+ >
+ );
+};
+
+const ItemMenu = ({ offset, item }: { offset: number; item: Item }) => {
+ const t = useScopedI18n("item");
+ const isEditMode = useAtomValue(editModeAtom);
+ const { updateItemOptions, removeItem } = useItemActions();
+
+ if (!isEditMode) return null;
+
+ const openEditModal = () => {
+ modalEvents.openManagedModal({
+ title: t("edit.title"),
+ modal: "widgetEditModal",
+ innerProps: {
+ kind: item.kind,
+ value: {
+ options: item.options,
+ integrations: item.integrations.map(({ id }) => id),
+ },
+ onSuccessfulEdit: ({ options, integrations: _ }) => {
+ updateItemOptions({
+ itemId: item.id,
+ newOptions: options,
+ });
+ },
+ integrationData: [],
+ integrationSupport: false,
+ },
+ });
+ };
+
+ const openRemoveModal = () => {
+ modalEvents.openConfirmModal({
+ title: t("remove.title"),
+ children: t("remove.message"),
+ onConfirm: () => {
+ removeItem({ itemId: item.id });
+ },
+ confirmProps: {
+ color: "red",
+ },
+ });
+ };
+
+ return (
+
+
+
+
+
+
+
+ {t("menu.label.settings")}
+ }
+ onClick={openEditModal}
+ >
+ {t("action.edit")}
+
+ }>
+ {t("action.move")}
+
+
+ {t("menu.label.dangerZone")}
+ }
+ onClick={openRemoveModal}
+ >
+ {t("action.remove")}
+
+
+
+ );
+};
diff --git a/apps/nextjs/src/components/board/sections/empty-section.tsx b/apps/nextjs/src/components/board/sections/empty-section.tsx
new file mode 100644
index 000000000..268a7ec88
--- /dev/null
+++ b/apps/nextjs/src/components/board/sections/empty-section.tsx
@@ -0,0 +1,35 @@
+import type { RefObject } from "react";
+import { useAtomValue } from "jotai";
+
+import type { EmptySection } from "~/app/[locale]/boards/_types";
+import { editModeAtom } from "../editMode";
+import { SectionContent } from "./content";
+import { useGridstack } from "./gridstack/use-gridstack";
+
+interface Props {
+ section: EmptySection;
+ mainRef: RefObject;
+}
+
+const defaultClasses = "grid-stack grid-stack-empty min-row";
+
+export const BoardEmptySection = ({ section, mainRef }: Props) => {
+ const { refs } = useGridstack({ section, mainRef });
+ const isEditMode = useAtomValue(editModeAtom);
+
+ return (
+ 0 || isEditMode
+ ? defaultClasses
+ : `${defaultClasses} gridstack-empty-wrapper`
+ }
+ style={{ transitionDuration: "0s" }}
+ data-empty
+ data-section-id={section.id}
+ ref={refs.wrapper}
+ >
+
+
+ );
+};
diff --git a/apps/nextjs/src/components/board/sections/gridstack/init-gridstack.ts b/apps/nextjs/src/components/board/sections/gridstack/init-gridstack.ts
new file mode 100644
index 000000000..78cc0deb5
--- /dev/null
+++ b/apps/nextjs/src/components/board/sections/gridstack/init-gridstack.ts
@@ -0,0 +1,62 @@
+import type { MutableRefObject, RefObject } from "react";
+
+import type { GridItemHTMLElement } from "@homarr/gridstack";
+import { GridStack } from "@homarr/gridstack";
+
+import type { Section } from "~/app/[locale]/boards/_types";
+
+interface InitializeGridstackProps {
+ section: Section;
+ refs: {
+ wrapper: RefObject;
+ items: MutableRefObject>>;
+ gridstack: MutableRefObject;
+ };
+ sectionColumnCount: number;
+}
+
+export const initializeGridstack = ({
+ section,
+ refs,
+ sectionColumnCount,
+}: InitializeGridstackProps) => {
+ if (!refs.wrapper.current) return false;
+ // calculates the currently available count of columns
+ const columnCount = section.kind === "sidebar" ? 2 : sectionColumnCount;
+ const minRow =
+ section.kind !== "sidebar"
+ ? 1
+ : Math.floor(refs.wrapper.current.offsetHeight / 128);
+ // initialize gridstack
+ const newGrid = refs.gridstack;
+ newGrid.current = GridStack.init(
+ {
+ column: columnCount,
+ margin: section.kind === "sidebar" ? 5 : 10,
+ cellHeight: 128,
+ float: true,
+ alwaysShowResizeHandle: true,
+ acceptWidgets: true,
+ staticGrid: true,
+ minRow,
+ animate: false,
+ styleInHead: true,
+ disableRemoveNodeOnDrop: true,
+ },
+ // selector of the gridstack item (it's eather category or wrapper)
+ `.grid-stack-${section.kind}[data-section-id='${section.id}']`,
+ );
+ const grid = newGrid.current;
+ if (!grid) return false;
+ // Must be used to update the column count after the initialization
+ grid.column(columnCount, "none");
+
+ grid.batchUpdate();
+ grid.removeAll(false);
+ section.items.forEach(({ id }) => {
+ const ref = refs.items.current[id]?.current;
+ ref && grid.makeWidget(ref);
+ });
+ grid.batchUpdate(false);
+ return true;
+};
diff --git a/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts b/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts
new file mode 100644
index 000000000..6dcad138d
--- /dev/null
+++ b/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts
@@ -0,0 +1,209 @@
+import type { MutableRefObject, RefObject } from "react";
+import { createRef, useCallback, useEffect, useMemo, useRef } from "react";
+import { useAtomValue } from "jotai";
+
+import type {
+ GridItemHTMLElement,
+ GridStack,
+ GridStackNode,
+} from "@homarr/gridstack";
+
+import {
+ useMarkSectionAsReady,
+ useRequiredBoard,
+} from "~/app/[locale]/boards/_context";
+import type { Section } from "~/app/[locale]/boards/_types";
+import { editModeAtom } from "../../editMode";
+import { useItemActions } from "../../items/item-actions";
+import { initializeGridstack } from "./init-gridstack";
+
+export interface UseGridstackRefs {
+ wrapper: RefObject;
+ items: MutableRefObject>>;
+ gridstack: MutableRefObject;
+}
+
+interface UseGristackReturnType {
+ refs: UseGridstackRefs;
+}
+
+interface UseGridstackProps {
+ section: Section;
+ mainRef?: RefObject;
+}
+
+export const useGridstack = ({
+ section,
+ mainRef,
+}: UseGridstackProps): UseGristackReturnType => {
+ const isEditMode = useAtomValue(editModeAtom);
+ const markAsReady = useMarkSectionAsReady();
+ const { moveAndResizeItem, moveItemToSection } = useItemActions();
+ // define reference for wrapper - is used to calculate the width of the wrapper
+ const wrapperRef = useRef(null);
+ // references to the diffrent items contained in the gridstack
+ const itemRefs = useRef>>({});
+ // reference of the gridstack object for modifications after initialization
+ const gridRef = useRef();
+
+ useCssVariableConfiguration({ section, mainRef, gridRef });
+
+ const sectionColumnCount = useSectionColumnCount(section.kind);
+
+ const items = useMemo(() => section.items, [section.items]);
+
+ // define items in itemRefs for easy access and reference to items
+ if (Object.keys(itemRefs.current).length !== items.length) {
+ items.forEach(({ id }: { id: keyof typeof itemRefs.current }) => {
+ itemRefs.current[id] = itemRefs.current[id] ?? createRef();
+ });
+ }
+
+ useEffect(() => {
+ gridRef.current?.setStatic(!isEditMode);
+ }, [isEditMode]);
+
+ const onChange = useCallback(
+ (changedNode: GridStackNode) => {
+ const itemId = changedNode.el?.getAttribute("data-id");
+ if (!itemId) return;
+
+ // Updates the react-query state
+ moveAndResizeItem({
+ itemId,
+ xOffset: changedNode.x!,
+ yOffset: changedNode.y!,
+ width: changedNode.w!,
+ height: changedNode.h!,
+ });
+ },
+ [moveAndResizeItem],
+ );
+ const onAdd = useCallback(
+ (addedNode: GridStackNode) => {
+ const itemId = addedNode.el?.getAttribute("data-id");
+ if (!itemId) return;
+
+ // Updates the react-query state
+ moveItemToSection({
+ itemId,
+ sectionId: section.id,
+ xOffset: addedNode.x!,
+ yOffset: addedNode.y!,
+ width: addedNode.w!,
+ height: addedNode.h!,
+ });
+ },
+ [moveItemToSection, section.id],
+ );
+
+ useEffect(() => {
+ if (!isEditMode) return;
+ const currentGrid = gridRef.current;
+ // Add listener for moving items around in a wrapper
+ currentGrid?.on("change", (_, nodes) => {
+ nodes.forEach(onChange);
+ });
+
+ // Add listener for moving items in config from one wrapper to another
+ currentGrid?.on("added", (_, nodes) => {
+ nodes.forEach((node) => onAdd(node));
+ });
+
+ return () => {
+ currentGrid?.off("change");
+ currentGrid?.off("added");
+ };
+ }, [isEditMode, onAdd, onChange]);
+
+ // initialize the gridstack
+ useEffect(() => {
+ const isReady = initializeGridstack({
+ section,
+ refs: {
+ items: itemRefs,
+ wrapper: wrapperRef,
+ gridstack: gridRef,
+ },
+ sectionColumnCount,
+ });
+
+ if (isReady) {
+ markAsReady(section.id);
+ }
+
+ // Only run this effect when the section items change
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [items.length, section.items.length]);
+
+ return {
+ refs: {
+ items: itemRefs,
+ wrapper: wrapperRef,
+ gridstack: gridRef,
+ },
+ };
+};
+
+/**
+ * Get the column count for the section
+ * For the sidebar it's always 2 otherwise it's the column count of the board
+ * @param sectionKind kind of the section
+ * @returns count of columns
+ */
+const useSectionColumnCount = (sectionKind: Section["kind"]) => {
+ const board = useRequiredBoard();
+ if (sectionKind === "sidebar") return 2;
+
+ return board.columnCount;
+};
+
+interface UseCssVariableConfiguration {
+ section: Section;
+ mainRef?: RefObject;
+ gridRef: UseGridstackRefs["gridstack"];
+}
+
+/**
+ * This hook is used to configure the css variables for the gridstack
+ * Those css variables are used to define the size of the gridstack items
+ * @see gridstack.scss
+ * @param section section of the board
+ * @param mainRef reference to the main div wrapping all sections
+ * @param gridRef reference to the gridstack object
+ */
+const useCssVariableConfiguration = ({
+ section,
+ mainRef,
+ gridRef,
+}: UseCssVariableConfiguration) => {
+ const sectionColumnCount = useSectionColumnCount(section.kind);
+
+ // Get reference to the :root element
+ const typeofDocument = typeof document;
+ const root = useMemo(() => {
+ if (typeofDocument === "undefined") return;
+ return document.documentElement;
+ }, [typeofDocument]);
+
+ // Define widget-width by calculating the width of one column with mainRef width and column count
+ useEffect(() => {
+ if (section.kind === "sidebar" || !mainRef?.current) return;
+ const widgetWidth = mainRef.current.clientWidth / sectionColumnCount;
+ // widget width is used to define sizes of gridstack items within global.scss
+ root?.style.setProperty("--gridstack-widget-width", widgetWidth.toString());
+ console.log("widgetWidth", widgetWidth);
+ console.log(gridRef.current);
+ gridRef.current?.cellHeight(widgetWidth);
+ // gridRef.current is required otherwise the cellheight is run on production as undefined
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [sectionColumnCount, root, section.kind, mainRef, gridRef.current]);
+
+ // Define column count by using the sectionColumnCount
+ useEffect(() => {
+ root?.style.setProperty(
+ "--gridstack-column-count",
+ sectionColumnCount.toString(),
+ );
+ }, [sectionColumnCount, root]);
+};
diff --git a/apps/nextjs/src/components/layout/header.tsx b/apps/nextjs/src/components/layout/header.tsx
index 6a36bcd9f..c9c06744a 100644
--- a/apps/nextjs/src/components/layout/header.tsx
+++ b/apps/nextjs/src/components/layout/header.tsx
@@ -1,3 +1,4 @@
+import type { ReactNode } from "react";
import Link from "next/link";
import { AppShellHeader, Group, UnstyledButton } from "@homarr/ui";
@@ -6,20 +7,33 @@ import { ClientBurger } from "./header/burger";
import { DesktopSearchInput, MobileSearchButton } from "./header/search";
import { ClientSpotlight } from "./header/spotlight";
import { UserButton } from "./header/user";
-import { LogoWithTitle } from "./logo";
+import { HomarrLogoWithTitle } from "./logo/homarr-logo";
-export const MainHeader = () => {
+interface Props {
+ logo?: ReactNode;
+ actions?: ReactNode;
+ hasNavigation?: boolean;
+}
+
+export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
return (
-
+ {hasNavigation && }
-
+ {logo ?? }
-
+
+ {actions}
diff --git a/apps/nextjs/src/components/layout/header/button.tsx b/apps/nextjs/src/components/layout/header/button.tsx
new file mode 100644
index 000000000..967dd837a
--- /dev/null
+++ b/apps/nextjs/src/components/layout/header/button.tsx
@@ -0,0 +1,47 @@
+import type { ForwardedRef, ReactNode } from "react";
+import { forwardRef } from "react";
+import Link from "next/link";
+
+import type { ActionIconProps } from "@homarr/ui";
+import { ActionIcon } from "@homarr/ui";
+
+type HeaderButtonProps = (
+ | {
+ onClick?: () => void;
+ }
+ | {
+ href: string;
+ }
+) & {
+ children: ReactNode;
+} & Partial;
+
+const headerButtonActionIconProps: ActionIconProps = {
+ variant: "subtle",
+ style: { border: "none" },
+ color: "gray",
+ size: "lg",
+};
+
+// eslint-disable-next-line react/display-name
+export const HeaderButton = forwardRef(
+ (props, ref) => {
+ if ("href" in props) {
+ return (
+ }
+ component={Link}
+ {...props}
+ {...headerButtonActionIconProps}
+ >
+ {props.children}
+
+ );
+ }
+ return (
+
+ {props.children}
+
+ );
+ },
+);
diff --git a/apps/nextjs/src/components/layout/header/search.tsx b/apps/nextjs/src/components/layout/header/search.tsx
index 1bb742640..dcccd3360 100644
--- a/apps/nextjs/src/components/layout/header/search.tsx
+++ b/apps/nextjs/src/components/layout/header/search.tsx
@@ -2,8 +2,9 @@
import { spotlight } from "@homarr/spotlight";
import { useScopedI18n } from "@homarr/translation/client";
-import { ActionIcon, IconSearch, TextInput, UnstyledButton } from "@homarr/ui";
+import { IconSearch, TextInput, UnstyledButton } from "@homarr/ui";
+import { HeaderButton } from "./button";
import classes from "./search.module.css";
export const DesktopSearchInput = () => {
@@ -25,13 +26,8 @@ export const DesktopSearchInput = () => {
export const MobileSearchButton = () => {
return (
-
+
-
+
);
};
diff --git a/apps/nextjs/src/components/layout/header/user.tsx b/apps/nextjs/src/components/layout/header/user.tsx
index 376b31e3d..7bbc7fee1 100644
--- a/apps/nextjs/src/components/layout/header/user.tsx
+++ b/apps/nextjs/src/components/layout/header/user.tsx
@@ -1,11 +1,14 @@
import { UnstyledButton } from "@homarr/ui";
import { UserAvatar } from "~/components/user-avatar";
+import { UserAvatarMenu } from "~/components/user-avatar-menu";
export const UserButton = () => {
return (
-
-
-
+
+
+
+
+
);
};
diff --git a/apps/nextjs/src/components/layout/logo.tsx b/apps/nextjs/src/components/layout/logo.tsx
deleted file mode 100644
index 6a5fe592e..000000000
--- a/apps/nextjs/src/components/layout/logo.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import Image from "next/image";
-
-import type { TitleOrder } from "@homarr/ui";
-import { Group, Title } from "@homarr/ui";
-
-interface LogoProps {
- size: number;
-}
-
-export const Logo = ({ size = 60 }: LogoProps) => (
-
-);
-
-const logoWithTitleSizes = {
- lg: { logoSize: 48, titleOrder: 1 },
- md: { logoSize: 32, titleOrder: 2 },
- sm: { logoSize: 24, titleOrder: 3 },
-} satisfies Record;
-
-interface LogoWithTitleProps {
- size: keyof typeof logoWithTitleSizes;
-}
-
-export const LogoWithTitle = ({ size }: LogoWithTitleProps) => {
- const { logoSize, titleOrder } = logoWithTitleSizes[size];
-
- return (
-
-
- lparr
-
- );
-};
diff --git a/apps/nextjs/src/components/layout/logo/board-logo.tsx b/apps/nextjs/src/components/layout/logo/board-logo.tsx
new file mode 100644
index 000000000..13379f2fa
--- /dev/null
+++ b/apps/nextjs/src/components/layout/logo/board-logo.tsx
@@ -0,0 +1,40 @@
+"use client";
+
+import { useRequiredBoard } from "~/app/[locale]/boards/_context";
+import { homarrLogoPath, homarrPageTitle } from "./homarr-logo";
+import type { LogoWithTitleProps } from "./logo";
+import { Logo, LogoWithTitle } from "./logo";
+
+interface LogoProps {
+ size: number;
+}
+
+const useImageOptions = () => {
+ const board = useRequiredBoard();
+ return {
+ src: board.logoImageUrl ?? homarrLogoPath,
+ alt: "Board logo",
+ shouldUseNextImage: false,
+ };
+};
+
+export const BoardLogo = ({ size }: LogoProps) => {
+ const imageOptions = useImageOptions();
+ return ;
+};
+
+interface CommonLogoWithTitleProps {
+ size: LogoWithTitleProps["size"];
+}
+
+export const BoardLogoWithTitle = ({ size }: CommonLogoWithTitleProps) => {
+ const board = useRequiredBoard();
+ const imageOptions = useImageOptions();
+ return (
+
+ );
+};
diff --git a/apps/nextjs/src/components/layout/logo/homarr-logo.tsx b/apps/nextjs/src/components/layout/logo/homarr-logo.tsx
new file mode 100644
index 000000000..fad2242f9
--- /dev/null
+++ b/apps/nextjs/src/components/layout/logo/homarr-logo.tsx
@@ -0,0 +1,29 @@
+import type { LogoWithTitleProps } from "./logo";
+import { Logo, LogoWithTitle } from "./logo";
+
+interface LogoProps {
+ size: number;
+}
+
+export const homarrLogoPath = "/logo/homarr.png";
+export const homarrPageTitle = "Homarr";
+
+const imageOptions = {
+ src: homarrLogoPath,
+ alt: "Homarr logo",
+ shouldUseNextImage: true,
+};
+
+export const HomarrLogo = ({ size }: LogoProps) => (
+
+);
+
+interface CommonLogoWithTitleProps {
+ size: LogoWithTitleProps["size"];
+}
+
+export const HomarrLogoWithTitle = ({ size }: CommonLogoWithTitleProps) => {
+ return (
+
+ );
+};
diff --git a/apps/nextjs/src/components/layout/logo/logo.tsx b/apps/nextjs/src/components/layout/logo/logo.tsx
new file mode 100644
index 000000000..6332adb74
--- /dev/null
+++ b/apps/nextjs/src/components/layout/logo/logo.tsx
@@ -0,0 +1,48 @@
+import Image from "next/image";
+
+import type { TitleOrder } from "@homarr/ui";
+import { Group, Title } from "@homarr/ui";
+
+interface LogoProps {
+ size: number;
+ src: string;
+ alt: string;
+ shouldUseNextImage?: boolean;
+}
+
+export const Logo = ({
+ size = 60,
+ shouldUseNextImage = false,
+ src,
+ alt,
+}: LogoProps) =>
+ shouldUseNextImage ? (
+
+ ) : (
+ // we only want to use next/image for logos that we are sure will be preloaded and are allowed
+ // eslint-disable-next-line @next/next/no-img-element
+
+ );
+
+const logoWithTitleSizes = {
+ lg: { logoSize: 48, titleOrder: 1 },
+ md: { logoSize: 32, titleOrder: 2 },
+ sm: { logoSize: 24, titleOrder: 3 },
+} satisfies Record;
+
+export interface LogoWithTitleProps {
+ size: keyof typeof logoWithTitleSizes;
+ title: string;
+ image: Omit;
+}
+
+export const LogoWithTitle = ({ size, title, image }: LogoWithTitleProps) => {
+ const { logoSize, titleOrder } = logoWithTitleSizes[size];
+
+ return (
+
+
+ {title}
+
+ );
+};
diff --git a/apps/nextjs/src/components/user-avatar-menu.tsx b/apps/nextjs/src/components/user-avatar-menu.tsx
new file mode 100644
index 000000000..1745eb847
--- /dev/null
+++ b/apps/nextjs/src/components/user-avatar-menu.tsx
@@ -0,0 +1,61 @@
+"use client";
+
+import type { ReactNode } from "react";
+import Link from "next/link";
+
+import { useScopedI18n } from "@homarr/translation/client";
+import {
+ IconDashboard,
+ IconLogout,
+ IconMoon,
+ IconSun,
+ IconTool,
+ Menu,
+ useMantineColorScheme,
+} from "@homarr/ui";
+
+interface UserAvatarMenuProps {
+ children: ReactNode;
+}
+
+export const UserAvatarMenu = ({ children }: UserAvatarMenuProps) => {
+ const t = useScopedI18n("common.userAvatar.menu");
+ const { colorScheme, toggleColorScheme } = useMantineColorScheme();
+
+ const ColorSchemeIcon = colorScheme === "dark" ? IconSun : IconMoon;
+
+ const colorSchemeText =
+ colorScheme === "dark" ? t("switchToLightMode") : t("switchToDarkMode");
+
+ return (
+
+
+ }
+ >
+ {colorSchemeText}
+
+ }
+ >
+ {t("navigateDefaultBoard")}
+
+ }
+ >
+ {t("management")}
+
+
+ } color="red">
+ {t("logout")}
+
+
+ {children}
+
+ );
+};
diff --git a/apps/nextjs/src/env.mjs b/apps/nextjs/src/env.mjs
index f098ee199..c4d2d4e1e 100644
--- a/apps/nextjs/src/env.mjs
+++ b/apps/nextjs/src/env.mjs
@@ -33,7 +33,7 @@ export const env = createEnv({
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
},
skipValidation:
- !!process.env.CI ||
- !!process.env.SKIP_ENV_VALIDATION ||
+ Boolean(process.env.CI) ||
+ Boolean(process.env.SKIP_ENV_VALIDATION) ||
process.env.npm_lifecycle_event === "lint",
});
diff --git a/apps/nextjs/src/styles/gridstack.scss b/apps/nextjs/src/styles/gridstack.scss
new file mode 100644
index 000000000..a58e3e620
--- /dev/null
+++ b/apps/nextjs/src/styles/gridstack.scss
@@ -0,0 +1,126 @@
+@import "@homarr/gridstack/dist/gridstack.min.css";
+
+:root {
+ --gridstack-widget-width: 64;
+ --gridstack-column-count: 12;
+}
+
+.grid-stack-placeholder > .placeholder-content {
+ background-color: rgb(248, 249, 250) !important;
+ border-radius: 12px;
+ border: 1px solid rgba(0, 0, 0, 0.05);
+}
+
+@media (prefers-color-scheme: dark) {
+ .grid-stack-placeholder > .placeholder-content {
+ background-color: rgba(255, 255, 255, 0.05) !important;
+ }
+}
+
+// Define min size for gridstack items
+.grid-stack > .grid-stack-item {
+ min-width: calc(100% / var(--gridstack-column-count));
+ min-height: calc(1px * var(--gridstack-widget-width));
+}
+
+// Styling for grid-stack main area
+@for $i from 1 to 96 {
+ .grid-stack > .grid-stack-item[gs-w="#{$i}"] {
+ width: calc(100% / #{var(--gridstack-column-count)} * #{$i});
+ }
+ .grid-stack > .grid-stack-item[gs-min-w="#{$i}"] {
+ min-width: calc(100% / #{var(--gridstack-column-count)} * #{$i});
+ }
+ .grid-stack > .grid-stack-item[gs-max-w="#{$i}"] {
+ max-width: calc(100% / #{var(--gridstack-column-count)} * #{$i});
+ }
+}
+
+@for $i from 1 to 96 {
+ .grid-stack > .grid-stack-item[gs-h="#{$i}"] {
+ height: calc(#{$i}px * #{var(--gridstack-widget-width)});
+ }
+ .grid-stack > .grid-stack-item[gs-min-h="#{$i}"] {
+ min-height: calc(#{$i}px * #{var(--gridstack-widget-width)});
+ }
+ .grid-stack > .grid-stack-item[gs-max-h="#{$i}"] {
+ max-height: calc(#{$i}px * #{var(--gridstack-widget-width)});
+ }
+}
+
+@for $i from 1 to 96 {
+ .grid-stack > .grid-stack-item[gs-x="#{$i}"] {
+ left: calc(100% / #{var(--gridstack-column-count)} * #{$i});
+ }
+}
+
+@for $i from 1 to 96 {
+ .grid-stack > .grid-stack-item[gs-y="#{$i}"] {
+ top: calc(#{$i}px * #{var(--gridstack-widget-width)});
+ }
+}
+
+// Styling for sidebar grid-stack elements
+@for $i from 1 to 96 {
+ .grid-stack.grid-stack-sidebar > .grid-stack-item[gs-w="#{$i}"] {
+ width: 128px * $i;
+ }
+ .grid-stack.grid-stack-sidebar > .grid-stack-item[gs-min-w="#{$i}"] {
+ min-width: 128px * $i;
+ }
+ .grid-stack.grid-stack-sidebar > .grid-stack-item[gs-max-w="#{$i}"] {
+ max-width: 128px * $i;
+ }
+}
+
+@for $i from 1 to 96 {
+ .grid-stack.grid-stack-sidebar > .grid-stack-item[gs-h="#{$i}"] {
+ height: 128px * $i;
+ }
+ .grid-stack.grid-stack-sidebar > .grid-stack-item[gs-min-h="#{$i}"] {
+ min-height: 128px * $i;
+ }
+ .grid-stack.grid-stack-sidebar > .grid-stack-item[gs-max-h="#{$i}"] {
+ max-height: 128px * $i;
+ }
+}
+
+@for $i from 1 to 3 {
+ .grid-stack.grid-stack-sidebar > .grid-stack-item[gs-x="#{$i}"] {
+ left: 128px * $i;
+ }
+}
+
+@for $i from 1 to 96 {
+ .grid-stack.grid-stack-sidebar > .grid-stack-item[gs-y="#{$i}"] {
+ top: 128px * $i;
+ }
+}
+
+.grid-stack.grid-stack-sidebar > .grid-stack-item {
+ min-width: 128px;
+}
+
+// General gridstack styling
+.grid-stack > .grid-stack-item > .grid-stack-item-content,
+.grid-stack > .grid-stack-item > .placeholder-content {
+ inset: 10px;
+}
+
+.grid-stack > .grid-stack-item > .ui-resizable-se {
+ bottom: 10px;
+ right: 10px;
+}
+
+.grid-stack > .grid-stack-item > .grid-stack-item-content {
+ overflow-y: auto;
+}
+
+.grid-stack.grid-stack-animate {
+ transition: none;
+}
+
+.gridstack-empty-wrapper {
+ height: 0px;
+ min-height: 0px !important;
+}
diff --git a/apps/nextjs/src/trpc/react.ts b/apps/nextjs/src/trpc/react.ts
deleted file mode 100644
index 43339ff3c..000000000
--- a/apps/nextjs/src/trpc/react.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { createTRPCReact } from "@trpc/react-query";
-
-import type { AppRouter } from "@homarr/api";
-
-export const api = createTRPCReact();
-
-export { type RouterInputs, type RouterOutputs } from "@homarr/api";
diff --git a/package.json b/package.json
index 418c398e4..5dc6d4c29 100644
--- a/package.json
+++ b/package.json
@@ -2,35 +2,44 @@
"name": "homarr",
"private": true,
"engines": {
- "node": ">=18.18.2"
+ "node": ">=20.11.0"
},
- "packageManager": "pnpm@8.11.0",
+ "type": "module",
+ "packageManager": "pnpm@8.15.2",
"scripts": {
"build": "turbo build",
"clean": "git clean -xdf node_modules",
"clean:workspaces": "turbo clean",
- "postinstall": "pnpm lint:ws",
"db:push": "pnpm -F db push",
"db:studio": "pnpm -F db studio",
- "dev": "turbo dev --parallel",
+ "db:migration:generate": "pnpm -F db migration:generate",
+ "dev": "node start.js",
"format": "turbo format --continue -- --cache --cache-location node_modules/.cache/.prettiercache",
"format:fix": "turbo format --continue -- --write --cache --cache-location node_modules/.cache/.prettiercache",
"lint": "turbo lint --continue -- --cache --cache-location node_modules/.cache/.eslintcache",
"lint:fix": "turbo lint --continue -- --fix --cache --cache-location node_modules/.cache/.eslintcache",
"lint:ws": "pnpm dlx sherif@latest",
+ "test": "cross-env NODE_ENV=development vitest run --coverage.enabled",
+ "test:ui": "cross-env NODE_ENV=development vitest --ui --coverage.enabled",
"typecheck": "turbo typecheck"
},
"devDependencies": {
"@homarr/prettier-config": "workspace:^0.1.0",
- "@turbo/gen": "^1.10.16",
- "prettier": "^3.1.0",
- "turbo": "^1.10.16",
- "typescript": "^5.3.3"
+ "@testing-library/react-hooks": "^8.0.1",
+ "@turbo/gen": "^1.12.4",
+ "@vitejs/plugin-react": "^4.2.1",
+ "@vitest/coverage-v8": "^1.2.2",
+ "@vitest/ui": "^1.2.2",
+ "cross-env": "^7.0.3",
+ "jsdom": "^24.0.0",
+ "prettier": "^3.2.5",
+ "turbo": "^1.12.4",
+ "typescript": "^5.3.3",
+ "vite-tsconfig-paths": "^4.3.1",
+ "vitest": "^1.2.2"
},
- "pnpm": {
- "overrides": {
- "@auth/core": "0.18.0"
- }
- },
- "prettier": "@homarr/prettier-config"
-}
\ No newline at end of file
+ "prettier": "@homarr/prettier-config",
+ "dependencies": {
+ "winston": "^3.11.0"
+ }
+}
diff --git a/packages/api/index.ts b/packages/api/index.ts
index 639e50c04..1903f05e0 100644
--- a/packages/api/index.ts
+++ b/packages/api/index.ts
@@ -4,7 +4,6 @@ import type { AppRouter } from "./src/root";
export { appRouter, type AppRouter } from "./src/root";
export { createTRPCContext } from "./src/trpc";
-
/**
* Inference helpers for input types
* @example type HelloInput = RouterInputs['example']['hello']
diff --git a/packages/api/package.json b/packages/api/package.json
index e281c5ad1..1b84f6487 100644
--- a/packages/api/package.json
+++ b/packages/api/package.json
@@ -1,6 +1,10 @@
{
"name": "@homarr/api",
"version": "0.1.0",
+ "exports": {
+ ".": "./index.ts",
+ "./client": "./src/client.ts"
+ },
"private": true,
"main": "./index.ts",
"types": "./index.ts",
@@ -25,7 +29,7 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^8.56.0",
- "prettier": "^3.1.0",
+ "prettier": "^3.2.5",
"typescript": "^5.3.3"
},
"eslintConfig": {
diff --git a/packages/api/src/client.ts b/packages/api/src/client.ts
new file mode 100644
index 000000000..a8f1f59aa
--- /dev/null
+++ b/packages/api/src/client.ts
@@ -0,0 +1,5 @@
+import { createTRPCReact } from "@trpc/react-query";
+
+import type { AppRouter } from "..";
+
+export const clientApi = createTRPCReact();
diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts
index f0b54cfc8..28afc6416 100644
--- a/packages/api/src/root.ts
+++ b/packages/api/src/root.ts
@@ -1,3 +1,4 @@
+import { boardRouter } from "./router/board";
import { integrationRouter } from "./router/integration";
import { userRouter } from "./router/user";
import { createTRPCRouter } from "./trpc";
@@ -5,6 +6,7 @@ import { createTRPCRouter } from "./trpc";
export const appRouter = createTRPCRouter({
user: userRouter,
integration: integrationRouter,
+ board: boardRouter,
});
// export type definition of API
diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts
new file mode 100644
index 000000000..a0eeb6f59
--- /dev/null
+++ b/packages/api/src/router/board.ts
@@ -0,0 +1,354 @@
+import { TRPCError } from "@trpc/server";
+import superjson from "superjson";
+
+import type { Database, SQL } from "@homarr/db";
+import { and, createId, eq, inArray } from "@homarr/db";
+import {
+ boards,
+ integrationItems,
+ items,
+ sections,
+} from "@homarr/db/schema/sqlite";
+import type { WidgetKind } from "@homarr/definitions";
+import { widgetKinds } from "@homarr/definitions";
+import {
+ createSectionSchema,
+ sharedItemSchema,
+ validation,
+ z,
+} from "@homarr/validation";
+
+import { zodUnionFromArray } from "../../../validation/src/enums";
+import type { WidgetComponentProps } from "../../../widgets/src/definition";
+import { createTRPCRouter, publicProcedure } from "../trpc";
+
+const filterAddedItems = (
+ inputArray: TInput[],
+ dbArray: TInput[],
+) =>
+ inputArray.filter(
+ (inputItem) => !dbArray.some((dbItem) => dbItem.id === inputItem.id),
+ );
+
+const filterRemovedItems = (
+ inputArray: TInput[],
+ dbArray: TInput[],
+) =>
+ dbArray.filter(
+ (dbItem) => !inputArray.some((inputItem) => dbItem.id === inputItem.id),
+ );
+
+const filterUpdatedItems = (
+ inputArray: TInput[],
+ dbArray: TInput[],
+) =>
+ inputArray.filter((inputItem) =>
+ dbArray.some((dbItem) => dbItem.id === inputItem.id),
+ );
+
+export const boardRouter = createTRPCRouter({
+ getAll: publicProcedure.query(async ({ ctx }) => {
+ return await ctx.db.query.boards.findMany({
+ columns: {
+ id: true,
+ name: true,
+ },
+ with: {
+ sections: {
+ with: {
+ items: true,
+ },
+ },
+ },
+ });
+ }),
+ create: publicProcedure
+ .input(validation.board.create)
+ .mutation(async ({ ctx, input }) => {
+ const boardId = createId();
+ await ctx.db.transaction(async (transaction) => {
+ await transaction.insert(boards).values({
+ id: boardId,
+ name: input.name,
+ });
+ await transaction.insert(sections).values({
+ id: createId(),
+ kind: "empty",
+ position: 0,
+ boardId,
+ });
+ });
+ }),
+ delete: publicProcedure
+ .input(z.object({ id: z.string() }))
+ .mutation(async ({ ctx, input }) => {
+ await ctx.db.delete(boards).where(eq(boards.id, input.id));
+ }),
+ default: publicProcedure.query(async ({ ctx }) => {
+ return await getFullBoardWithWhere(ctx.db, eq(boards.name, "default"));
+ }),
+ byName: publicProcedure
+ .input(validation.board.byName)
+ .query(async ({ input, ctx }) => {
+ return await getFullBoardWithWhere(ctx.db, eq(boards.name, input.name));
+ }),
+ saveGeneralSettings: publicProcedure
+ .input(validation.board.saveGeneralSettings)
+ .mutation(async ({ ctx, input }) => {
+ const board = await ctx.db.query.boards.findFirst({
+ where: eq(boards.id, input.boardId),
+ });
+
+ if (!board) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Board not found",
+ });
+ }
+
+ await ctx.db
+ .update(boards)
+ .set({
+ pageTitle: input.pageTitle,
+ metaTitle: input.metaTitle,
+ logoImageUrl: input.logoImageUrl,
+ faviconImageUrl: input.faviconImageUrl,
+ })
+ .where(eq(boards.id, input.boardId));
+ }),
+ save: publicProcedure
+ .input(validation.board.save)
+ .mutation(async ({ input, ctx }) => {
+ await ctx.db.transaction(async (tx) => {
+ const dbBoard = await getFullBoardWithWhere(
+ tx,
+ eq(boards.id, input.boardId),
+ );
+
+ const addedSections = filterAddedItems(
+ input.sections,
+ dbBoard.sections,
+ );
+
+ if (addedSections.length > 0) {
+ await tx.insert(sections).values(
+ addedSections.map((section) => ({
+ id: section.id,
+ kind: section.kind,
+ position: section.position,
+ name: "name" in section ? section.name : null,
+ boardId: dbBoard.id,
+ })),
+ );
+ }
+
+ const inputItems = input.sections.flatMap((section) =>
+ section.items.map((item) => ({ ...item, sectionId: section.id })),
+ );
+ const dbItems = dbBoard.sections.flatMap((section) =>
+ section.items.map((item) => ({ ...item, sectionId: section.id })),
+ );
+
+ const addedItems = filterAddedItems(inputItems, dbItems);
+
+ if (addedItems.length > 0) {
+ await tx.insert(items).values(
+ addedItems.map((item) => ({
+ id: item.id,
+ kind: item.kind,
+ height: item.height,
+ width: item.width,
+ xOffset: item.xOffset,
+ yOffset: item.yOffset,
+ options: superjson.stringify(item.options),
+ sectionId: item.sectionId,
+ })),
+ );
+ }
+
+ const inputIntegrationRelations = inputItems.flatMap(
+ ({ integrations, id: itemId }) =>
+ integrations.map((integration) => ({
+ integrationId: integration.id,
+ itemId,
+ })),
+ );
+ const dbIntegrationRelations = dbItems.flatMap(
+ ({ integrations, id: itemId }) =>
+ integrations.map((integration) => ({
+ integrationId: integration.id,
+ itemId,
+ })),
+ );
+ const addedIntegrationRelations = inputIntegrationRelations.filter(
+ (inputRelation) =>
+ !dbIntegrationRelations.some(
+ (dbRelation) =>
+ dbRelation.itemId === inputRelation.itemId &&
+ dbRelation.integrationId === inputRelation.integrationId,
+ ),
+ );
+
+ if (addedIntegrationRelations.length > 0) {
+ await tx.insert(integrationItems).values(
+ addedIntegrationRelations.map((relation) => ({
+ itemId: relation.itemId,
+ integrationId: relation.integrationId,
+ })),
+ );
+ }
+
+ const updatedItems = filterUpdatedItems(inputItems, dbItems);
+
+ for (const item of updatedItems) {
+ await tx
+ .update(items)
+ .set({
+ kind: item.kind,
+ height: item.height,
+ width: item.width,
+ xOffset: item.xOffset,
+ yOffset: item.yOffset,
+ options: superjson.stringify(item.options),
+ sectionId: item.sectionId,
+ })
+ .where(eq(items.id, item.id));
+ }
+
+ const updatedSections = filterUpdatedItems(
+ input.sections,
+ dbBoard.sections,
+ );
+
+ for (const section of updatedSections) {
+ const prev = dbBoard.sections.find(
+ (dbSection) => dbSection.id === section.id,
+ );
+ await tx
+ .update(sections)
+ .set({
+ position: section.position,
+ name:
+ prev?.kind === "category" && "name" in section
+ ? section.name
+ : null,
+ })
+ .where(eq(sections.id, section.id));
+ }
+
+ const removedIntegrationRelations = dbIntegrationRelations.filter(
+ (dbRelation) =>
+ !inputIntegrationRelations.some(
+ (inputRelation) =>
+ dbRelation.itemId === inputRelation.itemId &&
+ dbRelation.integrationId === inputRelation.integrationId,
+ ),
+ );
+
+ for (const relation of removedIntegrationRelations) {
+ await tx
+ .delete(integrationItems)
+ .where(
+ and(
+ eq(integrationItems.itemId, relation.itemId),
+ eq(integrationItems.integrationId, relation.integrationId),
+ ),
+ );
+ }
+
+ const removedItems = filterRemovedItems(inputItems, dbItems);
+
+ const itemIds = removedItems.map((item) => item.id);
+ if (itemIds.length > 0) {
+ await tx.delete(items).where(inArray(items.id, itemIds));
+ }
+
+ const removedSections = filterRemovedItems(
+ input.sections,
+ dbBoard.sections,
+ );
+ const sectionIds = removedSections.map((section) => section.id);
+
+ if (sectionIds.length > 0) {
+ await tx.delete(sections).where(inArray(sections.id, sectionIds));
+ }
+ });
+ }),
+});
+
+const getFullBoardWithWhere = async (db: Database, where: SQL) => {
+ const board = await db.query.boards.findFirst({
+ where,
+ with: {
+ sections: {
+ with: {
+ items: {
+ with: {
+ integrations: {
+ with: {
+ integration: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ });
+
+ if (!board) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Board not found",
+ });
+ }
+
+ const { sections, ...otherBoardProperties } = board;
+
+ return {
+ ...otherBoardProperties,
+ sections: sections.map((section) =>
+ parseSection({
+ ...section,
+ items: section.items.map((item) => ({
+ ...item,
+ integrations: item.integrations.map((item) => item.integration),
+ options: superjson.parse>(item.options),
+ })),
+ }),
+ ),
+ };
+};
+
+// The following is a bit of a mess, it's providing us typesafe options matching the widget kind.
+// But I might be able to do this in a better way in the future.
+const forKind = (kind: T) =>
+ z.object({
+ kind: z.literal(kind),
+ options: z.custom["options"]>>(),
+ }) as UnionizeSpecificItemSchemaForWidgetKind;
+
+type SpecificItemSchemaForWidgetKind = z.ZodObject<{
+ kind: z.ZodLiteral;
+ options: z.ZodType<
+ Partial["options"]>,
+ z.ZodTypeDef,
+ Partial["options"]>
+ >;
+}>;
+
+type UnionizeSpecificItemSchemaForWidgetKind = T extends WidgetKind
+ ? SpecificItemSchemaForWidgetKind
+ : never;
+
+const outputItemSchema = zodUnionFromArray(
+ widgetKinds.map((kind) => forKind(kind)),
+).and(sharedItemSchema);
+
+const parseSection = (section: unknown) => {
+ const result = createSectionSchema(outputItemSchema).safeParse(section);
+ if (!result.success) {
+ throw new Error(result.error.message);
+ }
+ return result.data;
+};
diff --git a/packages/api/src/router/integration.ts b/packages/api/src/router/integration.ts
index 1cfe85cca..1be00335a 100644
--- a/packages/api/src/router/integration.ts
+++ b/packages/api/src/router/integration.ts
@@ -1,6 +1,7 @@
import crypto from "crypto";
import { TRPCError } from "@trpc/server";
+import type { Database } from "@homarr/db";
import { and, createId, eq } from "@homarr/db";
import { integrations, integrationSecrets } from "@homarr/db/schema/sqlite";
import type { IntegrationSecretKind } from "@homarr/definitions";
@@ -128,18 +129,20 @@ export const integrationRouter = createTRPCRouter({
if (changedSecrets.length > 0) {
for (const changedSecret of changedSecrets) {
- await ctx.db
- .update(integrationSecrets)
- .set({
- value: encryptSecret(changedSecret.value),
- updatedAt: new Date(),
- })
- .where(
- and(
- eq(integrationSecrets.integrationId, input.id),
- eq(integrationSecrets.kind, changedSecret.kind),
- ),
- );
+ const secretInput = {
+ integrationId: input.id,
+ value: changedSecret.value,
+ kind: changedSecret.kind,
+ };
+ if (
+ !decryptedSecrets.some(
+ (secret) => secret.kind === changedSecret.kind,
+ )
+ ) {
+ await addSecret(ctx.db, secretInput);
+ } else {
+ await updateSecret(ctx.db, secretInput);
+ }
}
}
}),
@@ -165,7 +168,7 @@ export const integrationRouter = createTRPCRouter({
const secretKinds = getSecretKinds(input.kind);
const secrets = input.secrets.filter(
(secret): secret is { kind: IntegrationSecretKind; value: string } =>
- !!secret.value,
+ Boolean(secret.value),
);
const everyInputSecretDefined = secretKinds.every((secretKind) =>
secrets.some((secret) => secret.kind === secretKind),
@@ -204,6 +207,17 @@ export const integrationRouter = createTRPCRouter({
});
}
}
+
+ const everySecretDefined = secretKinds.every((secretKind) =>
+ secrets.some((secret) => secret.kind === secretKind),
+ );
+
+ if (!everySecretDefined) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "SECRETS_NOT_DEFINED",
+ });
+ }
}
// TODO: actually test the connection
@@ -223,7 +237,7 @@ const key = Buffer.from(
); // TODO: generate with const data = crypto.randomBytes(32).toString('hex')
//Encrypting text
-function encryptSecret(text: string): `${string}.${string}` {
+export function encryptSecret(text: string): `${string}.${string}` {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(algorithm, Buffer.from(key), iv);
let encrypted = cipher.update(text);
@@ -241,3 +255,37 @@ function decryptSecret(value: `${string}.${string}`) {
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString();
}
+
+interface UpdateSecretInput {
+ integrationId: string;
+ value: string;
+ kind: IntegrationSecretKind;
+}
+const updateSecret = async (db: Database, input: UpdateSecretInput) => {
+ await db
+ .update(integrationSecrets)
+ .set({
+ value: encryptSecret(input.value),
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(integrationSecrets.integrationId, input.integrationId),
+ eq(integrationSecrets.kind, input.kind),
+ ),
+ );
+};
+
+interface AddSecretInput {
+ integrationId: string;
+ value: string;
+ kind: IntegrationSecretKind;
+}
+const addSecret = async (db: Database, input: AddSecretInput) => {
+ await db.insert(integrationSecrets).values({
+ kind: input.kind,
+ value: encryptSecret(input.value),
+ updatedAt: new Date(),
+ integrationId: input.integrationId,
+ });
+};
diff --git a/packages/api/src/router/test/board.spec.ts b/packages/api/src/router/test/board.spec.ts
new file mode 100644
index 000000000..079fb4ccc
--- /dev/null
+++ b/packages/api/src/router/test/board.spec.ts
@@ -0,0 +1,648 @@
+import SuperJSON from "superjson";
+import { describe, expect, it, vi } from "vitest";
+
+import type { Session } from "@homarr/auth";
+import type { Database } from "@homarr/db";
+import { createId, eq } from "@homarr/db";
+import {
+ boards,
+ integrationItems,
+ integrations,
+ items,
+ sections,
+} from "@homarr/db/schema/sqlite";
+import { createDb } from "@homarr/db/test";
+
+import type { RouterOutputs } from "../../..";
+import { boardRouter } from "../board";
+
+// Mock the auth module to return an empty session
+vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
+
+export const expectToBeDefined = (value: T) => {
+ if (value === undefined) {
+ expect(value).toBeDefined();
+ }
+ if (value === null) {
+ expect(value).not.toBeNull();
+ }
+ return value as Exclude;
+};
+
+describe("default should return default board", () => {
+ it("should return default board", async () => {
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, session: null });
+
+ const fullBoardProps = await createFullBoardAsync(db, "default");
+
+ const result = await caller.default();
+
+ expectInputToBeFullBoardWithName(result, {
+ name: "default",
+ ...fullBoardProps,
+ });
+ });
+});
+
+describe("byName should return board by name", () => {
+ it.each([["default"], ["something"]])(
+ "should return board by name %s when present",
+ async (name) => {
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, session: null });
+
+ const fullBoardProps = await createFullBoardAsync(db, name);
+
+ const result = await caller.byName({ name });
+
+ expectInputToBeFullBoardWithName(result, {
+ name,
+ ...fullBoardProps,
+ });
+ },
+ );
+
+ it("should throw error when not present");
+});
+
+describe("saveGeneralSettings should save general settings", () => {
+ it("should save general settings", async () => {
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, session: null });
+
+ const newPageTitle = "newPageTitle";
+ const newMetaTitle = "newMetaTitle";
+ const newLogoImageUrl = "http://logo.image/url.png";
+ const newFaviconImageUrl = "http://favicon.image/url.png";
+
+ const { boardId } = await createFullBoardAsync(db, "default");
+
+ await caller.saveGeneralSettings({
+ pageTitle: newPageTitle,
+ metaTitle: newMetaTitle,
+ logoImageUrl: newLogoImageUrl,
+ faviconImageUrl: newFaviconImageUrl,
+ boardId,
+ });
+ });
+
+ it("should throw error when board not found", async () => {
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, session: null });
+
+ const act = async () =>
+ await caller.saveGeneralSettings({
+ pageTitle: "newPageTitle",
+ metaTitle: "newMetaTitle",
+ logoImageUrl: "http://logo.image/url.png",
+ faviconImageUrl: "http://favicon.image/url.png",
+ boardId: "nonExistentBoardId",
+ });
+
+ await expect(act()).rejects.toThrowError("Board not found");
+ });
+});
+
+describe("save should save full board", () => {
+ it("should remove section when not present in input", async () => {
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, session: null });
+
+ const { boardId, sectionId } = await createFullBoardAsync(db, "default");
+
+ await caller.save({
+ boardId,
+ sections: [
+ {
+ id: createId(),
+ kind: "empty",
+ position: 0,
+ items: [],
+ },
+ ],
+ });
+
+ const board = await db.query.boards.findFirst({
+ where: eq(boards.id, boardId),
+ with: {
+ sections: true,
+ },
+ });
+
+ const section = await db.query.boards.findFirst({
+ where: eq(sections.id, sectionId),
+ });
+
+ const definedBoard = expectToBeDefined(board);
+ expect(definedBoard.sections.length).toBe(1);
+ expect(definedBoard.sections[0]?.id).not.toBe(sectionId);
+ expect(section).toBeUndefined();
+ });
+ it("should remove item when not present in input", async () => {
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, session: null });
+
+ const { boardId, itemId, sectionId } = await createFullBoardAsync(
+ db,
+ "default",
+ );
+
+ await caller.save({
+ boardId,
+ sections: [
+ {
+ id: sectionId,
+ kind: "empty",
+ position: 0,
+ items: [
+ {
+ id: createId(),
+ kind: "clock",
+ options: { is24HourFormat: true },
+ integrations: [],
+ height: 1,
+ width: 1,
+ xOffset: 0,
+ yOffset: 0,
+ },
+ ],
+ },
+ ],
+ });
+
+ const board = await db.query.boards.findFirst({
+ where: eq(boards.id, boardId),
+ with: {
+ sections: {
+ with: {
+ items: true,
+ },
+ },
+ },
+ });
+
+ const item = await db.query.items.findFirst({
+ where: eq(items.id, itemId),
+ });
+
+ const definedBoard = expectToBeDefined(board);
+ expect(definedBoard.sections.length).toBe(1);
+ const firstSection = expectToBeDefined(definedBoard.sections[0]);
+ expect(firstSection.items.length).toBe(1);
+ expect(firstSection.items[0]?.id).not.toBe(itemId);
+ expect(item).toBeUndefined();
+ });
+ it("should remove integration reference when not present in input", async () => {
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, session: null });
+ const anotherIntegration = {
+ id: createId(),
+ kind: "adGuardHome",
+ name: "AdGuard Home",
+ url: "http://localhost:3000",
+ } as const;
+
+ const { boardId, itemId, integrationId, sectionId } =
+ await createFullBoardAsync(db, "default");
+ await db.insert(integrations).values(anotherIntegration);
+
+ await caller.save({
+ boardId,
+ sections: [
+ {
+ id: sectionId,
+ kind: "empty",
+ position: 0,
+ items: [
+ {
+ id: itemId,
+ kind: "clock",
+ options: { is24HourFormat: true },
+ integrations: [anotherIntegration],
+ height: 1,
+ width: 1,
+ xOffset: 0,
+ yOffset: 0,
+ },
+ ],
+ },
+ ],
+ });
+
+ const board = await db.query.boards.findFirst({
+ where: eq(boards.id, boardId),
+ with: {
+ sections: {
+ with: {
+ items: {
+ with: {
+ integrations: true,
+ },
+ },
+ },
+ },
+ },
+ });
+
+ const integration = await db.query.integrationItems.findFirst({
+ where: eq(integrationItems.integrationId, integrationId),
+ });
+
+ const definedBoard = expectToBeDefined(board);
+ expect(definedBoard.sections.length).toBe(1);
+ const firstSection = expectToBeDefined(definedBoard.sections[0]);
+ expect(firstSection.items.length).toBe(1);
+ const firstItem = expectToBeDefined(firstSection.items[0]);
+ expect(firstItem.integrations.length).toBe(1);
+ expect(firstItem.integrations[0]?.integrationId).not.toBe(integrationId);
+ expect(integration).toBeUndefined();
+ });
+ it.each([
+ [{ kind: "empty" as const }],
+ [{ kind: "category" as const, name: "My first category" }],
+ ])("should add section when present in input", async (partialSection) => {
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, session: null });
+
+ const { boardId, sectionId } = await createFullBoardAsync(db, "default");
+
+ const newSectionId = createId();
+ await caller.save({
+ boardId,
+ sections: [
+ {
+ id: newSectionId,
+ position: 1,
+ items: [],
+ ...partialSection,
+ },
+ {
+ id: sectionId,
+ kind: "empty",
+ position: 0,
+ items: [],
+ },
+ ],
+ });
+
+ const board = await db.query.boards.findFirst({
+ where: eq(boards.id, boardId),
+ with: {
+ sections: true,
+ },
+ });
+
+ const section = await db.query.sections.findFirst({
+ where: eq(sections.id, newSectionId),
+ });
+
+ const definedBoard = expectToBeDefined(board);
+ expect(definedBoard.sections.length).toBe(2);
+ const addedSection = expectToBeDefined(
+ definedBoard.sections.find((section) => section.id === newSectionId),
+ );
+ expect(addedSection).toBeDefined();
+ expect(addedSection.id).toBe(newSectionId);
+ expect(addedSection.kind).toBe(partialSection.kind);
+ expect(addedSection.position).toBe(1);
+ if ("name" in partialSection) {
+ expect(addedSection.name).toBe(partialSection.name);
+ }
+ expect(section).toBeDefined();
+ });
+ it("should add item when present in input", async () => {
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, session: null });
+
+ const { boardId, sectionId } = await createFullBoardAsync(db, "default");
+
+ const newItemId = createId();
+ await caller.save({
+ boardId,
+ sections: [
+ {
+ id: sectionId,
+ kind: "empty",
+ position: 0,
+ items: [
+ {
+ id: newItemId,
+ kind: "clock",
+ options: { is24HourFormat: true },
+ integrations: [],
+ height: 1,
+ width: 1,
+ xOffset: 3,
+ yOffset: 2,
+ },
+ ],
+ },
+ ],
+ });
+
+ const board = await db.query.boards.findFirst({
+ where: eq(boards.id, boardId),
+ with: {
+ sections: {
+ with: {
+ items: true,
+ },
+ },
+ },
+ });
+
+ const item = await db.query.items.findFirst({
+ where: eq(items.id, newItemId),
+ });
+
+ const definedBoard = expectToBeDefined(board);
+ expect(definedBoard.sections.length).toBe(1);
+ const firstSection = expectToBeDefined(definedBoard.sections[0]);
+ expect(firstSection.items.length).toBe(1);
+ const addedItem = expectToBeDefined(
+ firstSection.items.find((item) => item.id === newItemId),
+ );
+ expect(addedItem).toBeDefined();
+ expect(addedItem.id).toBe(newItemId);
+ expect(addedItem.kind).toBe("clock");
+ expect(addedItem.options).toBe(
+ SuperJSON.stringify({ is24HourFormat: true }),
+ );
+ expect(addedItem.height).toBe(1);
+ expect(addedItem.width).toBe(1);
+ expect(addedItem.xOffset).toBe(3);
+ expect(addedItem.yOffset).toBe(2);
+ expect(item).toBeDefined();
+ });
+ it("should add integration reference when present in input", async () => {
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, session: null });
+ const integration = {
+ id: createId(),
+ kind: "plex",
+ name: "Plex",
+ url: "http://plex.local",
+ } as const;
+
+ const { boardId, itemId, sectionId } = await createFullBoardAsync(
+ db,
+ "default",
+ );
+ await db.insert(integrations).values(integration);
+
+ await caller.save({
+ boardId,
+ sections: [
+ {
+ id: sectionId,
+ kind: "empty",
+ position: 0,
+ items: [
+ {
+ id: itemId,
+ kind: "clock",
+ options: { is24HourFormat: true },
+ integrations: [integration],
+ height: 1,
+ width: 1,
+ xOffset: 0,
+ yOffset: 0,
+ },
+ ],
+ },
+ ],
+ });
+
+ const board = await db.query.boards.findFirst({
+ where: eq(boards.id, boardId),
+ with: {
+ sections: {
+ with: {
+ items: {
+ with: {
+ integrations: true,
+ },
+ },
+ },
+ },
+ },
+ });
+
+ const integrationItem = await db.query.integrationItems.findFirst({
+ where: eq(integrationItems.integrationId, integration.id),
+ });
+
+ const definedBoard = expectToBeDefined(board);
+ expect(definedBoard.sections.length).toBe(1);
+ const firstSection = expectToBeDefined(definedBoard.sections[0]);
+ expect(firstSection.items.length).toBe(1);
+ const firstItem = expectToBeDefined(
+ firstSection.items.find((item) => item.id === itemId),
+ );
+ expect(firstItem.integrations.length).toBe(1);
+ expect(firstItem.integrations[0]?.integrationId).toBe(integration.id);
+ expect(integrationItem).toBeDefined();
+ });
+ it("should update section when present in input", async () => {
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, session: null });
+
+ const { boardId, sectionId } = await createFullBoardAsync(db, "default");
+ const newSectionId = createId();
+ await db.insert(sections).values({
+ id: newSectionId,
+ kind: "category",
+ name: "Before",
+ position: 1,
+ boardId,
+ });
+
+ await caller.save({
+ boardId,
+ sections: [
+ {
+ id: sectionId,
+ kind: "category",
+ position: 1,
+ name: "Test",
+ items: [],
+ },
+ {
+ id: newSectionId,
+ kind: "category",
+ name: "After",
+ position: 0,
+ items: [],
+ },
+ ],
+ });
+
+ const board = await db.query.boards.findFirst({
+ where: eq(boards.id, boardId),
+ with: {
+ sections: true,
+ },
+ });
+
+ const definedBoard = expectToBeDefined(board);
+ expect(definedBoard.sections.length).toBe(2);
+ const firstSection = expectToBeDefined(
+ definedBoard.sections.find((section) => section.id === sectionId),
+ );
+ expect(firstSection.id).toBe(sectionId);
+ expect(firstSection.kind).toBe("empty");
+ expect(firstSection.position).toBe(1);
+ expect(firstSection.name).toBe(null);
+ const secondSection = expectToBeDefined(
+ definedBoard.sections.find((section) => section.id === newSectionId),
+ );
+ expect(secondSection.id).toBe(newSectionId);
+ expect(secondSection.kind).toBe("category");
+ expect(secondSection.position).toBe(0);
+ expect(secondSection.name).toBe("After");
+ });
+ it("should update item when present in input", async () => {
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, session: null });
+
+ const { boardId, itemId, sectionId } = await createFullBoardAsync(
+ db,
+ "default",
+ );
+
+ await caller.save({
+ boardId,
+ sections: [
+ {
+ id: sectionId,
+ kind: "empty",
+ position: 0,
+ items: [
+ {
+ id: itemId,
+ kind: "clock",
+ options: { is24HourFormat: false },
+ integrations: [],
+ height: 3,
+ width: 2,
+ xOffset: 7,
+ yOffset: 5,
+ },
+ ],
+ },
+ ],
+ });
+
+ const board = await db.query.boards.findFirst({
+ where: eq(boards.id, boardId),
+ with: {
+ sections: {
+ with: {
+ items: true,
+ },
+ },
+ },
+ });
+
+ const definedBoard = expectToBeDefined(board);
+ expect(definedBoard.sections.length).toBe(1);
+ const firstSection = expectToBeDefined(definedBoard.sections[0]);
+ expect(firstSection.items.length).toBe(1);
+ const firstItem = expectToBeDefined(
+ firstSection.items.find((item) => item.id === itemId),
+ );
+ expect(firstItem.id).toBe(itemId);
+ expect(firstItem.kind).toBe("clock");
+ expect(
+ SuperJSON.parse<{ is24HourFormat: boolean }>(firstItem.options)
+ .is24HourFormat,
+ ).toBe(false);
+ expect(firstItem.height).toBe(3);
+ expect(firstItem.width).toBe(2);
+ expect(firstItem.xOffset).toBe(7);
+ expect(firstItem.yOffset).toBe(5);
+ });
+ it("should fail when board not found", async () => {
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, session: null });
+
+ const act = async () =>
+ await caller.save({
+ boardId: "nonExistentBoardId",
+ sections: [],
+ });
+
+ await expect(act()).rejects.toThrowError("Board not found");
+ });
+});
+
+const expectInputToBeFullBoardWithName = (
+ input: RouterOutputs["board"]["default"],
+ props: { name: string } & Awaited>,
+) => {
+ expect(input.id).toBe(props.boardId);
+ expect(input.name).toBe(props.name);
+ expect(input.sections.length).toBe(1);
+ const firstSection = expectToBeDefined(input.sections[0]);
+ expect(firstSection.id).toBe(props.sectionId);
+ expect(firstSection.items.length).toBe(1);
+ const firstItem = expectToBeDefined(firstSection.items[0]);
+ expect(firstItem.id).toBe(props.itemId);
+ expect(firstItem.kind).toBe("clock");
+ if (firstItem.kind === "clock") {
+ expect(firstItem.options.is24HourFormat).toBe(true);
+ }
+ expect(firstItem.integrations.length).toBe(1);
+ const firstIntegration = expectToBeDefined(firstItem.integrations[0]);
+ expect(firstIntegration.id).toBe(props.integrationId);
+ expect(firstIntegration.kind).toBe("adGuardHome");
+};
+
+const createFullBoardAsync = async (db: Database, name: string) => {
+ const boardId = createId();
+ await db.insert(boards).values({
+ id: boardId,
+ name,
+ });
+
+ const sectionId = createId();
+ await db.insert(sections).values({
+ id: sectionId,
+ kind: "empty",
+ position: 0,
+ boardId,
+ });
+
+ const itemId = createId();
+ await db.insert(items).values({
+ id: itemId,
+ kind: "clock",
+ height: 1,
+ width: 1,
+ xOffset: 0,
+ yOffset: 0,
+ sectionId,
+ options: SuperJSON.stringify({ is24HourFormat: true }),
+ });
+
+ const integrationId = createId();
+ await db.insert(integrations).values({
+ id: integrationId,
+ kind: "adGuardHome",
+ name: "AdGuard Home",
+ url: "http://localhost:3000",
+ });
+
+ await db.insert(integrationItems).values({
+ integrationId,
+ itemId,
+ });
+
+ return {
+ boardId,
+ sectionId,
+ itemId,
+ integrationId,
+ };
+};
diff --git a/packages/api/src/router/test/integration.spec.ts b/packages/api/src/router/test/integration.spec.ts
new file mode 100644
index 000000000..11c14de18
--- /dev/null
+++ b/packages/api/src/router/test/integration.spec.ts
@@ -0,0 +1,503 @@
+import { describe, expect, it, vi } from "vitest";
+
+import type { Session } from "@homarr/auth";
+import { createId } from "@homarr/db";
+import { integrations, integrationSecrets } from "@homarr/db/schema/sqlite";
+import { createDb } from "@homarr/db/test";
+
+import type { RouterInputs } from "../../..";
+import { encryptSecret, integrationRouter } from "../integration";
+import { expectToBeDefined } from "./board.spec";
+
+// Mock the auth module to return an empty session
+vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
+
+describe("all should return all integrations", () => {
+ it("should return all integrations", async () => {
+ const db = createDb();
+ const caller = integrationRouter.createCaller({
+ db,
+ session: null,
+ });
+
+ await db.insert(integrations).values([
+ {
+ id: "1",
+ name: "Home assistant",
+ kind: "homeAssistant",
+ url: "http://homeassist.local",
+ },
+ {
+ id: "2",
+ name: "Home plex server",
+ kind: "plex",
+ url: "http://plex.local",
+ },
+ ]);
+
+ const result = await caller.all();
+ expect(result.length).toBe(2);
+ expect(result[0]!.kind).toBe("plex");
+ expect(result[1]!.kind).toBe("homeAssistant");
+ });
+});
+
+describe("byId should return an integration by id", () => {
+ it("should return an integration by id", async () => {
+ const db = createDb();
+ const caller = integrationRouter.createCaller({
+ db,
+ session: null,
+ });
+
+ await db.insert(integrations).values([
+ {
+ id: "1",
+ name: "Home assistant",
+ kind: "homeAssistant",
+ url: "http://homeassist.local",
+ },
+ {
+ id: "2",
+ name: "Home plex server",
+ kind: "plex",
+ url: "http://plex.local",
+ },
+ ]);
+
+ const result = await caller.byId({ id: "2" });
+ expect(result.kind).toBe("plex");
+ });
+
+ it("should throw an error if the integration does not exist", async () => {
+ const db = createDb();
+ const caller = integrationRouter.createCaller({
+ db,
+ session: null,
+ });
+
+ const act = async () => await caller.byId({ id: "2" });
+ await expect(act()).rejects.toThrow("Integration not found");
+ });
+
+ it("should only return the public secret values", async () => {
+ const db = createDb();
+ const caller = integrationRouter.createCaller({
+ db,
+ session: null,
+ });
+
+ await db.insert(integrations).values([
+ {
+ id: "1",
+ name: "Home assistant",
+ kind: "homeAssistant",
+ url: "http://homeassist.local",
+ },
+ ]);
+ await db.insert(integrationSecrets).values([
+ {
+ kind: "username",
+ value: encryptSecret("musterUser"),
+ integrationId: "1",
+ updatedAt: new Date(),
+ },
+ {
+ kind: "password",
+ value: encryptSecret("Password123!"),
+ integrationId: "1",
+ updatedAt: new Date(),
+ },
+ {
+ kind: "apiKey",
+ value: encryptSecret("1234567890"),
+ integrationId: "1",
+ updatedAt: new Date(),
+ },
+ ]);
+
+ const result = await caller.byId({ id: "1" });
+ expect(result.secrets.length).toBe(3);
+ const username = expectToBeDefined(
+ result.secrets.find((secret) => secret.kind === "username"),
+ );
+ expect(username.value).not.toBeNull();
+ const password = expectToBeDefined(
+ result.secrets.find((secret) => secret.kind === "password"),
+ );
+ expect(password.value).toBeNull();
+ const apiKey = expectToBeDefined(
+ result.secrets.find((secret) => secret.kind === "apiKey"),
+ );
+ expect(apiKey.value).toBeNull();
+ });
+});
+
+describe("create should create a new integration", () => {
+ it("should create a new integration", async () => {
+ const db = createDb();
+ const caller = integrationRouter.createCaller({
+ db,
+ session: null,
+ });
+ const input = {
+ name: "Jellyfin",
+ kind: "jellyfin" as const,
+ url: "http://jellyfin.local",
+ secrets: [{ kind: "apiKey" as const, value: "1234567890" }],
+ };
+
+ const fakeNow = new Date("2023-07-01T00:00:00Z");
+ vi.useFakeTimers();
+ vi.setSystemTime(fakeNow);
+ await caller.create(input);
+ vi.useRealTimers();
+
+ const dbIntegration = await db.query.integrations.findFirst();
+ const dbSecret = await db.query.integrationSecrets.findFirst();
+ expect(dbIntegration).toBeDefined();
+ expect(dbIntegration!.name).toBe(input.name);
+ expect(dbIntegration!.kind).toBe(input.kind);
+ expect(dbIntegration!.url).toBe(input.url);
+
+ expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
+ expect(dbSecret).toBeDefined();
+ expect(dbSecret!.kind).toBe(input.secrets[0]!.kind);
+ expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
+ expect(dbSecret!.updatedAt).toEqual(fakeNow);
+ });
+});
+
+describe("update should update an integration", () => {
+ it("should update an integration", async () => {
+ const db = createDb();
+ const caller = integrationRouter.createCaller({
+ db,
+ session: null,
+ });
+
+ const lastWeek = new Date("2023-06-24T00:00:00Z");
+ const integrationId = createId();
+ const toInsert = {
+ id: integrationId,
+ name: "Pi Hole",
+ kind: "piHole" as const,
+ url: "http://hole.local",
+ };
+
+ await db.insert(integrations).values(toInsert);
+
+ const usernameToInsert = {
+ kind: "username" as const,
+ value: encryptSecret("musterUser"),
+ integrationId,
+ updatedAt: lastWeek,
+ };
+
+ const passwordToInsert = {
+ kind: "password" as const,
+ value: encryptSecret("Password123!"),
+ integrationId,
+ updatedAt: lastWeek,
+ };
+ await db
+ .insert(integrationSecrets)
+ .values([usernameToInsert, passwordToInsert]);
+
+ const input = {
+ id: integrationId,
+ name: "Milky Way Pi Hole",
+ kind: "piHole" as const,
+ url: "http://milkyway.local",
+ secrets: [
+ { kind: "username" as const, value: "newUser" },
+ { kind: "password" as const, value: null },
+ { kind: "apiKey" as const, value: "1234567890" },
+ ],
+ };
+
+ const fakeNow = new Date("2023-07-01T00:00:00Z");
+ vi.useFakeTimers();
+ vi.setSystemTime(fakeNow);
+ await caller.update(input);
+ vi.useRealTimers();
+
+ const dbIntegration = await db.query.integrations.findFirst();
+ const dbSecrets = await db.query.integrationSecrets.findMany();
+
+ expect(dbIntegration).toBeDefined();
+ expect(dbIntegration!.name).toBe(input.name);
+ expect(dbIntegration!.kind).toBe(input.kind);
+ expect(dbIntegration!.url).toBe(input.url);
+
+ expect(dbSecrets.length).toBe(3);
+ const username = expectToBeDefined(
+ dbSecrets.find((secret) => secret.kind === "username"),
+ );
+ const password = expectToBeDefined(
+ dbSecrets.find((secret) => secret.kind === "password"),
+ );
+ const apiKey = expectToBeDefined(
+ dbSecrets.find((secret) => secret.kind === "apiKey"),
+ );
+ expect(username.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
+ expect(password.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
+ expect(apiKey.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
+ expect(username.updatedAt).toEqual(fakeNow);
+ expect(password.updatedAt).toEqual(lastWeek);
+ expect(apiKey.updatedAt).toEqual(fakeNow);
+ expect(username.value).not.toEqual(usernameToInsert.value);
+ expect(password.value).toEqual(passwordToInsert.value);
+ expect(apiKey.value).not.toEqual(input.secrets[2]!.value);
+ });
+
+ it("should throw an error if the integration does not exist", async () => {
+ const db = createDb();
+ const caller = integrationRouter.createCaller({
+ db,
+ session: null,
+ });
+
+ const act = async () =>
+ await caller.update({
+ id: createId(),
+ name: "Pi Hole",
+ url: "http://hole.local",
+ secrets: [],
+ });
+ await expect(act()).rejects.toThrow("Integration not found");
+ });
+});
+
+describe("delete should delete an integration", () => {
+ it("should delete an integration", async () => {
+ const db = createDb();
+ const caller = integrationRouter.createCaller({
+ db,
+ session: null,
+ });
+
+ const integrationId = createId();
+ await db.insert(integrations).values({
+ id: integrationId,
+ name: "Home assistant",
+ kind: "homeAssistant",
+ url: "http://homeassist.local",
+ });
+
+ await db.insert(integrationSecrets).values([
+ {
+ kind: "username",
+ value: encryptSecret("example"),
+ integrationId,
+ updatedAt: new Date(),
+ },
+ ]);
+
+ await caller.delete({ id: integrationId });
+
+ const dbIntegration = await db.query.integrations.findFirst();
+ expect(dbIntegration).toBeUndefined();
+ const dbSecrets = await db.query.integrationSecrets.findMany();
+ expect(dbSecrets.length).toBe(0);
+ });
+});
+
+describe("testConnection should test the connection to an integration", () => {
+ it.each([
+ [
+ "nzbGet" as const,
+ [
+ { kind: "username" as const, value: null },
+ { kind: "password" as const, value: "Password123!" },
+ ],
+ ],
+ [
+ "nzbGet" as const,
+ [
+ { kind: "username" as const, value: "exampleUser" },
+ { kind: "password" as const, value: null },
+ ],
+ ],
+ ["sabNzbd" as const, [{ kind: "apiKey" as const, value: null }]],
+ [
+ "sabNzbd" as const,
+ [
+ { kind: "username" as const, value: "exampleUser" },
+ { kind: "password" as const, value: "Password123!" },
+ ],
+ ],
+ ])(
+ "should fail when a required secret is missing when creating %s integration",
+ async (kind, secrets) => {
+ const db = createDb();
+ const caller = integrationRouter.createCaller({
+ db,
+ session: null,
+ });
+
+ const input: RouterInputs["integration"]["testConnection"] = {
+ id: null,
+ kind,
+ url: `http://${kind}.local`,
+ secrets,
+ };
+
+ const act = async () => await caller.testConnection(input);
+ await expect(act()).rejects.toThrow("SECRETS_NOT_DEFINED");
+ },
+ );
+
+ it.each([
+ [
+ "nzbGet" as const,
+ [
+ { kind: "username" as const, value: "exampleUser" },
+ { kind: "password" as const, value: "Password123!" },
+ ],
+ ],
+ ["sabNzbd" as const, [{ kind: "apiKey" as const, value: "1234567890" }]],
+ ])(
+ "should be successful when all required secrets are defined for creation of %s integration",
+ async (kind, secrets) => {
+ const db = createDb();
+ const caller = integrationRouter.createCaller({
+ db,
+ session: null,
+ });
+
+ const input: RouterInputs["integration"]["testConnection"] = {
+ id: null,
+ kind,
+ url: `http://${kind}.local`,
+ secrets,
+ };
+
+ const act = async () => await caller.testConnection(input);
+ await expect(act()).resolves.toBeUndefined();
+ },
+ );
+
+ it("should be successful when all required secrets are defined for updating an nzbGet integration", async () => {
+ const db = createDb();
+ const caller = integrationRouter.createCaller({
+ db,
+ session: null,
+ });
+
+ const input: RouterInputs["integration"]["testConnection"] = {
+ id: createId(),
+ kind: "nzbGet",
+ url: "http://nzbGet.local",
+ secrets: [
+ { kind: "username", value: "exampleUser" },
+ { kind: "password", value: "Password123!" },
+ ],
+ };
+
+ const act = async () => await caller.testConnection(input);
+ await expect(act()).resolves.toBeUndefined();
+ });
+
+ it("should be successful when overriding one of the secrets for an existing nzbGet integration", async () => {
+ const db = createDb();
+ const caller = integrationRouter.createCaller({
+ db,
+ session: null,
+ });
+
+ const integrationId = createId();
+ await db.insert(integrations).values({
+ id: integrationId,
+ name: "NZBGet",
+ kind: "nzbGet",
+ url: "http://nzbGet.local",
+ });
+
+ await db.insert(integrationSecrets).values([
+ {
+ kind: "username",
+ value: encryptSecret("exampleUser"),
+ integrationId,
+ updatedAt: new Date(),
+ },
+ {
+ kind: "password",
+ value: encryptSecret("Password123!"),
+ integrationId,
+ updatedAt: new Date(),
+ },
+ ]);
+
+ const input: RouterInputs["integration"]["testConnection"] = {
+ id: integrationId,
+ kind: "nzbGet",
+ url: "http://nzbGet.local",
+ secrets: [
+ { kind: "username", value: "newUser" },
+ { kind: "password", value: null },
+ ],
+ };
+
+ const act = async () => await caller.testConnection(input);
+ await expect(act()).resolves.toBeUndefined();
+ });
+
+ it("should fail when a required secret is missing for an existing nzbGet integration", async () => {
+ const db = createDb();
+ const caller = integrationRouter.createCaller({
+ db,
+ session: null,
+ });
+
+ const integrationId = createId();
+ await db.insert(integrations).values({
+ id: integrationId,
+ name: "NZBGet",
+ kind: "nzbGet",
+ url: "http://nzbGet.local",
+ });
+
+ await db.insert(integrationSecrets).values([
+ {
+ kind: "username",
+ value: encryptSecret("exampleUser"),
+ integrationId,
+ updatedAt: new Date(),
+ },
+ ]);
+
+ const input: RouterInputs["integration"]["testConnection"] = {
+ id: integrationId,
+ kind: "nzbGet",
+ url: "http://nzbGet.local",
+ secrets: [
+ { kind: "username", value: "newUser" },
+ { kind: "apiKey", value: "1234567890" },
+ ],
+ };
+
+ const act = async () => await caller.testConnection(input);
+ await expect(act()).rejects.toThrow("SECRETS_NOT_DEFINED");
+ });
+
+ it("should fail when the updating integration does not exist", async () => {
+ const db = createDb();
+ const caller = integrationRouter.createCaller({
+ db,
+ session: null,
+ });
+
+ const act = async () =>
+ await caller.testConnection({
+ id: createId(),
+ kind: "nzbGet",
+ url: "http://nzbGet.local",
+ secrets: [
+ { kind: "username", value: null },
+ { kind: "password", value: "Password123!" },
+ ],
+ });
+ await expect(act()).rejects.toThrow("SECRETS_NOT_DEFINED");
+ });
+});
diff --git a/packages/api/src/router/test/user.spec.ts b/packages/api/src/router/test/user.spec.ts
new file mode 100644
index 000000000..29ce6e349
--- /dev/null
+++ b/packages/api/src/router/test/user.spec.ts
@@ -0,0 +1,94 @@
+import { describe, expect, it, vi } from "vitest";
+
+import type { Session } from "@homarr/auth";
+import { schema } from "@homarr/db";
+import { createDb } from "@homarr/db/test";
+
+import { userRouter } from "../user";
+
+// Mock the auth module to return an empty session
+vi.mock("@homarr/auth", async () => {
+ const mod = await import("@homarr/auth/security");
+ return { ...mod, auth: () => ({}) as Session };
+});
+
+describe("initUser should initialize the first user", () => {
+ it("should throw an error if a user already exists", async () => {
+ const db = createDb();
+ const caller = userRouter.createCaller({
+ db,
+ session: null,
+ });
+
+ await db.insert(schema.users).values({
+ id: "test",
+ name: "test",
+ password: "test",
+ });
+
+ const act = async () =>
+ await caller.initUser({
+ username: "test",
+ password: "12345678",
+ confirmPassword: "12345678",
+ });
+
+ await expect(act()).rejects.toThrow("User already exists");
+ });
+
+ it("should create a user if none exists", async () => {
+ const db = createDb();
+ const caller = userRouter.createCaller({
+ db,
+ session: null,
+ });
+
+ await caller.initUser({
+ username: "test",
+ password: "12345678",
+ confirmPassword: "12345678",
+ });
+
+ const user = await db.query.users.findFirst({
+ columns: {
+ id: true,
+ },
+ });
+
+ expect(user).toBeDefined();
+ });
+
+ it("should not create a user if the password and confirmPassword do not match", async () => {
+ const db = createDb();
+ const caller = userRouter.createCaller({
+ db,
+ session: null,
+ });
+
+ const act = async () =>
+ await caller.initUser({
+ username: "test",
+ password: "12345678",
+ confirmPassword: "12345679",
+ });
+
+ await expect(act()).rejects.toThrow("Passwords do not match");
+ });
+
+ it("should not create a user if the password is too short", async () => {
+ const db = createDb();
+ const caller = userRouter.createCaller({
+ db,
+ session: null,
+ });
+
+ const act = async () =>
+ await caller.initUser({
+ username: "test",
+ password: "1234567",
+ confirmPassword: "1234567",
+ });
+
+ await expect(act()).rejects.toThrow("too_small");
+ });
+});
diff --git a/packages/auth/callbacks.ts b/packages/auth/callbacks.ts
new file mode 100644
index 000000000..05a298290
--- /dev/null
+++ b/packages/auth/callbacks.ts
@@ -0,0 +1,61 @@
+import { cookies } from "next/headers";
+import type { Adapter } from "@auth/core/adapters";
+import type { NextAuthConfig } from "next-auth";
+
+import {
+ expireDateAfter,
+ generateSessionToken,
+ sessionMaxAgeInSeconds,
+ sessionTokenCookieName,
+} from "./session";
+
+export const sessionCallback: NextAuthCallbackOf<"session"> = ({
+ session,
+ user,
+}) => ({
+ ...session,
+ user: {
+ ...session.user,
+ id: user.id,
+ name: user.name,
+ },
+});
+
+export const createSignInCallback =
+ (
+ adapter: Adapter,
+ isCredentialsRequest: boolean,
+ ): NextAuthCallbackOf<"signIn"> =>
+ async ({ user }) => {
+ if (!isCredentialsRequest) return true;
+
+ if (!user) return true;
+
+ // https://github.com/nextauthjs/next-auth/issues/6106
+ if (!adapter?.createSession) {
+ return false;
+ }
+
+ const sessionToken = generateSessionToken();
+ const sessionExpiry = expireDateAfter(sessionMaxAgeInSeconds);
+
+ await adapter.createSession({
+ sessionToken,
+ userId: user.id!,
+ expires: sessionExpiry,
+ });
+
+ cookies().set(sessionTokenCookieName, sessionToken, {
+ path: "/",
+ expires: sessionExpiry,
+ httpOnly: true,
+ sameSite: "lax",
+ secure: true,
+ });
+
+ return true;
+ };
+
+type NextAuthCallbackRecord = Exclude;
+export type NextAuthCallbackOf =
+ Exclude;
diff --git a/packages/auth/configuration.ts b/packages/auth/configuration.ts
index 047f11491..32289da46 100644
--- a/packages/auth/configuration.ts
+++ b/packages/auth/configuration.ts
@@ -5,55 +5,23 @@ import Credentials from "next-auth/providers/credentials";
import { db } from "@homarr/db";
-import { credentialsConfiguration } from "./providers/credentials";
+import { createSignInCallback, sessionCallback } from "./callbacks";
+import { createCredentialsConfiguration } from "./providers/credentials";
import { EmptyNextAuthProvider } from "./providers/empty";
-import { expireDateAfter, generateSessionToken } from "./session";
+import { sessionMaxAgeInSeconds, sessionTokenCookieName } from "./session";
const adapter = DrizzleAdapter(db);
-const sessionMaxAgeInSeconds = 30 * 24 * 60 * 60; // 30 days
export const createConfiguration = (isCredentialsRequest: boolean) =>
NextAuth({
adapter,
- providers: [Credentials(credentialsConfiguration), EmptyNextAuthProvider()],
+ providers: [
+ Credentials(createCredentialsConfiguration(db)),
+ EmptyNextAuthProvider(),
+ ],
callbacks: {
- session: ({ session, user }) => ({
- ...session,
- user: {
- ...session.user,
- id: user.id,
- name: user.name,
- },
- }),
- signIn: async ({ user }) => {
- if (!isCredentialsRequest) return true;
-
- if (!user) return true;
-
- const sessionToken = generateSessionToken();
- const sessionExpiry = expireDateAfter(sessionMaxAgeInSeconds);
-
- // https://github.com/nextauthjs/next-auth/issues/6106
- if (!adapter?.createSession) {
- return false;
- }
-
- await adapter.createSession({
- sessionToken: sessionToken,
- userId: user.id,
- expires: sessionExpiry,
- });
-
- cookies().set("next-auth.session-token", sessionToken, {
- path: "/",
- expires: sessionExpiry,
- httpOnly: true,
- sameSite: "lax",
- secure: true,
- });
-
- return true;
- },
+ session: sessionCallback,
+ signIn: createSignInCallback(adapter, isCredentialsRequest),
},
session: {
strategy: "database",
@@ -65,7 +33,7 @@ export const createConfiguration = (isCredentialsRequest: boolean) =>
},
jwt: {
encode() {
- const cookie = cookies().get("next-auth.session-token")?.value;
+ const cookie = cookies().get(sessionTokenCookieName)?.value;
return cookie ?? "";
},
diff --git a/packages/auth/env.mjs b/packages/auth/env.mjs
index 2866529a5..9c260510b 100644
--- a/packages/auth/env.mjs
+++ b/packages/auth/env.mjs
@@ -20,5 +20,6 @@ export const env = createEnv({
AUTH_SECRET: process.env.AUTH_SECRET,
AUTH_URL: process.env.AUTH_URL,
},
- skipValidation: !!process.env.CI || !!process.env.SKIP_ENV_VALIDATION,
+ skipValidation:
+ Boolean(process.env.CI) || Boolean(process.env.SKIP_ENV_VALIDATION),
});
diff --git a/packages/auth/package.json b/packages/auth/package.json
index 8325ce8d8..23bd81cea 100644
--- a/packages/auth/package.json
+++ b/packages/auth/package.json
@@ -1,6 +1,12 @@
{
"name": "@homarr/auth",
"version": "0.1.0",
+ "exports": {
+ ".": "./index.ts",
+ "./security": "./security.ts",
+ "./client": "./client.ts",
+ "./env.mjs": "./env.mjs"
+ },
"private": true,
"main": "./index.ts",
"types": "./index.ts",
@@ -13,13 +19,13 @@
},
"dependencies": {
"@homarr/db": "workspace:^0.1.0",
- "@auth/core": "^0.19.0",
- "@auth/drizzle-adapter": "^0.3.12",
- "@t3-oss/env-nextjs": "^0.7.1",
+ "@auth/core": "^0.27.0",
+ "@auth/drizzle-adapter": "^0.7.0",
+ "@t3-oss/env-nextjs": "^0.9.2",
"bcrypt": "^5.1.1",
"cookies": "^0.9.1",
- "next": "^14.0.4",
- "next-auth": "5.0.0-beta.5",
+ "next": "^14.1.0",
+ "next-auth": "5.0.0-beta.11",
"react": "18.2.0",
"react-dom": "18.2.0"
},
@@ -29,9 +35,9 @@
"@homarr/tsconfig": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@types/bcrypt": "5.0.2",
- "@types/cookies": "0.7.10",
+ "@types/cookies": "0.9.0",
"eslint": "^8.56.0",
- "prettier": "^3.1.0",
+ "prettier": "^3.2.5",
"typescript": "^5.3.3"
},
"eslintConfig": {
diff --git a/packages/auth/providers/credentials.ts b/packages/auth/providers/credentials.ts
index 114c8ecba..04470dbfa 100644
--- a/packages/auth/providers/credentials.ts
+++ b/packages/auth/providers/credentials.ts
@@ -1,49 +1,56 @@
import type Credentials from "@auth/core/providers/credentials";
import bcrypt from "bcrypt";
-import { db, eq } from "@homarr/db";
+import type { Database } from "@homarr/db";
+import { eq } from "@homarr/db";
import { users } from "@homarr/db/schema/sqlite";
import { validation } from "@homarr/validation";
type CredentialsConfiguration = Parameters[0];
-export const credentialsConfiguration = {
- type: "credentials",
- name: "Credentials",
- credentials: {
- name: {
- label: "Username",
- type: "text",
+export const createCredentialsConfiguration = (db: Database) =>
+ ({
+ type: "credentials",
+ name: "Credentials",
+ credentials: {
+ name: {
+ label: "Username",
+ type: "text",
+ },
+ password: {
+ label: "Password",
+ type: "password",
+ },
},
- password: {
- label: "Password",
- type: "password",
+ async authorize(credentials) {
+ const data = await validation.user.signIn.parseAsync(credentials);
+
+ const user = await db.query.users.findFirst({
+ where: eq(users.name, data.name),
+ });
+
+ if (!user?.password) {
+ return null;
+ }
+
+ console.log(
+ `user ${user.name} is trying to log in. checking password...`,
+ );
+ const isValidPassword = await bcrypt.compare(
+ data.password,
+ user.password,
+ );
+
+ if (!isValidPassword) {
+ console.log(`password for user ${user.name} was incorrect`);
+ return null;
+ }
+
+ console.log(`user ${user.name} successfully authorized`);
+
+ return {
+ id: user.id,
+ name: user.name,
+ };
},
- },
- async authorize(credentials) {
- const data = await validation.user.signIn.parseAsync(credentials);
-
- const user = await db.query.users.findFirst({
- where: eq(users.name, data.name),
- });
-
- if (!user?.password) {
- return null;
- }
-
- console.log(`user ${user.name} is trying to log in. checking password...`);
- const isValidPassword = await bcrypt.compare(data.password, user.password);
-
- if (!isValidPassword) {
- console.log(`password for user ${user.name} was incorrect`);
- return null;
- }
-
- console.log(`user ${user.name} successfully authorized`);
-
- return {
- id: user.id,
- name: user.name,
- };
- },
-} satisfies CredentialsConfiguration;
+ }) satisfies CredentialsConfiguration;
diff --git a/packages/auth/providers/test/credentials.spec.ts b/packages/auth/providers/test/credentials.spec.ts
new file mode 100644
index 000000000..3feb4a919
--- /dev/null
+++ b/packages/auth/providers/test/credentials.spec.ts
@@ -0,0 +1,66 @@
+import { describe, expect, it } from "vitest";
+
+import { createId } from "@homarr/db";
+import { users } from "@homarr/db/schema/sqlite";
+import { createDb } from "@homarr/db/test";
+
+import { createSalt, hashPassword } from "../../security";
+import { createCredentialsConfiguration } from "../credentials";
+
+describe("Credentials authorization", () => {
+ it("should authorize user with correct credentials", async () => {
+ const db = createDb();
+ const userId = createId();
+ const salt = await createSalt();
+ await db.insert(users).values({
+ id: userId,
+ name: "test",
+ password: await hashPassword("test", salt),
+ salt,
+ });
+ const result = await createCredentialsConfiguration(db).authorize({
+ name: "test",
+ password: "test",
+ });
+
+ expect(result).toEqual({ id: userId, name: "test" });
+ });
+
+ const passwordsThatShouldNotAuthorize = [
+ "wrong",
+ "Test",
+ "test ",
+ " test",
+ " test ",
+ ];
+
+ passwordsThatShouldNotAuthorize.forEach((password) => {
+ it(`should not authorize user with incorrect credentials (${password})`, async () => {
+ const db = createDb();
+ const userId = createId();
+ const salt = await createSalt();
+ await db.insert(users).values({
+ id: userId,
+ name: "test",
+ password: await hashPassword("test", salt),
+ salt,
+ });
+ const result = await createCredentialsConfiguration(db).authorize({
+ name: "test",
+ password,
+ });
+
+ expect(result).toBeNull();
+ });
+ });
+
+ it("should not authorize user for not existing user", async () => {
+ const db = createDb();
+ const result = await createCredentialsConfiguration(db).authorize({
+ name: "test",
+ password: "test",
+ });
+
+ expect(result).toBeNull();
+ });
+});
diff --git a/packages/auth/session.ts b/packages/auth/session.ts
index 3329a0ffa..356401439 100644
--- a/packages/auth/session.ts
+++ b/packages/auth/session.ts
@@ -1,5 +1,8 @@
import { randomUUID } from "crypto";
+export const sessionMaxAgeInSeconds = 30 * 24 * 60 * 60; // 30 days
+export const sessionTokenCookieName = "next-auth.session-token";
+
export const expireDateAfter = (seconds: number) => {
return new Date(Date.now() + seconds * 1000);
};
diff --git a/packages/auth/test/callbacks.spec.ts b/packages/auth/test/callbacks.spec.ts
new file mode 100644
index 000000000..ab24e2c1b
--- /dev/null
+++ b/packages/auth/test/callbacks.spec.ts
@@ -0,0 +1,153 @@
+import type { ResponseCookie } from "next/dist/compiled/@edge-runtime/cookies";
+import type { ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies";
+import { cookies } from "next/headers";
+import type { Adapter, AdapterUser } from "@auth/core/adapters";
+import type { Account, User } from "next-auth";
+import type { JWT } from "next-auth/jwt";
+import { describe, expect, it, vi } from "vitest";
+
+import { createSignInCallback, sessionCallback } from "../callbacks";
+
+describe("session callback", () => {
+ it("should add id and name to session user", async () => {
+ const user: AdapterUser = {
+ id: "id",
+ name: "name",
+ email: "email",
+ emailVerified: new Date("2023-01-13"),
+ };
+ const token: JWT = {};
+ const result = await sessionCallback({
+ session: {
+ user: {
+ id: "no-id",
+ email: "no-email",
+ emailVerified: new Date("2023-01-13"),
+ },
+ expires: "2023-01-13" as Date & string,
+ sessionToken: "token",
+ userId: "no-id",
+ },
+ user,
+ token,
+ trigger: "update",
+ newSession: {},
+ });
+ expect(result.user).toBeDefined();
+ expect(result.user!.id).toEqual(user.id);
+ expect(result.user!.name).toEqual(user.name);
+ });
+});
+
+type AdapterSessionInput = Parameters<
+ Exclude
+>[0];
+
+const createAdapter = () => {
+ const result = {
+ createSession: (input: AdapterSessionInput) => input,
+ };
+
+ vi.spyOn(result, "createSession");
+ return result;
+};
+
+// eslint-disable-next-line @typescript-eslint/consistent-type-imports
+type SessionExport = typeof import("../session");
+const mockSessionToken = "e9ef3010-6981-4a81-b9d6-8495d09cf3b5" as const;
+const mockSessionExpiry = new Date("2023-07-01");
+vi.mock("../session", async (importOriginal) => {
+ const mod = await importOriginal();
+
+ const generateSessionToken = () => mockSessionToken;
+ const expireDateAfter = (_seconds: number) => mockSessionExpiry;
+
+ return {
+ ...mod,
+ generateSessionToken,
+ expireDateAfter,
+ } satisfies SessionExport;
+});
+// eslint-disable-next-line @typescript-eslint/consistent-type-imports
+type HeadersExport = typeof import("next/headers");
+vi.mock("next/headers", async (importOriginal) => {
+ const mod = await importOriginal();
+
+ const result = {
+ set: (name: string, value: string, options: Partial) =>
+ options as ResponseCookie,
+ } as unknown as ReadonlyRequestCookies;
+
+ vi.spyOn(result, "set");
+
+ const cookies = () => result;
+
+ return { ...mod, cookies } satisfies HeadersExport;
+});
+
+describe("createSignInCallback", () => {
+ it("should return true if not credentials request", async () => {
+ const isCredentialsRequest = false;
+ const signInCallback = createSignInCallback(
+ createAdapter(),
+ isCredentialsRequest,
+ );
+ const result = await signInCallback({
+ user: { id: "1", emailVerified: new Date("2023-01-13") },
+ account: {} as Account,
+ });
+ expect(result).toBe(true);
+ });
+
+ it("should return true if no user", async () => {
+ const isCredentialsRequest = true;
+ const signInCallback = createSignInCallback(
+ createAdapter(),
+ isCredentialsRequest,
+ );
+ const result = await signInCallback({
+ user: undefined as unknown as User,
+ account: {} as Account,
+ });
+ expect(result).toBe(true);
+ });
+
+ it("should return false if no adapter.createSession", async () => {
+ const isCredentialsRequest = true;
+ const signInCallback = createSignInCallback(
+ // https://github.com/nextauthjs/next-auth/issues/6106
+ undefined as unknown as Adapter,
+ isCredentialsRequest,
+ );
+ const result = await signInCallback({
+ user: { id: "1", emailVerified: new Date("2023-01-13") },
+ account: {} as Account,
+ });
+ expect(result).toBe(false);
+ });
+
+ it("should call adapter.createSession with correct input", async () => {
+ const adapter = createAdapter();
+ const isCredentialsRequest = true;
+ const signInCallback = createSignInCallback(adapter, isCredentialsRequest);
+ const user = { id: "1", emailVerified: new Date("2023-01-13") };
+ const account = {} as Account;
+ await signInCallback({ user, account });
+ expect(adapter.createSession).toHaveBeenCalledWith({
+ sessionToken: mockSessionToken,
+ userId: user.id,
+ expires: mockSessionExpiry,
+ });
+ expect(cookies().set).toHaveBeenCalledWith(
+ "next-auth.session-token",
+ mockSessionToken,
+ {
+ path: "/",
+ expires: mockSessionExpiry,
+ httpOnly: true,
+ sameSite: "lax",
+ secure: true,
+ },
+ );
+ });
+});
diff --git a/packages/auth/test/security.spec.ts b/packages/auth/test/security.spec.ts
new file mode 100644
index 000000000..f0f186966
--- /dev/null
+++ b/packages/auth/test/security.spec.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from "vitest";
+
+import { createSalt, hashPassword } from "../security";
+
+describe("createSalt should return a salt", () => {
+ it("should return a salt", async () => {
+ const result = await createSalt();
+ expect(result).toBeDefined();
+ expect(result.length).toBeGreaterThan(25);
+ });
+ it("should return a different salt each time", async () => {
+ const result1 = await createSalt();
+ const result2 = await createSalt();
+ expect(result1).not.toEqual(result2);
+ });
+});
+
+describe("hashPassword should return a hash", () => {
+ it("should return a hash", async () => {
+ const password = "password";
+ const salt = await createSalt();
+ const result = await hashPassword(password, salt);
+ expect(result).toBeDefined();
+ expect(result.length).toBeGreaterThan(55);
+ expect(result).not.toEqual(password);
+ });
+ it("should return a different hash each time", async () => {
+ const password = "password";
+ const password2 = "another password";
+ const salt = await createSalt();
+
+ const result1 = await hashPassword(password, salt);
+ const result2 = await hashPassword(password2, salt);
+
+ expect(result1).not.toEqual(result2);
+ });
+ it("should return a different hash for the same password with different salts", async () => {
+ const password = "password";
+ const salt1 = await createSalt();
+ const salt2 = await createSalt();
+
+ const result1 = await hashPassword(password, salt1);
+ const result2 = await hashPassword(password, salt2);
+
+ expect(result1).not.toEqual(result2);
+ });
+});
diff --git a/packages/auth/test/session.spec.ts b/packages/auth/test/session.spec.ts
new file mode 100644
index 000000000..2d2ced596
--- /dev/null
+++ b/packages/auth/test/session.spec.ts
@@ -0,0 +1,43 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { z } from "@homarr/validation";
+
+import { expireDateAfter, generateSessionToken } from "../session";
+
+describe("expireDateAfter should calculate date after specified seconds", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it.each([
+ ["2023-07-01T00:00:00Z", 60, "2023-07-01T00:01:00Z"], // 1 minute
+ ["2023-07-01T00:00:00Z", 60 * 60, "2023-07-01T01:00:00Z"], // 1 hour
+ ["2023-07-01T00:00:00Z", 60 * 60 * 24, "2023-07-02T00:00:00Z"], // 1 day
+ ["2023-07-01T00:00:00Z", 60 * 60 * 24 * 30, "2023-07-31T00:00:00Z"], // 30 days
+ ["2023-07-01T00:00:00Z", 60 * 60 * 24 * 365, "2024-06-30T00:00:00Z"], // 1 year
+ ["2023-07-01T00:00:00Z", 60 * 60 * 24 * 365 * 10, "2033-06-28T00:00:00Z"], // 10 years
+ ])(
+ "should calculate date %s and after %i seconds to equal %s",
+ (initialDate, seconds, expectedDate) => {
+ vi.setSystemTime(new Date(initialDate));
+ const result = expireDateAfter(seconds);
+ expect(result).toEqual(new Date(expectedDate));
+ },
+ );
+});
+
+describe("generateSessionToken should return a random UUID", () => {
+ it("should return a random UUID", () => {
+ const result = generateSessionToken();
+ expect(z.string().uuid().safeParse(result).success).toBe(true);
+ });
+ it("should return a different token each time", () => {
+ const result1 = generateSessionToken();
+ const result2 = generateSessionToken();
+ expect(result1).not.toEqual(result2);
+ });
+});
diff --git a/packages/common/src/test/object.spec.ts b/packages/common/src/test/object.spec.ts
new file mode 100644
index 000000000..02a1ed241
--- /dev/null
+++ b/packages/common/src/test/object.spec.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from "vitest";
+
+import { objectEntries, objectKeys } from "../object";
+
+const testObjects = [
+ { a: 1, c: 3, b: 2 },
+ { a: 1, b: 2 },
+ { a: 1 },
+ {},
+] as const;
+
+describe("objectKeys should return all keys of an object", () => {
+ testObjects.forEach((obj) => {
+ it(`should return all keys of the object ${JSON.stringify(obj)}`, () => {
+ expect(objectKeys(obj)).toEqual(Object.keys(obj));
+ });
+ });
+});
+
+describe("objectEntries should return all entries of an object", () => {
+ testObjects.forEach((obj) => {
+ it(`should return all entries of the object ${JSON.stringify(obj)}`, () => {
+ expect(objectEntries(obj)).toEqual(Object.entries(obj));
+ });
+ });
+});
diff --git a/packages/common/src/test/string.spec.ts b/packages/common/src/test/string.spec.ts
new file mode 100644
index 000000000..df8b4461a
--- /dev/null
+++ b/packages/common/src/test/string.spec.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from "vitest";
+
+import { capitalize } from "../string";
+
+const capitalizeTestCases = [
+ ["hello", "Hello"],
+ ["World", "World"],
+ ["123", "123"],
+ ["a", "A"],
+ ["two words", "Two words"],
+] as const;
+
+describe("capitalize should capitalize the first letter of a string", () => {
+ capitalizeTestCases.forEach(([input, expected]) => {
+ it(`should capitalize ${input} to ${expected}`, () => {
+ expect(capitalize(input)).toEqual(expected);
+ });
+ });
+});
diff --git a/packages/db/client.ts b/packages/db/client.ts
new file mode 100644
index 000000000..72b8e27b0
--- /dev/null
+++ b/packages/db/client.ts
@@ -0,0 +1 @@
+export { createId } from "@paralleldrive/cuid2";
diff --git a/packages/db/drizzle.config.ts b/packages/db/drizzle.config.ts
index 61d448cde..65212ebd1 100644
--- a/packages/db/drizzle.config.ts
+++ b/packages/db/drizzle.config.ts
@@ -7,4 +7,5 @@ export default {
schema: "./schema",
driver: "better-sqlite",
dbCredentials: { url: process.env.DB_URL! },
+ out: "./migrations",
} satisfies Config;
diff --git a/packages/db/index.ts b/packages/db/index.ts
index 161b0a537..54ce46b92 100644
--- a/packages/db/index.ts
+++ b/packages/db/index.ts
@@ -1,4 +1,5 @@
import Database from "better-sqlite3";
+import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import * as sqliteSchema from "./schema/sqlite";
@@ -7,8 +8,10 @@ export const schema = sqliteSchema;
export * from "drizzle-orm";
-const sqlite = new Database(process.env.DB_URL!);
+export const sqlite = new Database(process.env.DB_URL);
export const db = drizzle(sqlite, { schema });
+export type Database = BetterSQLite3Database;
+
export { createId } from "@paralleldrive/cuid2";
diff --git a/packages/db/migrations/0000_true_red_wolf.sql b/packages/db/migrations/0000_true_red_wolf.sql
new file mode 100644
index 000000000..3a9c02707
--- /dev/null
+++ b/packages/db/migrations/0000_true_red_wolf.sql
@@ -0,0 +1,112 @@
+CREATE TABLE `account` (
+ `userId` text NOT NULL,
+ `type` text NOT NULL,
+ `provider` text NOT NULL,
+ `providerAccountId` text NOT NULL,
+ `refresh_token` text,
+ `access_token` text,
+ `expires_at` integer,
+ `token_type` text,
+ `scope` text,
+ `id_token` text,
+ `session_state` text,
+ PRIMARY KEY(`provider`, `providerAccountId`),
+ FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
+);
+--> statement-breakpoint
+CREATE TABLE `board` (
+ `id` text PRIMARY KEY NOT NULL,
+ `name` text NOT NULL,
+ `is_public` integer DEFAULT false NOT NULL,
+ `page_title` text,
+ `meta_title` text,
+ `logo_image_url` text,
+ `favicon_image_url` text,
+ `background_image_url` text,
+ `background_image_attachment` text DEFAULT 'fixed' NOT NULL,
+ `background_image_repeat` text DEFAULT 'no-repeat' NOT NULL,
+ `background_image_size` text DEFAULT 'cover' NOT NULL,
+ `primary_color` text DEFAULT 'red' NOT NULL,
+ `secondary_color` text DEFAULT 'orange' NOT NULL,
+ `primary_shade` integer DEFAULT 6 NOT NULL,
+ `app_opacity` integer DEFAULT 100 NOT NULL,
+ `custom_css` text,
+ `show_right_sidebar` integer DEFAULT false NOT NULL,
+ `show_left_sidebar` integer DEFAULT false NOT NULL,
+ `column_count` integer DEFAULT 10 NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE `integration_item` (
+ `item_id` text NOT NULL,
+ `integration_id` text NOT NULL,
+ PRIMARY KEY(`integration_id`, `item_id`),
+ FOREIGN KEY (`item_id`) REFERENCES `item`(`id`) ON UPDATE no action ON DELETE cascade,
+ FOREIGN KEY (`integration_id`) REFERENCES `integration`(`id`) ON UPDATE no action ON DELETE cascade
+);
+--> statement-breakpoint
+CREATE TABLE `integrationSecret` (
+ `kind` text NOT NULL,
+ `value` text NOT NULL,
+ `updated_at` integer NOT NULL,
+ `integration_id` text NOT NULL,
+ PRIMARY KEY(`integration_id`, `kind`),
+ FOREIGN KEY (`integration_id`) REFERENCES `integration`(`id`) ON UPDATE no action ON DELETE cascade
+);
+--> statement-breakpoint
+CREATE TABLE `integration` (
+ `id` text PRIMARY KEY NOT NULL,
+ `name` text NOT NULL,
+ `url` text NOT NULL,
+ `kind` text NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE `item` (
+ `id` text PRIMARY KEY NOT NULL,
+ `section_id` text NOT NULL,
+ `kind` text NOT NULL,
+ `x_offset` integer NOT NULL,
+ `y_offset` integer NOT NULL,
+ `width` integer NOT NULL,
+ `height` integer NOT NULL,
+ `options` text DEFAULT '{"json": {}}' NOT NULL,
+ FOREIGN KEY (`section_id`) REFERENCES `section`(`id`) ON UPDATE no action ON DELETE cascade
+);
+--> statement-breakpoint
+CREATE TABLE `section` (
+ `id` text PRIMARY KEY NOT NULL,
+ `board_id` text NOT NULL,
+ `kind` text NOT NULL,
+ `position` integer NOT NULL,
+ `name` text,
+ FOREIGN KEY (`board_id`) REFERENCES `board`(`id`) ON UPDATE no action ON DELETE cascade
+);
+--> statement-breakpoint
+CREATE TABLE `session` (
+ `sessionToken` text PRIMARY KEY NOT NULL,
+ `userId` text NOT NULL,
+ `expires` integer NOT NULL,
+ FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
+);
+--> statement-breakpoint
+CREATE TABLE `user` (
+ `id` text PRIMARY KEY NOT NULL,
+ `name` text,
+ `email` text,
+ `emailVerified` integer,
+ `image` text,
+ `password` text,
+ `salt` text
+);
+--> statement-breakpoint
+CREATE TABLE `verificationToken` (
+ `identifier` text NOT NULL,
+ `token` text NOT NULL,
+ `expires` integer NOT NULL,
+ PRIMARY KEY(`identifier`, `token`)
+);
+--> statement-breakpoint
+CREATE INDEX `userId_idx` ON `account` (`userId`);--> statement-breakpoint
+CREATE INDEX `integration_secret__kind_idx` ON `integrationSecret` (`kind`);--> statement-breakpoint
+CREATE INDEX `integration_secret__updated_at_idx` ON `integrationSecret` (`updated_at`);--> statement-breakpoint
+CREATE INDEX `integration__kind_idx` ON `integration` (`kind`);--> statement-breakpoint
+CREATE INDEX `user_id_idx` ON `session` (`userId`);
\ No newline at end of file
diff --git a/packages/db/migrations/meta/0000_snapshot.json b/packages/db/migrations/meta/0000_snapshot.json
new file mode 100644
index 000000000..2a8c974ab
--- /dev/null
+++ b/packages/db/migrations/meta/0000_snapshot.json
@@ -0,0 +1,696 @@
+{
+ "version": "5",
+ "dialect": "sqlite",
+ "id": "c9ea435a-5bbf-4439-84a1-55d3e2581b13",
+ "prevId": "00000000-0000-0000-0000-000000000000",
+ "tables": {
+ "account": {
+ "name": "account",
+ "columns": {
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "providerAccountId": {
+ "name": "providerAccountId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_type": {
+ "name": "token_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "session_state": {
+ "name": "session_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "userId_idx": {
+ "name": "userId_idx",
+ "columns": ["userId"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "account_userId_user_id_fk": {
+ "name": "account_userId_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": ["userId"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "account_provider_providerAccountId_pk": {
+ "columns": ["provider", "providerAccountId"],
+ "name": "account_provider_providerAccountId_pk"
+ }
+ },
+ "uniqueConstraints": {}
+ },
+ "board": {
+ "name": "board",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_public": {
+ "name": "is_public",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "page_title": {
+ "name": "page_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "meta_title": {
+ "name": "meta_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "logo_image_url": {
+ "name": "logo_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "favicon_image_url": {
+ "name": "favicon_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "background_image_url": {
+ "name": "background_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "background_image_attachment": {
+ "name": "background_image_attachment",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'fixed'"
+ },
+ "background_image_repeat": {
+ "name": "background_image_repeat",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'no-repeat'"
+ },
+ "background_image_size": {
+ "name": "background_image_size",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'cover'"
+ },
+ "primary_color": {
+ "name": "primary_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'red'"
+ },
+ "secondary_color": {
+ "name": "secondary_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'orange'"
+ },
+ "primary_shade": {
+ "name": "primary_shade",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 6
+ },
+ "app_opacity": {
+ "name": "app_opacity",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 100
+ },
+ "custom_css": {
+ "name": "custom_css",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "show_right_sidebar": {
+ "name": "show_right_sidebar",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "show_left_sidebar": {
+ "name": "show_left_sidebar",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "column_count": {
+ "name": "column_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 10
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "integration_item": {
+ "name": "integration_item",
+ "columns": {
+ "item_id": {
+ "name": "item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "integration_id": {
+ "name": "integration_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "integration_item_item_id_item_id_fk": {
+ "name": "integration_item_item_id_item_id_fk",
+ "tableFrom": "integration_item",
+ "tableTo": "item",
+ "columnsFrom": ["item_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "integration_item_integration_id_integration_id_fk": {
+ "name": "integration_item_integration_id_integration_id_fk",
+ "tableFrom": "integration_item",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integration_item_item_id_integration_id_pk": {
+ "columns": ["integration_id", "item_id"],
+ "name": "integration_item_item_id_integration_id_pk"
+ }
+ },
+ "uniqueConstraints": {}
+ },
+ "integrationSecret": {
+ "name": "integrationSecret",
+ "columns": {
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "integration_id": {
+ "name": "integration_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "integration_secret__kind_idx": {
+ "name": "integration_secret__kind_idx",
+ "columns": ["kind"],
+ "isUnique": false
+ },
+ "integration_secret__updated_at_idx": {
+ "name": "integration_secret__updated_at_idx",
+ "columns": ["updated_at"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "integrationSecret_integration_id_integration_id_fk": {
+ "name": "integrationSecret_integration_id_integration_id_fk",
+ "tableFrom": "integrationSecret",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integrationSecret_integration_id_kind_pk": {
+ "columns": ["integration_id", "kind"],
+ "name": "integrationSecret_integration_id_kind_pk"
+ }
+ },
+ "uniqueConstraints": {}
+ },
+ "integration": {
+ "name": "integration",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "integration__kind_idx": {
+ "name": "integration__kind_idx",
+ "columns": ["kind"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "item": {
+ "name": "item",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "section_id": {
+ "name": "section_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "x_offset": {
+ "name": "x_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "y_offset": {
+ "name": "y_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "width": {
+ "name": "width",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "height": {
+ "name": "height",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "options": {
+ "name": "options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'{\"json\": {}}'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "item_section_id_section_id_fk": {
+ "name": "item_section_id_section_id_fk",
+ "tableFrom": "item",
+ "tableTo": "section",
+ "columnsFrom": ["section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "section": {
+ "name": "section",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "board_id": {
+ "name": "board_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "section_board_id_board_id_fk": {
+ "name": "section_board_id_board_id_fk",
+ "tableFrom": "section",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "session": {
+ "name": "session",
+ "columns": {
+ "sessionToken": {
+ "name": "sessionToken",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "user_id_idx": {
+ "name": "user_id_idx",
+ "columns": ["userId"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "session_userId_user_id_fk": {
+ "name": "session_userId_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": ["userId"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "user": {
+ "name": "user",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "emailVerified": {
+ "name": "emailVerified",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "salt": {
+ "name": "salt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "verificationToken": {
+ "name": "verificationToken",
+ "columns": {
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "verificationToken_identifier_token_pk": {
+ "columns": ["identifier", "token"],
+ "name": "verificationToken_identifier_token_pk"
+ }
+ },
+ "uniqueConstraints": {}
+ }
+ },
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ }
+}
diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json
new file mode 100644
index 000000000..843dad674
--- /dev/null
+++ b/packages/db/migrations/meta/_journal.json
@@ -0,0 +1,13 @@
+{
+ "version": "5",
+ "dialect": "sqlite",
+ "entries": [
+ {
+ "idx": 0,
+ "version": "5",
+ "when": 1707511343363,
+ "tag": "0000_true_red_wolf",
+ "breakpoints": true
+ }
+ ]
+}
diff --git a/packages/db/package.json b/packages/db/package.json
index 0b520e625..1566f189d 100644
--- a/packages/db/package.json
+++ b/packages/db/package.json
@@ -1,6 +1,12 @@
{
"name": "@homarr/db",
"version": "0.1.0",
+ "exports": {
+ ".": "./index.ts",
+ "./client": "./client.ts",
+ "./schema/sqlite": "./schema/sqlite.ts",
+ "./test": "./test/index.ts"
+ },
"private": true,
"main": "./index.ts",
"types": "./index.ts",
@@ -9,6 +15,7 @@
"clean": "rm -rf .turbo node_modules",
"lint": "eslint .",
"format": "prettier --check . --ignore-path ../../.gitignore",
+ "migration:generate": "drizzle-kit generate:sqlite",
"push": "drizzle-kit push:sqlite",
"studio": "drizzle-kit studio",
"typecheck": "tsc --noEmit"
@@ -17,18 +24,18 @@
"@homarr/common": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@paralleldrive/cuid2": "^2.2.2",
- "better-sqlite3": "^9.2.2",
+ "better-sqlite3": "^9.4.1",
"drizzle-orm": "^0.29.3"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
- "@types/better-sqlite3": "7.6.8",
+ "@types/better-sqlite3": "7.6.9",
"dotenv-cli": "^7.3.0",
- "drizzle-kit": "^0.20.9",
+ "drizzle-kit": "^0.20.14",
"eslint": "^8.56.0",
- "prettier": "^3.1.0",
+ "prettier": "^3.2.5",
"typescript": "^5.3.3"
},
"eslintConfig": {
diff --git a/packages/db/schema/sqlite.ts b/packages/db/schema/sqlite.ts
index b25b99dce..a154cf4f7 100644
--- a/packages/db/schema/sqlite.ts
+++ b/packages/db/schema/sqlite.ts
@@ -1,8 +1,10 @@
import type { AdapterAccount } from "@auth/core/adapters";
+import type { MantineColor } from "@mantine/core";
import type { InferSelectModel } from "drizzle-orm";
import { relations } from "drizzle-orm";
import {
index,
+ int,
integer,
primaryKey,
sqliteTable,
@@ -10,8 +12,13 @@ import {
} from "drizzle-orm/sqlite-core";
import type {
+ BackgroundImageAttachment,
+ BackgroundImageRepeat,
+ BackgroundImageSize,
IntegrationKind,
IntegrationSecretKind,
+ SectionKind,
+ WidgetKind,
} from "@homarr/definitions";
export const users = sqliteTable("user", {
@@ -107,6 +114,91 @@ export const integrationSecrets = sqliteTable(
}),
);
+export const boards = sqliteTable("board", {
+ id: text("id").notNull().primaryKey(),
+ name: text("name").unique().notNull(),
+ isPublic: int("is_public", { mode: "boolean" }).default(false).notNull(),
+ pageTitle: text("page_title"),
+ metaTitle: text("meta_title"),
+ logoImageUrl: text("logo_image_url"),
+ faviconImageUrl: text("favicon_image_url"),
+ backgroundImageUrl: text("background_image_url"),
+ backgroundImageAttachment: text("background_image_attachment")
+ .$type()
+ .default("fixed")
+ .notNull(),
+ backgroundImageRepeat: text("background_image_repeat")
+ .$type()
+ .default("no-repeat")
+ .notNull(),
+ backgroundImageSize: text("background_image_size")
+ .$type()
+ .default("cover")
+ .notNull(),
+ primaryColor: text("primary_color")
+ .$type()
+ .default("red")
+ .notNull(),
+ secondaryColor: text("secondary_color")
+ .$type()
+ .default("orange")
+ .notNull(),
+ primaryShade: int("primary_shade").default(6).notNull(),
+ appOpacity: int("app_opacity").default(100).notNull(),
+ customCss: text("custom_css"),
+ showRightSidebar: int("show_right_sidebar", {
+ mode: "boolean",
+ })
+ .default(false)
+ .notNull(),
+ showLeftSidebar: int("show_left_sidebar", {
+ mode: "boolean",
+ })
+ .default(false)
+ .notNull(),
+ columnCount: int("column_count").default(10).notNull(),
+});
+
+export const sections = sqliteTable("section", {
+ id: text("id").notNull().primaryKey(),
+ boardId: text("board_id")
+ .notNull()
+ .references(() => boards.id, { onDelete: "cascade" }),
+ kind: text("kind").$type().notNull(),
+ position: int("position").notNull(),
+ name: text("name"),
+});
+
+export const items = sqliteTable("item", {
+ id: text("id").notNull().primaryKey(),
+ sectionId: text("section_id")
+ .notNull()
+ .references(() => sections.id, { onDelete: "cascade" }),
+ kind: text("kind").$type().notNull(),
+ xOffset: int("x_offset").notNull(),
+ yOffset: int("y_offset").notNull(),
+ width: int("width").notNull(),
+ height: int("height").notNull(),
+ options: text("options").default('{"json": {}}').notNull(), // empty superjson object
+});
+
+export const integrationItems = sqliteTable(
+ "integration_item",
+ {
+ itemId: text("item_id")
+ .notNull()
+ .references(() => items.id, { onDelete: "cascade" }),
+ integrationId: text("integration_id")
+ .notNull()
+ .references(() => integrations.id, { onDelete: "cascade" }),
+ },
+ (table) => ({
+ compoundKey: primaryKey({
+ columns: [table.itemId, table.integrationId],
+ }),
+ }),
+);
+
export const accountRelations = relations(accounts, ({ one }) => ({
user: one(users, {
fields: [accounts.userId],
@@ -120,6 +212,7 @@ export const userRelations = relations(users, ({ many }) => ({
export const integrationRelations = relations(integrations, ({ many }) => ({
secrets: many(integrationSecrets),
+ items: many(integrationItems),
}));
export const integrationSecretRelations = relations(
@@ -132,6 +225,40 @@ export const integrationSecretRelations = relations(
}),
);
+export const boardRelations = relations(boards, ({ many }) => ({
+ sections: many(sections),
+}));
+
+export const sectionRelations = relations(sections, ({ many, one }) => ({
+ items: many(items),
+ board: one(boards, {
+ fields: [sections.boardId],
+ references: [boards.id],
+ }),
+}));
+
+export const itemRelations = relations(items, ({ one, many }) => ({
+ section: one(sections, {
+ fields: [items.sectionId],
+ references: [sections.id],
+ }),
+ integrations: many(integrationItems),
+}));
+
+export const integrationItemRelations = relations(
+ integrationItems,
+ ({ one }) => ({
+ integration: one(integrations, {
+ fields: [integrationItems.integrationId],
+ references: [integrations.id],
+ }),
+ item: one(items, {
+ fields: [integrationItems.itemId],
+ references: [items.id],
+ }),
+ }),
+);
+
export type User = InferSelectModel;
export type Account = InferSelectModel;
export type Session = InferSelectModel;
diff --git a/packages/db/test/db-mock.ts b/packages/db/test/db-mock.ts
new file mode 100644
index 000000000..659e1e21b
--- /dev/null
+++ b/packages/db/test/db-mock.ts
@@ -0,0 +1,14 @@
+import Database from "better-sqlite3";
+import { drizzle } from "drizzle-orm/better-sqlite3";
+import { migrate } from "drizzle-orm/better-sqlite3/migrator";
+
+import { schema } from "..";
+
+export const createDb = () => {
+ const sqlite = new Database(":memory:");
+ const db = drizzle(sqlite, { schema });
+ migrate(db, {
+ migrationsFolder: "./packages/db/migrations",
+ });
+ return db;
+};
diff --git a/packages/db/test/index.ts b/packages/db/test/index.ts
new file mode 100644
index 000000000..31046067d
--- /dev/null
+++ b/packages/db/test/index.ts
@@ -0,0 +1 @@
+export * from "./db-mock";
diff --git a/packages/definitions/src/board.ts b/packages/definitions/src/board.ts
new file mode 100644
index 000000000..e66e6ba7e
--- /dev/null
+++ b/packages/definitions/src/board.ts
@@ -0,0 +1,13 @@
+export const backgroundImageAttachments = ["fixed", "scroll"] as const;
+export const backgroundImageRepeats = [
+ "repeat",
+ "repeat-x",
+ "repeat-y",
+ "no-repeat",
+] as const;
+export const backgroundImageSizes = ["cover", "contain"] as const;
+
+export type BackgroundImageAttachment =
+ (typeof backgroundImageAttachments)[number];
+export type BackgroundImageRepeat = (typeof backgroundImageRepeats)[number];
+export type BackgroundImageSize = (typeof backgroundImageSizes)[number];
diff --git a/packages/definitions/src/index.ts b/packages/definitions/src/index.ts
index 852c5e31b..d305465db 100644
--- a/packages/definitions/src/index.ts
+++ b/packages/definitions/src/index.ts
@@ -1 +1,4 @@
+export * from "./board";
export * from "./integration";
+export * from "./section";
+export * from "./widget";
diff --git a/packages/definitions/src/section.ts b/packages/definitions/src/section.ts
new file mode 100644
index 000000000..0276fe021
--- /dev/null
+++ b/packages/definitions/src/section.ts
@@ -0,0 +1,2 @@
+export const sectionKinds = ["category", "empty", "sidebar"] as const;
+export type SectionKind = (typeof sectionKinds)[number];
diff --git a/packages/definitions/src/test/integration.spec.ts b/packages/definitions/src/test/integration.spec.ts
new file mode 100644
index 000000000..bdb5194fd
--- /dev/null
+++ b/packages/definitions/src/test/integration.spec.ts
@@ -0,0 +1,14 @@
+import { describe, expect, it } from "vitest";
+
+import { objectEntries } from "@homarr/common";
+
+import { integrationDefs } from "../integration";
+
+describe("Icon url's of integrations should be valid and return 200", () => {
+ objectEntries(integrationDefs).forEach(([integration, { iconUrl }]) => {
+ it.concurrent(`should return 200 for ${integration}`, async () => {
+ const res = await fetch(iconUrl);
+ expect(res.status).toBe(200);
+ });
+ });
+});
diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts
new file mode 100644
index 000000000..59d842383
--- /dev/null
+++ b/packages/definitions/src/widget.ts
@@ -0,0 +1,2 @@
+export const widgetKinds = ["clock", "weather"] as const;
+export type WidgetKind = (typeof widgetKinds)[number];
diff --git a/packages/form/package.json b/packages/form/package.json
index fb15caa5e..653a19037 100644
--- a/packages/form/package.json
+++ b/packages/form/package.json
@@ -33,6 +33,6 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
- "@mantine/form": "^7.4.0"
+ "@mantine/form": "^7.5.2"
}
}
diff --git a/packages/notifications/package.json b/packages/notifications/package.json
index 60a01af91..50c175f22 100644
--- a/packages/notifications/package.json
+++ b/packages/notifications/package.json
@@ -28,7 +28,7 @@
"typescript": "^5.3.3"
},
"dependencies": {
- "@mantine/notifications": "^7.4.0",
+ "@mantine/notifications": "^7.5.2",
"@homarr/ui": "workspace:^0.1.0"
},
"eslintConfig": {
diff --git a/packages/spotlight/package.json b/packages/spotlight/package.json
index bf8a3d887..5462b7111 100644
--- a/packages/spotlight/package.json
+++ b/packages/spotlight/package.json
@@ -34,6 +34,6 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
- "@mantine/spotlight": "^7.4.0"
+ "@mantine/spotlight": "^7.5.2"
}
}
diff --git a/packages/translation/package.json b/packages/translation/package.json
index b0c453234..f2f8716e7 100644
--- a/packages/translation/package.json
+++ b/packages/translation/package.json
@@ -36,6 +36,6 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
- "next-international": "^1.1.4"
+ "next-international": "^1.2.4"
}
}
diff --git a/packages/translation/src/client.ts b/packages/translation/src/client.ts
index 23c427063..9f3ec201c 100644
--- a/packages/translation/src/client.ts
+++ b/packages/translation/src/client.ts
@@ -3,6 +3,11 @@
import { createI18nClient } from "next-international/client";
import { languageMapping } from "./lang";
+import en from "./lang/en";
-export const { useI18n, useScopedI18n, I18nProviderClient } =
- createI18nClient(languageMapping());
+export const { useI18n, useScopedI18n, I18nProviderClient } = createI18nClient(
+ languageMapping(),
+ {
+ fallbackLocale: en,
+ },
+);
diff --git a/packages/translation/src/index.ts b/packages/translation/src/index.ts
index c49f69163..9fb09f4f6 100644
--- a/packages/translation/src/index.ts
+++ b/packages/translation/src/index.ts
@@ -2,3 +2,4 @@ export const supportedLanguages = ["en", "de"] as const;
export type SupportedLanguage = (typeof supportedLanguages)[number];
export const defaultLocale = "en";
+export { languageMapping } from "./lang";
diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts
index e45f17469..ea712acad 100644
--- a/packages/translation/src/lang/en.ts
+++ b/packages/translation/src/lang/en.ts
@@ -144,6 +144,7 @@ export default {
create: "Create",
edit: "Edit",
save: "Save",
+ saveChanges: "Save changes",
cancel: "Cancel",
confirm: "Confirm",
},
@@ -154,15 +155,88 @@ export default {
placeholder: "Search for anything...",
nothingFound: "Nothing found",
},
- noResults: "No results found",
- },
- widget: {
- editModal: {
- integrations: {
- label: "Integrations",
+ userAvatar: {
+ menu: {
+ switchToDarkMode: "Switch to dark mode",
+ switchToLightMode: "Switch to light mode",
+ management: "Management",
+ logout: "Logout",
+ navigateDefaultBoard: "Navigate to default board",
},
},
+ noResults: "No results found",
+ },
+ section: {
+ category: {
+ field: {
+ name: {
+ label: "Name",
+ },
+ },
+ action: {
+ create: "New category",
+ edit: "Rename category",
+ remove: "Remove category",
+ moveUp: "Move up",
+ moveDown: "Move down",
+ createAbove: "New category above",
+ createBelow: "New category below",
+ },
+ create: {
+ title: "New category",
+ submit: "Add category",
+ },
+ remove: {
+ title: "Remove category",
+ message: "Are you sure you want to remove the category {name}?",
+ },
+ edit: {
+ title: "Rename category",
+ submit: "Rename category",
+ },
+ menu: {
+ label: {
+ create: "New category",
+ changePosition: "Change position",
+ },
+ },
+ },
+ },
+ item: {
+ action: {
+ create: "New item",
+ import: "Import item",
+ edit: "Edit item",
+ move: "Move item",
+ remove: "Remove item",
+ },
+ menu: {
+ label: {
+ settings: "Settings",
+ dangerZone: "Danger Zone",
+ },
+ },
+ create: {
+ title: "Choose item to add",
+ addToBoard: "Add to board",
+ },
+ edit: {
+ title: "Edit item",
+ field: {
+ integrations: {
+ label: "Integrations",
+ },
+ },
+ },
+ remove: {
+ title: "Remove item",
+ message: "Are you sure you want to remove this item?",
+ },
+ },
+ widget: {
clock: {
+ name: "Date and time",
+ description: "Displays the current date and time.",
option: {
is24HourFormat: {
label: "24-hour format",
@@ -177,6 +251,9 @@ export default {
},
},
weather: {
+ name: "Weather",
+ description:
+ "Displays the current weather information of a set location.",
option: {
location: {
label: "Location",
@@ -187,6 +264,78 @@ export default {
},
},
},
+ board: {
+ action: {
+ edit: {
+ notification: {
+ success: {
+ title: "Changes applied successfully",
+ message: "The board was successfully saved",
+ },
+ error: {
+ title: "Unable to apply changes",
+ message: "The board could not be saved",
+ },
+ },
+ },
+ },
+ field: {
+ pageTitle: {
+ label: "Page title",
+ },
+ metaTitle: {
+ label: "Meta title",
+ },
+ logoImageUrl: {
+ label: "Logo image URL",
+ },
+ faviconImageUrl: {
+ label: "Favicon image URL",
+ },
+ },
+ setting: {
+ title: "Settings for {boardName} board",
+ section: {
+ general: {
+ title: "General",
+ },
+ layout: {
+ title: "Layout",
+ },
+ appearance: {
+ title: "Appearance",
+ },
+ dangerZone: {
+ title: "Danger Zone",
+ action: {
+ rename: {
+ label: "Rename board",
+ description:
+ "Changing the name will break any links to this board.",
+ button: "Change name",
+ },
+ visibility: {
+ label: "Change board visibility",
+ description: {
+ public: "This board is currently public.",
+ private: "This board is currently private.",
+ },
+ button: {
+ public: "Make private",
+ private: "Make public",
+ },
+ },
+ delete: {
+ label: "Delete this board",
+ description:
+ "Once you delete a board, there is no going back. Please be certain.",
+ button: "Delete this board",
+ },
+ },
+ },
+ },
+ },
+ },
management: {
metaTitle: "Management",
title: {
@@ -223,5 +372,14 @@ export default {
about: "About",
},
},
+ page: {
+ board: {
+ title: "Manage boards",
+ button: {
+ create: "Create board",
+ delete: "Delete board",
+ },
+ },
+ },
},
} as const;
diff --git a/packages/translation/src/server.ts b/packages/translation/src/server.ts
index c77b66f26..79aa5815a 100644
--- a/packages/translation/src/server.ts
+++ b/packages/translation/src/server.ts
@@ -1,6 +1,11 @@
import { createI18nServer } from "next-international/server";
import { languageMapping } from "./lang";
+import en from "./lang/en";
-export const { getI18n, getScopedI18n, getStaticParams } =
- createI18nServer(languageMapping());
+export const { getI18n, getScopedI18n, getStaticParams } = createI18nServer(
+ languageMapping(),
+ {
+ fallbackLocale: en,
+ },
+);
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 66148e2e5..29da02d12 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -35,8 +35,8 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
- "@mantine/core": "^7.4.0",
- "@mantine/dates": "^7.4.0",
- "@tabler/icons-react": "^2.42.0"
+ "@mantine/core": "^7.5.2",
+ "@mantine/dates": "^7.5.2",
+ "@tabler/icons-react": "^2.47.0"
}
}
diff --git a/packages/validation/src/board.ts b/packages/validation/src/board.ts
new file mode 100644
index 000000000..96d0e4f9e
--- /dev/null
+++ b/packages/validation/src/board.ts
@@ -0,0 +1,47 @@
+import { z } from "zod";
+
+import { commonItemSchema, createSectionSchema } from "./shared";
+
+const boardNameSchema = z
+ .string()
+ .min(1)
+ .max(255)
+ .regex(/^[A-Za-z0-9-\\._]+$/);
+
+const byNameSchema = z.object({
+ name: boardNameSchema,
+});
+
+const saveGeneralSettingsSchema = z.object({
+ pageTitle: z
+ .string()
+ .nullable()
+ .transform((value) => (value?.trim().length === 0 ? null : value)),
+ metaTitle: z
+ .string()
+ .nullable()
+ .transform((value) => (value?.trim().length === 0 ? null : value)),
+ logoImageUrl: z
+ .string()
+ .nullable()
+ .transform((value) => (value?.trim().length === 0 ? null : value)),
+ faviconImageUrl: z
+ .string()
+ .nullable()
+ .transform((value) => (value?.trim().length === 0 ? null : value)),
+ boardId: z.string(),
+});
+
+const saveSchema = z.object({
+ boardId: z.string(),
+ sections: z.array(createSectionSchema(commonItemSchema)),
+});
+
+const createSchema = z.object({ name: z.string() });
+
+export const boardSchemas = {
+ byName: byNameSchema,
+ saveGeneralSettings: saveGeneralSettingsSchema,
+ save: saveSchema,
+ create: createSchema,
+};
diff --git a/packages/validation/src/enums.ts b/packages/validation/src/enums.ts
index a59c32796..d9644a776 100644
--- a/packages/validation/src/enums.ts
+++ b/packages/validation/src/enums.ts
@@ -1,4 +1,11 @@
import { z } from "zod";
-export const zodEnumFromArray = (arr: T[]) =>
- z.enum([arr[0]!, ...arr.slice(1)]);
+type CouldBeReadonlyArray = T[] | readonly T[];
+
+export const zodEnumFromArray = (
+ array: CouldBeReadonlyArray,
+) => z.enum([array[0]!, ...array.slice(1)]);
+
+export const zodUnionFromArray = (
+ array: CouldBeReadonlyArray,
+) => z.union([array[0]!, array[1]!, ...array.slice(2)]);
diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts
index 7c2ed8269..91716e812 100644
--- a/packages/validation/src/index.ts
+++ b/packages/validation/src/index.ts
@@ -1,7 +1,11 @@
+import { boardSchemas } from "./board";
import { integrationSchemas } from "./integration";
import { userSchemas } from "./user";
export const validation = {
user: userSchemas,
integration: integrationSchemas,
+ board: boardSchemas,
};
+
+export { createSectionSchema, sharedItemSchema } from "./shared";
diff --git a/packages/validation/src/shared.ts b/packages/validation/src/shared.ts
new file mode 100644
index 000000000..662ae55f4
--- /dev/null
+++ b/packages/validation/src/shared.ts
@@ -0,0 +1,68 @@
+import { z } from "zod";
+
+import { integrationKinds, widgetKinds } from "@homarr/definitions";
+
+import { zodEnumFromArray } from "./enums";
+
+export const integrationSchema = z.object({
+ id: z.string(),
+ kind: zodEnumFromArray(integrationKinds),
+ name: z.string(),
+ url: z.string(),
+});
+
+export const sharedItemSchema = z.object({
+ id: z.string(),
+ xOffset: z.number(),
+ yOffset: z.number(),
+ height: z.number(),
+ width: z.number(),
+ integrations: z.array(integrationSchema),
+});
+
+export const commonItemSchema = z
+ .object({
+ kind: zodEnumFromArray(widgetKinds),
+ options: z.record(z.unknown()),
+ })
+ .and(sharedItemSchema);
+
+const createCategorySchema = (
+ itemSchema: TItemSchema,
+) =>
+ z.object({
+ id: z.string(),
+ name: z.string(),
+ kind: z.literal("category"),
+ position: z.number(),
+ items: z.array(itemSchema),
+ });
+
+const createEmptySchema = (
+ itemSchema: TItemSchema,
+) =>
+ z.object({
+ id: z.string(),
+ kind: z.literal("empty"),
+ position: z.number(),
+ items: z.array(itemSchema),
+ });
+
+const createSidebarSchema = (
+ itemSchema: TItemSchema,
+) =>
+ z.object({
+ id: z.string(),
+ kind: z.literal("sidebar"),
+ position: z.union([z.literal(0), z.literal(1)]),
+ items: z.array(itemSchema),
+ });
+
+export const createSectionSchema = (
+ itemSchema: TItemSchema,
+) =>
+ z.union([
+ createCategorySchema(itemSchema),
+ createEmptySchema(itemSchema),
+ createSidebarSchema(itemSchema),
+ ]);
diff --git a/packages/widgets/package.json b/packages/widgets/package.json
index faca5b476..e8281f4af 100644
--- a/packages/widgets/package.json
+++ b/packages/widgets/package.json
@@ -36,6 +36,7 @@
"@homarr/common": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/form": "workspace:^0.1.0",
+ "@homarr/api": "workspace:^0.1.0",
"@homarr/notifications": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",
diff --git a/packages/widgets/src/_inputs/common.tsx b/packages/widgets/src/_inputs/common.tsx
index 5027e13d4..486c4b650 100644
--- a/packages/widgets/src/_inputs/common.tsx
+++ b/packages/widgets/src/_inputs/common.tsx
@@ -1,10 +1,10 @@
+import type { WidgetKind } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
-import type { WidgetSort } from "..";
import type { WidgetOptionOfType, WidgetOptionType } from "../options";
export interface CommonWidgetInputProps {
- sort: WidgetSort;
+ kind: WidgetKind;
property: string;
options: Omit, "defaultValue" | "type">;
}
@@ -15,8 +15,8 @@ type UseWidgetInputTranslationReturnType = (
/**
* Short description why as and unknown convertions are used below:
- * Typescript was not smart enought to work with the generic of the WidgetSort to only allow properties that are relying within that specified sort.
- * This does not mean, that the function useWidgetInputTranslation can be called with invalid arguments without type errors and rather means that the above widget..option. string
+ * Typescript was not smart enought to work with the generic of the WidgetKind to only allow properties that are relying within that specified kind.
+ * This does not mean, that the function useWidgetInputTranslation can be called with invalid arguments without type errors and rather means that the above widget..option. string
* is not recognized as valid argument for the scoped i18n hook. Because the typesafety should remain outside the usage of those methods I (Meierschlumpf) decided to provide this fully typesafe useWidgetInputTranslation method.
*
* Some notes about it:
@@ -24,10 +24,10 @@ type UseWidgetInputTranslationReturnType = (
* is defined for the option. The method does sadly not reconize issues with those definitions. So it does not yell at you when you somewhere show the label without having it defined in the translations.
*/
export const useWidgetInputTranslation = (
- sort: WidgetSort,
+ kind: WidgetKind,
property: string,
): UseWidgetInputTranslationReturnType => {
return useScopedI18n(
- `widget.${sort}.option.${property}` as never, // Because the type is complex and not recognized by typescript, we need to cast it to never to make it work.
+ `widget.${kind}.option.${property}` as never, // Because the type is complex and not recognized by typescript, we need to cast it to never to make it work.
) as unknown as UseWidgetInputTranslationReturnType;
};
diff --git a/packages/widgets/src/_inputs/widget-multiselect-input.tsx b/packages/widgets/src/_inputs/widget-multiselect-input.tsx
index 9d276da3a..759a326af 100644
--- a/packages/widgets/src/_inputs/widget-multiselect-input.tsx
+++ b/packages/widgets/src/_inputs/widget-multiselect-input.tsx
@@ -8,10 +8,10 @@ import { useFormContext } from "./form";
export const WidgetMultiSelectInput = ({
property,
- sort,
+ kind,
options,
}: CommonWidgetInputProps<"multiSelect">) => {
- const t = useWidgetInputTranslation(sort, property);
+ const t = useWidgetInputTranslation(kind, property);
const form = useFormContext();
return (
diff --git a/packages/widgets/src/_inputs/widget-number-input.tsx b/packages/widgets/src/_inputs/widget-number-input.tsx
index 58e7dafae..eed76f112 100644
--- a/packages/widgets/src/_inputs/widget-number-input.tsx
+++ b/packages/widgets/src/_inputs/widget-number-input.tsx
@@ -8,10 +8,10 @@ import { useFormContext } from "./form";
export const WidgetNumberInput = ({
property,
- sort,
+ kind,
options,
}: CommonWidgetInputProps<"number">) => {
- const t = useWidgetInputTranslation(sort, property);
+ const t = useWidgetInputTranslation(kind, property);
const form = useFormContext();
return (
diff --git a/packages/widgets/src/_inputs/widget-select-input.tsx b/packages/widgets/src/_inputs/widget-select-input.tsx
index 85e34c558..ad36118b1 100644
--- a/packages/widgets/src/_inputs/widget-select-input.tsx
+++ b/packages/widgets/src/_inputs/widget-select-input.tsx
@@ -8,10 +8,10 @@ import { useFormContext } from "./form";
export const WidgetSelectInput = ({
property,
- sort,
+ kind,
options,
}: CommonWidgetInputProps<"select">) => {
- const t = useWidgetInputTranslation(sort, property);
+ const t = useWidgetInputTranslation(kind, property);
const form = useFormContext();
return (
diff --git a/packages/widgets/src/_inputs/widget-slider-input.tsx b/packages/widgets/src/_inputs/widget-slider-input.tsx
index 31ef00fab..76c725e80 100644
--- a/packages/widgets/src/_inputs/widget-slider-input.tsx
+++ b/packages/widgets/src/_inputs/widget-slider-input.tsx
@@ -8,10 +8,10 @@ import { useFormContext } from "./form";
export const WidgetSliderInput = ({
property,
- sort,
+ kind,
options,
}: CommonWidgetInputProps<"slider">) => {
- const t = useWidgetInputTranslation(sort, property);
+ const t = useWidgetInputTranslation(kind, property);
const form = useFormContext();
return (
diff --git a/packages/widgets/src/_inputs/widget-switch-input.tsx b/packages/widgets/src/_inputs/widget-switch-input.tsx
index d09906f9e..7d18fab4a 100644
--- a/packages/widgets/src/_inputs/widget-switch-input.tsx
+++ b/packages/widgets/src/_inputs/widget-switch-input.tsx
@@ -8,10 +8,10 @@ import { useFormContext } from "./form";
export const WidgetSwitchInput = ({
property,
- sort,
+ kind,
options,
}: CommonWidgetInputProps<"switch">) => {
- const t = useWidgetInputTranslation(sort, property);
+ const t = useWidgetInputTranslation(kind, property);
const form = useFormContext();
return (
diff --git a/packages/widgets/src/_inputs/widget-text-input.tsx b/packages/widgets/src/_inputs/widget-text-input.tsx
index af27560b1..1d251c872 100644
--- a/packages/widgets/src/_inputs/widget-text-input.tsx
+++ b/packages/widgets/src/_inputs/widget-text-input.tsx
@@ -8,10 +8,10 @@ import { useFormContext } from "./form";
export const WidgetTextInput = ({
property,
- sort: widgetSort,
+ kind,
options,
}: CommonWidgetInputProps<"text">) => {
- const t = useWidgetInputTranslation(widgetSort, property);
+ const t = useWidgetInputTranslation(kind, property);
const form = useFormContext();
return (
diff --git a/packages/widgets/src/clock/component.tsx b/packages/widgets/src/clock/component.tsx
index c8f3b7887..c1fab8aa0 100644
--- a/packages/widgets/src/clock/component.tsx
+++ b/packages/widgets/src/clock/component.tsx
@@ -3,6 +3,7 @@ import type { WidgetComponentProps } from "../definition";
export default function ClockWidget({
options: _options,
integrations: _integrations,
+ serverData: _serverData,
}: WidgetComponentProps<"clock">) {
return CLOCK
;
}
diff --git a/packages/widgets/src/clock/index.ts b/packages/widgets/src/clock/index.ts
index 09337d32f..6892e5ef4 100644
--- a/packages/widgets/src/clock/index.ts
+++ b/packages/widgets/src/clock/index.ts
@@ -1,27 +1,30 @@
import { IconClock } from "@homarr/ui";
import { createWidgetDefinition } from "../definition";
-import { opt } from "../options";
+import { optionsBuilder } from "../options";
-export const { definition, componentLoader } = createWidgetDefinition("clock", {
- icon: IconClock,
- supportedIntegrations: ["adGuardHome", "piHole"],
- options: opt.from(
- (fac) => ({
- is24HourFormat: fac.switch({
- defaultValue: true,
- withDescription: true,
+export const { definition, componentLoader, serverDataLoader } =
+ createWidgetDefinition("clock", {
+ icon: IconClock,
+ supportedIntegrations: ["adGuardHome", "piHole"],
+ options: optionsBuilder.from(
+ (factory) => ({
+ is24HourFormat: factory.switch({
+ defaultValue: true,
+ withDescription: true,
+ }),
+ isLocaleTime: factory.switch({ defaultValue: true }),
+ timezone: factory.select({
+ options: ["Europe/Berlin", "Europe/London", "Europe/Moscow"] as const,
+ defaultValue: "Europe/Berlin",
+ }),
}),
- isLocaleTime: fac.switch({ defaultValue: true }),
- timezone: fac.select({
- options: ["Europe/Berlin", "Europe/London", "Europe/Moscow"] as const,
- defaultValue: "Europe/Berlin",
- }),
- }),
- {
- timezone: {
- shouldHide: (options) => options.isLocaleTime,
+ {
+ timezone: {
+ shouldHide: (options) => options.isLocaleTime,
+ },
},
- },
- ),
-}).withDynamicImport(() => import("./component"));
+ ),
+ })
+ .withServerData(() => import("./serverData"))
+ .withDynamicImport(() => import("./component"));
diff --git a/packages/widgets/src/clock/serverData.ts b/packages/widgets/src/clock/serverData.ts
new file mode 100644
index 000000000..e73580db1
--- /dev/null
+++ b/packages/widgets/src/clock/serverData.ts
@@ -0,0 +1,10 @@
+"use server";
+
+import { db } from "../../../db";
+import type { WidgetProps } from "../definition";
+
+export default async function getServerData(_item: WidgetProps<"clock">) {
+ const randomUuid = crypto.randomUUID();
+ const data = await db.query.items.findMany();
+ return { data, count: data.length, randomUuid };
+}
diff --git a/packages/widgets/src/definition.ts b/packages/widgets/src/definition.ts
index 25b9303db..ad4554f96 100644
--- a/packages/widgets/src/definition.ts
+++ b/packages/widgets/src/definition.ts
@@ -1,47 +1,112 @@
import type { LoaderComponent } from "next/dynamic";
-import type { IntegrationKind } from "@homarr/definitions";
+import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import type { TablerIconsProps } from "@homarr/ui";
-import type { WidgetImports, WidgetSort } from ".";
+import type { WidgetImports } from ".";
import type {
inferOptionsFromDefinition,
WidgetOptionsRecord,
} from "./options";
import type { IntegrationSelectOption } from "./widget-integration-select";
-export const createWidgetDefinition = <
- TSort extends WidgetSort,
- TDefinition extends Definition,
->(
- sort: TSort,
- definition: TDefinition,
-) => ({
- withDynamicImport: (
- componentLoader: () => LoaderComponent>,
+type ServerDataLoader = () => Promise<{
+ default: (props: WidgetProps) => Promise>;
+}>;
+
+const createWithDynamicImport =
+ <
+ TKind extends WidgetKind,
+ TDefinition extends WidgetDefinition,
+ TServerDataLoader extends ServerDataLoader | undefined,
+ >(
+ kind: TKind,
+ definition: TDefinition,
+ serverDataLoader: TServerDataLoader,
+ ) =>
+ (
+ componentLoader: () => LoaderComponent<
+ WidgetComponentProps &
+ (TServerDataLoader extends ServerDataLoader
+ ? {
+ serverData: Awaited<
+ ReturnType>["default"]>
+ >;
+ }
+ : never)
+ >,
) => ({
definition: {
- sort,
...definition,
+ kind,
},
+ kind,
+ serverDataLoader,
componentLoader,
- }),
+ });
+
+const createWithServerData =
+ (
+ kind: TKind,
+ definition: TDefinition,
+ ) =>
+ >(
+ serverDataLoader: TServerDataLoader,
+ ) => ({
+ definition: {
+ ...definition,
+ kind,
+ },
+ kind,
+ serverDataLoader,
+ withDynamicImport: createWithDynamicImport(
+ kind,
+ definition,
+ serverDataLoader,
+ ),
+ });
+
+export const createWidgetDefinition = <
+ TKind extends WidgetKind,
+ TDefinition extends WidgetDefinition,
+>(
+ kind: TKind,
+ definition: TDefinition,
+) => ({
+ withServerData: createWithServerData(kind, definition),
+ withDynamicImport: createWithDynamicImport(kind, definition, undefined),
});
-interface Definition {
+export interface WidgetDefinition {
icon: (props: TablerIconsProps) => JSX.Element;
supportedIntegrations?: IntegrationKind[];
options: WidgetOptionsRecord;
}
-export interface WidgetComponentProps {
- options: inferOptionsFromDefinition>;
+export interface WidgetProps {
+ options: inferOptionsFromDefinition>;
integrations: inferIntegrationsFromDefinition<
- WidgetImports[TSort]["definition"]
+ WidgetImports[TKind]["definition"]
>;
}
-type inferIntegrationsFromDefinition =
+type inferServerDataForKind =
+ WidgetImports[TKind] extends { serverDataLoader: ServerDataLoader }
+ ? Awaited<
+ ReturnType<
+ Awaited<
+ ReturnType
+ >["default"]
+ >
+ >
+ : undefined;
+
+export type WidgetComponentProps =
+ WidgetProps & {
+ serverData?: inferServerDataForKind;
+ };
+
+type inferIntegrationsFromDefinition =
TDefinition extends {
supportedIntegrations: infer TSupportedIntegrations;
} // check if definition has supportedIntegrations
@@ -57,5 +122,5 @@ interface IntegrationSelectOptionFor {
kind: TIntegration[number];
}
-export type WidgetOptionsRecordOf =
- WidgetImports[TSort]["definition"]["options"];
+export type WidgetOptionsRecordOf =
+ WidgetImports[TKind]["definition"]["options"];
diff --git a/packages/widgets/src/import.ts b/packages/widgets/src/import.ts
index 4805784d3..2868bb32e 100644
--- a/packages/widgets/src/import.ts
+++ b/packages/widgets/src/import.ts
@@ -1,5 +1,5 @@
-import type { WidgetSort } from ".";
+import type { WidgetKind } from "@homarr/definitions";
export type WidgetImportRecord = {
- [K in WidgetSort]: unknown;
+ [K in WidgetKind]: unknown;
};
diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx
index 5c9f64038..0dd573f38 100644
--- a/packages/widgets/src/index.tsx
+++ b/packages/widgets/src/index.tsx
@@ -1,6 +1,8 @@
+import type { ComponentType } from "react";
import dynamic from "next/dynamic";
import type { Loader } from "next/dynamic";
+import type { WidgetKind } from "@homarr/definitions";
import { Loader as UiLoader } from "@homarr/ui";
import * as clock from "./clock";
@@ -11,22 +13,33 @@ import * as weather from "./weather";
export { reduceWidgetOptionsWithDefaultValues } from "./options";
export { WidgetEditModal } from "./modals/widget-edit-modal";
-
-export const widgetSorts = ["clock", "weather"] as const;
+export { GlobalItemServerDataRunner } from "./server/runner";
+export { useServerDataFor } from "./server/provider";
export const widgetImports = {
clock,
weather,
} satisfies WidgetImportRecord;
-export type WidgetSort = (typeof widgetSorts)[number];
export type WidgetImports = typeof widgetImports;
export type WidgetImportKey = keyof WidgetImports;
-export const loadWidgetDynamic = (sort: TSort) =>
- dynamic>(
- widgetImports[sort].componentLoader as Loader>,
+const loadedComponents = new Map<
+ WidgetKind,
+ ComponentType>
+>();
+
+export const loadWidgetDynamic = (kind: TKind) => {
+ const existingComponent = loadedComponents.get(kind);
+ if (existingComponent) return existingComponent;
+
+ const newlyLoadedComponent = dynamic>(
+ widgetImports[kind].componentLoader as Loader>,
{
loading: () => ,
},
);
+
+ loadedComponents.set(kind, newlyLoadedComponent as never);
+ return newlyLoadedComponent;
+};
diff --git a/packages/widgets/src/modals/widget-edit-modal.tsx b/packages/widgets/src/modals/widget-edit-modal.tsx
index 09355eb29..cedc75b28 100644
--- a/packages/widgets/src/modals/widget-edit-modal.tsx
+++ b/packages/widgets/src/modals/widget-edit-modal.tsx
@@ -1,46 +1,46 @@
"use client";
-import type { Dispatch, SetStateAction } from "react";
import type { ManagedModal } from "mantine-modal-manager";
-import { useScopedI18n } from "@homarr/translation/client";
+import type { WidgetKind } from "@homarr/definitions";
+import { useI18n } from "@homarr/translation/client";
import { Button, Group, Stack } from "@homarr/ui";
-import type { WidgetSort } from "..";
+import { widgetImports } from "..";
import { getInputForType } from "../_inputs";
import { FormProvider, useForm } from "../_inputs/form";
-import type { WidgetOptionsRecordOf } from "../definition";
-import type { WidgetOptionDefinition } from "../options";
-import { WidgetIntegrationSelect } from "../widget-integration-select";
+import type { OptionsBuilderResult } from "../options";
import type { IntegrationSelectOption } from "../widget-integration-select";
+import { WidgetIntegrationSelect } from "../widget-integration-select";
export interface WidgetEditModalState {
options: Record;
integrations: string[];
}
-interface ModalProps {
- sort: TSort;
- state: [WidgetEditModalState, Dispatch>];
- definition: WidgetOptionsRecordOf;
+interface ModalProps {
+ kind: TSort;
+ value: WidgetEditModalState;
+ onSuccessfulEdit: (value: WidgetEditModalState) => void;
integrationData: IntegrationSelectOption[];
integrationSupport: boolean;
}
-export const WidgetEditModal: ManagedModal> = ({
+export const WidgetEditModal: ManagedModal> = ({
actions,
innerProps,
}) => {
- const t = useScopedI18n("widget.editModal");
- const [value, setValue] = innerProps.state;
+ const t = useI18n();
const form = useForm({
- initialValues: value,
+ initialValues: innerProps.value,
});
+ const { definition } = widgetImports[innerProps.kind];
+
return (