mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23:47 +00:00
Fixed issue where users had to click the Telegram Mini App back button twice in many admin pages. The replace flag was causing conflicts between Telegram's BackButton internal history and React Router's history management. Updated all 32 admin pages to use simple navigate() calls without replace flag.
1221 lines
45 KiB
TypeScript
1221 lines
45 KiB
TypeScript
import { useState, useRef } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { useTranslation } from 'react-i18next';
|
|
import {
|
|
adminAppsApi,
|
|
AppDefinition,
|
|
LocalizedText,
|
|
AppStep,
|
|
AppButton,
|
|
exportToRemnawaveFormat,
|
|
importFromRemnawaveFormat,
|
|
RemnawaveConfig,
|
|
importFromRemnawaveFormat as convertRemnawave,
|
|
} from '../api/adminApps';
|
|
import { useBackButton } from '../platform/hooks/useBackButton';
|
|
import { usePlatform } from '../platform/hooks/usePlatform';
|
|
|
|
// Icons
|
|
|
|
const BackIcon = () => (
|
|
<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 PlusIcon = () => (
|
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
|
</svg>
|
|
);
|
|
|
|
const EditIcon = () => (
|
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
|
/>
|
|
</svg>
|
|
);
|
|
|
|
const TrashIcon = () => (
|
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
|
/>
|
|
</svg>
|
|
);
|
|
|
|
const ChevronUpIcon = () => (
|
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" />
|
|
</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 CopyIcon = () => (
|
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5a3.375 3.375 0 00-3.375-3.375H9.75"
|
|
/>
|
|
</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 DownloadIcon = () => (
|
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="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>
|
|
);
|
|
|
|
const UploadIcon = () => (
|
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
|
|
/>
|
|
</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 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'];
|
|
|
|
// 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<AppDefinition>({ ...app });
|
|
const [activeTab, setActiveTab] = useState<
|
|
'basic' | 'installation' | 'subscription' | 'connect' | 'additional'
|
|
>('basic');
|
|
|
|
const updateField = <K extends keyof AppDefinition>(field: K, value: AppDefinition[K]) => {
|
|
setEditedApp((prev) => ({ ...prev, [field]: value }));
|
|
};
|
|
|
|
const updateLocalizedText = (
|
|
stepKey:
|
|
| 'installationStep'
|
|
| 'addSubscriptionStep'
|
|
| 'connectAndUseStep'
|
|
| 'additionalBeforeAddSubscriptionStep'
|
|
| 'additionalAfterAddSubscriptionStep',
|
|
field: 'description' | 'title',
|
|
lang: keyof LocalizedText,
|
|
value: string,
|
|
) => {
|
|
setEditedApp((prev) => {
|
|
const step = prev[stepKey] || emptyAppStep();
|
|
const fieldValue = step[field] || emptyLocalizedText();
|
|
return {
|
|
...prev,
|
|
[stepKey]: {
|
|
...step,
|
|
[field]: { ...fieldValue, [lang]: value },
|
|
},
|
|
};
|
|
});
|
|
};
|
|
|
|
const updateButtons = (
|
|
stepKey:
|
|
| 'installationStep'
|
|
| 'addSubscriptionStep'
|
|
| 'connectAndUseStep'
|
|
| 'additionalBeforeAddSubscriptionStep'
|
|
| 'additionalAfterAddSubscriptionStep',
|
|
buttons: AppButton[],
|
|
) => {
|
|
setEditedApp((prev) => {
|
|
const step = prev[stepKey] || emptyAppStep();
|
|
return {
|
|
...prev,
|
|
[stepKey]: { ...step, buttons },
|
|
};
|
|
});
|
|
};
|
|
|
|
const addButton = (
|
|
stepKey:
|
|
| 'installationStep'
|
|
| 'additionalBeforeAddSubscriptionStep'
|
|
| 'additionalAfterAddSubscriptionStep',
|
|
) => {
|
|
const step = editedApp[stepKey] || emptyAppStep();
|
|
const buttons = step.buttons || [];
|
|
// Generate unique id for React key
|
|
const newButton: AppButton = {
|
|
id: `btn-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
|
buttonLink: '',
|
|
buttonText: emptyLocalizedText(),
|
|
};
|
|
updateButtons(stepKey, [...buttons, newButton]);
|
|
};
|
|
|
|
const removeButton = (
|
|
stepKey:
|
|
| 'installationStep'
|
|
| 'additionalBeforeAddSubscriptionStep'
|
|
| 'additionalAfterAddSubscriptionStep',
|
|
index: number,
|
|
) => {
|
|
const step = editedApp[stepKey] || emptyAppStep();
|
|
const buttons = step.buttons || [];
|
|
updateButtons(
|
|
stepKey,
|
|
buttons.filter((_, i) => i !== index),
|
|
);
|
|
};
|
|
|
|
const updateButton = (
|
|
stepKey:
|
|
| 'installationStep'
|
|
| 'additionalBeforeAddSubscriptionStep'
|
|
| 'additionalAfterAddSubscriptionStep',
|
|
index: number,
|
|
field: 'buttonLink' | 'buttonText',
|
|
value: string | LocalizedText,
|
|
) => {
|
|
const step = editedApp[stepKey] || emptyAppStep();
|
|
const buttons = [...(step.buttons || [])];
|
|
buttons[index] = { ...buttons[index], [field]: value };
|
|
updateButtons(stepKey, buttons);
|
|
};
|
|
|
|
const renderLocalizedTextInputs = (
|
|
stepKey:
|
|
| 'installationStep'
|
|
| 'addSubscriptionStep'
|
|
| 'connectAndUseStep'
|
|
| 'additionalBeforeAddSubscriptionStep'
|
|
| 'additionalAfterAddSubscriptionStep',
|
|
field: 'description' | 'title',
|
|
label: string,
|
|
) => {
|
|
const step = editedApp[stepKey];
|
|
const value = step?.[field] || emptyLocalizedText();
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
<label className="block text-sm font-medium text-dark-300">{label}</label>
|
|
{(['en', 'ru'] as const).map((lang) => (
|
|
<div key={lang} className="flex items-center gap-2">
|
|
<span className="w-8 text-xs uppercase text-dark-500">{lang}</span>
|
|
<textarea
|
|
value={value[lang] || ''}
|
|
onChange={(e) => updateLocalizedText(stepKey, field, lang, e.target.value)}
|
|
className="input flex-1 text-sm"
|
|
rows={2}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderButtonsEditor = (
|
|
stepKey:
|
|
| 'installationStep'
|
|
| 'additionalBeforeAddSubscriptionStep'
|
|
| 'additionalAfterAddSubscriptionStep',
|
|
) => {
|
|
const step = editedApp[stepKey];
|
|
const buttons = step?.buttons || [];
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-sm font-medium text-dark-300">{t('admin.apps.buttons')}</label>
|
|
<button
|
|
type="button"
|
|
onClick={() => addButton(stepKey)}
|
|
className="text-xs text-accent-400 hover:text-accent-300"
|
|
>
|
|
+ {t('admin.apps.addButton')}
|
|
</button>
|
|
</div>
|
|
{buttons.map((button, index) => (
|
|
<div
|
|
key={button.id || `fallback-${index}`}
|
|
className="space-y-2 rounded-lg bg-dark-800/50 p-3"
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs text-dark-400">
|
|
{t('admin.apps.button')} #{index + 1}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => removeButton(stepKey, index)}
|
|
className="text-error-400 hover:text-error-300"
|
|
>
|
|
<TrashIcon />
|
|
</button>
|
|
</div>
|
|
<input
|
|
type="url"
|
|
value={button.buttonLink}
|
|
onChange={(e) => updateButton(stepKey, index, 'buttonLink', e.target.value)}
|
|
placeholder="https://..."
|
|
className="input w-full text-sm"
|
|
/>
|
|
{(['en', 'ru'] as const).map((lang) => (
|
|
<div key={lang} className="flex items-center gap-2">
|
|
<span className="w-8 text-xs uppercase text-dark-500">{lang}</span>
|
|
<input
|
|
type="text"
|
|
value={button.buttonText[lang] || ''}
|
|
onChange={(e) => {
|
|
const newButtonText = { ...button.buttonText, [lang]: e.target.value };
|
|
updateButton(stepKey, index, 'buttonText', newButtonText);
|
|
}}
|
|
placeholder={t('admin.apps.buttonText')}
|
|
className="input flex-1 text-sm"
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
|
|
<div className="flex max-h-[90vh] w-full max-w-3xl flex-col overflow-hidden rounded-xl bg-dark-900">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between border-b border-dark-700 p-4">
|
|
<h2 className="text-lg font-semibold text-dark-100">
|
|
{isNew ? t('admin.apps.createApp') : t('admin.apps.editApp')} -{' '}
|
|
{PLATFORM_LABELS[platform]}
|
|
</h2>
|
|
<button onClick={onClose} className="text-dark-400 hover:text-dark-200">
|
|
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M6 18L18 6M6 6l12 12"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex overflow-x-auto border-b border-dark-700">
|
|
{(['basic', 'installation', 'subscription', 'connect', 'additional'] as const).map(
|
|
(tab) => (
|
|
<button
|
|
key={tab}
|
|
onClick={() => setActiveTab(tab)}
|
|
className={`whitespace-nowrap px-4 py-2 text-sm font-medium ${
|
|
activeTab === tab
|
|
? 'border-b-2 border-accent-400 text-accent-400'
|
|
: 'text-dark-400 hover:text-dark-200'
|
|
}`}
|
|
>
|
|
{t(`admin.apps.tabs.${tab}`)}
|
|
</button>
|
|
),
|
|
)}
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 space-y-4 overflow-y-auto p-4">
|
|
{activeTab === 'basic' && (
|
|
<>
|
|
<div>
|
|
<label className="mb-1 block text-sm font-medium text-dark-300">
|
|
{t('admin.apps.appId')}
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={editedApp.id}
|
|
onChange={(e) => updateField('id', e.target.value)}
|
|
className="input w-full"
|
|
disabled={!isNew}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="mb-1 block text-sm font-medium text-dark-300">
|
|
{t('admin.apps.appName')}
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={editedApp.name}
|
|
onChange={(e) => updateField('name', e.target.value)}
|
|
className="input w-full"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="mb-1 block text-sm font-medium text-dark-300">
|
|
{t('admin.apps.urlScheme')}
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={editedApp.urlScheme}
|
|
onChange={(e) => updateField('urlScheme', e.target.value)}
|
|
className="input w-full"
|
|
placeholder="app://add/"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-4">
|
|
<label className="flex cursor-pointer items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={editedApp.isFeatured}
|
|
onChange={(e) => updateField('isFeatured', e.target.checked)}
|
|
className="h-4 w-4 accent-accent-500"
|
|
/>
|
|
<span className="text-sm text-dark-300">{t('admin.apps.featured')}</span>
|
|
</label>
|
|
<label className="flex cursor-pointer items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={editedApp.isNeedBase64Encoding || false}
|
|
onChange={(e) =>
|
|
updateField('isNeedBase64Encoding', e.target.checked || undefined)
|
|
}
|
|
className="h-4 w-4 accent-accent-500"
|
|
/>
|
|
<span className="text-sm text-dark-300">{t('admin.apps.base64Encoding')}</span>
|
|
</label>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{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' && (
|
|
<>
|
|
<div className="space-y-4">
|
|
<h3 className="text-sm font-semibold text-dark-200">
|
|
{t('admin.apps.beforeSubscription')}
|
|
</h3>
|
|
{renderLocalizedTextInputs(
|
|
'additionalBeforeAddSubscriptionStep',
|
|
'title',
|
|
t('admin.apps.stepTitle'),
|
|
)}
|
|
{renderLocalizedTextInputs(
|
|
'additionalBeforeAddSubscriptionStep',
|
|
'description',
|
|
t('admin.apps.stepDescription'),
|
|
)}
|
|
{renderButtonsEditor('additionalBeforeAddSubscriptionStep')}
|
|
</div>
|
|
<div className="space-y-4 border-t border-dark-700 pt-4">
|
|
<h3 className="text-sm font-semibold text-dark-200">
|
|
{t('admin.apps.afterSubscription')}
|
|
</h3>
|
|
{renderLocalizedTextInputs(
|
|
'additionalAfterAddSubscriptionStep',
|
|
'title',
|
|
t('admin.apps.stepTitle'),
|
|
)}
|
|
{renderLocalizedTextInputs(
|
|
'additionalAfterAddSubscriptionStep',
|
|
'description',
|
|
t('admin.apps.stepDescription'),
|
|
)}
|
|
{renderButtonsEditor('additionalAfterAddSubscriptionStep')}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="flex justify-end gap-3 border-t border-dark-700 p-4">
|
|
<button onClick={onClose} className="btn-secondary">
|
|
{t('common.cancel')}
|
|
</button>
|
|
<button onClick={() => onSave(editedApp)} className="btn-primary">
|
|
{t('common.save')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function AdminApps() {
|
|
const { t } = useTranslation();
|
|
const navigate = useNavigate();
|
|
const queryClient = useQueryClient();
|
|
const { capabilities } = usePlatform();
|
|
|
|
// Use native Telegram back button in Mini App
|
|
useBackButton(() => navigate('/admin'));
|
|
|
|
const [selectedPlatform, setSelectedPlatform] = useState<string>('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<string | null>(null);
|
|
const [importPreview, setImportPreview] = useState<{
|
|
platformApps: Record<string, AppDefinition[]>;
|
|
} | null>(null);
|
|
const fileInputRef = useRef<HTMLInputElement>(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<string, AppDefinition[]> = {};
|
|
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<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
setImportError(null);
|
|
setImportPreview(null);
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = (event) => {
|
|
try {
|
|
const json = JSON.parse(event.target?.result as string) as RemnawaveConfig;
|
|
const imported = importFromRemnawaveFormat(json);
|
|
setImportPreview(imported);
|
|
setShowImportModal(true);
|
|
} catch {
|
|
setImportError(t('admin.apps.importError', 'Invalid JSON file'));
|
|
setShowImportModal(true);
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
// Reset file input
|
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
|
};
|
|
|
|
// Confirm import handler
|
|
const handleConfirmImport = async () => {
|
|
if (!importPreview) return;
|
|
try {
|
|
for (const [platform, apps] of Object.entries(importPreview.platformApps)) {
|
|
for (const app of apps) {
|
|
await adminAppsApi.createApp(platform, app);
|
|
}
|
|
}
|
|
queryClient.invalidateQueries({ queryKey: ['admin-apps'] });
|
|
queryClient.invalidateQueries({ queryKey: ['admin-apps-all'] });
|
|
setShowImportModal(false);
|
|
setImportPreview(null);
|
|
} catch {
|
|
setImportError(t('admin.apps.importCreateError', 'Failed to create some apps'));
|
|
}
|
|
};
|
|
|
|
const handleMoveUp = (index: number) => {
|
|
if (!apps || index === 0) return;
|
|
const newOrder = [...apps];
|
|
[newOrder[index - 1], newOrder[index]] = [newOrder[index], newOrder[index - 1]];
|
|
reorderMutation.mutate({ platform: selectedPlatform, appIds: newOrder.map((a) => a.id) });
|
|
};
|
|
|
|
const handleMoveDown = (index: number) => {
|
|
if (!apps || index === apps.length - 1) return;
|
|
const newOrder = [...apps];
|
|
[newOrder[index], newOrder[index + 1]] = [newOrder[index + 1], newOrder[index]];
|
|
reorderMutation.mutate({ platform: selectedPlatform, appIds: newOrder.map((a) => a.id) });
|
|
};
|
|
|
|
const handleSave = (app: AppDefinition) => {
|
|
if (editingApp?.isNew) {
|
|
createMutation.mutate({ platform: selectedPlatform, app });
|
|
} else {
|
|
updateMutation.mutate({ platform: selectedPlatform, appId: app.id, app });
|
|
}
|
|
};
|
|
|
|
const handleDelete = (appId: string) => {
|
|
if (confirm(t('admin.apps.confirmDelete'))) {
|
|
deleteMutation.mutate({ platform: selectedPlatform, appId });
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="flex items-center gap-3">
|
|
{/* Show back button only on web, not in Telegram Mini App */}
|
|
{!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>
|
|
|
|
{/* RemnaWave Toggle & Settings */}
|
|
<div className="flex items-center gap-2">
|
|
{remnaWaveStatus?.enabled && (
|
|
<>
|
|
<button
|
|
onClick={() => setUseRemnaWave(!useRemnaWave)}
|
|
className={`flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all ${
|
|
useRemnaWave
|
|
? 'bg-accent-500 text-white'
|
|
: 'bg-dark-800 text-dark-300 hover:bg-dark-700'
|
|
}`}
|
|
title={t('admin.apps.remnaWaveToggle', 'Toggle RemnaWave source')}
|
|
>
|
|
<CloudSyncIcon />
|
|
<span>RemnaWave</span>
|
|
</button>
|
|
{useRemnaWave && (
|
|
<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>
|
|
)}
|
|
</>
|
|
)}
|
|
<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')}
|
|
>
|
|
<SettingsIcon />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* RemnaWave Info Banner */}
|
|
{useRemnaWave && (
|
|
<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. Changes here are read-only.',
|
|
)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Platform Tabs */}
|
|
<div className="flex flex-wrap gap-2">
|
|
{PLATFORMS.map((platform) => (
|
|
<button
|
|
key={platform}
|
|
onClick={() => setSelectedPlatform(platform)}
|
|
className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
|
|
selectedPlatform === platform
|
|
? 'bg-accent-500 text-white'
|
|
: 'bg-dark-800 text-dark-300 hover:bg-dark-700'
|
|
}`}
|
|
>
|
|
{PLATFORM_LABELS[platform]}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Action Buttons - Only show when not in RemnaWave mode */}
|
|
{!useRemnaWave && (
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="flex gap-2">
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept=".json"
|
|
onChange={handleFileSelect}
|
|
className="hidden"
|
|
/>
|
|
<button
|
|
onClick={() => fileInputRef.current?.click()}
|
|
className="btn-secondary flex items-center gap-2"
|
|
title={t('admin.apps.importApps', 'Import Apps')}
|
|
>
|
|
<UploadIcon />
|
|
{t('admin.apps.import', 'Import')}
|
|
</button>
|
|
<button
|
|
onClick={handleExport}
|
|
disabled={!allPlatformApps}
|
|
className="btn-secondary flex items-center gap-2"
|
|
title={t('admin.apps.exportApps', 'Export Apps')}
|
|
>
|
|
<DownloadIcon />
|
|
{t('admin.apps.export', 'Export')}
|
|
</button>
|
|
</div>
|
|
<button
|
|
onClick={() => setEditingApp({ app: createEmptyApp(selectedPlatform), isNew: true })}
|
|
className="btn-primary flex items-center gap-2"
|
|
>
|
|
<PlusIcon />
|
|
{t('admin.apps.addApp')}
|
|
</button>
|
|
</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>
|
|
) : apps && apps.length > 0 ? (
|
|
<div className="space-y-3">
|
|
{apps.map((app, index) => (
|
|
<div key={app.id} className="card flex items-center gap-4 p-4">
|
|
{/* Reorder buttons - Only show when not in RemnaWave mode */}
|
|
{!useRemnaWave && (
|
|
<div className="flex flex-col gap-1">
|
|
<button
|
|
onClick={() => handleMoveUp(index)}
|
|
disabled={index === 0}
|
|
className="p-1 text-dark-400 hover:text-dark-200 disabled:cursor-not-allowed disabled:opacity-30"
|
|
>
|
|
<ChevronUpIcon />
|
|
</button>
|
|
<button
|
|
onClick={() => handleMoveDown(index)}
|
|
disabled={index === apps.length - 1}
|
|
className="p-1 text-dark-400 hover:text-dark-200 disabled:cursor-not-allowed disabled:opacity-30"
|
|
>
|
|
<ChevronDownIcon />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* App Info */}
|
|
<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.isFeatured && <StarIcon filled />}
|
|
{app.isNeedBase64Encoding && (
|
|
<span className="rounded bg-dark-700 px-2 py-0.5 text-xs text-dark-400">
|
|
Base64
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="truncate font-mono text-xs text-dark-500">{app.id}</div>
|
|
<div className="mt-1 text-xs text-dark-400">{app.urlScheme}</div>
|
|
</div>
|
|
|
|
{/* Actions - Only show when not in RemnaWave mode */}
|
|
{!useRemnaWave && (
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => setCopyTarget({ appId: app.id, platform: selectedPlatform })}
|
|
className="rounded p-2 text-dark-400 hover:bg-dark-700 hover:text-dark-200"
|
|
title={t('admin.apps.copyTo')}
|
|
>
|
|
<CopyIcon />
|
|
</button>
|
|
<button
|
|
onClick={() => setEditingApp({ app, isNew: false })}
|
|
className="rounded p-2 text-dark-400 hover:bg-dark-700 hover:text-dark-200"
|
|
title={t('common.edit')}
|
|
>
|
|
<EditIcon />
|
|
</button>
|
|
<button
|
|
onClick={() => handleDelete(app.id)}
|
|
className="rounded p-2 text-error-400 hover:bg-error-500/10 hover:text-error-300"
|
|
title={t('common.delete')}
|
|
>
|
|
<TrashIcon />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="card py-12 text-center">
|
|
<AppsIcon />
|
|
<p className="mt-4 text-dark-400">{t('admin.apps.noApps')}</p>
|
|
<button
|
|
onClick={() => setEditingApp({ app: createEmptyApp(selectedPlatform), isNew: true })}
|
|
className="btn-primary mt-4"
|
|
>
|
|
{t('admin.apps.addFirstApp')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Edit Modal */}
|
|
{editingApp && (
|
|
<AppEditorModal
|
|
app={editingApp.app}
|
|
platform={selectedPlatform}
|
|
isNew={editingApp.isNew}
|
|
onSave={handleSave}
|
|
onClose={() => setEditingApp(null)}
|
|
/>
|
|
)}
|
|
|
|
{/* Copy Modal */}
|
|
{copyTarget && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
|
|
<div className="w-full max-w-md rounded-xl bg-dark-900 p-6">
|
|
<h3 className="mb-4 text-lg font-semibold text-dark-100">{t('admin.apps.copyTo')}</h3>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{PLATFORMS.filter((p) => p !== copyTarget.platform).map((platform) => (
|
|
<button
|
|
key={platform}
|
|
onClick={() =>
|
|
copyMutation.mutate({
|
|
platform: copyTarget.platform,
|
|
appId: copyTarget.appId,
|
|
targetPlatform: platform,
|
|
})
|
|
}
|
|
className="rounded-lg bg-dark-800 px-4 py-2 text-sm text-dark-200 hover:bg-dark-700"
|
|
>
|
|
{PLATFORM_LABELS[platform]}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<button onClick={() => setCopyTarget(null)} className="btn-secondary mt-4 w-full">
|
|
{t('common.cancel')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Import Modal */}
|
|
{showImportModal && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
|
|
<div className="flex max-h-[80vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl bg-dark-900 p-6">
|
|
<h3 className="mb-4 text-lg font-semibold text-dark-100">
|
|
{t('admin.apps.importPreview', 'Import Preview')}
|
|
</h3>
|
|
|
|
{importError && (
|
|
<div className="mb-4 rounded-lg border border-error-500/30 bg-error-500/10 p-3 text-error-400">
|
|
{importError}
|
|
</div>
|
|
)}
|
|
|
|
{importPreview && (
|
|
<div className="mb-4 flex-1 space-y-4 overflow-y-auto">
|
|
{Object.entries(importPreview.platformApps).map(
|
|
([platform, platformApps]) =>
|
|
platformApps.length > 0 && (
|
|
<div key={platform} className="rounded-lg bg-dark-800/50 p-4">
|
|
<h4 className="mb-2 font-medium text-dark-200">
|
|
{PLATFORM_LABELS[platform] || platform} ({platformApps.length})
|
|
</h4>
|
|
<ul className="space-y-1">
|
|
{platformApps.map((app, idx) => (
|
|
<li key={idx} className="flex items-center gap-2 text-sm text-dark-400">
|
|
{app.isFeatured && <StarIcon filled />}
|
|
<span>{app.name}</span>
|
|
<span className="font-mono text-xs text-dark-600">
|
|
({app.urlScheme || '-'})
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
),
|
|
)}
|
|
{Object.values(importPreview.platformApps).every((arr) => arr.length === 0) && (
|
|
<div className="py-8 text-center text-dark-400">
|
|
{t('admin.apps.noAppsToImport', 'No apps to import')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex justify-end gap-3 border-t border-dark-700 pt-4">
|
|
<button
|
|
onClick={() => {
|
|
setShowImportModal(false);
|
|
setImportPreview(null);
|
|
setImportError(null);
|
|
}}
|
|
className="btn-secondary"
|
|
>
|
|
{t('common.cancel')}
|
|
</button>
|
|
{importPreview && !importError && (
|
|
<button onClick={handleConfirmImport} className="btn-primary">
|
|
{t('admin.apps.confirmImport', 'Import Apps')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 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>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|