mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
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:
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user