Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d3ae23660 | ||
|
|
8d4bf04452 | ||
|
|
65b725e1bb | ||
|
|
5e631b2f80 | ||
|
|
0b7b0b9650 | ||
|
|
0c59c7572f | ||
|
|
6dc1ee4536 | ||
|
|
f9e62a4932 |
No files matched your search
@@ -63,9 +63,9 @@ When you connect a service from a board, Homarr keeps you in the same setup flow
|
|||||||
confirms whether an application shortcut was linked and offers the widgets that support the new integration. Selecting a
|
confirms whether an application shortcut was linked and offers the widgets that support the new integration. Selecting a
|
||||||
widget returns to its settings with the new integration already selected.
|
widget returns to its settings with the new integration already selected.
|
||||||
|
|
||||||
Users with full access can also test and update a selected integration directly in the **Edit widget → Edit integration**
|
Users with full access can also test and update a selected integration from **Edit widget → Edit integration** on a
|
||||||
tab on a board. Failed tests keep the editor open and show the same connection diagnostics as the management page.
|
board. Failed tests keep the editor open and show the same connection diagnostics as the management page. Successful
|
||||||
Successful changes apply to every app and widget that uses the integration.
|
changes apply to every app and widget that uses the integration.
|
||||||
|
|
||||||
When app creation is not permitted, the setup form offers linking an existing accessible app instead of attempting to
|
When app creation is not permitted, the setup form offers linking an existing accessible app instead of attempting to
|
||||||
create one.
|
create one.
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { plexIntegration } from "@site/docs/integrations/plex";
|
|||||||
<WidgetHeader widget={mediaServerWidget} categories={["Media server"]} />
|
<WidgetHeader widget={mediaServerWidget} categories={["Media server"]} />
|
||||||
|
|
||||||
The media server widget shows active sessions across your media servers.
|
The media server widget shows active sessions across your media servers.
|
||||||
It shows playback progress, pause state, time remaining, the streaming resolution (e.g. `1080p`), and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently.
|
It shows playback progress, pause state, time remaining, and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently.
|
||||||
|
|
||||||
Select a session to inspect its media metadata in an anchored popover. Advanced mode adds search, sorting, location, bitrate, and a summary of playing and transcoding sessions while keeping separate integration instances identifiable. Compact visibility switches do not hide location or bitrate from the complete advanced session view.
|
Select a session to inspect its media metadata in an anchored popover. Advanced mode adds search, sorting, location, bitrate, and a summary of playing and transcoding sessions while keeping separate integration instances identifiable. Compact visibility switches do not hide location or bitrate from the complete advanced session view.
|
||||||
If one selected server is unavailable, a warning indicator identifies the degraded result while sessions from healthy servers remain usable.
|
If one selected server is unavailable, a warning indicator identifies the degraded result while sessions from healthy servers remain usable.
|
||||||
|
|||||||
+120
-156
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useImperativeHandle, useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Alert, Anchor, Button, ButtonGroup, Fieldset, Group, Stack, Text, TextInput } from "@mantine/core";
|
import { Alert, Anchor, Button, ButtonGroup, Fieldset, Group, Stack, Text, TextInput } from "@mantine/core";
|
||||||
import { IconInfoCircle, IconPencil, IconPlus, IconUnlink } from "@tabler/icons-react";
|
import { IconInfoCircle, IconPencil, IconPlus, IconUnlink } from "@tabler/icons-react";
|
||||||
@@ -27,14 +27,8 @@ import type { AnyMappedTestConnectionError } from "../../_components/test-connec
|
|||||||
|
|
||||||
interface EditIntegrationFormProps {
|
interface EditIntegrationFormProps {
|
||||||
integration: RouterOutputs["integration"]["byId"];
|
integration: RouterOutputs["integration"]["byId"];
|
||||||
hideButtons?: boolean;
|
embedded?: boolean;
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
formRef?: React.Ref<EditIntegrationFormHandle>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EditIntegrationFormHandle {
|
|
||||||
submit: () => Promise<boolean>;
|
|
||||||
isDirty: () => boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const formSchema = integrationUpdateSchema.omit({ id: true, appId: true }).and(
|
const formSchema = integrationUpdateSchema.omit({ id: true, appId: true }).and(
|
||||||
@@ -50,12 +44,7 @@ const formSchema = integrationUpdateSchema.omit({ id: true, appId: true }).and(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const EditIntegrationForm = ({
|
export const EditIntegrationForm = ({ integration, embedded = false, onSuccess }: EditIntegrationFormProps) => {
|
||||||
integration,
|
|
||||||
hideButtons = false,
|
|
||||||
onSuccess,
|
|
||||||
formRef,
|
|
||||||
}: EditIntegrationFormProps) => {
|
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { openConfirmModal } = useConfirmModal();
|
const { openConfirmModal } = useConfirmModal();
|
||||||
const allSecretKinds = getAllSecretKindOptions(integration.kind);
|
const allSecretKinds = getAllSecretKindOptions(integration.kind);
|
||||||
@@ -93,172 +82,147 @@ export const EditIntegrationForm = ({
|
|||||||
|
|
||||||
const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret]));
|
const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret]));
|
||||||
|
|
||||||
const handleSubmitAsync = useCallback(
|
const handleSubmitAsync = async ({ app, ...values }: FormType) => {
|
||||||
async ({ app, ...values }: FormType) => {
|
setError(null);
|
||||||
setError(null);
|
let url: string;
|
||||||
let url: string;
|
try {
|
||||||
try {
|
url = hasUrlSecret
|
||||||
url = hasUrlSecret
|
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
|
||||||
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
|
: values.url;
|
||||||
: values.url;
|
} catch {
|
||||||
} catch {
|
showErrorNotification({
|
||||||
showErrorNotification({
|
title: t("integration.page.edit.notification.error.title"),
|
||||||
title: t("integration.page.edit.notification.error.title"),
|
message: t("integration.page.edit.notification.error.message"),
|
||||||
message: t("integration.page.edit.notification.error.message"),
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await mutateAsync({
|
|
||||||
id: integration.id,
|
|
||||||
...values,
|
|
||||||
url,
|
|
||||||
secrets: values.secrets.map((secret) => ({
|
|
||||||
kind: secret.kind,
|
|
||||||
value: secret.value === "" ? null : secret.value,
|
|
||||||
})),
|
|
||||||
appId: app?.id ?? null,
|
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// We do it this way as we are unable to send a typesafe error through onError
|
const data = await mutateAsync({
|
||||||
if (data?.error) {
|
id: integration.id,
|
||||||
setError(data.error);
|
...values,
|
||||||
showErrorNotification({
|
url,
|
||||||
title: t("integration.page.edit.notification.error.title"),
|
secrets: values.secrets.map((secret) => ({
|
||||||
message: t("integration.page.edit.notification.error.message"),
|
kind: secret.kind,
|
||||||
});
|
value: secret.value === "" ? null : secret.value,
|
||||||
requestAnimationFrame(() => errorRef.current?.focus());
|
})),
|
||||||
return false;
|
appId: app?.id ?? null,
|
||||||
}
|
});
|
||||||
|
|
||||||
showSuccessNotification({
|
// We do it this way as we are unable to send a typesafe error through onError
|
||||||
title: t("integration.page.edit.notification.success.title"),
|
if (data?.error) {
|
||||||
message: t("integration.page.edit.notification.success.message"),
|
setError(data.error);
|
||||||
|
showErrorNotification({
|
||||||
|
title: t("integration.page.edit.notification.error.title"),
|
||||||
|
message: t("integration.page.edit.notification.error.message"),
|
||||||
});
|
});
|
||||||
void Promise.allSettled([utils.integration.invalidate(), utils.widget.invalidate()]);
|
requestAnimationFrame(() => errorRef.current?.focus());
|
||||||
onSuccess?.();
|
return;
|
||||||
if (!hideButtons) {
|
}
|
||||||
void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
[hasUrlSecret, hideButtons, integration.id, mutateAsync, onSuccess, router, t, utils.integration, utils.widget],
|
|
||||||
);
|
|
||||||
|
|
||||||
useImperativeHandle(
|
showSuccessNotification({
|
||||||
formRef,
|
title: t("integration.page.edit.notification.success.title"),
|
||||||
() => ({
|
message: t("integration.page.edit.notification.success.message"),
|
||||||
submit: () =>
|
});
|
||||||
new Promise<boolean>((resolve) => {
|
void Promise.allSettled([utils.integration.invalidate(), utils.widget.invalidate()]);
|
||||||
form.onSubmit(
|
onSuccess?.();
|
||||||
async (values) => {
|
if (!embedded) {
|
||||||
try {
|
void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
|
||||||
resolve(await handleSubmitAsync(values));
|
}
|
||||||
} catch {
|
};
|
||||||
resolve(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
() => resolve(false),
|
|
||||||
)();
|
|
||||||
}),
|
|
||||||
isDirty: () => form.isDirty(),
|
|
||||||
}),
|
|
||||||
[form, handleSubmitAsync],
|
|
||||||
);
|
|
||||||
|
|
||||||
const isInitialSecretKinds =
|
const isInitialSecretKinds =
|
||||||
initialSecretsKinds.every((kind) => form.values.secrets.some((secret) => secret.kind === kind)) &&
|
initialSecretsKinds.every((kind) => form.values.secrets.some((secret) => secret.kind === kind)) &&
|
||||||
form.values.secrets.length === initialSecretsKinds.length;
|
form.values.secrets.length === initialSecretsKinds.length;
|
||||||
|
|
||||||
const formFields = (
|
return (
|
||||||
<Stack>
|
<form onSubmit={form.onSubmit(async (values) => await handleSubmitAsync(values))}>
|
||||||
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
|
<Stack>
|
||||||
|
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
|
||||||
|
|
||||||
{hasUrlSecret ? null : (
|
{hasUrlSecret ? null : (
|
||||||
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
|
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Fieldset legend={t("integration.secrets.title")}>
|
<Fieldset legend={t("integration.secrets.title")}>
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
{allSecretKinds.length > 1 && (
|
{allSecretKinds.length > 1 && (
|
||||||
<SecretKindsSegmentedControl defaultKinds={initialSecretsKinds} secretKinds={allSecretKinds} form={form} />
|
<SecretKindsSegmentedControl
|
||||||
)}
|
defaultKinds={initialSecretsKinds}
|
||||||
{!isInitialSecretKinds
|
secretKinds={allSecretKinds}
|
||||||
? null
|
form={form}
|
||||||
: form.values.secrets.map((secret, index) => (
|
/>
|
||||||
<SecretCard
|
)}
|
||||||
key={secret.kind}
|
{!isInitialSecretKinds
|
||||||
secret={secretsMap.get(secret.kind) ?? { kind: secret.kind, value: null, updatedAt: null }}
|
? null
|
||||||
onCancel={() =>
|
: form.values.secrets.map((secret, index) => (
|
||||||
new Promise((resolve) => {
|
<SecretCard
|
||||||
// When nothing changed, just close the secret card
|
|
||||||
if ((secret.value ?? "") === (secretsMap.get(secret.kind)?.value ?? "")) {
|
|
||||||
return resolve(true);
|
|
||||||
}
|
|
||||||
openConfirmModal({
|
|
||||||
title: t("integration.secrets.reset.title"),
|
|
||||||
children: t("integration.secrets.reset.message"),
|
|
||||||
onCancel: () => resolve(false),
|
|
||||||
onConfirm: () => {
|
|
||||||
form.setFieldValue(`secrets.${index}.value`, secretsMap.get(secret.kind)?.value ?? "");
|
|
||||||
resolve(true);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<IntegrationSecretInput
|
|
||||||
label={t(`integration.secrets.kind.${secret.kind}.newLabel`)}
|
|
||||||
key={secret.kind}
|
key={secret.kind}
|
||||||
kind={secret.kind}
|
secret={secretsMap.get(secret.kind) ?? { kind: secret.kind, value: null, updatedAt: null }}
|
||||||
|
onCancel={() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
// When nothing changed, just close the secret card
|
||||||
|
if ((secret.value ?? "") === (secretsMap.get(secret.kind)?.value ?? "")) {
|
||||||
|
return resolve(true);
|
||||||
|
}
|
||||||
|
openConfirmModal({
|
||||||
|
title: t("integration.secrets.reset.title"),
|
||||||
|
children: t("integration.secrets.reset.message"),
|
||||||
|
onCancel: () => resolve(false),
|
||||||
|
onConfirm: () => {
|
||||||
|
form.setFieldValue(`secrets.${index}.value`, secretsMap.get(secret.kind)?.value ?? "");
|
||||||
|
resolve(true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IntegrationSecretInput
|
||||||
|
label={t(`integration.secrets.kind.${secret.kind}.newLabel`)}
|
||||||
|
key={secret.kind}
|
||||||
|
kind={secret.kind}
|
||||||
|
{...form.getInputProps(`secrets.${index}.value`)}
|
||||||
|
/>
|
||||||
|
</SecretCard>
|
||||||
|
))}
|
||||||
|
{isInitialSecretKinds
|
||||||
|
? null
|
||||||
|
: form.values.secrets.map(({ kind }, index) => (
|
||||||
|
<IntegrationSecretInput
|
||||||
|
withAsterisk
|
||||||
|
key={kind}
|
||||||
|
kind={kind}
|
||||||
{...form.getInputProps(`secrets.${index}.value`)}
|
{...form.getInputProps(`secrets.${index}.value`)}
|
||||||
/>
|
/>
|
||||||
</SecretCard>
|
))}
|
||||||
))}
|
{form.values.secrets.length === 0 && (
|
||||||
{isInitialSecretKinds
|
<Alert icon={<IconInfoCircle size={"1rem"} />} color={"blue"}>
|
||||||
? null
|
<Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text>
|
||||||
: form.values.secrets.map(({ kind }, index) => (
|
</Alert>
|
||||||
<IntegrationSecretInput
|
)}
|
||||||
withAsterisk
|
</Stack>
|
||||||
key={kind}
|
</Fieldset>
|
||||||
kind={kind}
|
|
||||||
{...form.getInputProps(`secrets.${index}.value`)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{form.values.secrets.length === 0 && (
|
|
||||||
<Alert icon={<IconInfoCircle size={"1rem"} />} color={"blue"}>
|
|
||||||
<Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Fieldset>
|
|
||||||
|
|
||||||
<IntegrationLinkApp value={form.values.app} onChange={(app) => form.setFieldValue("app", app)} />
|
<IntegrationLinkApp value={form.values.app} onChange={(app) => form.setFieldValue("app", app)} />
|
||||||
|
|
||||||
{error !== null && (
|
{error !== null && (
|
||||||
<div ref={errorRef} role="alert" tabIndex={-1}>
|
<div ref={errorRef} role="alert" tabIndex={-1}>
|
||||||
<IntegrationTestConnectionError error={error} url={form.values.url} />
|
<IntegrationTestConnectionError error={error} url={form.values.url} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!hideButtons && (
|
|
||||||
<Group justify="end" align="center">
|
<Group justify="end" align="center">
|
||||||
<Button variant="default" component={Link} href="/manage/integrations">
|
{!embedded && (
|
||||||
{t("common.action.backToOverview")}
|
<Button variant="default" component={Link} href="/manage/integrations">
|
||||||
</Button>
|
{t("common.action.backToOverview")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button type="submit" loading={isPending}>
|
<Button type="submit" loading={isPending}>
|
||||||
{t("integration.testConnection.action.edit")}
|
{t("integration.testConnection.action.edit")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
</Stack>
|
||||||
</Stack>
|
</form>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hideButtons) {
|
|
||||||
return formFields;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <form onSubmit={form.onSubmit(async (values) => await handleSubmitAsync(values))}>{formFields}</form>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type FormType = z.infer<typeof formSchema>;
|
type FormType = z.infer<typeof formSchema>;
|
||||||
|
|||||||
@@ -3,16 +3,16 @@
|
|||||||
import { Suspense, use } from "react";
|
import { Suspense, use } from "react";
|
||||||
import { Center, Loader } from "@mantine/core";
|
import { Center, Loader } from "@mantine/core";
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
import type { FallbackProps } from "react-error-boundary";
|
|
||||||
|
|
||||||
import type { WidgetKind } from "@homarr/definitions";
|
import type { WidgetKind } from "@homarr/definitions";
|
||||||
import { createModal, modalSizeForm } from "@homarr/modals";
|
import { createModal, modalSizeForm } from "@homarr/modals";
|
||||||
|
import { useModalAction } from "@homarr/modals";
|
||||||
import { WidgetError } from "@homarr/widgets/errors";
|
import { WidgetError } from "@homarr/widgets/errors";
|
||||||
import type * as WidgetModalsModule from "@homarr/widgets/modals";
|
import type * as WidgetModalsModule from "@homarr/widgets/modals";
|
||||||
import type { WidgetEditModalProps } from "@homarr/widgets/modals";
|
import type { WidgetEditModalProps } from "@homarr/widgets/modals";
|
||||||
|
|
||||||
import type * as IntegrationEditFormModule from "~/components/integration/embedded-integration-edit-form";
|
import type * as IntegrationEditModalModule from "~/components/integration/embedded-integration-edit-modal";
|
||||||
import type { EmbeddedIntegrationEditFormProps } from "~/components/integration/embedded-integration-edit-form";
|
import type { EmbeddedIntegrationEditModalProps } from "~/components/integration/embedded-integration-edit-modal";
|
||||||
|
|
||||||
let widgetEditModalPromise: Promise<typeof WidgetModalsModule> | undefined;
|
let widgetEditModalPromise: Promise<typeof WidgetModalsModule> | undefined;
|
||||||
|
|
||||||
@@ -29,38 +29,42 @@ export const loadWidgetEditModal = () => {
|
|||||||
|
|
||||||
export const preloadWidgetEditModal = () => void loadWidgetEditModal().catch(() => undefined);
|
export const preloadWidgetEditModal = () => void loadWidgetEditModal().catch(() => undefined);
|
||||||
|
|
||||||
let integrationEditFormPromise: Promise<typeof IntegrationEditFormModule> | undefined;
|
let integrationEditModalPromise: Promise<typeof IntegrationEditModalModule> | undefined;
|
||||||
|
|
||||||
const loadIntegrationEditForm = () => {
|
const loadIntegrationEditModal = () => {
|
||||||
if (integrationEditFormPromise) return integrationEditFormPromise;
|
if (integrationEditModalPromise) return integrationEditModalPromise;
|
||||||
|
|
||||||
const currentPromise = import("~/components/integration/embedded-integration-edit-form");
|
const currentPromise = import("~/components/integration/embedded-integration-edit-modal");
|
||||||
integrationEditFormPromise = currentPromise;
|
integrationEditModalPromise = currentPromise;
|
||||||
void currentPromise.catch(() => {
|
void currentPromise.catch(() => {
|
||||||
if (integrationEditFormPromise === currentPromise) integrationEditFormPromise = undefined;
|
if (integrationEditModalPromise === currentPromise) integrationEditModalPromise = undefined;
|
||||||
});
|
});
|
||||||
return currentPromise;
|
return currentPromise;
|
||||||
};
|
};
|
||||||
|
|
||||||
const LazyIntegrationEditFormContent = (props: EmbeddedIntegrationEditFormProps) => {
|
const LazyIntegrationEditModalContent = (props: {
|
||||||
const { EmbeddedIntegrationEditForm } = use(loadIntegrationEditForm());
|
actions: { closeModal: () => void };
|
||||||
return <EmbeddedIntegrationEditForm {...props} />;
|
innerProps: EmbeddedIntegrationEditModalProps;
|
||||||
|
}) => {
|
||||||
|
const { EmbeddedIntegrationEditModal } = use(loadIntegrationEditModal());
|
||||||
|
const Component = EmbeddedIntegrationEditModal.component;
|
||||||
|
return <Component {...props} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const IntegrationEditErrorFallback = ({ error, resetErrorBoundary }: FallbackProps) => (
|
const LazyIntegrationEditModal = createModal<EmbeddedIntegrationEditModalProps>((props) => (
|
||||||
<Center py="xl">
|
<ErrorBoundary
|
||||||
<WidgetError
|
fallbackRender={({ error, resetErrorBoundary }) => (
|
||||||
error={error}
|
<Center py="xl">
|
||||||
resetErrorBoundary={() => {
|
<WidgetError
|
||||||
integrationEditFormPromise = undefined;
|
error={error}
|
||||||
resetErrorBoundary();
|
resetErrorBoundary={() => {
|
||||||
}}
|
integrationEditModalPromise = undefined;
|
||||||
/>
|
resetErrorBoundary();
|
||||||
</Center>
|
}}
|
||||||
);
|
/>
|
||||||
|
</Center>
|
||||||
const LazyIntegrationEditForm = (props: EmbeddedIntegrationEditFormProps) => (
|
)}
|
||||||
<ErrorBoundary FallbackComponent={IntegrationEditErrorFallback}>
|
>
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<Center py="xl">
|
<Center py="xl">
|
||||||
@@ -68,10 +72,15 @@ const LazyIntegrationEditForm = (props: EmbeddedIntegrationEditFormProps) => (
|
|||||||
</Center>
|
</Center>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<LazyIntegrationEditFormContent {...props} />
|
<LazyIntegrationEditModalContent {...props} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
)).withOptions({
|
||||||
|
defaultTitle: (t) => t("item.edit.tab.integration"),
|
||||||
|
size: modalSizeForm,
|
||||||
|
presentation: "inspector",
|
||||||
|
closeOnClickOutside: true,
|
||||||
|
});
|
||||||
|
|
||||||
interface LazyWidgetEditModalContentProps {
|
interface LazyWidgetEditModalContentProps {
|
||||||
actions: { closeModal: () => void };
|
actions: { closeModal: () => void };
|
||||||
@@ -81,12 +90,17 @@ interface LazyWidgetEditModalContentProps {
|
|||||||
const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
|
const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
|
||||||
const { WidgetEditModal } = use(loadWidgetEditModal());
|
const { WidgetEditModal } = use(loadWidgetEditModal());
|
||||||
const Component = WidgetEditModal.component;
|
const Component = WidgetEditModal.component;
|
||||||
|
const { openModal: openIntegrationEditModal } = useModalAction(LazyIntegrationEditModal);
|
||||||
return (
|
return (
|
||||||
<Component
|
<Component
|
||||||
{...props}
|
{...props}
|
||||||
innerProps={{
|
innerProps={{
|
||||||
...props.innerProps,
|
...props.innerProps,
|
||||||
integrationEditForm: LazyIntegrationEditForm,
|
onEditIntegration: (integrationId) =>
|
||||||
|
openIntegrationEditModal({
|
||||||
|
integrationId,
|
||||||
|
onSuccess: props.innerProps.onIntegrationSaved,
|
||||||
|
}),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useImperativeHandle, useRef } from "react";
|
|
||||||
import { Alert, Button, Group, Skeleton, Stack, Text } from "@mantine/core";
|
|
||||||
import { IconInfoCircle } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
import { getIntegrationName } from "@homarr/definitions";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
|
||||||
import { IntegrationAvatar } from "@homarr/ui";
|
|
||||||
import type { EmbeddedIntegrationEditFormProps } from "@homarr/widgets/modals";
|
|
||||||
|
|
||||||
import type { EditIntegrationFormHandle } from "~/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form";
|
|
||||||
import { EditIntegrationForm } from "~/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form";
|
|
||||||
|
|
||||||
export type { EmbeddedIntegrationEditFormProps } from "@homarr/widgets/modals";
|
|
||||||
|
|
||||||
export const EmbeddedIntegrationEditForm = ({
|
|
||||||
integrationId,
|
|
||||||
handleRef,
|
|
||||||
onSuccess,
|
|
||||||
}: EmbeddedIntegrationEditFormProps) => {
|
|
||||||
const t = useI18n();
|
|
||||||
const integrationFormRef = useRef<EditIntegrationFormHandle>(null);
|
|
||||||
const {
|
|
||||||
data: integration,
|
|
||||||
isPending,
|
|
||||||
isError,
|
|
||||||
refetch,
|
|
||||||
} = clientApi.integration.byId.useQuery({
|
|
||||||
id: integrationId,
|
|
||||||
});
|
|
||||||
|
|
||||||
useImperativeHandle(
|
|
||||||
handleRef,
|
|
||||||
() => ({
|
|
||||||
submitIfDirty: async () => {
|
|
||||||
if (!integrationFormRef.current?.isDirty()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return integrationFormRef.current.submit();
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isPending) {
|
|
||||||
return (
|
|
||||||
<Stack gap="sm" aria-busy="true" aria-label={t("item.edit.integration.loading")}>
|
|
||||||
<Skeleton height={36} width="60%" />
|
|
||||||
<Skeleton height={60} />
|
|
||||||
<Skeleton height={120} />
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError || !integration) {
|
|
||||||
return (
|
|
||||||
<Alert color="red" title={t("common.error")}>
|
|
||||||
<Stack gap="sm">
|
|
||||||
<Text size="sm">{t("item.edit.integration.loadError")}</Text>
|
|
||||||
<Button variant="light" color="red" onClick={() => void refetch()}>
|
|
||||||
{t("common.action.tryAgain")}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Alert>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack>
|
|
||||||
<Group gap="sm" wrap="nowrap">
|
|
||||||
<IntegrationAvatar kind={integration.kind} size="sm" />
|
|
||||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
|
||||||
<Text fw={600} truncate>
|
|
||||||
{integration.name}
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{getIntegrationName(integration.kind)}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
|
||||||
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
|
|
||||||
{t("item.edit.integration.propagationNotice")}
|
|
||||||
</Alert>
|
|
||||||
<EditIntegrationForm integration={integration} hideButtons formRef={integrationFormRef} onSuccess={onSuccess} />
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Alert, Button, Group, Skeleton, Stack, Text } from "@mantine/core";
|
||||||
|
import { IconInfoCircle } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { getIntegrationName } from "@homarr/definitions";
|
||||||
|
import { createModal, modalSizeForm } from "@homarr/modals";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { IntegrationAvatar } from "@homarr/ui";
|
||||||
|
|
||||||
|
import { EditIntegrationForm } from "~/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form";
|
||||||
|
|
||||||
|
export interface EmbeddedIntegrationEditModalProps {
|
||||||
|
integrationId: string;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EmbeddedIntegrationEditModal = createModal<EmbeddedIntegrationEditModalProps>(
|
||||||
|
({ actions, innerProps }) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const {
|
||||||
|
data: integration,
|
||||||
|
isPending,
|
||||||
|
isError,
|
||||||
|
refetch,
|
||||||
|
} = clientApi.integration.byId.useQuery({
|
||||||
|
id: innerProps.integrationId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
|
return (
|
||||||
|
<Stack gap="sm" aria-busy="true" aria-label={t("item.edit.integration.loading")}>
|
||||||
|
<Skeleton height={36} width="60%" />
|
||||||
|
<Skeleton height={60} />
|
||||||
|
<Skeleton height={120} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !integration) {
|
||||||
|
return (
|
||||||
|
<Alert color="red" title={t("common.error")}>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Text size="sm">{t("item.edit.integration.loadError")}</Text>
|
||||||
|
<Button variant="light" color="red" onClick={() => void refetch()}>
|
||||||
|
{t("common.action.tryAgain")}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
<IntegrationAvatar kind={integration.kind} size="sm" />
|
||||||
|
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||||
|
<Text fw={600} truncate>
|
||||||
|
{integration.name}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{getIntegrationName(integration.kind)}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
|
||||||
|
{t("item.edit.integration.propagationNotice")}
|
||||||
|
</Alert>
|
||||||
|
<EditIntegrationForm
|
||||||
|
integration={integration}
|
||||||
|
embedded
|
||||||
|
onSuccess={() => {
|
||||||
|
innerProps.onSuccess?.();
|
||||||
|
actions.closeModal();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
).withOptions({
|
||||||
|
defaultTitle: (t) => t("item.edit.tab.integration"),
|
||||||
|
size: modalSizeForm,
|
||||||
|
presentation: "inspector",
|
||||||
|
closeOnClickOutside: true,
|
||||||
|
});
|
||||||
@@ -112,15 +112,13 @@ export class EmbyIntegration extends Integration implements IMediaServerIntegrat
|
|||||||
let currentlyPlaying: StreamSession["currentlyPlaying"] | null = null;
|
let currentlyPlaying: StreamSession["currentlyPlaying"] | null = null;
|
||||||
|
|
||||||
if (sessionInfo.NowPlayingItem) {
|
if (sessionInfo.NowPlayingItem) {
|
||||||
const isEpisode = sessionInfo.NowPlayingItem.Type === BaseItemKind.Episode;
|
|
||||||
|
|
||||||
currentlyPlaying = {
|
currentlyPlaying = {
|
||||||
type: convertJellyfinType(sessionInfo.NowPlayingItem.Type),
|
type: convertJellyfinType(sessionInfo.NowPlayingItem.Type),
|
||||||
name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "",
|
name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "",
|
||||||
seasonName: sessionInfo.NowPlayingItem.SeasonName ?? "",
|
seasonName: sessionInfo.NowPlayingItem.SeasonName ?? "",
|
||||||
seasonNumber: isEpisode ? (sessionInfo.NowPlayingItem.ParentIndexNumber ?? null) : null,
|
seasonNumber: sessionInfo.NowPlayingItem.ParentIndexNumber ?? null,
|
||||||
episodeName: isEpisode ? sessionInfo.NowPlayingItem.EpisodeTitle : null,
|
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
|
||||||
episodeNumber: isEpisode ? (sessionInfo.NowPlayingItem.IndexNumber ?? null) : null,
|
episodeNumber: sessionInfo.NowPlayingItem.IndexNumber ?? null,
|
||||||
albumName: sessionInfo.NowPlayingItem.Album ?? "",
|
albumName: sessionInfo.NowPlayingItem.Album ?? "",
|
||||||
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
|
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
|
||||||
playback: {
|
playback: {
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
|
||||||
|
|
||||||
import { extractHost, parseLocation } from "./jellyfin-integration";
|
|
||||||
|
|
||||||
describe("extractHost", () => {
|
|
||||||
test("strips the port from an IPv4 endpoint", () => {
|
|
||||||
expect(extractHost("192.168.1.100:54321")).toBe("192.168.1.100");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns an IPv4 address unchanged when there's no port", () => {
|
|
||||||
expect(extractHost("8.8.8.8")).toBe("8.8.8.8");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("strips brackets and the port from a bracketed IPv6 endpoint", () => {
|
|
||||||
expect(extractHost("[::1]:8096")).toBe("::1");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns a bare IPv6 address unchanged when there's no port", () => {
|
|
||||||
expect(extractHost("::1")).toBe("::1");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("parseLocation", () => {
|
|
||||||
test("returns null when there's no endpoint to classify", () => {
|
|
||||||
expect(parseLocation(null)).toBeNull();
|
|
||||||
expect(parseLocation(undefined)).toBeNull();
|
|
||||||
expect(parseLocation("")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns null for a value that isn't an IP", () => {
|
|
||||||
expect(parseLocation("not-an-ip:1234")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("classifies private IPv4 ranges as lan", () => {
|
|
||||||
expect(parseLocation("192.168.1.100:54321")).toBe("lan");
|
|
||||||
expect(parseLocation("10.0.0.5:1234")).toBe("lan");
|
|
||||||
expect(parseLocation("172.16.5.5:1234")).toBe("lan");
|
|
||||||
expect(parseLocation("127.0.0.1:8096")).toBe("lan");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("classifies a public IPv4 address as wan", () => {
|
|
||||||
expect(parseLocation("8.8.8.8:1234")).toBe("wan");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("classifies IPv6 loopback and unique-local addresses as lan", () => {
|
|
||||||
expect(parseLocation("[::1]:8096")).toBe("lan");
|
|
||||||
expect(parseLocation("[fd12:3456:789a::1]:8096")).toBe("lan");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("classifies a public IPv6 address as wan", () => {
|
|
||||||
expect(parseLocation("[2606:4700:4700::1111]:8096")).toBe("wan");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("unwraps an IPv4-mapped IPv6 address before classifying it", () => {
|
|
||||||
expect(parseLocation("[::ffff:192.168.1.5]:8096")).toBe("lan");
|
|
||||||
expect(parseLocation("[::ffff:8.8.8.8]:8096")).toBe("wan");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("classifies link-local addresses as lan", () => {
|
|
||||||
// Link-local traffic can't traverse a router, so it's always LAN even though
|
|
||||||
// it isn't a private range in the traditional (RFC 1918 / ULA) sense.
|
|
||||||
expect(parseLocation("169.254.1.5:8096")).toBe("lan");
|
|
||||||
expect(parseLocation("[fe80::1]:8096")).toBe("lan");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { BlockList, isIP } from "node:net";
|
|
||||||
import { Jellyfin } from "@jellyfin/sdk";
|
import { Jellyfin } from "@jellyfin/sdk";
|
||||||
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||||
@@ -22,42 +21,6 @@ function ticksToMs(ticks: number | null | undefined): number | null {
|
|||||||
return ticks ? Math.round(ticks / 10_000) : null;
|
return ticks ? Math.round(ticks / 10_000) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unlike Plex, Jellyfin doesn't report a session's network location directly - only the
|
|
||||||
// client's RemoteEndPoint (an IP, optionally with a port). Classify it ourselves.
|
|
||||||
const privateAddresses = new BlockList();
|
|
||||||
privateAddresses.addSubnet("10.0.0.0", 8, "ipv4");
|
|
||||||
privateAddresses.addSubnet("172.16.0.0", 12, "ipv4");
|
|
||||||
privateAddresses.addSubnet("192.168.0.0", 16, "ipv4");
|
|
||||||
privateAddresses.addSubnet("127.0.0.0", 8, "ipv4");
|
|
||||||
privateAddresses.addSubnet("169.254.0.0", 16, "ipv4");
|
|
||||||
privateAddresses.addSubnet("fc00::", 7, "ipv6");
|
|
||||||
privateAddresses.addSubnet("fe80::", 10, "ipv6");
|
|
||||||
privateAddresses.addAddress("::1", "ipv6");
|
|
||||||
|
|
||||||
export function extractHost(remoteEndPoint: string): string {
|
|
||||||
if (remoteEndPoint.startsWith("[")) {
|
|
||||||
const end = remoteEndPoint.indexOf("]");
|
|
||||||
return end === -1 ? remoteEndPoint : remoteEndPoint.slice(1, end);
|
|
||||||
}
|
|
||||||
// A bare IPv6 address (no brackets, no port) has more than one colon.
|
|
||||||
const isIPv4WithPort = remoteEndPoint.split(":").length === 2;
|
|
||||||
return isIPv4WithPort ? remoteEndPoint.slice(0, remoteEndPoint.lastIndexOf(":")) : remoteEndPoint;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseLocation(remoteEndPoint: string | null | undefined): "lan" | "wan" | null {
|
|
||||||
if (!remoteEndPoint) return null;
|
|
||||||
let host = extractHost(remoteEndPoint);
|
|
||||||
let family = isIP(host);
|
|
||||||
// A dual-stack server can report an IPv4 client as an IPv4-mapped IPv6 address
|
|
||||||
// (e.g. "::ffff:192.168.1.5") - unwrap it so the IPv4 private ranges still apply.
|
|
||||||
if (family === 6 && host.toLowerCase().startsWith("::ffff:") && isIP(host.slice(7)) === 4) {
|
|
||||||
host = host.slice(7);
|
|
||||||
family = 4;
|
|
||||||
}
|
|
||||||
if (!family) return null;
|
|
||||||
return privateAddresses.check(host, family === 4 ? "ipv4" : "ipv6") ? "lan" : "wan";
|
|
||||||
}
|
|
||||||
|
|
||||||
@HandleIntegrationErrors([integrationAxiosHttpErrorHandler])
|
@HandleIntegrationErrors([integrationAxiosHttpErrorHandler])
|
||||||
export class JellyfinIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
|
export class JellyfinIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
|
||||||
private readonly jellyfin: Jellyfin = new Jellyfin({
|
private readonly jellyfin: Jellyfin = new Jellyfin({
|
||||||
@@ -93,27 +56,17 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
|
|||||||
if (sessionInfo.NowPlayingItem) {
|
if (sessionInfo.NowPlayingItem) {
|
||||||
const positionMs = ticksToMs(sessionInfo.PlayState?.PositionTicks);
|
const positionMs = ticksToMs(sessionInfo.PlayState?.PositionTicks);
|
||||||
const durationMs = ticksToMs(sessionInfo.NowPlayingItem.RunTimeTicks);
|
const durationMs = ticksToMs(sessionInfo.NowPlayingItem.RunTimeTicks);
|
||||||
// Jellyfin only reliably fills in MediaSources[].Bitrate for a transcoding session -
|
|
||||||
// /Sessions has no `fields` selector to force it, so fall back to summing the individual
|
|
||||||
// media streams' own bitrates (populated from the source file's metadata) for direct play.
|
|
||||||
const mediaStreamsBitrateBps = sessionInfo.NowPlayingItem.MediaStreams?.reduce(
|
|
||||||
(sum, stream) => sum + (stream.BitRate ?? 0),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
const bitrateBps =
|
const bitrateBps =
|
||||||
sessionInfo.TranscodingInfo?.Bitrate ??
|
sessionInfo.TranscodingInfo?.Bitrate ?? sessionInfo.NowPlayingItem.MediaSources?.[0]?.Bitrate ?? null;
|
||||||
sessionInfo.NowPlayingItem.MediaSources?.[0]?.Bitrate ??
|
|
||||||
(mediaStreamsBitrateBps || null);
|
|
||||||
const bitrateKbps = bitrateBps !== null ? Math.round(bitrateBps / 1000) : null;
|
const bitrateKbps = bitrateBps !== null ? Math.round(bitrateBps / 1000) : null;
|
||||||
const isEpisode = sessionInfo.NowPlayingItem.Type === BaseItemKind.Episode;
|
|
||||||
|
|
||||||
currentlyPlaying = {
|
currentlyPlaying = {
|
||||||
type: convertJellyfinType(sessionInfo.NowPlayingItem.Type),
|
type: convertJellyfinType(sessionInfo.NowPlayingItem.Type),
|
||||||
name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "",
|
name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "",
|
||||||
seasonName: sessionInfo.NowPlayingItem.SeasonName ?? "",
|
seasonName: sessionInfo.NowPlayingItem.SeasonName ?? "",
|
||||||
seasonNumber: isEpisode ? (sessionInfo.NowPlayingItem.ParentIndexNumber ?? null) : null,
|
seasonNumber: sessionInfo.NowPlayingItem.ParentIndexNumber ?? null,
|
||||||
episodeName: isEpisode ? sessionInfo.NowPlayingItem.EpisodeTitle : null,
|
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
|
||||||
episodeNumber: isEpisode ? (sessionInfo.NowPlayingItem.IndexNumber ?? null) : null,
|
episodeNumber: sessionInfo.NowPlayingItem.IndexNumber ?? null,
|
||||||
albumName: sessionInfo.NowPlayingItem.Album ?? "",
|
albumName: sessionInfo.NowPlayingItem.Album ?? "",
|
||||||
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
|
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
|
||||||
playback: {
|
playback: {
|
||||||
@@ -121,7 +74,7 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
|
|||||||
positionMs,
|
positionMs,
|
||||||
durationMs,
|
durationMs,
|
||||||
},
|
},
|
||||||
location: parseLocation(sessionInfo.RemoteEndPoint),
|
location: null,
|
||||||
metadata: {
|
metadata: {
|
||||||
video: {
|
video: {
|
||||||
resolution:
|
resolution:
|
||||||
|
|||||||
@@ -115,9 +115,9 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
|
|||||||
type: mediaElement.$.live === "1" ? "tv" : PlexIntegration.getCurrentlyPlayingType(mediaElement.$.type),
|
type: mediaElement.$.live === "1" ? "tv" : PlexIntegration.getCurrentlyPlayingType(mediaElement.$.type),
|
||||||
name: mediaElement.$.grandparentTitle ?? mediaElement.$.title ?? "Unknown",
|
name: mediaElement.$.grandparentTitle ?? mediaElement.$.title ?? "Unknown",
|
||||||
seasonName: mediaElement.$.parentTitle,
|
seasonName: mediaElement.$.parentTitle,
|
||||||
seasonNumber: mediaElement.$.type === "episode" ? parseOptionalNumber(mediaElement.$.parentIndex) : null,
|
seasonNumber: parseOptionalNumber(mediaElement.$.parentIndex),
|
||||||
episodeName: mediaElement.$.type === "episode" ? (mediaElement.$.title ?? null) : null,
|
episodeName: mediaElement.$.title ?? null,
|
||||||
episodeNumber: mediaElement.$.type === "episode" ? parseOptionalNumber(mediaElement.$.index) : null,
|
episodeNumber: parseOptionalNumber(mediaElement.$.index),
|
||||||
albumName: mediaElement.$.type === "track" ? (mediaElement.$.parentTitle ?? null) : null,
|
albumName: mediaElement.$.type === "track" ? (mediaElement.$.parentTitle ?? null) : null,
|
||||||
episodeCount: parseOptionalNumber(mediaElement.$.index),
|
episodeCount: parseOptionalNumber(mediaElement.$.index),
|
||||||
playback: {
|
playback: {
|
||||||
|
|||||||
@@ -31,7 +31,6 @@
|
|||||||
|
|
||||||
.tableViewport :global(th) {
|
.tableViewport :global(th) {
|
||||||
background-color: var(--background-color);
|
background-color: var(--background-color);
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sortButton {
|
.sortButton {
|
||||||
|
|||||||
@@ -9,12 +9,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|||||||
|
|
||||||
import type { StreamSession } from "@homarr/integrations";
|
import type { StreamSession } from "@homarr/integrations";
|
||||||
|
|
||||||
import {
|
import { getMediaServerColumnVisibility, getSeasonEpisodeParams, SessionDetailsPopover } from "./component";
|
||||||
getMediaServerColumnVisibility,
|
|
||||||
getResolutionLabel,
|
|
||||||
getSeasonEpisodeParams,
|
|
||||||
SessionDetailsPopover,
|
|
||||||
} from "./component";
|
|
||||||
|
|
||||||
vi.mock("@homarr/translation/client", () => ({
|
vi.mock("@homarr/translation/client", () => ({
|
||||||
useScopedI18n: () => (key: string) => key,
|
useScopedI18n: () => (key: string) => key,
|
||||||
@@ -109,8 +104,7 @@ describe("getMediaServerColumnVisibility", () => {
|
|||||||
test("keeps a stable responsive column model from the zero-sized first render", () => {
|
test("keeps a stable responsive column model from the zero-sized first render", () => {
|
||||||
expect(getMediaServerColumnVisibility(0, false)).toEqual({ user: false, status: false });
|
expect(getMediaServerColumnVisibility(0, false)).toEqual({ user: false, status: false });
|
||||||
expect(getMediaServerColumnVisibility(300, false)).toEqual({ user: true, status: false });
|
expect(getMediaServerColumnVisibility(300, false)).toEqual({ user: true, status: false });
|
||||||
expect(getMediaServerColumnVisibility(420, false)).toEqual({ user: true, status: false });
|
expect(getMediaServerColumnVisibility(420, false)).toEqual({ user: true, status: true });
|
||||||
expect(getMediaServerColumnVisibility(540, false)).toEqual({ user: true, status: true });
|
|
||||||
expect(getMediaServerColumnVisibility(0, true)).toEqual({ user: true, status: true });
|
expect(getMediaServerColumnVisibility(0, true)).toEqual({ user: true, status: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -130,30 +124,3 @@ describe("getSeasonEpisodeParams", () => {
|
|||||||
expect(getSeasonEpisodeParams(null, undefined)).toBeNull();
|
expect(getSeasonEpisodeParams(null, undefined)).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getResolutionLabel", () => {
|
|
||||||
test("labels a standard 16:9 resolution by its exact height", () => {
|
|
||||||
expect(getResolutionLabel(1920, 1080)).toBe("1080p");
|
|
||||||
expect(getResolutionLabel(1280, 720)).toBe("720p");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("classifies a cinemascope source by its width, not its cropped height", () => {
|
|
||||||
// A 1920x804 encode (2.35:1, cropped top and bottom) is still a 1080p source.
|
|
||||||
expect(getResolutionLabel(1920, 804)).toBe("1080p");
|
|
||||||
expect(getResolutionLabel(1280, 640)).toBe("720p");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rounds a near-standard resolution up to its nearest tier", () => {
|
|
||||||
expect(getResolutionLabel(1912, 796)).toBe("1080p");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("falls back to the raw height when it doesn't clear any known tier", () => {
|
|
||||||
expect(getResolutionLabel(100, 100)).toBe("100p");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("classifies a portrait video by its width, not its long-axis height", () => {
|
|
||||||
// A 1080x1920 portrait phone recording is still a 1080p source, not a 1440p one.
|
|
||||||
expect(getResolutionLabel(1080, 1920)).toBe("1080p");
|
|
||||||
expect(getResolutionLabel(720, 1280)).toBe("720p");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -51,9 +51,7 @@ type SortState = { column: SortColumn; descending: boolean } | null;
|
|||||||
|
|
||||||
export const getMediaServerColumnVisibility = (width: number, isAdvanced: boolean) => ({
|
export const getMediaServerColumnVisibility = (width: number, isAdvanced: boolean) => ({
|
||||||
user: isAdvanced || width >= 300,
|
user: isAdvanced || width >= 300,
|
||||||
// The user (200px) and status (190px) columns together need a floor here, otherwise the
|
status: isAdvanced || width >= 420,
|
||||||
// currentlyPlaying column - the primary content - gets squeezed to almost nothing.
|
|
||||||
status: isAdvanced || width >= 540,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function getPlaybackStatus(transcoding: TranscodingDecision | undefined): PlaybackStatus {
|
function getPlaybackStatus(transcoding: TranscodingDecision | undefined): PlaybackStatus {
|
||||||
@@ -79,21 +77,6 @@ function formatBitrate(bitrateKbps: number | null | undefined): string | null {
|
|||||||
return bitrateKbps >= 1000 ? `${(bitrateKbps / 1000).toFixed(1)} Mbps` : `${Math.round(bitrateKbps)} kbps`;
|
return bitrateKbps >= 1000 ? `${(bitrateKbps / 1000).toFixed(1)} Mbps` : `${Math.round(bitrateKbps)} kbps`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RESOLUTION_TIER_HEIGHTS = [4320, 2160, 1440, 1080, 720, 480, 360, 240] as const;
|
|
||||||
|
|
||||||
export function getResolutionLabel(width: number, height: number): string {
|
|
||||||
// Classify off the short axis (height for landscape, width for portrait) so orientation
|
|
||||||
// doesn't flip which dimension drives the label. Letterboxed/cinemascope sources are cropped
|
|
||||||
// shorter than their nominal resolution class (a 1920x804 encode is still "1080p"), so derive
|
|
||||||
// a floor for the short axis from the long axis assuming 16:9 - matches how Plex/Jellyfin label these themselves.
|
|
||||||
const isPortrait = height > width;
|
|
||||||
const shortAxis = isPortrait ? width : height;
|
|
||||||
const longAxis = isPortrait ? height : width;
|
|
||||||
const effectiveShortAxis = Math.max(shortAxis, Math.round((longAxis * 9) / 16));
|
|
||||||
const tier = RESOLUTION_TIER_HEIGHTS.find((candidate) => effectiveShortAxis >= candidate * 0.9);
|
|
||||||
return `${tier ?? effectiveShortAxis}p`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSeasonEpisodeParams(
|
export function getSeasonEpisodeParams(
|
||||||
seasonNumber: number | null | undefined,
|
seasonNumber: number | null | undefined,
|
||||||
episodeNumber: number | null | undefined,
|
episodeNumber: number | null | undefined,
|
||||||
@@ -134,33 +117,27 @@ function StreamTableHeader({
|
|||||||
sortable,
|
sortable,
|
||||||
sort,
|
sort,
|
||||||
onSort,
|
onSort,
|
||||||
width,
|
|
||||||
}: {
|
}: {
|
||||||
column: SortColumn;
|
column: SortColumn;
|
||||||
label: string;
|
label: string;
|
||||||
sortable: boolean;
|
sortable: boolean;
|
||||||
sort: SortState;
|
sort: SortState;
|
||||||
onSort: (column: SortColumn) => void;
|
onSort: (column: SortColumn) => void;
|
||||||
width?: number;
|
|
||||||
}) {
|
}) {
|
||||||
const active = sort?.column === column;
|
const active = sort?.column === column;
|
||||||
const SortIcon = !active ? IconArrowsSort : sort.descending ? IconChevronDown : IconChevronUp;
|
const SortIcon = !active ? IconArrowsSort : sort.descending ? IconChevronDown : IconChevronUp;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table.Th w={width} aria-sort={active ? (sort.descending ? "descending" : "ascending") : "none"}>
|
<Table.Th aria-sort={active ? (sort.descending ? "descending" : "ascending") : "none"}>
|
||||||
{sortable ? (
|
{sortable ? (
|
||||||
<UnstyledButton className={classes.sortButton} onClick={() => onSort(column)}>
|
<UnstyledButton className={classes.sortButton} onClick={() => onSort(column)}>
|
||||||
<Text component="span" size="xs" fw={600} c="dimmed" style={{ letterSpacing: "0.02em" }} truncate>
|
<Text component="span" size="xs" fw={600} truncate>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
<SortIcon
|
<SortIcon size="var(--mantine-font-size-xs)" aria-hidden />
|
||||||
size="var(--mantine-font-size-xs)"
|
|
||||||
aria-hidden
|
|
||||||
color={active ? undefined : "var(--mantine-color-dimmed)"}
|
|
||||||
/>
|
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
) : (
|
) : (
|
||||||
<Text size="xs" fw={600} c="dimmed" style={{ letterSpacing: "0.02em" }} truncate>
|
<Text size="xs" fw={600} truncate>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@@ -218,14 +195,12 @@ export default function MediaServerWidget({
|
|||||||
current?.column === column ? { column, descending: !current.descending } : { column, descending: false },
|
current?.column === column ? { column, descending: !current.descending } : { column, descending: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
const uniqueIntegrations = currentStreams
|
const uniqueIntegrations = currentStreams.map((stream) => ({
|
||||||
.filter((stream) => stream.sessions.length > 0)
|
integrationId: stream.integrationId,
|
||||||
.map((stream) => ({
|
integrationKind: stream.integrationKind,
|
||||||
integrationId: stream.integrationId,
|
integrationIcon: getIconUrl(stream.integrationKind),
|
||||||
integrationKind: stream.integrationKind,
|
integrationName: stream.integrationName,
|
||||||
integrationIcon: getIconUrl(stream.integrationKind),
|
}));
|
||||||
integrationName: stream.integrationName,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const playingCount = flatSessions.filter(
|
const playingCount = flatSessions.filter(
|
||||||
(session) => session.currentlyPlaying && session.currentlyPlaying.playback?.state !== "paused",
|
(session) => session.currentlyPlaying && session.currentlyPlaying.playback?.state !== "paused",
|
||||||
@@ -288,7 +263,6 @@ export default function MediaServerWidget({
|
|||||||
sortable={isAdvanced}
|
sortable={isAdvanced}
|
||||||
sort={sort}
|
sort={sort}
|
||||||
onSort={toggleSort}
|
onSort={toggleSort}
|
||||||
width={200}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<StreamTableHeader
|
<StreamTableHeader
|
||||||
@@ -305,7 +279,6 @@ export default function MediaServerWidget({
|
|||||||
sortable={isAdvanced}
|
sortable={isAdvanced}
|
||||||
sort={sort}
|
sort={sort}
|
||||||
onSort={toggleSort}
|
onSort={toggleSort}
|
||||||
width={190}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
@@ -317,12 +290,6 @@ export default function MediaServerWidget({
|
|||||||
const status = getPlaybackStatus(currentlyPlaying?.metadata?.transcoding);
|
const status = getPlaybackStatus(currentlyPlaying?.metadata?.transcoding);
|
||||||
const location = showLocation ? currentlyPlaying?.location : null;
|
const location = showLocation ? currentlyPlaying?.location : null;
|
||||||
const bitrateLabel = showBitrate ? formatBitrate(currentlyPlaying?.metadata?.bitrateKbps) : null;
|
const bitrateLabel = showBitrate ? formatBitrate(currentlyPlaying?.metadata?.bitrateKbps) : null;
|
||||||
// The transcoding resolution reflects what's actually being streamed - fall back to the
|
|
||||||
// source's own resolution when it isn't set (direct play, or a transcode that only
|
|
||||||
// touches audio/container and leaves the video resolution untouched).
|
|
||||||
const resolution =
|
|
||||||
currentlyPlaying?.metadata?.transcoding.resolution ?? currentlyPlaying?.metadata?.video.resolution;
|
|
||||||
const resolutionLabel = resolution ? getResolutionLabel(resolution.width, resolution.height) : null;
|
|
||||||
const toggleDetails = () => setSelectedRowId((current) => (current === rowId ? null : rowId));
|
const toggleDetails = () => setSelectedRowId((current) => (current === rowId ? null : rowId));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -348,7 +315,7 @@ export default function MediaServerWidget({
|
|||||||
{columnVisibility.user && (
|
{columnVisibility.user && (
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Group gap="xs" wrap="nowrap" w="100%">
|
<Group gap="xs" wrap="nowrap" w="100%">
|
||||||
<Avatar size={28} src={session.user?.profilePictureUrl} style={{ flexShrink: 0 }} />
|
<Avatar size={28} src={session.user?.profilePictureUrl} />
|
||||||
<Stack gap={2} className={classes.cellContent}>
|
<Stack gap={2} className={classes.cellContent}>
|
||||||
<Text size="xs" truncate>
|
<Text size="xs" truncate>
|
||||||
{session.user?.username ?? t("items.unknownUser")}
|
{session.user?.username ?? t("items.unknownUser")}
|
||||||
@@ -373,30 +340,24 @@ export default function MediaServerWidget({
|
|||||||
<Table.Td>
|
<Table.Td>
|
||||||
{currentlyPlaying && (
|
{currentlyPlaying && (
|
||||||
<Stack gap={4} align="flex-start" w="100%" className={classes.cellContent}>
|
<Stack gap={4} align="flex-start" w="100%" className={classes.cellContent}>
|
||||||
<Group gap={4} align="center" justify="space-between" wrap="nowrap" w="100%">
|
<Badge size="xs" variant="light" color={playbackStatusColorMap[status]}>
|
||||||
<Badge size="xs" variant="light" color={playbackStatusColorMap[status]}>
|
{t(`items.${status}` as never)}
|
||||||
{t(`items.${status}` as never)}
|
</Badge>
|
||||||
</Badge>
|
{(location ?? bitrateLabel) && (
|
||||||
{location && (
|
|
||||||
<Group gap={4} align="center" wrap="nowrap">
|
|
||||||
{location === "lan" ? (
|
|
||||||
<IconWifi size="var(--mantine-font-size-xs)" />
|
|
||||||
) : (
|
|
||||||
<IconWorld size="var(--mantine-font-size-xs)" />
|
|
||||||
)}
|
|
||||||
<Text size="xs" c="dimmed" tt="uppercase">
|
|
||||||
{location}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
{(resolutionLabel ?? bitrateLabel) && (
|
|
||||||
<Group gap={4} align="center" justify="space-between" wrap="nowrap" w="100%">
|
<Group gap={4} align="center" justify="space-between" wrap="nowrap" w="100%">
|
||||||
{resolutionLabel && (
|
<Group gap={4} align="center" wrap="nowrap">
|
||||||
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
{location &&
|
||||||
{resolutionLabel}
|
(location === "lan" ? (
|
||||||
</Text>
|
<IconWifi size="var(--mantine-font-size-xs)" />
|
||||||
)}
|
) : (
|
||||||
|
<IconWorld size="var(--mantine-font-size-xs)" />
|
||||||
|
))}
|
||||||
|
{location && (
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase">
|
||||||
|
{location}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
{bitrateLabel && (
|
{bitrateLabel && (
|
||||||
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||||
{bitrateLabel}
|
{bitrateLabel}
|
||||||
@@ -425,42 +386,25 @@ export default function MediaServerWidget({
|
|||||||
borderTop: "1px solid var(--border-color)",
|
borderTop: "1px solid var(--border-color)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={4} wrap="nowrap">
|
||||||
<IconVideo size="var(--mantine-font-size-xs)" style={{ flexShrink: 0 }} />
|
<IconVideo size="var(--mantine-font-size-xs)" style={{ flexShrink: 0 }} />
|
||||||
<Text size="sm" fw={500} style={{ whiteSpace: "nowrap" }}>
|
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
|
||||||
{(t as unknown as (key: string, params?: { count: number }) => string)("footer.streams", {
|
{(t as unknown as (key: string, params?: { count: number }) => string)("footer.streams", {
|
||||||
count: flatSessions.length,
|
count: flatSessions.length,
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
{totalBitrateLabel && (isAdvanced || width >= 300) && (
|
{totalBitrateLabel && (isAdvanced || width >= 300) && (
|
||||||
<>
|
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||||
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
{t("footer.totalBitrate", { bitrate: totalBitrateLabel })}
|
||||||
•
|
</Text>
|
||||||
</Text>
|
|
||||||
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
|
||||||
{t("footer.totalBitrate", { bitrate: totalBitrateLabel })}
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap={6}>
|
<Group gap="xs">
|
||||||
{uniqueIntegrations.map((integration) => (
|
{uniqueIntegrations.map((integration) => (
|
||||||
<Group
|
<Group key={integration.integrationId} gap="xs" align="center">
|
||||||
key={integration.integrationId}
|
<Avatar className="media-server-icon" src={integration.integrationIcon} radius={"xs"} size="xs" />
|
||||||
gap={6}
|
|
||||||
align="center"
|
|
||||||
wrap="nowrap"
|
|
||||||
pl={2}
|
|
||||||
pr={8}
|
|
||||||
py={2}
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--mantine-color-default-hover)",
|
|
||||||
borderRadius: 999,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Avatar className="media-server-icon" src={integration.integrationIcon} radius="xl" size={18} />
|
|
||||||
{(isAdvanced || width >= 480) && (
|
{(isAdvanced || width >= 480) && (
|
||||||
<Text className="media-server-name" size="xs" fw={500} truncate="end">
|
<Text className="media-server-name" size="sm" truncate="end">
|
||||||
{integration.integrationName}
|
{integration.integrationName}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import type { ComponentType, FormEvent } from "react";
|
import type { FormEvent } from "react";
|
||||||
import { Box, Button, Group, Stack, Tabs, Text } from "@mantine/core";
|
import { Box, Button, Group, Stack, Tabs, Text } from "@mantine/core";
|
||||||
import { schemaResolver } from "@mantine/form";
|
import { schemaResolver } from "@mantine/form";
|
||||||
import { IconArrowLeft, IconPencil } from "@tabler/icons-react";
|
import { IconPencil } from "@tabler/icons-react";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
import { objectEntries } from "@homarr/common";
|
import { objectEntries } from "@homarr/common";
|
||||||
@@ -35,16 +35,6 @@ export interface WidgetEditModalState {
|
|||||||
advancedOptions: BoardItemAdvancedOptions;
|
advancedOptions: BoardItemAdvancedOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EmbeddedIntegrationEditFormHandle {
|
|
||||||
submitIfDirty: () => Promise<boolean>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EmbeddedIntegrationEditFormProps {
|
|
||||||
integrationId: string;
|
|
||||||
handleRef: React.Ref<EmbeddedIntegrationEditFormHandle>;
|
|
||||||
onSuccess?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WidgetEditModalProps<TSort extends WidgetKind> {
|
export interface WidgetEditModalProps<TSort extends WidgetKind> {
|
||||||
kind: TSort;
|
kind: TSort;
|
||||||
definition: WidgetDefinition;
|
definition: WidgetDefinition;
|
||||||
@@ -56,7 +46,7 @@ export interface WidgetEditModalProps<TSort extends WidgetKind> {
|
|||||||
itemId?: string;
|
itemId?: string;
|
||||||
boardId?: string;
|
boardId?: string;
|
||||||
appId?: string;
|
appId?: string;
|
||||||
integrationEditForm?: ComponentType<EmbeddedIntegrationEditFormProps>;
|
onEditIntegration?: (integrationId: string) => void;
|
||||||
onIntegrationSaved?: () => void;
|
onIntegrationSaved?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,12 +61,6 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
|||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const [advancedOptions, setAdvancedOptions] = useState<BoardItemAdvancedOptions>(innerProps.value.advancedOptions);
|
const [advancedOptions, setAdvancedOptions] = useState<BoardItemAdvancedOptions>(innerProps.value.advancedOptions);
|
||||||
const appEditRef = useRef<EmbeddedAppEditFormHandle>(null);
|
const appEditRef = useRef<EmbeddedAppEditFormHandle>(null);
|
||||||
const integrationEditHandles = useRef(new Map<string, EmbeddedIntegrationEditFormHandle>());
|
|
||||||
const integrationEditRefCallbacks = useRef(
|
|
||||||
new Map<string, (handle: EmbeddedIntegrationEditFormHandle | null) => void>(),
|
|
||||||
);
|
|
||||||
const [mountedIntegrationIds, setMountedIntegrationIds] = useState<string[]>([]);
|
|
||||||
const [activeIntegrationId, setActiveIntegrationId] = useState<string | null>(null);
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
z.config({
|
z.config({
|
||||||
@@ -134,24 +118,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
|||||||
const showAppTab = innerProps.kind === "app" && canModifyApps && Boolean(appId);
|
const showAppTab = innerProps.kind === "app" && canModifyApps && Boolean(appId);
|
||||||
const canModifyAllIntegrations = session?.user.permissions.includes("integration-full-all") ?? false;
|
const canModifyAllIntegrations = session?.user.permissions.includes("integration-full-all") ?? false;
|
||||||
const selectedIntegrations = getSelectedWidgetIntegrations(innerProps.integrationData, form.values.integrationIds);
|
const selectedIntegrations = getSelectedWidgetIntegrations(innerProps.integrationData, form.values.integrationIds);
|
||||||
const editableIntegrationIds = selectedIntegrations
|
const showIntegrationTab = Boolean(innerProps.onEditIntegration) && selectedIntegrations.length > 0;
|
||||||
.filter((integration) => canModifyAllIntegrations || integration.permissions?.hasFullAccess === true)
|
|
||||||
.map((integration) => integration.id);
|
|
||||||
let soleEditableIntegrationId: string | null = null;
|
|
||||||
if (selectedIntegrations.length === 1) {
|
|
||||||
soleEditableIntegrationId = editableIntegrationIds[0] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let displayedIntegrationId = soleEditableIntegrationId;
|
|
||||||
if (activeIntegrationId && editableIntegrationIds.includes(activeIntegrationId)) {
|
|
||||||
displayedIntegrationId = activeIntegrationId;
|
|
||||||
}
|
|
||||||
const integrationIdsToRender = mountedIntegrationIds.filter((id) => editableIntegrationIds.includes(id));
|
|
||||||
if (displayedIntegrationId && !integrationIdsToRender.includes(displayedIntegrationId)) {
|
|
||||||
integrationIdsToRender.push(displayedIntegrationId);
|
|
||||||
}
|
|
||||||
const IntegrationEditForm = innerProps.integrationEditForm;
|
|
||||||
const showIntegrationTab = Boolean(IntegrationEditForm) && selectedIntegrations.length > 0;
|
|
||||||
const showResourceTabs = showAppTab || showIntegrationTab;
|
const showResourceTabs = showAppTab || showIntegrationTab;
|
||||||
const [activeTab, setActiveTab] = useState<string | null>("widget");
|
const [activeTab, setActiveTab] = useState<string | null>("widget");
|
||||||
|
|
||||||
@@ -160,31 +127,15 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
|||||||
if (activeTab === "app" && !showAppTab) setActiveTab("widget");
|
if (activeTab === "app" && !showAppTab) setActiveTab("widget");
|
||||||
}, [activeTab, showAppTab, showIntegrationTab]);
|
}, [activeTab, showAppTab, showIntegrationTab]);
|
||||||
|
|
||||||
const getIntegrationEditRef = (integrationId: string) => {
|
const handleTabChange = (value: string | null) => {
|
||||||
const existingCallback = integrationEditRefCallbacks.current.get(integrationId);
|
if (value === "integration" && selectedIntegrations.length === 1) {
|
||||||
if (existingCallback) {
|
const integration = selectedIntegrations[0];
|
||||||
return existingCallback;
|
if (integration) {
|
||||||
|
innerProps.onEditIntegration?.(integration.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
setActiveTab(value);
|
||||||
const callback = (handle: EmbeddedIntegrationEditFormHandle | null) => {
|
|
||||||
if (handle) {
|
|
||||||
integrationEditHandles.current.set(integrationId, handle);
|
|
||||||
} else {
|
|
||||||
integrationEditHandles.current.delete(integrationId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
integrationEditRefCallbacks.current.set(integrationId, callback);
|
|
||||||
return callback;
|
|
||||||
};
|
|
||||||
|
|
||||||
const beginEditingIntegration = (integrationId: string) => {
|
|
||||||
setMountedIntegrationIds((current) => {
|
|
||||||
if (current.includes(integrationId)) {
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
return [...current, integrationId];
|
|
||||||
});
|
|
||||||
setActiveIntegrationId(integrationId);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = form.onSubmit(async (values) => {
|
const handleSubmit = form.onSubmit(async (values) => {
|
||||||
@@ -196,13 +147,6 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const integrationEditHandle of integrationEditHandles.current.values()) {
|
|
||||||
const integrationSaved = await integrationEditHandle.submitIfDirty();
|
|
||||||
if (!integrationSaved) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
innerProps.onSuccessfulEdit({
|
innerProps.onSuccessfulEdit({
|
||||||
...values,
|
...values,
|
||||||
advancedOptions,
|
advancedOptions,
|
||||||
@@ -296,7 +240,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
|||||||
<FormProvider form={form}>
|
<FormProvider form={form}>
|
||||||
{showResourceTabs ? (
|
{showResourceTabs ? (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Tabs value={activeTab} onChange={setActiveTab}>
|
<Tabs value={activeTab} onChange={handleTabChange}>
|
||||||
<Tabs.List grow>
|
<Tabs.List grow>
|
||||||
<Tabs.Tab value="widget">{t("item.edit.tab.widget")}</Tabs.Tab>
|
<Tabs.Tab value="widget">{t("item.edit.tab.widget")}</Tabs.Tab>
|
||||||
{showAppTab && <Tabs.Tab value="app">{t("item.edit.tab.app")}</Tabs.Tab>}
|
{showAppTab && <Tabs.Tab value="app">{t("item.edit.tab.app")}</Tabs.Tab>}
|
||||||
@@ -313,68 +257,42 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
|||||||
{showIntegrationTab && (
|
{showIntegrationTab && (
|
||||||
<Tabs.Panel value="integration" pt="md">
|
<Tabs.Panel value="integration" pt="md">
|
||||||
<Stack>
|
<Stack>
|
||||||
{displayedIntegrationId === null && (
|
<Text size="sm" c="dimmed">
|
||||||
<>
|
{t("item.edit.integration.description")}
|
||||||
<Text size="sm" c="dimmed">
|
</Text>
|
||||||
{t("item.edit.integration.description")}
|
{selectedIntegrations.map((integration) => {
|
||||||
</Text>
|
const canEdit = canModifyAllIntegrations || integration.permissions?.hasFullAccess === true;
|
||||||
{selectedIntegrations.map((integration) => {
|
return (
|
||||||
const canEdit = editableIntegrationIds.includes(integration.id);
|
<Group key={integration.id} justify="space-between" wrap="nowrap" gap="sm">
|
||||||
return (
|
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
<Group key={integration.id} justify="space-between" wrap="nowrap" gap="sm">
|
<IntegrationAvatar kind={integration.kind} size="sm" />
|
||||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||||
<IntegrationAvatar kind={integration.kind} size="sm" />
|
<Text fw={600} size="sm" truncate>
|
||||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
{integration.name}
|
||||||
<Text fw={600} size="sm" truncate>
|
</Text>
|
||||||
{integration.name}
|
<Text size="xs" c="dimmed" truncate>
|
||||||
</Text>
|
{integration.url}
|
||||||
<Text size="xs" c="dimmed" truncate>
|
</Text>
|
||||||
{integration.url}
|
</Stack>
|
||||||
</Text>
|
</Group>
|
||||||
</Stack>
|
{canEdit ? (
|
||||||
</Group>
|
<Button
|
||||||
{canEdit ? (
|
type="button"
|
||||||
<Button
|
variant="light"
|
||||||
type="button"
|
leftSection={<IconPencil size={16} />}
|
||||||
variant="light"
|
onClick={() => innerProps.onEditIntegration?.(integration.id)}
|
||||||
leftSection={<IconPencil size={16} />}
|
aria-label={t("item.edit.integration.editLabel", { name: integration.name })}
|
||||||
onClick={() => beginEditingIntegration(integration.id)}
|
>
|
||||||
aria-label={t("item.edit.integration.editLabel", { name: integration.name })}
|
{t("item.edit.integration.action")}
|
||||||
>
|
</Button>
|
||||||
{t("item.edit.integration.action")}
|
) : (
|
||||||
</Button>
|
<Text size="xs" c="dimmed" ta="end">
|
||||||
) : (
|
{t("item.edit.integration.fullAccessRequired")}
|
||||||
<Text size="xs" c="dimmed" ta="end">
|
</Text>
|
||||||
{t("item.edit.integration.fullAccessRequired")}
|
)}
|
||||||
</Text>
|
</Group>
|
||||||
)}
|
);
|
||||||
</Group>
|
})}
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{displayedIntegrationId !== null && selectedIntegrations.length > 1 && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="subtle"
|
|
||||||
leftSection={<IconArrowLeft size={16} />}
|
|
||||||
onClick={() => setActiveIntegrationId(null)}
|
|
||||||
style={{ alignSelf: "start" }}
|
|
||||||
>
|
|
||||||
{t("common.action.previous")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{integrationIdsToRender.map((integrationId) => (
|
|
||||||
<Box key={integrationId} hidden={displayedIntegrationId !== integrationId}>
|
|
||||||
{IntegrationEditForm && (
|
|
||||||
<IntegrationEditForm
|
|
||||||
integrationId={integrationId}
|
|
||||||
handleRef={getIntegrationEditRef(integrationId)}
|
|
||||||
onSuccess={innerProps.onIntegrationSaved}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in new issue
Block a user