From 05292ae1f503a8acb3e4296dd79db43b19cd5ea8 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 3 Aug 2026 15:24:30 +0200 Subject: [PATCH 01/17] fix: map legacy media server options --- packages/old-import/src/widgets/options.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/old-import/src/widgets/options.ts b/packages/old-import/src/widgets/options.ts index 0cf84b493..b2ed298a7 100644 --- a/packages/old-import/src/widgets/options.ts +++ b/packages/old-import/src/widgets/options.ts @@ -148,6 +148,8 @@ const optionMapping: OptionMapping = { }, mediaServer: { showOnlyPlaying: () => undefined, + showBitrate: () => undefined, + showLocation: () => undefined, }, indexerManager: { openIndexerSiteInNewTab: (oldOptions) => oldOptions.openIndexerSiteInNewTab, From f1b7a5146ab7da4f5138c43bbc37a00d52d95b7c Mon Sep 17 00:00:00 2001 From: Thomas Date: Mon, 3 Aug 2026 16:47:40 +0200 Subject: [PATCH 02/17] fix: allow [homarr_base] template variables in app href validation (#6508) --- packages/definitions/src/docs/homarr-docs-sitemap.ts | 1 + packages/validation/src/app.ts | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/packages/definitions/src/docs/homarr-docs-sitemap.ts b/packages/definitions/src/docs/homarr-docs-sitemap.ts index 22bc5f75b..7185a2fdd 100644 --- a/packages/definitions/src/docs/homarr-docs-sitemap.ts +++ b/packages/definitions/src/docs/homarr-docs-sitemap.ts @@ -119,6 +119,7 @@ export type HomarrDocumentationPath = | "/docs/tags/variables" | "/docs/tags/widgets" | "/docs/advanced/command-line" + | "/docs/advanced/command-line/development" | "/docs/advanced/command-line/fix-usernames" | "/docs/advanced/command-line/integrations" | "/docs/advanced/command-line/password-recovery" diff --git a/packages/validation/src/app.ts b/packages/validation/src/app.ts index 21a4335be..e3b94218b 100644 --- a/packages/validation/src/app.ts +++ b/packages/validation/src/app.ts @@ -5,6 +5,10 @@ export const appHrefSchema = z .trim() .url() .regex(/^(?!javascript)[a-zA-Z]*:\/\//i) // javascript: is not allowed, i for case insensitive (so Javascript: is also not allowed) + .or(z.string().trim().includes("[homarr_base]")) + .or(z.string().trim().includes("[homarr_hostname]")) + .or(z.string().trim().includes("[homarr_domain]")) + .or(z.string().trim().includes("[homarr_protocol]")) .or(z.literal("")) .transform((value) => (value.length === 0 ? null : value)) .nullable(); From 2f6b96e5ba92d950d5280d9ec3566cbc30c0c2c1 Mon Sep 17 00:00:00 2001 From: Thomas Date: Mon, 3 Aug 2026 17:04:35 +0200 Subject: [PATCH 03/17] feat: restrict integrations management to integration-full-all permission (#6509) --- .../app/[locale]/manage/integrations/edit/[id]/page.tsx | 7 +++++++ .../src/app/[locale]/manage/integrations/new/page.tsx | 5 ++++- apps/nextjs/src/app/[locale]/manage/integrations/page.tsx | 5 ++++- apps/nextjs/src/app/[locale]/manage/layout.tsx | 2 +- packages/api/src/router/home.ts | 2 +- packages/api/src/router/integration/integration-router.ts | 6 ++++-- packages/spotlight/src/modes/command/global-group.tsx | 4 +++- packages/spotlight/src/modes/page/pages-search-group.tsx | 2 +- 8 files changed, 25 insertions(+), 8 deletions(-) diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/page.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/page.tsx index 8cd8bf01b..ed2da1aab 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/page.tsx @@ -1,5 +1,7 @@ +import { notFound } from "next/navigation"; import { Container, Fieldset, Group, Stack, Title } from "@mantine/core"; +import { auth } from "@homarr/auth/next"; import { api } from "@homarr/api/server"; import { getIntegrationName } from "@homarr/definitions"; import { getI18n, getScopedI18n } from "@homarr/translation/server"; @@ -16,6 +18,11 @@ interface EditIntegrationPageProps { export default async function EditIntegrationPage(props: EditIntegrationPageProps) { const params = await props.params; + const session = await auth(); + + if (!session?.user.permissions.includes("integration-full-all")) { + notFound(); + } const editT = await getScopedI18n("integration.page.edit"); const t = await getI18n(); const integration = await api.integration.byId({ id: params.id }).catch(catchTrpcNotFound); diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx index 3194134c3..d6f1cce98 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx @@ -23,7 +23,10 @@ interface NewIntegrationPageProps { export default async function IntegrationsNewPage(props: NewIntegrationPageProps) { const searchParams = await props.searchParams; const session = await auth(); - if (!session?.user.permissions.includes("integration-create")) { + if ( + !session?.user.permissions.includes("integration-full-all") || + !session?.user.permissions.includes("integration-create") + ) { notFound(); } diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx index 0eeb25c1a..692329896 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx @@ -1,5 +1,5 @@ import { Fragment } from "react"; -import { redirect } from "next/navigation"; +import { notFound, redirect } from "next/navigation"; import { AccordionControl, AccordionItem, @@ -50,6 +50,9 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) { if (!session) { redirect("/auth/login"); } + if (!session.user.permissions.includes("integration-full-all")) { + notFound(); + } const integrations = await api.integration.all(); const t = await getScopedI18n("integration"); diff --git a/apps/nextjs/src/app/[locale]/manage/layout.tsx b/apps/nextjs/src/app/[locale]/manage/layout.tsx index 1cd0d3d2e..ba2440c25 100644 --- a/apps/nextjs/src/app/[locale]/manage/layout.tsx +++ b/apps/nextjs/src/app/[locale]/manage/layout.tsx @@ -71,7 +71,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) { icon: IconAffiliateFilled, href: "/manage/integrations", label: t("items.integrations"), - hidden: !session?.user.permissions.includes("integration-create"), + hidden: !session?.user.permissions.includes("integration-full-all"), "data-onboarding-tour-id": "manage-integrations", }, { diff --git a/packages/api/src/router/home.ts b/packages/api/src/router/home.ts index 4b63f9326..a9cee2c21 100644 --- a/packages/api/src/router/home.ts +++ b/packages/api/src/router/home.ts @@ -92,7 +92,7 @@ export const homeRouter = createTRPCRouter({ }); } - if (ctx.session?.user.permissions.includes("integration-create")) { + if (ctx.session?.user.permissions.includes("integration-full-all")) { statistics.push({ titleKey: "integration", subtitleKey: "resources", diff --git a/packages/api/src/router/integration/integration-router.ts b/packages/api/src/router/integration/integration-router.ts index af237bb31..2b40dc2a8 100644 --- a/packages/api/src/router/integration/integration-router.ts +++ b/packages/api/src/router/integration/integration-router.ts @@ -62,12 +62,13 @@ export const integrationRouter = createTRPCRouter({ requiredSecrets: def.secretKinds, })); }), - all: protectedProcedure + all: permissionRequiredProcedure + .requiresPermission("integration-full-all") .meta({ mcp: { enabled: true, description: - "List all configured integrations (connections to services like Sonarr, Radarr, Plex, etc.). Returns each integration's id, name, kind, url, and permissions. Use the 'id' field as 'integrationId' in other tools. Check permissions.hasUseAccess before reading data and permissions.hasInteractAccess before performing actions — false means the API key owner lacks that permission level for this integration, not an error", + "List all configured integrations (connections to services like Sonarr, Radarr, Plex, etc.). Requires integration-full-all permission. Returns each integration's id, name, kind, url, and permissions. Use the 'id' field as 'integrationId' in other tools. Check permissions.hasUseAccess before reading data and permissions.hasInteractAccess before performing actions — false means the API key owner lacks that permission level for this integration, not an error", }, }) .query(async ({ ctx }) => { @@ -296,6 +297,7 @@ export const integrationRouter = createTRPCRouter({ }; }), create: permissionRequiredProcedure + .requiresPermission("integration-full-all") .requiresPermission("integration-create") .meta({ mcp: { diff --git a/packages/spotlight/src/modes/command/global-group.tsx b/packages/spotlight/src/modes/command/global-group.tsx index fd277b55e..fa8f53c5f 100644 --- a/packages/spotlight/src/modes/command/global-group.tsx +++ b/packages/spotlight/src/modes/command/global-group.tsx @@ -95,7 +95,9 @@ export const globalCommandGroup = createGroup({ icon: IconPlug, name: tOption("newIntegration.label"), useInteraction: interaction.children(newIntegrationChildrenOptions), - hidden: !session?.user.permissions.includes("integration-create"), + hidden: + !session?.user.permissions.includes("integration-full-all") || + !session?.user.permissions.includes("integration-create"), }, { commandKey: "newUser", diff --git a/packages/spotlight/src/modes/page/pages-search-group.tsx b/packages/spotlight/src/modes/page/pages-search-group.tsx index 66fe1ce4f..1b3b0ee68 100644 --- a/packages/spotlight/src/modes/page/pages-search-group.tsx +++ b/packages/spotlight/src/modes/page/pages-search-group.tsx @@ -82,7 +82,7 @@ export const pagesSearchGroup = createGroup<{ icon: IconPlug, path: "/manage/integrations", name: t("manageIntegration.label"), - hidden: !session, + hidden: !session?.user.permissions.includes("integration-full-all"), }, { icon: IconSearch, From 76ddfceb130d88fa1005c5e14961851b880b898f Mon Sep 17 00:00:00 2001 From: Thomas Date: Mon, 3 Aug 2026 17:07:46 +0200 Subject: [PATCH 04/17] fix: restore auto-refresh for docker and notifications widgets (#6511) --- packages/widgets/src/docker/index.ts | 1 + packages/widgets/src/notifications/index.ts | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/widgets/src/docker/index.ts b/packages/widgets/src/docker/index.ts index 03c6e990d..bc229fed5 100644 --- a/packages/widgets/src/docker/index.ts +++ b/packages/widgets/src/docker/index.ts @@ -26,6 +26,7 @@ const columnTranslationKeyMap = { export const { definition, componentLoader } = createWidgetDefinition("dockerContainers", { icon: IconBrandDocker, queryKey: [["docker", "getContainers"]], + refetchInterval: 30, createOptions() { return optionsBuilder.from( (factory) => ({ diff --git a/packages/widgets/src/notifications/index.ts b/packages/widgets/src/notifications/index.ts index 17fd13393..553ef5d9f 100644 --- a/packages/widgets/src/notifications/index.ts +++ b/packages/widgets/src/notifications/index.ts @@ -7,7 +7,6 @@ import { optionsBuilder } from "../options"; export const { componentLoader, definition } = createWidgetDefinition("notifications", { icon: IconMessage, - refetchInterval: null, createOptions() { return optionsBuilder.from((factory) => ({ hideLogos: factory.switch({ defaultValue: false }), From b4fba47a23ff4ada4670d4b4c69be6922dc8c2c4 Mon Sep 17 00:00:00 2001 From: Thomas Date: Mon, 3 Aug 2026 17:10:35 +0200 Subject: [PATCH 05/17] fix(releases): add success flag to successful response for correct error detection (#6512) --- packages/api/src/router/widgets/releases.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/api/src/router/widgets/releases.ts b/packages/api/src/router/widgets/releases.ts index d234a75c1..7480de536 100644 --- a/packages/api/src/router/widgets/releases.ts +++ b/packages/api/src/router/widgets/releases.ts @@ -104,6 +104,7 @@ export const releasesRouter = createTRPCRouter({ id: repositoryId, provider: repository.provider, timestamp: response.timestamp, + success: true as const, ...response.data, }; } catch (error) { From 778533a62e7b0316479824c5e6b6fa23a085c3e6 Mon Sep 17 00:00:00 2001 From: Thomas Date: Mon, 3 Aug 2026 17:37:53 +0200 Subject: [PATCH 06/17] fix: revert regressions from recent integration/releases/app-href changes (#6514) --- .../app/[locale]/manage/integrations/edit/[id]/page.tsx | 7 ------- .../src/app/[locale]/manage/integrations/new/page.tsx | 5 +---- apps/nextjs/src/app/[locale]/manage/integrations/page.tsx | 5 +---- apps/nextjs/src/app/[locale]/manage/layout.tsx | 2 +- packages/api/src/router/home.ts | 2 +- packages/api/src/router/integration/integration-router.ts | 6 ++---- packages/api/src/router/widgets/releases.ts | 1 - packages/definitions/src/docs/homarr-docs-sitemap.ts | 1 - packages/spotlight/src/modes/command/global-group.tsx | 4 +--- packages/spotlight/src/modes/page/pages-search-group.tsx | 2 +- packages/validation/src/app.ts | 4 ---- 11 files changed, 8 insertions(+), 31 deletions(-) diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/page.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/page.tsx index ed2da1aab..8cd8bf01b 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/page.tsx @@ -1,7 +1,5 @@ -import { notFound } from "next/navigation"; import { Container, Fieldset, Group, Stack, Title } from "@mantine/core"; -import { auth } from "@homarr/auth/next"; import { api } from "@homarr/api/server"; import { getIntegrationName } from "@homarr/definitions"; import { getI18n, getScopedI18n } from "@homarr/translation/server"; @@ -18,11 +16,6 @@ interface EditIntegrationPageProps { export default async function EditIntegrationPage(props: EditIntegrationPageProps) { const params = await props.params; - const session = await auth(); - - if (!session?.user.permissions.includes("integration-full-all")) { - notFound(); - } const editT = await getScopedI18n("integration.page.edit"); const t = await getI18n(); const integration = await api.integration.byId({ id: params.id }).catch(catchTrpcNotFound); diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx index d6f1cce98..3194134c3 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx @@ -23,10 +23,7 @@ interface NewIntegrationPageProps { export default async function IntegrationsNewPage(props: NewIntegrationPageProps) { const searchParams = await props.searchParams; const session = await auth(); - if ( - !session?.user.permissions.includes("integration-full-all") || - !session?.user.permissions.includes("integration-create") - ) { + if (!session?.user.permissions.includes("integration-create")) { notFound(); } diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx index 692329896..0eeb25c1a 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx @@ -1,5 +1,5 @@ import { Fragment } from "react"; -import { notFound, redirect } from "next/navigation"; +import { redirect } from "next/navigation"; import { AccordionControl, AccordionItem, @@ -50,9 +50,6 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) { if (!session) { redirect("/auth/login"); } - if (!session.user.permissions.includes("integration-full-all")) { - notFound(); - } const integrations = await api.integration.all(); const t = await getScopedI18n("integration"); diff --git a/apps/nextjs/src/app/[locale]/manage/layout.tsx b/apps/nextjs/src/app/[locale]/manage/layout.tsx index ba2440c25..1cd0d3d2e 100644 --- a/apps/nextjs/src/app/[locale]/manage/layout.tsx +++ b/apps/nextjs/src/app/[locale]/manage/layout.tsx @@ -71,7 +71,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) { icon: IconAffiliateFilled, href: "/manage/integrations", label: t("items.integrations"), - hidden: !session?.user.permissions.includes("integration-full-all"), + hidden: !session?.user.permissions.includes("integration-create"), "data-onboarding-tour-id": "manage-integrations", }, { diff --git a/packages/api/src/router/home.ts b/packages/api/src/router/home.ts index a9cee2c21..4b63f9326 100644 --- a/packages/api/src/router/home.ts +++ b/packages/api/src/router/home.ts @@ -92,7 +92,7 @@ export const homeRouter = createTRPCRouter({ }); } - if (ctx.session?.user.permissions.includes("integration-full-all")) { + if (ctx.session?.user.permissions.includes("integration-create")) { statistics.push({ titleKey: "integration", subtitleKey: "resources", diff --git a/packages/api/src/router/integration/integration-router.ts b/packages/api/src/router/integration/integration-router.ts index 2b40dc2a8..af237bb31 100644 --- a/packages/api/src/router/integration/integration-router.ts +++ b/packages/api/src/router/integration/integration-router.ts @@ -62,13 +62,12 @@ export const integrationRouter = createTRPCRouter({ requiredSecrets: def.secretKinds, })); }), - all: permissionRequiredProcedure - .requiresPermission("integration-full-all") + all: protectedProcedure .meta({ mcp: { enabled: true, description: - "List all configured integrations (connections to services like Sonarr, Radarr, Plex, etc.). Requires integration-full-all permission. Returns each integration's id, name, kind, url, and permissions. Use the 'id' field as 'integrationId' in other tools. Check permissions.hasUseAccess before reading data and permissions.hasInteractAccess before performing actions — false means the API key owner lacks that permission level for this integration, not an error", + "List all configured integrations (connections to services like Sonarr, Radarr, Plex, etc.). Returns each integration's id, name, kind, url, and permissions. Use the 'id' field as 'integrationId' in other tools. Check permissions.hasUseAccess before reading data and permissions.hasInteractAccess before performing actions — false means the API key owner lacks that permission level for this integration, not an error", }, }) .query(async ({ ctx }) => { @@ -297,7 +296,6 @@ export const integrationRouter = createTRPCRouter({ }; }), create: permissionRequiredProcedure - .requiresPermission("integration-full-all") .requiresPermission("integration-create") .meta({ mcp: { diff --git a/packages/api/src/router/widgets/releases.ts b/packages/api/src/router/widgets/releases.ts index 7480de536..d234a75c1 100644 --- a/packages/api/src/router/widgets/releases.ts +++ b/packages/api/src/router/widgets/releases.ts @@ -104,7 +104,6 @@ export const releasesRouter = createTRPCRouter({ id: repositoryId, provider: repository.provider, timestamp: response.timestamp, - success: true as const, ...response.data, }; } catch (error) { diff --git a/packages/definitions/src/docs/homarr-docs-sitemap.ts b/packages/definitions/src/docs/homarr-docs-sitemap.ts index 7185a2fdd..22bc5f75b 100644 --- a/packages/definitions/src/docs/homarr-docs-sitemap.ts +++ b/packages/definitions/src/docs/homarr-docs-sitemap.ts @@ -119,7 +119,6 @@ export type HomarrDocumentationPath = | "/docs/tags/variables" | "/docs/tags/widgets" | "/docs/advanced/command-line" - | "/docs/advanced/command-line/development" | "/docs/advanced/command-line/fix-usernames" | "/docs/advanced/command-line/integrations" | "/docs/advanced/command-line/password-recovery" diff --git a/packages/spotlight/src/modes/command/global-group.tsx b/packages/spotlight/src/modes/command/global-group.tsx index fa8f53c5f..fd277b55e 100644 --- a/packages/spotlight/src/modes/command/global-group.tsx +++ b/packages/spotlight/src/modes/command/global-group.tsx @@ -95,9 +95,7 @@ export const globalCommandGroup = createGroup({ icon: IconPlug, name: tOption("newIntegration.label"), useInteraction: interaction.children(newIntegrationChildrenOptions), - hidden: - !session?.user.permissions.includes("integration-full-all") || - !session?.user.permissions.includes("integration-create"), + hidden: !session?.user.permissions.includes("integration-create"), }, { commandKey: "newUser", diff --git a/packages/spotlight/src/modes/page/pages-search-group.tsx b/packages/spotlight/src/modes/page/pages-search-group.tsx index 1b3b0ee68..66fe1ce4f 100644 --- a/packages/spotlight/src/modes/page/pages-search-group.tsx +++ b/packages/spotlight/src/modes/page/pages-search-group.tsx @@ -82,7 +82,7 @@ export const pagesSearchGroup = createGroup<{ icon: IconPlug, path: "/manage/integrations", name: t("manageIntegration.label"), - hidden: !session?.user.permissions.includes("integration-full-all"), + hidden: !session, }, { icon: IconSearch, diff --git a/packages/validation/src/app.ts b/packages/validation/src/app.ts index e3b94218b..21a4335be 100644 --- a/packages/validation/src/app.ts +++ b/packages/validation/src/app.ts @@ -5,10 +5,6 @@ export const appHrefSchema = z .trim() .url() .regex(/^(?!javascript)[a-zA-Z]*:\/\//i) // javascript: is not allowed, i for case insensitive (so Javascript: is also not allowed) - .or(z.string().trim().includes("[homarr_base]")) - .or(z.string().trim().includes("[homarr_hostname]")) - .or(z.string().trim().includes("[homarr_domain]")) - .or(z.string().trim().includes("[homarr_protocol]")) .or(z.literal("")) .transform((value) => (value.length === 0 ? null : value)) .nullable(); From 55214ccbc59143b2606c2fc61e82deaa6ad4d985 Mon Sep 17 00:00:00 2001 From: Thomas Date: Thu, 6 Aug 2026 21:42:46 +0200 Subject: [PATCH 07/17] fix(integrations): throw on total item-info failure to preserve stale-cache fallback (#6522) --- packages/definitions/src/docs/homarr-docs-sitemap.ts | 1 + .../integrations/src/overseerr/overseerr-integration.ts | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/definitions/src/docs/homarr-docs-sitemap.ts b/packages/definitions/src/docs/homarr-docs-sitemap.ts index 22bc5f75b..7185a2fdd 100644 --- a/packages/definitions/src/docs/homarr-docs-sitemap.ts +++ b/packages/definitions/src/docs/homarr-docs-sitemap.ts @@ -119,6 +119,7 @@ export type HomarrDocumentationPath = | "/docs/tags/variables" | "/docs/tags/widgets" | "/docs/advanced/command-line" + | "/docs/advanced/command-line/development" | "/docs/advanced/command-line/fix-usernames" | "/docs/advanced/command-line/integrations" | "/docs/advanced/command-line/password-recovery" diff --git a/packages/integrations/src/overseerr/overseerr-integration.ts b/packages/integrations/src/overseerr/overseerr-integration.ts index 9de482008..10aa73114 100644 --- a/packages/integrations/src/overseerr/overseerr-integration.ts +++ b/packages/integrations/src/overseerr/overseerr-integration.ts @@ -185,9 +185,15 @@ export class OverseerrIntegration }), ); - return settled + const fulfilled = settled .filter((result): result is PromiseFulfilledResult => result.status === "fulfilled") .map((result) => result.value); + + if (fulfilled.length === 0) { + throw new Error("Failed to resolve any media request information"); + } + + return fulfilled; } protected mapRequestStatus(status: UpstreamMediaRequestStatus): MediaRequestStatus { From 637f03e1077c664c7fc5472aab2e3cce02e2df44 Mon Sep 17 00:00:00 2001 From: Thomas Date: Thu, 6 Aug 2026 21:47:04 +0200 Subject: [PATCH 08/17] docs(settings): update analytics docs to PostHog (#6525) --- apps/docs/docs/management/settings/index.mdx | 134 +++++++++++-------- 1 file changed, 81 insertions(+), 53 deletions(-) diff --git a/apps/docs/docs/management/settings/index.mdx b/apps/docs/docs/management/settings/index.mdx index d7705e61a..016d56d10 100644 --- a/apps/docs/docs/management/settings/index.mdx +++ b/apps/docs/docs/management/settings/index.mdx @@ -9,97 +9,125 @@ tags: --- # Settings -The settings page has a few options that will be applied to the entire Homarr application. -They may require a full page reload for changes to apply. + +The settings page applies options to the whole Homarr application. +Some changes need a full page reload before they take effect. ## Analytics -By default, Homarr is collecting anonymous analytics to identify issues and prioritize bugfixes and improvements. -The data is never shared with any third-parties and is fully GDPR & CCPA compliant. -The software being used is [Umami Analytics](https://umami.is/) which is fully open source. -The main server software can be found [here](https://github.com/umami-software/umami) and [this node library](https://github.com/umami-software/node) to perform server side calls to the API. -A full list of the collected data by default can be found here: https://umami.is/docs/metric-definitions. +Homarr uses [PostHog](https://posthog.com/) to collect anonymous usage data. +We collect this data to see what features people ACTUALLY use. +We use this data to decide where we spend our development time. -We understand that you might not want Homarr to communicate with our services. -By unchecking the ``Send anonymous analytics`` checkbox and reloading the full page, all communication by Umami will be disabled and no data will be collected. -If you are paranoid, you can permanently block ``umami.homarr.dev`` from your DNS / firewall. +We do not sell the data. We do not share the data with third parties. +The data does not contain personal information and does not identify you or your installation. -Additionally, you opt in to more data collection to help us provide more useful data. +Homarr sends the data to our own PostHog server at `https://hog.homarr.dev`. +The server sends the data once a week and when the server starts. -### Integration data -Enabling this optional data will send the types of integrations (e.g. SABnzbd, Sonarr, ...) and how many you have configured of them. -This will not include any URLs, names, secrets, errors and others. This is disabled by default. +### What we collect -### Widget data -Enabling this optional data will send the types of widgets (e.g. downloads, media streams, iframe, ...) and how many you have configured of them. -This will not include any URLs, names, secrets, errors and others. This is disabled by default. +Homarr collects technical data about your installation: -### Users data -Enabling this optional data will send the count of users and whether you activated SSO. -This will not include any usernames, passwords, emails, URLs, authentication flows or client IDs. -This is disabled by default. +- Your Homarr version +- Your database type (SQLite, MySQL, or PostgreSQL) +- Whether Docker or Kubernetes is enabled +- Your enabled authentication providers +- Your operating system and CPU architecture +- Your default language +- The number of boards, apps, sections, layouts, users, groups, search engines, icon repositories, cron jobs, API keys, invites, sessions, and accounts +- The integration types you use and how many you have of each (for example, Sonarr or SABnzbd) +- The widget types you use and how many you have of each + +Homarr does NOT collect: + +- URLs +- Names +- Usernames, emails, or passwords +- Integration secrets or API keys +- Error messages or logs +- Any data that can identify you + +Each installation gets a random ID. We use this ID only to count unique installations. We cannot use this ID to identify you. + +Homarr does not use cookies for analytics. Homarr does not track page views automatically. Homarr only sends the data listed above. + +### Disable analytics + +Analytics are enabled by default. To disable analytics: + +1. Open the Settings page. +2. Uncheck the "Help us improve Homarr" checkbox. +3. Reload the full page. + +When analytics are disabled, Homarr does not send any data to PostHog. + +You can also set the `NO_EXTERNAL_CONNECTION` environment variable to `true`. This stops all external requests, including analytics. See the [environment variables](/docs/advanced/environment-variables) page for more information. + +You can block `hog.homarr.dev` in your DNS or firewall. Homarr cannot send data when this address is blocked. ## Search Engine Optimization (SEO) -Big companies [like Google, Microsoft, Baidu and Yandex](https://de.wikipedia.org/wiki/Web-Index) will use [web crawlers](https://de.wikipedia.org/wiki/Webcrawler) to index the Internet for new websites & pages. -This is desirable in many cases (for example when selling products in an online shop) but can lead to unwanted traffic and attention to your personal dashboard. -Homarr is requesting the crawlers to not index Homarr instances by default for this reason. -If you use Homarr as a public website and want it to be searchable on these search engines, you can disable these settings. -Please note that Homarr doesn't have control over what the crawlers do; it is a mere request to them to not perform these actions but will not guarantee that your website doesn't land on some index. + +Search engines use web crawlers to index websites. This can cause unwanted traffic to your dashboard. +Homarr asks crawlers to not index your instance by default. Use these settings to change this request. + +Homarr cannot control what the crawlers do. A crawler can ignore the request. Your website can still appear in an index. ### No index -This will ask crawlers to not index your Homarr instance on any indexes. -It is up to the crawler to properly respect this request. -By default, this setting is enabled. Disabling it will allow indexing. + +This setting asks crawlers to not index your instance. +This setting is enabled by default. Disable this setting to allow indexing. ### No follow -If indexing is allowed, crawlers will also attempt to follow any links on your page to further "discover" new parts of the Internet and on your website. -By default, this setting is enabled and will ask crawlers to not follow any links. -Crawler of different companies may behave differently when indexing is prohibited and following is allowed. + +Crawlers follow links to discover new pages. +This setting asks crawlers to not follow links. This setting is enabled by default. +Crawler behavior can differ between search engines. ### No translate -Google and Chrome on Android will prompt users to translate pages that are not in their device language. -This can lead to some shifting and can be undesired in some cases. By enabling this, -you ask crawlers to never translate and never prompt users for translation. -It should be noted that some browsers will ignore this and translate regardless. + +Some browsers offer to translate pages. +This setting asks crawlers to not offer translation. Some browsers ignore this setting. ### No site links search box -This setting is exclusive to Google. -[Google will create a box below your domain](https://developers.google.com/search/docs/appearance/structured-data/sitelinks-searchbox) that lists the paths that were discovered by the "folowing". -The user can search them directly in Google. -You can ask the crawlers to not allow searches by enabling this setting. -This setting is disabled by default. + +This setting applies to Google only. +Google can show a search box below your domain. +This setting asks Google to not show this box. This setting is disabled by default. ## Boards ### Global home board -You can set a global home board that will be used for all users that have not set a home board themselves. -It will also be used for anonymous users. -Note that the board must be public to be used as a global home board. + +You can set a global home board. +This board is the fallback for users without their own home board. It is also the fallback for anonymous users. +The board must be public. ### Global mobile board -You can set a global mobile board. It will be shown instead of the global home board on mobile devices that can be detected by Homarr. (Using the User-Agent header). -It will similar to the global home board be as fallback for normal users and for any anonymous users. -It also has to be public. +You can set a global mobile board. +Homarr shows this board on mobile devices instead of the global home board. +Homarr detects mobile devices with the User-Agent header. +The board must be public. ### App status #### Enable status by default -You can decide if the app status should be enabled by default when adding a new app to the board. +You can decide if app status is enabled when you add a new app. #### Force disable status -You can decide if the app status should be disabled for all boards. - -### +You can decide if app status is disabled on all boards. ## Appearance ### Default color scheme -You can set the default color scheme for all users that have not set a color scheme themselves. + +You can set the default color scheme for users without their own color scheme. ## Culture ### Default language -You can set the default language for all users that have not set a language themselves. \ No newline at end of file + +You can set the default language for users without their own language. From f76a0f240a016261d781949cac84102c700c83b1 Mon Sep 17 00:00:00 2001 From: Thomas Date: Thu, 6 Aug 2026 21:52:56 +0200 Subject: [PATCH 09/17] feat(apps): allow custom URI schemes in application links (#6521) --- apps/docs/docs/management/apps/index.mdx | 11 +++++++---- packages/api/src/router/app.ts | 2 +- packages/validation/src/app.ts | 3 +-- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/docs/docs/management/apps/index.mdx b/apps/docs/docs/management/apps/index.mdx index 35c02a4fc..7c84df588 100644 --- a/apps/docs/docs/management/apps/index.mdx +++ b/apps/docs/docs/management/apps/index.mdx @@ -7,8 +7,8 @@ tags: - Ping --- -Apps are the building blocks of your Homarr board. -Most of the time they are a simple link to a website, but used on your board they also support a simple ping to check if your service is up. +Apps are the building blocks of your Homarr board. +Most of the time they are a simple link to a website, but used on your board they also support a simple ping to check if your service is up. Apps are used in the bookmark and app widget. ## Create an app @@ -27,7 +27,7 @@ The name must be at least one character long and can be chosen freely. ### Icon URL -This is the URL to the icon that will be displayed on the app widget. +This is the URL to the icon that will be displayed on the app widget. You can paste a direct HTTP(S) URL to an image, search the included icon repositories by name, or upload a local file through the upload button. Direct image URLs are used as entered and are not searched in the icon repositories. ![Icon URL](./img/app-icon-url.png) @@ -40,11 +40,14 @@ An optional description that can be shown as tooltip on the app widget. The URL to the website you want to link to. Clicking on the app widget or bookmark will open this URL. +You can also use custom URI schemes such as `vscode://file/path`, `microsoft-edge:https://example.com`, or `myapp://open` to link to local applications or third-party programs. We trust that you know what you are doing — only `javascript:` URIs are blocked for security reasons. + ### Ping URL -When you want to use another url for the simple status check you can set it here. +When you want to use another url for the simple status check you can set it here. This will allow you to set a different URL for the ping than the URL that is opened when clicking on the app widget. It's useful when you want to ping the service internally for example with a custom ip but open the website externally. +Note that the ping URL only accepts `http://` and `https://` URLs — custom URI schemes are not supported for pings. ## List of apps diff --git a/packages/api/src/router/app.ts b/packages/api/src/router/app.ts index 3d0285689..3c623b55a 100644 --- a/packages/api/src/router/app.ts +++ b/packages/api/src/router/app.ts @@ -173,7 +173,7 @@ export const appRouter = createTRPCRouter({ mcp: { enabled: true, description: - "Create a new app (bookmark/shortcut to a service). REQUIRED: name (string), iconUrl (icon URL string), href (app URL, http/https or blank). OPTIONAL: description (string or null), pingUrl (URL to check reachability, or empty string)", + "Create a new app (bookmark/shortcut to a service). REQUIRED: name (string), iconUrl (icon URL string), href (app URL or custom URI scheme such as vscode://, or blank). OPTIONAL: description (string or null), pingUrl (URL to check reachability, or empty string)", }, }) .mutation(async ({ ctx, input }) => { diff --git a/packages/validation/src/app.ts b/packages/validation/src/app.ts index 21a4335be..dfc7652a7 100644 --- a/packages/validation/src/app.ts +++ b/packages/validation/src/app.ts @@ -3,8 +3,7 @@ import { z } from "zod/v4"; export const appHrefSchema = z .string() .trim() - .url() - .regex(/^(?!javascript)[a-zA-Z]*:\/\//i) // javascript: is not allowed, i for case insensitive (so Javascript: is also not allowed) + .refine((val) => !/^javascript:/i.test(val), "JavaScript URIs are not permitted") .or(z.literal("")) .transform((value) => (value.length === 0 ? null : value)) .nullable(); From 85fcea383773e33e1c3bb28c9d5ef4b64111ab48 Mon Sep 17 00:00:00 2001 From: "homarr-crowdin[bot]" <190541745+homarr-crowdin[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:22:43 +0200 Subject: [PATCH 10/17] chore(lang): updated translations from crowdin (#6500) --- packages/translation/src/lang/ca.json | 53 +- packages/translation/src/lang/cn.json | 53 +- packages/translation/src/lang/cr.json | 53 +- packages/translation/src/lang/cs.json | 53 +- packages/translation/src/lang/da.json | 53 +- packages/translation/src/lang/de-CH.json | 53 +- packages/translation/src/lang/de.json | 101 +- packages/translation/src/lang/el.json | 53 +- packages/translation/src/lang/en-gb.json | 83 +- packages/translation/src/lang/es.json | 53 +- packages/translation/src/lang/et.json | 53 +- packages/translation/src/lang/fi.json | 53 +- packages/translation/src/lang/fr.json | 53 +- packages/translation/src/lang/he.json | 53 +- packages/translation/src/lang/hr.json | 53 +- packages/translation/src/lang/hu.json | 53 +- packages/translation/src/lang/it.json | 53 +- packages/translation/src/lang/ja.json | 53 +- packages/translation/src/lang/ko.json | 53 +- packages/translation/src/lang/lt.json | 53 +- packages/translation/src/lang/lv.json | 53 +- packages/translation/src/lang/nl.json | 53 +- packages/translation/src/lang/no.json | 53 +- packages/translation/src/lang/pl.json | 53 +- packages/translation/src/lang/pt-br.json | 53 +- packages/translation/src/lang/pt.json | 53 +- packages/translation/src/lang/ro.json | 53 +- packages/translation/src/lang/ru.json | 53 +- packages/translation/src/lang/sk.json | 53 +- packages/translation/src/lang/sl.json | 53 +- packages/translation/src/lang/sv.json | 53 +- packages/translation/src/lang/tr.json | 1573 +++++++++++----------- packages/translation/src/lang/uk.json | 53 +- packages/translation/src/lang/vi.json | 53 +- packages/translation/src/lang/zh.json | 53 +- 35 files changed, 2094 insertions(+), 1359 deletions(-) diff --git a/packages/translation/src/lang/ca.json b/packages/translation/src/lang/ca.json index 446cae858..191706c23 100644 --- a/packages/translation/src/lang/ca.json +++ b/packages/translation/src/lang/ca.json @@ -2428,10 +2428,21 @@ }, "descendingDefaultSort": { "label": "" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "error": { - "internalServerError": "" + "internalServerError": "", + "layoutSaveTitle": "", + "layoutSaveMessage": "" + }, + "empty": { + "noContainers": "" } }, "coolify": { @@ -2725,8 +2736,20 @@ "showOnlyPlaying": { "label": "", "description": "" + }, + "showBitrate": { + "label": "", + "description": "" + }, + "showLocation": { + "label": "", + "description": "" } }, + "footer": { + "streams": "", + "totalBitrate": "" + }, "items": { "currentlyPlaying": "S'està reproduint", "user": "Usuari", @@ -2734,6 +2757,9 @@ "id": "ID", "status": "", "directPlay": "", + "directStream": "", + "transcodeVideo": "", + "transcodeAudio": "", "transcoding": "", "paused": "", "remaining": "", @@ -3768,18 +3794,6 @@ "label": "", "description": "" }, - "showNowPlaying": { - "label": "", - "description": "" - }, - "showNowPlayingList": { - "label": "", - "description": "" - }, - "maxNowPlayingItems": { - "label": "", - "description": "" - }, "showLibraryCount": { "label": "", "description": "" @@ -3813,8 +3827,6 @@ "podcasts": "", "listeningTime": "", "activeSessions": "", - "nowPlaying": "", - "nowPlayingCount": "", "error": { "internalServerError": "" } @@ -3954,11 +3966,19 @@ }, "showAgent": { "label": "" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "noRecords": "", "error": { - "internalServerError": "" + "internalServerError": "", + "layoutSaveTitle": "", + "layoutSaveMessage": "" }, "column": { "system": "", @@ -5356,6 +5376,7 @@ }, "remove": { "label": "Elimina", + "confirm": "", "notification": { "success": { "title": "Contenidors eliminats", diff --git a/packages/translation/src/lang/cn.json b/packages/translation/src/lang/cn.json index 11bf03043..79145d7fd 100644 --- a/packages/translation/src/lang/cn.json +++ b/packages/translation/src/lang/cn.json @@ -2428,10 +2428,21 @@ }, "descendingDefaultSort": { "label": "倒序" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "error": { - "internalServerError": "获取容器统计信息失败" + "internalServerError": "获取容器统计信息失败", + "layoutSaveTitle": "", + "layoutSaveMessage": "" + }, + "empty": { + "noContainers": "" } }, "coolify": { @@ -2725,8 +2736,20 @@ "showOnlyPlaying": { "label": "只显示当前正在播放", "description": "禁用此功能对plex不起作用" + }, + "showBitrate": { + "label": "", + "description": "" + }, + "showLocation": { + "label": "", + "description": "" } }, + "footer": { + "streams": "", + "totalBitrate": "" + }, "items": { "currentlyPlaying": "正在播放", "user": "用户", @@ -2734,6 +2757,9 @@ "id": "ID", "status": "", "directPlay": "", + "directStream": "", + "transcodeVideo": "", + "transcodeAudio": "", "transcoding": "", "paused": "", "remaining": "", @@ -3768,18 +3794,6 @@ "label": "显示歌曲", "description": "显示音乐库中歌曲的总数 (Navidrome)" }, - "showNowPlaying": { - "label": "显示正在播放", - "description": "显示当前播放的曲目 (Navidrome)" - }, - "showNowPlayingList": { - "label": "显示正在播放列表", - "description": "显示单个曲目而不仅仅是活跃流计数 (Navidrome)" - }, - "maxNowPlayingItems": { - "label": "最大正在播放项目数", - "description": "正在播放列表中显示的最大曲目数 (Navidrome)" - }, "showLibraryCount": { "label": "显示库数量", "description": "显示实例中的库数量 (Audiobookshelf)" @@ -3813,8 +3827,6 @@ "podcasts": "播客", "listeningTime": "收听时长", "activeSessions": "活跃会话", - "nowPlaying": "正在播放", - "nowPlayingCount": "{count, plural, other {# 个流}}", "error": { "internalServerError": "获取音频统计数据失败" } @@ -3954,11 +3966,19 @@ }, "showAgent": { "label": "显示代理版本" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "noRecords": "未找到系统", "error": { - "internalServerError": "" + "internalServerError": "", + "layoutSaveTitle": "", + "layoutSaveMessage": "" }, "column": { "system": "系统", @@ -5356,6 +5376,7 @@ }, "remove": { "label": "删除", + "confirm": "", "notification": { "success": { "title": "容器已删除", diff --git a/packages/translation/src/lang/cr.json b/packages/translation/src/lang/cr.json index ecfba6a40..a164a6354 100644 --- a/packages/translation/src/lang/cr.json +++ b/packages/translation/src/lang/cr.json @@ -2428,10 +2428,21 @@ }, "descendingDefaultSort": { "label": "crwdns3820:0crwdne3820:0" + }, + "columnOrder": { + "label": "crwdns6155:0crwdne6155:0" + }, + "columnWidths": { + "label": "crwdns6157:0crwdne6157:0" } }, "error": { - "internalServerError": "crwdns3428:0crwdne3428:0" + "internalServerError": "crwdns3428:0crwdne3428:0", + "layoutSaveTitle": "crwdns6159:0crwdne6159:0", + "layoutSaveMessage": "crwdns6161:0crwdne6161:0" + }, + "empty": { + "noContainers": "crwdns6163:0crwdne6163:0" } }, "coolify": { @@ -2725,8 +2736,20 @@ "showOnlyPlaying": { "label": "crwdns3176:0crwdne3176:0", "description": "crwdns3178:0crwdne3178:0" + }, + "showBitrate": { + "label": "crwdns6175:0crwdne6175:0", + "description": "crwdns6177:0crwdne6177:0" + }, + "showLocation": { + "label": "crwdns6179:0crwdne6179:0", + "description": "crwdns6181:0crwdne6181:0" } }, + "footer": { + "streams": "crwdns6183:0count={count}crwdne6183:0", + "totalBitrate": "crwdns6185:0{bitrate}crwdne6185:0" + }, "items": { "currentlyPlaying": "crwdns2847:0crwdne2847:0", "user": "crwdns2476:0crwdne2476:0", @@ -2734,6 +2757,9 @@ "id": "crwdns2480:0crwdne2480:0", "status": "crwdns6085:0crwdne6085:0", "directPlay": "crwdns6087:0crwdne6087:0", + "directStream": "crwdns6187:0crwdne6187:0", + "transcodeVideo": "crwdns6189:0crwdne6189:0", + "transcodeAudio": "crwdns6191:0crwdne6191:0", "transcoding": "crwdns6089:0crwdne6089:0", "paused": "crwdns6091:0crwdne6091:0", "remaining": "crwdns6093:0{minutes}crwdne6093:0", @@ -3768,18 +3794,6 @@ "label": "crwdns5109:0crwdne5109:0", "description": "crwdns5111:0crwdne5111:0" }, - "showNowPlaying": { - "label": "crwdns5113:0crwdne5113:0", - "description": "crwdns5115:0crwdne5115:0" - }, - "showNowPlayingList": { - "label": "crwdns5117:0crwdne5117:0", - "description": "crwdns5119:0crwdne5119:0" - }, - "maxNowPlayingItems": { - "label": "crwdns5121:0crwdne5121:0", - "description": "crwdns5123:0crwdne5123:0" - }, "showLibraryCount": { "label": "crwdns5125:0crwdne5125:0", "description": "crwdns5127:0crwdne5127:0" @@ -3813,8 +3827,6 @@ "podcasts": "crwdns5159:0crwdne5159:0", "listeningTime": "crwdns5161:0crwdne5161:0", "activeSessions": "crwdns5163:0crwdne5163:0", - "nowPlaying": "crwdns5165:0crwdne5165:0", - "nowPlayingCount": "crwdns5167:0count={count}crwdne5167:0", "error": { "internalServerError": "crwdns5169:0crwdne5169:0" } @@ -3954,11 +3966,19 @@ }, "showAgent": { "label": "crwdns4809:0crwdne4809:0" + }, + "columnOrder": { + "label": "crwdns6165:0crwdne6165:0" + }, + "columnWidths": { + "label": "crwdns6167:0crwdne6167:0" } }, "noRecords": "crwdns4811:0crwdne4811:0", "error": { - "internalServerError": "crwdns5651:0crwdne5651:0" + "internalServerError": "crwdns5651:0crwdne5651:0", + "layoutSaveTitle": "crwdns6169:0crwdne6169:0", + "layoutSaveMessage": "crwdns6171:0crwdne6171:0" }, "column": { "system": "crwdns4813:0crwdne4813:0", @@ -5356,6 +5376,7 @@ }, "remove": { "label": "crwdns1952:0crwdne1952:0", + "confirm": "crwdns6173:0crwdne6173:0", "notification": { "success": { "title": "crwdns1954:0crwdne1954:0", diff --git a/packages/translation/src/lang/cs.json b/packages/translation/src/lang/cs.json index 3ab9bd708..8e9a14e72 100644 --- a/packages/translation/src/lang/cs.json +++ b/packages/translation/src/lang/cs.json @@ -2428,10 +2428,21 @@ }, "descendingDefaultSort": { "label": "" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "error": { - "internalServerError": "" + "internalServerError": "", + "layoutSaveTitle": "", + "layoutSaveMessage": "" + }, + "empty": { + "noContainers": "" } }, "coolify": { @@ -2725,8 +2736,20 @@ "showOnlyPlaying": { "label": "", "description": "" + }, + "showBitrate": { + "label": "", + "description": "" + }, + "showLocation": { + "label": "", + "description": "" } }, + "footer": { + "streams": "", + "totalBitrate": "" + }, "items": { "currentlyPlaying": "", "user": "Uživatel", @@ -2734,6 +2757,9 @@ "id": "Id", "status": "", "directPlay": "", + "directStream": "", + "transcodeVideo": "", + "transcodeAudio": "", "transcoding": "", "paused": "", "remaining": "", @@ -3768,18 +3794,6 @@ "label": "", "description": "" }, - "showNowPlaying": { - "label": "", - "description": "" - }, - "showNowPlayingList": { - "label": "", - "description": "" - }, - "maxNowPlayingItems": { - "label": "", - "description": "" - }, "showLibraryCount": { "label": "", "description": "" @@ -3813,8 +3827,6 @@ "podcasts": "", "listeningTime": "", "activeSessions": "", - "nowPlaying": "", - "nowPlayingCount": "", "error": { "internalServerError": "" } @@ -3954,11 +3966,19 @@ }, "showAgent": { "label": "Zobrazit verzi agenta" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "noRecords": "Nenalezeny žádné systémy", "error": { - "internalServerError": "" + "internalServerError": "", + "layoutSaveTitle": "", + "layoutSaveMessage": "" }, "column": { "system": "Systém", @@ -5356,6 +5376,7 @@ }, "remove": { "label": "Odstranit", + "confirm": "", "notification": { "success": { "title": "", diff --git a/packages/translation/src/lang/da.json b/packages/translation/src/lang/da.json index 9bc29daf7..5f0de785b 100644 --- a/packages/translation/src/lang/da.json +++ b/packages/translation/src/lang/da.json @@ -2428,10 +2428,21 @@ }, "descendingDefaultSort": { "label": "Inverter sortering" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "error": { - "internalServerError": "Kunne ikke hente containerstatistik" + "internalServerError": "Kunne ikke hente containerstatistik", + "layoutSaveTitle": "", + "layoutSaveMessage": "" + }, + "empty": { + "noContainers": "" } }, "coolify": { @@ -2725,8 +2736,20 @@ "showOnlyPlaying": { "label": "Vis kun hvad der aktuelt afspilles", "description": "Deaktivering af dette vil ikke virke for plex" + }, + "showBitrate": { + "label": "", + "description": "" + }, + "showLocation": { + "label": "", + "description": "" } }, + "footer": { + "streams": "", + "totalBitrate": "" + }, "items": { "currentlyPlaying": "Afspiller lige nu", "user": "Bruger", @@ -2734,6 +2757,9 @@ "id": "Id", "status": "", "directPlay": "", + "directStream": "", + "transcodeVideo": "", + "transcodeAudio": "", "transcoding": "", "paused": "", "remaining": "", @@ -3768,18 +3794,6 @@ "label": "", "description": "" }, - "showNowPlaying": { - "label": "", - "description": "" - }, - "showNowPlayingList": { - "label": "", - "description": "" - }, - "maxNowPlayingItems": { - "label": "", - "description": "" - }, "showLibraryCount": { "label": "", "description": "" @@ -3813,8 +3827,6 @@ "podcasts": "", "listeningTime": "", "activeSessions": "", - "nowPlaying": "", - "nowPlayingCount": "", "error": { "internalServerError": "" } @@ -3954,11 +3966,19 @@ }, "showAgent": { "label": "Vis agentversion" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "noRecords": "Ingen systemer fundet", "error": { - "internalServerError": "" + "internalServerError": "", + "layoutSaveTitle": "", + "layoutSaveMessage": "" }, "column": { "system": "System", @@ -5356,6 +5376,7 @@ }, "remove": { "label": "Fjern", + "confirm": "", "notification": { "success": { "title": "Containere fjernet", diff --git a/packages/translation/src/lang/de-CH.json b/packages/translation/src/lang/de-CH.json index c7806787e..c29ee7f19 100644 --- a/packages/translation/src/lang/de-CH.json +++ b/packages/translation/src/lang/de-CH.json @@ -2428,10 +2428,21 @@ }, "descendingDefaultSort": { "label": "" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "error": { - "internalServerError": "" + "internalServerError": "", + "layoutSaveTitle": "", + "layoutSaveMessage": "" + }, + "empty": { + "noContainers": "" } }, "coolify": { @@ -2725,8 +2736,20 @@ "showOnlyPlaying": { "label": "", "description": "" + }, + "showBitrate": { + "label": "", + "description": "" + }, + "showLocation": { + "label": "", + "description": "" } }, + "footer": { + "streams": "", + "totalBitrate": "" + }, "items": { "currentlyPlaying": "Aktuelle Wiedergabe", "user": "Benutzer", @@ -2734,6 +2757,9 @@ "id": "", "status": "", "directPlay": "", + "directStream": "", + "transcodeVideo": "", + "transcodeAudio": "", "transcoding": "", "paused": "", "remaining": "", @@ -3768,18 +3794,6 @@ "label": "", "description": "" }, - "showNowPlaying": { - "label": "", - "description": "" - }, - "showNowPlayingList": { - "label": "", - "description": "" - }, - "maxNowPlayingItems": { - "label": "", - "description": "" - }, "showLibraryCount": { "label": "", "description": "" @@ -3813,8 +3827,6 @@ "podcasts": "", "listeningTime": "", "activeSessions": "", - "nowPlaying": "", - "nowPlayingCount": "", "error": { "internalServerError": "" } @@ -3954,11 +3966,19 @@ }, "showAgent": { "label": "" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "noRecords": "", "error": { - "internalServerError": "" + "internalServerError": "", + "layoutSaveTitle": "", + "layoutSaveMessage": "" }, "column": { "system": "", @@ -5356,6 +5376,7 @@ }, "remove": { "label": "Entfernen", + "confirm": "", "notification": { "success": { "title": "Container entfernt", diff --git a/packages/translation/src/lang/de.json b/packages/translation/src/lang/de.json index 924f7f877..3b4d0efa3 100644 --- a/packages/translation/src/lang/de.json +++ b/packages/translation/src/lang/de.json @@ -1342,19 +1342,19 @@ }, "traefik": { "name": "Traefik", - "description": "" + "description": "Cloud-nativer Reverse-Proxy und Lastverteiler" }, "paperlessNgx": { "name": "Paperless-ngx", "description": "Dokumentenverwaltungssystem zum Archivieren und Organisieren gescannter Dokumente" }, "patchmon": { - "name": "", - "description": "" + "name": "PatchMon", + "description": "Patch-Verwaltung und Systemüberwachung für Linux-Hosts" }, "bazarr": { "name": "Bazarr", - "description": "" + "description": "Schwesteranwendung zu Sonarr und Radarr, welche Untertitel und Downloads verwaltet" } }, "media": { @@ -2328,8 +2328,8 @@ "label": "Dateisystem Info anzeigen" }, "visibleStorageVolumes": { - "label": "", - "description": "", + "label": "Sichtbare Speichervolumes", + "description": "Nur verfügbar, wenn alle Integrationen Synology betreffen. Nicht ausfüllen, um alle erkannten Speichervolumes zu zeigen. ", "placeholder": "Speichervolumen auswählen" }, "defaultTab": { @@ -2428,10 +2428,21 @@ }, "descendingDefaultSort": { "label": "Sortierung umkehren" + }, + "columnOrder": { + "label": "Spaltenordnung" + }, + "columnWidths": { + "label": "Spaltenbreite" } }, "error": { - "internalServerError": "Fehler beim Abrufen der Container Statistiken" + "internalServerError": "Fehler beim Abrufen der Container Statistiken", + "layoutSaveTitle": "Tabellenlayout nicht gespeichert", + "layoutSaveMessage": "Die Docker-Tabelle konnte nicht gespeichert werden. Versuche die Spalte zu verschieben oder eine andere Spaltengröße zu verwenden. " + }, + "empty": { + "noContainers": "Keine Docker-Container gefunden" } }, "coolify": { @@ -2725,8 +2736,20 @@ "showOnlyPlaying": { "label": "Nur aktuelle Wiedergabe anzeigen", "description": "Diese Einstellung zu deaktivieren wird für Plex nicht funktionieren" + }, + "showBitrate": { + "label": "Zeigt die Bitrate an", + "description": "" + }, + "showLocation": { + "label": "", + "description": "Zeigt an, ob der Stream über das LAN oder WAN läuft" } }, + "footer": { + "streams": "", + "totalBitrate": "Gesamtbitrate: {bitrate}" + }, "items": { "currentlyPlaying": "Aktuelle Wiedergabe", "user": "Benutzer", @@ -2734,6 +2757,9 @@ "id": "Id", "status": "Status", "directPlay": "Direkte Wiedergabe", + "directStream": "Direkter Stream", + "transcodeVideo": "", + "transcodeAudio": "", "transcoding": "Transkodierung", "paused": "Pausiert", "remaining": "{minutes} Min. verbleibend", @@ -2803,21 +2829,21 @@ } }, "patchmon": { - "name": "", + "name": "PatchMon", "description": "", - "title": "", + "title": "PatchMon-Statistiken", "totalHosts": "Hosts gesamt", "hostsNeedingUpdates": "Update erforderlich", "securityUpdates": "Sicherheits-Updates", - "upToDateHosts": "", + "upToDateHosts": "Aktualisierte Hosts", "hostsWithSecurityUpdates": "Hosts mit Sicherheits-Updates", "recentUpdates24h": "Updates (24h)", "totalOutdatedPackages": "Veraltete Pakete", "totalRepos": "Repositories", - "osDistribution": "", - "compliance": "", - "complianceRatio": "", - "lastUpdated": "", + "osDistribution": "Betriebssystem-Distribution", + "compliance": "Patch-Kompatibilität", + "complianceRatio": "{upToDate} / {total}", + "lastUpdated": "Zuletzt aktualisiert: {time}", "option": { "showTotalHosts": { "label": "Alle Hosts anzeigen", @@ -2833,30 +2859,30 @@ }, "showUpToDateHosts": { "label": "Zeige aktuelle Hosts", - "description": "" + "description": "Zeigt Hosts mit keinen veralteten Paketen an" }, "showHostsWithSecurityUpdates": { - "label": "", - "description": "" + "label": "Zeigt Hosts mit Sicherheitsupdates an", + "description": "Zeigt Hosts an, welche Sicherheitsupdates benötigen" }, "showRecentUpdates24h": { - "label": "", - "description": "" + "label": "Zeigt vergangene Updates in den letzten 24 Stunden an", + "description": "Zeigt die Anzahl der angewendeten Updates in den letzten 24 Stunden an" }, "showTotalOutdatedPackages": { - "label": "", - "description": "" + "label": "Zeigt veraltete Pakete an", + "description": "Zeigt die Gesamtanzahl der veralteten Pakete über mehrere Hosts verteilt an" }, "showTotalRepos": { - "label": "", - "description": "" + "label": "Zeigt Repositorys an", + "description": "Zeigt die Anzahl der zu überwachenden aktiven Repositorys an" }, "enableThresholdColors": { - "label": "", + "label": "Aktiviert die Schwellwert-Farben", "description": "" }, "useCustomThresholds": { - "label": "", + "label": "Erweiterte Schwellwerte", "description": "" }, "thresholdMode": { @@ -3768,18 +3794,6 @@ "label": "Songs anzeigen", "description": "Zeigt die Gesamtzahl der Titel in deiner Musikbibliothek an (Navidrome)" }, - "showNowPlaying": { - "label": "\"Sie hören\" anzeigen", - "description": "Zeige aktuell gespielte Titel an (Navidrome)" - }, - "showNowPlayingList": { - "label": "\"Sie hören jetzt\" Wiedergabeliste anzeigen", - "description": "Einzelne Tracks anstelle der aktiven Streamanzahl anzeigen (Navidrome)" - }, - "maxNowPlayingItems": { - "label": "Maximale jetzt abgespielte Elemente", - "description": "Maximale Anzahl an Titeln, die in der \"Sie hören jetzt\" Wiedergabeliste angezeigt werden (Navidrome)" - }, "showLibraryCount": { "label": "Anzahl der Bibliotheken anzeigen", "description": "Zeige die Anzahl der Bibliotheken in deiner Instanz (Hörbücherregal)" @@ -3813,8 +3827,6 @@ "podcasts": "Podcasts", "listeningTime": "Hördauer", "activeSessions": "Aktive Sitzungen", - "nowPlaying": "Aktuelle Wiedergabe", - "nowPlayingCount": "{count, plural, one {# stream} other {# streams}}", "error": { "internalServerError": "Fehler beim Abrufen der Audio-Statistikdaten" } @@ -3954,11 +3966,19 @@ }, "showAgent": { "label": "Agent-Version anzeigen" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "noRecords": "Keine Systeme gefunden", "error": { - "internalServerError": "Keine Verbindung zu Beszel. Überprüfen Sie Ihre Integrationseinstellungen." + "internalServerError": "Keine Verbindung zu Beszel. Überprüfen Sie Ihre Integrationseinstellungen.", + "layoutSaveTitle": "", + "layoutSaveMessage": "" }, "column": { "system": "System", @@ -5356,6 +5376,7 @@ }, "remove": { "label": "Entfernen", + "confirm": "", "notification": { "success": { "title": "Container entfernt", diff --git a/packages/translation/src/lang/el.json b/packages/translation/src/lang/el.json index 43f723feb..89b71379e 100644 --- a/packages/translation/src/lang/el.json +++ b/packages/translation/src/lang/el.json @@ -2428,10 +2428,21 @@ }, "descendingDefaultSort": { "label": "" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "error": { - "internalServerError": "" + "internalServerError": "", + "layoutSaveTitle": "", + "layoutSaveMessage": "" + }, + "empty": { + "noContainers": "" } }, "coolify": { @@ -2725,8 +2736,20 @@ "showOnlyPlaying": { "label": "", "description": "" + }, + "showBitrate": { + "label": "", + "description": "" + }, + "showLocation": { + "label": "", + "description": "" } }, + "footer": { + "streams": "", + "totalBitrate": "" + }, "items": { "currentlyPlaying": "", "user": "", @@ -2734,6 +2757,9 @@ "id": "", "status": "", "directPlay": "", + "directStream": "", + "transcodeVideo": "", + "transcodeAudio": "", "transcoding": "", "paused": "", "remaining": "", @@ -3768,18 +3794,6 @@ "label": "Εμφάνιση τραγουδιών", "description": "Εμφάνιση του συνολικού αριθμού τραγουδιών στη βιβλιοθήκη μουσικής σας (Navidrome)" }, - "showNowPlaying": { - "label": "Εμφάνιση τρέχουσας αναπαραγωγής", - "description": "Εμφάνιση κομματιών που αναπαράγονται αυτήν τη στιγμή (Navidrome)" - }, - "showNowPlayingList": { - "label": "Εμφάνιση λίστας τρέχουσας αναπαραγωγής", - "description": "Εμφάνιση μεμονωμένων κομματιών αντί μόνο του αριθμού ενεργών ροών (Navidrome)" - }, - "maxNowPlayingItems": { - "label": "Μέγιστα στοιχεία τρέχουσας αναπαραγωγής", - "description": "Μέγιστος αριθμός κομματιών προς εμφάνιση στη λίστα τρέχουσας αναπαραγωγής (Navidrome)" - }, "showLibraryCount": { "label": "Εμφάνιση αριθμού βιβλιοθηκών", "description": "Εμφάνιση του αριθμού βιβλιοθηκών στην εγκατάστασή σας (Audiobookshelf)" @@ -3813,8 +3827,6 @@ "podcasts": "Ποντκαστ", "listeningTime": "Χρόνος Ακρόασης", "activeSessions": "Ενεργές Συνεδρίες", - "nowPlaying": "Τρέχουσα Αναπαραγωγή", - "nowPlayingCount": "{count, plural, one {# ροή} other {# ροές}}", "error": { "internalServerError": "Αποτυχία λήψης δεδομένων στατιστικών ήχου" } @@ -3954,11 +3966,19 @@ }, "showAgent": { "label": "Εμφάνιση έκδοσης agent" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "noRecords": "Δεν βρέθηκαν συστήματα", "error": { - "internalServerError": "" + "internalServerError": "", + "layoutSaveTitle": "", + "layoutSaveMessage": "" }, "column": { "system": "Σύστημα", @@ -5356,6 +5376,7 @@ }, "remove": { "label": "Αφαίρεση", + "confirm": "", "notification": { "success": { "title": "", diff --git a/packages/translation/src/lang/en-gb.json b/packages/translation/src/lang/en-gb.json index 86937c68a..76abff9a8 100644 --- a/packages/translation/src/lang/en-gb.json +++ b/packages/translation/src/lang/en-gb.json @@ -2428,10 +2428,21 @@ }, "descendingDefaultSort": { "label": "Invert sorting" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "error": { - "internalServerError": "Failed to fetch containers stats" + "internalServerError": "Failed to fetch containers stats", + "layoutSaveTitle": "", + "layoutSaveMessage": "" + }, + "empty": { + "noContainers": "" } }, "coolify": { @@ -2564,26 +2575,26 @@ "description": "Display visitor stats from your Umami analytics instance", "visitors": "visitors", "active": "active", - "pageviews": "", - "visits": "", - "bounceRate": "", - "avgDuration": "", + "pageviews": "pageviews", + "visits": "visits", + "bounceRate": "bounce", + "avgDuration": "avg", "option": { "websiteId": { - "label": "", - "description": "", - "placeholder": "", - "loading": "", - "noWebsites": "", - "configureIntegrationFirst": "" + "label": "Website", + "description": "Select a website tracked by your Umami instance", + "placeholder": "Select a website", + "loading": "Loading websites...", + "noWebsites": "No websites found", + "configureIntegrationFirst": "Connect a Umami integration first" }, "timeFrame": { - "label": "", - "description": "", + "label": "Time frame", + "description": "The time period to display visitor data for", "option": { - "today": "", - "24h": "", - "7d": "", + "today": "Today", + "24h": "Last 24 hours", + "7d": "Last 7 days", "30d": "Last 30 days", "month": "This month", "lastMonth": "Last month" @@ -2725,8 +2736,20 @@ "showOnlyPlaying": { "label": "Show only currently playing", "description": "Disabling this will not work for Plex" + }, + "showBitrate": { + "label": "", + "description": "" + }, + "showLocation": { + "label": "", + "description": "" } }, + "footer": { + "streams": "", + "totalBitrate": "" + }, "items": { "currentlyPlaying": "Currently playing", "user": "User", @@ -2734,6 +2757,9 @@ "id": "Id", "status": "Status", "directPlay": "Direct play", + "directStream": "", + "transcodeVideo": "", + "transcodeAudio": "", "transcoding": "Transcoding", "paused": "Paused", "remaining": "{minutes} min left", @@ -3768,18 +3794,6 @@ "label": "Show songs", "description": "Show the total number of songs in your music library (Navidrome)" }, - "showNowPlaying": { - "label": "Show now playing", - "description": "Show tracks currently being played (Navidrome)" - }, - "showNowPlayingList": { - "label": "Show now playing list", - "description": "Show individual tracks instead of only the active stream count (Navidrome)" - }, - "maxNowPlayingItems": { - "label": "Max now playing items", - "description": "Maximum number of tracks to show in the now playing list (Navidrome)" - }, "showLibraryCount": { "label": "Show library count", "description": "Show the number of libraries on your instance (Audiobookshelf)" @@ -3813,8 +3827,6 @@ "podcasts": "Podcasts", "listeningTime": "Listening Time", "activeSessions": "Active Sessions", - "nowPlaying": "Now Playing", - "nowPlayingCount": "{count, plural, one {# stream} other {# streams}}", "error": { "internalServerError": "Failed to fetch audio stats data" } @@ -3954,11 +3966,19 @@ }, "showAgent": { "label": "Show agent version" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "noRecords": "No systems found", "error": { - "internalServerError": "Unable to connect to Beszel. Check your integration settings." + "internalServerError": "Unable to connect to Beszel. Check your integration settings.", + "layoutSaveTitle": "", + "layoutSaveMessage": "" }, "column": { "system": "System", @@ -5356,6 +5376,7 @@ }, "remove": { "label": "", + "confirm": "", "notification": { "success": { "title": "", diff --git a/packages/translation/src/lang/es.json b/packages/translation/src/lang/es.json index 92b01205c..15a5b1e8d 100644 --- a/packages/translation/src/lang/es.json +++ b/packages/translation/src/lang/es.json @@ -2428,10 +2428,21 @@ }, "descendingDefaultSort": { "label": "Invertir orden" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "error": { - "internalServerError": "No se pudo obtener las estadísticas de los contenedores" + "internalServerError": "No se pudo obtener las estadísticas de los contenedores", + "layoutSaveTitle": "", + "layoutSaveMessage": "" + }, + "empty": { + "noContainers": "" } }, "coolify": { @@ -2725,8 +2736,20 @@ "showOnlyPlaying": { "label": "Mostrar sólo reproducción actual", "description": "Desactivar esta opción no funcionará para plex" + }, + "showBitrate": { + "label": "", + "description": "" + }, + "showLocation": { + "label": "", + "description": "" } }, + "footer": { + "streams": "", + "totalBitrate": "" + }, "items": { "currentlyPlaying": "Reproducción en curso", "user": "Usuario", @@ -2734,6 +2757,9 @@ "id": "ID", "status": "", "directPlay": "", + "directStream": "", + "transcodeVideo": "", + "transcodeAudio": "", "transcoding": "", "paused": "", "remaining": "", @@ -3768,18 +3794,6 @@ "label": "", "description": "" }, - "showNowPlaying": { - "label": "", - "description": "" - }, - "showNowPlayingList": { - "label": "", - "description": "" - }, - "maxNowPlayingItems": { - "label": "", - "description": "" - }, "showLibraryCount": { "label": "", "description": "" @@ -3813,8 +3827,6 @@ "podcasts": "", "listeningTime": "", "activeSessions": "", - "nowPlaying": "", - "nowPlayingCount": "", "error": { "internalServerError": "" } @@ -3954,11 +3966,19 @@ }, "showAgent": { "label": "" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "noRecords": "", "error": { - "internalServerError": "" + "internalServerError": "", + "layoutSaveTitle": "", + "layoutSaveMessage": "" }, "column": { "system": "", @@ -5356,6 +5376,7 @@ }, "remove": { "label": "Eliminar", + "confirm": "", "notification": { "success": { "title": "Contenedores eliminados", diff --git a/packages/translation/src/lang/et.json b/packages/translation/src/lang/et.json index b6eef388c..b490502e0 100644 --- a/packages/translation/src/lang/et.json +++ b/packages/translation/src/lang/et.json @@ -2428,10 +2428,21 @@ }, "descendingDefaultSort": { "label": "" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "error": { - "internalServerError": "" + "internalServerError": "", + "layoutSaveTitle": "", + "layoutSaveMessage": "" + }, + "empty": { + "noContainers": "" } }, "coolify": { @@ -2725,8 +2736,20 @@ "showOnlyPlaying": { "label": "", "description": "" + }, + "showBitrate": { + "label": "", + "description": "" + }, + "showLocation": { + "label": "", + "description": "" } }, + "footer": { + "streams": "", + "totalBitrate": "" + }, "items": { "currentlyPlaying": "", "user": "", @@ -2734,6 +2757,9 @@ "id": "", "status": "", "directPlay": "", + "directStream": "", + "transcodeVideo": "", + "transcodeAudio": "", "transcoding": "", "paused": "", "remaining": "", @@ -3768,18 +3794,6 @@ "label": "", "description": "" }, - "showNowPlaying": { - "label": "", - "description": "" - }, - "showNowPlayingList": { - "label": "", - "description": "" - }, - "maxNowPlayingItems": { - "label": "", - "description": "" - }, "showLibraryCount": { "label": "", "description": "" @@ -3813,8 +3827,6 @@ "podcasts": "", "listeningTime": "", "activeSessions": "", - "nowPlaying": "", - "nowPlayingCount": "", "error": { "internalServerError": "" } @@ -3954,11 +3966,19 @@ }, "showAgent": { "label": "" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "noRecords": "", "error": { - "internalServerError": "" + "internalServerError": "", + "layoutSaveTitle": "", + "layoutSaveMessage": "" }, "column": { "system": "", @@ -5356,6 +5376,7 @@ }, "remove": { "label": "", + "confirm": "", "notification": { "success": { "title": "", diff --git a/packages/translation/src/lang/fi.json b/packages/translation/src/lang/fi.json index 93dad52af..5db39e6f4 100644 --- a/packages/translation/src/lang/fi.json +++ b/packages/translation/src/lang/fi.json @@ -2428,10 +2428,21 @@ }, "descendingDefaultSort": { "label": "Käänteinen järjestys" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "error": { - "internalServerError": "" + "internalServerError": "", + "layoutSaveTitle": "", + "layoutSaveMessage": "" + }, + "empty": { + "noContainers": "" } }, "coolify": { @@ -2725,8 +2736,20 @@ "showOnlyPlaying": { "label": "", "description": "" + }, + "showBitrate": { + "label": "", + "description": "" + }, + "showLocation": { + "label": "", + "description": "" } }, + "footer": { + "streams": "", + "totalBitrate": "" + }, "items": { "currentlyPlaying": "", "user": "", @@ -2734,6 +2757,9 @@ "id": "", "status": "Tila", "directPlay": "Muuntamaton toisto", + "directStream": "", + "transcodeVideo": "", + "transcodeAudio": "", "transcoding": "Transkoodaus", "paused": "Keskeytetty", "remaining": "{minutes} min jäljellä", @@ -3768,18 +3794,6 @@ "label": "Näytä kappaleet", "description": "Näytä musiikkikirjastosi kappaleiden kokonaismäärä (Navidrome)" }, - "showNowPlaying": { - "label": "Näytä nyt soi", - "description": "Näytä tällä hetkellä toistettavat kappaleet (Navidrome)" - }, - "showNowPlayingList": { - "label": "Näytä nyt soiva lista", - "description": "Näytä yksittäiset kappaleet pelkän aktiivisten striimien määrän sijaan (Navidrome)" - }, - "maxNowPlayingItems": { - "label": "Nyt soivien kohteiden enimmäismäärä", - "description": "Nyt soivassa listassa näytettävien kappaleiden enimmäismäärä (Navidrome)" - }, "showLibraryCount": { "label": "Näytä kirjastojen määrä", "description": "Näytä instanssisi kirjastojen lukumäärä (Audiobookshelf)" @@ -3813,8 +3827,6 @@ "podcasts": "Podcastit", "listeningTime": "Kuunteluaika", "activeSessions": "Aktiiviset istunnot", - "nowPlaying": "Nyt soitetaan", - "nowPlayingCount": "{count, plural, one {# striimi} other {# striimiä}}", "error": { "internalServerError": "Audiotilastojen nouto epäonnistui" } @@ -3954,11 +3966,19 @@ }, "showAgent": { "label": "Näytä agentin versio" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "noRecords": "Järjestelmiä ei löytynyt", "error": { - "internalServerError": "Beszel-yhteyden muodostaminen epäonnistui. Tarkista integraatioasetuksesi." + "internalServerError": "Beszel-yhteyden muodostaminen epäonnistui. Tarkista integraatioasetuksesi.", + "layoutSaveTitle": "", + "layoutSaveMessage": "" }, "column": { "system": "Järjestelmä", @@ -5356,6 +5376,7 @@ }, "remove": { "label": "Poista", + "confirm": "", "notification": { "success": { "title": "", diff --git a/packages/translation/src/lang/fr.json b/packages/translation/src/lang/fr.json index 024c774d2..9c73c03c2 100644 --- a/packages/translation/src/lang/fr.json +++ b/packages/translation/src/lang/fr.json @@ -2428,10 +2428,21 @@ }, "descendingDefaultSort": { "label": "Inverser le tri" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "error": { - "internalServerError": "Impossible de récupérer les statistiques des conteneurs" + "internalServerError": "Impossible de récupérer les statistiques des conteneurs", + "layoutSaveTitle": "", + "layoutSaveMessage": "" + }, + "empty": { + "noContainers": "" } }, "coolify": { @@ -2725,8 +2736,20 @@ "showOnlyPlaying": { "label": "Afficher seulement les médias en cours de lecture", "description": "Désactiver cette option ne fonctionnera pas pour Plex" + }, + "showBitrate": { + "label": "", + "description": "" + }, + "showLocation": { + "label": "", + "description": "" } }, + "footer": { + "streams": "", + "totalBitrate": "" + }, "items": { "currentlyPlaying": "En cours de lecture", "user": "Utilisateur", @@ -2734,6 +2757,9 @@ "id": "Id", "status": "", "directPlay": "", + "directStream": "", + "transcodeVideo": "", + "transcodeAudio": "", "transcoding": "", "paused": "", "remaining": "", @@ -3768,18 +3794,6 @@ "label": "Afficher les titres", "description": "" }, - "showNowPlaying": { - "label": "Afficher la lecture en cours", - "description": "" - }, - "showNowPlayingList": { - "label": "", - "description": "" - }, - "maxNowPlayingItems": { - "label": "", - "description": "" - }, "showLibraryCount": { "label": "", "description": "" @@ -3813,8 +3827,6 @@ "podcasts": "", "listeningTime": "", "activeSessions": "", - "nowPlaying": "", - "nowPlayingCount": "", "error": { "internalServerError": "" } @@ -3954,11 +3966,19 @@ }, "showAgent": { "label": "" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "noRecords": "", "error": { - "internalServerError": "" + "internalServerError": "", + "layoutSaveTitle": "", + "layoutSaveMessage": "" }, "column": { "system": "", @@ -5356,6 +5376,7 @@ }, "remove": { "label": "Supprimer", + "confirm": "", "notification": { "success": { "title": "Conteneurs supprimés", diff --git a/packages/translation/src/lang/he.json b/packages/translation/src/lang/he.json index 47fc7f26b..1c37a681c 100644 --- a/packages/translation/src/lang/he.json +++ b/packages/translation/src/lang/he.json @@ -2428,10 +2428,21 @@ }, "descendingDefaultSort": { "label": "מיון הפוך" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "error": { - "internalServerError": "נכשל באחזור סטטיסטיקות המכולות" + "internalServerError": "נכשל באחזור סטטיסטיקות המכולות", + "layoutSaveTitle": "", + "layoutSaveMessage": "" + }, + "empty": { + "noContainers": "" } }, "coolify": { @@ -2725,8 +2736,20 @@ "showOnlyPlaying": { "label": "הצג רק את ההפעלה הנוכחית", "description": "השבתת אפשרות זו לא תעבוד עבור פלקס" + }, + "showBitrate": { + "label": "", + "description": "" + }, + "showLocation": { + "label": "", + "description": "" } }, + "footer": { + "streams": "", + "totalBitrate": "" + }, "items": { "currentlyPlaying": "מתנגן כרגע", "user": "משתמש", @@ -2734,6 +2757,9 @@ "id": "מזהה", "status": "", "directPlay": "", + "directStream": "", + "transcodeVideo": "", + "transcodeAudio": "", "transcoding": "", "paused": "", "remaining": "", @@ -3768,18 +3794,6 @@ "label": "", "description": "" }, - "showNowPlaying": { - "label": "", - "description": "" - }, - "showNowPlayingList": { - "label": "", - "description": "" - }, - "maxNowPlayingItems": { - "label": "", - "description": "" - }, "showLibraryCount": { "label": "", "description": "" @@ -3813,8 +3827,6 @@ "podcasts": "", "listeningTime": "", "activeSessions": "", - "nowPlaying": "", - "nowPlayingCount": "", "error": { "internalServerError": "" } @@ -3954,11 +3966,19 @@ }, "showAgent": { "label": "" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "noRecords": "", "error": { - "internalServerError": "" + "internalServerError": "", + "layoutSaveTitle": "", + "layoutSaveMessage": "" }, "column": { "system": "", @@ -5356,6 +5376,7 @@ }, "remove": { "label": "הסר", + "confirm": "", "notification": { "success": { "title": "הקונטיינרים הוסרו", diff --git a/packages/translation/src/lang/hr.json b/packages/translation/src/lang/hr.json index 5a5450f54..536865656 100644 --- a/packages/translation/src/lang/hr.json +++ b/packages/translation/src/lang/hr.json @@ -2428,10 +2428,21 @@ }, "descendingDefaultSort": { "label": "" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "error": { - "internalServerError": "" + "internalServerError": "", + "layoutSaveTitle": "", + "layoutSaveMessage": "" + }, + "empty": { + "noContainers": "" } }, "coolify": { @@ -2725,8 +2736,20 @@ "showOnlyPlaying": { "label": "", "description": "" + }, + "showBitrate": { + "label": "", + "description": "" + }, + "showLocation": { + "label": "", + "description": "" } }, + "footer": { + "streams": "", + "totalBitrate": "" + }, "items": { "currentlyPlaying": "", "user": "", @@ -2734,6 +2757,9 @@ "id": "", "status": "", "directPlay": "", + "directStream": "", + "transcodeVideo": "", + "transcodeAudio": "", "transcoding": "", "paused": "", "remaining": "", @@ -3768,18 +3794,6 @@ "label": "", "description": "" }, - "showNowPlaying": { - "label": "", - "description": "" - }, - "showNowPlayingList": { - "label": "", - "description": "" - }, - "maxNowPlayingItems": { - "label": "", - "description": "" - }, "showLibraryCount": { "label": "", "description": "" @@ -3813,8 +3827,6 @@ "podcasts": "", "listeningTime": "", "activeSessions": "", - "nowPlaying": "", - "nowPlayingCount": "", "error": { "internalServerError": "" } @@ -3954,11 +3966,19 @@ }, "showAgent": { "label": "Prikaži verziju agenta" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "noRecords": "Nisu pronađeni sustavi", "error": { - "internalServerError": "" + "internalServerError": "", + "layoutSaveTitle": "", + "layoutSaveMessage": "" }, "column": { "system": "Sustav", @@ -5356,6 +5376,7 @@ }, "remove": { "label": "Ukloni", + "confirm": "", "notification": { "success": { "title": "Kontejneri su uklonjeni", diff --git a/packages/translation/src/lang/hu.json b/packages/translation/src/lang/hu.json index bd939e030..979b24bce 100644 --- a/packages/translation/src/lang/hu.json +++ b/packages/translation/src/lang/hu.json @@ -2428,10 +2428,21 @@ }, "descendingDefaultSort": { "label": "Fordított rendezés" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "error": { - "internalServerError": "A konténerek statisztikáinak lekérése sikertelen" + "internalServerError": "A konténerek statisztikáinak lekérése sikertelen", + "layoutSaveTitle": "", + "layoutSaveMessage": "" + }, + "empty": { + "noContainers": "" } }, "coolify": { @@ -2725,8 +2736,20 @@ "showOnlyPlaying": { "label": "Csak a jelenleg lejátszottakat mutasd", "description": "Ennek letiltása a Plex esetében nem fog működni" + }, + "showBitrate": { + "label": "", + "description": "" + }, + "showLocation": { + "label": "", + "description": "" } }, + "footer": { + "streams": "", + "totalBitrate": "" + }, "items": { "currentlyPlaying": "Jelenleg lejátszás alatt", "user": "Felhasználó", @@ -2734,6 +2757,9 @@ "id": "Azonosító", "status": "", "directPlay": "", + "directStream": "", + "transcodeVideo": "", + "transcodeAudio": "", "transcoding": "", "paused": "", "remaining": "", @@ -3768,18 +3794,6 @@ "label": "Zeneszámok megjelenítése", "description": "A zeneszámok teljes számának megjelenítése a zenekönyvtárban (Navidrome)" }, - "showNowPlaying": { - "label": "Most játszott megjelenítése", - "description": "A jelenleg játszott sávok megjelenítése (Navidrome)" - }, - "showNowPlayingList": { - "label": "Most játszott lista megjelenítése", - "description": "Egyes sávok megjelenítése a csak aktív streamszám helyett (Navidrome)" - }, - "maxNowPlayingItems": { - "label": "Maximális most játszott elemek", - "description": "A most játszott listában megjelenítendő sávok maximális száma (Navidrome)" - }, "showLibraryCount": { "label": "Könyvtárak számának megjelenítése", "description": "A példányodon lévő könyvtárak számának megjelenítése (Audiobookshelf)" @@ -3813,8 +3827,6 @@ "podcasts": "Podcastek", "listeningTime": "Hallgatási idő", "activeSessions": "Aktív munkamenetek", - "nowPlaying": "Most játszott", - "nowPlayingCount": "{count, plural, one {# stream} other {# stream}}", "error": { "internalServerError": "Az audió statisztikai adatok letöltése nem sikerült" } @@ -3954,11 +3966,19 @@ }, "showAgent": { "label": "Agent verzió mutatása" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "noRecords": "Nincsenek rendszerek", "error": { - "internalServerError": "" + "internalServerError": "", + "layoutSaveTitle": "", + "layoutSaveMessage": "" }, "column": { "system": "Rendszer", @@ -5356,6 +5376,7 @@ }, "remove": { "label": "Eltávolítás", + "confirm": "", "notification": { "success": { "title": "Konténerek eltávolítva", diff --git a/packages/translation/src/lang/it.json b/packages/translation/src/lang/it.json index d313e3fed..d1fe23155 100644 --- a/packages/translation/src/lang/it.json +++ b/packages/translation/src/lang/it.json @@ -2428,10 +2428,21 @@ }, "descendingDefaultSort": { "label": "Inverti ordinamento" + }, + "columnOrder": { + "label": "Ordine delle colonne" + }, + "columnWidths": { + "label": "Larghezza delle colonne" } }, "error": { - "internalServerError": "Recupero statistiche container non riuscito" + "internalServerError": "Recupero statistiche container non riuscito", + "layoutSaveTitle": "Layout della tabella non salvato", + "layoutSaveMessage": "Impossibile salvare il layout della tabella Docker. Prova a spostare o ridimensionare nuovamente la colonna." + }, + "empty": { + "noContainers": "Nessun container trovato" } }, "coolify": { @@ -2725,8 +2736,20 @@ "showOnlyPlaying": { "label": "Mostra solo attualmente in riproduzione", "description": "La disabilitazione non funzionerà per Plex" + }, + "showBitrate": { + "label": "Mostra bitrate", + "description": "Mostra il bitrate di riproduzione corrente accanto alla posizione" + }, + "showLocation": { + "label": "Mostra posizione", + "description": "Mostra se lo stream si trova sulla tua LAN o WAN" } }, + "footer": { + "streams": "{count, plural, one {# stream attivo} other {# stream attivi}}", + "totalBitrate": "Totale: {bitrate}" + }, "items": { "currentlyPlaying": "Attualmente in riproduzione", "user": "Utente", @@ -2734,6 +2757,9 @@ "id": "Id", "status": "Stato", "directPlay": "Riproduzione diretta", + "directStream": "Streaming diretto", + "transcodeVideo": "Transcodifica video", + "transcodeAudio": "Transcodifica audio", "transcoding": "Transcodifica", "paused": "In pausa", "remaining": "{minutes} min rimanenti", @@ -3768,18 +3794,6 @@ "label": "Mostra brani", "description": "Mostra il numero totale di brani nella tua libreria musicale (Navidrome)" }, - "showNowPlaying": { - "label": "Mostra in riproduzione", - "description": "Mostra le tracce attualmente in riproduzione (Navidrome)" - }, - "showNowPlayingList": { - "label": "Mostra elenco in riproduzione", - "description": "Mostra le singole tracce invece del solo conteggio degli stream attivi (Navidrome)" - }, - "maxNowPlayingItems": { - "label": "Numero massimo elementi in riproduzione", - "description": "Numero massimo di tracce da mostrare nell'elenco in riproduzione (Navidrome)" - }, "showLibraryCount": { "label": "Mostra conteggio librerie", "description": "Mostra il numero di librerie sulla tua istanza (Audiobookshelf)" @@ -3813,8 +3827,6 @@ "podcasts": "Podcast", "listeningTime": "Tempo di Ascolto", "activeSessions": "Sessioni Attive", - "nowPlaying": "In Riproduzione", - "nowPlayingCount": "{count, plural, one {# stream} other {# streams}}", "error": { "internalServerError": "Impossibile recuperare i dati delle statistiche audio" } @@ -3954,11 +3966,19 @@ }, "showAgent": { "label": "Mostra versione agente" + }, + "columnOrder": { + "label": "Ordine delle colonne" + }, + "columnWidths": { + "label": "Larghezza delle colonne" } }, "noRecords": "Nessun sistema trovato", "error": { - "internalServerError": "Impossibile connettersi a Beszel. Controlla le tue impostazioni di integrazione." + "internalServerError": "Impossibile connettersi a Beszel. Controlla le tue impostazioni di integrazione.", + "layoutSaveTitle": "Impossibile salvare il layout della tabella", + "layoutSaveMessage": "Impossibile salvare l'ordine o la larghezza delle colonne. Riprova." }, "column": { "system": "Sistema", @@ -5356,6 +5376,7 @@ }, "remove": { "label": "Rimuovi", + "confirm": "Fare clic di nuovo per rimuovere definitivamente", "notification": { "success": { "title": "Containers rimossi", diff --git a/packages/translation/src/lang/ja.json b/packages/translation/src/lang/ja.json index a03ac115f..4972ad11d 100644 --- a/packages/translation/src/lang/ja.json +++ b/packages/translation/src/lang/ja.json @@ -2428,10 +2428,21 @@ }, "descendingDefaultSort": { "label": "並び替えを反転" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "error": { - "internalServerError": "コンテナステータスの取得に失敗しました" + "internalServerError": "コンテナステータスの取得に失敗しました", + "layoutSaveTitle": "", + "layoutSaveMessage": "" + }, + "empty": { + "noContainers": "" } }, "coolify": { @@ -2725,8 +2736,20 @@ "showOnlyPlaying": { "label": "現在再生中のみ表示", "description": "これを無効にすると plex では動作しません" + }, + "showBitrate": { + "label": "", + "description": "" + }, + "showLocation": { + "label": "", + "description": "" } }, + "footer": { + "streams": "", + "totalBitrate": "" + }, "items": { "currentlyPlaying": "再生中の曲", "user": "ユーザー", @@ -2734,6 +2757,9 @@ "id": "Id", "status": "", "directPlay": "", + "directStream": "", + "transcodeVideo": "", + "transcodeAudio": "", "transcoding": "", "paused": "", "remaining": "", @@ -3768,18 +3794,6 @@ "label": "", "description": "" }, - "showNowPlaying": { - "label": "", - "description": "" - }, - "showNowPlayingList": { - "label": "", - "description": "" - }, - "maxNowPlayingItems": { - "label": "", - "description": "" - }, "showLibraryCount": { "label": "", "description": "" @@ -3813,8 +3827,6 @@ "podcasts": "", "listeningTime": "", "activeSessions": "", - "nowPlaying": "", - "nowPlayingCount": "", "error": { "internalServerError": "" } @@ -3954,11 +3966,19 @@ }, "showAgent": { "label": "" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "noRecords": "", "error": { - "internalServerError": "" + "internalServerError": "", + "layoutSaveTitle": "", + "layoutSaveMessage": "" }, "column": { "system": "", @@ -5356,6 +5376,7 @@ }, "remove": { "label": "削除", + "confirm": "", "notification": { "success": { "title": "コンテナー削除", diff --git a/packages/translation/src/lang/ko.json b/packages/translation/src/lang/ko.json index 4ea9e31c1..9941463ff 100644 --- a/packages/translation/src/lang/ko.json +++ b/packages/translation/src/lang/ko.json @@ -2428,10 +2428,21 @@ }, "descendingDefaultSort": { "label": "역순 정렬" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "error": { - "internalServerError": "컨테이너 상태 불러오기 실패" + "internalServerError": "컨테이너 상태 불러오기 실패", + "layoutSaveTitle": "", + "layoutSaveMessage": "" + }, + "empty": { + "noContainers": "" } }, "coolify": { @@ -2725,8 +2736,20 @@ "showOnlyPlaying": { "label": "현재 재생 중 항목만 표시", "description": "비활성화 옵션은 plex에서는 지원되지 않음" + }, + "showBitrate": { + "label": "", + "description": "" + }, + "showLocation": { + "label": "", + "description": "" } }, + "footer": { + "streams": "", + "totalBitrate": "" + }, "items": { "currentlyPlaying": "현재 재생 중", "user": "사용자", @@ -2734,6 +2757,9 @@ "id": "아이디", "status": "상태", "directPlay": "직접 재생", + "directStream": "", + "transcodeVideo": "", + "transcodeAudio": "", "transcoding": "트랜스코딩", "paused": "일시 중지됨", "remaining": "남은 시간 {minutes}분", @@ -3768,18 +3794,6 @@ "label": "음원 표시", "description": "음악 라이브러리에 있는 음원 총 수를 표시 (Navidrome)" }, - "showNowPlaying": { - "label": "현재 재생 중 표시", - "description": "현재 재생 중인 트랙을 표시 (Navidrome)" - }, - "showNowPlayingList": { - "label": "현재 재생 목록 표시", - "description": "활성 스트림 수 대신 개별 트랙 표시 (Navidrome)" - }, - "maxNowPlayingItems": { - "label": "최대 현재 재생 항목 수", - "description": "현재 재생 목록에 표시할 최대 트랙 수 (Navidrome)" - }, "showLibraryCount": { "label": "라이브러리 수 표시", "description": "인스턴스에 있는 라이브러리 수 표시 (Audiobookshelf)" @@ -3813,8 +3827,6 @@ "podcasts": "팟캐스트", "listeningTime": "청취 시간", "activeSessions": "활성 세션", - "nowPlaying": "현재 재생 중", - "nowPlayingCount": "{count, plural, one {# 스트림} other {# 스트림}}", "error": { "internalServerError": "오디오 정보 가져오기 실패" } @@ -3954,11 +3966,19 @@ }, "showAgent": { "label": "에이전트 버전 표시" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "noRecords": "시스템을 찾을 수 없음", "error": { - "internalServerError": "Beszel에 연결할 수 없습니다. 통합 설정을 확인하십시오." + "internalServerError": "Beszel에 연결할 수 없습니다. 통합 설정을 확인하십시오.", + "layoutSaveTitle": "", + "layoutSaveMessage": "" }, "column": { "system": "시스템", @@ -5356,6 +5376,7 @@ }, "remove": { "label": "제거", + "confirm": "", "notification": { "success": { "title": "컨테이너 제거됨", diff --git a/packages/translation/src/lang/lt.json b/packages/translation/src/lang/lt.json index 1e3346892..efd19f523 100644 --- a/packages/translation/src/lang/lt.json +++ b/packages/translation/src/lang/lt.json @@ -2428,10 +2428,21 @@ }, "descendingDefaultSort": { "label": "" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "error": { - "internalServerError": "" + "internalServerError": "", + "layoutSaveTitle": "", + "layoutSaveMessage": "" + }, + "empty": { + "noContainers": "" } }, "coolify": { @@ -2725,8 +2736,20 @@ "showOnlyPlaying": { "label": "", "description": "" + }, + "showBitrate": { + "label": "", + "description": "" + }, + "showLocation": { + "label": "", + "description": "" } }, + "footer": { + "streams": "", + "totalBitrate": "" + }, "items": { "currentlyPlaying": "", "user": "", @@ -2734,6 +2757,9 @@ "id": "", "status": "", "directPlay": "", + "directStream": "", + "transcodeVideo": "", + "transcodeAudio": "", "transcoding": "", "paused": "", "remaining": "", @@ -3768,18 +3794,6 @@ "label": "Rodyti dainas", "description": "Rodyti bendrą dainų skaičių savo muzikos bibliotekoje (Navidrome)" }, - "showNowPlaying": { - "label": "Rodyti dabar grojamą", - "description": "Rodyti šiuo metu grojamas dainas (Navidrome)" - }, - "showNowPlayingList": { - "label": "Rodyti dabar grojamų sąrašą", - "description": "Rodyti atskiras dainas vietoj tik aktyvių srautų skaičiaus (Navidrome)" - }, - "maxNowPlayingItems": { - "label": "Maks. dabar grojamų elementų", - "description": "Didžiausias rodomų dainų skaičius dabar grojamų sąraše (Navidrome)" - }, "showLibraryCount": { "label": "Rodyti bibliotekų skaičių", "description": "Rodyti bibliotekų skaičių savo egzemplioriuje (Audiobookshelf)" @@ -3813,8 +3827,6 @@ "podcasts": "", "listeningTime": "", "activeSessions": "", - "nowPlaying": "", - "nowPlayingCount": "", "error": { "internalServerError": "" } @@ -3954,11 +3966,19 @@ }, "showAgent": { "label": "Rodyti agento versiją" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "noRecords": "Sistemų nerasta", "error": { - "internalServerError": "" + "internalServerError": "", + "layoutSaveTitle": "", + "layoutSaveMessage": "" }, "column": { "system": "Sistema", @@ -5356,6 +5376,7 @@ }, "remove": { "label": "Pašalinti", + "confirm": "", "notification": { "success": { "title": "", diff --git a/packages/translation/src/lang/lv.json b/packages/translation/src/lang/lv.json index 366b9be38..86cfc30b6 100644 --- a/packages/translation/src/lang/lv.json +++ b/packages/translation/src/lang/lv.json @@ -2428,10 +2428,21 @@ }, "descendingDefaultSort": { "label": "" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "error": { - "internalServerError": "" + "internalServerError": "", + "layoutSaveTitle": "", + "layoutSaveMessage": "" + }, + "empty": { + "noContainers": "" } }, "coolify": { @@ -2725,8 +2736,20 @@ "showOnlyPlaying": { "label": "", "description": "" + }, + "showBitrate": { + "label": "", + "description": "" + }, + "showLocation": { + "label": "", + "description": "" } }, + "footer": { + "streams": "", + "totalBitrate": "" + }, "items": { "currentlyPlaying": "", "user": "", @@ -2734,6 +2757,9 @@ "id": "", "status": "", "directPlay": "", + "directStream": "", + "transcodeVideo": "", + "transcodeAudio": "", "transcoding": "", "paused": "", "remaining": "", @@ -3768,18 +3794,6 @@ "label": "Rādīt dziesmas", "description": "Parādīt kopējo dziesmu skaitu mūzikas bibliotēkā (Navidrome)" }, - "showNowPlaying": { - "label": "Rādīt pašlaik atskaņoto", - "description": "Parādīt šobrīd atskaņotās dziesmas (Navidrome)" - }, - "showNowPlayingList": { - "label": "Rādīt pašlaik atskaņoto sarakstu", - "description": "Rādīt atsevišķas dziesmas, nevis tikai aktīvo straumju skaitu (Navidrome)" - }, - "maxNowPlayingItems": { - "label": "Maks. pašlaik atskaņoto vienumu skaits", - "description": "Maksimālais dziesmu skaits, ko rādīt pašlaik atskaņotajā sarakstā (Navidrome)" - }, "showLibraryCount": { "label": "Rādīt bibliotēku skaitu", "description": "Parādīt bibliotēku skaitu jūsu instancē (Audiobookshelf)" @@ -3813,8 +3827,6 @@ "podcasts": "Podkāsti", "listeningTime": "Klausīšanās laiks", "activeSessions": "Aktīvās sesijas", - "nowPlaying": "Atskaņo pašlaik", - "nowPlayingCount": "{count, plural, zero {# straumju} one {# straume} other {# straumes}}", "error": { "internalServerError": "Neizdevās iegūt audio statistikas datus" } @@ -3954,11 +3966,19 @@ }, "showAgent": { "label": "Rādīt aģenta versiju" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "noRecords": "Nav atrasta neviena sistēma", "error": { - "internalServerError": "" + "internalServerError": "", + "layoutSaveTitle": "", + "layoutSaveMessage": "" }, "column": { "system": "Sistēma", @@ -5356,6 +5376,7 @@ }, "remove": { "label": "Noņemt", + "confirm": "", "notification": { "success": { "title": "", diff --git a/packages/translation/src/lang/nl.json b/packages/translation/src/lang/nl.json index b02528b56..7f8103886 100644 --- a/packages/translation/src/lang/nl.json +++ b/packages/translation/src/lang/nl.json @@ -2428,10 +2428,21 @@ }, "descendingDefaultSort": { "label": "Sorteren omkeren" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "error": { - "internalServerError": "Ophalen van containerstatistieken mislukt" + "internalServerError": "Ophalen van containerstatistieken mislukt", + "layoutSaveTitle": "", + "layoutSaveMessage": "" + }, + "empty": { + "noContainers": "" } }, "coolify": { @@ -2725,8 +2736,20 @@ "showOnlyPlaying": { "label": "Alleen huidig spelende weergeven", "description": "Dit uitschakelen werkt niet voor Plex" + }, + "showBitrate": { + "label": "", + "description": "" + }, + "showLocation": { + "label": "", + "description": "" } }, + "footer": { + "streams": "", + "totalBitrate": "" + }, "items": { "currentlyPlaying": "Momenteel aan het afspelen", "user": "Gebruiker", @@ -2734,6 +2757,9 @@ "id": "Id", "status": "", "directPlay": "", + "directStream": "", + "transcodeVideo": "", + "transcodeAudio": "", "transcoding": "", "paused": "", "remaining": "", @@ -3768,18 +3794,6 @@ "label": "", "description": "" }, - "showNowPlaying": { - "label": "", - "description": "" - }, - "showNowPlayingList": { - "label": "", - "description": "" - }, - "maxNowPlayingItems": { - "label": "", - "description": "" - }, "showLibraryCount": { "label": "", "description": "" @@ -3813,8 +3827,6 @@ "podcasts": "", "listeningTime": "", "activeSessions": "", - "nowPlaying": "", - "nowPlayingCount": "", "error": { "internalServerError": "" } @@ -3954,11 +3966,19 @@ }, "showAgent": { "label": "Toon agentversie" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "noRecords": "Geen systemen gevonden", "error": { - "internalServerError": "" + "internalServerError": "", + "layoutSaveTitle": "", + "layoutSaveMessage": "" }, "column": { "system": "Systeem", @@ -5356,6 +5376,7 @@ }, "remove": { "label": "Verwijderen", + "confirm": "", "notification": { "success": { "title": "Containers verwijderd", diff --git a/packages/translation/src/lang/no.json b/packages/translation/src/lang/no.json index a92c5af46..d9bc061a1 100644 --- a/packages/translation/src/lang/no.json +++ b/packages/translation/src/lang/no.json @@ -2428,10 +2428,21 @@ }, "descendingDefaultSort": { "label": "Reverser sortering" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "error": { - "internalServerError": "Kunne ikke hente containerstatistikk" + "internalServerError": "Kunne ikke hente containerstatistikk", + "layoutSaveTitle": "", + "layoutSaveMessage": "" + }, + "empty": { + "noContainers": "" } }, "coolify": { @@ -2725,8 +2736,20 @@ "showOnlyPlaying": { "label": "Vis kun det som spiller nå", "description": "Å deaktivere dette fungerer ikke for Plex" + }, + "showBitrate": { + "label": "", + "description": "" + }, + "showLocation": { + "label": "", + "description": "" } }, + "footer": { + "streams": "", + "totalBitrate": "" + }, "items": { "currentlyPlaying": "Spilles nå", "user": "Bruker", @@ -2734,6 +2757,9 @@ "id": "Id", "status": "", "directPlay": "", + "directStream": "", + "transcodeVideo": "", + "transcodeAudio": "", "transcoding": "", "paused": "", "remaining": "", @@ -3768,18 +3794,6 @@ "label": "", "description": "" }, - "showNowPlaying": { - "label": "", - "description": "" - }, - "showNowPlayingList": { - "label": "", - "description": "" - }, - "maxNowPlayingItems": { - "label": "", - "description": "" - }, "showLibraryCount": { "label": "", "description": "" @@ -3813,8 +3827,6 @@ "podcasts": "", "listeningTime": "", "activeSessions": "", - "nowPlaying": "", - "nowPlayingCount": "", "error": { "internalServerError": "" } @@ -3954,11 +3966,19 @@ }, "showAgent": { "label": "" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "noRecords": "", "error": { - "internalServerError": "" + "internalServerError": "", + "layoutSaveTitle": "", + "layoutSaveMessage": "" }, "column": { "system": "", @@ -5356,6 +5376,7 @@ }, "remove": { "label": "Fjern", + "confirm": "", "notification": { "success": { "title": "Containere fjernet", diff --git a/packages/translation/src/lang/pl.json b/packages/translation/src/lang/pl.json index 5e3e3dd89..e447bf7f9 100644 --- a/packages/translation/src/lang/pl.json +++ b/packages/translation/src/lang/pl.json @@ -2428,10 +2428,21 @@ }, "descendingDefaultSort": { "label": "Odwróć sortowanie" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "error": { - "internalServerError": "Nie udało się pobrać statystyk kontenerów" + "internalServerError": "Nie udało się pobrać statystyk kontenerów", + "layoutSaveTitle": "", + "layoutSaveMessage": "" + }, + "empty": { + "noContainers": "" } }, "coolify": { @@ -2725,8 +2736,20 @@ "showOnlyPlaying": { "label": "Pokaż tylko aktualnie odtwarzane", "description": "Wyłączenie tej opcji nie zadziała w przypadku Plex" + }, + "showBitrate": { + "label": "", + "description": "" + }, + "showLocation": { + "label": "", + "description": "" } }, + "footer": { + "streams": "", + "totalBitrate": "" + }, "items": { "currentlyPlaying": "Obecnie odtwarzane", "user": "Użytkownik", @@ -2734,6 +2757,9 @@ "id": "ID", "status": "Status", "directPlay": "Odtwarzanie bezpośrednie", + "directStream": "", + "transcodeVideo": "", + "transcodeAudio": "", "transcoding": "Transkodowanie", "paused": "Zatrzymane", "remaining": "{minutes} min pozostało", @@ -3768,18 +3794,6 @@ "label": "Pokaż utwory", "description": "Pokaż całkowitą liczbę utworów w twojej bibliotece muzycznej (Navidrome)" }, - "showNowPlaying": { - "label": "Pokaż teraz odtwarzane", - "description": "Pokaż aktualnie odtwarzane utwory (Navidrome)" - }, - "showNowPlayingList": { - "label": "Pokaż listę teraz odtwarzanych", - "description": "Pokaż poszczególne utwory zamiast tylko aktywnej liczby strumieni (Navidrome)" - }, - "maxNowPlayingItems": { - "label": "Maksymalna ilość teraz odtwarzanych elementów", - "description": "Maksymalna liczba elementów wyświetlanych na liście teraz odtwarzanych (Navidrome)" - }, "showLibraryCount": { "label": "Pokaż ilość bibliotek", "description": "Pokaż liczbę bibliotek na twojej instancji (Audiobookshelf)" @@ -3813,8 +3827,6 @@ "podcasts": "Podcasty", "listeningTime": "Czas Słuchania", "activeSessions": "Aktywne Sesje", - "nowPlaying": "Teraz Odtwarzane", - "nowPlayingCount": "{count, plural, one {# stream} other {# streams}}", "error": { "internalServerError": "Nie udało się pobrać danych audio stats" } @@ -3954,11 +3966,19 @@ }, "showAgent": { "label": "Pokaż wersję agenta" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "noRecords": "Nie znaleziono systemów", "error": { - "internalServerError": "Nie można połączyć się z Beszel. Sprawdź ustawienia integracji." + "internalServerError": "Nie można połączyć się z Beszel. Sprawdź ustawienia integracji.", + "layoutSaveTitle": "", + "layoutSaveMessage": "" }, "column": { "system": "System", @@ -5356,6 +5376,7 @@ }, "remove": { "label": "Usuń", + "confirm": "", "notification": { "success": { "title": "Usunięto kontenery", diff --git a/packages/translation/src/lang/pt-br.json b/packages/translation/src/lang/pt-br.json index 6d9d4f6aa..d96857250 100644 --- a/packages/translation/src/lang/pt-br.json +++ b/packages/translation/src/lang/pt-br.json @@ -2428,10 +2428,21 @@ }, "descendingDefaultSort": { "label": "Inverter ordenação" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "error": { - "internalServerError": "Erro ao obter as estatísticas dos contêineres" + "internalServerError": "Erro ao obter as estatísticas dos contêineres", + "layoutSaveTitle": "", + "layoutSaveMessage": "" + }, + "empty": { + "noContainers": "" } }, "coolify": { @@ -2725,8 +2736,20 @@ "showOnlyPlaying": { "label": "Exibir apenas o que está sendo reproduzido", "description": "Desabilitar isso não funcionará para o Plex" + }, + "showBitrate": { + "label": "", + "description": "" + }, + "showLocation": { + "label": "", + "description": "" } }, + "footer": { + "streams": "", + "totalBitrate": "" + }, "items": { "currentlyPlaying": "Reproduzindo agora", "user": "Usuário", @@ -2734,6 +2757,9 @@ "id": "Id", "status": "", "directPlay": "", + "directStream": "", + "transcodeVideo": "", + "transcodeAudio": "", "transcoding": "", "paused": "", "remaining": "", @@ -3768,18 +3794,6 @@ "label": "Mostrar músicas", "description": "Mostrar o número total de músicas na sua biblioteca de música (Navidrome)" }, - "showNowPlaying": { - "label": "Mostrar tocando agora", - "description": "Mostrar as faixas que estão sendo reproduzidas no momento (Navidrome)" - }, - "showNowPlayingList": { - "label": "Mostrar lista de reprodução atual", - "description": "Mostrar faixas individuais em vez de apenas a contagem de streams ativos (Navidrome)" - }, - "maxNowPlayingItems": { - "label": "Máximo de itens em reprodução", - "description": "Número máximo de faixas para mostrar na lista de reprodução atual (Navidrome)" - }, "showLibraryCount": { "label": "Mostrar contagem de bibliotecas", "description": "Mostrar o número de bibliotecas na sua instância (Audiobookshelf)" @@ -3813,8 +3827,6 @@ "podcasts": "Podcasts", "listeningTime": "Tempo de audição", "activeSessions": "Sessões ativas", - "nowPlaying": "Tocando agora", - "nowPlayingCount": "{count, plural, one {# transmissão} other {# transmissões}}", "error": { "internalServerError": "Falha ao buscar dados de estatísticas de áudio" } @@ -3954,11 +3966,19 @@ }, "showAgent": { "label": "Mostrar versão do agente" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "noRecords": "Nenhum sistema encontrado", "error": { - "internalServerError": "Não foi possível conectar ao Beszel. Verifique as configurações de integração." + "internalServerError": "Não foi possível conectar ao Beszel. Verifique as configurações de integração.", + "layoutSaveTitle": "", + "layoutSaveMessage": "" }, "column": { "system": "Sistema", @@ -5356,6 +5376,7 @@ }, "remove": { "label": "Excluir", + "confirm": "", "notification": { "success": { "title": "Contêineres removidos", diff --git a/packages/translation/src/lang/pt.json b/packages/translation/src/lang/pt.json index e18d7f234..580ba60b3 100644 --- a/packages/translation/src/lang/pt.json +++ b/packages/translation/src/lang/pt.json @@ -2428,10 +2428,21 @@ }, "descendingDefaultSort": { "label": "" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "error": { - "internalServerError": "" + "internalServerError": "", + "layoutSaveTitle": "", + "layoutSaveMessage": "" + }, + "empty": { + "noContainers": "" } }, "coolify": { @@ -2725,8 +2736,20 @@ "showOnlyPlaying": { "label": "", "description": "" + }, + "showBitrate": { + "label": "", + "description": "" + }, + "showLocation": { + "label": "", + "description": "" } }, + "footer": { + "streams": "", + "totalBitrate": "" + }, "items": { "currentlyPlaying": "", "user": "", @@ -2734,6 +2757,9 @@ "id": "", "status": "", "directPlay": "", + "directStream": "", + "transcodeVideo": "", + "transcodeAudio": "", "transcoding": "", "paused": "", "remaining": "", @@ -3768,18 +3794,6 @@ "label": "", "description": "" }, - "showNowPlaying": { - "label": "", - "description": "" - }, - "showNowPlayingList": { - "label": "", - "description": "" - }, - "maxNowPlayingItems": { - "label": "", - "description": "" - }, "showLibraryCount": { "label": "", "description": "" @@ -3813,8 +3827,6 @@ "podcasts": "", "listeningTime": "", "activeSessions": "", - "nowPlaying": "", - "nowPlayingCount": "", "error": { "internalServerError": "" } @@ -3954,11 +3966,19 @@ }, "showAgent": { "label": "Exibir versão do agente" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "noRecords": "Nenhum sistema encontrado", "error": { - "internalServerError": "" + "internalServerError": "", + "layoutSaveTitle": "", + "layoutSaveMessage": "" }, "column": { "system": "Sistema", @@ -5356,6 +5376,7 @@ }, "remove": { "label": "Remover", + "confirm": "", "notification": { "success": { "title": "Contêineres removidos", diff --git a/packages/translation/src/lang/ro.json b/packages/translation/src/lang/ro.json index da232acd7..86ad0b371 100644 --- a/packages/translation/src/lang/ro.json +++ b/packages/translation/src/lang/ro.json @@ -2428,10 +2428,21 @@ }, "descendingDefaultSort": { "label": "" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "error": { - "internalServerError": "" + "internalServerError": "", + "layoutSaveTitle": "", + "layoutSaveMessage": "" + }, + "empty": { + "noContainers": "" } }, "coolify": { @@ -2725,8 +2736,20 @@ "showOnlyPlaying": { "label": "", "description": "" + }, + "showBitrate": { + "label": "", + "description": "" + }, + "showLocation": { + "label": "", + "description": "" } }, + "footer": { + "streams": "", + "totalBitrate": "" + }, "items": { "currentlyPlaying": "", "user": "", @@ -2734,6 +2757,9 @@ "id": "", "status": "", "directPlay": "", + "directStream": "", + "transcodeVideo": "", + "transcodeAudio": "", "transcoding": "", "paused": "", "remaining": "", @@ -3768,18 +3794,6 @@ "label": "", "description": "" }, - "showNowPlaying": { - "label": "", - "description": "" - }, - "showNowPlayingList": { - "label": "", - "description": "" - }, - "maxNowPlayingItems": { - "label": "", - "description": "" - }, "showLibraryCount": { "label": "", "description": "" @@ -3813,8 +3827,6 @@ "podcasts": "", "listeningTime": "", "activeSessions": "", - "nowPlaying": "", - "nowPlayingCount": "", "error": { "internalServerError": "" } @@ -3954,11 +3966,19 @@ }, "showAgent": { "label": "Arată versiunea agentului" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "noRecords": "Niciun sistem găsit", "error": { - "internalServerError": "" + "internalServerError": "", + "layoutSaveTitle": "", + "layoutSaveMessage": "" }, "column": { "system": "Sistem", @@ -5356,6 +5376,7 @@ }, "remove": { "label": "Elimină", + "confirm": "", "notification": { "success": { "title": "", diff --git a/packages/translation/src/lang/ru.json b/packages/translation/src/lang/ru.json index 90082f7ab..c48dd7d8c 100644 --- a/packages/translation/src/lang/ru.json +++ b/packages/translation/src/lang/ru.json @@ -2428,10 +2428,21 @@ }, "descendingDefaultSort": { "label": "Обратная сортировка" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "error": { - "internalServerError": "Не удалось получить статистику контейнеров" + "internalServerError": "Не удалось получить статистику контейнеров", + "layoutSaveTitle": "", + "layoutSaveMessage": "" + }, + "empty": { + "noContainers": "" } }, "coolify": { @@ -2725,8 +2736,20 @@ "showOnlyPlaying": { "label": "Показывать только воспроизводимое", "description": "Отключение этой опции не работает для Plex" + }, + "showBitrate": { + "label": "", + "description": "" + }, + "showLocation": { + "label": "", + "description": "" } }, + "footer": { + "streams": "", + "totalBitrate": "" + }, "items": { "currentlyPlaying": "Сейчас воспроизводится", "user": "Пользователь", @@ -2734,6 +2757,9 @@ "id": "Id", "status": "", "directPlay": "", + "directStream": "", + "transcodeVideo": "", + "transcodeAudio": "", "transcoding": "", "paused": "", "remaining": "", @@ -3768,18 +3794,6 @@ "label": "", "description": "" }, - "showNowPlaying": { - "label": "", - "description": "" - }, - "showNowPlayingList": { - "label": "", - "description": "" - }, - "maxNowPlayingItems": { - "label": "", - "description": "" - }, "showLibraryCount": { "label": "", "description": "" @@ -3813,8 +3827,6 @@ "podcasts": "", "listeningTime": "", "activeSessions": "", - "nowPlaying": "", - "nowPlayingCount": "", "error": { "internalServerError": "" } @@ -3954,11 +3966,19 @@ }, "showAgent": { "label": "Показать версию агента" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "noRecords": "Системы не найдены", "error": { - "internalServerError": "" + "internalServerError": "", + "layoutSaveTitle": "", + "layoutSaveMessage": "" }, "column": { "system": "Система", @@ -5356,6 +5376,7 @@ }, "remove": { "label": "Удалить", + "confirm": "", "notification": { "success": { "title": "Контейнеры удалены", diff --git a/packages/translation/src/lang/sk.json b/packages/translation/src/lang/sk.json index 381c20d0a..c19915673 100644 --- a/packages/translation/src/lang/sk.json +++ b/packages/translation/src/lang/sk.json @@ -2428,10 +2428,21 @@ }, "descendingDefaultSort": { "label": "" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "error": { - "internalServerError": "Nepodarilo sa načítať štatistiky kontajnerov" + "internalServerError": "Nepodarilo sa načítať štatistiky kontajnerov", + "layoutSaveTitle": "", + "layoutSaveMessage": "" + }, + "empty": { + "noContainers": "" } }, "coolify": { @@ -2725,8 +2736,20 @@ "showOnlyPlaying": { "label": "Zobraziť len aktuálne prehrávanú", "description": "Zakázanie tohto nebude fungovať pre plex" + }, + "showBitrate": { + "label": "", + "description": "" + }, + "showLocation": { + "label": "", + "description": "" } }, + "footer": { + "streams": "", + "totalBitrate": "" + }, "items": { "currentlyPlaying": "Momentálne sa prehráva", "user": "Používateľ", @@ -2734,6 +2757,9 @@ "id": "Id", "status": "", "directPlay": "", + "directStream": "", + "transcodeVideo": "", + "transcodeAudio": "", "transcoding": "", "paused": "", "remaining": "", @@ -3768,18 +3794,6 @@ "label": "Zobraziť skladby", "description": "Zobraziť celkový počet skladieb vo vašej hudobnej knižnici (Navidrome)" }, - "showNowPlaying": { - "label": "Zobraziť práve hrané", - "description": "Zobraziť skladby, ktoré sa práve prehrávajú (Navidrome)" - }, - "showNowPlayingList": { - "label": "Zobraziť zoznam práve hraných", - "description": "Zobraziť jednotlivé skladby namiesto len počtu aktívnych streamov (Navidrome)" - }, - "maxNowPlayingItems": { - "label": "Max. počet položiek práve hraných", - "description": "Maximálny počet skladieb zobrazených v zozname práve hraných (Navidrome)" - }, "showLibraryCount": { "label": "Zobraziť počet knižníc", "description": "Zobraziť počet knižníc na vašej inštancii (Audiobookshelf)" @@ -3813,8 +3827,6 @@ "podcasts": "Podcasty", "listeningTime": "Čas počúvania", "activeSessions": "Aktívne relácie", - "nowPlaying": "Práve hrané", - "nowPlayingCount": "{count, plural, one {# stream} few {# streamy} many {# streamov} other {# streamov}}", "error": { "internalServerError": "Nepodarilo sa načítať údaje audio štatistík" } @@ -3954,11 +3966,19 @@ }, "showAgent": { "label": "Zobraziť verziu agenta" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "noRecords": "Nenašli sa žiadne systémy", "error": { - "internalServerError": "" + "internalServerError": "", + "layoutSaveTitle": "", + "layoutSaveMessage": "" }, "column": { "system": "Systém", @@ -5356,6 +5376,7 @@ }, "remove": { "label": "Odstrániť", + "confirm": "", "notification": { "success": { "title": "Kontajnery boli odstránené", diff --git a/packages/translation/src/lang/sl.json b/packages/translation/src/lang/sl.json index 75f7cf7ee..9490c28d1 100644 --- a/packages/translation/src/lang/sl.json +++ b/packages/translation/src/lang/sl.json @@ -2428,10 +2428,21 @@ }, "descendingDefaultSort": { "label": "" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "error": { - "internalServerError": "" + "internalServerError": "", + "layoutSaveTitle": "", + "layoutSaveMessage": "" + }, + "empty": { + "noContainers": "" } }, "coolify": { @@ -2725,8 +2736,20 @@ "showOnlyPlaying": { "label": "", "description": "" + }, + "showBitrate": { + "label": "", + "description": "" + }, + "showLocation": { + "label": "", + "description": "" } }, + "footer": { + "streams": "", + "totalBitrate": "" + }, "items": { "currentlyPlaying": "", "user": "", @@ -2734,6 +2757,9 @@ "id": "", "status": "", "directPlay": "", + "directStream": "", + "transcodeVideo": "", + "transcodeAudio": "", "transcoding": "", "paused": "", "remaining": "", @@ -3768,18 +3794,6 @@ "label": "", "description": "" }, - "showNowPlaying": { - "label": "", - "description": "" - }, - "showNowPlayingList": { - "label": "", - "description": "" - }, - "maxNowPlayingItems": { - "label": "", - "description": "" - }, "showLibraryCount": { "label": "", "description": "" @@ -3813,8 +3827,6 @@ "podcasts": "", "listeningTime": "", "activeSessions": "", - "nowPlaying": "", - "nowPlayingCount": "", "error": { "internalServerError": "" } @@ -3954,11 +3966,19 @@ }, "showAgent": { "label": "Pokaži različico agenta" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "noRecords": "Ni najdenih sistemov", "error": { - "internalServerError": "" + "internalServerError": "", + "layoutSaveTitle": "", + "layoutSaveMessage": "" }, "column": { "system": "Sistem", @@ -5356,6 +5376,7 @@ }, "remove": { "label": "Odstrani", + "confirm": "", "notification": { "success": { "title": "", diff --git a/packages/translation/src/lang/sv.json b/packages/translation/src/lang/sv.json index 31eee6880..e77420845 100644 --- a/packages/translation/src/lang/sv.json +++ b/packages/translation/src/lang/sv.json @@ -2428,10 +2428,21 @@ }, "descendingDefaultSort": { "label": "" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "error": { - "internalServerError": "" + "internalServerError": "", + "layoutSaveTitle": "", + "layoutSaveMessage": "" + }, + "empty": { + "noContainers": "" } }, "coolify": { @@ -2725,8 +2736,20 @@ "showOnlyPlaying": { "label": "", "description": "" + }, + "showBitrate": { + "label": "", + "description": "" + }, + "showLocation": { + "label": "", + "description": "" } }, + "footer": { + "streams": "", + "totalBitrate": "" + }, "items": { "currentlyPlaying": "", "user": "", @@ -2734,6 +2757,9 @@ "id": "", "status": "", "directPlay": "", + "directStream": "", + "transcodeVideo": "", + "transcodeAudio": "", "transcoding": "", "paused": "", "remaining": "", @@ -3768,18 +3794,6 @@ "label": "", "description": "" }, - "showNowPlaying": { - "label": "", - "description": "" - }, - "showNowPlayingList": { - "label": "", - "description": "" - }, - "maxNowPlayingItems": { - "label": "", - "description": "" - }, "showLibraryCount": { "label": "", "description": "" @@ -3813,8 +3827,6 @@ "podcasts": "", "listeningTime": "", "activeSessions": "", - "nowPlaying": "", - "nowPlayingCount": "", "error": { "internalServerError": "" } @@ -3954,11 +3966,19 @@ }, "showAgent": { "label": "Visa agentversion" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "noRecords": "Inga system hittades", "error": { - "internalServerError": "" + "internalServerError": "", + "layoutSaveTitle": "", + "layoutSaveMessage": "" }, "column": { "system": "System", @@ -5356,6 +5376,7 @@ }, "remove": { "label": "Ta bort", + "confirm": "", "notification": { "success": { "title": "", diff --git a/packages/translation/src/lang/tr.json b/packages/translation/src/lang/tr.json index 90d55908d..46296c573 100644 --- a/packages/translation/src/lang/tr.json +++ b/packages/translation/src/lang/tr.json @@ -143,7 +143,7 @@ "goToBoard": "{name} Paneline Git", "createBoard": "İlk Panelinizi Oluşturun", "inviteUser": "Yeni Kullanıcı Davet Et", - "docs": "Dökümanları Oku" + "docs": "Dokümanları Oku" } } }, @@ -169,15 +169,15 @@ }, "field": { "email": { - "label": "E-Posta", + "label": "E-posta", "verified": "Doğrulandı" }, "username": { "label": "Kullanıcı adı" }, "password": { - "label": "Şifre", - "suggestionsTitle": "Şifre önerileri", + "label": "Parola", + "suggestionsTitle": "Parola önerileri", "suggestion": { "length": "En az 8 karakter içermeli", "lowercase": "Küçük harf içermeli", @@ -187,10 +187,10 @@ } }, "passwordConfirm": { - "label": "Şifreyi Onayla" + "label": "Parolayı Onayla" }, "previousPassword": { - "label": "Eski Şifre" + "label": "Eski Parola" }, "homeBoard": { "label": "Ana panel" @@ -199,8 +199,8 @@ "label": "Pingler İçin Simge Kullanın" }, "enableRightClickOnWidgets": { - "label": "", - "description": "" + "label": "Widget'lara sağ tıklamayı etkinleştir", + "description": "Bir widget'a sağ tıklandığında Homarr bağlam menüsünü gösterir. Tarayıcının varsayılan menüsünü (ör. “Bağlantıyı yeni sekmede aç”) kullanmak için devre dışı bırakın." }, "defaultSearchEngine": { "label": "Öntanımlı arama motoru" @@ -662,7 +662,7 @@ "label": "Tanım" }, "url": { - "label": "Url" + "label": "URL" }, "useDifferentUrlForPing": { "checkbox": { @@ -691,206 +691,206 @@ "customWidget": { "page": { "list": { - "title": "", - "noResults": "", - "loading": "" + "title": "Özel Widget'lar", + "noResults": "Henüz özel widget yok. İlk widget'ınızı oluşturun!", + "loading": "Yükleniyor..." }, "create": { - "title": "", - "subtitle": "" + "title": "Özel Widget Oluştur", + "subtitle": "API URL'sini, kimlik doğrulamasını ve yanıt verilerinin nasıl görüntüleneceğini tanımlayın." }, "edit": { - "title": "" + "title": "Özel Widget'ı Düzenle" } }, "action": { - "create": "", - "import": "", - "save": "", - "delete": "", - "deleteConfirm": "", - "export": "", - "addMapping": "", - "addColumn": "", - "edit": "", - "removeMapping": "", - "removeColumn": "", - "addItem": "", - "removeItem": "", - "addBar": "", - "duplicate": "", - "copyAiPrompt": "", - "enable": "", - "disable": "", - "menu": "" + "create": "Oluştur", + "import": "İçe aktar", + "save": "Kaydet", + "delete": "Özel Widget'ı Sil", + "deleteConfirm": "“{name}” adlı widget'ı silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.", + "export": "Dışa aktar", + "addMapping": "Eşleme Ekle", + "addColumn": "Sütun Ekle", + "edit": "Düzenle", + "removeMapping": "Eşlemeyi kaldır", + "removeColumn": "Sütunu kaldır", + "addItem": "Öğe Ekle", + "removeItem": "Öğeyi kaldır", + "addBar": "Çubuk Ekle", + "duplicate": "Çoğalt", + "copyAiPrompt": "Yapay zekâ istemini kopyala", + "enable": "Etkinleştir", + "disable": "Devre dışı bırak", + "menu": "Eylemler" }, "field": { - "name": "", - "description": "", - "iconUrl": "", - "url": "", - "authType": "", - "headerName": "", - "method": "", - "requestBody": "", - "displayType": "", - "jsonPath": "", - "jsonPathHint": "", - "label": "", - "unit": "", - "mappings": "", - "tablePath": "", - "tablePathHint": "", - "columns": "", - "header": "", - "items": "", - "gridColumns": "", - "cardStyle": "", - "bars": "", - "valuePath": "", - "maxPath": "", - "showPercentage": "", - "barSize": "", - "goodValues": "", - "dotSize": "", - "valueSize": "", - "labelPosition": "", - "kvLayout": "", - "striped": "", - "compact": "", - "maxHeight": "", - "color": "", - "buttonLabel": "", - "buttonColor": "", - "confirmText": "", - "confirmTextHint": "", - "successMessage": "", - "successMessageHint": "", - "enabled": "", + "name": "Ad", + "description": "Açıklama", + "iconUrl": "Simge URL'si", + "url": "URL", + "authType": "Kimlik Doğrulama Türü", + "headerName": "Üst Bilgi/Sorgu Parametresi Adı", + "method": "Yöntem", + "requestBody": "İstek Gövdesi", + "displayType": "Görüntüleme Türü", + "jsonPath": "JSON Yolu", + "jsonPathHint": "Değerleri ayıklamak için JSONPath söz dizimini kullanın; ör. $.data.temperature veya $.items[0].name", + "label": "Etiket", + "unit": "Birim", + "mappings": "Eşlemeler", + "tablePath": "Dizi JSON Yolu", + "tablePathHint": "Yanıttaki dizinin yolu; ör. $.data.items", + "columns": "Sütunlar", + "header": "Başlık", + "items": "Öğeler", + "gridColumns": "Izgara Sütunları", + "cardStyle": "Kart Stili", + "bars": "Çubuklar", + "valuePath": "Değer JSON Yolu", + "maxPath": "En Yüksek Değer JSON Yolu", + "showPercentage": "Yüzdeyi Göster", + "barSize": "Çubuk Boyutu", + "goodValues": "İyi Değerler (virgülle ayrılmış)", + "dotSize": "Nokta Boyutu", + "valueSize": "Değer Boyutu", + "labelPosition": "Etiket Konumu", + "kvLayout": "Düzen", + "striped": "Şeritli Satırlar", + "compact": "Kompakt Mod", + "maxHeight": "En Fazla Yükseklik (px)", + "color": "Renk", + "buttonLabel": "Düğme Etiketi", + "buttonColor": "Düğme Rengi", + "confirmText": "Onay Metni", + "confirmTextHint": "Ayarlanırsa istek yürütülmeden önce bir onay iletişim kutusu gösterilir", + "successMessage": "Başarı Mesajı", + "successMessageHint": "Başarılı yürütmeden sonra gösterilecek mesaj", + "enabled": "Etkin", "template": { - "label": "", - "description": "" + "label": "JSX Şablonu", + "description": "Mantine JSX şablonu. API yanıt alanlarına veri bağlamalarıyla (ör. data.name) erişin. Yalnızca izin verilen bileşenleri kullanın. Yasak: constructor, __proto__, eval, Function, window, document." } }, "placeholder": { - "exampleLabel": "", - "exampleLabelShort": "", - "exampleHeader": "", - "exampleUnit": "", - "iconUrl": "", - "url": "", - "headerName": "", - "jsonPath": "", - "tablePath": "", - "columnJsonPath": "", - "valuePath": "", - "maxPath": "", - "goodValues": "", - "color": "" + "exampleLabel": "CPU Sıcaklığı", + "exampleLabelShort": "CPU Sıcaklığı", + "exampleHeader": "Ad", + "exampleUnit": "°C", + "iconUrl": "https://...", + "url": "https://api.example.com/api/v1/status", + "headerName": "X-API-Key", + "jsonPath": "$.data.value", + "tablePath": "$.data.items", + "columnJsonPath": "$.name", + "valuePath": "$.data.used", + "maxPath": "$.data.total", + "goodValues": "online, true, up", + "color": "mavi" }, "cardStyleOption": { - "filled": "", - "outline": "", - "subtle": "" + "filled": "Dolu", + "outline": "Çerçeveli", + "subtle": "Hafif" }, "layoutOption": { - "list": "", - "grid": "" + "list": "Liste", + "grid": "Izgara" }, "sizeOption": { - "sm": "", - "md": "", - "lg": "", - "xl": "" + "sm": "Küçük", + "md": "Orta", + "lg": "Büyük", + "xl": "Çok Büyük" }, "labelPositionOption": { - "above": "", - "below": "" + "above": "Üstte", + "below": "Altta" }, "fieldset": { - "general": "", - "connection": "", - "display": "" + "general": "Genel", + "connection": "API Bağlantısı", + "display": "Görüntüleme Yapılandırması" }, "displayTypeDescription": { - "singleValue": "", - "keyValue": "", - "table": "", - "statGrid": "", - "progressBars": "", - "statusIndicator": "", - "countGrid": "", - "raw": "", - "actionButton": "", - "customJsx": "" + "singleValue": "Ayıklanan tek bir değeri etiket ve birimle gösterir", + "keyValue": "Birden fazla etiketli değeri yan yana gösterir", + "table": "Veri satırlarını tablo biçiminde gösterir", + "statGrid": "Pi-hole veya Speedtest widget'ları gibi, ızgara içinde renkli istatistik kartları gösterir", + "progressBars": "Disk, bellek veya kotalar için kullanımı ya da tamamlanmayı gösteren ilerleme çubukları", + "statusIndicator": "Çevrimiçi/çevrimdışı ya da sağlık durumlarını gösteren renkli durum noktaları", + "countGrid": "Sıkı bir ızgarada kompakt sayı ve etiket sayaçları", + "raw": "Hata ayıklama için okunaklı biçimlendirilmiş JSON çıktısı", + "actionButton": "Tıklandığında istek başlatan bir düğme", + "customJsx": "Tam API veri bağlamasıyla izin verilen Mantine bileşenlerini kullanan özel JSX düzeni" }, "method": { - "GET": "", - "POST": "", - "PUT": "", - "DELETE": "", - "PATCH": "" + "GET": "GET", + "POST": "POST", + "PUT": "PUT", + "DELETE": "DELETE", + "PATCH": "PATCH" }, "authType": { - "none": "", - "bearer": "", - "basic": "", - "apiKeyHeader": "", - "apiKeyQuery": "" + "none": "Yok", + "bearer": "Bearer Token", + "basic": "Temel Kimlik Doğrulama", + "apiKeyHeader": "API Anahtarı (Üst Bilgi)", + "apiKeyQuery": "API Anahtarı (Sorgu)" }, "displayType": { - "singleValue": "", - "keyValue": "", - "table": "", - "statGrid": "", - "progressBars": "", - "statusIndicator": "", - "countGrid": "", - "raw": "", - "actionButton": "", - "customJsx": "" + "singleValue": "Tek Değer", + "keyValue": "Anahtar-Değer", + "table": "Tablo", + "statGrid": "İstatistik Izgarası", + "progressBars": "İlerleme Çubukları", + "statusIndicator": "Durum Göstergesi", + "countGrid": "Sayaç Izgarası", + "raw": "Ham JSON", + "actionButton": "Eylem Düğmesi", + "customJsx": "Özel JSX" }, "secret": { - "apiKey": "", - "username": "", - "password": "", - "savedPlaceholder": "" + "apiKey": "API Anahtarı / Token", + "username": "Kullanıcı adı", + "password": "Parola", + "savedPlaceholder": "••••••• (kaydedildi, korumak için boş bırakın)" }, "preview": { - "title": "", - "test": "", - "hint": "", - "rawResponse": "" + "title": "Önizleme", + "test": "Test Et", + "hint": "Geçerli yapılandırmanızla API yanıtını önizlemek için Test Et'e tıklayın.", + "rawResponse": "Ham JSON yanıtı" }, "notification": { - "created": "", - "updated": "", - "deleted": "", - "imported": "", - "aiPromptCopied": "", - "aiPromptDescription": "", - "aiPromptCopy": "", - "aiPromptNoResponse": "", - "aiPromptCopyError": "", - "createError": "", - "updateError": "", - "importError": "", - "exportError": "", - "duplicated": "", - "duplicateError": "", - "toggleError": "", - "deleteError": "", - "previewError": "", - "executed": "", - "executeError": "" + "created": "“{name}” widget'ı başarıyla oluşturuldu.", + "updated": "“{name}” widget'ı başarıyla güncellendi.", + "deleted": "“{name}” widget'ı silindi.", + "imported": "Widget başarıyla içe aktarıldı.", + "aiPromptCopied": "Yapay zekâ istemi panoya kopyalandı", + "aiPromptDescription": "Herhangi bir yapay zekâ sohbet botuna (ChatGPT, Claude, Gemini...) yapıştırmak üzere önceden hazırlanmış bir istemi kopyalayın. İstem, yapay zekânın içe aktarmaya hazır bir JSON yapılandırması oluşturabilmesi için widget şemasının tamamını ve API yanıtınızı içerir.", + "aiPromptCopy": "Panoya kopyala", + "aiPromptNoResponse": "İpucu: Daha iyi sonuçlar için önce bir test çalıştırarak API yanıtınızı isteme ekleyin.", + "aiPromptCopyError": "İstem panoya kopyalanamadı. Tarayıcınız bu özelliği desteklemiyor olabilir.", + "createError": "Widget oluşturulamadı. API uç noktanızın erişilebilir olduğunu kontrol edin.", + "updateError": "Widget güncellenemedi. Değişiklikleriniz kaydedilmedi.", + "importError": "Widget içe aktarılamadı. Dosyanın geçerli bir özel widget JSON dışa aktarımı olduğundan emin olun.", + "exportError": "Widget dışa aktarılamadı.", + "duplicated": "“{name}” widget'ı çoğaltıldı.", + "duplicateError": "Widget çoğaltılamadı.", + "toggleError": "Widget durumu değiştirilemedi.", + "deleteError": "Widget silinemedi.", + "previewError": "Önizleme alınamadı. URL'yi kontrol edip yeniden deneyin.", + "executed": "Eylem başarıyla yürütüldü.", + "executeError": "Eylem başarısız oldu: {error}" }, "table": { - "name": "", - "url": "", - "display": "" + "name": "Ad", + "url": "URL", + "display": "Görüntüleme" }, "widget": { - "disabled": "" + "disabled": "Bu widget devre dışı" } }, "integration": { @@ -970,7 +970,7 @@ "label": "İsim" }, "url": { - "label": "Url" + "label": "URL" }, "attemptSearchEngineCreation": { "label": "Arama Motoru Oluştur", @@ -1002,7 +1002,7 @@ "dnsHole": "DNS", "mediaService": "Medya", "calendar": "Takvim", - "mediaSearch": "Ara", + "mediaSearch": "Arama", "mediaRelease": "Sürümler", "mediaRequest": "Talepler", "downloadClient": "İndirmeler", @@ -1012,7 +1012,7 @@ "smartHomeServer": "Akıllı Ev", "indexerManager": "İndeksleyici", "healthMonitoring": "Sağlık", - "search": "Ara", + "search": "Arama", "mediaTranscoding": "Kod Dönüştürme", "networkController": "Ağ", "releasesProvider": "Sürümler", @@ -1020,14 +1020,14 @@ "firewall": "Güvenlik Duvarı", "timetable": "Zaman Çizelgesi", "photoService": "Fotoğraflar", - "documents": "", + "documents": "Belgeler", "notes": "Notlar", "mediaMonitoring": "İzleme", "speedtest": "Hız Testi", "analytics": "Analiz", - "vpn": "", - "mediaLibrary": "", - "uptimeMonitoring": "" + "vpn": "VPN", + "mediaLibrary": "Medya Kütüphanesi", + "uptimeMonitoring": "Çalışma Süresi İzleme" }, "testConnection": { "action": { @@ -1196,7 +1196,7 @@ }, "notFound": { "title": "Bulunamadı", - "message": "Muhtemelen yanlış Url veya yol" + "message": "Muhtemelen yanlış URL veya yol" }, "internalServerError": { "title": "İç Sunucu Hatası", @@ -1278,7 +1278,7 @@ "newLabel": "Yeni konu" }, "url": { - "label": "Url", + "label": "URL", "newLabel": "Yeni url" }, "opnsenseApiKey": { @@ -1290,12 +1290,12 @@ "newLabel": "Yeni API Anahtarı (Gizli Anahtar)" }, "patchmonApiKey": { - "label": "", - "newLabel": "" + "label": "API Anahtarı", + "newLabel": "Yeni API Anahtarı" }, "patchmonApiSecret": { - "label": "", - "newLabel": "" + "label": "API Gizli Anahtarı", + "newLabel": "Yeni API Gizli Anahtarı" }, "githubAppId": { "label": "Uygulama Kimliği", @@ -1310,8 +1310,8 @@ "newLabel": "Yeni özel anahtar" }, "slug": { - "label": "", - "newLabel": "" + "label": "Durum sayfası kısa adı", + "newLabel": "Yeni durum sayfası kısa adı" } } }, @@ -1321,40 +1321,40 @@ "full": "Tam entegrasyon erişimi" }, "audiobookshelf": { - "name": "", - "description": "" + "name": "Audiobookshelf", + "description": "Kendi sunucunuzda barındırılan sesli kitap ve podcast sunucusu" }, "navidrome": { - "name": "", - "description": "" + "name": "Navidrome", + "description": "Subsonic API uyumlu, kendi sunucunuzda barındırılan müzik akış sunucusu" }, "uptimeKuma": { - "name": "", - "description": "", + "name": "Uptime Kuma", + "description": "Herkese açık durum sayfaları sunan, kendi sunucunuzda barındırılan çalışma süresi izleme sistemi", "secret": { "slug": { - "description": "" + "description": "Herkese açık durum sayfanızın kısa adı. Ayarlanmazsa varsayılan olarak “default” kullanılır." }, "apiKey": { - "description": "" + "description": "Kimlik doğrulamalı /metrics erişimi için isteğe bağlı API anahtarı" } } }, "traefik": { - "name": "", - "description": "" + "name": "Traefik", + "description": "Bulut tabanlı ters proxy ve yük dengeleyici" }, "paperlessNgx": { - "name": "", - "description": "" + "name": "Paperless-ngx", + "description": "Taranmış belgeleri arşivlemek ve düzenlemek için belge yönetim sistemi" }, "patchmon": { - "name": "", - "description": "" + "name": "PatchMon", + "description": "Linux ana makineleri için yama yönetimi ve izleme" }, "bazarr": { - "name": "", - "description": "" + "name": "Bazarr", + "description": "Altyazıları yöneten ve indiren Sonarr ve Radarr yardımcı uygulaması" } }, "media": { @@ -1398,7 +1398,7 @@ } }, "noResults": { - "title": "Medya yüklenmedi" + "title": "Yüklenmiş medya yok" } }, "common": { @@ -1427,7 +1427,7 @@ "continue": "Devam Et", "previous": "Önceki", "next": "İleri", - "checkoutDocs": "Dökümanlara göz at", + "checkoutDocs": "Dokümanlara göz at", "checkLogs": "Daha fazla ayrıntı için günlükleri kontrol edin", "tryAgain": "Tekrar Deneyin", "loading": "Yükleniyor", @@ -1438,9 +1438,9 @@ "iconPicker": { "label": "Simge URL'si", "header": "Simgeleri filtrelemek için isim veya nesne yazın... Homarr sizin için {countIcons} simge arasında arama yapacaktır.", - "headerLoading": "", - "noResults": "", - "error": "" + "headerLoading": "Simge aramak için yazın…", + "noResults": "“{search}” aramasıyla eşleşen simge yok.", + "error": "Simgeler yüklenemedi." }, "colorScheme": { "options": { @@ -1492,7 +1492,7 @@ "switchToDarkMode": "Koyu Temaya Geç", "switchToLightMode": "Aydınlık Temaya Geç", "management": "Yönetim", - "docker": "", + "docker": "Docker", "preferences": "Tercihleriniz", "logout": "Oturumu kapat", "login": "Giriş", @@ -1543,8 +1543,8 @@ }, "unit": { "speed": { - "kilometersPerHour": "km/h", - "milesPerHour": "mph" + "kilometersPerHour": "km/sa", + "milesPerHour": "mil/sa" } } }, @@ -1623,15 +1623,15 @@ "menu": { "label": { "settings": "Ayarlar", - "refresh": "", - "options": "", - "actions": "" + "refresh": "Yenile", + "options": "Seçenekler", + "actions": "Eylemler" }, "status": { - "success": "", - "error": "", - "loading": "", - "idle": "" + "success": "Tamam", + "error": "Hata", + "loading": "Yükleniyor", + "idle": "Boşta" } }, "create": { @@ -1691,11 +1691,11 @@ }, "widget": { "integrationSelect": { - "configureIntegrationFirst": "", - "loading": "", - "placeholder": "", - "noResults": "", - "loadError": "" + "configureIntegrationFirst": "Önce bir entegrasyon bağlayın", + "loading": "Yükleniyor...", + "placeholder": "Bir seçenek belirleyin", + "noResults": "Sonuç bulunamadı", + "loadError": "Seçenekler yüklenemedi" }, "app": { "name": "Uygulama", @@ -1785,11 +1785,11 @@ } }, "archiveTeamWarrior": { - "name": "", - "description": "", + "name": "ArchiveTeam Warrior", + "description": "ArchiveTeam Warrior durumunu, projelerini ve etkin arşivleme işlerini izleyin.", "option": { "showBroadcastMessage": { - "label": "" + "label": "Yayın mesajlarını göster" } } }, @@ -1907,14 +1907,14 @@ "description": "Tarih biçimini değiştirmek için ISO 8601 standardını kullanın (bu diğer seçenekleri geçersiz kılacaktır)" }, "showWeather": { - "label": "", - "description": "" + "label": "Hava durumunu göster", + "description": "Kartın köşesinde küçük, hareketli bir hava durumu simgesi ve sıcaklık gösterir" }, "weatherLocation": { - "label": "" + "label": "Hava durumu konumu" }, "isWeatherFormatFahrenheit": { - "label": "" + "label": "Fahrenhayt kullan" } } }, @@ -1944,7 +1944,7 @@ "noStation": "İstasyon seçilmedi", "option": { "baseUrl": { - "label": "" + "label": "Search.ch URL'si" }, "station": { "label": "İstasyon" @@ -1976,8 +1976,8 @@ "clear": "Biçimlendirmeyi temizle", "heading": "Başlık {level}", "align": "Metin hizalama: {position}", - "directionLtr": "", - "directionRtl": "", + "directionLtr": "Metin yönünü soldan sağa ayarla", + "directionRtl": "Metin yönünü sağdan sola ayarla", "blockquote": "Blok alıntı", "horizontalLine": "Yatay çizgi", "bulletList": "Maddeli liste", @@ -2083,7 +2083,7 @@ "label": "Coğrafi konuma izin ver (geolocation)" }, "allowModals": { - "label": "Allow modals" + "label": "İletişim kutularına izin ver" } }, "error": { @@ -2242,7 +2242,7 @@ "description": "Yalnızca mevcut hava durumunda" }, "useImperialSpeed": { - "label": "Use mph for windspeed" + "label": "Rüzgâr hızı için mil/sa kullan" }, "location": { "label": "Hava durumu konumu" @@ -2262,13 +2262,13 @@ "description": "Görüntülenecek tarihin biçimi" } }, - "currentWindSpeed": "{currentWindSpeed} km/s", + "currentWindSpeed": "{currentWindSpeed} {unit}", "dailyForecast": { "sunrise": "Gün Doğumu", "sunset": "Gün Batımı", - "maxWindSpeed": "Maks. Rüzgar Hızı: {maxWindSpeed} km/s", - "maxWindGusts": "Maks. Rüzgar Hamlesi: {maxWindGusts} km/s", - "humidity": "" + "maxWindSpeed": "En yüksek rüzgâr hızı: {maxWindSpeed} {unit}", + "maxWindGusts": "En yüksek rüzgâr hamlesi: {maxWindGusts} {unit}", + "humidity": "Nem: %{humidity}" }, "kind": { "clear": "Açık", @@ -2319,7 +2319,7 @@ "label": "Bellek Bilgilerini Göster" }, "gpu": { - "label": "Show GPU Info" + "label": "GPU bilgilerini göster" }, "showUptime": { "label": "Çalışma Süresini Göster" @@ -2328,9 +2328,9 @@ "label": "Dosya Sistemi Bilgilerini Göster" }, "visibleStorageVolumes": { - "label": "", - "description": "", - "placeholder": "" + "label": "Görünür depolama birimleri", + "description": "Yalnızca seçilen entegrasyonların tümü Synology olduğunda kullanılabilir. Algılanan tüm depolama birimlerini göstermek için boş bırakın.", + "placeholder": "Depolama birimlerini seçin" }, "defaultTab": { "label": "Varsayılan sekme" @@ -2348,7 +2348,7 @@ "memory": "Bellek: {memory}GiB", "memoryAvailable": "Kullanılabilir: {memoryAvailable} GiB ({percent}%)", "version": "Sürüm: {version}", - "uptime": "Çalışma Süresi: {days} Gün, {hours} Saat, {minutes} Dakika", + "uptime": "Çalışma süresi: {months} Ay, {days} Gün, {hours} Saat, {minutes} Dakika", "loadAverage": "Ortalama yük:", "minute": "1 dakika", "minutes": "{count} dakika", @@ -2367,7 +2367,7 @@ }, "resource": { "node": { - "name": "Nodes" + "name": "Düğümler" }, "qemu": { "name": "VM's" @@ -2381,7 +2381,7 @@ }, "popover": { "rightSection": { - "node": "Node", + "node": "Düğüm", "vmId": "VM Kimliği", "plugin": "Eklenti" }, @@ -2402,7 +2402,7 @@ "name": "İsim", "cpu": "İşlemci", "memory": "BELLEK", - "node": "Node" + "node": "Düğüm" } } } @@ -2412,65 +2412,76 @@ "description": "Konteynerlerinizin istatistikleri (Bu widget yalnızca yönetici ayrıcalıklarıyla eklenebilir)", "option": { "columns": { - "label": "" + "label": "Gösterilecek sütunlar" }, "enableRowSorting": { - "label": "Enable items sorting" + "label": "Öğeleri sıralamayı etkinleştir" }, "defaultSort": { - "label": "Column used for sorting by default", + "label": "Varsayılan sıralama sütunu", "option": { - "name": "Name", - "state": "State", - "cpuUsage": "CPU usage", - "memoryUsage": "Memory usage" + "name": "Ad", + "state": "Durum", + "cpuUsage": "CPU kullanımı", + "memoryUsage": "Bellek kullanımı" } }, "descendingDefaultSort": { - "label": "Invert sorting" + "label": "Sıralamayı ters çevir" + }, + "columnOrder": { + "label": "Sütun sırası" + }, + "columnWidths": { + "label": "Sütun genişlikleri" } }, "error": { - "internalServerError": "Konteyner istatistikleri alınamadı" + "internalServerError": "Konteyner istatistikleri alınamadı", + "layoutSaveTitle": "Tablo düzeni kaydedilemedi", + "layoutSaveMessage": "Docker tablo düzeni kaydedilemedi. Sütunu yeniden taşımayı veya boyutlandırmayı deneyin." + }, + "empty": { + "noContainers": "Konteyner bulunamadı" } }, "coolify": { "name": "Coolify", - "description": "Overview of your Coolify instance with servers and applications", + "description": "Sunucular ve uygulamalarla birlikte Coolify örneğinizin genel görünümü", "option": { "showServers": { - "label": "Show servers" + "label": "Sunucuları göster" }, "showApplications": { - "label": "Show applications" + "label": "Uygulamaları göster" }, "showServices": { - "label": "Show services" + "label": "Hizmetleri göster" } }, "empty": { - "servers": "No servers found", - "applications": "No applications found", - "services": "No services found" + "servers": "Sunucu bulunamadı", + "applications": "Uygulama bulunamadı", + "services": "Hizmet bulunamadı" }, "footer": { - "updated": "Updated {when}" + "updated": "Güncellendi: {when}" }, "error": { - "noIntegration": "No Coolify integration configured", - "internalServerError": "Failed to fetch Coolify data" + "noIntegration": "Yapılandırılmış Coolify entegrasyonu yok", + "internalServerError": "Coolify verileri alınamadı" }, "server": { - "buildServer": "Build" + "buildServer": "Derleme" }, "instances": { - "title": "Instances" + "title": "Örnekler" } }, "vpn": { - "name": "", - "description": "", - "serviceUnavailable": "", + "name": "VPN", + "description": "VPN bağlantılarınızın genel görünümü", + "serviceUnavailable": "VPN kullanılamıyor", "option": {} }, "ups": { @@ -2506,68 +2517,68 @@ }, "tracearr": { "name": "Tracearr", - "description": "Enhanced media server monitoring with detailed stream and transcoding data", + "description": "Ayrıntılı yayın ve kod dönüştürme verileriyle gelişmiş medya sunucusu izleme", "option": { "showStreams": { - "label": "Show active streams" + "label": "Etkin yayınları göster" }, "showStats": { - "label": "Show stats summary" + "label": "İstatistik özetini göster" }, "showRecentActivity": { - "label": "Show recent activity" + "label": "Son etkinlikleri göster" }, "showViolations": { - "label": "Show violations" + "label": "İhlalleri göster" } }, "stats": { - "activeStreams": "Active Streams", - "totalUsers": "Total Users", - "transcodes": "Transcodes", - "bandwidth": "Bandwidth" + "activeStreams": "Etkin Yayınlar", + "totalUsers": "Toplam Kullanıcı", + "transcodes": "Kod Dönüştürmeler", + "bandwidth": "Bant Genişliği" }, "streams": { - "empty": "No active streams", - "user": "User", - "media": "Media", - "state": "State", - "quality": "Quality", - "device": "Device", - "progress": "Progress", + "empty": "Etkin yayın yok", + "user": "Kullanıcı", + "media": "Medya", + "state": "Durum", + "quality": "Kalite", + "device": "Cihaz", + "progress": "İlerleme", "videoDecision": { - "directPlay": "Direct Play", - "transcode": "Transcode", - "directStream": "Direct Stream" + "directPlay": "Doğrudan Oynatma", + "transcode": "Kod Dönüştürme", + "directStream": "Doğrudan Yayın" } }, "recentActivity": { - "title": "Recent Activity", - "empty": "No recent activity", - "watched": "Watched", - "partial": "Partial" + "title": "Son Etkinlikler", + "empty": "Yakın zamanda etkinlik yok", + "watched": "İzlendi", + "partial": "Kısmi" }, "violations": { - "title": "Violations", - "empty": "No recent violations", - "rule": "Rule", - "user": "User" + "title": "İhlaller", + "empty": "Yakın zamanda ihlal yok", + "rule": "Kural", + "user": "Kullanıcı" }, - "noSectionsEnabled": "No sections enabled", + "noSectionsEnabled": "Etkinleştirilmiş bölüm yok", "error": { - "noIntegration": "No Tracearr integration configured", - "internalServerError": "Failed to fetch Tracearr data" + "noIntegration": "Yapılandırılmış Tracearr entegrasyonu yok", + "internalServerError": "Tracearr verileri alınamadı" } }, "umami": { - "name": "Umami Analiz", + "name": "Umami Analytics", "description": "Umami analiz kurulumunuzdaki ziyaretçi istatistiklerini gösterir", "visitors": "ziyaretçiler", - "active": "aktif", + "active": "etkin", "pageviews": "sayfa görüntüleme", "visits": "ziyaretler", "bounceRate": "hemen çıkma", - "avgDuration": "ortalama", + "avgDuration": "ort.", "option": { "websiteId": { "label": "Web sitesi", @@ -2620,8 +2631,8 @@ "option": { "chart": "Grafik", "events": "Etkinlik grafiği", - "topPages": "En çok sayfa", - "topReferrers": "En çok yönlendiren" + "topPages": "En çok ziyaret edilen sayfalar", + "topReferrers": "En çok yönlendirenler" } }, "eventNames": { @@ -2634,7 +2645,7 @@ }, "topCount": { "label": "Gösterilecek öğeler", - "description": "Gösterilecek en yüksek öğe sayısı (maks 500)" + "description": "Gösterilecek en fazla öğe sayısı (en çok 500)" } }, "event": { @@ -2725,18 +2736,33 @@ "showOnlyPlaying": { "label": "Yalnızca şu an oynatılanı göster", "description": "Bunu devre dışı bırakmak plex için işe yaramayacaktır" + }, + "showBitrate": { + "label": "Bit hızını göster", + "description": "Geçerli oynatma bit hızını konumun yanında gösterir" + }, + "showLocation": { + "label": "Konumu göster", + "description": "Yayının LAN'da mı yoksa WAN'da mı olduğunu gösterir" } }, + "footer": { + "streams": "{count, plural, one {# etkin yayın} other {# etkin yayın}}", + "totalBitrate": "Toplam: {bitrate}" + }, "items": { "currentlyPlaying": "Şuan Oynatılan", "user": "Kullanıcı", "name": "İsim", "id": "Kimlik", - "status": "", - "directPlay": "", - "transcoding": "", - "paused": "", - "remaining": "", + "status": "Durum", + "directPlay": "Doğrudan oynatma", + "directStream": "Doğrudan yayın", + "transcodeVideo": "Video kodu dönüştürülüyor", + "transcodeAudio": "Ses kodu dönüştürülüyor", + "transcoding": "Kod dönüştürülüyor", + "paused": "Duraklatıldı", + "remaining": "{minutes} dk kaldı", "metadata": { "title": "Meraklısı için istatistikler", "video": { @@ -2758,286 +2784,286 @@ } }, "paperlessNgx": { - "name": "", - "description": "", - "documentsTotal": "", - "documentsInbox": "", - "correspondents": "", - "tags": "", - "documentTypes": "", - "inboxRatio": "", - "inbox": "", - "total": "", + "name": "Paperless-ngx", + "description": "Paperless-ngx belge arşivinizin genel görünümü", + "documentsTotal": "Toplam Belge", + "documentsInbox": "Gelen Kutusu", + "correspondents": "Muhataplar", + "tags": "Etiketler", + "documentTypes": "Belge Türleri", + "inboxRatio": "Gelen Kutusu Oranı", + "inbox": "gelen kutusu", + "total": "toplam", "option": { "showInboxRatio": { - "label": "", - "description": "" + "label": "Gelen kutusu oranını göster", + "description": "Gelen kutusundaki belgelerin toplam belgelere oranını öne çıkan bölümde gösterir" }, "showInboxRing": { - "label": "", - "description": "" + "label": "Gelen kutusu halkasını göster", + "description": "Gelen kutusu oranının yanında bir ilerleme halkası gösterir" }, "showDocumentsTotal": { - "label": "", - "description": "" + "label": "Toplam belge sayısını göster", + "description": "Arşivinizdeki toplam belge sayısını gösterir" }, "showDocumentsInbox": { - "label": "", - "description": "" + "label": "Gelen kutusu sayısını göster", + "description": "Gelen kutusundaki işlenmemiş belge sayısını gösterir" }, "showCorrespondents": { - "label": "", - "description": "" + "label": "Muhatapları göster", + "description": "Toplam muhatap sayısını gösterir" }, "showTags": { - "label": "", - "description": "" + "label": "Etiketleri göster", + "description": "Toplam etiket sayısını gösterir" }, "showDocumentTypes": { - "label": "", - "description": "" + "label": "Belge türlerini göster", + "description": "Toplam belge türü sayısını gösterir" } }, "error": { - "internalServerError": "" + "internalServerError": "Paperless-ngx verileri alınamadı" } }, "patchmon": { - "name": "", - "description": "", - "title": "", - "totalHosts": "", - "hostsNeedingUpdates": "", - "securityUpdates": "", - "upToDateHosts": "", - "hostsWithSecurityUpdates": "", - "recentUpdates24h": "", - "totalOutdatedPackages": "", - "totalRepos": "", - "osDistribution": "", - "compliance": "", - "complianceRatio": "", - "lastUpdated": "", + "name": "PatchMon", + "description": "PatchMon'daki ana makine yama durumunun genel görünümü", + "title": "PatchMon İstatistikleri", + "totalHosts": "Toplam Ana Makine", + "hostsNeedingUpdates": "Güncelleme Gerekiyor", + "securityUpdates": "Güvenlik Güncellemeleri", + "upToDateHosts": "Güncel Ana Makineler", + "hostsWithSecurityUpdates": "Güvenlik Güncellemesi Olan Ana Makineler", + "recentUpdates24h": "Güncellemeler (24 sa.)", + "totalOutdatedPackages": "Güncel Olmayan Paketler", + "totalRepos": "Depolar", + "osDistribution": "İşletim Sistemi Dağılımı", + "compliance": "Yama Uyumluluğu", + "complianceRatio": "{upToDate} / {total}", + "lastUpdated": "Güncellendi: {time}", "option": { "showTotalHosts": { - "label": "", - "description": "" + "label": "Toplam ana makine sayısını göster", + "description": "İzlenen toplam ana makine sayısını gösterir" }, "showHostsNeedingUpdates": { - "label": "", - "description": "" + "label": "Güncelleme gereken ana makineleri göster", + "description": "En az bir güncel olmayan paketi bulunan ana makineleri gösterir" }, "showSecurityUpdates": { - "label": "", - "description": "" + "label": "Güvenlik güncellemelerini göster", + "description": "Kullanılabilir toplam güvenlik güncellemesi sayısını gösterir" }, "showUpToDateHosts": { - "label": "", - "description": "" + "label": "Güncel ana makineleri göster", + "description": "Güncel olmayan paketi bulunmayan ana makineleri gösterir" }, "showHostsWithSecurityUpdates": { - "label": "", - "description": "" + "label": "Güvenlik güncellemesi olan ana makineleri göster", + "description": "Güvenlik yaması gereken ana makineleri gösterir" }, "showRecentUpdates24h": { - "label": "", - "description": "" + "label": "Son güncellemeleri göster (24 sa.)", + "description": "Son 24 saatte uygulanan güncelleme sayısını gösterir" }, "showTotalOutdatedPackages": { - "label": "", - "description": "" + "label": "Güncel olmayan paketleri göster", + "description": "Tüm ana makinelerdeki güncel olmayan paketlerin toplam sayısını gösterir" }, "showTotalRepos": { - "label": "", - "description": "" + "label": "Depoları göster", + "description": "İzlenen etkin depo sayısını gösterir" }, "enableThresholdColors": { - "label": "", - "description": "" + "label": "Eşik renklerini etkinleştir", + "description": "İstatistik değerlerini önem düzeyi ön ayarlarına göre yeşil, sarı veya kırmızı renklendirir" }, "useCustomThresholds": { - "label": "", - "description": "" + "label": "Gelişmiş eşikler", + "description": "Her istatistik için uyarı ve kritik eşikleri mutlak sayılarla veya toplam ana makine yüzdesiyle özelleştirin" }, "thresholdMode": { "option": { - "absolute": "", - "percent": "" + "absolute": "Mutlak sayı", + "percent": "Toplam ana makinelerin yüzdesi" } }, "hostsNeedingUpdatesThresholdMode": { - "label": "", - "description": "" + "label": "Güncelleme gereksinimi eşik modu", + "description": "Güncelleme gerekenlerin sayısını mutlak değer veya toplam ana makinelerin yüzdesi olarak karşılaştırır" }, "hostsNeedingUpdatesWarningAt": { - "label": "", - "description": "" + "label": "Güncelleme gereksinimi uyarı eşiği", + "description": "Değer bu eşiğe ulaştığında sarı renge geçer" }, "hostsNeedingUpdatesCriticalAt": { - "label": "", - "description": "" + "label": "Güncelleme gereksinimi kritik eşiği", + "description": "Değer bu eşiğe ulaştığında kırmızı renge geçer" }, "securityUpdatesThresholdMode": { - "label": "", - "description": "" + "label": "Güvenlik güncellemeleri eşik modu", + "description": "Güvenlik güncellemesi sayısını mutlak değer veya toplam ana makinelerin yüzdesi olarak karşılaştırır" }, "securityUpdatesWarningAt": { - "label": "", - "description": "" + "label": "Güvenlik güncellemeleri uyarı eşiği", + "description": "Değer bu eşiğe ulaştığında sarı renge geçer" }, "securityUpdatesCriticalAt": { - "label": "", - "description": "" + "label": "Güvenlik güncellemeleri kritik eşiği", + "description": "Değer bu eşiğe ulaştığında kırmızı renge geçer" }, "hostsWithSecurityUpdatesThresholdMode": { - "label": "", - "description": "" + "label": "Güvenlik güncellemesi olan ana makineler için eşik modu", + "description": "Ana makine sayısını mutlak değer veya toplam ana makinelerin yüzdesi olarak karşılaştırır" }, "hostsWithSecurityUpdatesWarningAt": { - "label": "", - "description": "" + "label": "Güvenlik güncellemesi olan ana makineler için uyarı eşiği", + "description": "Değer bu eşiğe ulaştığında sarı renge geçer" }, "hostsWithSecurityUpdatesCriticalAt": { - "label": "", - "description": "" + "label": "Güvenlik güncellemesi olan ana makineler için kritik eşik", + "description": "Değer bu eşiğe ulaştığında kırmızı renge geçer" }, "upToDateHostsThresholdMode": { - "label": "", - "description": "" + "label": "Güncel ana makineler için eşik modu", + "description": "Güncel ana makine sayısını mutlak değer veya toplam ana makinelerin yüzdesi olarak karşılaştırır" }, "upToDateHostsWarningAt": { - "label": "", - "description": "" + "label": "Güncel ana makineler için uyarı eşiği", + "description": "Değer bu eşikte veya üzerinde, ancak tam kapsamanın altındaysa sarı renge geçer" }, "upToDateHostsCriticalAt": { - "label": "", - "description": "" + "label": "Güncel ana makineler için kritik eşik", + "description": "Değer bu eşiğin altına düştüğünde kırmızı renge geçer" }, "totalOutdatedPackagesThresholdMode": { - "label": "", - "description": "" + "label": "Güncel olmayan paketler için eşik modu", + "description": "Güncel olmayan paketlerin sayısını mutlak değer veya toplam ana makinelerin yüzdesi olarak karşılaştırır" }, "totalOutdatedPackagesWarningAt": { - "label": "", - "description": "" + "label": "Güncel olmayan paketler için uyarı eşiği", + "description": "Değer bu eşiğe ulaştığında sarı renge geçer" }, "totalOutdatedPackagesCriticalAt": { - "label": "", - "description": "" + "label": "Güncel olmayan paketler için kritik eşik", + "description": "Değer bu eşiğe ulaştığında kırmızı renge geçer" }, "showOsDistribution": { - "label": "", - "description": "" + "label": "İşletim sistemi dağılımını göster", + "description": "İzlenen ana makinelerin işletim sistemine göre dağılımını gösterir" }, "osDistributionLimit": { - "label": "", - "description": "", + "label": "İşletim sistemi dağılımı sınırı", + "description": "Gösterilecek en fazla işletim sistemi sayısı", "option": { - "3": "", - "5": "", - "10": "", - "all": "" + "3": "İlk 3", + "5": "İlk 5", + "10": "İlk 10", + "all": "Tümü" } }, "showOsVersion": { - "label": "", - "description": "" + "label": "İşletim sistemi sürümünü göster", + "description": "Mevcut olduğunda işletim sistemi sürümünü her dağılım etiketine ekler" }, "showComplianceHero": { - "label": "", - "description": "" + "label": "Uyumluluk özetini göster", + "description": "Güncel ana makinelerin yüzdesini gösteren bir uyumluluk halkası görüntüler" }, "osDisplayMode": { - "label": "", - "description": "", + "label": "İşletim sistemi görüntüleme modu", + "description": "İşletim sistemi dağılımının nasıl gösterileceğini seçin", "option": { - "bars": "", - "donut": "" + "bars": "Yatay çubuklar", + "donut": "Halka grafik" } } }, "error": { - "internalServerError": "" + "internalServerError": "PatchMon verileri alınamadı" } }, "bazarr": { - "name": "", - "description": "", - "episodes": "", - "movies": "", - "providers": "", - "status": "", + "name": "Bazarr", + "description": "Bazarr örneğinizdeki eksik altyazıların ve sağlık durumunun genel görünümü", + "episodes": "Eksik Bölümler", + "movies": "Eksik Filmler", + "providers": "Sağlayıcı Sorunları", + "status": "Sağlık Sorunları", "option": { "showMissingEpisodes": { - "label": "", - "description": "" + "label": "Eksik bölümleri göster", + "description": "Altyazısı eksik bölüm sayısını gösterir" }, "showMissingMovies": { - "label": "", - "description": "" + "label": "Eksik filmleri göster", + "description": "Altyazısı eksik film sayısını gösterir" }, "showProviderIssues": { - "label": "", - "description": "" + "label": "Sağlayıcı sorunlarını göster", + "description": "Sorun yaşayan altyazı sağlayıcılarının sayısını gösterir" }, "showHealthIssues": { - "label": "", - "description": "" + "label": "Sağlık sorunlarını göster", + "description": "Bazarr sağlık sorunlarının sayısını gösterir" } }, "error": { - "internalServerError": "" + "internalServerError": "Bazarr verileri alınamadı" } }, "immich-serverStats": { - "name": "Immich Server Stats", - "description": "Information about your Immich instance", + "name": "Immich Sunucu İstatistikleri", + "description": "Immich örneğiniz hakkında bilgiler", "option": { "showUsers": { - "label": "Show users", - "description": "Show count of users on your instance" + "label": "Kullanıcıları göster", + "description": "Örneğinizdeki kullanıcı sayısını gösterir" }, "showPhotos": { - "label": "Show photos", - "description": "Show count of photos on your instance" + "label": "Fotoğrafları göster", + "description": "Örneğinizdeki fotoğraf sayısını gösterir" }, "showVideos": { - "label": "Show videos", - "description": "Show count of videos on your instance" + "label": "Videoları göster", + "description": "Örneğinizdeki video sayısını gösterir" }, "showStorage": { - "label": "Show storage", - "description": "Show the total amount of storage used" + "label": "Depolamayı göster", + "description": "Kullanılan toplam depolama alanını gösterir" } }, - "users": "Users", - "photos": "Photos", - "videos": "Videos", - "storage": "Used Storage" + "users": "Kullanıcılar", + "photos": "Fotoğraflar", + "videos": "Videolar", + "storage": "Kullanılan Depolama" }, "immich-albumCarousel": { - "name": "Immich Album", - "allPhotos": "", + "name": "Immich Albümü", + "allPhotos": "Tüm fotoğraflar", "option": { "albumId": { - "label": "Album ID", - "description": "This is a unique ID which refers to your Immich album. You can find this id by opening an album and copying the unique string from the URL, for example 'a21aca09-9f05-47dc-b1a1-7b53c547226e'" + "label": "Albüm", + "description": "Bir Immich albümü veya Tüm fotoğraflar seçeneğini belirleyin. Seçimi boş bırakmak da tüm kütüphanenizden rastgele fotoğraflar gösterir." }, "rotationIntervalSeconds": { - "label": "Interval in seconds", - "description": "Rotate between pictures in the album every X seconds. Must be at least 1. Must be below 3600." + "label": "Saniye cinsinden aralık", + "description": "Albümdeki resimler arasında her X saniyede bir geçiş yapar. Değer en az 1 ve 3600'den küçük olmalıdır." }, "showPhotoInfo": { - "label": "Show photo info", - "description": "Display date of the picture and the current index at the bottom of the slideshow" + "label": "Fotoğraf bilgilerini göster", + "description": "Slayt gösterisinin altında resmin tarihini ve geçerli sıra numarasını gösterir" }, "randomizePhotos": { - "label": "", - "description": "" + "label": "Fotoğrafları rastgele sırala", + "description": "Her zaman aynı albüm sırasından başlamak yerine fotoğrafları rastgele sırada gösterir." } }, - "description": "Slideshow of pictures in your Immich album", - "noPhotos": "No photos in this album" + "description": "Bir Immich albümündeki veya tüm kütüphanenizdeki fotoğrafların slayt gösterisi", + "noPhotos": "Fotoğraf bulunamadı" }, "downloads": { "name": "İndirme İstemcisi", @@ -3081,18 +3107,18 @@ "description": "Bu, gösterilecek öğe sayısını tüm entegrasyonlar için değil, her entegrasyon özelinde sınırlar" }, "columnOrder": { - "label": "" + "label": "Sütun sırası" }, "columnWidths": { - "label": "" + "label": "Sütun genişlikleri" } }, "errors": { "noColumns": "Öğelerdeki Sütunları Seç", "noCommunications": "Entegrasyondan veri yüklenemiyor", - "noItems": "", - "actionFailed": "", - "actionFailedMessage": "" + "noItems": "İndirme bulunamadı", + "actionFailed": "Eylem başarısız oldu", + "actionFailedMessage": "Eylem tamamlanamadı. Lütfen yeniden deneyin." }, "items": { "actions": { @@ -3116,7 +3142,7 @@ }, "id": { "columnTitle": "İd", - "detailsTitle": "" + "detailsTitle": "Öğe Kimliği" }, "integration": { "columnTitle": "Entegrasyon" @@ -3151,7 +3177,7 @@ "time": { "columnTitle": "Bitiş zamanı", "detailsTitle": "Tamamlanmasından bu yana geçen süre", - "eta": "" + "eta": "Tahmini Kalan Süre" }, "type": { "columnTitle": "Tür", @@ -3197,21 +3223,21 @@ }, "globalRatio": "Genel Oran", "contextMenu": { - "copyName": "", - "copyId": "", - "clearFilters": "" + "copyName": "Adı kopyala", + "copyId": "Kimliği kopyala", + "clearFilters": "Filtreleri temizle" }, "footer": { - "filter": "" + "filter": "İndirmeleri filtrele" }, "stats": { - "show": "", - "hide": "", - "overallProgress": "", - "totalSize": "", - "clients": "" + "show": "Genel istatistikleri göster", + "hide": "Genel istatistikleri gizle", + "overallProgress": "Genel kuyruk ilerlemesi", + "totalSize": "Toplam kuyruk boyutu", + "clients": "{count, plural, one {# istemci} other {# istemci}}" }, - "disconnected": "" + "disconnected": "(bağlantı kesildi)" }, "mediaReleases": { "name": "Medya yayınları", @@ -3250,10 +3276,10 @@ "label": "Bağlantıları yeni sekmede aç" }, "statusFilter": { - "label": "" + "label": "Gösterilecek durumlar" }, "recentDays": { - "label": "" + "label": "Yalnızca son talepleri göster" } }, "pending": { @@ -3265,7 +3291,7 @@ "unknown": "Bilinmeyen", "pending": "Bekleyen", "processing": "İşlemde", - "requested": "Requested", + "requested": "Talep Edildi", "partiallyAvailable": "Kısmi", "available": "Mevcut", "blacklisted": "Engellenenler", @@ -3362,33 +3388,33 @@ } }, "mediaMissing": { - "name": "", - "description": "", + "name": "Eksik ve Kuyruktaki Medya", + "description": "Radarr ve Sonarr'daki eksik ve kuyruğa alınmış film ve bölümleri gösterir", "option": { "showMissing": { - "label": "" + "label": "Eksik öğeleri göster" }, "showQueued": { - "label": "" + "label": "Kuyruktaki öğeleri göster" }, "pageSize": { - "label": "" + "label": "Sayfa başına öğe" } }, "tab": { - "missing": "", - "queued": "" + "missing": "Eksik", + "queued": "Kuyrukta" }, "type": { - "movie": "", - "episode": "" + "movie": "Film", + "episode": "Bölüm" }, "empty": { - "missing": "", - "queued": "", - "noTabsEnabled": "" + "missing": "Eksik öğe yok", + "queued": "Kuyrukta öğe yok", + "noTabsEnabled": "Widget ayarlarında en az bir sekmeyi etkinleştirin" }, - "timeLeft": "" + "timeLeft": "Kalan süre" }, "rssFeed": { "name": "RSS Beslemeleri", @@ -3410,7 +3436,7 @@ "label": "Açıklamayı gizle" }, "showPosterImage": { - "label": "Show poster image" + "label": "Afiş görselini göster" } } }, @@ -3460,7 +3486,7 @@ "label": "Sağlayıcı" }, "providerUrl": { - "label": "" + "label": "Sağlayıcı URL'si" }, "identifier": { "label": "Tanımlayıcı", @@ -3511,11 +3537,11 @@ "tooltip": "Sağlayıcı çözümlenemedi, lütfen görselleri içe aktardıktan sonra elle ayarlayın" }, "tokens": { - "label": "", - "token": "", - "configured": "", - "notConfigured": "", - "save": "" + "label": "Sağlayıcı Tokenları", + "token": "Token", + "configured": "Token yapılandırıldı", + "notConfigured": "Token ayarlanmadı", + "save": "Kaydet" } } }, @@ -3538,9 +3564,9 @@ "invalidIdentifier": "Geçersiz tanımlayıcı", "noMatchingVersion": "Eşleşen sürüm bulunamadı", "noReleasesFound": "Hiçbir sürüm bulunamadı", - "noProviderSelected": "", + "noProviderSelected": "Sağlayıcı seçilmedi", "noProviderResponse": "Sağlayıcıdan yanıt alınamadı", - "unexpected": "" + "unexpected": "Beklenmeyen sağlayıcı hatası" } } }, @@ -3635,188 +3661,174 @@ "noItems": "Görüntülenecek bildirim yok.", "option": { "hideLogos": { - "label": "" + "label": "Logoları gizle" } } }, "speedtestTracker": { "name": "Speedtest Tracker", - "description": "Displays speed test results from your Speedtest Tracker instance.", + "description": "Speedtest Tracker örneğinizdeki hız testi sonuçlarını gösterir.", "option": { "showLatestResult": { - "label": "Show latest result" + "label": "Son sonucu göster" }, "showStats": { - "label": "Show averages" + "label": "Ortalamaları göster" }, "showRecentResults": { - "label": "Show recent results" + "label": "Son sonuçları göster" }, "showPingGraph": { - "label": "Show ping graph" + "label": "Ping grafiğini göster" } }, - "latestResult": "Latest Result", - "averages": "Averages", - "tests": "{count} tests", - "recentResults": "Recent Results", - "recentResultsSpeed": "Speed", + "latestResult": "Son Sonuç", + "averages": "Ortalamalar", + "tests": "{count} test", + "recentResults": "Son Sonuçlar", + "recentResultsSpeed": "Hız", "recentResultsPing": "Ping", - "download": "Download", - "upload": "Upload", + "download": "İndirme", + "upload": "Yükleme", "ping": "Ping", - "status": "Status", - "avgDownload": "Avg Download", - "avgUpload": "Avg Upload", - "avgPing": "Avg Ping", - "healthy": "Healthy", - "unhealthy": "Unhealthy", - "ok": "OK", - "fail": "Fail", - "noSectionsEnabled": "Enable at least one section in widget options.", + "status": "Durum", + "avgDownload": "Ort. İndirme", + "avgUpload": "Ort. Yükleme", + "avgPing": "Ort. Ping", + "healthy": "Sağlıklı", + "unhealthy": "Sağlıksız", + "ok": "Tamam", + "fail": "Başarısız", + "noSectionsEnabled": "Widget seçeneklerinde en az bir bölümü etkinleştirin.", "error": { - "internalServerError": "Failed to fetch speedtest data" + "internalServerError": "Hız testi verileri alınamadı" } }, "uptimeKuma": { - "name": "", - "description": "", + "name": "Uptime Kuma", + "description": "Uptime Kuma durum sayfanızdaki çalışma süresi istatistiklerini izleyin", "option": { "showAverageUptime": { - "label": "", - "description": "" + "label": "Ortalama çalışma süresini göster", + "description": "Birleşik 24 saatlik ortalama çalışma süresi yüzdesini gösterir" }, "showUptimeRing": { - "label": "", - "description": "" + "label": "Çalışma süresi halkasını göster", + "description": "Ortalama çalışma süresi değerinin yanında bir ilerleme halkası gösterir" }, "showTotalMonitors": { - "label": "", - "description": "" + "label": "Toplam izleyici sayısını göster", + "description": "Yapılandırılmış toplam izleyici sayısını gösterir" }, "showUpCount": { - "label": "", - "description": "" + "label": "Çalışanların sayısını göster", + "description": "Şu anda kaç izleyicinin çalıştığını gösterir" }, "showDownCount": { - "label": "", - "description": "" + "label": "Çalışmayanların sayısını göster", + "description": "Şu anda kaç izleyicinin çalışmadığını gösterir" }, "showPausedCount": { - "label": "", - "description": "" + "label": "Duraklatılanların sayısını göster", + "description": "Şu anda kaç izleyicinin duraklatıldığını gösterir" } }, - "averageUptime": "", + "averageUptime": "Ortalama çalışma süresi (24 sa.)", "stats": { - "totalMonitors": "", - "upCount": "", - "downCount": "", - "pausedCount": "" + "totalMonitors": "Toplam izleyici", + "upCount": "Çalışıyor", + "downCount": "Çalışmıyor", + "pausedCount": "Duraklatıldı" }, "error": { - "internalServerError": "" + "internalServerError": "Çalışma süresi verileri alınamadı" } }, "traefik": { - "name": "", - "description": "", + "name": "Traefik", + "description": "Traefik yönlendiricilerinin, hizmetlerinin, ara yazılımlarının ve giriş noktalarının genel görünümü", "option": { "showTcp": { - "label": "" + "label": "TCP'yi göster" }, "showUdp": { - "label": "" + "label": "UDP'yi göster" }, "showEntryPoints": { - "label": "" + "label": "Giriş noktalarını göster" } }, - "instances": "", - "versionUnknown": "", + "instances": "Traefik örnekleri", + "versionUnknown": "Sürüm bilinmiyor", "summary": { - "routers": "", - "entryPoints": "" + "routers": "Yönlendiriciler", + "entryPoints": "Giriş noktaları" }, "resource": { - "routers": "", - "services": "", - "middlewares": "" + "routers": "Yönlendiriciler", + "services": "Hizmetler", + "middlewares": "Ara Yazılımlar" }, "status": { - "healthy": "", - "errors": "", - "warnings": "" + "healthy": "Sağlıklı", + "errors": "{count} hata", + "warnings": "{count} uyarı" }, "error": { - "internalServerError": "" + "internalServerError": "Traefik verileri alınamadı" } }, "audioStats": { - "name": "", - "description": "", + "name": "Ses İstatistikleri", + "description": "Navidrome veya Audiobookshelf'ten kütüphane ve oynatma istatistikleri", "option": { "showArtists": { - "label": "", - "description": "" + "label": "Sanatçıları göster", + "description": "Müzik kütüphanenizdeki toplam sanatçı sayısını gösterir (Navidrome)" }, "showAlbums": { - "label": "", - "description": "" + "label": "Albümleri göster", + "description": "Müzik kütüphanenizdeki toplam albüm sayısını gösterir (Navidrome)" }, "showSongs": { - "label": "", - "description": "" - }, - "showNowPlaying": { - "label": "", - "description": "" - }, - "showNowPlayingList": { - "label": "", - "description": "" - }, - "maxNowPlayingItems": { - "label": "", - "description": "" + "label": "Şarkıları göster", + "description": "Müzik kütüphanenizdeki toplam şarkı sayısını gösterir (Navidrome)" }, "showLibraryCount": { - "label": "", - "description": "" + "label": "Kütüphane sayısını göster", + "description": "Örneğinizdeki kütüphane sayısını gösterir (Audiobookshelf)" }, "showAudiobooks": { - "label": "", - "description": "" + "label": "Sesli kitapları göster", + "description": "Tüm kütüphanelerdeki toplam sesli kitap sayısını gösterir (Audiobookshelf)" }, "showPodcasts": { - "label": "", - "description": "" + "label": "Podcast'leri göster", + "description": "Tüm kütüphanelerdeki toplam podcast sayısını gösterir (Audiobookshelf)" }, "showListeningTime": { - "label": "", - "description": "" + "label": "Dinleme süresini göster", + "description": "Toplam dinleme sürenizi gösterir (Audiobookshelf)" }, "showActiveSessions": { - "label": "", - "description": "" + "label": "Etkin oturumları göster", + "description": "Şu anda etkin olan oynatma oturumlarının sayısını gösterir (Audiobookshelf)" }, "compactMode": { - "label": "", - "description": "" + "label": "Kompakt mod", + "description": "Küçük widget boyutları için ideal olan, daha küçük kutucuklara sahip daha sıkı bir düzen kullanır" } }, - "artists": "", - "albums": "", - "songs": "", - "libraryCount": "", - "audiobooks": "", - "podcasts": "", - "listeningTime": "", - "activeSessions": "", - "nowPlaying": "", - "nowPlayingCount": "", + "artists": "Sanatçılar", + "albums": "Albümler", + "songs": "Şarkılar", + "libraryCount": "Kütüphaneler", + "audiobooks": "Sesli Kitaplar", + "podcasts": "Podcast'ler", + "listeningTime": "Dinleme Süresi", + "activeSessions": "Etkin Oturumlar", "error": { - "internalServerError": "" + "internalServerError": "Ses istatistikleri verileri alınamadı" } }, "systemResources": { @@ -3856,58 +3868,58 @@ } }, "systemDisks": { - "name": "System disks", - "description": "Disk usage of your system", + "name": "Sistem Diskleri", + "description": "Sisteminizin disk kullanımı", "option": { "showTemperatureIfAvailable": { - "label": "Show temperature if available" + "label": "Mevcutsa sıcaklığı göster" }, "displayMode": { - "label": "Display mode", + "label": "Görüntüleme modu", "option": { "percentage": { - "label": "Percentage (e.g., 76%)" + "label": "Yüzde (ör. %76)" }, "absolute": { - "label": "Absolute values (e.g., 800GB / 1TB)" + "label": "Mutlak değerler (ör. 800 GB / 1 TB)" }, "free": { - "label": "Free space percentage (e.g., 24% free)" + "label": "Boş alan yüzdesi (ör. %24 boş)" } } }, "showBackgroundBar": { - "label": "Show background usage bar" + "label": "Arka plan kullanım çubuğunu göster" }, "visibleStorageVolumes": { - "label": "", - "description": "", - "placeholder": "" + "label": "Görünür depolama birimleri", + "description": "Yalnızca seçilen entegrasyonların tümü Synology olduğunda kullanılabilir. Algılanan tüm depolama birimlerini göstermek için boş bırakın.", + "placeholder": "Depolama birimlerini seçin" } }, "status": { - "unhealthy": "Unhealthy" + "unhealthy": "Sağlıksız" } }, "customApi": { - "name": "", - "description": "", + "name": "Özel API", + "description": "Özel yapılandırmayla herhangi bir harici API'den veri gösterir.", "option": { "definitionId": { - "label": "" + "label": "Tanım Kimliği" }, "refreshInterval": { - "label": "", - "description": "" + "label": "Yenileme aralığı", + "description": "Verilerin API'den hangi sıklıkta yeniden alınacağı (saniye)" } }, - "noDefinition": "", - "definitionNotFound": "", - "noDisplayOutput": "", - "executeConfirm": "", - "executing": "", - "executeSuccess": "", - "executeFailed": "" + "noDefinition": "Tanım seçilmedi", + "definitionNotFound": "Widget tanımı bulunamadı. Silinmiş olabilir.", + "noDisplayOutput": "Görüntülenecek çıktı yok", + "executeConfirm": "Bu eylemi yürütmek istediğinizden emin misiniz?", + "executing": "Yürütülüyor...", + "executeSuccess": "Eylem başarıyla yürütüldü", + "executeFailed": "Eylem başarısız oldu. Widget yapılandırmasını kontrol edin." }, "beszelSystemTable": { "name": "Beszel Sistemleri (Tablo)", @@ -3954,11 +3966,19 @@ }, "showAgent": { "label": "Ajan sürümünü göster" + }, + "columnOrder": { + "label": "Sütun sırası" + }, + "columnWidths": { + "label": "Sütun genişlikleri" } }, "noRecords": "Sistem bulunamadı", "error": { - "internalServerError": "" + "internalServerError": "Beszel'e bağlanılamadı. Entegrasyon ayarlarınızı kontrol edin.", + "layoutSaveTitle": "Tablo düzeni kaydedilemedi", + "layoutSaveMessage": "Sütun sırası veya genişlikleri kaydedilemedi. Lütfen yeniden deneyin." }, "column": { "system": "Sistem", @@ -4017,138 +4037,138 @@ } }, "error": { - "internalServerError": "" + "internalServerError": "Beszel'e bağlanılamadı. Entegrasyon ayarlarınızı kontrol edin." }, "empty": { - "noSystems": "" + "noSystems": "Sistem bulunamadı" }, "metric": { - "cpu": "", - "memory": "", - "disk": "", - "gpu": "", - "loadAvg": "", - "net": "", - "temp": "", - "battery": "", - "services": "", - "uptime": "", - "agent": "" + "cpu": "CPU", + "memory": "Bellek", + "disk": "Disk", + "gpu": "GPU", + "loadAvg": "Ort. Yük", + "net": "Ağ", + "temp": "Sıcaklık", + "battery": "Pil", + "services": "Hizmetler", + "uptime": "Çalışma Süresi", + "agent": "Ajan" } }, "beszelAlerts": { - "name": "", - "description": "", + "name": "Beszel Uyarıları", + "description": "Beszel uyarı yapılandırmalarını ve geçmişini görüntüleyin", "option": { "showHistory": { - "label": "" + "label": "Uyarı geçmişini göster" }, "maxHistoryItems": { - "label": "" + "label": "En fazla geçmiş öğesi" } }, - "empty": "", + "empty": "Yapılandırılmış uyarı yok", "error": { - "internalServerError": "" + "internalServerError": "Beszel'e bağlanılamadı. Entegrasyon ayarlarınızı kontrol edin." }, - "history": "", - "resolved": "", + "history": "Geçmiş", + "resolved": "Çözüldü", "status": { - "triggered": "", - "ok": "" + "triggered": "Tetiklendi", + "ok": "Tamam" } }, "beszelSystemStats": { - "name": "", - "description": "", + "name": "Beszel Sistem İstatistikleri", + "description": "CPU, bellek, disk, ağ ve Docker konteyner ölçümleri için zaman serisi grafikleri", "option": { "systemId": { - "label": "", - "description": "" + "label": "Sistem", + "description": "İstatistikleri gösterilecek Beszel sistemini seçin" }, "timePeriod": { - "label": "" + "label": "Zaman aralığı" }, "showCpu": { - "label": "" + "label": "CPU grafiğini göster" }, "showMemory": { - "label": "" + "label": "Bellek grafiğini göster" }, "showDisk": { - "label": "" + "label": "Disk grafiğini göster" }, "showDiskIO": { - "label": "" + "label": "Disk G/Ç grafiğini göster" }, "showNetwork": { - "label": "" + "label": "Ağ grafiğini göster" }, "showDockerCpu": { - "label": "" + "label": "Docker CPU grafiğini göster" }, "showDockerMemory": { - "label": "" + "label": "Docker bellek grafiğini göster" }, "showDockerNetwork": { - "label": "" + "label": "Docker ağ grafiğini göster" } }, - "selectSystem": "", + "selectSystem": "Sistem seçin", "empty": { - "noSystems": "" + "noSystems": "Sistem bulunamadı" }, "error": { - "internalServerError": "", - "liveConnectionFailed": "", - "switchToHistorical": "", - "systemNotFound": "" + "internalServerError": "Beszel'e bağlanılamadı. Entegrasyon ayarlarınızı kontrol edin.", + "liveConnectionFailed": "Canlı bağlantı başarısız oldu. Beszel örneğiniz gerçek zamanlı akışı desteklemiyor olabilir.", + "switchToHistorical": "Geçmiş görünümüne geç", + "systemNotFound": "Seçilen sistem bulunamadı. Farklı bir sistem seçin." }, "chart": { "cpu": { - "title": "", - "subtitle": "", - "series": "" + "title": "CPU Kullanımı", + "subtitle": "Sistem genelindeki ortalama CPU kullanımı", + "series": "CPU" }, "memory": { - "title": "", - "subtitle": "", - "series": "", - "cache": "" + "title": "Bellek Kullanımı", + "subtitle": "Kaydedilen andaki kesin kullanım oranı", + "series": "Kullanılan", + "cache": "Önbellek/Tamponlar" }, "disk": { - "title": "", - "subtitle": "", - "series": "" + "title": "Disk Kullanımı", + "subtitle": "Dosya sistemine göre kullanım", + "series": "Kök" }, "diskIO": { - "title": "", - "subtitle": "", - "read": "", - "write": "" + "title": "Disk G/Ç", + "subtitle": "Kök dosya sisteminin aktarım hızı", + "read": "Okuma", + "write": "Yazma" }, "storage": { - "title": "", - "subtitle": "", - "series": "" + "title": "Depolama", + "subtitle": "Dosya sistemi başına disk kullanımı", + "series": "Kullanılan" }, "network": { - "title": "", - "subtitle": "", - "sent": "", - "recv": "" + "title": "Bant Genişliği", + "subtitle": "Genel ağ arayüzlerinin trafiği", + "sent": "Gönderilen", + "recv": "Alınan" }, "dockerCpu": { - "title": "", - "subtitle": "" + "title": "Docker CPU Kullanımı", + "subtitle": "Konteynerlerin ortalama CPU kullanımı" }, "dockerMemory": { - "title": "", - "subtitle": "" + "title": "Docker Bellek Kullanımı", + "subtitle": "Docker konteynerlerinin bellek kullanımı" }, "dockerNetwork": { - "title": "", - "subtitle": "" + "title": "Docker Ağ G/Ç", + "subtitle": "Docker konteynerlerinin ağ trafiği" } } } @@ -4525,9 +4545,9 @@ "text": "İstenen kaynak bulunamadı" }, "error": { - "title": "", - "text": "", - "digest": "" + "title": "Bu sayfa yüklenemedi", + "text": "Bu sayfa yüklenirken beklenmeyen bir hata oluştu. Gezinme menüsü hâlâ kullanılabilir; sayfayı yeniden yüklemeyi deneyin veya kenar çubuğundan başka bir bölüme geçin.", + "digest": "Hata başvurusu: {digest}" }, "navbar": { "items": { @@ -4535,7 +4555,7 @@ "boards": "Paneller", "apps": "Uygulamalar", "integrations": "Entegrasyonlar", - "customWidgets": "", + "customWidgets": "Özel Widget'lar", "searchEngies": "Arama Motorları", "medias": "Medyalar", "users": { @@ -4552,7 +4572,7 @@ "docker": "Docker", "kubernetes": "Kubernetes", "logs": "Günlükler", - "api": "API", + "api": "API ve MCP", "certificates": "Sertifikalar", "tasks": "Görevler", "backup": "Yedekleme" @@ -4580,8 +4600,8 @@ "integration": "Entegrasyonlar", "app": "Uygulamalar", "group": "Gruplar", - "searchEngine": "Search engines", - "media": "Medias" + "searchEngine": "Arama motorları", + "media": "Medya" }, "statisticLabel": { "boards": "Paneller", @@ -4590,8 +4610,8 @@ "authorization": "Yetkilendirme" }, "heroBanner": { - "title": "Welcome back to your", - "subtitle": "{app} Board" + "title": "Tekrar hoş geldiniz", + "subtitle": "{app} Panosu" } }, "board": { @@ -4809,8 +4829,8 @@ "analytics": { "title": "Analiz", "general": { - "title": "Anonim analizler gönder", - "text": "Homarr, açık kaynaklı yazılım Umami'yi kullanarak anonimleştirilmiş analizler gönderecektir. Hiçbir zaman kişisel bilgi toplamaz ve bu nedenle tamamen GDPR ve CCPA uyumludur. Analizleri etkinleştirmenizi öneririz çünkü, açık kaynak yazılım ekibimizin sorunları daha hızlı belirlemesine ve birikmiş işlerimizi önceliklendirmesine yardımcı olmaktadır." + "title": "Homarr'ı geliştirmemize yardımcı olun", + "text": "Bu, kullanıcıların gerçekte neleri kullandığını anlamamıza ve zamanımızı nereye ayıracağımıza karar vermemize yardımcı olur. Kaç widget veya entegrasyona sahip olduğunuz, Homarr sürümünüz ve kullandığınız veritabanı gibi bilgileri görürüz. Hepsi bu; kişisel bilgi, URL, ad ya da sizi veya kurulumunuzu tanımlayabilecek hiçbir şey toplanmaz." } }, "crawlingAndIndexing": { @@ -5011,92 +5031,92 @@ } }, "mcp": { - "label": "", - "title": "", - "subtitle": "", - "description": "", - "noApiKey": "", - "createApiKeyButton": "", + "label": "Yapay Zekâ / MCP", + "title": "Yapay Zekâ Entegrasyonu", + "subtitle": "Model Context Protocol (MCP) Sunucusu", + "description": "Yapay zekâ asistanlarını Homarr örneğinize bağlayın ve tüm homelab ortamınızı doğal dille yönetin. Overseerr'da medya arayıp talep edin, Docker konteynerlerini yönetin, Pi-hole'u açıp kapatın, sunucu sağlığını denetleyin, Home Assistant cihazlarını kontrol edin ve daha fazlasını doğrudan yapay zekâ asistanınızdan gerçekleştirin.", + "noApiKey": "MCP sunucusunda kimlik doğrulamak için bir API anahtarı gerekir.", + "createApiKeyButton": "API Anahtarı Oluştur", "endpoint": { - "title": "" + "title": "MCP Uç Noktası" }, "authentication": { - "title": "", - "intro": "", + "title": "Kimlik Doğrulama", + "intro": "MCP sunucunuzda API anahtarı veya OAuth kullanarak kimlik doğrulayabilirsiniz. Her iki yöntem de aynı erişim düzeyini sağlar.", "apiKey": { - "title": "", - "description": "" + "title": "API Anahtarı (manuel)", + "description": "MCP istemci yapılandırmasında ApiKey üst bilgisini tam API anahtarınızla gönderin." }, "oauth": { - "title": "", - "description": "", - "hint": "" + "title": "OAuth (tarayıcıyla giriş)", + "description": "OAuth destekleyen istemciler (Claude Code, opencode veya Cursor gibi) bir tarayıcı penceresi açarak kimlik doğrulayabilir. İstemcinizde auth komutunu çalıştırın, tarayıcıda Homarr'a giriş yapın; bağlantı kurulur ve otomatik olarak bir API anahtarı oluşturulur.", + "hint": "OAuth tokenları Kimlik Doğrulama sekmesinde normal API anahtarları olarak görünür. Bunları istediğiniz zaman iptal edebilirsiniz." }, - "permissions": "" + "permissions": "API anahtarı, yapay zekânın kullanabileceği izinleri belirler; yapay zekâ anahtar sahibiyle aynı erişim düzeyinde çalışır." }, "clientConfig": { - "title": "", + "title": "İstemci Yapılandırması", "streamableHttp": { - "recommended": "", - "title": "", - "worksWith": "", - "addToConfig": "", - "cursor": "", - "claudeDesktop": "", - "claudeCode": "", - "vsCodeCopilot": "", - "replaceHint": "" + "recommended": "Önerilen", + "title": "Akış Destekli HTTP", + "worksWith": "Şunlarla çalışır: Cursor, Claude Desktop, VS Code Copilot, Windsurf, Claude Code", + "addToConfig": "MCP istemci yapılandırma dosyanıza ekleyin:", + "cursor": "{name}: projenizdeki veya genel ayarlardaki {file}", + "claudeDesktop": "{name}: {file}", + "claudeCode": "{name}: {file} veya {command} komutunu kullanın", + "vsCodeCopilot": "{name}: çalışma alanı ayarlarındaki {file}", + "replaceHint": "Yer tutucuyu, oluştururken aldığınız API anahtarıyla değiştirin." }, "stdio": { - "title": "", - "description": "" + "title": "stdio (mcp-remote köprüsü üzerinden eski istemciler)", + "description": "Yalnızca stdio aktarımını destekleyen istemciler içindir. Yerel olarak {dependency} (Node.js) kurulu olmalıdır:" } }, "testConnection": { - "title": "", - "description": "", - "hint": "" + "title": "Bağlantınızı Test Edin", + "description": "Uç noktanın erişilebilir ve API anahtarınızın çalışır olduğunu doğrulamak için bunu terminalinizde çalıştırın:", + "hint": "Başarılı bir yanıt MCP sunucusunun yeteneklerini döndürür. Hata görürseniz ipuçları için JSON yanıtını inceleyin." }, "availableTools": { - "title": "", - "description": "", - "columnType": "", - "columnName": "", - "columnDescription": "" + "title": "Kullanılabilir Araçlar", + "description": "{count} araç kullanılabilir. Araçları ve açıklamalarını görmek için bir kategoriyi genişletin.", + "columnType": "Tür", + "columnName": "Araç", + "columnDescription": "Açıklama" }, "troubleshooting": { - "title": "", + "title": "Sorun Giderme", "missingHeader": { - "title": "", - "description": "" + "title": "“ApiKey üst bilgisi eksik”", + "description": "İstemciniz {header} üst bilgisini göndermiyor. MCP yapılandırmanızdaki {section} bölümünün doğru olduğunu doğrulayın. Bazı istemcilerin yapılandırma değişikliklerinden sonra yeniden başlatılması gerekir." }, "invalidFormat": { - "title": "", - "description": "" + "title": "“Geçersiz ApiKey biçimi”", + "description": "API anahtarının yalnızca bir bölümünü değil tamamını kopyaladığınızdan emin olun." }, "invalidKey": { - "title": "", - "description": "" + "title": "“Geçersiz API anahtarı”", + "description": "Anahtar kimliği bulunamadı veya token yanlış. API anahtarları oluşturulduklarında yalnızca bir kez görüntülenebilir. Anahtarı kaybettiyseniz eskisini silip yenisini oluşturun." }, "connectionRefused": { - "title": "", - "description": "" + "title": "Bağlantı reddedildi / zaman aşımı", + "description": "URL'nin yapay zekâ istemcinizden erişilebilir olduğundan emin olun. Homarr ters proxy arkasında veya Docker'da çalışıyorsa {localhost} yerine harici URL'yi kullanın. Portun doğru olduğunu doğrulayın (Docker için varsayılan: 7575)." }, "toolsNotAppearing": { - "title": "", - "description": "" + "title": "Araçlar görünmüyor", + "description": "Bazı araçlar belirli entegrasyonların yapılandırılmasını gerektirir (ör. Docker, Overseerr). Araç listesi Homarr örneğinizde kullanılabilir olanlara bağlıdır." } }, - "copy": "", + "copy": "Kopyala", "oneClick": { - "title": "", - "subtitle": "", - "vsCode": "", - "cursor": "", - "copyUrl": "", - "copied": "", - "copyHint": "", - "manualConfig": "" + "title": "Tek Tıkla Kur", + "subtitle": "OAuth kullanır; ilk kullanımda tarayıcınızda kimlik doğrulayın", + "vsCode": "VS Code'a Kur", + "cursor": "Cursor'a Kur", + "copyUrl": "MCP URL'sini Kopyala", + "copied": "Kopyalandı!", + "copyHint": "ChatGPT, Claude Code veya herhangi bir MCP istemcisi için", + "manualConfig": "Manuel Yapılandırma" } } } @@ -5265,13 +5285,13 @@ "table": { "updated": "{when} Güncellendi", "refresh": { - "lastUpdated": "" + "lastUpdated": "Son yenileme: {when}" }, "search": "{count} konteyner içinde ara", "selected": "{selectCount} / {totalCount} konteyner seçildi", "footer": "Toplam {count} konteyner", - "totalCpu": "", - "totalMemory": "" + "totalCpu": "Toplam CPU: %{cpu}", + "totalMemory": "Toplam Bellek: {memory}" }, "field": { "name": { @@ -5356,6 +5376,7 @@ }, "remove": { "label": "Kaldır", + "confirm": "Kalıcı olarak kaldırmak için yeniden tıklayın", "notification": { "success": { "title": "Konteynerler kaldırıldı", @@ -5399,7 +5420,7 @@ }, "error": { "internalServerError": "Docker konteynerlerları getirilemedi", - "noColumns": "" + "noColumns": "Widget ayarlarında en az bir sütun seçin" } }, "kubernetes": { @@ -5418,7 +5439,7 @@ }, "resources": { "title": "Kaynaklar", - "nodes": "Nodes", + "nodes": "Düğümler", "namespaces": "İsim Alanları", "ingresses": "Ingress'ler", "services": "Hizmetler", @@ -5708,14 +5729,14 @@ } }, "custom-widgets": { - "label": "", + "label": "Özel widget'lar", "new": { - "label": "" + "label": "Yeni" }, "edit": { - "label": "", + "label": "Düzenle", "flow": { - "label": "" + "label": "Akış" } } }, @@ -5805,7 +5826,7 @@ "children": { "action": { "open": { - "label": "Uygulama Url'sini aç" + "label": "Uygulama URL'sini aç" }, "edit": { "label": "Uygulamayı düzenle" @@ -5996,10 +6017,10 @@ } }, "enableRightClickOnWidgets": { - "label": "", + "label": "Widget'lara sağ tıklamayı etkinleştir", "children": { "detail": { - "title": "" + "title": "Widget'lara sağ tıklamayı etkinleştir" } } }, @@ -6455,7 +6476,7 @@ }, "integrationsList": { "title": "Entegrasyonlarınız", - "description": "Bunlar Homarr'a bağlı hizmetlerdir — Sonarr, Radarr, Plex, Jellyfin ve 50'den fazlası. Tüm kimlik bilgileri beklemede şifrelenir." + "description": "Bunlar Homarr'a bağlı hizmetlerdir — Sonarr, Radarr, Plex, Jellyfin ve 50'den fazlası. Tüm kimlik bilgileri depolanırken şifrelenir." }, "integrationsCreate": { "title": "Entegrasyon Ekle", @@ -6467,7 +6488,7 @@ }, "usersCreate": { "title": "Kullanıcı Oluştur", - "description": "Buraya yeni bir kullanıcı hesabı ekleyin. Kolay katılım için davet bağlantılarını da kullanabilirsiniz. Tur bu kadar — hepiniz hazırsınız!" + "description": "Buraya yeni bir kullanıcı hesabı ekleyin. Kolay katılım için davet bağlantılarını da kullanabilirsiniz. Tur bu kadar — artık hazırsınız!" } }, "board": { diff --git a/packages/translation/src/lang/uk.json b/packages/translation/src/lang/uk.json index fac2b07ee..fd87688e4 100644 --- a/packages/translation/src/lang/uk.json +++ b/packages/translation/src/lang/uk.json @@ -2428,10 +2428,21 @@ }, "descendingDefaultSort": { "label": "Інверсія сортування" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "error": { - "internalServerError": "Не вдалося отримати статистику контейнерів" + "internalServerError": "Не вдалося отримати статистику контейнерів", + "layoutSaveTitle": "", + "layoutSaveMessage": "" + }, + "empty": { + "noContainers": "" } }, "coolify": { @@ -2725,8 +2736,20 @@ "showOnlyPlaying": { "label": "Показувати тільки поточне відтворення", "description": "Вимкнення цієї опції не працюватиме для Plex." + }, + "showBitrate": { + "label": "", + "description": "" + }, + "showLocation": { + "label": "", + "description": "" } }, + "footer": { + "streams": "", + "totalBitrate": "" + }, "items": { "currentlyPlaying": "Зараз відтворюється", "user": "Користувач", @@ -2734,6 +2757,9 @@ "id": "Id", "status": "Статус", "directPlay": "Пряме відтворення", + "directStream": "", + "transcodeVideo": "", + "transcodeAudio": "", "transcoding": "Транскодування", "paused": "Призупинено", "remaining": "Залишилося {minutes} хв", @@ -3768,18 +3794,6 @@ "label": "Показувати пісні", "description": "Показати загальну кількість пісень у вашій музичній бібліотеці (Navidrome)" }, - "showNowPlaying": { - "label": "Показати, що зараз відтворюються", - "description": "Показувати пісні, що зараз відтворюються (Navidrome)" - }, - "showNowPlayingList": { - "label": "Показати список відтворення зараз", - "description": "Відображати окремі треки замість лише кількості активних потоків (Navidrome)" - }, - "maxNowPlayingItems": { - "label": "Максимум елементів для відтворення", - "description": "Максимальна кількість треків для відображення у списку відтворення (Navidrome)" - }, "showLibraryCount": { "label": "Показувати кількість бібліотек", "description": "Показати кількість бібліотек на вашому екземплярі (Audiobookshelf)" @@ -3813,8 +3827,6 @@ "podcasts": "Подкасти", "listeningTime": "Час прослуховування", "activeSessions": "Активні Сесії", - "nowPlaying": "Зараз програється", - "nowPlayingCount": "{count, plural, one {# трансляція} few {# трансляції} many {# трансляцій} other {# трансляцій}}", "error": { "internalServerError": "Не вдалося отримати статистику аудіо" } @@ -3954,11 +3966,19 @@ }, "showAgent": { "label": "Показати версію агента" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "noRecords": "Системи не знайдені", "error": { - "internalServerError": "Не вдається підключитися до Beszel. Перевірте параметри інтеграції." + "internalServerError": "Не вдається підключитися до Beszel. Перевірте параметри інтеграції.", + "layoutSaveTitle": "", + "layoutSaveMessage": "" }, "column": { "system": "Система", @@ -5356,6 +5376,7 @@ }, "remove": { "label": "Видалити", + "confirm": "", "notification": { "success": { "title": "Контейнери видалено", diff --git a/packages/translation/src/lang/vi.json b/packages/translation/src/lang/vi.json index 0ade183c5..017569e36 100644 --- a/packages/translation/src/lang/vi.json +++ b/packages/translation/src/lang/vi.json @@ -2428,10 +2428,21 @@ }, "descendingDefaultSort": { "label": "" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "error": { - "internalServerError": "" + "internalServerError": "", + "layoutSaveTitle": "", + "layoutSaveMessage": "" + }, + "empty": { + "noContainers": "" } }, "coolify": { @@ -2725,8 +2736,20 @@ "showOnlyPlaying": { "label": "", "description": "" + }, + "showBitrate": { + "label": "", + "description": "" + }, + "showLocation": { + "label": "", + "description": "" } }, + "footer": { + "streams": "", + "totalBitrate": "" + }, "items": { "currentlyPlaying": "", "user": "", @@ -2734,6 +2757,9 @@ "id": "", "status": "", "directPlay": "", + "directStream": "", + "transcodeVideo": "", + "transcodeAudio": "", "transcoding": "", "paused": "", "remaining": "", @@ -3768,18 +3794,6 @@ "label": "", "description": "" }, - "showNowPlaying": { - "label": "", - "description": "" - }, - "showNowPlayingList": { - "label": "", - "description": "" - }, - "maxNowPlayingItems": { - "label": "", - "description": "" - }, "showLibraryCount": { "label": "", "description": "" @@ -3813,8 +3827,6 @@ "podcasts": "", "listeningTime": "", "activeSessions": "", - "nowPlaying": "", - "nowPlayingCount": "", "error": { "internalServerError": "" } @@ -3954,11 +3966,19 @@ }, "showAgent": { "label": "Hiển thị phiên bản tác tử" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "noRecords": "Không tìm thấy hệ thống", "error": { - "internalServerError": "" + "internalServerError": "", + "layoutSaveTitle": "", + "layoutSaveMessage": "" }, "column": { "system": "Hệ thống", @@ -5356,6 +5376,7 @@ }, "remove": { "label": "Xóa", + "confirm": "", "notification": { "success": { "title": "", diff --git a/packages/translation/src/lang/zh.json b/packages/translation/src/lang/zh.json index 6b6dcca96..58e679768 100644 --- a/packages/translation/src/lang/zh.json +++ b/packages/translation/src/lang/zh.json @@ -2428,10 +2428,21 @@ }, "descendingDefaultSort": { "label": "" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "error": { - "internalServerError": "" + "internalServerError": "", + "layoutSaveTitle": "", + "layoutSaveMessage": "" + }, + "empty": { + "noContainers": "" } }, "coolify": { @@ -2725,8 +2736,20 @@ "showOnlyPlaying": { "label": "只顯示目前播放中的項目", "description": "停用此功能將無法在 Plex 上運作" + }, + "showBitrate": { + "label": "", + "description": "" + }, + "showLocation": { + "label": "", + "description": "" } }, + "footer": { + "streams": "", + "totalBitrate": "" + }, "items": { "currentlyPlaying": "目前播放中", "user": "使用者", @@ -2734,6 +2757,9 @@ "id": "ID", "status": "狀態", "directPlay": "直接播放", + "directStream": "", + "transcodeVideo": "", + "transcodeAudio": "", "transcoding": "轉碼中", "paused": "已暫停", "remaining": "剩餘 {minutes} 分鐘", @@ -3768,18 +3794,6 @@ "label": "顯示歌曲", "description": "顯示音樂庫中的歌曲總數 (Navidrome)" }, - "showNowPlaying": { - "label": "顯示正在播放", - "description": "顯示目前播放中的曲目 (Navidrome)" - }, - "showNowPlayingList": { - "label": "顯示正在播放清單", - "description": "顯示個別曲目,而非僅顯示活動串流計數 (Navidrome)" - }, - "maxNowPlayingItems": { - "label": "最大正在播放項目數", - "description": "在正在播放清單中顯示的最大曲目數量 (Navidrome)" - }, "showLibraryCount": { "label": "顯示庫數量", "description": "顯示你的執行個體上的庫數量 (Audiobookshelf)" @@ -3813,8 +3827,6 @@ "podcasts": "播客", "listeningTime": "聆聽時間", "activeSessions": "活躍會話", - "nowPlaying": "正在播放", - "nowPlayingCount": "", "error": { "internalServerError": "無法擷取音訊統計資料" } @@ -3954,11 +3966,19 @@ }, "showAgent": { "label": "顯示代理版本" + }, + "columnOrder": { + "label": "" + }, + "columnWidths": { + "label": "" } }, "noRecords": "找不到系統", "error": { - "internalServerError": "無法連線到 Beszel。請檢查整合設定。" + "internalServerError": "無法連線到 Beszel。請檢查整合設定。", + "layoutSaveTitle": "", + "layoutSaveMessage": "" }, "column": { "system": "系統", @@ -5356,6 +5376,7 @@ }, "remove": { "label": "移除", + "confirm": "", "notification": { "success": { "title": "容器已移除", From bff3383ac9e83f78388310c97ea8fb98721772eb Mon Sep 17 00:00:00 2001 From: Releases Homarr <175486441+homarr-releases[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:41:07 +0000 Subject: [PATCH 11/17] chore(release): 1.74.0 [skip ci] --- CHANGELOG.md | 2 ++ package.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f89968fdb..a829e2042 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,5 @@ +## [1.74.0](https://github.com/homarr-labs/homarr/compare/v1.73.0...v1.74.0) (2026-08-07) + ## [1.73.0](https://github.com/homarr-labs/homarr/compare/v1.72.0...v1.73.0) (2026-07-31) ## [1.72.0](https://github.com/homarr-labs/homarr/compare/v1.71.0...v1.72.0) (2026-07-24) diff --git a/package.json b/package.json index 705cae439..49f002ba6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homarr", - "version": "1.73.0", + "version": "1.74.0", "private": true, "scripts": { "build": "cross-env CI=true turbo build", From b479ada14f7bb434684fac6c139a2714f180d217 Mon Sep 17 00:00:00 2001 From: Thomas Date: Fri, 7 Aug 2026 20:05:46 +0200 Subject: [PATCH 12/17] ci: auto-approve and auto-merge trusted bot pull requests (#6531) * ci: auto-approve and auto-merge trusted bot pull requests Replace the approve-only automatic-approval workflow with a combined approve + enable-auto-merge workflow that also re-runs on every push, plus a scheduled reconcile job that re-enables auto-merge for prepared bot PRs if GitHub ever disables it. Bot PRs are gated to the known bot actors, same-repo head, and base branch dev. * ci: match bot authors by app slug in reconcile filter gh pr list reports pull request authors of GitHub Apps as app/; adjust the allowlist so the reconcile job actually matches bot PRs. --- .github/workflows/automatic-approval.yml | 35 --------------- .../workflows/bot-auto-merge-reconcile.yml | 40 +++++++++++++++++ .github/workflows/bot-pr-automerge.yml | 44 +++++++++++++++++++ 3 files changed, 84 insertions(+), 35 deletions(-) delete mode 100644 .github/workflows/automatic-approval.yml create mode 100644 .github/workflows/bot-auto-merge-reconcile.yml create mode 100644 .github/workflows/bot-pr-automerge.yml diff --git a/.github/workflows/automatic-approval.yml b/.github/workflows/automatic-approval.yml deleted file mode 100644 index 9e246f838..000000000 --- a/.github/workflows/automatic-approval.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: "[Dependency Updates] Auto Approve" -on: - pull_request: - types: [opened, synchronize] - -permissions: {} - -jobs: - approve-automatic-prs: - runs-on: ubuntu-latest - timeout-minutes: 2 - # 158783068: homarr-renovate[bot] - # 190541745: homarr-crowdin[bot] - # 210161987: homarr-update-contributors[bot] - # 175486441: homarr-releases[bot] - # Extracted from https://api.github.com/users/ - if: github.actor_id == 158783068 || github.actor_id == 190541745 || github.actor_id == 210161987 || github.actor_id == 175486441 - steps: - - name: Checkout code - uses: actions/checkout@v7 - - name: Obtain token - id: obtainToken - uses: actions/create-github-app-token@v3 - with: - private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }} - client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }} - permission-pull-requests: write # required to approve pull request - - - name: Install GitHub CLI - run: sudo apt-get install -y gh - - name: Approve automatic PRs - env: - GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }} - run: | - gh pr review ${{github.event.pull_request.number}} --approve --body "Automatically approved by GitHub Action" diff --git a/.github/workflows/bot-auto-merge-reconcile.yml b/.github/workflows/bot-auto-merge-reconcile.yml new file mode 100644 index 000000000..3bc45c127 --- /dev/null +++ b/.github/workflows/bot-auto-merge-reconcile.yml @@ -0,0 +1,40 @@ +name: "[Bots] Reconcile Auto-merge" + +on: + schedule: + - cron: "*/30 * * * *" + workflow_dispatch: + +permissions: {} + +jobs: + reconcile: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Obtain token + id: obtainToken + uses: actions/create-github-app-token@v3 + with: + private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }} + client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }} + permission-pull-requests: write # required to enable auto-merge + + - name: Install GitHub CLI + run: sudo apt-get install -y gh + + - name: Re-enable auto-merge for prepared bot PRs + env: + GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }} + run: | + # 158783068: homarr-renovate[bot] + # 190541745: homarr-crowdin[bot] + # 210161987: homarr-update-contributors[bot] + # 175441: homarr-releases[bot] + gh pr list --repo "${{ github.repository }}" --base dev --state open --limit 100 \ + --json number,author,reviewDecision,mergeStateStatus,autoMergeRequest \ + --jq '.[] | select(.author.login as $login | ["app/homarr-renovate", "app/homarr-crowdin", "app/homarr-update-contributors", "app/homarr-releases"] | index($login)) | select(.reviewDecision == "APPROVED") | select(.mergeStateStatus == "CLEAN") | select(.autoMergeRequest == null) | .number' \ + | while read -r pr; do + echo "Re-enabling auto-merge on PR #$pr" + gh pr merge "$pr" --auto --squash --delete-branch + done diff --git a/.github/workflows/bot-pr-automerge.yml b/.github/workflows/bot-pr-automerge.yml new file mode 100644 index 000000000..62e604443 --- /dev/null +++ b/.github/workflows/bot-pr-automerge.yml @@ -0,0 +1,44 @@ +name: "[Bots] Approve and Auto-merge" + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +permissions: {} + +jobs: + approve-and-automerge: + runs-on: ubuntu-latest + timeout-minutes: 5 + # 158783068: homarr-renovate[bot] + # 190541745: homarr-crowdin[bot] + # 210161987: homarr-update-contributors[bot] + # 175441: homarr-releases[bot] + # Extracted from https://api.github.com/users/ + if: | + (github.actor_id == 158783068 || github.actor_id == 190541745 || github.actor_id == 210161987 || github.actor_id == 175441) && + github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.base.ref == 'dev' + steps: + - name: Obtain token + id: obtainToken + uses: actions/create-github-app-token@v3 + with: + private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }} + client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }} + permission-pull-requests: write # required to approve a pull request and enable auto-merge + + - name: Install GitHub CLI + run: sudo apt-get install -y gh + + - name: Approve and enable auto-merge + env: + GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }} + run: | + PR_NUMBER="${{ github.event.pull_request.number }}" + if [ "$(gh pr view "$PR_NUMBER" --json reviewDecision --jq .reviewDecision)" != "APPROVED" ]; then + gh pr review "$PR_NUMBER" --approve --body "Automatically approved by Homarr bot policy" + fi + if [ "$(gh pr view "$PR_NUMBER" --json autoMergeRequest --jq '.autoMergeRequest != null')" != "true" ]; then + gh pr merge "$PR_NUMBER" --auto --squash + fi From 9674bde7583130097d7cca20ec12b07adc7da4b0 Mon Sep 17 00:00:00 2001 From: "homarr-releases[bot]" <175486441+homarr-releases[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:08:24 +0000 Subject: [PATCH 13/17] chore: update bug report template (#6533) Co-authored-by: Releases Homarr <175486441+homarr-releases[bot]@users.noreply.github.com> --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 0d8b33a33..d4b730726 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -33,6 +33,7 @@ body: options: # The below comment is used to insert a new version with on-release.yml #NEXT_VERSION# + - 1.74.0 - 1.73.0 - 1.72.0 - 1.71.0 From 622e1dcfa5c1080a3d98ea09ac4f06b27785db9a Mon Sep 17 00:00:00 2001 From: Thomas Date: Fri, 7 Aug 2026 20:12:37 +0200 Subject: [PATCH 14/17] ci: drop --delete-branch from reconcile auto-merge call (#6534) The reconcile job runs without a git checkout, so gh pr merge --delete-branch fails with 'not a git repository' before enabling auto-merge. Auto-merge itself needs no local git state; branch cleanup is already handled by the PR-creating workflows. --- .github/workflows/bot-auto-merge-reconcile.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/bot-auto-merge-reconcile.yml b/.github/workflows/bot-auto-merge-reconcile.yml index 3bc45c127..0bf7ea598 100644 --- a/.github/workflows/bot-auto-merge-reconcile.yml +++ b/.github/workflows/bot-auto-merge-reconcile.yml @@ -36,5 +36,5 @@ jobs: --jq '.[] | select(.author.login as $login | ["app/homarr-renovate", "app/homarr-crowdin", "app/homarr-update-contributors", "app/homarr-releases"] | index($login)) | select(.reviewDecision == "APPROVED") | select(.mergeStateStatus == "CLEAN") | select(.autoMergeRequest == null) | .number' \ | while read -r pr; do echo "Re-enabling auto-merge on PR #$pr" - gh pr merge "$pr" --auto --squash --delete-branch + gh pr merge "$pr" --auto --squash done From b32ad82850d327f3ac1ba3fd8344d4ee6f58aa22 Mon Sep 17 00:00:00 2001 From: Thomas Date: Fri, 7 Aug 2026 20:24:26 +0200 Subject: [PATCH 15/17] perf: cut cold route compilation and unblock preview images (#6441) --- .dockerignore | 3 +- .github/workflows/ci.yml | 2 +- .gitignore | 1 + Dockerfile | 26 +- .../advanced/development/getting-started.mdx | 10 +- .../advanced/environment-variables/index.mdx | 90 +- apps/nextjs/next.config.ts | 33 +- apps/nextjs/package.json | 3 +- .../_client-providers/development-tools.tsx | 120 ++ .../query-cache-persister.ts | 32 - .../_client-providers/query-retry.spec.ts | 27 + .../[locale]/_client-providers/query-retry.ts | 20 + .../session-query-scope.spec.ts | 52 + .../_client-providers/session-query-scope.ts | 39 + .../app/[locale]/_client-providers/trpc.tsx | 112 +- .../boards/(content)/(home)/_definition.ts | 4 +- .../(content)/[name]/(board)/_definition.tsx | 4 +- .../boards/(content)/_board-add-menu.tsx | 84 + .../app/[locale]/boards/(content)/_client.tsx | 4 +- .../[locale]/boards/(content)/_creator.tsx | 43 +- .../boards/(content)/_dynamic-client.tsx | 17 + .../boards/(content)/_header-actions.tsx | 187 +- .../src/app/[locale]/boards/[name]/layout.tsx | 4 +- .../app/[locale]/boards/_layout-creator.tsx | 45 +- apps/nextjs/src/app/[locale]/init/page.tsx | 11 +- apps/nextjs/src/app/[locale]/layout.tsx | 34 +- .../nextjs/src/app/[locale]/manage/layout.tsx | 34 +- .../general/_components/_reset-tours.tsx | 3 + .../app/[locale]/widgets/[kind]/_content.tsx | 99 +- .../app/[locale]/widgets/[kind]/layout.tsx | 19 +- .../src/app/[locale]/widgets/[kind]/page.tsx | 25 +- .../components/board/items/item-content.tsx | 188 +- .../src/components/board/items/item-menu.tsx | 48 +- .../board/items/item-move-modal.tsx | 4 +- .../board/items/item-select-data.spec.ts | 48 + .../board/items/item-select-data.ts | 28 + .../board/items/item-select-modal.module.css | 21 + .../board/items/item-select-modal.tsx | 235 ++- .../board/items/lazy-item-move-modal.tsx | 67 + .../board/items/lazy-widget-edit-modal.tsx | 68 + .../board/items/widget-context-menu.tsx | 332 ++-- .../board/items/widget-hover-metadata.ts | 155 -- .../board/items/widget-hover-overlay.tsx | 20 +- .../board/items/widget-query-recovery.spec.ts | 27 +- .../board/items/widget-query-recovery.ts | 8 +- .../category/category-menu-actions.tsx | 14 +- .../board/sections/category/filter.ts | 12 +- .../src/components/board/sections/content.tsx | 8 +- .../integration/integration-grid-shared.ts | 36 +- apps/nextjs/src/components/layout/header.tsx | 4 +- .../src/components/layout/header/button.tsx | 3 + .../layout/header/lazy-spotlight.tsx | 89 + .../src/components/layout/header/search.tsx | 16 +- .../layout/header/tour-target.spec.ts | 71 + .../components/layout/header/tour-target.tsx | 42 +- .../src/components/layout/header/update.tsx | 52 +- .../components/layout/header/user-client.tsx | 125 ++ .../src/components/layout/header/user.tsx | 20 +- .../src/components/layout/navigation-link.tsx | 15 +- .../components/onboarding/board-tour-gate.tsx | 11 + .../src/components/onboarding/board-tour.tsx | 27 +- .../onboarding/manage-tour-gate.tsx | 12 + .../src/components/onboarding/manage-tour.tsx | 51 +- .../src/components/user-avatar-menu.tsx | 85 +- apps/nextjs/src/instrumentation-noop.ts | 4 + apps/nextjs/src/instrumentation.spec.ts | 125 ++ apps/nextjs/src/instrumentation.ts | 40 +- apps/nextjs/src/proxy.spec.ts | 122 ++ apps/nextjs/src/proxy.ts | 47 +- apps/nextjs/src/theme/color-scheme.ts | 7 +- apps/tasks/src/main.ts | 2 +- apps/tasks/src/on-start/index.ts | 2 - .../tasks/src/on-start/warm-update-checker.ts | 14 - apps/websocket/src/main.ts | 20 +- e2e/custom-widgets-custom-jsx.spec.ts | 3 +- e2e/health-checks.spec.ts | 44 +- e2e/home.spec.ts | 12 +- e2e/lazy-widgets.spec.ts | 110 ++ e2e/lldap.spec.ts | 71 +- e2e/onboarding.spec.ts | 68 +- .../assertions/onboarding-assertions.ts | 2 +- package.json | 10 +- packages/api/package.json | 3 + packages/api/src/board-server.ts | 16 + packages/api/src/query-cache.ts | 62 +- packages/api/src/root.ts | 1 - packages/api/src/router/board.ts | 17 +- packages/api/src/router/board/board-access.ts | 11 +- packages/api/src/router/query-cache.ts | 42 - .../src/router/test/update-checker.spec.ts | 58 + packages/api/src/router/update-checker.ts | 4 +- packages/api/src/router/widgets/rssFeed.ts | 3 + packages/api/src/router/widgets/weather.ts | 3 + packages/api/src/rsc-context.ts | 16 + packages/api/src/server-settings-server.ts | 8 + packages/api/src/server.ts | 15 +- packages/api/src/test/query-cache.spec.ts | 173 +- packages/api/src/user-server.ts | 24 + packages/auth/callbacks.ts | 29 +- .../auth/permissions/integration-provider.tsx | 5 - .../integrations-with-permissions.ts | 23 +- packages/boards/src/context.tsx | 11 - packages/db/collection.ts | 8 +- packages/db/package.json | 1 + packages/db/proxy-reader.integration.spec.ts | 128 ++ packages/db/proxy-reader.spec.ts | 128 ++ packages/db/proxy-reader.ts | 173 ++ packages/db/proxy/mysql.ts | 11 + packages/db/proxy/postgresql.ts | 11 + packages/db/proxy/sqlite.ts | 11 + packages/db/test/schema.spec.ts | 3 + packages/db/tsconfig.json | 2 +- packages/definitions/package.json | 1 + .../definitions/src/widget-integration-map.ts | 8 +- packages/redis/src/index.ts | 4 +- packages/redis/src/lib/channel.spec.ts | 21 + packages/redis/src/lib/channel.ts | 58 +- .../src/lib/integration-request-handler.ts | 21 +- .../src/lib/request-handler.spec.ts | 224 +++ .../src/lib/request-handler.ts | 87 +- .../src/lib/widget-request-handler.ts | 10 +- .../src/test/update-checker-cache.spec.ts | 244 +++ .../request-handler/src/update-checker.ts | 109 +- packages/spotlight/package.json | 2 + packages/spotlight/src/component.ts | 3 + .../spotlight/src/components/spotlight.tsx | 41 +- packages/spotlight/src/index.ts | 22 +- packages/spotlight/src/open.spec.ts | 54 + packages/spotlight/src/open.ts | 44 + packages/spotlight/src/spotlight-store.ts | 1 - packages/translation/package.json | 1 + packages/translation/src/client/index.ts | 13 +- packages/translation/src/config.ts | 10 +- packages/translation/src/index.ts | 4 +- packages/translation/src/languages.spec.ts | 10 + packages/translation/src/languages.ts | 46 + packages/translation/src/middleware.ts | 2 +- packages/translation/src/routing.ts | 6 +- packages/widgets/package.json | 7 +- packages/widgets/src/anchor-note/index.ts | 1 + packages/widgets/src/app/index.ts | 1 + packages/widgets/src/app/prefetch.ts | 4 +- packages/widgets/src/beszel-alerts/index.ts | 1 + packages/widgets/src/bookmarks/index.tsx | 1 + packages/widgets/src/bookmarks/prefetch.ts | 4 +- packages/widgets/src/catalog.ts | 110 ++ packages/widgets/src/clock/index.ts | 1 + packages/widgets/src/definition.ts | 4 + packages/widgets/src/downloads/prefetch.ts | 29 - .../widgets/src/errors/base-component.tsx | 2 +- packages/widgets/src/errors/component.tsx | 22 +- .../widgets/src/health-monitoring/index.ts | 1 + packages/widgets/src/manifest.spec.ts | 151 ++ packages/widgets/src/manifest.ts | 229 +++ packages/widgets/src/media-missing/index.ts | 1 + .../widgets/src/media-requests/list/index.ts | 1 + .../widgets/src/media-requests/stats/index.ts | 1 + .../widgets/src/modals/widget-edit-modal.tsx | 14 +- packages/widgets/src/prefetch.ts | 18 +- packages/widgets/src/refetch-intervals.ts | 29 + .../src/smart-home/entity-state/index.ts | 1 + packages/widgets/src/test/translation.spec.ts | 24 +- packages/widgets/src/trpc-query-key.spec.ts | 12 + packages/widgets/src/trpc-query-key.ts | 4 + packages/widgets/src/weather/component.tsx | 2 +- pnpm-lock.yaml | 1673 +++++++++++------ pnpm-workspace.yaml | 10 +- scripts/benchmarks/benchmark-compare.mts | 40 + scripts/benchmarks/benchmark-pair-lib.mts | 713 +++++++ scripts/benchmarks/benchmark-pair-lib.spec.ts | 360 ++++ scripts/benchmarks/board-static-graph.mts | 235 +++ scripts/benchmarks/board-static-graph.spec.ts | 37 + scripts/benchmarks/dev-benchmark-lib.mts | 81 + scripts/benchmarks/dev-benchmark-lib.spec.ts | 56 + scripts/benchmarks/dev-board.mts | 324 ++++ scripts/benchmarks/docker-build-lib.mts | 259 +++ scripts/benchmarks/docker-build-lib.spec.ts | 224 +++ scripts/benchmarks/docker-build.mts | 351 ++++ scripts/benchmarks/docker-runtime-lib.mts | 649 +++++++ scripts/benchmarks/docker-runtime-lib.spec.ts | 432 +++++ scripts/benchmarks/docker-runtime.mts | 1656 ++++++++++++++++ scripts/benchmarks/request-handler-memory.mts | 107 ++ 182 files changed, 11421 insertions(+), 2339 deletions(-) create mode 100644 apps/nextjs/src/app/[locale]/_client-providers/development-tools.tsx delete mode 100644 apps/nextjs/src/app/[locale]/_client-providers/query-cache-persister.ts create mode 100644 apps/nextjs/src/app/[locale]/_client-providers/query-retry.spec.ts create mode 100644 apps/nextjs/src/app/[locale]/_client-providers/query-retry.ts create mode 100644 apps/nextjs/src/app/[locale]/_client-providers/session-query-scope.spec.ts create mode 100644 apps/nextjs/src/app/[locale]/_client-providers/session-query-scope.ts create mode 100644 apps/nextjs/src/app/[locale]/boards/(content)/_board-add-menu.tsx create mode 100644 apps/nextjs/src/components/board/items/item-select-data.spec.ts create mode 100644 apps/nextjs/src/components/board/items/item-select-data.ts create mode 100644 apps/nextjs/src/components/board/items/item-select-modal.module.css create mode 100644 apps/nextjs/src/components/board/items/lazy-item-move-modal.tsx create mode 100644 apps/nextjs/src/components/board/items/lazy-widget-edit-modal.tsx delete mode 100644 apps/nextjs/src/components/board/items/widget-hover-metadata.ts create mode 100644 apps/nextjs/src/components/layout/header/lazy-spotlight.tsx create mode 100644 apps/nextjs/src/components/layout/header/tour-target.spec.ts create mode 100644 apps/nextjs/src/components/layout/header/user-client.tsx create mode 100644 apps/nextjs/src/components/onboarding/board-tour-gate.tsx create mode 100644 apps/nextjs/src/components/onboarding/manage-tour-gate.tsx create mode 100644 apps/nextjs/src/instrumentation-noop.ts create mode 100644 apps/nextjs/src/instrumentation.spec.ts create mode 100644 apps/nextjs/src/proxy.spec.ts delete mode 100644 apps/tasks/src/on-start/warm-update-checker.ts create mode 100644 e2e/lazy-widgets.spec.ts create mode 100644 packages/api/src/board-server.ts delete mode 100644 packages/api/src/router/query-cache.ts create mode 100644 packages/api/src/router/test/update-checker.spec.ts create mode 100644 packages/api/src/rsc-context.ts create mode 100644 packages/api/src/server-settings-server.ts create mode 100644 packages/api/src/user-server.ts create mode 100644 packages/db/proxy-reader.integration.spec.ts create mode 100644 packages/db/proxy-reader.spec.ts create mode 100644 packages/db/proxy-reader.ts create mode 100644 packages/db/proxy/mysql.ts create mode 100644 packages/db/proxy/postgresql.ts create mode 100644 packages/db/proxy/sqlite.ts create mode 100644 packages/redis/src/lib/channel.spec.ts create mode 100644 packages/request-handler/src/lib/request-handler.spec.ts create mode 100644 packages/request-handler/src/test/update-checker-cache.spec.ts create mode 100644 packages/spotlight/src/component.ts create mode 100644 packages/spotlight/src/open.spec.ts create mode 100644 packages/spotlight/src/open.ts create mode 100644 packages/translation/src/languages.spec.ts create mode 100644 packages/translation/src/languages.ts create mode 100644 packages/widgets/src/catalog.ts delete mode 100644 packages/widgets/src/downloads/prefetch.ts create mode 100644 packages/widgets/src/manifest.spec.ts create mode 100644 packages/widgets/src/manifest.ts create mode 100644 packages/widgets/src/refetch-intervals.ts create mode 100644 packages/widgets/src/trpc-query-key.spec.ts create mode 100644 packages/widgets/src/trpc-query-key.ts create mode 100644 scripts/benchmarks/benchmark-compare.mts create mode 100644 scripts/benchmarks/benchmark-pair-lib.mts create mode 100644 scripts/benchmarks/benchmark-pair-lib.spec.ts create mode 100644 scripts/benchmarks/board-static-graph.mts create mode 100644 scripts/benchmarks/board-static-graph.spec.ts create mode 100644 scripts/benchmarks/dev-benchmark-lib.mts create mode 100644 scripts/benchmarks/dev-benchmark-lib.spec.ts create mode 100644 scripts/benchmarks/dev-board.mts create mode 100644 scripts/benchmarks/docker-build-lib.mts create mode 100644 scripts/benchmarks/docker-build-lib.spec.ts create mode 100644 scripts/benchmarks/docker-build.mts create mode 100644 scripts/benchmarks/docker-runtime-lib.mts create mode 100644 scripts/benchmarks/docker-runtime-lib.spec.ts create mode 100644 scripts/benchmarks/docker-runtime.mts create mode 100644 scripts/benchmarks/request-handler-memory.mts diff --git a/.dockerignore b/.dockerignore index 521c55109..c0f2129a3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -17,7 +17,8 @@ coverage dev .build e2e -apps/docs +apps/docs/* +!apps/docs/package.json apps/nextjs/.next .env .env.* diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dcbb352c3..dbb3d2a2d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,7 +82,7 @@ jobs: run: cp .env.example .env - name: Lint, typecheck, and build affected workspaces - run: pnpm turbo run lint typecheck build --affected --continue=dependencies-successful + run: pnpm turbo run lint typecheck build --affected --continue=dependencies-successful --concurrency=2 - name: Validate workspace configuration run: pnpm lint:ws diff --git a/.gitignore b/.gitignore index c22262f6d..71a27829a 100644 --- a/.gitignore +++ b/.gitignore @@ -78,3 +78,4 @@ skills-lock.json apps/docs/.docusaurus apps/docs/build .pnpm-store +benchmark-results diff --git a/Dockerfile b/Dockerfile index 8f4c5e49a..afc7b5ded 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,12 +3,18 @@ FROM node:24.18.0-alpine AS base FROM base AS builder +ARG TARGETPLATFORM WORKDIR /app -RUN apk add --no-cache libc6-compat curl bash && apk update +# Native dependencies normally use prebuilds, but the build must remain +# reproducible when a prebuild download times out and compilation is required. +RUN apk add --no-cache libc6-compat curl bash python3 make g++ RUN corepack enable pnpm -COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ +COPY .npmrc pnpm-lock.yaml pnpm-workspace.yaml package.json ./ COPY patches ./patches +COPY --parents ./apps/*/package.json ./packages/*/package.json ./tooling/*/package.json ./ +# @homarr/definitions generates documentation types during install. +COPY --parents ./packages/definitions/src ./ # Workaround for pnpm/pnpm#5268: pnpm fetch crashes when patchedDependencies # are configured with nodeLinker: hoisted. The applyPatchToDir function tries # to chdir into node_modules/ which doesn't exist during fetch (only the @@ -19,14 +25,16 @@ COPY patches ./patches RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \ pnpm config set store-dir /pnpm/store && \ sed -i 's/nodeLinker: hoisted/nodeLinker: isolated/' pnpm-workspace.yaml && \ - pnpm fetch && \ + pnpm fetch --ignore-scripts && \ sed -i 's/nodeLinker: isolated/nodeLinker: hoisted/' pnpm-workspace.yaml -COPY . . -# Follow the pnpm fetch pattern from https://pnpm.io/cli/fetch -# --frozen-lockfile is omitted as recommended by the pnpm fetch docs +# Install only from the fetched, committed lockfile so local and Docker builds +# resolve the same dependency graph. Serial lifecycle builds avoid esbuild's +# atomic binary replacement racing across the hoisted workspace (pnpm/pnpm#8200). RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \ - pnpm install --recursive + npm_config_nodedir=/usr/local pnpm install --recursive --offline --frozen-lockfile --child-concurrency=1 + +COPY . . ARG SKIP_ENV_VALIDATION='true' ARG CI='true' @@ -37,7 +45,9 @@ RUN --mount=type=secret,id=TURBO_API,env=TURBO_API \ --mount=type=secret,id=TURBO_TEAM,env=TURBO_TEAM \ --mount=type=secret,id=TURBO_TOKEN,env=TURBO_TOKEN \ --mount=type=secret,id=TURBO_REMOTE_CACHE_SIGNATURE_KEY,env=TURBO_REMOTE_CACHE_SIGNATURE_KEY \ - TURBO_PLATFORM="${TARGETPLATFORM:-linux/amd64}" pnpm build + --mount=type=cache,id=homarr-next-build-${TARGETPLATFORM},target=/app/apps/nextjs/.next/cache,sharing=locked \ + TURBO_PLATFORM="${TARGETPLATFORM:-linux/amd64}" \ + pnpm turbo build --filter=@homarr/nextjs... --filter=@homarr/cli FROM base AS runner WORKDIR /app diff --git a/apps/docs/docs/advanced/development/getting-started.mdx b/apps/docs/docs/advanced/development/getting-started.mdx index e8f68ea6d..1bad7be05 100644 --- a/apps/docs/docs/advanced/development/getting-started.mdx +++ b/apps/docs/docs/advanced/development/getting-started.mdx @@ -14,8 +14,8 @@ This guide will help you set up your development environment to start contributi ### Prerequisites -- **Node.js (lts)**: You can download it from the [official website](https://nodejs.org/). -- **pnpm (latest)**: You can install it by running `npm install -g pnpm`. +- **Node.js 24.18 or newer**: You can download it from the [official website](https://nodejs.org/). +- **Corepack**: Run `corepack enable` so the repository selects its pinned pnpm version. - **Git**: You can download it from the [official website](https://git-scm.com/). - **Docker or Docker Desktop**: Required for the Redis service and the developer Docker CLI. - **Go 1.25+**: Required only for the developer Docker CLI. You can download it from the [official website](https://go.dev/). @@ -49,7 +49,10 @@ This guide will help you set up your development environment to start contributi #### During development -- `pnpm dev`: Start the development server. +- `pnpm dev`: Start the Next.js development server. +- `pnpm dev:benchmark`: Run the local authenticated-board benchmark. Set `DEV_BENCHMARK_BOARD_URL` and + `DEV_BENCHMARK_STORAGE_STATE`; readiness-only measurements require the explicit `--smoke` flag and are not + suitable for performance claims. - `pnpm dev:cli -- dev`: Browse and run local images and remote pull-request images. - `pnpm dev:cli -- build `: Build the current checkout as `homarr:`. - `pnpm dev:cli -- build --pr `: Build a pull request locally from a temporary checkout. @@ -74,4 +77,5 @@ This guide will help you set up your development environment to start contributi - `pnpm build`: Build the project for production. - `pnpm dev:cli -- build `: Build a `homarr:` image and record its checkout for later rebuilds. +- `docker build -t homarr .`: Build a docker image for the project. - `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:`: Run the created docker image. diff --git a/apps/docs/docs/advanced/environment-variables/index.mdx b/apps/docs/docs/advanced/environment-variables/index.mdx index ad249e734..e7dc6960d 100644 --- a/apps/docs/docs/advanced/environment-variables/index.mdx +++ b/apps/docs/docs/advanced/environment-variables/index.mdx @@ -25,15 +25,13 @@ This is especially useful for [docker secrets](https://docs.docker.com/engine/sw Using the `PUID` and `PGID` will require you to set the correct permissions on the mounted volumes and if used the docker socket. See more in the [Running as a different user](/docs/advanced/running-as-different-user) documentation. ::: -| Environment Variable | Description | Possible values | Default | -| ------------------------ | ----------- | --------------- | ------- | -| ``PUID`` | User ID to run the container as | Any valid user ID | ``0`` | -| ``PGID`` | Group ID to run the container as | Any valid group ID | ``0`` | -| ``LOG_LEVEL`` | Log level to use | ``debug`` / ``info`` / ``warn`` / ``error`` | ``info`` | -| ``NO_EXTERNAL_CONNECTION`` | Disables some requests that need internet connection | `true` or `false` | `false` | -| ``ENABLE_DNS_CACHING`` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` | - - +| Environment Variable | Description | Possible values | Default | +| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | ------- | +| `PUID` | User ID to run the container as | Any valid user ID | `0` | +| `PGID` | Group ID to run the container as | Any valid group ID | `0` | +| `LOG_LEVEL` | Log level to use | `debug` / `info` / `warn` / `error` | `info` | +| `NO_EXTERNAL_CONNECTION` | Disables Homarr's internet-backed requests, including update checks, weather, and RSS feeds | `true` or `false` | `false` | +| `ENABLE_DNS_CACHING` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` | ## Authentication @@ -43,9 +41,9 @@ See [Single Sign-On](/docs/advanced/single-sign-on) for more informations. The `SECRET_ENCRYPTION_KEY` is required. If none is specified before starting the container, a random key will be shown in the error message and the container will exit. -| Environment Variable | Description | Possible values | Default | -| ------------------------ | ----------- | --------------- | ------- | -| ``SECRET_ENCRYPTION_KEY`` | Secret used to encrypt secrets in database. | 64 character hex string | - | +| Environment Variable | Description | Possible values | Default | +| ----------------------- | ------------------------------------------- | ----------------------- | ------- | +| `SECRET_ENCRYPTION_KEY` | Secret used to encrypt secrets in database. | 64 character hex string | - | :::info A random secret can be generated by using the following command: `openssl rand -hex 32` @@ -53,27 +51,27 @@ A random secret can be generated by using the following command: `openssl rand - ## Docker -| Environment Variable | Description | Possible values | Default | -| ------------------------ | ----------- | --------------- | ------- | -| ``DOCKER_HOSTNAMES`` | Comma-separated list of hostnames to connect to | For example `localhost,docker.example.com` | - | -| ``DOCKER_PORTS`` | Comma-separated list of ports to connect to | For example `2375,2376` | - | -| ``DOCKER_SOCKET_PATHS`` | Comma-separated list of mounted Docker-compatible socket paths. See the [Docker and Podman integration guide](/docs/integrations/docker) | For example `/var/run/docker.sock,/var/run/podman.sock` | - | +| Environment Variable | Description | Possible values | Default | +| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------- | +| `DOCKER_HOSTNAMES` | Comma-separated list of hostnames to connect to | For example `localhost,docker.example.com` | - | +| `DOCKER_PORTS` | Comma-separated list of ports to connect to | For example `2375,2376` | - | +| `DOCKER_SOCKET_PATHS` | Comma-separated list of mounted Docker-compatible socket paths. See the [Docker and Podman integration guide](/docs/integrations/docker) | For example `/var/run/docker.sock,/var/run/podman.sock` | - | ## Database -| Environment Variable | Description | Possible values | Default | -| ------------------------ | ----------- | --------------- | ------- | -| ``DB_DRIVER`` | Database driver to use. Currently `better-sqlite3` is used for sqlite, `mysql2` for mysql and `node-postgres` for postgresql | ``better-sqlite3`` / ``mysql2`` / ``node-postgres`` | ``better-sqlite3`` | -| ``DB_DIALECT`` | Database dialect to use. | ``sqlite`` / ``mysql`` / ``postgresql`` | ``sqlite`` | -| ``DB_URL`` | Database URL to connect to. | Any valid database URL | ``/appdata/db/db.sqlite`` | -| ``DB_HOST`` | Database host to connect to. | Any valid database host | - | -| ``DB_PORT`` | Database port to connect to. | Any valid database port | - | -| ``DB_NAME`` | Database name to connect to. | Any valid database name | - | -| ``DB_USER`` | Database user to connect with. | Any valid database user | - | -| ``DB_PASSWORD`` | Database password to connect with. | Any valid database password | - | + +| Environment Variable | Description | Possible values | Default | +| -------------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | ----------------------- | +| `DB_DRIVER` | Database driver to use. Currently `better-sqlite3` is used for sqlite, `mysql2` for mysql and `node-postgres` for postgresql | `better-sqlite3` / `mysql2` / `node-postgres` | `better-sqlite3` | +| `DB_DIALECT` | Database dialect to use. | `sqlite` / `mysql` / `postgresql` | `sqlite` | +| `DB_URL` | Database URL to connect to. | Any valid database URL | `/appdata/db/db.sqlite` | +| `DB_HOST` | Database host to connect to. | Any valid database host | - | +| `DB_PORT` | Database port to connect to. | Any valid database port | - | +| `DB_NAME` | Database name to connect to. | Any valid database name | - | +| `DB_USER` | Database user to connect with. | Any valid database user | - | +| `DB_PASSWORD` | Database password to connect with. | Any valid database password | - | You can either use the url or host, port, name and credentials combined. The URL will be prioritized over the other values. - ## Redis By default Redis is running within the installation. (for example in Docker). @@ -83,25 +81,23 @@ However for example for K8s it can be useful to use an external Redis instance. External redis is currently not supported with Proxmox Community Scripts ::: -| Environment Variable | Description | Possible values | Default | -| ------------------------ | ----------- | --------------- | ------- | -| `REDIS_IS_EXTERNAL` | Whether Redis is running externally. | `true` / `false` | `false` | -| `REDIS_HOST` | Hostname of the Redis instance. | Any valid hostname | - | -| `REDIS_PORT` | Port of the Redis instance. | Any valid port | `6379` | -| `REDIS_USERNAME` | Username to connect to the Redis instance. | Any valid username | - | -| `REDIS_PASSWORD` | Password to connect to the Redis instance. | Any valid password | - | -| `REDIS_DATABASE_INDEX` | Select which database of your redis instance should be used. | Any numeric index | - | -| `REDIS_TLS_CA` | CA certificate for Redis TLS connections. If a certificate is specified the connection will use TLS. | Any valid CA certificate | - | - +| Environment Variable | Description | Possible values | Default | +| ---------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------ | ------- | +| `REDIS_IS_EXTERNAL` | Whether Redis is running externally. | `true` / `false` | `false` | +| `REDIS_HOST` | Hostname of the Redis instance. | Any valid hostname | - | +| `REDIS_PORT` | Port of the Redis instance. | Any valid port | `6379` | +| `REDIS_USERNAME` | Username to connect to the Redis instance. | Any valid username | - | +| `REDIS_PASSWORD` | Password to connect to the Redis instance. | Any valid password | - | +| `REDIS_DATABASE_INDEX` | Select which database of your redis instance should be used. | Any numeric index | - | +| `REDIS_TLS_CA` | CA certificate for Redis TLS connections. If a certificate is specified the connection will use TLS. | Any valid CA certificate | - | ## Proxy -| Environment Variable | Description | Possible values | Default | -| ------------------------ | ----------- | --------------- | ------- | -| ``HTTP_PROXY`` | HTTP proxy to use | Any valid HTTP proxy URL | - | -| ``HTTPS_PROXY`` | HTTPS proxy to use | Any valid HTTPS proxy URL | - | -| ``NO_PROXY`` | Comma separated list of hosts that should be excluded from proxying | Any valid hostnames | - | - +| Environment Variable | Description | Possible values | Default | +| -------------------- | ------------------------------------------------------------------- | ------------------------- | ------- | +| `HTTP_PROXY` | HTTP proxy to use | Any valid HTTP proxy URL | - | +| `HTTPS_PROXY` | HTTPS proxy to use | Any valid HTTPS proxy URL | - | +| `NO_PROXY` | Comma separated list of hosts that should be excluded from proxying | Any valid hostnames | - | ## Advanced deployments @@ -109,6 +105,6 @@ External redis is currently not supported with Proxmox Community Scripts The advanced deployments environment variables should only be used if you know what you are doing. ::: -| Environment Variable | Description | -| ------------------------ | ----------- | -| ``DB_MIGRATIONS_DISABLED`` | Disable db migrations. For example for helm charts | +| Environment Variable | Description | +| ------------------------ | -------------------------------------------------- | +| `DB_MIGRATIONS_DISABLED` | Disable db migrations. For example for helm charts | diff --git a/apps/nextjs/next.config.ts b/apps/nextjs/next.config.ts index 4ff31b9aa..2105259be 100644 --- a/apps/nextjs/next.config.ts +++ b/apps/nextjs/next.config.ts @@ -1,4 +1,6 @@ // Importing env files here to validate on build +import path from "node:path"; + import "@homarr/auth/env"; import "@homarr/core/infrastructure/db/env"; import "@homarr/common/env"; @@ -13,7 +15,21 @@ const withNextIntl = createNextIntlPlugin({ requestConfig: "../../packages/translation/src/request.ts", }); +const getDevelopmentServiceAliases = () => { + if (process.env.NODE_ENV !== "development") { + return undefined; + } + + return { + "@homarr/tasks": "./src/instrumentation-noop.ts", + "@homarr/websocket": "./src/instrumentation-noop.ts", + }; +}; + const nextConfig: NextConfig = { + // Next previews otherwise create agent instruction files in the application + // directory during development. + agentRules: false, env: { HOMARR_VERSION: process.env.HOMARR_VERSION ?? "unknown", }, @@ -30,19 +46,14 @@ const nextConfig: NextConfig = { serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"], experimental: { optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"], - turbopackFileSystemCacheForDev: true, - webpackMemoryOptimizations: true, + turbopackFileSystemCacheForBuild: true, + useTypeScriptCli: true, }, turbopack: { - // ponytail: known Turbopack NFT warning from path.join(process.cwd(), …) in - // src/app/api/backup/{route,shared}.ts. No working placement in 16.2.x - // (see vercel/next.js#95125). Suppress until upstream fix lands. - ignoreIssue: [ - { - path: "**/*", - title: "Encountered unexpected file in NFT list", - }, - ], + root: path.resolve(import.meta.dirname, "../.."), + // Development runs tasks and WebSocket as separate processes. These aliases + // keep their production-only instrumentation imports out of the dev graph. + resolveAlias: getDevelopmentServiceAliases(), }, transpilePackages: ["@homarr/ui", "@homarr/notifications", "@homarr/modals", "@homarr/spotlight", "@homarr/widgets"], images: { diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index 4a3e0dd3e..3c33a1545 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -59,11 +59,10 @@ "@modelcontextprotocol/sdk": "catalog:", "@scalar/api-reference-react": "catalog:", "@tabler/icons-react": "catalog:", - "@tanstack/query-async-storage-persister": "catalog:", + "@tanstack/react-devtools": "catalog:", "@tanstack/react-query": "catalog:", "@tanstack/react-query-devtools": "catalog:", "@tanstack/react-query-next-experimental": "catalog:", - "@tanstack/react-query-persist-client": "catalog:", "@trpc/client": "catalog:", "@trpc/next": "catalog:", "@trpc/react-query": "catalog:", diff --git a/apps/nextjs/src/app/[locale]/_client-providers/development-tools.tsx b/apps/nextjs/src/app/[locale]/_client-providers/development-tools.tsx new file mode 100644 index 000000000..a23164547 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/_client-providers/development-tools.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { usePathname } from "next/navigation"; +import { TanStackDevtools } from "@tanstack/react-devtools"; +import type { TanStackDevtoolsReactPlugin } from "@tanstack/react-devtools"; +import type { QueryClient } from "@tanstack/react-query"; +import { useQueryClient } from "@tanstack/react-query"; +import { ReactQueryDevtoolsPanel } from "@tanstack/react-query-devtools"; + +const valueStyle = { fontFamily: "monospace", overflowWrap: "anywhere" } as const; +const metricStyle = { + background: "color-mix(in srgb, currentColor 4%, transparent)", + border: "1px solid color-mix(in srgb, currentColor 18%, transparent)", + borderRadius: 8, + padding: 12, +} as const; + +const readDiagnostics = (queryClient: QueryClient) => { + const queries = queryClient.getQueryCache().getAll(); + const mutations = queryClient.getMutationCache().getAll(); + const board = document.querySelector("[data-homarr-dev-benchmark-board]"); + const widgetItems = board?.querySelectorAll('[data-type="item"]').length ?? 0; + const readyWidgets = board?.querySelectorAll("[data-homarr-widget-ready]").length ?? 0; + const resources = performance.getEntriesByType("resource") as PerformanceResourceTiming[]; + + return { + active: queries.filter((query) => query.getObserversCount() > 0).length, + errors: queries.filter((query) => query.state.status === "error").length, + fetching: queries.filter((query) => query.state.fetchStatus === "fetching").length, + pendingMutations: mutations.filter((mutation) => mutation.state.status === "pending").length, + stale: queries.filter((query) => query.isStale()).length, + total: queries.length, + readyWidgets, + widgetItems, + scriptKiB: Math.round( + resources + .filter((resource) => resource.initiatorType === "script") + .reduce((total, resource) => total + resource.decodedBodySize, 0) / 1024, + ), + }; +}; + +const Metric = ({ label, value }: { label: string; value: number | string }) => ( +
+
{label}
+ {value} +
+); + +const HomarrDevtoolsPanel = ({ active }: { active: boolean }) => { + const pathname = usePathname(); + const queryClient = useQueryClient(); + const [diagnostics, setDiagnostics] = useState(() => readDiagnostics(queryClient)); + + useEffect(() => { + if (!active) return; + + const update = () => setDiagnostics(readDiagnostics(queryClient)); + let frame: number | undefined; + const scheduleUpdate = () => { + if (frame !== undefined) return; + frame = window.requestAnimationFrame(() => { + frame = undefined; + update(); + }); + }; + const unsubscribeQueries = queryClient.getQueryCache().subscribe(scheduleUpdate); + const unsubscribeMutations = queryClient.getMutationCache().subscribe(scheduleUpdate); + const observer = new MutationObserver(scheduleUpdate); + observer.observe(document.body, { childList: true, subtree: true }); + update(); + + return () => { + unsubscribeQueries(); + unsubscribeMutations(); + observer.disconnect(); + if (frame !== undefined) window.cancelAnimationFrame(frame); + }; + }, [active, pathname, queryClient]); + + if (!active) return null; + + return ( +
+
+

Homarr performance

+

Live state for {pathname}

+
+
+ + + + + + + + +
+
+ ); +}; + +const developmentToolsPlugins = [ + { + id: "tanstack-query", + name: "TanStack Query", + render: () => , + }, + { + id: "homarr-performance", + name: "Homarr", + defaultOpen: true, + render: (_element, { devtoolsOpen }) => , + }, +] satisfies TanStackDevtoolsReactPlugin[]; + +export const DevelopmentTools = () => ( + +); diff --git a/apps/nextjs/src/app/[locale]/_client-providers/query-cache-persister.ts b/apps/nextjs/src/app/[locale]/_client-providers/query-cache-persister.ts deleted file mode 100644 index 3af24c508..000000000 --- a/apps/nextjs/src/app/[locale]/_client-providers/query-cache-persister.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister"; -import superjson from "superjson"; - -import { fetchApi } from "@homarr/api/client"; -import { getActiveQueryCacheBoardId, queryCacheStoragePrefix } from "@homarr/api/query-cache"; - -const queryCacheStorage = { - getItem: (_key: string) => null as string | null, - async setItem(_key: string, value: string) { - const boardId = getActiveQueryCacheBoardId(); - if (!boardId) return; - await fetchApi.queryCache.setItem.mutate({ boardId, value }).catch((error) => { - console.warn("[query-cache] persist failed", error); - }); - }, - async removeItem(_key: string) { - const boardId = getActiveQueryCacheBoardId(); - if (!boardId) return; - await fetchApi.queryCache.removeItem.mutate({ boardId }).catch((error) => { - console.warn("[query-cache] remove failed", error); - }); - }, -}; - -export const createWidgetQueryPersister = () => - createAsyncStoragePersister({ - storage: typeof window === "undefined" ? undefined : queryCacheStorage, - key: queryCacheStoragePrefix, - throttleTime: 2000, - serialize: (data) => superjson.stringify(data), - deserialize: (data) => superjson.parse(data), - }); diff --git a/apps/nextjs/src/app/[locale]/_client-providers/query-retry.spec.ts b/apps/nextjs/src/app/[locale]/_client-providers/query-retry.spec.ts new file mode 100644 index 000000000..77aa7c918 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/_client-providers/query-retry.spec.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { TRPCClientError } from "@trpc/client"; + +import { createQueryRetry } from "./query-retry"; + +const createTrpcError = (code: string) => { + const error = Object.assign(new Error(code), { data: { code } }); + Object.setPrototypeOf(error, TRPCClientError.prototype); + return error; +}; + +describe("createQueryRetry", () => { + it.each(["BAD_REQUEST", "UNAUTHORIZED", "FORBIDDEN", "NOT_FOUND", "UNPROCESSABLE_CONTENT"])( + "does not retry the permanent %s response", + (code) => { + expect(createQueryRetry(3)(0, createTrpcError(code))).toBe(false); + }, + ); + + it("retries transient and network failures only up to the configured limit", () => { + const retry = createQueryRetry(3); + + expect(retry(0, createTrpcError("INTERNAL_SERVER_ERROR"))).toBe(true); + expect(retry(2, new Error("connection reset"))).toBe(true); + expect(retry(3, new Error("connection reset"))).toBe(false); + }); +}); diff --git a/apps/nextjs/src/app/[locale]/_client-providers/query-retry.ts b/apps/nextjs/src/app/[locale]/_client-providers/query-retry.ts new file mode 100644 index 000000000..09b5f19c3 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/_client-providers/query-retry.ts @@ -0,0 +1,20 @@ +import { TRPCClientError } from "@trpc/client"; + +const nonRetryableTrpcCodes = new Set([ + "PARSE_ERROR", + "BAD_REQUEST", + "UNAUTHORIZED", + "FORBIDDEN", + "NOT_FOUND", + "METHOD_NOT_SUPPORTED", + "CONFLICT", + "PRECONDITION_FAILED", + "PAYLOAD_TOO_LARGE", + "UNPROCESSABLE_CONTENT", +]); + +export const createQueryRetry = (maxRetries: number) => (failureCount: number, error: unknown) => { + if (failureCount >= maxRetries) return false; + if (error instanceof TRPCClientError && error.data?.code && nonRetryableTrpcCodes.has(error.data.code)) return false; + return true; +}; diff --git a/apps/nextjs/src/app/[locale]/_client-providers/session-query-scope.spec.ts b/apps/nextjs/src/app/[locale]/_client-providers/session-query-scope.spec.ts new file mode 100644 index 000000000..f82001a5e --- /dev/null +++ b/apps/nextjs/src/app/[locale]/_client-providers/session-query-scope.spec.ts @@ -0,0 +1,52 @@ +// @vitest-environment jsdom + +import { act, createElement } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, expect, it, vi } from "vitest"; + +import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope"; + +describe("getSessionQueryScope", () => { + it("is stable when permission order changes", () => { + expect(getSessionQueryScope({ user: { id: "user-a", permissions: ["board-view", "integration-use"] } })).toBe( + getSessionQueryScope({ user: { id: "user-a", permissions: ["integration-use", "board-view"] } }), + ); + }); + + it("changes for a different user or permission set", () => { + const original = getSessionQueryScope({ user: { id: "user-a", permissions: ["board-view"] } }); + + expect(getSessionQueryScope({ user: { id: "user-b", permissions: ["board-view"] } })).not.toBe(original); + expect(getSessionQueryScope({ user: { id: "user-a", permissions: [] } })).not.toBe(original); + expect(getSessionQueryScope(null)).toBeNull(); + }); + + it("removes the previous session subtree before requesting a document reload", async () => { + const host = document.createElement("div"); + const root = createRoot(host); + const onScopeChange = vi.fn(); + const renderScope = async (currentScope: string) => { + await act(async () => { + root.render( + createElement( + SessionQueryScopeGuard, + { currentScope, initialScope: "a", onScopeChange }, + createElement("span", null, "session-a-secret"), + ), + ); + }); + }; + + try { + await renderScope("a"); + expect(host.textContent).toBe("session-a-secret"); + + await renderScope("b"); + expect(host.textContent).not.toContain("session-a-secret"); + expect(host.querySelector('[role="status"]')?.getAttribute("aria-busy")).toBe("true"); + expect(onScopeChange).toHaveBeenCalledOnce(); + } finally { + await act(async () => root.unmount()); + } + }); +}); diff --git a/apps/nextjs/src/app/[locale]/_client-providers/session-query-scope.ts b/apps/nextjs/src/app/[locale]/_client-providers/session-query-scope.ts new file mode 100644 index 000000000..d2fa1e732 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/_client-providers/session-query-scope.ts @@ -0,0 +1,39 @@ +import type { PropsWithChildren } from "react"; +import { createElement, useEffect } from "react"; + +interface QuerySession { + user: { + id: string; + permissions: string[]; + }; +} + +export const getSessionQueryScope = (session: QuerySession | null | undefined) => + session ? JSON.stringify([session.user.id, session.user.permissions.toSorted()]) : null; + +interface SessionQueryScopeGuardProps { + initialScope: string | null; + currentScope: string | null; + onScopeChange: () => void; +} + +export const SessionQueryScopeGuard = ({ + initialScope, + currentScope, + onScopeChange, + children, +}: PropsWithChildren) => { + const hasScopeChanged = initialScope !== currentScope; + useEffect(() => { + if (hasScopeChanged) onScopeChange(); + }, [hasScopeChanged, onScopeChange]); + + if (!hasScopeChanged) return children; + + return createElement("div", { + "aria-busy": true, + "aria-label": "Refreshing session", + role: "status", + style: { minHeight: "100dvh" }, + }); +}; diff --git a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx index 3e167dd8c..535d88e1c 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx @@ -1,12 +1,10 @@ "use client"; import type { PropsWithChildren } from "react"; -import { useState } from "react"; -import type { QueryKey } from "@tanstack/react-query"; -import { QueryClient } from "@tanstack/react-query"; -import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { useEffect, useMemo, useState } from "react"; +import dynamic from "next/dynamic"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental"; -import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client"; import { createWSClient, httpBatchStreamLink, @@ -25,18 +23,24 @@ import { TRPCClientError } from "@trpc/client"; import type { AppRouter } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { - isPersistableWidgetQueryKey, - queryCacheBuster, + dashboardSupportingQueryPolicies, queryCacheDefaultGcTimeMs, queryCacheDefaultRefetchIntervalMs, queryCacheDefaultStaleTimeMs, } from "@homarr/api/query-cache"; import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared"; +import { useSession } from "@homarr/auth/client"; import { env } from "@homarr/common/env"; import { showWarningNotification } from "@homarr/notifications"; -import { widgetImports } from "@homarr/widgets"; +import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals"; -import { createWidgetQueryPersister } from "./query-cache-persister"; +import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope"; +import { createQueryRetry } from "./query-retry"; + +const DevelopmentTools = + process.env.NODE_ENV === "development" + ? dynamic(() => import("./development-tools").then(({ DevelopmentTools: Tools }) => Tools), { ssr: false }) + : () => null; const getWebSocketProtocol = () => { if (typeof window === "undefined") { @@ -59,21 +63,46 @@ const constructWebsocketUrl = () => { return `${getWebSocketProtocol()}://${window.location.hostname}:${window.location.port}/websockets`; }; -const wsClient = createWSClient({ - url: constructWebsocketUrl(), -}); +export function TRPCReactProvider({ children }: PropsWithChildren) { + const { data: session } = useSession(); + const sessionQueryScope = getSessionQueryScope(session); + const [initialSessionQueryScope] = useState(() => sessionQueryScope); -export function TRPCReactProvider(props: PropsWithChildren) { - const [persister] = useState(() => createWidgetQueryPersister()); + return ( + + {children} + + ); +} + +const reloadPage = () => window.location.reload(); + +const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => { + const wsClient = useMemo( + () => + createWSClient({ + url: constructWebsocketUrl(), + lazy: { enabled: true, closeMs: 30_000 }, + }), + [], + ); + useEffect( + () => () => { + void wsClient.close(); + }, + [wsClient], + ); const [queryClient] = useState(() => { const client = new QueryClient({ defaultOptions: { queries: { staleTime: queryCacheDefaultStaleTimeMs, gcTime: queryCacheDefaultGcTimeMs, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - retry: 3, + retry: createQueryRetry(env.NODE_ENV === "development" ? 1 : 3), }, mutations: { onError(error) { @@ -91,23 +120,29 @@ export function TRPCReactProvider(props: PropsWithChildren) { }, }, }); - client.setQueryDefaults([["widget"]], { refetchInterval: queryCacheDefaultRefetchIntervalMs }); - for (const { definition } of Object.values(widgetImports)) { - const def = definition as { refetchInterval?: number | null; queryKey?: QueryKey; kind: string }; - if (def.refetchInterval === undefined) continue; - const key = def.queryKey ?? [["widget", def.kind]]; - const interval = def.refetchInterval === null ? false : def.refetchInterval * 1000; - client.setQueryDefaults(key, { refetchInterval: interval }); + client.setQueryDefaults([["widget"]], { + refetchInterval: queryCacheDefaultRefetchIntervalMs, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); + for (const { queryKey, intervalSeconds } of widgetQueryRefetchIntervals) { + client.setQueryDefaults(queryKey, { + refetchInterval: intervalSeconds === null ? false : intervalSeconds * 1000, + }); + } + for (const { queryKey, ...policy } of dashboardSupportingQueryPolicies) { + client.setQueryDefaults(queryKey, policy); } return client; }); - const [trpcClient] = useState(() => { + useEffect(() => () => queryClient.clear(), [queryClient]); + + const trpcClient = useMemo(() => { return clientApi.createClient({ links: [ loggerLink({ - enabled: (opts) => - env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error), + enabled: (opts) => opts.direction === "down" && opts.result instanceof Error, }), splitLink({ condition: ({ type }) => type === "subscription", @@ -147,25 +182,14 @@ export function TRPCReactProvider(props: PropsWithChildren) { }), ], }); - }); + }, [wsClient]); return ( - - query.state.status === "success" && isPersistableWidgetQueryKey(query.queryKey), - }, - }} - > - {props.children} - - + + {children} + {process.env.NODE_ENV === "development" && } + ); -} +}; diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/(home)/_definition.ts b/apps/nextjs/src/app/[locale]/boards/(content)/(home)/_definition.ts index 2357d7424..d64fdb56e 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/(home)/_definition.ts +++ b/apps/nextjs/src/app/[locale]/boards/(content)/(home)/_definition.ts @@ -1,9 +1,9 @@ -import { api } from "@homarr/api/server"; +import { getHomeBoardAsync } from "@homarr/api/board-server"; import { createBoardContentPage } from "../_creator"; export default createBoardContentPage<{ locale: string }>({ async getInitialBoardAsync() { - return await api.board.getHomeBoard(); + return await getHomeBoardAsync(); }, }); diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/[name]/(board)/_definition.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/[name]/(board)/_definition.tsx index d3b65a5e9..f0924bb6e 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/[name]/(board)/_definition.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/[name]/(board)/_definition.tsx @@ -1,9 +1,9 @@ -import { api } from "@homarr/api/server"; +import { getBoardByNameAsync } from "@homarr/api/board-server"; import { createBoardContentPage } from "../../_creator"; export default createBoardContentPage<{ locale: string; name: string }>({ async getInitialBoardAsync({ name }) { - return await api.board.getBoardByName({ name }); + return await getBoardByNameAsync(name); }, }); diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_board-add-menu.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_board-add-menu.tsx new file mode 100644 index 000000000..e49fefe5e --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_board-add-menu.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useCallback } from "react"; +import { Group, Menu } from "@mantine/core"; +import { IconBox, IconBoxAlignTop, IconChevronDown, IconPlug, IconPlus, IconResize } from "@tabler/icons-react"; + +import { useSession } from "@homarr/auth/client"; +import { useModalAction } from "@homarr/modals"; +import { AppSelectModal } from "@homarr/modals-collection"; +import { useI18n } from "@homarr/translation/client"; + +import { useItemActions } from "~/components/board/items/item-actions"; +import { ItemSelectModal } from "~/components/board/items/item-select-modal"; +import { useCategoryActions } from "~/components/board/sections/category/category-actions"; +import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal"; +import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions"; +import { IntegrationSelectModal } from "~/components/integration/integration-select-modal"; +import { HeaderButton } from "~/components/layout/header/button"; + +export const BoardAddMenu = () => { + const { data: session } = useSession(); + const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal); + const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal); + const { openModal: openAppSelectModal } = useModalAction(AppSelectModal); + const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal); + const { addCategoryToEnd } = useCategoryActions(); + const { addDynamicSection } = useDynamicSectionActions(); + const { createItem } = useItemActions(); + const t = useI18n(); + + const handleAddCategory = useCallback( + () => + openCategoryEditModal( + { + category: { id: "new", name: "" }, + onSuccess({ name }) { + addCategoryToEnd({ name }); + }, + submitLabel: t("section.category.create.submit"), + }, + { title: (translate) => translate("section.category.create.title") }, + ), + [addCategoryToEnd, openCategoryEditModal, t], + ); + + return ( + + + + + + + + + + + } onClick={() => openItemSelectModal()}> + {t("item.action.create")} + + } + onClick={() => + openAppSelectModal({ + onSelect: (app) => createItem({ kind: "app", options: { appId: app.id } }), + withCreate: session?.user.permissions.includes("app-create") ?? false, + }) + } + > + {t("app.action.add")} + + } onClick={() => openIntegrationSelectModal({})}> + {t("integration.action.create")} + + + } onClick={handleAddCategory}> + {t("section.category.action.create")} + + } onClick={addDynamicSection}> + {t("section.dynamic.action.create")} + + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx index 0ba502a9a..1b4308d38 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx @@ -14,10 +14,10 @@ export const ClientBoard = () => { const fullWidthSortedSections = board.sections .filter((section) => section.kind === "empty" || section.kind === "category") - .sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset); + .toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset); return ( - + {fullWidthSortedSections.map((section) => diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx index 16be5c478..2bb868f7d 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx @@ -1,15 +1,12 @@ import type { Metadata } from "next"; -import { Suspense } from "react"; +import { cache } from "react"; import { TRPCError } from "@trpc/server"; // Placed here because gridstack styles are used for board content import "~/styles/gridstack.scss"; import { dehydrate, HydrationBoundary } from "@tanstack/react-query"; -import type { PersistedClient } from "@tanstack/react-query-persist-client"; -import superjson from "superjson"; -import { queryCacheBuster } from "@homarr/api/query-cache"; -import { getQueryClient } from "@homarr/api/server"; +import { makeQueryClient } from "@homarr/api/shared"; import { IntegrationProvider } from "@homarr/auth/client"; import { auth } from "@homarr/auth/next"; import { getIntegrationsWithPermissionsAsync } from "@homarr/auth/server"; @@ -17,7 +14,6 @@ import { isNullOrWhitespace } from "@homarr/common"; import { createLogger } from "@homarr/core/infrastructure/logs"; import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; import type { WidgetKind } from "@homarr/definitions"; -import { getQueryCacheAsync } from "@homarr/redis"; import { getI18n } from "@homarr/translation/server"; import { prefetchForKindAsync } from "@homarr/widgets/prefetch"; @@ -29,6 +25,7 @@ import { DynamicClientBoard } from "./_dynamic-client"; import { BoardContentHeaderActions } from "./_header-actions"; const logger = createLogger({ module: "createBoardContentPage" }); +const getQueryClient = cache(makeQueryClient); export type Params = Record; @@ -61,7 +58,6 @@ export const createBoardContentPage = >( } return acc; }, new Map()); - const [integrations] = await Promise.all([ getIntegrationsWithPermissionsAsync(session), ...Array.from(itemsMap).map(([kind, items]) => @@ -77,19 +73,12 @@ export const createBoardContentPage = >( ), ]); - const userId = session?.user.id ?? "anonymous"; - return ( - <> - - - - - - - - - + + + + + ); }, generateMetadataAsync: async ({ params }: { params: Promise }): Promise => { @@ -120,19 +109,3 @@ export const createBoardContentPage = >( }, }; }; - -async function QueryCacheHydration({ userId, boardId }: { userId: string; boardId: string }) { - try { - const serialized = await getQueryCacheAsync(userId, boardId); - if (!serialized) return null; - - const persisted = superjson.parse(serialized); - if (!persisted || persisted.buster !== queryCacheBuster) return null; - if (!persisted.clientState?.queries?.length) return null; - - return ; - } catch (error) { - logger.warn(new ErrorWithMetadata("Failed to hydrate query cache", { userId, boardId }, { cause: error })); - return null; - } -} diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_dynamic-client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_dynamic-client.tsx index 50b0aff66..44d0a77c6 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_dynamic-client.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_dynamic-client.tsx @@ -1,7 +1,24 @@ "use client"; import dynamic from "next/dynamic"; +import { SimpleGrid, Skeleton, Stack } from "@mantine/core"; + +const BoardLoadingShell = () => ( + + + {[2, 1, 2, 2, 1, 2].map((rows, index) => ( + + ))} + + +); export const DynamicClientBoard = dynamic(() => import("./_client").then((mod) => mod.ClientBoard), { ssr: false, + loading: BoardLoadingShell, }); diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx index 30c91de40..6036a4905 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx @@ -1,45 +1,36 @@ "use client"; -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useState } from "react"; +import dynamic from "next/dynamic"; import { useRouter } from "next/navigation"; -import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour"; -import { Box, Group, Menu, ScrollArea } from "@mantine/core"; +import { Box, Center, Loader, Menu, ScrollArea } from "@mantine/core"; import { useHotkeys } from "@mantine/hooks"; -import { - IconBox, - IconBoxAlignTop, - IconChevronDown, - IconLayoutBoard, - IconPencil, - IconPencilOff, - IconPlug, - IconPlus, - IconReplace, - IconResize, - IconSettings, -} from "@tabler/icons-react"; +import { IconLayoutBoard, IconPencil, IconPencilOff, IconPlus, IconReplace, IconSettings } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; -import { useSession } from "@homarr/auth/client"; import { useRequiredBoard } from "@homarr/boards/context"; import { useEditMode } from "@homarr/boards/edit-mode"; import { revalidatePathActionAsync } from "@homarr/common/client"; import { env } from "@homarr/common/env"; import { hotkeys } from "@homarr/definitions"; -import { useConfirmModal, useModalAction } from "@homarr/modals"; -import { AppSelectModal } from "@homarr/modals-collection"; +import { useConfirmModal } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { Link } from "@homarr/ui"; -import { useItemActions } from "~/components/board/items/item-actions"; -import { ItemSelectModal } from "~/components/board/items/item-select-modal"; import { useBoardPermissions } from "~/components/board/permissions/client"; -import { useCategoryActions } from "~/components/board/sections/category/category-actions"; -import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal"; -import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions"; -import { IntegrationSelectModal } from "~/components/integration/integration-select-modal"; import { HeaderButton } from "~/components/layout/header/button"; +import { TourTarget } from "~/components/layout/header/tour-target"; + +const loadBoardAddMenu = () => import("./_board-add-menu"); +const preloadBoardAddMenu = () => void loadBoardAddMenu().catch(() => undefined); +const BoardAddMenu = dynamic(() => loadBoardAddMenu().then(({ BoardAddMenu: AddMenu }) => AddMenu), { + loading: () => ( + + + + ), +}); export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => { const [isEditMode] = useEditMode(); @@ -52,16 +43,16 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool return ( <> - {isEditMode && } + {isEditMode && } {!demoReadOnly && ( - + - + )} @@ -69,94 +60,6 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool ); }; -const AddMenu = () => { - const { data: session } = useSession(); - const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal); - const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal); - const { openModal: openAppSelectModal } = useModalAction(AppSelectModal); - const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal); - const { addCategoryToEnd } = useCategoryActions(); - const { addDynamicSection } = useDynamicSectionActions(); - const { createItem } = useItemActions(); - const t = useI18n(); - - const handleAddCategory = useCallback( - () => - openCategoryEditModal( - { - category: { - id: "new", - name: "", - }, - onSuccess({ name }) { - addCategoryToEnd({ name }); - }, - submitLabel: t("section.category.create.submit"), - }, - { - title: (t) => t("section.category.create.title"), - }, - ), - [addCategoryToEnd, openCategoryEditModal, t], - ); - - const handleSelectItem = useCallback(() => { - openItemSelectModal(); - }, [openItemSelectModal]); - - const handleSelectApp = useCallback(() => { - openAppSelectModal({ - onSelect: (app) => { - createItem({ - kind: "app", - options: { appId: app.id }, - }); - }, - withCreate: session?.user.permissions.includes("app-create") ?? false, - }); - }, [openAppSelectModal, createItem]); - - const handleAddIntegration = useCallback(() => { - openIntegrationSelectModal({}); - }, [openIntegrationSelectModal]); - - return ( - - - - - - - - - - - } onClick={handleSelectItem}> - {t("item.action.create")} - - - } onClick={handleSelectApp}> - {t("app.action.add")} - - - } onClick={handleAddIntegration}> - {t("integration.action.create")} - - - - - } onClick={handleAddCategory}> - {t("section.category.action.create")} - - - } onClick={addDynamicSection}> - {t("section.dynamic.action.create")} - - - - ); -}; - const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => { const [isEditMode, { open, close }] = useEditMode(); const board = useRequiredBoard(); @@ -197,21 +100,29 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => { usePreventLeaveWithDirty(isEditMode); return ( - - + + {isEditMode ? : } - + ); }; const SelectBoardsMenu = () => { - const { data: boards = [] } = clientApi.board.getAllBoards.useQuery(); + const [isOpen, setIsOpen] = useState(false); + const utils = clientApi.useUtils(); + const { data: boards = [], isPending } = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isOpen }); + const preloadBoards = () => void utils.board.getAllBoards.prefetch(); return ( - - - + + + @@ -219,6 +130,11 @@ const SelectBoardsMenu = () => { + {isPending && ( +
+ +
+ )} {boards.map((board) => ( {
- +
); }; @@ -246,15 +162,13 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => { useEffect(() => { if (!isDirty) return; - const handleClick = (event: Event) => { - const target = (event.target as HTMLElement).closest("a"); - - if (!target) { - console.warn("No anchor element found for click event", event); - return; - } + const handleClick = (event: MouseEvent) => { + if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + const target = event.target instanceof Element ? event.target.closest(anchorSelector) : null; + if (!target) return; event.preventDefault(); + event.stopPropagation(); openConfirmModal({ title: t("board.action.edit.confirmLeave.title"), @@ -280,19 +194,14 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => { event.returnValue = true; }; - const anchors = document.querySelectorAll(anchorSelector); - anchors.forEach((link) => { - link.addEventListener("click", handleClick); - }); + document.addEventListener("click", handleClick, true); window.addEventListener("popstate", handlePopState); window.addEventListener("beforeunload", handleBeforeUnload); return () => { - anchors.forEach((link) => { - link.removeEventListener("click", handleClick); - }); + document.removeEventListener("click", handleClick, true); window.removeEventListener("popstate", handlePopState); window.removeEventListener("beforeunload", handleBeforeUnload); }; - }, [isDirty]); + }, [isDirty, openConfirmModal, router, t]); }; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/layout.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/layout.tsx index 1f129e4c1..0c5fd10e0 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/layout.tsx @@ -1,4 +1,4 @@ -import { api } from "@homarr/api/server"; +import { getBoardByNameAsync } from "@homarr/api/board-server"; import { BoardOtherHeaderActions } from "../_header-actions"; import { createBoardLayout } from "../_layout-creator"; @@ -6,6 +6,6 @@ import { createBoardLayout } from "../_layout-creator"; export default createBoardLayout<{ locale: string; name: string }>({ headerActions: , async getInitialBoardAsync({ name }) { - return await api.board.getBoardByName({ name }); + return await getBoardByNameAsync(name); }, }); diff --git a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx index 3cddc6685..24ebe3d03 100644 --- a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx @@ -3,6 +3,7 @@ import { notFound, redirect } from "next/navigation"; import { AppShellMain } from "@mantine/core"; import { TRPCError } from "@trpc/server"; +import { getRscUserSettingsAsync } from "@homarr/api/user-server"; import { auth } from "@homarr/auth/next"; import { BoardProvider } from "@homarr/boards/context"; import { EditModeProvider } from "@homarr/boards/edit-mode"; @@ -11,7 +12,8 @@ import { createLogger } from "@homarr/core/infrastructure/logs"; import { MainHeader } from "~/components/layout/header"; import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo"; import { ClientShell } from "~/components/layout/shell"; -import { BoardTourProvider } from "~/components/onboarding/board-tour"; +import { BoardTourGate } from "~/components/onboarding/board-tour-gate"; +import { env } from "~/env"; import { getCurrentColorSchemeAsync } from "~/theme/color-scheme"; import type { Board } from "./_types"; import type { Params } from "./(content)/_creator"; @@ -21,11 +23,6 @@ import { BoardMantineProvider } from "./(content)/_theme"; const logger = createLogger({ module: "createBoardLayout" }); -const BoardTourWrapper = ({ hasSession, children }: PropsWithChildren<{ hasSession: boolean }>) => { - if (!hasSession) return <>{children}; - return {children}; -}; - interface CreateBoardLayoutProps { headerActions: JSX.Element; getInitialBoardAsync: (params: TParams) => Promise; @@ -43,8 +40,32 @@ export const createBoardLayout = ({ }: PropsWithChildren<{ params: Promise; }>) => { - const session = await auth(); - const initialBoard = await getInitialBoard(await params).catch((error) => { + const resolvedParams = await params; + const sessionPromise = auth(); + const initialBoardPromise = getInitialBoard(resolvedParams).then( + (board) => ({ status: "fulfilled", board }) as const, + (error: unknown) => ({ status: "rejected", error }) as const, + ); + const colorSchemePromise = getCurrentColorSchemeAsync(); + const shouldRunBoardTourPromise = sessionPromise.then(async (session) => { + if (!withTour || !session || env.DEMO_MODE) return false; + + try { + const user = await getRscUserSettingsAsync(session.user.id); + return user !== undefined && !user.completedBoardTour; + } catch (error) { + logger.error(new Error("Failed to load the board tour status", { cause: error })); + return false; + } + }); + const [session, initialBoardResult, colorScheme, shouldRunBoardTour] = await Promise.all([ + sessionPromise, + initialBoardPromise, + colorSchemePromise, + shouldRunBoardTourPromise, + ]); + if (initialBoardResult.status === "rejected") { + const { error } = initialBoardResult; if (error instanceof TRPCError && error.code === "NOT_FOUND") { if (!session) { logger.debug("No home board found for anonymous user, redirecting to login"); @@ -60,8 +81,8 @@ export const createBoardLayout = ({ } throw error; - }); - const colorScheme = await getCurrentColorSchemeAsync(); + } + const initialBoard = initialBoardResult.board; return ( @@ -69,7 +90,7 @@ export const createBoardLayout = ({ - + } @@ -78,7 +99,7 @@ export const createBoardLayout = ({ /> {children} - + diff --git a/apps/nextjs/src/app/[locale]/init/page.tsx b/apps/nextjs/src/app/[locale]/init/page.tsx index 212c74e73..0013c3170 100644 --- a/apps/nextjs/src/app/[locale]/init/page.tsx +++ b/apps/nextjs/src/app/[locale]/init/page.tsx @@ -1,8 +1,8 @@ import type { JSX } from "react"; import { Box, Center, Stack, Text, Title } from "@mantine/core"; -import { api } from "@homarr/api/server"; import type { MaybePromise } from "@homarr/common/types"; +import { db } from "@homarr/db"; import type { OnboardingStep } from "@homarr/definitions"; import { getScopedI18n } from "@homarr/translation/server"; @@ -29,9 +29,16 @@ const stepComponents: Record MaybePromise { + const value = await db.query.onboarding.findFirst(); + if (!value) return { current: "start" as const, previous: null }; + + return { current: value.step, previous: value.previousStep }; +}; + export default async function InitPage() { const t = await getScopedI18n("init.step"); - const currentStep = await api.onboard.currentStep(); + const currentStep = await getCurrentOnboardingStepAsync(); const CurrentComponent = stepComponents[currentStep.current]; diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx index 2388e2812..ea62beadf 100644 --- a/apps/nextjs/src/app/[locale]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/layout.tsx @@ -13,11 +13,11 @@ import { notFound } from "next/navigation"; import type { DayOfWeek } from "@mantine/dates"; import { NextIntlClientProvider } from "next-intl"; -import { api } from "@homarr/api/server"; +import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server"; +import { getRscUserSettingsAsync } from "@homarr/api/user-server"; import { env } from "@homarr/auth/env"; import { auth } from "@homarr/auth/next"; -import { db } from "@homarr/db"; -import { getServerSettingsAsync } from "@homarr/db/queries"; +import { createLogger } from "@homarr/core/infrastructure/logs"; import { ModalProvider } from "@homarr/modals"; import { Notifications } from "@homarr/notifications"; import { SettingsProvider } from "@homarr/settings"; @@ -43,6 +43,8 @@ const fontSans = Inter({ variable: "--font-sans", }); +const logger = createLogger({ module: "rootLayout" }); + // eslint-disable-next-line no-restricted-syntax export const generateMetadata = async (): Promise => ({ title: "Homarr", @@ -78,15 +80,27 @@ export default async function Layout(props: { children: React.ReactNode; params: Promise<{ locale: SupportedLanguage }>; }) { - if (!isLocaleSupported((await props.params).locale)) { + const { locale } = await props.params; + if (!isLocaleSupported(locale)) { notFound(); } - const session = await auth(); - const user = session ? await api.user.getById({ userId: session.user.id }).catch(() => null) : null; - const serverSettings = await getServerSettingsAsync(db); - const colorScheme = await getCurrentColorSchemeAsync(); - const direction = isLocaleRTL((await props.params).locale) ? "rtl" : "ltr"; + const sessionPromise = auth(); + const userPromise = sessionPromise.then((session) => + session + ? getRscUserSettingsAsync(session.user.id).catch((error: unknown) => { + logger.error(new Error("Failed to load the authenticated user in the root layout", { cause: error })); + return null; + }) + : null, + ); + const [session, user, serverSettings, colorScheme] = await Promise.all([ + sessionPromise, + userPromise, + getRscServerSettingsAsync(), + getCurrentColorSchemeAsync(), + ]); + const direction = isLocaleRTL(locale) ? "rtl" : "ltr"; const StackedProvider = composeWrappers([ (innerProps) => { @@ -125,8 +139,6 @@ export default async function Layout(props: { (innerProps) => , ]); - const { locale } = await props.params; - return ( // Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering { + if (!session || nextEnv.DEMO_MODE) return false; + + try { + const user = await getRscUserSettingsAsync(session.user.id); + return user !== undefined && !user.completedManageTour; + } catch (error) { + logger.error(new Error("Failed to load the management tour status", { cause: error })); + return false; + } + }); + const [t, session, shouldRunManageTour] = await Promise.all([ + getScopedI18n("management.navbar"), + sessionPromise, + shouldRunManageTourPromise, + ]); const navigationLinks: NavigationLink[] = [ { label: t("items.home"), @@ -225,7 +245,9 @@ export default async function ManageLayout({ children }: PropsWithChildren) { ); - if (!session) return shell; - - return {shell}; + return ( + + {shell} + + ); } diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_reset-tours.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_reset-tours.tsx index 76f33a8db..82e50d218 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_reset-tours.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_reset-tours.tsx @@ -1,5 +1,6 @@ "use client"; +import { useRouter } from "next/navigation"; import { Badge, Button, Group, Stack, Text } from "@mantine/core"; import { IconRefresh } from "@tabler/icons-react"; @@ -9,6 +10,7 @@ import { useScopedI18n } from "@homarr/translation/client"; export const ResetTours = () => { const t = useScopedI18n("management.page.user.setting.general.item.onboardingTours"); + const router = useRouter(); const utils = clientApi.useUtils(); const { data: tourStatus } = clientApi.user.getTourStatus.useQuery(); const { mutate: resetTours, isPending } = clientApi.user.resetTours.useMutation({ @@ -18,6 +20,7 @@ export const ResetTours = () => { message: t("resetSuccess.message"), }); void utils.user.getTourStatus.invalidate(); + router.refresh(); }, }); diff --git a/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx b/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx index 9bbc0ac93..882842ee7 100644 --- a/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx +++ b/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx @@ -1,41 +1,38 @@ "use client"; -import { useCallback, useMemo, useState } from "react"; +import { use, useCallback, useState } from "react"; import { ActionIcon, Affix, Card } from "@mantine/core"; import { IconDimensions, IconPencil, IconToggleLeft, IconToggleRight } from "@tabler/icons-react"; import { QueryErrorResetBoundary } from "@tanstack/react-query"; import { ErrorBoundary } from "react-error-boundary"; -import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; +import { clientApi } from "@homarr/api/client"; +import type { WidgetKind } from "@homarr/definitions"; import { useModalAction } from "@homarr/modals"; -import { showSuccessNotification } from "@homarr/notifications"; +import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useSettings } from "@homarr/settings"; import { useI18n } from "@homarr/translation/client"; import type { BoardItemAdvancedOptions } from "@homarr/validation/shared"; -import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets"; import { WidgetError } from "@homarr/widgets/errors"; -import { WidgetEditModal } from "@homarr/widgets/modals"; +import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; +import { LazyWidgetEditModal, preloadWidgetEditModal } from "~/components/board/items/lazy-widget-edit-modal"; import type { Dimensions } from "./_dimension-modal"; import { PreviewDimensionsModal } from "./_dimension-modal"; interface WidgetPreviewPageContentProps { kind: WidgetKind; - integrationData: { - id: string; - name: string; - url: string; - kind: IntegrationKind; - }[]; } -export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPreviewPageContentProps) => { +export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps) => { const settings = useSettings(); const t = useI18n(); - const { openModal: openWidgetEditModal } = useModalAction(WidgetEditModal); + const utils = clientApi.useUtils(); + const { openModal: openWidgetEditModal } = useModalAction(LazyWidgetEditModal); const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal); - const currentDefinition = useMemo(() => widgetImports[kind].definition, [kind]); + const { definition: currentDefinition, Component } = use(loadWidgetResources(kind)); const [editMode, setEditMode] = useState(false); + const [isEditorLoading, setIsEditorLoading] = useState(false); const [dimensions, setDimensions] = useState({ width: 128, height: 128, @@ -45,7 +42,7 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie integrationIds: string[]; advancedOptions: BoardItemAdvancedOptions; }>({ - options: reduceWidgetOptionsWithDefaultValues(kind, settings, {}), + options: reduceWidgetOptionsWithDefinition(currentDefinition, settings, {}), integrationIds: [], advancedOptions: { title: null, @@ -54,31 +51,41 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie }, }); - const handleOpenEditWidgetModal = useCallback(() => { - openWidgetEditModal( - { - kind, - value: state, - onSuccessfulEdit: (value) => { - setState(value); + const handleOpenEditWidgetModal = useCallback(async () => { + const hasIntegrationSupport = "supportedIntegrations" in currentDefinition; + setIsEditorLoading(true); + preloadWidgetEditModal(); + try { + const integrationData = hasIntegrationSupport ? await utils.integration.all.ensureData() : []; + openWidgetEditModal( + { + kind, + definition: currentDefinition, + value: state, + onSuccessfulEdit: (value) => { + setState(value); + }, + integrationData: integrationData.filter((integration) => + (currentDefinition.supportedIntegrations ?? []).includes(integration.kind), + ), + integrationSupport: hasIntegrationSupport, + settings, }, - integrationData: integrationData.filter( - (integration) => - "supportedIntegrations" in currentDefinition && - (currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind), - ), - integrationSupport: "supportedIntegrations" in currentDefinition, - settings, - }, - { - title(t) { - return `${t("item.edit.title")} - ${t(`widget.${kind}.name`)}`; + { + title(translate) { + return `${translate("item.edit.title")} - ${translate(`widget.${kind}.name`)}`; + }, }, - }, - ); - }, [currentDefinition, integrationData, kind, openWidgetEditModal, settings, state]); - - const Comp = loadWidgetDynamic(kind); + ); + } catch (error) { + showErrorNotification({ + title: t("common.error"), + message: error instanceof Error ? error.message : String(error), + }); + } finally { + setIsEditorLoading(false); + } + }, [currentDefinition, kind, openWidgetEditModal, settings, state, t, utils]); const toggleEditMode = useCallback(() => { setEditMode((editMode) => !editMode); @@ -94,8 +101,11 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie }); }, [dimensions, openPreviewDimensionsModal]); - const updateOptions = ({ newOptions }: { newOptions: Record }) => - setState({ ...state, options: { ...state.options, newOptions } }); + const updateOptions = useCallback( + ({ newOptions }: { newOptions: Record }) => + setState((current) => ({ ...current, options: { ...current.options, ...newOptions } })), + [], + ); return ( <> @@ -105,10 +115,10 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie ( - + )} > - void handleOpenEditWidgetModal()} + onFocus={preloadWidgetEditModal} + onPointerEnter={preloadWidgetEditModal} + loading={isEditorLoading} aria-label={t("common.action.edit")} > diff --git a/apps/nextjs/src/app/[locale]/widgets/[kind]/layout.tsx b/apps/nextjs/src/app/[locale]/widgets/[kind]/layout.tsx index cfd213f9d..9cdf91241 100644 --- a/apps/nextjs/src/app/[locale]/widgets/[kind]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/widgets/[kind]/layout.tsx @@ -1,23 +1,18 @@ import type { PropsWithChildren } from "react"; -import { widgetImports } from "@homarr/widgets"; +import { widgetKinds } from "@homarr/definitions"; +import { widgetCatalogIcons } from "@homarr/widgets/catalog"; import { MainNavigation } from "~/components/layout/navigation"; import { ClientShell } from "~/components/layout/shell"; -const getLinks = () => { - return Object.entries(widgetImports).map(([key, value]) => { - return { - href: `/widgets/${key}`, - icon: value.definition.icon, - label: value.definition.kind, - }; - }); -}; +const links = widgetKinds.map((kind) => ({ + href: `/widgets/${kind}`, + icon: widgetCatalogIcons[kind], + label: kind, +})); export default function WidgetPreviewLayout({ children }: PropsWithChildren) { - const links = getLinks(); - return ( diff --git a/apps/nextjs/src/app/[locale]/widgets/[kind]/page.tsx b/apps/nextjs/src/app/[locale]/widgets/[kind]/page.tsx index fbcd741c2..454e7a805 100644 --- a/apps/nextjs/src/app/[locale]/widgets/[kind]/page.tsx +++ b/apps/nextjs/src/app/[locale]/widgets/[kind]/page.tsx @@ -1,10 +1,9 @@ +import { Suspense } from "react"; import { notFound } from "next/navigation"; -import { Center } from "@mantine/core"; +import { Center, Loader } from "@mantine/core"; -import { env } from "@homarr/common/env"; -import { db } from "@homarr/db"; import type { WidgetKind } from "@homarr/definitions"; -import { widgetImports } from "@homarr/widgets"; +import { widgetKinds } from "@homarr/widgets/manifest"; import { WidgetPreviewPageContent } from "./_content"; @@ -13,24 +12,16 @@ interface Props { } export default async function WidgetPreview(props: Props) { - if (!((await props.params).kind in widgetImports || env.NODE_ENV !== "development")) { + const { kind } = await props.params; + if (!widgetKinds.includes(kind as WidgetKind)) { notFound(); } - const integrationData = await db.query.integrations.findMany({ - columns: { - id: true, - name: true, - url: true, - kind: true, - }, - }); - - const sort = (await props.params).kind as WidgetKind; - return (
- + }> + +
); } diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx index ad0f3a305..bd6ef5fa3 100644 --- a/apps/nextjs/src/components/board/items/item-content.tsx +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -1,17 +1,21 @@ -import type { MutableRefObject } from "react"; -import { useRef } from "react"; -import { Badge, Card } from "@mantine/core"; +import type { ComponentType, MutableRefObject } from "react"; +import { Suspense, use, useRef } from "react"; +import type { CardProps } from "@mantine/core"; +import { Badge, Card, Center, Loader } from "@mantine/core"; import { useElementSize } from "@mantine/hooks"; import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query"; import combineClasses from "clsx"; import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes"; +import type { FallbackProps } from "react-error-boundary"; import { ErrorBoundary } from "react-error-boundary"; import { useRequiredBoard } from "@homarr/boards/context"; import { useEditMode } from "@homarr/boards/edit-mode"; import { useSettings } from "@homarr/settings"; -import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets"; import { WidgetError } from "@homarr/widgets/errors"; +import { getWidgetQueryKeys } from "@homarr/widgets/definition"; +import type { WidgetComponentProps, WidgetDefinition } from "@homarr/widgets/definition"; +import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; import type { SectionItem } from "~/app/[locale]/boards/_types"; import classes from "../sections/item.module.css"; @@ -19,7 +23,7 @@ import { useItemActions } from "./item-actions"; import itemContentClasses from "./item-content.module.css"; import { BoardItemMenu } from "./item-menu"; import { WidgetContextMenu } from "./widget-context-menu"; -import { removePersistedWidgetQueries } from "./widget-query-recovery"; +import { removeWidgetDataQueries } from "./widget-query-recovery"; interface BoardItemContentProps { item: SectionItem; @@ -31,6 +35,53 @@ const getOverflowFromKind = (kind: SectionItem["kind"]) => { return undefined; }; +type BoardItemCardProps = CardProps & { + item: SectionItem; + innerRef: (element: HTMLDivElement | null) => void; +}; + +const BoardItemCard = ({ item, innerRef, children, ...cardProps }: BoardItemCardProps) => { + const board = useRequiredBoard(); + + return ( + + {children} + + ); +}; + +const WidgetDefinitionLoadError = ({ + error, + resetErrorBoundary, + item, + innerRef, +}: FallbackProps & Pick) => ( + +
+ +
+
+); + export const BoardItemContent = ({ item }: BoardItemContentProps) => { const { ref, width, height } = useElementSize(); const board = useRequiredBoard(); @@ -38,29 +89,28 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => { return ( <> - - } + > + +
+ +
+ + } > - -
-
+ + +
{item.advancedOptions.title?.trim() && ( { ); }; +interface LoadedBoardItemContentProps extends Omit { + innerRef: (element: HTMLDivElement | null) => void; +} + +const LoadedBoardItemContent = ({ item, innerRef, ...innerContentProps }: LoadedBoardItemContentProps) => { + const { definition, Component } = use(loadWidgetResources(item.kind)); + + return ( + + + ( + <> + + + + )} + > + + + + + ); +}; + interface InnerContentProps { item: SectionItem; width: number; height: number; widgetStateRef: MutableRefObject | null>; + definition: WidgetDefinition; + Component: ComponentType>; } -const InnerContent = ({ item, ...dimensions }: InnerContentProps) => { +const InnerContent = ({ item, definition, Component, ...dimensions }: InnerContentProps) => { const settings = useSettings(); const board = useRequiredBoard(); const [isEditMode] = useEditMode(); - const Comp = loadWidgetDynamic(item.kind); - const { definition } = widgetImports[item.kind]; - const options = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options); + const options = reduceWidgetOptionsWithDefinition(definition, settings, item.options); const newItem = { ...item, options }; const { updateItemOptions } = useItemActions(); const updateOptions = ({ newOptions }: { newOptions: Record }) => updateItemOptions({ itemId: item.id, newOptions }); const widgetSupportsIntegrations = - "supportedIntegrations" in definition && definition.supportedIntegrations.length >= 1; + "supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) >= 1; const queryClient = useQueryClient(); + const widgetQueryKeys = getWidgetQueryKeys(definition, item.kind); return ( {({ reset }) => ( { - removePersistedWidgetQueries(queryClient); + removeWidgetDataQueries(queryClient, widgetQueryKeys); reset(); }} fallbackRender={({ resetErrorBoundary, error }) => ( <> - - + + )} > @@ -131,23 +213,25 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => { (!("integrationsRequired" in definition) || definition.integrationsRequired !== false) } /> - - - updateOptions({ - newOptions: { - ...options, - ...partialNewOptions.newOptions, - }, - }) - } - {...dimensions} - /> + +
+ + updateOptions({ + newOptions: { + ...options, + ...partialNewOptions.newOptions, + }, + }) + } + {...dimensions} + /> +
)}
diff --git a/apps/nextjs/src/components/board/items/item-menu.tsx b/apps/nextjs/src/components/board/items/item-menu.tsx index b8a136bbb..13fa8a16e 100644 --- a/apps/nextjs/src/components/board/items/item-menu.tsx +++ b/apps/nextjs/src/components/board/items/item-menu.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { ActionIcon, Menu } from "@mantine/core"; import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react"; @@ -8,39 +8,44 @@ import { useEditMode } from "@homarr/boards/edit-mode"; import { useConfirmModal, useModalAction } from "@homarr/modals"; import { useSettings } from "@homarr/settings"; import { useI18n, useScopedI18n } from "@homarr/translation/client"; -import { widgetImports } from "@homarr/widgets"; -import { WidgetEditModal } from "@homarr/widgets/modals"; +import type { WidgetDefinition } from "@homarr/widgets/definition"; import type { SectionItem } from "~/app/[locale]/boards/_types"; import { useSectionContext } from "../sections/section-context"; import { useItemActions } from "./item-actions"; -import { ItemMoveModal } from "./item-move-modal"; +import { LazyItemMoveModal, preloadItemMoveModal } from "./lazy-item-move-modal"; +import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal"; interface BoardItemMenuProps { offset: number; item: SectionItem; + definition: WidgetDefinition; resetErrorBoundary?: () => void; } export const BoardItemMenu = (props: BoardItemMenuProps) => { const { data: session } = useSession(); - if (!session) return null; + const [isEditMode] = useEditMode(); + if (!session || !isEditMode) return null; return ; }; -const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuProps) => { +const BoardItemMenuInner = ({ offset, item, definition, resetErrorBoundary }: BoardItemMenuProps) => { const refResetErrorBoundaryOnNextRender = useRef(false); const tItem = useScopedI18n("item"); const t = useI18n(); - const { openModal } = useModalAction(WidgetEditModal); - const { openModal: openMoveModal } = useModalAction(ItemMoveModal); + const { openModal } = useModalAction(LazyWidgetEditModal); + const { openModal: openMoveModal } = useModalAction(LazyItemMoveModal); const { openConfirmModal } = useConfirmModal(); - const [isEditMode] = useEditMode(); + const [isMenuOpen, setIsMenuOpen] = useState(false); + const hasSupportedIntegrations = + "supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) > 0; const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } = useItemActions(); - const { data: integrationData, isPending } = clientApi.integration.all.useQuery(); - const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]); + const { data: integrationData, isPending } = clientApi.integration.all.useQuery(undefined, { + enabled: isMenuOpen && hasSupportedIntegrations, + }); const { gridstack } = useSectionContext().refs; const settings = useSettings(); @@ -52,12 +57,11 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP } }, [item, resetErrorBoundary]); - if (!isEditMode || isPending) return null; - const openEditModal = () => { openModal( { kind: item.kind, + definition, value: { advancedOptions: item.advancedOptions, options: item.options, @@ -80,10 +84,10 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP }, integrationData: (integrationData ?? []).filter( (integration) => - "supportedIntegrations" in currentDefinition && - (currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind), + "supportedIntegrations" in definition && + (definition.supportedIntegrations as string[]).some((kind) => kind === integration.kind), ), - integrationSupport: "supportedIntegrations" in currentDefinition, + integrationSupport: "supportedIntegrations" in definition, settings, itemId: item.id, appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined, @@ -107,7 +111,7 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP }; return ( - + {tItem("menu.label.settings")} - } onClick={openEditModal}> + } + onClick={openEditModal} + onFocus={preloadWidgetEditModal} + onPointerEnter={preloadWidgetEditModal} + disabled={hasSupportedIntegrations && isPending} + > {tItem("action.edit")} } + onFocus={preloadItemMoveModal} + onPointerEnter={preloadItemMoveModal} onClick={() => { if (!gridstack.current) return; openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current }); diff --git a/apps/nextjs/src/components/board/items/item-move-modal.tsx b/apps/nextjs/src/components/board/items/item-move-modal.tsx index 38e362ede..276e1124e 100644 --- a/apps/nextjs/src/components/board/items/item-move-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-move-modal.tsx @@ -9,13 +9,13 @@ import { useI18n } from "@homarr/translation/client"; import type { Item, SectionItem } from "~/app/[locale]/boards/_types"; -interface InnerProps { +export interface ItemMoveModalProps { gridStack: GridStack; item: Pick; columnCount: number; } -export const ItemMoveModal = createModal(({ actions, innerProps }) => { +export const ItemMoveModal = createModal(({ actions, innerProps }) => { const t = useI18n(); const maxWidthRef = useRef(innerProps.columnCount - innerProps.item.xOffset); const form = useZodForm( diff --git a/apps/nextjs/src/components/board/items/item-select-data.spec.ts b/apps/nextjs/src/components/board/items/item-select-data.spec.ts new file mode 100644 index 000000000..4d0e77860 --- /dev/null +++ b/apps/nextjs/src/components/board/items/item-select-data.spec.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from "vitest"; + +import { resolveMatchingIntegrationsAsync, tryLockSelection, unlockSelection } from "./item-select-data"; + +describe("widget selection lock", () => { + it("rejects repeated selection synchronously until the active selection finishes", () => { + const lock = { current: false }; + + expect(tryLockSelection(lock)).toBe(true); + expect(tryLockSelection(lock)).toBe(false); + + unlockSelection(lock); + expect(tryLockSelection(lock)).toBe(true); + }); +}); + +describe("resolveMatchingIntegrationsAsync", () => { + it("waits for the shared integration query before a cold integration-backed selection", async () => { + const ensureDataAsync = vi.fn(async () => [ + { id: "matching", kind: "sonarr" as const }, + { id: "other", kind: "radarr" as const }, + ]); + + await expect( + resolveMatchingIntegrationsAsync({ + hasIntegrationSupport: true, + supportedIntegrations: ["sonarr"], + currentData: undefined, + ensureDataAsync, + }), + ).resolves.toEqual([{ id: "matching", kind: "sonarr" }]); + expect(ensureDataAsync).toHaveBeenCalledOnce(); + }); + + it("does not wait for integrations when the widget has no integration support", async () => { + const ensureDataAsync = vi.fn(async () => [{ id: "unused", kind: "sonarr" as const }]); + + await expect( + resolveMatchingIntegrationsAsync({ + hasIntegrationSupport: false, + supportedIntegrations: [], + currentData: undefined, + ensureDataAsync, + }), + ).resolves.toEqual([]); + expect(ensureDataAsync).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/nextjs/src/components/board/items/item-select-data.ts b/apps/nextjs/src/components/board/items/item-select-data.ts new file mode 100644 index 000000000..f2b37f700 --- /dev/null +++ b/apps/nextjs/src/components/board/items/item-select-data.ts @@ -0,0 +1,28 @@ +import type { IntegrationKind } from "@homarr/definitions"; + +export const tryLockSelection = (lock: { current: boolean }) => { + if (lock.current) return false; + lock.current = true; + return true; +}; + +export const unlockSelection = (lock: { current: boolean }) => { + lock.current = false; +}; + +export const resolveMatchingIntegrationsAsync = async ({ + hasIntegrationSupport, + supportedIntegrations, + currentData, + ensureDataAsync, +}: { + hasIntegrationSupport: boolean; + supportedIntegrations: readonly IntegrationKind[]; + currentData: TIntegration[] | undefined; + ensureDataAsync: () => Promise; +}) => { + if (!hasIntegrationSupport) return []; + + const integrations = currentData ?? (await ensureDataAsync()); + return integrations.filter((integration) => supportedIntegrations.includes(integration.kind)); +}; diff --git a/apps/nextjs/src/components/board/items/item-select-modal.module.css b/apps/nextjs/src/components/board/items/item-select-modal.module.css new file mode 100644 index 000000000..ef8c56d8d --- /dev/null +++ b/apps/nextjs/src/components/board/items/item-select-modal.module.css @@ -0,0 +1,21 @@ +.action { + opacity: 0; + transition: opacity 150ms ease; +} + +.card:hover .action, +.card:focus-within .action { + opacity: 1; +} + +@media (hover: none), (pointer: coarse) { + .action { + opacity: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .action { + transition: none; + } +} diff --git a/apps/nextjs/src/components/board/items/item-select-modal.tsx b/apps/nextjs/src/components/board/items/item-select-modal.tsx index 2ac349c6d..497a63e49 100644 --- a/apps/nextjs/src/components/board/items/item-select-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx @@ -1,46 +1,65 @@ -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { Avatar, Box, Button, Card, Center, Divider, Group, Image, Stack, Text, Tooltip } from "@mantine/core"; import { IconApi } from "@tabler/icons-react"; +import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; -import { createId, objectEntries } from "@homarr/common"; -import { getIconUrl, getIntegrationName } from "@homarr/definitions"; +import { createId } from "@homarr/common"; +import { getIconUrl, getIntegrationName, widgetIntegrationSupport, widgetKinds } from "@homarr/definitions"; import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals"; +import { showErrorNotification } from "@homarr/notifications"; import { useSettings } from "@homarr/settings"; import { useI18n } from "@homarr/translation/client"; import { SelectGridLayout, selectGridCardHeight } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui"; -import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets"; -import { WidgetEditModal } from "@homarr/widgets/modals"; +import { widgetCatalogIcons } from "@homarr/widgets/catalog"; +import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; import { useItemActions } from "./item-actions"; +import { resolveMatchingIntegrationsAsync, tryLockSelection, unlockSelection } from "./item-select-data"; +import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal"; +import classes from "./item-select-modal.module.css"; -export const ItemSelectModal = createModal(({ actions }) => { +interface ItemSelectModalContentProps { + actions: { closeModal: () => void }; + innerProps: void; + integrationData: RouterOutputs["integration"]["all"] | undefined; + customWidgetDefs: RouterOutputs["customWidget"]["all"] | undefined; + ensureIntegrationDataAsync: () => Promise; +} + +const ItemSelectModalContent = ({ + actions, + integrationData, + customWidgetDefs, + ensureIntegrationDataAsync, +}: ItemSelectModalContentProps) => { const [search, setSearch] = useState(""); + const [loadingSelection, setLoadingSelection] = useState(null); + const selectionLock = useRef(false); const t = useI18n(); const { createItem, updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions(); - const { openModal: openEditModal } = useModalAction(WidgetEditModal); - const { data: integrationData } = clientApi.integration.all.useQuery(); - const { data: customWidgetDefs } = clientApi.customWidget.all.useQuery(); + const { openModal: openEditModal } = useModalAction(LazyWidgetEditModal); const settings = useSettings(); const availableKinds = useMemo(() => new Set((integrationData ?? []).map((i) => i.kind)), [integrationData]); const items = useMemo( () => - objectEntries(widgetImports) - .filter(([kind]) => kind !== "customApi") - .map(([kind, value]) => ({ - kind, - supportedIntegrations: - "supportedIntegrations" in value.definition - ? value.definition.supportedIntegrations.filter((integration) => integration !== "mock") - : [], - icon: value.definition.icon, - name: t(`widget.${kind}.name`), - description: t(`widget.${kind}.description`), - })) + widgetKinds + .filter((kind) => kind !== "customApi") + .map((kind) => { + return { + kind, + supportedIntegrations: (widgetIntegrationSupport[kind] ?? []).filter( + (integration) => integration !== "mock", + ), + icon: widgetCatalogIcons[kind], + name: t(`widget.${kind}.name`), + description: t(`widget.${kind}.description`), + }; + }) .sort((itemA, itemB) => { if (itemA.kind === "app") return -1; if (itemB.kind === "app") return 1; @@ -66,52 +85,81 @@ export const ItemSelectModal = createModal(({ actions }) => { [customWidgetDefs, search], ); - const handleAddCustomWidget = (definitionId: string) => { - const itemId = createId(); - const defaultOptions = reduceWidgetOptionsWithDefaultValues("customApi", settings); - createItem({ id: itemId, kind: "customApi", integrationIds: [] }); - updateItemOptions({ itemId, newOptions: { ...defaultOptions, definitionId } }); - actions.closeModal(); + const notifyDefinitionLoadError = (error: unknown) => { + showErrorNotification({ + title: t("common.error"), + message: error instanceof Error ? error.message : String(error), + }); }; - const handleAdd = (kind: WidgetKind) => { - const definition = widgetImports[kind].definition; - const hasIntegrationSupport = "supportedIntegrations" in definition; + const handleAddCustomWidget = async (definitionId: string) => { + if (!tryLockSelection(selectionLock)) return; + setLoadingSelection(`custom:${definitionId}`); + try { + const definition = await loadWidgetDefinition("customApi"); + const itemId = createId(); + const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings); + createItem({ id: itemId, kind: "customApi", integrationIds: [] }); + updateItemOptions({ itemId, newOptions: { ...defaultOptions, definitionId } }); + actions.closeModal(); + } catch (error) { + notifyDefinitionLoadError(error); + } finally { + unlockSelection(selectionLock); + setLoadingSelection(null); + } + }; - const matchingIntegrations = hasIntegrationSupport - ? (integrationData ?? []).filter((integration) => - (definition.supportedIntegrations as string[]).includes(integration.kind), - ) - : []; + const handleAdd = async (kind: WidgetKind) => { + if (!tryLockSelection(selectionLock)) return; + setLoadingSelection(kind); + preloadWidgetEditModal(); + try { + const definition = await loadWidgetDefinition(kind); + const hasIntegrationSupport = "supportedIntegrations" in definition; - const integrationIds = matchingIntegrations.map((i) => i.id); - const itemId = createId(); - const defaultOptions = reduceWidgetOptionsWithDefaultValues(kind, settings); + const matchingIntegrations = await resolveMatchingIntegrationsAsync({ + hasIntegrationSupport, + supportedIntegrations: definition.supportedIntegrations ?? [], + currentData: integrationData, + ensureDataAsync: ensureIntegrationDataAsync, + }); - createItem({ id: itemId, kind, integrationIds }); - actions.closeModal(); + const integrationIds = matchingIntegrations.map((i) => i.id); + const itemId = createId(); + const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings); - openEditModal( - { - kind, - value: { - advancedOptions: { title: null, customCssClasses: [], borderColor: "" }, - options: defaultOptions, - integrationIds, + createItem({ id: itemId, kind, integrationIds }); + actions.closeModal(); + + openEditModal( + { + kind, + definition, + value: { + advancedOptions: { title: null, customCssClasses: [], borderColor: "" }, + options: defaultOptions, + integrationIds, + }, + onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => { + updateItemOptions({ itemId, newOptions: options }); + updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions }); + updateItemIntegrations({ itemId, newIntegrations: newIntegrationIds }); + }, + integrationData: matchingIntegrations, + integrationSupport: hasIntegrationSupport, + settings, }, - onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => { - updateItemOptions({ itemId, newOptions: options }); - updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions }); - updateItemIntegrations({ itemId, newIntegrations: newIntegrationIds }); + { + title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`, }, - integrationData: matchingIntegrations, - integrationSupport: hasIntegrationSupport, - settings, - }, - { - title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`, - }, - ); + ); + } catch (error) { + notifyDefinitionLoadError(error); + } finally { + unlockSelection(selectionLock); + setLoadingSelection(null); + } }; return ( @@ -120,9 +168,9 @@ export const ItemSelectModal = createModal(({ actions }) => { onSearchChange={setSearch} placeholder={`${t("item.create.search")}...`} onSearchKeyDown={(event) => { - if (event.key === "Enter" && filteredItems.length === 1) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - handleAdd(filteredItems[0]!.kind); + if (event.key === "Enter" && loadingSelection === null && filteredItems.length === 1) { + const [item] = filteredItems; + if (item) void handleAdd(item.kind); } }} > @@ -130,7 +178,13 @@ export const ItemSelectModal = createModal(({ actions }) => { handleAdd(item.kind)} + onSelect={() => void handleAdd(item.kind)} + onIntent={() => { + void loadWidgetDefinition(item.kind).catch(() => undefined); + preloadWidgetEditModal(); + }} + disabled={loadingSelection !== null} + loading={loadingSelection === item.kind} hasMatchingIntegration={item.supportedIntegrations.some((kind) => availableKinds.has(kind))} /> ))} @@ -146,12 +200,12 @@ export const ItemSelectModal = createModal(({ actions }) => { {filteredCustomWidgets.map((def) => ( e.currentTarget.style.setProperty("--_hover-opacity", "1")} - onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")} + style={{ overflow: "hidden" }} + onPointerEnter={() => void loadWidgetDefinition("customApi").catch(() => undefined)} > @@ -169,18 +223,24 @@ export const ItemSelectModal = createModal(({ actions }) => { - @@ -196,7 +256,24 @@ export const ItemSelectModal = createModal(({ actions }) => { )} ); -}).withOptions({ +}; + +const ItemSelectModalFrame = (props: Pick) => { + const utils = clientApi.useUtils(); + const { data: integrationData } = clientApi.integration.all.useQuery(); + const { data: customWidgetDefs } = clientApi.customWidget.all.useQuery(); + + return ( + utils.integration.all.ensureData()} + /> + ); +}; + +export const ItemSelectModal = createModal((props) => ).withOptions({ defaultTitle: (t) => t("item.create.title"), size: modalSizeSelect, }); @@ -204,6 +281,9 @@ export const ItemSelectModal = createModal(({ actions }) => { const WidgetItem = ({ item, onSelect, + onIntent, + disabled, + loading, hasMatchingIntegration, }: { item: { @@ -214,23 +294,27 @@ const WidgetItem = ({ icon: TablerIcon; }; onSelect: () => void; + onIntent: () => void; + disabled: boolean; + loading: boolean; hasMatchingIntegration: boolean; }) => { const t = useI18n(); return ( e.currentTarget.style.setProperty("--_hover-opacity", "1")} - onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")} + onFocus={onIntent} + onPointerEnter={onIntent} + aria-busy={loading || undefined} > @@ -247,18 +331,17 @@ const WidgetItem = ({ - diff --git a/apps/nextjs/src/components/board/items/lazy-item-move-modal.tsx b/apps/nextjs/src/components/board/items/lazy-item-move-modal.tsx new file mode 100644 index 000000000..836017bc1 --- /dev/null +++ b/apps/nextjs/src/components/board/items/lazy-item-move-modal.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { Suspense, use } from "react"; +import { Center, Loader } from "@mantine/core"; +import { ErrorBoundary } from "react-error-boundary"; + +import { createModal, modalSizeForm } from "@homarr/modals"; +import { WidgetError } from "@homarr/widgets/errors"; + +import type { ItemMoveModalProps } from "./item-move-modal"; + +let itemMoveModalPromise: Promise | undefined; + +const loadItemMoveModal = () => { + if (itemMoveModalPromise) return itemMoveModalPromise; + + const currentPromise = import("./item-move-modal"); + itemMoveModalPromise = currentPromise; + void currentPromise.catch(() => { + if (itemMoveModalPromise === currentPromise) itemMoveModalPromise = undefined; + }); + return currentPromise; +}; + +export const preloadItemMoveModal = () => void loadItemMoveModal().catch(() => undefined); + +interface LazyItemMoveModalContentProps { + actions: { closeModal: () => void }; + innerProps: ItemMoveModalProps; +} + +const LazyItemMoveModalContent = (props: LazyItemMoveModalContentProps) => { + const { ItemMoveModal } = use(loadItemMoveModal()); + const Component = ItemMoveModal.component; + return ; +}; + +export const LazyItemMoveModal = createModal((props) => ( + ( +
+ { + itemMoveModalPromise = undefined; + resetErrorBoundary(); + }} + /> +
+ )} + > + + + + } + > + + +
+)).withOptions({ + defaultTitle(t) { + return t("item.moveResize.title"); + }, + size: modalSizeForm, +}); diff --git a/apps/nextjs/src/components/board/items/lazy-widget-edit-modal.tsx b/apps/nextjs/src/components/board/items/lazy-widget-edit-modal.tsx new file mode 100644 index 000000000..fb7a26bcd --- /dev/null +++ b/apps/nextjs/src/components/board/items/lazy-widget-edit-modal.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { Suspense, use } from "react"; +import { Center, Loader } from "@mantine/core"; +import { ErrorBoundary } from "react-error-boundary"; + +import type { WidgetKind } from "@homarr/definitions"; +import { createModal, modalSizeForm } from "@homarr/modals"; +import { WidgetError } from "@homarr/widgets/errors"; +import type { WidgetEditModalProps } from "@homarr/widgets/modals"; + +let widgetEditModalPromise: Promise | undefined; + +export const loadWidgetEditModal = () => { + if (widgetEditModalPromise) return widgetEditModalPromise; + + const currentPromise = import("@homarr/widgets/modals"); + widgetEditModalPromise = currentPromise; + void currentPromise.catch(() => { + if (widgetEditModalPromise === currentPromise) widgetEditModalPromise = undefined; + }); + return currentPromise; +}; + +export const preloadWidgetEditModal = () => void loadWidgetEditModal().catch(() => undefined); + +interface LazyWidgetEditModalContentProps { + actions: { closeModal: () => void }; + innerProps: WidgetEditModalProps; +} + +const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => { + const { WidgetEditModal } = use(loadWidgetEditModal()); + const Component = WidgetEditModal.component; + return ; +}; + +export const LazyWidgetEditModal = createModal>((props) => ( + ( +
+ { + widgetEditModalPromise = undefined; + resetErrorBoundary(); + }} + /> +
+ )} + > + + + + } + > + + +
+)).withOptions({ + keepMounted: true, + defaultTitle(t) { + return t("item.edit.title"); + }, + size: modalSizeForm, +}); diff --git a/apps/nextjs/src/components/board/items/widget-context-menu.tsx b/apps/nextjs/src/components/board/items/widget-context-menu.tsx index 8520d6b89..3b844874f 100644 --- a/apps/nextjs/src/components/board/items/widget-context-menu.tsx +++ b/apps/nextjs/src/components/board/items/widget-context-menu.tsx @@ -1,7 +1,7 @@ "use client"; import type { MutableRefObject, ReactNode } from "react"; -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, useState } from "react"; import { Group, Loader, Menu, Switch, Text, Tooltip } from "@mantine/core"; import { IconAlertTriangle, @@ -13,7 +13,7 @@ import { IconTrash, } from "@tabler/icons-react"; import type { QueryClient, QueryKey } from "@tanstack/react-query"; -import { useIsFetching, useQueryClient } from "@tanstack/react-query"; +import { partialMatchKey, useIsFetching, useQueryClient } from "@tanstack/react-query"; import { clientApi } from "@homarr/api/client"; import { useSession } from "@homarr/auth/client"; @@ -25,63 +25,116 @@ import { useSettings } from "@homarr/settings"; import { translateIfNecessary } from "@homarr/translation"; import { useI18n, useScopedI18n } from "@homarr/translation/client"; import type { TranslationFunction } from "@homarr/translation"; -import type { WidgetContextMenuAction } from "@homarr/widgets"; -import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets"; -import { WidgetEditModal } from "@homarr/widgets/modals"; +import type { WidgetContextMenuAction, WidgetDefinition } from "@homarr/widgets/definition"; +import { getWidgetQueryKeys } from "@homarr/widgets/definition"; +import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; import type { SectionItem } from "~/app/[locale]/boards/_types"; import { useSectionContext } from "../sections/section-context"; import { useItemActions } from "./item-actions"; -import { ItemMoveModal } from "./item-move-modal"; +import { LazyItemMoveModal, preloadItemMoveModal } from "./lazy-item-move-modal"; +import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal"; interface WidgetContextMenuProps { item: SectionItem; + definition: WidgetDefinition; widgetStateRef: MutableRefObject | null>; children: ReactNode; } -export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetContextMenuProps) => { +export const WidgetContextMenu = ({ item, definition, widgetStateRef, children }: WidgetContextMenuProps) => { const { data: session } = useSession(); + const settings = useSettings(); + + if (!session || !settings.enableRightClickOnWidgets) return <>{children}; + + return ( + + {children} + + ); +}; + +const WidgetContextMenuInner = ({ + item, + definition, + widgetStateRef, + children, + settings, +}: WidgetContextMenuProps & { settings: ReturnType }) => { + const [isMenuOpen, setIsMenuOpen] = useState(false); + + return ( + + {children} + + {isMenuOpen && ( + + )} + + + ); +}; + +type WidgetContextMenuDropdownProps = Omit & { + settings: ReturnType; +}; + +const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings }: WidgetContextMenuDropdownProps) => { const [isEditMode] = useEditMode(); const board = useRequiredBoard(); const tItem = useScopedI18n("item"); const tMenu = useScopedI18n("item.menu.label"); const t = useI18n(); - const settings = useSettings(); - const isRightClickEnabled = settings.enableRightClickOnWidgets; - const { openModal } = useModalAction(WidgetEditModal); - const { openModal: openMoveModal } = useModalAction(ItemMoveModal); + const hasSupportedIntegrations = + "supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) > 0; + const { openModal } = useModalAction(LazyWidgetEditModal); + const { openModal: openMoveModal } = useModalAction(LazyItemMoveModal); const { openConfirmModal } = useConfirmModal(); const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } = useItemActions(); - const { data: integrationData, isPending } = clientApi.integration.all.useQuery(); + const { data: integrationData, isPending } = clientApi.integration.all.useQuery(undefined, { + enabled: hasSupportedIntegrations, + }); const { mutate: saveBoard } = clientApi.board.saveBoard.useMutation(); - const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]); const { gridstack } = useSectionContext().refs; const queryClient = useQueryClient(); - const widgetQueryKey = useMemo( - () => ("queryKey" in currentDefinition ? (currentDefinition.queryKey as QueryKey) : [["widget", item.kind]]), - [currentDefinition, item.kind], - ); - const isWidgetFetching = useIsFetching({ queryKey: widgetQueryKey }) > 0; + const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(definition, item.kind), [definition, item.kind]); + const isWidgetFetching = + useIsFetching({ + predicate: (query) => widgetQueryKeys.some((queryKey) => partialMatchKey(query.queryKey, queryKey)), + }) > 0; const handleRefetch = useCallback(() => { - void queryClient.refetchQueries({ queryKey: widgetQueryKey, type: "all" }); - }, [queryClient, widgetQueryKey]); + void Promise.all(widgetQueryKeys.map((queryKey) => queryClient.refetchQueries({ queryKey, type: "all" }))); + }, [queryClient, widgetQueryKeys]); const options = useMemo( - () => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record, - [item.kind, settings, item.options], + () => reduceWidgetOptionsWithDefinition(definition, settings, item.options), + [definition, settings, item.options], ); type OptionDef = { type: string; skipContextMenu?: boolean }; const toggleOptions = useMemo(() => { - const rawOptions = currentDefinition.createOptions(settings) as unknown as Record; + const rawOptions = definition.createOptions(settings) as unknown as Record; return Object.entries(rawOptions).filter(([, def]) => def.type === "switch" && !def.skipContextMenu); - }, [currentDefinition, settings]); + }, [definition, settings]); const widgetContextActions = useMemo(() => { - const def = currentDefinition as Record; + const def = definition as unknown as Record; if (typeof def.contextActions !== "function") return []; // eslint-disable-next-line @typescript-eslint/no-explicit-any const actions = (def.contextActions as any)({ @@ -94,7 +147,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont widgetStateRef, }); return (Array.isArray(actions) ? actions : []) as WidgetContextMenuAction[]; - }, [currentDefinition, options, item, updateItemOptions, isEditMode, board.id, widgetStateRef]); + }, [definition, options, item, updateItemOptions, isEditMode, board.id, widgetStateRef]); const persistBoard = useCallback( (updatedItems: typeof board.items) => { @@ -107,6 +160,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont openModal( { kind: item.kind, + definition, value: { advancedOptions: item.advancedOptions, options: item.options, @@ -133,10 +187,10 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont }, integrationData: (integrationData ?? []).filter( (integration) => - "supportedIntegrations" in currentDefinition && - (currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind), + "supportedIntegrations" in definition && + (definition.supportedIntegrations as string[]).some((kind) => kind === integration.kind), ), - integrationSupport: "supportedIntegrations" in currentDefinition, + integrationSupport: "supportedIntegrations" in definition, settings, appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined, }, @@ -150,7 +204,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont updateItemAdvancedOptions, updateItemIntegrations, integrationData, - currentDefinition, + definition, settings, isEditMode, persistBoard, @@ -168,135 +222,133 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont [options, item.id, updateItemOptions, persistBoard, board], ); - if (!session) return <>{children}; - if (!isRightClickEnabled) return <>{children}; - const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden); return ( - - {children} - - {toggleOptions.length > 0 && ( - <> - {tMenu("options")} - {toggleOptions.map(([key]) => ( - handleToggle(key)(!options[key])}> - {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} - - {String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))} - - - - ))} - - )} - - {visibleWidgetActions.length > 0 && ( - <> - {toggleOptions.length > 0 && } - {tMenu("actions")} - {visibleWidgetActions.map((action) => { - const Icon = action.icon; - return ( - : undefined} - onClick={action.onClick} - disabled={action.disabled} - color={action.color} - > - {String(translateIfNecessary(t, action.label))} - - ); - })} - - )} - - {isEditMode && ( - <> - {(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && } - } - onClick={() => { - if (!gridstack.current) return; - openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current }); - }} - > - {tItem("action.moveResize")} - - } - onClick={() => duplicateItem({ itemId: item.id })} - > - {tItem("action.duplicate")} - - - )} - + <> + {toggleOptions.length > 0 && ( <> - {(toggleOptions.length > 0 || visibleWidgetActions.length > 0 || isEditMode) && } + {tMenu("options")} + {toggleOptions.map(([key]) => ( + handleToggle(key)(!options[key])}> + {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + + {String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))} + + + + ))} + + )} + + {visibleWidgetActions.length > 0 && ( + <> + {toggleOptions.length > 0 && } + {tMenu("actions")} + {visibleWidgetActions.map((action) => { + const Icon = action.icon; + return ( + : undefined} + onClick={action.onClick} + disabled={action.disabled} + color={action.color} + > + {String(translateIfNecessary(t, action.label))} + + ); + })} + + )} + + {isEditMode && ( + <> + {(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && } : } - onClick={handleRefetch} - disabled={isWidgetFetching} + closeMenuOnClick + leftSection={} + onFocus={preloadItemMoveModal} + onPointerEnter={preloadItemMoveModal} + onClick={() => { + if (!gridstack.current) return; + openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current }); + }} > - - {tMenu("refresh")} - - + {tItem("action.moveResize")} } - onClick={openEditModal} - disabled={isPending} + leftSection={} + onClick={() => duplicateItem({ itemId: item.id })} > - {tMenu("settings")} + {tItem("action.duplicate")} + )} - {isEditMode && ( - <> - - } - onClick={() => { - openConfirmModal({ - title: tItem("remove.title"), - children: tItem("remove.message"), - onConfirm: () => removeItem({ itemId: item.id }), - }); - }} - > - {tItem("action.remove")} - - - )} - - + <> + {(toggleOptions.length > 0 || visibleWidgetActions.length > 0 || isEditMode) && } + : } + onClick={handleRefetch} + disabled={isWidgetFetching} + > + + {tMenu("refresh")} + + + + } + onClick={openEditModal} + onFocus={preloadWidgetEditModal} + onPointerEnter={preloadWidgetEditModal} + disabled={hasSupportedIntegrations && isPending} + > + {tMenu("settings")} + + + + {isEditMode && ( + <> + + } + onClick={() => { + openConfirmModal({ + title: tItem("remove.title"), + children: tItem("remove.message"), + onConfirm: () => removeItem({ itemId: item.id }), + }); + }} + > + {tItem("action.remove")} + + + )} + ); }; interface WidgetQueryStatusProps { queryClient: QueryClient; - queryKey: QueryKey; + queryKeys: QueryKey[]; isFetching: boolean; t: TranslationFunction; } -const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQueryStatusProps) => { - const queries = queryClient.getQueryCache().findAll({ queryKey }); +const WidgetQueryStatus = ({ queryClient, queryKeys, isFetching, t }: WidgetQueryStatusProps) => { + const queries = [...new Set(queryKeys.flatMap((queryKey) => queryClient.getQueryCache().findAll({ queryKey })))]; const timestamps = queries.map((query) => query.state.dataUpdatedAt).filter(Boolean); const latest = timestamps.length > 0 ? Math.max(...timestamps) : 0; const ageLabel = useTimeAgo(new Date(latest)); @@ -334,7 +386,8 @@ const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQuery - {t("item.menu.status.error")} · {ageLabel} + {t("item.menu.status.error")} + {latest > 0 && ` · ${ageLabel}`} @@ -345,7 +398,8 @@ const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQuery - {t("item.menu.status.success")} · {ageLabel} + {t("item.menu.status.success")} + {latest > 0 && ` · ${ageLabel}`} ); diff --git a/apps/nextjs/src/components/board/items/widget-hover-metadata.ts b/apps/nextjs/src/components/board/items/widget-hover-metadata.ts deleted file mode 100644 index 22a75c9b3..000000000 --- a/apps/nextjs/src/components/board/items/widget-hover-metadata.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { objectEntries } from "@homarr/common"; -import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; -import type { SettingsContextProps } from "@homarr/settings/creator"; -import type { WidgetOptionDefinition, WidgetOptionType } from "@homarr/widgets"; -import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets"; - -import type { SectionItem } from "~/app/[locale]/boards/_types"; - -export type Translator = { - (key: string): string; -}; - -export interface HoverMetadataItem { - label: string; - value: string; -} - -const maxMetadataItems = 3; - -const skippedOptionTypes: Partial> = { - app: true, - anchorNote: true, - dynamicSelect: true, - sortableItemList: true, - multiReleasesRepositories: true, - umamiEventName: true, - umamiEventNames: true, - umamiWebsite: true, -}; - -const isEqualValue = (left: unknown, right: unknown) => JSON.stringify(left) === JSON.stringify(right); - -type OptionDefinitionWithRules = WidgetOptionDefinition & { - shouldHide?: (options: never, integrationKinds: IntegrationKind[]) => boolean; - options?: readonly { value: unknown; label: unknown }[]; -}; - -type OptionFormatter = (value: unknown, definition: OptionDefinitionWithRules, t: Translator) => string | null; - -const resolveSelectLabel = (value: unknown, definition: OptionDefinitionWithRules, t: Translator) => { - const match = definition.options?.find((option) => option.value === value); - const label = match?.label; - const fallbackLabel = () => String(value); - const labelResolvers: Record string> = { - function: () => (label as (translator: Translator) => string)(t), - string: () => label as string, - missing: fallbackLabel, - }; - const labelType = typeof label; - const resolveLabel: () => string = labelResolvers[labelType] ?? fallbackLabel; - return resolveLabel(); -}; - -const optionTypeFormatters: Partial> = { - switch: (value, _, t) => { - const switchLabels: Record = { - true: t("common.action.show"), - false: t("common.action.hide"), - }; - return switchLabels[String(Boolean(value))] ?? null; - }, - text: (value) => { - const text = String(value).trim(); - return text.length > 0 ? text : null; - }, - number: (value) => String(value), - slider: (value) => String(value), - select: (value, definition, t) => resolveSelectLabel(value, definition, t), - multiSelect: (value) => { - const items = value as unknown[]; - return items.length > 0 ? String(items.length) : null; - }, - multiText: (value) => { - const items = (value as string[]).filter((entry) => entry.trim().length > 0); - return items.length > 0 ? items.join(", ") : null; - }, - location: (value) => { - const location = value as { name?: string }; - const name = location.name?.trim() ?? ""; - return name.length > 0 ? name : null; - }, -}; - -const formatOptionValue = ( - type: WidgetOptionType, - value: unknown, - definition: OptionDefinitionWithRules, - t: Translator, -) => { - const formatter = optionTypeFormatters[type]; - return formatter?.(value, definition, t) ?? null; -}; - -const getOptionLabel = (kind: WidgetKind, key: string, t: Translator) => { - const labelKey = `widget.${kind}.option.${key}.label`; - const translated = t(labelKey); - return translated === labelKey ? key : translated; -}; - -export const collectWidgetHoverMetadata = ( - item: SectionItem, - settings: Pick, - integrationKinds: IntegrationKind[], - t: Translator, -): HoverMetadataItem[] => { - const { definition } = widgetImports[item.kind]; - const optionDefinitions = definition.createOptions(settings) as Record; - const resolvedOptions = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options); - - return objectEntries(optionDefinitions) - .filter(([key, optionDefinition]) => { - const skip = skippedOptionTypes[optionDefinition.type]; - const hidden = optionDefinition.shouldHide?.(resolvedOptions as never, integrationKinds); - const unchanged = isEqualValue(resolvedOptions[key], optionDefinition.defaultValue); - return !skip && !hidden && !unchanged; - }) - .map(([key, optionDefinition]) => { - const formattedValue = formatOptionValue(optionDefinition.type, resolvedOptions[key], optionDefinition, t); - return { - label: getOptionLabel(item.kind, key, t), - value: formattedValue ?? "", - }; - }) - .filter((entry) => entry.value.length > 0) - .slice(0, maxMetadataItems); -}; - -interface DisplayNameSource { - matches: (item: SectionItem, options: Record) => boolean; - resolve: (item: SectionItem, options: Record) => string; -} - -const displayNameSources: DisplayNameSource[] = [ - { - matches: (item) => Boolean(item.advancedOptions.title?.trim()), - resolve: (item) => item.advancedOptions.title?.trim() ?? "", - }, - { - matches: (item, options) => - item.kind === "clock" && - Boolean(options.customTitleToggle) && - String(options.customTitle ?? "").trim().length > 0, - resolve: (_, options) => String(options.customTitle).trim(), - }, -]; - -export const resolveWidgetDisplayName = ( - item: SectionItem, - settings: Pick, - t: Translator, -) => { - const resolvedOptions = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options); - const source = displayNameSources.find((entry) => entry.matches(item, resolvedOptions)); - return source?.resolve(item, resolvedOptions) ?? t(`widget.${item.kind}.name`); -}; diff --git a/apps/nextjs/src/components/board/items/widget-hover-overlay.tsx b/apps/nextjs/src/components/board/items/widget-hover-overlay.tsx index 6fcdc4a93..6529eed12 100644 --- a/apps/nextjs/src/components/board/items/widget-hover-overlay.tsx +++ b/apps/nextjs/src/components/board/items/widget-hover-overlay.tsx @@ -1,12 +1,13 @@ "use client"; +import { Suspense, use } from "react"; import { Avatar, Text, Tooltip } from "@mantine/core"; import type { RouterOutputs } from "@homarr/api"; import { useEditMode } from "@homarr/boards/edit-mode"; import { getIconUrl } from "@homarr/definitions"; import { useI18n } from "@homarr/translation/client"; -import { widgetImports } from "@homarr/widgets"; +import { loadWidgetDefinition } from "@homarr/widgets/manifest"; import type { SectionItem } from "~/app/[locale]/boards/_types"; import classes from "./widget-hover-overlay.module.css"; @@ -18,8 +19,19 @@ interface WidgetHoverOverlayProps { export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProps) => { const [isEditMode] = useEditMode(); + + if (!isEditMode) return null; + + return ( + + + + ); +}; + +const LoadedWidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProps) => { const t = useI18n(); - const { definition } = widgetImports[item.kind]; + const definition = use(loadWidgetDefinition(item.kind)); const WidgetIcon = definition.icon; const displayName = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`); @@ -28,10 +40,6 @@ export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayPro item.integrationIds.includes(integration.id), ); - if (!isEditMode) { - return null; - } - return (
diff --git a/apps/nextjs/src/components/board/items/widget-query-recovery.spec.ts b/apps/nextjs/src/components/board/items/widget-query-recovery.spec.ts index b666df4e9..dabb464f3 100644 --- a/apps/nextjs/src/components/board/items/widget-query-recovery.spec.ts +++ b/apps/nextjs/src/components/board/items/widget-query-recovery.spec.ts @@ -1,27 +1,36 @@ import { QueryClient } from "@tanstack/react-query"; import { describe, expect, test } from "vitest"; -import { removePersistedWidgetQueries } from "./widget-query-recovery"; +import { removeWidgetDataQueries } from "./widget-query-recovery"; -describe("removePersistedWidgetQueries", () => { - test("removes stale widget queries and allows fresh data to be fetched", async () => { +describe("removeWidgetDataQueries", () => { + test("removes only the failed widget prefix and allows fresh data to be fetched", async () => { const queryClient = new QueryClient(); const crashedWidgetQueryKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }]; + const sameWidgetQueryKey = [["widget", "mediaServer", "getLibraries"], { input: {} }]; + const unrelatedWidgetQueryKey = [["widget", "calendar", "getEvents"], { input: {} }]; + const appQueryKey = [["app", "byId"], { input: {} }]; const unrelatedQueryKey = [["user", "me"], { input: {} }]; queryClient.setQueryData(crashedWidgetQueryKey, "stale"); + queryClient.setQueryData(sameWidgetQueryKey, "related"); + queryClient.setQueryData(unrelatedWidgetQueryKey, "other-widget"); + queryClient.setQueryData(appQueryKey, "app"); queryClient.setQueryData(unrelatedQueryKey, "unrelated"); - removePersistedWidgetQueries(queryClient); + removeWidgetDataQueries(queryClient, [[["widget", "mediaServer"]]]); expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined(); + expect(queryClient.getQueryData(sameWidgetQueryKey)).toBeUndefined(); + expect(queryClient.getQueryData(unrelatedWidgetQueryKey)).toBe("other-widget"); + expect(queryClient.getQueryData(appQueryKey)).toBe("app"); expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated"); await expect( queryClient.fetchQuery({ queryKey: crashedWidgetQueryKey, queryFn: () => Promise.resolve("fresh") }), ).resolves.toBe("fresh"); }); - test("removes persisted queries outside the widget router", () => { + test("uses an explicit supporting query prefix without purging other supporting data", () => { const queryClient = new QueryClient(); const dockerQueryKey = [["docker", "getContainers"], { input: {} }]; const appQueryKey = [["app", "byId"], { input: {} }]; @@ -31,14 +40,14 @@ describe("removePersistedWidgetQueries", () => { queryClient.setQueryData(appQueryKey, "stale"); queryClient.setQueryData(unrelatedQueryKey, "unrelated"); - removePersistedWidgetQueries(queryClient); + removeWidgetDataQueries(queryClient, [[["docker", "getContainers"]]]); expect(queryClient.getQueryData(dockerQueryKey)).toBeUndefined(); - expect(queryClient.getQueryData(appQueryKey)).toBeUndefined(); + expect(queryClient.getQueryData(appQueryKey)).toBe("stale"); expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated"); }); - test("removes persisted widget queries when the widget kind and query path differ", () => { + test("uses the definition query prefix when the widget kind and query path differ", () => { const queryClient = new QueryClient(); const crashedWidgetQueryKey = [["widget", "anchorNotes", "getNote"], { input: {} }]; const unrelatedQueryKey = [["user", "me"], { input: {} }]; @@ -46,7 +55,7 @@ describe("removePersistedWidgetQueries", () => { queryClient.setQueryData(crashedWidgetQueryKey, "stale"); queryClient.setQueryData(unrelatedQueryKey, "unrelated"); - removePersistedWidgetQueries(queryClient); + removeWidgetDataQueries(queryClient, [[["widget", "anchorNotes"]]]); expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined(); expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated"); diff --git a/apps/nextjs/src/components/board/items/widget-query-recovery.ts b/apps/nextjs/src/components/board/items/widget-query-recovery.ts index 800614bf4..9bb0b81c5 100644 --- a/apps/nextjs/src/components/board/items/widget-query-recovery.ts +++ b/apps/nextjs/src/components/board/items/widget-query-recovery.ts @@ -1,7 +1,5 @@ -import type { QueryClient } from "@tanstack/react-query"; +import type { QueryClient, QueryKey } from "@tanstack/react-query"; -import { isPersistableWidgetQueryKey } from "@homarr/api/query-cache"; - -export const removePersistedWidgetQueries = (queryClient: QueryClient) => { - queryClient.removeQueries({ predicate: (query) => isPersistableWidgetQueryKey(query.queryKey) }); +export const removeWidgetDataQueries = (queryClient: QueryClient, queryKeys: QueryKey[]) => { + queryKeys.forEach((queryKey) => queryClient.removeQueries({ queryKey })); }; diff --git a/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx b/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx index 906abdea6..894bcbad6 100644 --- a/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx +++ b/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx @@ -105,12 +105,14 @@ export const useCategoryMenuActions = (category: CategorySection) => { const settings = useSettings(); const openAllInNewTabs = useCallback(async () => { const currentLayoutId = getCurrentLayout(board); - const appIds = filterByItemKind( - board.items.filter( - (item) => item.layouts.find((layout) => layout.layoutId === currentLayoutId)?.sectionId === category.id, - ), - settings, - "app", + const appIds = ( + await filterByItemKind( + board.items.filter( + (item) => item.layouts.find((layout) => layout.layoutId === currentLayoutId)?.sectionId === category.id, + ), + settings, + "app", + ) ).map((item) => { return item.options.appId; }); diff --git a/apps/nextjs/src/components/board/sections/category/filter.ts b/apps/nextjs/src/components/board/sections/category/filter.ts index 72bb4443f..3866a0075 100644 --- a/apps/nextjs/src/components/board/sections/category/filter.ts +++ b/apps/nextjs/src/components/board/sections/category/filter.ts @@ -1,21 +1,23 @@ import type { WidgetKind } from "@homarr/definitions"; import type { SettingsContextProps } from "@homarr/settings/creator"; -import type { WidgetComponentProps } from "@homarr/widgets"; -import { reduceWidgetOptionsWithDefaultValues } from "@homarr/widgets"; +import type { WidgetComponentProps } from "@homarr/widgets/definition"; +import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; import type { Item } from "~/app/[locale]/boards/_types"; -export const filterByItemKind = ( +export const filterByItemKind = async ( items: Item[], settings: SettingsContextProps, kind: TKind, ) => { + const definition = await loadWidgetDefinition(kind); + return items .filter((item) => item.kind === kind) .map((item) => ({ ...item, - options: reduceWidgetOptionsWithDefaultValues( - kind, + options: reduceWidgetOptionsWithDefinition( + definition, settings, item.options, ) as WidgetComponentProps["options"], diff --git a/apps/nextjs/src/components/board/sections/content.tsx b/apps/nextjs/src/components/board/sections/content.tsx index ced30c796..aa3b5adc2 100644 --- a/apps/nextjs/src/components/board/sections/content.tsx +++ b/apps/nextjs/src/components/board/sections/content.tsx @@ -4,6 +4,8 @@ import type { GridItemHTMLElement } from "@homarr/gridstack"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; +import { useSession } from "@homarr/auth/client"; +import { useEditMode } from "@homarr/boards/edit-mode"; import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types"; import { BoardItemContent } from "../items/item-content"; @@ -15,7 +17,11 @@ import { useSectionItems } from "./use-section-items"; export const SectionContent = () => { const { innerSections, items, refs } = useSectionContext(); - const { data: integrations } = clientApi.integration.all.useQuery(); + const { data: session } = useSession(); + const [isEditMode] = useEditMode(); + const { data: integrations } = clientApi.integration.all.useQuery(undefined, { + enabled: Boolean(session) && isEditMode, + }); /** * IMPORTANT: THE ORDER OF THE BELOW ITEMS HAS TO MATCH THE ORDER OF diff --git a/apps/nextjs/src/components/integration/integration-grid-shared.ts b/apps/nextjs/src/components/integration/integration-grid-shared.ts index 7451af1e7..03056c130 100644 --- a/apps/nextjs/src/components/integration/integration-grid-shared.ts +++ b/apps/nextjs/src/components/integration/integration-grid-shared.ts @@ -1,4 +1,3 @@ -import { objectEntries } from "@homarr/common"; import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; import { featuredIntegrations, @@ -6,30 +5,8 @@ import { hiddenFromOnboarding, integrationDefs, integrationKinds, + getWidgetKindsForIntegration, } from "@homarr/definitions"; -import { widgetImports } from "@homarr/widgets"; - -const buildWidgetsByIntegration = () => { - const map = Object.fromEntries(integrationKinds.map((kind) => [kind, [] as WidgetKind[]])) as Record< - IntegrationKind, - WidgetKind[] - >; - - for (const [widgetKind, widget] of objectEntries(widgetImports)) { - const supported = - "supportedIntegrations" in widget.definition - ? (widget.definition.supportedIntegrations as IntegrationKind[]) - : []; - for (const kind of supported) { - if (kind in map) { - map[kind].push(widgetKind); - } - } - } - return map; -}; - -export const widgetsByIntegration = buildWidgetsByIntegration(); export const categoryTranslationKeys: Record = { dnsHole: "integration.category.dnsHole", @@ -70,11 +47,11 @@ export interface IntegrationGridItem { export const buildSortedIntegrations = ( options: { enableMockIntegration?: boolean; onboarding?: boolean } = {}, -): IntegrationGridItem[] => - integrationKinds +): IntegrationGridItem[] => { + return integrationKinds .filter((kind) => { if (options.onboarding && hiddenFromOnboarding.has(kind)) return false; - if (options.onboarding && widgetsByIntegration[kind].length === 0) return false; + if (options.onboarding && getWidgetKindsForIntegration(kind).length === 0) return false; if (!options.enableMockIntegration && kind === "mock") return false; return true; }) @@ -82,9 +59,9 @@ export const buildSortedIntegrations = ( kind, name: getIntegrationName(kind), categories: [...new Set(integrationDefs[kind].category.flat())] as string[], - widgets: widgetsByIntegration[kind], + widgets: getWidgetKindsForIntegration(kind), })) - .sort((left, right) => { + .toSorted((left, right) => { const leftIdx = featuredIntegrations.indexOf(left.kind); const rightIdx = featuredIntegrations.indexOf(right.kind); if (leftIdx !== -1 && rightIdx !== -1) return leftIdx - rightIdx; @@ -92,6 +69,7 @@ export const buildSortedIntegrations = ( if (rightIdx !== -1) return 1; return right.widgets.length - left.widgets.length || left.name.localeCompare(right.name); }); +}; export const filterIntegrations = (items: IntegrationGridItem[], search: string) => items.filter((i) => i.name.toLowerCase().includes(search.toLowerCase().trim())); diff --git a/apps/nextjs/src/components/layout/header.tsx b/apps/nextjs/src/components/layout/header.tsx index e9d93f7fc..2c46608c0 100644 --- a/apps/nextjs/src/components/layout/header.tsx +++ b/apps/nextjs/src/components/layout/header.tsx @@ -1,7 +1,6 @@ import type { ReactNode } from "react"; import { AppShellHeader, Group, UnstyledButton } from "@mantine/core"; -import { Spotlight } from "@homarr/spotlight"; import { Link } from "@homarr/ui"; import { ClientBurger } from "./header/burger"; @@ -9,6 +8,7 @@ import { DesktopSearchInput, MobileSearchButton } from "./header/search"; import { TourTarget } from "./header/tour-target"; import { UserButton } from "./header/user"; import { HomarrLogoWithTitle } from "./logo/homarr-logo"; +import { LazySpotlight } from "./header/lazy-spotlight"; interface Props { logo?: ReactNode; @@ -37,7 +37,7 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => { - + ); }; diff --git a/apps/nextjs/src/components/layout/header/button.tsx b/apps/nextjs/src/components/layout/header/button.tsx index e8d858b6a..27d48445d 100644 --- a/apps/nextjs/src/components/layout/header/button.tsx +++ b/apps/nextjs/src/components/layout/header/button.tsx @@ -14,6 +14,9 @@ type HeaderButtonProps = ( } ) & { children: ReactNode; + onFocus?: () => void; + onPointerDown?: () => void; + onPointerEnter?: () => void; } & Partial; const headerButtonActionIconProps: ActionIconProps = { diff --git a/apps/nextjs/src/components/layout/header/lazy-spotlight.tsx b/apps/nextjs/src/components/layout/header/lazy-spotlight.tsx new file mode 100644 index 000000000..958c0d537 --- /dev/null +++ b/apps/nextjs/src/components/layout/header/lazy-spotlight.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useEffect, useState } from "react"; +import dynamic from "next/dynamic"; +import { Center, Loader, Paper } from "@mantine/core"; +import { useHotkeys } from "@mantine/hooks"; + +import { hotkeys } from "@homarr/definitions"; +import { hasPendingSpotlightOpen, openSpotlight, spotlightOpenEvent } from "@homarr/spotlight/open"; + +const loadSpotlight = () => import("@homarr/spotlight/component"); +const SpotlightLoading = () => ( +
+ + + +
+); +const Spotlight = dynamic(() => loadSpotlight().then(({ Spotlight: Component }) => Component), { + ssr: false, + loading: SpotlightLoading, +}); + +export const preloadSpotlight = () => void loadSpotlight().catch(() => undefined); + +export const LazySpotlight = () => { + const [isMounted, setIsMounted] = useState(false); + const [isPreloaded, setIsPreloaded] = useState(false); + + useHotkeys( + isMounted + ? [] + : [ + [ + hotkeys.openSpotlight, + () => { + setIsMounted(true); + openSpotlight(); + }, + ], + ], + ); + + useEffect(() => { + let isActive = true; + const mount = () => setIsMounted(true); + window.addEventListener(spotlightOpenEvent, mount); + if (hasPendingSpotlightOpen()) mount(); + + const preload = () => { + void loadSpotlight().then( + () => { + if (isActive) setIsPreloaded(true); + }, + () => undefined, + ); + }; + let idleId: number | undefined; + const delayId = window.setTimeout(() => { + idleId = window.requestIdleCallback?.(preload, { timeout: 1_000 }); + if (idleId === undefined) preload(); + }, 1_000); + return () => { + isActive = false; + window.removeEventListener(spotlightOpenEvent, mount); + window.clearTimeout(delayId); + if (idleId !== undefined) window.cancelIdleCallback?.(idleId); + }; + }, []); + + return isMounted || isPreloaded ? ( + <> + {isPreloaded &&