Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
510c9b945d | ||
|
|
2405e1b931 |
No files matched your search
@@ -0,0 +1,2 @@
|
|||||||
|
-P ubuntu-latest=catthehacker/ubuntu:act-latest
|
||||||
|
--container-architecture linux/amd64
|
||||||
@@ -19,7 +19,10 @@ body:
|
|||||||
- type: textarea
|
- 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?
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
name: Update CODEOWNERS
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch: {}
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- CODEOWNERS
|
||||||
|
- packages/widgets/src/**
|
||||||
|
- packages/integrations/src/**
|
||||||
|
- packages/api/src/router/widgets/**
|
||||||
|
- packages/cron-jobs/src/jobs/**
|
||||||
|
- scripts/generate-codeowners.mts
|
||||||
|
- scripts/validate-codeowners.mts
|
||||||
|
- scripts/codeowners-overrides.json
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- packages/widgets/src/**
|
||||||
|
- packages/integrations/src/**
|
||||||
|
- packages/api/src/router/widgets/**
|
||||||
|
- packages/cron-jobs/src/jobs/**
|
||||||
|
- scripts/generate-codeowners.mts
|
||||||
|
- scripts/validate-codeowners.mts
|
||||||
|
- scripts/codeowners-overrides.json
|
||||||
|
branches:
|
||||||
|
- dev
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate:
|
||||||
|
name: Validate CODEOWNERS
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Use Node.js
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version-file: .nvmrc
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v6
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Check CODEOWNERS is up to date
|
||||||
|
run: pnpm exec tsx ./scripts/generate-codeowners.mts --check
|
||||||
|
|
||||||
|
- name: Validate CODEOWNERS
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
|
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||||
|
run: pnpm exec tsx ./scripts/validate-codeowners.mts
|
||||||
|
|
||||||
|
update:
|
||||||
|
name: Open CODEOWNERS update PR
|
||||||
|
needs: validate
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
concurrency:
|
||||||
|
group: update-codeowners
|
||||||
|
cancel-in-progress: false
|
||||||
|
steps:
|
||||||
|
- name: Obtain token
|
||||||
|
id: obtainToken
|
||||||
|
uses: actions/create-github-app-token@v3
|
||||||
|
with:
|
||||||
|
private-key: ${{ secrets.HOMARR_UPDATE_CONTRIBUTORS_PRIVATE_KEY }}
|
||||||
|
client-id: ${{ vars.HOMARR_UPDATE_CONTRIBUTORS_APP_ID }}
|
||||||
|
permission-contents: write
|
||||||
|
permission-pull-requests: write
|
||||||
|
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||||
|
|
||||||
|
- name: Use Node.js
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version-file: .nvmrc
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v6
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Generate CODEOWNERS
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||||
|
run: pnpm scripts:generate-codeowners
|
||||||
|
|
||||||
|
- name: Validate CODEOWNERS
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||||
|
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||||
|
run: pnpm exec tsx ./scripts/validate-codeowners.mts
|
||||||
|
|
||||||
|
- name: Create Pull Request
|
||||||
|
id: create-pull-request
|
||||||
|
uses: peter-evans/create-pull-request@v8
|
||||||
|
with:
|
||||||
|
token: ${{ steps.obtainToken.outputs.token }}
|
||||||
|
branch: update-codeowners
|
||||||
|
base: dev
|
||||||
|
title: "chore: update widget/integration CODEOWNERS"
|
||||||
|
delete-branch: true
|
||||||
|
commit-message: "chore: update widget/integration CODEOWNERS"
|
||||||
|
body: |
|
||||||
|
This PR updates per-widget and per-integration CODEOWNERS entries from git history.
|
||||||
|
|
||||||
|
Unmapped authors can be added in `scripts/codeowners-overrides.json`.
|
||||||
|
|
||||||
|
- name: Install GitHub CLI
|
||||||
|
run: sudo apt-get install -y gh
|
||||||
|
|
||||||
|
- name: Enable auto-merge
|
||||||
|
if: steps.create-pull-request.outputs.pull-request-number != ''
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||||
|
run: |
|
||||||
|
gh pr merge ${{ steps.create-pull-request.outputs.pull-request-number }} --auto --squash
|
||||||
+83
@@ -11,3 +11,86 @@ pnpm-lock.yaml
|
|||||||
pnpm-workspace.yaml
|
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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
@@ -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> •
|
||||||
|
|||||||
@@ -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,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,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,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 () => {
|
||||||
|
|||||||
@@ -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));
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.",
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()));
|
||||||
}
|
}
|
||||||
@@ -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,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],
|
||||||
|
|||||||
@@ -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,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);
|
|
||||||
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
@@ -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,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,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 }));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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": ""
|
||||||
|
|||||||
@@ -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": "添加书签"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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": ""
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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": ""
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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": ""
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "הוסף סימניה"
|
||||||
|
|||||||
@@ -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": ""
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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": "ブックマークに追加"
|
||||||
|
|||||||
@@ -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": ""
|
||||||
|
|||||||
@@ -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": ""
|
||||||
|
|||||||
@@ -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": ""
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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ę"
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": ""
|
||||||
|
|||||||
@@ -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": ""
|
||||||
|
|||||||
@@ -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": "Добавить закладку"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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": ""
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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": "Додати закладку"
|
||||||
|
|||||||
@@ -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": ""
|
||||||
|
|||||||
@@ -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
Reference in new issue
Block a user