refactor: simplify AdminApps page to config selector and compact app chips

- Rewrite AdminApps.tsx from ~560 to ~165 lines: status card, config list, UUID input, save/disconnect
- Remove legacy types and conversion functions from adminApps.ts (~370 lines)
- Connection.tsx: flex-wrap app chips, reduce padding and icon size for compact layout
This commit is contained in:
c0mrade
2026-02-05 14:56:41 +03:00
parent 53940a0169
commit 1d7b984880
3 changed files with 118 additions and 885 deletions

View File

@@ -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<string, AppDefinition[]>;
}
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<string, AppDefinition[]>,
branding?: AppConfigBranding,
): RemnawaveConfig => {
const platforms: Record<string, RemnawavePlatform> = {};
for (const [platform, apps] of Object.entries(platformApps)) {
platforms[platform] = {
apps: apps.map(cabinetAppToRemnawave),
};
}
return {
platforms,
svgLibrary: {
download: {
svgString:
'<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" /></svg>',
},
plus: {
svgString:
'<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>',
},
check: {
svgString:
'<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" /></svg>',
},
settings: {
svgString:
'<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>',
},
},
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<string, AppDefinition[]>; branding?: AppConfigBranding } => {
const platformApps: Record<string, AppDefinition[]> = {};
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 };
};

View File

@@ -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 = () => (
<svg
className="h-5 w-5 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
);
const AppsIcon = () => (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3"
/>
</svg>
);
const StarIcon = ({ filled }: { filled: boolean }) => (
<svg
className={`h-4 w-4 ${filled ? 'fill-warning-400 text-warning-400' : 'text-dark-500'}`}
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z"
/>
</svg>
);
const CloudSyncIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 15a4.5 4.5 0 004.5 4.5H18a3.75 3.75 0 001.332-7.257 3 3 0 00-3.758-3.848 5.25 5.25 0 00-10.233 2.33A4.502 4.502 0 002.25 15z"
/>
</svg>
);
const RefreshIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
);
const SettingsIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);
const ChevronDownIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
);
const PLATFORM_LABELS: Record<string, string> = {
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<string, RemnawaveSvgItem>, 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 (
<div
className="h-6 w-6 flex-shrink-0 [&>svg]:h-full [&>svg]:w-full"
style={{ color: color || 'currentColor' }}
dangerouslySetInnerHTML={{ __html: sanitized }}
/>
);
}
function BaseSettingsPanel({ settings }: { settings: RemnawaveBaseSettings }) {
const { t } = useTranslation();
return (
<div className="card space-y-2 p-4">
<h3 className="text-sm font-semibold text-dark-200">
{t('admin.apps.baseSettings', 'Base Settings')}
</h3>
<div className="flex items-center gap-3 text-sm">
<span className="text-dark-400">{t('admin.apps.tutorialButton', 'Tutorial button')}:</span>
<span className={settings.isShowTutorialButton ? 'text-success-400' : 'text-dark-500'}>
{settings.isShowTutorialButton ? 'ON' : 'OFF'}
</span>
</div>
{settings.tutorialUrl && (
<div className="flex items-center gap-3 text-sm">
<span className="text-dark-400">URL:</span>
<span className="truncate font-mono text-xs text-dark-300">{settings.tutorialUrl}</span>
</div>
)}
</div>
);
}
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 (
<div className="card overflow-hidden">
<button
onClick={() => setExpanded(!expanded)}
className="flex w-full items-center justify-between p-4 text-left"
>
<h3 className="text-sm font-semibold text-dark-200">
{t('admin.apps.baseTranslations', 'Base Translations')}
</h3>
<div className={`transform transition-transform ${expanded ? 'rotate-180' : ''}`}>
<ChevronDownIcon />
</div>
</button>
{expanded && (
<div className="border-t border-dark-700 p-4">
<table className="w-full text-sm">
<thead>
<tr className="text-dark-500">
<th className="pb-2 text-left font-medium">Key</th>
<th className="pb-2 text-left font-medium">Value</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-800">
{keys.map((key) => (
<tr key={key}>
<td className="py-2 pr-4 font-mono text-xs text-dark-400">{key}</td>
<td className="py-2 text-dark-200">
{getLocalizedText(translations[key], lang)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
function AppCard({
app,
svgLibrary,
lang,
}: {
app: RemnawaveApp;
svgLibrary: Record<string, RemnawaveSvgItem>;
lang: string;
}) {
const [expanded, setExpanded] = useState(false);
return (
<div className="card overflow-hidden">
<button
onClick={() => setExpanded(!expanded)}
className="flex w-full items-center gap-4 p-4 text-left"
>
{/* App icon from svgLibrary */}
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-dark-700/50">
{renderSvgIcon(svgLibrary, app.svgIconKey) || <AppsIcon />}
</div>
{/* Name + badges */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-dark-100">{app.name}</span>
{app.featured && <StarIcon filled />}
{app.isNeedBase64Encoding && (
<span className="rounded bg-dark-700 px-2 py-0.5 text-xs text-dark-400">Base64</span>
)}
</div>
{app.urlScheme && (
<div className="mt-0.5 truncate font-mono text-xs text-dark-500">{app.urlScheme}</div>
)}
</div>
{/* Expand indicator */}
<div
className={`transform text-dark-500 transition-transform ${expanded ? 'rotate-180' : ''}`}
>
<ChevronDownIcon />
</div>
</button>
{/* Expanded blocks */}
{expanded && (
<div className="space-y-3 border-t border-dark-700 p-4">
{app.blocks?.map((block: RemnawaveBlock, idx: number) => (
<BlockCard key={idx} block={block} index={idx} svgLibrary={svgLibrary} lang={lang} />
))}
{(!app.blocks || app.blocks.length === 0) && (
<p className="py-4 text-center text-sm text-dark-500">No blocks</p>
)}
</div>
)}
</div>
);
}
function BlockCard({
block,
index,
svgLibrary,
lang,
}: {
block: RemnawaveBlock;
index: number;
svgLibrary: Record<string, RemnawaveSvgItem>;
lang: string;
}) {
const title = getLocalizedText(block.title, lang);
const description = getLocalizedText(block.description, lang);
const iconColor = block.svgIconColor || '#8B5CF6';
return (
<div className="rounded-lg bg-dark-800/50 p-3">
<div className="flex items-start gap-3">
<div
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full"
style={{
backgroundColor: iconColor + '20',
color: iconColor,
}}
>
{renderSvgIcon(svgLibrary, block.svgIconKey, block.svgIconColor) || (
<span className="text-xs font-bold text-dark-400">{index + 1}</span>
)}
</div>
<div className="min-w-0 flex-1">
{title && <div className="text-sm font-medium text-dark-200">{title}</div>}
{description && <div className="mt-1 text-sm text-dark-400">{description}</div>}
{block.buttons && block.buttons.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2">
{block.buttons.map((btn: RemnawaveButton, btnIdx: number) => (
<a
key={btnIdx}
href={btn.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-lg bg-dark-700 px-3 py-1.5 text-xs text-dark-200 hover:bg-dark-600"
>
{btn.svgIconKey && renderSvgIcon(svgLibrary, btn.svgIconKey)}
{getLocalizedText(btn.text, lang)}
{btn.type && (
<span className="rounded bg-dark-600 px-1.5 py-0.5 text-[10px] text-dark-400">
{btn.type}
</span>
)}
</a>
))}
</div>
)}
</div>
</div>
</div>
);
}
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<string>('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 (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
{!capabilities.hasBackButton && (
<button
onClick={() => 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"
>
<BackIcon />
</button>
)}
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">{t('admin.apps.title')}</h1>
</div>
<div className="flex items-center gap-2">
{remnaWaveStatus?.enabled && (
<button
onClick={() => 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')}
>
<RefreshIcon />
</button>
)}
<div className="flex items-center gap-3">
{!capabilities.hasBackButton && (
<button
onClick={() => {
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')}
onClick={() => 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"
>
<SettingsIcon />
<svg
className="h-5 w-5 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
</div>
)}
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">{t('admin.apps.title')}</h1>
</div>
{/* RemnaWave Info Banner */}
{remnaWaveStatus?.enabled && (
<div className="flex items-center gap-2 rounded-lg border border-accent-500/30 bg-accent-500/10 p-3 text-sm text-accent-200">
<CloudSyncIcon />
<span>
{t('admin.apps.remnaWaveMode', 'Showing apps from RemnaWave panel. Read-only mode.')}
{/* Status card */}
<div className="card p-4">
<div className="flex items-center gap-3">
<div
className={`h-3 w-3 rounded-full ${status?.enabled ? 'bg-success-400' : 'bg-dark-600'}`}
/>
<span className="text-sm font-medium text-dark-200">
{status?.enabled
? t('admin.apps.remnaWaveConnected', 'RemnaWave connected')
: t('admin.apps.remnaWaveDisconnected', 'RemnaWave not connected')}
</span>
</div>
)}
{/* Base Settings */}
{baseSettings && <BaseSettingsPanel settings={baseSettings} />}
{/* Platform Tabs */}
<div className="flex flex-wrap gap-2">
{PLATFORMS.map((platform) => {
const platformSvgKey = remnaWaveConfig?.platforms?.[platform]?.svgIconKey;
return (
<button
key={platform}
onClick={() => setSelectedPlatform(platform)}
className={`flex items-center gap-2 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'
}`}
>
{renderSvgIcon(svgLibrary, platformSvgKey)}
{PLATFORM_LABELS[platform]}
</button>
);
})}
{status?.config_uuid && (
<div className="mt-2 truncate font-mono text-xs text-dark-500">
UUID: {status.config_uuid}
</div>
)}
</div>
{/* Apps List */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : currentPlatformApps.length > 0 ? (
<div className="space-y-3">
{currentPlatformApps.map((app: RemnawaveApp, index: number) => (
<AppCard key={`${app.name}-${index}`} app={app} svgLibrary={svgLibrary} lang={lang} />
))}
</div>
) : (
<div className="card py-12 text-center">
<AppsIcon />
<p className="mt-4 text-dark-400">{t('admin.apps.noApps')}</p>
</div>
)}
{/* Base Translations */}
{baseTranslations && <BaseTranslationsPanel translations={baseTranslations} lang={lang} />}
{/* RemnaWave Settings Modal */}
{showRemnaWaveSettings && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
<div className="w-full max-w-lg rounded-xl bg-dark-900 p-6">
<h3 className="mb-4 flex items-center gap-2 text-lg font-semibold text-dark-100">
<CloudSyncIcon />
{t('admin.apps.remnaWaveSettings', 'RemnaWave Settings')}
</h3>
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm text-dark-300">
{t('admin.apps.remnaWaveConfigUuid', 'Config UUID')}
</label>
<input
type="text"
value={remnaWaveUuidInput}
onChange={(e) => setRemnaWaveUuidInput(e.target.value)}
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
className="input w-full font-mono text-sm"
/>
<p className="mt-1 text-xs text-dark-500">
{t(
'admin.apps.remnaWaveConfigUuidHint',
'UUID of subscription page config from RemnaWave panel',
)}
</p>
</div>
{/* Available configs from RemnaWave */}
{isLoadingConfigs ? (
<div className="flex items-center justify-center py-4">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : (
availableConfigs &&
availableConfigs.length > 0 && (
<div>
<label className="mb-2 block text-sm text-dark-300">
{t('admin.apps.availableConfigs', 'Available configs')}
</label>
<div className="max-h-48 space-y-2 overflow-y-auto">
{availableConfigs.map((config) => (
<button
key={config.uuid}
onClick={() => 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'
}`}
>
<div className="font-medium text-dark-100">{config.name}</div>
<div className="mt-1 font-mono text-xs text-dark-500">{config.uuid}</div>
</button>
))}
</div>
</div>
)
)}
</div>
<div className="mt-6 flex justify-end gap-3 border-t border-dark-700 pt-4">
<button onClick={() => setShowRemnaWaveSettings(false)} className="btn-secondary">
{t('common.cancel')}
</button>
<button
onClick={() => setUuidMutation.mutate(remnaWaveUuidInput || null)}
disabled={setUuidMutation.isPending}
className="btn-primary"
>
{setUuidMutation.isPending ? t('common.loading') : t('common.save')}
</button>
</div>
{/* Available configs */}
<div className="space-y-3">
<h2 className="text-sm font-semibold text-dark-300">
{t('admin.apps.availableConfigs', 'Available configs')}
</h2>
{isLoadingConfigs ? (
<div className="flex items-center justify-center py-8">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
</div>
)}
) : configs && configs.length > 0 ? (
<div className="space-y-2">
{configs.map((config) => (
<button
key={config.uuid}
onClick={() => setUuidInput(config.uuid)}
className={`w-full rounded-lg border p-4 text-left transition-colors ${
inputValue === config.uuid
? 'border-accent-500 bg-accent-500/10'
: 'border-dark-700 bg-dark-800/50 hover:border-dark-600'
}`}
>
<div className="font-medium text-dark-100">{config.name}</div>
<div className="mt-1 font-mono text-xs text-dark-500">{config.uuid}</div>
</button>
))}
</div>
) : (
<div className="card py-8 text-center text-sm text-dark-500">
{t('admin.apps.noConfigs', 'No configs available')}
</div>
)}
</div>
{/* UUID input */}
<div>
<label className="mb-2 block text-sm font-semibold text-dark-300">
{t('admin.apps.remnaWaveConfigUuid', 'Config UUID')}
</label>
<input
type="text"
value={inputValue}
onChange={(e) => setUuidInput(e.target.value)}
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
className="input w-full font-mono text-sm"
/>
<p className="mt-1 text-xs text-dark-500">
{t(
'admin.apps.remnaWaveConfigUuidHint',
'UUID of subscription page config from RemnaWave panel',
)}
</p>
</div>
{/* Actions */}
<div className="flex gap-3">
{status?.enabled && (
<button
onClick={() => {
setUuidInput('');
setUuidMutation.mutate(null);
}}
disabled={setUuidMutation.isPending}
className="btn-secondary"
>
{t('admin.apps.disconnect', 'Disconnect')}
</button>
)}
<button
onClick={() => setUuidMutation.mutate(inputValue || null)}
disabled={setUuidMutation.isPending || inputValue === currentUuid}
className="btn-primary"
>
{setUuidMutation.isPending ? t('common.loading') : t('common.save')}
</button>
</div>
</div>
);
}

View File

@@ -526,7 +526,7 @@ export default function Connection() {
{/* App cards grid (2 cols mobile, row on desktop) */}
{currentPlatformApps.length > 0 && (
<div className="grid grid-cols-2 gap-2 sm:flex">
<div className="flex flex-wrap gap-2">
{currentPlatformApps.map((app, idx) => {
const isSelected = currentApp?.name === app.name;
const appIconSvg = getSvgHtml(app.svgIconKey);
@@ -534,7 +534,7 @@ export default function Connection() {
<button
key={app.name + idx}
onClick={() => setSelectedRemnawaveApp(app)}
className={`relative flex min-w-0 items-center gap-2 overflow-hidden rounded-xl px-4 py-3 text-sm font-medium transition-all active:scale-[0.97] sm:flex-1 ${
className={`relative flex min-w-[calc(50%-0.25rem)] items-center gap-2 overflow-hidden rounded-xl px-4 py-2 text-sm font-medium transition-all active:scale-[0.97] ${
isSelected
? 'bg-accent-500/15 text-accent-400 ring-1 ring-accent-500/40'
: 'border border-dark-700/50 bg-dark-800/80 text-dark-200 hover:border-dark-600/50 hover:bg-dark-700/80'
@@ -544,7 +544,7 @@ export default function Connection() {
<span className="relative z-10 truncate">{app.name}</span>
{appIconSvg && (
<div
className="ml-auto h-12 w-12 shrink-0 opacity-30 [&>svg]:h-full [&>svg]:w-full"
className="ml-auto h-7 w-7 shrink-0 opacity-30 [&>svg]:h-full [&>svg]:w-full"
dangerouslySetInnerHTML={{ __html: appIconSvg }}
/>
)}