Files
kandev/apps/web/components/settings/notifications-settings-external-providers.tsx
T
2026-09-22 21:24:43 +01:00

434 lines
14 KiB
TypeScript

"use client";
import { type FormEvent } from "react";
import { IconBell, IconRefresh } from "@tabler/icons-react";
import { Trans, useTranslation } from "react-i18next";
import { Button } from "@kandev/ui/button";
import { Input } from "@kandev/ui/input";
import { Textarea } from "@kandev/ui/textarea";
import { SettingsGroup } from "@/components/settings/settings-group";
import { type AppriseFormMode } from "@/components/settings/notifications-settings-actions";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip";
import type { NotificationProvider } from "@/lib/types/http";
import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences";
import { controlSizingClassName } from "@kandev/ui/control-sizing";
function AppriseProviderCardActions({
provider,
onOpenForm,
onDeleteProvider,
onTestProvider,
}: {
provider: NotificationProvider;
onOpenForm: (mode: AppriseFormMode, provider: NotificationProvider) => void;
onDeleteProvider: (providerId: string) => void;
onTestProvider: (providerId: string) => Promise<void>;
}) {
const { t } = useTranslation();
return (
<div className="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="cursor-pointer"
aria-label={t("settings:sendTestNotificationFor", { name: provider.name })}
onClick={() => void onTestProvider(provider.id)}
>
<IconBell className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("settings:sendTestNotification")}</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
variant="outline"
size="sm"
className="cursor-pointer"
onClick={() => onOpenForm("edit", provider)}
>
{t("settings:edit")}
</Button>
<Button
variant="outline"
size="sm"
className="cursor-pointer"
onClick={() => onDeleteProvider(provider.id)}
>
{t("settings:remove")}
</Button>
</div>
);
}
function AppriseProviderList({
providers,
baselineProviders,
appriseFormMode,
activeAppriseId,
appriseName,
appriseUrls,
onNameChange,
onUrlsChange,
onAppriseNameEdit,
onAppriseEdit,
onOpenForm,
onCloseForm,
onCancelForm,
onDeleteProvider,
onTestProvider,
onTextareaInput,
}: {
providers: NotificationProvider[];
baselineProviders: NotificationProvider[];
appriseFormMode: AppriseFormMode;
activeAppriseId: string | null;
appriseName: string;
appriseUrls: string;
onNameChange: (value: string) => void;
onUrlsChange: (value: string) => void;
onAppriseNameEdit: (providerId: string, value: string) => void;
onAppriseEdit: (providerId: string, value: string) => void;
onOpenForm: (mode: AppriseFormMode, provider?: NotificationProvider) => void;
onCloseForm: () => void;
onCancelForm: () => void;
onDeleteProvider: (providerId: string) => void;
onTestProvider: (providerId: string) => Promise<void>;
onTextareaInput: (event: FormEvent<HTMLTextAreaElement>) => void;
}) {
return (
<>
{providers.map((provider) => {
const isEditing = appriseFormMode === "edit" && activeAppriseId === provider.id;
const baseline = baselineProviders.find((candidate) => candidate.id === provider.id);
const nameIsDirty = isEditing && provider.name !== baseline?.name;
const urlsIsDirty =
isEditing &&
JSON.stringify(provider.config?.urls ?? []) !==
JSON.stringify(baseline?.config?.urls ?? []);
return (
<div
key={provider.id}
className="rounded-lg border border-muted p-4 space-y-3"
data-settings-dirty={nameIsDirty || urlsIsDirty}
data-settings-dirty-level="container"
>
{isEditing ? (
<AppriseProviderForm
mode="edit"
name={appriseName}
urls={appriseUrls}
onNameChange={(value) => {
onNameChange(value);
onAppriseNameEdit(provider.id, value);
}}
onUrlsChange={(value) => {
onUrlsChange(value);
onAppriseEdit(provider.id, value);
}}
onSubmit={onCloseForm}
onCancel={onCancelForm}
onInput={onTextareaInput}
nameIsDirty={nameIsDirty}
urlsIsDirty={urlsIsDirty}
/>
) : (
<div className="flex items-center justify-between gap-4">
<div className="space-y-1 flex-1">
<div className="font-medium">{provider.name}</div>
{/* Product name — never translated (docs/i18n.md "Do not translate"). */}
<div className="text-xs text-muted-foreground">Apprise</div>
</div>
<AppriseProviderCardActions
provider={provider}
onOpenForm={onOpenForm}
onDeleteProvider={onDeleteProvider}
onTestProvider={onTestProvider}
/>
</div>
)}
</div>
);
})}
</>
);
}
type ExternalProvidersSectionProps = {
appriseAvailable: boolean;
appriseProviders: NotificationProvider[];
baselineProviders: NotificationProvider[];
appriseFormMode: AppriseFormMode;
activeAppriseId: string | null;
appriseName: string;
appriseUrls: string;
showAppriseForm: boolean;
notificationProvidersLoaded: boolean;
notificationProvidersLoading: boolean;
appriseRescanPending: boolean;
appriseRescanError: boolean;
appriseRescanResult: boolean | null;
setAppriseName: (v: string) => void;
setAppriseUrls: (v: string) => void;
onAppriseNameEdit: (id: string, v: string) => void;
onAppriseEdit: (id: string, v: string) => void;
onOpenForm: (mode: AppriseFormMode, provider?: NotificationProvider) => void;
onCloseForm: () => void;
onCancelForm: () => void;
onDeleteProvider: (id: string) => void;
onTestProvider: (id: string) => Promise<void>;
onRescanApprise: () => Promise<void>;
};
function AppriseRescanControls({
notificationProvidersLoaded,
notificationProvidersLoading,
appriseRescanPending,
appriseRescanError,
appriseRescanResult,
onRescanApprise,
}: Pick<
ExternalProvidersSectionProps,
| "notificationProvidersLoaded"
| "notificationProvidersLoading"
| "appriseRescanPending"
| "appriseRescanError"
| "appriseRescanResult"
| "onRescanApprise"
>) {
const { t } = useTranslation();
return (
<div className="flex min-w-0 flex-col items-start gap-1 md:items-end">
<Button
variant="outline"
className={controlSizingClassName("standard", "cursor-pointer")}
data-testid="apprise-rescan"
disabled={
!notificationProvidersLoaded || notificationProvidersLoading || appriseRescanPending
}
onClick={() => void onRescanApprise()}
>
<IconRefresh className={appriseRescanPending ? "h-4 w-4 animate-spin" : "h-4 w-4"} />
{appriseRescanPending ? t("settings:checkingApprise") : t("settings:rescanApprise")}
</Button>
<p className="text-xs text-muted-foreground">{t("settings:appriseDetectionServer")}</p>
{appriseRescanError && (
<p className="text-xs text-amber-600" role="alert" data-testid="apprise-rescan-error">
{t("settings:appriseRescanFailed")}
</p>
)}
{!appriseRescanPending && !appriseRescanError && appriseRescanResult !== null && (
<p
className="text-xs text-muted-foreground"
role="status"
data-testid="apprise-rescan-result"
>
{appriseRescanResult ? t("settings:appriseDetected") : t("settings:appriseNotDetected")}
</p>
)}
</div>
);
}
function AppriseProviderSectionBody({
appriseAvailable,
appriseProviders,
baselineProviders,
appriseFormMode,
activeAppriseId,
appriseName,
appriseUrls,
showAppriseForm,
setAppriseName,
setAppriseUrls,
onAppriseNameEdit,
onAppriseEdit,
onOpenForm,
onCloseForm,
onCancelForm,
onDeleteProvider,
onTestProvider,
}: Omit<
ExternalProvidersSectionProps,
| "notificationProvidersLoaded"
| "notificationProvidersLoading"
| "appriseRescanPending"
| "appriseRescanError"
| "appriseRescanResult"
| "onRescanApprise"
>) {
const { t } = useTranslation();
return (
<>
{!appriseAvailable && (
<p className="text-xs text-muted-foreground">
{/* The link is part of the sentence, so the whole notice is one
message and the <2> tag addresses the anchor by child index. */}
<Trans i18nKey="settings:appriseNotInstalled">
Apprise is not installed yet. You can add it later to enable remote notifications.{" "}
<a
className="underline"
href="https://github.com/caronc/apprise?tab=readme-ov-file#installation"
target="_blank"
rel="noreferrer"
>
View installation instructions
</a>
.
</Trans>
</p>
)}
{appriseProviders.length === 0 && (
<p className="text-xs text-muted-foreground">{t("settings:noAppriseProviders")}</p>
)}
<AppriseProviderList
providers={appriseProviders}
baselineProviders={baselineProviders}
appriseFormMode={appriseFormMode}
activeAppriseId={activeAppriseId}
appriseName={appriseName}
appriseUrls={appriseUrls}
onNameChange={setAppriseName}
onUrlsChange={setAppriseUrls}
onAppriseNameEdit={onAppriseNameEdit}
onAppriseEdit={onAppriseEdit}
onOpenForm={onOpenForm}
onCloseForm={onCloseForm}
onCancelForm={onCancelForm}
onDeleteProvider={onDeleteProvider}
onTestProvider={onTestProvider}
onTextareaInput={handleTextareaInput}
/>
<div className="space-y-3">
{appriseAvailable && (
<Button
variant="outline"
className="cursor-pointer"
onClick={() => onOpenForm("create")}
disabled={showAppriseForm}
>
{t("settings:addAppriseProvider")}
</Button>
)}
{showAppriseForm && appriseFormMode === "create" && (
<AppriseProviderForm
mode="create"
name={appriseName}
urls={appriseUrls}
onNameChange={setAppriseName}
onUrlsChange={setAppriseUrls}
onSubmit={onCloseForm}
onCancel={onCancelForm}
onInput={handleTextareaInput}
formIsDirty
nameIsDirty={appriseName.length > 0}
urlsIsDirty={appriseUrls.length > 0}
showSubmit={false}
/>
)}
</div>
</>
);
}
export function ExternalProvidersSection(props: ExternalProvidersSectionProps) {
const { t } = useTranslation();
return (
<SettingsGroup
title={t("settings:externalProviders")}
description={
<span data-testid="external-providers-description">
{t("settings:externalProvidersDescription")}
</span>
}
discoveryTargetId={GENERAL_SETTINGS_TARGETS.notificationProviders}
action={
<AppriseRescanControls
notificationProvidersLoaded={props.notificationProvidersLoaded}
notificationProvidersLoading={props.notificationProvidersLoading}
appriseRescanPending={props.appriseRescanPending}
appriseRescanError={props.appriseRescanError}
appriseRescanResult={props.appriseRescanResult}
onRescanApprise={props.onRescanApprise}
/>
}
>
<AppriseProviderSectionBody {...props} />
</SettingsGroup>
);
}
type AppriseProviderFormProps = {
mode: AppriseFormMode;
name: string;
urls: string;
onNameChange: (value: string) => void;
onUrlsChange: (value: string) => void;
onSubmit: () => void | Promise<void>;
onCancel: () => void;
onInput: (event: FormEvent<HTMLTextAreaElement>) => void;
nameIsDirty?: boolean;
urlsIsDirty?: boolean;
formIsDirty?: boolean;
showSubmit?: boolean;
};
function AppriseProviderForm({
mode,
name,
urls,
onNameChange,
onUrlsChange,
onSubmit,
onCancel,
onInput,
nameIsDirty = false,
urlsIsDirty = false,
formIsDirty = nameIsDirty || urlsIsDirty,
showSubmit = true,
}: AppriseProviderFormProps) {
const { t } = useTranslation();
return (
<div
className="rounded-lg border border-dashed border-muted p-4 space-y-3"
data-settings-dirty={formIsDirty}
data-settings-dirty-level="container"
>
<div className="text-sm font-medium">{t("settings:appriseProvider")}</div>
<Input
value={name}
onChange={(event) => onNameChange(event.target.value)}
placeholder={t("settings:providerName")}
data-settings-dirty={nameIsDirty}
/>
<Textarea
value={urls}
onChange={(event) => onUrlsChange(event.target.value)}
onInput={onInput}
placeholder={t("settings:appriseServiceUrls")}
rows={1}
className="min-h-0 h-auto"
data-settings-dirty={urlsIsDirty}
/>
<div className="flex items-center gap-2">
{showSubmit && (
<Button className="cursor-pointer" onClick={onSubmit}>
{mode === "create" ? t("settings:addProvider") : t("settings:done")}
</Button>
)}
<Button variant="ghost" className="cursor-pointer" onClick={onCancel}>
{t("settings:cancel")}
</Button>
</div>
</div>
);
}
function handleTextareaInput(event: FormEvent<HTMLTextAreaElement>) {
const t = event.currentTarget;
t.style.height = "auto";
t.style.height = `${t.scrollHeight}px`;
}