mirror of
https://git.vectorsigma.ru/public/AdGuardHome.git
synced 2026-08-03 22:19:28 +00:00
ADG-10293 common settings page
This commit is contained in:
@@ -97,7 +97,6 @@
|
||||
"settings": "Settings",
|
||||
"filters": "Filters",
|
||||
"filter": "Filter",
|
||||
"query_log": "Query Log",
|
||||
"compact": "Compact",
|
||||
"nothing_found": "Nothing found",
|
||||
"faq": "FAQ",
|
||||
@@ -195,7 +194,6 @@
|
||||
"no_whitelist_added": "No allowlists added",
|
||||
"add_blocklist": "Add blocklist",
|
||||
"add_allowlist": "Add allowlist",
|
||||
"cancel_btn": "Cancel",
|
||||
"enter_name_hint": "Enter name",
|
||||
"enter_url_or_path_hint": "Enter a URL or an absolute path of the list",
|
||||
"check_updates_btn": "Check for updates",
|
||||
@@ -452,10 +450,8 @@
|
||||
"clients_title": "Persistent clients",
|
||||
"clients_desc": "Configure persistent client records for devices connected to AdGuard Home",
|
||||
"settings_global": "Global",
|
||||
"settings_custom": "Custom",
|
||||
"table_client": "Client",
|
||||
"table_name": "Name",
|
||||
"save_btn": "Save",
|
||||
"client_add": "Add Client",
|
||||
"client_new": "New Client",
|
||||
"client_edit": "Edit Client",
|
||||
@@ -762,5 +758,52 @@
|
||||
"release_notes": "Release notes",
|
||||
"privacy_policy": "Privacy policy",
|
||||
"selected": "%value% selected",
|
||||
"general_settings_short": "General"
|
||||
"settings_general_short": "General",
|
||||
"settings_filtering_and_security": "Filtering and security",
|
||||
"settings_filter_requests": "Filter requests",
|
||||
"settings_filter_requests_desc": "You can use <a>Blocklists</a>, <b>Allowlists</b>, and <c>User rules</c> to set up filtering rules",
|
||||
"settings_browsing_security": "Browsing security",
|
||||
"settings_browsing_security_desc": "Block access to domains found in the database of malicious and phishing domains",
|
||||
"settings_parental_control": "Parental control",
|
||||
"settings_parental_control_desc": "Block websites with content inappropriate for children",
|
||||
"settings_safe_search": "Safe search",
|
||||
"settings_safe_search_desc": "Block inappropriate content in search results",
|
||||
"settings_log_dns_requests": "Log DNS requests",
|
||||
"settings_anonymize_client_ip": "Anonymize client IP addresses",
|
||||
"settings_anonymize_client_ip_desc": "Replace part of the IP address with zeros",
|
||||
"settings_log_rotation": "Log rotation",
|
||||
"settings_hours": "| %count% hour | %count% hours",
|
||||
"settings_days": "| %count% day | %count% days",
|
||||
"settings_custom": "Custom",
|
||||
"settings_log_rotation_hours": "Log rotation, in hours",
|
||||
"settings_rotation_placeholder": "Use numbers",
|
||||
"settings_log_ignored_domains": "Ignored domains",
|
||||
"settings_log_ignored_domains_desc": "Requests that match these rules are not displayed in Query log",
|
||||
"settings_domain_names": "Domain names, one per line",
|
||||
"settings_rule_syntax": "Rule syntax",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"query_log": "Query Log",
|
||||
"clear_query_log": "Clear Query log",
|
||||
"settings_statistics": "Statistics",
|
||||
"settings_statistics_desc": "Display request and client statistics on the dashboard",
|
||||
"settings_statistics_retention": "Statistics retention",
|
||||
"settings_statistics_retention_hours": "Statistics retention, in hours",
|
||||
"settings_statistics_ignored_domains": "Requests that match these rules are not included in the statistics",
|
||||
"settings_statistics_clear": "Clear statistics",
|
||||
"settings_confirm_decrease_log_rotation_interval": "Decrease log rotation interval?",
|
||||
"settings_confirm_decrease_log_rotation_interval_desc": "This will delete all logs older than %value%",
|
||||
"settings_yes_decrease": "Yes, decrease",
|
||||
"settings_confirm_decrease_stats_rotation_interval": "Decrease stats retention interval?",
|
||||
"settings_confirm_decrease_stats_rotation_interval_desc": "This will delete all stats older than %value%",
|
||||
"settings_confirm_clear_statistics": "Clear statistics?",
|
||||
"settings_confirm_clear_statistics_desc": "This will clear all statistics. Query log will still be available",
|
||||
"settings_yes_clear": "Yes, clear",
|
||||
"settings_confirm_clear_query_log": "Clear Query log?",
|
||||
"settings_confirm_clear_query_log_desc": "This will delete all requests from <strong>Query log</strong>. Statistics will still be available",
|
||||
"settings_tooltip_domain_names": "Domain names, wildcards, or filtering rules",
|
||||
"settings_tooltip_examples": "Examples:",
|
||||
"settings_notify_changes_saved": "Changes saved",
|
||||
"settings_notify_query_log_cleared": "Query log cleared",
|
||||
"settings_notify_statistics_cleared": "Statistics cleared"
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { apiClient } from '../api/Api';
|
||||
|
||||
import { splitByNewLine } from '../helpers/helpers';
|
||||
import { addErrorToast, addSuccessToast } from './toasts';
|
||||
import intl from 'panel/common/intl';
|
||||
|
||||
export const getDnsConfigRequest = createAction('GET_DNS_CONFIG_REQUEST');
|
||||
export const getDnsConfigFailure = createAction('GET_DNS_CONFIG_FAILURE');
|
||||
@@ -30,7 +31,7 @@ export const clearDnsCache = () => async (dispatch: any) => {
|
||||
try {
|
||||
const data = await apiClient.clearCache();
|
||||
dispatch(clearDnsCacheSuccess(data));
|
||||
dispatch(addSuccessToast(i18next.t('cache_cleared')));
|
||||
dispatch(addSuccessToast(intl.getMessage('cache_cleared')));
|
||||
} catch (error) {
|
||||
dispatch(addErrorToast({ error }));
|
||||
dispatch(clearDnsCacheFailure());
|
||||
@@ -71,9 +72,9 @@ export const setDnsConfig = (config: any) => async (dispatch: any) => {
|
||||
await apiClient.setDnsConfig(data);
|
||||
|
||||
if (hasDnsSettings) {
|
||||
dispatch(addSuccessToast('updated_upstream_dns_toast'));
|
||||
dispatch(addSuccessToast(intl.getMessage('updated_upstream_dns_toast')));
|
||||
} else {
|
||||
dispatch(addSuccessToast('config_successfully_saved'));
|
||||
dispatch(addSuccessToast(intl.getMessage('settings_notify_changes_saved')));
|
||||
}
|
||||
|
||||
dispatch(setDnsConfigSuccess(config));
|
||||
|
||||
@@ -3,6 +3,7 @@ import { apiClient } from '../api/Api';
|
||||
|
||||
import { redirectToCurrentProtocol } from '../helpers/helpers';
|
||||
import { addErrorToast, addSuccessToast } from './toasts';
|
||||
import intl from 'panel/common/intl';
|
||||
|
||||
export const getTlsStatusRequest = createAction('GET_TLS_STATUS_REQUEST');
|
||||
export const getTlsStatusFailure = createAction('GET_TLS_STATUS_FAILURE');
|
||||
@@ -57,7 +58,7 @@ export const setTlsConfig = (config: any) => async (dispatch: any, getState: any
|
||||
}
|
||||
|
||||
dispatch(setTlsConfigSuccess(response));
|
||||
dispatch(addSuccessToast('encryption_config_saved'));
|
||||
dispatch(addSuccessToast(intl.getMessage('encryption_config_saved')));
|
||||
} catch (error) {
|
||||
dispatch(addErrorToast({ error }));
|
||||
dispatch(setTlsConfigFailure());
|
||||
|
||||
@@ -154,19 +154,10 @@ export const setFiltersConfigRequest = createAction('SET_FILTERS_CONFIG_REQUEST'
|
||||
export const setFiltersConfigFailure = createAction('SET_FILTERS_CONFIG_FAILURE');
|
||||
export const setFiltersConfigSuccess = createAction('SET_FILTERS_CONFIG_SUCCESS');
|
||||
|
||||
export const setFiltersConfig = (config: any) => async (dispatch: any, getState: any) => {
|
||||
export const setFiltersConfig = (config: any) => async (dispatch: any) => {
|
||||
dispatch(setFiltersConfigRequest());
|
||||
try {
|
||||
const { enabled } = config;
|
||||
const prevEnabled = getState().filtering.enabled;
|
||||
let successToastMessage = 'config_successfully_saved';
|
||||
|
||||
if (prevEnabled !== enabled) {
|
||||
successToastMessage = enabled ? 'enabled_filtering_toast' : 'disabled_filtering_toast';
|
||||
}
|
||||
|
||||
await apiClient.setFiltersConfig(config);
|
||||
dispatch(addSuccessToast(successToastMessage));
|
||||
dispatch(setFiltersConfigSuccess(config));
|
||||
} catch (error) {
|
||||
dispatch(addErrorToast({ error }));
|
||||
|
||||
@@ -27,72 +27,83 @@ import { getTlsStatus } from './encryption';
|
||||
import { apiClient } from '../api/Api';
|
||||
import { addErrorToast, addNoticeToast, addSuccessToast } from './toasts';
|
||||
import { getFilteringStatus, setRules } from './filtering';
|
||||
import type { Dispatch } from 'redux';
|
||||
import type { SettingsData } from '../initialState';
|
||||
import intl from 'panel/common/intl';
|
||||
|
||||
export const toggleSettingStatus = createAction('SETTING_STATUS_TOGGLE');
|
||||
export const toggleSettingStatus = createAction<{
|
||||
settingKey: keyof typeof SETTINGS_NAMES;
|
||||
value?: boolean | SafeSearchConfig;
|
||||
}>('SETTING_STATUS_TOGGLE');
|
||||
export const showSettingsFailure = createAction('SETTINGS_FAILURE_SHOW');
|
||||
|
||||
// Types for settings actions
|
||||
type SafeSearchConfig = Record<string, boolean> & { enabled: boolean };
|
||||
type ToggleSettingKey = keyof typeof SETTINGS_NAMES;
|
||||
type SettingsItem = NonNullable<SettingsData['settingsList']>['safebrowsing'];
|
||||
type InitSettingsItemInput = Partial<Pick<SettingsItem, 'enabled' | 'order' | 'subtitle' | 'title'>> &
|
||||
Record<string, unknown>;
|
||||
type InitSettingsArg = {
|
||||
safebrowsing?: InitSettingsItemInput;
|
||||
parental?: InitSettingsItemInput;
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {*} settingKey = SETTINGS_NAMES
|
||||
* @param {*} status: boolean | SafeSearchConfig
|
||||
* @returns
|
||||
*/
|
||||
export const toggleSetting = (settingKey: any, status: any) => async (dispatch: any) => {
|
||||
let successMessage = '';
|
||||
try {
|
||||
switch (settingKey) {
|
||||
case SETTINGS_NAMES.safebrowsing:
|
||||
if (status) {
|
||||
successMessage = 'disabled_safe_browsing_toast';
|
||||
await apiClient.disableSafebrowsing();
|
||||
} else {
|
||||
successMessage = 'enabled_safe_browsing_toast';
|
||||
await apiClient.enableSafebrowsing();
|
||||
}
|
||||
dispatch(toggleSettingStatus({ settingKey }));
|
||||
break;
|
||||
case SETTINGS_NAMES.parental:
|
||||
if (status) {
|
||||
successMessage = 'disabled_parental_toast';
|
||||
await apiClient.disableParentalControl();
|
||||
} else {
|
||||
successMessage = 'enabled_parental_toast';
|
||||
await apiClient.enableParentalControl();
|
||||
}
|
||||
dispatch(toggleSettingStatus({ settingKey }));
|
||||
break;
|
||||
case SETTINGS_NAMES.safesearch:
|
||||
successMessage = 'updated_save_search_toast';
|
||||
await apiClient.updateSafesearch(status);
|
||||
dispatch(toggleSettingStatus({ settingKey, value: status }));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
export const toggleSetting =
|
||||
(settingKey: ToggleSettingKey, status: boolean | SafeSearchConfig) => async (dispatch: Dispatch) => {
|
||||
try {
|
||||
switch (settingKey) {
|
||||
case SETTINGS_NAMES.safebrowsing:
|
||||
if (status) {
|
||||
await apiClient.disableSafebrowsing();
|
||||
} else {
|
||||
await apiClient.enableSafebrowsing();
|
||||
}
|
||||
dispatch(toggleSettingStatus({ settingKey }));
|
||||
break;
|
||||
case SETTINGS_NAMES.parental:
|
||||
if (status) {
|
||||
await apiClient.disableParentalControl();
|
||||
} else {
|
||||
await apiClient.enableParentalControl();
|
||||
}
|
||||
dispatch(toggleSettingStatus({ settingKey }));
|
||||
break;
|
||||
case SETTINGS_NAMES.safesearch:
|
||||
await apiClient.updateSafesearch(status as SafeSearchConfig);
|
||||
dispatch(toggleSettingStatus({ settingKey, value: status }));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
dispatch(addErrorToast({ error }));
|
||||
}
|
||||
dispatch(addSuccessToast(successMessage));
|
||||
} catch (error) {
|
||||
dispatch(addErrorToast({ error }));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const initSettingsRequest = createAction('SETTINGS_INIT_REQUEST');
|
||||
export const initSettingsFailure = createAction('SETTINGS_INIT_FAILURE');
|
||||
export const initSettingsSuccess = createAction('SETTINGS_INIT_SUCCESS');
|
||||
type SettingsSuccessList = {
|
||||
safebrowsing: Record<string, unknown> & { enabled: boolean };
|
||||
parental: Record<string, unknown> & { enabled: boolean };
|
||||
safesearch: SafeSearchConfig;
|
||||
};
|
||||
export const initSettingsSuccess = createAction<{ settingsList: SettingsSuccessList }>('SETTINGS_INIT_SUCCESS');
|
||||
|
||||
export const initSettings =
|
||||
(
|
||||
settingsList = {
|
||||
safebrowsing: {},
|
||||
parental: {},
|
||||
},
|
||||
) =>
|
||||
async (dispatch: any) => {
|
||||
(settingsList: InitSettingsArg = { safebrowsing: {}, parental: {} }) =>
|
||||
async (dispatch: Dispatch) => {
|
||||
dispatch(initSettingsRequest());
|
||||
try {
|
||||
const safebrowsingStatus = await apiClient.getSafebrowsingStatus();
|
||||
const parentalStatus = await apiClient.getParentalStatus();
|
||||
const safesearchStatus = await apiClient.getSafesearchStatus();
|
||||
const { safebrowsing, parental } = settingsList;
|
||||
const safesearchStatus = (await apiClient.getSafesearchStatus()) as SafeSearchConfig;
|
||||
const { safebrowsing = {}, parental = {} } = settingsList;
|
||||
const newSettingsList = {
|
||||
safebrowsing: {
|
||||
...safebrowsing,
|
||||
@@ -120,19 +131,21 @@ export const toggleProtectionSuccess = createAction('TOGGLE_PROTECTION_SUCCESS')
|
||||
const getDisabledMessage = (time: any) => {
|
||||
switch (time) {
|
||||
case DISABLE_PROTECTION_TIMINGS.HALF_MINUTE:
|
||||
return i18next.t('disable_notify_for_seconds', {
|
||||
return intl.getMessage('disable_notify_for_seconds', {
|
||||
count: msToSeconds(DISABLE_PROTECTION_TIMINGS.HALF_MINUTE),
|
||||
});
|
||||
case DISABLE_PROTECTION_TIMINGS.MINUTE:
|
||||
return i18next.t('disable_notify_for_minutes', { count: msToMinutes(DISABLE_PROTECTION_TIMINGS.MINUTE) });
|
||||
return intl.getMessage('disable_notify_for_minutes', {
|
||||
count: msToMinutes(DISABLE_PROTECTION_TIMINGS.MINUTE),
|
||||
});
|
||||
case DISABLE_PROTECTION_TIMINGS.TEN_MINUTES:
|
||||
return i18next.t('disable_notify_for_minutes', {
|
||||
return intl.getMessage('disable_notify_for_minutes', {
|
||||
count: msToMinutes(DISABLE_PROTECTION_TIMINGS.TEN_MINUTES),
|
||||
});
|
||||
case DISABLE_PROTECTION_TIMINGS.HOUR:
|
||||
return i18next.t('disable_notify_for_hours', { count: msToHours(DISABLE_PROTECTION_TIMINGS.HOUR) });
|
||||
return intl.getMessage('disable_notify_for_hours', { count: msToHours(DISABLE_PROTECTION_TIMINGS.HOUR) });
|
||||
case DISABLE_PROTECTION_TIMINGS.TOMORROW:
|
||||
return i18next.t('disable_notify_until_tomorrow');
|
||||
return intl.getMessage('disable_notify_until_tomorrow');
|
||||
default:
|
||||
return 'disabled_protection';
|
||||
}
|
||||
@@ -143,7 +156,7 @@ export const toggleProtection =
|
||||
async (dispatch: any) => {
|
||||
dispatch(toggleProtectionRequest());
|
||||
try {
|
||||
const successMessage = status ? getDisabledMessage(time) : 'enabled_protection';
|
||||
const successMessage = status ? getDisabledMessage(time) : intl.getMessage('enabled_protection');
|
||||
await apiClient.setProtection({ enabled: !status, duration: time });
|
||||
dispatch(addSuccessToast(successMessage));
|
||||
dispatch(toggleProtectionSuccess({ disabledDuration: time }));
|
||||
@@ -176,9 +189,9 @@ export const getVersion =
|
||||
const currentVersion = dnsVersion === 'undefined' ? 0 : dnsVersion;
|
||||
|
||||
if (data && !areEqualVersions(currentVersion, data.new_version)) {
|
||||
dispatch(addSuccessToast('updates_checked'));
|
||||
dispatch(addSuccessToast(intl.getMessage('updates_checked')));
|
||||
} else {
|
||||
dispatch(addSuccessToast('updates_version_equal'));
|
||||
dispatch(addSuccessToast(intl.getMessage('updates_version_equal')));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -413,7 +426,7 @@ export const testUpstream =
|
||||
});
|
||||
|
||||
if (testMessages.every((message) => message === 'OK' || message.startsWith('WARNING:'))) {
|
||||
dispatch(addSuccessToast('dns_test_ok_toast'));
|
||||
dispatch(addSuccessToast(intl.getMessage('dns_test_ok_toast')));
|
||||
}
|
||||
|
||||
dispatch(testUpstreamSuccess());
|
||||
@@ -572,7 +585,7 @@ export const findActiveDhcp = (selectedInterface: any) => async (dispatch: any,
|
||||
});
|
||||
dispatch(addErrorToast({ error: warning }));
|
||||
} else {
|
||||
dispatch(addSuccessToast('dhcp_not_found'));
|
||||
dispatch(addSuccessToast(intl.getMessage('dhcp_not_found')));
|
||||
}
|
||||
} catch (error) {
|
||||
dispatch(addErrorToast({ error }));
|
||||
@@ -589,7 +602,7 @@ export const setDhcpConfig = (values: any) => async (dispatch: any) => {
|
||||
try {
|
||||
await apiClient.setDhcpConfig(values);
|
||||
dispatch(setDhcpConfigSuccess(values));
|
||||
dispatch(addSuccessToast('dhcp_config_saved'));
|
||||
dispatch(addSuccessToast(intl.getMessage('dhcp_config_saved')));
|
||||
} catch (error) {
|
||||
dispatch(addErrorToast({ error }));
|
||||
dispatch(setDhcpConfigFailure());
|
||||
@@ -606,14 +619,14 @@ export const toggleDhcp = (values: any) => async (dispatch: any) => {
|
||||
...values,
|
||||
enabled: false,
|
||||
};
|
||||
let successMessage = 'disabled_dhcp';
|
||||
let successMessage = intl.getMessage('disabled_dhcp');
|
||||
|
||||
if (!values.enabled) {
|
||||
config = {
|
||||
...values,
|
||||
enabled: true,
|
||||
};
|
||||
successMessage = 'enabled_dhcp';
|
||||
successMessage = intl.getMessage('enabled_dhcp');
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -635,7 +648,7 @@ export const resetDhcp = () => async (dispatch: any) => {
|
||||
try {
|
||||
const status = await apiClient.resetDhcp();
|
||||
dispatch(resetDhcpSuccess(status));
|
||||
dispatch(addSuccessToast('dhcp_config_saved'));
|
||||
dispatch(addSuccessToast(intl.getMessage('dhcp_config_saved')));
|
||||
} catch (error) {
|
||||
dispatch(addErrorToast({ error }));
|
||||
dispatch(resetDhcpFailure());
|
||||
@@ -651,7 +664,7 @@ export const resetDhcpLeases = () => async (dispatch: any) => {
|
||||
try {
|
||||
const status = await apiClient.resetDhcpLeases();
|
||||
dispatch(resetDhcpLeasesSuccess(status));
|
||||
dispatch(addSuccessToast('dhcp_reset_leases_success'));
|
||||
dispatch(addSuccessToast(intl.getMessage('dhcp_reset_leases_success')));
|
||||
} catch (error) {
|
||||
dispatch(addErrorToast({ error }));
|
||||
dispatch(resetDhcpLeasesFailure());
|
||||
@@ -670,7 +683,7 @@ export const addStaticLease = (config: any) => async (dispatch: any) => {
|
||||
const name = config.hostname || config.ip;
|
||||
await apiClient.addStaticLease(config);
|
||||
dispatch(addStaticLeaseSuccess(config));
|
||||
dispatch(addSuccessToast(i18next.t('dhcp_lease_added', { key: name })));
|
||||
dispatch(addSuccessToast(intl.getMessage('dhcp_lease_added', { key: name })));
|
||||
dispatch(toggleLeaseModal());
|
||||
dispatch(getDhcpStatus());
|
||||
} catch (error) {
|
||||
@@ -689,7 +702,7 @@ export const removeStaticLease = (config: any) => async (dispatch: any) => {
|
||||
const name = config.hostname || config.ip;
|
||||
await apiClient.removeStaticLease(config);
|
||||
dispatch(removeStaticLeaseSuccess(config));
|
||||
dispatch(addSuccessToast(i18next.t('dhcp_lease_deleted', { key: name })));
|
||||
dispatch(addSuccessToast(intl.getMessage('dhcp_lease_deleted', { key: name })));
|
||||
} catch (error) {
|
||||
dispatch(addErrorToast({ error }));
|
||||
dispatch(removeStaticLeaseFailure());
|
||||
@@ -705,7 +718,7 @@ export const updateStaticLease = (config: any) => async (dispatch: any) => {
|
||||
try {
|
||||
await apiClient.updateStaticLease(config);
|
||||
dispatch(updateStaticLeaseSuccess(config));
|
||||
dispatch(addSuccessToast(i18next.t('dhcp_lease_updated', { key: config.hostname || config.ip })));
|
||||
dispatch(addSuccessToast(intl.getMessage('dhcp_lease_updated', { key: config.hostname || config.ip })));
|
||||
dispatch(toggleLeaseModal());
|
||||
dispatch(getDhcpStatus());
|
||||
} catch (error) {
|
||||
@@ -734,15 +747,23 @@ export const toggleBlocking =
|
||||
|
||||
if (matchPreparedBlockingRule) {
|
||||
await dispatch(setRules(userRules.replace(`${blockingRule}`, '')));
|
||||
dispatch(addSuccessToast(i18next.t('rule_removed_from_custom_filtering_toast', { rule: blockingRule })));
|
||||
dispatch(
|
||||
addSuccessToast(intl.getMessage('rule_removed_from_custom_filtering_toast', { rule: blockingRule })),
|
||||
);
|
||||
} else if (!matchPreparedUnblockingRule) {
|
||||
await dispatch(setRules(`${userRules}${lineEnding}${unblockingRule}\n`));
|
||||
dispatch(addSuccessToast(i18next.t('rule_added_to_custom_filtering_toast', { rule: unblockingRule })));
|
||||
dispatch(
|
||||
addSuccessToast(intl.getMessage('rule_added_to_custom_filtering_toast', { rule: unblockingRule })),
|
||||
);
|
||||
} else if (matchPreparedUnblockingRule) {
|
||||
dispatch(addSuccessToast(i18next.t('rule_added_to_custom_filtering_toast', { rule: unblockingRule })));
|
||||
dispatch(
|
||||
addSuccessToast(intl.getMessage('rule_added_to_custom_filtering_toast', { rule: unblockingRule })),
|
||||
);
|
||||
return;
|
||||
} else if (!matchPreparedBlockingRule) {
|
||||
dispatch(addSuccessToast(i18next.t('rule_removed_from_custom_filtering_toast', { rule: blockingRule })));
|
||||
dispatch(
|
||||
addSuccessToast(intl.getMessage('rule_removed_from_custom_filtering_toast', { rule: blockingRule })),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ export const setStatsConfig = (config: any) => async (dispatch: any) => {
|
||||
dispatch(setStatsConfigRequest());
|
||||
try {
|
||||
await apiClient.setStatsConfig(config);
|
||||
dispatch(addSuccessToast('config_successfully_saved'));
|
||||
dispatch(addSuccessToast('settings_notify_changes_saved'));
|
||||
dispatch(setStatsConfigSuccess(config));
|
||||
} catch (error) {
|
||||
dispatch(addErrorToast({ error }));
|
||||
@@ -74,7 +74,7 @@ export const resetStats = () => async (dispatch: any) => {
|
||||
dispatch(getStatsRequest());
|
||||
try {
|
||||
await apiClient.resetStats();
|
||||
dispatch(addSuccessToast('statistics_cleared'));
|
||||
dispatch(addSuccessToast('settings_notify_statistics_cleared'));
|
||||
dispatch(resetStatsSuccess());
|
||||
} catch (error) {
|
||||
dispatch(addErrorToast({ error }));
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
gap: 8px;
|
||||
|
||||
&.disabled {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
@@ -45,11 +49,9 @@
|
||||
position: absolute;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
transition: color var(--t2);
|
||||
color: var(--default-gray-icons);
|
||||
}
|
||||
|
||||
.active {
|
||||
transition: none;
|
||||
color: var(--default-product-icon);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export const Checkbox = ({
|
||||
onClick,
|
||||
plusStyle,
|
||||
}: Props) => (
|
||||
<label htmlFor={id} className={cn(s.checkbox, className)} onClick={onClick}>
|
||||
<label htmlFor={id} className={cn(s.checkbox, { [s.disabled]: disabled }, className)} onClick={onClick}>
|
||||
<input
|
||||
id={id}
|
||||
name={name}
|
||||
|
||||
@@ -11,6 +11,7 @@ type Props<T> = {
|
||||
handleChange: (e: T) => void;
|
||||
value: T;
|
||||
options: { text: string; value: T }[];
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export const Radio = <T extends number | string | boolean = string>({
|
||||
@@ -20,14 +21,19 @@ export const Radio = <T extends number | string | boolean = string>({
|
||||
handleChange,
|
||||
value,
|
||||
options,
|
||||
name,
|
||||
}: Props<T>) => (
|
||||
<div className={cn(s.wrap, wrapClass)}>
|
||||
{options.map((o) => (
|
||||
<label key={`${o.value}`} htmlFor={String(o.value)} className={cn(s.radio, className)}>
|
||||
<label
|
||||
key={`${o.value}`}
|
||||
htmlFor={name ? `${name}-${String(o.value)}` : String(o.value)}
|
||||
className={cn(s.radio, className)}>
|
||||
<input
|
||||
id={String(o.value)}
|
||||
id={name ? `${name}-${String(o.value)}` : String(o.value)}
|
||||
type="radio"
|
||||
className={s.input}
|
||||
name={name}
|
||||
onChange={() => handleChange(o.value)}
|
||||
checked={value === o.value}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { ChangeEvent, ReactNode } from 'react';
|
||||
import React, { ChangeEvent, ReactNode, forwardRef, ForwardedRef } from 'react';
|
||||
import cn from 'clsx';
|
||||
|
||||
import s from './Switch.module.pcss';
|
||||
@@ -10,48 +10,45 @@ type Props = {
|
||||
labelClassName?: string;
|
||||
className?: string;
|
||||
wrapperClassName?: string;
|
||||
handleChange: (e: ChangeEvent<HTMLInputElement>) => void;
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export const Switch = ({
|
||||
id,
|
||||
checked,
|
||||
disabled,
|
||||
children,
|
||||
className,
|
||||
labelClassName,
|
||||
wrapperClassName,
|
||||
handleChange,
|
||||
}: Props) => {
|
||||
const switchControls = (
|
||||
<>
|
||||
<input
|
||||
id={id}
|
||||
type="checkbox"
|
||||
className={s.input}
|
||||
onChange={handleChange}
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div className={s.handler} />
|
||||
{children && <div className={cn(s.label, labelClassName)}>{children}</div>}
|
||||
</>
|
||||
);
|
||||
export const Switch = forwardRef(
|
||||
(
|
||||
{ id, checked, disabled, children, className, labelClassName, wrapperClassName, onChange }: Props,
|
||||
ref: ForwardedRef<HTMLInputElement>,
|
||||
) => {
|
||||
const switchControls = (
|
||||
<>
|
||||
<input
|
||||
id={id}
|
||||
type="checkbox"
|
||||
className={s.input}
|
||||
onChange={onChange}
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
ref={ref}
|
||||
/>
|
||||
<div className={s.handler} />
|
||||
{children && <div className={cn(s.label, labelClassName)}>{children}</div>}
|
||||
</>
|
||||
);
|
||||
|
||||
const getContent = () => {
|
||||
if (wrapperClassName) {
|
||||
return <div className={wrapperClassName}>{switchControls}</div>;
|
||||
}
|
||||
const getContent = () => {
|
||||
if (wrapperClassName) {
|
||||
return <div className={wrapperClassName}>{switchControls}</div>;
|
||||
}
|
||||
|
||||
return switchControls;
|
||||
};
|
||||
return switchControls;
|
||||
};
|
||||
|
||||
return (
|
||||
<label htmlFor={id} className={cn(s.switch, className, { [s.disabled]: disabled })}>
|
||||
{getContent()}
|
||||
</label>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<label htmlFor={id} className={cn(s.switch, className, { [s.disabled]: disabled })}>
|
||||
{getContent()}
|
||||
</label>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default Switch;
|
||||
|
||||
@@ -1,45 +1,55 @@
|
||||
import React, { ComponentProps } from 'react';
|
||||
import React, { ComponentProps, ReactNode, forwardRef } from 'react';
|
||||
import cn from 'clsx';
|
||||
|
||||
import s from './styles.module.pcss';
|
||||
|
||||
type Props = ComponentProps<'textarea'> & {
|
||||
label?: string;
|
||||
label?: ReactNode;
|
||||
size?: 'small' | 'large';
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
export const Textarea = ({
|
||||
id,
|
||||
label,
|
||||
placeholder,
|
||||
onChange,
|
||||
value,
|
||||
rows,
|
||||
cols,
|
||||
wrap,
|
||||
className,
|
||||
maxLength,
|
||||
errorMessage,
|
||||
disabled,
|
||||
}: Props) => (
|
||||
<div className={s.textareaWrapper}>
|
||||
{label && (
|
||||
<label className={s.label} htmlFor={id}>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<textarea
|
||||
className={cn(s.textarea, { [s.error]: !!errorMessage }, className)}
|
||||
id={id}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
cols={cols}
|
||||
rows={rows}
|
||||
onChange={onChange}
|
||||
wrap={wrap}
|
||||
maxLength={maxLength}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{errorMessage && <div className={s.errorMessage}>{errorMessage}</div>}
|
||||
</div>
|
||||
export const Textarea = forwardRef<HTMLTextAreaElement, Props>(
|
||||
(
|
||||
{
|
||||
id,
|
||||
label,
|
||||
size,
|
||||
placeholder,
|
||||
onChange,
|
||||
value,
|
||||
rows,
|
||||
cols,
|
||||
wrap,
|
||||
className,
|
||||
maxLength,
|
||||
errorMessage,
|
||||
disabled,
|
||||
...rest
|
||||
}: Props,
|
||||
ref,
|
||||
) => (
|
||||
<div className={s.textareaWrapper}>
|
||||
{label && (
|
||||
<label className={s.label} htmlFor={id}>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<textarea
|
||||
className={cn(s.textarea, s[size], { [s.error]: !!errorMessage }, className)}
|
||||
id={id}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
cols={cols}
|
||||
rows={rows}
|
||||
onChange={onChange}
|
||||
wrap={wrap}
|
||||
maxLength={maxLength}
|
||||
disabled={disabled}
|
||||
ref={ref}
|
||||
{...rest}
|
||||
/>
|
||||
{errorMessage && <div className={s.errorMessage}>{errorMessage}</div>}
|
||||
</div>
|
||||
),
|
||||
);
|
||||
|
||||
@@ -41,6 +41,10 @@
|
||||
box-shadow: 0 0 0 100px var(--pressed-page-background) inset;
|
||||
}
|
||||
}
|
||||
|
||||
&.large {
|
||||
min-height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
|
||||
@@ -64,6 +64,10 @@ const intl = {
|
||||
return translator.getMessage(key, values);
|
||||
},
|
||||
|
||||
getPlural: (key: string, values?: any) => {
|
||||
return translator.getPlural(key, values);
|
||||
},
|
||||
|
||||
getUILanguage: () => currentLanguage,
|
||||
|
||||
getBaseMessage: (key: string) => {
|
||||
|
||||
@@ -22,7 +22,7 @@ export const Breadcrumbs = ({ parentLinks, currentTitle }: Props) => (
|
||||
<div key={path} className={s.link}>
|
||||
<Link
|
||||
to={path}
|
||||
className={cn(theme.link.link, theme.link.no_decoration, theme.common.textOverflow)}
|
||||
className={cn(theme.link.link, theme.link.noDecoration, theme.common.textOverflow)}
|
||||
props={props}>
|
||||
{title}
|
||||
</Link>
|
||||
|
||||
@@ -105,7 +105,7 @@ export const Footer = () => {
|
||||
<a
|
||||
key={name}
|
||||
href={href}
|
||||
className={cn(theme.link.link, theme.link.no_decoration)}
|
||||
className={cn(theme.link.link, theme.link.noDecoration)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
{name}
|
||||
|
||||
@@ -62,7 +62,7 @@ export const Menu = ({ headerMenu }: Props) => {
|
||||
icon="settings"
|
||||
items={[
|
||||
{
|
||||
label: intl.getMessage('general_settings_short'),
|
||||
label: intl.getMessage('settings_general_short'),
|
||||
path: Paths.SettingsPage,
|
||||
routePath: RoutePath.SettingsPage,
|
||||
},
|
||||
|
||||
@@ -18,6 +18,7 @@ import { changeLanguage, getDnsStatus, getTimerStatus } from '../../actions';
|
||||
import { RootState } from '../../initialState';
|
||||
|
||||
import s from './styles.module.pcss';
|
||||
import Settings from 'panel/containers/Settings';
|
||||
|
||||
type RouteConfig = {
|
||||
path: string;
|
||||
@@ -25,7 +26,13 @@ type RouteConfig = {
|
||||
exact: boolean;
|
||||
};
|
||||
|
||||
const ROUTES: RouteConfig[] = [];
|
||||
const ROUTES: RouteConfig[] = [
|
||||
{
|
||||
path: '/settings',
|
||||
component: Settings,
|
||||
exact: true,
|
||||
},
|
||||
];
|
||||
|
||||
const App = () => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
.title {
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin-top: 40px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.iconsContainer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.iconItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--default-item-divider);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--default-gray-icons);
|
||||
}
|
||||
|
||||
.iconName {
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.colorsContainer {
|
||||
list-style: none;
|
||||
margin: 8px 0 0 0;
|
||||
padding: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.colorSwatch {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 6px 0;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
margin-bottom: 0;
|
||||
gap: 16px;
|
||||
border-bottom: 1px solid var(--default-item-divider);
|
||||
}
|
||||
|
||||
.colorBox {
|
||||
width: 36px;
|
||||
height: 18px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--default-item-divider);
|
||||
margin-bottom: 0;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.colorName {
|
||||
font-size: 12px;
|
||||
margin: 0 12px 0 0;
|
||||
text-align: left;
|
||||
word-break: break-all;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.colorValue {
|
||||
min-width: 55px;
|
||||
font-size: 12px;
|
||||
color: var(--default-main-text);
|
||||
text-align: left;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.iconsContainer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.iconItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 110px;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--default-item-divider);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-bottom: 4px;
|
||||
color: var(--default-gray-icons);
|
||||
}
|
||||
|
||||
.iconName {
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.buttonsContainer {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
margin-top: 16px;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.typographySection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.typographyBlock {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.typographyHeading {
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.typographyList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.selectExamples {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.selectExample {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.exampleTitle {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.selectRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.selectItem {
|
||||
min-width: 200px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.selectLabel {
|
||||
font-size: 12px;
|
||||
margin-bottom: 4px;
|
||||
color: var(--default-description-text);
|
||||
}
|
||||
|
||||
.contolsList {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.deprecationNotice {
|
||||
padding: 24px;
|
||||
border: 2px solid #ff9800;
|
||||
border-radius: 8px;
|
||||
background-color: #fff8e1;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.storyBookInstructions {
|
||||
background-color: #f5f5f5;
|
||||
padding: 16px 24px;
|
||||
border-radius: 4px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.storyBookInstructions code {
|
||||
background-color: #e0e0e0;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
76
client_v2/src/components/Settings/FiltersConfig/index.tsx
Normal file
76
client_v2/src/components/Settings/FiltersConfig/index.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
|
||||
import intl from 'panel/common/intl';
|
||||
import theme from 'panel/lib/theme';
|
||||
import { SwitchGroup } from '../SettingsGroup';
|
||||
import { RoutePath } from 'panel/components/Routes/Paths';
|
||||
import { Link } from 'panel/common/ui/Link';
|
||||
|
||||
export type FormValues = {
|
||||
enabled: boolean;
|
||||
interval: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
initialValues: FormValues;
|
||||
setFiltersConfig: (values: FormValues) => void;
|
||||
processing: boolean;
|
||||
};
|
||||
|
||||
export const FiltersConfig = ({ initialValues, setFiltersConfig, processing }: Props) => {
|
||||
const { watch, control, setValue } = useForm({
|
||||
mode: 'onBlur',
|
||||
defaultValues: initialValues,
|
||||
});
|
||||
|
||||
const enabled = watch('enabled');
|
||||
|
||||
useEffect(() => {
|
||||
setFiltersConfig({ ...initialValues, enabled });
|
||||
}, [enabled]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Controller
|
||||
name="enabled"
|
||||
control={control}
|
||||
render={() => (
|
||||
<SwitchGroup
|
||||
title={intl.getMessage('settings_filter_requests')}
|
||||
description={intl.getMessage('settings_filter_requests_desc', {
|
||||
a: (text: string) => (
|
||||
<Link
|
||||
to={RoutePath.DnsBlocklists}
|
||||
className={theme.link.link}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
{text}
|
||||
</Link>
|
||||
),
|
||||
b: (text: string) => (
|
||||
<Link
|
||||
to={RoutePath.DnsAllowlists}
|
||||
className={theme.link.link}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
{text}
|
||||
</Link>
|
||||
),
|
||||
c: (text: string) => (
|
||||
<Link
|
||||
to={RoutePath.UserRules}
|
||||
className={theme.link.link}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
{text}
|
||||
</Link>
|
||||
),
|
||||
})}
|
||||
disabled={processing}
|
||||
onChange={(e) => setValue('enabled', e.target.checked)}
|
||||
id="filters_enabled"
|
||||
checked={enabled}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
import React from 'react';
|
||||
import { Controller, type Control, type UseFormSetValue } from 'react-hook-form';
|
||||
import cn from 'clsx';
|
||||
|
||||
import { Textarea } from 'panel/common/controls/Textarea';
|
||||
import { SwitchGroup } from '../SettingsGroup';
|
||||
import intl from 'panel/common/intl';
|
||||
|
||||
import { trimLinesAndRemoveEmpty } from 'panel/helpers/helpers';
|
||||
|
||||
import s from './styles.module.pcss';
|
||||
import { Dropdown } from 'panel/common/ui/Dropdown';
|
||||
import theme from 'panel/lib/theme';
|
||||
import { Icon } from 'panel/common/ui/Icon';
|
||||
|
||||
type Props = {
|
||||
control: Control<any>;
|
||||
processing: boolean;
|
||||
ignoreEnabled: boolean;
|
||||
setValue: UseFormSetValue<any>;
|
||||
switchId: string;
|
||||
textareaId: string;
|
||||
};
|
||||
|
||||
export const IgnoredDomains = ({ control, processing, ignoreEnabled, setValue, switchId, textareaId }: Props) => {
|
||||
return (
|
||||
<SwitchGroup
|
||||
id={switchId}
|
||||
title={intl.getMessage('ignore_domains_title')}
|
||||
description={intl.getMessage('ignore_domains_desc_query')}
|
||||
checked={ignoreEnabled}
|
||||
onChange={(e) => setValue('ignore_enabled', e.target.checked)}
|
||||
disabled={processing}>
|
||||
<Controller
|
||||
name={'ignored'}
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Textarea
|
||||
{...field}
|
||||
id={textareaId}
|
||||
label={
|
||||
<div className={s.label}>
|
||||
{intl.getMessage('settings_domain_names')}
|
||||
<Dropdown
|
||||
trigger="hover"
|
||||
menu={
|
||||
<div className={cn(theme.dropdown.menu, s.dropdownMenu)}>
|
||||
<div className={s.dropdownTitle}>
|
||||
{intl.getMessage('settings_tooltip_domain_names')}
|
||||
</div>
|
||||
<div className={s.dropdownText}>
|
||||
<strong>{intl.getMessage('settings_tooltip_examples')}</strong>
|
||||
<div>example.com</div>
|
||||
<div>*.example.com</div>
|
||||
<div>||example.com^</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
className={s.dropdown}
|
||||
position="bottomLeft"
|
||||
noIcon>
|
||||
<div className={s.dropdownTrigger}>
|
||||
<Icon icon="faq" className={s.icon} />
|
||||
</div>
|
||||
</Dropdown>
|
||||
<a
|
||||
href="https://link.adtidy.org/forward.html?action=dns_kb_filtering_syntax&from=ui&app=home"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(s.link, theme.link.link, theme.link.noDecoration)}>
|
||||
{intl.getMessage('settings_rule_syntax')}
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
placeholder={'example.com\n*.example.com\n||example.com^'}
|
||||
size="large"
|
||||
disabled={processing || !ignoreEnabled}
|
||||
errorMessage={fieldState.error?.message}
|
||||
onBlur={(e) => {
|
||||
const trimmed = trimLinesAndRemoveEmpty(e.target.value);
|
||||
setValue('ignored', trimmed);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</SwitchGroup>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { IgnoredDomains } from './IgnoredDomains';
|
||||
@@ -0,0 +1,17 @@
|
||||
.link {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dropdownMenu {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.dropdownTitle {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
181
client_v2/src/components/Settings/LogsConfig/Form.tsx
Normal file
181
client_v2/src/components/Settings/LogsConfig/Form.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
|
||||
import intl from 'panel/common/intl';
|
||||
import { toNumber } from 'panel/helpers/form';
|
||||
import { Input } from 'panel/common/controls/Input';
|
||||
import { Button } from 'panel/common/ui/Button';
|
||||
import theme from 'panel/lib/theme';
|
||||
|
||||
import { QUERY_LOG_INTERVALS_DAYS, HOUR, DAY, RETENTION_CUSTOM, RETENTION_RANGE } from '../../../helpers/constants';
|
||||
import { RadioGroup } from '../SettingsGroup/RadioGroup';
|
||||
import { SwitchGroup } from '../SettingsGroup';
|
||||
import { IgnoredDomains } from '../IgnoredDomains';
|
||||
|
||||
const getIntervalTitle = (intervalDays: number) => {
|
||||
switch (intervalDays) {
|
||||
case RETENTION_CUSTOM:
|
||||
return intl.getMessage('settings_custom');
|
||||
case 6 * HOUR:
|
||||
return intl.getPlural('settings_hours', 6);
|
||||
case DAY:
|
||||
return intl.getPlural('settings_hours', 24);
|
||||
default:
|
||||
return intl.getPlural('settings_days', intervalDays / DAY);
|
||||
}
|
||||
};
|
||||
|
||||
export type FormValues = {
|
||||
enabled: boolean;
|
||||
anonymize_client_ip: boolean;
|
||||
interval: number;
|
||||
customInterval?: number | null;
|
||||
ignored: string;
|
||||
ignore_enabled: boolean;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
initialValues: Partial<FormValues>;
|
||||
processing: boolean;
|
||||
processingReset: boolean;
|
||||
onSubmit: (values: FormValues) => void;
|
||||
onReset: () => void;
|
||||
};
|
||||
|
||||
export const Form = ({ initialValues, processing, processingReset, onSubmit, onReset }: Props) => {
|
||||
const {
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
control,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<FormValues>({
|
||||
mode: 'onBlur',
|
||||
defaultValues: {
|
||||
enabled: initialValues.enabled || false,
|
||||
anonymize_client_ip: initialValues.anonymize_client_ip || false,
|
||||
interval: initialValues.interval || DAY,
|
||||
customInterval: initialValues.customInterval ?? undefined,
|
||||
ignored: initialValues.ignored || '',
|
||||
ignore_enabled: initialValues.ignore_enabled || true,
|
||||
},
|
||||
});
|
||||
|
||||
const intervalValue = watch('interval');
|
||||
const customIntervalValue = watch('customInterval');
|
||||
const ignoreEnabled = watch('ignore_enabled');
|
||||
|
||||
useEffect(() => {
|
||||
if (QUERY_LOG_INTERVALS_DAYS.includes(intervalValue)) {
|
||||
setValue('customInterval', undefined);
|
||||
}
|
||||
}, [intervalValue]);
|
||||
|
||||
const onSubmitForm = (data: FormValues) => {
|
||||
onSubmit(data);
|
||||
};
|
||||
|
||||
const disableSubmit = isSubmitting || processing || (intervalValue === RETENTION_CUSTOM && !customIntervalValue);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmitForm)}>
|
||||
<Controller
|
||||
name="enabled"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SwitchGroup
|
||||
id="logs_enabled"
|
||||
title={intl.getMessage('settings_log_dns_requests')}
|
||||
checked={field.value}
|
||||
onChange={field.onChange}
|
||||
disabled={processing}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="anonymize_client_ip"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SwitchGroup
|
||||
id="logs_anonymize_client_ip"
|
||||
title={intl.getMessage('settings_anonymize_client_ip')}
|
||||
description={intl.getMessage('settings_anonymize_client_ip_desc')}
|
||||
checked={field.value}
|
||||
onChange={field.onChange}
|
||||
disabled={processing}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<RadioGroup
|
||||
title={intl.getMessage('query_log_retention')}
|
||||
disabled={processing}
|
||||
value={intervalValue}
|
||||
onChange={(val) => setValue('interval', Number(val))}
|
||||
name="logs-interval"
|
||||
options={[
|
||||
{ text: getIntervalTitle(RETENTION_CUSTOM), value: RETENTION_CUSTOM },
|
||||
...QUERY_LOG_INTERVALS_DAYS.map((interval) => ({
|
||||
text: getIntervalTitle(interval),
|
||||
value: interval,
|
||||
})),
|
||||
]}>
|
||||
<Controller
|
||||
name="customInterval"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<div className={theme.form.input}>
|
||||
<Input
|
||||
id="logs_config_custom_interval"
|
||||
label={intl.getMessage('settings_log_rotation_hours')}
|
||||
placeholder={intl.getMessage('settings_rotation_placeholder')}
|
||||
value={field.value ?? ''}
|
||||
onChange={(e) => {
|
||||
const { value } = e.target;
|
||||
field.onChange(toNumber(value));
|
||||
}}
|
||||
disabled={processing || QUERY_LOG_INTERVALS_DAYS.includes(intervalValue)}
|
||||
errorMessage={fieldState.error?.message}
|
||||
min={RETENTION_RANGE.MIN}
|
||||
max={RETENTION_RANGE.MAX}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</RadioGroup>
|
||||
|
||||
<IgnoredDomains
|
||||
control={control}
|
||||
processing={processing}
|
||||
ignoreEnabled={ignoreEnabled}
|
||||
setValue={setValue}
|
||||
switchId="logs_config_ignored_enabled"
|
||||
textareaId="logs_config_ignored"
|
||||
/>
|
||||
|
||||
<div className={theme.form.buttonGroup}>
|
||||
<Button
|
||||
type="submit"
|
||||
data-testid="logs_config_save"
|
||||
variant="primary"
|
||||
size="small"
|
||||
disabled={disableSubmit}
|
||||
className={theme.form.button}>
|
||||
{intl.getMessage('save')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
data-testid="logs_config_clear"
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={onReset}
|
||||
disabled={processingReset}
|
||||
className={theme.form.button}>
|
||||
{intl.getMessage('clear_query_log')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
119
client_v2/src/components/Settings/LogsConfig/LogsConfig.tsx
Normal file
119
client_v2/src/components/Settings/LogsConfig/LogsConfig.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { Form, FormValues } from './Form';
|
||||
import { ConfirmDialog } from 'panel/common/ui/ConfirmDialog';
|
||||
import intl from 'panel/common/intl';
|
||||
import { HOUR } from 'panel/helpers/constants';
|
||||
import { formatIntervalText } from 'panel/components/Settings/helpers';
|
||||
|
||||
export type LogsConfigPayload = {
|
||||
enabled: boolean;
|
||||
anonymize_client_ip: boolean;
|
||||
ignore_enabled: boolean;
|
||||
ignored: string[];
|
||||
interval: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
interval: number;
|
||||
customInterval?: number;
|
||||
enabled: boolean;
|
||||
anonymize_client_ip: boolean;
|
||||
processing: boolean;
|
||||
ignored: string[];
|
||||
processingClear: boolean;
|
||||
setLogsConfig: (values: LogsConfigPayload) => unknown;
|
||||
clearLogs: () => void;
|
||||
};
|
||||
|
||||
export const LogsConfig = ({
|
||||
interval: prevInterval,
|
||||
customInterval,
|
||||
enabled,
|
||||
anonymize_client_ip,
|
||||
processing,
|
||||
processingClear,
|
||||
ignored,
|
||||
setLogsConfig,
|
||||
clearLogs,
|
||||
}: Props) => {
|
||||
const [openConfirmDialog, setOpenConfirmDialog] = useState(false);
|
||||
const [confirmConfig, setConfirmConfig] = useState<LogsConfigPayload | null>(null);
|
||||
|
||||
const handleClear = () => {
|
||||
setOpenConfirmDialog(true);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setOpenConfirmDialog(false);
|
||||
};
|
||||
|
||||
const handleClearConfirm = () => {
|
||||
clearLogs();
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleFormSubmit = (values: FormValues) => {
|
||||
const { interval, customInterval, ...rest } = values;
|
||||
|
||||
const newInterval = customInterval ? customInterval * HOUR : interval;
|
||||
|
||||
const data: LogsConfigPayload = {
|
||||
...rest,
|
||||
ignored: values.ignored ? values.ignored.split('\n') : [],
|
||||
interval: newInterval,
|
||||
};
|
||||
|
||||
if (newInterval < prevInterval) {
|
||||
setConfirmConfig(data);
|
||||
return;
|
||||
}
|
||||
|
||||
setLogsConfig(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form
|
||||
initialValues={{
|
||||
enabled,
|
||||
interval: prevInterval,
|
||||
customInterval,
|
||||
anonymize_client_ip,
|
||||
ignored: ignored?.join('\n'),
|
||||
}}
|
||||
processing={processing}
|
||||
processingReset={processingClear}
|
||||
onSubmit={handleFormSubmit}
|
||||
onReset={handleClear}
|
||||
/>
|
||||
{confirmConfig && (
|
||||
<ConfirmDialog
|
||||
onClose={() => setConfirmConfig(null)}
|
||||
onConfirm={() => {
|
||||
setLogsConfig(confirmConfig);
|
||||
setConfirmConfig(null);
|
||||
}}
|
||||
buttonText={intl.getMessage('settings_yes_decrease')}
|
||||
cancelText={intl.getMessage('cancel')}
|
||||
title={intl.getMessage('settings_confirm_decrease_log_rotation_interval')}
|
||||
text={intl.getMessage('settings_confirm_decrease_log_rotation_interval_desc', {
|
||||
value: formatIntervalText(confirmConfig.interval),
|
||||
})}
|
||||
buttonVariant="danger"
|
||||
/>
|
||||
)}
|
||||
{openConfirmDialog && (
|
||||
<ConfirmDialog
|
||||
onClose={handleClose}
|
||||
onConfirm={handleClearConfirm}
|
||||
buttonText={intl.getMessage('settings_yes_clear')}
|
||||
cancelText={intl.getMessage('cancel')}
|
||||
title={intl.getMessage('settings_confirm_clear_query_log')}
|
||||
text={intl.getMessage('settings_confirm_clear_query_log_desc')}
|
||||
buttonVariant="danger"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
1
client_v2/src/components/Settings/LogsConfig/index.ts
Normal file
1
client_v2/src/components/Settings/LogsConfig/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { LogsConfig } from './LogsConfig';
|
||||
205
client_v2/src/components/Settings/Settings.tsx
Normal file
205
client_v2/src/components/Settings/Settings.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import cn from 'clsx';
|
||||
|
||||
import intl from 'panel/common/intl';
|
||||
import { Checkbox } from 'panel/common/controls/Checkbox';
|
||||
import { Loader } from 'panel/common/ui/Loader';
|
||||
import theme from 'panel/lib/theme';
|
||||
|
||||
import { StatsConfig } from './StatsConfig/StatsConfig';
|
||||
import { LogsConfig } from './LogsConfig';
|
||||
import { FiltersConfig } from './FiltersConfig';
|
||||
import { getObjectKeysSorted, captitalizeWords } from '../../helpers/helpers';
|
||||
import { SettingsData, StatsData, QueryLogsData, FilteringData } from '../../initialState';
|
||||
import type { StatsConfigPayload } from './StatsConfig/StatsConfig';
|
||||
import type { LogsConfigPayload } from './LogsConfig/LogsConfig';
|
||||
import type { FormValues as FiltersFormValues } from './FiltersConfig';
|
||||
import { SwitchGroup } from './SettingsGroup';
|
||||
|
||||
import s from './styles.module.pcss';
|
||||
|
||||
const ORDER_KEY = 'order';
|
||||
|
||||
const SETTINGS = {
|
||||
safebrowsing: {
|
||||
enabled: false,
|
||||
title: intl.getMessage('settings_browsing_security'),
|
||||
subtitle: intl.getMessage('settings_browsing_security_desc'),
|
||||
testId: 'safebrowsing',
|
||||
[ORDER_KEY]: 0,
|
||||
},
|
||||
parental: {
|
||||
enabled: false,
|
||||
title: intl.getMessage('settings_parental_control'),
|
||||
subtitle: intl.getMessage('settings_parental_control_desc'),
|
||||
testId: 'parental',
|
||||
[ORDER_KEY]: 1,
|
||||
},
|
||||
};
|
||||
|
||||
type InitSettingsArg = typeof SETTINGS;
|
||||
type ToggleSettingArgKey = keyof typeof SETTINGS | 'safesearch';
|
||||
type ToggleSettingArgValue = boolean | Record<string, boolean>;
|
||||
|
||||
type Props = {
|
||||
settings: SettingsData;
|
||||
stats: StatsData;
|
||||
queryLogs: QueryLogsData;
|
||||
filtering: FilteringData;
|
||||
initSettings: (settings: InitSettingsArg) => void;
|
||||
toggleSetting: (key: ToggleSettingArgKey, value: ToggleSettingArgValue) => void;
|
||||
getStatsConfig: () => void;
|
||||
setStatsConfig: (config: StatsConfigPayload) => void;
|
||||
resetStats: () => void;
|
||||
setFiltersConfig: (values: FiltersFormValues) => void;
|
||||
getFilteringStatus: () => void;
|
||||
getLogsConfig?: () => void;
|
||||
setLogsConfig?: (values: LogsConfigPayload) => void;
|
||||
clearLogs?: () => void;
|
||||
};
|
||||
|
||||
export const Settings = ({
|
||||
initSettings,
|
||||
getStatsConfig,
|
||||
getLogsConfig,
|
||||
getFilteringStatus,
|
||||
settings,
|
||||
toggleSetting,
|
||||
setStatsConfig,
|
||||
resetStats,
|
||||
stats,
|
||||
queryLogs,
|
||||
setLogsConfig,
|
||||
clearLogs,
|
||||
filtering,
|
||||
setFiltersConfig,
|
||||
}: Props) => {
|
||||
useEffect(() => {
|
||||
initSettings(SETTINGS);
|
||||
getStatsConfig();
|
||||
getFilteringStatus();
|
||||
|
||||
if (getLogsConfig) {
|
||||
getLogsConfig();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const renderSettings = (settingsList?: SettingsData['settingsList']) =>
|
||||
settingsList
|
||||
? getObjectKeysSorted(SETTINGS, ORDER_KEY).map((key: keyof typeof SETTINGS) => {
|
||||
const setting = settingsList[key];
|
||||
const { enabled, title, subtitle } = setting;
|
||||
|
||||
return (
|
||||
<div key={key}>
|
||||
<SwitchGroup
|
||||
title={title}
|
||||
description={subtitle}
|
||||
id={String(key)}
|
||||
checked={enabled}
|
||||
onChange={(checked) => toggleSetting(key as ToggleSettingArgKey, !checked)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: null;
|
||||
|
||||
const renderSafeSearch = () => {
|
||||
const safesearch = settings.settingsList?.safesearch;
|
||||
|
||||
if (!safesearch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { enabled, ...searches } = safesearch;
|
||||
|
||||
return (
|
||||
<SwitchGroup
|
||||
id="safesearch"
|
||||
title={intl.getMessage('settings_safe_search')}
|
||||
description={intl.getMessage('settings_safe_search_desc')}
|
||||
checked={enabled}
|
||||
onChange={(e) => toggleSetting('safesearch', { ...safesearch, enabled: e.target.checked })}>
|
||||
<div>
|
||||
{Object.keys(searches).map((searchKey) => (
|
||||
<div key={searchKey} className={s.checkbox}>
|
||||
<Checkbox
|
||||
id={searchKey}
|
||||
checked={searches[searchKey]}
|
||||
disabled={!enabled}
|
||||
onChange={(e) => {
|
||||
toggleSetting('safesearch', { ...safesearch, [searchKey]: e.target.checked });
|
||||
}}>
|
||||
{captitalizeWords(searchKey)}
|
||||
</Checkbox>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SwitchGroup>
|
||||
);
|
||||
};
|
||||
|
||||
const isDataReady = !settings.processing && !stats.processingGetConfig && !queryLogs.processingGetConfig;
|
||||
|
||||
return (
|
||||
<div className={theme.layout.container}>
|
||||
<h1 className={cn(theme.layout.title, theme.title.h4, theme.title.h3_tablet)}>
|
||||
{intl.getMessage('general_settings')}
|
||||
</h1>
|
||||
|
||||
{!isDataReady && <Loader />}
|
||||
|
||||
{isDataReady && (
|
||||
<>
|
||||
<h2 className={cn(theme.layout.subtitle, theme.title.h5, theme.title.h4_tablet)}>
|
||||
{intl.getMessage('settings_filter_requests')}
|
||||
</h2>
|
||||
|
||||
<FiltersConfig
|
||||
initialValues={{
|
||||
interval: filtering.interval,
|
||||
enabled: filtering.enabled,
|
||||
}}
|
||||
processing={filtering.processingSetConfig}
|
||||
setFiltersConfig={setFiltersConfig}
|
||||
/>
|
||||
|
||||
{renderSettings(settings.settingsList)}
|
||||
|
||||
{renderSafeSearch()}
|
||||
|
||||
<h2 className={cn(theme.layout.subtitle, theme.title.h5, theme.title.h4_tablet)}>
|
||||
{intl.getMessage('query_log')}
|
||||
</h2>
|
||||
|
||||
<LogsConfig
|
||||
enabled={queryLogs.enabled}
|
||||
ignored={queryLogs.ignored}
|
||||
interval={queryLogs.interval}
|
||||
customInterval={queryLogs.customInterval}
|
||||
anonymize_client_ip={queryLogs.anonymize_client_ip}
|
||||
processing={queryLogs.processingSetConfig}
|
||||
processingClear={queryLogs.processingClear}
|
||||
setLogsConfig={setLogsConfig}
|
||||
clearLogs={clearLogs}
|
||||
/>
|
||||
|
||||
<h2 className={cn(theme.layout.subtitle, theme.title.h5, theme.title.h4_tablet)}>
|
||||
{intl.getMessage('settings_statistics')}
|
||||
</h2>
|
||||
|
||||
<StatsConfig
|
||||
interval={stats.interval}
|
||||
customInterval={stats.customInterval}
|
||||
ignored={stats.ignored}
|
||||
enabled={stats.enabled}
|
||||
processing={stats.processingSetConfig}
|
||||
processingReset={stats.processingReset}
|
||||
setStatsConfig={setStatsConfig}
|
||||
resetStats={resetStats}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import cn from 'clsx';
|
||||
|
||||
import { Radio } from 'panel/common/controls/Radio';
|
||||
|
||||
import s from './styles.module.pcss';
|
||||
|
||||
type Option<T> = { text: string; value: T };
|
||||
|
||||
type Props<T> = {
|
||||
title: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
value: T;
|
||||
options: Option<T>[];
|
||||
onChange: (value: T) => void;
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export const RadioGroup = <T extends number | string | boolean>({
|
||||
title,
|
||||
description,
|
||||
disabled,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
className,
|
||||
children,
|
||||
name,
|
||||
}: Props<T>) => {
|
||||
return (
|
||||
<div className={cn(s.switch, className)}>
|
||||
<div className={s.row}>
|
||||
<div className={s.text}>
|
||||
<div className={s.title}>{title}</div>
|
||||
{description && <div className={s.desc}>{description}</div>}
|
||||
</div>
|
||||
<div className={s.input} />
|
||||
</div>
|
||||
|
||||
<div className={s.content}>
|
||||
<Radio<T> disabled={disabled} value={value} options={options} handleChange={onChange} name={name} />
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import React, { ReactNode, useRef } from 'react';
|
||||
import cn from 'clsx';
|
||||
|
||||
import { Switch } from 'panel/common/controls/Switch';
|
||||
|
||||
import s from './styles.module.pcss';
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
description?: string;
|
||||
id: string;
|
||||
className?: string;
|
||||
checked: boolean;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
disabled?: boolean;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export const SwitchGroup = ({ title, description, id, className, checked, onChange, disabled, children }: Props) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleRowClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (disabled || !inputRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
inputRef.current?.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn(s.switch, className)}>
|
||||
<div className={s.row} onClick={handleRowClick}>
|
||||
<div className={s.text}>
|
||||
<div className={s.title}>{title}</div>
|
||||
{description && <div className={s.desc}>{description}</div>}
|
||||
</div>
|
||||
<div className={s.input} onClick={(e) => e.stopPropagation()}>
|
||||
<Switch id={id} checked={checked} onChange={onChange} disabled={disabled} ref={inputRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{children && <div className={s.content}>{children}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
2
client_v2/src/components/Settings/SettingsGroup/index.ts
Normal file
2
client_v2/src/components/Settings/SettingsGroup/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { RadioGroup } from './RadioGroup';
|
||||
export { SwitchGroup } from './SwitchGroup';
|
||||
@@ -0,0 +1,44 @@
|
||||
.switch {
|
||||
width: 100%;
|
||||
padding: 8px 16px;
|
||||
transition: background-color var(--t2);
|
||||
border-radius: 8px;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: var(--fills-backgrounds-page-background-additional);
|
||||
}
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
cursor: pointer;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
gap: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: var(--weight-semi-bold);
|
||||
line-height: 24px;
|
||||
color: var(--default-main-text);
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 14px;
|
||||
color: var(--default-description-text);
|
||||
}
|
||||
|
||||
.input,
|
||||
.content {
|
||||
padding: 8px 0;
|
||||
}
|
||||
168
client_v2/src/components/Settings/StatsConfig/Form.tsx
Normal file
168
client_v2/src/components/Settings/StatsConfig/Form.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import i18next from 'i18next';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
|
||||
import { Input } from 'panel/common/controls/Input';
|
||||
import { Button } from 'panel/common/ui/Button';
|
||||
import intl from 'panel/common/intl';
|
||||
import theme from 'panel/lib/theme';
|
||||
|
||||
import { STATS_INTERVALS_DAYS, DAY, RETENTION_CUSTOM, RETENTION_RANGE } from '../../../helpers/constants';
|
||||
import { toNumber } from '../../../helpers/form';
|
||||
import { RadioGroup, SwitchGroup } from '../SettingsGroup';
|
||||
import { IgnoredDomains } from '../IgnoredDomains';
|
||||
|
||||
const getIntervalTitle = (interval: any) => {
|
||||
switch (interval) {
|
||||
case RETENTION_CUSTOM:
|
||||
return i18next.t('settings_custom');
|
||||
case DAY:
|
||||
return i18next.t('interval_24_hour');
|
||||
default:
|
||||
return i18next.t('interval_days', { count: interval / DAY });
|
||||
}
|
||||
};
|
||||
|
||||
export type FormValues = {
|
||||
enabled: boolean;
|
||||
interval: number;
|
||||
customInterval?: number | null;
|
||||
ignored: string;
|
||||
ignore_enabled: boolean;
|
||||
};
|
||||
|
||||
const defaultFormValues = {
|
||||
enabled: false,
|
||||
interval: DAY,
|
||||
customInterval: null,
|
||||
ignored: '',
|
||||
ignore_enabled: false,
|
||||
};
|
||||
|
||||
type Props = {
|
||||
initialValues: FormValues;
|
||||
processing: boolean;
|
||||
processingReset: boolean;
|
||||
onSubmit: (values: FormValues) => void;
|
||||
onReset: () => void;
|
||||
};
|
||||
|
||||
export const Form = ({ initialValues, processing, processingReset, onSubmit, onReset }: Props) => {
|
||||
const {
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
control,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<FormValues>({
|
||||
mode: 'onBlur',
|
||||
defaultValues: {
|
||||
...defaultFormValues,
|
||||
...initialValues,
|
||||
},
|
||||
});
|
||||
|
||||
const intervalValue = watch('interval');
|
||||
const customIntervalValue = watch('customInterval');
|
||||
const ignoreEnabled = watch('ignore_enabled');
|
||||
|
||||
useEffect(() => {
|
||||
if (STATS_INTERVALS_DAYS.includes(intervalValue)) {
|
||||
setValue('customInterval', null);
|
||||
}
|
||||
}, [intervalValue]);
|
||||
|
||||
const onSubmitForm = (data: FormValues) => {
|
||||
onSubmit(data);
|
||||
};
|
||||
|
||||
const disableSubmit = isSubmitting || processing || (intervalValue === RETENTION_CUSTOM && !customIntervalValue);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmitForm)}>
|
||||
<Controller
|
||||
name="enabled"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SwitchGroup
|
||||
checked={field.value}
|
||||
onChange={field.onChange}
|
||||
id="stats_config_enabled"
|
||||
title={intl.getMessage('settings_statistics')}
|
||||
description={intl.getMessage('settings_statistics_desc')}
|
||||
disabled={processing}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<RadioGroup
|
||||
title={intl.getMessage('settings_statistics_retention')}
|
||||
disabled={processing}
|
||||
value={intervalValue}
|
||||
onChange={(val) => setValue('interval', Number(val))}
|
||||
name="stats-interval"
|
||||
options={[
|
||||
{ text: getIntervalTitle(RETENTION_CUSTOM), value: RETENTION_CUSTOM },
|
||||
...STATS_INTERVALS_DAYS.map((interval) => ({
|
||||
text: getIntervalTitle(interval),
|
||||
value: interval,
|
||||
})),
|
||||
]}>
|
||||
<Controller
|
||||
name="customInterval"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<div className={theme.form.input}>
|
||||
<Input
|
||||
id="stats_config_custom_interval"
|
||||
label={intl.getMessage('settings_statistics_retention_hours')}
|
||||
placeholder={intl.getMessage('settings_rotation_placeholder')}
|
||||
value={field.value ?? ''}
|
||||
onChange={(e) => {
|
||||
const { value } = e.target;
|
||||
field.onChange(toNumber(value));
|
||||
}}
|
||||
disabled={processing || STATS_INTERVALS_DAYS.includes(intervalValue)}
|
||||
errorMessage={fieldState.error?.message}
|
||||
min={RETENTION_RANGE.MIN}
|
||||
max={RETENTION_RANGE.MAX}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</RadioGroup>
|
||||
|
||||
<IgnoredDomains
|
||||
control={control}
|
||||
processing={processing}
|
||||
ignoreEnabled={ignoreEnabled}
|
||||
setValue={setValue}
|
||||
switchId="stats_config_ignored_enabled"
|
||||
textareaId="stats_config_ignored"
|
||||
/>
|
||||
|
||||
<div className={theme.form.buttonGroup}>
|
||||
<Button
|
||||
type="submit"
|
||||
id="stats_config_save"
|
||||
variant="primary"
|
||||
size="small"
|
||||
disabled={disableSubmit}
|
||||
className={theme.form.button}>
|
||||
{intl.getMessage('save')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
id="stats_config_clear"
|
||||
onClick={onReset}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
disabled={processingReset}
|
||||
className={theme.form.button}>
|
||||
{intl.getMessage('settings_statistics_clear')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
115
client_v2/src/components/Settings/StatsConfig/StatsConfig.tsx
Normal file
115
client_v2/src/components/Settings/StatsConfig/StatsConfig.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { Form, FormValues } from './Form';
|
||||
import { ConfirmDialog } from 'panel/common/ui/ConfirmDialog';
|
||||
import intl from 'panel/common/intl';
|
||||
import { HOUR } from 'panel/helpers/constants';
|
||||
import { formatIntervalText } from 'panel/components/Settings/helpers';
|
||||
|
||||
export type StatsConfigPayload = {
|
||||
enabled: boolean;
|
||||
ignored: string[];
|
||||
interval: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
interval: number;
|
||||
customInterval?: number;
|
||||
ignored: string[];
|
||||
enabled: boolean;
|
||||
processing: boolean;
|
||||
processingReset: boolean;
|
||||
setStatsConfig: (config: StatsConfigPayload) => void;
|
||||
resetStats: () => void;
|
||||
};
|
||||
|
||||
export const StatsConfig = ({
|
||||
interval: prevInterval,
|
||||
customInterval,
|
||||
ignored,
|
||||
enabled,
|
||||
processing,
|
||||
processingReset,
|
||||
setStatsConfig,
|
||||
resetStats,
|
||||
}: Props) => {
|
||||
const [openClearDialog, setOpenClearDialog] = useState(false);
|
||||
const [confirmConfig, setConfirmConfig] = useState<StatsConfigPayload | null>(null);
|
||||
|
||||
const handleClear = () => {
|
||||
setOpenClearDialog(true);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setOpenClearDialog(false);
|
||||
};
|
||||
|
||||
const handleClearConfirm = () => {
|
||||
resetStats();
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleFormSubmit = (values: FormValues) => {
|
||||
const { interval, customInterval, enabled, ignored } = values;
|
||||
|
||||
const newInterval = customInterval ? customInterval * HOUR : interval;
|
||||
|
||||
const data: StatsConfigPayload = {
|
||||
enabled,
|
||||
interval: newInterval,
|
||||
ignored: ignored ? ignored.split('\n') : [],
|
||||
};
|
||||
|
||||
if (newInterval < prevInterval) {
|
||||
setConfirmConfig(data);
|
||||
return;
|
||||
}
|
||||
|
||||
setStatsConfig(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form
|
||||
initialValues={{
|
||||
interval: prevInterval,
|
||||
customInterval,
|
||||
enabled,
|
||||
ignored: ignored.join('\n'),
|
||||
ignore_enabled: false,
|
||||
}}
|
||||
processing={processing}
|
||||
processingReset={processingReset}
|
||||
onSubmit={handleFormSubmit}
|
||||
onReset={handleClear}
|
||||
/>
|
||||
{confirmConfig && (
|
||||
<ConfirmDialog
|
||||
onClose={() => setConfirmConfig(null)}
|
||||
onConfirm={() => {
|
||||
setStatsConfig(confirmConfig);
|
||||
setConfirmConfig(null);
|
||||
}}
|
||||
buttonText={intl.getMessage('settings_yes_decrease')}
|
||||
cancelText={intl.getMessage('cancel')}
|
||||
title={intl.getMessage('settings_confirm_decrease_stats_rotation_interval')}
|
||||
text={intl.getMessage('settings_confirm_decrease_stats_rotation_interval_desc', {
|
||||
value: formatIntervalText(confirmConfig.interval),
|
||||
})}
|
||||
buttonVariant="danger"
|
||||
/>
|
||||
)}
|
||||
{openClearDialog && (
|
||||
<ConfirmDialog
|
||||
onClose={handleClose}
|
||||
onConfirm={handleClearConfirm}
|
||||
buttonText={intl.getMessage('settings_yes_clear')}
|
||||
cancelText={intl.getMessage('cancel')}
|
||||
title={intl.getMessage('settings_confirm_clear_statistics')}
|
||||
text={intl.getMessage('settings_confirm_clear_statistics_desc')}
|
||||
buttonVariant="danger"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
1
client_v2/src/components/Settings/StatsConfig/index.ts
Normal file
1
client_v2/src/components/Settings/StatsConfig/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { StatsConfig } from './StatsConfig';
|
||||
15
client_v2/src/components/Settings/helpers.ts
Normal file
15
client_v2/src/components/Settings/helpers.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import intl from 'panel/common/intl';
|
||||
import { HOUR, DAY } from 'panel/helpers/constants';
|
||||
|
||||
export const formatIntervalText = (intervalMs: number) => {
|
||||
if (intervalMs === 6 * HOUR) {
|
||||
return intl.getPlural('settings_hours', 6);
|
||||
}
|
||||
if (intervalMs === DAY) {
|
||||
return intl.getPlural('settings_hours', 24);
|
||||
}
|
||||
if (intervalMs % DAY === 0) {
|
||||
return intl.getPlural('settings_days', intervalMs / DAY);
|
||||
}
|
||||
return intl.getPlural('settings_hours', Math.floor(intervalMs / HOUR));
|
||||
};
|
||||
1
client_v2/src/components/Settings/index.ts
Normal file
1
client_v2/src/components/Settings/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Settings } from './Settings';
|
||||
8
client_v2/src/components/Settings/styles.module.pcss
Normal file
8
client_v2/src/components/Settings/styles.module.pcss
Normal file
@@ -0,0 +1,8 @@
|
||||
.checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
.toasts {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
z-index: 105;
|
||||
width: 345px;
|
||||
}
|
||||
|
||||
@media (max-width: 425px) {
|
||||
.toasts {
|
||||
right: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
padding: 16px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
border-radius: 4px;
|
||||
background-color: rgba(236, 53, 53, 0.75);
|
||||
}
|
||||
|
||||
.toast--success {
|
||||
background-color: rgba(90, 173, 99, 0.75);
|
||||
}
|
||||
|
||||
.toast:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.toast__content {
|
||||
flex: 1 1 auto;
|
||||
margin: 0 12px 0 0;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toast__content a {
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.toast__dismiss {
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toast__dismiss:hover,
|
||||
.toast__dismiss:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.toast-enter {
|
||||
opacity: 0.01;
|
||||
}
|
||||
|
||||
.toast-enter-active {
|
||||
opacity: 1;
|
||||
transition: all 0.3s ease-out;
|
||||
}
|
||||
|
||||
.toast-exit {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toast-exit-active {
|
||||
opacity: 0.01;
|
||||
transition: all 0.3s ease-out;
|
||||
}
|
||||
70
client_v2/src/components/Toasts/Toast.pcss
Normal file
70
client_v2/src/components/Toasts/Toast.pcss
Normal file
@@ -0,0 +1,70 @@
|
||||
.toasts {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
width: 100%;
|
||||
max-width: 345px;
|
||||
bottom: 24px;
|
||||
z-index: 105;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
right: 24px;
|
||||
width: 345px;
|
||||
}
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
padding: 16px;
|
||||
font-weight: var(--weight-regular);
|
||||
color: var(--default-main-text);
|
||||
background-color: var(--default-page-background);
|
||||
border-radius: 8px;
|
||||
box-shadow:
|
||||
2px 2px 4px 0 rgba(0, 0, 0, 0.1),
|
||||
4px 4px 8px 0 rgba(0, 0, 0, 0.2);
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&__content {
|
||||
flex: 1 1 auto;
|
||||
margin: 0 12px 0 0;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
|
||||
&_success {
|
||||
color: var(--default-product-icon);
|
||||
}
|
||||
|
||||
&_error {
|
||||
color: var(--default-error-icon);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.toast-enter {
|
||||
opacity: 0.01;
|
||||
}
|
||||
|
||||
.toast-enter-active {
|
||||
opacity: 1;
|
||||
transition: all 0.3s ease-out;
|
||||
}
|
||||
|
||||
.toast-exit {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toast-exit-active {
|
||||
opacity: 0.01;
|
||||
transition: all 0.3s ease-out;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useDispatch } from 'react-redux';
|
||||
import { TOAST_TIMEOUTS } from '../../helpers/constants';
|
||||
|
||||
import { removeToast } from '../../actions';
|
||||
import { Icon } from 'panel/common/ui/Icon';
|
||||
|
||||
interface ToastProps {
|
||||
id: string;
|
||||
@@ -30,28 +31,12 @@ const Toast = ({ id, message, type, options }: ToastProps) => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`toast toast--${type}`}
|
||||
onMouseOver={clearRemoveToastTimeout}
|
||||
onMouseOut={setRemoveToastTimeout}>
|
||||
<p className="toast__content">
|
||||
<div className="toast" onMouseOver={clearRemoveToastTimeout} onMouseOut={setRemoveToastTimeout}>
|
||||
<Icon icon={type === 'success' ? 'check' : 'cross'} className={`toast__icon toast__icon_${type}`} />
|
||||
|
||||
<div className="toast__content">
|
||||
<Trans i18nKey={message} {...options} />
|
||||
</p>
|
||||
|
||||
<button className="toast__dismiss" onClick={removeCurrentToast}>
|
||||
<svg
|
||||
stroke="#fff"
|
||||
fill="none"
|
||||
width="20"
|
||||
height="20"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m18 6-12 12" />
|
||||
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ import { CSSTransition, TransitionGroup } from 'react-transition-group';
|
||||
import { TOAST_TRANSITION_TIMEOUT } from '../../helpers/constants';
|
||||
|
||||
import Toast from './Toast';
|
||||
import './Toast.css';
|
||||
import './Toast.pcss';
|
||||
import { RootState } from '../../initialState';
|
||||
|
||||
const Toasts = () => {
|
||||
|
||||
155
client_v2/src/components/ui/Card.css
Normal file
155
client_v2/src/components/ui/Card.css
Normal file
@@ -0,0 +1,155 @@
|
||||
.card-header {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.6rem 1.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
margin: 4px 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.card-table-overflow {
|
||||
overflow-y: auto;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.card-table-overflow--limited {
|
||||
overflow-y: auto;
|
||||
max-height: 17.5rem;
|
||||
}
|
||||
|
||||
.dashboard .card-table {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dashboard .card-table-overflow--limited {
|
||||
max-height: 292px;
|
||||
}
|
||||
|
||||
.dashboard .ReactTable .rt-tr-group {
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.card-actions-top {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card-graph {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.card-body--status {
|
||||
padding: 2.5rem 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.card-body--loading {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card-body--loading:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.card-body--loading:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
z-index: 101;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-top: -20px;
|
||||
margin-left: -20px;
|
||||
background-image: url('data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2047.6%2047.6%22%20height%3D%22100%25%22%20width%3D%22100%25%22%3E%3Cpath%20opacity%3D%22.235%22%20fill%3D%22%23979797%22%20d%3D%22M44.4%2011.9l-5.2%203c1.5%202.6%202.4%205.6%202.4%208.9%200%209.8-8%2017.8-17.8%2017.8-6.6%200-12.3-3.6-15.4-8.9l-5.2%203C7.3%2042.8%2015%2047.6%2023.8%2047.6c13.1%200%2023.8-10.7%2023.8-23.8%200-4.3-1.2-8.4-3.2-11.9z%22%2F%3E%3Cpath%20fill%3D%22%2366b574%22%20d%3D%22M3.2%2035.7C0%2030.2-.8%2023.8.8%2017.6%202.5%2011.5%206.4%206.4%2011.9%203.2%2017.4%200%2023.8-.8%2030%20.8c6.1%201.6%2011.3%205.6%2014.4%2011.1l-5.2%203c-2.4-4.1-6.2-7.1-10.8-8.3C23.8%205.4%2019%206%2014.9%208.4s-7.1%206.2-8.3%2010.8c-1.2%204.6-.6%209.4%201.8%2013.5l-5.2%203z%22%2F%3E%3C%2Fsvg%3E');
|
||||
will-change: transform;
|
||||
animation: clockwise 2s linear infinite;
|
||||
}
|
||||
|
||||
.card-title-stats {
|
||||
font-size: 13px;
|
||||
color: #9aa0ac;
|
||||
}
|
||||
|
||||
.card-body-stats {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
margin: 0;
|
||||
padding: 1rem 1.5rem;
|
||||
}
|
||||
|
||||
.card-value-stats {
|
||||
display: block;
|
||||
font-size: 2.1rem;
|
||||
line-height: 2.7rem;
|
||||
height: 2.7rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-value-percent {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
right: 15px;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.card-value-percent:after {
|
||||
content: '%';
|
||||
}
|
||||
|
||||
.card--full {
|
||||
height: calc(100% - 1.5rem);
|
||||
}
|
||||
|
||||
.card-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1280px) {
|
||||
.card-title-stats {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
.dashboard .card:not(.card--full) {
|
||||
height: 360px;
|
||||
}
|
||||
}
|
||||
|
||||
.card .logs__cell--red {
|
||||
background-color: #fff4f2;
|
||||
}
|
||||
|
||||
.card .logs__cell--green {
|
||||
background-color: #f1faf3;
|
||||
}
|
||||
|
||||
.card .logs__row--blue {
|
||||
background-color: #ecf7ff;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .card .logs__row--blue {
|
||||
background-color: var(--logs__row--blue-bgcolor);
|
||||
}
|
||||
33
client_v2/src/components/ui/Card.tsx
Normal file
33
client_v2/src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
|
||||
import './Card.css';
|
||||
|
||||
interface CardProps {
|
||||
id?: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
bodyType?: string;
|
||||
type?: string;
|
||||
refresh?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Card = ({ type, id, title, subtitle, refresh, bodyType, children }: CardProps) => (
|
||||
<div className={type ? `card ${type}` : 'card'} id={id || ''}>
|
||||
{(title || subtitle) && (
|
||||
<div className="card-header with-border">
|
||||
<div className="card-inner">
|
||||
{title && <div className="card-title">{title}</div>}
|
||||
|
||||
{subtitle && <div className="card-subtitle" dangerouslySetInnerHTML={{ __html: subtitle }} />}
|
||||
</div>
|
||||
|
||||
{refresh && <div className="card-options">{refresh}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={bodyType || 'card-body'}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Card;
|
||||
113
client_v2/src/components/ui/Controls/Checkbox/checkbox.css
Normal file
113
client_v2/src/components/ui/Controls/Checkbox/checkbox.css
Normal file
@@ -0,0 +1,113 @@
|
||||
.checkbox {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.checkbox--single {
|
||||
display: block;
|
||||
margin: 2px auto 6px;
|
||||
}
|
||||
|
||||
.checkbox--single .checkbox__label:before {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.checkbox--settings .checkbox__label:before {
|
||||
top: 2px;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.checkbox--settings .checkbox__label-title {
|
||||
margin-bottom: 2px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.checkbox--form .checkbox__label:before {
|
||||
top: 1px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.checkbox__label {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox__label:before {
|
||||
content: '';
|
||||
position: relative;
|
||||
top: 1px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
min-width: 20px;
|
||||
margin-right: 10px;
|
||||
background-color: var(--checkbox-bg);
|
||||
background-repeat: no-repeat;
|
||||
background-position: center center;
|
||||
background-size: 12px 10px;
|
||||
border-radius: 3px;
|
||||
transition:
|
||||
0.3s ease-in-out box-shadow,
|
||||
0.3s ease-in-out opacity;
|
||||
}
|
||||
|
||||
.checkbox__label .checkbox__label-text {
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.checkbox__label .checkbox__label-text .md__paragraph {
|
||||
display: inline-block;
|
||||
vertical-align: baseline;
|
||||
margin: 0;
|
||||
text-align: left;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.checkbox__input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.checkbox__input:checked + .checkbox__label:before {
|
||||
background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMi4zIDkuMiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2UtbGluZWNhcD0icm91bmQiPjxwYXRoIGQ9Ik0xMS44IDAuNUw1LjMgOC41IDAuNSA0LjIiLz48L3N2Zz4=);
|
||||
}
|
||||
|
||||
.checkbox__input:focus + .checkbox__label:before {
|
||||
box-shadow: 0 0 1px 1px rgba(74, 74, 74, 0.32);
|
||||
}
|
||||
|
||||
.checkbox__input:disabled + .checkbox__label {
|
||||
cursor: default;
|
||||
color: var(--gray);
|
||||
}
|
||||
|
||||
.checkbox__input:disabled + .checkbox__label:before {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.checkbox__label-text {
|
||||
max-width: 515px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.checkbox__label-text--long {
|
||||
max-width: initial;
|
||||
}
|
||||
|
||||
.checkbox__label-title {
|
||||
display: block;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.checkbox__label-subtitle {
|
||||
display: block;
|
||||
line-height: 1.5;
|
||||
color: var(--scolor);
|
||||
}
|
||||
50
client_v2/src/components/ui/Controls/Checkbox/index.tsx
Normal file
50
client_v2/src/components/ui/Controls/Checkbox/index.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React, { forwardRef, ReactNode } from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import './checkbox.css';
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
subtitle?: ReactNode;
|
||||
value: boolean;
|
||||
name?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
error?: string;
|
||||
onChange: (value: boolean) => void;
|
||||
onBlur?: () => void;
|
||||
};
|
||||
|
||||
export const Checkbox = forwardRef<HTMLInputElement, Props>(
|
||||
(
|
||||
{ title, subtitle, value, name, disabled, error, className = 'checkbox--form', onChange, onBlur, ...rest },
|
||||
ref,
|
||||
) => (
|
||||
<>
|
||||
<label className={clsx('checkbox', className)}>
|
||||
<span className="checkbox__marker" />
|
||||
<input
|
||||
name={name}
|
||||
type="checkbox"
|
||||
className="checkbox__input"
|
||||
disabled={disabled}
|
||||
checked={value}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
onBlur={onBlur}
|
||||
ref={ref}
|
||||
{...rest}
|
||||
/>
|
||||
<span className="checkbox__label">
|
||||
<span className="checkbox__label-text checkbox__label-text--long">
|
||||
<span className="checkbox__label-title">{title}</span>
|
||||
|
||||
{subtitle && <span className="checkbox__label-subtitle">{subtitle}</span>}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
{error && <div className="form__message form__message--error">{error}</div>}
|
||||
</>
|
||||
),
|
||||
);
|
||||
|
||||
Checkbox.displayName = 'Checkbox';
|
||||
45
client_v2/src/components/ui/Controls/Textarea.tsx
Normal file
45
client_v2/src/components/ui/Controls/Textarea.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React, { ComponentProps, forwardRef } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { trimLinesAndRemoveEmpty } from '../../../helpers/helpers';
|
||||
|
||||
type Props = ComponentProps<'textarea'> & {
|
||||
className?: string;
|
||||
wrapperClassName?: string;
|
||||
label?: string;
|
||||
desc?: string;
|
||||
error?: string;
|
||||
trimOnBlur?: boolean;
|
||||
};
|
||||
|
||||
export const Textarea = forwardRef<HTMLTextAreaElement, Props>(
|
||||
({ name, label, desc, className, wrapperClassName, error, trimOnBlur, onBlur, ...rest }, ref) => (
|
||||
<div className={clsx('form-group', wrapperClassName, { 'has-error': !!error })}>
|
||||
{label && (
|
||||
<label className={clsx('form__label', { 'form__label--with-desc': !!desc })} htmlFor={name}>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
{desc && <div className="form__desc form__desc--top">{desc}</div>}
|
||||
<textarea
|
||||
className={clsx(
|
||||
'form-control form-control--textarea form-control--textarea-small font-monospace',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
onBlur={(e) => {
|
||||
if (trimOnBlur) {
|
||||
const normalizedValue = trimLinesAndRemoveEmpty(e.target.value);
|
||||
rest.onChange(normalizedValue);
|
||||
}
|
||||
if (onBlur) {
|
||||
onBlur(e);
|
||||
}
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
{error && <div className="form__message form__message--error">{error}</div>}
|
||||
</div>
|
||||
),
|
||||
);
|
||||
|
||||
Textarea.displayName = 'Textarea';
|
||||
52
client_v2/src/components/ui/Loading.css
Normal file
52
client_v2/src/components/ui/Loading.css
Normal file
@@ -0,0 +1,52 @@
|
||||
.loading {
|
||||
position: relative;
|
||||
z-index: 101;
|
||||
opacity: 0;
|
||||
animation: opacity 0.2s linear 0.2s forwards;
|
||||
}
|
||||
|
||||
.loading:before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
background-color: var(--loading-bg);
|
||||
}
|
||||
|
||||
.loading:after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
z-index: 101;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-top: -20px;
|
||||
margin-left: -20px;
|
||||
background-image: url('data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2047.6%2047.6%22%20height%3D%22100%25%22%20width%3D%22100%25%22%3E%3Cpath%20opacity%3D%22.235%22%20fill%3D%22%23979797%22%20d%3D%22M44.4%2011.9l-5.2%203c1.5%202.6%202.4%205.6%202.4%208.9%200%209.8-8%2017.8-17.8%2017.8-6.6%200-12.3-3.6-15.4-8.9l-5.2%203C7.3%2042.8%2015%2047.6%2023.8%2047.6c13.1%200%2023.8-10.7%2023.8-23.8%200-4.3-1.2-8.4-3.2-11.9z%22%2F%3E%3Cpath%20fill%3D%22%2366b574%22%20d%3D%22M3.2%2035.7C0%2030.2-.8%2023.8.8%2017.6%202.5%2011.5%206.4%206.4%2011.9%203.2%2017.4%200%2023.8-.8%2030%20.8c6.1%201.6%2011.3%205.6%2014.4%2011.1l-5.2%203c-2.4-4.1-6.2-7.1-10.8-8.3C23.8%205.4%2019%206%2014.9%208.4s-7.1%206.2-8.3%2010.8c-1.2%204.6-.6%209.4%201.8%2013.5l-5.2%203z%22%2F%3E%3C%2Fsvg%3E');
|
||||
will-change: transform;
|
||||
animation: clockwise 2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes opacity {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes clockwise {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
17
client_v2/src/components/ui/Loading.tsx
Normal file
17
client_v2/src/components/ui/Loading.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import classNames from 'clsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import './Loading.css';
|
||||
|
||||
interface LoadingProps {
|
||||
className?: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
const Loading = ({ className, text }: LoadingProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return <div className={classNames('loading', className)}>{t(text)}</div>;
|
||||
};
|
||||
|
||||
export default Loading;
|
||||
38
client_v2/src/components/ui/PageTitle.css
Normal file
38
client_v2/src/components/ui/PageTitle.css
Normal file
@@ -0,0 +1,38 @@
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.page-header--logs {
|
||||
flex-direction: row;
|
||||
align-items: flex-end;
|
||||
margin: 0.5rem 0 2.8rem;
|
||||
}
|
||||
|
||||
.page-header--logs .page-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (max-width: 991px) {
|
||||
.page-header--logs {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 0 0 1.1rem;
|
||||
}
|
||||
|
||||
.page-header--logs .page-title {
|
||||
margin-bottom: 1.1rem;
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin-left: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.page-title--large {
|
||||
font-size: 36px;
|
||||
line-height: 46px;
|
||||
}
|
||||
23
client_v2/src/components/ui/PageTitle.tsx
Normal file
23
client_v2/src/components/ui/PageTitle.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
|
||||
import './PageTitle.css';
|
||||
|
||||
interface PageTitleProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children?: React.ReactNode;
|
||||
containerClass?: string;
|
||||
}
|
||||
|
||||
const PageTitle = ({ title, subtitle, children, containerClass }: PageTitleProps) => (
|
||||
<div className="page-header">
|
||||
<div className={containerClass}>
|
||||
<h1 className="page-title pr-2">{title}</h1>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{subtitle && <div className="page-subtitle">{subtitle}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default PageTitle;
|
||||
38
client_v2/src/containers/Settings.ts
Normal file
38
client_v2/src/containers/Settings.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { initSettings, toggleSetting } from '../actions';
|
||||
import { getBlockedServices, updateBlockedServices } from '../actions/services';
|
||||
import { getStatsConfig, setStatsConfig, resetStats } from '../actions/stats';
|
||||
import { clearLogs, getLogsConfig, setLogsConfig } from '../actions/queryLogs';
|
||||
import { getFilteringStatus, setFiltersConfig } from '../actions/filtering';
|
||||
|
||||
import { Settings } from '../components/Settings';
|
||||
|
||||
const mapStateToProps = (state: any) => {
|
||||
const { settings, services, stats, queryLogs, filtering } = state;
|
||||
const props = {
|
||||
settings,
|
||||
services,
|
||||
stats,
|
||||
queryLogs,
|
||||
filtering,
|
||||
};
|
||||
return props;
|
||||
};
|
||||
|
||||
const mapDispatchToProps = {
|
||||
initSettings,
|
||||
toggleSetting,
|
||||
getBlockedServices,
|
||||
updateBlockedServices,
|
||||
getStatsConfig,
|
||||
setStatsConfig,
|
||||
resetStats,
|
||||
clearLogs,
|
||||
getLogsConfig,
|
||||
setLogsConfig,
|
||||
getFilteringStatus,
|
||||
setFiltersConfig,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Settings);
|
||||
@@ -476,7 +476,7 @@ export const TOAST_TYPES = {
|
||||
export const SUCCESS_TOAST_TIMEOUT = 5000;
|
||||
|
||||
export const ONE_SECOND_IN_MS = 1000;
|
||||
export const FAILURE_TOAST_TIMEOUT = 30000;
|
||||
export const FAILURE_TOAST_TIMEOUT = 5000;
|
||||
|
||||
export const TOAST_TIMEOUTS = {
|
||||
[TOAST_TYPES.SUCCESS]: SUCCESS_TOAST_TIMEOUT,
|
||||
|
||||
@@ -213,6 +213,7 @@ export type StatsData = {
|
||||
enabled: boolean;
|
||||
topUpstreamsAvgTime: { name: string; count: number }[];
|
||||
topUpstreamsResponses: { name: string; count: number }[];
|
||||
ignored: string[];
|
||||
};
|
||||
|
||||
export type ClientsData = {
|
||||
@@ -365,6 +366,7 @@ export type QueryLogsData = {
|
||||
isDetailed: boolean;
|
||||
isEntireLog: boolean;
|
||||
customInterval: any;
|
||||
ignored: string[];
|
||||
};
|
||||
|
||||
export type ServicesData = {
|
||||
@@ -566,6 +568,7 @@ export const initialState: RootState = {
|
||||
isDetailed: true,
|
||||
isEntireLog: false,
|
||||
customInterval: null,
|
||||
ignored: [],
|
||||
},
|
||||
rewrites: {
|
||||
processing: true,
|
||||
@@ -612,6 +615,7 @@ export const initialState: RootState = {
|
||||
enabled: true,
|
||||
topUpstreamsAvgTime: [],
|
||||
topUpstreamsResponses: [],
|
||||
ignored: [],
|
||||
},
|
||||
toasts: { notices: [] },
|
||||
};
|
||||
|
||||
20
client_v2/src/lib/theme/Form.module.pcss
Normal file
20
client_v2/src/lib/theme/Form.module.pcss
Normal file
@@ -0,0 +1,20 @@
|
||||
.buttonGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 8px 16px;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.button {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
padding: 8px 0;
|
||||
}
|
||||
@@ -1,37 +1,29 @@
|
||||
.container {
|
||||
margin: 0 16px;
|
||||
padding: 24px 0;
|
||||
padding: 40px 0;
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
color: var(--default-main-text);
|
||||
|
||||
@media (min-width: 768px) {
|
||||
padding: 48px 24px 80px;
|
||||
|
||||
&[data-height='true'] {
|
||||
min-height: 70vh;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
max-width: 1164px;
|
||||
margin: 0 auto;
|
||||
|
||||
&[data-height='true'] {
|
||||
min-height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1400px) {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&_update {
|
||||
@media (min-width: 1024px) {
|
||||
margin: 0;
|
||||
padding: 48px 24px 72px;
|
||||
max-width: 632px;
|
||||
}
|
||||
|
||||
@media (min-width: 1400px) {
|
||||
margin: 0 auto;
|
||||
}
|
||||
&_full {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
padding: 40px 16px 16px 16px;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
padding-top: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.no_decoration {
|
||||
&.noDecoration {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import title from './Title.module.pcss';
|
||||
import text from './Text.module.pcss';
|
||||
import dropdown from './Dropdown.module.pcss';
|
||||
import select from './Select.module.pcss';
|
||||
import form from './Form.module.pcss';
|
||||
|
||||
const theme = {
|
||||
link,
|
||||
@@ -16,6 +17,7 @@ const theme = {
|
||||
text,
|
||||
dropdown,
|
||||
select,
|
||||
form,
|
||||
};
|
||||
|
||||
export default theme;
|
||||
|
||||
Reference in New Issue
Block a user