diff --git a/src/api/adminApps.ts b/src/api/adminApps.ts index b661d7c..2b6d2c9 100644 --- a/src/api/adminApps.ts +++ b/src/api/adminApps.ts @@ -8,47 +8,6 @@ export interface LocalizedText { fr?: string; } -export interface AppButton { - id?: string; // Unique identifier for React key (client-side only) - buttonLink: string; - buttonText: LocalizedText; -} - -export interface AppStep { - description: LocalizedText; - buttons?: AppButton[]; - title?: LocalizedText; -} - -export interface AppDefinition { - id: string; - name: string; - isFeatured: boolean; - urlScheme: string; - isNeedBase64Encoding?: boolean; - installationStep: AppStep; - addSubscriptionStep: AppStep; - connectAndUseStep: AppStep; - additionalBeforeAddSubscriptionStep?: AppStep; - additionalAfterAddSubscriptionStep?: AppStep; -} - -export interface AppConfigBranding { - name: string; - logoUrl: string; - supportUrl: string; -} - -export interface AppConfigConfig { - additionalLocales: string[]; - branding: AppConfigBranding; -} - -export interface AppConfigResponse { - config: AppConfigConfig; - platforms: Record; -} - export const adminAppsApi = { // Get RemnaWave config status getRemnaWaveStatus: async (): Promise<{ enabled: boolean; config_uuid: string | null }> => { @@ -155,332 +114,3 @@ export interface RemnawaveConfig { baseTranslations?: RemnawaveBaseTranslations; brandingSettings?: RemnawaveBrandingSettings; } - -// ============== Converter Functions ============== - -const emptyLocalizedText = (): LocalizedText => ({ - en: '', - ru: '', - zh: '', - fa: '', - fr: '', -}); - -// Convert Cabinet button to RemnaWave button -const cabinetButtonToRemnawave = (button: AppButton): RemnawaveButton => ({ - url: button.buttonLink, - text: { ...emptyLocalizedText(), ...button.buttonText }, -}); - -// Convert RemnaWave button to Cabinet button -const remnawaveButtonToCabinet = (button: RemnawaveButton): AppButton => ({ - buttonLink: button.url, - buttonText: { - en: button.text.en || '', - ru: button.text.ru || '', - zh: button.text.zh, - fa: button.text.fa, - fr: button.text.fr, - }, -}); - -// Convert Cabinet app to RemnaWave app format -export const cabinetAppToRemnawave = (app: AppDefinition): RemnawaveApp => { - const blocks: RemnawaveBlock[] = []; - - // Block 1: Installation - blocks.push({ - title: { ...emptyLocalizedText(), en: 'Install App', ru: 'Установка приложения' }, - description: { ...emptyLocalizedText(), ...app.installationStep.description }, - buttons: app.installationStep.buttons?.map(cabinetButtonToRemnawave), - svgIconKey: 'download', - svgIconColor: '#3B82F6', - }); - - // Block 2 (optional): Additional before subscription - if ( - app.additionalBeforeAddSubscriptionStep?.description?.en || - app.additionalBeforeAddSubscriptionStep?.description?.ru - ) { - blocks.push({ - title: app.additionalBeforeAddSubscriptionStep.title || { - ...emptyLocalizedText(), - en: 'Preparation', - ru: 'Подготовка', - }, - description: { - ...emptyLocalizedText(), - ...app.additionalBeforeAddSubscriptionStep.description, - }, - buttons: app.additionalBeforeAddSubscriptionStep.buttons?.map(cabinetButtonToRemnawave), - svgIconKey: 'settings', - svgIconColor: '#8B5CF6', - }); - } - - // Block 3: Add subscription - blocks.push({ - title: { ...emptyLocalizedText(), en: 'Add Subscription', ru: 'Добавить подписку' }, - description: { ...emptyLocalizedText(), ...app.addSubscriptionStep.description }, - buttons: app.addSubscriptionStep.buttons?.map(cabinetButtonToRemnawave), - svgIconKey: 'plus', - svgIconColor: '#10B981', - }); - - // Block 4 (optional): Additional after subscription - if ( - app.additionalAfterAddSubscriptionStep?.description?.en || - app.additionalAfterAddSubscriptionStep?.description?.ru - ) { - blocks.push({ - title: app.additionalAfterAddSubscriptionStep.title || { - ...emptyLocalizedText(), - en: 'Configuration', - ru: 'Настройка', - }, - description: { - ...emptyLocalizedText(), - ...app.additionalAfterAddSubscriptionStep.description, - }, - buttons: app.additionalAfterAddSubscriptionStep.buttons?.map(cabinetButtonToRemnawave), - svgIconKey: 'settings', - svgIconColor: '#F59E0B', - }); - } - - // Block 5: Connect and use - blocks.push({ - title: { ...emptyLocalizedText(), en: 'Connect and Use', ru: 'Подключение и использование' }, - description: { ...emptyLocalizedText(), ...app.connectAndUseStep.description }, - buttons: app.connectAndUseStep.buttons?.map(cabinetButtonToRemnawave), - svgIconKey: 'check', - svgIconColor: '#22C55E', - }); - - return { - name: app.name, - featured: app.isFeatured, - urlScheme: app.urlScheme, - isNeedBase64Encoding: app.isNeedBase64Encoding, - blocks, - }; -}; - -// Convert RemnaWave app to Cabinet app format -export const remnawaveAppToCabinet = (app: RemnawaveApp, platform: string): AppDefinition => { - const blocks = app.blocks || []; - - // Default empty step - const emptyStep = (): AppStep => ({ - description: emptyLocalizedText(), - buttons: [], - }); - - // Try to identify blocks by their titles or position - let installationBlock: RemnawaveBlock | undefined; - let subscriptionBlock: RemnawaveBlock | undefined; - let connectBlock: RemnawaveBlock | undefined; - let beforeSubBlock: RemnawaveBlock | undefined; - let afterSubBlock: RemnawaveBlock | undefined; - - // First pass: try to identify by title keywords - for (const block of blocks) { - const enTitle = (block.title?.en || '').toLowerCase(); - const ruTitle = (block.title?.ru || '').toLowerCase(); - - if (enTitle.includes('install') || ruTitle.includes('установ') || ruTitle.includes('скачай')) { - if (!installationBlock) installationBlock = block; - } else if ( - enTitle.includes('subscription') || - enTitle.includes('add') || - ruTitle.includes('подписк') || - ruTitle.includes('добав') - ) { - if (!subscriptionBlock) subscriptionBlock = block; - } else if ( - enTitle.includes('connect') || - enTitle.includes('use') || - ruTitle.includes('подключ') || - ruTitle.includes('использ') - ) { - if (!connectBlock) connectBlock = block; - } - } - - // Second pass: assign remaining blocks by position if not found by title - if (!installationBlock && blocks.length > 0) { - installationBlock = blocks[0]; - } - if (!subscriptionBlock && blocks.length > 1) { - subscriptionBlock = blocks.find((b) => b !== installationBlock) || blocks[1]; - } - if (!connectBlock && blocks.length > 2) { - connectBlock = - blocks.find((b) => b !== installationBlock && b !== subscriptionBlock) || - blocks[blocks.length - 1]; - } - - // Assign additional blocks - const additionalBlocks = blocks.filter( - (b) => b !== installationBlock && b !== subscriptionBlock && b !== connectBlock, - ); - if (additionalBlocks.length >= 1) { - // Check if block appears before subscription - const subIndex = blocks.indexOf(subscriptionBlock!); - const firstAdditionalIndex = blocks.indexOf(additionalBlocks[0]); - if (firstAdditionalIndex < subIndex) { - beforeSubBlock = additionalBlocks[0]; - if (additionalBlocks.length >= 2) { - afterSubBlock = additionalBlocks[1]; - } - } else { - afterSubBlock = additionalBlocks[0]; - } - } - - // Convert block to cabinet step - const blockToStep = (block: RemnawaveBlock | undefined): AppStep => { - if (!block) return emptyStep(); - return { - description: { - en: block.description?.en || '', - ru: block.description?.ru || '', - zh: block.description?.zh, - fa: block.description?.fa, - fr: block.description?.fr, - }, - title: block.title - ? { - en: block.title.en || '', - ru: block.title.ru || '', - zh: block.title.zh, - fa: block.title.fa, - fr: block.title.fr, - } - : undefined, - buttons: block.buttons?.map(remnawaveButtonToCabinet), - }; - }; - - return { - id: `${app.name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${platform}-${Date.now()}`, - name: app.name, - isFeatured: app.featured || false, - urlScheme: app.urlScheme || '', - isNeedBase64Encoding: app.isNeedBase64Encoding, - installationStep: blockToStep(installationBlock), - addSubscriptionStep: blockToStep(subscriptionBlock), - connectAndUseStep: blockToStep(connectBlock), - additionalBeforeAddSubscriptionStep: beforeSubBlock ? blockToStep(beforeSubBlock) : undefined, - additionalAfterAddSubscriptionStep: afterSubBlock ? blockToStep(afterSubBlock) : undefined, - }; -}; - -// Export all apps from cabinet to RemnaWave format -export const exportToRemnawaveFormat = ( - platformApps: Record, - branding?: AppConfigBranding, -): RemnawaveConfig => { - const platforms: Record = {}; - - for (const [platform, apps] of Object.entries(platformApps)) { - platforms[platform] = { - apps: apps.map(cabinetAppToRemnawave), - }; - } - - return { - platforms, - svgLibrary: { - download: { - svgString: - '', - }, - plus: { - svgString: - '', - }, - check: { - svgString: - '', - }, - settings: { - svgString: - '', - }, - }, - baseSettings: { - isShowTutorialButton: false, - tutorialUrl: '', - }, - baseTranslations: { - installApp: { - en: 'Install App', - ru: 'Установить приложение', - zh: '安装应用', - fa: 'نصب برنامه', - fr: "Installer l'application", - }, - addSubscription: { - en: 'Add Subscription', - ru: 'Добавить подписку', - zh: '添加订阅', - fa: 'اضافه کردن اشتراک', - fr: 'Ajouter un abonnement', - }, - connectAndUse: { - en: 'Connect and Use', - ru: 'Подключиться и использовать', - zh: '连接并使用', - fa: 'اتصال و استفاده', - fr: 'Connecter et utiliser', - }, - copyLink: { - en: 'Copy Link', - ru: 'Скопировать ссылку', - zh: '复制链接', - fa: 'کپی لینک', - fr: 'Copier le lien', - }, - openApp: { - en: 'Open App', - ru: 'Открыть приложение', - zh: '打开应用', - fa: 'باز کردن برنامه', - fr: "Ouvrir l'application", - }, - tutorial: { en: 'Tutorial', ru: 'Инструкция', zh: '教程', fa: 'آموزش', fr: 'Tutoriel' }, - close: { en: 'Close', ru: 'Закрыть', zh: '关闭', fa: 'بستن', fr: 'Fermer' }, - }, - brandingSettings: branding - ? { - name: branding.name, - logoUrl: branding.logoUrl, - supportUrl: branding.supportUrl, - } - : undefined, - }; -}; - -// Import apps from RemnaWave format to cabinet -export const importFromRemnawaveFormat = ( - config: RemnawaveConfig, -): { platformApps: Record; branding?: AppConfigBranding } => { - const platformApps: Record = {}; - - for (const [platform, platformData] of Object.entries(config.platforms || {})) { - platformApps[platform] = (platformData.apps || []).map((app) => - remnawaveAppToCabinet(app, platform), - ); - } - - const branding = config.brandingSettings - ? { - name: config.brandingSettings.name, - logoUrl: config.brandingSettings.logoUrl, - supportUrl: config.brandingSettings.supportUrl, - } - : undefined; - - return { platformApps, branding }; -}; diff --git a/src/pages/AdminApps.tsx b/src/pages/AdminApps.tsx index 7ab4b3c..ba61ed1 100644 --- a/src/pages/AdminApps.tsx +++ b/src/pages/AdminApps.tsx @@ -2,561 +2,164 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; -import DOMPurify from 'dompurify'; -import { - adminAppsApi, - LocalizedText, - RemnawaveApp, - RemnawaveSvgItem, - RemnawaveBlock, - RemnawaveButton, - RemnawaveBaseSettings, - RemnawaveBaseTranslations, -} from '../api/adminApps'; +import { adminAppsApi } from '../api/adminApps'; import { useBackButton } from '../platform/hooks/useBackButton'; import { usePlatform } from '../platform/hooks/usePlatform'; -// Icons - -const BackIcon = () => ( - - - -); - -const AppsIcon = () => ( - - - -); - -const StarIcon = ({ filled }: { filled: boolean }) => ( - - - -); - -const CloudSyncIcon = () => ( - - - -); - -const RefreshIcon = () => ( - - - -); - -const SettingsIcon = () => ( - - - - -); - -const ChevronDownIcon = () => ( - - - -); - -const PLATFORM_LABELS: Record = { - ios: 'iOS', - android: 'Android', - macos: 'macOS', - windows: 'Windows', - linux: 'Linux', - androidTV: 'Android TV', - appleTV: 'Apple TV', -}; - -const PLATFORMS = ['ios', 'android', 'macos', 'windows', 'linux', 'androidTV', 'appleTV']; - -function getLocalizedText(text: LocalizedText | undefined, lang: string): string { - if (!text) return ''; - return ( - text[lang as keyof LocalizedText] || - text['en'] || - text['ru'] || - Object.values(text).find((v) => v) || - '' - ); -} - -function renderSvgIcon(svgLibrary: Record, key?: string, color?: string) { - if (!key || !svgLibrary[key]?.svgString) return null; - const sanitized = DOMPurify.sanitize(svgLibrary[key].svgString, { - USE_PROFILES: { svg: true, svgFilters: true }, - }); - return ( -
- ); -} - -function BaseSettingsPanel({ settings }: { settings: RemnawaveBaseSettings }) { - const { t } = useTranslation(); - return ( -
-

- {t('admin.apps.baseSettings', 'Base Settings')} -

-
- {t('admin.apps.tutorialButton', 'Tutorial button')}: - - {settings.isShowTutorialButton ? 'ON' : 'OFF'} - -
- {settings.tutorialUrl && ( -
- URL: - {settings.tutorialUrl} -
- )} -
- ); -} - -function BaseTranslationsPanel({ - translations, - lang, -}: { - translations: RemnawaveBaseTranslations; - lang: string; -}) { - const { t } = useTranslation(); - const [expanded, setExpanded] = useState(false); - - const keys: (keyof RemnawaveBaseTranslations)[] = [ - 'installApp', - 'addSubscription', - 'connectAndUse', - 'copyLink', - 'openApp', - 'tutorial', - 'close', - ]; - - return ( -
- - {expanded && ( -
- - - - - - - - - {keys.map((key) => ( - - - - - ))} - -
KeyValue
{key} - {getLocalizedText(translations[key], lang)} -
-
- )} -
- ); -} - -function AppCard({ - app, - svgLibrary, - lang, -}: { - app: RemnawaveApp; - svgLibrary: Record; - lang: string; -}) { - const [expanded, setExpanded] = useState(false); - - return ( -
- - - {/* Expanded blocks */} - {expanded && ( -
- {app.blocks?.map((block: RemnawaveBlock, idx: number) => ( - - ))} - {(!app.blocks || app.blocks.length === 0) && ( -

No blocks

- )} -
- )} -
- ); -} - -function BlockCard({ - block, - index, - svgLibrary, - lang, -}: { - block: RemnawaveBlock; - index: number; - svgLibrary: Record; - lang: string; -}) { - const title = getLocalizedText(block.title, lang); - const description = getLocalizedText(block.description, lang); - - const iconColor = block.svgIconColor || '#8B5CF6'; - - return ( -
-
-
- {renderSvgIcon(svgLibrary, block.svgIconKey, block.svgIconColor) || ( - {index + 1} - )} -
-
- {title &&
{title}
} - {description &&
{description}
} - {block.buttons && block.buttons.length > 0 && ( - - )} -
-
-
- ); -} - export default function AdminApps() { - const { t, i18n } = useTranslation(); + const { t } = useTranslation(); const navigate = useNavigate(); const queryClient = useQueryClient(); const { capabilities } = usePlatform(); - const lang = i18n.language || 'en'; useBackButton(() => navigate('/admin')); - const [selectedPlatform, setSelectedPlatform] = useState('ios'); - const [showRemnaWaveSettings, setShowRemnaWaveSettings] = useState(false); - const [remnaWaveUuidInput, setRemnaWaveUuidInput] = useState(''); + const [uuidInput, setUuidInput] = useState(''); // RemnaWave status - const { data: remnaWaveStatus, refetch: refetchStatus } = useQuery({ + const { data: status } = useQuery({ queryKey: ['remnawave-status'], queryFn: adminAppsApi.getRemnaWaveStatus, staleTime: 60000, }); - // List available RemnaWave configs (for settings modal) - const { data: availableConfigs, isLoading: isLoadingConfigs } = useQuery({ + // Available configs + const { data: configs, isLoading: isLoadingConfigs } = useQuery({ queryKey: ['remnawave-configs-list'], queryFn: adminAppsApi.listRemnaWaveConfigs, - enabled: showRemnaWaveSettings, staleTime: 30000, }); - // Mutation for setting UUID + // Set UUID mutation const setUuidMutation = useMutation({ mutationFn: adminAppsApi.setRemnaWaveUuid, onSuccess: () => { - refetchStatus(); + queryClient.invalidateQueries({ queryKey: ['remnawave-status'] }); queryClient.invalidateQueries({ queryKey: ['remnawave-config'] }); - setShowRemnaWaveSettings(false); }, }); - // Fetch RemnaWave config - const { - data: remnaWaveConfig, - isLoading, - refetch: refetchRemnaWave, - } = useQuery({ - queryKey: ['remnawave-config'], - queryFn: adminAppsApi.getRemnaWaveConfig, - enabled: !!remnaWaveStatus?.enabled, - staleTime: 0, - }); - - // Extract data from raw RemnaWave config - const currentPlatformApps = remnaWaveConfig?.platforms?.[selectedPlatform]?.apps || []; - const svgLibrary = remnaWaveConfig?.svgLibrary || {}; - const baseSettings = remnaWaveConfig?.baseSettings; - const baseTranslations = remnaWaveConfig?.baseTranslations; + // Sync input with status on first load + const currentUuid = status?.config_uuid || ''; + const inputValue = uuidInput || currentUuid; return (
{/* Header */} -
-
- {!capabilities.hasBackButton && ( - - )} -

{t('admin.apps.title')}

-
- -
- {remnaWaveStatus?.enabled && ( - - )} +
+ {!capabilities.hasBackButton && ( -
+ )} +

{t('admin.apps.title')}

- {/* RemnaWave Info Banner */} - {remnaWaveStatus?.enabled && ( -
- - - {t('admin.apps.remnaWaveMode', 'Showing apps from RemnaWave panel. Read-only mode.')} + {/* Status card */} +
+
+
+ + {status?.enabled + ? t('admin.apps.remnaWaveConnected', 'RemnaWave connected') + : t('admin.apps.remnaWaveDisconnected', 'RemnaWave not connected')}
- )} - - {/* Base Settings */} - {baseSettings && } - - {/* Platform Tabs */} -
- {PLATFORMS.map((platform) => { - const platformSvgKey = remnaWaveConfig?.platforms?.[platform]?.svgIconKey; - return ( - - ); - })} + {status?.config_uuid && ( +
+ UUID: {status.config_uuid} +
+ )}
- {/* Apps List */} - {isLoading ? ( -
-
-
- ) : currentPlatformApps.length > 0 ? ( -
- {currentPlatformApps.map((app: RemnawaveApp, index: number) => ( - - ))} -
- ) : ( -
- -

{t('admin.apps.noApps')}

-
- )} - - {/* Base Translations */} - {baseTranslations && } - - {/* RemnaWave Settings Modal */} - {showRemnaWaveSettings && ( -
-
-

- - {t('admin.apps.remnaWaveSettings', 'RemnaWave Settings')} -

- -
-
- - setRemnaWaveUuidInput(e.target.value)} - placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" - className="input w-full font-mono text-sm" - /> -

- {t( - 'admin.apps.remnaWaveConfigUuidHint', - 'UUID of subscription page config from RemnaWave panel', - )} -

-
- - {/* Available configs from RemnaWave */} - {isLoadingConfigs ? ( -
-
-
- ) : ( - availableConfigs && - availableConfigs.length > 0 && ( -
- -
- {availableConfigs.map((config) => ( - - ))} -
-
- ) - )} -
- -
- - -
+ {/* Available configs */} +
+

+ {t('admin.apps.availableConfigs', 'Available configs')} +

+ {isLoadingConfigs ? ( +
+
-
- )} + ) : configs && configs.length > 0 ? ( +
+ {configs.map((config) => ( + + ))} +
+ ) : ( +
+ {t('admin.apps.noConfigs', 'No configs available')} +
+ )} +
+ + {/* UUID input */} +
+ + setUuidInput(e.target.value)} + placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + className="input w-full font-mono text-sm" + /> +

+ {t( + 'admin.apps.remnaWaveConfigUuidHint', + 'UUID of subscription page config from RemnaWave panel', + )} +

+
+ + {/* Actions */} +
+ {status?.enabled && ( + + )} + +
); } diff --git a/src/pages/Connection.tsx b/src/pages/Connection.tsx index d4ec4a6..006cddd 100644 --- a/src/pages/Connection.tsx +++ b/src/pages/Connection.tsx @@ -526,7 +526,7 @@ export default function Connection() { {/* App cards grid (2 cols mobile, row on desktop) */} {currentPlatformApps.length > 0 && ( -
+
{currentPlatformApps.map((app, idx) => { const isSelected = currentApp?.name === app.name; const appIconSvg = getSvgHtml(app.svgIconKey); @@ -534,7 +534,7 @@ export default function Connection() {