chore(release): automatic release v0.1.0
This commit is contained in:
26
.github/workflows/code-quality.yml
vendored
26
.github/workflows/code-quality.yml
vendored
@@ -67,11 +67,33 @@ jobs:
|
|||||||
- name: Test
|
- name: Test
|
||||||
run: pnpm test
|
run: pnpm test
|
||||||
|
|
||||||
- name: 'Report Coverage'
|
- name: "Report Coverage"
|
||||||
# Set if: always() to also generate the report if tests are failing
|
# Set if: always() to also generate the report if tests are failing
|
||||||
# Only works if you set `reportOnFailure: true` in your vite config as specified above
|
# Only works if you set `reportOnFailure: true` in your vite config as specified above
|
||||||
if: always()
|
if: always()
|
||||||
uses: davelosert/vitest-coverage-report-action@v2
|
uses: davelosert/vitest-coverage-report-action@v2
|
||||||
|
|
||||||
|
e2e:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Setup
|
||||||
|
uses: ./tooling/github/setup
|
||||||
|
- name: Build docker image
|
||||||
|
id: build-docker-image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
platforms: linux/amd64
|
||||||
|
context: .
|
||||||
|
push: false
|
||||||
|
tags: homarr-e2e
|
||||||
|
network: host
|
||||||
|
env:
|
||||||
|
SKIP_ENV_VALIDATION: true
|
||||||
|
- name: Run E2E Tests
|
||||||
|
shell: bash
|
||||||
|
run: pnpm test:e2e
|
||||||
|
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
25
.github/workflows/deployment-docker-image.yml
vendored
25
.github/workflows/deployment-docker-image.yml
vendored
@@ -33,9 +33,6 @@ jobs:
|
|||||||
deploy:
|
deploy:
|
||||||
name: Deploy docker image
|
name: Deploy docker image
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
node-version: [20]
|
|
||||||
steps:
|
steps:
|
||||||
- name: Discord notification
|
- name: Discord notification
|
||||||
if: ${{ github.events.inputs.send-notifications != false }}
|
if: ${{ github.events.inputs.send-notifications != false }}
|
||||||
@@ -57,21 +54,7 @@ jobs:
|
|||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
uses: Ilshidur/action-discord@master
|
uses: Ilshidur/action-discord@master
|
||||||
with:
|
with:
|
||||||
args: "Semver computed next tag to be ${{ steps.semver.outputs.next }}. Current is ${{ steps.semver.outputs.current }}"
|
args: "Semver computed next tag to be ${{ steps.semver.outputs.next }}. Current is ${{ steps.semver.outputs.current }}. Building images..."
|
||||||
- uses: pnpm/action-setup@v2
|
|
||||||
with:
|
|
||||||
version: 8
|
|
||||||
- name: Use Node.js ${{ matrix.node-version }}
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: ${{ matrix.node-version }}
|
|
||||||
- name: Discord notification
|
|
||||||
if: ${{ github.events.inputs.send-notifications != false }}
|
|
||||||
env:
|
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
|
||||||
uses: Ilshidur/action-discord@master
|
|
||||||
with:
|
|
||||||
args: "Built application artifacts. Building images..."
|
|
||||||
- name: Log in to the Container registry
|
- name: Log in to the Container registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
@@ -90,9 +73,9 @@ jobs:
|
|||||||
tags: |
|
tags: |
|
||||||
type=raw,value=alpha
|
type=raw,value=alpha
|
||||||
type=raw,value=early-adopters
|
type=raw,value=early-adopters
|
||||||
# tags: |
|
# tags: |
|
||||||
# type=raw,value=latest
|
# type=raw,value=latest
|
||||||
# type=raw,value=${{ steps.semver.outputs.next }}
|
# type=raw,value=${{ steps.semver.outputs.next }}
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
id: buildPushAction
|
id: buildPushAction
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
|
|||||||
@@ -59,8 +59,6 @@ WORKDIR /app
|
|||||||
# gettext is required for envsubst
|
# gettext is required for envsubst
|
||||||
RUN apk add --no-cache redis nginx bash gettext
|
RUN apk add --no-cache redis nginx bash gettext
|
||||||
RUN mkdir /appdata
|
RUN mkdir /appdata
|
||||||
RUN mkdir /appdata/db
|
|
||||||
RUN mkdir /appdata/redis
|
|
||||||
VOLUME /appdata
|
VOLUME /appdata
|
||||||
RUN mkdir /secrets
|
RUN mkdir /secrets
|
||||||
VOLUME /secrets
|
VOLUME /secrets
|
||||||
|
|||||||
@@ -37,17 +37,17 @@
|
|||||||
"@homarr/ui": "workspace:^0.1.0",
|
"@homarr/ui": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@homarr/widgets": "workspace:^0.1.0",
|
"@homarr/widgets": "workspace:^0.1.0",
|
||||||
"@mantine/colors-generator": "^7.13.4",
|
"@mantine/colors-generator": "^7.13.5",
|
||||||
"@mantine/core": "^7.13.4",
|
"@mantine/core": "^7.13.5",
|
||||||
"@mantine/hooks": "^7.13.4",
|
"@mantine/hooks": "^7.13.5",
|
||||||
"@mantine/modals": "^7.13.4",
|
"@mantine/modals": "^7.13.5",
|
||||||
"@mantine/tiptap": "^7.13.4",
|
"@mantine/tiptap": "^7.13.5",
|
||||||
"@million/lint": "1.0.11",
|
"@million/lint": "1.0.11",
|
||||||
"@t3-oss/env-nextjs": "^0.11.1",
|
"@t3-oss/env-nextjs": "^0.11.1",
|
||||||
"@tabler/icons-react": "^3.21.0",
|
"@tabler/icons-react": "^3.21.0",
|
||||||
"@tanstack/react-query": "^5.59.19",
|
"@tanstack/react-query": "^5.59.20",
|
||||||
"@tanstack/react-query-devtools": "^5.59.19",
|
"@tanstack/react-query-devtools": "^5.59.20",
|
||||||
"@tanstack/react-query-next-experimental": "5.59.19",
|
"@tanstack/react-query-next-experimental": "5.59.20",
|
||||||
"@trpc/client": "next",
|
"@trpc/client": "next",
|
||||||
"@trpc/next": "next",
|
"@trpc/next": "next",
|
||||||
"@trpc/react-query": "next",
|
"@trpc/react-query": "next",
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
"glob": "^11.0.0",
|
"glob": "^11.0.0",
|
||||||
"jotai": "^2.10.1",
|
"jotai": "^2.10.1",
|
||||||
"mantine-react-table": "2.0.0-beta.7",
|
"mantine-react-table": "2.0.0-beta.7",
|
||||||
"next": "^14.2.16",
|
"next": "^14.2.17",
|
||||||
"postcss-preset-mantine": "^1.17.0",
|
"postcss-preset-mantine": "^1.17.0",
|
||||||
"prismjs": "^1.29.0",
|
"prismjs": "^1.29.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
"react-simple-code-editor": "^0.14.1",
|
"react-simple-code-editor": "^0.14.1",
|
||||||
"sass": "^1.80.6",
|
"sass": "^1.80.6",
|
||||||
"superjson": "2.2.1",
|
"superjson": "2.2.1",
|
||||||
"swagger-ui-react": "^5.17.14",
|
"swagger-ui-react": "^5.18.2",
|
||||||
"use-deep-compare-effect": "^1.8.1"
|
"use-deep-compare-effect": "^1.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/chroma-js": "2.4.4",
|
"@types/chroma-js": "2.4.4",
|
||||||
"@types/node": "^22.8.7",
|
"@types/node": "^22.9.0",
|
||||||
"@types/prismjs": "^1.26.5",
|
"@types/prismjs": "^1.26.5",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Button, Grid, Group, Stack, Textarea, TextInput } from "@mantine/core";
|
import type { SegmentedControlItem } from "@mantine/core";
|
||||||
|
import { Button, Fieldset, Grid, Group, SegmentedControl, Stack, Textarea, TextInput } from "@mantine/core";
|
||||||
|
import { WidgetIntegrationSelect } from "node_modules/@homarr/widgets/src/widget-integration-select";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { searchEngineTypes } from "@homarr/definitions";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import type { TranslationFunction } from "@homarr/translation";
|
import type { TranslationFunction } from "@homarr/translation";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
@@ -25,6 +29,8 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
|
|||||||
const { submitButtonTranslation, handleSubmit, initialValues, isPending, disableShort } = props;
|
const { submitButtonTranslation, handleSubmit, initialValues, isPending, disableShort } = props;
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
|
|
||||||
|
const [integrationData] = clientApi.integration.allThatSupportSearch.useSuspenseQuery();
|
||||||
|
|
||||||
const form = useZodForm(validation.searchEngine.manage, {
|
const form = useZodForm(validation.searchEngine.manage, {
|
||||||
initialValues: initialValues ?? {
|
initialValues: initialValues ?? {
|
||||||
name: "",
|
name: "",
|
||||||
@@ -32,6 +38,7 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
|
|||||||
iconUrl: "",
|
iconUrl: "",
|
||||||
urlTemplate: "",
|
urlTemplate: "",
|
||||||
description: "",
|
description: "",
|
||||||
|
type: "generic",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,11 +59,40 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
|
|||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
</Grid>
|
</Grid>
|
||||||
<IconPicker initialValue={initialValues?.iconUrl} {...form.getInputProps("iconUrl")} />
|
<IconPicker initialValue={initialValues?.iconUrl} {...form.getInputProps("iconUrl")} />
|
||||||
<TextInput
|
|
||||||
{...form.getInputProps("urlTemplate")}
|
<Fieldset legend={t("search.engine.page.edit.configControl")}>
|
||||||
withAsterisk
|
<SegmentedControl
|
||||||
label={t("search.engine.field.urlTemplate.label")}
|
data={searchEngineTypes.map(
|
||||||
/>
|
(type) =>
|
||||||
|
({
|
||||||
|
label: t(`search.engine.page.edit.searchEngineType.${type}`),
|
||||||
|
value: type,
|
||||||
|
}) satisfies SegmentedControlItem,
|
||||||
|
)}
|
||||||
|
{...form.getInputProps("type")}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
|
||||||
|
{form.values.type === "generic" && (
|
||||||
|
<TextInput
|
||||||
|
{...form.getInputProps("urlTemplate")}
|
||||||
|
withAsterisk
|
||||||
|
label={t("search.engine.field.urlTemplate.label")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.values.type === "fromIntegration" && (
|
||||||
|
<WidgetIntegrationSelect
|
||||||
|
label="Integration"
|
||||||
|
data={integrationData}
|
||||||
|
canSelectMultiple={false}
|
||||||
|
onChange={(value) => form.setFieldValue("integrationId", value[0])}
|
||||||
|
value={form.values.integrationId !== undefined ? [form.values.integrationId] : []}
|
||||||
|
withAsterisk
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Fieldset>
|
||||||
|
|
||||||
<Textarea {...form.getInputProps("description")} label={t("search.engine.field.description.label")} />
|
<Textarea {...form.getInputProps("description")} label={t("search.engine.field.description.label")} />
|
||||||
|
|
||||||
<Group justify="end">
|
<Group justify="end">
|
||||||
|
|||||||
@@ -91,9 +91,16 @@ const SearchEngineCard = async ({ searchEngine }: SearchEngineCardProps) => {
|
|||||||
{searchEngine.description}
|
{searchEngine.description}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<Anchor href={searchEngine.urlTemplate.replace("%s", "test")} lineClamp={1} size="sm">
|
{searchEngine.type === "generic" && searchEngine.urlTemplate !== null && (
|
||||||
{searchEngine.urlTemplate}
|
<Anchor href={searchEngine.urlTemplate.replace("%s", "test")} lineClamp={1} size="sm">
|
||||||
</Anchor>
|
{searchEngine.urlTemplate}
|
||||||
|
</Anchor>
|
||||||
|
)}
|
||||||
|
{searchEngine.type === "fromIntegration" && searchEngine.integrationId !== null && (
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
{t("page.list.interactive")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
<Group>
|
<Group>
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/node": "^22.8.7",
|
"@types/node": "^22.9.0",
|
||||||
"dotenv-cli": "^7.4.2",
|
"dotenv-cli": "^7.4.2",
|
||||||
"eslint": "^9.14.0",
|
"eslint": "^9.14.0",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
|
|||||||
18
e2e/health-checks.spec.ts
Normal file
18
e2e/health-checks.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { createHomarrContainer } from "./shared/create-homarr-container";
|
||||||
|
|
||||||
|
describe("Health checks", () => {
|
||||||
|
test("ready and live should return 200 OK", async () => {
|
||||||
|
// Arrange
|
||||||
|
const homarrContainer = await createHomarrContainer().start();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const readyResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/ready`);
|
||||||
|
const liveResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/live`);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(readyResponse.status).toBe(200);
|
||||||
|
expect(liveResponse.status).toBe(200);
|
||||||
|
}, 20_000);
|
||||||
|
});
|
||||||
16
e2e/home.spec.ts
Normal file
16
e2e/home.spec.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { createHomarrContainer } from "./shared/create-homarr-container";
|
||||||
|
|
||||||
|
describe("Home", () => {
|
||||||
|
test("should open with status code 200", async () => {
|
||||||
|
// Arrange
|
||||||
|
const homarrContainer = await createHomarrContainer().start();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const homeResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/`);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(homeResponse.status).toBe(200);
|
||||||
|
}, 20_000);
|
||||||
|
});
|
||||||
14
e2e/shared/create-homarr-container.ts
Normal file
14
e2e/shared/create-homarr-container.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { GenericContainer, Wait } from "testcontainers";
|
||||||
|
|
||||||
|
export const createHomarrContainer = () => {
|
||||||
|
if (!process.env.CI) {
|
||||||
|
throw new Error("This test should only be run in CI or with a homarr image named 'homarr-e2e'");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new GenericContainer("homarr-e2e")
|
||||||
|
.withEnvironment({
|
||||||
|
AUTH_SECRET: "secret",
|
||||||
|
})
|
||||||
|
.withExposedPorts(7575)
|
||||||
|
.withWaitStrategy(Wait.forHttp("/api/health/ready", 7575));
|
||||||
|
};
|
||||||
@@ -19,8 +19,9 @@
|
|||||||
"lint": "turbo lint --continue -- --cache --cache-location node_modules/.cache/.eslintcache",
|
"lint": "turbo lint --continue -- --cache --cache-location node_modules/.cache/.eslintcache",
|
||||||
"lint:fix": "turbo lint --continue -- --fix --cache --cache-location node_modules/.cache/.eslintcache",
|
"lint:fix": "turbo lint --continue -- --fix --cache --cache-location node_modules/.cache/.eslintcache",
|
||||||
"lint:ws": "pnpm dlx sherif@latest",
|
"lint:ws": "pnpm dlx sherif@latest",
|
||||||
"test": "cross-env NODE_ENV=development vitest run --coverage.enabled",
|
"test": "cross-env NODE_ENV=development vitest run --exclude e2e --coverage.enabled ",
|
||||||
"test:ui": "cross-env NODE_ENV=development vitest --ui --coverage.enabled",
|
"test:e2e": "cross-env NODE_ENV=development vitest e2e",
|
||||||
|
"test:ui": "cross-env NODE_ENV=development vitest --exclude e2e --ui --coverage.enabled",
|
||||||
"typecheck": "turbo typecheck",
|
"typecheck": "turbo typecheck",
|
||||||
"with-env": "dotenv -e .env --"
|
"with-env": "dotenv -e .env --"
|
||||||
},
|
},
|
||||||
@@ -34,7 +35,7 @@
|
|||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"jsdom": "^25.0.1",
|
"jsdom": "^25.0.1",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"testcontainers": "^10.13.2",
|
"testcontainers": "^10.14.0",
|
||||||
"turbo": "^2.2.3",
|
"turbo": "^2.2.3",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
"vite-tsconfig-paths": "^5.1.0",
|
"vite-tsconfig-paths": "^5.1.0",
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
"@trpc/react-query": "next",
|
"@trpc/react-query": "next",
|
||||||
"@trpc/server": "next",
|
"@trpc/server": "next",
|
||||||
"dockerode": "^4.0.2",
|
"dockerode": "^4.0.2",
|
||||||
"next": "^14.2.16",
|
"next": "^14.2.17",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"superjson": "2.2.1",
|
"superjson": "2.2.1",
|
||||||
"trpc-swagger": "^1.2.6"
|
"trpc-swagger": "^1.2.6"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
|
import { objectEntries } from "@homarr/common";
|
||||||
import { decryptSecret, encryptSecret } from "@homarr/common/server";
|
import { decryptSecret, encryptSecret } from "@homarr/common/server";
|
||||||
import type { Database } from "@homarr/db";
|
import type { Database } from "@homarr/db";
|
||||||
import { and, asc, createId, eq, inArray, like } from "@homarr/db";
|
import { and, asc, createId, eq, inArray, like } from "@homarr/db";
|
||||||
@@ -12,7 +13,14 @@ import {
|
|||||||
integrationUserPermissions,
|
integrationUserPermissions,
|
||||||
} from "@homarr/db/schema/sqlite";
|
} from "@homarr/db/schema/sqlite";
|
||||||
import type { IntegrationSecretKind } from "@homarr/definitions";
|
import type { IntegrationSecretKind } from "@homarr/definitions";
|
||||||
import { getPermissionsWithParents, integrationKinds, integrationSecretKindObject } from "@homarr/definitions";
|
import {
|
||||||
|
getPermissionsWithParents,
|
||||||
|
integrationDefs,
|
||||||
|
integrationKinds,
|
||||||
|
integrationSecretKindObject,
|
||||||
|
isIntegrationWithSearchSupport,
|
||||||
|
} from "@homarr/definitions";
|
||||||
|
import { integrationCreatorFromSecrets } from "@homarr/integrations";
|
||||||
import { validation, z } from "@homarr/validation";
|
import { validation, z } from "@homarr/validation";
|
||||||
|
|
||||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
|
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
|
||||||
@@ -62,6 +70,54 @@ export const integrationRouter = createTRPCRouter({
|
|||||||
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
|
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
allThatSupportSearch: publicProcedure.query(async ({ ctx }) => {
|
||||||
|
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
|
||||||
|
where: eq(groupMembers.userId, ctx.session?.user.id ?? ""),
|
||||||
|
});
|
||||||
|
|
||||||
|
const integrationsFromDb = await ctx.db.query.integrations.findMany({
|
||||||
|
with: {
|
||||||
|
userPermissions: {
|
||||||
|
where: eq(integrationUserPermissions.userId, ctx.session?.user.id ?? ""),
|
||||||
|
},
|
||||||
|
groupPermissions: {
|
||||||
|
where: inArray(
|
||||||
|
integrationGroupPermissions.groupId,
|
||||||
|
groupsOfCurrentUser.map((group) => group.groupId),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
where: inArray(
|
||||||
|
integrations.kind,
|
||||||
|
objectEntries(integrationDefs)
|
||||||
|
.filter(([_, integration]) => integration.supportsSearch)
|
||||||
|
.map(([kind, _]) => kind),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
return integrationsFromDb
|
||||||
|
.map((integration) => {
|
||||||
|
const permissions = integration.userPermissions
|
||||||
|
.map(({ permission }) => permission)
|
||||||
|
.concat(integration.groupPermissions.map(({ permission }) => permission));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: integration.id,
|
||||||
|
name: integration.name,
|
||||||
|
kind: integration.kind,
|
||||||
|
url: integration.url,
|
||||||
|
permissions: {
|
||||||
|
hasUseAccess:
|
||||||
|
permissions.includes("use") || permissions.includes("interact") || permissions.includes("full"),
|
||||||
|
hasInteractAccess: permissions.includes("interact") || permissions.includes("full"),
|
||||||
|
hasFullAccess: permissions.includes("full"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort(
|
||||||
|
(integrationA, integrationB) =>
|
||||||
|
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
|
||||||
|
);
|
||||||
|
}),
|
||||||
search: protectedProcedure
|
search: protectedProcedure
|
||||||
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
|
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
@@ -326,6 +382,33 @@ export const integrationRouter = createTRPCRouter({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
searchInIntegration: protectedProcedure
|
||||||
|
.input(z.object({ integrationId: z.string(), query: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const integration = await ctx.db.query.integrations.findFirst({
|
||||||
|
where: eq(integrations.id, input.integrationId),
|
||||||
|
with: {
|
||||||
|
secrets: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!integration) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "The requested integration does not exist",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isIntegrationWithSearchSupport(integration)) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "The requested integration does not support searching",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const integrationInstance = integrationCreatorFromSecrets(integration);
|
||||||
|
return await integrationInstance.searchAsync(input.query);
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
interface UpdateSecretInput {
|
interface UpdateSecretInput {
|
||||||
|
|||||||
@@ -39,7 +39,19 @@ export const searchEngineRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return searchEngine;
|
return searchEngine.type === "fromIntegration"
|
||||||
|
? {
|
||||||
|
...searchEngine,
|
||||||
|
type: "fromIntegration" as const,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
integrationId: searchEngine.integrationId!,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
...searchEngine,
|
||||||
|
type: "generic" as const,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
urlTemplate: searchEngine.urlTemplate!,
|
||||||
|
};
|
||||||
}),
|
}),
|
||||||
search: protectedProcedure.input(validation.common.search).query(async ({ ctx, input }) => {
|
search: protectedProcedure.input(validation.common.search).query(async ({ ctx, input }) => {
|
||||||
return await ctx.db.query.searchEngines.findMany({
|
return await ctx.db.query.searchEngines.findMany({
|
||||||
@@ -53,8 +65,10 @@ export const searchEngineRouter = createTRPCRouter({
|
|||||||
name: input.name,
|
name: input.name,
|
||||||
short: input.short.toLowerCase(),
|
short: input.short.toLowerCase(),
|
||||||
iconUrl: input.iconUrl,
|
iconUrl: input.iconUrl,
|
||||||
urlTemplate: input.urlTemplate,
|
urlTemplate: "urlTemplate" in input ? input.urlTemplate : null,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
|
type: input.type,
|
||||||
|
integrationId: "integrationId" in input ? input.integrationId : null,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
update: protectedProcedure.input(validation.searchEngine.edit).mutation(async ({ ctx, input }) => {
|
update: protectedProcedure.input(validation.searchEngine.edit).mutation(async ({ ctx, input }) => {
|
||||||
@@ -74,8 +88,10 @@ export const searchEngineRouter = createTRPCRouter({
|
|||||||
.set({
|
.set({
|
||||||
name: input.name,
|
name: input.name,
|
||||||
iconUrl: input.iconUrl,
|
iconUrl: input.iconUrl,
|
||||||
urlTemplate: input.urlTemplate,
|
urlTemplate: "urlTemplate" in input ? input.urlTemplate : null,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
|
integrationId: "integrationId" in input ? input.integrationId : null,
|
||||||
|
type: input.type,
|
||||||
})
|
})
|
||||||
.where(eq(searchEngines.id, input.id));
|
.where(eq(searchEngines.id, input.id));
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"cookies": "^0.9.1",
|
"cookies": "^0.9.1",
|
||||||
"ldapts": "7.2.1",
|
"ldapts": "7.2.1",
|
||||||
"next": "^14.2.16",
|
"next": "^14.2.17",
|
||||||
"next-auth": "5.0.0-beta.25",
|
"next-auth": "5.0.0-beta.25",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config",
|
"prettier": "@homarr/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@drizzle-team/brocli": "^0.10.2",
|
"@drizzle-team/brocli": "^0.11.0",
|
||||||
"@homarr/auth": "workspace:^0.1.0",
|
"@homarr/auth": "workspace:^0.1.0",
|
||||||
"@homarr/common": "workspace:^0.1.0",
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
"@homarr/db": "workspace:^0.1.0",
|
"@homarr/db": "workspace:^0.1.0",
|
||||||
|
|||||||
@@ -27,9 +27,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@homarr/log": "workspace:^0.1.0",
|
"@homarr/log": "workspace:^0.1.0",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"next": "^14.2.16",
|
"next": "^14.2.17",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"tldts": "^6.1.58"
|
"tldts": "^6.1.59"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
|
|||||||
4
packages/db/migrations/mysql/0015_unknown_firedrake.sql
Normal file
4
packages/db/migrations/mysql/0015_unknown_firedrake.sql
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE `search_engine` MODIFY COLUMN `url_template` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `search_engine` ADD `type` varchar(64) DEFAULT 'generic' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE `search_engine` ADD `integration_id` varchar(64);--> statement-breakpoint
|
||||||
|
ALTER TABLE `search_engine` ADD CONSTRAINT `search_engine_integration_id_integration_id_fk` FOREIGN KEY (`integration_id`) REFERENCES `integration`(`id`) ON DELETE cascade ON UPDATE no action;
|
||||||
1627
packages/db/migrations/mysql/meta/0015_snapshot.json
Normal file
1627
packages/db/migrations/mysql/meta/0015_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -106,6 +106,13 @@
|
|||||||
"when": 1729524382483,
|
"when": 1729524382483,
|
||||||
"tag": "0014_bizarre_red_shift",
|
"tag": "0014_bizarre_red_shift",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 15,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1730653393442,
|
||||||
|
"tag": "0015_unknown_firedrake",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
17
packages/db/migrations/sqlite/0015_superb_psylocke.sql
Normal file
17
packages/db/migrations/sqlite/0015_superb_psylocke.sql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||||
|
CREATE TABLE `__new_search_engine` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`icon_url` text NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`short` text NOT NULL,
|
||||||
|
`description` text,
|
||||||
|
`url_template` text,
|
||||||
|
`type` text DEFAULT 'generic' NOT NULL,
|
||||||
|
`integration_id` text,
|
||||||
|
FOREIGN KEY (`integration_id`) REFERENCES `integration`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO `__new_search_engine`("id", "icon_url", "name", "short", "description", "url_template") SELECT "id", "icon_url", "name", "short", "description", "url_template" FROM `search_engine`;--> statement-breakpoint
|
||||||
|
DROP TABLE `search_engine`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `__new_search_engine` RENAME TO `search_engine`;--> statement-breakpoint
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
1556
packages/db/migrations/sqlite/meta/0015_snapshot.json
Normal file
1556
packages/db/migrations/sqlite/meta/0015_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -106,6 +106,13 @@
|
|||||||
"when": 1729524387583,
|
"when": 1729524387583,
|
||||||
"tag": "0014_colorful_cargill",
|
"tag": "0014_colorful_cargill",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 15,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1730653336134,
|
||||||
|
"tag": "0015_superb_psylocke",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,12 +41,12 @@
|
|||||||
"@homarr/log": "workspace:^0.1.0",
|
"@homarr/log": "workspace:^0.1.0",
|
||||||
"@homarr/server-settings": "workspace:^0.1.0",
|
"@homarr/server-settings": "workspace:^0.1.0",
|
||||||
"@paralleldrive/cuid2": "^2.2.2",
|
"@paralleldrive/cuid2": "^2.2.2",
|
||||||
"@testcontainers/mysql": "^10.13.2",
|
"@testcontainers/mysql": "^10.14.0",
|
||||||
"better-sqlite3": "^11.5.0",
|
"better-sqlite3": "^11.5.0",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"drizzle-kit": "^0.27.1",
|
"drizzle-kit": "^0.28.0",
|
||||||
"drizzle-orm": "^0.36.0",
|
"drizzle-orm": "^0.36.1",
|
||||||
"mysql2": "3.11.3"
|
"mysql2": "3.11.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import type {
|
|||||||
IntegrationKind,
|
IntegrationKind,
|
||||||
IntegrationPermission,
|
IntegrationPermission,
|
||||||
IntegrationSecretKind,
|
IntegrationSecretKind,
|
||||||
|
SearchEngineType,
|
||||||
SectionKind,
|
SectionKind,
|
||||||
SupportedAuthProvider,
|
SupportedAuthProvider,
|
||||||
WidgetKind,
|
WidgetKind,
|
||||||
@@ -395,7 +396,9 @@ export const searchEngines = mysqlTable("search_engine", {
|
|||||||
name: varchar("name", { length: 64 }).notNull(),
|
name: varchar("name", { length: 64 }).notNull(),
|
||||||
short: varchar("short", { length: 8 }).notNull(),
|
short: varchar("short", { length: 8 }).notNull(),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
urlTemplate: text("url_template").notNull(),
|
urlTemplate: text("url_template"),
|
||||||
|
type: varchar("type", { length: 64 }).$type<SearchEngineType>().notNull().default("generic"),
|
||||||
|
integrationId: varchar("integration_id", { length: 64 }).references(() => integrations.id, { onDelete: "cascade" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const accountRelations = relations(accounts, ({ one }) => ({
|
export const accountRelations = relations(accounts, ({ one }) => ({
|
||||||
@@ -568,3 +571,10 @@ export const integrationItemRelations = relations(integrationItems, ({ one }) =>
|
|||||||
references: [items.id],
|
references: [items.id],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const searchEngineRelations = relations(searchEngines, ({ one }) => ({
|
||||||
|
integration: one(integrations, {
|
||||||
|
fields: [searchEngines.integrationId],
|
||||||
|
references: [integrations.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type {
|
|||||||
IntegrationKind,
|
IntegrationKind,
|
||||||
IntegrationPermission,
|
IntegrationPermission,
|
||||||
IntegrationSecretKind,
|
IntegrationSecretKind,
|
||||||
|
SearchEngineType,
|
||||||
SectionKind,
|
SectionKind,
|
||||||
SupportedAuthProvider,
|
SupportedAuthProvider,
|
||||||
WidgetKind,
|
WidgetKind,
|
||||||
@@ -382,7 +383,9 @@ export const searchEngines = sqliteTable("search_engine", {
|
|||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
short: text("short").notNull(),
|
short: text("short").notNull(),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
urlTemplate: text("url_template").notNull(),
|
urlTemplate: text("url_template"),
|
||||||
|
type: text("type").$type<SearchEngineType>().notNull().default("generic"),
|
||||||
|
integrationId: text("integration_id").references(() => integrations.id, { onDelete: "cascade" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const accountRelations = relations(accounts, ({ one }) => ({
|
export const accountRelations = relations(accounts, ({ one }) => ({
|
||||||
@@ -557,6 +560,13 @@ export const integrationItemRelations = relations(integrationItems, ({ one }) =>
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const searchEngineRelations = relations(searchEngines, ({ one }) => ({
|
||||||
|
integration: one(integrations, {
|
||||||
|
fields: [searchEngines.integrationId],
|
||||||
|
references: [integrations.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
export type User = InferSelectModel<typeof users>;
|
export type User = InferSelectModel<typeof users>;
|
||||||
export type Account = InferSelectModel<typeof accounts>;
|
export type Account = InferSelectModel<typeof accounts>;
|
||||||
export type Session = InferSelectModel<typeof sessions>;
|
export type Session = InferSelectModel<typeof sessions>;
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ export * from "./user";
|
|||||||
export * from "./group";
|
export * from "./group";
|
||||||
export * from "./docs";
|
export * from "./docs";
|
||||||
export * from "./cookie";
|
export * from "./cookie";
|
||||||
|
export * from "./search-engine";
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface integrationDefinition {
|
|||||||
iconUrl: string;
|
iconUrl: string;
|
||||||
secretKinds: AtLeastOneOf<IntegrationSecretKind[]>; // at least one secret kind set is required
|
secretKinds: AtLeastOneOf<IntegrationSecretKind[]>; // at least one secret kind set is required
|
||||||
category: AtLeastOneOf<IntegrationCategory>;
|
category: AtLeastOneOf<IntegrationCategory>;
|
||||||
|
supportsSearch: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const integrationDefs = {
|
export const integrationDefs = {
|
||||||
@@ -22,108 +23,126 @@ export const integrationDefs = {
|
|||||||
secretKinds: [["apiKey"]],
|
secretKinds: [["apiKey"]],
|
||||||
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sabnzbd.png",
|
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sabnzbd.png",
|
||||||
category: ["downloadClient", "usenet"],
|
category: ["downloadClient", "usenet"],
|
||||||
|
supportsSearch: false,
|
||||||
},
|
},
|
||||||
nzbGet: {
|
nzbGet: {
|
||||||
name: "NZBGet",
|
name: "NZBGet",
|
||||||
secretKinds: [["username", "password"]],
|
secretKinds: [["username", "password"]],
|
||||||
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/nzbget.png",
|
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/nzbget.png",
|
||||||
category: ["downloadClient", "usenet"],
|
category: ["downloadClient", "usenet"],
|
||||||
|
supportsSearch: false,
|
||||||
},
|
},
|
||||||
deluge: {
|
deluge: {
|
||||||
name: "Deluge",
|
name: "Deluge",
|
||||||
secretKinds: [["password"]],
|
secretKinds: [["password"]],
|
||||||
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/deluge.png",
|
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/deluge.png",
|
||||||
category: ["downloadClient", "torrent"],
|
category: ["downloadClient", "torrent"],
|
||||||
|
supportsSearch: false,
|
||||||
},
|
},
|
||||||
transmission: {
|
transmission: {
|
||||||
name: "Transmission",
|
name: "Transmission",
|
||||||
secretKinds: [["username", "password"]],
|
secretKinds: [["username", "password"]],
|
||||||
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/transmission.png",
|
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/transmission.png",
|
||||||
category: ["downloadClient", "torrent"],
|
category: ["downloadClient", "torrent"],
|
||||||
|
supportsSearch: false,
|
||||||
},
|
},
|
||||||
qBittorrent: {
|
qBittorrent: {
|
||||||
name: "qBittorrent",
|
name: "qBittorrent",
|
||||||
secretKinds: [["username", "password"]],
|
secretKinds: [["username", "password"]],
|
||||||
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/qbittorrent.png",
|
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/qbittorrent.png",
|
||||||
category: ["downloadClient", "torrent"],
|
category: ["downloadClient", "torrent"],
|
||||||
|
supportsSearch: false,
|
||||||
},
|
},
|
||||||
sonarr: {
|
sonarr: {
|
||||||
name: "Sonarr",
|
name: "Sonarr",
|
||||||
secretKinds: [["apiKey"]],
|
secretKinds: [["apiKey"]],
|
||||||
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sonarr.png",
|
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sonarr.png",
|
||||||
category: ["calendar"],
|
category: ["calendar"],
|
||||||
|
supportsSearch: false,
|
||||||
},
|
},
|
||||||
radarr: {
|
radarr: {
|
||||||
name: "Radarr",
|
name: "Radarr",
|
||||||
secretKinds: [["apiKey"]],
|
secretKinds: [["apiKey"]],
|
||||||
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/radarr.png",
|
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/radarr.png",
|
||||||
category: ["calendar"],
|
category: ["calendar"],
|
||||||
|
supportsSearch: false,
|
||||||
},
|
},
|
||||||
lidarr: {
|
lidarr: {
|
||||||
name: "Lidarr",
|
name: "Lidarr",
|
||||||
secretKinds: [["apiKey"]],
|
secretKinds: [["apiKey"]],
|
||||||
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/lidarr.png",
|
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/lidarr.png",
|
||||||
category: ["calendar"],
|
category: ["calendar"],
|
||||||
|
supportsSearch: false,
|
||||||
},
|
},
|
||||||
readarr: {
|
readarr: {
|
||||||
name: "Readarr",
|
name: "Readarr",
|
||||||
secretKinds: [["apiKey"]],
|
secretKinds: [["apiKey"]],
|
||||||
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/readarr.png",
|
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/readarr.png",
|
||||||
category: ["calendar"],
|
category: ["calendar"],
|
||||||
|
supportsSearch: false,
|
||||||
},
|
},
|
||||||
prowlarr: {
|
prowlarr: {
|
||||||
name: "Prowlarr",
|
name: "Prowlarr",
|
||||||
secretKinds: [["apiKey"]],
|
secretKinds: [["apiKey"]],
|
||||||
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/prowlarr.png",
|
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/prowlarr.png",
|
||||||
category: ["indexerManager"],
|
category: ["indexerManager"],
|
||||||
|
supportsSearch: false,
|
||||||
},
|
},
|
||||||
jellyfin: {
|
jellyfin: {
|
||||||
name: "Jellyfin",
|
name: "Jellyfin",
|
||||||
secretKinds: [["username", "password"], ["apiKey"]],
|
secretKinds: [["username", "password"], ["apiKey"]],
|
||||||
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyfin.png",
|
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyfin.png",
|
||||||
category: ["mediaService"],
|
category: ["mediaService"],
|
||||||
|
supportsSearch: false,
|
||||||
},
|
},
|
||||||
plex: {
|
plex: {
|
||||||
name: "Plex",
|
name: "Plex",
|
||||||
secretKinds: [["apiKey"]],
|
secretKinds: [["apiKey"]],
|
||||||
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/plex.png",
|
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/plex.png",
|
||||||
category: ["mediaService"],
|
category: ["mediaService"],
|
||||||
|
supportsSearch: false,
|
||||||
},
|
},
|
||||||
jellyseerr: {
|
jellyseerr: {
|
||||||
name: "Jellyseerr",
|
name: "Jellyseerr",
|
||||||
secretKinds: [["apiKey"]],
|
secretKinds: [["apiKey"]],
|
||||||
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyseerr.png",
|
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyseerr.png",
|
||||||
category: ["mediaSearch", "mediaRequest"],
|
category: ["mediaSearch", "mediaRequest"],
|
||||||
|
supportsSearch: true,
|
||||||
},
|
},
|
||||||
overseerr: {
|
overseerr: {
|
||||||
name: "Overseerr",
|
name: "Overseerr",
|
||||||
secretKinds: [["apiKey"]],
|
secretKinds: [["apiKey"]],
|
||||||
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/overseerr.png",
|
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/overseerr.png",
|
||||||
category: ["mediaSearch", "mediaRequest"],
|
category: ["mediaSearch", "mediaRequest"],
|
||||||
|
supportsSearch: true,
|
||||||
},
|
},
|
||||||
piHole: {
|
piHole: {
|
||||||
name: "Pi-hole",
|
name: "Pi-hole",
|
||||||
secretKinds: [["apiKey"]],
|
secretKinds: [["apiKey"]],
|
||||||
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/pi-hole.png",
|
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/pi-hole.png",
|
||||||
category: ["dnsHole"],
|
category: ["dnsHole"],
|
||||||
|
supportsSearch: false,
|
||||||
},
|
},
|
||||||
adGuardHome: {
|
adGuardHome: {
|
||||||
name: "AdGuard Home",
|
name: "AdGuard Home",
|
||||||
secretKinds: [["username", "password"]],
|
secretKinds: [["username", "password"]],
|
||||||
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/adguard-home.png",
|
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/adguard-home.png",
|
||||||
category: ["dnsHole"],
|
category: ["dnsHole"],
|
||||||
|
supportsSearch: false,
|
||||||
},
|
},
|
||||||
homeAssistant: {
|
homeAssistant: {
|
||||||
name: "Home Assistant",
|
name: "Home Assistant",
|
||||||
secretKinds: [["apiKey"]],
|
secretKinds: [["apiKey"]],
|
||||||
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/home-assistant.png",
|
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/home-assistant.png",
|
||||||
category: ["smartHomeServer"],
|
category: ["smartHomeServer"],
|
||||||
|
supportsSearch: false,
|
||||||
},
|
},
|
||||||
openmediavault: {
|
openmediavault: {
|
||||||
name: "OpenMediaVault",
|
name: "OpenMediaVault",
|
||||||
secretKinds: [["username", "password"]],
|
secretKinds: [["username", "password"]],
|
||||||
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/openmediavault.png",
|
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/openmediavault.png",
|
||||||
category: ["healthMonitoring"],
|
category: ["healthMonitoring"],
|
||||||
|
supportsSearch: false,
|
||||||
},
|
},
|
||||||
} as const satisfies Record<string, integrationDefinition>;
|
} as const satisfies Record<string, integrationDefinition>;
|
||||||
|
|
||||||
@@ -162,6 +181,22 @@ export type IntegrationKindByCategory<TCategory extends IntegrationCategory> = {
|
|||||||
U
|
U
|
||||||
: never;
|
: never;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if search is supported by the integration
|
||||||
|
* Uses a typescript guard with is to allow only integrations with search support within if statement
|
||||||
|
* @param integration integration with kind
|
||||||
|
* @returns true if the integration supports search
|
||||||
|
*/
|
||||||
|
export const isIntegrationWithSearchSupport = (integration: {
|
||||||
|
kind: IntegrationKind;
|
||||||
|
}): integration is { kind: IntegrationWithSearchSupport } => {
|
||||||
|
return integrationDefs[integration.kind].supportsSearch;
|
||||||
|
};
|
||||||
|
|
||||||
|
type IntegrationWithSearchSupport = {
|
||||||
|
[Key in keyof typeof integrationDefs]: true extends (typeof integrationDefs)[Key]["supportsSearch"] ? Key : never;
|
||||||
|
}[keyof typeof integrationDefs];
|
||||||
|
|
||||||
export type IntegrationSecretKind = keyof typeof integrationSecretKindObject;
|
export type IntegrationSecretKind = keyof typeof integrationSecretKindObject;
|
||||||
export type IntegrationKind = keyof typeof integrationDefs;
|
export type IntegrationKind = keyof typeof integrationDefs;
|
||||||
export type IntegrationCategory =
|
export type IntegrationCategory =
|
||||||
|
|||||||
2
packages/definitions/src/search-engine.ts
Normal file
2
packages/definitions/src/search-engine.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export const searchEngineTypes = ["generic", "fromIntegration"] as const;
|
||||||
|
export type SearchEngineType = (typeof searchEngineTypes)[number];
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@homarr/translation": "workspace:^0.1.0",
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@mantine/form": "^7.13.4"
|
"@mantine/form": "^7.13.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ import { useForm, zodResolver } from "@mantine/form";
|
|||||||
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { z } from "@homarr/validation";
|
import { z } from "@homarr/validation";
|
||||||
import type { AnyZodObject, ZodEffects, ZodIntersection } from "@homarr/validation";
|
import type { AnyZodObject, ZodDiscriminatedUnion, ZodEffects, ZodIntersection } from "@homarr/validation";
|
||||||
import { zodErrorMap } from "@homarr/validation/form";
|
import { zodErrorMap } from "@homarr/validation/form";
|
||||||
|
|
||||||
export const useZodForm = <
|
export const useZodForm = <
|
||||||
TSchema extends AnyZodObject | ZodEffects<AnyZodObject> | ZodIntersection<AnyZodObject, AnyZodObject>,
|
TSchema extends
|
||||||
|
| AnyZodObject
|
||||||
|
| ZodEffects<AnyZodObject>
|
||||||
|
| ZodIntersection<AnyZodObject | ZodDiscriminatedUnion<string, AnyZodObject[]>, AnyZodObject>,
|
||||||
>(
|
>(
|
||||||
schema: TSchema,
|
schema: TSchema,
|
||||||
options: Omit<
|
options: Omit<
|
||||||
|
|||||||
3
packages/integrations/src/base/searchable-integration.ts
Normal file
3
packages/integrations/src/base/searchable-integration.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export interface ISearchableIntegration {
|
||||||
|
searchAsync(query: string): Promise<{ image?: string; name: string; link: string }[]>;
|
||||||
|
}
|
||||||
@@ -1,13 +1,33 @@
|
|||||||
import { z } from "@homarr/validation";
|
import { z } from "@homarr/validation";
|
||||||
|
|
||||||
import { Integration } from "../base/integration";
|
import { Integration } from "../base/integration";
|
||||||
|
import type { ISearchableIntegration } from "../base/searchable-integration";
|
||||||
import type { MediaRequest, RequestStats, RequestUser } from "../interfaces/media-requests/media-request";
|
import type { MediaRequest, RequestStats, RequestUser } from "../interfaces/media-requests/media-request";
|
||||||
import { MediaAvailability, MediaRequestStatus } from "../interfaces/media-requests/media-request";
|
import { MediaAvailability, MediaRequestStatus } from "../interfaces/media-requests/media-request";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Overseerr Integration. See https://api-docs.overseerr.dev
|
* Overseerr Integration. See https://api-docs.overseerr.dev
|
||||||
*/
|
*/
|
||||||
export class OverseerrIntegration extends Integration {
|
export class OverseerrIntegration extends Integration implements ISearchableIntegration {
|
||||||
|
public async searchAsync(query: string): Promise<{ image?: string; name: string; link: string; text?: string }[]> {
|
||||||
|
const response = await fetch(`${this.integration.url}/api/v1/search?query=${query}`, {
|
||||||
|
headers: {
|
||||||
|
"X-Api-Key": this.getSecretValue("apiKey"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const schemaData = await searchSchema.parseAsync(await response.json());
|
||||||
|
|
||||||
|
if (!schemaData.results) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return schemaData.results.map((result) => ({
|
||||||
|
name: "name" in result ? result.name : result.title,
|
||||||
|
link: `${this.integration.url}/${result.mediaType}/${result.id}`,
|
||||||
|
image: constructSearchResultImage(this.integration.url, result),
|
||||||
|
text: "overview" in result ? result.overview : undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
public async testConnectionAsync(): Promise<void> {
|
public async testConnectionAsync(): Promise<void> {
|
||||||
const response = await fetch(`${this.integration.url}/api/v1/auth/me`, {
|
const response = await fetch(`${this.integration.url}/api/v1/auth/me`, {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -180,6 +200,35 @@ interface MovieInformation {
|
|||||||
releaseDate: string;
|
releaseDate: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const searchSchema = z.object({
|
||||||
|
results: z
|
||||||
|
.array(
|
||||||
|
z.discriminatedUnion("mediaType", [
|
||||||
|
z.object({
|
||||||
|
id: z.number(),
|
||||||
|
mediaType: z.literal("tv"),
|
||||||
|
name: z.string(),
|
||||||
|
posterPath: z.string().startsWith("/").endsWith(".jpg").nullable(),
|
||||||
|
overview: z.string(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
id: z.number(),
|
||||||
|
mediaType: z.literal("movie"),
|
||||||
|
title: z.string(),
|
||||||
|
posterPath: z.string().startsWith("/").endsWith(".jpg").nullable(),
|
||||||
|
overview: z.string(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
id: z.number(),
|
||||||
|
mediaType: z.literal("person"),
|
||||||
|
name: z.string(),
|
||||||
|
profilePath: z.string().startsWith("/").endsWith(".jpg").nullable(),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
const getRequestsSchema = z.object({
|
const getRequestsSchema = z.object({
|
||||||
results: z
|
results: z
|
||||||
.array(
|
.array(
|
||||||
@@ -239,3 +288,32 @@ const getUsersSchema = z.object({
|
|||||||
return val;
|
return val;
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const constructSearchResultImage = (
|
||||||
|
appUrl: string,
|
||||||
|
result: Exclude<z.infer<typeof searchSchema>["results"], undefined>[number],
|
||||||
|
) => {
|
||||||
|
const path = getResultImagePath(appUrl, result);
|
||||||
|
if (!path) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `https://image.tmdb.org/t/p/w600_and_h900_bestv2${path}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getResultImagePath = (
|
||||||
|
appUrl: string,
|
||||||
|
result: Exclude<z.infer<typeof searchSchema>["results"], undefined>[number],
|
||||||
|
) => {
|
||||||
|
switch (result.mediaType) {
|
||||||
|
case "person":
|
||||||
|
return result.profilePath;
|
||||||
|
case "tv":
|
||||||
|
case "movie":
|
||||||
|
return result.posterPath;
|
||||||
|
default:
|
||||||
|
throw new Error(
|
||||||
|
`Unable to get search result image from media type '${(result as { mediaType: string }).mediaType}'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ export * from "./interfaces/health-monitoring/healt-monitoring";
|
|||||||
export * from "./interfaces/indexer-manager/indexer";
|
export * from "./interfaces/indexer-manager/indexer";
|
||||||
export * from "./interfaces/media-requests/media-request";
|
export * from "./interfaces/media-requests/media-request";
|
||||||
export * from "./pi-hole/pi-hole-types";
|
export * from "./pi-hole/pi-hole-types";
|
||||||
|
export * from "./base/searchable-integration";
|
||||||
|
|||||||
@@ -30,10 +30,10 @@
|
|||||||
"@homarr/translation": "workspace:^0.1.0",
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
"@homarr/ui": "workspace:^0.1.0",
|
"@homarr/ui": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@mantine/core": "^7.13.4",
|
"@mantine/core": "^7.13.5",
|
||||||
"@tabler/icons-react": "^3.21.0",
|
"@tabler/icons-react": "^3.21.0",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"next": "^14.2.16",
|
"next": "^14.2.17",
|
||||||
"react": "^18.3.1"
|
"react": "^18.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -24,8 +24,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@homarr/translation": "workspace:^0.1.0",
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
"@homarr/ui": "workspace:^0.1.0",
|
"@homarr/ui": "workspace:^0.1.0",
|
||||||
"@mantine/core": "^7.13.4",
|
"@mantine/core": "^7.13.5",
|
||||||
"@mantine/hooks": "^7.13.4",
|
"@mantine/hooks": "^7.13.5",
|
||||||
"react": "^18.3.1"
|
"react": "^18.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
"prettier": "@homarr/prettier-config",
|
"prettier": "@homarr/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@homarr/ui": "workspace:^0.1.0",
|
"@homarr/ui": "workspace:^0.1.0",
|
||||||
"@mantine/notifications": "^7.13.4",
|
"@mantine/notifications": "^7.13.5",
|
||||||
"@tabler/icons-react": "^3.21.0"
|
"@tabler/icons-react": "^3.21.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -12,5 +12,15 @@ export type OldmarrWeatherDefinition = CommonOldmarrWidgetDefinition<
|
|||||||
latitude: number;
|
latitude: number;
|
||||||
longitude: number;
|
longitude: number;
|
||||||
};
|
};
|
||||||
|
dateFormat:
|
||||||
|
| "hide"
|
||||||
|
| "dddd, MMMM D"
|
||||||
|
| "dddd, D MMMM"
|
||||||
|
| "MMM D"
|
||||||
|
| "D MMM"
|
||||||
|
| "DD/MM/YYYY"
|
||||||
|
| "MM/DD/YYYY"
|
||||||
|
| "DD/MM"
|
||||||
|
| "MM/DD";
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ const optionMapping: OptionMapping = {
|
|||||||
isFormatFahrenheit: (oldOptions) => oldOptions.displayInFahrenheit,
|
isFormatFahrenheit: (oldOptions) => oldOptions.displayInFahrenheit,
|
||||||
location: (oldOptions) => oldOptions.location,
|
location: (oldOptions) => oldOptions.location,
|
||||||
showCity: (oldOptions) => oldOptions.displayCityName,
|
showCity: (oldOptions) => oldOptions.displayCityName,
|
||||||
|
dateFormat: (oldOptions) => (oldOptions.dateFormat === "hide" ? undefined : oldOptions.dateFormat),
|
||||||
},
|
},
|
||||||
iframe: {
|
iframe: {
|
||||||
embedUrl: (oldOptions) => oldOptions.embedUrl,
|
embedUrl: (oldOptions) => oldOptions.embedUrl,
|
||||||
|
|||||||
@@ -26,16 +26,17 @@
|
|||||||
"@homarr/auth": "workspace:^0.1.0",
|
"@homarr/auth": "workspace:^0.1.0",
|
||||||
"@homarr/common": "workspace:^0.1.0",
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
"@homarr/definitions": "workspace:^0.1.0",
|
"@homarr/definitions": "workspace:^0.1.0",
|
||||||
|
"@homarr/integrations": "workspace:^0.1.0",
|
||||||
"@homarr/modals": "workspace:^0.1.0",
|
"@homarr/modals": "workspace:^0.1.0",
|
||||||
"@homarr/modals-collection": "workspace:^0.1.0",
|
"@homarr/modals-collection": "workspace:^0.1.0",
|
||||||
"@homarr/translation": "workspace:^0.1.0",
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
"@homarr/ui": "workspace:^0.1.0",
|
"@homarr/ui": "workspace:^0.1.0",
|
||||||
"@mantine/core": "^7.13.4",
|
"@mantine/core": "^7.13.5",
|
||||||
"@mantine/hooks": "^7.13.4",
|
"@mantine/hooks": "^7.13.5",
|
||||||
"@mantine/spotlight": "^7.13.4",
|
"@mantine/spotlight": "^7.13.5",
|
||||||
"@tabler/icons-react": "^3.21.0",
|
"@tabler/icons-react": "^3.21.0",
|
||||||
"jotai": "^2.10.1",
|
"jotai": "^2.10.1",
|
||||||
"next": "^14.2.16",
|
"next": "^14.2.17",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"use-deep-compare-effect": "^1.8.1"
|
"use-deep-compare-effect": "^1.8.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Group, Kbd, Stack, Text } from "@mantine/core";
|
import { Group, Image, Kbd, Stack, Text } from "@mantine/core";
|
||||||
import { IconSearch } from "@tabler/icons-react";
|
import { IconSearch } from "@tabler/icons-react";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
@@ -12,29 +12,69 @@ import { interaction } from "../../lib/interaction";
|
|||||||
type SearchEngine = RouterOutputs["searchEngine"]["search"][number];
|
type SearchEngine = RouterOutputs["searchEngine"]["search"][number];
|
||||||
|
|
||||||
export const searchEnginesChildrenOptions = createChildrenOptions<SearchEngine>({
|
export const searchEnginesChildrenOptions = createChildrenOptions<SearchEngine>({
|
||||||
useActions: () => [
|
useActions: (searchEngine, query) => {
|
||||||
{
|
const { data } = clientApi.integration.searchInIntegration.useQuery(
|
||||||
key: "search",
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
Component: ({ name }) => {
|
{ integrationId: searchEngine.integrationId!, query },
|
||||||
const tChildren = useScopedI18n("search.mode.external.group.searchEngine.children");
|
{
|
||||||
|
enabled: searchEngine.type === "fromIntegration" && searchEngine.integrationId !== null && query.length > 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (searchEngine.type === "generic") {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "search",
|
||||||
|
Component: ({ name }) => {
|
||||||
|
const tChildren = useScopedI18n("search.mode.external.group.searchEngine.children");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group mx="md" my="sm">
|
||||||
|
<IconSearch stroke={1.5} />
|
||||||
|
<Text>{tChildren("action.search.label", { name })}</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
useInteraction: interaction.link(({ urlTemplate }, query) => ({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
href: urlTemplate!.replace("%s", query),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return (data ?? []).map((searchResult, index) => ({
|
||||||
|
key: `search-result-${index}`,
|
||||||
|
Component: () => {
|
||||||
return (
|
return (
|
||||||
<Group mx="md" my="sm">
|
<Group mx="md" my="sm" wrap="nowrap">
|
||||||
<IconSearch stroke={1.5} />
|
{searchResult.image ? (
|
||||||
<Text>{tChildren("action.search.label", { name })}</Text>
|
<Image src={searchResult.image} w={35} h={50} fit="cover" radius={"md"} />
|
||||||
|
) : (
|
||||||
|
<IconSearch stroke={1.5} size={35} />
|
||||||
|
)}
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Text>{searchResult.name}</Text>
|
||||||
|
{searchResult.text && (
|
||||||
|
<Text c="dimmed" size="sm" lineClamp={2}>
|
||||||
|
{searchResult.text}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
useInteraction: interaction.link(({ urlTemplate }, query) => ({
|
useInteraction: interaction.link(() => ({
|
||||||
href: urlTemplate.replace("%s", query),
|
href: searchResult.link,
|
||||||
|
newTab: true,
|
||||||
})),
|
})),
|
||||||
},
|
}));
|
||||||
],
|
},
|
||||||
DetailComponent({ options }) {
|
DetailComponent({ options }) {
|
||||||
const tChildren = useScopedI18n("search.mode.external.group.searchEngine.children");
|
const tChildren = useScopedI18n("search.mode.external.group.searchEngine.children");
|
||||||
return (
|
return (
|
||||||
<Stack mx="md" my="sm">
|
<Stack mx="md" my="sm">
|
||||||
<Text>{tChildren("detail.title")}</Text>
|
<Text>{options.type === "generic" ? tChildren("detail.title") : tChildren("searchResults.title")}</Text>
|
||||||
<Group>
|
<Group>
|
||||||
<img height={24} width={24} src={options.iconUrl} alt={options.name} />
|
<img height={24} width={24} src={options.iconUrl} alt={options.name} />
|
||||||
<Text>{options.name}</Text>
|
<Text>{options.name}</Text>
|
||||||
@@ -72,10 +112,24 @@ export const searchEnginesSearchGroups = createGroup<SearchEngine>({
|
|||||||
|
|
||||||
setChildrenOptions(searchEnginesChildrenOptions(engine));
|
setChildrenOptions(searchEnginesChildrenOptions(engine));
|
||||||
},
|
},
|
||||||
useInteraction: interaction.link(({ urlTemplate }, query) => ({
|
useInteraction: (searchEngine, query) => {
|
||||||
href: urlTemplate.replace("%s", query),
|
if (searchEngine.type === "generic" && searchEngine.urlTemplate) {
|
||||||
newTab: true,
|
return {
|
||||||
})),
|
type: "link" as const,
|
||||||
|
href: searchEngine.urlTemplate.replace("%s", query),
|
||||||
|
newTab: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchEngine.type === "fromIntegration" && searchEngine.integrationId !== null) {
|
||||||
|
return {
|
||||||
|
type: "children",
|
||||||
|
...searchEnginesChildrenOptions(searchEngine),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unable to process search engine with type ${searchEngine.type}`);
|
||||||
|
},
|
||||||
useQueryOptions(query) {
|
useQueryOptions(query) {
|
||||||
return clientApi.searchEngine.search.useQuery({
|
return clientApi.searchEngine.search.useQuery({
|
||||||
query: query.trim(),
|
query: query.trim(),
|
||||||
|
|||||||
@@ -32,8 +32,8 @@
|
|||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"deepmerge": "4.3.1",
|
"deepmerge": "4.3.1",
|
||||||
"mantine-react-table": "2.0.0-beta.7",
|
"mantine-react-table": "2.0.0-beta.7",
|
||||||
"next": "^14.2.16",
|
"next": "^14.2.17",
|
||||||
"next-intl": "3.24.0",
|
"next-intl": "3.25.0",
|
||||||
"react": "^18.3.1"
|
"react": "^18.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1134,6 +1134,10 @@
|
|||||||
"forecastDayCount": {
|
"forecastDayCount": {
|
||||||
"label": "Amount of forecast days",
|
"label": "Amount of forecast days",
|
||||||
"description": "When the widget is not wide enough, less days are shown"
|
"description": "When the widget is not wide enough, less days are shown"
|
||||||
|
},
|
||||||
|
"dateFormat": {
|
||||||
|
"label": "Date Format",
|
||||||
|
"description": "How the date should look like"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"kind": {
|
"kind": {
|
||||||
@@ -2427,6 +2431,9 @@
|
|||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"title": "Select an action for the search engine"
|
"title": "Select an action for the search engine"
|
||||||
|
},
|
||||||
|
"searchResults": {
|
||||||
|
"title": "Select a search result for actions"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"option": {
|
"option": {
|
||||||
@@ -2596,7 +2603,8 @@
|
|||||||
"noResults": {
|
"noResults": {
|
||||||
"title": "There aren't any search engines",
|
"title": "There aren't any search engines",
|
||||||
"action": "Create your first search engine"
|
"action": "Create your first search engine"
|
||||||
}
|
},
|
||||||
|
"interactive": "Interactive, uses an integration"
|
||||||
},
|
},
|
||||||
"create": {
|
"create": {
|
||||||
"title": "New search engine",
|
"title": "New search engine",
|
||||||
@@ -2622,6 +2630,11 @@
|
|||||||
"title": "Unable to apply changes",
|
"title": "Unable to apply changes",
|
||||||
"message": "The search engine could not be saved"
|
"message": "The search engine could not be saved"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"configControl": "Configuration",
|
||||||
|
"searchEngineType": {
|
||||||
|
"generic": "Generic",
|
||||||
|
"fromIntegration": "From integration"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"delete": {
|
"delete": {
|
||||||
|
|||||||
@@ -28,12 +28,12 @@
|
|||||||
"@homarr/log": "workspace:^0.1.0",
|
"@homarr/log": "workspace:^0.1.0",
|
||||||
"@homarr/translation": "workspace:^0.1.0",
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@mantine/core": "^7.13.4",
|
"@mantine/core": "^7.13.5",
|
||||||
"@mantine/dates": "^7.13.4",
|
"@mantine/dates": "^7.13.5",
|
||||||
"@mantine/hooks": "^7.13.4",
|
"@mantine/hooks": "^7.13.5",
|
||||||
"@tabler/icons-react": "^3.21.0",
|
"@tabler/icons-react": "^3.21.0",
|
||||||
"mantine-react-table": "2.0.0-beta.7",
|
"mantine-react-table": "2.0.0-beta.7",
|
||||||
"next": "^14.2.16",
|
"next": "^14.2.17",
|
||||||
"react": "^18.3.1"
|
"react": "^18.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,20 +1,41 @@
|
|||||||
|
import type { ZodTypeAny } from "zod";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import type { SearchEngineType } from "@homarr/definitions";
|
||||||
|
|
||||||
|
const genericSearchEngine = z.object({
|
||||||
|
type: z.literal("generic" satisfies SearchEngineType),
|
||||||
|
urlTemplate: z.string().min(1).startsWith("http").includes("%s"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const fromIntegrationSearchEngine = z.object({
|
||||||
|
type: z.literal("fromIntegration" satisfies SearchEngineType),
|
||||||
|
integrationId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
const manageSearchEngineSchema = z.object({
|
const manageSearchEngineSchema = z.object({
|
||||||
name: z.string().min(1).max(64),
|
name: z.string().min(1).max(64),
|
||||||
short: z.string().min(1).max(8),
|
short: z.string().min(1).max(8),
|
||||||
iconUrl: z.string().min(1),
|
iconUrl: z.string().min(1),
|
||||||
urlTemplate: z.string().min(1).startsWith("http").includes("%s"),
|
|
||||||
description: z.string().max(512).nullable(),
|
description: z.string().max(512).nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const editSearchEngineSchema = manageSearchEngineSchema
|
const createManageSearchEngineSchema = <T extends ZodTypeAny>(
|
||||||
.extend({
|
callback: (schema: typeof manageSearchEngineSchema) => T,
|
||||||
id: z.string(),
|
) =>
|
||||||
})
|
z
|
||||||
.omit({ short: true });
|
.discriminatedUnion("type", [genericSearchEngine, fromIntegrationSearchEngine])
|
||||||
|
.and(callback(manageSearchEngineSchema));
|
||||||
|
|
||||||
|
const editSearchEngineSchema = createManageSearchEngineSchema((schema) =>
|
||||||
|
schema
|
||||||
|
.extend({
|
||||||
|
id: z.string(),
|
||||||
|
})
|
||||||
|
.omit({ short: true }),
|
||||||
|
);
|
||||||
|
|
||||||
export const searchEngineSchemas = {
|
export const searchEngineSchemas = {
|
||||||
manage: manageSearchEngineSchema,
|
manage: createManageSearchEngineSchema((schema) => schema),
|
||||||
edit: editSearchEngineSchema,
|
edit: editSearchEngineSchema,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -40,8 +40,8 @@
|
|||||||
"@homarr/translation": "workspace:^0.1.0",
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
"@homarr/ui": "workspace:^0.1.0",
|
"@homarr/ui": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@mantine/core": "^7.13.4",
|
"@mantine/core": "^7.13.5",
|
||||||
"@mantine/hooks": "^7.13.4",
|
"@mantine/hooks": "^7.13.5",
|
||||||
"@tabler/icons-react": "^3.21.0",
|
"@tabler/icons-react": "^3.21.0",
|
||||||
"@tiptap/extension-color": "2.9.1",
|
"@tiptap/extension-color": "2.9.1",
|
||||||
"@tiptap/extension-highlight": "2.9.1",
|
"@tiptap/extension-highlight": "2.9.1",
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"mantine-react-table": "2.0.0-beta.7",
|
"mantine-react-table": "2.0.0-beta.7",
|
||||||
"next": "^14.2.16",
|
"next": "^14.2.17",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"video.js": "^8.19.1"
|
"video.js": "^8.19.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ const WeeklyForecast = ({ options, weather }: WeatherProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function Forecast({ weather, options }: WeatherProps) {
|
function Forecast({ weather, options }: WeatherProps) {
|
||||||
|
const dateFormat = options.dateFormat;
|
||||||
return (
|
return (
|
||||||
<Group className="weather-forecast-days-group" w="100%" justify="space-evenly" wrap="nowrap" pb="2.5cqmin">
|
<Group className="weather-forecast-days-group" w="100%" justify="space-evenly" wrap="nowrap" pb="2.5cqmin">
|
||||||
{weather.daily.slice(0, options.forecastDayCount).map((dayWeather, index) => (
|
{weather.daily.slice(0, options.forecastDayCount).map((dayWeather, index) => (
|
||||||
@@ -136,6 +137,7 @@ function Forecast({ weather, options }: WeatherProps) {
|
|||||||
</HoverCard.Target>
|
</HoverCard.Target>
|
||||||
<HoverCard.Dropdown>
|
<HoverCard.Dropdown>
|
||||||
<WeatherDescription
|
<WeatherDescription
|
||||||
|
dateFormat={dateFormat}
|
||||||
time={dayWeather.time}
|
time={dayWeather.time}
|
||||||
weatherCode={dayWeather.weatherCode}
|
weatherCode={dayWeather.weatherCode}
|
||||||
maxTemp={getPreferredUnit(dayWeather.maxTemp, options.isFormatFahrenheit)}
|
maxTemp={getPreferredUnit(dayWeather.maxTemp, options.isFormatFahrenheit)}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import type { TranslationObject } from "@homarr/translation";
|
|||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
import type { TablerIcon } from "@homarr/ui";
|
import type { TablerIcon } from "@homarr/ui";
|
||||||
|
|
||||||
|
import type { WidgetProps } from "../definition";
|
||||||
|
|
||||||
interface WeatherIconProps {
|
interface WeatherIconProps {
|
||||||
code: number;
|
code: number;
|
||||||
size?: string | number;
|
size?: string | number;
|
||||||
@@ -34,6 +36,7 @@ export const WeatherIcon = ({ code, size = 50 }: WeatherIconProps) => {
|
|||||||
|
|
||||||
interface WeatherDescriptionProps {
|
interface WeatherDescriptionProps {
|
||||||
weatherOnly?: boolean;
|
weatherOnly?: boolean;
|
||||||
|
dateFormat?: WidgetProps<"weather">["options"]["dateFormat"];
|
||||||
time?: string;
|
time?: string;
|
||||||
weatherCode: number;
|
weatherCode: number;
|
||||||
maxTemp?: string;
|
maxTemp?: string;
|
||||||
@@ -42,13 +45,21 @@ interface WeatherDescriptionProps {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Description Dropdown for a given set of parameters
|
* Description Dropdown for a given set of parameters
|
||||||
|
* @param dateFormat format of the date that will be displayed on the widget
|
||||||
* @param time date that can be formatted by dayjs
|
* @param time date that can be formatted by dayjs
|
||||||
* @param weatherCode weather code from api
|
* @param weatherCode weather code from api
|
||||||
* @param maxTemp preformatted string for max temperature
|
* @param maxTemp preformatted string for max temperature
|
||||||
* @param minTemp preformatted string for min temperature
|
* @param minTemp preformatted string for min temperature
|
||||||
* @returns Content for a HoverCard dropdown presenting weather information
|
* @returns Content for a HoverCard dropdown presenting weather information
|
||||||
*/
|
*/
|
||||||
export const WeatherDescription = ({ weatherOnly, time, weatherCode, maxTemp, minTemp }: WeatherDescriptionProps) => {
|
export const WeatherDescription = ({
|
||||||
|
weatherOnly,
|
||||||
|
dateFormat,
|
||||||
|
time,
|
||||||
|
weatherCode,
|
||||||
|
maxTemp,
|
||||||
|
minTemp,
|
||||||
|
}: WeatherDescriptionProps) => {
|
||||||
const t = useScopedI18n("widget.weather");
|
const t = useScopedI18n("widget.weather");
|
||||||
const tCommon = useScopedI18n("common");
|
const tCommon = useScopedI18n("common");
|
||||||
|
|
||||||
@@ -60,7 +71,7 @@ export const WeatherDescription = ({ weatherOnly, time, weatherCode, maxTemp, mi
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack align="center" gap="0">
|
<Stack align="center" gap="0">
|
||||||
<Text fz="24px">{dayjs(time).format("dddd MMMM D YYYY")}</Text>
|
<Text fz="24px">{dayjs(time).format(dateFormat)}</Text>
|
||||||
<Text fz="16px">{t(`kind.${name}`)}</Text>
|
<Text fz="16px">{t(`kind.${name}`)}</Text>
|
||||||
<Text fz="16px">{`${tCommon("information.max")}: ${maxTemp}`}</Text>
|
<Text fz="16px">{`${tCommon("information.max")}: ${maxTemp}`}</Text>
|
||||||
<Text fz="16px">{`${tCommon("information.min")}: ${minTemp}`}</Text>
|
<Text fz="16px">{`${tCommon("information.min")}: ${minTemp}`}</Text>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { IconCloud } from "@tabler/icons-react";
|
import { IconCloud } from "@tabler/icons-react";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
import { z } from "@homarr/validation";
|
import { z } from "@homarr/validation";
|
||||||
|
|
||||||
@@ -17,6 +18,20 @@ export const { definition, componentLoader } = createWidgetDefinition("weather",
|
|||||||
longitude: 2.3488,
|
longitude: 2.3488,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
dateFormat: factory.select({
|
||||||
|
options: [
|
||||||
|
{ value: "dddd, MMMM D", label: dayjs().format("dddd, MMMM D") },
|
||||||
|
{ value: "dddd, D MMMM", label: dayjs().format("dddd, D MMMM") },
|
||||||
|
{ value: "MMM D", label: dayjs().format("MMM D") },
|
||||||
|
{ value: "D MMM", label: dayjs().format("D MMM") },
|
||||||
|
{ value: "DD/MM/YYYY", label: dayjs().format("DD/MM/YYYY") },
|
||||||
|
{ value: "MM/DD/YYYY", label: dayjs().format("MM/DD/YYYY") },
|
||||||
|
{ value: "DD/MM", label: dayjs().format("DD/MM") },
|
||||||
|
{ value: "MM/DD", label: dayjs().format("MM/DD") },
|
||||||
|
],
|
||||||
|
defaultValue: "dddd, MMMM D",
|
||||||
|
withDescription: true,
|
||||||
|
}),
|
||||||
showCity: factory.switch(),
|
showCity: factory.switch(),
|
||||||
hasForecast: factory.switch(),
|
hasForecast: factory.switch(),
|
||||||
forecastDayCount: factory.slider({
|
forecastDayCount: factory.slider({
|
||||||
|
|||||||
@@ -30,13 +30,16 @@ interface WidgetIntegrationSelectProps {
|
|||||||
error?: string;
|
error?: string;
|
||||||
onFocus?: FocusEventHandler<HTMLInputElement>;
|
onFocus?: FocusEventHandler<HTMLInputElement>;
|
||||||
onBlur?: FocusEventHandler<HTMLInputElement>;
|
onBlur?: FocusEventHandler<HTMLInputElement>;
|
||||||
|
canSelectMultiple?: boolean;
|
||||||
data: IntegrationSelectOption[];
|
data: IntegrationSelectOption[];
|
||||||
|
withAsterisk?: boolean;
|
||||||
}
|
}
|
||||||
export const WidgetIntegrationSelect = ({
|
export const WidgetIntegrationSelect = ({
|
||||||
data,
|
data,
|
||||||
onChange,
|
onChange,
|
||||||
value: valueProp,
|
value: valueProp,
|
||||||
|
canSelectMultiple = true,
|
||||||
|
withAsterisk = false,
|
||||||
...props
|
...props
|
||||||
}: WidgetIntegrationSelectProps) => {
|
}: WidgetIntegrationSelectProps) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
@@ -47,12 +50,16 @@ export const WidgetIntegrationSelect = ({
|
|||||||
onDropdownOpen: () => combobox.updateSelectedOptionIndex("active"),
|
onDropdownOpen: () => combobox.updateSelectedOptionIndex("active"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleValueSelect = (selectedValue: string) =>
|
const handleValueSelect = (selectedValue: string) => {
|
||||||
onChange(
|
onChange(
|
||||||
multiSelectValues.includes(selectedValue)
|
multiSelectValues.includes(selectedValue)
|
||||||
? multiSelectValues.filter((value) => value !== selectedValue)
|
? multiSelectValues.filter((value) => value !== selectedValue)
|
||||||
: [...multiSelectValues, selectedValue],
|
: [...multiSelectValues, selectedValue],
|
||||||
);
|
);
|
||||||
|
if (!canSelectMultiple) {
|
||||||
|
combobox.closeDropdown();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleValueRemove = (valueToRemove: string) =>
|
const handleValueRemove = (valueToRemove: string) =>
|
||||||
onChange(multiSelectValues.filter((value) => value !== valueToRemove));
|
onChange(multiSelectValues.filter((value) => value !== valueToRemove));
|
||||||
@@ -63,7 +70,14 @@ export const WidgetIntegrationSelect = ({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <IntegrationPill key={item} option={option} onRemove={() => handleValueRemove(item)} />;
|
return (
|
||||||
|
<IntegrationPill
|
||||||
|
key={item}
|
||||||
|
option={option}
|
||||||
|
onRemove={() => handleValueRemove(item)}
|
||||||
|
showRemoveButton={canSelectMultiple}
|
||||||
|
/>
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const options = data.map((item) => {
|
const options = data.map((item) => {
|
||||||
@@ -103,6 +117,7 @@ export const WidgetIntegrationSelect = ({
|
|||||||
}
|
}
|
||||||
pointer
|
pointer
|
||||||
onClick={() => combobox.toggleDropdown()}
|
onClick={() => combobox.toggleDropdown()}
|
||||||
|
withAsterisk={withAsterisk}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<Pill.Group>
|
<Pill.Group>
|
||||||
@@ -150,14 +165,17 @@ export interface IntegrationSelectOption {
|
|||||||
interface IntegrationPillProps {
|
interface IntegrationPillProps {
|
||||||
option: IntegrationSelectOption;
|
option: IntegrationSelectOption;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
|
showRemoveButton: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const IntegrationPill = ({ option, onRemove }: IntegrationPillProps) => (
|
const IntegrationPill = ({ option, onRemove, showRemoveButton }: IntegrationPillProps) => (
|
||||||
<Group align="center" wrap="nowrap" gap={0} className={classes.pill}>
|
<Group align="center" wrap="nowrap" gap={0} className={classes.pill} mih={24} pr={!showRemoveButton ? 10 : undefined}>
|
||||||
<Avatar src={getIconUrl(option.kind)} size={14} mr={6} />
|
<Avatar src={getIconUrl(option.kind)} size={14} mr={6} />
|
||||||
<Text span size="xs" lh={1} fw={500}>
|
<Text span size="xs" lh={1} fw={500}>
|
||||||
{option.name}
|
{option.name}
|
||||||
</Text>
|
</Text>
|
||||||
<CloseButton onMouseDown={onRemove} variant="transparent" color="gray" size={22} iconSize={14} tabIndex={-1} />
|
{showRemoveButton && (
|
||||||
|
<CloseButton onMouseDown={onRemove} variant="transparent" color="gray" size={22} iconSize={14} tabIndex={-1} />
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
|
|||||||
843
pnpm-lock.yaml
generated
843
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,7 @@
|
|||||||
|
# Creating folders in volume
|
||||||
|
mkdir -p /appdata/db
|
||||||
|
mkdir -p /appdata/redis
|
||||||
|
|
||||||
# Run migrations
|
# Run migrations
|
||||||
if [ $DB_MIGRATIONS_DISABLED = "true" ]; then
|
if [ $DB_MIGRATIONS_DISABLED = "true" ]; then
|
||||||
echo "DB migrations are disabled, skipping"
|
echo "DB migrations are disabled, skipping"
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/eslint-plugin-next": "^14.2.16",
|
"@next/eslint-plugin-next": "^14.2.17",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-config-turbo": "^2.2.3",
|
"eslint-config-turbo": "^2.2.3",
|
||||||
"eslint-plugin-import": "^2.31.0",
|
"eslint-plugin-import": "^2.31.0",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ runs:
|
|||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 22
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|
||||||
- shell: bash
|
- shell: bash
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ianvs/prettier-plugin-sort-imports": "^4.3.1",
|
"@ianvs/prettier-plugin-sort-imports": "^4.4.0",
|
||||||
"prettier": "^3.3.3"
|
"prettier": "^3.3.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
8
tsconfig.e2e.json
Normal file
8
tsconfig.e2e.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "@homarr/tsconfig/base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||||
|
},
|
||||||
|
"include": ["e2e"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user