Compare commits

..
Author SHA1 Message Date
Thomas Camlong 510c9b945d chore: validate CODEOWNERS in CI and act
Split workflow into validate (PR + act) and update (dev bot PR). Add
structure checks per GitHub docs, API error validation, and act config.
2026-06-04 15:37:31 +02:00
Thomas Camlong 2405e1b931 chore: add per-widget/integration CODEOWNERS generator
Derive owners from git history per feature folder and keep entries in sync
via a bot workflow on dev, following common monorepo automation patterns.
2026-06-04 15:24:07 +02:00
108 changed files with 1390 additions and 3243 deletions

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
-P ubuntu-latest=catthehacker/ubuntu:act-latest
--container-architecture linux/amd64
+4 -1
View File
@@ -19,7 +19,10 @@ body:
- type: textarea - type: textarea
attributes: attributes:
label: Additional information 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 - type: dropdown
attributes: attributes:
label: Public API available? label: Public API available?
+4 -1
View File
@@ -19,7 +19,10 @@ body:
- type: textarea - type: textarea
attributes: attributes:
label: Additional information 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 - type: dropdown
attributes: attributes:
label: Are you willing to contribute this yourself? label: Are you willing to contribute this yourself?
+131
View File
@@ -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
View File
@@ -11,3 +11,86 @@ pnpm-lock.yaml
pnpm-workspace.yaml pnpm-workspace.yaml
Dockerfile Dockerfile
docker-compose.yml 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
@@ -17,13 +17,10 @@ import {
import superjson from "superjson"; import superjson from "superjson";
import type { SuperJSONResult } from "superjson"; import type { SuperJSONResult } from "superjson";
import { TRPCClientError } from "@trpc/client";
import type { AppRouter } from "@homarr/api"; import type { AppRouter } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared"; import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
import { env } from "@homarr/common/env"; import { env } from "@homarr/common/env";
import { showWarningNotification } from "@homarr/notifications";
const getWebSocketProtocol = () => { const getWebSocketProtocol = () => {
// window is not defined on server side // window is not defined on server side
@@ -59,20 +56,6 @@ export function TRPCReactProvider(props: PropsWithChildren) {
queries: { queries: {
staleTime: 5 * 1000, staleTime: 5 * 1000,
}, },
mutations: {
onError(error) {
if (
error instanceof TRPCClientError &&
error.data?.code === "FORBIDDEN" &&
error.message === "Mutations are disabled in demo mode"
) {
showWarningNotification({
title: "Demo mode",
message: "This action is disabled in demo mode.",
});
}
},
},
}, },
}), }),
); );
@@ -1,14 +1,11 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { Alert, Card, Center, Code, Stack, Text, Title } from "@mantine/core"; import { Card, Center, Stack, Text, Title } from "@mantine/core";
import { IconLogin } from "@tabler/icons-react";
import { env } from "@homarr/auth/env"; import { env } from "@homarr/auth/env";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url"; import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
import { env as appEnv } from "~/env";
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo"; import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
import { LoginForm } from "./_login-form"; import { LoginForm } from "./_login-form";
@@ -40,13 +37,6 @@ export default async function Login(props: LoginProps) {
{t("subtitle")} {t("subtitle")}
</Text> </Text>
</Stack> </Stack>
{appEnv.DEMO_MODE && (
<Alert icon={<IconLogin size={18} />} color="blue" variant="light" w={64 * 6} maw="90vw">
<Text size="sm" fw={500}>
Demo mode is enabled. Sign in with username <Code>demo</Code> and password <Code>demo</Code>
</Text>
</Alert>
)}
<Card w={64 * 6} maw="90vw"> <Card w={64 * 6} maw="90vw">
<LoginForm <LoginForm
providers={env.AUTH_PROVIDERS} providers={env.AUTH_PROVIDERS}
@@ -42,9 +42,8 @@ export const BoardContentHeaderActions = () => {
const [isEditMode] = useEditMode(); const [isEditMode] = useEditMode();
const board = useRequiredBoard(); const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board); const { hasChangeAccess } = useBoardPermissions(board);
const { data: demoMode, isLoading } = clientApi.info.isDemoMode.useQuery();
if (!hasChangeAccess || isLoading) { if (!hasChangeAccess) {
return <SelectBoardsMenu />; return <SelectBoardsMenu />;
} }
@@ -52,13 +51,11 @@ export const BoardContentHeaderActions = () => {
<> <>
{isEditMode && <AddMenu />} {isEditMode && <AddMenu />}
<EditModeMenu demoMode={demoMode ?? false} /> <EditModeMenu />
{!demoMode && ( <HeaderButton href={`/boards/${board.name}/settings`}>
<HeaderButton href={`/boards/${board.name}/settings`}> <IconSettings stroke={1.5} />
<IconSettings stroke={1.5} /> </HeaderButton>
</HeaderButton>
)}
<SelectBoardsMenu /> <SelectBoardsMenu />
</> </>
@@ -144,7 +141,7 @@ const AddMenu = () => {
); );
}; };
const EditModeMenu = ({ demoMode }: { demoMode: boolean }) => { const EditModeMenu = () => {
const [isEditMode, { open, close }] = useEditMode(); const [isEditMode, { open, close }] = useEditMode();
const board = useRequiredBoard(); const board = useRequiredBoard();
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
@@ -167,18 +164,10 @@ const EditModeMenu = ({ demoMode }: { demoMode: boolean }) => {
}, },
}); });
const discardDemoChanges = useCallback(() => {
void utils.board.getBoardByName.invalidate({ name: board.name });
close();
}, [utils, board.name, close]);
const toggle = useCallback(() => { const toggle = useCallback(() => {
if (isEditMode) { if (isEditMode) return saveBoard(board);
if (demoMode) return discardDemoChanges();
return saveBoard(board);
}
open(); open();
}, [board, isEditMode, demoMode, saveBoard, open, discardDemoChanges]); }, [board, isEditMode, saveBoard, open]);
useHotkeys([[hotkeys.toggleBoardEdit, toggle]]); useHotkeys([[hotkeys.toggleBoardEdit, toggle]]);
usePreventLeaveWithDirty(isEditMode); usePreventLeaveWithDirty(isEditMode);
-1
View File
@@ -25,7 +25,6 @@ import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
import { Analytics } from "~/components/layout/analytics"; import { Analytics } from "~/components/layout/analytics";
import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation"; import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation";
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization"; import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration"; import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme"; import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
@@ -148,9 +148,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
); );
}; };
const integrationCategories = integrationDefs[searchParams.kind].category.flat(); const supportsSearchEngine = integrationDefs[searchParams.kind].category.flat().includes("search");
const supportsSearchEngine =
integrationCategories.includes("search") && !integrationCategories.includes("mediaSearch");
return ( return (
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}> <form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
@@ -96,7 +96,7 @@ const IntegrationSelectMenu = ({ children }: PropsWithChildren) => {
> >
{children} {children}
<MenuDropdown> <MenuDropdown>
<IntegrationCreateDropdownContent enableMockIntegration={env.UNSAFE_ENABLE_MOCK_INTEGRATION || env.DEMO_MODE} /> <IntegrationCreateDropdownContent enableMockIntegration={env.UNSAFE_ENABLE_MOCK_INTEGRATION} />
</MenuDropdown> </MenuDropdown>
</Menu> </Menu>
); );
@@ -85,7 +85,6 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
), ),
integrationSupport: "supportedIntegrations" in currentDefinition, integrationSupport: "supportedIntegrations" in currentDefinition,
settings, settings,
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
}, },
{ {
title(t) { title(t) {
-1
View File
@@ -3,7 +3,6 @@ import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env"
export const env = createEnv({ export const env = createEnv({
server: { server: {
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false), UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
DEMO_MODE: createBooleanSchema(false),
}, },
experimental__runtimeEnv: process.env, experimental__runtimeEnv: process.env,
}); });
-3
View File
@@ -19,9 +19,6 @@
<!-- Links --> <!-- Links -->
<p align="center"> <p align="center">
<a href="https://demo.homarr.dev">
<strong>Live Demo 🎮</strong>
</a> •
<a href="https://homarr.dev/docs/category/installation-1/"> <a href="https://homarr.dev/docs/category/installation-1/">
<strong>Install 💻</strong> <strong>Install 💻</strong>
</a> • </a> •
+2
View File
@@ -25,6 +25,8 @@
"release": "semantic-release", "release": "semantic-release",
"scripts:update-bug-report-template": "tsx ./scripts/update-bug-report-template.mts", "scripts:update-bug-report-template": "tsx ./scripts/update-bug-report-template.mts",
"scripts:update-readme-integrations": "tsx ./scripts/update-integration-list.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\"", "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": "cross-env NODE_ENV=development CI=true vitest run --exclude e2e --coverage.enabled ",
"test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e", "test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e",
+1 -3
View File
@@ -1,14 +1,12 @@
import { z } from "zod/v4"; import { z } from "zod/v4";
import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env"; import { createEnv } from "@homarr/core/infrastructure/env";
export const env = createEnv({ export const env = createEnv({
server: { server: {
KUBERNETES_SERVICE_ACCOUNT_NAME: z.string().optional(), KUBERNETES_SERVICE_ACCOUNT_NAME: z.string().optional(),
DEMO_MODE: createBooleanSchema(false),
}, },
runtimeEnv: { runtimeEnv: {
KUBERNETES_SERVICE_ACCOUNT_NAME: process.env.KUBERNETES_SERVICE_ACCOUNT_NAME, KUBERNETES_SERVICE_ACCOUNT_NAME: process.env.KUBERNETES_SERVICE_ACCOUNT_NAME,
DEMO_MODE: process.env.DEMO_MODE,
}, },
}); });
+2 -2
View File
@@ -2,11 +2,11 @@ import { TRPCError } from "@trpc/server";
import { env } from "@homarr/docker/env"; import { env } from "@homarr/docker/env";
import { isDemoMode, publicProcedure } from "../trpc"; import { publicProcedure } from "../trpc";
export const dockerMiddleware = () => { export const dockerMiddleware = () => {
return publicProcedure.use(async ({ next }) => { return publicProcedure.use(async ({ next }) => {
if (env.ENABLE_DOCKER || isDemoMode) { if (env.ENABLE_DOCKER) {
return await next(); return await next();
} }
throw new TRPCError({ throw new TRPCError({
+1 -2
View File
@@ -1,7 +1,7 @@
import z from "zod/v4"; import z from "zod/v4";
import packageJson from "../../../../package.json"; import packageJson from "../../../../package.json";
import { createTRPCRouter, isDemoMode, publicProcedure, protectedProcedure } from "../trpc"; import { createTRPCRouter, protectedProcedure } from "../trpc";
export const infoRouter = createTRPCRouter({ export const infoRouter = createTRPCRouter({
getInfo: protectedProcedure getInfo: protectedProcedure
@@ -13,5 +13,4 @@ export const infoRouter = createTRPCRouter({
version: packageJson.version, version: packageJson.version,
}; };
}), }),
isDemoMode: publicProcedure.query(() => isDemoMode),
}); });
@@ -1,8 +1,6 @@
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { hasQueryAccessToIntegrationsAsync } from "@homarr/auth/server";
import { constructIntegrationPermissions } from "@homarr/auth/shared";
import { createId, objectEntries } from "@homarr/common"; import { createId, objectEntries } from "@homarr/common";
import { decryptSecret, encryptSecret } from "@homarr/common/server"; import { decryptSecret, encryptSecret } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs"; import { createLogger } from "@homarr/core/infrastructure/logs";
@@ -16,9 +14,11 @@ import {
integrations, integrations,
integrationSecrets, integrationSecrets,
integrationUserPermissions, integrationUserPermissions,
searchEngines,
} from "@homarr/db/schema"; } from "@homarr/db/schema";
import type { IntegrationSecretKind } from "@homarr/definitions"; import type { IntegrationSecretKind } from "@homarr/definitions";
import { import {
getIconUrl,
getIntegrationKindsByCategory, getIntegrationKindsByCategory,
getPermissionsWithParents, getPermissionsWithParents,
integrationCategories, integrationCategories,
@@ -33,7 +33,6 @@ import {
integrationSavePermissionsSchema, integrationSavePermissionsSchema,
integrationUpdateSchema, integrationUpdateSchema,
} from "@homarr/validation/integration"; } from "@homarr/validation/integration";
import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/validation/widgets/media-request";
import { createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc"; 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"; import { mapTestConnectionError } from "./map-test-connection-error";
const logger = createLogger({ module: "integrationRouter" }); const logger = createLogger({ module: "integrationRouter" });
const mediaRequestSearchKinds = getIntegrationKindsByCategory("mediaSearch");
export const integrationRouter = createTRPCRouter({ export const integrationRouter = createTRPCRouter({
all: protectedProcedure.query(async ({ ctx }) => { all: protectedProcedure.query(async ({ ctx }) => {
@@ -82,7 +80,7 @@ export const integrationRouter = createTRPCRouter({
}, },
}; };
}) })
.toSorted( .sort(
(integrationA, integrationB) => (integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind), integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
); );
@@ -130,7 +128,7 @@ export const integrationRouter = createTRPCRouter({
}, },
}; };
}) })
.toSorted( .sort(
(integrationA, integrationB) => (integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind), integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
); );
@@ -181,7 +179,7 @@ export const integrationRouter = createTRPCRouter({
}, },
}; };
}) })
.toSorted( .sort(
(integrationA, integrationB) => (integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind), integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
); );
@@ -316,7 +314,20 @@ export const integrationRouter = createTRPCRouter({
url: input.url, 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 }) => { update: protectedProcedure.input(integrationUpdateSchema).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full"); await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
@@ -484,7 +495,7 @@ export const integrationRouter = createTRPCRouter({
}); });
return { return {
inherited: dbGroupPermissions.toSorted((permissionA, permissionB) => { inherited: dbGroupPermissions.sort((permissionA, permissionB) => {
return permissionA.group.name.localeCompare(permissionB.group.name); return permissionA.group.name.localeCompare(permissionB.group.name);
}), }),
users: userPermissions users: userPermissions
@@ -492,7 +503,7 @@ export const integrationRouter = createTRPCRouter({
user, user,
permission, permission,
})) }))
.toSorted((permissionA, permissionB) => { .sort((permissionA, permissionB) => {
return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? ""); return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? "");
}), }),
groups: dbGroupIntegrationPermission groups: dbGroupIntegrationPermission
@@ -503,7 +514,7 @@ export const integrationRouter = createTRPCRouter({
}, },
permission, permission,
})) }))
.toSorted((permissionA, permissionB) => { .sort((permissionA, permissionB) => {
return permissionA.group.name.localeCompare(permissionB.group.name); return permissionA.group.name.localeCompare(permissionB.group.name);
}), }),
}; };
@@ -607,149 +618,8 @@ export const integrationRouter = createTRPCRouter({
const integrationInstance = await createIntegrationAsync(ctx.integration); const integrationInstance = await createIntegrationAsync(ctx.integration);
return await integrationInstance.searchAsync(encodeURI(input.query)); 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 { interface UpdateSecretInput {
integrationId: string; integrationId: string;
value: string; value: string;
@@ -770,6 +640,35 @@ interface AddSecretInput {
kind: IntegrationSecretKind; 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) => { const addSecretAsync = async (db: Database, input: AddSecretInput) => {
await db.insert(integrationSecrets).values({ await db.insert(integrationSecrets).values({
kind: input.kind, kind: input.kind,
@@ -6,9 +6,12 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
import { asc, eq, like } from "@homarr/db"; import { asc, eq, like } from "@homarr/db";
import { getServerSettingByKeyAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries"; import { getServerSettingByKeyAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries";
import { searchEngines, users } from "@homarr/db/schema"; import { searchEngines, users } from "@homarr/db/schema";
import { createIntegrationAsync } from "@homarr/integrations";
import { byIdSchema, paginatedSchema, searchSchema } from "@homarr/validation/common"; import { byIdSchema, paginatedSchema, searchSchema } from "@homarr/validation/common";
import { searchEngineEditSchema, searchEngineManageSchema } from "@homarr/validation/search-engine"; 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"; import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "searchEngineRouter" }); const logger = createLogger({ module: "searchEngineRouter" });
@@ -145,6 +148,20 @@ export const searchEngineRouter = createTRPCRouter({
limit: input.limit, 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 create: permissionRequiredProcedure
.requiresPermission("search-engine-create") .requiresPermission("search-engine-create")
.input(searchEngineManageSchema) .input(searchEngineManageSchema)
@@ -4,7 +4,7 @@ import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth"; import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common"; import { createId } from "@homarr/common";
import { encryptSecret } from "@homarr/common/server"; 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 { createDb } from "@homarr/db/test";
import type { GroupPermissionKey } from "@homarr/definitions"; 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", () => { describe("byId should return an integration by id", () => {
test("with full access should return an integration by id", async () => { test("with full access should return an integration by id", async () => {
const db = createDb(); const db = createDb();
@@ -329,7 +208,7 @@ describe("create should create a new integration", () => {
expect(dbSecret!.updatedAt).toEqual(fakeNow); 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 db = createDb();
const caller = integrationRouter.createCaller({ const caller = integrationRouter.createCaller({
db, db,
@@ -364,7 +243,12 @@ describe("create should create a new integration", () => {
expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/); expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
expect(dbSecret!.updatedAt).toEqual(fakeNow); 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 () => { test("with create integration access should create a new integration with new linked app", async () => {
+2 -2
View File
@@ -542,7 +542,7 @@ export const userRouter = createTRPCRouter({
.set({ .set({
pingIconsEnabled: input.pingIconsEnabled, pingIconsEnabled: input.pingIconsEnabled,
}) })
.where(eq(users.id, input.id)); .where(eq(users.id, ctx.session.user.id));
}), }),
changeDdgBangs: protectedProcedure changeDdgBangs: protectedProcedure
.input(convertIntersectionToZodObject(userDdgBangsSchema.and(byIdSchema))) .input(convertIntersectionToZodObject(userDdgBangsSchema.and(byIdSchema)))
@@ -603,7 +603,7 @@ export const userRouter = createTRPCRouter({
.set({ .set({
firstDayOfWeek: input.firstDayOfWeek, firstDayOfWeek: input.firstDayOfWeek,
}) })
.where(eq(users.id, input.id)); .where(eq(users.id, ctx.session.user.id));
}), }),
}); });
+7 -17
View File
@@ -18,7 +18,6 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
import { db } from "@homarr/db"; import { db } from "@homarr/db";
import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions"; import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions";
import { env } from "./env";
import { getOnboardingOrFallbackAsync } from "./router/onboard/onboard-queries"; import { getOnboardingOrFallbackAsync } from "./router/onboard/onboard-queries";
const logger = createLogger({ module: "trpc" }); const logger = createLogger({ module: "trpc" });
@@ -88,20 +87,6 @@ export const createCallerFactory = t.createCallerFactory;
*/ */
export const createTRPCRouter = t.router; export const createTRPCRouter = t.router;
export const isDemoMode = env.DEMO_MODE;
const enforceDemoModeReadOnly = t.middleware(({ ctx, next, type }) => {
if (env.DEMO_MODE && type === "mutation") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Mutations are disabled in demo mode",
});
}
return next({ ctx });
});
const baseProcedure = env.DEMO_MODE ? t.procedure.use(enforceDemoModeReadOnly) : t.procedure;
/** /**
* Public (unauthed) procedure * Public (unauthed) procedure
* *
@@ -109,14 +94,19 @@ const baseProcedure = env.DEMO_MODE ? t.procedure.use(enforceDemoModeReadOnly) :
* tRPC API. It does not guarantee that a user querying is authorized, but you * tRPC API. It does not guarantee that a user querying is authorized, but you
* can still access user session data if they are logged in * can still access user session data if they are logged in
*/ */
export const publicProcedure = baseProcedure; export const publicProcedure = t.procedure;
/**
* Reusable middleware that enforces users are logged in before running the
* procedure
*/
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => { const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.session?.user) { if (!ctx.session?.user) {
throw new TRPCError({ code: "UNAUTHORIZED" }); throw new TRPCError({ code: "UNAUTHORIZED" });
} }
return next({ return next({
ctx: { ctx: {
// infers the `session` as non-nullable
session: { ...ctx.session, user: ctx.session.user }, session: { ...ctx.session, user: ctx.session.user },
}, },
}); });
@@ -131,7 +121,7 @@ const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
* *
* @see https://trpc.io/docs/procedures * @see https://trpc.io/docs/procedures
*/ */
export const protectedProcedure = baseProcedure.use(enforceUserIsAuthed); export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);
/** /**
* Procedure that requires a specific permission * Procedure that requires a specific permission
+5 -203
View File
@@ -1,5 +1,3 @@
import SuperJSON from "superjson";
import { createId, objectKeys } from "@homarr/common"; import { createId, objectKeys } from "@homarr/common";
import { import {
createDocumentationLink, createDocumentationLink,
@@ -10,7 +8,6 @@ import {
integrationDefs, integrationDefs,
integrationKinds, integrationKinds,
} from "@homarr/definitions"; } from "@homarr/definitions";
import type { WidgetKind } from "@homarr/definitions";
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings"; import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
import type { Database } from ".."; import type { Database } from "..";
@@ -21,17 +18,12 @@ import {
insertServerSettingByKeyAsync, insertServerSettingByKeyAsync,
updateServerSettingByKeyAsync, updateServerSettingByKeyAsync,
} from "../queries/server-setting"; } from "../queries/server-setting";
import { import {
apps,
boards, boards,
groupMembers, groupMembers,
groupPermissions, groupPermissions,
groups, groups,
integrationItems,
integrations, integrations,
itemLayouts,
items,
layouts, layouts,
onboarding, onboarding,
searchEngines, searchEngines,
@@ -47,7 +39,7 @@ export const seedDataAsync = async (db: Database) => {
await seedDefaultSearchEnginesAsync(db); await seedDefaultSearchEnginesAsync(db);
await seedDefaultIntegrationsAsync(db); await seedDefaultIntegrationsAsync(db);
if (["1", "yes", "t", "true"].includes((process.env.DEMO_MODE ?? "").toLowerCase())) { if (process.env.DEMO_MODE === "true") {
await seedDemoUserAsync(db); await seedDemoUserAsync(db);
} }
}; };
@@ -217,134 +209,6 @@ const seedDefaultIntegrationsAsync = async (db: Database) => {
console.log(`Created ${createdCount} default integrations through seeding process`); console.log(`Created ${createdCount} default integrations through seeding process`);
}; };
interface DemoWidget {
kind: WidgetKind;
width: number;
height: number;
needsIntegration: boolean;
options?: Record<string, unknown>;
}
const demoApps = [
{
name: "Sonarr",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/sonarr.svg",
href: "https://sonarr.tv",
},
{
name: "Radarr",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/radarr.svg",
href: "https://radarr.video",
},
{
name: "Plex",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/plex.svg",
href: "https://plex.tv",
},
{
name: "Jellyfin",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/jellyfin.svg",
href: "https://jellyfin.org",
},
{
name: "Proxmox",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/proxmox.svg",
href: "https://proxmox.com",
},
{
name: "Pi-hole",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/pi-hole.svg",
href: "https://pi-hole.net",
},
{
name: "Grafana",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/grafana.svg",
href: "https://grafana.com",
},
{
name: "Portainer",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/portainer.svg",
href: "https://portainer.io",
},
{
name: "Home Assistant",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/home-assistant.svg",
href: "https://home-assistant.io",
},
{
name: "Nextcloud",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/nextcloud.svg",
href: "https://nextcloud.com",
},
{
name: "qBittorrent",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/qbittorrent.svg",
href: "https://qbittorrent.org",
},
{
name: "Overseerr",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/overseerr.svg",
href: "https://overseerr.dev",
},
] as const;
const buildDemoWidgets = (appIds: string[]): DemoWidget[] => [
// Row 1: calendar + downloads + clock = 12
{ kind: "calendar", width: 5, height: 3, needsIntegration: true },
{ kind: "downloads", width: 5, height: 3, needsIntegration: true },
{ kind: "clock", width: 2, height: 1, needsIntegration: false },
// Row 2: healthMonitoring + bookmarks = 12
{ kind: "healthMonitoring", width: 6, height: 3, needsIntegration: true },
{
kind: "bookmarks",
width: 6,
height: 3,
needsIntegration: false,
options: { title: "Homelab", items: appIds, layout: "grid", openNewTab: true },
},
// Row 3: mediaServer + mediaTranscoding + dnsHoleSummary = 12
{ kind: "mediaServer", width: 3, height: 2, needsIntegration: true },
{ kind: "mediaTranscoding", width: 3, height: 2, needsIntegration: true },
{ kind: "dnsHoleSummary", width: 6, height: 2, needsIntegration: true },
// Row 4: dnsHoleControls + mediaReleases + notifications = 12
{ kind: "dnsHoleControls", width: 2, height: 1, needsIntegration: true },
{ kind: "mediaReleases", width: 4, height: 2, needsIntegration: true },
{ kind: "notifications", width: 6, height: 2, needsIntegration: true },
// Row 5: requestList + requestStats + indexerManager = 12
{ kind: "mediaRequests-requestList", width: 4, height: 3, needsIntegration: true },
{ kind: "mediaRequests-requestStats", width: 4, height: 2, needsIntegration: true },
{ kind: "indexerManager", width: 4, height: 2, needsIntegration: true },
// Row 6: networkControllerSummary + networkControllerStatus + rssFeed = 12
{ kind: "networkControllerSummary", width: 3, height: 2, needsIntegration: true },
{ kind: "networkControllerStatus", width: 3, height: 2, needsIntegration: true },
{
kind: "rssFeed",
width: 6,
height: 2,
needsIntegration: false,
options: {
feedUrls: ["https://selfh.st/rss/", "https://hnrss.org/newest?q=self-hosted"],
maximumAmountPosts: 20,
textLinesClamp: 2,
hideDescription: false,
},
},
// Row 7-8: app widgets (2x1 each, 6 per row = 12)
...appIds.map(
(appId): DemoWidget => ({
kind: "app",
width: 2,
height: 1,
needsIntegration: false,
options: { appId, openInNewTab: true, showTitle: true, pingEnabled: false },
}),
),
// Row 9: notebook + dockerContainers + weather = 12
{ kind: "notebook", width: 4, height: 4, needsIntegration: false },
{ kind: "dockerContainers", width: 6, height: 2, needsIntegration: false },
{ kind: "weather", width: 2, height: 1, needsIntegration: false },
];
const seedDemoUserAsync = async (db: Database) => { const seedDemoUserAsync = async (db: Database) => {
const existingUser = await db.query.users.findFirst({ const existingUser = await db.query.users.findFirst({
where: eq(users.name, "demo"), where: eq(users.name, "demo"),
@@ -361,7 +225,7 @@ const seedDemoUserAsync = async (db: Database) => {
id: userId, id: userId,
name: "demo", name: "demo",
email: "demo@example.com", email: "demo@example.com",
password: "$2b$10$odRXt5e95kSQV5Axmk/FeO6GVOxuRQQ8NnRcBA78Wg4V3kZxPY68u", password: "$2b$10$c5AaQb0oj32/bui5Z55PyOb7pY.iEkXYqgdD8MjS9LjSjLMeOkZu2",
}); });
const maxPosition = await getMaxGroupPositionAsync(db); const maxPosition = await getMaxGroupPositionAsync(db);
@@ -386,27 +250,6 @@ const seedDemoUserAsync = async (db: Database) => {
previousStep: "settings", previousStep: "settings",
}); });
const integrationId = createId();
await db.insert(integrations).values({
id: integrationId,
name: "Demo Integration",
url: "https://demo.homarr.dev",
kind: "mock",
appId: null,
});
const appIds: string[] = [];
for (const app of demoApps) {
const appId = createId();
appIds.push(appId);
await db.insert(apps).values({
id: appId,
name: app.name,
iconUrl: app.iconUrl,
href: app.href,
});
}
const boardId = createId(); const boardId = createId();
await db.insert(boards).values({ await db.insert(boards).values({
id: boardId, id: boardId,
@@ -414,64 +257,23 @@ const seedDemoUserAsync = async (db: Database) => {
isPublic: false, isPublic: false,
creatorId: userId, creatorId: userId,
}); });
const sectionId = createId();
await db.insert(sections).values({ await db.insert(sections).values({
id: sectionId, id: createId(),
kind: "empty", kind: "empty",
xOffset: 0, xOffset: 0,
yOffset: 0, yOffset: 0,
boardId, boardId,
}); });
const layoutId = createId();
await db.insert(layouts).values({ await db.insert(layouts).values({
id: layoutId, id: createId(),
name: "Base", name: "Base",
columnCount: 12, columnCount: 12,
breakpoint: 0, breakpoint: 0,
boardId, boardId,
}); });
const demoWidgets = buildDemoWidgets(appIds);
let xOffset = 0;
let yOffset = 0;
let rowMaxHeight = 0;
for (const widget of demoWidgets) {
if (xOffset + widget.width > 12) {
xOffset = 0;
yOffset += rowMaxHeight;
rowMaxHeight = 0;
}
rowMaxHeight = Math.max(rowMaxHeight, widget.height);
const itemId = createId();
await db.insert(items).values({
id: itemId,
boardId,
kind: widget.kind,
...(widget.options ? { options: SuperJSON.stringify(widget.options) } : {}),
});
await db.insert(itemLayouts).values({
itemId,
sectionId,
layoutId,
xOffset,
yOffset,
width: widget.width,
height: widget.height,
});
if (widget.needsIntegration) {
await db.insert(integrationItems).values({
itemId,
integrationId,
});
}
xOffset += widget.width;
}
await db.update(users).set({ homeBoardId: boardId }).where(eq(users.id, userId)); await db.update(users).set({ homeBoardId: boardId }).where(eq(users.id, userId));
console.log( console.log(
"Demo mode enabled: created demo user, mock integration, and sample board with widgets. Disable by setting DEMO_MODE=false.", "Your instance has enabled demo mode. Homarr therefore created example data for your demo. You can disable demo mode anytime by setting the env 'DEMO_MODE' to 'false' or unset it.",
); );
}; };
+34 -90
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import type { ChangeEventHandler } from "react"; 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 { Button, Checkbox, Collapse, Group, Stack, Textarea, TextInput } from "@mantine/core";
import { useDebouncedValue, useDisclosure } from "@mantine/hooks"; import { useDebouncedValue, useDisclosure } from "@mantine/hooks";
import type { z } from "zod/v4"; import type { z } from "zod/v4";
@@ -17,19 +17,6 @@ import { findBestIconMatch } from "./icon-matcher";
type FormType = z.infer<typeof appManageSchema>; 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 { interface AppFormProps {
showBackToOverview: boolean; showBackToOverview: boolean;
buttonLabels: { buttonLabels: {
@@ -37,10 +24,8 @@ interface AppFormProps {
submitAndCreateAnother?: string; submitAndCreateAnother?: string;
}; };
initialValues?: FormType; initialValues?: FormType;
handleSubmit: (values: FormType, redirect: boolean, afterSuccess?: () => void) => void | Promise<void>; handleSubmit: (values: FormType, redirect: boolean, afterSuccess?: () => void) => void;
isPending: boolean; isPending: boolean;
hideButtons?: boolean;
formRef?: React.Ref<AppFormHandle>;
} }
export const AppForm = ({ export const AppForm = ({
@@ -49,31 +34,19 @@ export const AppForm = ({
handleSubmit: originalHandleSubmit, handleSubmit: originalHandleSubmit,
initialValues, initialValues,
isPending, isPending,
hideButtons,
formRef,
}: AppFormProps) => { }: AppFormProps) => {
const t = useI18n(); const t = useI18n();
const form = useZodForm(appManageSchema, { 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 // Debounce the name value with 200ms delay
const [debouncedName] = useDebouncedValue(form.values.name, 200); const [debouncedName] = useDebouncedValue(form.values.name, 200);
@@ -89,29 +62,6 @@ export const AppForm = ({
originalHandleSubmit(values, redirect, afterSuccess); 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 [opened, { open, close }] = useDisclosure((initialValues?.pingUrl?.length ?? 0) > 0);
const handleClickDifferentUrlPing: ChangeEventHandler<HTMLInputElement> = () => { const handleClickDifferentUrlPing: ChangeEventHandler<HTMLInputElement> = () => {
@@ -142,32 +92,32 @@ export const AppForm = ({
} }
}, [debouncedName, iconsData]); }, [debouncedName, iconsData]);
const formFields = ( return (
<Stack> <form onSubmit={form.onSubmit(handleSubmit)}>
<TextInput {...form.getInputProps("name")} withAsterisk label={t("app.field.name.label")} /> <Stack>
<IconPicker {...form.getInputProps("iconUrl")} /> <TextInput {...form.getInputProps("name")} withAsterisk label={t("app.field.name.label")} />
<Textarea <IconPicker {...form.getInputProps("iconUrl")} />
{...form.getInputProps("description")} <Textarea
label={t("app.field.description.label")} {...form.getInputProps("description")}
autosize label={t("app.field.description.label")}
minRows={2} autosize
resize="vertical" minRows={2}
/> resize="vertical"
<TextInput {...form.getInputProps("href")} label={t("app.field.url.label")} /> />
<TextInput {...form.getInputProps("href")} label={t("app.field.url.label")} />
<Checkbox <Checkbox
checked={opened} checked={opened}
onChange={handleClickDifferentUrlPing} onChange={handleClickDifferentUrlPing}
label={t("app.field.useDifferentUrlForPing.checkbox.label")} label={t("app.field.useDifferentUrlForPing.checkbox.label")}
description={t("app.field.useDifferentUrlForPing.checkbox.description")} description={t("app.field.useDifferentUrlForPing.checkbox.description")}
mt="md" mt="md"
/> />
<Collapse expanded={opened}> <Collapse expanded={opened}>
<TextInput {...form.getInputProps("pingUrl")} /> <TextInput {...form.getInputProps("pingUrl")} />
</Collapse> </Collapse>
{!hideButtons && (
<Group justify="end"> <Group justify="end">
{showBackToOverview && ( {showBackToOverview && (
<Button variant="default" component={Link} href="/manage/apps"> <Button variant="default" component={Link} href="/manage/apps">
@@ -189,13 +139,7 @@ export const AppForm = ({
{buttonLabels.submit} {buttonLabels.submit}
</Button> </Button>
</Group> </Group>
)} </Stack>
</Stack> </form>
); );
if (hideButtons) {
return formFields;
}
return <form onSubmit={form.onSubmit(handleSubmit)}>{formFields}</form>;
}; };
@@ -12,14 +12,12 @@ interface SeriesInformation {
overview: string; overview: string;
seasons: SerieSeason[]; seasons: SerieSeason[];
posterPath: string; posterPath: string;
requestedSeasons?: number[];
} }
interface MovieInformation { interface MovieInformation {
id: number; id: number;
overview: string; overview: string;
posterPath: string; posterPath: string;
requestedSeasons?: number[];
} }
export type MediaInformation = SeriesInformation | MovieInformation; export type MediaInformation = SeriesInformation | MovieInformation;
+69 -191
View File
@@ -1,130 +1,24 @@
import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration"; import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration";
import type { CalendarEvent } from "../../interfaces/calendar/calendar-types"; import type { CalendarEvent } from "../../interfaces/calendar/calendar-types";
interface EventTemplate { export class CalendarMockService implements ICalendarIntegration {
title: string; public async getCalendarEventsAsync(start: Date, end: Date, _includeUnmonitored: boolean): Promise<CalendarEvent[]> {
subTitle: string; const result = [homarrMeetup(start, end), titanicRelease(start, end), seriesRelease(start, end)];
description: string; return await Promise.resolve(result);
dayOffset: number; }
hours: number;
image: CalendarEvent["image"];
indicatorColor: string;
metadata?: CalendarEvent["metadata"];
location?: string;
links: CalendarEvent["links"];
} }
const eventTemplates: EventTemplate[] = [ const homarrMeetup = (start: Date, end: Date): CalendarEvent => {
{ const startDate = randomDateBetween(start, end);
title: "The Mandalorian", const endDate = new Date(startDate.getTime() + 2 * 60 * 60 * 1000); // 2 hours later
subTitle: "Season 4", return {
description: "A lone bounty hunter in the outer reaches of the galaxy.",
dayOffset: 0,
hours: 20,
image: {
src: "https://image.tmdb.org/t/p/w300/sWgBv7LV2PRoQgkxwlibdGXKz1S.jpg",
aspectRatio: { width: 7, height: 12 },
badge: { content: "S4/E3", color: "red" },
},
indicatorColor: "blue",
links: [
{
href: "https://www.imdb.com/title/tt8111088/",
name: "IMDb",
color: "#f5c518",
isDark: false,
logo: "/images/apps/imdb.svg",
},
],
},
{
title: "Silo",
subTitle: "Season 2",
description: "In a ruined and toxic future, thousands live in a giant underground silo.",
dayOffset: 3,
hours: 21,
image: {
src: "https://image.tmdb.org/t/p/w300/2xSzAuKMhERTcPA19SpFQtVrMC0.jpg",
aspectRatio: { width: 7, height: 12 },
badge: { content: "S2/E8", color: "teal" },
},
indicatorColor: "teal",
links: [
{
href: "https://www.imdb.com/title/tt14688458/",
name: "IMDb",
color: "#f5c518",
isDark: false,
logo: "/images/apps/imdb.svg",
},
],
},
{
title: "Dune: Part Two",
subTitle: "Digital Release",
description: "Paul Atreides unites with the Fremen to seek revenge against the conspirators.",
dayOffset: 5,
hours: 0,
image: {
src: "https://image.tmdb.org/t/p/w300/8b8R8l88Qje9dn9OE8PY05Nez7.jpg",
aspectRatio: { width: 7, height: 12 },
},
metadata: { type: "radarr", releaseType: "digitalRelease" },
indicatorColor: "cyan",
links: [
{
href: "https://www.imdb.com/title/tt15239678/",
name: "IMDb",
color: "#f5c518",
isDark: false,
logo: "/images/apps/imdb.svg",
},
],
},
{
title: "Severance",
subTitle: "Season 2",
description: "Mark leads a team of office workers whose memories are surgically divided.",
dayOffset: 7,
hours: 22,
image: {
src: "https://image.tmdb.org/t/p/w300/pJAtR4gMFnalHCmT9cByPFgkZ6B.jpg",
aspectRatio: { width: 7, height: 12 },
badge: { content: "S2/E5", color: "violet" },
},
indicatorColor: "violet",
links: [],
},
{
title: "Oppenheimer",
subTitle: "Physical Release",
description: "The story of the development of the atomic bomb.",
dayOffset: 10,
hours: 0,
image: {
src: "https://image.tmdb.org/t/p/w300/8Gxv8gSFCU0XGDykEGv7zR1n2ua.jpg",
aspectRatio: { width: 7, height: 12 },
},
metadata: { type: "radarr", releaseType: "physicalRelease" },
indicatorColor: "orange",
links: [
{
href: "https://www.imdb.com/title/tt15398776/",
name: "IMDb",
color: "#f5c518",
isDark: false,
logo: "/images/apps/imdb.svg",
},
],
},
{
title: "Homarr Meetup", title: "Homarr Meetup",
subTitle: "", subTitle: "",
description: "Yearly meetup of the Homarr community", description: "Yearly meetup of the Homarr community",
dayOffset: 14, startDate,
hours: 14, endDate,
location: "Mountains",
image: null, image: null,
location: "Mountains",
indicatorColor: "#fa5252", indicatorColor: "#fa5252",
links: [ links: [
{ {
@@ -135,79 +29,63 @@ const eventTemplates: EventTemplate[] = [
isDark: true, isDark: true,
}, },
], ],
}, };
{
title: "The Bear",
subTitle: "Season 3",
description: "A young chef fights to transform a sandwich shop.",
dayOffset: 18,
hours: 3,
image: {
src: "https://image.tmdb.org/t/p/w300/sHFlYJhNbmpMOkr7JKGxFAnMPFm.jpg",
aspectRatio: { width: 7, height: 12 },
badge: { content: "S3/E1", color: "yellow" },
},
indicatorColor: "yellow",
links: [],
},
{
title: "Foundation",
subTitle: "Season 3",
description: "A band of exiles discovers that the only way to save the Galactic Empire is to defy it.",
dayOffset: 21,
hours: 20,
image: {
src: "https://image.tmdb.org/t/p/w300/wSByZTwCPVYAjlqcVCBTCLZchkC.jpg",
aspectRatio: { width: 7, height: 12 },
badge: { content: "S3/E1", color: "indigo" },
},
indicatorColor: "indigo",
links: [],
},
{
title: "Blade Runner 2099",
subTitle: "Premiere",
description: "A new chapter in the Blade Runner universe.",
dayOffset: 25,
hours: 21,
image: {
src: "https://image.tmdb.org/t/p/w300/63UMsIFOlCSz3uCPKtbgOdUr4pE.jpg",
aspectRatio: { width: 7, height: 12 },
},
metadata: { type: "radarr", releaseType: "digitalRelease" },
indicatorColor: "grape",
links: [],
},
];
const buildEventsForRange = (start: Date, end: Date): CalendarEvent[] => {
const rangeStart = new Date(start);
rangeStart.setDate(rangeStart.getDate() - 3);
return eventTemplates
.map((tpl) => {
const startDate = new Date(rangeStart);
startDate.setDate(startDate.getDate() + tpl.dayOffset);
startDate.setHours(tpl.hours, 0, 0, 0);
return {
title: tpl.title,
subTitle: tpl.subTitle,
description: tpl.description,
startDate,
endDate: tpl.location ? new Date(startDate.getTime() + 2 * 60 * 60 * 1000) : null,
image: tpl.image,
location: tpl.location ?? null,
indicatorColor: tpl.indicatorColor,
links: tpl.links,
...(tpl.metadata ? { metadata: tpl.metadata } : {}),
};
})
.filter((event) => event.startDate >= start && event.startDate <= end);
}; };
export class CalendarMockService implements ICalendarIntegration { const titanicRelease = (start: Date, end: Date): CalendarEvent => ({
public async getCalendarEventsAsync(start: Date, end: Date, _includeUnmonitored: boolean): Promise<CalendarEvent[]> { title: "Titanic",
return await Promise.resolve(buildEventsForRange(start, end)); subTitle: "A classic movie",
} description: "A tragic love story set on the ill-fated RMS Titanic.",
startDate: randomDateBetween(start, end),
endDate: null,
image: {
src: "https://image.tmdb.org/t/p/original/5bTWA20cL9LCIGNpde4Epc2Ijzn.jpg",
aspectRatio: { width: 7, height: 12 },
},
location: null,
metadata: {
type: "radarr",
releaseType: "inCinemas",
},
indicatorColor: "cyan",
links: [
{
href: "https://www.imdb.com/title/tt0120338/",
name: "IMDb",
color: "#f5c518",
isDark: false,
logo: "/images/apps/imdb.svg",
},
],
});
const seriesRelease = (start: Date, end: Date): CalendarEvent => ({
title: "The Mandalorian",
subTitle: "A Star Wars Series",
description: "A lone bounty hunter in the outer reaches of the galaxy.",
startDate: randomDateBetween(start, end),
endDate: null,
image: {
src: "https://image.tmdb.org/t/p/original/sWgBv7LV2PRoQgkxwlibdGXKz1S.jpg",
aspectRatio: { width: 7, height: 12 },
badge: {
content: "S1/E1",
color: "red",
},
},
location: null,
indicatorColor: "blue",
links: [
{
href: "https://www.imdb.com/title/tt8111088/",
name: "IMDb",
color: "#f5c518",
isDark: false,
logo: "/images/apps/imdb.svg",
},
],
});
function randomDateBetween(start: Date, end: Date): Date {
return new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime()));
} }
+29 -135
View File
@@ -2,163 +2,57 @@ import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/dow
import type { IDownloadClientIntegration } from "../../interfaces/downloads/download-client-integration"; import type { IDownloadClientIntegration } from "../../interfaces/downloads/download-client-integration";
import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items"; import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items";
const mockItems: Omit<DownloadClientItem, "id" | "index">[] = [
{
name: "Big.Buck.Bunny.2008.1080p.BluRay.x264",
type: "torrent",
size: 4_700_000_000,
progress: 0.47,
state: "leeching",
downSpeed: 12_400_000,
upSpeed: 890_000,
sent: 420_000_000,
received: 2_209_000_000,
time: 201_000,
category: "movies",
},
{
name: "Ubuntu.24.04.1.LTS.Desktop.amd64.iso",
type: "torrent",
size: 5_800_000_000,
progress: 0.82,
state: "leeching",
downSpeed: 34_500_000,
upSpeed: 2_100_000,
sent: 1_200_000_000,
received: 4_756_000_000,
time: 30_000,
category: "iso",
},
{
name: "Sintel.2010.4K.DTS-HD.MA.5.1.mkv",
type: "torrent",
size: 18_200_000_000,
progress: 0.03,
state: "leeching",
downSpeed: 1_200_000,
upSpeed: 50_000,
sent: 30_000_000,
received: 546_000_000,
time: 14_880_000,
category: "movies",
},
{
name: "Arch.Linux.2024.12.01.x86_64.iso",
type: "torrent",
size: 1_100_000_000,
progress: 1,
state: "seeding",
downSpeed: 0,
upSpeed: 4_500_000,
sent: 3_300_000_000,
received: 1_100_000_000,
time: -7_200_000,
category: "iso",
},
{
name: "Tears.of.Steel.2012.1080p.DTS.mkv",
type: "torrent",
size: 7_340_000_000,
progress: 0,
state: "paused",
downSpeed: 0,
upSpeed: 0,
time: 0,
category: "movies",
},
{
name: "Cosmos.Laundromat.First.Cycle.2015.4K.mkv",
type: "torrent",
size: 12_000_000_000,
progress: 0.65,
state: "stalled",
downSpeed: 0,
upSpeed: 0,
sent: 200_000_000,
received: 7_800_000_000,
time: 0,
category: "movies",
},
{
name: "Spring.2019.4K.HDR.mkv",
type: "usenet",
size: 3_200_000_000,
progress: 0.91,
state: "downloading",
downSpeed: 45_000_000,
time: 6_500,
category: "movies",
},
{
name: "Agent.327.Operation.Barbershop.2017.1080p.mkv",
type: "usenet",
size: 1_800_000_000,
progress: 1,
state: "completed",
time: -3_600_000,
category: "movies",
},
{
name: "Caminandes.3.Llamigos.2016.1080p.mkv",
type: "torrent",
size: 890_000_000,
progress: 1,
state: "seeding",
downSpeed: 0,
upSpeed: 1_800_000,
sent: 2_670_000_000,
received: 890_000_000,
time: -86_400_000,
category: "movies",
},
{
name: "Hero.2024.S01.1080p.WEB-DL.DDP5.1",
type: "usenet",
size: 9_400_000_000,
progress: 0.22,
state: "downloading",
downSpeed: 28_000_000,
time: 244_000,
category: "tv",
},
];
export class DownloadClientMockService implements IDownloadClientIntegration { export class DownloadClientMockService implements IDownloadClientIntegration {
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> { public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
return await Promise.resolve({ return await Promise.resolve({
status: { status: {
paused: false, paused: Math.random() < 0.5,
rates: { rates: {
down: 121_100_000, down: Math.floor(Math.random() * 5000),
up: 9_340_000, up: Math.floor(Math.random() * 5000),
}, },
types: ["torrent", "usenet"] as const, types: ["torrent", "usenet"],
}, },
items: mockItems.slice(0, input.limit).map((item, index) => ({ items: Array.from({ length: 20 }, (_, index) => DownloadClientMockService.createItem(index)).slice(
...item, 0,
id: `mock-${index}`, input.limit,
index, ),
})),
}); });
} }
public async pauseQueueAsync(): Promise<void> { public async pauseQueueAsync(): Promise<void> {
await Promise.resolve(); return await Promise.resolve();
} }
public async pauseItemAsync(_item: DownloadClientItem): Promise<void> { public async pauseItemAsync(_item: DownloadClientItem): Promise<void> {
await Promise.resolve(); return await Promise.resolve();
} }
public async resumeQueueAsync(): Promise<void> { public async resumeQueueAsync(): Promise<void> {
await Promise.resolve(); return Promise.resolve();
} }
public async resumeItemAsync(_item: DownloadClientItem): Promise<void> { public async resumeItemAsync(_item: DownloadClientItem): Promise<void> {
await Promise.resolve(); return await Promise.resolve();
} }
public async deleteItemAsync(_item: DownloadClientItem, _fromDisk: boolean): Promise<void> { public async deleteItemAsync(_item: DownloadClientItem, _fromDisk: boolean): Promise<void> {
await Promise.resolve(); return await Promise.resolve();
}
private static createItem(index: number): DownloadClientItem {
const progress = Math.random() < 0.5 ? Math.random() : 1;
return {
id: `item-${index}`,
index,
name: `Item ${index}`,
type: Math.random() > 0.5 ? "torrent" : "usenet",
progress,
size: Math.floor(Math.random() * 10000) + 1,
downSpeed: Math.floor(Math.random() * 5000),
upSpeed: Math.floor(Math.random() * 5000),
state: progress >= 1 ? "completed" : "downloading",
time: 0,
};
} }
} }
@@ -13,17 +13,17 @@ export const mockMediaReleases: MediaRelease[] = [
title: "Inception", title: "Inception",
subtitle: "A mind-bending thriller", subtitle: "A mind-bending thriller",
description: description:
"A thief who steals corporate secrets through dream-sharing technology is given the task of planting an idea into the mind of a CEO.", "A thief who steals corporate secrets through the use of dream-sharing technology is given the inverse task of planting an idea into the mind of a CEO.",
releaseDate: new Date("2010-07-16"), releaseDate: new Date("2010-07-16"),
imageUrls: { imageUrls: {
poster: "https://image.tmdb.org/t/p/w300/ljsZTbVsrQSqZgWeep2B1QiDKuh.jpg", poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
backdrop: "https://image.tmdb.org/t/p/w780/8ZTVqvKDQ8emSGUEMjsS4yHAwrp.jpg", backdrop: "https://example.com/inception_backdrop.jpg",
}, },
producer: "Warner Bros.", producer: "Warner Bros.",
price: 14.99, price: 14.99,
rating: "8.8/10", rating: "8.8/10",
tags: ["Sci-Fi", "Thriller"], tags: ["Sci-Fi", "Thriller"],
href: "https://www.themoviedb.org/movie/27205-inception", href: "https://example.com/inception",
length: 148, length: 148,
}, },
{ {
@@ -34,13 +34,13 @@ export const mockMediaReleases: MediaRelease[] = [
description: "When Walter White's secret is revealed, he must face the consequences of his actions.", description: "When Walter White's secret is revealed, he must face the consequences of his actions.",
releaseDate: new Date("2013-09-15"), releaseDate: new Date("2013-09-15"),
imageUrls: { imageUrls: {
poster: "https://image.tmdb.org/t/p/w300/ztkUQFLlC19CCMYHW73GM9hBNRh.jpg", poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
backdrop: "https://image.tmdb.org/t/p/w780/tsRy63Mu5cu8etL1X7ZLyf7UP1M.jpg", backdrop: "https://example.com/breaking_bad_backdrop.jpg",
}, },
producer: "AMC", producer: "AMC",
rating: "9.5/10", rating: "9.5/10",
tags: ["Crime", "Drama"], tags: ["Crime", "Drama"],
href: "https://www.themoviedb.org/tv/1396-breaking-bad", href: "https://example.com/breaking_bad",
}, },
{ {
id: "3", id: "3",
@@ -50,86 +50,79 @@ export const mockMediaReleases: MediaRelease[] = [
description: "The fourth studio album by French electronic music duo Daft Punk.", description: "The fourth studio album by French electronic music duo Daft Punk.",
releaseDate: new Date("2013-05-17"), releaseDate: new Date("2013-05-17"),
imageUrls: { imageUrls: {
poster: "https://image.tmdb.org/t/p/w300/mIfBmEBqA1ddSMFyMAvACvOQ6cL.jpg", poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
backdrop: "https://image.tmdb.org/t/p/w780/mIfBmEBqA1ddSMFyMAvACvOQ6cL.jpg", backdrop: "https://example.com/ram_backdrop.jpg",
}, },
producer: "Columbia Records", producer: "Columbia Records",
price: 9.99, price: 9.99,
rating: "8.5/10", rating: "8.5/10",
tags: ["Electronic", "Funk"], tags: ["Electronic", "Dance", "Pop", "Funk"],
href: "https://www.daftpunk.com", href: "https://example.com/ram",
}, },
{ {
id: "4", id: "4",
type: "movie", type: "book",
title: "The Dark Knight", title: "The Great Gatsby",
subtitle: "Theatrical Release", subtitle: "F. Scott Fitzgerald",
description: description: "A novel about the American dream and the disillusionment that comes with it.",
"Batman raises the stakes in his war on crime. With the help of Lt. Gordon and DA Harvey Dent, Batman sets out to dismantle organized crime.", releaseDate: new Date("1925-04-10"),
releaseDate: new Date("2008-07-18"),
imageUrls: { imageUrls: {
poster: "https://image.tmdb.org/t/p/w300/qJ2tW6WMUDux911BTUgMe1nV8Is.jpg", poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
backdrop: "https://image.tmdb.org/t/p/w780/nMKdUUepR0i5zn0y1T4CsSB5ez.jpg", backdrop: "https://example.com/gatsby_backdrop.jpg",
}, },
producer: "Warner Bros.", producer: "Scribner",
price: 12.99, price: 10.99,
rating: "9.0/10", rating: "4.2/5",
tags: ["Action", "Crime", "Drama"], tags: ["Classic", "Fiction"],
href: "https://www.themoviedb.org/movie/155-the-dark-knight", href: "https://example.com/gatsby",
length: 152,
}, },
{ {
id: "5", id: "5",
type: "movie", type: "game",
title: "Interstellar", title: "The Legend of Zelda: Breath of the Wild",
subtitle: "Digital Release", subtitle: "Nintendo Switch",
description: "A team of explorers travel through a wormhole in space in an attempt to ensure humanity's survival.", description: "An open-world action-adventure game set in the fantasy land of Hyrule.",
releaseDate: new Date("2014-11-07"), releaseDate: new Date("2017-03-03"),
imageUrls: { imageUrls: {
poster: "https://image.tmdb.org/t/p/w300/gEU2QniE6E77NI6lCU6MxlNBvIx.jpg", poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
backdrop: "https://image.tmdb.org/t/p/w780/xJHokMbljXjADYdit5fK1DVfjko.jpg", backdrop: "https://example.com/zelda_backdrop.jpg",
}, },
producer: "Paramount", producer: "Nintendo",
price: 14.99, price: 59.99,
rating: "8.7/10", rating: "10/10",
tags: ["Sci-Fi", "Adventure"], tags: ["Action", "Adventure"],
href: "https://www.themoviedb.org/movie/157336-interstellar", href: "https://example.com/zelda",
length: 169,
}, },
{ {
id: "6", id: "6",
type: "tv", type: "article",
title: "Severance", title: "The Rise of AI in Healthcare",
subtitle: "S2E1 - Premiere", subtitle: "Tech Innovations",
description: description: "Exploring the impact of artificial intelligence on the healthcare industry.",
"Mark leads a team of office workers whose memories are surgically divided between work and personal lives.", releaseDate: new Date("2023-10-01"),
releaseDate: new Date("2025-01-17"),
imageUrls: { imageUrls: {
poster: "https://image.tmdb.org/t/p/w300/pJAtR4gMFnalHCmT9cByPFgkZ6B.jpg", poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
backdrop: "https://image.tmdb.org/t/p/w780/gvaAaDUbF41Z1NLuJsHPRHkv1Ie.jpg", backdrop: "https://example.com/ai_healthcare_backdrop.jpg",
}, },
producer: "Apple TV+", producer: "Tech Innovations",
rating: "8.7/10", rating: "4.8/5",
tags: ["Thriller", "Sci-Fi"], tags: ["Technology", "Healthcare"],
href: "https://www.themoviedb.org/tv/95396-severance", href: "https://example.com/ai_healthcare",
}, },
{ {
id: "7", id: "7",
type: "movie", type: "video",
title: "Dune: Part Two", title: "Wir LIEBEN unsere MAMAS | 50 Fragen zu Mamas",
subtitle: "Theatrical Release", releaseDate: new Date("2024-05-18T17:00:00Z"),
description:
"Paul Atreides unites with the Fremen to seek revenge against the conspirators who destroyed his family.",
releaseDate: new Date("2024-03-01"),
imageUrls: { imageUrls: {
poster: "https://image.tmdb.org/t/p/w300/8b8R8l88Qje9dn9OE8PY05Nez7.jpg", poster:
backdrop: "https://image.tmdb.org/t/p/w780/xOMo8BRK7PfcJv9JCnx7s5hj0PX.jpg", "https://i.ytimg.com/vi/a3qyfXc1Pfg/hq720.jpg?sqp=-oaymwEnCNAFEJQDSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLBQKm0viRlfRjTV-V24vGO83rPaVw",
backdrop:
"https://i.ytimg.com/vi/a3qyfXc1Pfg/hq720.jpg?sqp=-oaymwEnCNAFEJQDSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLBQKm0viRlfRjTV-V24vGO83rPaVw",
}, },
producer: "Warner Bros.", producer: "PietSmiet",
price: 19.99, rating: "1K",
rating: "8.5/10", tags: [],
tags: ["Sci-Fi", "Adventure"], href: "https://www.youtube.com/watch?v=a3qyfXc1Pfg",
href: "https://www.themoviedb.org/movie/693134-dune-part-two",
length: 166,
}, },
]; ];
@@ -1,95 +1,36 @@
import type { IMediaServerIntegration } from "../../interfaces/media-server/media-server-integration"; import type { IMediaServerIntegration } from "../../interfaces/media-server/media-server-integration";
import type { CurrentSessionsInput, StreamSession } from "../../interfaces/media-server/media-server-types"; import type { CurrentSessionsInput, StreamSession } from "../../interfaces/media-server/media-server-types";
const sessions: StreamSession[] = [
{
sessionId: "s-0",
sessionName: "Living Room TV",
user: { userId: "u-0", username: "thomas", profilePictureUrl: null },
currentlyPlaying: {
type: "movie",
name: "Interstellar",
seasonName: undefined,
episodeName: null,
albumName: null,
episodeCount: null,
metadata: null,
},
},
{
sessionId: "s-1",
sessionName: "Bedroom Chromecast",
user: { userId: "u-1", username: "sarah", profilePictureUrl: null },
currentlyPlaying: {
type: "video",
name: "Breaking Bad",
seasonName: "Season 5",
episodeName: "Ozymandias",
albumName: null,
episodeCount: null,
metadata: null,
},
},
{
sessionId: "s-2",
sessionName: "Office Desktop",
user: { userId: "u-2", username: "mike", profilePictureUrl: null },
currentlyPlaying: {
type: "movie",
name: "The Dark Knight",
seasonName: undefined,
episodeName: null,
albumName: null,
episodeCount: null,
metadata: null,
},
},
{
sessionId: "s-3",
sessionName: "iPad",
user: { userId: "u-3", username: "emily", profilePictureUrl: null },
currentlyPlaying: {
type: "video",
name: "Severance",
seasonName: "Season 2",
episodeName: "The We We Are",
albumName: null,
episodeCount: null,
metadata: null,
},
},
{
sessionId: "s-4",
sessionName: "Mobile",
user: { userId: "u-4", username: "alex", profilePictureUrl: null },
currentlyPlaying: {
type: "audio",
name: "Daft Punk",
seasonName: undefined,
episodeName: null,
albumName: "Random Access Memories",
episodeCount: null,
metadata: null,
},
},
{
sessionId: "s-5",
sessionName: "Guest Room",
user: { userId: "u-5", username: "guest", profilePictureUrl: null },
currentlyPlaying: {
type: "movie",
name: "Oppenheimer",
seasonName: undefined,
episodeName: null,
albumName: null,
episodeCount: null,
metadata: null,
},
},
];
export class MediaServerMockService implements IMediaServerIntegration { export class MediaServerMockService implements IMediaServerIntegration {
public async getCurrentSessionsAsync(_options: CurrentSessionsInput): Promise<StreamSession[]> { public async getCurrentSessionsAsync(options: CurrentSessionsInput): Promise<StreamSession[]> {
return await Promise.resolve(sessions); return await Promise.resolve(
Array.from({ length: 10 }, (_, index) => MediaServerMockService.createSession(index)).filter(
(session) => !options.showOnlyPlaying || session.currentlyPlaying !== null,
),
);
}
private static createSession(index: number): StreamSession {
return {
sessionId: `session-${index}`,
sessionName: `Session ${index}`,
user: {
userId: `user-${index}`,
username: `User${index}`,
profilePictureUrl: "/images/mock/avatar.jpg",
},
currentlyPlaying:
Math.random() > 0.9 // 10% chance of being null (not currently playing)
? {
type: "movie",
name: `Movie ${index}`,
seasonName: undefined,
episodeName: null,
albumName: null,
episodeCount: null,
metadata: null,
}
: null,
};
} }
} }
@@ -4,11 +4,27 @@ import type { NetworkControllerSummary } from "../../types";
export class NetworkControllerSummaryMockService implements NetworkControllerSummaryIntegration { export class NetworkControllerSummaryMockService implements NetworkControllerSummaryIntegration {
public async getNetworkSummaryAsync(): Promise<NetworkControllerSummary> { public async getNetworkSummaryAsync(): Promise<NetworkControllerSummary> {
return await Promise.resolve({ return await Promise.resolve({
lan: { guests: 5, users: 18, status: "enabled" }, lan: {
vpn: { users: 3, status: "enabled" }, guests: 5,
wanStatus: "enabled", users: 10,
wifi: { status: "enabled", guests: 2, users: 12 }, status: "enabled",
www: { latency: 22, status: "enabled", ping: 14, uptime: 864000 }, },
vpn: {
users: 3,
status: "enabled",
},
wanStatus: "disabled",
wifi: {
status: "disabled",
guests: 0,
users: 0,
},
www: {
latency: 22,
status: "enabled",
ping: 32,
uptime: 3600,
},
}); });
} }
} }
@@ -85,7 +85,7 @@ export class MockIntegration
// Health Monitoring Integrations // Health Monitoring Integrations
getSystemInfoAsync = MockIntegration.systemMonitoring.getSystemInfoAsync.bind(MockIntegration.systemMonitoring); getSystemInfoAsync = MockIntegration.systemMonitoring.getSystemInfoAsync.bind(MockIntegration.systemMonitoring);
getClusterInfoAsync = MockIntegration.clusterMonitoring.getClusterInfoAsync.bind(MockIntegration.clusterMonitoring); getClusterInfoAsync = MockIntegration.clusterMonitoring.getClusterInfoAsync.bind(MockIntegration.downloadClient);
// IndexerManagerIntegration // IndexerManagerIntegration
getIndexersAsync = MockIntegration.indexerManager.getIndexersAsync.bind(MockIntegration.indexerManager); getIndexersAsync = MockIntegration.indexerManager.getIndexersAsync.bind(MockIntegration.indexerManager);
@@ -104,7 +104,7 @@ export class MockIntegration
declineRequestAsync = MockIntegration.mediaRequest.declineRequestAsync.bind(MockIntegration.mediaRequest); declineRequestAsync = MockIntegration.mediaRequest.declineRequestAsync.bind(MockIntegration.mediaRequest);
// MediaServerIntegration // MediaServerIntegration
getCurrentSessionsAsync = MockIntegration.mediaServer.getCurrentSessionsAsync.bind(MockIntegration.mediaServer); getCurrentSessionsAsync = MockIntegration.mediaServer.getCurrentSessionsAsync.bind(MockIntegration.mediaRequest);
// MediaTranscodingIntegration // MediaTranscodingIntegration
getStatisticsAsync = MockIntegration.mediaTranscoding.getStatisticsAsync.bind(MockIntegration.mediaTranscoding); getStatisticsAsync = MockIntegration.mediaTranscoding.getStatisticsAsync.bind(MockIntegration.mediaTranscoding);
@@ -60,9 +60,6 @@ export class OverseerrIntegration
text: "overview" in result ? result.overview : undefined, text: "overview" in result ? result.overview : undefined,
type: result.mediaType, type: result.mediaType,
inLibrary: result.mediaInfo !== undefined, 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"), "X-Api-Key": this.getSecretValue("apiKey"),
}, },
}); });
const data = await mediaInformationSchema.parseAsync(await response.json()); return 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 };
} }
/** /**
@@ -349,22 +341,6 @@ interface MovieInformation {
releaseDate: string; 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([ const mediaInformationSchema = z.union([
z.object({ z.object({
id: z.number(), id: z.number(),
@@ -379,22 +355,14 @@ const mediaInformationSchema = z.union([
), ),
numberOfSeasons: z.number(), numberOfSeasons: z.number(),
posterPath: z.string().startsWith("/"), posterPath: z.string().startsWith("/"),
mediaInfo: mediaInfoRequestsSchema,
}), }),
z.object({ z.object({
id: z.number(), id: z.number(),
overview: z.string(), overview: z.string(),
posterPath: z.string().startsWith("/"), posterPath: z.string().startsWith("/"),
mediaInfo: mediaInfoRequestsSchema,
}), }),
]); ]);
const searchMediaInfoSchema = z
.object({
status: z.number(),
})
.optional();
const searchSchema = z.object({ const searchSchema = z.object({
results: z results: z
.array( .array(
@@ -405,7 +373,7 @@ const searchSchema = z.object({
name: z.string(), name: z.string(),
posterPath: z.string().startsWith("/").endsWith(".jpg").nullable(), posterPath: z.string().startsWith("/").endsWith(".jpg").nullable(),
overview: z.string(), overview: z.string(),
mediaInfo: searchMediaInfoSchema, mediaInfo: z.object({}).optional(),
}), }),
z.object({ z.object({
id: z.number(), id: z.number(),
@@ -413,14 +381,14 @@ const searchSchema = z.object({
title: z.string(), title: z.string(),
posterPath: z.string().startsWith("/").endsWith(".jpg").nullable(), posterPath: z.string().startsWith("/").endsWith(".jpg").nullable(),
overview: z.string(), overview: z.string(),
mediaInfo: searchMediaInfoSchema, mediaInfo: z.object({}).optional(),
}), }),
z.object({ z.object({
id: z.number(), id: z.number(),
mediaType: z.literal("person"), mediaType: z.literal("person"),
name: z.string(), name: z.string(),
profilePath: z.string().startsWith("/").endsWith(".jpg").nullable(), profilePath: z.string().startsWith("/").endsWith(".jpg").nullable(),
mediaInfo: searchMediaInfoSchema, mediaInfo: z.object({}).optional(),
}), }),
]), ]),
) )
@@ -1,6 +1,6 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { Badge, Button, Group, Image, LoadingOverlay, Stack, Text } from "@mantine/core"; import { Button, Group, Image, LoadingOverlay, Stack, Text } from "@mantine/core";
import type { MRT_ColumnDef, MRT_Row } from "mantine-react-table"; import type { MRT_ColumnDef } from "mantine-react-table";
import { MRT_Table } from "mantine-react-table"; import { MRT_Table } from "mantine-react-table";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
@@ -16,13 +16,13 @@ interface RequestMediaModalProps {
} }
export const RequestMediaModal = createModal<RequestMediaModalProps>(({ actions, innerProps }) => { 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, integrationId: innerProps.integrationId,
mediaId: innerProps.mediaId, mediaId: innerProps.mediaId,
mediaType: innerProps.mediaType, mediaType: innerProps.mediaType,
}); });
const { mutate, isPending: isPendingMutation } = clientApi.integration.requestMedia.useMutation({ const { mutate, isPending: isPendingMutation } = clientApi.searchEngine.requestMedia.useMutation({
onSuccess() { onSuccess() {
actions.closeModal(); actions.closeModal();
showSuccessNotification({ showSuccessNotification({
@@ -33,7 +33,6 @@ export const RequestMediaModal = createModal<RequestMediaModalProps>(({ actions,
const isPending = isPendingQuery || isPendingMutation; const isPending = isPendingQuery || isPendingMutation;
const t = useI18n(); const t = useI18n();
const requestedSeasons = new Set(data?.requestedSeasons ?? []);
const columns = useMemo<MRT_ColumnDef<Season>[]>( const columns = useMemo<MRT_ColumnDef<Season>[]>(
() => [ () => [
@@ -45,18 +44,8 @@ export const RequestMediaModal = createModal<RequestMediaModalProps>(({ actions,
accessorKey: "episodeCount", accessorKey: "episodeCount",
header: t("search.engine.media.request.modal.table.header.episodes"), 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({ const table = useTranslatedMantineReactTable({
@@ -67,7 +56,7 @@ export const RequestMediaModal = createModal<RequestMediaModalProps>(({ actions,
enablePagination: false, enablePagination: false,
enableSorting: false, enableSorting: false,
enableSelectAll: true, enableSelectAll: true,
enableRowSelection: (row: MRT_Row<Season>) => !requestedSeasons.has(row.original.seasonNumber), enableRowSelection: true,
mantineTableProps: { mantineTableProps: {
highlightOnHover: false, highlightOnHover: false,
striped: "odd", striped: "odd",
+1 -8
View File
@@ -1,6 +1,6 @@
import type { NotificationData } from "@mantine/notifications"; import type { NotificationData } from "@mantine/notifications";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { IconCheck, IconInfoCircle, IconX } from "@tabler/icons-react"; import { IconCheck, IconX } from "@tabler/icons-react";
export const showSuccessNotification = (props: NotificationData) => export const showSuccessNotification = (props: NotificationData) =>
notifications.show({ notifications.show({
@@ -15,10 +15,3 @@ export const showErrorNotification = (props: NotificationData) =>
color: "red", color: "red",
icon: <IconX size={20} />, icon: <IconX size={20} />,
}); });
export const showWarningNotification = (props: NotificationData) =>
notifications.show({
...props,
color: "yellow",
icon: <IconInfoCircle size={20} />,
});
@@ -44,7 +44,6 @@ const optionMapping: OptionMapping = {
hideIcon: (oldOptions) => oldOptions.items.some((item) => item.hideIcon), hideIcon: (oldOptions) => oldOptions.items.some((item) => item.hideIcon),
hideHostname: (oldOptions) => oldOptions.items.some((item) => item.hideHostname), hideHostname: (oldOptions) => oldOptions.items.some((item) => item.hideHostname),
openNewTab: (oldOptions) => oldOptions.items.some((item) => item.openNewTab), openNewTab: (oldOptions) => oldOptions.items.some((item) => item.openNewTab),
withBorder: () => undefined,
}, },
calendar: { calendar: {
releaseType: (oldOptions) => [oldOptions.radarrReleaseType], releaseType: (oldOptions) => [oldOptions.radarrReleaseType],
+1 -158
View File
@@ -7,174 +7,17 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { db, like, or } from "@homarr/db"; import { db, like, or } from "@homarr/db";
import { icons } from "@homarr/db/schema"; import { icons } from "@homarr/db/schema";
import type { ContainerState, Port } from "@homarr/docker"; import type { ContainerState } from "@homarr/docker";
import { dockerLabels, DockerSingleton } from "@homarr/docker"; import { dockerLabels, DockerSingleton } from "@homarr/docker";
import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler"; import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler";
const logger = createLogger({ module: "dockerRequestHandler" }); const logger = createLogger({ module: "dockerRequestHandler" });
const isDemoMode = ["1", "yes", "t", "true"].includes((process.env.DEMO_MODE ?? "").toLowerCase());
const port = (privatePort: number, publicPort: number, type: string): Port => ({
IP: "0.0.0.0",
PrivatePort: privatePort,
PublicPort: publicPort,
Type: type,
});
const mockContainers: {
id: string;
name: string;
host: string;
state: ContainerState;
image: string;
iconUrl: string;
cpuUsage: number;
memoryUsage: number;
ports: Port[];
}[] = [
{
id: "a1b2c3d4e5f6",
name: "sonarr",
host: "local",
state: "running",
image: "lscr.io/linuxserver/sonarr:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/sonarr.svg",
cpuUsage: 2.3,
memoryUsage: 256 * 1024 * 1024,
ports: [port(8989, 8989, "tcp")],
},
{
id: "b2c3d4e5f6a7",
name: "radarr",
host: "local",
state: "running",
image: "lscr.io/linuxserver/radarr:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/radarr.svg",
cpuUsage: 1.8,
memoryUsage: 220 * 1024 * 1024,
ports: [port(7878, 7878, "tcp")],
},
{
id: "c3d4e5f6a7b8",
name: "plex",
host: "local",
state: "running",
image: "lscr.io/linuxserver/plex:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/plex.svg",
cpuUsage: 12.5,
memoryUsage: 1024 * 1024 * 1024,
ports: [port(32400, 32400, "tcp")],
},
{
id: "d4e5f6a7b8c9",
name: "qbittorrent",
host: "local",
state: "running",
image: "lscr.io/linuxserver/qbittorrent:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/qbittorrent.svg",
cpuUsage: 5.1,
memoryUsage: 380 * 1024 * 1024,
ports: [port(8080, 8080, "tcp")],
},
{
id: "e5f6a7b8c9d0",
name: "prowlarr",
host: "local",
state: "running",
image: "lscr.io/linuxserver/prowlarr:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/prowlarr.svg",
cpuUsage: 0.8,
memoryUsage: 120 * 1024 * 1024,
ports: [port(9696, 9696, "tcp")],
},
{
id: "f6a7b8c9d0e1",
name: "overseerr",
host: "local",
state: "running",
image: "lscr.io/linuxserver/overseerr:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/overseerr.svg",
cpuUsage: 1.2,
memoryUsage: 180 * 1024 * 1024,
ports: [port(5055, 5055, "tcp")],
},
{
id: "a7b8c9d0e1f2",
name: "homarr",
host: "local",
state: "running",
image: "ghcr.io/homarr-labs/homarr:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg",
cpuUsage: 3.4,
memoryUsage: 290 * 1024 * 1024,
ports: [port(7575, 7575, "tcp")],
},
{
id: "b8c9d0e1f2a3",
name: "pihole",
host: "local",
state: "running",
image: "pihole/pihole:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/pi-hole.svg",
cpuUsage: 0.5,
memoryUsage: 95 * 1024 * 1024,
ports: [port(80, 80, "tcp"), port(53, 53, "udp")],
},
{
id: "c9d0e1f2a3b4",
name: "nginx-proxy",
host: "local",
state: "running",
image: "jc21/nginx-proxy-manager:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/nginx-proxy-manager.svg",
cpuUsage: 0.3,
memoryUsage: 65 * 1024 * 1024,
ports: [port(443, 443, "tcp"), port(81, 81, "tcp")],
},
{
id: "d0e1f2a3b4c5",
name: "watchtower",
host: "local",
state: "running",
image: "containrrr/watchtower:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/watchtower.svg",
cpuUsage: 0.1,
memoryUsage: 30 * 1024 * 1024,
ports: [],
},
{
id: "e1f2a3b4c5d6",
name: "tdarr",
host: "local",
state: "exited",
image: "ghcr.io/haveagitgat/tdarr:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/tdarr.svg",
cpuUsage: 0,
memoryUsage: 0,
ports: [],
},
{
id: "f2a3b4c5d6e7",
name: "bazarr",
host: "local",
state: "running",
image: "lscr.io/linuxserver/bazarr:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/bazarr.svg",
cpuUsage: 0.6,
memoryUsage: 110 * 1024 * 1024,
ports: [port(6767, 6767, "tcp")],
},
];
export const dockerContainersRequestHandler = createCachedWidgetRequestHandler({ export const dockerContainersRequestHandler = createCachedWidgetRequestHandler({
queryKey: "dockerContainersResult", queryKey: "dockerContainersResult",
widgetKind: "dockerContainers", widgetKind: "dockerContainers",
async requestAsync() { async requestAsync() {
if (isDemoMode) {
return mockContainers;
}
return await getContainersWithStatsAsync(); return await getContainersWithStatsAsync();
}, },
cacheDuration: dayjs.duration(20, "seconds"), cacheDuration: dayjs.duration(20, "seconds"),
-1
View File
@@ -1,2 +1 @@
export * from "./src/context"; 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);
-1
View File
@@ -29,7 +29,6 @@
"@homarr/integrations": "workspace:^0.1.0", "@homarr/integrations": "workspace:^0.1.0",
"@homarr/modals": "workspace:^0.1.0", "@homarr/modals": "workspace:^0.1.0",
"@homarr/modals-collection": "workspace:^0.1.0", "@homarr/modals-collection": "workspace:^0.1.0",
"@homarr/notifications": "workspace:^0.1.0",
"@homarr/settings": "workspace:^0.1.0", "@homarr/settings": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
@@ -33,10 +33,7 @@ export const ChildrenActionItem = ({ childrenOptions, action, query, setChildren
<Spotlight.Action <Spotlight.Action
renderRoot={renderRoot} renderRoot={renderRoot}
onClick={onClick} onClick={onClick}
closeSpotlightOnTrigger={ closeSpotlightOnTrigger={interaction.type !== "children"}
interaction.type !== "children" &&
(interaction.type !== "javaScript" || interaction.closeSpotlightOnTrigger !== false)
}
className={classes.spotlightAction} className={classes.spotlightAction}
> >
<action.Component {...childrenOptions.option} /> <action.Component {...childrenOptions.option} />
@@ -44,9 +44,6 @@ export const SpotlightGroupActionItem = <TOption extends Record<string, unknown>
setQuery(interaction.query); setQuery(interaction.query);
setTimeout(() => selectAction(0, spotlightStore)); setTimeout(() => selectAction(0, spotlightStore));
} else if (interaction.type === "mode") { } else if (interaction.type === "mode") {
if (interaction.query !== undefined) {
setQuery(interaction.query);
}
setMode(interaction.mode); setMode(interaction.mode);
} else if (interaction.type === "children") { } else if (interaction.type === "children") {
setChildrenOptions(interaction); setChildrenOptions(interaction);
@@ -61,8 +58,7 @@ export const SpotlightGroupActionItem = <TOption extends Record<string, unknown>
interaction.type !== "mode" && interaction.type !== "mode" &&
interaction.type !== "children" && interaction.type !== "children" &&
interaction.type !== "none" && interaction.type !== "none" &&
interaction.type !== "setQuery" && interaction.type !== "setQuery"
(interaction.type !== "javaScript" || interaction.closeSpotlightOnTrigger !== false)
} }
className={classes.spotlightAction} className={classes.spotlightAction}
> >
+33 -100
View File
@@ -1,33 +1,24 @@
"use client"; "use client";
import type { Dispatch, SetStateAction } from "react"; 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 { ActionIcon, Center, Group, Kbd } from "@mantine/core";
import { Spotlight as MantineSpotlight } from "@mantine/spotlight"; import { Spotlight as MantineSpotlight } from "@mantine/spotlight";
import { IconArrowLeft, IconSearch, IconX } from "@tabler/icons-react"; import { IconSearch, IconX } from "@tabler/icons-react";
import { useSetAtom } from "jotai";
import { hotkeys } from "@homarr/definitions"; import { hotkeys } from "@homarr/definitions";
import type { TranslationObject } from "@homarr/translation"; import type { TranslationObject } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import type { OpenMediaRequestSearchOptions } from "../index";
import type { inferSearchInteractionOptions } from "../lib/interaction"; import type { inferSearchInteractionOptions } from "../lib/interaction";
import type { SearchMode } from "../lib/mode"; import type { SearchMode } from "../lib/mode";
import { searchModes } from "../modes"; import { searchModes } from "../modes";
import { useHomeEmptyGroupsWithPreferences } from "../modes/help/home-empty-groups"; import { useHomeEmptyGroups } from "../modes/help/home-empty-groups";
import { import { selectAction, spotlightStore } from "../spotlight-store";
mediaRequestSearchEvent,
mediaRequestSearchScopeAtom,
selectAction,
spotlightActions,
spotlightStore,
} from "../spotlight-store";
import { SpotlightChildrenActions } from "./actions/children-actions"; import { SpotlightChildrenActions } from "./actions/children-actions";
import { SpotlightActionGroups } from "./actions/groups/action-group"; import { SpotlightActionGroups } from "./actions/groups/action-group";
type SearchModeKey = keyof TranslationObject["search"]["mode"]; type SearchModeKey = keyof TranslationObject["search"]["mode"];
type ChildrenOptions = inferSearchInteractionOptions<"children">;
const defaultMode = "home"; const defaultMode = "home";
export const Spotlight = () => { export const Spotlight = () => {
@@ -40,6 +31,7 @@ export const Spotlight = () => {
return null; return null;
} }
// We use the "key" below to prevent the 'Different amounts of hooks' error
return ( return (
<SpotlightWithActiveMode key={mode} modeState={searchModeState} queryState={queryState} activeMode={activeMode} /> <SpotlightWithActiveMode key={mode} modeState={searchModeState} queryState={queryState} activeMode={activeMode} />
); );
@@ -54,57 +46,13 @@ interface SpotlightWithActiveModeProps {
const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: SpotlightWithActiveModeProps) => { const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: SpotlightWithActiveModeProps) => {
const [query, setQuery] = queryState; const [query, setQuery] = queryState;
const [mode, setMode] = modeState; const [mode, setMode] = modeState;
const [childrenStack, setChildrenStack] = useState<ChildrenOptions[]>([]); const [childrenOptions, setChildrenOptions] = useState<inferSearchInteractionOptions<"children"> | null>(null);
const childrenOptions = childrenStack.at(-1) ?? null;
const t = useI18n(); const t = useI18n();
const inputRef = useRef<HTMLInputElement>(null); 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 useGroups = "groups" in activeMode ? () => activeMode.groups : activeMode.useGroups;
const groups = useGroups(); const groups = useGroups();
const homeEmptyGroups = useHomeEmptyGroupsWithPreferences(); const homeEmptyGroups = useHomeEmptyGroups();
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]);
return ( return (
<MantineSpotlight.Root <MantineSpotlight.Root
@@ -113,7 +61,7 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
overlayProps={{ blur: 2, backgroundOpacity: 0.2 }} overlayProps={{ blur: 2, backgroundOpacity: 0.2 }}
onSpotlightClose={() => { onSpotlightClose={() => {
setMode(defaultMode); setMode(defaultMode);
clearChildrenStack(); setChildrenOptions(null);
setQuery(""); setQuery("");
}} }}
query={query} query={query}
@@ -121,14 +69,17 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
const sanitizedQuery = mode === "external" && nextQuery.startsWith("!") ? nextQuery.slice(1) : nextQuery; const sanitizedQuery = mode === "external" && nextQuery.startsWith("!") ? nextQuery.slice(1) : nextQuery;
setQuery(sanitizedQuery); setQuery(sanitizedQuery);
// Only switch modes when a single trigger character is typed
if (sanitizedQuery.length !== 1) return; if (sanitizedQuery.length !== 1) return;
const modeToActivate = searchModes.find((searchMode) => searchMode.character === sanitizedQuery); const modeToActivate = searchModes.find((mode) => mode.character === sanitizedQuery);
if (!modeToActivate) return; if (!modeToActivate) return;
setMode(modeToActivate.modeKey); setMode(modeToActivate.modeKey);
clearChildrenStack(); setChildrenOptions(null);
setQuery(""); setQuery("");
setTimeout(() => selectAction(0, spotlightStore)); setTimeout(() => selectAction(0, spotlightStore));
}} }}
store={spotlightStore} store={spotlightStore}
@@ -136,13 +87,13 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
<MantineSpotlight.Search <MantineSpotlight.Search
placeholder={`${t("search.placeholder")}...`} placeholder={`${t("search.placeholder")}...`}
ref={inputRef} ref={inputRef}
leftSectionWidth={hasModeCharacter ? 80 : 48} leftSectionWidth={activeMode.modeKey !== defaultMode ? 80 : 48}
leftSection={ leftSection={
<Group align="center" wrap="nowrap" gap="xs" w="100%" h="100%"> <Group align="center" wrap="nowrap" gap="xs" w="100%" h="100%">
<Center w={48} h="100%"> <Center w={48} h="100%">
<IconSearch stroke={1.5} /> <IconSearch stroke={1.5} />
</Center> </Center>
{hasModeCharacter ? <Kbd size="sm">{activeMode.character}</Kbd> : null} {activeMode.modeKey !== defaultMode ? <Kbd size="sm">{activeMode.character}</Kbd> : null}
</Group> </Group>
} }
styles={{ styles={{
@@ -151,53 +102,31 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
}, },
}} }}
rightSection={ rightSection={
mode === defaultMode && childrenStack.length === 0 ? null : ( mode === defaultMode ? null : (
<ActionIcon <ActionIcon
title={
childrenStack.length > 0
? t("search.mode.command.group.preferences.children.detail.backAction")
: t("common.action.close")
}
onClick={() => { onClick={() => {
if (childrenStack.length > 0) {
popChildrenOptions();
inputRef.current?.focus();
return;
}
setMode(defaultMode); setMode(defaultMode);
clearChildrenStack(); setChildrenOptions(null);
inputRef.current?.focus(); inputRef.current?.focus();
}} }}
variant="subtle" variant="subtle"
aria-label={ aria-label={t("common.action.close")}
childrenStack.length > 0
? t("search.mode.command.group.preferences.children.detail.backAction")
: t("common.action.close")
}
> >
{childrenStack.length > 0 ? <IconArrowLeft stroke={1.5} /> : <IconX stroke={1.5} />} <IconX stroke={1.5} />
</ActionIcon> </ActionIcon>
) )
} }
value={query} value={query}
onKeyDown={(event) => { onKeyDown={(event) => {
if (query.length !== 0 || event.key !== "Backspace") return; if (query.length === 0 && mode !== defaultMode && event.key === "Backspace") {
if (childrenStack.length > 0) {
popChildrenOptions();
return;
}
if (mode !== defaultMode) {
setMode(defaultMode); setMode(defaultMode);
clearChildrenStack(); setChildrenOptions(null);
} }
}} }}
/> />
{childrenOptions ? ( {childrenOptions ? (
<Group key={childrenStack.length}> <Group>
<childrenOptions.DetailComponent options={childrenOptions.option as never} /> <childrenOptions.DetailComponent options={childrenOptions.option as never} />
</Group> </Group>
) : null} ) : null}
@@ -205,21 +134,25 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
<MantineSpotlight.ActionsList> <MantineSpotlight.ActionsList>
{childrenOptions ? ( {childrenOptions ? (
<SpotlightChildrenActions <SpotlightChildrenActions
key={childrenStack.length}
childrenOptions={childrenOptions} childrenOptions={childrenOptions}
query={query} query={query}
setChildrenOptions={pushChildrenOptions} setChildrenOptions={setChildrenOptions}
/> />
) : ( ) : (
<SpotlightActionGroups <SpotlightActionGroups
setQuery={setQuery} setQuery={setQuery}
setMode={(nextMode) => { setMode={(mode) => {
setMode(nextMode); setMode(mode);
clearChildrenStack(); setChildrenOptions(null);
setTimeout(() => selectAction(0, spotlightStore)); setTimeout(() => selectAction(0, spotlightStore));
}} }}
setChildrenOptions={(options) => { setChildrenOptions={(options) => {
pushChildrenOptions(options); setChildrenOptions(options);
setTimeout(() => {
setQuery("");
selectAction(0, spotlightStore);
});
}} }}
query={query} query={query}
groups={mode === defaultMode && query.length === 0 ? [...homeEmptyGroups] : groups} groups={mode === defaultMode && query.length === 0 ? [...homeEmptyGroups] : groups}
+2 -17
View File
@@ -1,9 +1,9 @@
"use client"; "use client";
import { mediaRequestSearchEvent, spotlightActions } from "./spotlight-store"; import { spotlightActions } from "./spotlight-store";
export { Spotlight } from "./components/spotlight"; export { Spotlight } from "./components/spotlight";
export { openSpotlight, openMediaRequestSearch }; export { openSpotlight };
export { export {
SpotlightProvider, SpotlightProvider,
useRegisterSpotlightContextResults, useRegisterSpotlightContextResults,
@@ -11,18 +11,3 @@ export {
} from "./modes/home/context"; } from "./modes/home/context";
const openSpotlight = spotlightActions.open; 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 };
+2 -5
View File
@@ -10,12 +10,9 @@ const createSearchInteraction = <TType extends string>(type: TType) => ({
// This is used to define search interactions with their options // This is used to define search interactions with their options
const searchInteractions = [ const searchInteractions = [
createSearchInteraction("link").optionsType<{ href: string; newTab?: boolean }>(), createSearchInteraction("link").optionsType<{ href: string; newTab?: boolean }>(),
createSearchInteraction("javaScript").optionsType<{ createSearchInteraction("javaScript").optionsType<{ onSelect: () => MaybePromise<void> }>(),
onSelect: () => MaybePromise<void>;
closeSpotlightOnTrigger?: boolean;
}>(),
createSearchInteraction("setQuery").optionsType<{ query: string }>(), 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<{ createSearchInteraction("children").optionsType<{
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
useActions: CreateChildrenOptionsProps<any>["useActions"]; 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 { 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 { LanguageIcon } from "@homarr/ui";
import { createChildrenOptions } from "../../../lib/children"; import { createChildrenOptions } from "../../../lib/children";
import { createCheckmarkPreferenceAction, PreferenceDetailHeader } from "./preferences/action-row";
export const languageChildrenOptions = createChildrenOptions<Record<string, unknown>>({ export const languageChildrenOptions = createChildrenOptions<Record<string, unknown>>({
useActions: (_, query) => { useActions: (_, query) => {
const normalizedQuery = query.trim().toLowerCase(); const normalizedQuery = query.trim().toLowerCase();
const { value: currentLocale, setValue: setLocale, isPending } = useUserPreference("locale"); const currentLocale = useCurrentLocale();
return supportedLanguages return supportedLanguages
.map((localeKey) => ({ localeKey, configuration: localeConfigurations[localeKey] })) .map((localeKey) => ({ localeKey, configuration: localeConfigurations[localeKey] }))
.filter( .filter(
@@ -19,7 +18,7 @@ export const languageChildrenOptions = createChildrenOptions<Record<string, unkn
configuration.name.toLowerCase().includes(normalizedQuery) || configuration.name.toLowerCase().includes(normalizedQuery) ||
configuration.translatedName.toLowerCase().includes(normalizedQuery), configuration.translatedName.toLowerCase().includes(normalizedQuery),
) )
.toSorted( .sort(
(languageA, languageB) => (languageA, languageB) =>
Math.min( Math.min(
languageA.configuration.name.toLowerCase().indexOf(normalizedQuery), languageA.configuration.name.toLowerCase().indexOf(normalizedQuery),
@@ -30,25 +29,38 @@ export const languageChildrenOptions = createChildrenOptions<Record<string, unkn
languageB.configuration.translatedName.toLowerCase().indexOf(normalizedQuery), languageB.configuration.translatedName.toLowerCase().indexOf(normalizedQuery),
), ),
) )
.map(({ localeKey, configuration }) => .map(({ localeKey, configuration }) => ({
createCheckmarkPreferenceAction({ key: localeKey,
key: localeKey, Component() {
isSelected: localeKey === currentLocale, return (
onSelect: () => setLocale(localeKey as never), <Group mx="md" my="sm" wrap="nowrap" justify="space-between" w="100%">
isPending, <Group wrap="nowrap">
labelContent: ( <LanguageIcon icon={localeConfigurations[localeKey].icon} />
<Group wrap="nowrap"> <Group wrap="nowrap" gap="xs">
<LanguageIcon icon={localeConfigurations[localeKey].icon} /> <Text>{configuration.name}</Text>
<Group wrap="nowrap" gap="xs"> <Text size="xs" c="dimmed" inherit>
<Text>{configuration.name}</Text> ({configuration.translatedName})
<Text size="xs" c="dimmed" inherit> </Text>
({configuration.translatedName}) </Group>
</Text>
</Group> </Group>
{localeKey === currentLocale && <IconCheck color="currentColor" size={24} />}
</Group> </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 { import {
IconBox, IconBox,
IconCategoryPlus, IconCategoryPlus,
IconDeviceDesktop,
IconFileImport, IconFileImport,
IconLanguage,
IconMailForward, IconMailForward,
IconMoon,
IconPlug, IconPlug,
IconSun,
IconUserPlus, IconUserPlus,
IconUsersGroup, IconUsersGroup,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
@@ -18,6 +22,7 @@ import type { TablerIcon } from "@homarr/ui";
import { createGroup } from "../../lib/group"; import { createGroup } from "../../lib/group";
import type { inferSearchInteractionDefinition, SearchInteraction } from "../../lib/interaction"; import type { inferSearchInteractionDefinition, SearchInteraction } from "../../lib/interaction";
import { interaction } from "../../lib/interaction"; import { interaction } from "../../lib/interaction";
import { languageChildrenOptions } from "./children/language";
import { newIntegrationChildrenOptions } from "./children/new-integration"; import { newIntegrationChildrenOptions } from "./children/new-integration";
// This has to be type so it can be interpreted as Record<string, unknown>. // This has to be type so it can be interpreted as Record<string, unknown>.
@@ -47,9 +52,33 @@ export const globalCommandGroup = createGroup<Command>({
}, },
useOptions() { useOptions() {
const tOption = useScopedI18n("search.mode.command.group.globalCommand.option"); const tOption = useScopedI18n("search.mode.command.group.globalCommand.option");
const { colorScheme } = useMantineColorScheme();
const computedColorScheme = useComputedColorScheme("light");
const { data: session } = useSession(); const { data: session } = useSession();
const commands: (Command & { hidden?: boolean })[] = [ 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", commandKey: "newBoard",
icon: IconCategoryPlus, icon: IconCategoryPlus,
@@ -1,10 +1,9 @@
import type { SearchMode } from "../../lib/mode"; import type { SearchMode } from "../../lib/mode";
import { contextSpecificActionsSearchGroups } from "./context-specific-group"; import { contextSpecificActionsSearchGroups } from "./context-specific-group";
import { globalCommandGroup } from "./global-group"; import { globalCommandGroup } from "./global-group";
import { preferencesGroup } from "../preferences/groups";
export const commandMode = { export const commandMode = {
modeKey: "command", modeKey: "command",
character: ">", character: ">",
groups: [contextSpecificActionsSearchGroups, preferencesGroup, globalCommandGroup], groups: [contextSpecificActionsSearchGroups, globalCommandGroup],
} satisfies SearchMode; } satisfies SearchMode;
@@ -64,36 +64,6 @@ type MediaRequestChildrenProps = {
kind: IntegrationKind; kind: IntegrationKind;
url: string; url: string;
id: 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, searchEngine: SearchEngine,
searchResult: FromIntegrationSearchResult, searchResult: FromIntegrationSearchResult,
): inferSearchInteractionDefinition<"link" | "javaScript" | "children"> => { ): inferSearchInteractionDefinition<"link" | "javaScript" | "children"> => {
const { openSearchInNewTab } = useSettings();
if (searchEngine.type !== "fromIntegration") { if (searchEngine.type !== "fromIntegration") {
throw new Error("Invalid search engine type"); throw new Error("Invalid search engine type");
} }
@@ -115,7 +87,25 @@ export const useFromIntegrationSearchInteraction = (
) && ) &&
"type" in searchResult "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 { return {
@@ -125,15 +115,13 @@ export const useFromIntegrationSearchInteraction = (
}; };
}; };
export const mediaRequestsChildrenOptions = createChildrenOptions<MediaRequestChildrenProps>({ const mediaRequestsChildrenOptions = createChildrenOptions<MediaRequestChildrenProps>({
useActions() { useActions() {
const { openModal } = useModalAction(RequestMediaModal); const { openModal } = useModalAction(RequestMediaModal);
return [ return [
{ {
key: "request", key: "request",
hide: (option) => hide: (option) => option.result.inLibrary,
(option.result.type === "movie" && option.result.inLibrary) ||
option.integration.permissions?.hasInteractAccess === false,
Component(option) { Component(option) {
const t = useScopedI18n("search.mode.media"); const t = useScopedI18n("search.mode.media");
return ( return (
@@ -19,13 +19,12 @@ import type { SearchMode } from "../../lib/mode";
import { appIntegrationBoardMode } from "../app-integration-board"; import { appIntegrationBoardMode } from "../app-integration-board";
import { commandMode } from "../command"; import { commandMode } from "../command";
import { externalMode } from "../external"; import { externalMode } from "../external";
import { mediaMode } from "../media";
import { pageMode } from "../page"; import { pageMode } from "../page";
import { preferencesGroup } from "../preferences/groups";
import { userGroupMode } from "../user-group"; 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 = { type QuickLinkOption = {
key: string;
icon: TablerIcon; icon: TablerIcon;
name: string; name: string;
path: string; path: string;
@@ -34,7 +33,7 @@ type QuickLinkOption = {
export const useHomeEmptyGroups = () => { export const useHomeEmptyGroups = () => {
const { data: session } = useSession(); const { data: session } = useSession();
const tPages = useScopedI18n("search.mode.page.group.page.option"); 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")) { if (session?.user.permissions.includes("admin")) {
visibleSearchModes.unshift(userGroupMode); visibleSearchModes.unshift(userGroupMode);
@@ -42,13 +41,19 @@ export const useHomeEmptyGroups = () => {
return [ return [
createGroup({ createGroup({
keyPath: "key", keyPath: "path",
title: () => "Quick links", title: () => "Quick links",
options: (() => { options: (() => {
const quickLinks: QuickLinkOption[] = []; const quickLinks: QuickLinkOption[] = [];
if (session?.user) {
quickLinks.push({
icon: IconSettings,
path: `/manage/users/${session.user.id}/general`,
name: tPages("preferences.label"),
});
}
quickLinks.push({ quickLinks.push({
key: "manageBoards",
icon: IconLayoutDashboard, icon: IconLayoutDashboard,
path: "/manage/boards", path: "/manage/boards",
name: tPages("manageBoard.label"), name: tPages("manageBoard.label"),
@@ -56,7 +61,6 @@ export const useHomeEmptyGroups = () => {
if (session?.user.permissions.includes("admin")) { if (session?.user.permissions.includes("admin")) {
quickLinks.push({ quickLinks.push({
key: "manageSettings",
icon: IconSettings, icon: IconSettings,
path: "/manage/settings", path: "/manage/settings",
name: tPages("manageSettings.label"), name: tPages("manageSettings.label"),
@@ -65,7 +69,6 @@ export const useHomeEmptyGroups = () => {
if (session) { if (session) {
quickLinks.push({ quickLinks.push({
key: "manageSearchEngines",
icon: IconSearch, icon: IconSearch,
path: "/manage/search-engines", path: "/manage/search-engines",
name: tPages("manageSearchEngine.label"), name: tPages("manageSearchEngine.label"),
@@ -84,7 +87,7 @@ export const useHomeEmptyGroups = () => {
useInteraction: interaction.link(({ path }) => ({ href: path })), useInteraction: interaction.link(({ path }) => ({ href: path })),
}), }),
createGroup({ createGroup({
keyPath: "modeKey", keyPath: "character",
title: (t) => t("search.mode.help.group.mode.title"), title: (t) => t("search.mode.help.group.mode.title"),
options: visibleSearchModes.map(({ character, modeKey }) => ({ character, modeKey })), options: visibleSearchModes.map(({ character, modeKey }) => ({ character, modeKey })),
Component: ({ modeKey, character }) => { Component: ({ modeKey, character }) => {
@@ -93,7 +96,7 @@ export const useHomeEmptyGroups = () => {
return ( return (
<Group px="md" py="xs" w="100%" wrap="nowrap" align="center" justify="space-between"> <Group px="md" py="xs" w="100%" wrap="nowrap" align="center" justify="space-between">
<Text>{t("help")}</Text> <Text>{t("help")}</Text>
{character && <Kbd size="sm">{character}</Kbd>} <Kbd size="sm">{character}</Kbd>
</Group> </Group>
); );
}, },
@@ -135,5 +138,3 @@ export const useHomeEmptyGroups = () => {
}), }),
] as const; ] as const;
}; };
export const useHomeEmptyGroupsWithPreferences = () => [preferencesGroup, ...useHomeEmptyGroups()];
@@ -20,7 +20,6 @@ type GroupItem = {
name: string; name: string;
description?: string; description?: string;
icon: TablerIcon | string; icon: TablerIcon | string;
disabled?: boolean;
useInteraction: (query: string) => inferSearchInteractionDefinition<SearchInteraction>; useInteraction: (query: string) => inferSearchInteractionDefinition<SearchInteraction>;
}; };
@@ -38,7 +37,7 @@ export const homeSearchEngineGroup = createGroup<GroupItem>({
); );
return ( 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} {icon}
<Stack gap={0}> <Stack gap={0}>
<Text>{item.name}</Text> <Text>{item.name}</Text>
@@ -92,7 +91,9 @@ const createDefaultSearchEntries = (
query: string, query: string,
t: TranslationFunction, t: TranslationFunction,
): GroupItem[] => { ): GroupItem[] => {
if (!session?.user && !defaultSearchEngine) return []; if (!session?.user && !defaultSearchEngine) {
return [];
}
if (!defaultSearchEngine) { if (!defaultSearchEngine) {
return [ return [
+1 -2
View File
@@ -1,10 +1,9 @@
import type { SearchMode } from "../../lib/mode"; import type { SearchMode } from "../../lib/mode";
import { preferencesGroup } from "../preferences/groups";
import { contextSpecificSearchGroups } from "./context-specific-group"; import { contextSpecificSearchGroups } from "./context-specific-group";
import { homeSearchEngineGroup } from "./home-search-engine-group"; import { homeSearchEngineGroup } from "./home-search-engine-group";
export const homeMode = { export const homeMode = {
character: undefined, character: undefined,
modeKey: "home", modeKey: "home",
groups: [preferencesGroup, homeSearchEngineGroup, contextSpecificSearchGroups], groups: [homeSearchEngineGroup, contextSpecificSearchGroups],
} satisfies SearchMode; } satisfies SearchMode;
-2
View File
@@ -2,7 +2,6 @@ import { appIntegrationBoardMode } from "./app-integration-board";
import { commandMode } from "./command"; import { commandMode } from "./command";
import { externalMode } from "./external"; import { externalMode } from "./external";
import { homeMode } from "./home"; import { homeMode } from "./home";
import { mediaMode } from "./media";
import { pageMode } from "./page"; import { pageMode } from "./page";
import { userGroupMode } from "./user-group"; import { userGroupMode } from "./user-group";
@@ -12,6 +11,5 @@ export const searchModes = [
externalMode, externalMode,
commandMode, commandMode,
pageMode, pageMode,
mediaMode,
homeMode, homeMode,
] as const; ] 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 { clamp } from "@mantine/hooks";
import type { SpotlightStore } from "@mantine/spotlight"; import type { SpotlightStore } from "@mantine/spotlight";
import { createSpotlight } from "@mantine/spotlight"; import { createSpotlight } from "@mantine/spotlight";
import { atom } from "jotai";
export const [spotlightStore, spotlightActions] = createSpotlight(); 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) => { export const setSelectedAction = (index: number, store: SpotlightStore) => {
store.updateState((state) => ({ ...state, selected: index })); store.updateState((state) => ({ ...state, selected: index }));
}; };
-4
View File
@@ -132,7 +132,6 @@
}, },
"password": { "password": {
"label": "Contrasenya", "label": "Contrasenya",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "Inclou com a mínim 8 caràcters", "length": "Inclou com a mínim 8 caràcters",
"lowercase": "Inclou minúscules", "lowercase": "Inclou minúscules",
@@ -1406,9 +1405,6 @@
"openNewTab": { "openNewTab": {
"label": "" "label": ""
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "", "label": "",
"add": "" "add": ""
-4
View File
@@ -132,7 +132,6 @@
}, },
"password": { "password": {
"label": "密码", "label": "密码",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "至少包含 8 个字符", "length": "至少包含 8 个字符",
"lowercase": "包含小写字母", "lowercase": "包含小写字母",
@@ -1406,9 +1405,6 @@
"openNewTab": { "openNewTab": {
"label": "在新标签页中打开" "label": "在新标签页中打开"
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "书签", "label": "书签",
"add": "添加书签" "add": "添加书签"
+5 -9
View File
@@ -132,13 +132,12 @@
}, },
"password": { "password": {
"label": "crwdns26:0crwdne26:0", "label": "crwdns26:0crwdne26:0",
"suggestionsTitle": "crwdns4261:0crwdne4261:0",
"suggestion": { "suggestion": {
"length": "crwdns4263:0crwdne4263:0", "length": "crwdns28:0crwdne28:0",
"lowercase": "crwdns4265:0crwdne4265:0", "lowercase": "crwdns30:0crwdne30:0",
"uppercase": "crwdns4267:0crwdne4267:0", "uppercase": "crwdns32:0crwdne32:0",
"number": "crwdns4269:0crwdne4269:0", "number": "crwdns34:0crwdne34:0",
"special": "crwdns4271:0crwdne4271:0" "special": "crwdns36:0crwdne36:0"
} }
}, },
"passwordConfirm": { "passwordConfirm": {
@@ -1406,9 +1405,6 @@
"openNewTab": { "openNewTab": {
"label": "crwdns2686:0crwdne2686:0" "label": "crwdns2686:0crwdne2686:0"
}, },
"withBorder": {
"label": "crwdns4273:0crwdne4273:0"
},
"items": { "items": {
"label": "crwdns764:0crwdne764:0", "label": "crwdns764:0crwdne764:0",
"add": "crwdns766:0crwdne766:0" "add": "crwdns766:0crwdne766:0"
-4
View File
@@ -132,7 +132,6 @@
}, },
"password": { "password": {
"label": "Heslo", "label": "Heslo",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "Obsahuje alespoň 8 znaků", "length": "Obsahuje alespoň 8 znaků",
"lowercase": "Obsahuje malé písmeno", "lowercase": "Obsahuje malé písmeno",
@@ -1406,9 +1405,6 @@
"openNewTab": { "openNewTab": {
"label": "" "label": ""
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "", "label": "",
"add": "" "add": ""
-4
View File
@@ -132,7 +132,6 @@
}, },
"password": { "password": {
"label": "Adgangskode", "label": "Adgangskode",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "Inkluderer mindst 8 tegn", "length": "Inkluderer mindst 8 tegn",
"lowercase": "Inkluderer små bogstaver", "lowercase": "Inkluderer små bogstaver",
@@ -1406,9 +1405,6 @@
"openNewTab": { "openNewTab": {
"label": "Åbn i nyt faneblad" "label": "Åbn i nyt faneblad"
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "Bogmærker", "label": "Bogmærker",
"add": "Tilføj bogmærke" "add": "Tilføj bogmærke"
-4
View File
@@ -132,7 +132,6 @@
}, },
"password": { "password": {
"label": "Passwort", "label": "Passwort",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "Enthält mindestens 8 Zeichen", "length": "Enthält mindestens 8 Zeichen",
"lowercase": "Enthält Kleinbuchstaben", "lowercase": "Enthält Kleinbuchstaben",
@@ -1406,9 +1405,6 @@
"openNewTab": { "openNewTab": {
"label": "In neuem Tab öffnen" "label": "In neuem Tab öffnen"
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "Lesezeichen", "label": "Lesezeichen",
"add": "Lesezeichen hinzufügen" "add": "Lesezeichen hinzufügen"
-4
View File
@@ -132,7 +132,6 @@
}, },
"password": { "password": {
"label": "Passwort", "label": "Passwort",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "Enthält mindestens 8 Zeichen", "length": "Enthält mindestens 8 Zeichen",
"lowercase": "Enthält Kleinbuchstaben", "lowercase": "Enthält Kleinbuchstaben",
@@ -1406,9 +1405,6 @@
"openNewTab": { "openNewTab": {
"label": "In neuem Tab öffnen" "label": "In neuem Tab öffnen"
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "Lesezeichen", "label": "Lesezeichen",
"add": "Lesezeichen hinzufügen" "add": "Lesezeichen hinzufügen"
-4
View File
@@ -132,7 +132,6 @@
}, },
"password": { "password": {
"label": "Κωδικός", "label": "Κωδικός",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "", "length": "",
"lowercase": "Περιλαμβάνει πεζό γράμμα", "lowercase": "Περιλαμβάνει πεζό γράμμα",
@@ -1406,9 +1405,6 @@
"openNewTab": { "openNewTab": {
"label": "" "label": ""
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "", "label": "",
"add": "" "add": ""
+2 -24
View File
@@ -132,7 +132,6 @@
}, },
"password": { "password": {
"label": "Password", "label": "Password",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "Includes a minimum of 8 characters", "length": "Includes a minimum of 8 characters",
"lowercase": "Includes a lower-case letter", "lowercase": "Includes a lower-case letter",
@@ -1406,9 +1405,6 @@
"openNewTab": { "openNewTab": {
"label": "Open in new tab" "label": "Open in new tab"
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "Bookmarks", "label": "Bookmarks",
"add": "Add bookmark" "add": "Add bookmark"
@@ -4555,23 +4551,9 @@
} }
}, },
"media": { "media": {
"help": "Search for movies or shows to request",
"requestMovie": "", "requestMovie": "",
"requestSeries": "", "requestSeries": "",
"openIn": "", "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."
}
}
}
}, },
"external": { "external": {
"help": "", "help": "",
@@ -4847,11 +4829,7 @@
"table": { "table": {
"header": { "header": {
"season": "Season", "season": "Season",
"episodes": "Episodes", "episodes": "Episodes"
"status": "Status"
},
"status": {
"requested": "Requested"
} }
}, },
"button": { "button": {
+2 -141
View File
@@ -1303,13 +1303,6 @@
}, },
"edit": { "edit": {
"title": "Edit item", "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": { "advancedOptions": {
"label": "Advanced options", "label": "Advanced options",
"title": "Advanced item options" "title": "Advanced item options"
@@ -4558,141 +4551,13 @@
"label": "Create a new group" "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": { "media": {
"help": "Search for movies or shows to request",
"requestMovie": "Request movie", "requestMovie": "Request movie",
"requestSeries": "Request series", "requestSeries": "Request series",
"openIn": "Open in {kind}", "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"
}
}, },
"external": { "external": {
"help": "Use an external search engine", "help": "Use an external search engine",
@@ -4968,11 +4833,7 @@
"table": { "table": {
"header": { "header": {
"season": "Season", "season": "Season",
"episodes": "Episodes", "episodes": "Episodes"
"status": "Status"
},
"status": {
"requested": "Requested"
} }
}, },
"button": { "button": {
-4
View File
@@ -132,7 +132,6 @@
}, },
"password": { "password": {
"label": "Contraseña", "label": "Contraseña",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "Incluye al menos 8 caracteres", "length": "Incluye al menos 8 caracteres",
"lowercase": "Incluye al menos una letra minúscula", "lowercase": "Incluye al menos una letra minúscula",
@@ -1406,9 +1405,6 @@
"openNewTab": { "openNewTab": {
"label": "Abrir en una nueva pestaña" "label": "Abrir en una nueva pestaña"
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "Marcadores", "label": "Marcadores",
"add": "Añadir marcador" "add": "Añadir marcador"
-4
View File
@@ -132,7 +132,6 @@
}, },
"password": { "password": {
"label": "", "label": "",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "", "length": "",
"lowercase": "", "lowercase": "",
@@ -1406,9 +1405,6 @@
"openNewTab": { "openNewTab": {
"label": "" "label": ""
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "", "label": "",
"add": "" "add": ""
+1 -5
View File
@@ -132,7 +132,6 @@
}, },
"password": { "password": {
"label": "Mot de passe", "label": "Mot de passe",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "Contient au moins 8 caractères", "length": "Contient au moins 8 caractères",
"lowercase": "Inclut une lettre minuscule", "lowercase": "Inclut une lettre minuscule",
@@ -1406,9 +1405,6 @@
"openNewTab": { "openNewTab": {
"label": "Ouvrir dans un nouvel onglet" "label": "Ouvrir dans un nouvel onglet"
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "Signets", "label": "Signets",
"add": "Ajouter un signet" "add": "Ajouter un signet"
@@ -2092,7 +2088,7 @@
"empty": "", "empty": "",
"user": "Utilisateur", "user": "Utilisateur",
"media": "Média", "media": "Média",
"state": "État", "state": "",
"quality": "Qualité", "quality": "Qualité",
"device": "Appareil", "device": "Appareil",
"progress": "Progression", "progress": "Progression",
-4
View File
@@ -132,7 +132,6 @@
}, },
"password": { "password": {
"label": "סיסמה", "label": "סיסמה",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "כולל לפחות 8 תווים", "length": "כולל לפחות 8 תווים",
"lowercase": "אפשר אותיות קטנות", "lowercase": "אפשר אותיות קטנות",
@@ -1406,9 +1405,6 @@
"openNewTab": { "openNewTab": {
"label": "פתיחה בכרטיסיה חדשה" "label": "פתיחה בכרטיסיה חדשה"
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "סימניות", "label": "סימניות",
"add": "הוסף סימניה" "add": "הוסף סימניה"
-4
View File
@@ -132,7 +132,6 @@
}, },
"password": { "password": {
"label": "Lozinka", "label": "Lozinka",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "", "length": "",
"lowercase": "Uključuje mala slova", "lowercase": "Uključuje mala slova",
@@ -1406,9 +1405,6 @@
"openNewTab": { "openNewTab": {
"label": "" "label": ""
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "", "label": "",
"add": "" "add": ""
-4
View File
@@ -132,7 +132,6 @@
}, },
"password": { "password": {
"label": "Jelszó", "label": "Jelszó",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "Tartalmazzon legalább 8 karaktert", "length": "Tartalmazzon legalább 8 karaktert",
"lowercase": "Tartalmazzon kisbetűt", "lowercase": "Tartalmazzon kisbetűt",
@@ -1406,9 +1405,6 @@
"openNewTab": { "openNewTab": {
"label": "Megnyitás új lapon" "label": "Megnyitás új lapon"
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "Könyjelzők", "label": "Könyjelzők",
"add": "Könyvjelző hozzáadása" "add": "Könyvjelző hozzáadása"
-4
View File
@@ -132,7 +132,6 @@
}, },
"password": { "password": {
"label": "Password", "label": "Password",
"suggestionsTitle": "Suggerimenti password",
"suggestion": { "suggestion": {
"length": "Include almeno 8 caratteri", "length": "Include almeno 8 caratteri",
"lowercase": "Include lettera minuscola", "lowercase": "Include lettera minuscola",
@@ -1406,9 +1405,6 @@
"openNewTab": { "openNewTab": {
"label": "Apri in una nuova scheda" "label": "Apri in una nuova scheda"
}, },
"withBorder": {
"label": "Mostra bordo"
},
"items": { "items": {
"label": "Segnalibri", "label": "Segnalibri",
"add": "Aggiungi segnalibro" "add": "Aggiungi segnalibro"
-4
View File
@@ -132,7 +132,6 @@
}, },
"password": { "password": {
"label": "パスワード", "label": "パスワード",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "少なくとも 8 文字以上を含む", "length": "少なくとも 8 文字以上を含む",
"lowercase": "小文字を含む", "lowercase": "小文字を含む",
@@ -1406,9 +1405,6 @@
"openNewTab": { "openNewTab": {
"label": "新しいタブで開く" "label": "新しいタブで開く"
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "ブックマーク", "label": "ブックマーク",
"add": "ブックマークに追加" "add": "ブックマークに追加"
-4
View File
@@ -132,7 +132,6 @@
}, },
"password": { "password": {
"label": "비밀번호", "label": "비밀번호",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "", "length": "",
"lowercase": "소문자 포함", "lowercase": "소문자 포함",
@@ -1406,9 +1405,6 @@
"openNewTab": { "openNewTab": {
"label": "" "label": ""
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "", "label": "",
"add": "" "add": ""
-4
View File
@@ -132,7 +132,6 @@
}, },
"password": { "password": {
"label": "Slaptažodis", "label": "Slaptažodis",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "", "length": "",
"lowercase": "", "lowercase": "",
@@ -1406,9 +1405,6 @@
"openNewTab": { "openNewTab": {
"label": "" "label": ""
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "", "label": "",
"add": "" "add": ""
-4
View File
@@ -132,7 +132,6 @@
}, },
"password": { "password": {
"label": "Parole", "label": "Parole",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "", "length": "",
"lowercase": "Ietver mazo burtu", "lowercase": "Ietver mazo burtu",
@@ -1406,9 +1405,6 @@
"openNewTab": { "openNewTab": {
"label": "" "label": ""
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "", "label": "",
"add": "" "add": ""
-4
View File
@@ -132,7 +132,6 @@
}, },
"password": { "password": {
"label": "Wachtwoord", "label": "Wachtwoord",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "Bevat ten minste 8 tekens", "length": "Bevat ten minste 8 tekens",
"lowercase": "Inclusief kleine letter", "lowercase": "Inclusief kleine letter",
@@ -1406,9 +1405,6 @@
"openNewTab": { "openNewTab": {
"label": "In nieuw tabblad openen" "label": "In nieuw tabblad openen"
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "Bladwijzers", "label": "Bladwijzers",
"add": "Bladwijzer toevoegen" "add": "Bladwijzer toevoegen"
-4
View File
@@ -132,7 +132,6 @@
}, },
"password": { "password": {
"label": "Passord", "label": "Passord",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "Inkluderer minst 8 tegn", "length": "Inkluderer minst 8 tegn",
"lowercase": "Inkluderer liten bokstav", "lowercase": "Inkluderer liten bokstav",
@@ -1406,9 +1405,6 @@
"openNewTab": { "openNewTab": {
"label": "Åpne i ny fane" "label": "Åpne i ny fane"
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "Bokmerke", "label": "Bokmerke",
"add": "Legg til bokmerke" "add": "Legg til bokmerke"
-4
View File
@@ -132,7 +132,6 @@
}, },
"password": { "password": {
"label": "Hasło", "label": "Hasło",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "Zawiera co najmniej 8 znaków", "length": "Zawiera co najmniej 8 znaków",
"lowercase": "Zawiera małą literę", "lowercase": "Zawiera małą literę",
@@ -1406,9 +1405,6 @@
"openNewTab": { "openNewTab": {
"label": "Otwórz w nowej karcie" "label": "Otwórz w nowej karcie"
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "Zakładki", "label": "Zakładki",
"add": "Dodaj zakładkę" "add": "Dodaj zakładkę"
+5 -9
View File
@@ -132,7 +132,6 @@
}, },
"password": { "password": {
"label": "Senha", "label": "Senha",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "Inclua pelo menos 8 caracteres", "length": "Inclua pelo menos 8 caracteres",
"lowercase": "Inclui letras minúsculas", "lowercase": "Inclui letras minúsculas",
@@ -1057,7 +1056,7 @@
} }
}, },
"noResults": { "noResults": {
"title": "Nenhuma mídia enviada" "title": ""
} }
}, },
"common": { "common": {
@@ -1089,8 +1088,8 @@
"checkLogs": "Verifique os logs para mais detalhes", "checkLogs": "Verifique os logs para mais detalhes",
"tryAgain": "Tente novamente", "tryAgain": "Tente novamente",
"loading": "Carregando", "loading": "Carregando",
"show": "Mostrar", "show": "",
"hide": "Esconder" "hide": ""
}, },
"here": "aqui", "here": "aqui",
"iconPicker": { "iconPicker": {
@@ -1256,7 +1255,7 @@
"label": { "label": {
"create": "Nova categoria", "create": "Nova categoria",
"changePosition": "Mudar de posição", "changePosition": "Mudar de posição",
"settings": "Configurações" "settings": ""
} }
}, },
"openAllInNewTabs": { "openAllInNewTabs": {
@@ -1406,9 +1405,6 @@
"openNewTab": { "openNewTab": {
"label": "Abrir em nova guia" "label": "Abrir em nova guia"
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "Marcadores", "label": "Marcadores",
"add": "Adicionar marcador" "add": "Adicionar marcador"
@@ -3463,7 +3459,7 @@
"private": "Este quadro é privado" "private": "Este quadro é privado"
}, },
"noResults": { "noResults": {
"title": "Nenhum painel ainda" "title": ""
}, },
"modal": { "modal": {
"createBoard": { "createBoard": {
-4
View File
@@ -132,7 +132,6 @@
}, },
"password": { "password": {
"label": "", "label": "",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "", "length": "",
"lowercase": "", "lowercase": "",
@@ -1406,9 +1405,6 @@
"openNewTab": { "openNewTab": {
"label": "" "label": ""
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "", "label": "",
"add": "" "add": ""
-4
View File
@@ -132,7 +132,6 @@
}, },
"password": { "password": {
"label": "Parola", "label": "Parola",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "", "length": "",
"lowercase": "Să includă litere mici", "lowercase": "Să includă litere mici",
@@ -1406,9 +1405,6 @@
"openNewTab": { "openNewTab": {
"label": "" "label": ""
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "", "label": "",
"add": "" "add": ""
-4
View File
@@ -132,7 +132,6 @@
}, },
"password": { "password": {
"label": "Пароль", "label": "Пароль",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "Содержит не менее 8 символов", "length": "Содержит не менее 8 символов",
"lowercase": "Содержит строчную букву", "lowercase": "Содержит строчную букву",
@@ -1406,9 +1405,6 @@
"openNewTab": { "openNewTab": {
"label": "Открывать в новой вкладке" "label": "Открывать в новой вкладке"
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "Закладки", "label": "Закладки",
"add": "Добавить закладку" "add": "Добавить закладку"
-4
View File
@@ -132,7 +132,6 @@
}, },
"password": { "password": {
"label": "Heslo", "label": "Heslo",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "Obsahuje aspoň 8 znakov", "length": "Obsahuje aspoň 8 znakov",
"lowercase": "Zahŕňa malé písmeno", "lowercase": "Zahŕňa malé písmeno",
@@ -1406,9 +1405,6 @@
"openNewTab": { "openNewTab": {
"label": "Otvoriť na novej karte" "label": "Otvoriť na novej karte"
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "Záložky", "label": "Záložky",
"add": "Pridať záložku" "add": "Pridať záložku"
-4
View File
@@ -132,7 +132,6 @@
}, },
"password": { "password": {
"label": "Geslo", "label": "Geslo",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "", "length": "",
"lowercase": "Vključuje male črke", "lowercase": "Vključuje male črke",
@@ -1406,9 +1405,6 @@
"openNewTab": { "openNewTab": {
"label": "" "label": ""
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "", "label": "",
"add": "" "add": ""
-4
View File
@@ -132,7 +132,6 @@
}, },
"password": { "password": {
"label": "Lösenord", "label": "Lösenord",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "Använd minst 8 tecken", "length": "Använd minst 8 tecken",
"lowercase": "Använd liten bokstav", "lowercase": "Använd liten bokstav",
@@ -1406,9 +1405,6 @@
"openNewTab": { "openNewTab": {
"label": "Öppna i en ny flik" "label": "Öppna i en ny flik"
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "Bokmärken", "label": "Bokmärken",
"add": "Addera bokmärke" "add": "Addera bokmärke"
-4
View File
@@ -132,7 +132,6 @@
}, },
"password": { "password": {
"label": "Şifre", "label": "Şifre",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "En az 8 karakter içermeli", "length": "En az 8 karakter içermeli",
"lowercase": "Küçük harf içermeli", "lowercase": "Küçük harf içermeli",
@@ -1406,9 +1405,6 @@
"openNewTab": { "openNewTab": {
"label": "Yeni sekmede aç" "label": "Yeni sekmede aç"
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "Yer İmleri", "label": "Yer İmleri",
"add": "Yer imi ekle" "add": "Yer imi ekle"
-4
View File
@@ -132,7 +132,6 @@
}, },
"password": { "password": {
"label": "Пароль", "label": "Пароль",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "Включає принаймні 8 символів", "length": "Включає принаймні 8 символів",
"lowercase": "Включає малу літеру", "lowercase": "Включає малу літеру",
@@ -1406,9 +1405,6 @@
"openNewTab": { "openNewTab": {
"label": "Відкрити в новій вкладці" "label": "Відкрити в новій вкладці"
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "Закладки", "label": "Закладки",
"add": "Додати закладку" "add": "Додати закладку"
-4
View File
@@ -132,7 +132,6 @@
}, },
"password": { "password": {
"label": "Mật khẩu", "label": "Mật khẩu",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "Bao gồm ít nhất 8 kí tự", "length": "Bao gồm ít nhất 8 kí tự",
"lowercase": "Bao gồm chữ thường", "lowercase": "Bao gồm chữ thường",
@@ -1406,9 +1405,6 @@
"openNewTab": { "openNewTab": {
"label": "" "label": ""
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "", "label": "",
"add": "" "add": ""
-4
View File
@@ -132,7 +132,6 @@
}, },
"password": { "password": {
"label": "密碼", "label": "密碼",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "至少包含 8 個字符", "length": "至少包含 8 個字符",
"lowercase": "包含小寫字母", "lowercase": "包含小寫字母",
@@ -1406,9 +1405,6 @@
"openNewTab": { "openNewTab": {
"label": "於新分頁開啟" "label": "於新分頁開啟"
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "書籤", "label": "書籤",
"add": "新增書籤" "add": "新增書籤"
@@ -1,32 +1,17 @@
"use client"; "use client";
import { import { ActionIcon, Anchor, Avatar, Badge, Card, Group, Image, ScrollArea, Stack, Text, Tooltip } from "@mantine/core";
ActionIcon, import { IconThumbDown, IconThumbUp } from "@tabler/icons-react";
Anchor,
Avatar,
Badge,
Box,
Card,
Group,
Image,
ScrollArea,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import { IconSearch, IconThumbDown, IconThumbUp } from "@tabler/icons-react";
import type { RouterInputs, RouterOutputs } from "@homarr/api"; import type { RouterInputs, RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
import type { MediaRequestStatus } from "@homarr/integrations/types"; import type { MediaRequestStatus } from "@homarr/integrations/types";
import { mediaAvailabilityConfiguration, mediaRequestStatusConfiguration } from "@homarr/integrations/types"; import { mediaAvailabilityConfiguration, mediaRequestStatusConfiguration } from "@homarr/integrations/types";
import { openMediaRequestSearch } from "@homarr/spotlight";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../../definition"; import type { WidgetComponentProps } from "../../definition";
import { NoIntegrationDataError } from "../../errors/no-data-integration"; import { NoIntegrationDataError } from "../../errors/no-data-integration";
import classes from "../search-button.module.css";
export default function MediaServerWidget({ export default function MediaServerWidget({
integrationIds, integrationIds,
@@ -58,7 +43,7 @@ export default function MediaServerWidget({
const newData = filteredData.concat( const newData = filteredData.concat(
data.requests.map((request) => ({ ...request, integrationId: data.integrationId })), data.requests.map((request) => ({ ...request, integrationId: data.integrationId })),
); );
return newData.toSorted((dataA, dataB) => { return newData.sort((dataA, dataB) => {
if (dataA.status === dataB.status) { if (dataA.status === dataB.status) {
return dataB.createdAt.getTime() - dataA.createdAt.getTime(); return dataB.createdAt.getTime() - dataA.createdAt.getTime();
} }
@@ -76,46 +61,25 @@ export default function MediaServerWidget({
if (mediaRequests.length === 0) throw new NoIntegrationDataError(); if (mediaRequests.length === 0) throw new NoIntegrationDataError();
return ( return (
<Box className={classes.searchRoot}> <ScrollArea
{!isEditMode && <MediaRequestSearchButton integrationIds={integrationIds} />} className="mediaRequests-list-scrollArea"
<ScrollArea scrollbarSize="md"
className="mediaRequests-list-scrollArea" style={{ pointerEvents: isEditMode ? "none" : undefined }}
scrollbarSize="md" >
style={{ pointerEvents: isEditMode ? "none" : undefined }} <Stack className="mediaRequests-list-list" gap="xs" p="sm">
> {mediaRequests.map((mediaRequest) => (
<Stack className="mediaRequests-list-list" gap="xs" p="sm"> <MediaRequestCard
{mediaRequests.map((mediaRequest) => ( key={`${mediaRequest.integrationId}-${mediaRequest.id}`}
<MediaRequestCard request={mediaRequest}
key={`${mediaRequest.integrationId}-${mediaRequest.id}`} isTiny={width <= 256}
request={mediaRequest} options={options}
isTiny={width <= 256} />
options={options} ))}
/> </Stack>
))} </ScrollArea>
</Stack>
</ScrollArea>
</Box>
); );
} }
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 { interface MediaRequestCardProps {
request: RouterOutputs["widget"]["mediaRequests"]["getLatestRequests"][number]; request: RouterOutputs["widget"]["mediaRequests"]["getLatestRequests"][number];
isTiny: boolean; 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;
}
}
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff. Show more