diff --git a/.env.development b/.env.development index 75abc6c..d5857b4 100644 --- a/.env.development +++ b/.env.development @@ -1,7 +1,7 @@ REACT_APP_BASE_URL=http://localhost:9002 REACT_APP_WALLET_BASE_URL=http://localhost:9003 REACT_APP_ASSETS_BUCKET=http://localhost -REACT_APP_DEMO_MODE=true +REACT_APP_DEMO_MODE=false # More info https://create-react-app.dev/docs/advanced-configuration ESLINT_NO_DEV_ERRORS=true diff --git a/.env.example b/.env.example index 0b7ad59..9776e3c 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1 @@ -REACT_APP_BASE_URL=http://localhost:9002 -REACT_APP_ASSETS_BUCKET=https://lightence-assets.s3.amazonaws.com - -# more info https://create-react-app.dev/docs/advanced-configuration -ESLINT_NO_DEV_ERRORS= -TSC_COMPILE_ON_ERROR= +NODE_OPTIONS=--openssl-legacy-provider diff --git a/.gitignore b/.gitignore index 99955a8..339bf99 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ yarn-error.log* # generated files /public/themes +/memory-bank \ No newline at end of file diff --git a/README.md b/README.md index 89201b8..a00fa03 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ We have a live demo that can be found at http://hornetstorage.net for anyone tha ## Key Features - Manage your hornet-storage relay config directly from the panel -- Switch between our new dumb and smart model for accepting nostr notes +- Switch between our new whitelist and blacklist model for accepting nostr notes - Decide from which of the supported nostr kinds to enable - Choose which supported transport protocols to enable such as libp2p and websockets - Enable / disable which media extensions are accepted by the relay such as png and mp4 diff --git a/package.json b/package.json index f133859..77bf562 100644 --- a/package.json +++ b/package.json @@ -83,14 +83,15 @@ "workbox-streams": "^5.1.3" }, "scripts": { - "start": "yarn buildThemes && craco start", - "build": "yarn buildThemes && craco build", - "test": "craco test", - "eject": "craco eject", + "start": "NODE_OPTIONS=--openssl-legacy-provider yarn buildThemes && NODE_OPTIONS=--openssl-legacy-provider craco start", + "build": "NODE_OPTIONS=--openssl-legacy-provider yarn buildThemes && NODE_OPTIONS=--openssl-legacy-provider craco build", + "test": "NODE_OPTIONS=--openssl-legacy-provider craco test", + "eject": "NODE_OPTIONS=--openssl-legacy-provider craco eject", "lint": "eslint \"*/**/*.{js,ts,tsx}\" --fix", "lint:styles": "stylelint '*/**/*.{js,ts,tsx}'", "prepare": "husky install", - "buildThemes": "lessc --js --clean-css=\"--s1 --advanced\" src/styles/themes/main.less public/themes/main.css" + "update-browserslist": "npx update-browserslist-db@latest", + "buildThemes": "NODE_OPTIONS=--openssl-legacy-provider lessc --js --clean-css=\"--s1 --advanced\" src/styles/themes/main.less public/themes/main.css" }, "browserslist": [ ">0.2%", diff --git a/src/@types/event-target.d.ts b/src/@types/event-target.d.ts new file mode 100644 index 0000000..0790836 --- /dev/null +++ b/src/@types/event-target.d.ts @@ -0,0 +1,3 @@ +interface EventTarget { + state?: 'activated'; +} diff --git a/src/api/moderationNotifications.api.ts b/src/api/moderationNotifications.api.ts deleted file mode 100644 index e735e73..0000000 --- a/src/api/moderationNotifications.api.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { httpApi } from './http.api'; - -export interface ModerationNotificationParams { - page?: number; - limit?: number; - filter?: 'all' | 'unread' | 'user'; - pubkey?: string; -} - -export interface ModerationNotification { - id: number; - pubkey: string; - event_id: string; - reason: string; - created_at: string; - is_read: boolean; - content_type: string; - media_url?: string; - thumbnail_url?: string; -} - -export interface PaginationData { - currentPage: number; - pageSize: number; - totalItems: number; - totalPages: number; - hasNext: boolean; - hasPrevious: boolean; -} - -export interface ModerationNotificationsResponse { - notifications: ModerationNotification[]; - pagination: PaginationData; -} - -export interface ModerationStats { - total_blocked: number; - total_blocked_today: number; - by_content_type: Array<{ type: string; count: number }>; - by_user: Array<{ pubkey: string; count: number }>; - recent_reasons: string[]; -} - -// Fetch moderation notifications with filtering and pagination -export const getModerationNotifications = async ( - params: ModerationNotificationParams = {}, -): Promise => { - const response = await httpApi.get('/api/moderation/notifications', { params }); - return response.data; -}; - -// Mark a specific notification as read -export const markNotificationAsRead = async (id: number): Promise => { - await httpApi.post('/api/moderation/notifications/read', { id: [id] }); -}; - -// Mark all notifications as read -export const markAllNotificationsAsRead = async (): Promise => { - await httpApi.post('/api/moderation/notifications/read-all'); -}; - -// Get moderation statistics -export const getModerationStats = async (): Promise => { - const response = await httpApi.get('/api/moderation/stats'); - return response.data; -}; diff --git a/src/api/notifications.api.ts b/src/api/notifications.api.ts index 05098a5..19db7f7 100644 --- a/src/api/notifications.api.ts +++ b/src/api/notifications.api.ts @@ -1,17 +1,6 @@ export interface Message { id: number; description: string; - moderationData?: { - id: number; - pubkey: string; - event_id: string; - reason: string; - created_at: string; - is_read: boolean; - content_type: string; - media_url?: string; - thumbnail_url?: string; - }; } export interface Mention extends Message { @@ -23,5 +12,5 @@ export interface Mention extends Message { export type Notification = Mention | Message; -// Export an empty array now that we're using real moderation notifications +// Export an empty array for notifications export const notifications: Notification[] = []; diff --git a/src/components/common/BaseModal/BaseModal.tsx b/src/components/common/BaseModal/BaseModal.tsx index 92130cb..b08e8dc 100644 --- a/src/components/common/BaseModal/BaseModal.tsx +++ b/src/components/common/BaseModal/BaseModal.tsx @@ -2,8 +2,11 @@ import React from 'react'; import { Modal, ModalProps } from 'antd'; import { modalSizes } from 'constants/modalSizes'; -interface BaseModalProps extends ModalProps { +// Extend ModalProps to include our custom props +interface BaseModalProps extends Omit { size?: 'small' | 'medium' | 'large'; + visible?: boolean; // Deprecated prop + open?: boolean; // New prop that will replace visible } interface BaseModalInterface extends React.FC { @@ -13,11 +16,33 @@ interface BaseModalInterface extends React.FC { error: typeof Modal.error; } -export const BaseModal: BaseModalInterface = ({ size = 'medium', children, ...props }) => { +export const BaseModal: BaseModalInterface = ({ + size = 'medium', + visible, + open, + children, + ...props +}) => { const modalSize = Object.entries(modalSizes).find((sz) => sz[0] === size)?.[1]; + + // If open is provided, use it. Otherwise, fall back to visible. + // This ensures backward compatibility while supporting the new prop. + const isOpen = open !== undefined ? open : visible; + + // Show deprecation warning in development mode + if (process.env.NODE_ENV === 'development' && visible !== undefined && open === undefined) { + console.warn( + '[antd: Modal] `visible` will be removed in next major version, please use `open` instead.' + ); + } return ( - + {children} ); diff --git a/src/components/common/BaseNotification/BaseNotification.tsx b/src/components/common/BaseNotification/BaseNotification.tsx index c98b609..c84db42 100644 --- a/src/components/common/BaseNotification/BaseNotification.tsx +++ b/src/components/common/BaseNotification/BaseNotification.tsx @@ -8,8 +8,8 @@ interface Icons { success: React.ReactNode; warning: React.ReactNode; error: React.ReactNode; - moderation: React.ReactNode; mention: React.ReactNode; + moderation: React.ReactNode; } export type NotificationType = 'info' | 'mention' | 'success' | 'warning' | 'error' | 'moderation'; @@ -27,8 +27,8 @@ export const BaseNotification: React.FC = ({ type, mentio success: , warning: , error: , - moderation: , mention: mentionIconSrc, + moderation: , // Using the same icon as error for moderation }; const icon = icons[type] || icons.warning; diff --git a/src/components/header/components/notificationsDropdown/ModerationNotificationsOverlay/ModerationNotificationsOverlay.tsx b/src/components/header/components/notificationsDropdown/ModerationNotificationsOverlay/ModerationNotificationsOverlay.tsx deleted file mode 100644 index 5a7aa4f..0000000 --- a/src/components/header/components/notificationsDropdown/ModerationNotificationsOverlay/ModerationNotificationsOverlay.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import React, { useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Link } from 'react-router-dom'; -import { BaseSpace } from '@app/components/common/BaseSpace/BaseSpace'; -import { BaseRow } from '@app/components/common/BaseRow/BaseRow'; -import { BaseCol } from '@app/components/common/BaseCol/BaseCol'; -import { BaseButton } from '@app/components/common/BaseButton/BaseButton'; -import { BaseNotification } from '@app/components/common/BaseNotification/BaseNotification'; -import { useModerationNotifications } from '@app/hooks/useModerationNotifications'; -import { notificationController } from '@app/controllers/notificationController'; -import * as S from '../NotificationsOverlay/NotificationsOverlay.styles'; -import { ContentTypeTag } from '@app/components/moderation/ModerationNotifications/ModerationNotifications.styles'; - -export const ModerationNotificationsOverlay: React.FC = () => { - const { t } = useTranslation(); - const { notifications, markAllAsRead, markAsRead, isLoading } = useModerationNotifications(); - - const handleMarkAsRead = useCallback((id: number) => { - markAsRead(id); - }, [markAsRead]); - - const formatDate = (dateString: string): string => { - const date = new Date(dateString); - return date.toLocaleString(); - }; - - return ( - - - {isLoading ? ( -
-
- - {t('moderation.notifications.loading', 'Loading notifications...')} - -
- ) : notifications.length > 0 ? ( - }> - {notifications.slice(0, 5).map((notification) => ( - - - {notification.content_type} - - {notification.reason} - - } - description={ -
-
- {formatDate(notification.created_at)} -
-
- User: - {notification.pubkey.substring(0, 10)}... - { - e.stopPropagation(); - navigator.clipboard.writeText(notification.pubkey); - notificationController.success({ - message: 'Pubkey copied to clipboard' - }); - }} - style={{ padding: '0 4px', height: 'auto' }} - > - 📋 - -
- {notification.thumbnail_url && ( -
- Content thumbnail -
- )} - {!notification.is_read && ( - handleMarkAsRead(notification.id)}> - {t('moderation.notifications.markAsRead', 'Mark as read')} - - )} -
- } - /> - ))} -
- ) : ( -
-
🔍
- - {t('moderation.notifications.noNotifications', 'No moderation notifications')} - - - {t('moderation.notifications.emptyDescription', 'Moderation alerts will appear here when content is flagged')} - -
- )} - - - {notifications.some(n => !n.is_read) && ( - - markAllAsRead()}> - {t('moderation.notifications.readAll', 'Mark all as read')} - - - )} - - - {t('moderation.notifications.viewAll', 'View all')} - - - -
-
- ); -}; diff --git a/src/components/header/components/notificationsDropdown/NotificationsDropdown.tsx b/src/components/header/components/notificationsDropdown/NotificationsDropdown.tsx index ce44e8c..2b38fbe 100644 --- a/src/components/header/components/notificationsDropdown/NotificationsDropdown.tsx +++ b/src/components/header/components/notificationsDropdown/NotificationsDropdown.tsx @@ -1,12 +1,9 @@ -import React, { useState, useEffect, useCallback, useRef } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { BellOutlined } from '@ant-design/icons'; import { BaseButton } from '@app/components/common/BaseButton/BaseButton'; import { BaseBadge } from '@app/components/common/BaseBadge/BaseBadge'; -import { NotificationsOverlay } from '@app/components/header/components/notificationsDropdown/NotificationsOverlay/NotificationsOverlay'; import { PaymentNotificationsOverlay } from '@app/components/header/components/notificationsDropdown/PaymentNotificationsOverlay'; import ReportNotificationsOverlay from '@app/components/header/components/notificationsDropdown/ReportNotificationsOverlay'; -import { notifications as fetchedNotifications, Notification, Message } from '@app/api/notifications.api'; -import { useModerationNotifications } from '@app/hooks/useModerationNotifications'; import { usePaymentNotifications } from '@app/hooks/usePaymentNotifications'; import { useReportNotifications } from '@app/hooks/useReportNotifications'; import { HeaderActionWrapper } from '@app/components/header/Header.styles'; @@ -14,34 +11,10 @@ import { BasePopover } from '@app/components/common/BasePopover/BasePopover'; import { useTranslation } from 'react-i18next'; import { Tabs } from 'antd'; -// Create a map to transform moderation notifications into regular notifications -const transformModerationToRegular = (modNotification: any): Message => ({ - id: 3, // moderation type (using same ID as error but will use moderation display type) - description: `moderation.notification.${modNotification.content_type}`, - moderationData: { - id: modNotification.id, - pubkey: modNotification.pubkey, - event_id: modNotification.event_id, - reason: modNotification.reason, - created_at: modNotification.created_at, - is_read: modNotification.is_read, - content_type: modNotification.content_type, - media_url: modNotification.media_url, - thumbnail_url: modNotification.thumbnail_url - } -}); - export const NotificationsDropdown: React.FC = () => { const { t } = useTranslation(); - const [allNotifications, setAllNotifications] = useState(fetchedNotifications); const [isOpened, setOpened] = useState(false); - const { - notifications: allModerationNotifications, - markAsRead: markModerationAsRead, - fetchNotifications: refreshModerationNotifications - } = useModerationNotifications(); - const { notifications: allPaymentNotifications, markAsRead: markPaymentAsRead, @@ -56,80 +29,33 @@ export const NotificationsDropdown: React.FC = () => { fetchNotifications: refreshReportNotifications } = useReportNotifications(); - // Filter to only show unread moderation notifications in the dropdown - const moderationNotifications = allModerationNotifications.filter(notification => !notification.is_read); - // Filter to only show unread notifications in the dropdown const paymentNotifications = allPaymentNotifications.filter(notification => !notification.is_read); // Filter to only show unread report notifications in the dropdown const reportNotifications = allReportNotifications.filter(notification => !notification.is_read); - // Use ref to track if we've processed these notifications before - const processedModerationIdsRef = useRef>(new Set()); - - // When moderation notifications change, update the combined notifications list - useEffect(() => { - // Check if we have new notifications - const currentIds = new Set(allModerationNotifications.map(n => n.id)); - const hasChanges = allModerationNotifications.some(n => !processedModerationIdsRef.current.has(n.id)) || - processedModerationIdsRef.current.size !== currentIds.size; - - // Only update if there are changes to avoid unnecessary rerenders - if (hasChanges) { - // Only transform and show unread notifications - const transformedModNotifications = allModerationNotifications - .filter(notification => !notification.is_read) - .map(transformModerationToRegular); - - setAllNotifications([ - ...fetchedNotifications, - ...transformedModNotifications - ]); - - // Update processed IDs - processedModerationIdsRef.current = currentIds; - } - }, [allModerationNotifications]); - // Initialize all notification types useEffect(() => { - refreshModerationNotifications({ filter: 'unread' }); refreshPaymentNotifications({ filter: 'unread' }); refreshReportNotifications({ filter: 'unread' }); - }, [refreshModerationNotifications, refreshPaymentNotifications, refreshReportNotifications]); + }, [refreshPaymentNotifications, refreshReportNotifications]); // Refresh all notifications, only showing unread ones const handleRefresh = useCallback(() => { - refreshModerationNotifications({ filter: 'unread' }); refreshPaymentNotifications({ filter: 'unread' }); refreshReportNotifications({ filter: 'unread' }); - }, [refreshModerationNotifications, refreshPaymentNotifications, refreshReportNotifications]); - - // Handle clearing all notifications, including moderation ones - const handleClearAll = useCallback(() => { - // For regular notifications - setAllNotifications([]); - - // Mark all moderation notifications as read - moderationNotifications.forEach(notification => { - if (!notification.is_read) { - markModerationAsRead(notification.id); - } - }); - }, [moderationNotifications, markModerationAsRead]); + }, [refreshPaymentNotifications, refreshReportNotifications]); // Check specifically for unread notifications - const hasUnreadModerationNotifications = moderationNotifications.some(notification => !notification.is_read); const hasUnreadPaymentNotifications = paymentNotifications.some(notification => !notification.is_read); const hasUnreadReportNotifications = reportNotifications.some(notification => !notification.is_read); - const hasUnreadNotifications = hasUnreadModerationNotifications || hasUnreadPaymentNotifications || hasUnreadReportNotifications; + const hasUnreadNotifications = hasUnreadPaymentNotifications || hasUnreadReportNotifications; // Count unread notifications - const unreadModerationCount = moderationNotifications.filter(notification => !notification.is_read).length; const unreadPaymentCount = paymentNotifications.filter(notification => !notification.is_read).length; const unreadReportCount = reportNotifications.filter(notification => !notification.is_read).length; - const totalUnreadCount = unreadModerationCount + unreadPaymentCount + unreadReportCount; + const totalUnreadCount = unreadPaymentCount + unreadReportCount; // Handle clearing all payment notifications const handleClearAllPayments = useCallback(() => { @@ -142,7 +68,6 @@ export const NotificationsDropdown: React.FC = () => { }, [markAllReportsAsRead]); // Get translated tab names - const moderationLabel = t('moderation.notifications.title', 'Moderation'); const paymentsLabel = t('payment.notifications.title', 'Payments'); const reportsLabel = t('report.notifications.title', 'Reports'); @@ -158,34 +83,6 @@ export const NotificationsDropdown: React.FC = () => { const tabItems = [ { key: '1', - label: ( - - {moderationLabel} - {unreadModerationCount > 0 && ( - - )} - - ), - children: ( -
- { - // This function wrapper allows us to ignore the parameter and call handleClearAll instead - handleClearAll(); - return Promise.resolve(); - }} - markModerationAsRead={markModerationAsRead} - onRefresh={() => { - refreshModerationNotifications({ filter: 'unread' }); - return Promise.resolve(); - }} - /> -
- ), - }, - { - key: '2', label: ( {paymentsLabel} @@ -197,19 +94,19 @@ export const NotificationsDropdown: React.FC = () => { children: (
{ - refreshPaymentNotifications({ filter: 'unread' }); - return Promise.resolve(); - }} - /> + notifications={paymentNotifications} + markAsRead={markPaymentAsRead} + markAllAsRead={handleClearAllPayments} + onRefresh={() => { + refreshPaymentNotifications({ filter: 'unread' }); + return Promise.resolve(); + }} + />
), }, { - key: '3', + key: '2', label: ( {reportsLabel} @@ -221,14 +118,14 @@ export const NotificationsDropdown: React.FC = () => { children: (
{ - refreshReportNotifications({ filter: 'unread' }); - return Promise.resolve(); - }} - /> + notifications={reportNotifications} + markAsRead={markReportAsRead} + markAllAsRead={handleClearAllReports} + onRefresh={() => { + refreshReportNotifications({ filter: 'unread' }); + return Promise.resolve(); + }} + />
), }, diff --git a/src/components/header/components/notificationsDropdown/NotificationsOverlay/NotificationsOverlay.tsx b/src/components/header/components/notificationsDropdown/NotificationsOverlay/NotificationsOverlay.tsx index 3cce630..d7f84f3 100644 --- a/src/components/header/components/notificationsDropdown/NotificationsOverlay/NotificationsOverlay.tsx +++ b/src/components/header/components/notificationsDropdown/NotificationsOverlay/NotificationsOverlay.tsx @@ -3,7 +3,6 @@ import { Trans } from 'react-i18next'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { BaseNotification } from '@app/components/common/BaseNotification/BaseNotification'; -import { BaseButton } from '@app/components/common/BaseButton/BaseButton'; import { capitalize } from '@app/utils/utils'; import { Mention, Notification as NotificationType } from 'api/notifications.api'; import { notificationsSeverities } from 'constants/notificationsSeverities'; @@ -15,14 +14,12 @@ import { BaseSpace } from '@app/components/common/BaseSpace/BaseSpace'; interface NotificationsOverlayProps { notifications: NotificationType[]; setNotifications: (state: NotificationType[]) => void; - markModerationAsRead?: (id: number) => Promise; onRefresh?: () => void; } export const NotificationsOverlay: React.FC = ({ notifications, setNotifications, - markModerationAsRead, onRefresh, ...props }) => { @@ -32,58 +29,6 @@ export const NotificationsOverlay: React.FC = ({ () => notifications.map((notification, index) => { const type = notificationsSeverities.find((dbSeverity) => dbSeverity.id === notification.id)?.name; - const isModerationNotification = !!notification.moderationData; - - // Handle moderation notification specially - if (isModerationNotification) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const modData = notification.moderationData!; - return ( - - {capitalize('moderation')} - - {modData.content_type} - - - } - description={ -
-
{modData.reason}
-
- {new Date(modData.created_at).toLocaleString()} -
- {/* Thumbnail removed - content will be viewable in detail view */} - {!modData.is_read && markModerationAsRead && ( - markModerationAsRead(modData.id)} - style={{ padding: '4px 0', height: 'auto', marginTop: '4px' }} - > - {t('moderation.notifications.markAsRead', 'Mark as read')} - - )} -
- - {t('moderation.notifications.viewDetails', 'View details')} - -
-
- } - /> - ); - } // Regular notification return ( @@ -108,7 +53,7 @@ export const NotificationsOverlay: React.FC = ({ /> ); }), - [notifications, t, markModerationAsRead], + [notifications, t], ); return ( @@ -147,11 +92,6 @@ export const NotificationsOverlay: React.FC = ({ {t('header.notifications.refresh', 'Refresh')} - - - {t('header.notifications.viewAll', 'View all')} - - diff --git a/src/components/header/components/settingsDropdown/settingsOverlay/ServerPicker/SmartBaseModeSettings.tsx b/src/components/header/components/settingsDropdown/settingsOverlay/ServerPicker/SmartBaseModeSettings.tsx index 3d6bb66..5766af8 100644 --- a/src/components/header/components/settingsDropdown/settingsOverlay/ServerPicker/SmartBaseModeSettings.tsx +++ b/src/components/header/components/settingsDropdown/settingsOverlay/ServerPicker/SmartBaseModeSettings.tsx @@ -21,7 +21,7 @@ export const SmartBaseModeSettings: React.FC = () => { const kinds = useAppSelector((state) => state.mode.kinds); const handleModeChange = () => { - dispatch(setMode(mode === 'smart' ? 'unlimited' : 'smart')); + dispatch(setMode(mode === 'whitelist' ? 'blacklist' : 'whitelist')); }; // Example kinds options, adjust as necessary @@ -38,13 +38,13 @@ export const SmartBaseModeSettings: React.FC = () => { {t('common.serverSetting')} -

{mode === 'smart' ? t('common.supportedKindsAndMedia') : t('common.unsupportedKindsAndMedia')}

+

{mode === 'whitelist' ? t('common.supportedKindsAndMedia') : t('common.unsupportedKindsAndMedia')}

<> { url: '/relay-settings', icon: , }, + { + title: 'Advanced Settings', + key: 'advanced-settings', + url: '/settings', + icon: , + }, { title: 'common.access-control', key: 'blocked-pubkeys', diff --git a/src/components/moderation/ModerationNotifications/ModerationNotifications.styles.ts b/src/components/moderation/ModerationNotifications/ModerationNotifications.styles.ts deleted file mode 100644 index b44803f..0000000 --- a/src/components/moderation/ModerationNotifications/ModerationNotifications.styles.ts +++ /dev/null @@ -1,198 +0,0 @@ -import styled, { css } from 'styled-components'; -import { Input, Button, Divider } from 'antd'; -import { FONT_SIZE, BORDER_RADIUS, FONT_WEIGHT } from '@app/styles/themes/constants'; - -export const FiltersWrapper = styled.div` - margin-bottom: 1.5rem; -`; - -export const SplitDivider = styled(Divider)` - margin: 0.5rem 0; -`; - -export const NotificationItem = styled.div<{ $isRead: boolean }>` - padding: 0.75rem; - border-radius: ${BORDER_RADIUS}; - transition: background-color 0.3s ease; - - ${({ $isRead }) => - !$isRead && - css` - background-color: var(--background-color); - border-left: 4px solid var(--error-color); - `} - - &:hover { - background-color: var(--secondary-background-color); - } -`; - -export const NotificationContent = styled.div` - display: flex; - flex-direction: column; - gap: 0.5rem; -`; - -export const NotificationText = styled.div` - font-size: ${FONT_SIZE.md}; - color: var(--text-main-color); -`; - -export const NotificationMeta = styled.div` - display: flex; - flex-wrap: wrap; - gap: 1rem; - font-size: ${FONT_SIZE.xs}; - color: var(--text-light-color); - margin-bottom: 0.75rem; -`; - -export const MetaItem = styled.span` - display: inline-flex; - align-items: center; - gap: 0.25rem; -`; - -export const MetaLabel = styled.span` - font-weight: ${FONT_WEIGHT.semibold}; - margin-right: 0.5rem; -`; - -export const MetaValue = styled.span` - display: inline-flex; - align-items: center; -`; - -export const CopyButton = styled(Button)` - margin-left: 0.5rem; - display: inline-flex; - align-items: center; - justify-content: center; - font-size: ${FONT_SIZE.xs}; - padding: 2px 6px; - height: 20px; - border-radius: ${BORDER_RADIUS}; - background-color: var(--background-color); - - &:hover { - background-color: var(--secondary-background-color); - } -`; - -export const ContentContainer = styled.div` - margin-top: 0.75rem; - max-width: 100%; - border-radius: ${BORDER_RADIUS}; - display: flex; - flex-direction: column; - align-items: flex-start; - position: relative; -`; - -export const ModerationBanner = styled.div` - padding: 4px 8px; - background-color: rgba(var(--error-rgb-color), 0.1); - color: var(--error-color); - font-size: ${FONT_SIZE.xs}; - font-weight: ${FONT_WEIGHT.medium}; - border-radius: 4px; - margin-bottom: 0.5rem; - display: flex; - align-items: center; - gap: 6px; -`; - -export const MediaWrapper = styled.div` - position: relative; - width: 100%; - max-width: 300px; - border-radius: ${BORDER_RADIUS}; - overflow: hidden; - border: 1px solid var(--border-color); -`; - -export const StyledImage = styled.img` - max-width: 100%; - max-height: 200px; - object-fit: contain; - display: block; -`; - -export const StyledVideo = styled.video` - max-width: 100%; - max-height: 200px; - display: block; -`; - -export const StyledAudio = styled.audio` - width: 100%; - margin-top: 8px; -`; - -export const MediaError = styled.div` - padding: 1rem; - background-color: var(--secondary-background-color); - color: var(--text-light-color); - font-size: ${FONT_SIZE.xs}; - border-radius: ${BORDER_RADIUS}; - text-align: center; -`; - -export const MarkReadButton = styled(Button)` - align-self: flex-start; - margin-top: 0.5rem; -`; - -export const UserInput = styled(Input)` - width: 100%; -`; - -export const FooterWrapper = styled.div` - margin-top: 1.5rem; - padding-top: 1rem; - border-top: 1px solid var(--border-color); -`; - -export const Text = styled.span` - font-size: ${FONT_SIZE.md}; - font-weight: ${FONT_WEIGHT.regular}; - color: var(--text-main-color); -`; - -export const RedDot = styled.span` - display: inline-block; - width: 8px; - height: 8px; - border-radius: 50%; - background-color: var(--error-color); - margin-left: 5px; -`; - -export const ContentTypeTag = styled.span<{ $type: string }>` - padding: 2px 8px; - border-radius: 12px; - font-size: ${FONT_SIZE.xs}; - font-weight: ${FONT_WEIGHT.semibold}; - text-transform: uppercase; - margin-right: 8px; - - ${({ $type }) => { - switch ($type) { - case 'image': - return css` - background-color: rgba(var(--success-rgb-color), 0.1); - color: var(--success-color); - `; - case 'video': - return css` - background-color: rgba(var(--warning-rgb-color), 0.1); - color: var(--warning-color); - `; - default: - return css` - background-color: rgba(var(--primary-rgb-color), 0.1); - color: var(--primary-color); - `; - } - }} -`; diff --git a/src/components/moderation/ModerationNotifications/ModerationNotifications.tsx b/src/components/moderation/ModerationNotifications/ModerationNotifications.tsx deleted file mode 100644 index 5c74dbf..0000000 --- a/src/components/moderation/ModerationNotifications/ModerationNotifications.tsx +++ /dev/null @@ -1,315 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { useTranslation } from 'react-i18next'; -import { BaseCard } from '@app/components/common/BaseCard/BaseCard'; -import { BaseButton } from '@app/components/common/BaseButton/BaseButton'; -import { BaseSelect } from '@app/components/common/selects/BaseSelect/BaseSelect'; -import { BaseSpace } from '@app/components/common/BaseSpace/BaseSpace'; -import { BaseRow } from '@app/components/common/BaseRow/BaseRow'; -import { BaseCol } from '@app/components/common/BaseCol/BaseCol'; -import { BasePagination } from '@app/components/common/BasePagination/BasePagination'; -import { BaseNotification } from '@app/components/common/BaseNotification/BaseNotification'; -import { BaseInput } from '@app/components/common/inputs/BaseInput/BaseInput'; -import { EyeOutlined } from '@ant-design/icons'; -import { useModerationNotifications } from '@app/hooks/useModerationNotifications'; -import { ModerationNotification, ModerationNotificationParams } from '@app/hooks/useModerationNotifications'; -import { notificationController } from '@app/controllers/notificationController'; -import * as S from './ModerationNotifications.styles'; - -interface ModerationNotificationsProps { - className?: string; -} - -export const ModerationNotifications: React.FC = ({ className }) => { - const { t } = useTranslation(); - const [filter, setFilter] = useState<'all' | 'unread' | 'user'>('unread'); - const [userPubkey, setUserPubkey] = useState(''); - - const { - notifications, - pagination, - isLoading, - fetchNotifications, - markAsRead, - markAllAsRead - } = useModerationNotifications(); - - // Fetch unread notifications on component mount - useEffect(() => { - fetchNotifications({ - page: 1, - limit: pagination?.pageSize || 10, - filter: 'unread' - }); - }, [fetchNotifications, pagination?.pageSize]); - - const handleFilterChange = (value: unknown) => { - const filterValue = value as 'all' | 'unread' | 'user'; - setFilter(filterValue); - - // Only fetch immediately for "all" and "unread" filters - // For "user" filter, wait for the user to click the Filter button - if (filterValue !== 'user') { - fetchNotifications({ - page: 1, - limit: pagination?.pageSize || 10, - filter: filterValue - }); - } - }; - - const handlePageChange = (page: number) => { - const params: ModerationNotificationParams = { - page, - limit: pagination?.pageSize || 10, - filter - }; - - if (filter === 'user' && userPubkey) { - params.pubkey = userPubkey; - } - - fetchNotifications(params); - }; - - const handleUserPubkeyChange = (e: React.ChangeEvent) => { - setUserPubkey(e.target.value); - }; - - const handleUserPubkeyFilter = () => { - if (userPubkey && filter === 'user') { - fetchNotifications({ - page: 1, - limit: pagination?.pageSize || 10, - filter: 'user', - pubkey: userPubkey - }); - } - }; - - const formatDate = (dateString: string): string => { - const date = new Date(dateString); - return date.toLocaleString(); - }; - - return ( - - - - - - - - {filter === 'user' && ( - <> - - - - - - {t('moderation.notifications.filter', 'Filter')} - - - - )} - - - - {isLoading ? ( -
-
- - {t('common.loading', 'Loading...')} - -
- ) : notifications.length > 0 ? ( - <> - }> - {notifications.map((notification) => ( - - - - {notification.content_type} - - {notification.reason} - - } - description={ - - - - Date: - {formatDate(notification.created_at)} - - - - User: - - {notification.pubkey.substring(0, 10)}... - { - navigator.clipboard.writeText(notification.pubkey); - notificationController.success({ - message: 'User pubkey copied to clipboard' - }); - }} - > - Copy Pubkey - - - - - - Event ID: - - {notification.event_id.substring(0, 10)}... - { - navigator.clipboard.writeText(notification.event_id); - notificationController.success({ - message: 'Event ID copied to clipboard' - }); - }} - > - Copy Event ID - - - - - - {notification.thumbnail_url || notification.media_url ? ( - - - - {t('moderation.notifications.sensitiveContent', 'Sensitive content')} - - - {(() => { - // Get the media URL - const mediaUrl = notification.media_url || notification.thumbnail_url; - - if (!mediaUrl) { - return ( - - {t('moderation.notifications.noMedia', 'No media available')} - - ); - } - - // Format the full URL - const fullMediaUrl = mediaUrl.startsWith('http') - ? mediaUrl - : `${window.location.origin}${mediaUrl.startsWith('/') ? '' : '/'}${mediaUrl}`; - - // Mark notification as read when media is loaded - const handleMediaLoad = () => { - if (!notification.is_read) { - markAsRead(notification.id); - } - }; - - const handleMediaError = () => { - console.error(`Failed to load media: ${fullMediaUrl}`); - }; - - // Render the appropriate media element based on content type - return ( - - {notification.content_type.includes('image') ? ( - - ) : notification.content_type.includes('video') ? ( - - ) : notification.content_type.includes('audio') ? ( - - ) : ( - - {t('moderation.notifications.unsupportedType', 'Unsupported content type')}: {notification.content_type} - - )} - - ); - })()} - - ) : null} - - {!notification.is_read && ( - markAsRead(notification.id)} - size="small" - > - {t('moderation.notifications.markAsRead', 'Mark as read')} - - )} - - } - /> - - ))} - - - - - - {notifications.some(n => !n.is_read) && ( - - {t('moderation.notifications.readAll', 'Mark all as read')} - - )} - - - {pagination && ( - - )} - - - - - ) : ( -
-
🔍
- - {t('moderation.notifications.noNotifications', 'No moderation notifications')} - - - {t('moderation.notifications.emptyDescription', 'Moderation alerts will appear here when content is flagged by the system')} - -
- )} -
- ); -}; diff --git a/src/components/relay-settings/layouts/DesktopLayout.tsx b/src/components/relay-settings/layouts/DesktopLayout.tsx index 3f2bc8b..c192c42 100644 --- a/src/components/relay-settings/layouts/DesktopLayout.tsx +++ b/src/components/relay-settings/layouts/DesktopLayout.tsx @@ -13,6 +13,7 @@ import { AppBucketsSection } from '@app/components/relay-settings/sections/AppBu import { SubscriptionSection } from '@app/components/relay-settings/sections/SubscriptionSection'; import { KindsSection } from '@app/components/relay-settings/sections/KindsSection'; import { MediaSection } from '@app/components/relay-settings/sections/MediaSection'; +import { ModerationSection } from '@app/components/relay-settings/sections/ModerationSection'; import { useTranslation } from 'react-i18next'; import { SubscriptionTier } from '@app/constants/relaySettings'; @@ -68,6 +69,9 @@ interface DesktopLayoutProps { onChange: (values: string[]) => void; onToggle: (checked: boolean) => void; }; + // Moderation section props + moderationMode: string; + onModerationModeChange: (mode: string) => void; } export const DesktopLayout: React.FC = ({ @@ -93,7 +97,6 @@ export const DesktopLayout: React.FC = ({ freeTierEnabled, freeTierLimit, onFreeTierChange, - // Kinds props isKindsActive, selectedKinds, @@ -108,6 +111,9 @@ export const DesktopLayout: React.FC = ({ photos, videos, audio, + // Moderation props + moderationMode, + onModerationModeChange, }) => { const { t } = useTranslation(); @@ -143,6 +149,11 @@ export const DesktopLayout: React.FC = ({ freeTierLimit={freeTierLimit} onFreeTierChange={onFreeTierChange} /> + + @@ -160,9 +171,9 @@ export const DesktopLayout: React.FC = ({ {t('common.serverSetting')} @@ -215,4 +226,4 @@ export const DesktopLayout: React.FC = ({ ); }; -export default DesktopLayout; \ No newline at end of file +export default DesktopLayout; diff --git a/src/components/relay-settings/layouts/MobileLayout.tsx b/src/components/relay-settings/layouts/MobileLayout.tsx index 5313fab..b4f7d0b 100644 --- a/src/components/relay-settings/layouts/MobileLayout.tsx +++ b/src/components/relay-settings/layouts/MobileLayout.tsx @@ -10,6 +10,7 @@ import { AppBucketsSection } from '@app/components/relay-settings/sections/AppBu import { SubscriptionSection } from '@app/components/relay-settings/sections/SubscriptionSection'; import { KindsSection } from '@app/components/relay-settings/sections/KindsSection'; import { MediaSection } from '@app/components/relay-settings/sections/MediaSection'; +import { ModerationSection } from '@app/components/relay-settings/sections/ModerationSection'; import { useTranslation } from 'react-i18next'; import { SubscriptionTier } from '@app/constants/relaySettings'; @@ -65,6 +66,9 @@ interface MobileLayoutProps { onChange: (values: string[]) => void; onToggle: (checked: boolean) => void; }; + // Moderation section props + moderationMode: string; + onModerationModeChange: (mode: string) => void; } export const MobileLayout: React.FC = ({ @@ -104,6 +108,9 @@ export const MobileLayout: React.FC = ({ photos, videos, audio, + // Moderation props + moderationMode, + onModerationModeChange, }) => { const { t } = useTranslation(); @@ -138,6 +145,11 @@ export const MobileLayout: React.FC = ({ onFreeTierChange={onFreeTierChange} /> + + = ({ }} > {t('common.serverSetting')} - + = ({ ); }; -export default MobileLayout; \ No newline at end of file +export default MobileLayout; diff --git a/src/components/relay-settings/sections/KindsSection/KindsSection.tsx b/src/components/relay-settings/sections/KindsSection/KindsSection.tsx index 2b6fda6..7c7bfa0 100644 --- a/src/components/relay-settings/sections/KindsSection/KindsSection.tsx +++ b/src/components/relay-settings/sections/KindsSection/KindsSection.tsx @@ -33,13 +33,13 @@ export const KindsSection: React.FC = ({ onAddKind, onRemoveKind, }) => { - const header = mode !== 'smart' ? 'Blacklisted Kind Numbers' : 'Kind Numbers'; + const header = mode !== 'whitelist' ? 'Blacklisted Kind Numbers' : 'Kind Numbers'; return (
- {mode !== 'unlimited' && mode !== '' && ( + {mode !== 'blacklist' && mode !== '' && (
= ({ ); }; -export default KindsSection; \ No newline at end of file +export default KindsSection; diff --git a/src/components/relay-settings/sections/KindsSection/components/AddKindForm.tsx b/src/components/relay-settings/sections/KindsSection/components/AddKindForm.tsx index ee4880d..6a0b332 100644 --- a/src/components/relay-settings/sections/KindsSection/components/AddKindForm.tsx +++ b/src/components/relay-settings/sections/KindsSection/components/AddKindForm.tsx @@ -19,7 +19,7 @@ export const AddKindForm: React.FC = ({ onAddKind, mode }) => } }; - if (mode === 'smart') { + if (mode === 'whitelist') { return null; } @@ -40,4 +40,4 @@ export const AddKindForm: React.FC = ({ onAddKind, mode }) => ); }; -export default AddKindForm; \ No newline at end of file +export default AddKindForm; diff --git a/src/components/relay-settings/sections/KindsSection/components/DynamicKindsList.tsx b/src/components/relay-settings/sections/KindsSection/components/DynamicKindsList.tsx index 620d494..07c52a4 100644 --- a/src/components/relay-settings/sections/KindsSection/components/DynamicKindsList.tsx +++ b/src/components/relay-settings/sections/KindsSection/components/DynamicKindsList.tsx @@ -21,7 +21,7 @@ export const DynamicKindsList: React.FC = ({ onRemoveKind, mode, }) => { - if (!dynamicKinds.length || mode === 'smart') { + if (!dynamicKinds.length || mode === 'whitelist') { return null; } @@ -32,7 +32,7 @@ export const DynamicKindsList: React.FC = ({ return ( @@ -43,7 +43,7 @@ export const DynamicKindsList: React.FC = ({ >
= ({ ); }; -export default DynamicKindsList; \ No newline at end of file +export default DynamicKindsList; diff --git a/src/components/relay-settings/sections/KindsSection/components/KindsList.tsx b/src/components/relay-settings/sections/KindsSection/components/KindsList.tsx index aacdc41..ae7951c 100644 --- a/src/components/relay-settings/sections/KindsSection/components/KindsList.tsx +++ b/src/components/relay-settings/sections/KindsSection/components/KindsList.tsx @@ -34,7 +34,7 @@ export const KindsList: React.FC = ({ className="large-label" value={selectedKinds} onChange={(checkedValues) => onKindsChange(checkedValues as string[])} - disabled={mode !== 'smart' ? false : !isKindsActive} + disabled={mode !== 'whitelist' ? false : !isKindsActive} > {groupedNoteOptions.map((group) => (
@@ -44,11 +44,11 @@ export const KindsList: React.FC = ({
= ({ ); }; -export default KindsList; \ No newline at end of file +export default KindsList; diff --git a/src/components/relay-settings/sections/MediaSection/MediaSection.tsx b/src/components/relay-settings/sections/MediaSection/MediaSection.tsx index b68f8da..0b93df2 100644 --- a/src/components/relay-settings/sections/MediaSection/MediaSection.tsx +++ b/src/components/relay-settings/sections/MediaSection/MediaSection.tsx @@ -76,7 +76,7 @@ export const MediaSection: React.FC = ({ audio, }) => { const getHeader = (type: string) => - mode !== 'smart' ? `Blacklisted ${type} Extensions` : `${type} Extensions`; + mode !== 'whitelist' ? `Blacklisted ${type} Extensions` : `${type} Extensions`; return ( <> @@ -134,4 +134,4 @@ export const MediaSection: React.FC = ({ ); }; -export default MediaSection; \ No newline at end of file +export default MediaSection; diff --git a/src/components/relay-settings/sections/MediaSection/components/MediaToggle.tsx b/src/components/relay-settings/sections/MediaSection/components/MediaToggle.tsx index 647589a..edd670d 100644 --- a/src/components/relay-settings/sections/MediaSection/components/MediaToggle.tsx +++ b/src/components/relay-settings/sections/MediaSection/components/MediaToggle.tsx @@ -14,7 +14,7 @@ export const MediaToggle: React.FC = ({ onChange, mode, }) => { - if (mode === 'unlimited') { + if (mode === 'blacklist') { return null; } @@ -30,4 +30,4 @@ export const MediaToggle: React.FC = ({ ); }; -export default MediaToggle; \ No newline at end of file +export default MediaToggle; diff --git a/src/components/relay-settings/sections/MediaSection/components/MediaTypeList.tsx b/src/components/relay-settings/sections/MediaSection/components/MediaTypeList.tsx index da6c096..c03ef5e 100644 --- a/src/components/relay-settings/sections/MediaSection/components/MediaTypeList.tsx +++ b/src/components/relay-settings/sections/MediaSection/components/MediaTypeList.tsx @@ -44,13 +44,13 @@ export const MediaTypeList: React.FC = ({ return ( onChange(checkedValues as string[])} - disabled={mode !== 'smart' ? false : !isActive} + disabled={mode !== 'whitelist' ? false : !isActive} /> ); }; -export default MediaTypeList; \ No newline at end of file +export default MediaTypeList; diff --git a/src/components/relay-settings/sections/ModerationSection/ModerationSection.tsx b/src/components/relay-settings/sections/ModerationSection/ModerationSection.tsx new file mode 100644 index 0000000..22c2013 --- /dev/null +++ b/src/components/relay-settings/sections/ModerationSection/ModerationSection.tsx @@ -0,0 +1,43 @@ +// src/components/relay-settings/sections/ModerationSection/ModerationSection.tsx + +import React from 'react'; +import { BaseCol } from '@app/components/common/BaseCol/BaseCol'; +import * as S from '@app/pages/uiComponentsPages/UIComponentsPage.styles'; +import { CollapsibleSection } from '../../shared/CollapsibleSection/CollapsibleSection'; +import { ModerationModeSelect } from './components/ModerationModeSelect'; + +export interface ModerationSectionProps { + moderationMode: string; + onModerationModeChange: (mode: string) => void; +} + +export const ModerationSection: React.FC = ({ + moderationMode, + onModerationModeChange, +}) => { + return ( + + + + + +
+

+ Moderation mode determines how events with media are handled while pending moderation. +
+
+ Strict Mode: Events with media are not queryable while pending moderation, except by their authors. +
+ Passive Mode: Events with media are queryable by everyone while pending moderation. +

+
+
+
+
+ ); +}; + +export default ModerationSection; diff --git a/src/components/relay-settings/sections/ModerationSection/components/ModerationModeSelect.tsx b/src/components/relay-settings/sections/ModerationSection/components/ModerationModeSelect.tsx new file mode 100644 index 0000000..c53eab3 --- /dev/null +++ b/src/components/relay-settings/sections/ModerationSection/components/ModerationModeSelect.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { Radio, Space, Typography } from 'antd'; +import * as S from '@app/pages/uiComponentsPages/UIComponentsPage.styles'; +import { useTranslation } from 'react-i18next'; + +const { Text } = Typography; + +interface ModerationModeSelectProps { + moderationMode: string; + onChange: (mode: string) => void; +} + +export const ModerationModeSelect: React.FC = ({ + moderationMode, + onChange, +}) => { + const { t } = useTranslation(); + + return ( +
+ + {t('Moderation Mode')} + + + onChange(e.target.value)} + style={{ marginBottom: '1rem' }} + > + + + Strict Mode +
+ + Events with media are not queryable while pending moderation, except by their authors. + +
+
+ + + Passive Mode +
+ + Events with media are queryable by everyone while pending moderation. + +
+
+
+
+
+ ); +}; + +export default ModerationModeSelect; diff --git a/src/components/relay-settings/sections/ModerationSection/index.ts b/src/components/relay-settings/sections/ModerationSection/index.ts new file mode 100644 index 0000000..de14a18 --- /dev/null +++ b/src/components/relay-settings/sections/ModerationSection/index.ts @@ -0,0 +1,4 @@ +// src/components/relay-settings/sections/ModerationSection/index.ts + +export * from './ModerationSection'; +export { default } from './ModerationSection'; diff --git a/src/components/router/AppRouter.tsx b/src/components/router/AppRouter.tsx index 60f0a33..b9e4d61 100644 --- a/src/components/router/AppRouter.tsx +++ b/src/components/router/AppRouter.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { AuthGuard } from '@app/components/AuthGuard'; import AuthLayout from '@app/components/layouts/AuthLayout/AuthLayout'; +import SettingsPage from '@app/components/settings/SettingsPage'; AuthLayout.displayName = 'AuthLayout'; import LoginPage from '@app/pages/LoginPage'; @@ -35,7 +36,6 @@ const AdvancedFormsPage = React.lazy(() => import('@app/pages/AdvancedFormsPage' const PersonalInfoPage = React.lazy(() => import('@app/pages/PersonalInfoPage')); const SecuritySettingsPage = React.lazy(() => import('@app/pages/SecuritySettingsPage')); const NotificationsPage = React.lazy(() => import('@app/pages/NotificationsPage')); -const ModerationNotificationsPage = React.lazy(() => import('@app/pages/ModerationNotificationsPage')); const PaymentNotificationsPage = React.lazy(() => import('@app/pages/PaymentNotificationsPage')); const ReportNotificationsPage = React.lazy(() => import('@app/pages/ReportNotificationsPage')); const PaymentsPage = React.lazy(() => import('@app/pages/PaymentsPage')); @@ -75,6 +75,7 @@ const Logout = React.lazy(() => import('./Logout')); export const NFT_DASHBOARD_PATH = '/'; export const MEDICAL_DASHBOARD_PATH = '/medical-dashboard'; export const RELAY_SETTINGS_PATH = '/relay-settings'; +export const SETTINGS_PATH = '/settings'; export const TABLES_PAGE_PATH = '/nostr-stats'; const MedicalDashboard = withLoading(MedicalDashboardPage); @@ -115,6 +116,7 @@ const DataTables = withLoading(DataTablesPage); const Charts = withLoading(ChartsPage); const RelayStats = withLoading(RelayStatsPage); const RelaySettings = withLoading(RelaySettingsPage); +const Settings = withLoading(SettingsPage); const BlockedPubkeys = withLoading(BlockedPubkeysPage); // Maps @@ -130,7 +132,6 @@ const Error404 = withLoading(Error404Page); const PersonalInfo = withLoading(PersonalInfoPage); const SecuritySettings = withLoading(SecuritySettingsPage); const Notifications = withLoading(NotificationsPage); -const ModerationNotifications = withLoading(ModerationNotificationsPage); const PaymentNotifications = withLoading(PaymentNotificationsPage); const ReportNotifications = withLoading(ReportNotificationsPage); const Payments = withLoading(PaymentsPage); @@ -157,6 +158,7 @@ export const AppRouter: React.FC = () => { } /> } /> } /> + } /> } /> } /> @@ -180,7 +182,6 @@ export const AppRouter: React.FC = () => { } /> } /> - } /> } /> } /> diff --git a/src/components/settings/BaseSettingsForm.tsx b/src/components/settings/BaseSettingsForm.tsx new file mode 100644 index 0000000..530e148 --- /dev/null +++ b/src/components/settings/BaseSettingsForm.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { Form, Button, Card, Space, Alert, Spin } from 'antd'; +import { SaveOutlined, ReloadOutlined } from '@ant-design/icons'; +import styled from 'styled-components'; + +const StyledCard = styled(Card)` + margin-bottom: 1.5rem; +`; + +const ButtonsContainer = styled.div` + display: flex; + justify-content: flex-end; + margin-top: 1rem; +`; + +interface BaseSettingsFormProps { + title: string; + loading: boolean; + error: Error | null; + onSave: () => Promise; + onReset: () => void; + children: React.ReactNode; +} + +const BaseSettingsForm: React.FC = ({ + title, + loading, + error, + onSave, + onReset, + children, +}) => { + const [form] = Form.useForm(); + const [saving, setSaving] = React.useState(false); + + const handleSave = async () => { + try { + await form.validateFields(); + setSaving(true); + await onSave(); + } catch (error) { + console.error('Validation failed:', error); + } finally { + setSaving(false); + } + }; + + return ( + + {error && ( + + )} + + +
+ {children} + + + + + + + +
+
+
+ ); +}; + +export default BaseSettingsForm; diff --git a/src/components/settings/BaseSettingsPanel.tsx b/src/components/settings/BaseSettingsPanel.tsx new file mode 100644 index 0000000..e29b8d2 --- /dev/null +++ b/src/components/settings/BaseSettingsPanel.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { Form, Card, Alert, Spin } from 'antd'; +import styled from 'styled-components'; + +const StyledCard = styled(Card)` + margin-bottom: 1rem; + border: none; + box-shadow: none; +`; + +interface BaseSettingsPanelProps { + title?: string; + loading: boolean; + error: Error | null; + children: React.ReactNode; + extra?: React.ReactNode; +} + +const BaseSettingsPanel: React.FC = ({ + title, + loading, + error, + children, + extra, +}) => { + return ( + + {error && ( + + )} + + + {children} + + + ); +}; + +export default BaseSettingsPanel; diff --git a/src/components/settings/ContentFilterSettings.tsx b/src/components/settings/ContentFilterSettings.tsx new file mode 100644 index 0000000..2e58cd9 --- /dev/null +++ b/src/components/settings/ContentFilterSettings.tsx @@ -0,0 +1,167 @@ +import React, { useEffect, useState } from 'react'; +import { Form, InputNumber, Switch, Select, Tooltip } from 'antd'; +import { QuestionCircleOutlined } from '@ant-design/icons'; +import useGenericSettings from '@app/hooks/useGenericSettings'; +import { SettingsGroupType } from '@app/types/settings.types'; +import BaseSettingsForm from './BaseSettingsForm'; + +const { Option } = Select; + +const ContentFilterSettings: React.FC = () => { + const { + settings, + loading, + error, + fetchSettings, + updateSettings, + saveSettings, + } = useGenericSettings('content_filter'); + + const [form] = Form.useForm(); + const [isUserEditing, setIsUserEditing] = useState(false); + + // Update form values when settings change, but only if user isn't actively editing + useEffect(() => { + if (settings && !isUserEditing) { + console.log('ContentFilterSettings - Received settings:', settings); + + // Transform property names to match form field names + // The API returns properties without the prefix, but the form expects prefixed names + const settingsObj = settings as Record; + + const formValues = { + content_filter_enabled: settingsObj.enabled, + content_filter_cache_size: typeof settingsObj.cache_size === 'string' + ? parseInt(settingsObj.cache_size) + : settingsObj.cache_size, + content_filter_cache_ttl: typeof settingsObj.cache_ttl === 'string' + ? parseInt(settingsObj.cache_ttl) + : settingsObj.cache_ttl, + full_text_kinds: settingsObj.full_text_kinds || [] + }; + + console.log('ContentFilterSettings - Transformed form values:', formValues); + + // Set form values with a slight delay to ensure the form is ready + setTimeout(() => { + form.setFieldsValue(formValues); + console.log('ContentFilterSettings - Form values after set:', form.getFieldsValue()); + }, 100); + } + }, [settings, form, isUserEditing]); + + // Handle form value changes + const handleValuesChange = (changedValues: Partial>) => { + setIsUserEditing(true); // Mark that user is currently editing + updateSettings(changedValues); + }; + + // Modified save function to reset the editing flag + const handleSave = async () => { + await saveSettings(); + setIsUserEditing(false); // Reset after saving + }; + + // Available Nostr kind options for full text filtering + const kindOptions = [ + { value: 1, label: 'Kind 1 - Text Notes' }, + { value: 30023, label: 'Kind 30023 - Long-form Content' }, + { value: 1984, label: 'Kind 1984 - Reporting' }, + { value: 9735, label: 'Kind 9735 - Zap Receipt' }, + { value: 10002, label: 'Kind 10002 - Relay List' }, + ]; + + return ( + { + fetchSettings(); + setIsUserEditing(false); + }} + > +
{ + console.log('Form submitted with values:', values); + setIsUserEditing(false); + }} + > + + + + + + Cache Size  + + + + + } + rules={[ + { required: true, message: 'Please enter a cache size' }, + { type: 'number', min: 100, message: 'Value must be at least 100' } + ]} + > + + + + + Cache TTL (seconds)  + + + + + } + rules={[ + { required: true, message: 'Please enter a cache TTL' }, + { type: 'number', min: 1, message: 'Value must be at least 1' } + ]} + > + + + + + Full Text Kinds  + + + + + } + > + + +
+
+ ); +}; + +export default ContentFilterSettings; diff --git a/src/components/settings/GeneralSettings.tsx b/src/components/settings/GeneralSettings.tsx new file mode 100644 index 0000000..5e21505 --- /dev/null +++ b/src/components/settings/GeneralSettings.tsx @@ -0,0 +1,181 @@ +import React, { useEffect } from 'react'; +import { Form, Input, Switch, Tooltip } from 'antd'; +import { QuestionCircleOutlined, LockOutlined, DatabaseOutlined, TagOutlined } from '@ant-design/icons'; +import useGenericSettings from '@app/hooks/useGenericSettings'; +import { SettingsGroupType } from '@app/types/settings.types'; +import BaseSettingsForm from './BaseSettingsForm'; + +const GeneralSettings: React.FC = () => { + const { + settings, + loading, + error, + fetchSettings, + updateSettings, + saveSettings, + } = useGenericSettings('general'); + + const [form] = Form.useForm(); + + // Update form values when settings change + useEffect(() => { + if (settings) { + form.setFieldsValue(settings); + } + }, [settings, form]); + + // Handle form value changes + const handleValuesChange = (changedValues: Partial>) => { + updateSettings(changedValues); + }; + + return ( + +
+ + Port  + + + + + } + rules={[ + { required: true, message: 'Please enter a port number' }, + { pattern: /^\d+$/, message: 'Port must be a number' } + ]} + > + + + + + Private Key  + + + + + } + rules={[ + { required: true, message: 'Please enter the private key' } + ]} + > + } + placeholder="Enter private key" + /> + + + + Service Tag  + + + + + } + > + } + placeholder="Enter service tag" + /> + + + + Stats Database Path  + + + + + } + > + } + placeholder="./data/stats.db" + /> + + + + Enable Proxy  + + + + + } + valuePropName="checked" + > + + + + + Demo Mode  + + + + + } + valuePropName="checked" + > + + + + + Web Interface  + + + + + } + valuePropName="checked" + > + + + + +

+ Note: Changing these settings may require a restart of the relay server to take effect. + The private key should be kept secure and not shared with others. +

+
+
+
+ ); +}; + +export default GeneralSettings; diff --git a/src/components/settings/ImageModerationSettings.tsx b/src/components/settings/ImageModerationSettings.tsx new file mode 100644 index 0000000..7039e5b --- /dev/null +++ b/src/components/settings/ImageModerationSettings.tsx @@ -0,0 +1,215 @@ +import React, { useEffect, useState } from 'react'; +import { Form, Input, InputNumber, Switch, Select, Tooltip } from 'antd'; +import { QuestionCircleOutlined } from '@ant-design/icons'; +import useGenericSettings from '@app/hooks/useGenericSettings'; +import { ImageModerationSettings as ImageModerationSettingsType, SettingsGroupType } from '@app/types/settings.types'; +import BaseSettingsForm from './BaseSettingsForm'; + +const { Option } = Select; + +const ImageModerationSettings: React.FC = () => { + const { + settings, + loading, + error, + fetchSettings, + updateSettings, + saveSettings, + } = useGenericSettings('image_moderation'); + + const [form] = Form.useForm(); + const [isUserEditing, setIsUserEditing] = useState(false); + + // Update form values when settings change, but only if the user isn't currently editing + useEffect(() => { + if (settings && !isUserEditing) { + console.log('ImageModerationSettings - Received settings:', settings); + + // Transform property names to match form field names + // The API returns properties without the prefix, but the form expects prefixed names + const settingsObj = settings as Record; + + // Log the mode value specifically to debug + console.log('Mode field from settings:', settingsObj.mode); + + const formValues = { + image_moderation_api: settingsObj.api, + image_moderation_check_interval: typeof settingsObj.check_interval === 'string' + ? parseFloat(settingsObj.check_interval) + : settingsObj.check_interval, + image_moderation_concurrency: typeof settingsObj.concurrency === 'string' + ? parseFloat(settingsObj.concurrency) + : settingsObj.concurrency, + image_moderation_enabled: settingsObj.enabled, + image_moderation_mode: settingsObj.mode || 'basic', // Default to basic if mode is undefined + image_moderation_temp_dir: settingsObj.temp_dir, + image_moderation_threshold: typeof settingsObj.threshold === 'string' + ? parseFloat(settingsObj.threshold) + : settingsObj.threshold, + image_moderation_timeout: typeof settingsObj.timeout === 'string' + ? parseFloat(settingsObj.timeout) + : settingsObj.timeout + }; + + console.log('ImageModerationSettings - Transformed form values:', formValues); + + // Set form values with a slight delay to ensure the form is ready + setTimeout(() => { + form.setFieldsValue(formValues); + console.log('ImageModerationSettings - Form values after set:', form.getFieldsValue()); + }, 100); + } + }, [settings, form, isUserEditing]); + + // Handle form value changes + const handleValuesChange = (changedValues: Partial>) => { + setIsUserEditing(true); // Mark that user is currently editing + updateSettings(changedValues); + }; + + // Modified save function to reset the editing flag + const handleSave = async () => { + await saveSettings(); + setIsUserEditing(false); // Reset after saving + }; + + return ( + { + fetchSettings(); + setIsUserEditing(false); + }} + > +
{ + console.log('Form submitted with values:', values); + setIsUserEditing(false); + }} + > + + + + + + Moderation Mode  + + + + + } + > + + + + +
+

Moderation Mode Details:

+

Basic Mode: Only detects genitals, anus, and exposed breasts. Fastest processing (no Llama Vision). Best for high-volume applications.

+

Strict Mode: Includes all "basic" detection plus automatic blocking of all detected buttocks. Fast processing. Best for zero-tolerance platforms.

+

Full Mode (Default): Complete analysis with contextual evaluation. Slower due to Llama Vision, but most accurate. Reduces false positives.

+
+
+ + + + + + + + + + + Moderation Threshold  + + + + + } + rules={[ + { required: true, message: 'Please enter a threshold value' }, + { type: 'number', min: 0, max: 1, message: 'Value must be between 0 and 1' } + ]} + > + + + + + + + + + + + + + + +
+
+ ); +}; + +export default ImageModerationSettings; diff --git a/src/components/settings/NestFeederSettings.tsx b/src/components/settings/NestFeederSettings.tsx new file mode 100644 index 0000000..2b9def3 --- /dev/null +++ b/src/components/settings/NestFeederSettings.tsx @@ -0,0 +1,170 @@ +import React, { useEffect, useState } from 'react'; +import { Form, Input, InputNumber, Switch, Tooltip } from 'antd'; +import { QuestionCircleOutlined, ApiOutlined } from '@ant-design/icons'; +import useGenericSettings from '@app/hooks/useGenericSettings'; +import { SettingsGroupType } from '@app/types/settings.types'; +import BaseSettingsForm from './BaseSettingsForm'; + +const NestFeederSettings: React.FC = () => { + const { + settings, + loading, + error, + fetchSettings, + updateSettings, + saveSettings, + } = useGenericSettings('nest_feeder'); + + const [form] = Form.useForm(); + const [isUserEditing, setIsUserEditing] = useState(false); + + // Update form values when settings change, but only if user isn't actively editing + useEffect(() => { + if (settings && !isUserEditing) { + console.log('NestFeederSettings - Received settings:', settings); + + // Transform property names to match form field names + const settingsObj = settings as Record; + + const formValues = { + nest_feeder_enabled: settingsObj.enabled, + nest_feeder_url: settingsObj.url, + nest_feeder_cache_size: typeof settingsObj.cache_size === 'string' + ? parseInt(settingsObj.cache_size) + : settingsObj.cache_size, + nest_feeder_cache_ttl: typeof settingsObj.cache_ttl === 'string' + ? parseInt(settingsObj.cache_ttl) + : settingsObj.cache_ttl, + nest_feeder_timeout: typeof settingsObj.timeout === 'string' + ? parseInt(settingsObj.timeout) + : settingsObj.timeout, + }; + + console.log('NestFeederSettings - Transformed form values:', formValues); + + // Set form values with a slight delay to ensure the form is ready + setTimeout(() => { + form.setFieldsValue(formValues); + console.log('NestFeederSettings - Form values after set:', form.getFieldsValue()); + }, 100); + } + }, [settings, form, isUserEditing]); + + // Handle form value changes + const handleValuesChange = (changedValues: Partial>) => { + setIsUserEditing(true); // Mark that user is currently editing + updateSettings(changedValues); + }; + + // Modified save function to reset the editing flag + const handleSave = async () => { + await saveSettings(); + setIsUserEditing(false); // Reset after saving + }; + + return ( + { + fetchSettings(); + setIsUserEditing(false); + }} + > +
{ + console.log('Form submitted with values:', values); + setIsUserEditing(false); + }} + > + + + + + + API Endpoint  + + + + + } + rules={[{ required: true, message: 'Please enter the API endpoint' }]} + > + } + placeholder="http://localhost:8000/feed" + /> + + + + Cache Size  + + + + + } + rules={[ + { required: true, message: 'Please enter a cache size' }, + { type: 'number', min: 100, message: 'Value must be at least 100' } + ]} + > + + + + + Cache TTL (seconds)  + + + + + } + rules={[ + { required: true, message: 'Please enter a cache TTL' }, + { type: 'number', min: 1, message: 'Value must be at least 1' } + ]} + > + + + + + Timeout (seconds)  + + + + + } + rules={[ + { required: true, message: 'Please enter a timeout value' }, + { type: 'number', min: 1, message: 'Value must be at least 1' } + ]} + > + + +
+
+ ); +}; + +export default NestFeederSettings; diff --git a/src/components/settings/OllamaSettings.tsx b/src/components/settings/OllamaSettings.tsx new file mode 100644 index 0000000..cc9f49b --- /dev/null +++ b/src/components/settings/OllamaSettings.tsx @@ -0,0 +1,171 @@ +import React, { useEffect, useState } from 'react'; +import { Form, Input, InputNumber, Select, Tooltip } from 'antd'; +import { QuestionCircleOutlined } from '@ant-design/icons'; +import useGenericSettings from '@app/hooks/useGenericSettings'; +import { SettingsGroupType } from '@app/types/settings.types'; +import BaseSettingsForm from './BaseSettingsForm'; + +const { Option } = Select; + +const OllamaSettings: React.FC = () => { + const { + settings, + loading, + error, + fetchSettings, + updateSettings, + saveSettings, + } = useGenericSettings('ollama'); + + const [form] = Form.useForm(); + const [isUserEditing, setIsUserEditing] = useState(false); + + // Update form values when settings change, but only if user isn't actively editing + useEffect(() => { + if (settings && !isUserEditing) { + console.log('OllamaSettings - Received settings:', settings); + + // Transform property names to match form field names + // The API returns properties without the prefix, but the form expects prefixed names + const settingsObj = settings as Record; + + const formValues = { + ollama_url: settingsObj.url, + ollama_model: settingsObj.model, + ollama_timeout: typeof settingsObj.timeout === 'string' + ? parseInt(settingsObj.timeout) + : settingsObj.timeout + }; + + console.log('OllamaSettings - Transformed form values:', formValues); + + // Set form values with a slight delay to ensure the form is ready + setTimeout(() => { + form.setFieldsValue(formValues); + console.log('OllamaSettings - Form values after set:', form.getFieldsValue()); + }, 100); + } + }, [settings, form, isUserEditing]); + + // Handle form value changes + const handleValuesChange = (changedValues: Partial>) => { + setIsUserEditing(true); // Mark that user is currently editing + updateSettings(changedValues); + }; + + // Modified save function to reset the editing flag + const handleSave = async () => { + await saveSettings(); + setIsUserEditing(false); // Reset after saving + }; + + // Common Ollama models + const modelOptions = [ + { value: 'llama2', label: 'Llama 2' }, + { value: 'llama2:13b', label: 'Llama 2 (13B)' }, + { value: 'llama2:70b', label: 'Llama 2 (70B)' }, + { value: 'mistral', label: 'Mistral' }, + { value: 'mistral:7b', label: 'Mistral (7B)' }, + { value: 'mixtral', label: 'Mixtral' }, + { value: 'mixtral:8x7b', label: 'Mixtral (8x7B)' }, + { value: 'phi', label: 'Phi' }, + { value: 'phi:2', label: 'Phi 2' }, + { value: 'gemma', label: 'Gemma' }, + { value: 'gemma:7b', label: 'Gemma (7B)' }, + { value: 'vicuna', label: 'Vicuna' }, + { value: 'vicuna:13b', label: 'Vicuna (13B)' }, + { value: 'orca-mini', label: 'Orca Mini' }, + ]; + + return ( + { + fetchSettings(); + setIsUserEditing(false); + }} + > +
{ + console.log('Form submitted with values:', values); + setIsUserEditing(false); + }} + > + + Ollama API URL  + + + + + } + rules={[ + { required: true, message: 'Please enter the Ollama API URL' }, + { type: 'url', message: 'Please enter a valid URL' } + ]} + > + + + + + Ollama Model  + + + + + } + rules={[ + { required: true, message: 'Please select an Ollama model' } + ]} + > + + + + + Timeout (seconds)  + + + + + } + rules={[ + { required: true, message: 'Please enter a timeout value' }, + { type: 'number', min: 1, message: 'Value must be at least 1' } + ]} + > + + +
+
+ ); +}; + +export default OllamaSettings; diff --git a/src/components/settings/QueryCacheSettings.tsx b/src/components/settings/QueryCacheSettings.tsx new file mode 100644 index 0000000..a44dc57 --- /dev/null +++ b/src/components/settings/QueryCacheSettings.tsx @@ -0,0 +1,222 @@ +import React, { useEffect, useState } from 'react'; +import { Form, Input, Button, Space, Tooltip, Divider, InputNumber, Switch, Select } from 'antd'; +import { QuestionCircleOutlined, PlusOutlined, DeleteOutlined } from '@ant-design/icons'; +import useGenericSettings from '@app/hooks/useGenericSettings'; +import { SettingsGroupType } from '@app/types/settings.types'; +import BaseSettingsForm from './BaseSettingsForm'; +import styled from 'styled-components'; + +const KeyValueContainer = styled.div` + margin-bottom: 16px; +`; + +const QueryCacheSettings: React.FC = () => { + const { + settings, + loading, + error, + fetchSettings, + updateSettings, + saveSettings, + } = useGenericSettings('query_cache'); + + const [form] = Form.useForm(); + const [keyValuePairs, setKeyValuePairs] = useState>([]); + + // Update form values when settings change + useEffect(() => { + if (settings) { + // Convert settings object to array of key-value pairs + const pairs = Object.entries(settings).map(([key, value]) => ({ + key, + value, + type: typeof value === 'boolean' ? 'boolean' : + typeof value === 'number' ? 'number' : 'string' + })); + setKeyValuePairs(pairs); + } + }, [settings]); + + // Handle form value changes + const handleValuesChange = () => { + // Convert key-value pairs to settings object + const newSettings = keyValuePairs.reduce((acc, { key, value }) => { + if (key.trim()) { + acc[key.trim()] = value; + } + return acc; + }, {} as Record); + + updateSettings(newSettings); + }; + + // Add a new key-value pair + const handleAddPair = () => { + setKeyValuePairs([...keyValuePairs, { key: '', value: '', type: 'string' }]); + }; + + // Remove a key-value pair + const handleRemovePair = (index: number) => { + const newPairs = [...keyValuePairs]; + newPairs.splice(index, 1); + setKeyValuePairs(newPairs); + handleValuesChange(); + }; + + // Update a key-value pair + const handlePairChange = (index: number, field: 'key' | 'value' | 'type', value: string | number | boolean | null) => { + const newPairs = [...keyValuePairs]; + + if (field === 'type') { + // Convert the value to the new type + let convertedValue; + switch (value) { + case 'boolean': + convertedValue = Boolean(newPairs[index].value); + break; + case 'number': + convertedValue = Number(newPairs[index].value) || 0; + break; + case 'string': + convertedValue = String(newPairs[index].value); + break; + default: + convertedValue = newPairs[index].value; + } + newPairs[index].value = convertedValue; + } + + newPairs[index][field] = value; + setKeyValuePairs(newPairs); + handleValuesChange(); + }; + + // Render the value input based on the type + const renderValueInput = (pair: { key: string; value: any; type: string }, index: number) => { + switch (pair.type) { + case 'boolean': + return ( + handlePairChange(index, 'value', checked)} + /> + ); + case 'number': + return ( + handlePairChange(index, 'value', value ?? 0)} + style={{ width: '100%' }} + /> + ); + default: + return ( + handlePairChange(index, 'value', e.target.value)} + placeholder="Value" + /> + ); + } + }; + + return ( + +
+ + Cache Configuration  + + + + + } + > +

+ Add custom configuration parameters for the query cache. These settings control how the relay caches query results. +

+ + + + {keyValuePairs.map((pair, index) => ( + + + + handlePairChange(index, 'key', e.target.value)} + placeholder="Key" + /> + + + + {renderValueInput(pair, index)} + + + + + + + +
+ + +

+ Note: These settings affect how the relay caches query results. Improper configuration may impact performance. + Consult the documentation for recommended values. +

+
+
+
+ ); +}; + +export default QueryCacheSettings; diff --git a/src/components/settings/RelayInfoSettings.tsx b/src/components/settings/RelayInfoSettings.tsx new file mode 100644 index 0000000..bdfb65e --- /dev/null +++ b/src/components/settings/RelayInfoSettings.tsx @@ -0,0 +1,238 @@ +import React, { useEffect } from 'react'; +import { Form, Input, Select, Tooltip } from 'antd'; +import { QuestionCircleOutlined, InfoCircleOutlined, UserOutlined, KeyOutlined } from '@ant-design/icons'; +import useGenericSettings from '@app/hooks/useGenericSettings'; +import { SettingsGroupType } from '@app/types/settings.types'; +import BaseSettingsForm from './BaseSettingsForm'; + +const { Option } = Select; +const { TextArea } = Input; + +const RelayInfoSettings: React.FC = () => { + const { + settings, + loading, + error, + fetchSettings, + updateSettings, + saveSettings, + } = useGenericSettings('relay_info'); + + const [form] = Form.useForm(); + + // Update form values when settings change + useEffect(() => { + if (settings) { + form.setFieldsValue(settings); + } + }, [settings, form]); + + // Handle form value changes + const handleValuesChange = (changedValues: Partial>) => { + updateSettings(changedValues); + }; + + // Common NIPs that relays might support + const nipOptions = [ + { value: 1, label: 'NIP-01: Basic protocol flow' }, + { value: 2, label: 'NIP-02: Contact list' }, + { value: 4, label: 'NIP-04: Encrypted Direct Messages' }, + { value: 5, label: 'NIP-05: Mapping Nostr keys to DNS identifiers' }, + { value: 9, label: 'NIP-09: Event deletion' }, + { value: 11, label: 'NIP-11: Relay information document' }, + { value: 12, label: 'NIP-12: Generic tag queries' }, + { value: 15, label: 'NIP-15: End of Stored Events Notice' }, + { value: 16, label: 'NIP-16: Event Treatment' }, + { value: 20, label: 'NIP-20: Command Results' }, + { value: 22, label: 'NIP-22: Event created_at Limits' }, + { value: 26, label: 'NIP-26: Delegated Event Signing' }, + { value: 28, label: 'NIP-28: Public Chat' }, + { value: 33, label: 'NIP-33: Parameterized Replaceable Events' }, + { value: 40, label: 'NIP-40: Expiration Timestamp' }, + { value: 42, label: 'NIP-42: Authentication' }, + { value: 50, label: 'NIP-50: Search Capability' }, + { value: 56, label: 'NIP-56: Reporting' }, + { value: 65, label: 'NIP-65: Relay List Metadata' }, + { value: 78, label: 'NIP-78: Application-specific data' }, + ]; + + return ( + +
+ + Relay Name  + + + + + } + rules={[ + { required: true, message: 'Please enter the relay name' } + ]} + > + } + placeholder="My Nostr Relay" + /> + + + + Description  + + + + + } + > +