diff --git a/app.admin/package.json b/app.admin/package.json index 2887e28..da67826 100644 --- a/app.admin/package.json +++ b/app.admin/package.json @@ -20,9 +20,9 @@ "@radix-ui/react-tooltip": "^1.2.7", "@tanstack/react-query": "^4.39.2", "chart.js": "^4.4.9", - "react": "^18.2.0", + "react": "^18.3.1", "react-chartjs-2": "^5.3.0", - "react-dom": "^18.2.0", + "react-dom": "^18.3.1", "react-icons": "^5.5.0", "react-router-dom": "^6.30.1", "recharts": "^2.15.3", diff --git a/app.admin/src/features/admin/components/EtherealCredentials.tsx b/app.admin/src/features/admin/components/EtherealCredentials.tsx new file mode 100644 index 0000000..f447ad6 --- /dev/null +++ b/app.admin/src/features/admin/components/EtherealCredentials.tsx @@ -0,0 +1,217 @@ +import { + Alert, + Button, + Card, + CardContent, + Flex, + Loading, + Typography, +} from '@pairflix/components'; +import React, { useState } from 'react'; +import styled from 'styled-components'; + +// Styled components +const CredentialsContainer = styled.div` + margin-top: ${({ theme }) => theme.spacing.md}; +`; + +const CredentialItem = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + padding: ${({ theme }) => theme.spacing.sm}; + background-color: ${({ theme }) => theme.colors.background.secondary}; + border-radius: ${({ theme }) => theme.borderRadius.sm}; + margin-bottom: ${({ theme }) => theme.spacing.xs}; +`; + +const CredentialLabel = styled.span` + font-weight: 600; + color: ${({ theme }) => theme.colors.text.primary}; +`; + +const CredentialValue = styled.span` + font-family: monospace; + color: ${({ theme }) => theme.colors.text.secondary}; + background-color: ${({ theme }) => theme.colors.background.tertiary}; + padding: ${({ theme }) => theme.spacing.xs}; + border-radius: ${({ theme }) => theme.borderRadius.sm}; + border: 1px solid ${({ theme }) => theme.colors.border.light}; +`; + +const LoginButton = styled(Button)` + margin-top: ${({ theme }) => theme.spacing.sm}; +`; + +interface EtherealCredentials { + user: string; + pass: string; + previewUrl: string; +} + +interface EtherealCredentialsResponse { + message: string; + credentials: EtherealCredentials | null; +} + +const EtherealCredentials: React.FC = () => { + const [credentials, setCredentials] = useState( + null + ); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [isVisible, setIsVisible] = useState(false); + + const fetchCredentials = async () => { + setLoading(true); + setError(null); + + try { + const response = await fetch('/api/email/ethereal-credentials', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('token')}`, + }, + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to fetch credentials'); + } + + const data: EtherealCredentialsResponse = await response.json(); + setCredentials(data.credentials); + setIsVisible(true); + } catch (err) { + setError( + err instanceof Error ? err.message : 'Failed to fetch credentials' + ); + } finally { + setLoading(false); + } + }; + + const copyToClipboard = async (text: string, label: string) => { + try { + await navigator.clipboard.writeText(text); + // Could add a toast notification here + console.log(`${label} copied to clipboard`); + } catch (err) { + console.error('Failed to copy to clipboard:', err); + } + }; + + const openEtherealLogin = () => { + if (credentials?.previewUrl) { + window.open(credentials.previewUrl, '_blank'); + } + }; + + // Check if we're in development environment + const isDevelopment = + process.env.NODE_ENV === 'development' || + window.location.hostname === 'localhost' || + window.location.hostname === '127.0.0.1'; + + if (!isDevelopment) { + return null; // Don't show in production + } + + return ( + + + + + ๐Ÿงช Development Email Testing + + + Access your test email server credentials for development and + testing. + + + {error && ( + setError(null)}> + {error} + + )} + + {!isVisible && !loading && ( + + )} + + {loading && } + + {credentials && isVisible && ( + <> + + Username: + + {credentials.user} + + + + + + Password: + + {credentials.pass} + + + + + + Web Interface: + + {credentials.previewUrl} + + + + + + ๐Ÿ”— Open Ethereal Mail Login + + + + Use these credentials to log into the Ethereal Email web + interface and view test emails sent by the application. + + + )} + + {!credentials && isVisible && !loading && ( + + Email service not initialized yet. Try sending an email first to + generate credentials. + + )} + + + + ); +}; + +export default EtherealCredentials; diff --git a/app.admin/src/features/admin/components/dashboard/AdminDashboardContent.tsx b/app.admin/src/features/admin/components/dashboard/AdminDashboardContent.tsx index 3d16ad5..a2f2cfe 100644 --- a/app.admin/src/features/admin/components/dashboard/AdminDashboardContent.tsx +++ b/app.admin/src/features/admin/components/dashboard/AdminDashboardContent.tsx @@ -39,7 +39,7 @@ interface DashboardMetrics { }; content: { watchlistEntries: number; - matches: number; + groups: number; }; system: { status?: 'healthy' | 'unhealthy'; @@ -85,7 +85,7 @@ const AdminDashboardContent: React.FC = () => { }, content: { watchlistEntries: metricsData.watchlistEntries, - matches: metricsData.totalMatches, + groups: metricsData.totalGroups, }, // Add placeholder system data for the system health card system: { @@ -130,7 +130,7 @@ const AdminDashboardContent: React.FC = () => { }, content: { watchlistEntries: metricsData.watchlistEntries, - matches: metricsData.totalMatches, + groups: metricsData.totalGroups, }, // Add placeholder system data for the system health card system: { @@ -194,7 +194,7 @@ const AdminDashboardContent: React.FC = () => { <> Quick Actions diff --git a/app.admin/src/features/admin/components/dashboard/SystemMonitoringContent.tsx b/app.admin/src/features/admin/components/dashboard/SystemMonitoringContent.tsx index a82c979..ba740ed 100644 --- a/app.admin/src/features/admin/components/dashboard/SystemMonitoringContent.tsx +++ b/app.admin/src/features/admin/components/dashboard/SystemMonitoringContent.tsx @@ -266,7 +266,7 @@ const SystemMonitoringContent: React.FC = () => { current: data.database?.activeUsers || 0, peak24h: data.database?.totalUsers || 0, total24h: - data.database?.contentStats?.matches || + data.database?.contentStats?.groups || Math.round(data.database?.totalUsers * 1.5) || 0, }, diff --git a/app.admin/src/features/admin/components/shared/StatsOverview.tsx b/app.admin/src/features/admin/components/shared/StatsOverview.tsx index 9a2ddbb..8e52f9e 100644 --- a/app.admin/src/features/admin/components/shared/StatsOverview.tsx +++ b/app.admin/src/features/admin/components/shared/StatsOverview.tsx @@ -11,7 +11,6 @@ import { FaChartLine, FaClock, FaExclamationTriangle, - FaHeart, FaHeartbeat, FaList, FaMemory, @@ -115,7 +114,7 @@ export type StatsCardType = | 'users' | 'activeUsers' | 'content' - | 'matches' + | 'groups' | 'activity' | 'errors' | 'systemHealth' @@ -130,7 +129,7 @@ interface MetricsType { }; content?: { watchlistEntries: number; - matches: number; + groups: number; }; activity?: { last24Hours: number; @@ -184,8 +183,8 @@ const MetricsCard: React.FC = ({ type, metrics, icon }) => { return ; case 'content': return ; - case 'matches': - return ; + case 'groups': + return ; case 'activity': return ; case 'errors': @@ -255,7 +254,7 @@ const MetricsCard: React.FC = ({ type, metrics, icon }) => { ); - case 'matches': + case 'groups': if (!metrics.content) return null; return ( @@ -263,8 +262,8 @@ const MetricsCard: React.FC = ({ type, metrics, icon }) => { {icon && {renderIcon(type)}}
- {formatNumber(metrics.content.matches)} - Total Matches + {formatNumber(metrics.content.groups)} + Total Groups
@@ -404,7 +403,7 @@ interface StatsOverviewProps { // Main component for displaying stats in a grid export const StatsOverview: React.FC = ({ metrics, - cards = ['users', 'activeUsers', 'content', 'matches'], + cards = ['users', 'activeUsers', 'content', 'groups'], columns = 4, }) => { if (!isValidMetrics(metrics)) return null; diff --git a/app.admin/src/features/developer/DeveloperPage.tsx b/app.admin/src/features/developer/DeveloperPage.tsx new file mode 100644 index 0000000..07f3642 --- /dev/null +++ b/app.admin/src/features/developer/DeveloperPage.tsx @@ -0,0 +1,78 @@ +import { PageContainer, Typography } from '@pairflix/components'; +import React from 'react'; +import styled from 'styled-components'; + +import DocumentTitle from '../../components/common/DocumentTitle'; +import EtherealCredentials from '../admin/components/EtherealCredentials'; + +const DeveloperContainer = styled.div` + max-width: 1200px; + margin: 0 auto; +`; + +const Section = styled.div` + margin-bottom: ${({ theme }) => theme.spacing.lg}; +`; + +const DeveloperPage: React.FC = () => { + // Check if we're in development environment + const isDevelopment = + process.env.NODE_ENV === 'development' || + window.location.hostname === 'localhost' || + window.location.hostname === '127.0.0.1'; + + if (!isDevelopment) { + return ( + + + + + ๐Ÿšซ Not Available in Production + + + Developer tools are only available in development environments. + + + + ); + } + + return ( + + + + + ๐Ÿ› ๏ธ Developer Tools + + + Development and testing utilities for PairFlix. + + +
+ + Email Testing + + +
+ + {/* Future developer tools can be added here */} +
+ + ๐Ÿ”œ Coming Soon + + + Additional developer tools will be added here: + +
    +
  • API Request Tester
  • +
  • Database Query Tool
  • +
  • Log Viewer
  • +
  • Performance Metrics
  • +
+
+
+
+ ); +}; + +export default DeveloperPage; diff --git a/app.admin/src/pages/SystemStats.tsx b/app.admin/src/pages/SystemStats.tsx index 3b324e2..1dc3bd6 100644 --- a/app.admin/src/pages/SystemStats.tsx +++ b/app.admin/src/pages/SystemStats.tsx @@ -52,7 +52,7 @@ interface SystemStats { inactivePercentage: number; contentStats: { watchlistEntries: number; - matches: number; + groups: number; averageWatchlistPerUser: number; }; errorCount: number; @@ -199,9 +199,9 @@ const SystemStats: React.FC = () => { - Total Matches + Total Groups - {stats.database.contentStats.matches.toLocaleString()} + {stats.database.contentStats.groups.toLocaleString()} diff --git a/app.admin/src/services/api/admin.ts b/app.admin/src/services/api/admin.ts index 85bdc6d..ca235e7 100644 --- a/app.admin/src/services/api/admin.ts +++ b/app.admin/src/services/api/admin.ts @@ -31,6 +31,7 @@ export interface DashboardStats { totalUsers: number; activeUsers: number; totalMatches: number; + totalGroups: number; watchlistEntries: number; } @@ -48,6 +49,7 @@ export interface SystemStats { contentStats: { watchlistEntries: number; matches: number; + groups: number; averageWatchlistPerUser: number; }; errorCount: number; diff --git a/app.client/src/components/dev/DevLogin.tsx b/app.client/src/components/dev/DevLogin.tsx index 5bfb6f2..5d0df42 100644 --- a/app.client/src/components/dev/DevLogin.tsx +++ b/app.client/src/components/dev/DevLogin.tsx @@ -114,6 +114,58 @@ const DevContent = styled.div` padding: ${({ theme }) => theme.spacing.md}; `; +const EtherealSection = styled.div` + border-top: 1px solid ${({ theme }) => theme.colors.border.light}; + margin-top: ${({ theme }) => theme.spacing.sm}; + padding-top: ${({ theme }) => theme.spacing.sm}; +`; + +const EtherealHeader = styled.div` + font-size: 14px; + font-weight: ${({ theme }) => theme.typography.fontWeight.bold}; + margin-bottom: ${({ theme }) => theme.spacing.xs}; + color: ${({ theme }) => theme.colors.text.primary}; +`; + +const CredentialRow = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: ${({ theme }) => theme.spacing.xs}; + font-size: ${({ theme }) => theme.typography.fontSize.xs}; +`; + +const CredentialLabel = styled.span` + color: ${({ theme }) => theme.colors.text.secondary}; + font-weight: ${({ theme }) => theme.typography.fontWeight.medium}; +`; + +const CredentialValue = styled.span` + font-family: monospace; + color: ${({ theme }) => theme.colors.text.primary}; + background: ${({ theme }) => theme.colors.background.hover}; + padding: 2px 4px; + border-radius: ${({ theme }) => theme.borderRadius.sm}; + font-size: 10px; + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; +`; + +const EtherealButton = styled(Button)` + font-size: ${({ theme }) => theme.typography.fontSize.xs}; + padding: ${({ theme }) => theme.spacing.xs}; + min-height: auto; + margin-bottom: ${({ theme }) => theme.spacing.xs}; +`; + +const CopyButton = styled(Button)` + font-size: 10px; + padding: 2px 6px; + min-height: auto; + margin-left: 4px; +`; + interface TestUser { email: string; username: string; @@ -121,6 +173,17 @@ interface TestUser { description: string; } +interface EtherealCredentials { + user: string; + pass: string; + previewUrl: string; +} + +interface EtherealCredentialsResponse { + message: string; + credentials: EtherealCredentials | null; +} + const testUsers: TestUser[] = [ { email: 'useractive@example.com', @@ -214,6 +277,11 @@ const DevLogin: React.FC = () => { const [isExpanded, setIsExpanded] = useState(false); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(''); + const [showEthereal, setShowEthereal] = useState(false); + const [etherealCredentials, setEtherealCredentials] = + useState(null); + const [etherealLoading, setEtherealLoading] = useState(false); + const [etherealError, setEtherealError] = useState(''); // Only show in development if (import.meta.env.MODE !== 'development') { @@ -245,6 +313,73 @@ const DevLogin: React.FC = () => { } }; + const fetchEtherealCredentials = async () => { + setEtherealLoading(true); + setEtherealError(''); + + try { + console.log('Fetching Ethereal credentials...'); + const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:3000'; + const fullUrl = `${apiUrl}/api/email/ethereal-credentials`; + console.log('Full URL:', fullUrl); + + const response = await fetch(fullUrl, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('token')}`, + }, + }); + + console.log('Response status:', response.status); + console.log( + 'Response headers:', + Object.fromEntries(response.headers.entries()) + ); + + // Get the raw response text first to debug + const responseText = await response.text(); + console.log('Raw response:', responseText); + + if (!response.ok) { + // Try to parse as JSON, fallback to text error + let errorMessage = 'Failed to fetch credentials'; + try { + const errorData = JSON.parse(responseText); + errorMessage = errorData.error || errorMessage; + } catch { + errorMessage = responseText || errorMessage; + } + throw new Error(errorMessage); + } + + // Parse the response as JSON + const data: EtherealCredentialsResponse = JSON.parse(responseText); + setEtherealCredentials(data.credentials); + } catch (err) { + console.error('Error fetching credentials:', err); + setEtherealError( + err instanceof Error ? err.message : 'Failed to fetch credentials' + ); + } finally { + setEtherealLoading(false); + } + }; + + const copyToClipboard = async (text: string) => { + try { + await navigator.clipboard.writeText(text); + } catch (err) { + console.error('Failed to copy to clipboard:', err); + } + }; + + const openEtherealLogin = () => { + if (etherealCredentials?.previewUrl) { + window.open(etherealCredentials.previewUrl, '_blank'); + } + }; + return ( @@ -291,6 +426,100 @@ const DevLogin: React.FC = () => { )} + + + setShowEthereal(!showEthereal)} + isFullWidth + > + {showEthereal + ? 'Hide Ethereal Credentials' + : 'Show Ethereal Credentials'} + + + {showEthereal && ( + <> + ๐Ÿ“ง Test Email Server + + {etherealError && {etherealError}} + + {!etherealCredentials && !etherealLoading && ( + + Fetch Credentials + + )} + + {etherealLoading && ( + Fetching credentials... + )} + + {etherealCredentials && ( + <> + + Username: + + {etherealCredentials.user} + + + copyToClipboard(etherealCredentials.user) + } + > + Copy + + + + + Password: + + {etherealCredentials.pass} + + + copyToClipboard(etherealCredentials.pass) + } + > + Copy + + + + + ๐Ÿ”— Open Ethereal Mail + + + + ๐Ÿ’ก Use these credentials to view test emails at{' '} + + ethereal.email + + + + )} + + {!etherealCredentials && !etherealLoading && !etherealError && ( + + ๐Ÿ’ก Click "Fetch Credentials" to get your test email server + login details + + )} + + )} + diff --git a/app.client/src/components/layout/Routes.tsx b/app.client/src/components/layout/Routes.tsx index d5d3bf3..4729355 100644 --- a/app.client/src/components/layout/Routes.tsx +++ b/app.client/src/components/layout/Routes.tsx @@ -12,7 +12,7 @@ import LoginPage from '../../features/auth/LoginPage'; import ProfilePage from '../../features/auth/ProfilePage'; import RegisterPage from '../../features/auth/RegisterPage'; import ResetPasswordPage from '../../features/auth/ResetPasswordPage'; -import MatchPage from '../../features/match/MatchPage'; +import GroupsPage from '../../features/groups/pages/GroupsPage'; import WatchlistPage from '../../features/watchlist/WatchlistPage'; import { useAuth } from '../../hooks/useAuth'; @@ -82,9 +82,19 @@ const AppRoutes: React.FC = () => { element={} />} /> } />} + path="/groups" + element={} />} /> + } />} + /> + } />} + /> + {/* Legacy redirect for old matches route */} + } /> } />} diff --git a/app.client/src/config/navigation.ts b/app.client/src/config/navigation.ts index 1fd518d..53d1b4b 100644 --- a/app.client/src/config/navigation.ts +++ b/app.client/src/config/navigation.ts @@ -4,9 +4,9 @@ import { HiArrowLeftOnRectangle, HiArrowRightOnRectangle, HiChartBarSquare, - HiHeart, HiListBullet, HiUser, + HiUserGroup, } from 'react-icons/hi2'; // Extend the NavigationItem type to include onSelect @@ -56,10 +56,10 @@ export const createClientNavigation = ( icon: React.createElement(HiListBullet), }, { - key: 'matches', - label: 'Matches', - path: '/matches', - icon: React.createElement(HiHeart), + key: 'groups', + label: 'Groups', + path: '/groups', + icon: React.createElement(HiUserGroup), }, { key: 'activity', diff --git a/app.client/src/contexts/SettingsContext.tsx b/app.client/src/contexts/SettingsContext.tsx index 0cf9ff6..52b2b22 100644 --- a/app.client/src/contexts/SettingsContext.tsx +++ b/app.client/src/contexts/SettingsContext.tsx @@ -20,7 +20,7 @@ interface AppSettings { features: { enableNotifications: boolean; enableUserProfiles: boolean; - enableMatching: boolean; // Added new feature flag + enableGroups: boolean; // Added new feature flag }; security: { sessionTimeout: number; // session timeout in minutes @@ -53,7 +53,7 @@ const defaultSettings: AppSettings = { features: { enableNotifications: true, enableUserProfiles: false, // Added missing property with default value - enableMatching: true, // Added with default value true + enableGroups: true, // Added with default value true }, security: { sessionTimeout: 30, // default session timeout in minutes @@ -115,7 +115,7 @@ export const SettingsProvider: React.FC = ({ features: { enableNotifications: adminSettings.features.enableNotifications, enableUserProfiles: adminSettings.features.enableUserProfiles, // Added missing property - enableMatching: adminSettings.features.enableMatching, // Added new feature flag + enableGroups: (adminSettings.features as any).enableGroups ?? true, // Added new feature flag with fallback }, security: { sessionTimeout: adminSettings.security.sessionTimeout, diff --git a/app.client/src/features/groups/README.md b/app.client/src/features/groups/README.md new file mode 100644 index 0000000..782246f --- /dev/null +++ b/app.client/src/features/groups/README.md @@ -0,0 +1,177 @@ +# Groups Feature - Component Architecture + +This document outlines the refactored component structure for the Groups feature, following React best practices and principles. + +## ๐Ÿ“ Directory Structure + +``` +src/features/groups/ +โ”œโ”€โ”€ components/ +โ”‚ โ”œโ”€โ”€ ui/ # Small, focused UI components +โ”‚ โ”‚ โ”œโ”€โ”€ GroupHeader.tsx # Group card header with icon, title, badges +โ”‚ โ”‚ โ”œโ”€โ”€ GroupStats.tsx # Member count and date information +โ”‚ โ”‚ โ”œโ”€โ”€ GroupCreator.tsx # Creator avatar and info +โ”‚ โ”‚ โ”œโ”€โ”€ GroupSchedule.tsx # Schedule badge display +โ”‚ โ”‚ โ”œโ”€โ”€ GroupDescription.tsx # Group description text +โ”‚ โ”‚ โ”œโ”€โ”€ GroupActionHint.tsx # "Click to view details" hint +โ”‚ โ”‚ โ””โ”€โ”€ index.ts # UI components exports +โ”‚ โ”œโ”€โ”€ forms/ # Form-specific components +โ”‚ โ”‚ โ”œโ”€โ”€ GroupTypeSelector.tsx # Group type selection UI +โ”‚ โ”‚ โ”œโ”€โ”€ GroupSettingsSection.tsx # Settings checkboxes and preview +โ”‚ โ”‚ โ””โ”€โ”€ index.ts # Form components exports +โ”‚ โ”œโ”€โ”€ layout/ # Layout and grid components +โ”‚ โ”‚ โ”œโ”€โ”€ GroupsGrid.tsx # Responsive grid layout for groups +โ”‚ โ”‚ โ””โ”€โ”€ index.ts # Layout components exports +โ”‚ โ”œโ”€โ”€ GroupCard.tsx # Main group card (composed of UI components) +โ”‚ โ”œโ”€โ”€ GroupDetailPage.tsx # Group detail page +โ”‚ โ”œโ”€โ”€ CreateGroupForm.tsx # Group creation form +โ”‚ โ”œโ”€โ”€ CreateRelationshipForm.tsx # Relationship creation form +โ”‚ โ”œโ”€โ”€ GroupInvitations.tsx # Group invitations component +โ”‚ โ””โ”€โ”€ index.ts # Main components exports +โ”œโ”€โ”€ utils/ +โ”‚ โ””โ”€โ”€ groupHelpers.ts # Utility functions for group logic +โ”œโ”€โ”€ hooks/ # Custom hooks +โ”œโ”€โ”€ pages/ # Page components +โ””โ”€โ”€ index.ts # Feature exports +``` + +## ๐ŸŽฏ Design Principles Applied + +### 1. **Single Responsibility Principle** + +Each component has one clear purpose: + +- `GroupHeader` - Only handles header display +- `GroupStats` - Only handles stats display +- `GroupTypeSelector` - Only handles type selection + +### 2. **Component Composition** + +Large components are composed of smaller, reusable parts: + +```tsx +// Before: One large GroupCard with everything inline + + {/* 300+ lines of mixed concerns */} + + +// After: Composed of focused components + + + + + + + + +``` + +### 3. **Separation of Concerns** + +- **UI Components** (`/ui/`) - Pure presentation, no business logic +- **Form Components** (`/forms/`) - Form-specific logic and validation +- **Layout Components** (`/layout/`) - Responsive layouts and grids +- **Utils** (`/utils/`) - Pure functions for data transformation +- **Main Components** - Orchestration and data flow + +### 4. **Reusability** + +Components are designed to be reusable across different contexts: + +- `GroupsGrid` can be used anywhere groups need to be displayed +- `GroupHeader` can be used in cards, lists, or detail views +- Form components can be composed into different form layouts + +### 5. **Prop Interface Design** + +Clear, minimal prop interfaces: + +```tsx +// Focused, single-purpose props +interface GroupHeaderProps { + group: Group; +} + +// Flexible, reusable props +interface GroupActionHintProps { + show: boolean; +} +``` + +## ๐Ÿ”ง Utility Functions + +Extracted common logic into pure utility functions: + +```tsx +// groupHelpers.ts +export const getGroupTypeColor = (type: GroupType, theme: any): string => { ... } +export const formatMemberCount = (count: number): string => { ... } +export const formatGroupDate = (dateString: string): string => { ... } +export const getGroupDisplayName = (type: GroupType): string => { ... } +export const getBadgeVariantForStatus = (status: string) => { ... } +``` + +## ๐Ÿ“ฆ Import Strategy + +Organized exports for clean imports: + +```tsx +// Import specific UI components +import { GroupHeader, GroupStats } from '../components/ui'; + +// Import form components +import { GroupTypeSelector, GroupSettingsSection } from '../components/forms'; + +// Import layout components +import { GroupsGrid } from '../components/layout'; + +// Import utilities +import { getGroupTypeColor, formatMemberCount } from '../utils/groupHelpers'; +``` + +## โœ… Benefits Achieved + +1. **Maintainability** - Easier to find and modify specific functionality +2. **Testability** - Small components are easier to unit test +3. **Reusability** - Components can be used in multiple contexts +4. **Readability** - Clear component hierarchy and responsibilities +5. **Performance** - Smaller components enable better React optimization +6. **Developer Experience** - Easier to understand and contribute to + +## ๐Ÿš€ Usage Examples + +### Using the refactored GroupCard: + +```tsx +import { GroupCard } from '../components'; + +; +``` + +### Using individual UI components: + +```tsx +import { GroupHeader, GroupStats } from '../components/ui'; + +
+ + +
; +``` + +### Using the grid layout: + +```tsx +import { GroupsGrid } from '../components/layout'; + + + {groups.map(group => ( + + ))} +; +``` + +This refactored architecture provides a solid foundation for future development and makes the codebase more maintainable and scalable. diff --git a/app.client/src/features/groups/components/CreateGroupForm.tsx b/app.client/src/features/groups/components/CreateGroupForm.tsx new file mode 100644 index 0000000..eac2582 --- /dev/null +++ b/app.client/src/features/groups/components/CreateGroupForm.tsx @@ -0,0 +1,764 @@ +import { + Alert, + Badge, + Button, + Card, + Input, + Textarea, +} from '@pairflix/components'; +import { useState } from 'react'; +import { + HiCheckCircle, + HiEnvelope, + HiExclamationTriangle, + HiFilm, + HiGlobeAlt, + HiLockClosed, + HiSparkles, + HiUsers, +} from 'react-icons/hi2'; +import styled, { useTheme } from 'styled-components'; +import type { CreateGroupRequest, GroupType } from '../../../types/group'; + +interface CreateGroupFormProps { + onSubmit: (data: CreateGroupRequest) => Promise; + onCancel: () => void; + loading?: boolean; +} + +interface FormData { + name: string; + description: string; + type: 'friends' | 'watch_party'; + max_members: string; + isPublic: boolean; + requireApproval: boolean; + allowInvites: boolean; +} + +interface FormErrors { + name?: string; + type?: string; + max_members?: string; +} + +// Styled components +const FormContainer = styled.div` + max-width: 1024px; + margin: 0 auto; +`; + +const StyledForm = styled.form` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme?.spacing?.xl || '2rem'}; +`; + +const HeaderSection = styled.div` + text-align: center; + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme?.spacing?.lg || '1.5rem'}; +`; + +const HeaderIcon = styled.div` + font-size: 3.75rem; + line-height: 1; +`; + +const HeaderContent = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme?.spacing?.sm || '0.5rem'}; +`; + +const HeaderTitle = styled.h1` + font-size: ${({ theme }) => theme?.typography?.fontSize?.xl || '1.5rem'}; + font-size: 1.875rem; + font-weight: ${({ theme }) => theme?.typography?.fontWeight?.bold || '700'}; + color: ${({ theme }) => theme?.colors?.text?.primary || '#111827'}; + margin: 0; +`; + +const HeaderDescription = styled.p` + font-size: ${({ theme }) => theme?.typography?.fontSize?.lg || '1.25rem'}; + color: ${({ theme }) => theme?.colors?.text?.secondary || '#6b7280'}; + max-width: 512px; + margin: 0 auto; + line-height: 1.6; +`; + +const SectionContainer = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme?.spacing?.lg || '1.5rem'}; +`; + +const SectionHeader = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme?.spacing?.sm || '0.5rem'}; +`; + +const SectionTitle = styled.h3` + font-size: ${({ theme }) => theme?.typography?.fontSize?.lg || '1.25rem'}; + font-weight: ${({ theme }) => theme?.typography?.fontWeight?.bold || '700'}; + color: ${({ theme }) => theme?.colors?.text?.primary || '#111827'}; + margin: 0; +`; + +const SectionDescription = styled.p` + font-size: ${({ theme }) => theme?.typography?.fontSize?.sm || '0.875rem'}; + color: ${({ theme }) => theme?.colors?.text?.secondary || '#6b7280'}; + margin: 0; +`; + +const TypeGrid = styled.div` + display: grid; + grid-template-columns: 1fr; + gap: ${({ theme }) => theme?.spacing?.lg || '1.5rem'}; + + @media (min-width: ${({ theme }) => theme?.breakpoints?.md || '768px'}) { + grid-template-columns: 1fr 1fr; + } +`; + +const TypeOption = styled.div<{ $isSelected: boolean }>` + position: relative; + cursor: pointer; + border-radius: ${({ theme }) => theme?.borderRadius?.md || '8px'}; + border: 2px solid; + border-color: ${({ $isSelected, theme }) => + $isSelected + ? theme?.colors?.primary || '#3b82f6' + : theme?.colors?.border?.default || '#d1d5db'}; + background-color: ${({ $isSelected, theme }) => + $isSelected + ? theme?.colors?.background?.highlight || '#eff6ff' + : theme?.colors?.background?.primary || '#ffffff'}; + transition: all 0.2s ease; + + &:hover { + border-color: ${({ theme }) => theme?.colors?.border?.light || '#9ca3af'}; + } +`; + +const TypeOptionContent = styled.div` + padding: ${({ theme }) => theme?.spacing?.lg || '1.5rem'}; +`; + +const TypeOptionHeader = styled.div` + display: flex; + align-items: center; + gap: ${({ theme }) => theme?.spacing?.md || '1rem'}; + margin-bottom: ${({ theme }) => theme?.spacing?.sm || '0.5rem'}; +`; + +const TypeOptionIcon = styled.span` + font-size: 1.5rem; +`; + +const TypeOptionLabel = styled.span` + font-weight: ${({ theme }) => theme?.typography?.fontWeight?.bold || '700'}; + color: ${({ theme }) => theme?.colors?.text?.primary || '#111827'}; +`; + +const TypeOptionDescription = styled.p` + font-size: ${({ theme }) => theme?.typography?.fontSize?.sm || '0.875rem'}; + color: ${({ theme }) => theme?.colors?.text?.secondary || '#6b7280'}; + margin: 0; +`; + +const HiddenRadio = styled.input` + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +`; + +const ErrorMessage = styled.p` + margin-top: ${({ theme }) => theme?.spacing?.xs || '0.25rem'}; + font-size: ${({ theme }) => theme?.typography?.fontSize?.sm || '0.875rem'}; + color: ${({ theme }) => theme?.colors?.text?.error || '#dc2626'}; + display: flex; + align-items: center; + gap: ${({ theme }) => theme?.spacing?.xs || '0.25rem'}; + margin: 0; +`; + +const BasicInfoGrid = styled.div` + display: grid; + grid-template-columns: 1fr; + gap: ${({ theme }) => theme?.spacing?.xl || '2rem'}; + + @media (min-width: ${({ theme }) => theme?.breakpoints?.md || '768px'}) { + grid-template-columns: 1fr 1fr; + } +`; + +const BasicInfoLeft = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme?.spacing?.lg || '1.5rem'}; +`; + +const InputGroup = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme?.spacing?.sm || '0.5rem'}; +`; + +const InputLabel = styled.label` + display: block; + font-size: ${({ theme }) => theme?.typography?.fontSize?.sm || '0.875rem'}; + font-weight: ${({ theme }) => theme?.typography?.fontWeight?.medium || '500'}; + color: ${({ theme }) => theme?.colors?.text?.primary || '#374151'}; +`; + +const HelpText = styled.p` + margin-top: ${({ theme }) => theme?.spacing?.xs || '0.25rem'}; + font-size: ${({ theme }) => theme?.typography?.fontSize?.xs || '0.75rem'}; + color: ${({ theme }) => theme?.colors?.text?.secondary || '#6b7280'}; + margin: 0; +`; + +const CharacterCount = styled.p` + margin-top: ${({ theme }) => theme?.spacing?.xs || '0.25rem'}; + font-size: ${({ theme }) => theme?.typography?.fontSize?.xs || '0.75rem'}; + color: ${({ theme }) => theme?.colors?.text?.secondary || '#6b7280'}; + margin: 0; +`; + +const SettingsGrid = styled.div` + display: grid; + grid-template-columns: 1fr; + gap: ${({ theme }) => theme?.spacing?.xl || '2rem'}; + + @media (min-width: ${({ theme }) => theme?.breakpoints?.md || '768px'}) { + grid-template-columns: 1fr 1fr; + } +`; + +const SettingsLeft = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme?.spacing?.xl || '2rem'}; +`; + +const CheckboxGroup = styled.div` + display: flex; + align-items: flex-start; + gap: ${({ theme }) => theme?.spacing?.md || '1rem'}; +`; + +const StyledCheckbox = styled.input` + margin-top: ${({ theme }) => theme?.spacing?.xs || '0.25rem'}; + border-radius: ${({ theme }) => theme?.borderRadius?.sm || '4px'}; + border-color: ${({ theme }) => theme?.colors?.border?.default || '#d1d5db'}; + color: ${({ theme }) => theme?.colors?.primary || '#2563eb'}; + + &:focus { + ring: 2px; + ring-color: ${({ theme }) => theme?.colors?.primary || '#3b82f6'}; + } +`; + +const CheckboxContent = styled.div` + flex: 1; +`; + +const CheckboxLabel = styled.label` + font-weight: ${({ theme }) => theme?.typography?.fontWeight?.medium || '500'}; + color: ${({ theme }) => theme?.colors?.text?.primary || '#111827'}; + cursor: pointer; +`; + +const CheckboxDescription = styled.p` + font-size: ${({ theme }) => theme?.typography?.fontSize?.sm || '0.875rem'}; + color: ${({ theme }) => theme?.colors?.text?.secondary || '#6b7280'}; + margin-top: ${({ theme }) => theme?.spacing?.xs || '0.25rem'}; + margin: 0; +`; + +const PreviewPanel = styled.div` + background-color: ${({ theme }) => + theme?.colors?.background?.secondary || '#f9fafb'}; + border-radius: ${({ theme }) => theme?.borderRadius?.md || '8px'}; + padding: ${({ theme }) => theme?.spacing?.lg || '1.5rem'}; +`; + +const PreviewTitle = styled.h4` + font-weight: ${({ theme }) => theme?.typography?.fontWeight?.medium || '500'}; + color: ${({ theme }) => theme?.colors?.text?.primary || '#111827'}; + margin: 0 0 ${({ theme }) => theme?.spacing?.md || '1rem'} 0; +`; + +const PreviewContent = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme?.spacing?.sm || '0.5rem'}; + font-size: ${({ theme }) => theme?.typography?.fontSize?.sm || '0.875rem'}; +`; + +const PreviewRow = styled.div` + display: flex; + align-items: center; + justify-content: space-between; +`; + +const PreviewLabel = styled.span` + color: ${({ theme }) => theme?.colors?.text?.secondary || '#6b7280'}; +`; + +const ActionButtons = styled.div` + display: flex; + flex-direction: column; + justify-content: flex-end; + gap: ${({ theme }) => theme?.spacing?.md || '1rem'}; + padding-top: ${({ theme }) => theme?.spacing?.xl || '2rem'}; + border-top: 1px solid + ${({ theme }) => theme?.colors?.border?.default || '#e5e7eb'}; + + @media (min-width: ${({ theme }) => theme?.breakpoints?.sm || '640px'}) { + flex-direction: row; + gap: ${({ theme }) => theme?.spacing?.lg || '1.5rem'}; + } +`; + +const ButtonContent = styled.span` + display: flex; + align-items: center; + gap: ${({ theme }) => theme?.spacing?.sm || '0.5rem'}; +`; + +const ErrorContent = styled.div` + display: flex; + align-items: center; + gap: 0.5rem; +`; + +export default function CreateGroupForm({ + onSubmit, + onCancel, + loading = false, +}: CreateGroupFormProps) { + const theme = useTheme(); + + const [formData, setFormData] = useState({ + name: '', + description: '', + type: 'friends', + max_members: '', + isPublic: false, + requireApproval: true, + allowInvites: true, + }); + + const [errors, setErrors] = useState({}); + const [submitError, setSubmitError] = useState(null); + + const groupTypeOptions = [ + { + value: 'friends' as GroupType, + label: 'Friends Group', + description: 'Watch movies and shows with your friends', + icon: , + color: theme?.colors?.primary || '#2563eb', + }, + { + value: 'watch_party' as GroupType, + label: 'Watch Party', + description: 'Organize watch parties and events', + icon: , + color: theme?.colors?.text?.warning || '#ea580c', + }, + ]; + + const validateForm = (): boolean => { + const newErrors: FormErrors = {}; + + if (!formData.name.trim()) { + newErrors.name = 'Group name is required'; + } else if (formData.name.trim().length < 2) { + newErrors.name = 'Group name must be at least 2 characters'; + } else if (formData.name.trim().length > 50) { + newErrors.name = 'Group name cannot exceed 50 characters'; + } + + if (!formData.type) { + newErrors.type = 'Group type is required'; + } + + if (formData.max_members && isNaN(Number(formData.max_members))) { + newErrors.max_members = 'Max members must be a number'; + } else if (formData.max_members && Number(formData.max_members) < 2) { + newErrors.max_members = 'Max members must be at least 2'; + } else if (formData.max_members && Number(formData.max_members) > 50) { + newErrors.max_members = 'Max members cannot exceed 50'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateForm()) { + return; + } + + try { + setSubmitError(null); + + const submitData: CreateGroupRequest = { + name: formData.name, + ...(formData.description && { description: formData.description }), + type: formData.type, + ...(formData.max_members && { + max_members: parseInt(formData.max_members), + }), + settings: { + isPublic: formData.isPublic, + requireApproval: formData.requireApproval, + allowInvites: formData.allowInvites, + }, + }; + + await onSubmit(submitData); + } catch (error) { + setSubmitError( + error instanceof Error ? error.message : 'Failed to create group' + ); + } + }; + + const handleInputChange = ( + field: keyof FormData, + value: string | boolean + ) => { + setFormData(prev => ({ ...prev, [field]: value })); + // Clear error when user starts typing + if (errors[field as keyof FormErrors]) { + setErrors(prev => ({ ...prev, [field]: undefined })); + } + }; + + const selectedGroupType = groupTypeOptions.find( + option => option.value === formData.type + ); + + return ( + + + {/* Header Section */} + + + + + + Create New Group + + Set up your group to start watching together + + + + + {submitError && ( + + + + {submitError} + + + )} + + {/* Group Type Selection */} + + + + Choose Group Type + + Select the type that best describes your group + + + + + {groupTypeOptions.map(option => ( + handleInputChange('type', option.value)} + > + + + {option.icon} + {option.label} + {formData.type === option.value && ( + + Selected + + )} + + + {option.description} + + + handleInputChange('type', option.value)} + /> + + ))} + + + {errors.type && ( + + + {errors.type} + + )} + + + + {/* Basic Information */} + + + + Basic Information + + Give your group a name and description + + + + + + + Group Name * + handleInputChange('name', e.target.value)} + placeholder={`Enter your ${selectedGroupType?.label.toLowerCase()} group name`} + error={!!errors.name} + disabled={loading} + isFullWidth + /> + {errors.name && ( + + + {errors.name} + + )} + + + + + Max Members (Optional) + + + handleInputChange('max_members', e.target.value) + } + placeholder="Leave blank for unlimited" + min="2" + max="50" + error={!!errors.max_members} + disabled={loading} + isFullWidth + /> + {errors.max_members && ( + + + {errors.max_members} + + )} + + Recommended: 4-8 members for best experience + + + + + + + Description (Optional) + +