diff --git a/package-lock.json b/package-lock.json index c3b1b0e..73d9f3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@kastov/cryptohapp": "^1.1.2", "@lottiefiles/dotlottie-react": "^0.8.0", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15", @@ -33,6 +34,7 @@ "framer-motion": "^12.29.2", "i18next": "^23.7.0", "i18next-browser-languagedetector": "^7.2.0", + "jsencrypt": "^3.5.4", "ogl": "^1.0.11", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -1182,6 +1184,20 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kastov/cryptohapp": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@kastov/cryptohapp/-/cryptohapp-1.1.2.tgz", + "integrity": "sha512-vwBLWmKJ6g8opVfvzFPZxX3U65ncXawsasqlO5PxVvpejl3MgkpveUkOMoec6gNuGgSSjjxv4ydFH0h3qz3gBw==", + "license": "MIT", + "peerDependencies": { + "jsencrypt": ">=3" + }, + "peerDependenciesMeta": { + "jsencrypt": { + "optional": true + } + } + }, "node_modules/@lottiefiles/dotlottie-react": { "version": "0.8.12", "resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-react/-/dotlottie-react-0.8.12.tgz", @@ -4702,6 +4718,13 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsencrypt": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/jsencrypt/-/jsencrypt-3.5.4.tgz", + "integrity": "sha512-kNjfYEMNASxrDGsmcSQh/rUTmcoRfSUkxnAz+MMywM8jtGu+fFEZ3nJjHM58zscVnwR0fYmG9sGkTDjqUdpiwA==", + "license": "MIT", + "peer": true + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -5705,7 +5728,6 @@ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", "license": "MIT", - "peer": true, "dependencies": { "@remix-run/router": "1.23.2", "react-router": "6.30.3" diff --git a/package.json b/package.json index 21eeeeb..f7bd6cb 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@kastov/cryptohapp": "^1.1.2", "@lottiefiles/dotlottie-react": "^0.8.0", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15", @@ -41,6 +42,7 @@ "framer-motion": "^12.29.2", "i18next": "^23.7.0", "i18next-browser-languagedetector": "^7.2.0", + "jsencrypt": "^3.5.4", "ogl": "^1.0.11", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/src/App.tsx b/src/App.tsx index 28eda68..7cd8f0f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,6 +26,7 @@ const Contests = lazy(() => import('./pages/Contests')); const Polls = lazy(() => import('./pages/Polls')); const Info = lazy(() => import('./pages/Info')); const Wheel = lazy(() => import('./pages/Wheel')); +const Connection = lazy(() => import('./pages/Connection')); const TopUpMethodSelect = lazy(() => import('./pages/TopUpMethodSelect')); const TopUpAmount = lazy(() => import('./pages/TopUpAmount')); @@ -262,6 +263,16 @@ function App() { } /> + + + + + + } + /> {/* Admin routes */} => { - const response = await apiClient.get('/cabinet/admin/apps'); - return response.data; - }, - - // Get available platforms - getPlatforms: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/apps/platforms'); - return response.data; - }, - - // Get apps for a platform - getPlatformApps: async (platform: string): Promise => { - const response = await apiClient.get( - `/cabinet/admin/apps/platforms/${platform}`, - ); - return response.data; - }, - - // Create a new app - createApp: async (platform: string, app: AppDefinition): Promise => { - const response = await apiClient.post( - `/cabinet/admin/apps/platforms/${platform}`, - { - platform, - app, - }, - ); - return response.data; - }, - - // Update an app - updateApp: async ( - platform: string, - appId: string, - app: AppDefinition, - ): Promise => { - const response = await apiClient.put( - `/cabinet/admin/apps/platforms/${platform}/${appId}`, - { - app, - }, - ); - return response.data; - }, - - // Delete an app - deleteApp: async (platform: string, appId: string): Promise => { - await apiClient.delete(`/cabinet/admin/apps/platforms/${platform}/${appId}`); - }, - - // Reorder apps - reorderApps: async (platform: string, appIds: string[]): Promise => { - await apiClient.post(`/cabinet/admin/apps/platforms/${platform}/reorder`, { - app_ids: appIds, - }); - }, - - // Copy app to another platform - copyApp: async ( - platform: string, - appId: string, - targetPlatform: string, - ): Promise<{ new_id: string }> => { - const response = await apiClient.post<{ new_id: string; target_platform: string }>( - `/cabinet/admin/apps/platforms/${platform}/copy/${appId}?target_platform=${targetPlatform}`, - ); - return response.data; - }, - - // Get branding - getBranding: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/apps/branding'); - return response.data; - }, - - // Update branding - updateBranding: async (branding: AppConfigBranding): Promise => { - const response = await apiClient.put('/cabinet/admin/apps/branding', { - branding, - }); - return response.data; - }, - // Get RemnaWave config status getRemnaWaveStatus: async (): Promise<{ enabled: boolean; config_uuid: string | null }> => { const response = await apiClient.get<{ enabled: boolean; config_uuid: string | null }>( @@ -181,6 +96,8 @@ export const adminAppsApi = { export interface RemnawaveButton { url: string; text: LocalizedText; + type?: 'external' | 'subscriptionLink' | 'copyButton'; + svgIconKey?: string; } export interface RemnawaveBlock { diff --git a/src/components/admin/ThemeTab.tsx b/src/components/admin/ThemeTab.tsx index e31a59a..380d851 100644 --- a/src/components/admin/ThemeTab.tsx +++ b/src/components/admin/ThemeTab.tsx @@ -1,8 +1,8 @@ -import { useState } from 'react'; +import { useEffect, useState, useRef, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { themeColorsApi } from '../../api/themeColors'; -import { DEFAULT_THEME_COLORS } from '../../types/theme'; +import { DEFAULT_THEME_COLORS, ThemeColors } from '../../types/theme'; import { ColorPicker } from '../ColorPicker'; import { applyThemeColors } from '../../hooks/useThemeColors'; import { updateEnabledThemesCache } from '../../hooks/useTheme'; @@ -10,6 +10,23 @@ import { MoonIcon, SunIcon, ChevronDownIcon } from './icons'; import { Toggle } from './Toggle'; import { THEME_PRESETS } from './constants'; +function colorsEqual(a: ThemeColors, b: ThemeColors): boolean { + return ( + a.accent === b.accent && + a.darkBackground === b.darkBackground && + a.darkSurface === b.darkSurface && + a.darkText === b.darkText && + a.darkTextSecondary === b.darkTextSecondary && + a.lightBackground === b.lightBackground && + a.lightSurface === b.lightSurface && + a.lightText === b.lightText && + a.lightTextSecondary === b.lightTextSecondary && + a.success === b.success && + a.warning === b.warning && + a.error === b.error + ); +} + export function ThemeTab() { const { t } = useTranslation(); const queryClient = useQueryClient(); @@ -29,7 +46,7 @@ export function ThemeTab() { }; // Queries - const { data: themeColors } = useQuery({ + const { data: serverColors } = useQuery({ queryKey: ['theme-colors'], queryFn: themeColorsApi.getColors, }); @@ -39,20 +56,120 @@ export function ThemeTab() { queryFn: themeColorsApi.getEnabledThemes, }); + // Local draft state + const [draftColors, setDraftColors] = useState(DEFAULT_THEME_COLORS); + const savedColorsRef = useRef(DEFAULT_THEME_COLORS); + + // Sync server data into draft and saved snapshot when it arrives + useEffect(() => { + if (serverColors) { + const colors: ThemeColors = { + accent: serverColors.accent, + darkBackground: serverColors.darkBackground, + darkSurface: serverColors.darkSurface, + darkText: serverColors.darkText, + darkTextSecondary: serverColors.darkTextSecondary, + lightBackground: serverColors.lightBackground, + lightSurface: serverColors.lightSurface, + lightText: serverColors.lightText, + lightTextSecondary: serverColors.lightTextSecondary, + success: serverColors.success, + warning: serverColors.warning, + error: serverColors.error, + }; + // Only sync if saved snapshot matches current draft (no unsaved changes) + if ( + colorsEqual(savedColorsRef.current, draftColors) || + colorsEqual(savedColorsRef.current, DEFAULT_THEME_COLORS) + ) { + setDraftColors(colors); + } + savedColorsRef.current = colors; + } + }, [serverColors]); // eslint-disable-line react-hooks/exhaustive-deps + + const hasUnsavedChanges = !colorsEqual(draftColors, savedColorsRef.current); + + // Update a single color in the draft and apply preview instantly + const updateDraftColor = useCallback( + (key: keyof ThemeColors, value: string) => { + setDraftColors((prev) => { + const next = { ...prev, [key]: value }; + applyThemeColors(next); + queryClient.setQueryData(['theme-colors'], next); + return next; + }); + }, + [queryClient], + ); + + // Apply a full preset to draft + const applyPreset = useCallback( + (colors: Partial) => { + setDraftColors((prev) => { + const next = { ...prev, ...colors }; + applyThemeColors(next); + queryClient.setQueryData(['theme-colors'], next); + return next; + }); + }, + [queryClient], + ); + + // Cancel: revert draft to saved + const handleCancel = useCallback(() => { + const saved = savedColorsRef.current; + setDraftColors(saved); + applyThemeColors(saved); + queryClient.setQueryData(['theme-colors'], saved); + }, [queryClient]); + // Mutations const updateColorsMutation = useMutation({ mutationFn: themeColorsApi.updateColors, onSuccess: (data) => { - applyThemeColors(data); - queryClient.invalidateQueries({ queryKey: ['theme-colors'] }); + const colors: ThemeColors = { + accent: data.accent, + darkBackground: data.darkBackground, + darkSurface: data.darkSurface, + darkText: data.darkText, + darkTextSecondary: data.darkTextSecondary, + lightBackground: data.lightBackground, + lightSurface: data.lightSurface, + lightText: data.lightText, + lightTextSecondary: data.lightTextSecondary, + success: data.success, + warning: data.warning, + error: data.error, + }; + savedColorsRef.current = colors; + setDraftColors(colors); + applyThemeColors(colors); + queryClient.setQueryData(['theme-colors'], data); }, }); const resetColorsMutation = useMutation({ mutationFn: themeColorsApi.resetColors, onSuccess: (data) => { - applyThemeColors(data); - queryClient.invalidateQueries({ queryKey: ['theme-colors'] }); + const colors: ThemeColors = { + accent: data.accent, + darkBackground: data.darkBackground, + darkSurface: data.darkSurface, + darkText: data.darkText, + darkTextSecondary: data.darkTextSecondary, + lightBackground: data.lightBackground, + lightSurface: data.lightSurface, + lightText: data.lightText, + lightTextSecondary: data.lightTextSecondary, + success: data.success, + warning: data.warning, + error: data.error, + }; + savedColorsRef.current = colors; + setDraftColors(colors); + applyThemeColors(colors); + queryClient.setQueryData(['theme-colors'], data); }, }); @@ -130,8 +247,7 @@ export function ThemeTab() { {THEME_PRESETS.map((preset) => ( + {/* Save / Cancel / Reset buttons */} +
+ {hasUnsavedChanges && ( + <> + + + + )} + +
)} diff --git a/src/pages/AdminApps.tsx b/src/pages/AdminApps.tsx index 8ed24d2..e965c1a 100644 --- a/src/pages/AdminApps.tsx +++ b/src/pages/AdminApps.tsx @@ -1,17 +1,17 @@ -import { useState, useRef } from 'react'; +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, - AppDefinition, LocalizedText, - AppStep, - AppButton, - exportToRemnawaveFormat, - importFromRemnawaveFormat, - RemnawaveConfig, - importFromRemnawaveFormat as convertRemnawave, + RemnawaveApp, + RemnawaveSvgItem, + RemnawaveBlock, + RemnawaveButton, + RemnawaveBaseSettings, + RemnawaveBaseTranslations, } from '../api/adminApps'; import { useBackButton } from '../platform/hooks/useBackButton'; import { usePlatform } from '../platform/hooks/usePlatform'; @@ -40,54 +40,6 @@ const AppsIcon = () => ( ); -const PlusIcon = () => ( - - - -); - -const EditIcon = () => ( - - - -); - -const TrashIcon = () => ( - - - -); - -const ChevronUpIcon = () => ( - - - -); - -const ChevronDownIcon = () => ( - - - -); - -const CopyIcon = () => ( - - - -); - const StarIcon = ({ filled }: { filled: boolean }) => ( ( ); -const DownloadIcon = () => ( - - - -); - -const UploadIcon = () => ( - - - -); - const CloudSyncIcon = () => ( ( ); +const ChevronDownIcon = () => ( + + + +); + const PLATFORM_LABELS: Record = { ios: 'iOS', android: 'Android', @@ -166,417 +104,221 @@ const PLATFORM_LABELS: Record = { 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 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 AppEditorModal({ app, platform, isNew, onSave, onClose }: AppEditorModalProps) { +function renderSvgIcon(svgLibrary: Record, key?: string, color?: string) { + if (!key || !svgLibrary[key]) return null; + const sanitized = DOMPurify.sanitize(svgLibrary[key].svgString, { + USE_PROFILES: { svg: true, svgFilters: true }, + }); + return ( +
+ ); +} + +function BaseSettingsPanel({ settings }: { settings: RemnawaveBaseSettings }) { const { t } = useTranslation(); - const [editedApp, setEditedApp] = useState({ ...app }); - const [activeTab, setActiveTab] = useState< - 'basic' | 'installation' | 'subscription' | 'connect' | 'additional' - >('basic'); - - const updateField = (field: K, value: AppDefinition[K]) => { - setEditedApp((prev) => ({ ...prev, [field]: value })); - }; - - const updateLocalizedText = ( - stepKey: - | 'installationStep' - | 'addSubscriptionStep' - | 'connectAndUseStep' - | 'additionalBeforeAddSubscriptionStep' - | 'additionalAfterAddSubscriptionStep', - field: 'description' | 'title', - lang: keyof LocalizedText, - value: string, - ) => { - setEditedApp((prev) => { - const step = prev[stepKey] || emptyAppStep(); - const fieldValue = step[field] || emptyLocalizedText(); - return { - ...prev, - [stepKey]: { - ...step, - [field]: { ...fieldValue, [lang]: value }, - }, - }; - }); - }; - - const updateButtons = ( - stepKey: - | 'installationStep' - | 'addSubscriptionStep' - | 'connectAndUseStep' - | 'additionalBeforeAddSubscriptionStep' - | 'additionalAfterAddSubscriptionStep', - buttons: AppButton[], - ) => { - setEditedApp((prev) => { - const step = prev[stepKey] || emptyAppStep(); - return { - ...prev, - [stepKey]: { ...step, buttons }, - }; - }); - }; - - const addButton = ( - stepKey: - | 'installationStep' - | 'additionalBeforeAddSubscriptionStep' - | 'additionalAfterAddSubscriptionStep', - ) => { - const step = editedApp[stepKey] || emptyAppStep(); - const buttons = step.buttons || []; - // Generate unique id for React key - const newButton: AppButton = { - id: `btn-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, - buttonLink: '', - buttonText: emptyLocalizedText(), - }; - updateButtons(stepKey, [...buttons, newButton]); - }; - - const removeButton = ( - stepKey: - | 'installationStep' - | 'additionalBeforeAddSubscriptionStep' - | 'additionalAfterAddSubscriptionStep', - index: number, - ) => { - const step = editedApp[stepKey] || emptyAppStep(); - const buttons = step.buttons || []; - updateButtons( - stepKey, - buttons.filter((_, i) => i !== index), - ); - }; - - const updateButton = ( - stepKey: - | 'installationStep' - | 'additionalBeforeAddSubscriptionStep' - | 'additionalAfterAddSubscriptionStep', - index: number, - field: 'buttonLink' | 'buttonText', - value: string | LocalizedText, - ) => { - const step = editedApp[stepKey] || emptyAppStep(); - const buttons = [...(step.buttons || [])]; - buttons[index] = { ...buttons[index], [field]: value }; - updateButtons(stepKey, buttons); - }; - - const renderLocalizedTextInputs = ( - stepKey: - | 'installationStep' - | 'addSubscriptionStep' - | 'connectAndUseStep' - | 'additionalBeforeAddSubscriptionStep' - | 'additionalAfterAddSubscriptionStep', - field: 'description' | 'title', - label: string, - ) => { - const step = editedApp[stepKey]; - const value = step?.[field] || emptyLocalizedText(); - - return ( -
- - {(['en', 'ru'] as const).map((lang) => ( -
- {lang} -