chore(release): automatic release v0.1.0
This commit is contained in:
36
.github/workflows/deployment-docker-image.yml
vendored
36
.github/workflows/deployment-docker-image.yml
vendored
@@ -38,12 +38,12 @@ jobs:
|
|||||||
node-version: [20]
|
node-version: [20]
|
||||||
steps:
|
steps:
|
||||||
- name: Discord notification
|
- name: Discord notification
|
||||||
if: ${{ github.events.inputs.send-notifications }}
|
if: ${{ github.events.inputs.send-notifications || true }}
|
||||||
env:
|
env:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
uses: Ilshidur/action-discord@master
|
uses: Ilshidur/action-discord@master
|
||||||
with:
|
with:
|
||||||
args: "Deployment of an image has been triggered: [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
|
args: "Deployment of an image has been triggered: [run ${{ github.run_number }}](<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}>)"
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Get Next Version
|
- name: Get Next Version
|
||||||
id: semver
|
id: semver
|
||||||
@@ -52,7 +52,7 @@ jobs:
|
|||||||
token: ${{ github.token }}
|
token: ${{ github.token }}
|
||||||
branch: dev
|
branch: dev
|
||||||
- name: Discord notification
|
- name: Discord notification
|
||||||
if: ${{ github.events.inputs.send-notifications }}
|
if: ${{ github.events.inputs.send-notifications || true }}
|
||||||
env:
|
env:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
uses: Ilshidur/action-discord@master
|
uses: Ilshidur/action-discord@master
|
||||||
@@ -65,9 +65,8 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node-version }}
|
node-version: ${{ matrix.node-version }}
|
||||||
cache: "pnpm"
|
|
||||||
- name: Discord notification
|
- name: Discord notification
|
||||||
if: ${{ github.events.inputs.send-notifications }}
|
if: ${{ github.events.inputs.send-notifications || true }}
|
||||||
env:
|
env:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
uses: Ilshidur/action-discord@master
|
uses: Ilshidur/action-discord@master
|
||||||
@@ -97,19 +96,40 @@ jobs:
|
|||||||
- name: Build and push
|
- name: Build and push
|
||||||
id: buildPushAction
|
id: buildPushAction
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
|
if: ${{ github.events.inputs.push-image == 'true' || github.events.inputs.push-image == null }}
|
||||||
with:
|
with:
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
context: .
|
context: .
|
||||||
push: ${{ github.events.inputs.push-image && 'true' || 'false' }}
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
network: host
|
||||||
|
env:
|
||||||
|
SKIP_ENV_VALIDATION: true
|
||||||
|
- name: Build
|
||||||
|
id: buildPushDryAction
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
if: ${{ github.events.inputs.push-image == 'false' }}
|
||||||
|
with:
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
context: .
|
||||||
|
push: false
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
network: host
|
network: host
|
||||||
env:
|
env:
|
||||||
SKIP_ENV_VALIDATION: true
|
SKIP_ENV_VALIDATION: true
|
||||||
- name: Discord notification
|
- name: Discord notification
|
||||||
if: ${{ github.events.inputs.send-notifications }}
|
if: ${{ github.events.inputs.send-notifications || true && (github.events.inputs.push-image == 'true' || github.events.inputs.push-image == null) }}
|
||||||
env:
|
env:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
uses: Ilshidur/action-discord@master
|
uses: Ilshidur/action-discord@master
|
||||||
with:
|
with:
|
||||||
args: "Deployment of image has completed. Image ID is '${{ steps.buildPushAction.outputs.imageid }}'. This was a dry run."
|
args: "Deployment of image has completed. Image ID is '${{ steps.buildPushAction.outputs.imageid }}'."
|
||||||
|
- name: Discord notification
|
||||||
|
if: ${{ github.events.inputs.send-notifications || true && !(github.events.inputs.push-image == 'true' || github.events.inputs.push-image == null) }}
|
||||||
|
env:
|
||||||
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
|
uses: Ilshidur/action-discord@master
|
||||||
|
with:
|
||||||
|
args: "Deployment of image has completed. Image ID is '${{ steps.buildPushDryAction.outputs.imageid }}'. This was a dry run."
|
||||||
|
|||||||
10
Dockerfile
10
Dockerfile
@@ -61,7 +61,8 @@ RUN corepack enable pnpm && pnpm build
|
|||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apk add --no-cache redis bash
|
# gettext is required for envsubst
|
||||||
|
RUN apk add --no-cache redis nginx bash gettext
|
||||||
RUN mkdir /appdata
|
RUN mkdir /appdata
|
||||||
RUN mkdir /appdata/db
|
RUN mkdir /appdata/db
|
||||||
RUN mkdir /appdata/redis
|
RUN mkdir /appdata/redis
|
||||||
@@ -79,6 +80,11 @@ RUN chmod +x /usr/bin/homarr
|
|||||||
|
|
||||||
# Don't run production as root
|
# Don't run production as root
|
||||||
RUN chown -R nextjs:nodejs /appdata
|
RUN chown -R nextjs:nodejs /appdata
|
||||||
|
RUN mkdir -p /var/cache/nginx && chown -R nextjs:nodejs /var/cache/nginx && \
|
||||||
|
mkdir -p /var/log/nginx && chown -R nextjs:nodejs /var/log/nginx && \
|
||||||
|
mkdir -p /var/lib/nginx && chown -R nextjs:nodejs /var/lib/nginx && \
|
||||||
|
touch /run/nginx/nginx.pid && chown -R nextjs:nodejs /run/nginx/nginx.pid && \
|
||||||
|
mkdir -p /etc/nginx/templates /etc/nginx/ssl/certs && chown -R nextjs:nodejs /etc/nginx
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
COPY --from=installer /app/apps/nextjs/next.config.mjs .
|
COPY --from=installer /app/apps/nextjs/next.config.mjs .
|
||||||
@@ -97,6 +103,8 @@ COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/.next/static ./apps
|
|||||||
COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/public ./apps/nextjs/public
|
COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/public ./apps/nextjs/public
|
||||||
COPY --chown=nextjs:nodejs scripts/run.sh ./run.sh
|
COPY --chown=nextjs:nodejs scripts/run.sh ./run.sh
|
||||||
COPY --chown=nextjs:nodejs packages/redis/redis.conf /app/redis.conf
|
COPY --chown=nextjs:nodejs packages/redis/redis.conf /app/redis.conf
|
||||||
|
COPY --chown=nextjs:nodejs nginx.conf /etc/nginx/templates/nginx.conf
|
||||||
|
|
||||||
|
|
||||||
ENV DB_URL='/appdata/db/db.sqlite'
|
ENV DB_URL='/appdata/db/db.sqlite'
|
||||||
ENV DB_DIALECT='sqlite'
|
ENV DB_DIALECT='sqlite'
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
"@homarr/integrations": "workspace:^0.1.0",
|
"@homarr/integrations": "workspace:^0.1.0",
|
||||||
"@homarr/log": "workspace:^",
|
"@homarr/log": "workspace:^",
|
||||||
"@homarr/modals": "workspace:^0.1.0",
|
"@homarr/modals": "workspace:^0.1.0",
|
||||||
|
"@homarr/modals-collection": "workspace:^0.1.0",
|
||||||
"@homarr/notifications": "workspace:^0.1.0",
|
"@homarr/notifications": "workspace:^0.1.0",
|
||||||
"@homarr/old-schema": "workspace:^0.1.0",
|
"@homarr/old-schema": "workspace:^0.1.0",
|
||||||
"@homarr/server-settings": "workspace:^0.1.0",
|
"@homarr/server-settings": "workspace:^0.1.0",
|
||||||
@@ -42,7 +43,7 @@
|
|||||||
"@mantine/tiptap": "^7.12.2",
|
"@mantine/tiptap": "^7.12.2",
|
||||||
"@million/lint": "1.0.0-rc.84",
|
"@million/lint": "1.0.0-rc.84",
|
||||||
"@t3-oss/env-nextjs": "^0.11.1",
|
"@t3-oss/env-nextjs": "^0.11.1",
|
||||||
"@tabler/icons-react": "^3.16.0",
|
"@tabler/icons-react": "^3.17.0",
|
||||||
"@tanstack/react-query": "^5.56.2",
|
"@tanstack/react-query": "^5.56.2",
|
||||||
"@tanstack/react-query-devtools": "^5.56.2",
|
"@tanstack/react-query-devtools": "^5.56.2",
|
||||||
"@tanstack/react-query-next-experimental": "5.56.2",
|
"@tanstack/react-query-next-experimental": "5.56.2",
|
||||||
@@ -59,16 +60,16 @@
|
|||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"flag-icons": "^7.2.3",
|
"flag-icons": "^7.2.3",
|
||||||
"glob": "^11.0.0",
|
"glob": "^11.0.0",
|
||||||
"jotai": "^2.9.3",
|
"jotai": "^2.10.0",
|
||||||
"mantine-react-table": "2.0.0-beta.6",
|
"mantine-react-table": "2.0.0-beta.6",
|
||||||
"next": "^14.2.11",
|
"next": "^14.2.13",
|
||||||
"postcss-preset-mantine": "^1.17.0",
|
"postcss-preset-mantine": "^1.17.0",
|
||||||
"prismjs": "^1.29.0",
|
"prismjs": "^1.29.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-error-boundary": "^4.0.13",
|
"react-error-boundary": "^4.0.13",
|
||||||
"react-simple-code-editor": "^0.14.1",
|
"react-simple-code-editor": "^0.14.1",
|
||||||
"sass": "^1.78.0",
|
"sass": "^1.79.2",
|
||||||
"superjson": "2.2.1",
|
"superjson": "2.2.1",
|
||||||
"swagger-ui-react": "^5.17.14",
|
"swagger-ui-react": "^5.17.14",
|
||||||
"use-deep-compare-effect": "^1.8.1"
|
"use-deep-compare-effect": "^1.8.1"
|
||||||
@@ -80,7 +81,7 @@
|
|||||||
"@types/chroma-js": "2.4.4",
|
"@types/chroma-js": "2.4.4",
|
||||||
"@types/node": "^20.16.5",
|
"@types/node": "^20.16.5",
|
||||||
"@types/prismjs": "^1.26.4",
|
"@types/prismjs": "^1.26.4",
|
||||||
"@types/react": "^18.3.5",
|
"@types/react": "^18.3.8",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@types/swagger-ui-react": "^4.18.3",
|
"@types/swagger-ui-react": "^4.18.3",
|
||||||
"concurrently": "^9.0.1",
|
"concurrently": "^9.0.1",
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ import type { AppRouter } from "@homarr/api";
|
|||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
|
||||||
const wsClient = createWSClient({
|
const wsClient = createWSClient({
|
||||||
url: typeof window === "undefined" ? "ws://localhost:3001" : `ws://${window.location.hostname}:3001`,
|
url:
|
||||||
|
typeof window === "undefined"
|
||||||
|
? "ws://localhost:3001/websockets"
|
||||||
|
: `ws://${window.location.hostname}:${window.location.port}/websockets`,
|
||||||
});
|
});
|
||||||
|
|
||||||
export function TRPCReactProvider(props: PropsWithChildren) {
|
export function TRPCReactProvider(props: PropsWithChildren) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Anchor, Button, Card, Code, Collapse, Divider, PasswordInput, Stack, Te
|
|||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
|
||||||
import { signIn } from "@homarr/auth/client";
|
import { signIn } from "@homarr/auth/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import type { useForm } from "@homarr/form";
|
import type { useForm } from "@homarr/form";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
@@ -14,8 +15,6 @@ import { useScopedI18n } from "@homarr/translation/client";
|
|||||||
import type { z } from "@homarr/validation";
|
import type { z } from "@homarr/validation";
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
|
||||||
|
|
||||||
interface LoginFormProps {
|
interface LoginFormProps {
|
||||||
providers: string[];
|
providers: string[];
|
||||||
oidcClientName: string;
|
oidcClientName: string;
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ import {
|
|||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useModalAction } from "@homarr/modals";
|
import { useModalAction } from "@homarr/modals";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
|
||||||
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
|
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
|
||||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||||
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
|
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
|
||||||
|
|||||||
@@ -6,12 +6,11 @@ import { IconTrash } from "@tabler/icons-react";
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useConfirmModal } from "@homarr/modals";
|
import { useConfirmModal } from "@homarr/modals";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "../../../revalidatePathAction";
|
|
||||||
|
|
||||||
interface AppDeleteButtonProps {
|
interface AppDeleteButtonProps {
|
||||||
app: RouterOutputs["app"]["all"][number];
|
app: RouterOutputs["app"]["all"][number];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import { useRouter } from "next/navigation";
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import type { TranslationFunction } from "@homarr/translation";
|
import type { TranslationFunction } from "@homarr/translation";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
import type { validation, z } from "@homarr/validation";
|
import type { validation, z } from "@homarr/validation";
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
|
||||||
import { AppForm } from "../../_form";
|
import { AppForm } from "../../_form";
|
||||||
|
|
||||||
interface AppEditFormProps {
|
interface AppEditFormProps {
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import { useCallback } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import type { TranslationFunction } from "@homarr/translation";
|
import type { TranslationFunction } from "@homarr/translation";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
import type { validation, z } from "@homarr/validation";
|
import type { validation, z } from "@homarr/validation";
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
|
||||||
import { AppForm } from "../_form";
|
import { AppForm } from "../_form";
|
||||||
|
|
||||||
export const AppNewForm = () => {
|
export const AppNewForm = () => {
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import { IconHome, IconSettings, IconTrash } from "@tabler/icons-react";
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useConfirmModal } from "@homarr/modals";
|
import { useConfirmModal } from "@homarr/modals";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
|
||||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||||
|
|
||||||
const iconProps = {
|
const iconProps = {
|
||||||
|
|||||||
@@ -1,53 +1,21 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import { Affix, Button, Group, Menu } from "@mantine/core";
|
import { Affix, Button, Group, Menu } from "@mantine/core";
|
||||||
import { IconCategoryPlus, IconChevronDown, IconFileImport } from "@tabler/icons-react";
|
import { IconCategoryPlus, IconChevronDown, IconFileImport } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
import { useModalAction } from "@homarr/modals";
|
import { useModalAction } from "@homarr/modals";
|
||||||
|
import { AddBoardModal, ImportBoardModal } from "@homarr/modals-collection";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { BetaBadge } from "@homarr/ui";
|
import { BetaBadge } from "@homarr/ui";
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
export const CreateBoardButton = () => {
|
||||||
import { AddBoardModal } from "~/components/manage/boards/add-board-modal";
|
|
||||||
import { ImportBoardModal } from "~/components/manage/boards/import-board-modal";
|
|
||||||
|
|
||||||
interface CreateBoardButtonProps {
|
|
||||||
boardNames: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => {
|
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { openModal: openAddModal } = useModalAction(AddBoardModal);
|
const { openModal: openAddModal } = useModalAction(AddBoardModal);
|
||||||
const { openModal: openImportModal } = useModalAction(ImportBoardModal);
|
const { openModal: openImportModal } = useModalAction(ImportBoardModal);
|
||||||
|
|
||||||
const { mutateAsync, isPending } = clientApi.board.createBoard.useMutation({
|
|
||||||
onSettled: async () => {
|
|
||||||
await revalidatePathActionAsync("/manage/boards");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onCreateClick = useCallback(() => {
|
|
||||||
openAddModal({
|
|
||||||
onSuccess: async (values) => {
|
|
||||||
await mutateAsync({
|
|
||||||
name: values.name,
|
|
||||||
columnCount: values.columnCount,
|
|
||||||
isPublic: values.isPublic,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
boardNames,
|
|
||||||
});
|
|
||||||
}, [mutateAsync, boardNames, openAddModal]);
|
|
||||||
|
|
||||||
const onImportClick = useCallback(() => {
|
|
||||||
openImportModal({ boardNames });
|
|
||||||
}, [openImportModal, boardNames]);
|
|
||||||
|
|
||||||
const buttonGroupContent = (
|
const buttonGroupContent = (
|
||||||
<>
|
<>
|
||||||
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={onCreateClick} loading={isPending}>
|
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openAddModal}>
|
||||||
{t("management.page.board.action.new.label")}
|
{t("management.page.board.action.new.label")}
|
||||||
</Button>
|
</Button>
|
||||||
<Menu position="bottom-end">
|
<Menu position="bottom-end">
|
||||||
@@ -57,7 +25,7 @@ export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<Menu.Item onClick={onImportClick} leftSection={<IconFileImport size="1rem" />}>
|
<Menu.Item onClick={openImportModal} leftSection={<IconFileImport size="1rem" />}>
|
||||||
<Group>
|
<Group>
|
||||||
{t("board.action.oldImport.label")}
|
{t("board.action.oldImport.label")}
|
||||||
<BetaBadge size="xs" />
|
<BetaBadge size="xs" />
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export default async function ManageBoardsPage() {
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Title mb="md">{t("title")}</Title>
|
<Title mb="md">{t("title")}</Title>
|
||||||
<CreateBoardButton boardNames={boards.map((board) => board.name)} />
|
<CreateBoardButton />
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Grid mb={{ base: "xl", md: 0 }}>
|
<Grid mb={{ base: "xl", md: 0 }}>
|
||||||
|
|||||||
@@ -5,12 +5,11 @@ import { ActionIcon } from "@mantine/core";
|
|||||||
import { IconTrash } from "@tabler/icons-react";
|
import { IconTrash } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useConfirmModal } from "@homarr/modals";
|
import { useConfirmModal } from "@homarr/modals";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "../../../revalidatePathAction";
|
|
||||||
|
|
||||||
interface DeleteIntegrationActionButtonProps {
|
interface DeleteIntegrationActionButtonProps {
|
||||||
count: number;
|
count: number;
|
||||||
integration: { id: string; name: string };
|
integration: { id: string; name: string };
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Button, Fieldset, Group, Stack, TextInput } from "@mantine/core";
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { getAllSecretKindOptions, getDefaultSecretKinds } from "@homarr/definitions";
|
import { getAllSecretKindOptions, getDefaultSecretKinds } from "@homarr/definitions";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { convertIntegrationTestConnectionError } from "@homarr/integrations/client";
|
import { convertIntegrationTestConnectionError } from "@homarr/integrations/client";
|
||||||
@@ -15,7 +16,6 @@ import { useI18n } from "@homarr/translation/client";
|
|||||||
import type { z } from "@homarr/validation";
|
import type { z } from "@homarr/validation";
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
|
||||||
import { SecretCard } from "../../_components/secrets/integration-secret-card";
|
import { SecretCard } from "../../_components/secrets/integration-secret-card";
|
||||||
import { IntegrationSecretInput } from "../../_components/secrets/integration-secret-inputs";
|
import { IntegrationSecretInput } from "../../_components/secrets/integration-secret-inputs";
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { Container, Fieldset, Group, Stack, Title } from "@mantine/core";
|
|||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
import { getIntegrationName } from "@homarr/definitions";
|
import { getIntegrationName } from "@homarr/definitions";
|
||||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||||
|
import { IntegrationAvatar } from "@homarr/ui";
|
||||||
|
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { IntegrationAccessSettings } from "../../_components/integration-access-settings";
|
import { IntegrationAccessSettings } from "../../_components/integration-access-settings";
|
||||||
import { IntegrationAvatar } from "../../_integration-avatar";
|
|
||||||
import { EditIntegrationForm } from "./_integration-edit-form";
|
import { EditIntegrationForm } from "./_integration-edit-form";
|
||||||
|
|
||||||
interface EditIntegrationPageProps {
|
interface EditIntegrationPageProps {
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ import { IconSearch } from "@tabler/icons-react";
|
|||||||
|
|
||||||
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
|
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { IntegrationAvatar } from "@homarr/ui";
|
||||||
import { IntegrationAvatar } from "../_integration-avatar";
|
|
||||||
|
|
||||||
export const IntegrationCreateDropdownContent = () => {
|
export const IntegrationCreateDropdownContent = () => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Alert, Button, Fieldset, Group, SegmentedControl, Stack, Text, TextInpu
|
|||||||
import { IconInfoCircle } from "@tabler/icons-react";
|
import { IconInfoCircle } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
|
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
|
||||||
import { getAllSecretKindOptions } from "@homarr/definitions";
|
import { getAllSecretKindOptions } from "@homarr/definitions";
|
||||||
import type { UseFormReturnType } from "@homarr/form";
|
import type { UseFormReturnType } from "@homarr/form";
|
||||||
@@ -18,7 +19,6 @@ import type { z } from "@homarr/validation";
|
|||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
import { IntegrationSecretInput } from "../_components/secrets/integration-secret-inputs";
|
import { IntegrationSecretInput } from "../_components/secrets/integration-secret-inputs";
|
||||||
import { revalidatePathActionAsync } from "../../../../revalidatePathAction";
|
|
||||||
|
|
||||||
interface NewIntegrationFormProps {
|
interface NewIntegrationFormProps {
|
||||||
searchParams: Partial<z.infer<typeof validation.integration.create>> & {
|
searchParams: Partial<z.infer<typeof validation.integration.create>> & {
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import { Container, Group, Stack, Title } from "@mantine/core";
|
|||||||
import type { IntegrationKind } from "@homarr/definitions";
|
import type { IntegrationKind } from "@homarr/definitions";
|
||||||
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
|
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
import { IntegrationAvatar } from "@homarr/ui";
|
||||||
import type { validation } from "@homarr/validation";
|
import type { validation } from "@homarr/validation";
|
||||||
import { z } from "@homarr/validation";
|
import { z } from "@homarr/validation";
|
||||||
|
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { IntegrationAvatar } from "../_integration-avatar";
|
|
||||||
import { NewIntegrationForm } from "./_integration-new-form";
|
import { NewIntegrationForm } from "./_integration-new-form";
|
||||||
|
|
||||||
interface NewIntegrationPageProps {
|
interface NewIntegrationPageProps {
|
||||||
|
|||||||
@@ -34,12 +34,11 @@ import { objectEntries } from "@homarr/common";
|
|||||||
import type { IntegrationKind } from "@homarr/definitions";
|
import type { IntegrationKind } from "@homarr/definitions";
|
||||||
import { getIntegrationName } from "@homarr/definitions";
|
import { getIntegrationName } from "@homarr/definitions";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
import { CountBadge } from "@homarr/ui";
|
import { CountBadge, IntegrationAvatar } from "@homarr/ui";
|
||||||
|
|
||||||
import { ManageContainer } from "~/components/manage/manage-container";
|
import { ManageContainer } from "~/components/manage/manage-container";
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
|
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
|
||||||
import { IntegrationAvatar } from "./_integration-avatar";
|
|
||||||
import { DeleteIntegrationActionButton } from "./_integration-buttons";
|
import { DeleteIntegrationActionButton } from "./_integration-buttons";
|
||||||
import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown";
|
import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown";
|
||||||
|
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
{
|
{
|
||||||
label: t("items.help.items.documentation"),
|
label: t("items.help.items.documentation"),
|
||||||
icon: IconBook2,
|
icon: IconBook2,
|
||||||
href: "https://homarr.dev/docs/getting-started/prerequisites",
|
href: "https://homarr.dev/docs/getting-started/",
|
||||||
external: true,
|
external: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -123,7 +123,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
external: true,
|
external: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("items.tools.items.docker"),
|
label: t("items.help.items.discord"),
|
||||||
icon: IconBrandDiscord,
|
icon: IconBrandDiscord,
|
||||||
href: "https://discord.com/invite/aCsmEV5RgA",
|
href: "https://discord.com/invite/aCsmEV5RgA",
|
||||||
external: true,
|
external: true,
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import React from "react";
|
|||||||
import { Card, LoadingOverlay, Stack, Title } from "@mantine/core";
|
import { Card, LoadingOverlay, Stack, Title } from "@mantine/core";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useForm } from "@homarr/form";
|
import { useForm } from "@homarr/form";
|
||||||
import type { defaultServerSettings } from "@homarr/server-settings";
|
import type { defaultServerSettings } from "@homarr/server-settings";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { SwitchSetting } from "~/app/[locale]/manage/settings/_components/setting-switch";
|
import { SwitchSetting } from "~/app/[locale]/manage/settings/_components/setting-switch";
|
||||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
|
||||||
|
|
||||||
interface AnalyticsSettingsProps {
|
interface AnalyticsSettingsProps {
|
||||||
initialData: typeof defaultServerSettings.analytics;
|
initialData: typeof defaultServerSettings.analytics;
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import React from "react";
|
|||||||
import { Card, LoadingOverlay, Stack, Text, Title } from "@mantine/core";
|
import { Card, LoadingOverlay, Stack, Text, Title } from "@mantine/core";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useForm } from "@homarr/form";
|
import { useForm } from "@homarr/form";
|
||||||
import type { defaultServerSettings } from "@homarr/server-settings";
|
import type { defaultServerSettings } from "@homarr/server-settings";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { SwitchSetting } from "~/app/[locale]/manage/settings/_components/setting-switch";
|
import { SwitchSetting } from "~/app/[locale]/manage/settings/_components/setting-switch";
|
||||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
|
||||||
|
|
||||||
interface CrawlingAndIndexingSettingsProps {
|
interface CrawlingAndIndexingSettingsProps {
|
||||||
initialData: typeof defaultServerSettings.crawlingAndIndexing;
|
initialData: typeof defaultServerSettings.crawlingAndIndexing;
|
||||||
|
|||||||
@@ -4,14 +4,13 @@ import { Button, Group, Select, Stack } from "@mantine/core";
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import type { z } from "@homarr/validation";
|
import type { z } from "@homarr/validation";
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
|
||||||
|
|
||||||
interface ChangeHomeBoardFormProps {
|
interface ChangeHomeBoardFormProps {
|
||||||
user: RouterOutputs["user"]["getById"];
|
user: RouterOutputs["user"]["getById"];
|
||||||
boardsData: { value: string; label: string }[];
|
boardsData: { value: string; label: string }[];
|
||||||
|
|||||||
@@ -6,11 +6,10 @@ import { Button } from "@mantine/core";
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useConfirmModal } from "@homarr/modals";
|
import { useConfirmModal } from "@homarr/modals";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
|
||||||
|
|
||||||
interface DeleteUserButtonProps {
|
interface DeleteUserButtonProps {
|
||||||
user: RouterOutputs["user"]["getById"];
|
user: RouterOutputs["user"]["getById"];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,12 @@ import { IconPencil, IconPhotoEdit, IconPhotoX } from "@tabler/icons-react";
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useConfirmModal } from "@homarr/modals";
|
import { useConfirmModal } from "@homarr/modals";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
import { UserAvatar } from "@homarr/ui";
|
import { UserAvatar } from "@homarr/ui";
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
|
||||||
|
|
||||||
interface UserProfileAvatarForm {
|
interface UserProfileAvatarForm {
|
||||||
user: RouterOutputs["user"]["getById"];
|
user: RouterOutputs["user"]["getById"];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,12 @@ import { Button, Group, Stack, TextInput } from "@mantine/core";
|
|||||||
|
|
||||||
import type { RouterInputs, RouterOutputs } from "@homarr/api";
|
import type { RouterInputs, RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
|
||||||
|
|
||||||
interface UserProfileFormProps {
|
interface UserProfileFormProps {
|
||||||
user: RouterOutputs["user"]["getById"];
|
user: RouterOutputs["user"]["getById"];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,14 +5,13 @@ import { Button, Fieldset, Group, PasswordInput, Stack } from "@mantine/core";
|
|||||||
import type { RouterInputs, RouterOutputs } from "@homarr/api";
|
import type { RouterInputs, RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useSession } from "@homarr/auth/client";
|
import { useSession } from "@homarr/auth/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { CustomPasswordInput } from "@homarr/ui";
|
import { CustomPasswordInput } from "@homarr/ui";
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
|
||||||
|
|
||||||
interface ChangePasswordFormProps {
|
interface ChangePasswordFormProps {
|
||||||
user: RouterOutputs["user"]["getById"];
|
user: RouterOutputs["user"]["getById"];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,11 @@ import { useRouter } from "next/navigation";
|
|||||||
import { Button } from "@mantine/core";
|
import { Button } from "@mantine/core";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useConfirmModal } from "@homarr/modals";
|
import { useConfirmModal } from "@homarr/modals";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
|
||||||
|
|
||||||
interface DeleteGroupProps {
|
interface DeleteGroupProps {
|
||||||
group: {
|
group: {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -4,13 +4,12 @@ import { useCallback } from "react";
|
|||||||
import { Button, Group, Stack, TextInput } from "@mantine/core";
|
import { Button, Group, Stack, TextInput } from "@mantine/core";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
|
||||||
|
|
||||||
interface RenameGroupFormProps {
|
interface RenameGroupFormProps {
|
||||||
group: {
|
group: {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useModalAction } from "@homarr/modals";
|
import { useModalAction } from "@homarr/modals";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
|
||||||
import { UserSelectModal } from "~/components/access/user-select-modal";
|
import { UserSelectModal } from "~/components/access/user-select-modal";
|
||||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,10 @@ import { useCallback } from "react";
|
|||||||
import { Button } from "@mantine/core";
|
import { Button } from "@mantine/core";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useConfirmModal } from "@homarr/modals";
|
import { useConfirmModal } from "@homarr/modals";
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
|
||||||
|
|
||||||
interface RemoveGroupMemberProps {
|
interface RemoveGroupMemberProps {
|
||||||
groupId: string;
|
groupId: string;
|
||||||
user: { id: string; name: string | null };
|
user: { id: string; name: string | null };
|
||||||
|
|||||||
@@ -1,16 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { Button, Group, Stack, TextInput } from "@mantine/core";
|
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { useModalAction } from "@homarr/modals";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { AddGroupModal } from "@homarr/modals-collection";
|
||||||
import { createModal, useModalAction } from "@homarr/modals";
|
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { validation } from "@homarr/validation";
|
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
|
||||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||||
|
|
||||||
export const AddGroup = () => {
|
export const AddGroup = () => {
|
||||||
@@ -27,50 +22,3 @@ export const AddGroup = () => {
|
|||||||
</MobileAffixButton>
|
</MobileAffixButton>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const AddGroupModal = createModal<void>(({ actions }) => {
|
|
||||||
const t = useI18n();
|
|
||||||
const { mutate, isPending } = clientApi.group.createGroup.useMutation();
|
|
||||||
const form = useZodForm(validation.group.create, {
|
|
||||||
initialValues: {
|
|
||||||
name: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
onSubmit={form.onSubmit((values) => {
|
|
||||||
mutate(values, {
|
|
||||||
onSuccess() {
|
|
||||||
actions.closeModal();
|
|
||||||
void revalidatePathActionAsync("/manage/users/groups");
|
|
||||||
showSuccessNotification({
|
|
||||||
title: t("common.notification.create.success"),
|
|
||||||
message: t("group.action.create.notification.success.message"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError() {
|
|
||||||
showErrorNotification({
|
|
||||||
title: t("common.notification.create.error"),
|
|
||||||
message: t("group.action.create.notification.error.message"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Stack>
|
|
||||||
<TextInput label={t("group.field.name")} data-autofocus {...form.getInputProps("name")} />
|
|
||||||
<Group justify="right">
|
|
||||||
<Button onClick={actions.closeModal} variant="subtle" color="gray">
|
|
||||||
{t("common.action.cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button loading={isPending} type="submit" color="teal">
|
|
||||||
{t("common.action.create")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}).withOptions({
|
|
||||||
defaultTitle: (t) => t("group.action.create.label"),
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -11,11 +11,10 @@ import { MantineReactTable } from "mantine-react-table";
|
|||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||||
|
import { InviteCreateModal } from "@homarr/modals-collection";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||||
|
|
||||||
import { InviteCreateModal } from "./invite-create-modal";
|
|
||||||
|
|
||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
interface InviteListComponentProps {
|
interface InviteListComponentProps {
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export const BoardItemMenu = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{tItem("action.moveResize")}
|
{tItem("action.moveResize")}
|
||||||
</Menu.Item>{" "}
|
</Menu.Item>
|
||||||
<Menu.Item leftSection={<IconCopy size={16} />} onClick={() => duplicateItem({ itemId: item.id })}>
|
<Menu.Item leftSection={<IconCopy size={16} />} onClick={() => duplicateItem({ itemId: item.id })}>
|
||||||
{tItem("action.duplicate")}
|
{tItem("action.duplicate")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useState } from "react";
|
|||||||
import { Combobox, Group, Image, InputBase, Skeleton, Text, useCombobox } from "@mantine/core";
|
import { Combobox, Group, Image, InputBase, Skeleton, Text, useCombobox } from "@mantine/core";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
interface IconPickerProps {
|
interface IconPickerProps {
|
||||||
initialValue?: string;
|
initialValue?: string;
|
||||||
@@ -18,7 +18,8 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
|
|||||||
const [search, setSearch] = useState(initialValue ?? "");
|
const [search, setSearch] = useState(initialValue ?? "");
|
||||||
const [previewUrl, setPreviewUrl] = useState<string | null>(initialValue ?? null);
|
const [previewUrl, setPreviewUrl] = useState<string | null>(initialValue ?? null);
|
||||||
|
|
||||||
const t = useScopedI18n("common");
|
const t = useI18n();
|
||||||
|
const tCommon = useScopedI18n("common");
|
||||||
|
|
||||||
const { data, isFetching } = clientApi.icon.findIcons.useQuery({
|
const { data, isFetching } = clientApi.icon.findIcons.useQuery({
|
||||||
searchText: search,
|
searchText: search,
|
||||||
@@ -89,13 +90,13 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
|
|||||||
rightSectionPointerEvents="none"
|
rightSectionPointerEvents="none"
|
||||||
withAsterisk
|
withAsterisk
|
||||||
error={error}
|
error={error}
|
||||||
label={t("iconPicker.label")}
|
label={tCommon("iconPicker.label")}
|
||||||
/>
|
/>
|
||||||
</Combobox.Target>
|
</Combobox.Target>
|
||||||
|
|
||||||
<Combobox.Dropdown>
|
<Combobox.Dropdown>
|
||||||
<Combobox.Header>
|
<Combobox.Header>
|
||||||
<Text c="dimmed">{t("iconPicker.header", { countIcons: data?.countIcons })}</Text>
|
<Text c="dimmed">{tCommon("iconPicker.header", { countIcons: data?.countIcons })}</Text>
|
||||||
</Combobox.Header>
|
</Combobox.Header>
|
||||||
<Combobox.Options mah={350} style={{ overflowY: "auto" }}>
|
<Combobox.Options mah={350} style={{ overflowY: "auto" }}>
|
||||||
{totalOptions > 0 ? (
|
{totalOptions > 0 ? (
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import { TextInput, UnstyledButton } from "@mantine/core";
|
|||||||
import { IconSearch } from "@tabler/icons-react";
|
import { IconSearch } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { openSpotlight } from "@homarr/spotlight";
|
import { openSpotlight } from "@homarr/spotlight";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { HeaderButton } from "./button";
|
import { HeaderButton } from "./button";
|
||||||
import classes from "./search.module.css";
|
import classes from "./search.module.css";
|
||||||
|
|
||||||
export const DesktopSearchInput = () => {
|
export const DesktopSearchInput = () => {
|
||||||
const t = useScopedI18n("common.search");
|
const t = useI18n();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -21,7 +21,10 @@ export const DesktopSearchInput = () => {
|
|||||||
leftSection={<IconSearch size={20} stroke={1.5} />}
|
leftSection={<IconSearch size={20} stroke={1.5} />}
|
||||||
onClick={openSpotlight}
|
onClick={openSpotlight}
|
||||||
>
|
>
|
||||||
{t("placeholder")}
|
{t("common.rtl", {
|
||||||
|
value: t("search.placeholder"),
|
||||||
|
symbol: "...",
|
||||||
|
})}
|
||||||
</TextInput>
|
</TextInput>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
import { Button, Group, InputWrapper, Slider, Stack, Switch, TextInput } from "@mantine/core";
|
|
||||||
|
|
||||||
import { useZodForm } from "@homarr/form";
|
|
||||||
import { createModal } from "@homarr/modals";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
|
||||||
import { validation } from "@homarr/validation";
|
|
||||||
import { createCustomErrorParams } from "@homarr/validation/form";
|
|
||||||
|
|
||||||
interface InnerProps {
|
|
||||||
boardNames: string[];
|
|
||||||
onSuccess: (props: { name: string; columnCount: number; isPublic: boolean }) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AddBoardModal = createModal<InnerProps>(({ actions, innerProps }) => {
|
|
||||||
const t = useI18n();
|
|
||||||
const form = useZodForm(
|
|
||||||
validation.board.create.refine((value) => !innerProps.boardNames.includes(value.name), {
|
|
||||||
params: createCustomErrorParams("boardAlreadyExists"),
|
|
||||||
path: ["name"],
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
initialValues: {
|
|
||||||
name: "",
|
|
||||||
columnCount: 10,
|
|
||||||
isPublic: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const columnCountChecks = validation.board.create.shape.columnCount._def.checks;
|
|
||||||
const minColumnCount = columnCountChecks.find((check) => check.kind === "min")?.value;
|
|
||||||
const maxColumnCount = columnCountChecks.find((check) => check.kind === "max")?.value;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
onSubmit={form.onSubmit((values) => {
|
|
||||||
void innerProps.onSuccess(values);
|
|
||||||
actions.closeModal();
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Stack>
|
|
||||||
<TextInput label={t("board.field.name.label")} data-autofocus {...form.getInputProps("name")} />
|
|
||||||
<InputWrapper label={t("board.field.columnCount.label")} {...form.getInputProps("columnCount")}>
|
|
||||||
<Slider min={minColumnCount} max={maxColumnCount} step={1} {...form.getInputProps("columnCount")} />
|
|
||||||
</InputWrapper>
|
|
||||||
|
|
||||||
<Switch
|
|
||||||
label={t("board.field.isPublic.label")}
|
|
||||||
description={t("board.field.isPublic.description")}
|
|
||||||
{...form.getInputProps("isPublic")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Group justify="right">
|
|
||||||
<Button onClick={actions.closeModal} variant="subtle" color="gray">
|
|
||||||
{t("common.action.cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" color="teal">
|
|
||||||
{t("common.action.create")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}).withOptions({
|
|
||||||
defaultTitle: (t) => t("management.page.board.action.new.label"),
|
|
||||||
});
|
|
||||||
@@ -23,7 +23,14 @@ export const env = createEnv({
|
|||||||
// If the DB_HOST is set, the DB_URL is optional
|
// If the DB_HOST is set, the DB_URL is optional
|
||||||
DB_URL: isUsingDbHost ? z.string().optional() : z.string(),
|
DB_URL: isUsingDbHost ? z.string().optional() : z.string(),
|
||||||
DB_HOST: isUsingDbUrl ? z.string().optional() : z.string(),
|
DB_HOST: isUsingDbUrl ? z.string().optional() : z.string(),
|
||||||
DB_PORT: isUsingDbUrl ? z.number().optional() : z.number().min(1).default(3306),
|
DB_PORT: isUsingDbUrl
|
||||||
|
? z.string().regex(/\d+/).transform(Number).optional()
|
||||||
|
: z
|
||||||
|
.string()
|
||||||
|
.regex(/\d+/)
|
||||||
|
.transform(Number)
|
||||||
|
.refine((number) => number >= 1)
|
||||||
|
.default("3306"),
|
||||||
DB_USER: isUsingDbCredentials ? z.string() : z.string().optional(),
|
DB_USER: isUsingDbCredentials ? z.string() : z.string().optional(),
|
||||||
DB_PASSWORD: isUsingDbCredentials ? z.string() : z.string().optional(),
|
DB_PASSWORD: isUsingDbCredentials ? z.string() : z.string().optional(),
|
||||||
DB_NAME: isUsingDbUrl ? z.string().optional() : z.string(),
|
DB_NAME: isUsingDbUrl ? z.string().optional() : z.string(),
|
||||||
|
|||||||
27
nginx.conf
Normal file
27
nginx.conf
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
server {
|
||||||
|
listen 7575;
|
||||||
|
|
||||||
|
# Route websockets traffic to port 3001
|
||||||
|
location /websockets {
|
||||||
|
proxy_pass http://${HOSTNAME}:3001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "Upgrade";
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Route all other traffic to port 3000
|
||||||
|
location / {
|
||||||
|
proxy_pass http://${HOSTNAME}:3000;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
"vite-tsconfig-paths": "^5.0.1",
|
"vite-tsconfig-paths": "^5.0.1",
|
||||||
"vitest": "^2.1.1"
|
"vitest": "^2.1.1"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@9.10.0",
|
"packageManager": "pnpm@9.11.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.17.0"
|
"node": ">=20.17.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
"@trpc/react-query": "next",
|
"@trpc/react-query": "next",
|
||||||
"@trpc/server": "next",
|
"@trpc/server": "next",
|
||||||
"dockerode": "^4.0.2",
|
"dockerode": "^4.0.2",
|
||||||
"next": "^14.2.11",
|
"next": "^14.2.13",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"superjson": "2.2.1",
|
"superjson": "2.2.1",
|
||||||
"trpc-swagger": "^1.2.6"
|
"trpc-swagger": "^1.2.6"
|
||||||
|
|||||||
@@ -1,5 +1,25 @@
|
|||||||
import { createTRPCReact } from "@trpc/react-query";
|
import { createTRPCClient, createTRPCReact, httpLink } from "@trpc/react-query";
|
||||||
|
import SuperJSON from "superjson";
|
||||||
|
|
||||||
import type { AppRouter } from ".";
|
import type { AppRouter } from ".";
|
||||||
|
|
||||||
export const clientApi = createTRPCReact<AppRouter>();
|
export const clientApi = createTRPCReact<AppRouter>();
|
||||||
|
export const fetchApi = createTRPCClient<AppRouter>({
|
||||||
|
links: [
|
||||||
|
httpLink({
|
||||||
|
url: `${getBaseUrl()}/api/trpc`,
|
||||||
|
transformer: SuperJSON,
|
||||||
|
headers() {
|
||||||
|
const headers = new Headers();
|
||||||
|
headers.set("x-trpc-source", "fetch");
|
||||||
|
return headers;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
function getBaseUrl() {
|
||||||
|
if (typeof window !== "undefined") return window.location.origin;
|
||||||
|
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
|
||||||
|
return `http://localhost:${process.env.PORT ?? 3000}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
import { asc, createId, eq } from "@homarr/db";
|
import { asc, createId, eq, like } from "@homarr/db";
|
||||||
import { apps } from "@homarr/db/schema/sqlite";
|
import { apps } from "@homarr/db/schema/sqlite";
|
||||||
import { validation } from "@homarr/validation";
|
import { validation, z } from "@homarr/validation";
|
||||||
|
|
||||||
import { createTRPCRouter, publicProcedure } from "../trpc";
|
import { createTRPCRouter, publicProcedure } from "../trpc";
|
||||||
|
|
||||||
@@ -22,6 +22,15 @@ export const appRouter = createTRPCRouter({
|
|||||||
orderBy: asc(apps.name),
|
orderBy: asc(apps.name),
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
search: publicProcedure
|
||||||
|
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
return await ctx.db.query.apps.findMany({
|
||||||
|
where: like(apps.name, `%${input.query}%`),
|
||||||
|
orderBy: asc(apps.name),
|
||||||
|
limit: input.limit,
|
||||||
|
});
|
||||||
|
}),
|
||||||
byId: publicProcedure.input(validation.app.byId).query(async ({ ctx, input }) => {
|
byId: publicProcedure.input(validation.app.byId).query(async ({ ctx, input }) => {
|
||||||
const app = await ctx.db.query.apps.findFirst({
|
const app = await ctx.db.query.apps.findFirst({
|
||||||
where: eq(apps.id, input.id),
|
where: eq(apps.id, input.id),
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
|
|
||||||
|
import { constructBoardPermissions } from "@homarr/auth/shared";
|
||||||
import type { Database, SQL } from "@homarr/db";
|
import type { Database, SQL } from "@homarr/db";
|
||||||
import { and, createId, eq, inArray, or } from "@homarr/db";
|
import { and, createId, eq, inArray, like, or } from "@homarr/db";
|
||||||
import {
|
import {
|
||||||
boardGroupPermissions,
|
boardGroupPermissions,
|
||||||
boards,
|
boards,
|
||||||
@@ -26,6 +27,20 @@ import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publ
|
|||||||
import { throwIfActionForbiddenAsync } from "./board/board-access";
|
import { throwIfActionForbiddenAsync } from "./board/board-access";
|
||||||
|
|
||||||
export const boardRouter = createTRPCRouter({
|
export const boardRouter = createTRPCRouter({
|
||||||
|
exists: permissionRequiredProcedure
|
||||||
|
.requiresPermission("board-create")
|
||||||
|
.input(z.string())
|
||||||
|
.query(async ({ ctx, input: name }) => {
|
||||||
|
try {
|
||||||
|
await noBoardWithSimilarNameAsync(ctx.db, name);
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof TRPCError && error.code === "CONFLICT") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}),
|
||||||
getAllBoards: publicProcedure.query(async ({ ctx }) => {
|
getAllBoards: publicProcedure.query(async ({ ctx }) => {
|
||||||
const userId = ctx.session?.user.id;
|
const userId = ctx.session?.user.id;
|
||||||
const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
|
const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
|
||||||
@@ -95,6 +110,79 @@ export const boardRouter = createTRPCRouter({
|
|||||||
isHome: currentUserWhenPresent?.homeBoardId === board.id,
|
isHome: currentUserWhenPresent?.homeBoardId === board.id,
|
||||||
}));
|
}));
|
||||||
}),
|
}),
|
||||||
|
search: publicProcedure
|
||||||
|
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const userId = ctx.session?.user.id;
|
||||||
|
const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
|
||||||
|
where: eq(boardUserPermissions.userId, userId ?? ""),
|
||||||
|
});
|
||||||
|
|
||||||
|
const permissionsOfCurrentUserGroupsWhenPresent = await ctx.db.query.groupMembers.findMany({
|
||||||
|
where: eq(groupMembers.userId, userId ?? ""),
|
||||||
|
with: {
|
||||||
|
group: {
|
||||||
|
with: {
|
||||||
|
boardPermissions: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const boardIds = permissionsOfCurrentUserWhenPresent
|
||||||
|
.map((permission) => permission.boardId)
|
||||||
|
.concat(
|
||||||
|
permissionsOfCurrentUserGroupsWhenPresent
|
||||||
|
.map((groupMember) => groupMember.group.boardPermissions.map((permission) => permission.boardId))
|
||||||
|
.flat(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentUserWhenPresent = await ctx.db.query.users.findFirst({
|
||||||
|
where: eq(users.id, userId ?? ""),
|
||||||
|
});
|
||||||
|
|
||||||
|
const foundBoards = await ctx.db.query.boards.findMany({
|
||||||
|
where: and(
|
||||||
|
like(boards.name, `%${input.query}%`),
|
||||||
|
ctx.session?.user.permissions.includes("board-view-all")
|
||||||
|
? undefined
|
||||||
|
: or(
|
||||||
|
eq(boards.isPublic, true),
|
||||||
|
eq(boards.creatorId, ctx.session?.user.id ?? ""),
|
||||||
|
inArray(boards.id, boardIds),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
limit: input.limit,
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
creatorId: true,
|
||||||
|
isPublic: true,
|
||||||
|
logoImageUrl: true,
|
||||||
|
},
|
||||||
|
with: {
|
||||||
|
userPermissions: {
|
||||||
|
where: eq(boardUserPermissions.userId, ctx.session?.user.id ?? ""),
|
||||||
|
},
|
||||||
|
groupPermissions: {
|
||||||
|
where:
|
||||||
|
permissionsOfCurrentUserGroupsWhenPresent.length >= 1
|
||||||
|
? inArray(
|
||||||
|
boardGroupPermissions.groupId,
|
||||||
|
permissionsOfCurrentUserGroupsWhenPresent.map((groupMember) => groupMember.groupId),
|
||||||
|
)
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return foundBoards.map((board) => ({
|
||||||
|
id: board.id,
|
||||||
|
name: board.name,
|
||||||
|
logoImageUrl: board.logoImageUrl,
|
||||||
|
permissions: constructBoardPermissions(board, ctx.session),
|
||||||
|
isHome: currentUserWhenPresent?.homeBoardId === board.id,
|
||||||
|
}));
|
||||||
|
}),
|
||||||
createBoard: permissionRequiredProcedure
|
createBoard: permissionRequiredProcedure
|
||||||
.requiresPermission("board-create")
|
.requiresPermission("board-create")
|
||||||
.input(validation.board.create)
|
.input(validation.board.create)
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { TRPCError } from "@trpc/server";
|
|||||||
import type { Database } from "@homarr/db";
|
import type { Database } from "@homarr/db";
|
||||||
import { and, createId, eq, like, not, sql } from "@homarr/db";
|
import { and, createId, eq, like, not, sql } from "@homarr/db";
|
||||||
import { groupMembers, groupPermissions, groups } from "@homarr/db/schema/sqlite";
|
import { groupMembers, groupPermissions, groups } from "@homarr/db/schema/sqlite";
|
||||||
import { validation } from "@homarr/validation";
|
import { validation, z } from "@homarr/validation";
|
||||||
|
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||||
|
|
||||||
export const groupRouter = createTRPCRouter({
|
export const groupRouter = createTRPCRouter({
|
||||||
getPaginated: protectedProcedure.input(validation.group.paginated).query(async ({ input, ctx }) => {
|
getPaginated: protectedProcedure.input(validation.group.paginated).query(async ({ input, ctx }) => {
|
||||||
@@ -91,6 +91,23 @@ export const groupRouter = createTRPCRouter({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
search: publicProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
query: z.string(),
|
||||||
|
limit: z.number().min(1).max(100).default(10),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
return await ctx.db.query.groups.findMany({
|
||||||
|
where: like(groups.name, `%${input.query}%`),
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
limit: input.limit,
|
||||||
|
});
|
||||||
|
}),
|
||||||
createGroup: protectedProcedure.input(validation.group.create).mutation(async ({ input, ctx }) => {
|
createGroup: protectedProcedure.input(validation.group.create).mutation(async ({ input, ctx }) => {
|
||||||
const normalizedName = normalizeName(input.name);
|
const normalizedName = normalizeName(input.name);
|
||||||
await checkSimilarNameAndThrowAsync(ctx.db, normalizedName);
|
await checkSimilarNameAndThrowAsync(ctx.db, normalizedName);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { TRPCError } from "@trpc/server";
|
|||||||
|
|
||||||
import { decryptSecret, encryptSecret } from "@homarr/common/server";
|
import { decryptSecret, encryptSecret } from "@homarr/common/server";
|
||||||
import type { Database } from "@homarr/db";
|
import type { Database } from "@homarr/db";
|
||||||
import { and, createId, eq, inArray } from "@homarr/db";
|
import { and, asc, createId, eq, inArray, like } from "@homarr/db";
|
||||||
import {
|
import {
|
||||||
groupPermissions,
|
groupPermissions,
|
||||||
integrationGroupPermissions,
|
integrationGroupPermissions,
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
} from "@homarr/db/schema/sqlite";
|
} from "@homarr/db/schema/sqlite";
|
||||||
import type { IntegrationSecretKind } from "@homarr/definitions";
|
import type { IntegrationSecretKind } from "@homarr/definitions";
|
||||||
import { getPermissionsWithParents, integrationKinds, integrationSecretKindObject } from "@homarr/definitions";
|
import { getPermissionsWithParents, integrationKinds, integrationSecretKindObject } from "@homarr/definitions";
|
||||||
import { validation } from "@homarr/validation";
|
import { validation, z } from "@homarr/validation";
|
||||||
|
|
||||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../../trpc";
|
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../../trpc";
|
||||||
import { throwIfActionForbiddenAsync } from "./integration-access";
|
import { throwIfActionForbiddenAsync } from "./integration-access";
|
||||||
@@ -33,6 +33,15 @@ export const integrationRouter = createTRPCRouter({
|
|||||||
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
|
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
search: protectedProcedure
|
||||||
|
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
return await ctx.db.query.integrations.findMany({
|
||||||
|
where: like(integrations.name, `%${input.query}%`),
|
||||||
|
orderBy: asc(integrations.name),
|
||||||
|
limit: input.limit,
|
||||||
|
});
|
||||||
|
}),
|
||||||
byId: protectedProcedure.input(validation.integration.byId).query(async ({ ctx, input }) => {
|
byId: protectedProcedure.input(validation.integration.byId).query(async ({ ctx, input }) => {
|
||||||
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
|
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
|
||||||
const integration = await ctx.db.query.integrations.findFirst({
|
const integration = await ctx.db.query.integrations.findFirst({
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { TRPCError } from "@trpc/server";
|
|||||||
|
|
||||||
import { createSaltAsync, hashPasswordAsync } from "@homarr/auth";
|
import { createSaltAsync, hashPasswordAsync } from "@homarr/auth";
|
||||||
import type { Database } from "@homarr/db";
|
import type { Database } from "@homarr/db";
|
||||||
import { and, createId, eq, schema } from "@homarr/db";
|
import { and, createId, eq, like, schema } from "@homarr/db";
|
||||||
import { groupMembers, groupPermissions, groups, invites, users } from "@homarr/db/schema/sqlite";
|
import { groupMembers, groupPermissions, groups, invites, users } from "@homarr/db/schema/sqlite";
|
||||||
import type { SupportedAuthProvider } from "@homarr/definitions";
|
import type { SupportedAuthProvider } from "@homarr/definitions";
|
||||||
import { logger } from "@homarr/log";
|
import { logger } from "@homarr/log";
|
||||||
@@ -164,6 +164,29 @@ export const userRouter = createTRPCRouter({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
search: publicProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
query: z.string(),
|
||||||
|
limit: z.number().min(1).max(100).default(10),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
const dbUsers = await ctx.db.query.users.findMany({
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
image: true,
|
||||||
|
},
|
||||||
|
where: like(users.name, `%${input.query}%`),
|
||||||
|
limit: input.limit,
|
||||||
|
});
|
||||||
|
return dbUsers.map((user) => ({
|
||||||
|
id: user.id,
|
||||||
|
name: user.name ?? "",
|
||||||
|
image: user.image,
|
||||||
|
}));
|
||||||
|
}),
|
||||||
getById: publicProcedure.input(z.object({ userId: z.string() })).query(async ({ input, ctx }) => {
|
getById: publicProcedure.input(z.object({ userId: z.string() })).query(async ({ input, ctx }) => {
|
||||||
const user = await ctx.db.query.users.findFirst({
|
const user = await ctx.db.query.users.findFirst({
|
||||||
columns: {
|
columns: {
|
||||||
|
|||||||
@@ -23,8 +23,8 @@
|
|||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config",
|
"prettier": "@homarr/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/core": "^0.34.2",
|
"@auth/core": "^0.35.0",
|
||||||
"@auth/drizzle-adapter": "^1.4.2",
|
"@auth/drizzle-adapter": "^1.5.0",
|
||||||
"@homarr/common": "workspace:^0.1.0",
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
"@homarr/db": "workspace:^0.1.0",
|
"@homarr/db": "workspace:^0.1.0",
|
||||||
"@homarr/definitions": "workspace:^0.1.0",
|
"@homarr/definitions": "workspace:^0.1.0",
|
||||||
@@ -34,8 +34,8 @@
|
|||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"cookies": "^0.9.1",
|
"cookies": "^0.9.1",
|
||||||
"ldapts": "7.2.0",
|
"ldapts": "7.2.0",
|
||||||
"next": "^14.2.11",
|
"next": "^14.2.13",
|
||||||
"next-auth": "5.0.0-beta.20",
|
"next-auth": "5.0.0-beta.21",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -25,17 +25,18 @@ export const constructBoardPermissions = (board: BoardPermissionsProps, session:
|
|||||||
const creatorId = "creator" in board ? board.creator?.id : board.creatorId;
|
const creatorId = "creator" in board ? board.creator?.id : board.creatorId;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hasFullAccess: session?.user.id === creatorId || session?.user.permissions.includes("board-full-all"),
|
hasFullAccess: session?.user.id === creatorId || (session?.user.permissions.includes("board-full-all") ?? false),
|
||||||
hasChangeAccess:
|
hasChangeAccess:
|
||||||
session?.user.id === creatorId ||
|
session?.user.id === creatorId ||
|
||||||
board.userPermissions.some(({ permission }) => permission === "modify") ||
|
board.userPermissions.some(({ permission }) => permission === "modify") ||
|
||||||
board.groupPermissions.some(({ permission }) => permission === "modify") ||
|
board.groupPermissions.some(({ permission }) => permission === "modify") ||
|
||||||
session?.user.permissions.includes("board-modify-all"),
|
(session?.user.permissions.includes("board-modify-all") ?? false) ||
|
||||||
|
(session?.user.permissions.includes("board-full-all") ?? false),
|
||||||
hasViewAccess:
|
hasViewAccess:
|
||||||
session?.user.id === creatorId ||
|
session?.user.id === creatorId ||
|
||||||
board.userPermissions.length >= 1 ||
|
board.userPermissions.length >= 1 ||
|
||||||
board.groupPermissions.length >= 1 ||
|
board.groupPermissions.length >= 1 ||
|
||||||
board.isPublic ||
|
board.isPublic ||
|
||||||
session?.user.permissions.includes("board-view-all"),
|
(session?.user.permissions.includes("board-view-all") ?? false),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Session } from "@auth/core/types";
|
import type { Session } from "next-auth";
|
||||||
|
|
||||||
import { db, eq, inArray } from "@homarr/db";
|
import { db, eq, inArray } from "@homarr/db";
|
||||||
import { groupMembers, integrationGroupPermissions, integrationUserPermissions } from "@homarr/db/schema/sqlite";
|
import { groupMembers, integrationGroupPermissions, integrationUserPermissions } from "@homarr/db/schema/sqlite";
|
||||||
|
|||||||
@@ -26,9 +26,9 @@
|
|||||||
"prettier": "@homarr/prettier-config",
|
"prettier": "@homarr/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"next": "^14.2.11",
|
"next": "^14.2.13",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"tldts": "^6.1.44"
|
"tldts": "^6.1.47"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export * from "./app-url/client";
|
export * from "./app-url/client";
|
||||||
|
export * from "./revalidate-path-action";
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
CREATE TABLE `integrationGroupPermissions` (
|
CREATE TABLE `integrationGroupPermissions` (
|
||||||
`integration_id` varchar(64) NOT NULL,
|
`integration_id` varchar(64) NOT NULL,
|
||||||
`group_id` varchar(64) NOT NULL,
|
`group_id` varchar(64) NOT NULL,
|
||||||
`permission` text NOT NULL,
|
`permission` varchar(128) NOT NULL,
|
||||||
CONSTRAINT `integrationGroupPermissions_integration_id_group_id_permission_pk` PRIMARY KEY(`integration_id`,`group_id`,`permission`)
|
CONSTRAINT `integration_group_permission__pk` PRIMARY KEY(`integration_id`,`group_id`,`permission`)
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
CREATE TABLE `integrationUserPermission` (
|
CREATE TABLE `integrationUserPermission` (
|
||||||
`integration_id` varchar(64) NOT NULL,
|
`integration_id` varchar(64) NOT NULL,
|
||||||
`user_id` varchar(64) NOT NULL,
|
`user_id` varchar(64) NOT NULL,
|
||||||
`permission` text NOT NULL,
|
`permission` varchar(128) NOT NULL,
|
||||||
CONSTRAINT `integrationUserPermission_integration_id_user_id_permission_pk` PRIMARY KEY(`integration_id`,`user_id`,`permission`)
|
CONSTRAINT `integrationUserPermission_integration_id_user_id_permission_pk` PRIMARY KEY(`integration_id`,`user_id`,`permission`)
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
|
|||||||
@@ -2,5 +2,5 @@ ALTER TABLE `section` RENAME COLUMN `position` TO `y_offset`;--> statement-break
|
|||||||
ALTER TABLE `section` ADD `x_offset` int NOT NULL;--> statement-breakpoint
|
ALTER TABLE `section` ADD `x_offset` int NOT NULL;--> statement-breakpoint
|
||||||
ALTER TABLE `section` ADD `width` int;--> statement-breakpoint
|
ALTER TABLE `section` ADD `width` int;--> statement-breakpoint
|
||||||
ALTER TABLE `section` ADD `height` int;--> statement-breakpoint
|
ALTER TABLE `section` ADD `height` int;--> statement-breakpoint
|
||||||
ALTER TABLE `section` ADD `parent_section_id` text;--> statement-breakpoint
|
ALTER TABLE `section` ADD `parent_section_id` varchar(64);--> statement-breakpoint
|
||||||
ALTER TABLE `section` ADD CONSTRAINT `section_parent_section_id_section_id_fk` FOREIGN KEY (`parent_section_id`) REFERENCES `section`(`id`) ON DELETE cascade ON UPDATE no action;
|
ALTER TABLE `section` ADD CONSTRAINT `section_parent_section_id_section_id_fk` FOREIGN KEY (`parent_section_id`) REFERENCES `section`(`id`) ON DELETE cascade ON UPDATE no action;
|
||||||
@@ -655,7 +655,7 @@
|
|||||||
},
|
},
|
||||||
"permission": {
|
"permission": {
|
||||||
"name": "permission",
|
"name": "permission",
|
||||||
"type": "text",
|
"type": "varchar(128)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
@@ -683,8 +683,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"compositePrimaryKeys": {
|
"compositePrimaryKeys": {
|
||||||
"integrationGroupPermissions_integration_id_group_id_permission_pk": {
|
"integration_group_permission__pk": {
|
||||||
"name": "integrationGroupPermissions_integration_id_group_id_permission_pk",
|
"name": "integration_group_permission__pk",
|
||||||
"columns": ["integration_id", "group_id", "permission"]
|
"columns": ["integration_id", "group_id", "permission"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -819,7 +819,7 @@
|
|||||||
},
|
},
|
||||||
"permission": {
|
"permission": {
|
||||||
"name": "permission",
|
"name": "permission",
|
||||||
"type": "text",
|
"type": "varchar(128)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
|
|||||||
@@ -655,7 +655,7 @@
|
|||||||
},
|
},
|
||||||
"permission": {
|
"permission": {
|
||||||
"name": "permission",
|
"name": "permission",
|
||||||
"type": "text",
|
"type": "varchar(128)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
@@ -683,8 +683,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"compositePrimaryKeys": {
|
"compositePrimaryKeys": {
|
||||||
"integrationGroupPermissions_integration_id_group_id_permission_pk": {
|
"integration_group_permission__pk": {
|
||||||
"name": "integrationGroupPermissions_integration_id_group_id_permission_pk",
|
"name": "integration_group_permission__pk",
|
||||||
"columns": ["integration_id", "group_id", "permission"]
|
"columns": ["integration_id", "group_id", "permission"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -819,7 +819,7 @@
|
|||||||
},
|
},
|
||||||
"permission": {
|
"permission": {
|
||||||
"name": "permission",
|
"name": "permission",
|
||||||
"type": "text",
|
"type": "varchar(128)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
|
|||||||
@@ -655,7 +655,7 @@
|
|||||||
},
|
},
|
||||||
"permission": {
|
"permission": {
|
||||||
"name": "permission",
|
"name": "permission",
|
||||||
"type": "text",
|
"type": "varchar(128)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
@@ -683,8 +683,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"compositePrimaryKeys": {
|
"compositePrimaryKeys": {
|
||||||
"integrationGroupPermissions_integration_id_group_id_permission_pk": {
|
"integration_group_permission__pk": {
|
||||||
"name": "integrationGroupPermissions_integration_id_group_id_permission_pk",
|
"name": "integration_group_permission__pk",
|
||||||
"columns": ["integration_id", "group_id", "permission"]
|
"columns": ["integration_id", "group_id", "permission"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -819,7 +819,7 @@
|
|||||||
},
|
},
|
||||||
"permission": {
|
"permission": {
|
||||||
"name": "permission",
|
"name": "permission",
|
||||||
"type": "text",
|
"type": "varchar(128)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
@@ -1109,7 +1109,7 @@
|
|||||||
},
|
},
|
||||||
"parent_section_id": {
|
"parent_section_id": {
|
||||||
"name": "parent_section_id",
|
"name": "parent_section_id",
|
||||||
"type": "text",
|
"type": "varchar(64)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
|
|||||||
@@ -655,7 +655,7 @@
|
|||||||
},
|
},
|
||||||
"permission": {
|
"permission": {
|
||||||
"name": "permission",
|
"name": "permission",
|
||||||
"type": "text",
|
"type": "varchar(128)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
@@ -683,8 +683,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"compositePrimaryKeys": {
|
"compositePrimaryKeys": {
|
||||||
"integrationGroupPermissions_integration_id_group_id_permission_pk": {
|
"integration_group_permission__pk": {
|
||||||
"name": "integrationGroupPermissions_integration_id_group_id_permission_pk",
|
"name": "integration_group_permission__pk",
|
||||||
"columns": ["integration_id", "group_id", "permission"]
|
"columns": ["integration_id", "group_id", "permission"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -819,7 +819,7 @@
|
|||||||
},
|
},
|
||||||
"permission": {
|
"permission": {
|
||||||
"name": "permission",
|
"name": "permission",
|
||||||
"type": "text",
|
"type": "varchar(128)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
@@ -1109,7 +1109,7 @@
|
|||||||
},
|
},
|
||||||
"parent_section_id": {
|
"parent_section_id": {
|
||||||
"name": "parent_section_id",
|
"name": "parent_section_id",
|
||||||
"type": "text",
|
"type": "varchar(64)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
|
|||||||
@@ -659,9 +659,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"compositePrimaryKeys": {
|
"compositePrimaryKeys": {
|
||||||
"integrationGroupPermissions_integration_id_group_id_permission_pk": {
|
"integration_group_permission__pk": {
|
||||||
"columns": ["group_id", "integration_id", "permission"],
|
"columns": ["group_id", "integration_id", "permission"],
|
||||||
"name": "integrationGroupPermissions_integration_id_group_id_permission_pk"
|
"name": "integration_group_permission__pk"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"uniqueConstraints": {}
|
"uniqueConstraints": {}
|
||||||
|
|||||||
@@ -659,9 +659,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"compositePrimaryKeys": {
|
"compositePrimaryKeys": {
|
||||||
"integrationGroupPermissions_integration_id_group_id_permission_pk": {
|
"integration_group_permission__pk": {
|
||||||
"columns": ["group_id", "integration_id", "permission"],
|
"columns": ["group_id", "integration_id", "permission"],
|
||||||
"name": "integrationGroupPermissions_integration_id_group_id_permission_pk"
|
"name": "integration_group_permission__pk"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"uniqueConstraints": {}
|
"uniqueConstraints": {}
|
||||||
|
|||||||
@@ -659,9 +659,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"compositePrimaryKeys": {
|
"compositePrimaryKeys": {
|
||||||
"integrationGroupPermissions_integration_id_group_id_permission_pk": {
|
"integration_group_permission__pk": {
|
||||||
"columns": ["group_id", "integration_id", "permission"],
|
"columns": ["group_id", "integration_id", "permission"],
|
||||||
"name": "integrationGroupPermissions_integration_id_group_id_permission_pk"
|
"name": "integration_group_permission__pk"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"uniqueConstraints": {}
|
"uniqueConstraints": {}
|
||||||
|
|||||||
@@ -659,9 +659,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"compositePrimaryKeys": {
|
"compositePrimaryKeys": {
|
||||||
"integrationGroupPermissions_integration_id_group_id_permission_pk": {
|
"integration_group_permission__pk": {
|
||||||
"columns": ["integration_id", "group_id", "permission"],
|
"columns": ["integration_id", "group_id", "permission"],
|
||||||
"name": "integrationGroupPermissions_integration_id_group_id_permission_pk"
|
"name": "integration_group_permission__pk"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"uniqueConstraints": {}
|
"uniqueConstraints": {}
|
||||||
|
|||||||
@@ -31,16 +31,17 @@
|
|||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config",
|
"prettier": "@homarr/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/core": "^0.34.2",
|
"@auth/core": "^0.35.0",
|
||||||
"@homarr/common": "workspace:^0.1.0",
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
"@homarr/definitions": "workspace:^0.1.0",
|
"@homarr/definitions": "workspace:^0.1.0",
|
||||||
"@homarr/log": "workspace:^0.1.0",
|
"@homarr/log": "workspace:^0.1.0",
|
||||||
"@paralleldrive/cuid2": "^2.2.2",
|
"@paralleldrive/cuid2": "^2.2.2",
|
||||||
|
"@testcontainers/mysql": "^10.13.1",
|
||||||
"better-sqlite3": "^11.3.0",
|
"better-sqlite3": "^11.3.0",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"drizzle-kit": "^0.24.2",
|
"drizzle-kit": "^0.24.2",
|
||||||
"drizzle-orm": "^0.33.0",
|
"drizzle-orm": "^0.33.0",
|
||||||
"mysql2": "3.11.2"
|
"mysql2": "3.11.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ export const integrationUserPermissions = mysqlTable(
|
|||||||
userId: varchar("user_id", { length: 64 })
|
userId: varchar("user_id", { length: 64 })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
permission: text("permission").$type<IntegrationPermission>().notNull(),
|
permission: varchar("permission", { length: 128 }).$type<IntegrationPermission>().notNull(),
|
||||||
},
|
},
|
||||||
(table) => ({
|
(table) => ({
|
||||||
compoundKey: primaryKey({
|
compoundKey: primaryKey({
|
||||||
@@ -189,11 +189,12 @@ export const integrationGroupPermissions = mysqlTable(
|
|||||||
groupId: varchar("group_id", { length: 64 })
|
groupId: varchar("group_id", { length: 64 })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => groups.id, { onDelete: "cascade" }),
|
.references(() => groups.id, { onDelete: "cascade" }),
|
||||||
permission: text("permission").$type<IntegrationPermission>().notNull(),
|
permission: varchar("permission", { length: 128 }).$type<IntegrationPermission>().notNull(),
|
||||||
},
|
},
|
||||||
(table) => ({
|
(table) => ({
|
||||||
compoundKey: primaryKey({
|
compoundKey: primaryKey({
|
||||||
columns: [table.integrationId, table.groupId, table.permission],
|
columns: [table.integrationId, table.groupId, table.permission],
|
||||||
|
name: "integration_group_permission__pk",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -276,7 +277,7 @@ export const sections = mysqlTable("section", {
|
|||||||
width: int("width"),
|
width: int("width"),
|
||||||
height: int("height"),
|
height: int("height"),
|
||||||
name: text("name"),
|
name: text("name"),
|
||||||
parentSectionId: text("parent_section_id").references((): AnyMySqlColumn => sections.id, {
|
parentSectionId: varchar("parent_section_id", { length: 64 }).references((): AnyMySqlColumn => sections.id, {
|
||||||
onDelete: "cascade",
|
onDelete: "cascade",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
38
packages/db/test/mysql-migration.spec.ts
Normal file
38
packages/db/test/mysql-migration.spec.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import path from "path";
|
||||||
|
import { MySqlContainer } from "@testcontainers/mysql";
|
||||||
|
import { drizzle } from "drizzle-orm/mysql2";
|
||||||
|
import { migrate } from "drizzle-orm/mysql2/migrator";
|
||||||
|
import mysql from "mysql2";
|
||||||
|
import { describe, test } from "vitest";
|
||||||
|
|
||||||
|
import * as mysqlSchema from "../schema/mysql";
|
||||||
|
|
||||||
|
describe("Mysql Migration", () => {
|
||||||
|
test("should add all tables and keys specified in migration files", async () => {
|
||||||
|
const mysqlContainer = await new MySqlContainer().start();
|
||||||
|
|
||||||
|
const connection = mysql.createConnection({
|
||||||
|
host: mysqlContainer.getHost(),
|
||||||
|
database: mysqlContainer.getDatabase(),
|
||||||
|
port: mysqlContainer.getPort(),
|
||||||
|
user: mysqlContainer.getUsername(),
|
||||||
|
password: mysqlContainer.getUserPassword(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const database = drizzle(connection, {
|
||||||
|
schema: mysqlSchema,
|
||||||
|
mode: "default",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run migrations and check if it works
|
||||||
|
await migrate(database, {
|
||||||
|
migrationsFolder: path.join(__dirname, "..", "migrations", "mysql"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if users table exists
|
||||||
|
await database.query.users.findMany();
|
||||||
|
|
||||||
|
connection.end();
|
||||||
|
await mysqlContainer.stop();
|
||||||
|
}, 40_000);
|
||||||
|
});
|
||||||
@@ -11,6 +11,22 @@ const repositories = [
|
|||||||
new URL("https://api.github.com/repos/walkxcode/dashboard-icons/git/trees/main?recursive=true"),
|
new URL("https://api.github.com/repos/walkxcode/dashboard-icons/git/trees/main?recursive=true"),
|
||||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/{0}",
|
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/{0}",
|
||||||
),
|
),
|
||||||
|
new GitHubIconRepository(
|
||||||
|
"selfh.st",
|
||||||
|
"selfhst/icons",
|
||||||
|
"CC0-1.0",
|
||||||
|
new URL("https://github.com/selfhst/icons"),
|
||||||
|
new URL("https://api.github.com/repos/selfhst/icons/git/trees/main?recursive=true"),
|
||||||
|
"https://cdn.jsdelivr.net/gh/selfhst/icons/{0}",
|
||||||
|
),
|
||||||
|
new GitHubIconRepository(
|
||||||
|
"SimpleIcons",
|
||||||
|
"simple-icons/simple-icons",
|
||||||
|
"CC0-1.0",
|
||||||
|
new URL("https://github.com/simple-icons/simple-icons"),
|
||||||
|
new URL("https://api.github.com/repos/simple-icons/simple-icons/git/trees/master?recursive=true"),
|
||||||
|
"https://cdn.simpleicons.org/{1}",
|
||||||
|
),
|
||||||
new JsdelivrIconRepository(
|
new JsdelivrIconRepository(
|
||||||
"Papirus",
|
"Papirus",
|
||||||
"PapirusDevelopmentTeam/papirus-icon-theme",
|
"PapirusDevelopmentTeam/papirus-icon-theme",
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { parse } from "path";
|
||||||
|
|
||||||
import { fetchWithTimeout } from "@homarr/common";
|
import { fetchWithTimeout } from "@homarr/common";
|
||||||
|
|
||||||
import type { IconRepositoryLicense } from "../types/icon-repository-license";
|
import type { IconRepositoryLicense } from "../types/icon-repository-license";
|
||||||
@@ -27,19 +29,23 @@ export class GitHubIconRepository extends IconRepository {
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
icons: listOfFiles.tree
|
icons: listOfFiles.tree
|
||||||
.filter((treeItem) =>
|
.filter(({ path }) =>
|
||||||
this.allowedImageFileTypes.some((allowedExtension) => treeItem.path.includes(allowedExtension)),
|
this.allowedImageFileTypes.some((allowedImageFileType) => parse(path).ext === allowedImageFileType),
|
||||||
)
|
)
|
||||||
.map((treeItem) => {
|
.map(({ path, size: sizeInBytes, sha: checksum }) => {
|
||||||
const fileNameWithExtension = this.getFileNameWithoutExtensionFromPath(treeItem.path);
|
const file = parse(path);
|
||||||
|
const fileNameWithExtension = file.base;
|
||||||
|
const imageUrl = new URL(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
this.repositoryBlobUrlTemplate!.replace("{0}", path).replace("{1}", file.name),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
imageUrl,
|
||||||
imageUrl: new URL(this.repositoryBlobUrlTemplate!.replace("{0}", treeItem.path)),
|
fileNameWithExtension,
|
||||||
fileNameWithExtension: fileNameWithExtension,
|
|
||||||
local: false,
|
local: false,
|
||||||
sizeInBytes: treeItem.size,
|
sizeInBytes,
|
||||||
checksum: treeItem.sha,
|
checksum,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
slug: this.slug,
|
slug: this.slug,
|
||||||
|
|||||||
@@ -29,8 +29,4 @@ export abstract class IconRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected abstract getAllIconsInternalAsync(): Promise<RepositoryIconGroup>;
|
protected abstract getAllIconsInternalAsync(): Promise<RepositoryIconGroup>;
|
||||||
|
|
||||||
protected getFileNameWithoutExtensionFromPath(path: string) {
|
|
||||||
return path.replace(/^.*[\\/]/, "");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { parse } from "path";
|
||||||
|
|
||||||
import { fetchWithTimeout } from "@homarr/common";
|
import { fetchWithTimeout } from "@homarr/common";
|
||||||
|
|
||||||
import type { IconRepositoryLicense } from "../types/icon-repository-license";
|
import type { IconRepositoryLicense } from "../types/icon-repository-license";
|
||||||
@@ -23,18 +25,19 @@ export class JsdelivrIconRepository extends IconRepository {
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
icons: listOfFiles.files
|
icons: listOfFiles.files
|
||||||
.filter((file) =>
|
.filter(({ name: path }) =>
|
||||||
this.allowedImageFileTypes.some((allowedImageFileType) => file.name.includes(allowedImageFileType)),
|
this.allowedImageFileTypes.some((allowedImageFileType) => parse(path).ext === allowedImageFileType),
|
||||||
)
|
)
|
||||||
.map((file) => {
|
.map(({ name: path, size: sizeInBytes, hash: checksum }) => {
|
||||||
const fileNameWithExtension = this.getFileNameWithoutExtensionFromPath(file.name);
|
const file = parse(path);
|
||||||
|
const fileNameWithExtension = file.base;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
imageUrl: new URL(this.repositoryBlobUrlTemplate.replace("{0}", file.name)),
|
imageUrl: new URL(this.repositoryBlobUrlTemplate.replace("{0}", path).replace("{1}", file.name)),
|
||||||
fileNameWithExtension: fileNameWithExtension,
|
fileNameWithExtension,
|
||||||
local: false,
|
local: false,
|
||||||
sizeInBytes: file.size,
|
sizeInBytes,
|
||||||
checksum: file.hash,
|
checksum,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
slug: this.slug,
|
slug: this.slug,
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export type IconRepositoryLicense = "MIT" | "GPL-3.0" | undefined;
|
export type IconRepositoryLicense = "MIT" | "GPL-3.0" | "CC0-1.0" | undefined;
|
||||||
|
|||||||
@@ -26,15 +26,14 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ctrl/deluge": "^6.1.0",
|
"@ctrl/deluge": "^6.1.0",
|
||||||
"@ctrl/qbittorrent": "^9.0.1",
|
"@ctrl/qbittorrent": "^9.0.1",
|
||||||
"@ctrl/transmission": "^6.1.0",
|
"@ctrl/transmission": "^7.0.0",
|
||||||
"@homarr/common": "workspace:^0.1.0",
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
"@homarr/db": "workspace:^0.1.0",
|
"@homarr/db": "workspace:^0.1.0",
|
||||||
"@homarr/definitions": "workspace:^0.1.0",
|
"@homarr/definitions": "workspace:^0.1.0",
|
||||||
"@homarr/log": "workspace:^0.1.0",
|
"@homarr/log": "workspace:^0.1.0",
|
||||||
"@homarr/translation": "workspace:^0.1.0",
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@jellyfin/sdk": "^0.10.0",
|
"@jellyfin/sdk": "^0.10.0"
|
||||||
"typed-rpc": "^5.1.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { rpcClient } from "typed-rpc";
|
|
||||||
|
|
||||||
import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data";
|
import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data";
|
||||||
import { DownloadClientIntegration } from "../../interfaces/downloads/download-client-integration";
|
import { DownloadClientIntegration } from "../../interfaces/downloads/download-client-integration";
|
||||||
@@ -9,16 +8,14 @@ import type { NzbGetClient } from "./nzbget-types";
|
|||||||
|
|
||||||
export class NzbGetIntegration extends DownloadClientIntegration {
|
export class NzbGetIntegration extends DownloadClientIntegration {
|
||||||
public async testConnectionAsync(): Promise<void> {
|
public async testConnectionAsync(): Promise<void> {
|
||||||
const client = this.getClient();
|
await this.nzbGetApiCallAsync("version");
|
||||||
await client.version();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getClientJobsAndStatusAsync(): Promise<DownloadClientJobsAndStatus> {
|
public async getClientJobsAndStatusAsync(): Promise<DownloadClientJobsAndStatus> {
|
||||||
const type = "usenet";
|
const type = "usenet";
|
||||||
const nzbGetClient = this.getClient();
|
const queue = await this.nzbGetApiCallAsync("listgroups");
|
||||||
const queue = await nzbGetClient.listgroups();
|
const history = await this.nzbGetApiCallAsync("history");
|
||||||
const history = await nzbGetClient.history();
|
const nzbGetStatus = await this.nzbGetApiCallAsync("status");
|
||||||
const nzbGetStatus = await nzbGetClient.status();
|
|
||||||
const status: DownloadClientStatus = {
|
const status: DownloadClientStatus = {
|
||||||
paused: nzbGetStatus.DownloadPaused,
|
paused: nzbGetStatus.DownloadPaused,
|
||||||
rates: { down: nzbGetStatus.DownloadRate },
|
rates: { down: nzbGetStatus.DownloadRate },
|
||||||
@@ -64,39 +61,55 @@ export class NzbGetIntegration extends DownloadClientIntegration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async pauseQueueAsync() {
|
public async pauseQueueAsync() {
|
||||||
await this.getClient().pausedownload();
|
await this.nzbGetApiCallAsync("pausedownload");
|
||||||
}
|
}
|
||||||
|
|
||||||
public async pauseItemAsync({ id }: DownloadClientItem): Promise<void> {
|
public async pauseItemAsync({ id }: DownloadClientItem): Promise<void> {
|
||||||
await this.getClient().editqueue("GroupPause", "", [Number(id)]);
|
await this.nzbGetApiCallAsync("editqueue", "GroupPause", "", [Number(id)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async resumeQueueAsync() {
|
public async resumeQueueAsync() {
|
||||||
await this.getClient().resumedownload();
|
await this.nzbGetApiCallAsync("resumedownload");
|
||||||
}
|
}
|
||||||
|
|
||||||
public async resumeItemAsync({ id }: DownloadClientItem): Promise<void> {
|
public async resumeItemAsync({ id }: DownloadClientItem): Promise<void> {
|
||||||
await this.getClient().editqueue("GroupResume", "", [Number(id)]);
|
await this.nzbGetApiCallAsync("editqueue", "GroupResume", "", [Number(id)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deleteItemAsync({ id, progress }: DownloadClientItem, fromDisk: boolean): Promise<void> {
|
public async deleteItemAsync({ id, progress }: DownloadClientItem, fromDisk: boolean): Promise<void> {
|
||||||
const client = this.getClient();
|
|
||||||
if (fromDisk) {
|
if (fromDisk) {
|
||||||
const filesIds = (await client.listfiles(0, 0, Number(id))).map((value) => value.ID);
|
const filesIds = (await this.nzbGetApiCallAsync("listfiles", 0, 0, Number(id))).map((file) => file.ID);
|
||||||
await this.getClient().editqueue("FileDelete", "", filesIds);
|
await this.nzbGetApiCallAsync("editqueue", "FileDelete", "", filesIds);
|
||||||
}
|
}
|
||||||
if (progress !== 1) {
|
if (progress === 1) {
|
||||||
await client.editqueue("GroupFinalDelete", "", [Number(id)]);
|
await this.nzbGetApiCallAsync("editqueue", "GroupFinalDelete", "", [Number(id)]);
|
||||||
} else {
|
} else {
|
||||||
await client.editqueue("HistoryFinalDelete", "", [Number(id)]);
|
await this.nzbGetApiCallAsync("editqueue", "HistoryFinalDelete", "", [Number(id)]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getClient() {
|
private async nzbGetApiCallAsync<CallType extends keyof NzbGetClient>(
|
||||||
|
method: CallType,
|
||||||
|
...params: Parameters<NzbGetClient[CallType]>
|
||||||
|
): Promise<ReturnType<NzbGetClient[CallType]>> {
|
||||||
const url = new URL(this.integration.url);
|
const url = new URL(this.integration.url);
|
||||||
url.pathname += `${this.getSecretValue("username")}:${this.getSecretValue("password")}`;
|
url.pathname += `${this.getSecretValue("username")}:${this.getSecretValue("password")}`;
|
||||||
url.pathname += url.pathname.endsWith("/") ? "jsonrpc" : "/jsonrpc";
|
url.pathname += url.pathname.endsWith("/") ? "jsonrpc" : "/jsonrpc";
|
||||||
return rpcClient<NzbGetClient>(url.toString());
|
const body = JSON.stringify({ method, params });
|
||||||
|
return await fetch(url, { method: "POST", body })
|
||||||
|
.then(async (response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(response.statusText);
|
||||||
|
}
|
||||||
|
return ((await response.json()) as { result: ReturnType<NzbGetClient[CallType]> }).result;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
throw new Error(error.message);
|
||||||
|
} else {
|
||||||
|
throw new Error("Error communicating with NzbGet");
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static getUsenetQueueState(status: string): DownloadClientItem["state"] {
|
private static getUsenetQueueState(status: string): DownloadClientItem["state"] {
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ describe("Nzbget integration", () => {
|
|||||||
|
|
||||||
// Act
|
// Act
|
||||||
const getAsync = async () => await nzbGetIntegration.getClientJobsAndStatusAsync();
|
const getAsync = async () => await nzbGetIntegration.getClientJobsAndStatusAsync();
|
||||||
const actAsync = async () => await nzbGetIntegration.deleteItemAsync(item, false);
|
const actAsync = async () => await nzbGetIntegration.deleteItemAsync(item, true);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
await expect(actAsync()).resolves.not.toThrow();
|
await expect(actAsync()).resolves.not.toThrow();
|
||||||
|
|||||||
9
packages/modals-collection/eslint.config.js
Normal file
9
packages/modals-collection/eslint.config.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import baseConfig from "@homarr/eslint-config/base";
|
||||||
|
|
||||||
|
/** @type {import('typescript-eslint').Config} */
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
ignores: [],
|
||||||
|
},
|
||||||
|
...baseConfig,
|
||||||
|
];
|
||||||
1
packages/modals-collection/index.ts
Normal file
1
packages/modals-collection/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./src";
|
||||||
47
packages/modals-collection/package.json
Normal file
47
packages/modals-collection/package.json
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"name": "@homarr/modals-collection",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./index.ts"
|
||||||
|
},
|
||||||
|
"typesVersions": {
|
||||||
|
"*": {
|
||||||
|
"*": [
|
||||||
|
"src/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"clean": "rm -rf .turbo node_modules",
|
||||||
|
"lint": "eslint",
|
||||||
|
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@homarr/api": "workspace:^0.1.0",
|
||||||
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
|
"@homarr/form": "workspace:^0.1.0",
|
||||||
|
"@homarr/modals": "workspace:^0.1.0",
|
||||||
|
"@homarr/notifications": "workspace:^0.1.0",
|
||||||
|
"@homarr/old-schema": "workspace:^0.1.0",
|
||||||
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
|
"@homarr/ui": "workspace:^0.1.0",
|
||||||
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
|
"@mantine/core": "^7.12.2",
|
||||||
|
"@tabler/icons-react": "^3.17.0",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
|
"next": "^14.2.13",
|
||||||
|
"react": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
|
"eslint": "^9.10.0",
|
||||||
|
"typescript": "^5.6.2"
|
||||||
|
},
|
||||||
|
"prettier": "@homarr/prettier-config"
|
||||||
|
}
|
||||||
126
packages/modals-collection/src/boards/add-board-modal.tsx
Normal file
126
packages/modals-collection/src/boards/add-board-modal.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { Button, Group, InputWrapper, Slider, Stack, Switch, TextInput } from "@mantine/core";
|
||||||
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
|
import { IconAlertTriangle, IconCircleCheck } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
|
import { useZodForm } from "@homarr/form";
|
||||||
|
import { createModal } from "@homarr/modals";
|
||||||
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
|
export const AddBoardModal = createModal(({ actions }) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const form = useZodForm(validation.board.create, {
|
||||||
|
mode: "controlled",
|
||||||
|
initialValues: {
|
||||||
|
name: "",
|
||||||
|
columnCount: 10,
|
||||||
|
isPublic: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { mutate, isPending } = clientApi.board.createBoard.useMutation({
|
||||||
|
onSettled: async () => {
|
||||||
|
await revalidatePathActionAsync("/manage/boards");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const boardNameStatus = useBoardNameStatus(form.values.name);
|
||||||
|
|
||||||
|
const columnCountChecks = validation.board.create.shape.columnCount._def.checks;
|
||||||
|
const minColumnCount = columnCountChecks.find((check) => check.kind === "min")?.value;
|
||||||
|
const maxColumnCount = columnCountChecks.find((check) => check.kind === "max")?.value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={form.onSubmit((values) => {
|
||||||
|
// Prevent submit before name availability check
|
||||||
|
if (!boardNameStatus.canSubmit) return;
|
||||||
|
mutate(values, {
|
||||||
|
onSuccess: () => {
|
||||||
|
actions.closeModal();
|
||||||
|
showSuccessNotification({
|
||||||
|
title: "Board created",
|
||||||
|
message: `Board ${values.name} has been created`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
showErrorNotification({
|
||||||
|
title: "Failed to create board",
|
||||||
|
message: `Board ${values.name} could not be created`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
<TextInput
|
||||||
|
label={t("board.field.name.label")}
|
||||||
|
data-autofocus
|
||||||
|
{...form.getInputProps("name")}
|
||||||
|
description={
|
||||||
|
boardNameStatus.description ? (
|
||||||
|
<Group c={boardNameStatus.description.color} gap="xs" align="center">
|
||||||
|
{boardNameStatus.description.icon ? <boardNameStatus.description.icon size={16} /> : null}
|
||||||
|
<span>{boardNameStatus.description.label}</span>
|
||||||
|
</Group>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InputWrapper label={t("board.field.columnCount.label")} {...form.getInputProps("columnCount")}>
|
||||||
|
<Slider min={minColumnCount} max={maxColumnCount} step={1} {...form.getInputProps("columnCount")} />
|
||||||
|
</InputWrapper>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
label={t("board.field.isPublic.label")}
|
||||||
|
description={t("board.field.isPublic.description")}
|
||||||
|
{...form.getInputProps("isPublic")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group justify="right">
|
||||||
|
<Button onClick={actions.closeModal} variant="subtle" color="gray">
|
||||||
|
{t("common.action.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" color="teal" loading={isPending}>
|
||||||
|
{t("common.action.create")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}).withOptions({
|
||||||
|
defaultTitle: (t) => t("management.page.board.action.new.label"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useBoardNameStatus = (name: string) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const [debouncedName] = useDebouncedValue(name, 250);
|
||||||
|
const { data: boardExists, isLoading } = clientApi.board.exists.useQuery(debouncedName, {
|
||||||
|
enabled: validation.board.create.shape.name.safeParse(debouncedName).success,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
canSubmit: !boardExists && !isLoading,
|
||||||
|
description:
|
||||||
|
debouncedName.trim() === ""
|
||||||
|
? undefined
|
||||||
|
: isLoading
|
||||||
|
? {
|
||||||
|
label: "Checking availability...",
|
||||||
|
}
|
||||||
|
: boardExists === undefined
|
||||||
|
? undefined
|
||||||
|
: boardExists
|
||||||
|
? {
|
||||||
|
icon: IconAlertTriangle,
|
||||||
|
label: t("common.zod.errors.custom.boardAlreadyExists"), // The board ${debouncedName} already exists
|
||||||
|
color: "red",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
icon: IconCircleCheck,
|
||||||
|
label: `${debouncedName} is available`,
|
||||||
|
color: "green",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@ import { Button, Fieldset, FileInput, Grid, Group, Radio, Stack, Switch, TextInp
|
|||||||
import { IconFileUpload } from "@tabler/icons-react";
|
import { IconFileUpload } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { createModal } from "@homarr/modals";
|
import { createModal } from "@homarr/modals";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
@@ -10,24 +11,21 @@ import { oldmarrConfigSchema } from "@homarr/old-schema";
|
|||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
import { SelectWithDescription } from "@homarr/ui";
|
import { SelectWithDescription } from "@homarr/ui";
|
||||||
import type { OldmarrImportConfiguration } from "@homarr/validation";
|
import type { OldmarrImportConfiguration } from "@homarr/validation";
|
||||||
import { createOldmarrImportConfigurationSchema, superRefineJsonImportFile, z } from "@homarr/validation";
|
import { oldmarrImportConfigurationSchema, superRefineJsonImportFile, z } from "@homarr/validation";
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
import { useBoardNameStatus } from "./add-board-modal";
|
||||||
|
|
||||||
interface InnerProps {
|
export const ImportBoardModal = createModal(({ actions }) => {
|
||||||
boardNames: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ImportBoardModal = createModal<InnerProps>(({ actions, innerProps }) => {
|
|
||||||
const tOldImport = useScopedI18n("board.action.oldImport");
|
const tOldImport = useScopedI18n("board.action.oldImport");
|
||||||
const tCommon = useScopedI18n("common");
|
const tCommon = useScopedI18n("common");
|
||||||
const [fileValid, setFileValid] = useState(true);
|
const [fileValid, setFileValid] = useState(true);
|
||||||
const form = useZodForm(
|
const form = useZodForm(
|
||||||
z.object({
|
z.object({
|
||||||
file: z.instanceof(File).nullable().superRefine(superRefineJsonImportFile),
|
file: z.instanceof(File).nullable().superRefine(superRefineJsonImportFile),
|
||||||
configuration: createOldmarrImportConfigurationSchema(innerProps.boardNames),
|
configuration: oldmarrImportConfigurationSchema,
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
|
mode: "controlled",
|
||||||
initialValues: {
|
initialValues: {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
file: null!,
|
file: null!,
|
||||||
@@ -67,6 +65,7 @@ export const ImportBoardModal = createModal<InnerProps>(({ actions, innerProps }
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { mutateAsync, isPending } = clientApi.board.importOldmarrConfig.useMutation();
|
const { mutateAsync, isPending } = clientApi.board.importOldmarrConfig.useMutation();
|
||||||
|
const boardNameStatus = useBoardNameStatus(form.values.configuration.name);
|
||||||
|
|
||||||
const handleSubmitAsync = async (values: { file: File; configuration: OldmarrImportConfiguration }) => {
|
const handleSubmitAsync = async (values: { file: File; configuration: OldmarrImportConfiguration }) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
@@ -94,7 +93,7 @@ export const ImportBoardModal = createModal<InnerProps>(({ actions, innerProps }
|
|||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
onSubmit={form.onSubmit((values) => {
|
onSubmit={form.onSubmit((values) => {
|
||||||
if (!fileValid) {
|
if (!fileValid || !boardNameStatus.canSubmit) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,7 +138,19 @@ export const ImportBoardModal = createModal<InnerProps>(({ actions, innerProps }
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Fieldset>
|
</Fieldset>
|
||||||
|
|
||||||
<TextInput withAsterisk label={tOldImport("form.name.label")} {...form.getInputProps("configuration.name")} />
|
<TextInput
|
||||||
|
withAsterisk
|
||||||
|
label={tOldImport("form.name.label")}
|
||||||
|
description={
|
||||||
|
boardNameStatus.description ? (
|
||||||
|
<Group c={boardNameStatus.description.color} gap="xs" align="center">
|
||||||
|
{boardNameStatus.description.icon ? <boardNameStatus.description.icon size={16} /> : null}
|
||||||
|
<span>{boardNameStatus.description.label}</span>
|
||||||
|
</Group>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
{...form.getInputProps("configuration.name")}
|
||||||
|
/>
|
||||||
|
|
||||||
<Radio.Group
|
<Radio.Group
|
||||||
withAsterisk
|
withAsterisk
|
||||||
2
packages/modals-collection/src/boards/index.ts
Normal file
2
packages/modals-collection/src/boards/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { AddBoardModal } from "./add-board-modal";
|
||||||
|
export { ImportBoardModal } from "./import-board-modal";
|
||||||
56
packages/modals-collection/src/groups/add-group-modal.tsx
Normal file
56
packages/modals-collection/src/groups/add-group-modal.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { Button, Group, Stack, TextInput } from "@mantine/core";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
|
import { useZodForm } from "@homarr/form";
|
||||||
|
import { createModal } from "@homarr/modals";
|
||||||
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
|
export const AddGroupModal = createModal<void>(({ actions }) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const { mutate, isPending } = clientApi.group.createGroup.useMutation();
|
||||||
|
const form = useZodForm(validation.group.create, {
|
||||||
|
initialValues: {
|
||||||
|
name: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={form.onSubmit((values) => {
|
||||||
|
mutate(values, {
|
||||||
|
onSuccess() {
|
||||||
|
actions.closeModal();
|
||||||
|
void revalidatePathActionAsync("/manage/users/groups");
|
||||||
|
showSuccessNotification({
|
||||||
|
title: t("common.notification.create.success"),
|
||||||
|
message: t("group.action.create.notification.success.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
showErrorNotification({
|
||||||
|
title: t("common.notification.create.error"),
|
||||||
|
message: t("group.action.create.notification.error.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
<TextInput label={t("group.field.name")} data-autofocus {...form.getInputProps("name")} />
|
||||||
|
<Group justify="right">
|
||||||
|
<Button onClick={actions.closeModal} variant="subtle" color="gray">
|
||||||
|
{t("common.action.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button loading={isPending} type="submit" color="teal">
|
||||||
|
{t("common.action.create")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}).withOptions({
|
||||||
|
defaultTitle: (t) => t("group.action.create.label"),
|
||||||
|
});
|
||||||
1
packages/modals-collection/src/groups/index.ts
Normal file
1
packages/modals-collection/src/groups/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { AddGroupModal } from "./add-group-modal";
|
||||||
3
packages/modals-collection/src/index.ts
Normal file
3
packages/modals-collection/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./boards";
|
||||||
|
export * from "./invites";
|
||||||
|
export * from "./groups";
|
||||||
2
packages/modals-collection/src/invites/index.ts
Normal file
2
packages/modals-collection/src/invites/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { InviteCopyModal } from "./invite-copy-modal";
|
||||||
|
export { InviteCreateModal } from "./invite-create-modal";
|
||||||
8
packages/modals-collection/tsconfig.json
Normal file
8
packages/modals-collection/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "@homarr/tsconfig/base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||||
|
},
|
||||||
|
"include": ["*.ts", "src"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@homarr/ui": "workspace:^0.1.0",
|
"@homarr/ui": "workspace:^0.1.0",
|
||||||
"@mantine/notifications": "^7.12.2",
|
"@mantine/notifications": "^7.12.2",
|
||||||
"@tabler/icons-react": "^3.16.0"
|
"@tabler/icons-react": "^3.17.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
|
|||||||
@@ -1,145 +0,0 @@
|
|||||||
# Spotlight
|
|
||||||
|
|
||||||
Spotlight is the search functionality of Homarr. It can be opened by pressing `Ctrl + K` or `Cmd + K` on Mac. It is a quick way to search for anything in Homarr.
|
|
||||||
|
|
||||||
## API
|
|
||||||
|
|
||||||
### SpotlightActionData
|
|
||||||
|
|
||||||
The [SpotlightActionData](./src/type.ts) is the data structure that is used to define the actions that are shown in the spotlight.
|
|
||||||
|
|
||||||
#### Common properties
|
|
||||||
|
|
||||||
| Name | Type | Description |
|
|
||||||
| ------------------------------ | ------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
|
|
||||||
| id | `string` | The id of the action. |
|
|
||||||
| title | `string \| (t: TranslationFunction) => string` | The title of the action. Either static or generated with translation function |
|
|
||||||
| description | `string \| (t: TranslationFunction) => string` | The description of the action. Either static or generated with translation function |
|
|
||||||
| icon | `string \| TablerIcon` | The icon of the action. Either a url to an image or a TablerIcon |
|
|
||||||
| group | `string` | The group of the action. By default the groups all, web and action exist. |
|
|
||||||
| ignoreSearchAndOnlyShowInGroup | `boolean` | If true, the action will only be shown in the group and not in the search results. |
|
|
||||||
| type | `'link' \| 'button'` | The type of the action. Either link or button |
|
|
||||||
|
|
||||||
#### Properties for links
|
|
||||||
|
|
||||||
| Name | Type | Description |
|
|
||||||
| ---- | -------- | ---------------------------------------------------------------------------------------------------------- |
|
|
||||||
| href | `string` | The url the link should navigate to. If %s is contained it will be replaced with the current search query. |
|
|
||||||
|
|
||||||
#### Properties for buttons
|
|
||||||
|
|
||||||
| Name | Type | Description |
|
|
||||||
| ------- | -------------------------- | ----------------------------------------------------------------------------------------- |
|
|
||||||
| onClick | `() => MaybePromise<void>` | The function that should be called when the button is clicked. It can be async if needed. |
|
|
||||||
|
|
||||||
### useRegisterSpotlightActions
|
|
||||||
|
|
||||||
The [useRegisterSpotlightActions](./src/data-store.ts) hook is used to register actions to the spotlight. It takes an unique key and the array of [SpotlightActionData](#SpotlightActionData).
|
|
||||||
|
|
||||||
#### Usage
|
|
||||||
|
|
||||||
The following example shows how to use the `useRegisterSpotlightActions` hook to register an action to the spotlight.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useRegisterSpotlightActions } from "@homarr/spotlight";
|
|
||||||
|
|
||||||
const MyComponent = () => {
|
|
||||||
useRegisterSpotlightActions("my-component", [
|
|
||||||
{
|
|
||||||
id: "my-action",
|
|
||||||
title: "My Action",
|
|
||||||
description: "This is my action",
|
|
||||||
icon: "https://example.com/icon.png",
|
|
||||||
group: "web",
|
|
||||||
type: "link",
|
|
||||||
href: "https://example.com",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
return <div>My Component</div>;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
##### Using translation function
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useRegisterSpotlightActions } from "@homarr/spotlight";
|
|
||||||
|
|
||||||
const MyComponent = () => {
|
|
||||||
useRegisterSpotlightActions("my-component", [
|
|
||||||
{
|
|
||||||
id: "my-action",
|
|
||||||
title: (t) => t("some.path.to.translation.key"),
|
|
||||||
description: (t) => t("some.other.path.to.translation.key"),
|
|
||||||
icon: "https://example.com/icon.png",
|
|
||||||
group: "web",
|
|
||||||
type: "link",
|
|
||||||
href: "https://example.com",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
return <div>Component implementation</div>;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
##### Using TablerIcon
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { IconUserCog } from "tabler-react";
|
|
||||||
|
|
||||||
import { useRegisterSpotlightActions } from "@homarr/spotlight";
|
|
||||||
|
|
||||||
const UserMenu = () => {
|
|
||||||
useRegisterSpotlightActions("header-user-menu", [
|
|
||||||
{
|
|
||||||
id: "user-preferences",
|
|
||||||
title: (t) => t("user.preferences.title"),
|
|
||||||
description: (t) => t("user.preferences.description"),
|
|
||||||
icon: IconUserCog,
|
|
||||||
group: "action",
|
|
||||||
type: "link",
|
|
||||||
href: "/user/preferences",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
return <div>Component implementation</div>;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
##### Using dependency array
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { IconUserCog } from "tabler-react";
|
|
||||||
|
|
||||||
import { useRegisterSpotlightActions } from "@homarr/spotlight";
|
|
||||||
|
|
||||||
const ColorSchemeButton = () => {
|
|
||||||
const { colorScheme, toggleColorScheme } = useColorScheme();
|
|
||||||
|
|
||||||
useRegisterSpotlightActions(
|
|
||||||
"toggle-color-scheme",
|
|
||||||
[
|
|
||||||
{
|
|
||||||
id: "toggle-color-scheme",
|
|
||||||
title: (t) => t("common.colorScheme.toggle.title"),
|
|
||||||
description: (t) => t(`common.colorScheme.toggle.${colorScheme}.description`),
|
|
||||||
icon: colorScheme === "light" ? IconSun : IconMoon,
|
|
||||||
group: "action",
|
|
||||||
type: "button",
|
|
||||||
onClick: toggleColorScheme,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[colorScheme],
|
|
||||||
);
|
|
||||||
|
|
||||||
return <div>Component implementation</div>;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
@@ -21,16 +21,21 @@
|
|||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@homarr/api": "workspace:^0.1.0",
|
||||||
|
"@homarr/auth": "workspace:^0.1.0",
|
||||||
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
|
"@homarr/definitions": "workspace:^0.1.0",
|
||||||
|
"@homarr/modals": "workspace:^0.1.0",
|
||||||
|
"@homarr/modals-collection": "workspace:^0.1.0",
|
||||||
"@homarr/translation": "workspace:^0.1.0",
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
"@homarr/ui": "workspace:^0.1.0",
|
"@homarr/ui": "workspace:^0.1.0",
|
||||||
"@mantine/core": "^7.12.2",
|
"@mantine/core": "^7.12.2",
|
||||||
"@mantine/hooks": "^7.12.2",
|
"@mantine/hooks": "^7.12.2",
|
||||||
"@mantine/spotlight": "^7.12.2",
|
"@mantine/spotlight": "^7.12.2",
|
||||||
"@tabler/icons-react": "^3.16.0",
|
"@tabler/icons-react": "^3.17.0",
|
||||||
"jotai": "^2.9.3",
|
"jotai": "^2.10.0",
|
||||||
"next": "^14.2.11",
|
"next": "^14.2.13",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"use-deep-compare-effect": "^1.8.1"
|
"use-deep-compare-effect": "^1.8.1"
|
||||||
},
|
},
|
||||||
@@ -40,5 +45,6 @@
|
|||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"eslint": "^9.10.0",
|
"eslint": "^9.10.0",
|
||||||
"typescript": "^5.6.2"
|
"typescript": "^5.6.2"
|
||||||
}
|
},
|
||||||
|
"prettier": "@homarr/prettier-config"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
import { Chip } from "@mantine/core";
|
|
||||||
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
import { selectNextAction, selectPreviousAction, spotlightStore, triggerSelectedAction } from "./spotlight-store";
|
|
||||||
import type { SpotlightActionGroup } from "./type";
|
|
||||||
|
|
||||||
const disableArrowUpAndDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
if (event.key === "ArrowDown") {
|
|
||||||
selectNextAction(spotlightStore);
|
|
||||||
event.preventDefault();
|
|
||||||
} else if (event.key === "ArrowUp") {
|
|
||||||
selectPreviousAction(spotlightStore);
|
|
||||||
event.preventDefault();
|
|
||||||
} else if (event.key === "Enter") {
|
|
||||||
triggerSelectedAction(spotlightStore);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const focusActiveByDefault = (event: React.FocusEvent<HTMLInputElement>) => {
|
|
||||||
const relatedTarget = event.relatedTarget;
|
|
||||||
|
|
||||||
const isPreviousTargetRadio = relatedTarget && "type" in relatedTarget && relatedTarget.type === "radio";
|
|
||||||
if (isPreviousTargetRadio) return;
|
|
||||||
|
|
||||||
const group = event.currentTarget.parentElement?.parentElement;
|
|
||||||
if (!group) return;
|
|
||||||
const label = group.querySelector<HTMLLabelElement>("label[data-checked]");
|
|
||||||
if (!label) return;
|
|
||||||
label.focus();
|
|
||||||
};
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
group: SpotlightActionGroup;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GroupChip = ({ group }: Props) => {
|
|
||||||
const t = useScopedI18n("common.search.group");
|
|
||||||
return (
|
|
||||||
<Chip key={group} value={group} onFocus={focusActiveByDefault} onKeyDown={disableArrowUpAndDown}>
|
|
||||||
{t(group)}
|
|
||||||
</Chip>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useState } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { Center, Chip, Divider, Flex, Group, Text } from "@mantine/core";
|
|
||||||
import { Spotlight as MantineSpotlight, SpotlightAction } from "@mantine/spotlight";
|
|
||||||
import { IconSearch } from "@tabler/icons-react";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
|
|
||||||
import type { TranslationFunction } from "@homarr/translation";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
import { GroupChip } from "./chip-group";
|
|
||||||
import classes from "./component.module.css";
|
|
||||||
import { actionsAtomRead, groupsAtomRead } from "./data-store";
|
|
||||||
import { setSelectedAction, spotlightStore } from "./spotlight-store";
|
|
||||||
import type { SpotlightActionData } from "./type";
|
|
||||||
import { useWebSearchEngines } from "./web-search-engines";
|
|
||||||
|
|
||||||
export const Spotlight = () => {
|
|
||||||
useWebSearchEngines();
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const [group, setGroup] = useState("all");
|
|
||||||
const groups = useAtomValue(groupsAtomRead);
|
|
||||||
const actions = useAtomValue(actionsAtomRead);
|
|
||||||
const t = useI18n();
|
|
||||||
|
|
||||||
const preparedActions = actions.map((action) => prepareAction(action, t));
|
|
||||||
const items = preparedActions
|
|
||||||
.filter(
|
|
||||||
(item) =>
|
|
||||||
(item.ignoreSearchAndOnlyShowInGroup
|
|
||||||
? item.group === group
|
|
||||||
: item.title.toLowerCase().includes(query.toLowerCase().trim())) &&
|
|
||||||
(group === "all" || item.group === group),
|
|
||||||
)
|
|
||||||
.map((item) => {
|
|
||||||
const renderRoot =
|
|
||||||
item.type === "link"
|
|
||||||
? (props: Record<string, unknown>) => (
|
|
||||||
<Link href={prepareHref(item.href, query)} target={item.openInNewTab ? "_blank" : undefined} {...props} />
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SpotlightAction
|
|
||||||
key={item.id}
|
|
||||||
renderRoot={renderRoot}
|
|
||||||
onClick={item.type === "button" ? item.onClick : undefined}
|
|
||||||
className={classes.spotlightAction}
|
|
||||||
>
|
|
||||||
<Group wrap="nowrap" w="100%">
|
|
||||||
{item.icon && (
|
|
||||||
<Center w={50} h={50}>
|
|
||||||
{typeof item.icon !== "string" && <item.icon size={24} />}
|
|
||||||
{typeof item.icon === "string" && <img src={item.icon} alt={item.title} width={24} height={24} />}
|
|
||||||
</Center>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Flex direction="column">
|
|
||||||
<Text>{item.title}</Text>
|
|
||||||
|
|
||||||
{item.description && (
|
|
||||||
<Text opacity={0.6} size="xs">
|
|
||||||
{item.description}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Flex>
|
|
||||||
</Group>
|
|
||||||
</SpotlightAction>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const onGroupChange = useCallback(
|
|
||||||
(group: string) => {
|
|
||||||
setSelectedAction(-1, spotlightStore);
|
|
||||||
setGroup(group);
|
|
||||||
},
|
|
||||||
[setGroup, setSelectedAction],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MantineSpotlight.Root query={query} onQueryChange={setQuery} store={spotlightStore}>
|
|
||||||
<MantineSpotlight.Search
|
|
||||||
placeholder={t("common.rtl", {
|
|
||||||
value: t("common.search.placeholder"),
|
|
||||||
symbol: "...",
|
|
||||||
})}
|
|
||||||
leftSection={<IconSearch stroke={1.5} />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
<Group wrap="nowrap" p="sm">
|
|
||||||
<Chip.Group multiple={false} value={group} onChange={onGroupChange}>
|
|
||||||
<Group justify="start">
|
|
||||||
{groups.map((group) => (
|
|
||||||
<GroupChip key={group} group={group} />
|
|
||||||
))}
|
|
||||||
</Group>
|
|
||||||
</Chip.Group>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<MantineSpotlight.ActionsList>
|
|
||||||
{items.length > 0 ? items : <MantineSpotlight.Empty>{t("common.search.nothingFound")}</MantineSpotlight.Empty>}
|
|
||||||
</MantineSpotlight.ActionsList>
|
|
||||||
</MantineSpotlight.Root>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const prepareHref = (href: string, query: string) => {
|
|
||||||
return href.replace("%s", query);
|
|
||||||
};
|
|
||||||
|
|
||||||
const translateIfNecessary = (value: string | ((t: TranslationFunction) => string), t: TranslationFunction) => {
|
|
||||||
if (typeof value === "function") {
|
|
||||||
return value(t);
|
|
||||||
}
|
|
||||||
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const prepareAction = (action: SpotlightActionData, t: TranslationFunction) => ({
|
|
||||||
...action,
|
|
||||||
title: translateIfNecessary(action.title, t),
|
|
||||||
description: translateIfNecessary(action.description, t),
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { inferSearchInteractionOptions } from "../../lib/interaction";
|
||||||
|
import { ChildrenActionItem } from "./items/children-action-item";
|
||||||
|
|
||||||
|
interface SpotlightChildrenActionsProps {
|
||||||
|
childrenOptions: inferSearchInteractionOptions<"children">;
|
||||||
|
query: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SpotlightChildrenActions = ({ childrenOptions, query }: SpotlightChildrenActionsProps) => {
|
||||||
|
const actions = childrenOptions.useActions(childrenOptions.option, query);
|
||||||
|
|
||||||
|
return actions
|
||||||
|
.filter((action) => (typeof action.hide === "function" ? !action.hide(childrenOptions.option) : !action.hide))
|
||||||
|
.map((action) => (
|
||||||
|
<ChildrenActionItem key={action.key} childrenOptions={childrenOptions} query={query} action={action} />
|
||||||
|
));
|
||||||
|
};
|
||||||
87
packages/spotlight/src/components/actions/group-actions.tsx
Normal file
87
packages/spotlight/src/components/actions/group-actions.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { Center, Loader } from "@mantine/core";
|
||||||
|
|
||||||
|
import type { TranslationObject } from "@homarr/translation";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import type { SearchGroup } from "../../lib/group";
|
||||||
|
import type { inferSearchInteractionOptions } from "../../lib/interaction";
|
||||||
|
import { SpotlightNoResults } from "../no-results";
|
||||||
|
import { SpotlightGroupActionItem } from "./items/group-action-item";
|
||||||
|
|
||||||
|
interface GroupActionsProps<TOption extends Record<string, unknown>> {
|
||||||
|
group: SearchGroup<TOption>;
|
||||||
|
query: string;
|
||||||
|
setMode: (mode: keyof TranslationObject["search"]["mode"]) => void;
|
||||||
|
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SpotlightGroupActions = <TOption extends Record<string, unknown>>({
|
||||||
|
group,
|
||||||
|
query,
|
||||||
|
setMode,
|
||||||
|
setChildrenOptions,
|
||||||
|
}: GroupActionsProps<TOption>) => {
|
||||||
|
// This does work as the same amount of hooks is called on every render
|
||||||
|
const useOptions =
|
||||||
|
"options" in group ? () => group.options : "useOptions" in group ? group.useOptions : group.useQueryOptions;
|
||||||
|
const options = useOptions(query);
|
||||||
|
const t = useI18n();
|
||||||
|
|
||||||
|
if (Array.isArray(options)) {
|
||||||
|
const filteredOptions = options
|
||||||
|
.filter((option) => ("filter" in group ? group.filter(query, option) : false))
|
||||||
|
.sort((optionA, optionB) => {
|
||||||
|
if ("sort" in group) {
|
||||||
|
return group.sort?.(query, [optionA, optionB]) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filteredOptions.length === 0) {
|
||||||
|
return <SpotlightNoResults />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredOptions.map((option) => (
|
||||||
|
<SpotlightGroupActionItem
|
||||||
|
key={option[group.keyPath] as never}
|
||||||
|
option={option}
|
||||||
|
group={group}
|
||||||
|
query={query}
|
||||||
|
setMode={setMode}
|
||||||
|
setChildrenOptions={setChildrenOptions}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.isLoading) {
|
||||||
|
return (
|
||||||
|
<Center w="100%" py="sm">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.isError) {
|
||||||
|
return <Center py="sm">{t("search.error.fetch")}</Center>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.data.length === 0) {
|
||||||
|
return <SpotlightNoResults />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return options.data.map((option) => (
|
||||||
|
<SpotlightGroupActionItem
|
||||||
|
key={option[group.keyPath] as never}
|
||||||
|
option={option}
|
||||||
|
group={group}
|
||||||
|
query={query}
|
||||||
|
setMode={setMode}
|
||||||
|
setChildrenOptions={setChildrenOptions}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Spotlight } from "@mantine/spotlight";
|
||||||
|
|
||||||
|
import type { TranslationObject } from "@homarr/translation";
|
||||||
|
import { translateIfNecessary } from "@homarr/translation";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import type { SearchGroup } from "../../../lib/group";
|
||||||
|
import type { inferSearchInteractionOptions } from "../../../lib/interaction";
|
||||||
|
import { SpotlightGroupActions } from "../group-actions";
|
||||||
|
|
||||||
|
interface SpotlightActionGroupsProps {
|
||||||
|
groups: SearchGroup[];
|
||||||
|
query: string;
|
||||||
|
setMode: (mode: keyof TranslationObject["search"]["mode"]) => void;
|
||||||
|
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SpotlightActionGroups = ({ groups, query, setMode, setChildrenOptions }: SpotlightActionGroupsProps) => {
|
||||||
|
const t = useI18n();
|
||||||
|
|
||||||
|
return groups.map((group) => (
|
||||||
|
<Spotlight.ActionsGroup key={translateIfNecessary(t, group.title)} label={translateIfNecessary(t, group.title)}>
|
||||||
|
{/*eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
|
<SpotlightGroupActions<any>
|
||||||
|
group={group}
|
||||||
|
query={query}
|
||||||
|
setMode={setMode}
|
||||||
|
setChildrenOptions={setChildrenOptions}
|
||||||
|
/>
|
||||||
|
</Spotlight.ActionsGroup>
|
||||||
|
));
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Spotlight } from "@mantine/spotlight";
|
||||||
|
|
||||||
|
import type { inferSearchInteractionOptions } from "../../../lib/interaction";
|
||||||
|
import classes from "./action-item.module.css";
|
||||||
|
|
||||||
|
interface ChildrenActionItemProps {
|
||||||
|
childrenOptions: inferSearchInteractionOptions<"children">;
|
||||||
|
query: string;
|
||||||
|
action: ReturnType<inferSearchInteractionOptions<"children">["useActions"]>[number];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChildrenActionItem = ({ childrenOptions, action, query }: ChildrenActionItemProps) => {
|
||||||
|
const interaction = action.useInteraction(childrenOptions.option, query);
|
||||||
|
|
||||||
|
const renderRoot =
|
||||||
|
interaction.type === "link"
|
||||||
|
? (props: Record<string, unknown>) => {
|
||||||
|
return <Link href={interaction.href} target={interaction.newTab ? "_blank" : undefined} {...props} />;
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const onClick = interaction.type === "javaScript" ? interaction.onSelect : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Spotlight.Action renderRoot={renderRoot} onClick={onClick} className={classes.spotlightAction}>
|
||||||
|
<action.component {...childrenOptions.option} />
|
||||||
|
</Spotlight.Action>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Spotlight } from "@mantine/spotlight";
|
||||||
|
|
||||||
|
import type { TranslationObject } from "@homarr/translation";
|
||||||
|
|
||||||
|
import type { SearchGroup } from "../../../lib/group";
|
||||||
|
import type { inferSearchInteractionOptions } from "../../../lib/interaction";
|
||||||
|
import classes from "./action-item.module.css";
|
||||||
|
|
||||||
|
interface SpotlightGroupActionItemProps<TOption extends Record<string, unknown>> {
|
||||||
|
option: TOption;
|
||||||
|
query: string;
|
||||||
|
setMode: (mode: keyof TranslationObject["search"]["mode"]) => void;
|
||||||
|
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
|
||||||
|
group: SearchGroup<TOption>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SpotlightGroupActionItem = <TOption extends Record<string, unknown>>({
|
||||||
|
group,
|
||||||
|
query,
|
||||||
|
setMode,
|
||||||
|
setChildrenOptions,
|
||||||
|
option,
|
||||||
|
}: SpotlightGroupActionItemProps<TOption>) => {
|
||||||
|
const interaction = group.useInteraction(option, query);
|
||||||
|
|
||||||
|
const renderRoot =
|
||||||
|
interaction.type === "link"
|
||||||
|
? (props: Record<string, unknown>) => {
|
||||||
|
return <Link href={interaction.href} target={interaction.newTab ? "_blank" : undefined} {...props} />;
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const handleClickAsync = async () => {
|
||||||
|
if (interaction.type === "javaScript") {
|
||||||
|
await interaction.onSelect();
|
||||||
|
} else if (interaction.type === "mode") {
|
||||||
|
setMode(interaction.mode);
|
||||||
|
} else if (interaction.type === "children") {
|
||||||
|
setChildrenOptions(interaction);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Spotlight.Action
|
||||||
|
renderRoot={renderRoot}
|
||||||
|
onClick={handleClickAsync}
|
||||||
|
closeSpotlightOnTrigger={interaction.type !== "mode" && interaction.type !== "children"}
|
||||||
|
className={classes.spotlightAction}
|
||||||
|
>
|
||||||
|
<group.component {...option} />
|
||||||
|
</Spotlight.Action>
|
||||||
|
);
|
||||||
|
};
|
||||||
9
packages/spotlight/src/components/no-results.tsx
Normal file
9
packages/spotlight/src/components/no-results.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Spotlight } from "@mantine/spotlight";
|
||||||
|
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
export const SpotlightNoResults = () => {
|
||||||
|
const t = useI18n();
|
||||||
|
|
||||||
|
return <Spotlight.Empty>{t("search.nothingFound")}</Spotlight.Empty>;
|
||||||
|
};
|
||||||
118
packages/spotlight/src/components/spotlight.tsx
Normal file
118
packages/spotlight/src/components/spotlight.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useRef, useState } from "react";
|
||||||
|
import { ActionIcon, Center, Group, Kbd } from "@mantine/core";
|
||||||
|
import { Spotlight as MantineSpotlight } from "@mantine/spotlight";
|
||||||
|
import { IconSearch, IconX } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import type { TranslationObject } from "@homarr/translation";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import type { inferSearchInteractionOptions } from "../lib/interaction";
|
||||||
|
import { searchModes } from "../modes";
|
||||||
|
import { selectAction, spotlightStore } from "../spotlight-store";
|
||||||
|
import { SpotlightChildrenActions } from "./actions/children-actions";
|
||||||
|
import { SpotlightActionGroups } from "./actions/groups/action-group";
|
||||||
|
|
||||||
|
export const Spotlight = () => {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [mode, setMode] = useState<keyof TranslationObject["search"]["mode"]>("help");
|
||||||
|
const [childrenOptions, setChildrenOptions] = useState<inferSearchInteractionOptions<"children"> | null>(null);
|
||||||
|
const t = useI18n();
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const activeMode = useMemo(() => searchModes.find((searchMode) => searchMode.modeKey === mode), [mode]);
|
||||||
|
|
||||||
|
if (!activeMode) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MantineSpotlight.Root
|
||||||
|
onSpotlightClose={() => {
|
||||||
|
setMode("help");
|
||||||
|
setChildrenOptions(null);
|
||||||
|
}}
|
||||||
|
query={query}
|
||||||
|
onQueryChange={(query) => {
|
||||||
|
if (mode !== "help" || query.length !== 1) {
|
||||||
|
setQuery(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
const modeToActivate = searchModes.find((mode) => mode.character === query);
|
||||||
|
if (!modeToActivate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMode(modeToActivate.modeKey);
|
||||||
|
setQuery("");
|
||||||
|
setTimeout(() => selectAction(0, spotlightStore));
|
||||||
|
}}
|
||||||
|
store={spotlightStore}
|
||||||
|
>
|
||||||
|
<MantineSpotlight.Search
|
||||||
|
placeholder={t("common.rtl", {
|
||||||
|
value: t("search.placeholder"),
|
||||||
|
symbol: "...",
|
||||||
|
})}
|
||||||
|
ref={inputRef}
|
||||||
|
leftSectionWidth={activeMode.modeKey !== "help" ? 80 : 48}
|
||||||
|
leftSection={
|
||||||
|
<Group align="center" wrap="nowrap" gap="xs" w="100%" h="100%">
|
||||||
|
<Center w={48} h="100%">
|
||||||
|
<IconSearch stroke={1.5} />
|
||||||
|
</Center>
|
||||||
|
{activeMode.modeKey !== "help" ? <Kbd size="sm">{activeMode.character}</Kbd> : null}
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
rightSection={
|
||||||
|
mode === "help" ? undefined : (
|
||||||
|
<ActionIcon
|
||||||
|
onClick={() => {
|
||||||
|
setMode("help");
|
||||||
|
setChildrenOptions(null);
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
<IconX stroke={1.5} />
|
||||||
|
</ActionIcon>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
value={query}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (query.length === 0 && mode !== "help" && event.key === "Backspace") {
|
||||||
|
setMode("help");
|
||||||
|
setChildrenOptions(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{childrenOptions ? (
|
||||||
|
<Group>
|
||||||
|
<childrenOptions.detailComponent options={childrenOptions.option as never} />
|
||||||
|
</Group>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<MantineSpotlight.ActionsList>
|
||||||
|
{childrenOptions ? (
|
||||||
|
<SpotlightChildrenActions childrenOptions={childrenOptions} query={query} />
|
||||||
|
) : (
|
||||||
|
<SpotlightActionGroups
|
||||||
|
setMode={(mode) => {
|
||||||
|
setMode(mode);
|
||||||
|
setChildrenOptions(null);
|
||||||
|
setTimeout(() => selectAction(0, spotlightStore));
|
||||||
|
}}
|
||||||
|
setChildrenOptions={(options) => {
|
||||||
|
setChildrenOptions(options);
|
||||||
|
setQuery("");
|
||||||
|
setTimeout(() => selectAction(0, spotlightStore));
|
||||||
|
}}
|
||||||
|
query={query}
|
||||||
|
groups={activeMode.groups}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</MantineSpotlight.ActionsList>
|
||||||
|
</MantineSpotlight.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user