import { useState, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { adminAppsApi, AppDefinition, LocalizedText, AppStep, AppButton, exportToRemnawaveFormat, importFromRemnawaveFormat, RemnawaveConfig, importFromRemnawaveFormat as convertRemnawave, } from '../api/adminApps'; import { useBackButton } from '../platform/hooks/useBackButton'; import { usePlatform } from '../platform/hooks/usePlatform'; // Icons const BackIcon = () => ( ); const AppsIcon = () => ( ); const PlusIcon = () => ( ); const EditIcon = () => ( ); const TrashIcon = () => ( ); const ChevronUpIcon = () => ( ); const ChevronDownIcon = () => ( ); const CopyIcon = () => ( ); const StarIcon = ({ filled }: { filled: boolean }) => ( ); const DownloadIcon = () => ( ); const UploadIcon = () => ( ); const CloudSyncIcon = () => ( ); const RefreshIcon = () => ( ); const SettingsIcon = () => ( ); 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']; // Helper to create empty localized text const emptyLocalizedText = (): LocalizedText => ({ en: '', ru: '', zh: '', fa: '', }); // Helper to create empty app step const emptyAppStep = (): AppStep => ({ description: emptyLocalizedText(), buttons: [], }); // Helper to create empty app const createEmptyApp = (platform: string): AppDefinition => ({ id: `new-app-${platform}-${Date.now()}`, name: '', isFeatured: false, urlScheme: '', installationStep: emptyAppStep(), addSubscriptionStep: emptyAppStep(), connectAndUseStep: emptyAppStep(), }); interface AppEditorModalProps { app: AppDefinition; platform: string; isNew: boolean; onSave: (app: AppDefinition) => void; onClose: () => void; } function AppEditorModal({ app, platform, isNew, onSave, onClose }: AppEditorModalProps) { const { t } = useTranslation(); const [editedApp, setEditedApp] = useState({ ...app }); const [activeTab, setActiveTab] = useState< 'basic' | 'installation' | 'subscription' | 'connect' | 'additional' >('basic'); const updateField = (field: K, value: AppDefinition[K]) => { setEditedApp((prev) => ({ ...prev, [field]: value })); }; const updateLocalizedText = ( stepKey: | 'installationStep' | 'addSubscriptionStep' | 'connectAndUseStep' | 'additionalBeforeAddSubscriptionStep' | 'additionalAfterAddSubscriptionStep', field: 'description' | 'title', lang: keyof LocalizedText, value: string, ) => { setEditedApp((prev) => { const step = prev[stepKey] || emptyAppStep(); const fieldValue = step[field] || emptyLocalizedText(); return { ...prev, [stepKey]: { ...step, [field]: { ...fieldValue, [lang]: value }, }, }; }); }; const updateButtons = ( stepKey: | 'installationStep' | 'addSubscriptionStep' | 'connectAndUseStep' | 'additionalBeforeAddSubscriptionStep' | 'additionalAfterAddSubscriptionStep', buttons: AppButton[], ) => { setEditedApp((prev) => { const step = prev[stepKey] || emptyAppStep(); return { ...prev, [stepKey]: { ...step, buttons }, }; }); }; const addButton = ( stepKey: | 'installationStep' | 'additionalBeforeAddSubscriptionStep' | 'additionalAfterAddSubscriptionStep', ) => { const step = editedApp[stepKey] || emptyAppStep(); const buttons = step.buttons || []; // Generate unique id for React key const newButton: AppButton = { id: `btn-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, buttonLink: '', buttonText: emptyLocalizedText(), }; updateButtons(stepKey, [...buttons, newButton]); }; const removeButton = ( stepKey: | 'installationStep' | 'additionalBeforeAddSubscriptionStep' | 'additionalAfterAddSubscriptionStep', index: number, ) => { const step = editedApp[stepKey] || emptyAppStep(); const buttons = step.buttons || []; updateButtons( stepKey, buttons.filter((_, i) => i !== index), ); }; const updateButton = ( stepKey: | 'installationStep' | 'additionalBeforeAddSubscriptionStep' | 'additionalAfterAddSubscriptionStep', index: number, field: 'buttonLink' | 'buttonText', value: string | LocalizedText, ) => { const step = editedApp[stepKey] || emptyAppStep(); const buttons = [...(step.buttons || [])]; buttons[index] = { ...buttons[index], [field]: value }; updateButtons(stepKey, buttons); }; const renderLocalizedTextInputs = ( stepKey: | 'installationStep' | 'addSubscriptionStep' | 'connectAndUseStep' | 'additionalBeforeAddSubscriptionStep' | 'additionalAfterAddSubscriptionStep', field: 'description' | 'title', label: string, ) => { const step = editedApp[stepKey]; const value = step?.[field] || emptyLocalizedText(); return ( {label} {(['en', 'ru'] as const).map((lang) => ( {lang} updateLocalizedText(stepKey, field, lang, e.target.value)} className="input flex-1 text-sm" rows={2} /> ))} ); }; const renderButtonsEditor = ( stepKey: | 'installationStep' | 'additionalBeforeAddSubscriptionStep' | 'additionalAfterAddSubscriptionStep', ) => { const step = editedApp[stepKey]; const buttons = step?.buttons || []; return ( {t('admin.apps.buttons')} addButton(stepKey)} className="text-xs text-accent-400 hover:text-accent-300" > + {t('admin.apps.addButton')} {buttons.map((button, index) => ( {t('admin.apps.button')} #{index + 1} removeButton(stepKey, index)} className="text-error-400 hover:text-error-300" > updateButton(stepKey, index, 'buttonLink', e.target.value)} placeholder="https://..." className="input w-full text-sm" /> {(['en', 'ru'] as const).map((lang) => ( {lang} { const newButtonText = { ...button.buttonText, [lang]: e.target.value }; updateButton(stepKey, index, 'buttonText', newButtonText); }} placeholder={t('admin.apps.buttonText')} className="input flex-1 text-sm" /> ))} ))} ); }; return ( {/* Header */} {isNew ? t('admin.apps.createApp') : t('admin.apps.editApp')} -{' '} {PLATFORM_LABELS[platform]} {/* Tabs */} {(['basic', 'installation', 'subscription', 'connect', 'additional'] as const).map( (tab) => ( setActiveTab(tab)} className={`whitespace-nowrap px-4 py-2 text-sm font-medium ${ activeTab === tab ? 'border-b-2 border-accent-400 text-accent-400' : 'text-dark-400 hover:text-dark-200' }`} > {t(`admin.apps.tabs.${tab}`)} ), )} {/* Content */} {activeTab === 'basic' && ( <> {t('admin.apps.appId')} updateField('id', e.target.value)} className="input w-full" disabled={!isNew} /> {t('admin.apps.appName')} updateField('name', e.target.value)} className="input w-full" /> {t('admin.apps.urlScheme')} updateField('urlScheme', e.target.value)} className="input w-full" placeholder="app://add/" /> updateField('isFeatured', e.target.checked)} className="h-4 w-4 accent-accent-500" /> {t('admin.apps.featured')} updateField('isNeedBase64Encoding', e.target.checked || undefined) } className="h-4 w-4 accent-accent-500" /> {t('admin.apps.base64Encoding')} > )} {activeTab === 'installation' && ( <> {renderLocalizedTextInputs( 'installationStep', 'description', t('admin.apps.installDescription'), )} {renderButtonsEditor('installationStep')} > )} {activeTab === 'subscription' && ( <> {renderLocalizedTextInputs( 'addSubscriptionStep', 'description', t('admin.apps.subscriptionDescription'), )} > )} {activeTab === 'connect' && ( <> {renderLocalizedTextInputs( 'connectAndUseStep', 'description', t('admin.apps.connectDescription'), )} > )} {activeTab === 'additional' && ( <> {t('admin.apps.beforeSubscription')} {renderLocalizedTextInputs( 'additionalBeforeAddSubscriptionStep', 'title', t('admin.apps.stepTitle'), )} {renderLocalizedTextInputs( 'additionalBeforeAddSubscriptionStep', 'description', t('admin.apps.stepDescription'), )} {renderButtonsEditor('additionalBeforeAddSubscriptionStep')} {t('admin.apps.afterSubscription')} {renderLocalizedTextInputs( 'additionalAfterAddSubscriptionStep', 'title', t('admin.apps.stepTitle'), )} {renderLocalizedTextInputs( 'additionalAfterAddSubscriptionStep', 'description', t('admin.apps.stepDescription'), )} {renderButtonsEditor('additionalAfterAddSubscriptionStep')} > )} {/* Footer */} {t('common.cancel')} onSave(editedApp)} className="btn-primary"> {t('common.save')} ); } export default function AdminApps() { const { t } = useTranslation(); const navigate = useNavigate(); const queryClient = useQueryClient(); const { capabilities } = usePlatform(); // Use native Telegram back button in Mini App useBackButton(() => navigate('/admin')); const [selectedPlatform, setSelectedPlatform] = useState('ios'); const [editingApp, setEditingApp] = useState<{ app: AppDefinition; isNew: boolean } | null>(null); const [copyTarget, setCopyTarget] = useState<{ appId: string; platform: string } | null>(null); const [showImportModal, setShowImportModal] = useState(false); const [importError, setImportError] = useState(null); const [importPreview, setImportPreview] = useState<{ platformApps: Record; } | null>(null); const fileInputRef = useRef(null); const [useRemnaWave, setUseRemnaWave] = useState(false); const [showRemnaWaveSettings, setShowRemnaWaveSettings] = useState(false); const [remnaWaveUuidInput, setRemnaWaveUuidInput] = useState(''); // Check if RemnaWave config is available const { data: remnaWaveStatus, refetch: refetchStatus } = useQuery({ queryKey: ['remnawave-status'], queryFn: adminAppsApi.getRemnaWaveStatus, staleTime: 60000, }); // List available RemnaWave configs const { data: availableConfigs, isLoading: isLoadingConfigs } = useQuery({ queryKey: ['remnawave-configs-list'], queryFn: adminAppsApi.listRemnaWaveConfigs, enabled: showRemnaWaveSettings, staleTime: 30000, }); // Mutation for setting UUID const setUuidMutation = useMutation({ mutationFn: adminAppsApi.setRemnaWaveUuid, onSuccess: () => { refetchStatus(); queryClient.invalidateQueries({ queryKey: ['remnawave-config'] }); setShowRemnaWaveSettings(false); }, }); // Fetch RemnaWave config when toggle is enabled const { data: remnaWaveConfig, isLoading: isRemnaWaveLoading, refetch: refetchRemnaWave, } = useQuery({ queryKey: ['remnawave-config'], queryFn: adminAppsApi.getRemnaWaveConfig, enabled: useRemnaWave && remnaWaveStatus?.enabled, staleTime: 0, }); // Convert RemnaWave config to local format const remnaWaveApps = remnaWaveConfig ? convertRemnawave(remnaWaveConfig).platformApps[selectedPlatform] || [] : []; const { data: localApps, isLoading: isLocalLoading } = useQuery({ queryKey: ['admin-apps', selectedPlatform], queryFn: () => adminAppsApi.getPlatformApps(selectedPlatform), enabled: !useRemnaWave, }); // Select which apps to show based on toggle const apps = useRemnaWave ? remnaWaveApps : localApps; const isLoading = useRemnaWave ? isRemnaWaveLoading : isLocalLoading; const createMutation = useMutation({ mutationFn: ({ platform, app }: { platform: string; app: AppDefinition }) => adminAppsApi.createApp(platform, app), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-apps'] }); setEditingApp(null); }, }); const updateMutation = useMutation({ mutationFn: ({ platform, appId, app, }: { platform: string; appId: string; app: AppDefinition; }) => adminAppsApi.updateApp(platform, appId, app), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-apps'] }); setEditingApp(null); }, }); const deleteMutation = useMutation({ mutationFn: ({ platform, appId }: { platform: string; appId: string }) => adminAppsApi.deleteApp(platform, appId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-apps'] }); }, }); const reorderMutation = useMutation({ mutationFn: ({ platform, appIds }: { platform: string; appIds: string[] }) => adminAppsApi.reorderApps(platform, appIds), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-apps'] }); }, }); const copyMutation = useMutation({ mutationFn: ({ platform, appId, targetPlatform, }: { platform: string; appId: string; targetPlatform: string; }) => adminAppsApi.copyApp(platform, appId, targetPlatform), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-apps'] }); setCopyTarget(null); }, }); // Fetch all platforms for export const { data: allPlatformApps } = useQuery({ queryKey: ['admin-apps-all'], queryFn: async () => { const result: Record = {}; for (const platform of PLATFORMS) { try { result[platform] = await adminAppsApi.getPlatformApps(platform); } catch { result[platform] = []; } } return result; }, }); const { data: branding } = useQuery({ queryKey: ['admin-branding'], queryFn: adminAppsApi.getBranding, }); // Export handler const handleExport = () => { if (!allPlatformApps) return; const remnawaveConfig = exportToRemnawaveFormat(allPlatformApps, branding); const json = JSON.stringify(remnawaveConfig, null, 2); const blob = new Blob([json], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `apps-config-${new Date().toISOString().split('T')[0]}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }; // Import file handler const handleFileSelect = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; setImportError(null); setImportPreview(null); const reader = new FileReader(); reader.onload = (event) => { try { const json = JSON.parse(event.target?.result as string) as RemnawaveConfig; const imported = importFromRemnawaveFormat(json); setImportPreview(imported); setShowImportModal(true); } catch { setImportError(t('admin.apps.importError', 'Invalid JSON file')); setShowImportModal(true); } }; reader.readAsText(file); // Reset file input if (fileInputRef.current) fileInputRef.current.value = ''; }; // Confirm import handler const handleConfirmImport = async () => { if (!importPreview) return; try { for (const [platform, apps] of Object.entries(importPreview.platformApps)) { for (const app of apps) { await adminAppsApi.createApp(platform, app); } } queryClient.invalidateQueries({ queryKey: ['admin-apps'] }); queryClient.invalidateQueries({ queryKey: ['admin-apps-all'] }); setShowImportModal(false); setImportPreview(null); } catch { setImportError(t('admin.apps.importCreateError', 'Failed to create some apps')); } }; const handleMoveUp = (index: number) => { if (!apps || index === 0) return; const newOrder = [...apps]; [newOrder[index - 1], newOrder[index]] = [newOrder[index], newOrder[index - 1]]; reorderMutation.mutate({ platform: selectedPlatform, appIds: newOrder.map((a) => a.id) }); }; const handleMoveDown = (index: number) => { if (!apps || index === apps.length - 1) return; const newOrder = [...apps]; [newOrder[index], newOrder[index + 1]] = [newOrder[index + 1], newOrder[index]]; reorderMutation.mutate({ platform: selectedPlatform, appIds: newOrder.map((a) => a.id) }); }; const handleSave = (app: AppDefinition) => { if (editingApp?.isNew) { createMutation.mutate({ platform: selectedPlatform, app }); } else { updateMutation.mutate({ platform: selectedPlatform, appId: app.id, app }); } }; const handleDelete = (appId: string) => { if (confirm(t('admin.apps.confirmDelete'))) { deleteMutation.mutate({ platform: selectedPlatform, appId }); } }; return ( {/* Show back button only on web, not in Telegram Mini App */} {!capabilities.hasBackButton && ( navigate('/admin')} className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600" > )} {t('admin.apps.title')} {/* RemnaWave Toggle & Settings */} {remnaWaveStatus?.enabled && ( <> setUseRemnaWave(!useRemnaWave)} className={`flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all ${ useRemnaWave ? 'bg-accent-500 text-white' : 'bg-dark-800 text-dark-300 hover:bg-dark-700' }`} title={t('admin.apps.remnaWaveToggle', 'Toggle RemnaWave source')} > RemnaWave {useRemnaWave && ( refetchRemnaWave()} className="rounded-lg p-2 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200" title={t('admin.apps.refreshRemnaWave', 'Refresh from RemnaWave')} > )} > )} { setRemnaWaveUuidInput(remnaWaveStatus?.config_uuid || ''); setShowRemnaWaveSettings(true); }} className="rounded-lg p-2 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200" title={t('admin.apps.remnaWaveSettings', 'RemnaWave Settings')} > {/* RemnaWave Info Banner */} {useRemnaWave && ( {t( 'admin.apps.remnaWaveMode', 'Showing apps from RemnaWave panel. Changes here are read-only.', )} )} {/* Platform Tabs */} {PLATFORMS.map((platform) => ( setSelectedPlatform(platform)} className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors ${ selectedPlatform === platform ? 'bg-accent-500 text-white' : 'bg-dark-800 text-dark-300 hover:bg-dark-700' }`} > {PLATFORM_LABELS[platform]} ))} {/* Action Buttons - Only show when not in RemnaWave mode */} {!useRemnaWave && ( fileInputRef.current?.click()} className="btn-secondary flex items-center gap-2" title={t('admin.apps.importApps', 'Import Apps')} > {t('admin.apps.import', 'Import')} {t('admin.apps.export', 'Export')} setEditingApp({ app: createEmptyApp(selectedPlatform), isNew: true })} className="btn-primary flex items-center gap-2" > {t('admin.apps.addApp')} )} {/* Apps List */} {isLoading ? ( ) : apps && apps.length > 0 ? ( {apps.map((app, index) => ( {/* Reorder buttons - Only show when not in RemnaWave mode */} {!useRemnaWave && ( handleMoveUp(index)} disabled={index === 0} className="p-1 text-dark-400 hover:text-dark-200 disabled:cursor-not-allowed disabled:opacity-30" > handleMoveDown(index)} disabled={index === apps.length - 1} className="p-1 text-dark-400 hover:text-dark-200 disabled:cursor-not-allowed disabled:opacity-30" > )} {/* App Info */} {app.name} {app.isFeatured && } {app.isNeedBase64Encoding && ( Base64 )} {app.id} {app.urlScheme} {/* Actions - Only show when not in RemnaWave mode */} {!useRemnaWave && ( setCopyTarget({ appId: app.id, platform: selectedPlatform })} className="rounded p-2 text-dark-400 hover:bg-dark-700 hover:text-dark-200" title={t('admin.apps.copyTo')} > setEditingApp({ app, isNew: false })} className="rounded p-2 text-dark-400 hover:bg-dark-700 hover:text-dark-200" title={t('common.edit')} > handleDelete(app.id)} className="rounded p-2 text-error-400 hover:bg-error-500/10 hover:text-error-300" title={t('common.delete')} > )} ))} ) : ( {t('admin.apps.noApps')} setEditingApp({ app: createEmptyApp(selectedPlatform), isNew: true })} className="btn-primary mt-4" > {t('admin.apps.addFirstApp')} )} {/* Edit Modal */} {editingApp && ( setEditingApp(null)} /> )} {/* Copy Modal */} {copyTarget && ( {t('admin.apps.copyTo')} {PLATFORMS.filter((p) => p !== copyTarget.platform).map((platform) => ( copyMutation.mutate({ platform: copyTarget.platform, appId: copyTarget.appId, targetPlatform: platform, }) } className="rounded-lg bg-dark-800 px-4 py-2 text-sm text-dark-200 hover:bg-dark-700" > {PLATFORM_LABELS[platform]} ))} setCopyTarget(null)} className="btn-secondary mt-4 w-full"> {t('common.cancel')} )} {/* Import Modal */} {showImportModal && ( {t('admin.apps.importPreview', 'Import Preview')} {importError && ( {importError} )} {importPreview && ( {Object.entries(importPreview.platformApps).map( ([platform, platformApps]) => platformApps.length > 0 && ( {PLATFORM_LABELS[platform] || platform} ({platformApps.length}) {platformApps.map((app, idx) => ( {app.isFeatured && } {app.name} ({app.urlScheme || '-'}) ))} ), )} {Object.values(importPreview.platformApps).every((arr) => arr.length === 0) && ( {t('admin.apps.noAppsToImport', 'No apps to import')} )} )} { setShowImportModal(false); setImportPreview(null); setImportError(null); }} className="btn-secondary" > {t('common.cancel')} {importPreview && !importError && ( {t('admin.apps.confirmImport', 'Import Apps')} )} )} {/* RemnaWave Settings Modal */} {showRemnaWaveSettings && ( {t('admin.apps.remnaWaveSettings', 'RemnaWave Settings')} {t('admin.apps.remnaWaveConfigUuid', 'Config UUID')} 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 && ( {t('admin.apps.availableConfigs', 'Available configs')} {availableConfigs.map((config) => ( setRemnaWaveUuidInput(config.uuid)} className={`w-full rounded-lg border p-3 text-left transition-colors ${ remnaWaveUuidInput === config.uuid ? 'border-accent-500 bg-accent-500/10' : 'border-dark-700 bg-dark-800/50 hover:border-dark-600' }`} > {config.name} {config.uuid} ))} ) )} setShowRemnaWaveSettings(false)} className="btn-secondary"> {t('common.cancel')} setUuidMutation.mutate(remnaWaveUuidInput || null)} disabled={setUuidMutation.isPending} className="btn-primary" > {setUuidMutation.isPending ? t('common.loading') : t('common.save')} )} ); }
{t('admin.apps.noApps')}
{t( 'admin.apps.remnaWaveConfigUuidHint', 'UUID of subscription page config from RemnaWave panel', )}