Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
510c9b945d | ||
|
|
2405e1b931 |
No files matched your search
@@ -0,0 +1,2 @@
|
||||
-P ubuntu-latest=catthehacker/ubuntu:act-latest
|
||||
--container-architecture linux/amd64
|
||||
@@ -19,7 +19,10 @@ body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Add any other information related to the integration.
|
||||
description: |
|
||||
Add any other information related to the integration.
|
||||
|
||||
After your integration is merged to `dev`, a bot PR updates [CODEOWNERS](https://github.com/homarr-labs/homarr/blob/dev/CODEOWNERS) so you are requested on future PRs touching your folders. Run `pnpm scripts:generate-codeowners` locally for faster updates. Add email mappings in `scripts/codeowners-overrides.json` if the generator cannot resolve your GitHub user.
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Public API available?
|
||||
|
||||
@@ -19,7 +19,10 @@ body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Add any other information related to the widget.
|
||||
description: |
|
||||
Add any other information related to the widget.
|
||||
|
||||
After your widget is merged to `dev`, a bot PR updates [CODEOWNERS](https://github.com/homarr-labs/homarr/blob/dev/CODEOWNERS) so you are requested on future PRs touching your folders. Run `pnpm scripts:generate-codeowners` locally for faster updates. Add email mappings in `scripts/codeowners-overrides.json` if the generator cannot resolve your GitHub user.
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Are you willing to contribute this yourself?
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
name: Update CODEOWNERS
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
pull_request:
|
||||
paths:
|
||||
- CODEOWNERS
|
||||
- packages/widgets/src/**
|
||||
- packages/integrations/src/**
|
||||
- packages/api/src/router/widgets/**
|
||||
- packages/cron-jobs/src/jobs/**
|
||||
- scripts/generate-codeowners.mts
|
||||
- scripts/validate-codeowners.mts
|
||||
- scripts/codeowners-overrides.json
|
||||
push:
|
||||
paths:
|
||||
- packages/widgets/src/**
|
||||
- packages/integrations/src/**
|
||||
- packages/api/src/router/widgets/**
|
||||
- packages/cron-jobs/src/jobs/**
|
||||
- scripts/generate-codeowners.mts
|
||||
- scripts/validate-codeowners.mts
|
||||
- scripts/codeowners-overrides.json
|
||||
branches:
|
||||
- dev
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate CODEOWNERS
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Check CODEOWNERS is up to date
|
||||
run: pnpm exec tsx ./scripts/generate-codeowners.mts --check
|
||||
|
||||
- name: Validate CODEOWNERS
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
run: pnpm exec tsx ./scripts/validate-codeowners.mts
|
||||
|
||||
update:
|
||||
name: Open CODEOWNERS update PR
|
||||
needs: validate
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
concurrency:
|
||||
group: update-codeowners
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Obtain token
|
||||
id: obtainToken
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
private-key: ${{ secrets.HOMARR_UPDATE_CONTRIBUTORS_PRIVATE_KEY }}
|
||||
client-id: ${{ vars.HOMARR_UPDATE_CONTRIBUTORS_APP_ID }}
|
||||
permission-contents: write
|
||||
permission-pull-requests: write
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Generate CODEOWNERS
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||
run: pnpm scripts:generate-codeowners
|
||||
|
||||
- name: Validate CODEOWNERS
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
run: pnpm exec tsx ./scripts/validate-codeowners.mts
|
||||
|
||||
- name: Create Pull Request
|
||||
id: create-pull-request
|
||||
uses: peter-evans/create-pull-request@v8
|
||||
with:
|
||||
token: ${{ steps.obtainToken.outputs.token }}
|
||||
branch: update-codeowners
|
||||
base: dev
|
||||
title: "chore: update widget/integration CODEOWNERS"
|
||||
delete-branch: true
|
||||
commit-message: "chore: update widget/integration CODEOWNERS"
|
||||
body: |
|
||||
This PR updates per-widget and per-integration CODEOWNERS entries from git history.
|
||||
|
||||
Unmapped authors can be added in `scripts/codeowners-overrides.json`.
|
||||
|
||||
- name: Install GitHub CLI
|
||||
run: sudo apt-get install -y gh
|
||||
|
||||
- name: Enable auto-merge
|
||||
if: steps.create-pull-request.outputs.pull-request-number != ''
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||
run: |
|
||||
gh pr merge ${{ steps.create-pull-request.outputs.pull-request-number }} --auto --squash
|
||||
+83
@@ -11,3 +11,86 @@ pnpm-lock.yaml
|
||||
pnpm-workspace.yaml
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
|
||||
# BEGIN GENERATED widget/integration owners
|
||||
/packages/api/src/router/widgets/anchor-notes.ts /packages/integrations/src/anchor/ /packages/widgets/src/anchor-note/ @omariqbal
|
||||
/packages/api/src/router/widgets/app.ts /packages/widgets/src/app/ @manuel-rw
|
||||
/packages/api/src/router/widgets/calendar.ts /packages/widgets/src/calendar/ @manuel-rw
|
||||
/packages/api/src/router/widgets/coolify.ts /packages/integrations/src/coolify/ /packages/widgets/src/coolify/ @Rhiz3K
|
||||
/packages/api/src/router/widgets/dns-hole.ts /packages/cron-jobs/src/jobs/integrations/dns-hole.ts /packages/widgets/src/dns-hole/controls/ @manuel-rw
|
||||
/packages/api/src/router/widgets/dns-hole.ts /packages/cron-jobs/src/jobs/integrations/dns-hole.ts /packages/widgets/src/dns-hole/summary/ @manuel-rw
|
||||
/packages/api/src/router/widgets/downloads.ts /packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/widgets/src/downloads/ @manuel-rw
|
||||
/packages/api/src/router/widgets/firewall.ts /packages/cron-jobs/src/jobs/integrations/firewall.ts /packages/widgets/src/firewall/ @Oupsman
|
||||
/packages/api/src/router/widgets/health-monitoring.ts /packages/cron-jobs/src/jobs/integrations/health-monitoring.ts /packages/widgets/src/health-monitoring/ @dgshue
|
||||
/packages/api/src/router/widgets/immich.ts /packages/integrations/src/immich/ /packages/widgets/src/immich/album-carousel/ @manuel-rw
|
||||
/packages/api/src/router/widgets/immich.ts /packages/integrations/src/immich/ /packages/widgets/src/immich/server-stats/ @manuel-rw
|
||||
/packages/api/src/router/widgets/indexer-manager.ts /packages/cron-jobs/src/jobs/integrations/indexer-manager.ts /packages/widgets/src/indexer-manager/ @manuel-rw
|
||||
/packages/api/src/router/widgets/media-release.ts /packages/widgets/src/media-releases/ @manuel-rw
|
||||
/packages/api/src/router/widgets/media-requests.ts /packages/cron-jobs/src/jobs/integrations/media-requests.ts /packages/widgets/src/media-requests/list/ @manuel-rw
|
||||
/packages/api/src/router/widgets/media-requests.ts /packages/cron-jobs/src/jobs/integrations/media-requests.ts /packages/widgets/src/media-requests/stats/ @manuel-rw
|
||||
/packages/api/src/router/widgets/media-server.ts /packages/cron-jobs/src/jobs/integrations/media-server.ts /packages/widgets/src/media-server/ @manuel-rw
|
||||
/packages/api/src/router/widgets/media-transcoding.ts /packages/cron-jobs/src/jobs/integrations/media-transcoding.ts /packages/integrations/src/media-transcoding/ /packages/widgets/src/media-transcoding/ @manuel-rw
|
||||
/packages/api/src/router/widgets/minecraft.ts /packages/widgets/src/minecraft/server-status/ @manuel-rw
|
||||
/packages/api/src/router/widgets/network-controller.ts /packages/cron-jobs/src/jobs/integrations/network-controller.ts /packages/widgets/src/network-controller/network-status/ @pitschi
|
||||
/packages/api/src/router/widgets/network-controller.ts /packages/cron-jobs/src/jobs/integrations/network-controller.ts /packages/widgets/src/network-controller/summary/ @pitschi
|
||||
/packages/api/src/router/widgets/notebook.ts /packages/widgets/src/notebook/ @manuel-rw
|
||||
/packages/api/src/router/widgets/notifications.ts /packages/cron-jobs/src/jobs/integrations/notifications.ts /packages/widgets/src/notifications/ @itzTheMeow
|
||||
/packages/api/src/router/widgets/releases.ts /packages/widgets/src/releases/ @Aandree5
|
||||
/packages/api/src/router/widgets/rssFeed.ts /packages/widgets/src/rssFeed/ @manuel-rw
|
||||
/packages/api/src/router/widgets/smart-home.ts /packages/cron-jobs/src/jobs/integrations/home-assistant.ts /packages/widgets/src/smart-home/entity-state/ @manuel-rw
|
||||
/packages/api/src/router/widgets/smart-home.ts /packages/cron-jobs/src/jobs/integrations/home-assistant.ts /packages/widgets/src/smart-home/execute-automation/ @manuel-rw
|
||||
/packages/api/src/router/widgets/speedtest-tracker.ts /packages/integrations/src/speedtest-tracker/ /packages/widgets/src/speedtest-tracker/ @kylegrieder
|
||||
/packages/api/src/router/widgets/stocks.ts /packages/widgets/src/stocks/ @jackwellerreal
|
||||
/packages/api/src/router/widgets/timetable.ts /packages/cron-jobs/src/jobs/integrations/timetable.ts /packages/widgets/src/timetable/ @manuel-rw
|
||||
/packages/api/src/router/widgets/tracearr.ts /packages/cron-jobs/src/jobs/integrations/tracearr.ts /packages/integrations/src/tracearr/ /packages/widgets/src/tracearr/ @StrandedTurtle
|
||||
/packages/api/src/router/widgets/umami.ts /packages/cron-jobs/src/jobs/integrations/umami.ts /packages/integrations/src/umami/ /packages/widgets/src/umami/ @febrile42
|
||||
/packages/api/src/router/widgets/weather.ts /packages/cron-jobs/src/jobs/weather.ts /packages/widgets/src/weather/ @manuel-rw
|
||||
/packages/cron-jobs/src/jobs/docker.ts /packages/widgets/src/docker/ @ashtonbatty
|
||||
/packages/cron-jobs/src/jobs/integrations/dns-hole.ts /packages/integrations/src/pi-hole/ @manuel-rw
|
||||
/packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/integrations/src/download-client/aria2/ @manuel-rw
|
||||
/packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/integrations/src/download-client/deluge/ @manuel-rw
|
||||
/packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/integrations/src/download-client/nzbget/ @manuel-rw
|
||||
/packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/integrations/src/download-client/qbittorrent/ @manuel-rw
|
||||
/packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/integrations/src/download-client/sabnzbd/ @manuel-rw
|
||||
/packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/integrations/src/download-client/slskd/ @manuel-rw
|
||||
/packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/integrations/src/download-client/transmission/ @manuel-rw
|
||||
/packages/cron-jobs/src/jobs/integrations/home-assistant.ts /packages/integrations/src/homeassistant/ @manuel-rw
|
||||
/packages/cron-jobs/src/jobs/integrations/media-organizer.ts /packages/integrations/src/media-organizer/lidarr/ @NicolasNewman
|
||||
/packages/cron-jobs/src/jobs/integrations/media-organizer.ts /packages/integrations/src/media-organizer/radarr/ @manuel-rw
|
||||
/packages/cron-jobs/src/jobs/integrations/media-organizer.ts /packages/integrations/src/media-organizer/readarr/ @manuel-rw
|
||||
/packages/cron-jobs/src/jobs/integrations/media-organizer.ts /packages/integrations/src/media-organizer/sonarr/ @manuel-rw
|
||||
/packages/integrations/src/adguard-home/ @manuel-rw
|
||||
/packages/integrations/src/codeberg/ @Aandree5
|
||||
/packages/integrations/src/dashdot/ @dgshue
|
||||
/packages/integrations/src/docker-hub/ @Aandree5
|
||||
/packages/integrations/src/emby/ @manuel-rw
|
||||
/packages/integrations/src/github-container-registry/ @manuel-rw
|
||||
/packages/integrations/src/github/ @manuel-rw
|
||||
/packages/integrations/src/gitlab/ @Aandree5
|
||||
/packages/integrations/src/glances/ @dgshue
|
||||
/packages/integrations/src/ical/ @NicolasNewman
|
||||
/packages/integrations/src/jellyfin/ @manuel-rw
|
||||
/packages/integrations/src/jellyseerr/ @manuel-rw
|
||||
/packages/integrations/src/linuxserverio/ @Aandree5
|
||||
/packages/integrations/src/nextcloud/ @manuel-rw
|
||||
/packages/integrations/src/npm/ @Aandree5
|
||||
/packages/integrations/src/ntfy/ @manuel-rw
|
||||
/packages/integrations/src/openmediavault/ @dgshue
|
||||
/packages/integrations/src/opnsense/ @manuel-rw
|
||||
/packages/integrations/src/overseerr/ @manuel-rw
|
||||
/packages/integrations/src/plex/ @manuel-rw
|
||||
/packages/integrations/src/prowlarr/ @manuel-rw
|
||||
/packages/integrations/src/proxmox/ @manuel-rw
|
||||
/packages/integrations/src/quay/ @Aandree5
|
||||
/packages/integrations/src/search-ch/ @manuel-rw
|
||||
/packages/integrations/src/seerr/ @manuel-rw
|
||||
/packages/integrations/src/truenas/ @dgshue
|
||||
/packages/integrations/src/unifi-controller/ @manuel-rw
|
||||
/packages/integrations/src/unraid/ @dgshue
|
||||
/packages/widgets/src/bookmarks/ @manuel-rw
|
||||
/packages/widgets/src/clock/ @manuel-rw
|
||||
/packages/widgets/src/iframe/ @manuel-rw
|
||||
/packages/widgets/src/system-disks/ @manuel-rw
|
||||
/packages/widgets/src/system-resources/ @kylegrieder
|
||||
/packages/widgets/src/video/ @manuel-rw
|
||||
# END GENERATED widget/integration owners
|
||||
@@ -148,9 +148,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
||||
);
|
||||
};
|
||||
|
||||
const integrationCategories = integrationDefs[searchParams.kind].category.flat();
|
||||
const supportsSearchEngine =
|
||||
integrationCategories.includes("search") && !integrationCategories.includes("mediaSearch");
|
||||
const supportsSearchEngine = integrationDefs[searchParams.kind].category.flat().includes("search");
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
|
||||
|
||||
@@ -85,7 +85,6 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
|
||||
),
|
||||
integrationSupport: "supportedIntegrations" in currentDefinition,
|
||||
settings,
|
||||
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
||||
},
|
||||
{
|
||||
title(t) {
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
"release": "semantic-release",
|
||||
"scripts:update-bug-report-template": "tsx ./scripts/update-bug-report-template.mts",
|
||||
"scripts:update-readme-integrations": "tsx ./scripts/update-integration-list.mts",
|
||||
"scripts:generate-codeowners": "tsx ./scripts/generate-codeowners.mts",
|
||||
"scripts:validate-codeowners": "tsx ./scripts/validate-codeowners.mts",
|
||||
"start": "concurrently \"pnpm with-env node apps/tasks/tasks.cjs\" \"pnpm with-env node apps/websocket/wssServer.cjs\" \"pnpm -F nextjs start\"",
|
||||
"test": "cross-env NODE_ENV=development CI=true vitest run --exclude e2e --coverage.enabled ",
|
||||
"test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e",
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { hasQueryAccessToIntegrationsAsync } from "@homarr/auth/server";
|
||||
import { constructIntegrationPermissions } from "@homarr/auth/shared";
|
||||
import { createId, objectEntries } from "@homarr/common";
|
||||
import { decryptSecret, encryptSecret } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
@@ -16,9 +14,11 @@ import {
|
||||
integrations,
|
||||
integrationSecrets,
|
||||
integrationUserPermissions,
|
||||
searchEngines,
|
||||
} from "@homarr/db/schema";
|
||||
import type { IntegrationSecretKind } from "@homarr/definitions";
|
||||
import {
|
||||
getIconUrl,
|
||||
getIntegrationKindsByCategory,
|
||||
getPermissionsWithParents,
|
||||
integrationCategories,
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
integrationSavePermissionsSchema,
|
||||
integrationUpdateSchema,
|
||||
} from "@homarr/validation/integration";
|
||||
import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/validation/widgets/media-request";
|
||||
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
@@ -42,7 +41,6 @@ import { MissingSecretError, testConnectionAsync } from "./integration-test-conn
|
||||
import { mapTestConnectionError } from "./map-test-connection-error";
|
||||
|
||||
const logger = createLogger({ module: "integrationRouter" });
|
||||
const mediaRequestSearchKinds = getIntegrationKindsByCategory("mediaSearch");
|
||||
|
||||
export const integrationRouter = createTRPCRouter({
|
||||
all: protectedProcedure.query(async ({ ctx }) => {
|
||||
@@ -82,7 +80,7 @@ export const integrationRouter = createTRPCRouter({
|
||||
},
|
||||
};
|
||||
})
|
||||
.toSorted(
|
||||
.sort(
|
||||
(integrationA, integrationB) =>
|
||||
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
|
||||
);
|
||||
@@ -130,7 +128,7 @@ export const integrationRouter = createTRPCRouter({
|
||||
},
|
||||
};
|
||||
})
|
||||
.toSorted(
|
||||
.sort(
|
||||
(integrationA, integrationB) =>
|
||||
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
|
||||
);
|
||||
@@ -181,7 +179,7 @@ export const integrationRouter = createTRPCRouter({
|
||||
},
|
||||
};
|
||||
})
|
||||
.toSorted(
|
||||
.sort(
|
||||
(integrationA, integrationB) =>
|
||||
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
|
||||
);
|
||||
@@ -316,7 +314,20 @@ export const integrationRouter = createTRPCRouter({
|
||||
url: input.url,
|
||||
});
|
||||
|
||||
// Media request search is a first-class Spotlight action. Do not create a search engine for it.
|
||||
if (
|
||||
input.attemptSearchEngineCreation &&
|
||||
integrationDefs[input.kind].category.flatMap((category) => category).includes("search")
|
||||
) {
|
||||
const icon = getIconUrl(input.kind);
|
||||
await ctx.db.insert(searchEngines).values({
|
||||
id: createId(),
|
||||
name: input.name,
|
||||
integrationId,
|
||||
type: "fromIntegration",
|
||||
iconUrl: icon,
|
||||
short: await getNextValidShortNameForSearchEngineAsync(ctx.db, input.name),
|
||||
});
|
||||
}
|
||||
}),
|
||||
update: protectedProcedure.input(integrationUpdateSchema).mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
|
||||
@@ -484,7 +495,7 @@ export const integrationRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
return {
|
||||
inherited: dbGroupPermissions.toSorted((permissionA, permissionB) => {
|
||||
inherited: dbGroupPermissions.sort((permissionA, permissionB) => {
|
||||
return permissionA.group.name.localeCompare(permissionB.group.name);
|
||||
}),
|
||||
users: userPermissions
|
||||
@@ -492,7 +503,7 @@ export const integrationRouter = createTRPCRouter({
|
||||
user,
|
||||
permission,
|
||||
}))
|
||||
.toSorted((permissionA, permissionB) => {
|
||||
.sort((permissionA, permissionB) => {
|
||||
return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? "");
|
||||
}),
|
||||
groups: dbGroupIntegrationPermission
|
||||
@@ -503,7 +514,7 @@ export const integrationRouter = createTRPCRouter({
|
||||
},
|
||||
permission,
|
||||
}))
|
||||
.toSorted((permissionA, permissionB) => {
|
||||
.sort((permissionA, permissionB) => {
|
||||
return permissionA.group.name.localeCompare(permissionB.group.name);
|
||||
}),
|
||||
};
|
||||
@@ -607,149 +618,8 @@ export const integrationRouter = createTRPCRouter({
|
||||
const integrationInstance = await createIntegrationAsync(ctx.integration);
|
||||
return await integrationInstance.searchAsync(encodeURI(input.query));
|
||||
}),
|
||||
mediaRequestSearchTargets: protectedProcedure.query(async ({ ctx }) => {
|
||||
const integrationsWithAccess = await getAccessibleMediaRequestSearchIntegrationsAsync(ctx);
|
||||
|
||||
return integrationsWithAccess.map((integration) => {
|
||||
const permissions = constructIntegrationPermissions(integration, ctx.session);
|
||||
|
||||
return {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
url: integration.url,
|
||||
permissions: {
|
||||
hasInteractAccess: permissions.hasInteractAccess,
|
||||
},
|
||||
};
|
||||
});
|
||||
}),
|
||||
searchMediaRequests: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
query: z.string(),
|
||||
integrationIds: z.array(z.string()).optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const query = input.query.trim();
|
||||
if (query.length === 0) return [];
|
||||
|
||||
const integrationsWithAccess = await getAccessibleMediaRequestSearchIntegrationsAsync(ctx, input.integrationIds);
|
||||
|
||||
const results = await Promise.all(
|
||||
integrationsWithAccess.map(async (integration) => {
|
||||
const integrationInstance = await createIntegrationAsync({
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
url: integration.url,
|
||||
kind: integration.kind as (typeof mediaRequestSearchKinds)[number],
|
||||
externalUrl: integration.app?.href ?? null,
|
||||
decryptedSecrets: integration.secrets.map((secret) => ({
|
||||
...secret,
|
||||
value: decryptSecret(secret.value),
|
||||
})),
|
||||
});
|
||||
const permissions = constructIntegrationPermissions(integration, ctx.session);
|
||||
|
||||
return await integrationInstance.searchAsync(encodeURI(query)).then((searchResults) =>
|
||||
searchResults.map((result) => ({
|
||||
...result,
|
||||
integration: {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
url: integration.url,
|
||||
permissions: {
|
||||
hasInteractAccess: permissions.hasInteractAccess,
|
||||
},
|
||||
},
|
||||
})),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
return results.flat();
|
||||
}),
|
||||
getMediaRequestOptions: protectedProcedure
|
||||
.concat(createOneIntegrationMiddleware("query", "jellyseerr", "overseerr", "seerr"))
|
||||
.input(mediaRequestOptionsSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const integration = await createIntegrationAsync(ctx.integration);
|
||||
return await integration.getSeriesInformationAsync(input.mediaType, input.mediaId);
|
||||
}),
|
||||
requestMedia: protectedProcedure
|
||||
.concat(createOneIntegrationMiddleware("interact", "jellyseerr", "overseerr", "seerr"))
|
||||
.input(mediaRequestRequestSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const integration = await createIntegrationAsync(ctx.integration);
|
||||
return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons);
|
||||
}),
|
||||
});
|
||||
|
||||
interface IntegrationRouterContext {
|
||||
db: Database;
|
||||
session: Parameters<typeof hasQueryAccessToIntegrationsAsync>[2];
|
||||
}
|
||||
|
||||
const getAccessibleMediaRequestSearchIntegrationsAsync = async (
|
||||
ctx: IntegrationRouterContext,
|
||||
integrationIds?: string[],
|
||||
) => {
|
||||
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, ctx.session?.user.id ?? ""),
|
||||
});
|
||||
|
||||
const integrationsFromDb = await ctx.db.query.integrations.findMany({
|
||||
where:
|
||||
integrationIds && integrationIds.length > 0
|
||||
? and(inArray(integrations.id, integrationIds), inArray(integrations.kind, mediaRequestSearchKinds))
|
||||
: inArray(integrations.kind, mediaRequestSearchKinds),
|
||||
orderBy: asc(integrations.name),
|
||||
with: {
|
||||
app: true,
|
||||
secrets: true,
|
||||
items: {
|
||||
with: {
|
||||
item: true,
|
||||
},
|
||||
},
|
||||
userPermissions: {
|
||||
where: eq(integrationUserPermissions.userId, ctx.session?.user.id ?? ""),
|
||||
},
|
||||
groupPermissions: {
|
||||
where: inArray(
|
||||
integrationGroupPermissions.groupId,
|
||||
groupsOfCurrentUser.map((group) => group.groupId),
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (integrationIds && integrationsFromDb.length !== integrationIds.length) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "One or more media request search integrations were not found",
|
||||
});
|
||||
}
|
||||
|
||||
const integrationsWithAccess = await Promise.all(
|
||||
integrationsFromDb.map(async (integration) => ({
|
||||
integration,
|
||||
hasAccess: await hasQueryAccessToIntegrationsAsync(ctx.db, [integration], ctx.session),
|
||||
})),
|
||||
);
|
||||
|
||||
if (integrationIds && integrationsWithAccess.some(({ hasAccess }) => !hasAccess)) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "User does not have permission to query one or more media request search integrations",
|
||||
});
|
||||
}
|
||||
|
||||
return integrationsWithAccess.filter(({ hasAccess }) => hasAccess).map(({ integration }) => integration);
|
||||
};
|
||||
|
||||
interface UpdateSecretInput {
|
||||
integrationId: string;
|
||||
value: string;
|
||||
@@ -770,6 +640,35 @@ interface AddSecretInput {
|
||||
kind: IntegrationSecretKind;
|
||||
}
|
||||
|
||||
const getNextValidShortNameForSearchEngineAsync = async (db: Database, integrationName: string) => {
|
||||
const searchEngines = await db.query.searchEngines.findMany({
|
||||
columns: {
|
||||
short: true,
|
||||
},
|
||||
});
|
||||
|
||||
const usedShortNames = searchEngines.flatMap((searchEngine) => searchEngine.short.toLowerCase());
|
||||
const nameByIntegrationName = integrationName.slice(0, 1).toLowerCase();
|
||||
|
||||
if (!usedShortNames.includes(nameByIntegrationName)) {
|
||||
return nameByIntegrationName;
|
||||
}
|
||||
|
||||
// 8 is max length constraint
|
||||
for (let i = 2; i < 9999999; i++) {
|
||||
const generatedName = `${nameByIntegrationName}${i}`;
|
||||
if (usedShortNames.includes(generatedName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return generatedName;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Unable to automatically generate a short name. All possible variations were exhausted. Please disable the automatic creation and choose one later yourself.",
|
||||
);
|
||||
};
|
||||
|
||||
const addSecretAsync = async (db: Database, input: AddSecretInput) => {
|
||||
await db.insert(integrationSecrets).values({
|
||||
kind: input.kind,
|
||||
|
||||
@@ -6,9 +6,12 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { asc, eq, like } from "@homarr/db";
|
||||
import { getServerSettingByKeyAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
import { searchEngines, users } from "@homarr/db/schema";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { byIdSchema, paginatedSchema, searchSchema } from "@homarr/validation/common";
|
||||
import { searchEngineEditSchema, searchEngineManageSchema } from "@homarr/validation/search-engine";
|
||||
import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/validation/widgets/media-request";
|
||||
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
|
||||
const logger = createLogger({ module: "searchEngineRouter" });
|
||||
@@ -145,6 +148,20 @@ export const searchEngineRouter = createTRPCRouter({
|
||||
limit: input.limit,
|
||||
});
|
||||
}),
|
||||
getMediaRequestOptions: protectedProcedure
|
||||
.concat(createOneIntegrationMiddleware("query", "jellyseerr", "overseerr"))
|
||||
.input(mediaRequestOptionsSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const integration = await createIntegrationAsync(ctx.integration);
|
||||
return await integration.getSeriesInformationAsync(input.mediaType, input.mediaId);
|
||||
}),
|
||||
requestMedia: protectedProcedure
|
||||
.concat(createOneIntegrationMiddleware("interact", "jellyseerr", "overseerr"))
|
||||
.input(mediaRequestRequestSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const integration = await createIntegrationAsync(ctx.integration);
|
||||
return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons);
|
||||
}),
|
||||
create: permissionRequiredProcedure
|
||||
.requiresPermission("search-engine-create")
|
||||
.input(searchEngineManageSchema)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { describe, expect, test, vi } from "vitest";
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import { encryptSecret } from "@homarr/common/server";
|
||||
import { apps, integrations, integrationSecrets, integrationUserPermissions, users } from "@homarr/db/schema";
|
||||
import { apps, integrations, integrationSecrets } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
|
||||
@@ -59,127 +59,6 @@ describe("all should return all integrations", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("mediaRequestSearchTargets should return accessible media search integrations", () => {
|
||||
test("with integration use access should only return media request search integrations", async () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(["integration-use-all"]),
|
||||
});
|
||||
|
||||
await db.insert(integrations).values([
|
||||
{
|
||||
id: "1",
|
||||
name: "Jellyseerr",
|
||||
kind: "jellyseerr",
|
||||
url: "http://jellyseerr.local",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Jellyfin",
|
||||
kind: "jellyfin",
|
||||
url: "http://jellyfin.local",
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await caller.mediaRequestSearchTargets();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.id).toBe("1");
|
||||
expect(result[0]!.kind).toBe("jellyseerr");
|
||||
});
|
||||
|
||||
test("without access should not return media request search integrations", async () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(),
|
||||
});
|
||||
|
||||
await db.insert(integrations).values({
|
||||
id: "1",
|
||||
name: "Jellyseerr",
|
||||
kind: "jellyseerr",
|
||||
url: "http://jellyseerr.local",
|
||||
});
|
||||
|
||||
const result = await caller.mediaRequestSearchTargets();
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("with direct integration use access should return media request search integrations", async () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(),
|
||||
});
|
||||
|
||||
await db.insert(integrations).values({
|
||||
id: "1",
|
||||
name: "Jellyseerr",
|
||||
kind: "jellyseerr",
|
||||
url: "http://jellyseerr.local",
|
||||
});
|
||||
await db.insert(users).values({ id: defaultUserId });
|
||||
await db.insert(integrationUserPermissions).values({
|
||||
integrationId: "1",
|
||||
userId: defaultUserId,
|
||||
permission: "use",
|
||||
});
|
||||
|
||||
const result = await caller.mediaRequestSearchTargets();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.id).toBe("1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("searchMediaRequests should validate scoped integrations", () => {
|
||||
test("with a non media search integration should throw not found", async () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(["integration-use-all"]),
|
||||
});
|
||||
|
||||
await db.insert(integrations).values({
|
||||
id: "1",
|
||||
name: "Jellyfin",
|
||||
kind: "jellyfin",
|
||||
url: "http://jellyfin.local",
|
||||
});
|
||||
|
||||
await expect(caller.searchMediaRequests({ query: "matrix", integrationIds: ["1"] })).rejects.toThrow(
|
||||
"One or more media request search integrations were not found",
|
||||
);
|
||||
});
|
||||
|
||||
test("without query access to a scoped media search integration should throw forbidden", async () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(),
|
||||
});
|
||||
|
||||
await db.insert(integrations).values({
|
||||
id: "1",
|
||||
name: "Jellyseerr",
|
||||
kind: "jellyseerr",
|
||||
url: "http://jellyseerr.local",
|
||||
});
|
||||
|
||||
await expect(caller.searchMediaRequests({ query: "matrix", integrationIds: ["1"] })).rejects.toThrow(
|
||||
"User does not have permission to query one or more media request search integrations",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("byId should return an integration by id", () => {
|
||||
test("with full access should return an integration by id", async () => {
|
||||
const db = createDb();
|
||||
@@ -329,7 +208,7 @@ describe("create should create a new integration", () => {
|
||||
expect(dbSecret!.updatedAt).toEqual(fakeNow);
|
||||
});
|
||||
|
||||
test("with create integration access should not create a search engine for media request search integrations", async () => {
|
||||
test("with create integration access should create a new integration when creating search engine", async () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
@@ -364,7 +243,12 @@ describe("create should create a new integration", () => {
|
||||
expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
|
||||
expect(dbSecret!.updatedAt).toEqual(fakeNow);
|
||||
|
||||
expect(dbSearchEngine).toBeUndefined();
|
||||
expect(dbSearchEngine!.integrationId).toBe(dbIntegration!.id);
|
||||
expect(dbSearchEngine!.short).toBe("j");
|
||||
expect(dbSearchEngine!.name).toBe(input.name);
|
||||
expect(dbSearchEngine!.iconUrl).toBe(
|
||||
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyseerr.svg",
|
||||
);
|
||||
});
|
||||
|
||||
test("with create integration access should create a new integration with new linked app", async () => {
|
||||
|
||||
@@ -542,7 +542,7 @@ export const userRouter = createTRPCRouter({
|
||||
.set({
|
||||
pingIconsEnabled: input.pingIconsEnabled,
|
||||
})
|
||||
.where(eq(users.id, input.id));
|
||||
.where(eq(users.id, ctx.session.user.id));
|
||||
}),
|
||||
changeDdgBangs: protectedProcedure
|
||||
.input(convertIntersectionToZodObject(userDdgBangsSchema.and(byIdSchema)))
|
||||
@@ -603,7 +603,7 @@ export const userRouter = createTRPCRouter({
|
||||
.set({
|
||||
firstDayOfWeek: input.firstDayOfWeek,
|
||||
})
|
||||
.where(eq(users.id, input.id));
|
||||
.where(eq(users.id, ctx.session.user.id));
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { ChangeEventHandler } from "react";
|
||||
import { useEffect, useImperativeHandle, useRef } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Button, Checkbox, Collapse, Group, Stack, Textarea, TextInput } from "@mantine/core";
|
||||
import { useDebouncedValue, useDisclosure } from "@mantine/hooks";
|
||||
import type { z } from "zod/v4";
|
||||
@@ -17,19 +17,6 @@ import { findBestIconMatch } from "./icon-matcher";
|
||||
|
||||
type FormType = z.infer<typeof appManageSchema>;
|
||||
|
||||
const toFormValues = (values: FormType | undefined): FormType => ({
|
||||
name: values?.name ?? "",
|
||||
description: values?.description ?? "",
|
||||
iconUrl: values?.iconUrl ?? "",
|
||||
href: values?.href ?? "",
|
||||
pingUrl: values?.pingUrl ?? "",
|
||||
});
|
||||
|
||||
export interface AppFormHandle {
|
||||
submit: () => Promise<boolean>;
|
||||
isDirty: () => boolean;
|
||||
}
|
||||
|
||||
interface AppFormProps {
|
||||
showBackToOverview: boolean;
|
||||
buttonLabels: {
|
||||
@@ -37,10 +24,8 @@ interface AppFormProps {
|
||||
submitAndCreateAnother?: string;
|
||||
};
|
||||
initialValues?: FormType;
|
||||
handleSubmit: (values: FormType, redirect: boolean, afterSuccess?: () => void) => void | Promise<void>;
|
||||
handleSubmit: (values: FormType, redirect: boolean, afterSuccess?: () => void) => void;
|
||||
isPending: boolean;
|
||||
hideButtons?: boolean;
|
||||
formRef?: React.Ref<AppFormHandle>;
|
||||
}
|
||||
|
||||
export const AppForm = ({
|
||||
@@ -49,31 +34,19 @@ export const AppForm = ({
|
||||
handleSubmit: originalHandleSubmit,
|
||||
initialValues,
|
||||
isPending,
|
||||
hideButtons,
|
||||
formRef,
|
||||
}: AppFormProps) => {
|
||||
const t = useI18n();
|
||||
|
||||
const form = useZodForm(appManageSchema, {
|
||||
initialValues: toFormValues(initialValues),
|
||||
initialValues: {
|
||||
name: initialValues?.name ?? "",
|
||||
description: initialValues?.description ?? "",
|
||||
iconUrl: initialValues?.iconUrl ?? "",
|
||||
href: initialValues?.href ?? "",
|
||||
pingUrl: initialValues?.pingUrl ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const initialValuesKey = [
|
||||
initialValues?.name,
|
||||
initialValues?.description,
|
||||
initialValues?.iconUrl,
|
||||
initialValues?.href,
|
||||
initialValues?.pingUrl,
|
||||
].join("\0");
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialValues || form.isDirty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.initialize(toFormValues(initialValues));
|
||||
}, [initialValuesKey, form, initialValues]);
|
||||
|
||||
// Debounce the name value with 200ms delay
|
||||
const [debouncedName] = useDebouncedValue(form.values.name, 200);
|
||||
|
||||
@@ -89,29 +62,6 @@ export const AppForm = ({
|
||||
originalHandleSubmit(values, redirect, afterSuccess);
|
||||
};
|
||||
|
||||
useImperativeHandle(
|
||||
formRef,
|
||||
() => ({
|
||||
submit: () =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
form.onSubmit(
|
||||
async (values) => {
|
||||
try {
|
||||
await Promise.resolve(handleSubmit(values));
|
||||
form.initialize(values);
|
||||
resolve(true);
|
||||
} catch {
|
||||
resolve(false);
|
||||
}
|
||||
},
|
||||
() => resolve(false),
|
||||
)();
|
||||
}),
|
||||
isDirty: () => form.isDirty(),
|
||||
}),
|
||||
[form, handleSubmit],
|
||||
);
|
||||
|
||||
const [opened, { open, close }] = useDisclosure((initialValues?.pingUrl?.length ?? 0) > 0);
|
||||
|
||||
const handleClickDifferentUrlPing: ChangeEventHandler<HTMLInputElement> = () => {
|
||||
@@ -142,32 +92,32 @@ export const AppForm = ({
|
||||
}
|
||||
}, [debouncedName, iconsData]);
|
||||
|
||||
const formFields = (
|
||||
<Stack>
|
||||
<TextInput {...form.getInputProps("name")} withAsterisk label={t("app.field.name.label")} />
|
||||
<IconPicker {...form.getInputProps("iconUrl")} />
|
||||
<Textarea
|
||||
{...form.getInputProps("description")}
|
||||
label={t("app.field.description.label")}
|
||||
autosize
|
||||
minRows={2}
|
||||
resize="vertical"
|
||||
/>
|
||||
<TextInput {...form.getInputProps("href")} label={t("app.field.url.label")} />
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack>
|
||||
<TextInput {...form.getInputProps("name")} withAsterisk label={t("app.field.name.label")} />
|
||||
<IconPicker {...form.getInputProps("iconUrl")} />
|
||||
<Textarea
|
||||
{...form.getInputProps("description")}
|
||||
label={t("app.field.description.label")}
|
||||
autosize
|
||||
minRows={2}
|
||||
resize="vertical"
|
||||
/>
|
||||
<TextInput {...form.getInputProps("href")} label={t("app.field.url.label")} />
|
||||
|
||||
<Checkbox
|
||||
checked={opened}
|
||||
onChange={handleClickDifferentUrlPing}
|
||||
label={t("app.field.useDifferentUrlForPing.checkbox.label")}
|
||||
description={t("app.field.useDifferentUrlForPing.checkbox.description")}
|
||||
mt="md"
|
||||
/>
|
||||
<Checkbox
|
||||
checked={opened}
|
||||
onChange={handleClickDifferentUrlPing}
|
||||
label={t("app.field.useDifferentUrlForPing.checkbox.label")}
|
||||
description={t("app.field.useDifferentUrlForPing.checkbox.description")}
|
||||
mt="md"
|
||||
/>
|
||||
|
||||
<Collapse expanded={opened}>
|
||||
<TextInput {...form.getInputProps("pingUrl")} />
|
||||
</Collapse>
|
||||
<Collapse expanded={opened}>
|
||||
<TextInput {...form.getInputProps("pingUrl")} />
|
||||
</Collapse>
|
||||
|
||||
{!hideButtons && (
|
||||
<Group justify="end">
|
||||
{showBackToOverview && (
|
||||
<Button variant="default" component={Link} href="/manage/apps">
|
||||
@@ -189,13 +139,7 @@ export const AppForm = ({
|
||||
{buttonLabels.submit}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
|
||||
if (hideButtons) {
|
||||
return formFields;
|
||||
}
|
||||
|
||||
return <form onSubmit={form.onSubmit(handleSubmit)}>{formFields}</form>;
|
||||
};
|
||||
@@ -12,14 +12,12 @@ interface SeriesInformation {
|
||||
overview: string;
|
||||
seasons: SerieSeason[];
|
||||
posterPath: string;
|
||||
requestedSeasons?: number[];
|
||||
}
|
||||
|
||||
interface MovieInformation {
|
||||
id: number;
|
||||
overview: string;
|
||||
posterPath: string;
|
||||
requestedSeasons?: number[];
|
||||
}
|
||||
|
||||
export type MediaInformation = SeriesInformation | MovieInformation;
|
||||
|
||||
@@ -60,9 +60,6 @@ export class OverseerrIntegration
|
||||
text: "overview" in result ? result.overview : undefined,
|
||||
type: result.mediaType,
|
||||
inLibrary: result.mediaInfo !== undefined,
|
||||
availability: result.mediaInfo
|
||||
? this.mapAvailability(result.mediaInfo.status as UpstreamMediaAvailability, false)
|
||||
: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -73,12 +70,7 @@ export class OverseerrIntegration
|
||||
"X-Api-Key": this.getSecretValue("apiKey"),
|
||||
},
|
||||
});
|
||||
const data = await mediaInformationSchema.parseAsync(await response.json());
|
||||
const requestedSeasons = [
|
||||
...new Set(data.mediaInfo?.requests?.flatMap((req) => req.seasons.map((s) => s.seasonNumber)) ?? []),
|
||||
];
|
||||
const { mediaInfo: _strip, ...rest } = data;
|
||||
return { ...rest, requestedSeasons };
|
||||
return await mediaInformationSchema.parseAsync(await response.json());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -349,22 +341,6 @@ interface MovieInformation {
|
||||
releaseDate: string;
|
||||
}
|
||||
|
||||
const mediaInfoRequestsSchema = z
|
||||
.object({
|
||||
requests: z
|
||||
.array(
|
||||
z.object({
|
||||
seasons: z.array(
|
||||
z.object({
|
||||
seasonNumber: z.number(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
const mediaInformationSchema = z.union([
|
||||
z.object({
|
||||
id: z.number(),
|
||||
@@ -379,22 +355,14 @@ const mediaInformationSchema = z.union([
|
||||
),
|
||||
numberOfSeasons: z.number(),
|
||||
posterPath: z.string().startsWith("/"),
|
||||
mediaInfo: mediaInfoRequestsSchema,
|
||||
}),
|
||||
z.object({
|
||||
id: z.number(),
|
||||
overview: z.string(),
|
||||
posterPath: z.string().startsWith("/"),
|
||||
mediaInfo: mediaInfoRequestsSchema,
|
||||
}),
|
||||
]);
|
||||
|
||||
const searchMediaInfoSchema = z
|
||||
.object({
|
||||
status: z.number(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
const searchSchema = z.object({
|
||||
results: z
|
||||
.array(
|
||||
@@ -405,7 +373,7 @@ const searchSchema = z.object({
|
||||
name: z.string(),
|
||||
posterPath: z.string().startsWith("/").endsWith(".jpg").nullable(),
|
||||
overview: z.string(),
|
||||
mediaInfo: searchMediaInfoSchema,
|
||||
mediaInfo: z.object({}).optional(),
|
||||
}),
|
||||
z.object({
|
||||
id: z.number(),
|
||||
@@ -413,14 +381,14 @@ const searchSchema = z.object({
|
||||
title: z.string(),
|
||||
posterPath: z.string().startsWith("/").endsWith(".jpg").nullable(),
|
||||
overview: z.string(),
|
||||
mediaInfo: searchMediaInfoSchema,
|
||||
mediaInfo: z.object({}).optional(),
|
||||
}),
|
||||
z.object({
|
||||
id: z.number(),
|
||||
mediaType: z.literal("person"),
|
||||
name: z.string(),
|
||||
profilePath: z.string().startsWith("/").endsWith(".jpg").nullable(),
|
||||
mediaInfo: searchMediaInfoSchema,
|
||||
mediaInfo: z.object({}).optional(),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import { Badge, Button, Group, Image, LoadingOverlay, Stack, Text } from "@mantine/core";
|
||||
import type { MRT_ColumnDef, MRT_Row } from "mantine-react-table";
|
||||
import { Button, Group, Image, LoadingOverlay, Stack, Text } from "@mantine/core";
|
||||
import type { MRT_ColumnDef } from "mantine-react-table";
|
||||
import { MRT_Table } from "mantine-react-table";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
@@ -16,13 +16,13 @@ interface RequestMediaModalProps {
|
||||
}
|
||||
|
||||
export const RequestMediaModal = createModal<RequestMediaModalProps>(({ actions, innerProps }) => {
|
||||
const { data, isPending: isPendingQuery } = clientApi.integration.getMediaRequestOptions.useQuery({
|
||||
const { data, isPending: isPendingQuery } = clientApi.searchEngine.getMediaRequestOptions.useQuery({
|
||||
integrationId: innerProps.integrationId,
|
||||
mediaId: innerProps.mediaId,
|
||||
mediaType: innerProps.mediaType,
|
||||
});
|
||||
|
||||
const { mutate, isPending: isPendingMutation } = clientApi.integration.requestMedia.useMutation({
|
||||
const { mutate, isPending: isPendingMutation } = clientApi.searchEngine.requestMedia.useMutation({
|
||||
onSuccess() {
|
||||
actions.closeModal();
|
||||
showSuccessNotification({
|
||||
@@ -33,7 +33,6 @@ export const RequestMediaModal = createModal<RequestMediaModalProps>(({ actions,
|
||||
|
||||
const isPending = isPendingQuery || isPendingMutation;
|
||||
const t = useI18n();
|
||||
const requestedSeasons = new Set(data?.requestedSeasons ?? []);
|
||||
|
||||
const columns = useMemo<MRT_ColumnDef<Season>[]>(
|
||||
() => [
|
||||
@@ -45,18 +44,8 @@ export const RequestMediaModal = createModal<RequestMediaModalProps>(({ actions,
|
||||
accessorKey: "episodeCount",
|
||||
header: t("search.engine.media.request.modal.table.header.episodes"),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: t("search.engine.media.request.modal.table.header.status"),
|
||||
Cell: ({ row }) =>
|
||||
requestedSeasons.has(row.original.seasonNumber) ? (
|
||||
<Badge size="xs" color="violet" variant="light">
|
||||
{t("search.engine.media.request.modal.table.status.requested")}
|
||||
</Badge>
|
||||
) : null,
|
||||
},
|
||||
],
|
||||
[data?.requestedSeasons],
|
||||
[],
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
@@ -67,7 +56,7 @@ export const RequestMediaModal = createModal<RequestMediaModalProps>(({ actions,
|
||||
enablePagination: false,
|
||||
enableSorting: false,
|
||||
enableSelectAll: true,
|
||||
enableRowSelection: (row: MRT_Row<Season>) => !requestedSeasons.has(row.original.seasonNumber),
|
||||
enableRowSelection: true,
|
||||
mantineTableProps: {
|
||||
highlightOnHover: false,
|
||||
striped: "odd",
|
||||
|
||||
@@ -44,7 +44,6 @@ const optionMapping: OptionMapping = {
|
||||
hideIcon: (oldOptions) => oldOptions.items.some((item) => item.hideIcon),
|
||||
hideHostname: (oldOptions) => oldOptions.items.some((item) => item.hideHostname),
|
||||
openNewTab: (oldOptions) => oldOptions.items.some((item) => item.openNewTab),
|
||||
withBorder: () => undefined,
|
||||
},
|
||||
calendar: {
|
||||
releaseType: (oldOptions) => [oldOptions.radarrReleaseType],
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export * from "./src/context";
|
||||
export * from "./src/preferences/definitions";
|
||||
@@ -1,78 +0,0 @@
|
||||
export const userPreferenceDefinitions = [
|
||||
{
|
||||
key: "colorScheme",
|
||||
kind: "select",
|
||||
guest: true,
|
||||
mutationGroup: "colorScheme",
|
||||
aliases: ["theme", "dark", "light", "auto"],
|
||||
},
|
||||
{ key: "locale", kind: "select", guest: true, mutationGroup: "locale", aliases: ["language", "translation"] },
|
||||
{
|
||||
key: "defaultSearchEngineId",
|
||||
kind: "searchEngine",
|
||||
guest: false,
|
||||
mutationGroup: "searchPreferences",
|
||||
aliases: ["search", "search engine"],
|
||||
},
|
||||
{
|
||||
key: "openSearchInNewTab",
|
||||
kind: "boolean",
|
||||
guest: false,
|
||||
mutationGroup: "searchPreferences",
|
||||
aliases: ["search", "new tab", "external"],
|
||||
},
|
||||
{
|
||||
key: "ddgBangs",
|
||||
kind: "boolean",
|
||||
guest: false,
|
||||
mutationGroup: "searchPreferences",
|
||||
aliases: ["duckduckgo", "bangs", "search"],
|
||||
},
|
||||
{
|
||||
key: "firstDayOfWeek",
|
||||
kind: "select",
|
||||
guest: false,
|
||||
mutationGroup: "firstDayOfWeek",
|
||||
aliases: ["calendar", "week", "weekday"],
|
||||
},
|
||||
{
|
||||
key: "homeBoardId",
|
||||
kind: "board",
|
||||
guest: false,
|
||||
mutationGroup: "homeBoards",
|
||||
aliases: ["home", "board", "dashboard"],
|
||||
},
|
||||
{
|
||||
key: "mobileHomeBoardId",
|
||||
kind: "board",
|
||||
guest: false,
|
||||
mutationGroup: "homeBoards",
|
||||
aliases: ["mobile", "home", "board", "dashboard"],
|
||||
},
|
||||
{
|
||||
key: "pingIconsEnabled",
|
||||
kind: "boolean",
|
||||
guest: false,
|
||||
mutationGroup: "pingIconsEnabled",
|
||||
aliases: ["ping", "icon", "status"],
|
||||
},
|
||||
{
|
||||
key: "fullPreferencesPage",
|
||||
kind: "link",
|
||||
guest: false,
|
||||
mutationGroup: null,
|
||||
aliases: ["settings", "manage", "profile"],
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type UserPreferenceDefinition = (typeof userPreferenceDefinitions)[number];
|
||||
export type UserPreferenceKey = UserPreferenceDefinition["key"];
|
||||
export type UserPreferenceKind = UserPreferenceDefinition["kind"];
|
||||
export type UserPreferenceMutationGroup = Exclude<UserPreferenceDefinition["mutationGroup"], null>;
|
||||
|
||||
export const userPreferenceDefinitionByKey = Object.fromEntries(
|
||||
userPreferenceDefinitions.map((definition) => [definition.key, definition]),
|
||||
) as Record<UserPreferenceKey, UserPreferenceDefinition>;
|
||||
|
||||
export const visiblePreferenceDefinitions = (isAuthenticated: boolean) =>
|
||||
userPreferenceDefinitions.filter((definition) => definition.guest || isAuthenticated);
|
||||
@@ -29,7 +29,6 @@
|
||||
"@homarr/integrations": "workspace:^0.1.0",
|
||||
"@homarr/modals": "workspace:^0.1.0",
|
||||
"@homarr/modals-collection": "workspace:^0.1.0",
|
||||
"@homarr/notifications": "workspace:^0.1.0",
|
||||
"@homarr/settings": "workspace:^0.1.0",
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
"@homarr/ui": "workspace:^0.1.0",
|
||||
|
||||
@@ -33,10 +33,7 @@ export const ChildrenActionItem = ({ childrenOptions, action, query, setChildren
|
||||
<Spotlight.Action
|
||||
renderRoot={renderRoot}
|
||||
onClick={onClick}
|
||||
closeSpotlightOnTrigger={
|
||||
interaction.type !== "children" &&
|
||||
(interaction.type !== "javaScript" || interaction.closeSpotlightOnTrigger !== false)
|
||||
}
|
||||
closeSpotlightOnTrigger={interaction.type !== "children"}
|
||||
className={classes.spotlightAction}
|
||||
>
|
||||
<action.Component {...childrenOptions.option} />
|
||||
|
||||
@@ -44,9 +44,6 @@ export const SpotlightGroupActionItem = <TOption extends Record<string, unknown>
|
||||
setQuery(interaction.query);
|
||||
setTimeout(() => selectAction(0, spotlightStore));
|
||||
} else if (interaction.type === "mode") {
|
||||
if (interaction.query !== undefined) {
|
||||
setQuery(interaction.query);
|
||||
}
|
||||
setMode(interaction.mode);
|
||||
} else if (interaction.type === "children") {
|
||||
setChildrenOptions(interaction);
|
||||
@@ -61,8 +58,7 @@ export const SpotlightGroupActionItem = <TOption extends Record<string, unknown>
|
||||
interaction.type !== "mode" &&
|
||||
interaction.type !== "children" &&
|
||||
interaction.type !== "none" &&
|
||||
interaction.type !== "setQuery" &&
|
||||
(interaction.type !== "javaScript" || interaction.closeSpotlightOnTrigger !== false)
|
||||
interaction.type !== "setQuery"
|
||||
}
|
||||
className={classes.spotlightAction}
|
||||
>
|
||||
|
||||
@@ -1,33 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { ActionIcon, Center, Group, Kbd } from "@mantine/core";
|
||||
import { Spotlight as MantineSpotlight } from "@mantine/spotlight";
|
||||
import { IconArrowLeft, IconSearch, IconX } from "@tabler/icons-react";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { IconSearch, IconX } from "@tabler/icons-react";
|
||||
|
||||
import { hotkeys } from "@homarr/definitions";
|
||||
import type { TranslationObject } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { OpenMediaRequestSearchOptions } from "../index";
|
||||
import type { inferSearchInteractionOptions } from "../lib/interaction";
|
||||
import type { SearchMode } from "../lib/mode";
|
||||
import { searchModes } from "../modes";
|
||||
import { useHomeEmptyGroupsWithPreferences } from "../modes/help/home-empty-groups";
|
||||
import {
|
||||
mediaRequestSearchEvent,
|
||||
mediaRequestSearchScopeAtom,
|
||||
selectAction,
|
||||
spotlightActions,
|
||||
spotlightStore,
|
||||
} from "../spotlight-store";
|
||||
import { useHomeEmptyGroups } from "../modes/help/home-empty-groups";
|
||||
import { selectAction, spotlightStore } from "../spotlight-store";
|
||||
import { SpotlightChildrenActions } from "./actions/children-actions";
|
||||
import { SpotlightActionGroups } from "./actions/groups/action-group";
|
||||
|
||||
type SearchModeKey = keyof TranslationObject["search"]["mode"];
|
||||
type ChildrenOptions = inferSearchInteractionOptions<"children">;
|
||||
|
||||
const defaultMode = "home";
|
||||
export const Spotlight = () => {
|
||||
@@ -40,6 +31,7 @@ export const Spotlight = () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
// We use the "key" below to prevent the 'Different amounts of hooks' error
|
||||
return (
|
||||
<SpotlightWithActiveMode key={mode} modeState={searchModeState} queryState={queryState} activeMode={activeMode} />
|
||||
);
|
||||
@@ -54,57 +46,13 @@ interface SpotlightWithActiveModeProps {
|
||||
const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: SpotlightWithActiveModeProps) => {
|
||||
const [query, setQuery] = queryState;
|
||||
const [mode, setMode] = modeState;
|
||||
const [childrenStack, setChildrenStack] = useState<ChildrenOptions[]>([]);
|
||||
const childrenOptions = childrenStack.at(-1) ?? null;
|
||||
const [childrenOptions, setChildrenOptions] = useState<inferSearchInteractionOptions<"children"> | null>(null);
|
||||
const t = useI18n();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const setMediaRequestSearchScope = useSetAtom(mediaRequestSearchScopeAtom);
|
||||
// Works as always the same amount of hooks are executed
|
||||
const useGroups = "groups" in activeMode ? () => activeMode.groups : activeMode.useGroups;
|
||||
const groups = useGroups();
|
||||
const homeEmptyGroups = useHomeEmptyGroupsWithPreferences();
|
||||
const hasModeCharacter = activeMode.modeKey !== defaultMode && activeMode.character !== undefined;
|
||||
|
||||
const clearChildrenStack = useCallback(() => {
|
||||
setChildrenStack([]);
|
||||
}, []);
|
||||
|
||||
const pushChildrenOptions = useCallback(
|
||||
(options: ChildrenOptions) => {
|
||||
setChildrenStack((currentStack) => [...currentStack, options]);
|
||||
setQuery("");
|
||||
setTimeout(() => selectAction(0, spotlightStore));
|
||||
},
|
||||
[setQuery],
|
||||
);
|
||||
|
||||
const popChildrenOptions = useCallback(() => {
|
||||
setChildrenStack((currentStack) => currentStack.slice(0, -1));
|
||||
setQuery("");
|
||||
setTimeout(() => selectAction(0, spotlightStore));
|
||||
}, [setQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMediaRequestSearch = (event: Event) => {
|
||||
const { integrationIds, query } = (event as CustomEvent<OpenMediaRequestSearchOptions>).detail ?? {};
|
||||
setMediaRequestSearchScope({ integrationIds });
|
||||
setMode("media");
|
||||
clearChildrenStack();
|
||||
setQuery(query ?? "");
|
||||
spotlightActions.open();
|
||||
setTimeout(() => selectAction(0, spotlightStore));
|
||||
};
|
||||
|
||||
window.addEventListener(mediaRequestSearchEvent, handleMediaRequestSearch);
|
||||
return () => {
|
||||
window.removeEventListener(mediaRequestSearchEvent, handleMediaRequestSearch);
|
||||
};
|
||||
}, [setMediaRequestSearchScope, setMode, setQuery, clearChildrenStack]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "media") {
|
||||
setMediaRequestSearchScope({});
|
||||
}
|
||||
}, [mode, setMediaRequestSearchScope]);
|
||||
const homeEmptyGroups = useHomeEmptyGroups();
|
||||
|
||||
return (
|
||||
<MantineSpotlight.Root
|
||||
@@ -113,7 +61,7 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
|
||||
overlayProps={{ blur: 2, backgroundOpacity: 0.2 }}
|
||||
onSpotlightClose={() => {
|
||||
setMode(defaultMode);
|
||||
clearChildrenStack();
|
||||
setChildrenOptions(null);
|
||||
setQuery("");
|
||||
}}
|
||||
query={query}
|
||||
@@ -121,14 +69,17 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
|
||||
const sanitizedQuery = mode === "external" && nextQuery.startsWith("!") ? nextQuery.slice(1) : nextQuery;
|
||||
setQuery(sanitizedQuery);
|
||||
|
||||
// Only switch modes when a single trigger character is typed
|
||||
if (sanitizedQuery.length !== 1) return;
|
||||
|
||||
const modeToActivate = searchModes.find((searchMode) => searchMode.character === sanitizedQuery);
|
||||
const modeToActivate = searchModes.find((mode) => mode.character === sanitizedQuery);
|
||||
if (!modeToActivate) return;
|
||||
|
||||
setMode(modeToActivate.modeKey);
|
||||
clearChildrenStack();
|
||||
setChildrenOptions(null);
|
||||
|
||||
setQuery("");
|
||||
|
||||
setTimeout(() => selectAction(0, spotlightStore));
|
||||
}}
|
||||
store={spotlightStore}
|
||||
@@ -136,13 +87,13 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
|
||||
<MantineSpotlight.Search
|
||||
placeholder={`${t("search.placeholder")}...`}
|
||||
ref={inputRef}
|
||||
leftSectionWidth={hasModeCharacter ? 80 : 48}
|
||||
leftSectionWidth={activeMode.modeKey !== defaultMode ? 80 : 48}
|
||||
leftSection={
|
||||
<Group align="center" wrap="nowrap" gap="xs" w="100%" h="100%">
|
||||
<Center w={48} h="100%">
|
||||
<IconSearch stroke={1.5} />
|
||||
</Center>
|
||||
{hasModeCharacter ? <Kbd size="sm">{activeMode.character}</Kbd> : null}
|
||||
{activeMode.modeKey !== defaultMode ? <Kbd size="sm">{activeMode.character}</Kbd> : null}
|
||||
</Group>
|
||||
}
|
||||
styles={{
|
||||
@@ -151,53 +102,31 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
|
||||
},
|
||||
}}
|
||||
rightSection={
|
||||
mode === defaultMode && childrenStack.length === 0 ? null : (
|
||||
mode === defaultMode ? null : (
|
||||
<ActionIcon
|
||||
title={
|
||||
childrenStack.length > 0
|
||||
? t("search.mode.command.group.preferences.children.detail.backAction")
|
||||
: t("common.action.close")
|
||||
}
|
||||
onClick={() => {
|
||||
if (childrenStack.length > 0) {
|
||||
popChildrenOptions();
|
||||
inputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
setMode(defaultMode);
|
||||
clearChildrenStack();
|
||||
setChildrenOptions(null);
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
variant="subtle"
|
||||
aria-label={
|
||||
childrenStack.length > 0
|
||||
? t("search.mode.command.group.preferences.children.detail.backAction")
|
||||
: t("common.action.close")
|
||||
}
|
||||
aria-label={t("common.action.close")}
|
||||
>
|
||||
{childrenStack.length > 0 ? <IconArrowLeft stroke={1.5} /> : <IconX stroke={1.5} />}
|
||||
<IconX stroke={1.5} />
|
||||
</ActionIcon>
|
||||
)
|
||||
}
|
||||
value={query}
|
||||
onKeyDown={(event) => {
|
||||
if (query.length !== 0 || event.key !== "Backspace") return;
|
||||
|
||||
if (childrenStack.length > 0) {
|
||||
popChildrenOptions();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode !== defaultMode) {
|
||||
if (query.length === 0 && mode !== defaultMode && event.key === "Backspace") {
|
||||
setMode(defaultMode);
|
||||
clearChildrenStack();
|
||||
setChildrenOptions(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{childrenOptions ? (
|
||||
<Group key={childrenStack.length}>
|
||||
<Group>
|
||||
<childrenOptions.DetailComponent options={childrenOptions.option as never} />
|
||||
</Group>
|
||||
) : null}
|
||||
@@ -205,21 +134,25 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
|
||||
<MantineSpotlight.ActionsList>
|
||||
{childrenOptions ? (
|
||||
<SpotlightChildrenActions
|
||||
key={childrenStack.length}
|
||||
childrenOptions={childrenOptions}
|
||||
query={query}
|
||||
setChildrenOptions={pushChildrenOptions}
|
||||
setChildrenOptions={setChildrenOptions}
|
||||
/>
|
||||
) : (
|
||||
<SpotlightActionGroups
|
||||
setQuery={setQuery}
|
||||
setMode={(nextMode) => {
|
||||
setMode(nextMode);
|
||||
clearChildrenStack();
|
||||
setMode={(mode) => {
|
||||
setMode(mode);
|
||||
setChildrenOptions(null);
|
||||
setTimeout(() => selectAction(0, spotlightStore));
|
||||
}}
|
||||
setChildrenOptions={(options) => {
|
||||
pushChildrenOptions(options);
|
||||
setChildrenOptions(options);
|
||||
|
||||
setTimeout(() => {
|
||||
setQuery("");
|
||||
selectAction(0, spotlightStore);
|
||||
});
|
||||
}}
|
||||
query={query}
|
||||
groups={mode === defaultMode && query.length === 0 ? [...homeEmptyGroups] : groups}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { mediaRequestSearchEvent, spotlightActions } from "./spotlight-store";
|
||||
import { spotlightActions } from "./spotlight-store";
|
||||
|
||||
export { Spotlight } from "./components/spotlight";
|
||||
export { openSpotlight, openMediaRequestSearch };
|
||||
export { openSpotlight };
|
||||
export {
|
||||
SpotlightProvider,
|
||||
useRegisterSpotlightContextResults,
|
||||
@@ -11,18 +11,3 @@ export {
|
||||
} from "./modes/home/context";
|
||||
|
||||
const openSpotlight = spotlightActions.open;
|
||||
|
||||
export interface OpenMediaRequestSearchOptions {
|
||||
integrationIds?: string[];
|
||||
query?: string;
|
||||
}
|
||||
|
||||
const openMediaRequestSearch = (options: OpenMediaRequestSearchOptions = {}) => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent<OpenMediaRequestSearchOptions>(mediaRequestSearchEvent, { detail: options }));
|
||||
}
|
||||
|
||||
spotlightActions.open();
|
||||
};
|
||||
|
||||
export { mediaRequestSearchEvent };
|
||||
@@ -10,12 +10,9 @@ const createSearchInteraction = <TType extends string>(type: TType) => ({
|
||||
// This is used to define search interactions with their options
|
||||
const searchInteractions = [
|
||||
createSearchInteraction("link").optionsType<{ href: string; newTab?: boolean }>(),
|
||||
createSearchInteraction("javaScript").optionsType<{
|
||||
onSelect: () => MaybePromise<void>;
|
||||
closeSpotlightOnTrigger?: boolean;
|
||||
}>(),
|
||||
createSearchInteraction("javaScript").optionsType<{ onSelect: () => MaybePromise<void> }>(),
|
||||
createSearchInteraction("setQuery").optionsType<{ query: string }>(),
|
||||
createSearchInteraction("mode").optionsType<{ mode: keyof TranslationObject["search"]["mode"]; query?: string }>(),
|
||||
createSearchInteraction("mode").optionsType<{ mode: keyof TranslationObject["search"]["mode"] }>(),
|
||||
createSearchInteraction("children").optionsType<{
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
useActions: CreateChildrenOptionsProps<any>["useActions"];
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { Group, Text } from "@mantine/core";
|
||||
import { Group, Stack, Text } from "@mantine/core";
|
||||
import { IconCheck } from "@tabler/icons-react";
|
||||
|
||||
import { localeConfigurations, supportedLanguages } from "@homarr/translation";
|
||||
import { useUserPreference } from "../../../preferences/use-user-preference";
|
||||
import { useChangeLocale, useCurrentLocale, useI18n } from "@homarr/translation/client";
|
||||
import { LanguageIcon } from "@homarr/ui";
|
||||
|
||||
import { createChildrenOptions } from "../../../lib/children";
|
||||
import { createCheckmarkPreferenceAction, PreferenceDetailHeader } from "./preferences/action-row";
|
||||
|
||||
export const languageChildrenOptions = createChildrenOptions<Record<string, unknown>>({
|
||||
useActions: (_, query) => {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const { value: currentLocale, setValue: setLocale, isPending } = useUserPreference("locale");
|
||||
|
||||
const currentLocale = useCurrentLocale();
|
||||
return supportedLanguages
|
||||
.map((localeKey) => ({ localeKey, configuration: localeConfigurations[localeKey] }))
|
||||
.filter(
|
||||
@@ -19,7 +18,7 @@ export const languageChildrenOptions = createChildrenOptions<Record<string, unkn
|
||||
configuration.name.toLowerCase().includes(normalizedQuery) ||
|
||||
configuration.translatedName.toLowerCase().includes(normalizedQuery),
|
||||
)
|
||||
.toSorted(
|
||||
.sort(
|
||||
(languageA, languageB) =>
|
||||
Math.min(
|
||||
languageA.configuration.name.toLowerCase().indexOf(normalizedQuery),
|
||||
@@ -30,25 +29,38 @@ export const languageChildrenOptions = createChildrenOptions<Record<string, unkn
|
||||
languageB.configuration.translatedName.toLowerCase().indexOf(normalizedQuery),
|
||||
),
|
||||
)
|
||||
.map(({ localeKey, configuration }) =>
|
||||
createCheckmarkPreferenceAction({
|
||||
key: localeKey,
|
||||
isSelected: localeKey === currentLocale,
|
||||
onSelect: () => setLocale(localeKey as never),
|
||||
isPending,
|
||||
labelContent: (
|
||||
<Group wrap="nowrap">
|
||||
<LanguageIcon icon={localeConfigurations[localeKey].icon} />
|
||||
<Group wrap="nowrap" gap="xs">
|
||||
<Text>{configuration.name}</Text>
|
||||
<Text size="xs" c="dimmed" inherit>
|
||||
({configuration.translatedName})
|
||||
</Text>
|
||||
.map(({ localeKey, configuration }) => ({
|
||||
key: localeKey,
|
||||
Component() {
|
||||
return (
|
||||
<Group mx="md" my="sm" wrap="nowrap" justify="space-between" w="100%">
|
||||
<Group wrap="nowrap">
|
||||
<LanguageIcon icon={localeConfigurations[localeKey].icon} />
|
||||
<Group wrap="nowrap" gap="xs">
|
||||
<Text>{configuration.name}</Text>
|
||||
<Text size="xs" c="dimmed" inherit>
|
||||
({configuration.translatedName})
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
{localeKey === currentLocale && <IconCheck color="currentColor" size={24} />}
|
||||
</Group>
|
||||
),
|
||||
}),
|
||||
);
|
||||
);
|
||||
},
|
||||
useInteraction() {
|
||||
const { changeLocale } = useChangeLocale();
|
||||
|
||||
return { type: "javaScript", onSelect: () => changeLocale(localeKey) };
|
||||
},
|
||||
}));
|
||||
},
|
||||
DetailComponent: () => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Stack mx="md" my="sm">
|
||||
<Text>{t("search.mode.command.group.globalCommand.option.language.children.detail.title")}</Text>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
DetailComponent: () => <PreferenceDetailHeader titleKey="locale.children.detail.title" />,
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Center, Group, Loader, Stack, Text } from "@mantine/core";
|
||||
import { IconCheck } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { interaction } from "../../../../lib/interaction";
|
||||
|
||||
export const PreferenceDetailHeader = ({ titleKey }: { titleKey: string }) => {
|
||||
const tOption = useScopedI18n("search.mode.command.group.preferences.option");
|
||||
const t = useScopedI18n("search.mode.command.group.preferences");
|
||||
|
||||
return (
|
||||
<Stack mx="md" my="sm" gap="xs">
|
||||
<Text>{tOption(titleKey as never)}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("children.detail.backHint")}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export const createCheckmarkPreferenceAction = ({
|
||||
key,
|
||||
label,
|
||||
labelContent,
|
||||
isSelected,
|
||||
onSelect,
|
||||
dimmed,
|
||||
isPending,
|
||||
}: {
|
||||
key: string;
|
||||
label?: string;
|
||||
labelContent?: ReactNode;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
dimmed?: boolean;
|
||||
isPending?: boolean;
|
||||
}) => ({
|
||||
key,
|
||||
Component() {
|
||||
return (
|
||||
<Group mx="md" my="sm" wrap="nowrap" justify="space-between" w="100%" opacity={isPending ? 0.5 : 1}>
|
||||
{labelContent ?? <Text c={dimmed ? "dimmed" : undefined}>{label}</Text>}
|
||||
{isSelected && <IconCheck color="currentColor" size={24} />}
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
useInteraction: interaction.javaScript(() => ({
|
||||
onSelect: isPending ? () => undefined : onSelect,
|
||||
closeSpotlightOnTrigger: false,
|
||||
})),
|
||||
});
|
||||
|
||||
export const createLoadingPreferenceAction = () => ({
|
||||
key: "loading",
|
||||
Component() {
|
||||
return (
|
||||
<Center mx="md" my="sm">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
);
|
||||
},
|
||||
useInteraction: interaction.javaScript(() => ({
|
||||
onSelect: () => undefined,
|
||||
closeSpotlightOnTrigger: false,
|
||||
})),
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export { settingsChildrenOptions } from "./settings-children";
|
||||
@@ -1,55 +0,0 @@
|
||||
import { createCheckmarkPreferenceAction } from "./action-row";
|
||||
|
||||
interface SelectablePreferenceItem {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const createSelectablePreferenceActions = ({
|
||||
query,
|
||||
currentValue,
|
||||
noneLabel,
|
||||
unavailableLabel,
|
||||
items,
|
||||
onSelect,
|
||||
isPending,
|
||||
}: {
|
||||
query: string;
|
||||
currentValue: string | null;
|
||||
noneLabel: string;
|
||||
unavailableLabel: string;
|
||||
items: SelectablePreferenceItem[];
|
||||
onSelect: (value: string | null) => void;
|
||||
isPending?: boolean;
|
||||
}) => {
|
||||
const hasCurrentSelection = currentValue !== null && !items.some((item) => item.value === currentValue);
|
||||
const orphanedItem: SelectablePreferenceItem | null = hasCurrentSelection
|
||||
? { key: `orphaned-${currentValue}`, label: unavailableLabel, value: currentValue }
|
||||
: null;
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const filteredItems = items.filter((item) => item.label.toLowerCase().includes(normalizedQuery));
|
||||
const visibleItems = orphanedItem ? [orphanedItem, ...filteredItems] : filteredItems;
|
||||
|
||||
const noneAction = createCheckmarkPreferenceAction({
|
||||
key: "none",
|
||||
label: noneLabel,
|
||||
isSelected: currentValue === null,
|
||||
onSelect: () => onSelect(null),
|
||||
dimmed: true,
|
||||
isPending,
|
||||
});
|
||||
|
||||
const itemActions = visibleItems.map((item) =>
|
||||
createCheckmarkPreferenceAction({
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
isSelected: currentValue === item.value,
|
||||
onSelect: () => onSelect(item.value),
|
||||
dimmed: item.key.startsWith("orphaned-"),
|
||||
isPending,
|
||||
}),
|
||||
);
|
||||
|
||||
return [noneAction, ...itemActions];
|
||||
};
|
||||
@@ -1,156 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Group, Stack, Switch, Text } from "@mantine/core";
|
||||
import type { DayOfWeek } from "@mantine/dates";
|
||||
import dayjs from "dayjs";
|
||||
import localeData from "dayjs/plugin/localeData";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import type { ColorScheme } from "@homarr/definitions";
|
||||
import { visiblePreferenceDefinitions } from "@homarr/settings";
|
||||
import { localeConfigurations } from "@homarr/translation";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import { createChildrenOptions } from "../../../../lib/children";
|
||||
import type { ChildrenAction } from "../../../../lib/children";
|
||||
import { interaction } from "../../../../lib/interaction";
|
||||
import { useUserPreferences } from "../../../../preferences/use-user-preference";
|
||||
import { preferenceChildrenOptionsByKey, preferenceIcons } from "../../../../preferences/preference-registry";
|
||||
|
||||
dayjs.extend(localeData);
|
||||
|
||||
type SettingsAction = ChildrenAction<Record<string, unknown>>;
|
||||
|
||||
const BooleanRow = ({
|
||||
label,
|
||||
Icon,
|
||||
checked,
|
||||
isPending,
|
||||
}: {
|
||||
label: string;
|
||||
Icon: TablerIcon;
|
||||
checked: boolean;
|
||||
isPending: boolean;
|
||||
}) => (
|
||||
<Group mx="md" my="sm" wrap="nowrap" justify="space-between" w="100%">
|
||||
<Group wrap="nowrap" gap="sm">
|
||||
<Icon stroke={1.5} size={20} />
|
||||
<Text>{label}</Text>
|
||||
</Group>
|
||||
<Switch checked={checked} readOnly size="md" disabled={isPending} styles={{ track: { cursor: "pointer" } }} />
|
||||
</Group>
|
||||
);
|
||||
|
||||
const SelectRow = ({ label, Icon, valueLabel }: { label: string; Icon: TablerIcon; valueLabel: string }) => (
|
||||
<Group mx="md" my="sm" wrap="nowrap" justify="space-between" w="100%">
|
||||
<Group wrap="nowrap" gap="sm">
|
||||
<Icon stroke={1.5} size={20} />
|
||||
<Text>{label}</Text>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{valueLabel}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
|
||||
const LinkRow = ({ label, Icon }: { label: string; Icon: TablerIcon }) => (
|
||||
<Group mx="md" my="sm" wrap="nowrap" gap="sm">
|
||||
<Icon stroke={1.5} size={20} />
|
||||
<Text>{label}</Text>
|
||||
</Group>
|
||||
);
|
||||
|
||||
const SELECT_KINDS = new Set(["select", "searchEngine", "board"]);
|
||||
|
||||
const useSettingsActions = (_: Record<string, unknown>, query: string): SettingsAction[] => {
|
||||
const t = useScopedI18n("search.mode.command.group.preferences.option");
|
||||
const tColorScheme = useScopedI18n("common.colorScheme.options");
|
||||
const { data: session } = useSession();
|
||||
const isAuthenticated = Boolean(session?.user);
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const preferences = useUserPreferences();
|
||||
|
||||
const boardsQuery = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isAuthenticated });
|
||||
const searchEnginesQuery = clientApi.searchEngine.getSelectable.useQuery(undefined, { enabled: isAuthenticated });
|
||||
|
||||
const valueLabelResolvers: Record<string, () => string> = {
|
||||
colorScheme: () => tColorScheme(preferences.getPreference("colorScheme").value as ColorScheme),
|
||||
locale: () => {
|
||||
const val = preferences.getPreference("locale").value;
|
||||
return localeConfigurations[val as keyof typeof localeConfigurations]?.name ?? String(val);
|
||||
},
|
||||
defaultSearchEngineId: () =>
|
||||
searchEnginesQuery.data?.find((e) => e.value === preferences.getPreference("defaultSearchEngineId").value)
|
||||
?.label ?? t("searchEngine.none" as never),
|
||||
firstDayOfWeek: () => dayjs.weekdays(false)[preferences.getPreference("firstDayOfWeek").value as DayOfWeek] ?? "",
|
||||
homeBoardId: () =>
|
||||
boardsQuery.data?.find((b) => b.id === preferences.getPreference("homeBoardId").value)?.name ??
|
||||
t("board.none" as never),
|
||||
mobileHomeBoardId: () =>
|
||||
boardsQuery.data?.find((b) => b.id === preferences.getPreference("mobileHomeBoardId").value)?.name ??
|
||||
t("board.none" as never),
|
||||
};
|
||||
|
||||
return visiblePreferenceDefinitions(isAuthenticated)
|
||||
.map((definition): SettingsAction | null => {
|
||||
const label = t(`${definition.key}.label` as never);
|
||||
const Icon = preferenceIcons[definition.key];
|
||||
|
||||
const matchesSearch = [label, ...definition.aliases].some((v) => v.toLowerCase().includes(normalizedQuery));
|
||||
if (!matchesSearch) return null;
|
||||
|
||||
if (definition.kind === "boolean") {
|
||||
const pref = preferences.getPreference(definition.key);
|
||||
return {
|
||||
key: definition.key,
|
||||
Component: () => (
|
||||
<BooleanRow label={label} Icon={Icon} checked={pref.value as boolean} isPending={pref.isPending} />
|
||||
),
|
||||
useInteraction: interaction.javaScript(() => ({
|
||||
onSelect: pref.isPending ? () => undefined : () => pref.setValue(!(pref.value as boolean) as never),
|
||||
closeSpotlightOnTrigger: false,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
if (definition.kind === "link") {
|
||||
return {
|
||||
key: definition.key,
|
||||
Component: () => <LinkRow label={label} Icon={Icon} />,
|
||||
useInteraction: interaction.link(() => ({ href: `/manage/users/${session?.user.id ?? ""}/general` })),
|
||||
};
|
||||
}
|
||||
|
||||
if (SELECT_KINDS.has(definition.kind)) {
|
||||
const children = preferenceChildrenOptionsByKey[definition.key];
|
||||
if (!children) return null;
|
||||
return {
|
||||
key: definition.key,
|
||||
Component: () => (
|
||||
<SelectRow label={label} Icon={Icon} valueLabel={valueLabelResolvers[definition.key]?.() ?? ""} />
|
||||
),
|
||||
useInteraction: interaction.children(children),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
.filter((action): action is SettingsAction => action !== null);
|
||||
};
|
||||
|
||||
export const settingsChildrenOptions = createChildrenOptions<Record<string, unknown>>({
|
||||
useActions: useSettingsActions,
|
||||
DetailComponent: () => {
|
||||
const t = useScopedI18n("search.mode.command.group.preferences");
|
||||
return (
|
||||
<Stack mx="md" my="sm" gap="xs">
|
||||
<Text>{t("title")}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("children.detail.backHint")}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -1,10 +1,14 @@
|
||||
import { Group, Text } from "@mantine/core";
|
||||
import { Group, Text, useComputedColorScheme, useMantineColorScheme } from "@mantine/core";
|
||||
import {
|
||||
IconBox,
|
||||
IconCategoryPlus,
|
||||
IconDeviceDesktop,
|
||||
IconFileImport,
|
||||
IconLanguage,
|
||||
IconMailForward,
|
||||
IconMoon,
|
||||
IconPlug,
|
||||
IconSun,
|
||||
IconUserPlus,
|
||||
IconUsersGroup,
|
||||
} from "@tabler/icons-react";
|
||||
@@ -18,6 +22,7 @@ import type { TablerIcon } from "@homarr/ui";
|
||||
import { createGroup } from "../../lib/group";
|
||||
import type { inferSearchInteractionDefinition, SearchInteraction } from "../../lib/interaction";
|
||||
import { interaction } from "../../lib/interaction";
|
||||
import { languageChildrenOptions } from "./children/language";
|
||||
import { newIntegrationChildrenOptions } from "./children/new-integration";
|
||||
|
||||
// This has to be type so it can be interpreted as Record<string, unknown>.
|
||||
@@ -47,9 +52,33 @@ export const globalCommandGroup = createGroup<Command>({
|
||||
},
|
||||
useOptions() {
|
||||
const tOption = useScopedI18n("search.mode.command.group.globalCommand.option");
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const computedColorScheme = useComputedColorScheme("light");
|
||||
const { data: session } = useSession();
|
||||
|
||||
const commands: (Command & { hidden?: boolean })[] = [
|
||||
{
|
||||
commandKey: "colorScheme",
|
||||
icon: colorScheme === "auto" ? IconDeviceDesktop : computedColorScheme === "dark" ? IconSun : IconMoon,
|
||||
name:
|
||||
colorScheme === "auto"
|
||||
? tOption("colorScheme.auto")
|
||||
: tOption(`colorScheme.${computedColorScheme === "dark" ? "light" : "dark"}`),
|
||||
useInteraction: () => {
|
||||
const { toggleColorScheme } = useMantineColorScheme();
|
||||
|
||||
return {
|
||||
type: "javaScript",
|
||||
onSelect: toggleColorScheme,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
commandKey: "language",
|
||||
icon: IconLanguage,
|
||||
name: tOption("language.label"),
|
||||
useInteraction: interaction.children(languageChildrenOptions),
|
||||
},
|
||||
{
|
||||
commandKey: "newBoard",
|
||||
icon: IconCategoryPlus,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { SearchMode } from "../../lib/mode";
|
||||
import { contextSpecificActionsSearchGroups } from "./context-specific-group";
|
||||
import { globalCommandGroup } from "./global-group";
|
||||
import { preferencesGroup } from "../preferences/groups";
|
||||
|
||||
export const commandMode = {
|
||||
modeKey: "command",
|
||||
character: ">",
|
||||
groups: [contextSpecificActionsSearchGroups, preferencesGroup, globalCommandGroup],
|
||||
groups: [contextSpecificActionsSearchGroups, globalCommandGroup],
|
||||
} satisfies SearchMode;
|
||||
@@ -64,36 +64,6 @@ type MediaRequestChildrenProps = {
|
||||
kind: IntegrationKind;
|
||||
url: string;
|
||||
id: string;
|
||||
permissions?: {
|
||||
hasInteractAccess: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export const useMediaRequestSearchInteraction = (
|
||||
integration: MediaRequestChildrenProps["integration"],
|
||||
searchResult: FromIntegrationSearchResult,
|
||||
): inferSearchInteractionDefinition<"link" | "children"> => {
|
||||
const { openSearchInNewTab } = useSettings();
|
||||
|
||||
const type = searchResult.type;
|
||||
if (type === "person") {
|
||||
return {
|
||||
type: "link",
|
||||
href: searchResult.link,
|
||||
newTab: openSearchInNewTab,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "children",
|
||||
...mediaRequestsChildrenOptions({
|
||||
result: {
|
||||
...searchResult,
|
||||
type,
|
||||
},
|
||||
integration,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -101,6 +71,8 @@ export const useFromIntegrationSearchInteraction = (
|
||||
searchEngine: SearchEngine,
|
||||
searchResult: FromIntegrationSearchResult,
|
||||
): inferSearchInteractionDefinition<"link" | "javaScript" | "children"> => {
|
||||
const { openSearchInNewTab } = useSettings();
|
||||
|
||||
if (searchEngine.type !== "fromIntegration") {
|
||||
throw new Error("Invalid search engine type");
|
||||
}
|
||||
@@ -115,7 +87,25 @@ export const useFromIntegrationSearchInteraction = (
|
||||
) &&
|
||||
"type" in searchResult
|
||||
) {
|
||||
return useMediaRequestSearchInteraction(searchEngine.integration, searchResult);
|
||||
const type = searchResult.type;
|
||||
if (type === "person") {
|
||||
return {
|
||||
type: "link",
|
||||
href: searchResult.link,
|
||||
newTab: openSearchInNewTab,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "children",
|
||||
...mediaRequestsChildrenOptions({
|
||||
result: {
|
||||
...searchResult,
|
||||
type,
|
||||
},
|
||||
integration: searchEngine.integration,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -125,15 +115,13 @@ export const useFromIntegrationSearchInteraction = (
|
||||
};
|
||||
};
|
||||
|
||||
export const mediaRequestsChildrenOptions = createChildrenOptions<MediaRequestChildrenProps>({
|
||||
const mediaRequestsChildrenOptions = createChildrenOptions<MediaRequestChildrenProps>({
|
||||
useActions() {
|
||||
const { openModal } = useModalAction(RequestMediaModal);
|
||||
return [
|
||||
{
|
||||
key: "request",
|
||||
hide: (option) =>
|
||||
(option.result.type === "movie" && option.result.inLibrary) ||
|
||||
option.integration.permissions?.hasInteractAccess === false,
|
||||
hide: (option) => option.result.inLibrary,
|
||||
Component(option) {
|
||||
const t = useScopedI18n("search.mode.media");
|
||||
return (
|
||||
|
||||
@@ -19,13 +19,12 @@ import type { SearchMode } from "../../lib/mode";
|
||||
import { appIntegrationBoardMode } from "../app-integration-board";
|
||||
import { commandMode } from "../command";
|
||||
import { externalMode } from "../external";
|
||||
import { mediaMode } from "../media";
|
||||
import { pageMode } from "../page";
|
||||
import { preferencesGroup } from "../preferences/groups";
|
||||
import { userGroupMode } from "../user-group";
|
||||
|
||||
// We need type to allow it as generic type for Record<string, unknown>
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||
type QuickLinkOption = {
|
||||
key: string;
|
||||
icon: TablerIcon;
|
||||
name: string;
|
||||
path: string;
|
||||
@@ -34,7 +33,7 @@ type QuickLinkOption = {
|
||||
export const useHomeEmptyGroups = () => {
|
||||
const { data: session } = useSession();
|
||||
const tPages = useScopedI18n("search.mode.page.group.page.option");
|
||||
const visibleSearchModes: SearchMode[] = [appIntegrationBoardMode, externalMode, mediaMode, commandMode, pageMode];
|
||||
const visibleSearchModes: SearchMode[] = [appIntegrationBoardMode, externalMode, commandMode, pageMode];
|
||||
|
||||
if (session?.user.permissions.includes("admin")) {
|
||||
visibleSearchModes.unshift(userGroupMode);
|
||||
@@ -42,13 +41,19 @@ export const useHomeEmptyGroups = () => {
|
||||
|
||||
return [
|
||||
createGroup({
|
||||
keyPath: "key",
|
||||
keyPath: "path",
|
||||
title: () => "Quick links",
|
||||
options: (() => {
|
||||
const quickLinks: QuickLinkOption[] = [];
|
||||
if (session?.user) {
|
||||
quickLinks.push({
|
||||
icon: IconSettings,
|
||||
path: `/manage/users/${session.user.id}/general`,
|
||||
name: tPages("preferences.label"),
|
||||
});
|
||||
}
|
||||
|
||||
quickLinks.push({
|
||||
key: "manageBoards",
|
||||
icon: IconLayoutDashboard,
|
||||
path: "/manage/boards",
|
||||
name: tPages("manageBoard.label"),
|
||||
@@ -56,7 +61,6 @@ export const useHomeEmptyGroups = () => {
|
||||
|
||||
if (session?.user.permissions.includes("admin")) {
|
||||
quickLinks.push({
|
||||
key: "manageSettings",
|
||||
icon: IconSettings,
|
||||
path: "/manage/settings",
|
||||
name: tPages("manageSettings.label"),
|
||||
@@ -65,7 +69,6 @@ export const useHomeEmptyGroups = () => {
|
||||
|
||||
if (session) {
|
||||
quickLinks.push({
|
||||
key: "manageSearchEngines",
|
||||
icon: IconSearch,
|
||||
path: "/manage/search-engines",
|
||||
name: tPages("manageSearchEngine.label"),
|
||||
@@ -84,7 +87,7 @@ export const useHomeEmptyGroups = () => {
|
||||
useInteraction: interaction.link(({ path }) => ({ href: path })),
|
||||
}),
|
||||
createGroup({
|
||||
keyPath: "modeKey",
|
||||
keyPath: "character",
|
||||
title: (t) => t("search.mode.help.group.mode.title"),
|
||||
options: visibleSearchModes.map(({ character, modeKey }) => ({ character, modeKey })),
|
||||
Component: ({ modeKey, character }) => {
|
||||
@@ -93,7 +96,7 @@ export const useHomeEmptyGroups = () => {
|
||||
return (
|
||||
<Group px="md" py="xs" w="100%" wrap="nowrap" align="center" justify="space-between">
|
||||
<Text>{t("help")}</Text>
|
||||
{character && <Kbd size="sm">{character}</Kbd>}
|
||||
<Kbd size="sm">{character}</Kbd>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
@@ -135,5 +138,3 @@ export const useHomeEmptyGroups = () => {
|
||||
}),
|
||||
] as const;
|
||||
};
|
||||
|
||||
export const useHomeEmptyGroupsWithPreferences = () => [preferencesGroup, ...useHomeEmptyGroups()];
|
||||
@@ -20,7 +20,6 @@ type GroupItem = {
|
||||
name: string;
|
||||
description?: string;
|
||||
icon: TablerIcon | string;
|
||||
disabled?: boolean;
|
||||
useInteraction: (query: string) => inferSearchInteractionDefinition<SearchInteraction>;
|
||||
};
|
||||
|
||||
@@ -38,7 +37,7 @@ export const homeSearchEngineGroup = createGroup<GroupItem>({
|
||||
);
|
||||
|
||||
return (
|
||||
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs" opacity={item.disabled ? 0.55 : 1}>
|
||||
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs">
|
||||
{icon}
|
||||
<Stack gap={0}>
|
||||
<Text>{item.name}</Text>
|
||||
@@ -92,7 +91,9 @@ const createDefaultSearchEntries = (
|
||||
query: string,
|
||||
t: TranslationFunction,
|
||||
): GroupItem[] => {
|
||||
if (!session?.user && !defaultSearchEngine) return [];
|
||||
if (!session?.user && !defaultSearchEngine) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!defaultSearchEngine) {
|
||||
return [
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { SearchMode } from "../../lib/mode";
|
||||
import { preferencesGroup } from "../preferences/groups";
|
||||
import { contextSpecificSearchGroups } from "./context-specific-group";
|
||||
import { homeSearchEngineGroup } from "./home-search-engine-group";
|
||||
|
||||
export const homeMode = {
|
||||
character: undefined,
|
||||
modeKey: "home",
|
||||
groups: [preferencesGroup, homeSearchEngineGroup, contextSpecificSearchGroups],
|
||||
groups: [homeSearchEngineGroup, contextSpecificSearchGroups],
|
||||
} satisfies SearchMode;
|
||||
@@ -2,7 +2,6 @@ import { appIntegrationBoardMode } from "./app-integration-board";
|
||||
import { commandMode } from "./command";
|
||||
import { externalMode } from "./external";
|
||||
import { homeMode } from "./home";
|
||||
import { mediaMode } from "./media";
|
||||
import { pageMode } from "./page";
|
||||
import { userGroupMode } from "./user-group";
|
||||
|
||||
@@ -12,6 +11,5 @@ export const searchModes = [
|
||||
externalMode,
|
||||
commandMode,
|
||||
pageMode,
|
||||
mediaMode,
|
||||
homeMode,
|
||||
] as const;
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { SearchMode } from "../../lib/mode";
|
||||
import { mediaRequestSearchGroup } from "./media-request-search-group";
|
||||
|
||||
export const mediaMode = {
|
||||
modeKey: "media",
|
||||
character: undefined,
|
||||
groups: [mediaRequestSearchGroup],
|
||||
} satisfies SearchMode;
|
||||
@@ -1,173 +0,0 @@
|
||||
import { Badge, Group, Image, Stack, Text } from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { IconMovie, IconSearch } from "@tabler/icons-react";
|
||||
import { keepPreviousData } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import type { MediaAvailability } from "@homarr/integrations/types";
|
||||
import { mediaAvailabilityConfiguration } from "@homarr/integrations/types";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { createGroup } from "../../lib/group";
|
||||
import { mediaRequestSearchScopeAtom } from "../../spotlight-store";
|
||||
import { useMediaRequestSearchInteraction } from "../external/search-engines-search-group";
|
||||
|
||||
const availabilityLabelKeys = ["available", "partiallyAvailable", "processing", "requested", "pending"] as const;
|
||||
type AvailabilityWithLabel = (typeof availabilityLabelKeys)[number];
|
||||
|
||||
type MediaRequestSearchResult = RouterOutputs["integration"]["searchMediaRequests"][number];
|
||||
|
||||
type MediaRequestSearchOption =
|
||||
| {
|
||||
key: string;
|
||||
kind: "disabled" | "hint";
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
| {
|
||||
key: string;
|
||||
kind: "result";
|
||||
result: MediaRequestSearchResult;
|
||||
};
|
||||
|
||||
export const mediaRequestSearchGroup = createGroup<MediaRequestSearchOption>({
|
||||
keyPath: "key",
|
||||
title: (t) => t("search.mode.media.group.title"),
|
||||
Component(option) {
|
||||
const tMedia = useScopedI18n("search.mode.media");
|
||||
|
||||
if (option.kind !== "result") {
|
||||
return (
|
||||
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs" opacity={option.kind === "disabled" ? 0.55 : 1}>
|
||||
{option.kind === "disabled" ? <IconMovie stroke={1.5} /> : <IconSearch stroke={1.5} />}
|
||||
<Stack gap={0}>
|
||||
<Text size="sm">{option.label}</Text>
|
||||
<Text size="xs" c="gray.6">
|
||||
{option.description}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
const { result } = option;
|
||||
const hasAvailabilityLabel =
|
||||
result.availability && (availabilityLabelKeys as readonly string[]).includes(result.availability);
|
||||
const badgeLabel = hasAvailabilityLabel
|
||||
? tMedia(`availability.${result.availability as AvailabilityWithLabel}`)
|
||||
: undefined;
|
||||
const badgeColor = result.availability
|
||||
? mediaAvailabilityConfiguration[result.availability as MediaAvailability]?.color
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs">
|
||||
{result.image ? (
|
||||
<Image src={result.image} w={35} h={50} fit="cover" radius="md" />
|
||||
) : (
|
||||
<IconSearch stroke={1.5} size={35} />
|
||||
)}
|
||||
<Stack gap={2} style={{ flex: 1 }}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text>{result.name}</Text>
|
||||
{badgeLabel && (
|
||||
<Badge size="xs" color={badgeColor} variant="light">
|
||||
{badgeLabel}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text c="dimmed" size="sm" lineClamp={2}>
|
||||
{result.text ?? getIntegrationName(result.integration.kind)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
useInteraction(option) {
|
||||
if (option.kind !== "result") {
|
||||
return {
|
||||
type: "none",
|
||||
};
|
||||
}
|
||||
|
||||
return useMediaRequestSearchInteraction(option.result.integration, option.result);
|
||||
},
|
||||
useQueryOptions(query) {
|
||||
const tMedia = useScopedI18n("search.mode.media");
|
||||
const scope = useAtomValue(mediaRequestSearchScopeAtom);
|
||||
const [debouncedQuery] = useDebouncedValue(query.trim(), 150);
|
||||
const targetsQuery = clientApi.integration.mediaRequestSearchTargets.useQuery();
|
||||
|
||||
const scopedTargets = (targetsQuery.data ?? []).filter((target) =>
|
||||
scope.integrationIds ? scope.integrationIds.includes(target.id) : true,
|
||||
);
|
||||
const hasScopedTargets = scopedTargets.length > 0;
|
||||
const shouldSearch = debouncedQuery.length > 0 && hasScopedTargets;
|
||||
const integrationIds = scope.integrationIds ? scopedTargets.map((target) => target.id) : undefined;
|
||||
|
||||
const resultsQuery = clientApi.integration.searchMediaRequests.useQuery(
|
||||
{ query: debouncedQuery, integrationIds },
|
||||
{
|
||||
enabled: shouldSearch,
|
||||
placeholderData: keepPreviousData,
|
||||
select: (data) => data.slice(0, 10),
|
||||
},
|
||||
);
|
||||
|
||||
const isLoading = targetsQuery.isLoading || (shouldSearch && resultsQuery.isLoading);
|
||||
const isError = targetsQuery.isError || (shouldSearch && resultsQuery.isError);
|
||||
|
||||
if (!targetsQuery.data) {
|
||||
return {
|
||||
isLoading,
|
||||
isError,
|
||||
data: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (!hasScopedTargets) {
|
||||
return {
|
||||
isLoading,
|
||||
isError,
|
||||
data: [
|
||||
{
|
||||
key: "disabled",
|
||||
kind: "disabled",
|
||||
label: tMedia("action.search.label"),
|
||||
description: scope.integrationIds
|
||||
? tMedia("action.search.disabled.scoped")
|
||||
: tMedia("action.search.disabled.noIntegration"),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (debouncedQuery.length === 0) {
|
||||
return {
|
||||
isLoading,
|
||||
isError,
|
||||
data: [
|
||||
{
|
||||
key: "hint",
|
||||
kind: "hint",
|
||||
label: tMedia("action.search.label"),
|
||||
description: tMedia("action.search.description"),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
isError,
|
||||
data: (resultsQuery.data ?? []).map((result) => ({
|
||||
key: `result-${result.integration.id}-${result.type}-${result.id}`,
|
||||
kind: "result",
|
||||
result,
|
||||
})),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
import { Group, Text } from "@mantine/core";
|
||||
import { IconSettings } from "@tabler/icons-react";
|
||||
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { colorSchemes } from "@homarr/definitions";
|
||||
import { visiblePreferenceDefinitions } from "@homarr/settings";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { createGroup } from "../../lib/group";
|
||||
import { preferenceChildrenOptionsByKey, preferenceIcons } from "../../preferences/preference-registry";
|
||||
import { settingsChildrenOptions } from "../command/children/preferences/settings-children";
|
||||
|
||||
type PreferencesGroupOption = { key: "settings"; preferenceKey: null } | { key: string; preferenceKey: string };
|
||||
|
||||
const settingsOption: PreferencesGroupOption = { key: "settings", preferenceKey: null };
|
||||
|
||||
export const preferencesGroup = createGroup<PreferencesGroupOption>({
|
||||
keyPath: "key",
|
||||
title: (t) => t("search.mode.command.group.preferences.title"),
|
||||
useOptions(query) {
|
||||
const t = useI18n();
|
||||
const tColorScheme = useScopedI18n("common.colorScheme.options");
|
||||
const { data: session } = useSession();
|
||||
const definitions = visiblePreferenceDefinitions(Boolean(session?.user));
|
||||
const q = query.trim().toLowerCase();
|
||||
|
||||
if (q.length === 0) return [settingsOption];
|
||||
|
||||
const matching = definitions.flatMap((def): PreferencesGroupOption[] => {
|
||||
if (!preferenceChildrenOptionsByKey[def.key]) return [];
|
||||
const label = t(`search.mode.command.group.preferences.option.${def.key}.label` as never);
|
||||
const searchValues = [
|
||||
label,
|
||||
...def.aliases,
|
||||
...(def.key === "colorScheme" ? colorSchemes.map((s) => tColorScheme(s)) : []),
|
||||
];
|
||||
if (!searchValues.some((v) => v.toLowerCase().includes(q))) return [];
|
||||
return [{ key: def.key, preferenceKey: def.key }];
|
||||
});
|
||||
|
||||
if (matching.length > 0) return matching;
|
||||
|
||||
const allSearchValues = [
|
||||
t("search.mode.command.group.preferences.title"),
|
||||
...definitions.flatMap((d) => d.aliases),
|
||||
];
|
||||
return allSearchValues.some((v) => v.toLowerCase().includes(q)) ? [settingsOption] : [];
|
||||
},
|
||||
Component: (option) => {
|
||||
const t = useScopedI18n("search.mode.command.group.preferences");
|
||||
const tOption = useScopedI18n("search.mode.command.group.preferences.option");
|
||||
const Icon = option.preferenceKey
|
||||
? preferenceIcons[option.preferenceKey as keyof typeof preferenceIcons]
|
||||
: IconSettings;
|
||||
const label = option.preferenceKey ? tOption(`${option.preferenceKey}.label` as never) : t("title");
|
||||
|
||||
return (
|
||||
<Group px="md" py="sm" wrap="nowrap" gap="sm">
|
||||
<Icon stroke={1.5} />
|
||||
<Text>{label}</Text>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
filter: () => true,
|
||||
useInteraction: (option) => ({
|
||||
type: "children",
|
||||
...(option.preferenceKey
|
||||
? (preferenceChildrenOptionsByKey[option.preferenceKey as keyof typeof preferenceChildrenOptionsByKey]?.({}) ??
|
||||
settingsChildrenOptions({}))
|
||||
: settingsChildrenOptions({})),
|
||||
}),
|
||||
});
|
||||
@@ -1,136 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
IconActivity,
|
||||
IconCalendarWeek,
|
||||
IconDeviceDesktop,
|
||||
IconExternalLink,
|
||||
IconHome,
|
||||
IconLanguage,
|
||||
IconLayoutBoard,
|
||||
IconSearch,
|
||||
IconSettings,
|
||||
IconWorldWww,
|
||||
} from "@tabler/icons-react";
|
||||
import type { DayOfWeek } from "@mantine/dates";
|
||||
import dayjs from "dayjs";
|
||||
import localeData from "dayjs/plugin/localeData";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { colorSchemes } from "@homarr/definitions";
|
||||
import type { UserPreferenceKey } from "@homarr/settings";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import { createChildrenOptions } from "../lib/children";
|
||||
import {
|
||||
createCheckmarkPreferenceAction,
|
||||
createLoadingPreferenceAction,
|
||||
PreferenceDetailHeader,
|
||||
} from "../modes/command/children/preferences/action-row";
|
||||
import { createSelectablePreferenceActions } from "../modes/command/children/preferences/selectable-actions";
|
||||
import { languageChildrenOptions } from "../modes/command/children/language";
|
||||
import { useUserPreference } from "./use-user-preference";
|
||||
|
||||
dayjs.extend(localeData);
|
||||
|
||||
type ChildrenFactory = ReturnType<typeof createChildrenOptions<Record<string, unknown>>>;
|
||||
|
||||
export const preferenceIcons: Record<UserPreferenceKey, TablerIcon> = {
|
||||
colorScheme: IconDeviceDesktop,
|
||||
locale: IconLanguage,
|
||||
defaultSearchEngineId: IconSearch,
|
||||
openSearchInNewTab: IconExternalLink,
|
||||
ddgBangs: IconWorldWww,
|
||||
firstDayOfWeek: IconCalendarWeek,
|
||||
homeBoardId: IconHome,
|
||||
mobileHomeBoardId: IconLayoutBoard,
|
||||
pingIconsEnabled: IconActivity,
|
||||
fullPreferencesPage: IconSettings,
|
||||
};
|
||||
|
||||
const createStaticChildren = (
|
||||
preferenceKey: UserPreferenceKey,
|
||||
useOptions: () => Array<{ key: string; label: string; value: unknown }>,
|
||||
): ChildrenFactory =>
|
||||
createChildrenOptions<Record<string, unknown>>({
|
||||
useActions: () => {
|
||||
const { value, setValue, isPending } = useUserPreference(preferenceKey);
|
||||
return useOptions().map((opt) =>
|
||||
createCheckmarkPreferenceAction({
|
||||
key: opt.key,
|
||||
label: opt.label,
|
||||
isSelected: value === opt.value,
|
||||
onSelect: () => setValue(opt.value as never),
|
||||
isPending,
|
||||
}),
|
||||
);
|
||||
},
|
||||
DetailComponent: () => <PreferenceDetailHeader titleKey={`${preferenceKey}.children.detail.title`} />,
|
||||
});
|
||||
|
||||
const createAsyncSelectableChildren = <TItem,>(
|
||||
preferenceKey: UserPreferenceKey,
|
||||
useQuery: () => { data: TItem[] | undefined; isLoading: boolean },
|
||||
mapItem: (item: TItem) => { key: string; label: string; value: string },
|
||||
noneKey: string,
|
||||
unavailableKey: string,
|
||||
): ChildrenFactory =>
|
||||
createChildrenOptions<Record<string, unknown>>({
|
||||
useActions: (_, query) => {
|
||||
const { value, setValue, isPending } = useUserPreference(preferenceKey);
|
||||
const result = useQuery();
|
||||
const t = useScopedI18n("search.mode.command.group.preferences.option");
|
||||
|
||||
if (result.isLoading) return [createLoadingPreferenceAction()];
|
||||
|
||||
return createSelectablePreferenceActions({
|
||||
query,
|
||||
currentValue: value as string | null,
|
||||
noneLabel: t(noneKey as never),
|
||||
unavailableLabel: t(unavailableKey as never),
|
||||
items: (result.data ?? []).map(mapItem),
|
||||
onSelect: (v) => setValue(v as never),
|
||||
isPending,
|
||||
});
|
||||
},
|
||||
DetailComponent: () => <PreferenceDetailHeader titleKey={`${preferenceKey}.children.detail.title`} />,
|
||||
});
|
||||
|
||||
const firstDayOfWeekOptions: DayOfWeek[] = [1, 6, 0];
|
||||
|
||||
export const preferenceChildrenOptionsByKey: Partial<Record<UserPreferenceKey, ChildrenFactory>> = {
|
||||
colorScheme: createStaticChildren("colorScheme", () => {
|
||||
const t = useScopedI18n("common.colorScheme.options");
|
||||
return colorSchemes.map((s) => ({ key: s, label: t(s), value: s }));
|
||||
}),
|
||||
locale: languageChildrenOptions,
|
||||
defaultSearchEngineId: createAsyncSelectableChildren(
|
||||
"defaultSearchEngineId",
|
||||
() => clientApi.searchEngine.getSelectable.useQuery(),
|
||||
(e) => ({ key: e.value, label: e.label, value: e.value }),
|
||||
"searchEngine.none",
|
||||
"searchEngine.unavailable",
|
||||
),
|
||||
firstDayOfWeek: createStaticChildren("firstDayOfWeek", () =>
|
||||
firstDayOfWeekOptions.map((day) => ({
|
||||
key: day.toString(),
|
||||
label: dayjs.weekdays(false)[day] ?? day.toString(),
|
||||
value: day,
|
||||
})),
|
||||
),
|
||||
homeBoardId: createAsyncSelectableChildren(
|
||||
"homeBoardId",
|
||||
() => clientApi.board.getAllBoards.useQuery(),
|
||||
(b) => ({ key: b.id, label: b.name, value: b.id }),
|
||||
"board.none",
|
||||
"board.unavailable",
|
||||
),
|
||||
mobileHomeBoardId: createAsyncSelectableChildren(
|
||||
"mobileHomeBoardId",
|
||||
() => clientApi.board.getAllBoards.useQuery(),
|
||||
(b) => ({ key: b.id, label: b.name, value: b.id }),
|
||||
"board.none",
|
||||
"board.unavailable",
|
||||
),
|
||||
};
|
||||
@@ -1,204 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useSyncExternalStore } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMantineColorScheme } from "@mantine/core";
|
||||
import type { DayOfWeek } from "@mantine/dates";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import type { ColorScheme } from "@homarr/definitions";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||
import { userPreferenceDefinitions, userPreferenceDefinitionByKey } from "@homarr/settings";
|
||||
import type { UserPreferenceKey } from "@homarr/settings";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useChangeLocale, useCurrentLocale, useI18n } from "@homarr/translation/client";
|
||||
|
||||
// --- Optimistic store (module-level, shared across all hook instances) ---
|
||||
|
||||
const optimisticRef: { current: Partial<Record<UserPreferenceKey, unknown>> } = { current: {} };
|
||||
let optimisticVersion = 0;
|
||||
const optimisticListeners = new Set<() => void>();
|
||||
|
||||
const notifyListeners = () => {
|
||||
optimisticVersion += 1;
|
||||
optimisticListeners.forEach((fn) => fn());
|
||||
};
|
||||
|
||||
const subscribeOptimistic = (listener: () => void) => {
|
||||
optimisticListeners.add(listener);
|
||||
return () => optimisticListeners.delete(listener);
|
||||
};
|
||||
|
||||
const setOptimistic = (key: UserPreferenceKey, value: unknown) => {
|
||||
optimisticRef.current[key] = value;
|
||||
notifyListeners();
|
||||
};
|
||||
|
||||
const clearOptimistic = (keys: readonly UserPreferenceKey[]) => {
|
||||
let changed = false;
|
||||
for (const key of keys) {
|
||||
changed ||= key in optimisticRef.current;
|
||||
delete optimisticRef.current[key];
|
||||
}
|
||||
if (changed) notifyListeners();
|
||||
};
|
||||
|
||||
const CLIENT_ONLY_GROUPS = new Set(["colorScheme", "locale"]);
|
||||
|
||||
const isDbBacked = (key: UserPreferenceKey) => {
|
||||
const group = userPreferenceDefinitionByKey[key].mutationGroup;
|
||||
return group !== null && !CLIENT_ONLY_GROUPS.has(group);
|
||||
};
|
||||
|
||||
const getGroupKeys = (key: UserPreferenceKey): UserPreferenceKey[] => {
|
||||
const group = userPreferenceDefinitionByKey[key].mutationGroup;
|
||||
if (!group) return [key];
|
||||
return userPreferenceDefinitions.filter((d) => d.mutationGroup === group).map((d) => d.key);
|
||||
};
|
||||
|
||||
// --- Hook ---
|
||||
|
||||
export const useUserPreferences = () => {
|
||||
const router = useRouter();
|
||||
const settings = useSettings();
|
||||
const settingsRef = useRef(settings);
|
||||
settingsRef.current = settings;
|
||||
|
||||
const { data: session } = useSession();
|
||||
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
||||
const currentLocale = useCurrentLocale();
|
||||
const { changeLocale } = useChangeLocale();
|
||||
const t = useI18n();
|
||||
const userId = session?.user.id;
|
||||
const pendingRef = useRef<Record<string, boolean>>({});
|
||||
const prevUserIdRef = useRef(userId);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevUserIdRef.current === userId) return;
|
||||
prevUserIdRef.current = userId;
|
||||
clearOptimistic(userPreferenceDefinitions.map((d) => d.key));
|
||||
}, [userId]);
|
||||
|
||||
useSyncExternalStore(
|
||||
subscribeOptimistic,
|
||||
() => optimisticVersion,
|
||||
() => optimisticVersion,
|
||||
);
|
||||
|
||||
const refresh = () => router.refresh();
|
||||
|
||||
const searchMutation = clientApi.user.changeSearchPreferences.useMutation({ onSuccess: refresh });
|
||||
const homeBoardsMutation = clientApi.user.changeHomeBoards.useMutation({ onSuccess: refresh });
|
||||
const firstDayMutation = clientApi.user.changeFirstDayOfWeek.useMutation({ onSuccess: refresh });
|
||||
const pingMutation = clientApi.user.changePingIconsEnabled.useMutation({ onSuccess: refresh });
|
||||
|
||||
const getEffective = (key: UserPreferenceKey): unknown =>
|
||||
key in optimisticRef.current ? optimisticRef.current[key] : settingsRef.current[key as keyof SettingsContextProps];
|
||||
|
||||
const pick = (field: UserPreferenceKey, changedKey: UserPreferenceKey, value: unknown) =>
|
||||
field === changedKey ? value : getEffective(field);
|
||||
|
||||
const groupDispatchers: Record<string, (uid: string, key: UserPreferenceKey, value: unknown) => Promise<unknown>> = {
|
||||
searchPreferences: (uid, key, value) =>
|
||||
searchMutation.mutateAsync({
|
||||
userId: uid,
|
||||
defaultSearchEngineId: pick("defaultSearchEngineId", key, value) as string | null,
|
||||
openInNewTab: pick("openSearchInNewTab", key, value) as boolean,
|
||||
ddgBangsEnabled: pick("ddgBangs", key, value) as boolean,
|
||||
}),
|
||||
homeBoards: (uid, key, value) =>
|
||||
homeBoardsMutation.mutateAsync({
|
||||
userId: uid,
|
||||
homeBoardId: pick("homeBoardId", key, value) as string | null,
|
||||
mobileHomeBoardId: pick("mobileHomeBoardId", key, value) as string | null,
|
||||
}),
|
||||
firstDayOfWeek: (uid, _key, value) => firstDayMutation.mutateAsync({ id: uid, firstDayOfWeek: value as DayOfWeek }),
|
||||
pingIconsEnabled: (uid, _key, value) => pingMutation.mutateAsync({ id: uid, pingIconsEnabled: value as boolean }),
|
||||
};
|
||||
|
||||
const groupPending: Record<string, boolean> = {
|
||||
searchPreferences: searchMutation.isPending,
|
||||
homeBoards: homeBoardsMutation.isPending,
|
||||
firstDayOfWeek: firstDayMutation.isPending,
|
||||
pingIconsEnabled: pingMutation.isPending,
|
||||
};
|
||||
|
||||
const persistedValues = useMemo<Record<UserPreferenceKey, unknown>>(() => {
|
||||
const values: Record<string, unknown> = {
|
||||
colorScheme: colorScheme as ColorScheme,
|
||||
locale: currentLocale,
|
||||
fullPreferencesPage: userId ? `/manage/users/${userId}/general` : null,
|
||||
};
|
||||
for (const def of userPreferenceDefinitions) {
|
||||
if (def.key in values) continue;
|
||||
values[def.key] = settings[def.key as keyof SettingsContextProps];
|
||||
}
|
||||
return values as Record<UserPreferenceKey, unknown>;
|
||||
}, [colorScheme, currentLocale, userId, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
const matchedKeys = Object.entries(persistedValues)
|
||||
.filter(([key, value]) => optimisticRef.current[key as UserPreferenceKey] === value)
|
||||
.map(([key]) => key as UserPreferenceKey);
|
||||
clearOptimistic(matchedKeys);
|
||||
}, [persistedValues]);
|
||||
|
||||
const getValue = (key: UserPreferenceKey) =>
|
||||
key in optimisticRef.current ? optimisticRef.current[key] : persistedValues[key];
|
||||
|
||||
const isPreferencePending = (key: UserPreferenceKey): boolean => {
|
||||
const group = userPreferenceDefinitionByKey[key].mutationGroup;
|
||||
if (!group || CLIENT_ONLY_GROUPS.has(group)) return false;
|
||||
return (groupPending[group] ?? false) || (pendingRef.current[group] ?? false);
|
||||
};
|
||||
|
||||
const clientSetters: Partial<Record<UserPreferenceKey, (value: unknown) => void>> = {
|
||||
colorScheme: (value) => setColorScheme(value as ColorScheme),
|
||||
locale: (value) => changeLocale(value as typeof currentLocale),
|
||||
fullPreferencesPage: () => undefined,
|
||||
};
|
||||
|
||||
const setValueAsync = async (key: UserPreferenceKey, value: unknown) => {
|
||||
if (isDbBacked(key) && !userId) return;
|
||||
if (isPreferencePending(key)) return;
|
||||
|
||||
const clientSetter = clientSetters[key];
|
||||
if (clientSetter) {
|
||||
clientSetter(value);
|
||||
return;
|
||||
}
|
||||
|
||||
const group = userPreferenceDefinitionByKey[key].mutationGroup;
|
||||
if (!group) return;
|
||||
const dispatcher = groupDispatchers[group];
|
||||
if (!dispatcher || !userId) return;
|
||||
|
||||
setOptimistic(key, value);
|
||||
|
||||
pendingRef.current[group] = true;
|
||||
try {
|
||||
await dispatcher(userId, key, value);
|
||||
} catch {
|
||||
clearOptimistic(getGroupKeys(key));
|
||||
showErrorNotification({
|
||||
message: t("search.mode.command.group.preferences.notification.error.message"),
|
||||
});
|
||||
} finally {
|
||||
pendingRef.current[group] = false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
getPreference(preferenceKey: UserPreferenceKey) {
|
||||
return {
|
||||
value: getValue(preferenceKey),
|
||||
setValue: (value: never) => setValueAsync(preferenceKey, value),
|
||||
isPending: isPreferencePending(preferenceKey),
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const useUserPreference = (key: UserPreferenceKey) => useUserPreferences().getPreference(key);
|
||||
@@ -3,17 +3,9 @@
|
||||
import { clamp } from "@mantine/hooks";
|
||||
import type { SpotlightStore } from "@mantine/spotlight";
|
||||
import { createSpotlight } from "@mantine/spotlight";
|
||||
import { atom } from "jotai";
|
||||
|
||||
export const [spotlightStore, spotlightActions] = createSpotlight();
|
||||
|
||||
export interface MediaRequestSearchScope {
|
||||
integrationIds?: string[];
|
||||
}
|
||||
|
||||
export const mediaRequestSearchScopeAtom = atom<MediaRequestSearchScope>({});
|
||||
export const mediaRequestSearchEvent = "homarr:spotlight:media-request-search";
|
||||
|
||||
export const setSelectedAction = (index: number, store: SpotlightStore) => {
|
||||
store.updateState((state) => ({ ...state, selected: index }));
|
||||
};
|
||||
|
||||
@@ -132,7 +132,6 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Contrasenya",
|
||||
"suggestionsTitle": "",
|
||||
"suggestion": {
|
||||
"length": "Inclou com a mínim 8 caràcters",
|
||||
"lowercase": "Inclou minúscules",
|
||||
@@ -1406,9 +1405,6 @@
|
||||
"openNewTab": {
|
||||
"label": ""
|
||||
},
|
||||
"withBorder": {
|
||||
"label": ""
|
||||
},
|
||||
"items": {
|
||||
"label": "",
|
||||
"add": ""
|
||||
|
||||
@@ -132,7 +132,6 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "密码",
|
||||
"suggestionsTitle": "",
|
||||
"suggestion": {
|
||||
"length": "至少包含 8 个字符",
|
||||
"lowercase": "包含小写字母",
|
||||
@@ -1406,9 +1405,6 @@
|
||||
"openNewTab": {
|
||||
"label": "在新标签页中打开"
|
||||
},
|
||||
"withBorder": {
|
||||
"label": ""
|
||||
},
|
||||
"items": {
|
||||
"label": "书签",
|
||||
"add": "添加书签"
|
||||
|
||||
@@ -132,13 +132,12 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "crwdns26:0crwdne26:0",
|
||||
"suggestionsTitle": "crwdns4261:0crwdne4261:0",
|
||||
"suggestion": {
|
||||
"length": "crwdns4263:0crwdne4263:0",
|
||||
"lowercase": "crwdns4265:0crwdne4265:0",
|
||||
"uppercase": "crwdns4267:0crwdne4267:0",
|
||||
"number": "crwdns4269:0crwdne4269:0",
|
||||
"special": "crwdns4271:0crwdne4271:0"
|
||||
"length": "crwdns28:0crwdne28:0",
|
||||
"lowercase": "crwdns30:0crwdne30:0",
|
||||
"uppercase": "crwdns32:0crwdne32:0",
|
||||
"number": "crwdns34:0crwdne34:0",
|
||||
"special": "crwdns36:0crwdne36:0"
|
||||
}
|
||||
},
|
||||
"passwordConfirm": {
|
||||
@@ -1406,9 +1405,6 @@
|
||||
"openNewTab": {
|
||||
"label": "crwdns2686:0crwdne2686:0"
|
||||
},
|
||||
"withBorder": {
|
||||
"label": "crwdns4273:0crwdne4273:0"
|
||||
},
|
||||
"items": {
|
||||
"label": "crwdns764:0crwdne764:0",
|
||||
"add": "crwdns766:0crwdne766:0"
|
||||
|
||||
@@ -132,7 +132,6 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Heslo",
|
||||
"suggestionsTitle": "",
|
||||
"suggestion": {
|
||||
"length": "Obsahuje alespoň 8 znaků",
|
||||
"lowercase": "Obsahuje malé písmeno",
|
||||
@@ -1406,9 +1405,6 @@
|
||||
"openNewTab": {
|
||||
"label": ""
|
||||
},
|
||||
"withBorder": {
|
||||
"label": ""
|
||||
},
|
||||
"items": {
|
||||
"label": "",
|
||||
"add": ""
|
||||
|
||||
@@ -132,7 +132,6 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Adgangskode",
|
||||
"suggestionsTitle": "",
|
||||
"suggestion": {
|
||||
"length": "Inkluderer mindst 8 tegn",
|
||||
"lowercase": "Inkluderer små bogstaver",
|
||||
@@ -1406,9 +1405,6 @@
|
||||
"openNewTab": {
|
||||
"label": "Åbn i nyt faneblad"
|
||||
},
|
||||
"withBorder": {
|
||||
"label": ""
|
||||
},
|
||||
"items": {
|
||||
"label": "Bogmærker",
|
||||
"add": "Tilføj bogmærke"
|
||||
|
||||
@@ -132,7 +132,6 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Passwort",
|
||||
"suggestionsTitle": "",
|
||||
"suggestion": {
|
||||
"length": "Enthält mindestens 8 Zeichen",
|
||||
"lowercase": "Enthält Kleinbuchstaben",
|
||||
@@ -1406,9 +1405,6 @@
|
||||
"openNewTab": {
|
||||
"label": "In neuem Tab öffnen"
|
||||
},
|
||||
"withBorder": {
|
||||
"label": ""
|
||||
},
|
||||
"items": {
|
||||
"label": "Lesezeichen",
|
||||
"add": "Lesezeichen hinzufügen"
|
||||
|
||||
@@ -132,7 +132,6 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Passwort",
|
||||
"suggestionsTitle": "",
|
||||
"suggestion": {
|
||||
"length": "Enthält mindestens 8 Zeichen",
|
||||
"lowercase": "Enthält Kleinbuchstaben",
|
||||
@@ -1406,9 +1405,6 @@
|
||||
"openNewTab": {
|
||||
"label": "In neuem Tab öffnen"
|
||||
},
|
||||
"withBorder": {
|
||||
"label": ""
|
||||
},
|
||||
"items": {
|
||||
"label": "Lesezeichen",
|
||||
"add": "Lesezeichen hinzufügen"
|
||||
|
||||
@@ -132,7 +132,6 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Κωδικός",
|
||||
"suggestionsTitle": "",
|
||||
"suggestion": {
|
||||
"length": "",
|
||||
"lowercase": "Περιλαμβάνει πεζό γράμμα",
|
||||
@@ -1406,9 +1405,6 @@
|
||||
"openNewTab": {
|
||||
"label": ""
|
||||
},
|
||||
"withBorder": {
|
||||
"label": ""
|
||||
},
|
||||
"items": {
|
||||
"label": "",
|
||||
"add": ""
|
||||
|
||||
@@ -132,7 +132,6 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Password",
|
||||
"suggestionsTitle": "",
|
||||
"suggestion": {
|
||||
"length": "Includes a minimum of 8 characters",
|
||||
"lowercase": "Includes a lower-case letter",
|
||||
@@ -1406,9 +1405,6 @@
|
||||
"openNewTab": {
|
||||
"label": "Open in new tab"
|
||||
},
|
||||
"withBorder": {
|
||||
"label": ""
|
||||
},
|
||||
"items": {
|
||||
"label": "Bookmarks",
|
||||
"add": "Add bookmark"
|
||||
@@ -4555,23 +4551,9 @@
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"help": "Search for movies or shows to request",
|
||||
"requestMovie": "",
|
||||
"requestSeries": "",
|
||||
"openIn": "",
|
||||
"group": {
|
||||
"title": "Movies and shows"
|
||||
},
|
||||
"action": {
|
||||
"search": {
|
||||
"label": "Search for movies or shows",
|
||||
"description": "Start typing to search your media request integrations",
|
||||
"disabled": {
|
||||
"noIntegration": "Add an Overseerr, Jellyseerr, or Seerr integration to enable media search.",
|
||||
"scoped": "This widget does not have an accessible media request integration."
|
||||
}
|
||||
}
|
||||
}
|
||||
"openIn": ""
|
||||
},
|
||||
"external": {
|
||||
"help": "",
|
||||
@@ -4847,11 +4829,7 @@
|
||||
"table": {
|
||||
"header": {
|
||||
"season": "Season",
|
||||
"episodes": "Episodes",
|
||||
"status": "Status"
|
||||
},
|
||||
"status": {
|
||||
"requested": "Requested"
|
||||
"episodes": "Episodes"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
|
||||
@@ -1303,13 +1303,6 @@
|
||||
},
|
||||
"edit": {
|
||||
"title": "Edit item",
|
||||
"tab": {
|
||||
"widget": "Edit widget",
|
||||
"app": "Edit app"
|
||||
},
|
||||
"app": {
|
||||
"propagationNotice": "Changes to this app will be applied everywhere it is used across all dashboards."
|
||||
},
|
||||
"advancedOptions": {
|
||||
"label": "Advanced options",
|
||||
"title": "Advanced item options"
|
||||
@@ -4558,141 +4551,13 @@
|
||||
"label": "Create a new group"
|
||||
}
|
||||
}
|
||||
},
|
||||
"preferences": {
|
||||
"title": "Preferences",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": "Select a preference to change",
|
||||
"backHint": "Press Backspace or use the back button to go back",
|
||||
"backAction": "Go back"
|
||||
}
|
||||
},
|
||||
"notification": {
|
||||
"error": {
|
||||
"message": "Unable to update preference"
|
||||
}
|
||||
},
|
||||
"option": {
|
||||
"preferences": {
|
||||
"label": "Open preferences"
|
||||
},
|
||||
"boolean": {
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"board": {
|
||||
"none": "None",
|
||||
"unavailable": "Currently selected (unavailable)"
|
||||
},
|
||||
"searchEngine": {
|
||||
"none": "None",
|
||||
"unavailable": "Currently selected (unavailable)"
|
||||
},
|
||||
"colorScheme": {
|
||||
"label": "Color scheme",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": "Select your color scheme"
|
||||
}
|
||||
}
|
||||
},
|
||||
"locale": {
|
||||
"label": "Language",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": "Select your preferred language"
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultSearchEngineId": {
|
||||
"label": "Default search engine",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": "Select your default search engine"
|
||||
}
|
||||
}
|
||||
},
|
||||
"openSearchInNewTab": {
|
||||
"label": "Open search results in new tab",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": "Open search results in new tab"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ddgBangs": {
|
||||
"label": "Enable DuckDuckGo bangs",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": "Enable DuckDuckGo bangs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"firstDayOfWeek": {
|
||||
"label": "First day of the week",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": "Select the first day of the week"
|
||||
}
|
||||
}
|
||||
},
|
||||
"homeBoardId": {
|
||||
"label": "Home board",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": "Select your home board"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mobileHomeBoardId": {
|
||||
"label": "Mobile home board",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": "Select your mobile home board"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pingIconsEnabled": {
|
||||
"label": "Use icons for pings",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": "Use icons for pings"
|
||||
}
|
||||
}
|
||||
},
|
||||
"fullPreferencesPage": {
|
||||
"label": "Open full preferences page"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"help": "Search for movies or shows to request",
|
||||
"requestMovie": "Request movie",
|
||||
"requestSeries": "Request series",
|
||||
"openIn": "Open in {kind}",
|
||||
"group": {
|
||||
"title": "Movies and shows"
|
||||
},
|
||||
"action": {
|
||||
"search": {
|
||||
"label": "Search for movies or shows",
|
||||
"description": "Start typing to search your media request integrations",
|
||||
"disabled": {
|
||||
"noIntegration": "Add an Overseerr, Jellyseerr, or Seerr integration to enable media search.",
|
||||
"scoped": "This widget does not have an accessible media request integration."
|
||||
}
|
||||
}
|
||||
},
|
||||
"availability": {
|
||||
"available": "Available",
|
||||
"partiallyAvailable": "Partial",
|
||||
"processing": "Processing",
|
||||
"requested": "Requested",
|
||||
"pending": "Requested"
|
||||
}
|
||||
"openIn": "Open in {kind}"
|
||||
},
|
||||
"external": {
|
||||
"help": "Use an external search engine",
|
||||
@@ -4968,11 +4833,7 @@
|
||||
"table": {
|
||||
"header": {
|
||||
"season": "Season",
|
||||
"episodes": "Episodes",
|
||||
"status": "Status"
|
||||
},
|
||||
"status": {
|
||||
"requested": "Requested"
|
||||
"episodes": "Episodes"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
|
||||
@@ -132,7 +132,6 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Contraseña",
|
||||
"suggestionsTitle": "",
|
||||
"suggestion": {
|
||||
"length": "Incluye al menos 8 caracteres",
|
||||
"lowercase": "Incluye al menos una letra minúscula",
|
||||
@@ -1406,9 +1405,6 @@
|
||||
"openNewTab": {
|
||||
"label": "Abrir en una nueva pestaña"
|
||||
},
|
||||
"withBorder": {
|
||||
"label": ""
|
||||
},
|
||||
"items": {
|
||||
"label": "Marcadores",
|
||||
"add": "Añadir marcador"
|
||||
|
||||
@@ -132,7 +132,6 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "",
|
||||
"suggestionsTitle": "",
|
||||
"suggestion": {
|
||||
"length": "",
|
||||
"lowercase": "",
|
||||
@@ -1406,9 +1405,6 @@
|
||||
"openNewTab": {
|
||||
"label": ""
|
||||
},
|
||||
"withBorder": {
|
||||
"label": ""
|
||||
},
|
||||
"items": {
|
||||
"label": "",
|
||||
"add": ""
|
||||
|
||||
@@ -132,7 +132,6 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Mot de passe",
|
||||
"suggestionsTitle": "",
|
||||
"suggestion": {
|
||||
"length": "Contient au moins 8 caractères",
|
||||
"lowercase": "Inclut une lettre minuscule",
|
||||
@@ -1406,9 +1405,6 @@
|
||||
"openNewTab": {
|
||||
"label": "Ouvrir dans un nouvel onglet"
|
||||
},
|
||||
"withBorder": {
|
||||
"label": ""
|
||||
},
|
||||
"items": {
|
||||
"label": "Signets",
|
||||
"add": "Ajouter un signet"
|
||||
@@ -2092,7 +2088,7 @@
|
||||
"empty": "",
|
||||
"user": "Utilisateur",
|
||||
"media": "Média",
|
||||
"state": "État",
|
||||
"state": "",
|
||||
"quality": "Qualité",
|
||||
"device": "Appareil",
|
||||
"progress": "Progression",
|
||||
|
||||
@@ -132,7 +132,6 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "סיסמה",
|
||||
"suggestionsTitle": "",
|
||||
"suggestion": {
|
||||
"length": "כולל לפחות 8 תווים",
|
||||
"lowercase": "אפשר אותיות קטנות",
|
||||
@@ -1406,9 +1405,6 @@
|
||||
"openNewTab": {
|
||||
"label": "פתיחה בכרטיסיה חדשה"
|
||||
},
|
||||
"withBorder": {
|
||||
"label": ""
|
||||
},
|
||||
"items": {
|
||||
"label": "סימניות",
|
||||
"add": "הוסף סימניה"
|
||||
|
||||
@@ -132,7 +132,6 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Lozinka",
|
||||
"suggestionsTitle": "",
|
||||
"suggestion": {
|
||||
"length": "",
|
||||
"lowercase": "Uključuje mala slova",
|
||||
@@ -1406,9 +1405,6 @@
|
||||
"openNewTab": {
|
||||
"label": ""
|
||||
},
|
||||
"withBorder": {
|
||||
"label": ""
|
||||
},
|
||||
"items": {
|
||||
"label": "",
|
||||
"add": ""
|
||||
|
||||
@@ -132,7 +132,6 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Jelszó",
|
||||
"suggestionsTitle": "",
|
||||
"suggestion": {
|
||||
"length": "Tartalmazzon legalább 8 karaktert",
|
||||
"lowercase": "Tartalmazzon kisbetűt",
|
||||
@@ -1406,9 +1405,6 @@
|
||||
"openNewTab": {
|
||||
"label": "Megnyitás új lapon"
|
||||
},
|
||||
"withBorder": {
|
||||
"label": ""
|
||||
},
|
||||
"items": {
|
||||
"label": "Könyjelzők",
|
||||
"add": "Könyvjelző hozzáadása"
|
||||
|
||||
@@ -132,7 +132,6 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Password",
|
||||
"suggestionsTitle": "Suggerimenti password",
|
||||
"suggestion": {
|
||||
"length": "Include almeno 8 caratteri",
|
||||
"lowercase": "Include lettera minuscola",
|
||||
@@ -1406,9 +1405,6 @@
|
||||
"openNewTab": {
|
||||
"label": "Apri in una nuova scheda"
|
||||
},
|
||||
"withBorder": {
|
||||
"label": "Mostra bordo"
|
||||
},
|
||||
"items": {
|
||||
"label": "Segnalibri",
|
||||
"add": "Aggiungi segnalibro"
|
||||
|
||||
@@ -132,7 +132,6 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "パスワード",
|
||||
"suggestionsTitle": "",
|
||||
"suggestion": {
|
||||
"length": "少なくとも 8 文字以上を含む",
|
||||
"lowercase": "小文字を含む",
|
||||
@@ -1406,9 +1405,6 @@
|
||||
"openNewTab": {
|
||||
"label": "新しいタブで開く"
|
||||
},
|
||||
"withBorder": {
|
||||
"label": ""
|
||||
},
|
||||
"items": {
|
||||
"label": "ブックマーク",
|
||||
"add": "ブックマークに追加"
|
||||
|
||||
@@ -132,7 +132,6 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "비밀번호",
|
||||
"suggestionsTitle": "",
|
||||
"suggestion": {
|
||||
"length": "",
|
||||
"lowercase": "소문자 포함",
|
||||
@@ -1406,9 +1405,6 @@
|
||||
"openNewTab": {
|
||||
"label": ""
|
||||
},
|
||||
"withBorder": {
|
||||
"label": ""
|
||||
},
|
||||
"items": {
|
||||
"label": "",
|
||||
"add": ""
|
||||
|
||||
@@ -132,7 +132,6 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Slaptažodis",
|
||||
"suggestionsTitle": "",
|
||||
"suggestion": {
|
||||
"length": "",
|
||||
"lowercase": "",
|
||||
@@ -1406,9 +1405,6 @@
|
||||
"openNewTab": {
|
||||
"label": ""
|
||||
},
|
||||
"withBorder": {
|
||||
"label": ""
|
||||
},
|
||||
"items": {
|
||||
"label": "",
|
||||
"add": ""
|
||||
|
||||
@@ -132,7 +132,6 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Parole",
|
||||
"suggestionsTitle": "",
|
||||
"suggestion": {
|
||||
"length": "",
|
||||
"lowercase": "Ietver mazo burtu",
|
||||
@@ -1406,9 +1405,6 @@
|
||||
"openNewTab": {
|
||||
"label": ""
|
||||
},
|
||||
"withBorder": {
|
||||
"label": ""
|
||||
},
|
||||
"items": {
|
||||
"label": "",
|
||||
"add": ""
|
||||
|
||||
@@ -132,7 +132,6 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Wachtwoord",
|
||||
"suggestionsTitle": "",
|
||||
"suggestion": {
|
||||
"length": "Bevat ten minste 8 tekens",
|
||||
"lowercase": "Inclusief kleine letter",
|
||||
@@ -1406,9 +1405,6 @@
|
||||
"openNewTab": {
|
||||
"label": "In nieuw tabblad openen"
|
||||
},
|
||||
"withBorder": {
|
||||
"label": ""
|
||||
},
|
||||
"items": {
|
||||
"label": "Bladwijzers",
|
||||
"add": "Bladwijzer toevoegen"
|
||||
|
||||
@@ -132,7 +132,6 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Passord",
|
||||
"suggestionsTitle": "",
|
||||
"suggestion": {
|
||||
"length": "Inkluderer minst 8 tegn",
|
||||
"lowercase": "Inkluderer liten bokstav",
|
||||
@@ -1406,9 +1405,6 @@
|
||||
"openNewTab": {
|
||||
"label": "Åpne i ny fane"
|
||||
},
|
||||
"withBorder": {
|
||||
"label": ""
|
||||
},
|
||||
"items": {
|
||||
"label": "Bokmerke",
|
||||
"add": "Legg til bokmerke"
|
||||
|
||||
@@ -132,7 +132,6 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Hasło",
|
||||
"suggestionsTitle": "",
|
||||
"suggestion": {
|
||||
"length": "Zawiera co najmniej 8 znaków",
|
||||
"lowercase": "Zawiera małą literę",
|
||||
@@ -1406,9 +1405,6 @@
|
||||
"openNewTab": {
|
||||
"label": "Otwórz w nowej karcie"
|
||||
},
|
||||
"withBorder": {
|
||||
"label": ""
|
||||
},
|
||||
"items": {
|
||||
"label": "Zakładki",
|
||||
"add": "Dodaj zakładkę"
|
||||
|
||||
@@ -132,7 +132,6 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Senha",
|
||||
"suggestionsTitle": "",
|
||||
"suggestion": {
|
||||
"length": "Inclua pelo menos 8 caracteres",
|
||||
"lowercase": "Inclui letras minúsculas",
|
||||
@@ -1057,7 +1056,7 @@
|
||||
}
|
||||
},
|
||||
"noResults": {
|
||||
"title": "Nenhuma mídia enviada"
|
||||
"title": ""
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1089,8 +1088,8 @@
|
||||
"checkLogs": "Verifique os logs para mais detalhes",
|
||||
"tryAgain": "Tente novamente",
|
||||
"loading": "Carregando",
|
||||
"show": "Mostrar",
|
||||
"hide": "Esconder"
|
||||
"show": "",
|
||||
"hide": ""
|
||||
},
|
||||
"here": "aqui",
|
||||
"iconPicker": {
|
||||
@@ -1256,7 +1255,7 @@
|
||||
"label": {
|
||||
"create": "Nova categoria",
|
||||
"changePosition": "Mudar de posição",
|
||||
"settings": "Configurações"
|
||||
"settings": ""
|
||||
}
|
||||
},
|
||||
"openAllInNewTabs": {
|
||||
@@ -1406,9 +1405,6 @@
|
||||
"openNewTab": {
|
||||
"label": "Abrir em nova guia"
|
||||
},
|
||||
"withBorder": {
|
||||
"label": ""
|
||||
},
|
||||
"items": {
|
||||
"label": "Marcadores",
|
||||
"add": "Adicionar marcador"
|
||||
@@ -3463,7 +3459,7 @@
|
||||
"private": "Este quadro é privado"
|
||||
},
|
||||
"noResults": {
|
||||
"title": "Nenhum painel ainda"
|
||||
"title": ""
|
||||
},
|
||||
"modal": {
|
||||
"createBoard": {
|
||||
|
||||
@@ -132,7 +132,6 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "",
|
||||
"suggestionsTitle": "",
|
||||
"suggestion": {
|
||||
"length": "",
|
||||
"lowercase": "",
|
||||
@@ -1406,9 +1405,6 @@
|
||||
"openNewTab": {
|
||||
"label": ""
|
||||
},
|
||||
"withBorder": {
|
||||
"label": ""
|
||||
},
|
||||
"items": {
|
||||
"label": "",
|
||||
"add": ""
|
||||
|
||||
@@ -132,7 +132,6 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Parola",
|
||||
"suggestionsTitle": "",
|
||||
"suggestion": {
|
||||
"length": "",
|
||||
"lowercase": "Să includă litere mici",
|
||||
@@ -1406,9 +1405,6 @@
|
||||
"openNewTab": {
|
||||
"label": ""
|
||||
},
|
||||
"withBorder": {
|
||||
"label": ""
|
||||
},
|
||||
"items": {
|
||||
"label": "",
|
||||
"add": ""
|
||||
|
||||
@@ -132,7 +132,6 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Пароль",
|
||||
"suggestionsTitle": "",
|
||||
"suggestion": {
|
||||
"length": "Содержит не менее 8 символов",
|
||||
"lowercase": "Содержит строчную букву",
|
||||
@@ -1406,9 +1405,6 @@
|
||||
"openNewTab": {
|
||||
"label": "Открывать в новой вкладке"
|
||||
},
|
||||
"withBorder": {
|
||||
"label": ""
|
||||
},
|
||||
"items": {
|
||||
"label": "Закладки",
|
||||
"add": "Добавить закладку"
|
||||
|
||||
@@ -132,7 +132,6 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Heslo",
|
||||
"suggestionsTitle": "",
|
||||
"suggestion": {
|
||||
"length": "Obsahuje aspoň 8 znakov",
|
||||
"lowercase": "Zahŕňa malé písmeno",
|
||||
@@ -1406,9 +1405,6 @@
|
||||
"openNewTab": {
|
||||
"label": "Otvoriť na novej karte"
|
||||
},
|
||||
"withBorder": {
|
||||
"label": ""
|
||||
},
|
||||
"items": {
|
||||
"label": "Záložky",
|
||||
"add": "Pridať záložku"
|
||||
|
||||
@@ -132,7 +132,6 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Geslo",
|
||||
"suggestionsTitle": "",
|
||||
"suggestion": {
|
||||
"length": "",
|
||||
"lowercase": "Vključuje male črke",
|
||||
@@ -1406,9 +1405,6 @@
|
||||
"openNewTab": {
|
||||
"label": ""
|
||||
},
|
||||
"withBorder": {
|
||||
"label": ""
|
||||
},
|
||||
"items": {
|
||||
"label": "",
|
||||
"add": ""
|
||||
|
||||
@@ -132,7 +132,6 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Lösenord",
|
||||
"suggestionsTitle": "",
|
||||
"suggestion": {
|
||||
"length": "Använd minst 8 tecken",
|
||||
"lowercase": "Använd liten bokstav",
|
||||
@@ -1406,9 +1405,6 @@
|
||||
"openNewTab": {
|
||||
"label": "Öppna i en ny flik"
|
||||
},
|
||||
"withBorder": {
|
||||
"label": ""
|
||||
},
|
||||
"items": {
|
||||
"label": "Bokmärken",
|
||||
"add": "Addera bokmärke"
|
||||
|
||||
@@ -132,7 +132,6 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Şifre",
|
||||
"suggestionsTitle": "",
|
||||
"suggestion": {
|
||||
"length": "En az 8 karakter içermeli",
|
||||
"lowercase": "Küçük harf içermeli",
|
||||
@@ -1406,9 +1405,6 @@
|
||||
"openNewTab": {
|
||||
"label": "Yeni sekmede aç"
|
||||
},
|
||||
"withBorder": {
|
||||
"label": ""
|
||||
},
|
||||
"items": {
|
||||
"label": "Yer İmleri",
|
||||
"add": "Yer imi ekle"
|
||||
|
||||
@@ -132,7 +132,6 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Пароль",
|
||||
"suggestionsTitle": "",
|
||||
"suggestion": {
|
||||
"length": "Включає принаймні 8 символів",
|
||||
"lowercase": "Включає малу літеру",
|
||||
@@ -1406,9 +1405,6 @@
|
||||
"openNewTab": {
|
||||
"label": "Відкрити в новій вкладці"
|
||||
},
|
||||
"withBorder": {
|
||||
"label": ""
|
||||
},
|
||||
"items": {
|
||||
"label": "Закладки",
|
||||
"add": "Додати закладку"
|
||||
|
||||
@@ -132,7 +132,6 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Mật khẩu",
|
||||
"suggestionsTitle": "",
|
||||
"suggestion": {
|
||||
"length": "Bao gồm ít nhất 8 kí tự",
|
||||
"lowercase": "Bao gồm chữ thường",
|
||||
@@ -1406,9 +1405,6 @@
|
||||
"openNewTab": {
|
||||
"label": ""
|
||||
},
|
||||
"withBorder": {
|
||||
"label": ""
|
||||
},
|
||||
"items": {
|
||||
"label": "",
|
||||
"add": ""
|
||||
|
||||
@@ -132,7 +132,6 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "密碼",
|
||||
"suggestionsTitle": "",
|
||||
"suggestion": {
|
||||
"length": "至少包含 8 個字符",
|
||||
"lowercase": "包含小寫字母",
|
||||
@@ -1406,9 +1405,6 @@
|
||||
"openNewTab": {
|
||||
"label": "於新分頁開啟"
|
||||
},
|
||||
"withBorder": {
|
||||
"label": ""
|
||||
},
|
||||
"items": {
|
||||
"label": "書籤",
|
||||
"add": "新增書籤"
|
||||
|
||||
@@ -1,32 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Avatar,
|
||||
Badge,
|
||||
Box,
|
||||
Card,
|
||||
Group,
|
||||
Image,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { IconSearch, IconThumbDown, IconThumbUp } from "@tabler/icons-react";
|
||||
import { ActionIcon, Anchor, Avatar, Badge, Card, Group, Image, ScrollArea, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { IconThumbDown, IconThumbUp } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterInputs, RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import type { MediaRequestStatus } from "@homarr/integrations/types";
|
||||
import { mediaAvailabilityConfiguration, mediaRequestStatusConfiguration } from "@homarr/integrations/types";
|
||||
import { openMediaRequestSearch } from "@homarr/spotlight";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import { NoIntegrationDataError } from "../../errors/no-data-integration";
|
||||
import classes from "../search-button.module.css";
|
||||
|
||||
export default function MediaServerWidget({
|
||||
integrationIds,
|
||||
@@ -58,7 +43,7 @@ export default function MediaServerWidget({
|
||||
const newData = filteredData.concat(
|
||||
data.requests.map((request) => ({ ...request, integrationId: data.integrationId })),
|
||||
);
|
||||
return newData.toSorted((dataA, dataB) => {
|
||||
return newData.sort((dataA, dataB) => {
|
||||
if (dataA.status === dataB.status) {
|
||||
return dataB.createdAt.getTime() - dataA.createdAt.getTime();
|
||||
}
|
||||
@@ -76,46 +61,25 @@ export default function MediaServerWidget({
|
||||
if (mediaRequests.length === 0) throw new NoIntegrationDataError();
|
||||
|
||||
return (
|
||||
<Box className={classes.searchRoot}>
|
||||
{!isEditMode && <MediaRequestSearchButton integrationIds={integrationIds} />}
|
||||
<ScrollArea
|
||||
className="mediaRequests-list-scrollArea"
|
||||
scrollbarSize="md"
|
||||
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||
>
|
||||
<Stack className="mediaRequests-list-list" gap="xs" p="sm">
|
||||
{mediaRequests.map((mediaRequest) => (
|
||||
<MediaRequestCard
|
||||
key={`${mediaRequest.integrationId}-${mediaRequest.id}`}
|
||||
request={mediaRequest}
|
||||
isTiny={width <= 256}
|
||||
options={options}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
<ScrollArea
|
||||
className="mediaRequests-list-scrollArea"
|
||||
scrollbarSize="md"
|
||||
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||
>
|
||||
<Stack className="mediaRequests-list-list" gap="xs" p="sm">
|
||||
{mediaRequests.map((mediaRequest) => (
|
||||
<MediaRequestCard
|
||||
key={`${mediaRequest.integrationId}-${mediaRequest.id}`}
|
||||
request={mediaRequest}
|
||||
isTiny={width <= 256}
|
||||
options={options}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[] }) => {
|
||||
const t = useScopedI18n("search.mode.media");
|
||||
|
||||
return (
|
||||
<Tooltip label={t("action.search.label")}>
|
||||
<ActionIcon
|
||||
className={classes.searchButton}
|
||||
variant="light"
|
||||
size="sm"
|
||||
aria-label={t("action.search.label")}
|
||||
onClick={() => openMediaRequestSearch({ integrationIds })}
|
||||
>
|
||||
<IconSearch size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
interface MediaRequestCardProps {
|
||||
request: RouterOutputs["widget"]["mediaRequests"]["getLatestRequests"][number];
|
||||
isTiny: boolean;
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
.searchRoot {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.searchButton {
|
||||
position: absolute;
|
||||
top: var(--mantine-spacing-xs);
|
||||
right: var(--mantine-spacing-xs);
|
||||
z-index: 2;
|
||||
opacity: 0;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.searchRoot:hover .searchButton,
|
||||
.searchRoot:focus-within .searchButton {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.searchButton {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ActionIcon, Avatar, Box, Card, Grid, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { Avatar, Card, Grid, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import type { Icon } from "@tabler/icons-react";
|
||||
import {
|
||||
IconDeviceTv,
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
IconMovie,
|
||||
IconPlayerPlay,
|
||||
IconReceipt,
|
||||
IconSearch,
|
||||
IconThumbDown,
|
||||
IconThumbUp,
|
||||
} from "@tabler/icons-react";
|
||||
@@ -18,13 +17,11 @@ import combineClasses from "clsx";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import type { RequestStats } from "@homarr/integrations/types";
|
||||
import { openMediaRequestSearch } from "@homarr/spotlight";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import { NoIntegrationDataError } from "../../errors/no-data-integration";
|
||||
import classes from "./component.module.css";
|
||||
import searchClasses from "../search-button.module.css";
|
||||
|
||||
const OVERSEERR_COLOR = "#ECB000";
|
||||
const JELLYSEERR_COLOR = "#6677CC";
|
||||
@@ -96,104 +93,83 @@ export default function MediaServerWidget({
|
||||
const isTiny = width < 256;
|
||||
|
||||
return (
|
||||
<Box className={searchClasses.searchRoot}>
|
||||
{!isEditMode && <MediaRequestSearchButton integrationIds={integrationIds} />}
|
||||
<Stack
|
||||
className="mediaRequests-stats-layout"
|
||||
h="100%"
|
||||
gap="xs"
|
||||
p="sm"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||
>
|
||||
<Stack gap={4} w="100%">
|
||||
<Text className="mediaRequests-stats-stats-title" fw="bold" ta="center" size={isTiny ? "xs" : "sm"}>
|
||||
{t("titles.stats.main")}
|
||||
</Text>
|
||||
<Grid className="mediaRequests-stats-stats-grid" gap={4} w="100%">
|
||||
{data.map((stat) => (
|
||||
<Grid.Col
|
||||
className={combineClasses("mediaRequests-stats-stat-wrapper", `mediaRequests-stats-stat-${stat.name}`)}
|
||||
key={stat.name}
|
||||
span={isTiny ? 6 : 3}
|
||||
>
|
||||
<Tooltip label={t(`titles.stats.${stat.name}`)}>
|
||||
<Card p={0} radius={board.itemRadius} className={classes.card}>
|
||||
<Group className="mediaRequests-stats-stat-stack" justify="center" align="center" gap="xs" w="100%">
|
||||
<stat.icon className="mediaRequests-stats-stat-icon" size={16} />
|
||||
<Text className="mediaRequests-stats-stat-value" size="md">
|
||||
{stat.number}
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
</Tooltip>
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
</Stack>
|
||||
<Stack gap={4} w="100%">
|
||||
<Text className="mediaRequests-stats-users-title" fw="bold" ta="center" size={isTiny ? "xs" : "sm"}>
|
||||
{t("titles.users.main")} ({t("titles.users.requests")})
|
||||
</Text>
|
||||
<Stack className="mediaRequests-stats-users-wrapper" flex={1} w="100%" gap={4} style={{ overflow: "hidden" }}>
|
||||
{requestStats.users.slice(0, 10).map((user) => (
|
||||
<Card
|
||||
component="a"
|
||||
href={user.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={combineClasses(
|
||||
"mediaRequests-stats-users-user-wrapper",
|
||||
`mediaRequests-stats-users-user-${user.id}`,
|
||||
classes.card,
|
||||
)}
|
||||
key={user.id}
|
||||
p="xs"
|
||||
radius={board.itemRadius}
|
||||
>
|
||||
<Group className="mediaRequests-stats-users-user-group" h="100%" p={0} gap="sm" justify="space-between">
|
||||
<Group gap={4}>
|
||||
<Tooltip label={user.integration.name}>
|
||||
<Avatar
|
||||
className="mediaRequests-stats-users-user-avatar"
|
||||
size={20}
|
||||
src={user.avatar}
|
||||
bd={`2px solid ${user.integration.kind === "overseerr" ? OVERSEERR_COLOR : JELLYSEERR_COLOR}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Text className="mediaRequests-stats-users-user-userName" size="sm">
|
||||
{user.displayName}
|
||||
<Stack
|
||||
className="mediaRequests-stats-layout"
|
||||
h="100%"
|
||||
gap="xs"
|
||||
p="sm"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||
>
|
||||
<Stack gap={4} w="100%">
|
||||
<Text className="mediaRequests-stats-stats-title" fw="bold" ta="center" size={isTiny ? "xs" : "sm"}>
|
||||
{t("titles.stats.main")}
|
||||
</Text>
|
||||
<Grid className="mediaRequests-stats-stats-grid" gap={4} w="100%">
|
||||
{data.map((stat) => (
|
||||
<Grid.Col
|
||||
className={combineClasses("mediaRequests-stats-stat-wrapper", `mediaRequests-stats-stat-${stat.name}`)}
|
||||
key={stat.name}
|
||||
span={isTiny ? 6 : 3}
|
||||
>
|
||||
<Tooltip label={t(`titles.stats.${stat.name}`)}>
|
||||
<Card p={0} radius={board.itemRadius} className={classes.card}>
|
||||
<Group className="mediaRequests-stats-stat-stack" justify="center" align="center" gap="xs" w="100%">
|
||||
<stat.icon className="mediaRequests-stats-stat-icon" size={16} />
|
||||
<Text className="mediaRequests-stats-stat-value" size="md">
|
||||
{stat.number}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Text className="mediaRequests-stats-users-user-request-count" size="md" fw={500}>
|
||||
{user.requestCount}
|
||||
</Card>
|
||||
</Tooltip>
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
</Stack>
|
||||
<Stack gap={4} w="100%">
|
||||
<Text className="mediaRequests-stats-users-title" fw="bold" ta="center" size={isTiny ? "xs" : "sm"}>
|
||||
{t("titles.users.main")} ({t("titles.users.requests")})
|
||||
</Text>
|
||||
<Stack className="mediaRequests-stats-users-wrapper" flex={1} w="100%" gap={4} style={{ overflow: "hidden" }}>
|
||||
{requestStats.users.slice(0, 10).map((user) => (
|
||||
<Card
|
||||
component="a"
|
||||
href={user.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={combineClasses(
|
||||
"mediaRequests-stats-users-user-wrapper",
|
||||
`mediaRequests-stats-users-user-${user.id}`,
|
||||
classes.card,
|
||||
)}
|
||||
key={user.id}
|
||||
p="xs"
|
||||
radius={board.itemRadius}
|
||||
>
|
||||
<Group className="mediaRequests-stats-users-user-group" h="100%" p={0} gap="sm" justify="space-between">
|
||||
<Group gap={4}>
|
||||
<Tooltip label={user.integration.name}>
|
||||
<Avatar
|
||||
className="mediaRequests-stats-users-user-avatar"
|
||||
size={20}
|
||||
src={user.avatar}
|
||||
bd={`2px solid ${user.integration.kind === "overseerr" ? OVERSEERR_COLOR : JELLYSEERR_COLOR}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Text className="mediaRequests-stats-users-user-userName" size="sm">
|
||||
{user.displayName}
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Text className="mediaRequests-stats-users-user-request-count" size="md" fw={500}>
|
||||
{user.requestCount}
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[] }) => {
|
||||
const t = useScopedI18n("search.mode.media");
|
||||
|
||||
return (
|
||||
<Tooltip label={t("action.search.label")}>
|
||||
<ActionIcon
|
||||
className={searchClasses.searchButton}
|
||||
variant="light"
|
||||
size="sm"
|
||||
aria-label={t("action.search.label")}
|
||||
onClick={() => openMediaRequestSearch({ integrationIds })}
|
||||
>
|
||||
<IconSearch size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -1,95 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useImperativeHandle, useRef } from "react";
|
||||
import { Alert, Button, Center, Loader, Stack, Text } from "@mantine/core";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { AppFormHandle } from "@homarr/forms-collection";
|
||||
import { AppForm } from "@homarr/forms-collection";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
export interface EmbeddedAppEditFormHandle {
|
||||
submitIfDirty: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
interface EmbeddedAppEditFormProps {
|
||||
appId: string;
|
||||
handleRef: React.Ref<EmbeddedAppEditFormHandle>;
|
||||
}
|
||||
|
||||
export const EmbeddedAppEditForm = ({ appId, handleRef }: EmbeddedAppEditFormProps) => {
|
||||
const t = useI18n();
|
||||
const utils = clientApi.useUtils();
|
||||
const appFormRef = useRef<AppFormHandle>(null);
|
||||
const { data: app, isPending: isLoadingApp, isError, refetch } = clientApi.app.byId.useQuery({ id: appId });
|
||||
|
||||
const { mutateAsync, isPending: isMutating } = clientApi.app.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.app.invalidate();
|
||||
showSuccessNotification({
|
||||
title: t("app.page.edit.notification.success.title"),
|
||||
message: t("app.page.edit.notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: t("app.page.edit.notification.error.title"),
|
||||
message: t("app.page.edit.notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useImperativeHandle(
|
||||
handleRef,
|
||||
() => ({
|
||||
submitIfDirty: async () => {
|
||||
if (!appFormRef.current?.isDirty()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return appFormRef.current.submit();
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
if (isLoadingApp) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !app) {
|
||||
return (
|
||||
<Stack align="center" gap="md" py="xl">
|
||||
<Text c="dimmed">{t("app.action.select.notFound")}</Text>
|
||||
<Button variant="light" onClick={() => void refetch()}>
|
||||
{t("common.action.tryAgain")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
|
||||
{t("item.edit.app.propagationNotice")}
|
||||
</Alert>
|
||||
<AppForm
|
||||
formRef={appFormRef}
|
||||
hideButtons
|
||||
showBackToOverview={false}
|
||||
buttonLabels={{ submit: "" }}
|
||||
initialValues={app}
|
||||
handleSubmit={async (values) => {
|
||||
await mutateAsync({ id: appId, ...values });
|
||||
}}
|
||||
isPending={isMutating}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -1,13 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { Button, Group, Stack, Tabs } from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
import { Button, Group, Stack } from "@mantine/core";
|
||||
import { schemaResolver } from "@mantine/form";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { createModal, useModalAction } from "@homarr/modals";
|
||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||
@@ -22,8 +20,6 @@ import type { OptionsBuilderResult } from "../options";
|
||||
import type { IntegrationSelectOption } from "../widget-integration-select";
|
||||
import { WidgetIntegrationSelect } from "../widget-integration-select";
|
||||
import { WidgetAdvancedOptionsModal } from "./widget-advanced-options-modal";
|
||||
import type { EmbeddedAppEditFormHandle } from "./embedded-app-edit-form";
|
||||
import { EmbeddedAppEditForm } from "./embedded-app-edit-form";
|
||||
|
||||
export interface WidgetEditModalState {
|
||||
options: Record<string, unknown>;
|
||||
@@ -38,16 +34,13 @@ interface ModalProps<TSort extends WidgetKind> {
|
||||
integrationData: IntegrationSelectOption[];
|
||||
integrationSupport: boolean;
|
||||
settings: SettingsContextProps;
|
||||
appId?: string;
|
||||
}
|
||||
|
||||
export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const { data: session } = useSession();
|
||||
const [advancedOptions, setAdvancedOptions] = useState<BoardItemAdvancedOptions>(innerProps.value.advancedOptions);
|
||||
const appEditRef = useRef<EmbeddedAppEditFormHandle>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Translate the error messages
|
||||
z.config({
|
||||
customError: zodErrorMap(t),
|
||||
});
|
||||
@@ -84,128 +77,78 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
|
||||
});
|
||||
const { openModal } = useModalAction(WidgetAdvancedOptionsModal);
|
||||
|
||||
const canModifyApps = session?.user.permissions.includes("app-modify-all") ?? false;
|
||||
const appId = innerProps.appId;
|
||||
const showAppTab = innerProps.kind === "app" && canModifyApps && Boolean(appId);
|
||||
|
||||
const handleSubmit = form.onSubmit(async (values) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const appSaved = showAppTab ? await (appEditRef.current?.submitIfDirty() ?? true) : true;
|
||||
|
||||
if (!appSaved) {
|
||||
return;
|
||||
}
|
||||
|
||||
innerProps.onSuccessfulEdit({
|
||||
...values,
|
||||
advancedOptions,
|
||||
});
|
||||
actions.closeModal();
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
});
|
||||
|
||||
const onFormSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
handleSubmit(event);
|
||||
};
|
||||
|
||||
const widgetFormContent = (
|
||||
<Stack>
|
||||
{innerProps.integrationSupport && (
|
||||
<WidgetIntegrationSelect
|
||||
label={t("item.edit.field.integrations.label")}
|
||||
data={innerProps.integrationData}
|
||||
{...form.getInputProps("integrationIds")}
|
||||
/>
|
||||
)}
|
||||
{Object.entries(options).map(([key, value]) => {
|
||||
const Input = getInputForType(value.type);
|
||||
|
||||
if (
|
||||
!Input ||
|
||||
value.shouldHide?.(
|
||||
form.values.options as never,
|
||||
innerProps.integrationData
|
||||
.filter(({ id }) => form.values.integrationIds.includes(id))
|
||||
.map(({ kind }) => kind),
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
key={key}
|
||||
kind={innerProps.kind}
|
||||
property={key}
|
||||
options={value as never}
|
||||
initialOptions={innerProps.value.options}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<Group justify="space-between">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() =>
|
||||
openModal({
|
||||
advancedOptions,
|
||||
onSuccess(options) {
|
||||
setAdvancedOptions(options);
|
||||
innerProps.onSuccessfulEdit({
|
||||
...innerProps.value,
|
||||
advancedOptions: options,
|
||||
});
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("item.edit.advancedOptions.label")}
|
||||
</Button>
|
||||
{!showAppTab && (
|
||||
<Group justify="end" w={{ base: "100%", xs: "auto" }}>
|
||||
<Button onClick={actions.closeModal} variant="subtle" color="gray">
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isSubmitting}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={onFormSubmit}>
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
innerProps.onSuccessfulEdit({
|
||||
...values,
|
||||
advancedOptions,
|
||||
});
|
||||
actions.closeModal();
|
||||
})}
|
||||
>
|
||||
<FormProvider form={form}>
|
||||
{showAppTab ? (
|
||||
<Stack>
|
||||
<Tabs defaultValue="widget">
|
||||
<Tabs.List grow>
|
||||
<Tabs.Tab value="widget">{t("item.edit.tab.widget")}</Tabs.Tab>
|
||||
<Tabs.Tab value="app">{t("item.edit.tab.app")}</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<Tabs.Panel value="widget" pt="md">
|
||||
{widgetFormContent}
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="app" pt="md">
|
||||
{appId && <EmbeddedAppEditForm appId={appId} handleRef={appEditRef} />}
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
<Group justify="end">
|
||||
<Stack>
|
||||
{innerProps.integrationSupport && (
|
||||
<WidgetIntegrationSelect
|
||||
label={t("item.edit.field.integrations.label")}
|
||||
data={innerProps.integrationData}
|
||||
{...form.getInputProps("integrationIds")}
|
||||
/>
|
||||
)}
|
||||
{Object.entries(options).map(([key, value]) => {
|
||||
const Input = getInputForType(value.type);
|
||||
|
||||
if (
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
!Input ||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
value.shouldHide?.(
|
||||
form.values.options as never,
|
||||
innerProps.integrationData
|
||||
.filter(({ id }) => form.values.integrationIds.includes(id))
|
||||
.map(({ kind }) => kind),
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
key={key}
|
||||
kind={innerProps.kind}
|
||||
property={key}
|
||||
options={value as never}
|
||||
initialOptions={innerProps.value.options}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<Group justify="space-between">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() =>
|
||||
openModal({
|
||||
advancedOptions,
|
||||
onSuccess(options) {
|
||||
setAdvancedOptions(options);
|
||||
innerProps.onSuccessfulEdit({
|
||||
...innerProps.value,
|
||||
advancedOptions: options,
|
||||
});
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("item.edit.advancedOptions.label")}
|
||||
</Button>
|
||||
<Group justify="end" w={{ base: "100%", xs: "auto" }}>
|
||||
<Button onClick={actions.closeModal} variant="subtle" color="gray">
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isSubmitting}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
<Button type="submit">{t("common.action.saveChanges")}</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : (
|
||||
widgetFormContent
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</FormProvider>
|
||||
</form>
|
||||
);
|
||||
|
||||
Generated
-3
@@ -2144,9 +2144,6 @@ importers:
|
||||
'@homarr/modals-collection':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../modals-collection
|
||||
'@homarr/notifications':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../notifications
|
||||
'@homarr/settings':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../settings
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"meierschlumpf@gmail.com": "manuel-rw",
|
||||
"thomas@ajnart.fr": "ajnart",
|
||||
"omariqbal97@gmail.com": "omariqbal",
|
||||
"kyle.grieder@gmail.com": "kylegrieder",
|
||||
"oupsman@oupsman.fr": "Oupsman",
|
||||
"pitschi@posteo.de": "pitschi",
|
||||
"github@xela.codes": "itzTheMeow",
|
||||
"febrile42@gmail.com": "febrile42",
|
||||
"nanewman01@gmail.com": "NicolasNewman"
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, readdirSync, statSync } from "node:fs";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { join, relative } from "node:path";
|
||||
|
||||
const REPO_ROOT = join(import.meta.dirname, "..");
|
||||
const CODEOWNERS_PATH = join(REPO_ROOT, "CODEOWNERS");
|
||||
const OVERRIDES_PATH = join(REPO_ROOT, "scripts/codeowners-overrides.json");
|
||||
|
||||
const BEGIN_MARKER = "# BEGIN GENERATED widget/integration owners";
|
||||
const END_MARKER = "# END GENERATED widget/integration owners";
|
||||
|
||||
const WIDGET_SKIP = new Set(["_inputs", "errors", "modals", "test"]);
|
||||
const INTEGRATION_SKIP = new Set(["base", "interfaces", "mock"]);
|
||||
|
||||
const BOT_SUBSTRINGS = ["[bot]", "renovate", "homarr-update", "dependabot", "github-actions"];
|
||||
|
||||
const WIDGET_ROUTER_ALIASES: Record<string, string> = {
|
||||
"anchor-note": "anchor-notes",
|
||||
"media-releases": "media-release",
|
||||
rssFeed: "rssFeed",
|
||||
stocks: "stocks",
|
||||
};
|
||||
|
||||
const INTEGRATION_CRON_ALIASES: Record<string, string> = {
|
||||
homeassistant: "home-assistant",
|
||||
"download-client": "downloads",
|
||||
"media-organizer": "media-organizer",
|
||||
"pi-hole": "dns-hole",
|
||||
"smart-home": "home-assistant",
|
||||
};
|
||||
|
||||
const WIDGET_TO_INTEGRATION_DIR: Record<string, string> = {
|
||||
"anchor-note": "anchor",
|
||||
};
|
||||
|
||||
const NESTED_WIDGET_ROUTER_PARENT: Record<string, string> = {
|
||||
"dns-hole": "dns-hole",
|
||||
immich: "immich",
|
||||
"media-requests": "media-requests",
|
||||
"network-controller": "network-controller",
|
||||
minecraft: "minecraft",
|
||||
"smart-home": "smart-home",
|
||||
};
|
||||
|
||||
type Overrides = Record<string, string>;
|
||||
|
||||
const isBotEmail = (email: string) => BOT_SUBSTRINGS.some((fragment) => email.toLowerCase().includes(fragment));
|
||||
|
||||
const gitEmailForPath = (repoPath: string) => {
|
||||
const strategies = [
|
||||
["log", "--grep=feat", "-i", "--format=%ae", "--reverse", "-1", "--", repoPath],
|
||||
["log", "--diff-filter=A", "--format=%ae", "--reverse", "-1", "--", repoPath],
|
||||
["log", "--format=%ae", "--reverse", "-1", "--", repoPath],
|
||||
] as const;
|
||||
|
||||
for (const args of strategies) {
|
||||
const result = spawnSync("git", args, { cwd: REPO_ROOT, encoding: "utf8" });
|
||||
const email = (result.stdout ?? "").trim();
|
||||
if (email && !isBotEmail(email)) return email;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const noreplyHandle = (email: string) => {
|
||||
const match = email.match(/^\d+\+([^@]+)@users\.noreply\.github\.com$/i);
|
||||
return match?.[1] ?? null;
|
||||
};
|
||||
|
||||
const resolveHandle = (email: string, overrides: Overrides) => {
|
||||
const fromNoreply = noreplyHandle(email);
|
||||
if (fromNoreply) return fromNoreply;
|
||||
return overrides[email] ?? null;
|
||||
};
|
||||
|
||||
const hasLeafMarker = (dirPath: string) => {
|
||||
const names = readdirSync(dirPath);
|
||||
const leafPatterns = ["-integration.ts", "component.tsx", "index.ts", "index.tsx"];
|
||||
return names.some((name) => leafPatterns.some((suffix) => name.endsWith(suffix) || name === suffix));
|
||||
};
|
||||
|
||||
const discoverLeafDirs = (base: string, skip: Set<string>) => {
|
||||
const absoluteBase = join(REPO_ROOT, base);
|
||||
const leaves: string[] = [];
|
||||
|
||||
for (const name of readdirSync(absoluteBase).sort()) {
|
||||
const dirPath = join(absoluteBase, name);
|
||||
if (!statSync(dirPath).isDirectory() || skip.has(name)) continue;
|
||||
|
||||
if (hasLeafMarker(dirPath)) {
|
||||
leaves.push(join(base, name));
|
||||
continue;
|
||||
}
|
||||
|
||||
const nestedLeaves = readdirSync(dirPath)
|
||||
.filter((sub) => statSync(join(dirPath, sub)).isDirectory())
|
||||
.filter((sub) => hasLeafMarker(join(dirPath, sub)))
|
||||
.map((sub) => join(base, name, sub));
|
||||
|
||||
leaves.push(...(nestedLeaves.length > 0 ? nestedLeaves : [join(base, name)]));
|
||||
}
|
||||
|
||||
return leaves;
|
||||
};
|
||||
|
||||
const relFromBase = (fullPath: string, base: string) => relative(join(REPO_ROOT, base), join(REPO_ROOT, fullPath));
|
||||
|
||||
const widgetRouterKey = (widgetRel: string) => {
|
||||
const direct = WIDGET_ROUTER_ALIASES[widgetRel];
|
||||
if (direct) return direct;
|
||||
|
||||
const segment = widgetRel.includes("/") ? (widgetRel.split("/")[0] ?? widgetRel) : widgetRel;
|
||||
const parentAlias = NESTED_WIDGET_ROUTER_PARENT[segment];
|
||||
const segmentAlias = WIDGET_ROUTER_ALIASES[segment];
|
||||
return parentAlias ?? segmentAlias ?? segment;
|
||||
};
|
||||
|
||||
const integrationCronKey = (integrationRel: string) => {
|
||||
const segment = integrationRel.includes("/") ? (integrationRel.split("/")[0] ?? integrationRel) : integrationRel;
|
||||
return INTEGRATION_CRON_ALIASES[segment] ?? INTEGRATION_CRON_ALIASES[integrationRel] ?? segment;
|
||||
};
|
||||
|
||||
const existingFile = (repoPath: string) => {
|
||||
const full = join(REPO_ROOT, repoPath);
|
||||
return existsSync(full) ? `/${repoPath}` : null;
|
||||
};
|
||||
|
||||
const widgetSatellites = (widgetRel: string) => {
|
||||
const routerKey = widgetRouterKey(widgetRel);
|
||||
const cronKey = integrationCronKey(widgetRel);
|
||||
return [
|
||||
existingFile(`packages/api/src/router/widgets/${routerKey}.ts`),
|
||||
existingFile(`packages/cron-jobs/src/jobs/integrations/${cronKey}.ts`),
|
||||
existingFile(`packages/cron-jobs/src/jobs/${cronKey}.ts`),
|
||||
].filter((path): path is string => path !== null);
|
||||
};
|
||||
|
||||
const integrationSatellites = (integrationRel: string) => {
|
||||
const cronKey = integrationCronKey(integrationRel);
|
||||
return [
|
||||
existingFile(`packages/cron-jobs/src/jobs/integrations/${cronKey}.ts`),
|
||||
existingFile(`packages/cron-jobs/src/jobs/${cronKey}.ts`),
|
||||
].filter((path): path is string => path !== null);
|
||||
};
|
||||
|
||||
const dirCodeownersPath = (repoDir: string) => `/${repoDir}/`;
|
||||
|
||||
const integrationRelForWidget = (widgetRel: string) => {
|
||||
const mapped = WIDGET_TO_INTEGRATION_DIR[widgetRel] ?? WIDGET_TO_INTEGRATION_DIR[widgetRel.split("/")[0] ?? ""];
|
||||
if (mapped) return mapped;
|
||||
|
||||
const direct = join("packages/integrations/src", widgetRel);
|
||||
if (existsSync(join(REPO_ROOT, direct))) return widgetRel;
|
||||
|
||||
const segment = widgetRel.split("/")[0] ?? widgetRel;
|
||||
const segmentPath = join("packages/integrations/src", segment);
|
||||
if (existsSync(join(REPO_ROOT, segmentPath))) return segment;
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const collectPathsForWidget = (widgetRel: string) => {
|
||||
const paths = new Set<string>([dirCodeownersPath(`packages/widgets/src/${widgetRel}`), ...widgetSatellites(widgetRel)]);
|
||||
|
||||
const integrationRel = integrationRelForWidget(widgetRel);
|
||||
if (integrationRel) {
|
||||
paths.add(dirCodeownersPath(`packages/integrations/src/${integrationRel}`));
|
||||
for (const satellite of integrationSatellites(integrationRel)) paths.add(satellite);
|
||||
}
|
||||
|
||||
return [...paths];
|
||||
};
|
||||
|
||||
const collectPathsForIntegration = (integrationRel: string) => {
|
||||
const paths = new Set<string>([
|
||||
dirCodeownersPath(`packages/integrations/src/${integrationRel}`),
|
||||
...integrationSatellites(integrationRel),
|
||||
]);
|
||||
|
||||
const widgetPath = join(REPO_ROOT, "packages/widgets/src", integrationRel);
|
||||
if (existsSync(widgetPath)) {
|
||||
paths.add(dirCodeownersPath(`packages/widgets/src/${integrationRel}`));
|
||||
for (const satellite of widgetSatellites(integrationRel)) paths.add(satellite);
|
||||
}
|
||||
|
||||
return [...paths];
|
||||
};
|
||||
|
||||
const loadOverrides = async () => JSON.parse(await readFile(OVERRIDES_PATH, "utf8")) as Overrides;
|
||||
|
||||
const resolveGithubUser = async (email: string, overrides: Overrides) => {
|
||||
const fromOverrides = resolveHandle(email, overrides);
|
||||
if (fromOverrides) return fromOverrides;
|
||||
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
if (!token) return null;
|
||||
|
||||
const response = await fetch(`https://api.github.com/search/users?q=${encodeURIComponent(email)}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) return null;
|
||||
|
||||
const data = (await response.json()) as { items: { login: string }[] };
|
||||
return data.items[0]?.login ?? null;
|
||||
};
|
||||
|
||||
type OwnerEntry = { primary: string; line: string };
|
||||
|
||||
const buildOwnerEntries = async (overrides: Overrides) => {
|
||||
const entries: OwnerEntry[] = [];
|
||||
const unresolved: { path: string; email: string }[] = [];
|
||||
const claimedDirs = new Set<string>();
|
||||
|
||||
const addUnit = async (repoDir: string, collectPaths: (rel: string) => string[]) => {
|
||||
if (claimedDirs.has(repoDir)) return;
|
||||
|
||||
const rel =
|
||||
repoDir.startsWith("packages/widgets/") ?
|
||||
relFromBase(repoDir, "packages/widgets/src")
|
||||
: relFromBase(repoDir, "packages/integrations/src");
|
||||
|
||||
const email = gitEmailForPath(`${repoDir}/`);
|
||||
if (!email) {
|
||||
unresolved.push({ path: repoDir, email: "" });
|
||||
return;
|
||||
}
|
||||
|
||||
const handle = await resolveGithubUser(email, overrides);
|
||||
if (!handle) {
|
||||
unresolved.push({ path: repoDir, email });
|
||||
return;
|
||||
}
|
||||
|
||||
const paths = collectPaths(rel).sort();
|
||||
const primary = paths[0] ?? `/${repoDir}/`;
|
||||
entries.push({ primary, line: `${paths.join(" ")} @${handle}` });
|
||||
|
||||
claimedDirs.add(repoDir);
|
||||
for (const path of paths) {
|
||||
if (!path.endsWith("/")) continue;
|
||||
claimedDirs.add(path.slice(1, -1));
|
||||
}
|
||||
};
|
||||
|
||||
for (const widgetDir of discoverLeafDirs("packages/widgets/src", WIDGET_SKIP)) {
|
||||
await addUnit(widgetDir, collectPathsForWidget);
|
||||
}
|
||||
|
||||
for (const integrationDir of discoverLeafDirs("packages/integrations/src", INTEGRATION_SKIP)) {
|
||||
await addUnit(integrationDir, collectPathsForIntegration);
|
||||
}
|
||||
|
||||
entries.sort((a, b) => a.primary.localeCompare(b.primary));
|
||||
return { entries, unresolved };
|
||||
};
|
||||
|
||||
const formatGeneratedBlock = (entries: OwnerEntry[]) => {
|
||||
const lines = entries.map((entry) => entry.line);
|
||||
return [BEGIN_MARKER, ...lines, END_MARKER].join("\n");
|
||||
};
|
||||
|
||||
const replaceGeneratedSection = (content: string, generatedBlock: string) => {
|
||||
const startIndex = content.indexOf(BEGIN_MARKER);
|
||||
const endIndex = content.indexOf(END_MARKER);
|
||||
|
||||
if (startIndex === -1 || endIndex === -1) {
|
||||
return `${content.trimEnd()}\n\n${generatedBlock}\n`;
|
||||
}
|
||||
|
||||
const before = content.slice(0, startIndex).trimEnd();
|
||||
const after = content.slice(endIndex + END_MARKER.length).trimStart();
|
||||
const middle = generatedBlock;
|
||||
const chunks = [before, middle, after].filter((chunk) => chunk.length > 0);
|
||||
return `${chunks.join("\n\n")}\n`;
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
const overrides = await loadOverrides();
|
||||
const { entries, unresolved } = await buildOwnerEntries(overrides);
|
||||
const generatedBlock = formatGeneratedBlock(entries);
|
||||
|
||||
const current = await readFile(CODEOWNERS_PATH, "utf8");
|
||||
const next = replaceGeneratedSection(current, generatedBlock);
|
||||
|
||||
const checkOnly = process.argv.includes("--check");
|
||||
|
||||
if (unresolved.length > 0) {
|
||||
console.warn("Unresolved ownership units (add to scripts/codeowners-overrides.json):");
|
||||
for (const entry of unresolved) console.warn(` ${entry.path} (${entry.email || "no email"})`);
|
||||
}
|
||||
|
||||
if (checkOnly) {
|
||||
if (next !== current) {
|
||||
console.error("CODEOWNERS is out of date. Run: pnpm scripts:generate-codeowners");
|
||||
process.exit(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await writeFile(CODEOWNERS_PATH, next, "utf8");
|
||||
console.log(`Updated CODEOWNERS with ${entries.length} feature units.`);
|
||||
};
|
||||
|
||||
await main();
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
echo "==> Local CODEOWNERS checks"
|
||||
pnpm exec tsx ./scripts/generate-codeowners.mts --check
|
||||
export GITHUB_REPOSITORY="${GITHUB_REPOSITORY:-homarr-labs/homarr}"
|
||||
if [ -z "${GITHUB_TOKEN:-}" ] && [ -z "${GH_TOKEN:-}" ]; then
|
||||
GITHUB_TOKEN="$(gh auth token 2>/dev/null || true)"
|
||||
export GITHUB_TOKEN
|
||||
fi
|
||||
pnpm exec tsx ./scripts/validate-codeowners.mts
|
||||
|
||||
echo "==> act: validate job (requires Docker)"
|
||||
act workflow_dispatch -W .github/workflows/update-codeowners.yml -j validate \
|
||||
-s GITHUB_TOKEN="${GITHUB_TOKEN:-}"
|
||||
@@ -0,0 +1,145 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
const REPO_ROOT = join(import.meta.dirname, "..");
|
||||
const CODEOWNERS_PATH = join(REPO_ROOT, "CODEOWNERS");
|
||||
|
||||
const BEGIN_MARKER = "# BEGIN GENERATED widget/integration owners";
|
||||
const END_MARKER = "# END GENERATED widget/integration owners";
|
||||
|
||||
const UNSUPPORTED_PATTERN_CHECKS: { label: string; pattern: RegExp }[] = [
|
||||
{ label: "negation (!)", pattern: /^\s*!/ },
|
||||
{ label: "character range ([ ])", pattern: /^\s*[^#]*\[[^\]]+\]/ },
|
||||
];
|
||||
|
||||
const parseOwners = (line: string) => [...line.matchAll(/@[\w-]+(?:\/[\w-]+)?/g)].map((match) => match[0]);
|
||||
|
||||
const parsePatterns = (line: string) => {
|
||||
const withoutOwners = line.replace(/@[\w-]+(?:\/[\w-]+)?/g, "").trim();
|
||||
return withoutOwners.split(/\s+/).filter((token) => token.length > 0 && !token.startsWith("#"));
|
||||
};
|
||||
|
||||
const validateLine = (line: string, lineNumber: number) => {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.length === 0 || trimmed.startsWith("#")) return [];
|
||||
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const check of UNSUPPORTED_PATTERN_CHECKS) {
|
||||
if (check.pattern.test(line)) {
|
||||
errors.push(`line ${lineNumber}: unsupported CODEOWNERS syntax (${check.label})`);
|
||||
}
|
||||
}
|
||||
|
||||
const owners = parseOwners(line);
|
||||
const patterns = parsePatterns(line);
|
||||
|
||||
if (patterns.length === 0 && owners.length === 0) return errors;
|
||||
|
||||
if (patterns.length > 0 && owners.length === 0) return errors;
|
||||
|
||||
if (owners.length > 0 && patterns.length === 0) {
|
||||
errors.push(`line ${lineNumber}: owners without file patterns`);
|
||||
}
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const allowedGlobal = pattern === "*";
|
||||
const hasValidShape =
|
||||
allowedGlobal || pattern.startsWith("/") || pattern.includes("*") || pattern.includes(".");
|
||||
if (!hasValidShape) {
|
||||
errors.push(`line ${lineNumber}: pattern "${pattern}" should start with / or contain a wildcard`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const owner of owners) {
|
||||
if (!owner.startsWith("@")) {
|
||||
errors.push(`line ${lineNumber}: invalid owner "${owner}"`);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
const validateStructure = (content: string) => {
|
||||
const errors: string[] = [];
|
||||
const lines = content.split("\n");
|
||||
|
||||
if (!content.includes("* @")) {
|
||||
errors.push("missing default owner rule (* @team or * @user) — last matching pattern wins per GitHub docs");
|
||||
}
|
||||
|
||||
const starIndex = lines.findIndex((line) => /^\*(\s+|$)/.test(line.trim()));
|
||||
const beginIndex = lines.findIndex((line) => line.includes(BEGIN_MARKER));
|
||||
const endIndex = lines.findIndex((line) => line.includes(END_MARKER));
|
||||
|
||||
if (beginIndex === -1 || endIndex === -1) {
|
||||
errors.push("missing generated CODEOWNERS markers");
|
||||
return errors;
|
||||
}
|
||||
|
||||
if (starIndex !== -1 && beginIndex !== -1 && starIndex > beginIndex) {
|
||||
errors.push("global * rule must appear before generated feature rules so per-folder owners override maintainers");
|
||||
}
|
||||
|
||||
if (endIndex <= beginIndex) {
|
||||
errors.push("END GENERATED marker must follow BEGIN GENERATED marker");
|
||||
}
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const lineErrors = validateLine(lines[index] ?? "", index + 1);
|
||||
errors.push(...lineErrors);
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
const validateGithubApi = async () => {
|
||||
const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
|
||||
const repository = process.env.GITHUB_REPOSITORY ?? "homarr-labs/homarr";
|
||||
|
||||
if (!token) {
|
||||
console.warn("Skipping GitHub API CODEOWNERS check (GITHUB_TOKEN not set).");
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`https://api.github.com/repos/${repository}/codeowners/errors`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub API codeowners/errors failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as { errors: unknown[] };
|
||||
const apiErrors = payload.errors ?? [];
|
||||
|
||||
if (apiErrors.length > 0) {
|
||||
console.error("GitHub reported CODEOWNERS syntax errors:");
|
||||
console.error(JSON.stringify(apiErrors, null, 2));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("GitHub API: no CODEOWNERS syntax errors.");
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
const content = await readFile(CODEOWNERS_PATH, "utf8");
|
||||
const errors = validateStructure(content);
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error("CODEOWNERS validation failed:");
|
||||
for (const error of errors) {
|
||||
console.error(` ${error}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("CODEOWNERS structure validation passed.");
|
||||
await validateGithubApi();
|
||||
};
|
||||
|
||||
await main();
|
||||
Reference in new issue
Block a user