chore: merge update branch
This commit is contained in:
11
.deepsource.toml
Normal file
11
.deepsource.toml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
version = 1
|
||||||
|
|
||||||
|
[[analyzers]]
|
||||||
|
name = "javascript"
|
||||||
|
|
||||||
|
[analyzers.meta]
|
||||||
|
plugins = ["react"]
|
||||||
|
environment = ["nodejs"]
|
||||||
|
|
||||||
|
[[transformers]]
|
||||||
|
name = "prettier"
|
||||||
@@ -13,3 +13,5 @@ AUTH_URL='http://localhost:3000'
|
|||||||
# You can generate the secret via 'openssl rand -base64 32' on Unix
|
# You can generate the secret via 'openssl rand -base64 32' on Unix
|
||||||
# @see https://next-auth.js.org/configuration/options#secret
|
# @see https://next-auth.js.org/configuration/options#secret
|
||||||
AUTH_SECRET='supersecret'
|
AUTH_SECRET='supersecret'
|
||||||
|
|
||||||
|
TURBO_TELEMETRY_DISABLED=1
|
||||||
2
.github/FUNDING.yml
vendored
2
.github/FUNDING.yml
vendored
@@ -1,3 +1,3 @@
|
|||||||
# These are supported funding model platforms
|
# These are supported funding model platforms
|
||||||
|
|
||||||
github: juliusmarminge
|
open_collective: homarr
|
||||||
|
|||||||
1
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
1
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -26,4 +26,3 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: Additional information
|
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.
|
description: Add any other information related to the feature here. If your feature request is related to any issues or discussions, link them here.
|
||||||
|
|
||||||
|
|||||||
13
.github/PULL_REQUEST_TEMPLATE/pull_request_template.md
vendored
Normal file
13
.github/PULL_REQUEST_TEMPLATE/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<br/>
|
||||||
|
<div align="center">
|
||||||
|
<img src="https://homarr.dev/img/logo.png" height="80" alt="" />
|
||||||
|
<h3>Homarr</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
**Thank you for your contribution. Please ensure that your pull request meets the following pull request:**
|
||||||
|
|
||||||
|
- [ ] Builds without warnings or errors (``pnpm buid``, autofix with ``pnpm format:fix``)
|
||||||
|
- [ ] Pull request targets ``dev`` branch
|
||||||
|
- [ ] Commits follow the [conventional commits guideline](https://www.conventionalcommits.org/en/v1.0.0/)
|
||||||
|
- [ ] No shorthand variable names are used (eg. ``x``, ``y``, ``i`` or any abbrevation)
|
||||||
|
|
||||||
14
.github/renovate.json
vendored
14
.github/renovate.json
vendored
@@ -1,17 +1,15 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
"extends": [
|
"extends": ["config:recommended"],
|
||||||
"config:base"
|
|
||||||
],
|
|
||||||
"packageRules": [
|
"packageRules": [
|
||||||
{
|
{
|
||||||
"matchPackagePatterns": [
|
"matchPackagePatterns": ["^@homarr/"],
|
||||||
"^@homarr/"
|
|
||||||
],
|
|
||||||
"enabled": false
|
"enabled": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"updateInternalDeps": true,
|
"updateInternalDeps": true,
|
||||||
"rangeStrategy": "bump",
|
"rangeStrategy": "bump",
|
||||||
"automerge": true
|
"automerge": false,
|
||||||
}
|
"baseBranches": ["dev"],
|
||||||
|
"dependencyDashboard": false
|
||||||
|
}
|
||||||
|
|||||||
10
.github/workflows/automatic-release.yml
vendored
10
.github/workflows/automatic-release.yml
vendored
@@ -8,6 +8,10 @@ on:
|
|||||||
env:
|
env:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_AUTOMATIC_RELEASE }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_AUTOMATIC_RELEASE }}
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
merge:
|
merge:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -17,9 +21,9 @@ jobs:
|
|||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
uses: Ilshidur/action-discord@master
|
uses: Ilshidur/action-discord@master
|
||||||
with:
|
with:
|
||||||
args: 'Preparing the automatic release...'
|
args: "Preparing the automatic release..."
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: peter-evans/create-pull-request@v5
|
- uses: peter-evans/create-pull-request@v6
|
||||||
id: create-pull-request
|
id: create-pull-request
|
||||||
with:
|
with:
|
||||||
base: main
|
base: main
|
||||||
@@ -38,4 +42,4 @@ jobs:
|
|||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
uses: Ilshidur/action-discord@master
|
uses: Ilshidur/action-discord@master
|
||||||
with:
|
with:
|
||||||
args: 'Deployment pull request has been created at [${{ steps.create-pull-request.outputs.pull-request-number }}](${{ steps.create-pull-request.outputs.pull-request-url }})'
|
args: "Deployment pull request has been created at [${{ steps.create-pull-request.outputs.pull-request-number }}](${{ steps.create-pull-request.outputs.pull-request-url }})"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: CI
|
name: Code quality analysis
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
@@ -55,3 +55,14 @@ jobs:
|
|||||||
|
|
||||||
- name: Typecheck
|
- name: Typecheck
|
||||||
run: turbo typecheck
|
run: turbo typecheck
|
||||||
|
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup
|
||||||
|
uses: ./tooling/github/setup
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: pnpm test
|
||||||
89
.github/workflows/docker-image.yml
vendored
Normal file
89
.github/workflows/docker-image.yml
vendored
Normal file
@@ -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 }}"
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -4,6 +4,7 @@
|
|||||||
node_modules
|
node_modules
|
||||||
.pnp
|
.pnp
|
||||||
.pnp.js
|
.pnp.js
|
||||||
|
.idea/
|
||||||
|
|
||||||
# testing
|
# testing
|
||||||
coverage
|
coverage
|
||||||
@@ -44,4 +45,7 @@ yarn-error.log*
|
|||||||
.turbo
|
.turbo
|
||||||
|
|
||||||
# database
|
# database
|
||||||
db.sqlite
|
db.sqlite
|
||||||
|
|
||||||
|
# logs
|
||||||
|
*.log
|
||||||
@@ -25,39 +25,41 @@
|
|||||||
"@homarr/ui": "workspace:^0.1.0",
|
"@homarr/ui": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@homarr/widgets": "workspace:^0.1.0",
|
"@homarr/widgets": "workspace:^0.1.0",
|
||||||
"@mantine/hooks": "^7.4.0",
|
"@mantine/hooks": "^7.5.2",
|
||||||
"@mantine/modals": "^7.4.0",
|
"@mantine/modals": "^7.5.2",
|
||||||
"@mantine/tiptap": "^7.4.0",
|
"@mantine/tiptap": "^7.5.2",
|
||||||
"@t3-oss/env-nextjs": "^0.7.1",
|
"@t3-oss/env-nextjs": "^0.9.2",
|
||||||
"@tanstack/react-query": "^5.17.1",
|
"@tanstack/react-query": "^5.20.5",
|
||||||
"@tanstack/react-query-devtools": "^5.17.1",
|
"@tanstack/react-query-devtools": "^5.20.5",
|
||||||
"@tanstack/react-query-next-experimental": "5.17.1",
|
"@tanstack/react-query-next-experimental": "5.20.5",
|
||||||
"@tiptap/extension-link": "^2.1.13",
|
"@tiptap/extension-link": "^2.2.2",
|
||||||
"@tiptap/react": "^2.1.13",
|
"@tiptap/react": "^2.2.2",
|
||||||
"@tiptap/starter-kit": "^2.1.13",
|
"@tiptap/starter-kit": "^2.2.2",
|
||||||
"@trpc/client": "next",
|
"@trpc/client": "next",
|
||||||
"@trpc/next": "next",
|
"@trpc/next": "next",
|
||||||
"@trpc/react-query": "next",
|
"@trpc/react-query": "next",
|
||||||
"@trpc/server": "next",
|
"@trpc/server": "next",
|
||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
"jotai": "^2.6.1",
|
"@homarr/gridstack": "^1.0.0",
|
||||||
"mantine-modal-manager": "^7.4.0",
|
"jotai": "^2.6.4",
|
||||||
"next": "^14.0.4",
|
"mantine-modal-manager": "^7.5.2",
|
||||||
"postcss-preset-mantine": "^1.12.3",
|
"next": "^14.1.0",
|
||||||
|
"postcss-preset-mantine": "^1.13.0",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
|
"sass": "^1.70.0",
|
||||||
"superjson": "2.2.1"
|
"superjson": "2.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/node": "^18.18.13",
|
"@types/node": "^20.11.17",
|
||||||
"@types/react": "^18.2.46",
|
"@types/react": "^18.2.55",
|
||||||
"@types/react-dom": "^18.2.18",
|
"@types/react-dom": "^18.2.19",
|
||||||
"dotenv-cli": "^7.3.0",
|
"dotenv-cli": "^7.3.0",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
"prettier": "^3.1.0",
|
"prettier": "^3.2.5",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
import {
|
import {
|
||||||
showErrorNotification,
|
showErrorNotification,
|
||||||
showSuccessNotification,
|
showSuccessNotification,
|
||||||
@@ -9,7 +10,6 @@ import {
|
|||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
import { ActionIcon, IconTrash } from "@homarr/ui";
|
import { ActionIcon, IconTrash } from "@homarr/ui";
|
||||||
|
|
||||||
import { api } from "~/trpc/react";
|
|
||||||
import { revalidatePathAction } from "../../../revalidatePathAction";
|
import { revalidatePathAction } from "../../../revalidatePathAction";
|
||||||
import { modalEvents } from "../../modals";
|
import { modalEvents } from "../../modals";
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ export const DeleteIntegrationActionButton = ({
|
|||||||
}: DeleteIntegrationActionButtonProps) => {
|
}: DeleteIntegrationActionButtonProps) => {
|
||||||
const t = useScopedI18n("integration.page.delete");
|
const t = useScopedI18n("integration.page.delete");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { mutateAsync, isPending } = api.integration.delete.useMutation();
|
const { mutateAsync, isPending } = clientApi.integration.delete.useMutation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
|
|
||||||
import type { RouterInputs } from "@homarr/api";
|
import type { RouterInputs } from "@homarr/api";
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
import {
|
import {
|
||||||
showErrorNotification,
|
showErrorNotification,
|
||||||
showSuccessNotification,
|
showSuccessNotification,
|
||||||
@@ -18,8 +19,6 @@ import {
|
|||||||
Loader,
|
Loader,
|
||||||
} from "@homarr/ui";
|
} from "@homarr/ui";
|
||||||
|
|
||||||
import { api } from "~/trpc/react";
|
|
||||||
|
|
||||||
interface UseTestConnectionDirtyProps {
|
interface UseTestConnectionDirtyProps {
|
||||||
defaultDirty: boolean;
|
defaultDirty: boolean;
|
||||||
initialFormValue: {
|
initialFormValue: {
|
||||||
@@ -77,7 +76,7 @@ export const TestConnection = ({
|
|||||||
}: TestConnectionProps) => {
|
}: TestConnectionProps) => {
|
||||||
const t = useScopedI18n("integration.testConnection");
|
const t = useScopedI18n("integration.testConnection");
|
||||||
const { mutateAsync, ...mutation } =
|
const { mutateAsync, ...mutation } =
|
||||||
api.integration.testConnection.useMutation();
|
clientApi.integration.testConnection.useMutation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group>
|
<Group>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Link from "next/link";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { getSecretKinds } from "@homarr/definitions";
|
import { getSecretKinds } from "@homarr/definitions";
|
||||||
import { useForm, zodResolver } from "@homarr/form";
|
import { useForm, zodResolver } from "@homarr/form";
|
||||||
import {
|
import {
|
||||||
@@ -16,7 +17,6 @@ import type { z } from "@homarr/validation";
|
|||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
import { modalEvents } from "~/app/[locale]/modals";
|
import { modalEvents } from "~/app/[locale]/modals";
|
||||||
import { api } from "~/trpc/react";
|
|
||||||
import { SecretCard } from "../../_integration-secret-card";
|
import { SecretCard } from "../../_integration-secret-card";
|
||||||
import { IntegrationSecretInput } from "../../_integration-secret-inputs";
|
import { IntegrationSecretInput } from "../../_integration-secret-inputs";
|
||||||
import {
|
import {
|
||||||
@@ -55,7 +55,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
|||||||
),
|
),
|
||||||
onValuesChange,
|
onValuesChange,
|
||||||
});
|
});
|
||||||
const { mutateAsync, isPending } = api.integration.update.useMutation();
|
const { mutateAsync, isPending } = clientApi.integration.update.useMutation();
|
||||||
|
|
||||||
const secretsMap = new Map(
|
const secretsMap = new Map(
|
||||||
integration.secrets.map((secret) => [secret.kind, secret]),
|
integration.secrets.map((secret) => [secret.kind, secret]),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
import type { IntegrationKind } from "@homarr/definitions";
|
import type { IntegrationKind } from "@homarr/definitions";
|
||||||
import { getSecretKinds } from "@homarr/definitions";
|
import { getSecretKinds } from "@homarr/definitions";
|
||||||
import { useForm, zodResolver } from "@homarr/form";
|
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 type { z } from "@homarr/validation";
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
import { api } from "~/trpc/react";
|
|
||||||
import { IntegrationSecretInput } from "../_integration-secret-inputs";
|
import { IntegrationSecretInput } from "../_integration-secret-inputs";
|
||||||
import {
|
import {
|
||||||
TestConnection,
|
TestConnection,
|
||||||
@@ -53,7 +53,7 @@ export const NewIntegrationForm = ({
|
|||||||
validate: zodResolver(validation.integration.create.omit({ kind: true })),
|
validate: zodResolver(validation.integration.create.omit({ kind: true })),
|
||||||
onValuesChange,
|
onValuesChange,
|
||||||
});
|
});
|
||||||
const { mutateAsync, isPending } = api.integration.create.useMutation();
|
const { mutateAsync, isPending } = clientApi.integration.create.useMutation();
|
||||||
|
|
||||||
const handleSubmit = async (values: FormType) => {
|
const handleSubmit = async (values: FormType) => {
|
||||||
if (isDirty) return;
|
if (isDirty) return;
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experime
|
|||||||
import { loggerLink, unstable_httpBatchStreamLink } from "@trpc/client";
|
import { loggerLink, unstable_httpBatchStreamLink } from "@trpc/client";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
|
||||||
import { env } from "~/env.mjs";
|
import { env } from "~/env.mjs";
|
||||||
import { api } from "~/trpc/react";
|
|
||||||
|
|
||||||
const getBaseUrl = () => {
|
const getBaseUrl = () => {
|
||||||
if (typeof window !== "undefined") return ""; // browser should use relative url
|
if (typeof window !== "undefined") return ""; // browser should use relative url
|
||||||
@@ -33,7 +34,7 @@ export function TRPCReactProvider(props: {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [trpcClient] = useState(() =>
|
const [trpcClient] = useState(() =>
|
||||||
api.createClient({
|
clientApi.createClient({
|
||||||
transformer: superjson,
|
transformer: superjson,
|
||||||
links: [
|
links: [
|
||||||
loggerLink({
|
loggerLink({
|
||||||
@@ -54,13 +55,13 @@ export function TRPCReactProvider(props: {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<api.Provider client={trpcClient} queryClient={queryClient}>
|
<clientApi.Provider client={trpcClient} queryClient={queryClient}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ReactQueryStreamedHydration transformer={superjson}>
|
<ReactQueryStreamedHydration transformer={superjson}>
|
||||||
{props.children}
|
{props.children}
|
||||||
</ReactQueryStreamedHydration>
|
</ReactQueryStreamedHydration>
|
||||||
<ReactQueryDevtools initialIsOpen={false} />
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</api.Provider>
|
</clientApi.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
import { Card, Center, Stack, Text, Title } from "@homarr/ui";
|
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";
|
import { LoginForm } from "./_login-form";
|
||||||
|
|
||||||
export default async function Login() {
|
export default async function Login() {
|
||||||
@@ -10,7 +10,7 @@ export default async function Login() {
|
|||||||
return (
|
return (
|
||||||
<Center>
|
<Center>
|
||||||
<Stack align="center" mt="xl">
|
<Stack align="center" mt="xl">
|
||||||
<LogoWithTitle size="lg" />
|
<HomarrLogoWithTitle size="lg" />
|
||||||
<Stack gap={6} align="center">
|
<Stack gap={6} align="center">
|
||||||
<Title order={3} fw={400} ta="center">
|
<Title order={3} fw={400} ta="center">
|
||||||
{t("title")}
|
{t("title")}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import headerActions from "../../[name]/@headeractions/page";
|
||||||
|
|
||||||
|
export default headerActions;
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
|
});
|
||||||
5
apps/nextjs/src/app/[locale]/boards/(default)/layout.tsx
Normal file
5
apps/nextjs/src/app/[locale]/boards/(default)/layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import definition from "./_definition";
|
||||||
|
|
||||||
|
const { layout } = definition;
|
||||||
|
|
||||||
|
export default layout;
|
||||||
7
apps/nextjs/src/app/[locale]/boards/(default)/page.tsx
Normal file
7
apps/nextjs/src/app/[locale]/boards/(default)/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import definition from "./_definition";
|
||||||
|
|
||||||
|
const { generateMetadata, page } = definition;
|
||||||
|
|
||||||
|
export default page;
|
||||||
|
|
||||||
|
export { generateMetadata };
|
||||||
@@ -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 && <AddMenu />}
|
||||||
|
|
||||||
|
<EditModeMenu />
|
||||||
|
|
||||||
|
<HeaderButton href={`/boards/${board.name}/settings`}>
|
||||||
|
<IconSettings stroke={1.5} />
|
||||||
|
</HeaderButton>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const AddMenu = () => {
|
||||||
|
const { addCategoryToEnd } = useCategoryActions();
|
||||||
|
const t = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu position="bottom-end" withArrow>
|
||||||
|
<Menu.Target>
|
||||||
|
<HeaderButton w="auto" px={4}>
|
||||||
|
<Group gap={4} wrap="nowrap">
|
||||||
|
<IconPlus stroke={1.5} />
|
||||||
|
<IconChevronDown color="gray" size={16} />
|
||||||
|
</Group>
|
||||||
|
</HeaderButton>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconBox size={20} />}
|
||||||
|
onClick={() =>
|
||||||
|
modalEvents.openManagedModal({
|
||||||
|
title: t("item.create.title"),
|
||||||
|
size: "xl",
|
||||||
|
modal: "itemSelectModal",
|
||||||
|
innerProps: {},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("item.action.create")}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item leftSection={<IconPackageImport size={20} />}>
|
||||||
|
{t("item.action.import")}
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
|
<Menu.Divider />
|
||||||
|
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconBoxAlignTop size={20} />}
|
||||||
|
onClick={() =>
|
||||||
|
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")}
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<HeaderButton onClick={toggle} loading={isPending}>
|
||||||
|
{isEditMode ? (
|
||||||
|
<IconPencilOff stroke={1.5} />
|
||||||
|
) : (
|
||||||
|
<IconPencil stroke={1.5} />
|
||||||
|
)}
|
||||||
|
</HeaderButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
|
<HeaderButton href={`/boards/${board.name}`}>
|
||||||
|
<IconLayoutBoard stroke={1.5} />
|
||||||
|
</HeaderButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
},
|
||||||
|
});
|
||||||
5
apps/nextjs/src/app/[locale]/boards/[name]/layout.tsx
Normal file
5
apps/nextjs/src/app/[locale]/boards/[name]/layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import definition from "./_definition";
|
||||||
|
|
||||||
|
const { layout } = definition;
|
||||||
|
|
||||||
|
export default layout;
|
||||||
7
apps/nextjs/src/app/[locale]/boards/[name]/page.tsx
Normal file
7
apps/nextjs/src/app/[locale]/boards/[name]/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import definition from "./_definition";
|
||||||
|
|
||||||
|
const { generateMetadata, page } = definition;
|
||||||
|
|
||||||
|
export default page;
|
||||||
|
|
||||||
|
export { generateMetadata };
|
||||||
118
apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx
Normal file
118
apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx
Normal file
@@ -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 (
|
||||||
|
<form
|
||||||
|
onSubmit={form.onSubmit((values) => {
|
||||||
|
saveGeneralSettings({
|
||||||
|
boardId: board.id,
|
||||||
|
...values,
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
<Grid>
|
||||||
|
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||||
|
<TextInput
|
||||||
|
label={t("board.field.pageTitle.label")}
|
||||||
|
{...form.getInputProps("pageTitle")}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||||
|
<TextInput
|
||||||
|
label={t("board.field.metaTitle.label")}
|
||||||
|
{...form.getInputProps("metaTitle")}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||||
|
<TextInput
|
||||||
|
label={t("board.field.logoImageUrl.label")}
|
||||||
|
{...form.getInputProps("logoImageUrl")}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||||
|
<TextInput
|
||||||
|
label={t("board.field.faviconImageUrl.label")}
|
||||||
|
{...form.getInputProps("faviconImageUrl")}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
<Group justify="end">
|
||||||
|
<Button type="submit" loading={isPending}>
|
||||||
|
{t("common.action.saveChanges")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 : "");
|
||||||
|
};
|
||||||
133
apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx
Normal file
133
apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx
Normal file
@@ -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 (
|
||||||
|
<Container>
|
||||||
|
<Stack>
|
||||||
|
<Title>{t("title", { boardName: capitalize(board.name) })}</Title>
|
||||||
|
<Accordion variant="separated" defaultValue="general">
|
||||||
|
<AccordionItem value="general">
|
||||||
|
<AccordionControl icon={<IconSettings />}>
|
||||||
|
<Text fw="bold" size="lg">
|
||||||
|
{t("section.general.title")}
|
||||||
|
</Text>
|
||||||
|
</AccordionControl>
|
||||||
|
<AccordionPanel>
|
||||||
|
<GeneralSettingsContent board={board} />
|
||||||
|
</AccordionPanel>
|
||||||
|
</AccordionItem>
|
||||||
|
<AccordionItem value="layout">
|
||||||
|
<AccordionControl icon={<IconLayout />}>
|
||||||
|
<Text fw="bold" size="lg">
|
||||||
|
{t("section.layout.title")}
|
||||||
|
</Text>
|
||||||
|
</AccordionControl>
|
||||||
|
<AccordionPanel></AccordionPanel>
|
||||||
|
</AccordionItem>
|
||||||
|
<AccordionItem value="appearance">
|
||||||
|
<AccordionControl icon={<IconBrush />}>
|
||||||
|
<Text fw="bold" size="lg">
|
||||||
|
{t("section.appearance.title")}
|
||||||
|
</Text>
|
||||||
|
</AccordionControl>
|
||||||
|
<AccordionPanel></AccordionPanel>
|
||||||
|
</AccordionItem>
|
||||||
|
<AccordionItem
|
||||||
|
value="danger"
|
||||||
|
styles={{
|
||||||
|
item: {
|
||||||
|
"--__item-border-color": "rgba(248,81,73,0.4)",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AccordionControl icon={<IconAlertTriangle />}>
|
||||||
|
<Text fw="bold" size="lg">
|
||||||
|
{t("section.dangerZone.title")}
|
||||||
|
</Text>
|
||||||
|
</AccordionControl>
|
||||||
|
<AccordionPanel
|
||||||
|
styles={{ content: { paddingRight: 0, paddingLeft: 0 } }}
|
||||||
|
>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Divider />
|
||||||
|
<Group justify="space-between" px="md">
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text fw="bold" size="sm">
|
||||||
|
{t("section.dangerZone.action.rename.label")}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm">
|
||||||
|
{t("section.dangerZone.action.rename.description")}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Button variant="subtle" color="red">
|
||||||
|
{t("section.dangerZone.action.rename.button")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
<Divider />
|
||||||
|
<Group justify="space-between" px="md">
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text fw="bold" size="sm">
|
||||||
|
{t("section.dangerZone.action.visibility.label")}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm">
|
||||||
|
{t(
|
||||||
|
"section.dangerZone.action.visibility.description.private",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Button variant="subtle" color="red">
|
||||||
|
{t("section.dangerZone.action.visibility.button.private")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
<Divider />
|
||||||
|
<Group justify="space-between" px="md">
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text fw="bold" size="sm">
|
||||||
|
{t("section.dangerZone.action.delete.label")}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm">
|
||||||
|
{t("section.dangerZone.action.delete.description")}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Button variant="subtle" color="red">
|
||||||
|
{t("section.dangerZone.action.delete.button")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</AccordionPanel>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
apps/nextjs/src/app/[locale]/boards/_client.tsx
Normal file
79
apps/nextjs/src/app/[locale]/boards/_client.tsx
Normal file
@@ -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<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box h="100%" pos="relative">
|
||||||
|
<LoadingOverlay
|
||||||
|
visible={!isReady}
|
||||||
|
transitionProps={{ duration: 500 }}
|
||||||
|
loaderProps={{ size: "lg", variant: "bars" }}
|
||||||
|
h="calc(100dvh - var(--app-shell-header-offset, 0px) - var(--app-shell-padding) - var(--app-shell-footer-offset, 0px) - var(--app-shell-padding))"
|
||||||
|
/>
|
||||||
|
<Stack
|
||||||
|
ref={ref}
|
||||||
|
h="100%"
|
||||||
|
style={{ visibility: isReady ? "visible" : "hidden" }}
|
||||||
|
>
|
||||||
|
{sectionsWithoutSidebars.map((section) =>
|
||||||
|
section.kind === "empty" ? (
|
||||||
|
<BoardEmptySection
|
||||||
|
key={section.id}
|
||||||
|
section={section}
|
||||||
|
mainRef={ref}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<BoardCategorySection
|
||||||
|
key={section.id}
|
||||||
|
section={section}
|
||||||
|
mainRef={ref}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
80
apps/nextjs/src/app/[locale]/boards/_context.tsx
Normal file
80
apps/nextjs/src/app/[locale]/boards/_context.tsx
Normal file
@@ -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<string[]>([]);
|
||||||
|
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 (
|
||||||
|
<BoardContext.Provider
|
||||||
|
value={{
|
||||||
|
board: data,
|
||||||
|
isReady: data.sections.length === readySections.length,
|
||||||
|
markAsReady,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</BoardContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useMarkSectionAsReady = () => {
|
||||||
|
const context = useContext(BoardContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("Board is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.markAsReady;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useIsBoardReady = () => {
|
||||||
|
const context = useContext(BoardContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("Board is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.isReady;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRequiredBoard = () => {
|
||||||
|
const optionalBoard = useOptionalBoard();
|
||||||
|
|
||||||
|
if (!optionalBoard) {
|
||||||
|
throw new Error("Board is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return optionalBoard;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useOptionalBoard = () => {
|
||||||
|
const context = useContext(BoardContext);
|
||||||
|
|
||||||
|
return context?.board;
|
||||||
|
};
|
||||||
70
apps/nextjs/src/app/[locale]/boards/_creator.tsx
Normal file
70
apps/nextjs/src/app/[locale]/boards/_creator.tsx
Normal file
@@ -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<string, unknown>;
|
||||||
|
|
||||||
|
interface Props<TParams extends Params> {
|
||||||
|
getInitialBoard: (params: TParams) => Promise<Board>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createBoardPage = <TParams extends Record<string, unknown>>({
|
||||||
|
getInitialBoard,
|
||||||
|
}: Props<TParams>) => {
|
||||||
|
return {
|
||||||
|
layout: async ({
|
||||||
|
params,
|
||||||
|
children,
|
||||||
|
headeractions,
|
||||||
|
}: PropsWithChildren<{ params: TParams; headeractions: ReactNode }>) => {
|
||||||
|
const initialBoard = await getInitialBoard(params);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GlobalItemServerDataRunner board={initialBoard}>
|
||||||
|
<BoardProvider initialBoard={initialBoard}>
|
||||||
|
<ClientShell hasNavigation={false}>
|
||||||
|
<MainHeader
|
||||||
|
logo={<BoardLogoWithTitle size="md" />}
|
||||||
|
actions={headeractions}
|
||||||
|
hasNavigation={false}
|
||||||
|
/>
|
||||||
|
<AppShellMain>{children}</AppShellMain>
|
||||||
|
</ClientShell>
|
||||||
|
</BoardProvider>
|
||||||
|
</GlobalItemServerDataRunner>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
page: () => {
|
||||||
|
// TODO: Add check if board is private and user is not logged in
|
||||||
|
|
||||||
|
return <ClientBoard />;
|
||||||
|
},
|
||||||
|
generateMetadata: async ({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: TParams;
|
||||||
|
}): Promise<Metadata> => {
|
||||||
|
const board = await getInitialBoard(params);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: board.metaTitle ?? `${capitalize(board.name)} board | Homarr`,
|
||||||
|
icons: {
|
||||||
|
icon: board.faviconImageUrl ? board.faviconImageUrl : undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
15
apps/nextjs/src/app/[locale]/boards/_types.ts
Normal file
15
apps/nextjs/src/app/[locale]/boards/_types.ts
Normal file
@@ -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<Section, { kind: "category" }>;
|
||||||
|
export type EmptySection = Extract<Section, { kind: "empty" }>;
|
||||||
|
export type SidebarSection = Extract<Section, { kind: "sidebar" }>;
|
||||||
|
|
||||||
|
export type ItemOfKind<TKind extends WidgetKind> = Extract<
|
||||||
|
Item,
|
||||||
|
{ kind: TKind }
|
||||||
|
>;
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useForm, zodResolver } from "@homarr/form";
|
import { useForm, zodResolver } from "@homarr/form";
|
||||||
import {
|
import {
|
||||||
showErrorNotification,
|
showErrorNotification,
|
||||||
@@ -12,12 +13,11 @@ import { Button, PasswordInput, Stack, TextInput } from "@homarr/ui";
|
|||||||
import type { z } from "@homarr/validation";
|
import type { z } from "@homarr/validation";
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
import { api } from "~/trpc/react";
|
|
||||||
|
|
||||||
export const InitUserForm = () => {
|
export const InitUserForm = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const t = useScopedI18n("user");
|
const t = useScopedI18n("user");
|
||||||
const { mutateAsync, error, isPending } = api.user.initUser.useMutation();
|
const { mutateAsync, error, isPending } =
|
||||||
|
clientApi.user.initUser.useMutation();
|
||||||
const form = useForm<FormType>({
|
const form = useForm<FormType>({
|
||||||
validate: zodResolver(validation.user.init),
|
validate: zodResolver(validation.user.init),
|
||||||
validateInputOnBlur: true,
|
validateInputOnBlur: true,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { db } from "@homarr/db";
|
|||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
import { Card, Center, Stack, Text, Title } from "@homarr/ui";
|
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";
|
import { InitUserForm } from "./_init-user-form";
|
||||||
|
|
||||||
export default async function InitUser() {
|
export default async function InitUser() {
|
||||||
@@ -23,7 +23,7 @@ export default async function InitUser() {
|
|||||||
return (
|
return (
|
||||||
<Center>
|
<Center>
|
||||||
<Stack align="center" mt="xl">
|
<Stack align="center" mt="xl">
|
||||||
<LogoWithTitle size="lg" />
|
<HomarrLogoWithTitle size="lg" />
|
||||||
<Stack gap={6} align="center">
|
<Stack gap={6} align="center">
|
||||||
<Title order={3} fw={400} ta="center">
|
<Title order={3} fw={400} ta="center">
|
||||||
{t("title")}
|
{t("title")}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Button onClick={onClick} loading={isPending}>
|
||||||
|
{t("management.page.board.button.create")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
|
<Button onClick={onClick} loading={isPending} color="red">
|
||||||
|
{t("management.page.board.button.delete")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
38
apps/nextjs/src/app/[locale]/manage/boards/page.tsx
Normal file
38
apps/nextjs/src/app/[locale]/manage/boards/page.tsx
Normal file
@@ -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 (
|
||||||
|
<>
|
||||||
|
<Title>{t("title")}</Title>
|
||||||
|
|
||||||
|
<CreateBoardButton />
|
||||||
|
|
||||||
|
<Grid>
|
||||||
|
{boards.map((board) => (
|
||||||
|
<GridCol span={{ xs: 12, md: 4 }} key={board.id}>
|
||||||
|
<Card>
|
||||||
|
<Text fw={500}>{board.name}</Text>
|
||||||
|
|
||||||
|
<Text size="sm" my="md" style={{ lineBreak: "anywhere" }}>
|
||||||
|
{JSON.stringify(board)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<DeleteBoardButton id={board.id} />
|
||||||
|
</Card>
|
||||||
|
</GridCol>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -101,7 +101,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ClientShell hasNavigation={true}>
|
<ClientShell hasNavigation>
|
||||||
<MainHeader></MainHeader>
|
<MainHeader></MainHeader>
|
||||||
<MainNavigation links={navigationLinks}></MainNavigation>
|
<MainNavigation links={navigationLinks}></MainNavigation>
|
||||||
<AppShellMain>{children}</AppShellMain>
|
<AppShellMain>{children}</AppShellMain>
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import { createModalManager } from "mantine-modal-manager";
|
|||||||
|
|
||||||
import { WidgetEditModal } from "@homarr/widgets";
|
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({
|
export const [ModalsManager, modalEvents] = createModalManager({
|
||||||
|
categoryEditModal: CategoryEditModal,
|
||||||
widgetEditModal: WidgetEditModal,
|
widgetEditModal: WidgetEditModal,
|
||||||
|
itemSelectModal: ItemSelectModal,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,9 +3,8 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { WidgetOptionDefinition } from "node_modules/@homarr/widgets/src/options";
|
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 { ActionIcon, Affix, IconPencil } from "@homarr/ui";
|
||||||
import type { WidgetSort } from "@homarr/widgets";
|
|
||||||
import {
|
import {
|
||||||
loadWidgetDynamic,
|
loadWidgetDynamic,
|
||||||
reduceWidgetOptionsWithDefaultValues,
|
reduceWidgetOptionsWithDefaultValues,
|
||||||
@@ -15,7 +14,7 @@ import {
|
|||||||
import { modalEvents } from "../../modals";
|
import { modalEvents } from "../../modals";
|
||||||
|
|
||||||
interface WidgetPreviewPageContentProps {
|
interface WidgetPreviewPageContentProps {
|
||||||
sort: WidgetSort;
|
kind: WidgetKind;
|
||||||
integrationData: {
|
integrationData: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -25,10 +24,10 @@ interface WidgetPreviewPageContentProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const WidgetPreviewPageContent = ({
|
export const WidgetPreviewPageContent = ({
|
||||||
sort,
|
kind,
|
||||||
integrationData,
|
integrationData,
|
||||||
}: WidgetPreviewPageContentProps) => {
|
}: WidgetPreviewPageContentProps) => {
|
||||||
const currentDefinition = widgetImports[sort].definition;
|
const currentDefinition = widgetImports[kind].definition;
|
||||||
const options = currentDefinition.options as Record<
|
const options = currentDefinition.options as Record<
|
||||||
string,
|
string,
|
||||||
WidgetOptionDefinition
|
WidgetOptionDefinition
|
||||||
@@ -37,11 +36,11 @@ export const WidgetPreviewPageContent = ({
|
|||||||
options: Record<string, unknown>;
|
options: Record<string, unknown>;
|
||||||
integrations: string[];
|
integrations: string[];
|
||||||
}>({
|
}>({
|
||||||
options: reduceWidgetOptionsWithDefaultValues(options),
|
options: reduceWidgetOptionsWithDefaultValues(kind, options),
|
||||||
integrations: [],
|
integrations: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const Comp = loadWidgetDynamic(sort);
|
const Comp = loadWidgetDynamic(kind);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -60,9 +59,11 @@ export const WidgetPreviewPageContent = ({
|
|||||||
return modalEvents.openManagedModal({
|
return modalEvents.openManagedModal({
|
||||||
modal: "widgetEditModal",
|
modal: "widgetEditModal",
|
||||||
innerProps: {
|
innerProps: {
|
||||||
sort,
|
kind,
|
||||||
definition: currentDefinition.options,
|
value: state,
|
||||||
state: [state, setState],
|
onSuccessfulEdit: (value) => {
|
||||||
|
setState(value);
|
||||||
|
},
|
||||||
integrationData: integrationData.filter(
|
integrationData: integrationData.filter(
|
||||||
(integration) =>
|
(integration) =>
|
||||||
"supportedIntegrations" in currentDefinition &&
|
"supportedIntegrations" in currentDefinition &&
|
||||||
@@ -10,7 +10,7 @@ const getLinks = () => {
|
|||||||
return {
|
return {
|
||||||
href: `/widgets/${key}`,
|
href: `/widgets/${key}`,
|
||||||
icon: value.definition.icon,
|
icon: value.definition.icon,
|
||||||
label: value.definition.sort,
|
label: value.definition.kind,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -1,17 +1,18 @@
|
|||||||
import type { PropsWithChildren } from "react";
|
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
import { db } from "@homarr/db";
|
import { db } from "@homarr/db";
|
||||||
|
import type { WidgetKind } from "@homarr/definitions";
|
||||||
import { Center } from "@homarr/ui";
|
import { Center } from "@homarr/ui";
|
||||||
import type { WidgetSort } from "@homarr/widgets";
|
|
||||||
import { widgetImports } from "@homarr/widgets";
|
import { widgetImports } from "@homarr/widgets";
|
||||||
|
|
||||||
import { WidgetPreviewPageContent } from "./_content";
|
import { WidgetPreviewPageContent } from "./_content";
|
||||||
|
|
||||||
type Props = PropsWithChildren<{ params: { sort: string } }>;
|
interface Props {
|
||||||
|
params: { kind: string };
|
||||||
|
}
|
||||||
|
|
||||||
export default async function WidgetPreview(props: Props) {
|
export default async function WidgetPreview(props: Props) {
|
||||||
if (!(props.params.sort in widgetImports)) {
|
if (!(props.params.kind in widgetImports)) {
|
||||||
notFound();
|
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 (
|
return (
|
||||||
<Center h="100vh">
|
<Center h="100vh">
|
||||||
<WidgetPreviewPageContent sort={sort} integrationData={integrationData} />
|
<WidgetPreviewPageContent kind={sort} integrationData={integrationData} />
|
||||||
</Center>
|
</Center>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
3
apps/nextjs/src/components/board/editMode.ts
Normal file
3
apps/nextjs/src/components/board/editMode.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { atom } from "jotai";
|
||||||
|
|
||||||
|
export const editModeAtom = atom(false);
|
||||||
201
apps/nextjs/src/components/board/items/item-actions.tsx
Normal file
201
apps/nextjs/src/components/board/items/item-actions.tsx
Normal file
@@ -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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Item, "kind" | "yOffset" | "xOffset"> & {
|
||||||
|
kind: WidgetKind;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...previous,
|
||||||
|
sections: previous.sections.map((section) => {
|
||||||
|
// Return same section if item is not in it
|
||||||
|
if (section.id !== lastSection.id) return section;
|
||||||
|
return {
|
||||||
|
...section,
|
||||||
|
items: section.items.concat(widget as unknown as Item),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[updateBoard],
|
||||||
|
);
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
84
apps/nextjs/src/components/board/items/item-select-modal.tsx
Normal file
84
apps/nextjs/src/components/board/items/item-select-modal.tsx
Normal file
@@ -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<Record<string, never>> = ({
|
||||||
|
actions,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Grid>
|
||||||
|
{objectEntries(widgetImports).map(([key, value]) => {
|
||||||
|
return (
|
||||||
|
<WidgetItem
|
||||||
|
key={key}
|
||||||
|
kind={key}
|
||||||
|
definition={value.definition}
|
||||||
|
closeModal={actions.closeModal}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Grid.Col span={{ xs: 12, sm: 4, md: 3 }}>
|
||||||
|
<Card h="100%">
|
||||||
|
<Stack justify="space-between" h="100%">
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Center>
|
||||||
|
<definition.icon />
|
||||||
|
</Center>
|
||||||
|
<Text lh={1.2} style={{ whiteSpace: "normal" }} ta="center">
|
||||||
|
{t(`widget.${kind}.name`)}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
lh={1.2}
|
||||||
|
style={{ whiteSpace: "normal" }}
|
||||||
|
size="xs"
|
||||||
|
ta="center"
|
||||||
|
c="dimmed"
|
||||||
|
>
|
||||||
|
{t(`widget.${kind}.description`)}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
handleAdd(kind);
|
||||||
|
}}
|
||||||
|
variant="light"
|
||||||
|
size="xs"
|
||||||
|
mt="auto"
|
||||||
|
radius="md"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{t(`item.create.addToBoard`)}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Grid.Col>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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<HTMLDivElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BoardCategorySection = ({ section, mainRef }: Props) => {
|
||||||
|
const { refs } = useGridstack({ section, mainRef });
|
||||||
|
const [opened, { toggle }] = useDisclosure(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card withBorder p={0}>
|
||||||
|
<Stack>
|
||||||
|
<Group wrap="nowrap" gap="sm">
|
||||||
|
<UnstyledButton w="100%" p="sm" onClick={toggle}>
|
||||||
|
<Group wrap="nowrap">
|
||||||
|
{opened ? (
|
||||||
|
<IconChevronUp size={20} />
|
||||||
|
) : (
|
||||||
|
<IconChevronDown size={20} />
|
||||||
|
)}
|
||||||
|
<Title order={3}>{section.name}</Title>
|
||||||
|
</Group>
|
||||||
|
</UnstyledButton>
|
||||||
|
<CategoryMenu category={section} />
|
||||||
|
</Group>
|
||||||
|
<Collapse in={opened} p="sm" pt={0}>
|
||||||
|
<div
|
||||||
|
className="grid-stack grid-stack-category"
|
||||||
|
data-category
|
||||||
|
data-section-id={section.id}
|
||||||
|
ref={refs.wrapper}
|
||||||
|
>
|
||||||
|
<SectionContent items={section.items} refs={refs} />
|
||||||
|
</div>
|
||||||
|
</Collapse>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
@@ -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<InnerProps> = ({
|
||||||
|
actions,
|
||||||
|
innerProps,
|
||||||
|
}) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
name: innerProps.category.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={form.onSubmit((v) => {
|
||||||
|
void innerProps.onSuccess({
|
||||||
|
...innerProps.category,
|
||||||
|
name: v.name,
|
||||||
|
});
|
||||||
|
actions.closeModal();
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
<TextInput
|
||||||
|
label={t("section.category.field.name.label")}
|
||||||
|
data-autofocus
|
||||||
|
{...form.getInputProps("name")}
|
||||||
|
/>
|
||||||
|
<Group justify="right">
|
||||||
|
<Button onClick={actions.closeModal} variant="subtle" color="gray">
|
||||||
|
{t("common.action.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" color="teal">
|
||||||
|
{innerProps.submitLabel}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
|
<Menu withArrow>
|
||||||
|
<Menu.Target>
|
||||||
|
<ActionIcon mr="sm" variant="transparent">
|
||||||
|
<IconDotsVertical size={20} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
{actions.map((action) => (
|
||||||
|
<React.Fragment key={action.label}>
|
||||||
|
{"group" in action && <Menu.Label>{t(action.group)}</Menu.Label>}
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<action.icon size="1rem" />}
|
||||||
|
onClick={action.onClick}
|
||||||
|
color={"color" in action ? action.color : undefined}
|
||||||
|
>
|
||||||
|
{t(action.label)}
|
||||||
|
</Menu.Item>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
162
apps/nextjs/src/components/board/sections/content.tsx
Normal file
162
apps/nextjs/src/components/board/sections/content.tsx
Normal file
@@ -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) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="grid-stack-item"
|
||||||
|
data-id={item.id}
|
||||||
|
gs-x={item.xOffset}
|
||||||
|
gs-y={item.yOffset}
|
||||||
|
gs-w={item.width}
|
||||||
|
gs-h={item.height}
|
||||||
|
gs-min-w={1}
|
||||||
|
gs-min-h={1}
|
||||||
|
gs-max-w={4}
|
||||||
|
gs-max-h={4}
|
||||||
|
ref={refs.items.current[item.id] as RefObject<HTMLDivElement>}
|
||||||
|
>
|
||||||
|
<Card className="grid-stack-item-content" withBorder>
|
||||||
|
<BoardItem item={item} />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<ItemMenu offset={8} item={newItem} />
|
||||||
|
<Comp
|
||||||
|
options={options as never}
|
||||||
|
integrations={item.integrations}
|
||||||
|
serverData={serverData?.data as never}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Menu withinPortal withArrow position="right-start" arrowPosition="center">
|
||||||
|
<Menu.Target>
|
||||||
|
<ActionIcon
|
||||||
|
variant="transparent"
|
||||||
|
pos="absolute"
|
||||||
|
top={offset}
|
||||||
|
right={offset}
|
||||||
|
>
|
||||||
|
<IconDotsVertical />
|
||||||
|
</ActionIcon>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown miw={128}>
|
||||||
|
<Menu.Label>{t("menu.label.settings")}</Menu.Label>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconPencil size={16} />}
|
||||||
|
onClick={openEditModal}
|
||||||
|
>
|
||||||
|
{t("action.edit")}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item leftSection={<IconLayoutKanban size={16} />}>
|
||||||
|
{t("action.move")}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Divider />
|
||||||
|
<Menu.Label c="red.6">{t("menu.label.dangerZone")}</Menu.Label>
|
||||||
|
<Menu.Item
|
||||||
|
c="red.6"
|
||||||
|
leftSection={<IconTrash size={16} />}
|
||||||
|
onClick={openRemoveModal}
|
||||||
|
>
|
||||||
|
{t("action.remove")}
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
};
|
||||||
35
apps/nextjs/src/components/board/sections/empty-section.tsx
Normal file
35
apps/nextjs/src/components/board/sections/empty-section.tsx
Normal file
@@ -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<HTMLDivElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
section.items.length > 0 || isEditMode
|
||||||
|
? defaultClasses
|
||||||
|
: `${defaultClasses} gridstack-empty-wrapper`
|
||||||
|
}
|
||||||
|
style={{ transitionDuration: "0s" }}
|
||||||
|
data-empty
|
||||||
|
data-section-id={section.id}
|
||||||
|
ref={refs.wrapper}
|
||||||
|
>
|
||||||
|
<SectionContent items={section.items} refs={refs} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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<HTMLDivElement>;
|
||||||
|
items: MutableRefObject<Record<string, RefObject<GridItemHTMLElement>>>;
|
||||||
|
gridstack: MutableRefObject<GridStack | undefined>;
|
||||||
|
};
|
||||||
|
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;
|
||||||
|
};
|
||||||
@@ -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<HTMLDivElement>;
|
||||||
|
items: MutableRefObject<Record<string, RefObject<GridItemHTMLElement>>>;
|
||||||
|
gridstack: MutableRefObject<GridStack | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseGristackReturnType {
|
||||||
|
refs: UseGridstackRefs;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseGridstackProps {
|
||||||
|
section: Section;
|
||||||
|
mainRef?: RefObject<HTMLDivElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<HTMLDivElement>(null);
|
||||||
|
// references to the diffrent items contained in the gridstack
|
||||||
|
const itemRefs = useRef<Record<string, RefObject<GridItemHTMLElement>>>({});
|
||||||
|
// reference of the gridstack object for modifications after initialization
|
||||||
|
const gridRef = useRef<GridStack>();
|
||||||
|
|
||||||
|
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<HTMLDivElement>;
|
||||||
|
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]);
|
||||||
|
};
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import { AppShellHeader, Group, UnstyledButton } from "@homarr/ui";
|
import { AppShellHeader, Group, UnstyledButton } from "@homarr/ui";
|
||||||
@@ -6,20 +7,33 @@ import { ClientBurger } from "./header/burger";
|
|||||||
import { DesktopSearchInput, MobileSearchButton } from "./header/search";
|
import { DesktopSearchInput, MobileSearchButton } from "./header/search";
|
||||||
import { ClientSpotlight } from "./header/spotlight";
|
import { ClientSpotlight } from "./header/spotlight";
|
||||||
import { UserButton } from "./header/user";
|
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 (
|
return (
|
||||||
<AppShellHeader>
|
<AppShellHeader>
|
||||||
<Group h="100%" gap="xl" px="md" justify="apart" wrap="nowrap">
|
<Group h="100%" gap="xl" px="md" justify="apart" wrap="nowrap">
|
||||||
<Group h="100%" align="center" style={{ flex: 1 }} wrap="nowrap">
|
<Group h="100%" align="center" style={{ flex: 1 }} wrap="nowrap">
|
||||||
<ClientBurger />
|
{hasNavigation && <ClientBurger />}
|
||||||
<UnstyledButton component={Link} href="/">
|
<UnstyledButton component={Link} href="/">
|
||||||
<LogoWithTitle size="md" />
|
{logo ?? <HomarrLogoWithTitle size="md" />}
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
</Group>
|
</Group>
|
||||||
<DesktopSearchInput />
|
<DesktopSearchInput />
|
||||||
<Group h="100%" align="center" justify="end" style={{ flex: 1 }}>
|
<Group
|
||||||
|
h="100%"
|
||||||
|
align="center"
|
||||||
|
justify="end"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
wrap="nowrap"
|
||||||
|
>
|
||||||
|
{actions}
|
||||||
<MobileSearchButton />
|
<MobileSearchButton />
|
||||||
<UserButton />
|
<UserButton />
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
47
apps/nextjs/src/components/layout/header/button.tsx
Normal file
47
apps/nextjs/src/components/layout/header/button.tsx
Normal file
@@ -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<ActionIconProps>;
|
||||||
|
|
||||||
|
const headerButtonActionIconProps: ActionIconProps = {
|
||||||
|
variant: "subtle",
|
||||||
|
style: { border: "none" },
|
||||||
|
color: "gray",
|
||||||
|
size: "lg",
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line react/display-name
|
||||||
|
export const HeaderButton = forwardRef<HTMLButtonElement, HeaderButtonProps>(
|
||||||
|
(props, ref) => {
|
||||||
|
if ("href" in props) {
|
||||||
|
return (
|
||||||
|
<ActionIcon
|
||||||
|
ref={ref as ForwardedRef<HTMLAnchorElement>}
|
||||||
|
component={Link}
|
||||||
|
{...props}
|
||||||
|
{...headerButtonActionIconProps}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</ActionIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ActionIcon ref={ref} {...props} {...headerButtonActionIconProps}>
|
||||||
|
{props.children}
|
||||||
|
</ActionIcon>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
import { spotlight } from "@homarr/spotlight";
|
import { spotlight } from "@homarr/spotlight";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
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";
|
import classes from "./search.module.css";
|
||||||
|
|
||||||
export const DesktopSearchInput = () => {
|
export const DesktopSearchInput = () => {
|
||||||
@@ -25,13 +26,8 @@ export const DesktopSearchInput = () => {
|
|||||||
|
|
||||||
export const MobileSearchButton = () => {
|
export const MobileSearchButton = () => {
|
||||||
return (
|
return (
|
||||||
<ActionIcon
|
<HeaderButton onClick={spotlight.open} className={classes.mobileSearch}>
|
||||||
className={classes.mobileSearch}
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
onClick={spotlight.open}
|
|
||||||
>
|
|
||||||
<IconSearch size={20} stroke={1.5} />
|
<IconSearch size={20} stroke={1.5} />
|
||||||
</ActionIcon>
|
</HeaderButton>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { UnstyledButton } from "@homarr/ui";
|
import { UnstyledButton } from "@homarr/ui";
|
||||||
|
|
||||||
import { UserAvatar } from "~/components/user-avatar";
|
import { UserAvatar } from "~/components/user-avatar";
|
||||||
|
import { UserAvatarMenu } from "~/components/user-avatar-menu";
|
||||||
|
|
||||||
export const UserButton = () => {
|
export const UserButton = () => {
|
||||||
return (
|
return (
|
||||||
<UnstyledButton>
|
<UserAvatarMenu>
|
||||||
<UserAvatar size="md" />
|
<UnstyledButton>
|
||||||
</UnstyledButton>
|
<UserAvatar size="md" />
|
||||||
|
</UnstyledButton>
|
||||||
|
</UserAvatarMenu>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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) => (
|
|
||||||
<Image src="/logo/homarr.png" alt="Homarr logo" width={size} height={size} />
|
|
||||||
);
|
|
||||||
|
|
||||||
const logoWithTitleSizes = {
|
|
||||||
lg: { logoSize: 48, titleOrder: 1 },
|
|
||||||
md: { logoSize: 32, titleOrder: 2 },
|
|
||||||
sm: { logoSize: 24, titleOrder: 3 },
|
|
||||||
} satisfies Record<string, { logoSize: number; titleOrder: TitleOrder }>;
|
|
||||||
|
|
||||||
interface LogoWithTitleProps {
|
|
||||||
size: keyof typeof logoWithTitleSizes;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LogoWithTitle = ({ size }: LogoWithTitleProps) => {
|
|
||||||
const { logoSize, titleOrder } = logoWithTitleSizes[size];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Group gap={0} wrap="nowrap">
|
|
||||||
<Logo size={logoSize} />
|
|
||||||
<Title order={titleOrder}>lparr</Title>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
40
apps/nextjs/src/components/layout/logo/board-logo.tsx
Normal file
40
apps/nextjs/src/components/layout/logo/board-logo.tsx
Normal file
@@ -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 <Logo size={size} {...imageOptions} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CommonLogoWithTitleProps {
|
||||||
|
size: LogoWithTitleProps["size"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BoardLogoWithTitle = ({ size }: CommonLogoWithTitleProps) => {
|
||||||
|
const board = useRequiredBoard();
|
||||||
|
const imageOptions = useImageOptions();
|
||||||
|
return (
|
||||||
|
<LogoWithTitle
|
||||||
|
size={size}
|
||||||
|
title={board.pageTitle ?? homarrPageTitle}
|
||||||
|
image={imageOptions}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
29
apps/nextjs/src/components/layout/logo/homarr-logo.tsx
Normal file
29
apps/nextjs/src/components/layout/logo/homarr-logo.tsx
Normal file
@@ -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) => (
|
||||||
|
<Logo size={size} {...imageOptions} />
|
||||||
|
);
|
||||||
|
|
||||||
|
interface CommonLogoWithTitleProps {
|
||||||
|
size: LogoWithTitleProps["size"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HomarrLogoWithTitle = ({ size }: CommonLogoWithTitleProps) => {
|
||||||
|
return (
|
||||||
|
<LogoWithTitle size={size} title={homarrPageTitle} image={imageOptions} />
|
||||||
|
);
|
||||||
|
};
|
||||||
48
apps/nextjs/src/components/layout/logo/logo.tsx
Normal file
48
apps/nextjs/src/components/layout/logo/logo.tsx
Normal file
@@ -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 ? (
|
||||||
|
<Image src={src} alt={alt} width={size} height={size} />
|
||||||
|
) : (
|
||||||
|
// 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
|
||||||
|
<img src={src} alt={alt} width={size} height={size} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const logoWithTitleSizes = {
|
||||||
|
lg: { logoSize: 48, titleOrder: 1 },
|
||||||
|
md: { logoSize: 32, titleOrder: 2 },
|
||||||
|
sm: { logoSize: 24, titleOrder: 3 },
|
||||||
|
} satisfies Record<string, { logoSize: number; titleOrder: TitleOrder }>;
|
||||||
|
|
||||||
|
export interface LogoWithTitleProps {
|
||||||
|
size: keyof typeof logoWithTitleSizes;
|
||||||
|
title: string;
|
||||||
|
image: Omit<LogoProps, "size">;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LogoWithTitle = ({ size, title, image }: LogoWithTitleProps) => {
|
||||||
|
const { logoSize, titleOrder } = logoWithTitleSizes[size];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
<Logo {...image} size={logoSize} />
|
||||||
|
<Title order={titleOrder}>{title}</Title>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
61
apps/nextjs/src/components/user-avatar-menu.tsx
Normal file
61
apps/nextjs/src/components/user-avatar-menu.tsx
Normal file
@@ -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 (
|
||||||
|
<Menu width={200} withArrow withinPortal>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<Menu.Item
|
||||||
|
onClick={toggleColorScheme}
|
||||||
|
leftSection={<ColorSchemeIcon size="1rem" />}
|
||||||
|
>
|
||||||
|
{colorSchemeText}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
component={Link}
|
||||||
|
href="/boards"
|
||||||
|
leftSection={<IconDashboard size="1rem" />}
|
||||||
|
>
|
||||||
|
{t("navigateDefaultBoard")}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
component={Link}
|
||||||
|
href="/manage"
|
||||||
|
leftSection={<IconTool size="1rem" />}
|
||||||
|
>
|
||||||
|
{t("management")}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Divider />
|
||||||
|
<Menu.Item leftSection={<IconLogout size="1rem" />} color="red">
|
||||||
|
{t("logout")}
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
<Menu.Target>{children}</Menu.Target>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -33,7 +33,7 @@ export const env = createEnv({
|
|||||||
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
|
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
|
||||||
},
|
},
|
||||||
skipValidation:
|
skipValidation:
|
||||||
!!process.env.CI ||
|
Boolean(process.env.CI) ||
|
||||||
!!process.env.SKIP_ENV_VALIDATION ||
|
Boolean(process.env.SKIP_ENV_VALIDATION) ||
|
||||||
process.env.npm_lifecycle_event === "lint",
|
process.env.npm_lifecycle_event === "lint",
|
||||||
});
|
});
|
||||||
|
|||||||
126
apps/nextjs/src/styles/gridstack.scss
Normal file
126
apps/nextjs/src/styles/gridstack.scss
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { createTRPCReact } from "@trpc/react-query";
|
|
||||||
|
|
||||||
import type { AppRouter } from "@homarr/api";
|
|
||||||
|
|
||||||
export const api = createTRPCReact<AppRouter>();
|
|
||||||
|
|
||||||
export { type RouterInputs, type RouterOutputs } from "@homarr/api";
|
|
||||||
39
package.json
39
package.json
@@ -2,35 +2,44 @@
|
|||||||
"name": "homarr",
|
"name": "homarr",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.18.2"
|
"node": ">=20.11.0"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@8.11.0",
|
"type": "module",
|
||||||
|
"packageManager": "pnpm@8.15.2",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "turbo build",
|
"build": "turbo build",
|
||||||
"clean": "git clean -xdf node_modules",
|
"clean": "git clean -xdf node_modules",
|
||||||
"clean:workspaces": "turbo clean",
|
"clean:workspaces": "turbo clean",
|
||||||
"postinstall": "pnpm lint:ws",
|
|
||||||
"db:push": "pnpm -F db push",
|
"db:push": "pnpm -F db push",
|
||||||
"db:studio": "pnpm -F db studio",
|
"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": "turbo format --continue -- --cache --cache-location node_modules/.cache/.prettiercache",
|
||||||
"format:fix": "turbo format --continue -- --write --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": "turbo lint --continue -- --cache --cache-location node_modules/.cache/.eslintcache",
|
||||||
"lint:fix": "turbo lint --continue -- --fix --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",
|
"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"
|
"typecheck": "turbo typecheck"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@turbo/gen": "^1.10.16",
|
"@testing-library/react-hooks": "^8.0.1",
|
||||||
"prettier": "^3.1.0",
|
"@turbo/gen": "^1.12.4",
|
||||||
"turbo": "^1.10.16",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
"typescript": "^5.3.3"
|
"@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": {
|
"prettier": "@homarr/prettier-config",
|
||||||
"overrides": {
|
"dependencies": {
|
||||||
"@auth/core": "0.18.0"
|
"winston": "^3.11.0"
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"prettier": "@homarr/prettier-config"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import type { AppRouter } from "./src/root";
|
|||||||
|
|
||||||
export { appRouter, type AppRouter } from "./src/root";
|
export { appRouter, type AppRouter } from "./src/root";
|
||||||
export { createTRPCContext } from "./src/trpc";
|
export { createTRPCContext } from "./src/trpc";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inference helpers for input types
|
* Inference helpers for input types
|
||||||
* @example type HelloInput = RouterInputs['example']['hello']
|
* @example type HelloInput = RouterInputs['example']['hello']
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "@homarr/api",
|
"name": "@homarr/api",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
"exports": {
|
||||||
|
".": "./index.ts",
|
||||||
|
"./client": "./src/client.ts"
|
||||||
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "./index.ts",
|
"main": "./index.ts",
|
||||||
"types": "./index.ts",
|
"types": "./index.ts",
|
||||||
@@ -25,7 +29,7 @@
|
|||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
"prettier": "^3.1.0",
|
"prettier": "^3.2.5",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
|
|||||||
5
packages/api/src/client.ts
Normal file
5
packages/api/src/client.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { createTRPCReact } from "@trpc/react-query";
|
||||||
|
|
||||||
|
import type { AppRouter } from "..";
|
||||||
|
|
||||||
|
export const clientApi = createTRPCReact<AppRouter>();
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { boardRouter } from "./router/board";
|
||||||
import { integrationRouter } from "./router/integration";
|
import { integrationRouter } from "./router/integration";
|
||||||
import { userRouter } from "./router/user";
|
import { userRouter } from "./router/user";
|
||||||
import { createTRPCRouter } from "./trpc";
|
import { createTRPCRouter } from "./trpc";
|
||||||
@@ -5,6 +6,7 @@ import { createTRPCRouter } from "./trpc";
|
|||||||
export const appRouter = createTRPCRouter({
|
export const appRouter = createTRPCRouter({
|
||||||
user: userRouter,
|
user: userRouter,
|
||||||
integration: integrationRouter,
|
integration: integrationRouter,
|
||||||
|
board: boardRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
|||||||
354
packages/api/src/router/board.ts
Normal file
354
packages/api/src/router/board.ts
Normal file
@@ -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 = <TInput extends { id: string }>(
|
||||||
|
inputArray: TInput[],
|
||||||
|
dbArray: TInput[],
|
||||||
|
) =>
|
||||||
|
inputArray.filter(
|
||||||
|
(inputItem) => !dbArray.some((dbItem) => dbItem.id === inputItem.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
const filterRemovedItems = <TInput extends { id: string }>(
|
||||||
|
inputArray: TInput[],
|
||||||
|
dbArray: TInput[],
|
||||||
|
) =>
|
||||||
|
dbArray.filter(
|
||||||
|
(dbItem) => !inputArray.some((inputItem) => dbItem.id === inputItem.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
const filterUpdatedItems = <TInput extends { id: string }>(
|
||||||
|
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<unknown>) => {
|
||||||
|
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<Record<string, unknown>>(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 = <T extends WidgetKind>(kind: T) =>
|
||||||
|
z.object({
|
||||||
|
kind: z.literal(kind),
|
||||||
|
options: z.custom<Partial<WidgetComponentProps<T>["options"]>>(),
|
||||||
|
}) as UnionizeSpecificItemSchemaForWidgetKind<T>;
|
||||||
|
|
||||||
|
type SpecificItemSchemaForWidgetKind<TKind extends WidgetKind> = z.ZodObject<{
|
||||||
|
kind: z.ZodLiteral<TKind>;
|
||||||
|
options: z.ZodType<
|
||||||
|
Partial<WidgetComponentProps<TKind>["options"]>,
|
||||||
|
z.ZodTypeDef,
|
||||||
|
Partial<WidgetComponentProps<TKind>["options"]>
|
||||||
|
>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
type UnionizeSpecificItemSchemaForWidgetKind<T> = T extends WidgetKind
|
||||||
|
? SpecificItemSchemaForWidgetKind<T>
|
||||||
|
: 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;
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
|
import type { Database } from "@homarr/db";
|
||||||
import { and, createId, eq } from "@homarr/db";
|
import { and, createId, eq } from "@homarr/db";
|
||||||
import { integrations, integrationSecrets } from "@homarr/db/schema/sqlite";
|
import { integrations, integrationSecrets } from "@homarr/db/schema/sqlite";
|
||||||
import type { IntegrationSecretKind } from "@homarr/definitions";
|
import type { IntegrationSecretKind } from "@homarr/definitions";
|
||||||
@@ -128,18 +129,20 @@ export const integrationRouter = createTRPCRouter({
|
|||||||
|
|
||||||
if (changedSecrets.length > 0) {
|
if (changedSecrets.length > 0) {
|
||||||
for (const changedSecret of changedSecrets) {
|
for (const changedSecret of changedSecrets) {
|
||||||
await ctx.db
|
const secretInput = {
|
||||||
.update(integrationSecrets)
|
integrationId: input.id,
|
||||||
.set({
|
value: changedSecret.value,
|
||||||
value: encryptSecret(changedSecret.value),
|
kind: changedSecret.kind,
|
||||||
updatedAt: new Date(),
|
};
|
||||||
})
|
if (
|
||||||
.where(
|
!decryptedSecrets.some(
|
||||||
and(
|
(secret) => secret.kind === changedSecret.kind,
|
||||||
eq(integrationSecrets.integrationId, input.id),
|
)
|
||||||
eq(integrationSecrets.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 secretKinds = getSecretKinds(input.kind);
|
||||||
const secrets = input.secrets.filter(
|
const secrets = input.secrets.filter(
|
||||||
(secret): secret is { kind: IntegrationSecretKind; value: string } =>
|
(secret): secret is { kind: IntegrationSecretKind; value: string } =>
|
||||||
!!secret.value,
|
Boolean(secret.value),
|
||||||
);
|
);
|
||||||
const everyInputSecretDefined = secretKinds.every((secretKind) =>
|
const everyInputSecretDefined = secretKinds.every((secretKind) =>
|
||||||
secrets.some((secret) => secret.kind === 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
|
// TODO: actually test the connection
|
||||||
@@ -223,7 +237,7 @@ const key = Buffer.from(
|
|||||||
); // TODO: generate with const data = crypto.randomBytes(32).toString('hex')
|
); // TODO: generate with const data = crypto.randomBytes(32).toString('hex')
|
||||||
|
|
||||||
//Encrypting text
|
//Encrypting text
|
||||||
function encryptSecret(text: string): `${string}.${string}` {
|
export function encryptSecret(text: string): `${string}.${string}` {
|
||||||
const iv = crypto.randomBytes(16);
|
const iv = crypto.randomBytes(16);
|
||||||
const cipher = crypto.createCipheriv(algorithm, Buffer.from(key), iv);
|
const cipher = crypto.createCipheriv(algorithm, Buffer.from(key), iv);
|
||||||
let encrypted = cipher.update(text);
|
let encrypted = cipher.update(text);
|
||||||
@@ -241,3 +255,37 @@ function decryptSecret(value: `${string}.${string}`) {
|
|||||||
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
||||||
return decrypted.toString();
|
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,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
648
packages/api/src/router/test/board.spec.ts
Normal file
648
packages/api/src/router/test/board.spec.ts
Normal file
@@ -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 = <T>(value: T) => {
|
||||||
|
if (value === undefined) {
|
||||||
|
expect(value).toBeDefined();
|
||||||
|
}
|
||||||
|
if (value === null) {
|
||||||
|
expect(value).not.toBeNull();
|
||||||
|
}
|
||||||
|
return value as Exclude<T, undefined | null>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<ReturnType<typeof createFullBoardAsync>>,
|
||||||
|
) => {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
503
packages/api/src/router/test/integration.spec.ts
Normal file
503
packages/api/src/router/test/integration.spec.ts
Normal file
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
94
packages/api/src/router/test/user.spec.ts
Normal file
94
packages/api/src/router/test/user.spec.ts
Normal file
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
61
packages/auth/callbacks.ts
Normal file
61
packages/auth/callbacks.ts
Normal file
@@ -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<NextAuthConfig["callbacks"], undefined>;
|
||||||
|
export type NextAuthCallbackOf<TKey extends keyof NextAuthCallbackRecord> =
|
||||||
|
Exclude<NextAuthCallbackRecord[TKey], undefined>;
|
||||||
@@ -5,55 +5,23 @@ import Credentials from "next-auth/providers/credentials";
|
|||||||
|
|
||||||
import { db } from "@homarr/db";
|
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 { EmptyNextAuthProvider } from "./providers/empty";
|
||||||
import { expireDateAfter, generateSessionToken } from "./session";
|
import { sessionMaxAgeInSeconds, sessionTokenCookieName } from "./session";
|
||||||
|
|
||||||
const adapter = DrizzleAdapter(db);
|
const adapter = DrizzleAdapter(db);
|
||||||
const sessionMaxAgeInSeconds = 30 * 24 * 60 * 60; // 30 days
|
|
||||||
|
|
||||||
export const createConfiguration = (isCredentialsRequest: boolean) =>
|
export const createConfiguration = (isCredentialsRequest: boolean) =>
|
||||||
NextAuth({
|
NextAuth({
|
||||||
adapter,
|
adapter,
|
||||||
providers: [Credentials(credentialsConfiguration), EmptyNextAuthProvider()],
|
providers: [
|
||||||
|
Credentials(createCredentialsConfiguration(db)),
|
||||||
|
EmptyNextAuthProvider(),
|
||||||
|
],
|
||||||
callbacks: {
|
callbacks: {
|
||||||
session: ({ session, user }) => ({
|
session: sessionCallback,
|
||||||
...session,
|
signIn: createSignInCallback(adapter, isCredentialsRequest),
|
||||||
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: {
|
session: {
|
||||||
strategy: "database",
|
strategy: "database",
|
||||||
@@ -65,7 +33,7 @@ export const createConfiguration = (isCredentialsRequest: boolean) =>
|
|||||||
},
|
},
|
||||||
jwt: {
|
jwt: {
|
||||||
encode() {
|
encode() {
|
||||||
const cookie = cookies().get("next-auth.session-token")?.value;
|
const cookie = cookies().get(sessionTokenCookieName)?.value;
|
||||||
return cookie ?? "";
|
return cookie ?? "";
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -20,5 +20,6 @@ export const env = createEnv({
|
|||||||
AUTH_SECRET: process.env.AUTH_SECRET,
|
AUTH_SECRET: process.env.AUTH_SECRET,
|
||||||
AUTH_URL: process.env.AUTH_URL,
|
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),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@homarr/auth",
|
"name": "@homarr/auth",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
"exports": {
|
||||||
|
".": "./index.ts",
|
||||||
|
"./security": "./security.ts",
|
||||||
|
"./client": "./client.ts",
|
||||||
|
"./env.mjs": "./env.mjs"
|
||||||
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "./index.ts",
|
"main": "./index.ts",
|
||||||
"types": "./index.ts",
|
"types": "./index.ts",
|
||||||
@@ -13,13 +19,13 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@homarr/db": "workspace:^0.1.0",
|
"@homarr/db": "workspace:^0.1.0",
|
||||||
"@auth/core": "^0.19.0",
|
"@auth/core": "^0.27.0",
|
||||||
"@auth/drizzle-adapter": "^0.3.12",
|
"@auth/drizzle-adapter": "^0.7.0",
|
||||||
"@t3-oss/env-nextjs": "^0.7.1",
|
"@t3-oss/env-nextjs": "^0.9.2",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"cookies": "^0.9.1",
|
"cookies": "^0.9.1",
|
||||||
"next": "^14.0.4",
|
"next": "^14.1.0",
|
||||||
"next-auth": "5.0.0-beta.5",
|
"next-auth": "5.0.0-beta.11",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0"
|
"react-dom": "18.2.0"
|
||||||
},
|
},
|
||||||
@@ -29,9 +35,9 @@
|
|||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@types/bcrypt": "5.0.2",
|
"@types/bcrypt": "5.0.2",
|
||||||
"@types/cookies": "0.7.10",
|
"@types/cookies": "0.9.0",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
"prettier": "^3.1.0",
|
"prettier": "^3.2.5",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
|
|||||||
@@ -1,49 +1,56 @@
|
|||||||
import type Credentials from "@auth/core/providers/credentials";
|
import type Credentials from "@auth/core/providers/credentials";
|
||||||
import bcrypt from "bcrypt";
|
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 { users } from "@homarr/db/schema/sqlite";
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
type CredentialsConfiguration = Parameters<typeof Credentials>[0];
|
type CredentialsConfiguration = Parameters<typeof Credentials>[0];
|
||||||
|
|
||||||
export const credentialsConfiguration = {
|
export const createCredentialsConfiguration = (db: Database) =>
|
||||||
type: "credentials",
|
({
|
||||||
name: "Credentials",
|
type: "credentials",
|
||||||
credentials: {
|
name: "Credentials",
|
||||||
name: {
|
credentials: {
|
||||||
label: "Username",
|
name: {
|
||||||
type: "text",
|
label: "Username",
|
||||||
|
type: "text",
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
label: "Password",
|
||||||
|
type: "password",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
password: {
|
async authorize(credentials) {
|
||||||
label: "Password",
|
const data = await validation.user.signIn.parseAsync(credentials);
|
||||||
type: "password",
|
|
||||||
|
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;
|
||||||
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;
|
|
||||||
|
|||||||
66
packages/auth/providers/test/credentials.spec.ts
Normal file
66
packages/auth/providers/test/credentials.spec.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import { randomUUID } from "crypto";
|
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) => {
|
export const expireDateAfter = (seconds: number) => {
|
||||||
return new Date(Date.now() + seconds * 1000);
|
return new Date(Date.now() + seconds * 1000);
|
||||||
};
|
};
|
||||||
|
|||||||
153
packages/auth/test/callbacks.spec.ts
Normal file
153
packages/auth/test/callbacks.spec.ts
Normal file
@@ -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<Adapter["createSession"], undefined>
|
||||||
|
>[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<SessionExport>();
|
||||||
|
|
||||||
|
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<HeadersExport>();
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
set: (name: string, value: string, options: Partial<ResponseCookie>) =>
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
47
packages/auth/test/security.spec.ts
Normal file
47
packages/auth/test/security.spec.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
43
packages/auth/test/session.spec.ts
Normal file
43
packages/auth/test/session.spec.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
26
packages/common/src/test/object.spec.ts
Normal file
26
packages/common/src/test/object.spec.ts
Normal file
@@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
19
packages/common/src/test/string.spec.ts
Normal file
19
packages/common/src/test/string.spec.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
1
packages/db/client.ts
Normal file
1
packages/db/client.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { createId } from "@paralleldrive/cuid2";
|
||||||
@@ -7,4 +7,5 @@ export default {
|
|||||||
schema: "./schema",
|
schema: "./schema",
|
||||||
driver: "better-sqlite",
|
driver: "better-sqlite",
|
||||||
dbCredentials: { url: process.env.DB_URL! },
|
dbCredentials: { url: process.env.DB_URL! },
|
||||||
|
out: "./migrations",
|
||||||
} satisfies Config;
|
} satisfies Config;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Database from "better-sqlite3";
|
import Database from "better-sqlite3";
|
||||||
|
import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
|
||||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||||
|
|
||||||
import * as sqliteSchema from "./schema/sqlite";
|
import * as sqliteSchema from "./schema/sqlite";
|
||||||
@@ -7,8 +8,10 @@ export const schema = sqliteSchema;
|
|||||||
|
|
||||||
export * from "drizzle-orm";
|
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 const db = drizzle(sqlite, { schema });
|
||||||
|
|
||||||
|
export type Database = BetterSQLite3Database<typeof schema>;
|
||||||
|
|
||||||
export { createId } from "@paralleldrive/cuid2";
|
export { createId } from "@paralleldrive/cuid2";
|
||||||
|
|||||||
112
packages/db/migrations/0000_true_red_wolf.sql
Normal file
112
packages/db/migrations/0000_true_red_wolf.sql
Normal file
@@ -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`);
|
||||||
696
packages/db/migrations/meta/0000_snapshot.json
Normal file
696
packages/db/migrations/meta/0000_snapshot.json
Normal file
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
packages/db/migrations/meta/_journal.json
Normal file
13
packages/db/migrations/meta/_journal.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "5",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1707511343363,
|
||||||
|
"tag": "0000_true_red_wolf",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@homarr/db",
|
"name": "@homarr/db",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
"exports": {
|
||||||
|
".": "./index.ts",
|
||||||
|
"./client": "./client.ts",
|
||||||
|
"./schema/sqlite": "./schema/sqlite.ts",
|
||||||
|
"./test": "./test/index.ts"
|
||||||
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "./index.ts",
|
"main": "./index.ts",
|
||||||
"types": "./index.ts",
|
"types": "./index.ts",
|
||||||
@@ -9,6 +15,7 @@
|
|||||||
"clean": "rm -rf .turbo node_modules",
|
"clean": "rm -rf .turbo node_modules",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||||
|
"migration:generate": "drizzle-kit generate:sqlite",
|
||||||
"push": "drizzle-kit push:sqlite",
|
"push": "drizzle-kit push:sqlite",
|
||||||
"studio": "drizzle-kit studio",
|
"studio": "drizzle-kit studio",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
@@ -17,18 +24,18 @@
|
|||||||
"@homarr/common": "workspace:^0.1.0",
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
"@homarr/definitions": "workspace:^0.1.0",
|
"@homarr/definitions": "workspace:^0.1.0",
|
||||||
"@paralleldrive/cuid2": "^2.2.2",
|
"@paralleldrive/cuid2": "^2.2.2",
|
||||||
"better-sqlite3": "^9.2.2",
|
"better-sqlite3": "^9.4.1",
|
||||||
"drizzle-orm": "^0.29.3"
|
"drizzle-orm": "^0.29.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/better-sqlite3": "7.6.8",
|
"@types/better-sqlite3": "7.6.9",
|
||||||
"dotenv-cli": "^7.3.0",
|
"dotenv-cli": "^7.3.0",
|
||||||
"drizzle-kit": "^0.20.9",
|
"drizzle-kit": "^0.20.14",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
"prettier": "^3.1.0",
|
"prettier": "^3.2.5",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import type { AdapterAccount } from "@auth/core/adapters";
|
import type { AdapterAccount } from "@auth/core/adapters";
|
||||||
|
import type { MantineColor } from "@mantine/core";
|
||||||
import type { InferSelectModel } from "drizzle-orm";
|
import type { InferSelectModel } from "drizzle-orm";
|
||||||
import { relations } from "drizzle-orm";
|
import { relations } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
index,
|
index,
|
||||||
|
int,
|
||||||
integer,
|
integer,
|
||||||
primaryKey,
|
primaryKey,
|
||||||
sqliteTable,
|
sqliteTable,
|
||||||
@@ -10,8 +12,13 @@ import {
|
|||||||
} from "drizzle-orm/sqlite-core";
|
} from "drizzle-orm/sqlite-core";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
BackgroundImageAttachment,
|
||||||
|
BackgroundImageRepeat,
|
||||||
|
BackgroundImageSize,
|
||||||
IntegrationKind,
|
IntegrationKind,
|
||||||
IntegrationSecretKind,
|
IntegrationSecretKind,
|
||||||
|
SectionKind,
|
||||||
|
WidgetKind,
|
||||||
} from "@homarr/definitions";
|
} from "@homarr/definitions";
|
||||||
|
|
||||||
export const users = sqliteTable("user", {
|
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<BackgroundImageAttachment>()
|
||||||
|
.default("fixed")
|
||||||
|
.notNull(),
|
||||||
|
backgroundImageRepeat: text("background_image_repeat")
|
||||||
|
.$type<BackgroundImageRepeat>()
|
||||||
|
.default("no-repeat")
|
||||||
|
.notNull(),
|
||||||
|
backgroundImageSize: text("background_image_size")
|
||||||
|
.$type<BackgroundImageSize>()
|
||||||
|
.default("cover")
|
||||||
|
.notNull(),
|
||||||
|
primaryColor: text("primary_color")
|
||||||
|
.$type<MantineColor>()
|
||||||
|
.default("red")
|
||||||
|
.notNull(),
|
||||||
|
secondaryColor: text("secondary_color")
|
||||||
|
.$type<MantineColor>()
|
||||||
|
.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<SectionKind>().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<WidgetKind>().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 }) => ({
|
export const accountRelations = relations(accounts, ({ one }) => ({
|
||||||
user: one(users, {
|
user: one(users, {
|
||||||
fields: [accounts.userId],
|
fields: [accounts.userId],
|
||||||
@@ -120,6 +212,7 @@ export const userRelations = relations(users, ({ many }) => ({
|
|||||||
|
|
||||||
export const integrationRelations = relations(integrations, ({ many }) => ({
|
export const integrationRelations = relations(integrations, ({ many }) => ({
|
||||||
secrets: many(integrationSecrets),
|
secrets: many(integrationSecrets),
|
||||||
|
items: many(integrationItems),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const integrationSecretRelations = relations(
|
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<typeof users>;
|
export type User = InferSelectModel<typeof users>;
|
||||||
export type Account = InferSelectModel<typeof accounts>;
|
export type Account = InferSelectModel<typeof accounts>;
|
||||||
export type Session = InferSelectModel<typeof sessions>;
|
export type Session = InferSelectModel<typeof sessions>;
|
||||||
|
|||||||
14
packages/db/test/db-mock.ts
Normal file
14
packages/db/test/db-mock.ts
Normal file
@@ -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;
|
||||||
|
};
|
||||||
1
packages/db/test/index.ts
Normal file
1
packages/db/test/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./db-mock";
|
||||||
13
packages/definitions/src/board.ts
Normal file
13
packages/definitions/src/board.ts
Normal file
@@ -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];
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user