From 481338afa3f3b3d633950838f0382133c43aa2b2 Mon Sep 17 00:00:00 2001 From: MidnightKittenCat <66114416+MidnightKittenCat@users.noreply.github.com> Date: Sun, 19 Oct 2025 07:11:11 +1000 Subject: [PATCH] feat: add template system (#438) Co-authored-by: MidnightKittenCat <66114416+MidnightKittenCat@users.noreply.github.com> Co-authored-by: Viren070 --- packages/core/src/db/schemas.ts | 15 + packages/core/src/utils/constants.ts | 30 + packages/core/src/utils/env.ts | 16 +- packages/core/src/utils/index.ts | 2 +- packages/core/src/utils/resources.ts | 23 - packages/core/src/utils/templates.ts | 94 ++ .../frontend/src/components/menu/about.tsx | 146 +- .../src/components/menu/save-install.tsx | 206 ++- .../shared/config-templates-modal.tsx | 1273 +++++++++++++++++ .../src/components/shared/markdown-lite.tsx | 22 +- .../shared/template-export-modal.tsx | 285 ++++ packages/server/src/app.ts | 2 + packages/server/src/routes/api/index.ts | 1 + packages/server/src/routes/api/templates.ts | 31 + packages/server/src/server.ts | 20 + 15 files changed, 2040 insertions(+), 126 deletions(-) delete mode 100644 packages/core/src/utils/resources.ts create mode 100644 packages/core/src/utils/templates.ts create mode 100644 packages/frontend/src/components/shared/config-templates-modal.tsx create mode 100644 packages/frontend/src/components/shared/template-export-modal.tsx create mode 100644 packages/server/src/routes/api/templates.ts diff --git a/packages/core/src/db/schemas.ts b/packages/core/src/db/schemas.ts index 99d4b276..19f599cb 100644 --- a/packages/core/src/db/schemas.ts +++ b/packages/core/src/db/schemas.ts @@ -993,3 +993,18 @@ export const RPDBIsValidResponse = z.object({ valid: z.boolean(), }); export type RPDBIsValidResponse = z.infer; + +export const TemplateSchema = z.object({ + metadata: z.object({ + name: z.string().min(1).max(20), // name of the template + description: z.string().min(1).max(500), // description of the template + author: z.string().min(1).max(20), // author of the template (predefined templates will have Vire) + predefined: z.boolean().optional(), // whether the template is predefined or not. + category: z.string().min(1).max(20), // category of the template + services: z.array(ServiceIds).optional(), + serviceRequired: z.boolean().optional(), // whether a service is required for this template or not. + }), + config: UserDataSchema, // config of the template +}); + +export type Template = z.infer; diff --git a/packages/core/src/utils/constants.ts b/packages/core/src/utils/constants.ts index f7b72e5c..d1214f41 100644 --- a/packages/core/src/utils/constants.ts +++ b/packages/core/src/utils/constants.ts @@ -542,6 +542,35 @@ const SERVICE_DETAILS: Record< }, }; +const TOP_LEVEL_OPTION_DETAILS: Record< + 'tmdbApiKey' | 'tmdbAccessToken' | 'rpdbApiKey' | 'tvdbApiKey', + { + name: string; + description: string; + } +> = { + tmdbApiKey: { + name: 'TMDB API Key', + description: + 'Get your free API key from [here](https://www.themoviedb.org/settings/api). Make sure to copy the 32 character API Key and not the Read Access Token.', + }, + tmdbAccessToken: { + name: 'TMDB Access Token', + description: + 'Get your free access token from [here](https://www.themoviedb.org/settings/api). Make sure to copy the Read Access Token and not the 32 character API Key.', + }, + rpdbApiKey: { + name: 'RPDB API Key', + description: + 'Get your free API key from [here](https://ratingposterdb.com/api-key/) for posters with ratings.', + }, + tvdbApiKey: { + name: 'TVDB API Key', + description: + 'Sign up for a free API Key at [TVDB](https://www.thetvdb.com/api-information) and then get it from your [dashboard](https://www.thetvdb.com/dashboard/account/apikeys).', + }, +}; + export const DEDUPLICATOR_KEYS = [ 'filename', 'infoHash', @@ -1042,5 +1071,6 @@ export { SEEDR_SERVICE, EASYNEWS_SERVICE, SERVICE_DETAILS, + TOP_LEVEL_OPTION_DETAILS, HEADERS_FOR_IP_FORWARDING, }; diff --git a/packages/core/src/utils/env.ts b/packages/core/src/utils/env.ts index 2382c926..878fbdf0 100644 --- a/packages/core/src/utils/env.ts +++ b/packages/core/src/utils/env.ts @@ -14,9 +14,9 @@ import { port, EnvMissingError, } from 'envalid'; -import { ResourceManager } from './resources.js'; import * as constants from './constants.js'; import { randomBytes } from 'crypto'; +import fs from 'fs'; // Get __dirname equivalent in ESM const __filename = fileURLToPath(import.meta.url); @@ -29,7 +29,19 @@ try { } let metadata: any = undefined; try { - metadata = ResourceManager.getResource('metadata.json') || {}; + function getResource(resourceName: string) { + const filePath = path.join( + __dirname, + '../../../../', + 'resources', + resourceName + ); + if (!fs.existsSync(filePath)) { + throw new Error(`Resource ${resourceName} not found at ${filePath}`); + } + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } + metadata = getResource('metadata.json') || {}; } catch (error) { console.error('Error loading metadata.json file', error); } diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index e172dee1..95dd4203 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -2,7 +2,7 @@ export * from './cache.js'; export * from './constants.js'; export * from './env.js'; export * from './logger.js'; -export * from './resources.js'; +export * from './templates.js'; export * from './feature.js'; export * from './crypto.js'; export * from './http.js'; diff --git a/packages/core/src/utils/resources.ts b/packages/core/src/utils/resources.ts deleted file mode 100644 index ad46d129..00000000 --- a/packages/core/src/utils/resources.ts +++ /dev/null @@ -1,23 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; - -// Get __dirname equivalent in ESM -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -export class ResourceManager { - static getResource(resourceName: string) { - // check existence - const filePath = path.join( - __dirname, - '../../../../', - 'resources', - resourceName - ); - if (!fs.existsSync(filePath)) { - throw new Error(`Resource ${resourceName} not found at ${filePath}`); - } - return JSON.parse(fs.readFileSync(filePath, 'utf8')); - } -} diff --git a/packages/core/src/utils/templates.ts b/packages/core/src/utils/templates.ts new file mode 100644 index 00000000..964c2cee --- /dev/null +++ b/packages/core/src/utils/templates.ts @@ -0,0 +1,94 @@ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { getDataFolder } from './general'; +import { Template, TemplateSchema } from '../db/schemas'; +import { ZodError } from 'zod'; +import { formatZodError } from './config'; + +// Get __dirname equivalent in ESM +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const RESOURCE_DIR = path.join(__dirname, '../../../../', 'resources'); + +export class TemplateManager { + private static templates: Template[] = []; + + static getTemplates(): Template[] { + return TemplateManager.templates; + } + + static loadTemplates(): { + detected: number; + loaded: number; + errors: { file: string; error: string }[]; + } { + const predefinedTemplatePath = path.join(RESOURCE_DIR, 'templates'); + const userTemplatesPath = path.join(getDataFolder(), 'templates'); + + // load all predefined templates first. look for all JSON files in the predefined template path. + const predefinedTemplates = this.loadTemplatesFromPath( + predefinedTemplatePath, + true + ); + const userTemplates = this.loadTemplatesFromPath(userTemplatesPath, false); + this.templates = [ + ...predefinedTemplates.templates, + ...userTemplates.templates, + ]; + return { + detected: predefinedTemplates.detected + userTemplates.detected, + loaded: predefinedTemplates.loaded + userTemplates.loaded, + errors: [...predefinedTemplates.errors, ...userTemplates.errors], + }; + } + + private static loadTemplatesFromPath( + dirPath: string, + predefined: boolean + ): { + templates: Template[]; + detected: number; + loaded: number; + errors: { file: string; error: string }[]; + } { + if (!fs.existsSync(dirPath)) { + return { templates: [], detected: 0, loaded: 0, errors: [] }; + } + const errors: { file: string; error: string }[] = []; + const templates = fs.readdirSync(dirPath); + const templateList: Template[] = []; + for (const file of templates) { + const filePath = path.join(dirPath, file); + try { + if (file.endsWith('.json')) { + const template = TemplateSchema.parse( + JSON.parse(fs.readFileSync(filePath, 'utf8')) + ); + templateList.push({ + ...template, + metadata: { + ...template.metadata, + predefined: predefined || false, + }, + }); + } + } catch (error) { + errors.push({ + file: file, + error: + error instanceof ZodError + ? `Failed to parse template: ${formatZodError(error)}` + : `Failed to load template: ${error}`, + }); + } + } + return { + templates: templateList, + detected: templates.length, + loaded: templateList.length, + errors, + }; + } +} diff --git a/packages/frontend/src/components/menu/about.tsx b/packages/frontend/src/components/menu/about.tsx index bc815525..da89acb7 100644 --- a/packages/frontend/src/components/menu/about.tsx +++ b/packages/frontend/src/components/menu/about.tsx @@ -13,6 +13,7 @@ import { CoffeeIcon, MessageCircleIcon, PencilIcon, + PlusIcon, } from 'lucide-react'; import { FaGithub, FaDiscord, FaChevronRight } from 'react-icons/fa'; import { BiDonateHeart, BiLogInCircle, BiLogOutCircle } from 'react-icons/bi'; @@ -33,6 +34,7 @@ import { DonationModal } from '../shared/donation-modal'; import { ModeSwitch } from '../ui/mode-switch/mode-switch'; import { ModeSelectModal } from '../shared/mode-select-modal'; import { ConfigModal } from '../config-modal'; +import { ConfigTemplatesModal } from '../shared/config-templates-modal'; import { ConfirmationDialog, useConfirmationDialog, @@ -134,6 +136,8 @@ AIOStreams consolidates multiple Stremio addons and debrid services - including const donationModal = useDisclosure(false); const customizeModal = useDisclosure(false); const signInModal = useDisclosure(false); + const templatesModal = useDisclosure(false); + const setupChoiceModal = useDisclosure(false); const customHtml = status?.settings?.customHtml; const confirmClearConfig = useConfirmationDialog({ @@ -229,33 +233,46 @@ AIOStreams consolidates multiple Stremio addons and debrid services - including )} {/* Setup Mode Row */} -
-
- - Setup Mode - - +
+
+
+ + Setup Mode + + +
+
+ + +
+
+ +
-
- - -
-
- + Template Wizard + {' '} + for a guided, step-by-step setup experience with pre-configured + settings.
@@ -403,6 +420,22 @@ AIOStreams consolidates multiple Stremio addons and debrid services - including }} /> + + { + setupChoiceModal.close(); + nextMenu(); + }} + onUseTemplate={() => { + setupChoiceModal.close(); + templatesModal.open(); + }} + /> ); } @@ -948,3 +981,66 @@ function CustomizeModal({ ); } + +function SetupChoiceModal({ + open, + onOpenChange, + onStartFresh, + onUseTemplate, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onStartFresh: () => void; + onUseTemplate: () => void; +}) { + return ( + +
+ + + +
+
+ ); +} diff --git a/packages/frontend/src/components/menu/save-install.tsx b/packages/frontend/src/components/menu/save-install.tsx index d2a911f2..d3a8d6ed 100644 --- a/packages/frontend/src/components/menu/save-install.tsx +++ b/packages/frontend/src/components/menu/save-install.tsx @@ -15,6 +15,7 @@ import { PageControls } from '../shared/page-controls'; import { useDisclosure } from '@/hooks/disclosure'; import { Modal } from '../ui/modal'; import { Switch } from '../ui/switch'; +import { TemplateExportModal } from '../shared/template-export-modal'; import { Accordion, AccordionContent, @@ -60,12 +61,14 @@ function Content() { const importFileRef = React.useRef(null); const installModal = useDisclosure(false); const passwordModal = useDisclosure(false); - const [filterCredentialsInExport, setFilterCredentialsInExport] = - React.useState(false); const deleteUserModal = useDisclosure(false); const [confirmDeletionPassword, setConfirmDeletionPassword] = React.useState(''); const { setSelectedMenu, firstMenu } = useMenu(); + const templateExportModal = useDisclosure(false); + const exportMenuModal = useDisclosure(false); + const [filterCredentialsInExport, setFilterCredentialsInExport] = + React.useState(false); const confirmResetProps = useConfirmationDialog({ title: 'Confirm Reset', description: `Are you sure you want to reset your configuration? This will clear all your settings${uuid ? ` but keep your user account` : ''}. This action cannot be undone.`, @@ -196,64 +199,68 @@ function Content() { reader.readAsText(file); }; + const filterCredentials = (data: UserData): UserData => { + const clonedData = structuredClone(data); + + return { + ...clonedData, + ip: undefined, + uuid: undefined, + addonPassword: undefined, + tmdbAccessToken: undefined, + tmdbApiKey: undefined, + tvdbApiKey: undefined, + rpdbApiKey: undefined, + services: clonedData?.services?.map((service) => ({ + ...service, + credentials: {}, + })), + proxy: { + ...clonedData?.proxy, + credentials: undefined, + url: undefined, + publicUrl: undefined, + }, + presets: clonedData?.presets?.map((preset) => { + const presetMeta = status?.settings.presets.find( + (p) => p.ID === preset.type + ); + return { + ...preset, + options: Object.fromEntries( + Object.entries(preset.options || {}).filter(([key]) => { + const optionMeta = presetMeta?.OPTIONS?.find( + (opt) => opt.id === key + ); + return optionMeta?.type !== 'password'; + }) + ), + }; + }), + }; + }; + const handleExport = () => { try { - const filteredUserData: UserData = { - ...userData, - ip: filterCredentialsInExport ? undefined : userData.ip, - uuid: filterCredentialsInExport ? undefined : userData.uuid, - addonPassword: filterCredentialsInExport - ? undefined - : userData.addonPassword, - tmdbAccessToken: filterCredentialsInExport - ? undefined - : userData.tmdbAccessToken, - tmdbApiKey: filterCredentialsInExport ? undefined : userData.tmdbApiKey, - tvdbApiKey: filterCredentialsInExport ? undefined : userData.tvdbApiKey, - rpdbApiKey: filterCredentialsInExport ? undefined : userData.rpdbApiKey, - services: userData?.services?.map((service) => ({ - ...service, - credentials: filterCredentialsInExport ? {} : service.credentials, - })), - proxy: { - ...userData?.proxy, - credentials: filterCredentialsInExport - ? undefined - : userData?.proxy?.credentials, - url: filterCredentialsInExport ? undefined : userData?.proxy?.url, - publicUrl: filterCredentialsInExport - ? undefined - : userData?.proxy?.publicUrl, - }, - presets: userData?.presets?.map((preset) => { - const presetMeta = status?.settings.presets.find( - (p) => p.ID === preset.type - ); - return { - ...preset, - options: filterCredentialsInExport - ? Object.fromEntries( - Object.entries(preset.options || {}).filter(([key]) => { - const optionMeta = presetMeta?.OPTIONS?.find( - (opt) => opt.id === key - ); - return optionMeta?.type !== 'password'; - }) - ) - : preset.options, - }; - }), - }; - const dataStr = JSON.stringify(filteredUserData, null, 2); + const exportData = filterCredentialsInExport + ? filterCredentials(userData) + : structuredClone(userData); + const dataStr = JSON.stringify(exportData, null, 2); const blob = new Blob([dataStr], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = 'aiostreams-config.json'; + // format date as YYYY-MM-DD.HH-MM-SS + const now = new Date(); + const pad = (n: number) => n.toString().padStart(2, '0'); + const formattedDate = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}.${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`; + a.download = `aiostreams-config-${formattedDate}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); + toast.success('Configuration exported successfully'); + exportMenuModal.close(); } catch (err) { toast.error('Failed to export configuration'); } @@ -523,9 +530,9 @@ function Content() { title="Backups" description="Export your settings or restore from a backup file" > -
+
-
- - - - Export Settings - - -
- - setFilterCredentialsInExport(value) - } - side="right" - help="This will not exclude any URLs you have provided, these may contain credentials and you should always double check the contents of the exported file before sharing it." - label="Exclude Credentials" - /> -
-
-
-
-
+ + +
+ {/* Exclude Credentials Option */} +
+
+
+ Exclude Credentials +
+
+ Remove sensitive information from export +
+
+ +
+ +
+ {/* Standard Export Option */} + + + {/* Template Export Option */} + +
+
+
+ +
); diff --git a/packages/frontend/src/components/shared/config-templates-modal.tsx b/packages/frontend/src/components/shared/config-templates-modal.tsx new file mode 100644 index 00000000..e6e4bfd4 --- /dev/null +++ b/packages/frontend/src/components/shared/config-templates-modal.tsx @@ -0,0 +1,1273 @@ +'use client'; +import { useState, useEffect, useMemo } from 'react'; +import { Modal } from '../ui/modal'; +import { Button, IconButton } from '../ui/button'; +import { Alert } from '../ui/alert'; +import { toast } from 'sonner'; +import { applyMigrations, useUserData } from '@/context/userData'; +import { useStatus } from '@/context/status'; +import { SearchIcon, CheckIcon, AlertTriangleIcon } from 'lucide-react'; +import { TextInput } from '../ui/text-input'; +import { Textarea } from '../ui/textarea'; +import * as constants from '../../../../core/src/utils/constants'; +import { Template } from '@aiostreams/core'; +import MarkdownLite from './markdown-lite'; +import { BiImport } from 'react-icons/bi'; + +export interface TemplateValidation { + isValid: boolean; + warnings: string[]; + errors: string[]; +} + +interface TemplateWithId extends Template { + id: string; +} + +interface TemplateInput { + key: string; // Unique identifier for this input + path: string | string[]; // Path in the userData object (e.g., "tmdbApiKey", "presets.0.options.apiKey", "proxy.url") + label: string; + description?: string; + type: 'string' | 'password'; + required: boolean; + value: string; +} + +interface ProcessedTemplate { + template: TemplateWithId; + services: string[]; // Selected services + skipServiceSelection: boolean; // True if services = [] or single required service + showServiceSelection: boolean; // True if services = undefined or multiple options + allowSkipService: boolean; // True if serviceRequired = false + inputs: TemplateInput[]; // All inputs needed +} + +export interface ConfigTemplatesModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function ConfigTemplatesModal({ + open, + onOpenChange, +}: ConfigTemplatesModalProps) { + const { setUserData } = useUserData(); + const { status } = useStatus(); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedCategory, setSelectedCategory] = useState('all'); + const [isLoading, setIsLoading] = useState(false); + const [templates, setTemplates] = useState([]); + const [loadingTemplates, setLoadingTemplates] = useState(false); + const [templateValidations, setTemplateValidations] = useState< + Record + >({}); + const [showImportModal, setShowImportModal] = useState(false); + const [importUrl, setImportUrl] = useState(''); + const [isImporting, setIsImporting] = useState(false); + + // Template loading state + const [processedTemplate, setProcessedTemplate] = + useState(null); + const [currentStep, setCurrentStep] = useState< + 'browse' | 'selectService' | 'inputs' + >('browse'); + const [selectedServices, setSelectedServices] = useState([]); + const [inputValues, setInputValues] = useState>({}); + + // Fetch templates from API when modal opens + useEffect(() => { + if (open) { + fetchTemplates(); + } + }, [open]); + + const fetchTemplates = async () => { + setLoadingTemplates(true); + try { + const response = await fetch('/api/v1/templates'); + if (response.ok) { + const data = await response.json(); + const fetchedTemplates = data.data || []; + setTemplates(fetchedTemplates); + + // Validate all templates + if (status) { + const validations: Record = {}; + fetchedTemplates.forEach((template: Template, index: number) => { + validations[`template-${index}`] = validateTemplate( + Object.assign(template, { id: `template-${index}` }), + status + ); + }); + setTemplateValidations(validations); + } + } else { + toast.error('Failed to load templates'); + } + } catch (error) { + console.error('Error fetching templates:', error); + toast.error('Failed to load templates'); + } finally { + setLoadingTemplates(false); + } + }; + + const validateTemplate = ( + template: TemplateWithId, + statusData: any + ): TemplateValidation => { + const warnings: string[] = []; + const errors: string[] = []; + + // Check if template has required structure + if (!template.config) { + errors.push('Template is missing configuration data'); + return { isValid: false, warnings, errors }; + } + + // Check if addons exist on instance + if (template.config.presets) { + template.config.presets.forEach((preset: any) => { + const presetMeta = statusData.settings?.presets?.find( + (p: any) => p.ID === preset.type + ); + if (!presetMeta) { + warnings.push( + `Addon type "${preset.type}" not available on this instance` + ); + } + }); + } + + // Check if services exist on instance + const availableServices = Object.keys(statusData.settings?.services || {}); + if (template.config.services) { + template.config.services.forEach((service: any) => { + if (!availableServices.includes(service.id)) { + warnings.push( + `Service "${service.id}" not available on this instance` + ); + } + }); + } + + // Check regex patterns against allowed patterns + const excludedRegexes = template.config.excludedRegexPatterns || []; + const includedRegexes = template.config.includedRegexPatterns || []; + const requiredRegexes = template.config.requiredRegexPatterns || []; + const preferredRegexes = (template.config.preferredRegexPatterns || []).map( + (r: any) => (typeof r === 'string' ? r : r.pattern) + ); + + const allRegexes = [ + ...excludedRegexes, + ...includedRegexes, + ...requiredRegexes, + ...preferredRegexes, + ]; + + if (allRegexes.length > 0) { + // Get allowed patterns from status + const allowedPatterns = + statusData.settings?.allowedRegexPatterns?.patterns || []; + + // Check if regex access is restricted + if ( + statusData.settings?.regexFilterAccess === 'none' && + allowedPatterns.length === 0 + ) { + warnings.push( + 'Template uses regex patterns but regex access is disabled on this instance' + ); + } else if ( + statusData.settings?.regexFilterAccess === 'trusted' && + !template.config.trusted + ) { + warnings.push( + 'Template uses regex patterns which require trusted user status' + ); + } else if (allowedPatterns.length > 0) { + // Check if all patterns are allowed (exact match) + const unsupportedPatterns = allRegexes.filter( + (pattern) => !allowedPatterns.includes(pattern) + ); + + if (unsupportedPatterns.length > 0) { + const patternList = unsupportedPatterns.slice(0, 3).join(', '); + warnings.push( + `Template has ${unsupportedPatterns.length} unsupported regex pattern${unsupportedPatterns.length > 1 ? 's' : ''}: ${patternList}${unsupportedPatterns.length > 3 ? '...' : ''}` + ); + } + } + } + + const isValid = errors.length === 0; + return { isValid, warnings, errors }; + }; + + const categories = [ + 'all', + ...Array.from(new Set(templates.map((t) => t.metadata.category))), + ]; + + const filteredTemplates = templates.filter((template) => { + const matchesSearch = + template.metadata.name + .toLowerCase() + .includes(searchQuery.toLowerCase()) || + template.metadata.description + .toLowerCase() + .includes(searchQuery.toLowerCase()) || + template.metadata.services?.some((service) => + service.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + const matchesCategory = + selectedCategory === 'all' || + template.metadata.category === selectedCategory; + + return matchesSearch && matchesCategory; + }); + + const processImportedTemplate = (data: any) => { + try { + // Validate it has userData field + if (!data.config) { + toast.error('Invalid template: missing config field'); + return; + } + + // Create a template object from the data + const importedTemplate: TemplateWithId = { + id: `imported-${Date.now()}`, + metadata: { + name: data.metadata.name || 'Imported Template', + description: data.metadata.description || 'Imported from JSON', + author: data.metadata.author || 'Unknown', + category: data.metadata.category || 'Custom', + services: data.metadata.services, + serviceRequired: data.metadata.serviceRequired, + predefined: false, + }, + config: data.config || data, + }; + + // Validate the imported template + if (status) { + const validation = validateTemplate(importedTemplate, status); + setTemplateValidations((prev) => ({ + ...prev, + [importedTemplate.id]: validation, + })); + + if (validation.errors.length > 0) { + toast.error(`Cannot load template: ${validation.errors.join(', ')}`); + return; + } + } + + // Close import modal and load the template directly + setShowImportModal(false); + setImportUrl(''); + + // Load the template directly (will trigger processing) + handleLoadTemplate(importedTemplate); + } catch (error) { + toast.error('Invalid template format: ' + (error as Error).message); + } + }; + + const handleImportFromUrl = async () => { + if (!importUrl.trim()) { + toast.error('Please enter a URL'); + return; + } + + setIsImporting(true); + try { + const response = await fetch(importUrl); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const data = await response.json(); + processImportedTemplate(data); + } catch (error) { + toast.error('Failed to import template: ' + (error as Error).message); + } finally { + setIsImporting(false); + } + }; + + const handleImportFromFile = () => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json'; + input.onchange = async (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (!file) return; + + try { + const text = await file.text(); + const data = JSON.parse(text); + processImportedTemplate(data); + } catch (error) { + toast.error('Failed to read file: ' + (error as Error).message); + } + }; + input.click(); + }; + + // Parse placeholder values from a string + const parsePlaceholder = ( + value: any + ): { isPlaceholder: boolean; required: boolean } => { + if (typeof value !== 'string') + return { isPlaceholder: false, required: false }; + + const placeholderPatterns = [ + { pattern: //gi, required: true }, + { pattern: //gi, required: false }, + { pattern: //gi, required: true }, // default to required + ]; + + for (const { pattern, required } of placeholderPatterns) { + if (pattern.test(value)) { + return { isPlaceholder: true, required }; + } + } + + // // Also check for empty string or common placeholder patterns + // if ( + // !value || + // value === '' || + // value === '' || + // value === '' + // ) { + // return { isPlaceholder: true, required: false }; + // } + + return { isPlaceholder: false, required: false }; + }; + + // Process template to extract all inputs and determine service handling + const processTemplate = (template: TemplateWithId): ProcessedTemplate => { + const inputs: TemplateInput[] = []; + const availableServices = Object.keys(status?.settings?.services || {}); + + // Determine service handling based on services array and serviceRequired + let services: string[] = []; + let skipServiceSelection = false; + let showServiceSelection = false; + let allowSkipService = template.metadata.serviceRequired !== true; + + if (template.metadata.services === undefined) { + // Show all available services + showServiceSelection = true; + services = availableServices; + } else if ( + Array.isArray(template.metadata.services) && + template.metadata.services.length === 0 + ) { + // Skip service selection entirely + skipServiceSelection = true; + services = []; + } else if (Array.isArray(template.metadata.services)) { + // Filter to only services available on this instance + services = template.metadata.services.filter((s) => + availableServices.includes(s) + ); + + if (services.length === 1 && template.metadata.serviceRequired === true) { + // Single required service - skip selection, add to inputs + skipServiceSelection = true; + } else if (services.length > 0) { + // Multiple services or optional - show selection + showServiceSelection = true; + } else { + // No valid services + skipServiceSelection = true; + } + } + + // Parse proxy fields + if (template.config?.proxy && template.config.proxy.id) { + const id = template.config.proxy.id; + const proxyDetails = constants.PROXY_SERVICE_DETAILS[id]; + const proxyFields = [ + 'url', + 'publicUrl', + 'credentials', + 'publicIp', + ] as const; + + proxyFields.forEach((field) => { + const value = template.config.proxy?.[field]; + const placeholder = parsePlaceholder(value); + + if (placeholder.isPlaceholder) { + const fieldLabels: Record = { + url: `${proxyDetails.name} URL`, + publicUrl: `${proxyDetails.name} Public URL`, + credentials: `${proxyDetails.name} Credentials`, + publicIp: `${proxyDetails.name} Public IP`, + }; + + const fieldDescriptions: Record = { + url: `The URL of your ${proxyDetails.name} instance`, + publicUrl: `The public URL of your ${proxyDetails.name} instance (if different from URL)`, + credentials: proxyDetails.credentialDescription, + publicIp: `Public IP address of your ${proxyDetails.name} instance`, + }; + + inputs.push({ + key: `proxy_${field}`, + path: `proxy.${field}`, + label: fieldLabels[field] || field, + description: fieldDescriptions[field], + type: field === 'credentials' ? 'password' : 'string', + required: placeholder.required, + value: '', + }); + } + }); + } + + // Parse top-level API keys + const topLevelFields = [ + 'tmdbApiKey', + 'tmdbAccessToken', + 'tvdbApiKey', + 'rpdbApiKey', + ] as const; + + topLevelFields.forEach((field) => { + const value = template.config?.[field]; + const placeholder = parsePlaceholder(value); + + if (placeholder.isPlaceholder) { + const detail = constants.TOP_LEVEL_OPTION_DETAILS?.[field]; + inputs.push({ + key: `toplevel_${field}`, + path: field, + label: detail?.name || field, + description: detail?.description, + type: 'password', + required: placeholder.required, + value: '', + }); + } + }); + + // Parse preset options + template.config?.presets?.forEach((preset: any, presetIndex: number) => { + const presetMeta = status?.settings?.presets?.find( + (p: any) => p.ID === preset.type + ); + + if (!presetMeta) return; + + // Check all string/password options + presetMeta.OPTIONS?.forEach((option: any) => { + if (option.type === 'string' || option.type === 'password') { + const currentValue = preset.options?.[option.id]; + const placeholder = parsePlaceholder(currentValue); + + if (placeholder.isPlaceholder || (option.required && !currentValue)) { + if (option.id === 'debridioApiKey') { + const debridioApiKeyInput = inputs.find( + (input) => input.key === 'debridioApiKey' + ); + if (debridioApiKeyInput) { + if (Array.isArray(debridioApiKeyInput.path)) { + debridioApiKeyInput.path.push( + `presets.${presetIndex}.options.${option.id}` + ); + } else { + debridioApiKeyInput.path = [ + debridioApiKeyInput.path, + `presets.${presetIndex}.options.${option.id}`, + ]; + } + } else { + inputs.push({ + key: 'debridioApiKey', + path: `presets.${presetIndex}.options.${option.id}`, + label: 'Debridio API Key', + description: option.description, + type: 'password', + required: true, + value: '', + }); + } + } else { + inputs.push({ + key: `preset_${preset.instanceId}_${option.id}`, + path: `presets.${presetIndex}.options.${option.id}`, + label: `${preset.options?.name || preset.type} - ${option.name || option.id}`, + description: option.description, + type: option.type === 'password' ? 'password' : 'string', + required: placeholder.required || option.required || false, + value: '', + }); + } + } + } + }); + }); + + return { + template, + services, + skipServiceSelection, + showServiceSelection, + allowSkipService, + inputs, + }; + }; + + // Add service credentials to inputs + const addServiceInputs = ( + processed: ProcessedTemplate, + selectedServiceIds: string[] + ): TemplateInput[] => { + const serviceInputs: TemplateInput[] = []; + + selectedServiceIds.forEach((serviceId) => { + const serviceMeta = + status?.settings?.services?.[ + serviceId as keyof typeof status.settings.services + ]; + if (!serviceMeta?.credentials) return; + + serviceMeta.credentials.forEach((cred: any) => { + serviceInputs.push({ + key: `service_${serviceId}_${cred.id}`, + path: `services.${serviceId}.${cred.id}`, + label: `${serviceMeta.name} - ${cred.name || cred.id}`, + description: cred.description, + type: 'password', + required: true, + value: '', + }); + }); + }); + + return serviceInputs; + }; + + const handleLoadTemplate = (template: TemplateWithId) => { + // Show validation warnings if any + const validation = templateValidations[template.id]; + if (validation && validation.errors.length > 0) { + toast.error(`Cannot load template: ${validation.errors.join(', ')}`); + return; + } + + if (validation && validation.warnings.length > 0) { + toast.warning( + `Template has warnings: ${validation.warnings.slice(0, 2).join(', ')}${validation.warnings.length > 2 ? '...' : ''}`, + { + duration: 5000, + } + ); + } + + const processed = processTemplate(template); + setProcessedTemplate(processed); + + // Determine which step to show + if (processed.skipServiceSelection) { + // If single required service, add its credentials to inputs (only if the template says service is required) + if (processed.services.length === 1) { + const serviceInputs = addServiceInputs(processed, processed.services); + processed.inputs = [...serviceInputs, ...processed.inputs]; + setSelectedServices(processed.services); + } + + // Go directly to inputs + setInputValues( + processed.inputs.reduce( + (acc, input) => ({ ...acc, [input.key]: input.value }), + {} + ) + ); + setCurrentStep('inputs'); + } else if (processed.showServiceSelection) { + // Show service selection + setSelectedServices([]); + setCurrentStep('selectService'); + } else { + // No services, go directly to inputs + setInputValues( + processed.inputs.reduce( + (acc, input) => ({ ...acc, [input.key]: input.value }), + {} + ) + ); + setCurrentStep('inputs'); + } + }; + + const handleServiceSelectionNext = () => { + if (!processedTemplate) return; + + // Validate at least one service if required + if (!processedTemplate.allowSkipService && selectedServices.length === 0) { + toast.error('Please select at least one service'); + return; + } + + // Add service inputs + const serviceInputs = addServiceInputs(processedTemplate, selectedServices); + const allInputs = [...serviceInputs, ...processedTemplate.inputs]; + processedTemplate.inputs = allInputs; + + // Initialize input values + setInputValues( + allInputs.reduce( + (acc, input) => ({ ...acc, [input.key]: input.value }), + {} + ) + ); + setCurrentStep('inputs'); + }; + + const handleServiceSelectionSkip = () => { + if (!processedTemplate) return; + + if (!processedTemplate.allowSkipService) { + toast.error('Service selection cannot be skipped for this template'); + return; + } + + setSelectedServices([]); + setInputValues( + processedTemplate.inputs.reduce( + (acc, input) => ({ ...acc, [input.key]: input.value }), + {} + ) + ); + setCurrentStep('inputs'); + }; + + const applyInputValue = (obj: any, path: string, value: any) => { + const parts = path.split('.'); + let current = obj; + + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + const nextPart = parts[i + 1]; + + // Check if next part is a number (array index) + const isArrayIndex = /^\d+$/.test(nextPart); + + if (!(part in current)) { + current[part] = isArrayIndex ? [] : {}; + } + current = current[part]; + } + + current[parts[parts.length - 1]] = value; + }; + + const confirmLoadTemplate = async () => { + if (!processedTemplate) return; + + // Validate required inputs + const missingRequired = processedTemplate.inputs.filter( + (input) => input.required && !inputValues[input.key]?.trim() + ); + + if (missingRequired.length > 0) { + toast.error( + `Please fill in all required fields: ${missingRequired.map((i) => i.label).join(', ')}` + ); + return; + } + + setIsLoading(true); + try { + // Clone the userData + const migratedData = applyMigrations( + JSON.parse(JSON.stringify(processedTemplate.template.config)) + ); + + // Apply all input values + processedTemplate.inputs.forEach((input) => { + const value = inputValues[input.key]; + if (value) { + // Handle service credentials separately + const paths = Array.isArray(input.path) ? input.path : [input.path]; + for (const path of paths) { + if (path.startsWith('services.')) { + const pathParts = path.split('.'); + const serviceId = pathParts[1] as any; + const credKey = pathParts[2]; + + // Find or create service in userData + if (!migratedData.services) { + migratedData.services = []; + } + + let service = migratedData.services.find( + (s: any) => s.id === serviceId + ); + + if (!service) { + service = { + id: serviceId, + enabled: true, + credentials: {}, + }; + migratedData.services.push(service); + } + + if (!service.credentials) { + service.credentials = {}; + } + + service.credentials[credKey] = value; + } else { + // Apply to regular path + applyInputValue(migratedData, path, value); + } + } + } + }); + + // Filter services to only selected ones + if (selectedServices.length > 0 && migratedData.services) { + migratedData.services = migratedData.services.filter((s: any) => + selectedServices.includes(s.id) + ); + } + + setUserData((prev) => ({ + ...prev, + ...migratedData, + })); + + // Check if there are any addons that need manual setup + const addonsNeedingSetup = (migratedData.presets || []) + .filter((preset: any) => { + const presetType = preset.type.toLowerCase(); + // List of addons that need manual setup + return ['gdrive'].some((type) => presetType.includes(type)); + }) + .map((preset: any) => preset.options?.name || preset.type); + + toast.success( + `Template "${processedTemplate.template.metadata.name}" loaded successfully` + ); + + // Show additional guidance if needed + if (addonsNeedingSetup.length > 0) { + setTimeout(() => { + toast.info( + `Note: ${addonsNeedingSetup.join(', ')} require additional setup. Please configure them in the Addons section.`, + { duration: 8000 } + ); + }, 1000); + } + + // Reset state + setProcessedTemplate(null); + setCurrentStep('browse'); + setSelectedServices([]); + setInputValues({}); + onOpenChange(false); + } catch (err) { + console.error('Error loading template:', err); + toast.error('Failed to load template'); + } finally { + setIsLoading(false); + } + }; + + const handleBackFromInputs = () => { + if (!processedTemplate) return; + + if (processedTemplate.showServiceSelection) { + // Go back to service selection + // Remove service inputs from the list + const nonServiceInputs = processedTemplate.inputs.filter((input) => + Array.isArray(input.path) + ? !input.path.some((p) => p.startsWith('services.')) + : !input.path.startsWith('services.') + ); + processedTemplate.inputs = nonServiceInputs; + setCurrentStep('selectService'); + } else { + // Go back to browse + setProcessedTemplate(null); + setCurrentStep('browse'); + setSelectedServices([]); + setInputValues({}); + } + }; + + const handleCancel = () => { + setProcessedTemplate(null); + setCurrentStep('browse'); + setSelectedServices([]); + setInputValues({}); + onOpenChange(false); + }; + + // Render different steps + const renderBrowse = () => ( + <> + {/* Search and Filter */} +
+
+ } + /> +
+
+ {categories.map((category) => ( + + ))} +
+
+ + {/* Templates List */} +
+ {loadingTemplates ? ( +
+ Loading templates... +
+ ) : filteredTemplates.length === 0 ? ( +
+ No templates found matching your search +
+ ) : ( + filteredTemplates.map((template) => { + const validation = templateValidations[template.id]; + const hasWarnings = validation && validation.warnings.length > 0; + const hasErrors = validation && validation.errors.length > 0; + + const addons = Array.from( + new Set( + template.config.presets?.map( + (preset: any) => preset.options?.name + ) + ) + ); + + return ( +
+
+

+ {template.metadata.name} +

+ {template.metadata.predefined && ( + + Built-in + + )} + {(hasWarnings || hasErrors) && ( +
+ +
+ {validation.errors.length > 0 && ( +
+
+ Errors: +
+
    + {validation.errors.map((error, idx) => ( +
  • {error}
  • + ))} +
+
+ )} + {validation.warnings.length > 0 && ( +
+
+ Warnings: +
+
    + {validation.warnings.map((warning, idx) => ( +
  • {warning}
  • + ))} +
+
+ )} +
+
+ )} +
+ + + {template.metadata.description} + + + {/* Category and Author */} +
+
+
Category
+ + {template.metadata.category} + +
+ +
+
Author
+ + {template.metadata.author} + +
+
+ + {/* Addons */} + {addons.length > 0 && ( +
+
Addons
+
+ {addons.slice(0, 5).map((addon) => ( + + {addon} + + ))} + {addons.length > 5 && ( + + +{addons.length - 5} more + + )} +
+
+ )} + + {/* Services */} + {template.metadata.services && + template.metadata.services.length > 0 && ( +
+
+ Services +
+
+ {template.metadata.services.map((service) => ( + + {constants.SERVICE_DETAILS[ + service as keyof typeof constants.SERVICE_DETAILS + ]?.name || service} + + ))} +
+
+ )} + + {/* Load Template Button - Full Width at Bottom */} + +
+ ); + }) + )} +
+ +
+
+ {filteredTemplates.length} template + {filteredTemplates.length !== 1 ? 's' : ''} available +
+
+ } + intent="primary-outline" + onClick={() => setShowImportModal(true)} + /> + +
+
+ + ); + + const renderServiceSelection = () => { + if (!processedTemplate) return null; + + return ( + <> + + +
+ {processedTemplate.services.map((serviceId) => { + const service = + status?.settings?.services?.[ + serviceId as keyof typeof status.settings.services + ]; + if (!service) return null; + + const isSelected = selectedServices.includes(serviceId); + return ( + + ); + })} +
+ +
+ +
+ {processedTemplate.allowSkipService && ( + + )} + +
+
+ + ); + }; + + const renderInputs = () => { + if (!processedTemplate) return null; + + return ( + <> + + +
+ {processedTemplate.inputs.length === 0 ? ( +
+ No inputs required for this template +
+ ) : ( + processedTemplate.inputs.map((input) => ( +
+ { + setInputValues((prev) => ({ + ...prev, + [input.key]: newValue, + })); + }} + required={input.required} + /> + {input.description && ( + + {input.description} + + )} +
+ )) + )} +
+ +
+ + +
+ + ); + }; + + return ( + <> + { + if (!isOpen) handleCancel(); + }} + title="Templates" + description="Browse and load pre-configured templates for your AIOStreams setup" + > +
{renderBrowse()}
+
+ + {/* Service Selection Modal */} + { + if (!isOpen) handleCancel(); + }} + title="Select Services" + description="Choose which services you want to use with this template" + > +
{renderServiceSelection()}
+
+ + {/* Inputs Modal */} + { + if (!isOpen) handleCancel(); + }} + title="Enter Credentials" + description="Provide your API keys and credentials for the selected services and addons" + > +
{renderInputs()}
+
+ + {/* Import Template Modal */} + +
+ {/* URL Import */} +
+ + +
+ + {/* Separator */} +
+
+
+
+
+ or +
+
+ + {/* File Import */} + + +
+ +
+
+ + + ); +} diff --git a/packages/frontend/src/components/shared/markdown-lite.tsx b/packages/frontend/src/components/shared/markdown-lite.tsx index d6d24db9..53b18b2d 100644 --- a/packages/frontend/src/components/shared/markdown-lite.tsx +++ b/packages/frontend/src/components/shared/markdown-lite.tsx @@ -1,12 +1,18 @@ import React from 'react'; +import { toast } from 'sonner'; interface MarkdownLiteProps { children: string; className?: string; + stopPropagation?: boolean; } // Supports [text](url) and `code` only -const MarkdownLite: React.FC = ({ children, className }) => { +const MarkdownLite: React.FC = ({ + children, + className, + stopPropagation = false, +}) => { if (!children) return null; // Regex for [text](url) and `code` const regex = /(`[^`]+`|\[[^\]]+\]\([^\)]+\))/g; @@ -29,6 +35,19 @@ const MarkdownLite: React.FC = ({ children, className }) => { return ( { + if (stopPropagation) { + e.stopPropagation(); + } + // copy to clipboard + try { + await navigator.clipboard.writeText(match.slice(1, -1)); + toast.success('Copied to clipboard'); + } catch (error) { + console.error('Failed to copy to clipboard:', error); + toast.error('Failed to copy to clipboard'); + } + }} className="bg-muted px-1 py-0.5 rounded text-[--brand] font-mono text-xs break-all" > {match.slice(1, -1)} @@ -46,6 +65,7 @@ const MarkdownLite: React.FC = ({ children, className }) => { target="_blank" rel="noopener noreferrer" className="text-[--brand] hover:underline" + onClick={(e) => stopPropagation && e.stopPropagation()} > {text} diff --git a/packages/frontend/src/components/shared/template-export-modal.tsx b/packages/frontend/src/components/shared/template-export-modal.tsx new file mode 100644 index 00000000..b4d8e308 --- /dev/null +++ b/packages/frontend/src/components/shared/template-export-modal.tsx @@ -0,0 +1,285 @@ +'use client'; +import { useState, useEffect } from 'react'; +import { Modal } from '../ui/modal'; +import { Button } from '../ui/button'; +import { Alert } from '../ui/alert'; +import { toast } from 'sonner'; +import { Template, UserData } from '@aiostreams/core'; +import { useStatus } from '@/context/status'; +import { TextInput } from '../ui/text-input'; +import { Textarea } from '../ui/textarea'; + +export interface TemplateExportModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + userData: UserData; + filterCredentials: (data: UserData) => UserData; +} + +export function TemplateExportModal({ + open, + onOpenChange, + userData, + filterCredentials, +}: TemplateExportModalProps) { + const { status } = useStatus(); + const [templateName, setTemplateName] = useState(''); + const [description, setDescription] = useState(''); + const [author, setAuthor] = useState(''); + const [category, setCategory] = useState('Debrid'); + const [customCategory, setCustomCategory] = useState(''); + + useEffect(() => { + if (open) { + // Reset fields when modal opens + setTemplateName(''); + setDescription(''); + setAuthor(''); + setCategory('Debrid'); + setCustomCategory(''); + } + }, [open]); + + const handleExport = () => { + // Validate required fields + if (!templateName.trim()) { + toast.error('Please enter a template name'); + return; + } + if (!description.trim()) { + toast.error('Please enter a description'); + return; + } + if (!author.trim()) { + toast.error('Please enter an author name'); + return; + } + if (category === 'Custom' && !customCategory.trim()) { + toast.error('Please enter a custom category name'); + return; + } + + try { + // Start with filtered userData (credentials always removed for templates) + const templateData = filterCredentials(userData); + + // Smart handling for services - collect unique service IDs from enabled services + const enabledServiceIds = + userData.services + ?.filter((service) => service.enabled) + .map((service) => service.id) || []; + + // Add template placeholders to top-level API keys + if (userData.tmdbApiKey) { + templateData.tmdbApiKey = ''; + } + if (userData.tmdbAccessToken) { + templateData.tmdbAccessToken = ''; + } + if (userData.tvdbApiKey) { + templateData.tvdbApiKey = ''; + } + if (userData.rpdbApiKey) { + templateData.rpdbApiKey = ''; + } + + // // Handle services - add template placeholders to credentials + // if (templateData.services && templateData.services.length > 0) { + // templateData.services = templateData.services.map((service) => { + // const newCredentials: Record = {}; + + // // Replace all credential values with template placeholders + // Object.keys(service.credentials || {}).forEach((key) => { + // newCredentials[key] = ''; + // }); + + // return { + // ...service, + // credentials: newCredentials, + // }; + // }); + // } + + // Handle proxy - if proxy was enabled, keep id and add template placeholders + if (userData.proxy?.enabled) { + templateData.proxy = { + ...templateData.proxy, + url: userData.proxy.url ? '' : undefined, + publicUrl: userData.proxy.publicUrl + ? '' + : undefined, + credentials: userData.proxy.credentials + ? '' + : undefined, + publicIp: userData.proxy.publicIp + ? '' + : undefined, + }; + } + + // Handle preset password options + if (templateData.presets && templateData.presets.length > 0) { + templateData.presets = templateData.presets.map((preset) => { + const presetMeta = status?.settings.presets.find( + (p) => p.ID === preset.type + ); + const newOptions = { ...(preset.options || {}) }; + const presetInUserData = userData.presets?.find( + (p) => p.instanceId == preset.instanceId + ); + + // Replace password type options with template placeholders + presetMeta?.OPTIONS?.filter((opt) => opt.type === 'password').forEach( + (passwordOption) => { + if (presetInUserData?.options?.[passwordOption.id]) { + newOptions[passwordOption.id] = ''; + } + } + ); + + return { + ...preset, + options: newOptions, + }; + }); + } + + const finalCategory = + category === 'Custom' ? customCategory.trim() : category; + + // Create template with new structure + const template: Template = { + metadata: { + name: templateName, + description: description, + author: author, + category: finalCategory, + services: undefined, + serviceRequired: false, + }, + config: templateData, + }; + + const dataStr = JSON.stringify(template, null, 2); + const blob = new Blob([dataStr], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${templateName.toLowerCase().replace(/\s+/g, '-')}-template.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + toast.success('Template exported successfully'); + onOpenChange(false); + } catch (err) { + toast.error('Failed to export template'); + } + }; + + const categories = ['Debrid', 'P2P', 'Custom'] as const; + + return ( + +
+ + A template is a configuration file that others can use as a + starting point. All personal credentials will be replaced with + placeholders. +
+
+ For more customisability, edit the JSON file after exporting + manually. See the{' '} + + Templates wiki + {' '} + for more information. +
+ } + /> + +
+ + +