diff --git a/packages/core/src/builtins/base/debrid.ts b/packages/core/src/builtins/base/debrid.ts index afa5c4ec..1f2974dc 100644 --- a/packages/core/src/builtins/base/debrid.ts +++ b/packages/core/src/builtins/base/debrid.ts @@ -2,6 +2,7 @@ import { CacheAndPlaySchema, Manifest, Meta, + NNTPServersSchema, Stream, } from '../../db/schemas.js'; import { z, ZodError } from 'zod'; @@ -217,7 +218,7 @@ export abstract class BaseDebridAddon { (s) => !['nzbdav', 'altmount'].includes(s.id) // usenet only services excluded ); const nzbServices = this.userData.services.filter( - (s) => ['nzbdav', 'altmount', 'torbox'].includes(s.id) // only keep services that support usenet + (s) => ['nzbdav', 'altmount', 'torbox', 'stremio_nntp'].includes(s.id) // only keep services that support usenet ); if (torrentServices.length === 0 && torrentResults.length > 0) { @@ -255,16 +256,50 @@ export abstract class BaseDebridAddon { ), ]); + let servers: string[] | undefined; + const encodedNntpServers = this.userData?.services.find( + (s) => s.id === 'stremio_nntp' + )?.credential; + try { + if (encodedNntpServers) { + const nntpServers = NNTPServersSchema.parse( + JSON.parse( + Buffer.from(encodedNntpServers, 'base64').toString('utf-8') + ) + ); + // servers - array, a list of strings that each represent a connection to a NNTP (usenet) server (for nzbUrl) in the form of nntp(s)://{user}:{pass}@{nntpDomain}:{nntpPort}/{nntpConnections} (nntps = SSL; nntp = no encryption) (example: nntps://myuser:mypass@news.example.com/4) + servers = nntpServers.map( + (s) => + `${s.ssl ? 'nntps' : 'nntp'}://${encodeURIComponent( + s.username + )}:${encodeURIComponent(s.password)}@${s.host}:${s.port}/${ + s.connections + }` + ); + } + } catch (error) { + if (error instanceof ZodError) { + this.logger.error( + `Failed to parse NNTP servers for Stremio NNTP stream: ${formatZodError(error)}` + ); + } + throw error; + } + const encryptedStoreAuths = this.userData.services.reduce( (acc, service) => { const auth = { id: service.id, credential: service.credential, }; - acc[service.id] = encryptString(JSON.stringify(auth)).data ?? ''; + if (service.id === 'stremio_nntp' && servers) { + acc[service.id] = servers; + } else { + acc[service.id] = encryptString(JSON.stringify(auth)).data ?? ''; + } return acc; }, - {} as Record + {} as Record ); const debridTitleMetadata: DebridTitleMetadata = { titles: searchMetadata.titles, @@ -337,8 +372,8 @@ export abstract class BaseDebridAddon { results.map((result) => { const stream = this._createStream( result, - encryptedStoreAuths, - metadataId + metadataId, + encryptedStoreAuths ); if ( result.service?.id === 'nzbdav' && @@ -673,8 +708,8 @@ export abstract class BaseDebridAddon { protected _createStream( torrentOrNzb: TorrentWithSelectedFile | NZBWithSelectedFile, - encryptedStoreAuths: Record, - metadataId: string + metadataId: string, + encryptedStoreAuths: Record ): Stream { // Handle debrid streaming const encryptedStoreAuth = torrentOrNzb.service @@ -722,18 +757,27 @@ export abstract class BaseDebridAddon { }`; return { - url: torrentOrNzb.service - ? generatePlaybackUrl( - encryptedStoreAuth!, - metadataId!, - fileInfo!, - torrentOrNzb.title, - torrentOrNzb.file.name - ) - : undefined, + url: + torrentOrNzb.service && torrentOrNzb.service.id != 'stremio_nntp' + ? generatePlaybackUrl( + encryptedStoreAuth! as string, + metadataId!, + fileInfo!, + torrentOrNzb.title, + torrentOrNzb.file.name + ) + : undefined, + nzbUrl: torrentOrNzb.type === 'usenet' ? torrentOrNzb.nzb : undefined, + servers: + torrentOrNzb.service?.id === 'stremio_nntp' + ? (encryptedStoreAuth as string[]) + : undefined, name, description, - type: torrentOrNzb.type, + type: + torrentOrNzb.service?.id === 'stremio_nntp' + ? 'stremio-usenet' + : torrentOrNzb.type, age: torrentOrNzb.age, infoHash: torrentOrNzb.hash, fileIdx: torrentOrNzb.file.index, diff --git a/packages/core/src/builtins/newznab/addon.ts b/packages/core/src/builtins/newznab/addon.ts index 43796f7e..40098cef 100644 --- a/packages/core/src/builtins/newznab/addon.ts +++ b/packages/core/src/builtins/newznab/addon.ts @@ -42,6 +42,7 @@ export class NewznabAddon extends BaseNabAddon { constants.TORBOX_SERVICE, constants.NZBDAV_SERVICE, constants.ALTMOUNT_SERVICE, + constants.STREMIO_NNTP_SERVICE, ].includes(s.id) ) ) { diff --git a/packages/core/src/db/schemas.ts b/packages/core/src/db/schemas.ts index fa187431..206992ca 100644 --- a/packages/core/src/db/schemas.ts +++ b/packages/core/src/db/schemas.ts @@ -193,6 +193,7 @@ const OptionDefinition = z.object({ 'alert', 'socials', 'oauth', + 'custom-nntp-servers', ]), oauth: z .object({ @@ -606,9 +607,34 @@ export const SubtitleResponseSchema = z.object({ export type SubtitleResponse = z.infer; export type Subtitle = z.infer; +export const SourceSchema = z.object({ + url: z.string(), + bytes: z.number().nullable().optional(), +}); + +const NNTPServerSchema = z.object({ + username: z.string(), + password: z.string(), + host: z.string(), + port: z.number(), + ssl: z.boolean(), + connections: z.number(), +}); + +export const NNTPServersSchema = z.array(NNTPServerSchema); + +export type NNTPServers = z.infer; + export const StreamSchema = z .object({ url: z.string().or(z.null()).optional(), + nzbUrl: z.string().or(z.null()).optional(), + servers: z.array(z.string().min(1)).nullable().optional(), + rarUrls: z.array(SourceSchema).nullable().optional(), + zipUrls: z.array(SourceSchema).nullable().optional(), + '7zipUrls': z.array(SourceSchema).nullable().optional(), + tgzUrls: z.array(SourceSchema).nullable().optional(), + tarUrls: z.array(SourceSchema).nullable().optional(), ytId: z.string().nullable().optional(), infoHash: z.string().nullable().optional(), fileIdx: z.number().or(z.null()).optional(), @@ -718,6 +744,13 @@ export const ParsedStreamSchema = z.object({ duration: z.number().optional(), library: z.boolean().optional(), url: z.string().optional(), + nzbUrl: z.string().optional(), + servers: z.array(z.string().min(1)).optional(), + rarUrls: z.array(SourceSchema).nullable().optional(), + zipUrls: z.array(SourceSchema).nullable().optional(), + '7zipUrls': z.array(SourceSchema).nullable().optional(), + tgzUrls: z.array(SourceSchema).nullable().optional(), + tarUrls: z.array(SourceSchema).nullable().optional(), ytId: z.string().min(1).optional(), externalUrl: z.string().min(1).optional(), error: z diff --git a/packages/core/src/debrid/index.ts b/packages/core/src/debrid/index.ts index 154ef6ee..118c1e64 100644 --- a/packages/core/src/debrid/index.ts +++ b/packages/core/src/debrid/index.ts @@ -12,6 +12,7 @@ import { TorboxDebridService } from './torbox.js'; import { StremThruPreset } from '../presets/stremthru.js'; import { NzbDAVService } from './nzbdav.js'; import { AltmountService } from './altmount.js'; +import { StremioNNTPService } from './stremio-nntp.js'; export function getDebridService( serviceName: ServiceId, @@ -30,6 +31,8 @@ export function getDebridService( return new NzbDAVService(config); case 'altmount': return new AltmountService(config); + case 'stremio_nntp': + return new StremioNNTPService(config); default: if (StremThruPreset.supportedServices.includes(serviceName)) { return new StremThruInterface({ ...config, serviceName }); diff --git a/packages/core/src/debrid/stremio-nntp.ts b/packages/core/src/debrid/stremio-nntp.ts new file mode 100644 index 00000000..828a4197 --- /dev/null +++ b/packages/core/src/debrid/stremio-nntp.ts @@ -0,0 +1,72 @@ +// Credit goes to Sanket9225 for the idea and inspiration +// https://github.com/Sanket9225/UsenetStreamer/blob/master/server.js + +import { z } from 'zod'; +import { + UsenetStreamService, + UsenetStreamServiceConfig, +} from './usenet-stream-base.js'; +import { + DebridDownload, + DebridService, + DebridServiceConfig, + PlaybackInfo, +} from './base.js'; +import { ServiceId, createLogger, fromUrlSafeBase64 } from '../utils/index.js'; +import { NNTPServers, NNTPServersSchema } from '../db/schemas.js'; + +const logger = createLogger('stremio-nntp'); + +export class StremioNNTPService implements DebridService { + readonly serviceName: ServiceId = 'stremio_nntp'; + readonly serviceLogger = logger; + + private servers: NNTPServers; + + supportsUsenet: boolean = true; + + constructor(config: DebridServiceConfig) { + const parsedConfig = NNTPServersSchema.parse( + JSON.parse(Buffer.from(config.token, 'base64').toString()) + ); + this.servers = parsedConfig; + } + + checkMagnets(magnets: string[], sid?: string): Promise { + throw new Error('Method not implemented.'); + } + + listMagnets(): Promise { + throw new Error('Method not implemented.'); + } + + addMagnet(magnet: string): Promise { + throw new Error('Method not implemented.'); + } + async checkNzbs( + nzbs: { name?: string; hash?: string }[], + checkOwned?: boolean + ): Promise { + return nzbs.map(({ hash: h, name: n }, index) => { + return { + id: index, + status: 'cached', + library: false, + hash: h, + name: n, + }; + }); + } + + resolve( + playbackInfo: PlaybackInfo, + filename: string, + cacheAndPlay: boolean + ): Promise { + throw new Error('Method not implemented.'); + } + + generateTorrentLink(link: string, clientIp?: string): Promise { + throw new Error('Method not implemented.'); + } +} diff --git a/packages/core/src/parser/streams.ts b/packages/core/src/parser/streams.ts index 313f6363..4151d2dd 100644 --- a/packages/core/src/parser/streams.ts +++ b/packages/core/src/parser/streams.ts @@ -75,6 +75,12 @@ class StreamParser { type: 'http', proxied: this.isProxied(stream), url: this.applyUrlModifications(stream.url ?? undefined), + nzbUrl: stream.nzbUrl ?? undefined, + tarUrls: stream.tarUrls ?? undefined, + tgzUrls: stream.tgzUrls ?? undefined, + '7zipUrls': stream['7zipUrls'] ?? undefined, + rarUrls: stream.rarUrls ?? undefined, + servers: stream.servers ?? undefined, externalUrl: stream.externalUrl ?? undefined, ytId: stream.ytId ?? undefined, requestHeaders: stream.behaviorHints?.proxyHeaders?.request, @@ -436,6 +442,17 @@ class StreamParser { return 'youtube'; } + if (stream.nzbUrl) { + return 'stremio-usenet'; + } + if ( + stream['7zipUrls']?.length || + stream.rarUrls?.length || + stream?.tarUrls?.length || + stream.tgzUrls?.length + ) { + return 'archive'; + } throw new Error('Invalid stream, missing a required stream property'); } diff --git a/packages/core/src/presets/builtin.ts b/packages/core/src/presets/builtin.ts index d21015f0..c343ae05 100644 --- a/packages/core/src/presets/builtin.ts +++ b/packages/core/src/presets/builtin.ts @@ -74,7 +74,9 @@ export class BuiltinStreamParser extends StreamParser { service: ParsedStream['service'], currentParsedStream: ParsedStream ): ParsedStream['type'] { - return (stream as any).type === 'usenet' ? 'usenet' : 'debrid'; + return stream.type === 'torrent' + ? 'debrid' + : (stream.type as 'usenet' | 'stremio-usenet'); } } @@ -102,6 +104,8 @@ export class BuiltinAddonPreset extends Preset { aiostreamsAuth: credentials.aiostreamsAuth, }) ), + [constants.STREMIO_NNTP_SERVICE]: (credentials: any) => + credentials.servers, // this will be a base64 encoded json string of the nntp server config { username, password, host, port, useSsl, connections }[] }; const altmountSpecialCase: Partial< Record any> diff --git a/packages/core/src/presets/newznab.ts b/packages/core/src/presets/newznab.ts index be2d02b6..fdb24de1 100644 --- a/packages/core/src/presets/newznab.ts +++ b/packages/core/src/presets/newznab.ts @@ -10,6 +10,7 @@ export class NewznabPreset extends BuiltinAddonPreset { constants.TORBOX_SERVICE, constants.NZBDAV_SERVICE, constants.ALTMOUNT_SERVICE, + constants.STREMIO_NNTP_SERVICE, ] as ServiceId[]; const options: Option[] = [ { diff --git a/packages/core/src/presets/nzbhydra.ts b/packages/core/src/presets/nzbhydra.ts index 79d98073..663d66be 100644 --- a/packages/core/src/presets/nzbhydra.ts +++ b/packages/core/src/presets/nzbhydra.ts @@ -10,6 +10,7 @@ export class NZBHydraPreset extends NewznabPreset { constants.TORBOX_SERVICE, constants.NZBDAV_SERVICE, constants.ALTMOUNT_SERVICE, + constants.STREMIO_NNTP_SERVICE, ] as ServiceId[]; const options: Option[] = [ { diff --git a/packages/core/src/streams/deduplicator.ts b/packages/core/src/streams/deduplicator.ts index 5d892081..0df808e9 100644 --- a/packages/core/src/streams/deduplicator.ts +++ b/packages/core/src/streams/deduplicator.ts @@ -139,7 +139,12 @@ class StreamDeduplicator { const streamsByType = new Map(); for (const stream of group) { let type = stream.type as string; - if ((type === 'debrid' || type === 'usenet') && stream.service) { + if ( + (type === 'debrid' || + type === 'usenet' || + type === 'stremio-usenet') && + stream.service + ) { type = stream.service.cached ? 'cached' : 'uncached'; } if (stream.addon.resultPassthrough) { diff --git a/packages/core/src/transformers/api.ts b/packages/core/src/transformers/api.ts index 14c3d78f..814987c5 100644 --- a/packages/core/src/transformers/api.ts +++ b/packages/core/src/transformers/api.ts @@ -2,6 +2,7 @@ import { ParsedFileSchema, ParsedStream, Resource, + SourceSchema, SubtitleSchema, UserData, } from '../db/index.js'; @@ -27,6 +28,11 @@ const SearchApiResultSchema = z.object({ externalUrl: z.string().nullable(), fileIdx: z.number().nullable(), url: z.string().nullable(), + nzbUrl: z.string().nullable(), + rarUrls: z.array(SourceSchema).nullable(), + '7zipUrls': z.array(SourceSchema).nullable(), + tarUrls: z.array(SourceSchema).nullable(), + tgzUrls: z.array(SourceSchema).nullable(), proxied: z.boolean(), filename: z.string().nullable(), folderName: z.string().nullable(), @@ -64,9 +70,14 @@ export class ApiTransformer { const { data, errors } = response; let filteredCount = 0; const results: SearchApiResult[] = data.streams - .map((stream) => ({ + .map((stream: ParsedStream) => ({ infoHash: stream.torrent?.infoHash ?? null, url: stream.url ?? null, + nzbUrl: stream.nzbUrl ?? null, + rarUrls: stream.rarUrls ?? null, + '7zipUrls': stream['7zipUrls'] ?? null, + tarUrls: stream.tarUrls ?? null, + tgzUrls: stream.tgzUrls ?? null, seeders: stream.torrent?.seeders ?? null, age: stream.age ?? null, sources: stream.torrent?.sources ?? null, diff --git a/packages/core/src/transformers/stremio.ts b/packages/core/src/transformers/stremio.ts index 192c5f70..fd565fac 100644 --- a/packages/core/src/transformers/stremio.ts +++ b/packages/core/src/transformers/stremio.ts @@ -158,6 +158,34 @@ export class StremioTransformer { ytId: stream.type === 'youtube' ? stream.ytId : undefined, externalUrl: stream.type === 'external' ? stream.externalUrl : undefined, sources: stream.type === 'p2p' ? stream.torrent?.sources : undefined, + nzbUrl: + stream.type === constants.STREMIO_USENET_STREAM_TYPE + ? stream.nzbUrl + : undefined, + servers: + stream.type === constants.STREMIO_USENET_STREAM_TYPE + ? stream.servers + : undefined, + rarUrls: + stream.type === constants.ARCHIVE_STREAM_TYPE + ? stream.rarUrls + : undefined, + zipUrls: + stream.type === constants.ARCHIVE_STREAM_TYPE + ? stream.zipUrls + : undefined, + '7zipUrls': + stream.type === constants.ARCHIVE_STREAM_TYPE + ? stream['7zipUrls'] + : undefined, + tgzUrls: + stream.type === constants.ARCHIVE_STREAM_TYPE + ? stream.tgzUrls + : undefined, + tarUrls: + stream.type === constants.ARCHIVE_STREAM_TYPE + ? stream.tarUrls + : undefined, subtitles: stream.subtitles, behaviorHints: { countryWhitelist: stream.countryWhitelist, diff --git a/packages/core/src/utils/constants.ts b/packages/core/src/utils/constants.ts index 21f1b429..7c28117f 100644 --- a/packages/core/src/utils/constants.ts +++ b/packages/core/src/utils/constants.ts @@ -205,6 +205,7 @@ const SEEDR_SERVICE = 'seedr'; const EASYNEWS_SERVICE = 'easynews'; const NZBDAV_SERVICE = 'nzbdav'; const ALTMOUNT_SERVICE = 'altmount'; +const STREMIO_NNTP_SERVICE = 'stremio_nntp'; const SERVICES = [ REALDEBRID_SERVICE, @@ -221,6 +222,7 @@ const SERVICES = [ EASYNEWS_SERVICE, NZBDAV_SERVICE, ALTMOUNT_SERVICE, + STREMIO_NNTP_SERVICE, ] as const; export const BUILTIN_SUPPORTED_SERVICES = [ @@ -235,6 +237,7 @@ export const BUILTIN_SUPPORTED_SERVICES = [ OFFCLOUD_SERVICE, NZBDAV_SERVICE, ALTMOUNT_SERVICE, + STREMIO_NNTP_SERVICE, ] as const; export type ServiceId = (typeof SERVICES)[number]; @@ -386,6 +389,23 @@ const SERVICE_DETAILS: Record< }, ], }, + [STREMIO_NNTP_SERVICE]: { + id: STREMIO_NNTP_SERVICE, + name: 'Stremio NNTP', + shortName: 'SN', + knownNames: ['SN', 'Stremio NNTP', 'StremioNntp', 'Stremio-NNTP'], + signUpText: + "Stream usenet directly from your provider via Stremio's NNTP capabilities.", + credentials: [ + { + id: 'servers', + name: 'NNTP Servers', + description: 'Provide your Usenet NNTP server addresses', + type: 'custom-nntp-servers', + required: true, + }, + ], + }, [NZBDAV_SERVICE]: { id: NZBDAV_SERVICE, name: 'NzbDAV', @@ -1054,6 +1074,8 @@ const SORT_DIRECTIONS = ['asc', 'desc'] as const; export const P2P_STREAM_TYPE = 'p2p' as const; export const LIVE_STREAM_TYPE = 'live' as const; +export const STREMIO_USENET_STREAM_TYPE = 'stremio-usenet' as const; +export const ARCHIVE_STREAM_TYPE = 'archive' as const; export const USENET_STREAM_TYPE = 'usenet' as const; export const DEBRID_STREAM_TYPE = 'debrid' as const; export const HTTP_STREAM_TYPE = 'http' as const; @@ -1065,6 +1087,8 @@ export const STATISTIC_STREAM_TYPE = 'statistic' as const; const STREAM_TYPES = [ P2P_STREAM_TYPE, LIVE_STREAM_TYPE, + STREMIO_USENET_STREAM_TYPE, + ARCHIVE_STREAM_TYPE, USENET_STREAM_TYPE, DEBRID_STREAM_TYPE, HTTP_STREAM_TYPE, @@ -1256,6 +1280,7 @@ export { SEEDR_SERVICE, NZBDAV_SERVICE, ALTMOUNT_SERVICE, + STREMIO_NNTP_SERVICE, EASYNEWS_SERVICE, SERVICE_DETAILS, TOP_LEVEL_OPTION_DETAILS, diff --git a/packages/frontend/src/components/shared/template-option.tsx b/packages/frontend/src/components/shared/template-option.tsx index 846e0383..52c3c743 100644 --- a/packages/frontend/src/components/shared/template-option.tsx +++ b/packages/frontend/src/components/shared/template-option.tsx @@ -3,15 +3,17 @@ import { NumberInput } from '../ui/number-input'; import { Switch } from '../ui/switch'; import { Select } from '../ui/select'; import { Combobox } from '../ui/combobox'; -import { Option } from '@aiostreams/core'; -import React, { useState } from 'react'; +import { Option, NNTPServers } from '@aiostreams/core'; +import React, { useState, useEffect } from 'react'; import MarkdownLite from './markdown-lite'; import { Alert } from '../ui/alert'; import { SocialIcon } from './social-icon'; import { PasswordInput } from '../ui/password-input'; import { Button } from '../ui/button'; import { IconButton } from '../ui/button'; -import { ArrowLeftIcon, KeyIcon } from 'lucide-react'; +import { FaKey, FaChevronUp, FaChevronDown, FaArrowLeft } from 'react-icons/fa'; +import { Modal } from '../ui/modal'; +import { FaPlus, FaServer, FaTrashCan } from 'react-icons/fa6'; // this component, accepts an option and returns a component that renders the option. // string - TextInput // number - NumberInput @@ -341,7 +343,7 @@ const TemplateOption: React.FC = ({

} + icon={} intent="primary-outline" onClick={() => { window.open(oauth?.authorisationUrl || '', '_blank'); @@ -364,7 +366,7 @@ const TemplateOption: React.FC = ({ Enter {oauth?.oauthResultField?.name || 'Authorization Code'} } + icon={} intent="primary-subtle" size="sm" onClick={() => setShowInput(false)} @@ -391,9 +393,276 @@ const TemplateOption: React.FC = ({ ); } + case 'custom-nntp-servers': { + return ( + + ); + } default: return null; } }; +// Default empty server template +const createEmptyServer = (): NNTPServers[number] => ({ + username: '', + password: '', + host: '', + port: 563, + ssl: true, + connections: 8, +}); + +// Decode base64 value to servers array +const decodeServers = (value: string | undefined): NNTPServers => { + if (!value) return []; + try { + const decoded = Buffer.from(value, 'base64').toString('utf-8'); + return JSON.parse(decoded) as NNTPServers; + } catch { + return []; + } +}; + +// Encode servers array to base64 +const encodeServers = (servers: NNTPServers): string | undefined => { + if (servers.length === 0) return undefined; + return Buffer.from(JSON.stringify(servers)).toString('base64'); +}; + +interface NNTPServersInputProps { + name: string; + description?: string; + value: string | undefined; + onChange: (value: string | undefined) => void; + disabled?: boolean; +} + +function NNTPServersInput({ + name, + description, + value, + onChange, + disabled, +}: NNTPServersInputProps) { + const [modalOpen, setModalOpen] = useState(false); + const [servers, setServers] = useState([]); + + // Sync servers state when modal opens + useEffect(() => { + if (modalOpen) { + setServers(decodeServers(value)); + } + }, [modalOpen, value]); + + const serverCount = decodeServers(value).length; + + const handleAddServer = () => { + setServers((prev) => [...prev, createEmptyServer()]); + }; + + const handleRemoveServer = (index: number) => { + setServers((prev) => prev.filter((_, i) => i !== index)); + }; + + const handleMoveServer = (index: number, direction: 'up' | 'down') => { + setServers((prev) => { + const newServers = [...prev]; + const targetIndex = direction === 'up' ? index - 1 : index + 1; + if (targetIndex < 0 || targetIndex >= newServers.length) return prev; + [newServers[index], newServers[targetIndex]] = [ + newServers[targetIndex], + newServers[index], + ]; + return newServers; + }); + }; + + const handleServerChange = ( + index: number, + field: keyof NNTPServers[number], + fieldValue: any + ) => { + setServers((prev) => + prev.map((server, i) => + i === index ? { ...server, [field]: fieldValue } : server + ) + ); + }; + + const handleSave = () => { + // Filter out servers with empty required fields + const validServers = servers.filter((s) => s.host.trim() !== ''); + onChange(encodeServers(validServers)); + setModalOpen(false); + }; + + const handleCancel = () => { + setModalOpen(false); + }; + + return ( +
+
+
+

{name}

+ {description && ( +

+ {description} +

+ )} + {serverCount > 0 && ( +

+ {serverCount} server{serverCount !== 1 ? 's' : ''} configured +

+ )} +
+ } + intent="primary-outline" + onClick={() => setModalOpen(true)} + disabled={disabled} + className="shrink-0" + /> +
+ + +
+ {servers.length === 0 ? ( +
+ +

No servers configured

+

Click the button below to add a server

+
+ ) : ( + servers.map((server, index) => ( +
+
+ + Server {index + 1} + +
+ } + intent="gray-subtle" + size="sm" + onClick={() => handleMoveServer(index, 'up')} + disabled={index === 0} + /> + } + intent="gray-subtle" + size="sm" + onClick={() => handleMoveServer(index, 'down')} + disabled={index === servers.length - 1} + /> + } + intent="alert-subtle" + size="sm" + onClick={() => handleRemoveServer(index)} + /> +
+
+ +
+ handleServerChange(index, 'host', v)} + placeholder="news.example.com" + required + /> + handleServerChange(index, 'port', v)} + min={1} + max={65535} + required + /> + + handleServerChange(index, 'username', v) + } + placeholder="username" + required + /> + + handleServerChange(index, 'password', v) + } + placeholder="password" + required + /> + + handleServerChange(index, 'connections', v) + } + min={1} + max={500} + required + /> +
+ handleServerChange(index, 'ssl', v)} + side="right" + /> +
+
+
+ )) + )} + + +
+ +
+ + +
+
+
+ ); +} + export default TemplateOption;