mirror of
https://github.com/ajnart/homarr.git
synced 2026-02-01 12:19:21 +01:00
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]
|
||||
steps:
|
||||
- name: Discord notification
|
||||
if: ${{ github.events.inputs.send-notifications }}
|
||||
if: ${{ github.events.inputs.send-notifications || true }}
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
uses: Ilshidur/action-discord@master
|
||||
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
|
||||
- name: Get Next Version
|
||||
id: semver
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
token: ${{ github.token }}
|
||||
branch: dev
|
||||
- name: Discord notification
|
||||
if: ${{ github.events.inputs.send-notifications }}
|
||||
if: ${{ github.events.inputs.send-notifications || true }}
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
uses: Ilshidur/action-discord@master
|
||||
@@ -65,9 +65,8 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: "pnpm"
|
||||
- name: Discord notification
|
||||
if: ${{ github.events.inputs.send-notifications }}
|
||||
if: ${{ github.events.inputs.send-notifications || true }}
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
uses: Ilshidur/action-discord@master
|
||||
@@ -97,19 +96,40 @@ jobs:
|
||||
- name: Build and push
|
||||
id: buildPushAction
|
||||
uses: docker/build-push-action@v6
|
||||
if: ${{ github.events.inputs.push-image == 'true' || github.events.inputs.push-image == null }}
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
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 }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
network: host
|
||||
env:
|
||||
SKIP_ENV_VALIDATION: true
|
||||
- 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:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
uses: Ilshidur/action-discord@master
|
||||
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
|
||||
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/db
|
||||
RUN mkdir /appdata/redis
|
||||
@@ -79,6 +80,11 @@ RUN chmod +x /usr/bin/homarr
|
||||
|
||||
# Don't run production as root
|
||||
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
|
||||
|
||||
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 --chown=nextjs:nodejs scripts/run.sh ./run.sh
|
||||
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_DIALECT='sqlite'
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"@homarr/integrations": "workspace:^0.1.0",
|
||||
"@homarr/log": "workspace:^",
|
||||
"@homarr/modals": "workspace:^0.1.0",
|
||||
"@homarr/modals-collection": "workspace:^0.1.0",
|
||||
"@homarr/notifications": "workspace:^0.1.0",
|
||||
"@homarr/old-schema": "workspace:^0.1.0",
|
||||
"@homarr/server-settings": "workspace:^0.1.0",
|
||||
@@ -42,7 +43,7 @@
|
||||
"@mantine/tiptap": "^7.12.2",
|
||||
"@million/lint": "1.0.0-rc.84",
|
||||
"@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-devtools": "^5.56.2",
|
||||
"@tanstack/react-query-next-experimental": "5.56.2",
|
||||
@@ -59,16 +60,16 @@
|
||||
"dotenv": "^16.4.5",
|
||||
"flag-icons": "^7.2.3",
|
||||
"glob": "^11.0.0",
|
||||
"jotai": "^2.9.3",
|
||||
"jotai": "^2.10.0",
|
||||
"mantine-react-table": "2.0.0-beta.6",
|
||||
"next": "^14.2.11",
|
||||
"next": "^14.2.13",
|
||||
"postcss-preset-mantine": "^1.17.0",
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-error-boundary": "^4.0.13",
|
||||
"react-simple-code-editor": "^0.14.1",
|
||||
"sass": "^1.78.0",
|
||||
"sass": "^1.79.2",
|
||||
"superjson": "2.2.1",
|
||||
"swagger-ui-react": "^5.17.14",
|
||||
"use-deep-compare-effect": "^1.8.1"
|
||||
@@ -80,7 +81,7 @@
|
||||
"@types/chroma-js": "2.4.4",
|
||||
"@types/node": "^20.16.5",
|
||||
"@types/prismjs": "^1.26.4",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react": "^18.3.8",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/swagger-ui-react": "^4.18.3",
|
||||
"concurrently": "^9.0.1",
|
||||
|
||||
@@ -20,7 +20,10 @@ import type { AppRouter } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
|
||||
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) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Anchor, Button, Card, Code, Collapse, Divider, PasswordInput, Stack, Te
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
|
||||
import { signIn } from "@homarr/auth/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import type { useForm } from "@homarr/form";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
@@ -14,8 +15,6 @@ import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { z } from "@homarr/validation";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||
|
||||
interface LoginFormProps {
|
||||
providers: string[];
|
||||
oidcClientName: string;
|
||||
|
||||
@@ -15,11 +15,11 @@ import {
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
|
||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
|
||||
|
||||
@@ -6,12 +6,11 @@ import { IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { revalidatePathActionAsync } from "../../../revalidatePathAction";
|
||||
|
||||
interface AppDeleteButtonProps {
|
||||
app: RouterOutputs["app"]["all"][number];
|
||||
}
|
||||
|
||||
@@ -5,12 +5,12 @@ import { useRouter } from "next/navigation";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { validation, z } from "@homarr/validation";
|
||||
|
||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||
import { AppForm } from "../../_form";
|
||||
|
||||
interface AppEditFormProps {
|
||||
|
||||
@@ -4,12 +4,12 @@ import { useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { validation, z } from "@homarr/validation";
|
||||
|
||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||
import { AppForm } from "../_form";
|
||||
|
||||
export const AppNewForm = () => {
|
||||
|
||||
@@ -7,10 +7,10 @@ import { IconHome, IconSettings, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||
|
||||
const iconProps = {
|
||||
|
||||
@@ -1,53 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { Affix, Button, Group, Menu } from "@mantine/core";
|
||||
import { IconCategoryPlus, IconChevronDown, IconFileImport } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AddBoardModal, ImportBoardModal } from "@homarr/modals-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { BetaBadge } from "@homarr/ui";
|
||||
|
||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||
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) => {
|
||||
export const CreateBoardButton = () => {
|
||||
const t = useI18n();
|
||||
const { openModal: openAddModal } = useModalAction(AddBoardModal);
|
||||
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 = (
|
||||
<>
|
||||
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={onCreateClick} loading={isPending}>
|
||||
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openAddModal}>
|
||||
{t("management.page.board.action.new.label")}
|
||||
</Button>
|
||||
<Menu position="bottom-end">
|
||||
@@ -57,7 +25,7 @@ export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => {
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={onImportClick} leftSection={<IconFileImport size="1rem" />}>
|
||||
<Menu.Item onClick={openImportModal} leftSection={<IconFileImport size="1rem" />}>
|
||||
<Group>
|
||||
{t("board.action.oldImport.label")}
|
||||
<BetaBadge size="xs" />
|
||||
|
||||
@@ -39,7 +39,7 @@ export default async function ManageBoardsPage() {
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<Title mb="md">{t("title")}</Title>
|
||||
<CreateBoardButton boardNames={boards.map((board) => board.name)} />
|
||||
<CreateBoardButton />
|
||||
</Group>
|
||||
|
||||
<Grid mb={{ base: "xl", md: 0 }}>
|
||||
|
||||
@@ -5,12 +5,11 @@ import { ActionIcon } from "@mantine/core";
|
||||
import { IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { revalidatePathActionAsync } from "../../../revalidatePathAction";
|
||||
|
||||
interface DeleteIntegrationActionButtonProps {
|
||||
count: number;
|
||||
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 { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { getAllSecretKindOptions, getDefaultSecretKinds } from "@homarr/definitions";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { convertIntegrationTestConnectionError } from "@homarr/integrations/client";
|
||||
@@ -15,7 +16,6 @@ import { useI18n } from "@homarr/translation/client";
|
||||
import type { z } from "@homarr/validation";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||
import { SecretCard } from "../../_components/secrets/integration-secret-card";
|
||||
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 { getIntegrationName } from "@homarr/definitions";
|
||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
import { IntegrationAvatar } from "@homarr/ui";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { IntegrationAccessSettings } from "../../_components/integration-access-settings";
|
||||
import { IntegrationAvatar } from "../../_integration-avatar";
|
||||
import { EditIntegrationForm } from "./_integration-edit-form";
|
||||
|
||||
interface EditIntegrationPageProps {
|
||||
|
||||
@@ -8,8 +8,7 @@ import { IconSearch } from "@tabler/icons-react";
|
||||
|
||||
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { IntegrationAvatar } from "../_integration-avatar";
|
||||
import { IntegrationAvatar } from "@homarr/ui";
|
||||
|
||||
export const IntegrationCreateDropdownContent = () => {
|
||||
const t = useI18n();
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Alert, Button, Fieldset, Group, SegmentedControl, Stack, Text, TextInpu
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
|
||||
import { getAllSecretKindOptions } from "@homarr/definitions";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
@@ -18,7 +19,6 @@ import type { z } from "@homarr/validation";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import { IntegrationSecretInput } from "../_components/secrets/integration-secret-inputs";
|
||||
import { revalidatePathActionAsync } from "../../../../revalidatePathAction";
|
||||
|
||||
interface NewIntegrationFormProps {
|
||||
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 { getIntegrationName, integrationKinds } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { IntegrationAvatar } from "@homarr/ui";
|
||||
import type { validation } from "@homarr/validation";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { IntegrationAvatar } from "../_integration-avatar";
|
||||
import { NewIntegrationForm } from "./_integration-new-form";
|
||||
|
||||
interface NewIntegrationPageProps {
|
||||
|
||||
@@ -34,12 +34,11 @@ import { objectEntries } from "@homarr/common";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { CountBadge } from "@homarr/ui";
|
||||
import { CountBadge, IntegrationAvatar } from "@homarr/ui";
|
||||
|
||||
import { ManageContainer } from "~/components/manage/manage-container";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
|
||||
import { IntegrationAvatar } from "./_integration-avatar";
|
||||
import { DeleteIntegrationActionButton } from "./_integration-buttons";
|
||||
import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown";
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
{
|
||||
label: t("items.help.items.documentation"),
|
||||
icon: IconBook2,
|
||||
href: "https://homarr.dev/docs/getting-started/prerequisites",
|
||||
href: "https://homarr.dev/docs/getting-started/",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
@@ -123,7 +123,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
label: t("items.tools.items.docker"),
|
||||
label: t("items.help.items.discord"),
|
||||
icon: IconBrandDiscord,
|
||||
href: "https://discord.com/invite/aCsmEV5RgA",
|
||||
external: true,
|
||||
|
||||
@@ -4,12 +4,12 @@ import React from "react";
|
||||
import { Card, LoadingOverlay, Stack, Title } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useForm } from "@homarr/form";
|
||||
import type { defaultServerSettings } from "@homarr/server-settings";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { SwitchSetting } from "~/app/[locale]/manage/settings/_components/setting-switch";
|
||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||
|
||||
interface AnalyticsSettingsProps {
|
||||
initialData: typeof defaultServerSettings.analytics;
|
||||
|
||||
@@ -4,12 +4,12 @@ import React from "react";
|
||||
import { Card, LoadingOverlay, Stack, Text, Title } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useForm } from "@homarr/form";
|
||||
import type { defaultServerSettings } from "@homarr/server-settings";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { SwitchSetting } from "~/app/[locale]/manage/settings/_components/setting-switch";
|
||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||
|
||||
interface CrawlingAndIndexingSettingsProps {
|
||||
initialData: typeof defaultServerSettings.crawlingAndIndexing;
|
||||
|
||||
@@ -4,14 +4,13 @@ import { Button, Group, Select, Stack } from "@mantine/core";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { z } from "@homarr/validation";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||
|
||||
interface ChangeHomeBoardFormProps {
|
||||
user: RouterOutputs["user"]["getById"];
|
||||
boardsData: { value: string; label: string }[];
|
||||
|
||||
@@ -6,11 +6,10 @@ import { Button } from "@mantine/core";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||
|
||||
interface DeleteUserButtonProps {
|
||||
user: RouterOutputs["user"]["getById"];
|
||||
}
|
||||
|
||||
@@ -7,13 +7,12 @@ import { IconPencil, IconPhotoEdit, IconPhotoX } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { UserAvatar } from "@homarr/ui";
|
||||
|
||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||
|
||||
interface UserProfileAvatarForm {
|
||||
user: RouterOutputs["user"]["getById"];
|
||||
}
|
||||
|
||||
@@ -5,13 +5,12 @@ import { Button, Group, Stack, TextInput } from "@mantine/core";
|
||||
|
||||
import type { RouterInputs, RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||
|
||||
interface UserProfileFormProps {
|
||||
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 { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { CustomPasswordInput } from "@homarr/ui";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||
|
||||
interface ChangePasswordFormProps {
|
||||
user: RouterOutputs["user"]["getById"];
|
||||
}
|
||||
|
||||
@@ -5,12 +5,11 @@ import { useRouter } from "next/navigation";
|
||||
import { Button } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||
|
||||
interface DeleteGroupProps {
|
||||
group: {
|
||||
id: string;
|
||||
|
||||
@@ -4,13 +4,12 @@ import { useCallback } from "react";
|
||||
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 { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||
|
||||
interface RenameGroupFormProps {
|
||||
group: {
|
||||
id: string;
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||
import { UserSelectModal } from "~/components/access/user-select-modal";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
|
||||
|
||||
@@ -4,11 +4,10 @@ import { useCallback } from "react";
|
||||
import { Button } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||
|
||||
interface RemoveGroupMemberProps {
|
||||
groupId: string;
|
||||
user: { id: string; name: string | null };
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { Button, Group, Stack, TextInput } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { createModal, useModalAction } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AddGroupModal } from "@homarr/modals-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
|
||||
export const AddGroup = () => {
|
||||
@@ -27,50 +22,3 @@ export const AddGroup = () => {
|
||||
</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 { clientApi } from "@homarr/api/client";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { InviteCreateModal } from "@homarr/modals-collection";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
|
||||
import { InviteCreateModal } from "./invite-create-modal";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface InviteListComponentProps {
|
||||
|
||||
@@ -107,7 +107,7 @@ export const BoardItemMenu = ({
|
||||
}}
|
||||
>
|
||||
{tItem("action.moveResize")}
|
||||
</Menu.Item>{" "}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconCopy size={16} />} onClick={() => duplicateItem({ itemId: item.id })}>
|
||||
{tItem("action.duplicate")}
|
||||
</Menu.Item>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useState } from "react";
|
||||
import { Combobox, Group, Image, InputBase, Skeleton, Text, useCombobox } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
interface IconPickerProps {
|
||||
initialValue?: string;
|
||||
@@ -18,7 +18,8 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
|
||||
const [search, setSearch] = useState(initialValue ?? "");
|
||||
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({
|
||||
searchText: search,
|
||||
@@ -89,13 +90,13 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
|
||||
rightSectionPointerEvents="none"
|
||||
withAsterisk
|
||||
error={error}
|
||||
label={t("iconPicker.label")}
|
||||
label={tCommon("iconPicker.label")}
|
||||
/>
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown>
|
||||
<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.Options mah={350} style={{ overflowY: "auto" }}>
|
||||
{totalOptions > 0 ? (
|
||||
|
||||
@@ -4,13 +4,13 @@ import { TextInput, UnstyledButton } from "@mantine/core";
|
||||
import { IconSearch } from "@tabler/icons-react";
|
||||
|
||||
import { openSpotlight } from "@homarr/spotlight";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { HeaderButton } from "./button";
|
||||
import classes from "./search.module.css";
|
||||
|
||||
export const DesktopSearchInput = () => {
|
||||
const t = useScopedI18n("common.search");
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
@@ -21,7 +21,10 @@ export const DesktopSearchInput = () => {
|
||||
leftSection={<IconSearch size={20} stroke={1.5} />}
|
||||
onClick={openSpotlight}
|
||||
>
|
||||
{t("placeholder")}
|
||||
{t("common.rtl", {
|
||||
value: t("search.placeholder"),
|
||||
symbol: "...",
|
||||
})}
|
||||
</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
|
||||
DB_URL: isUsingDbHost ? 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_PASSWORD: isUsingDbCredentials ? z.string() : z.string().optional(),
|
||||
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",
|
||||
"vitest": "^2.1.1"
|
||||
},
|
||||
"packageManager": "pnpm@9.10.0",
|
||||
"packageManager": "pnpm@9.11.0",
|
||||
"engines": {
|
||||
"node": ">=20.17.0"
|
||||
},
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"@trpc/react-query": "next",
|
||||
"@trpc/server": "next",
|
||||
"dockerode": "^4.0.2",
|
||||
"next": "^14.2.11",
|
||||
"next": "^14.2.13",
|
||||
"react": "^18.3.1",
|
||||
"superjson": "2.2.1",
|
||||
"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 ".";
|
||||
|
||||
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 { asc, createId, eq } from "@homarr/db";
|
||||
import { asc, createId, eq, like } from "@homarr/db";
|
||||
import { apps } from "@homarr/db/schema/sqlite";
|
||||
import { validation } from "@homarr/validation";
|
||||
import { validation, z } from "@homarr/validation";
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from "../trpc";
|
||||
|
||||
@@ -22,6 +22,15 @@ export const appRouter = createTRPCRouter({
|
||||
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 }) => {
|
||||
const app = await ctx.db.query.apps.findFirst({
|
||||
where: eq(apps.id, input.id),
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import superjson from "superjson";
|
||||
|
||||
import { constructBoardPermissions } from "@homarr/auth/shared";
|
||||
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 {
|
||||
boardGroupPermissions,
|
||||
boards,
|
||||
@@ -26,6 +27,20 @@ import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publ
|
||||
import { throwIfActionForbiddenAsync } from "./board/board-access";
|
||||
|
||||
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 }) => {
|
||||
const userId = ctx.session?.user.id;
|
||||
const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
|
||||
@@ -95,6 +110,79 @@ export const boardRouter = createTRPCRouter({
|
||||
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
|
||||
.requiresPermission("board-create")
|
||||
.input(validation.board.create)
|
||||
|
||||
@@ -3,9 +3,9 @@ import { TRPCError } from "@trpc/server";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { and, createId, eq, like, not, sql } from "@homarr/db";
|
||||
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({
|
||||
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 }) => {
|
||||
const normalizedName = normalizeName(input.name);
|
||||
await checkSimilarNameAndThrowAsync(ctx.db, normalizedName);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { decryptSecret, encryptSecret } from "@homarr/common/server";
|
||||
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 {
|
||||
groupPermissions,
|
||||
integrationGroupPermissions,
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from "@homarr/db/schema/sqlite";
|
||||
import type { IntegrationSecretKind } 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 { throwIfActionForbiddenAsync } from "./integration-access";
|
||||
@@ -33,6 +33,15 @@ export const integrationRouter = createTRPCRouter({
|
||||
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 }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
|
||||
const integration = await ctx.db.query.integrations.findFirst({
|
||||
|
||||
@@ -2,7 +2,7 @@ import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { createSaltAsync, hashPasswordAsync } from "@homarr/auth";
|
||||
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 type { SupportedAuthProvider } from "@homarr/definitions";
|
||||
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 }) => {
|
||||
const user = await ctx.db.query.users.findFirst({
|
||||
columns: {
|
||||
|
||||
@@ -23,8 +23,8 @@
|
||||
},
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@auth/core": "^0.34.2",
|
||||
"@auth/drizzle-adapter": "^1.4.2",
|
||||
"@auth/core": "^0.35.0",
|
||||
"@auth/drizzle-adapter": "^1.5.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
@@ -34,8 +34,8 @@
|
||||
"bcrypt": "^5.1.1",
|
||||
"cookies": "^0.9.1",
|
||||
"ldapts": "7.2.0",
|
||||
"next": "^14.2.11",
|
||||
"next-auth": "5.0.0-beta.20",
|
||||
"next": "^14.2.13",
|
||||
"next-auth": "5.0.0-beta.21",
|
||||
"react": "^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;
|
||||
|
||||
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:
|
||||
session?.user.id === creatorId ||
|
||||
board.userPermissions.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:
|
||||
session?.user.id === creatorId ||
|
||||
board.userPermissions.length >= 1 ||
|
||||
board.groupPermissions.length >= 1 ||
|
||||
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 { groupMembers, integrationGroupPermissions, integrationUserPermissions } from "@homarr/db/schema/sqlite";
|
||||
|
||||
@@ -26,9 +26,9 @@
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"dayjs": "^1.11.13",
|
||||
"next": "^14.2.11",
|
||||
"next": "^14.2.13",
|
||||
"react": "^18.3.1",
|
||||
"tldts": "^6.1.44"
|
||||
"tldts": "^6.1.47"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./app-url/client";
|
||||
export * from "./revalidate-path-action";
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
CREATE TABLE `integrationGroupPermissions` (
|
||||
`integration_id` varchar(64) NOT NULL,
|
||||
`group_id` varchar(64) NOT NULL,
|
||||
`permission` text NOT NULL,
|
||||
CONSTRAINT `integrationGroupPermissions_integration_id_group_id_permission_pk` PRIMARY KEY(`integration_id`,`group_id`,`permission`)
|
||||
`permission` varchar(128) NOT NULL,
|
||||
CONSTRAINT `integration_group_permission__pk` PRIMARY KEY(`integration_id`,`group_id`,`permission`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `integrationUserPermission` (
|
||||
`integration_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`)
|
||||
);
|
||||
--> 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 `width` 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;
|
||||
@@ -655,7 +655,7 @@
|
||||
},
|
||||
"permission": {
|
||||
"name": "permission",
|
||||
"type": "text",
|
||||
"type": "varchar(128)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
@@ -683,8 +683,8 @@
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"integrationGroupPermissions_integration_id_group_id_permission_pk": {
|
||||
"name": "integrationGroupPermissions_integration_id_group_id_permission_pk",
|
||||
"integration_group_permission__pk": {
|
||||
"name": "integration_group_permission__pk",
|
||||
"columns": ["integration_id", "group_id", "permission"]
|
||||
}
|
||||
},
|
||||
@@ -819,7 +819,7 @@
|
||||
},
|
||||
"permission": {
|
||||
"name": "permission",
|
||||
"type": "text",
|
||||
"type": "varchar(128)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
|
||||
@@ -655,7 +655,7 @@
|
||||
},
|
||||
"permission": {
|
||||
"name": "permission",
|
||||
"type": "text",
|
||||
"type": "varchar(128)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
@@ -683,8 +683,8 @@
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"integrationGroupPermissions_integration_id_group_id_permission_pk": {
|
||||
"name": "integrationGroupPermissions_integration_id_group_id_permission_pk",
|
||||
"integration_group_permission__pk": {
|
||||
"name": "integration_group_permission__pk",
|
||||
"columns": ["integration_id", "group_id", "permission"]
|
||||
}
|
||||
},
|
||||
@@ -819,7 +819,7 @@
|
||||
},
|
||||
"permission": {
|
||||
"name": "permission",
|
||||
"type": "text",
|
||||
"type": "varchar(128)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
|
||||
@@ -655,7 +655,7 @@
|
||||
},
|
||||
"permission": {
|
||||
"name": "permission",
|
||||
"type": "text",
|
||||
"type": "varchar(128)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
@@ -683,8 +683,8 @@
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"integrationGroupPermissions_integration_id_group_id_permission_pk": {
|
||||
"name": "integrationGroupPermissions_integration_id_group_id_permission_pk",
|
||||
"integration_group_permission__pk": {
|
||||
"name": "integration_group_permission__pk",
|
||||
"columns": ["integration_id", "group_id", "permission"]
|
||||
}
|
||||
},
|
||||
@@ -819,7 +819,7 @@
|
||||
},
|
||||
"permission": {
|
||||
"name": "permission",
|
||||
"type": "text",
|
||||
"type": "varchar(128)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
@@ -1109,7 +1109,7 @@
|
||||
},
|
||||
"parent_section_id": {
|
||||
"name": "parent_section_id",
|
||||
"type": "text",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
|
||||
@@ -655,7 +655,7 @@
|
||||
},
|
||||
"permission": {
|
||||
"name": "permission",
|
||||
"type": "text",
|
||||
"type": "varchar(128)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
@@ -683,8 +683,8 @@
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"integrationGroupPermissions_integration_id_group_id_permission_pk": {
|
||||
"name": "integrationGroupPermissions_integration_id_group_id_permission_pk",
|
||||
"integration_group_permission__pk": {
|
||||
"name": "integration_group_permission__pk",
|
||||
"columns": ["integration_id", "group_id", "permission"]
|
||||
}
|
||||
},
|
||||
@@ -819,7 +819,7 @@
|
||||
},
|
||||
"permission": {
|
||||
"name": "permission",
|
||||
"type": "text",
|
||||
"type": "varchar(128)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
@@ -1109,7 +1109,7 @@
|
||||
},
|
||||
"parent_section_id": {
|
||||
"name": "parent_section_id",
|
||||
"type": "text",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
|
||||
@@ -659,9 +659,9 @@
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"integrationGroupPermissions_integration_id_group_id_permission_pk": {
|
||||
"integration_group_permission__pk": {
|
||||
"columns": ["group_id", "integration_id", "permission"],
|
||||
"name": "integrationGroupPermissions_integration_id_group_id_permission_pk"
|
||||
"name": "integration_group_permission__pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {}
|
||||
|
||||
@@ -659,9 +659,9 @@
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"integrationGroupPermissions_integration_id_group_id_permission_pk": {
|
||||
"integration_group_permission__pk": {
|
||||
"columns": ["group_id", "integration_id", "permission"],
|
||||
"name": "integrationGroupPermissions_integration_id_group_id_permission_pk"
|
||||
"name": "integration_group_permission__pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {}
|
||||
|
||||
@@ -659,9 +659,9 @@
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"integrationGroupPermissions_integration_id_group_id_permission_pk": {
|
||||
"integration_group_permission__pk": {
|
||||
"columns": ["group_id", "integration_id", "permission"],
|
||||
"name": "integrationGroupPermissions_integration_id_group_id_permission_pk"
|
||||
"name": "integration_group_permission__pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {}
|
||||
|
||||
@@ -659,9 +659,9 @@
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"integrationGroupPermissions_integration_id_group_id_permission_pk": {
|
||||
"integration_group_permission__pk": {
|
||||
"columns": ["integration_id", "group_id", "permission"],
|
||||
"name": "integrationGroupPermissions_integration_id_group_id_permission_pk"
|
||||
"name": "integration_group_permission__pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {}
|
||||
|
||||
@@ -31,16 +31,17 @@
|
||||
},
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@auth/core": "^0.34.2",
|
||||
"@auth/core": "^0.35.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/log": "workspace:^0.1.0",
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"@testcontainers/mysql": "^10.13.1",
|
||||
"better-sqlite3": "^11.3.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"drizzle-kit": "^0.24.2",
|
||||
"drizzle-orm": "^0.33.0",
|
||||
"mysql2": "3.11.2"
|
||||
"mysql2": "3.11.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
|
||||
@@ -171,7 +171,7 @@ export const integrationUserPermissions = mysqlTable(
|
||||
userId: varchar("user_id", { length: 64 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
permission: text("permission").$type<IntegrationPermission>().notNull(),
|
||||
permission: varchar("permission", { length: 128 }).$type<IntegrationPermission>().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
compoundKey: primaryKey({
|
||||
@@ -189,11 +189,12 @@ export const integrationGroupPermissions = mysqlTable(
|
||||
groupId: varchar("group_id", { length: 64 })
|
||||
.notNull()
|
||||
.references(() => groups.id, { onDelete: "cascade" }),
|
||||
permission: text("permission").$type<IntegrationPermission>().notNull(),
|
||||
permission: varchar("permission", { length: 128 }).$type<IntegrationPermission>().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
compoundKey: primaryKey({
|
||||
columns: [table.integrationId, table.groupId, table.permission],
|
||||
name: "integration_group_permission__pk",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -276,7 +277,7 @@ export const sections = mysqlTable("section", {
|
||||
width: int("width"),
|
||||
height: int("height"),
|
||||
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",
|
||||
}),
|
||||
});
|
||||
|
||||
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"),
|
||||
"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(
|
||||
"Papirus",
|
||||
"PapirusDevelopmentTeam/papirus-icon-theme",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { parse } from "path";
|
||||
|
||||
import { fetchWithTimeout } from "@homarr/common";
|
||||
|
||||
import type { IconRepositoryLicense } from "../types/icon-repository-license";
|
||||
@@ -27,19 +29,23 @@ export class GitHubIconRepository extends IconRepository {
|
||||
return {
|
||||
success: true,
|
||||
icons: listOfFiles.tree
|
||||
.filter((treeItem) =>
|
||||
this.allowedImageFileTypes.some((allowedExtension) => treeItem.path.includes(allowedExtension)),
|
||||
.filter(({ path }) =>
|
||||
this.allowedImageFileTypes.some((allowedImageFileType) => parse(path).ext === allowedImageFileType),
|
||||
)
|
||||
.map((treeItem) => {
|
||||
const fileNameWithExtension = this.getFileNameWithoutExtensionFromPath(treeItem.path);
|
||||
.map(({ path, size: sizeInBytes, sha: checksum }) => {
|
||||
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 {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
imageUrl: new URL(this.repositoryBlobUrlTemplate!.replace("{0}", treeItem.path)),
|
||||
fileNameWithExtension: fileNameWithExtension,
|
||||
imageUrl,
|
||||
fileNameWithExtension,
|
||||
local: false,
|
||||
sizeInBytes: treeItem.size,
|
||||
checksum: treeItem.sha,
|
||||
sizeInBytes,
|
||||
checksum,
|
||||
};
|
||||
}),
|
||||
slug: this.slug,
|
||||
|
||||
@@ -29,8 +29,4 @@ export abstract class IconRepository {
|
||||
}
|
||||
|
||||
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 type { IconRepositoryLicense } from "../types/icon-repository-license";
|
||||
@@ -23,18 +25,19 @@ export class JsdelivrIconRepository extends IconRepository {
|
||||
return {
|
||||
success: true,
|
||||
icons: listOfFiles.files
|
||||
.filter((file) =>
|
||||
this.allowedImageFileTypes.some((allowedImageFileType) => file.name.includes(allowedImageFileType)),
|
||||
.filter(({ name: path }) =>
|
||||
this.allowedImageFileTypes.some((allowedImageFileType) => parse(path).ext === allowedImageFileType),
|
||||
)
|
||||
.map((file) => {
|
||||
const fileNameWithExtension = this.getFileNameWithoutExtensionFromPath(file.name);
|
||||
.map(({ name: path, size: sizeInBytes, hash: checksum }) => {
|
||||
const file = parse(path);
|
||||
const fileNameWithExtension = file.base;
|
||||
|
||||
return {
|
||||
imageUrl: new URL(this.repositoryBlobUrlTemplate.replace("{0}", file.name)),
|
||||
fileNameWithExtension: fileNameWithExtension,
|
||||
imageUrl: new URL(this.repositoryBlobUrlTemplate.replace("{0}", path).replace("{1}", file.name)),
|
||||
fileNameWithExtension,
|
||||
local: false,
|
||||
sizeInBytes: file.size,
|
||||
checksum: file.hash,
|
||||
sizeInBytes,
|
||||
checksum,
|
||||
};
|
||||
}),
|
||||
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": {
|
||||
"@ctrl/deluge": "^6.1.0",
|
||||
"@ctrl/qbittorrent": "^9.0.1",
|
||||
"@ctrl/transmission": "^6.1.0",
|
||||
"@ctrl/transmission": "^7.0.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/log": "workspace:^0.1.0",
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@jellyfin/sdk": "^0.10.0",
|
||||
"typed-rpc": "^5.1.0"
|
||||
"@jellyfin/sdk": "^0.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import dayjs from "dayjs";
|
||||
import { rpcClient } from "typed-rpc";
|
||||
|
||||
import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data";
|
||||
import { DownloadClientIntegration } from "../../interfaces/downloads/download-client-integration";
|
||||
@@ -9,16 +8,14 @@ import type { NzbGetClient } from "./nzbget-types";
|
||||
|
||||
export class NzbGetIntegration extends DownloadClientIntegration {
|
||||
public async testConnectionAsync(): Promise<void> {
|
||||
const client = this.getClient();
|
||||
await client.version();
|
||||
await this.nzbGetApiCallAsync("version");
|
||||
}
|
||||
|
||||
public async getClientJobsAndStatusAsync(): Promise<DownloadClientJobsAndStatus> {
|
||||
const type = "usenet";
|
||||
const nzbGetClient = this.getClient();
|
||||
const queue = await nzbGetClient.listgroups();
|
||||
const history = await nzbGetClient.history();
|
||||
const nzbGetStatus = await nzbGetClient.status();
|
||||
const queue = await this.nzbGetApiCallAsync("listgroups");
|
||||
const history = await this.nzbGetApiCallAsync("history");
|
||||
const nzbGetStatus = await this.nzbGetApiCallAsync("status");
|
||||
const status: DownloadClientStatus = {
|
||||
paused: nzbGetStatus.DownloadPaused,
|
||||
rates: { down: nzbGetStatus.DownloadRate },
|
||||
@@ -64,39 +61,55 @@ export class NzbGetIntegration extends DownloadClientIntegration {
|
||||
}
|
||||
|
||||
public async pauseQueueAsync() {
|
||||
await this.getClient().pausedownload();
|
||||
await this.nzbGetApiCallAsync("pausedownload");
|
||||
}
|
||||
|
||||
public async pauseItemAsync({ id }: DownloadClientItem): Promise<void> {
|
||||
await this.getClient().editqueue("GroupPause", "", [Number(id)]);
|
||||
await this.nzbGetApiCallAsync("editqueue", "GroupPause", "", [Number(id)]);
|
||||
}
|
||||
|
||||
public async resumeQueueAsync() {
|
||||
await this.getClient().resumedownload();
|
||||
await this.nzbGetApiCallAsync("resumedownload");
|
||||
}
|
||||
|
||||
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> {
|
||||
const client = this.getClient();
|
||||
if (fromDisk) {
|
||||
const filesIds = (await client.listfiles(0, 0, Number(id))).map((value) => value.ID);
|
||||
await this.getClient().editqueue("FileDelete", "", filesIds);
|
||||
const filesIds = (await this.nzbGetApiCallAsync("listfiles", 0, 0, Number(id))).map((file) => file.ID);
|
||||
await this.nzbGetApiCallAsync("editqueue", "FileDelete", "", filesIds);
|
||||
}
|
||||
if (progress !== 1) {
|
||||
await client.editqueue("GroupFinalDelete", "", [Number(id)]);
|
||||
if (progress === 1) {
|
||||
await this.nzbGetApiCallAsync("editqueue", "GroupFinalDelete", "", [Number(id)]);
|
||||
} 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);
|
||||
url.pathname += `${this.getSecretValue("username")}:${this.getSecretValue("password")}`;
|
||||
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"] {
|
||||
|
||||
@@ -125,7 +125,7 @@ describe("Nzbget integration", () => {
|
||||
|
||||
// Act
|
||||
const getAsync = async () => await nzbGetIntegration.getClientJobsAndStatusAsync();
|
||||
const actAsync = async () => await nzbGetIntegration.deleteItemAsync(item, false);
|
||||
const actAsync = async () => await nzbGetIntegration.deleteItemAsync(item, true);
|
||||
|
||||
// Assert
|
||||
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 { 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";
|
||||
@@ -10,24 +11,21 @@ import { oldmarrConfigSchema } from "@homarr/old-schema";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { SelectWithDescription } from "@homarr/ui";
|
||||
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 {
|
||||
boardNames: string[];
|
||||
}
|
||||
|
||||
export const ImportBoardModal = createModal<InnerProps>(({ actions, innerProps }) => {
|
||||
export const ImportBoardModal = createModal(({ actions }) => {
|
||||
const tOldImport = useScopedI18n("board.action.oldImport");
|
||||
const tCommon = useScopedI18n("common");
|
||||
const [fileValid, setFileValid] = useState(true);
|
||||
const form = useZodForm(
|
||||
z.object({
|
||||
file: z.instanceof(File).nullable().superRefine(superRefineJsonImportFile),
|
||||
configuration: createOldmarrImportConfigurationSchema(innerProps.boardNames),
|
||||
configuration: oldmarrImportConfigurationSchema,
|
||||
}),
|
||||
{
|
||||
mode: "controlled",
|
||||
initialValues: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
file: null!,
|
||||
@@ -67,6 +65,7 @@ export const ImportBoardModal = createModal<InnerProps>(({ actions, innerProps }
|
||||
);
|
||||
|
||||
const { mutateAsync, isPending } = clientApi.board.importOldmarrConfig.useMutation();
|
||||
const boardNameStatus = useBoardNameStatus(form.values.configuration.name);
|
||||
|
||||
const handleSubmitAsync = async (values: { file: File; configuration: OldmarrImportConfiguration }) => {
|
||||
const formData = new FormData();
|
||||
@@ -94,7 +93,7 @@ export const ImportBoardModal = createModal<InnerProps>(({ actions, innerProps }
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
if (!fileValid) {
|
||||
if (!fileValid || !boardNameStatus.canSubmit) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -139,7 +138,19 @@ export const ImportBoardModal = createModal<InnerProps>(({ actions, innerProps }
|
||||
</Grid>
|
||||
</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
|
||||
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": {
|
||||
"@homarr/ui": "workspace:^0.1.0",
|
||||
"@mantine/notifications": "^7.12.2",
|
||||
"@tabler/icons-react": "^3.16.0"
|
||||
"@tabler/icons-react": "^3.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"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/ui": "workspace:^0.1.0",
|
||||
"@mantine/core": "^7.12.2",
|
||||
"@mantine/hooks": "^7.12.2",
|
||||
"@mantine/spotlight": "^7.12.2",
|
||||
"@tabler/icons-react": "^3.16.0",
|
||||
"jotai": "^2.9.3",
|
||||
"next": "^14.2.11",
|
||||
"@tabler/icons-react": "^3.17.0",
|
||||
"jotai": "^2.10.0",
|
||||
"next": "^14.2.13",
|
||||
"react": "^18.3.1",
|
||||
"use-deep-compare-effect": "^1.8.1"
|
||||
},
|
||||
@@ -40,5 +45,6 @@
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.10.0",
|
||||
"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