import { useState, useRef } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import { Link } from 'react-router-dom' import { adminAppsApi, AppDefinition, LocalizedText, AppStep, AppButton, exportToRemnawaveFormat, importFromRemnawaveFormat, RemnawaveConfig, importFromRemnawaveFormat as convertRemnawave, } from '../api/adminApps' // 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 || [] updateButtons(stepKey, [...buttons, { buttonLink: '', buttonText: emptyLocalizedText() }]) } 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={`px-4 py-2 text-sm font-medium whitespace-nowrap ${ activeTab === tab ? 'text-accent-400 border-b-2 border-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="w-4 h-4 accent-accent-500" /> {t('admin.apps.featured')} updateField('isNeedBase64Encoding', e.target.checked || undefined)} className="w-4 h-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 queryClient = useQueryClient() 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 (err) { 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 (err) { 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 ( {t('admin.apps.title')} {/* RemnaWave Toggle & Settings */} {remnaWaveStatus?.enabled && ( <> setUseRemnaWave(!useRemnaWave)} className={`flex items-center gap-2 px-4 py-2 rounded-lg 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="p-2 text-dark-400 hover:text-dark-200 hover:bg-dark-700 rounded-lg transition-colors" title={t('admin.apps.refreshRemnaWave', 'Refresh from RemnaWave')} > )} > )} { setRemnaWaveUuidInput(remnaWaveStatus?.config_uuid || '') setShowRemnaWaveSettings(true) }} className="p-2 text-dark-400 hover:text-dark-200 hover:bg-dark-700 rounded-lg transition-colors" 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={`px-4 py-2 rounded-lg 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:opacity-30 disabled:cursor-not-allowed" > handleMoveDown(index)} disabled={index === apps.length - 1} className="p-1 text-dark-400 hover:text-dark-200 disabled:opacity-30 disabled:cursor-not-allowed" > )} {/* 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="p-2 text-dark-400 hover:text-dark-200 hover:bg-dark-700 rounded" title={t('admin.apps.copyTo')} > setEditingApp({ app, isNew: false })} className="p-2 text-dark-400 hover:text-dark-200 hover:bg-dark-700 rounded" title={t('common.edit')} > handleDelete(app.id)} className="p-2 text-error-400 hover:text-error-300 hover:bg-error-500/10 rounded" 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="px-4 py-2 bg-dark-800 hover:bg-dark-700 rounded-lg text-dark-200 text-sm" > {PLATFORM_LABELS[platform]} ))} setCopyTarget(null)} className="btn-secondary w-full mt-4" > {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 text-left p-3 rounded-lg border 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')}