diff --git a/documentation/sidebar.md b/documentation/sidebar.md new file mode 100644 index 0000000..5be96b0 --- /dev/null +++ b/documentation/sidebar.md @@ -0,0 +1,463 @@ +# Sidebar Component Documentation + +## Architecture + +``` +src/ +├── components/Sidebar/ +│ ├── Sidebar.tsx # Main container component +│ ├── SidebarItem.tsx # Individual menu item +│ ├── SidebarSubmenu.tsx # Submenu component +│ └── SidebarUser.tsx # User info display +├── machines/ +│ └── sidebarMachine.ts # State machine definition +└── hooks/machines/ +│ └── useSidebarMachine.ts # React hook integration +└── contexts/ + └── SidebarData.tsx # Navigation data provider +``` + +## State Machine Explanation + +### Core Concepts + +**Events** - Things that can happen: +- `TOGGLE_ITEM` - User clicks a menu item +- `CLOSE_ALL` - Close all submenus +- `NAVIGATE` - User navigates to a new route + +**Context** - Data the machine remembers: +```typescript +interface SidebarContext { + openItemId: string | null; // Which submenu is open (only one allowed) + activePath: string; // Current route for highlighting +} +``` + +**States** - Possible configurations: +- `collapsed` - No submenus are open +- `expanded` - One submenu is open + +### State Transitions + +```mermaid +stateDiagram-v2 + [*] --> collapsed + collapsed --> expanded : TOGGLE_ITEM(id) + expanded --> collapsed : TOGGLE_ITEM(same_id) + expanded --> expanded : TOGGLE_ITEM(different_id) + collapsed --> collapsed : CLOSE_ALL + expanded --> collapsed : CLOSE_ALL +``` + +### Machine Definition + +```typescript +export const sidebarMachine = setup({ + types: { + context: {} as SidebarContext, + events: {} as SidebarEvent, + }, +}).createMachine({ + id: 'sidebar', + initial: 'collapsed', + context: { + openItemId: null, + activePath: '/', + }, + states: { + collapsed: { + on: { + TOGGLE_ITEM: { + target: 'expanded', + actions: assign({ + openItemId: ({ event }) => event.itemId, + }), + }, + }, + }, + expanded: { + on: { + TOGGLE_ITEM: [ + { + // Same item clicked - close it + guard: ({ context, event }) => context.openItemId === event.itemId, + target: 'collapsed', + actions: assign({ openItemId: null }), + }, + { + // Different item clicked - switch to it + target: 'expanded', + actions: assign({ + openItemId: ({ event }) => event.itemId, + }), + }, + ], + }, + }, + }, +}); +``` + +## React Hook Integration + +### useSidebarMachine Hook + +The `useSidebarMachine` hook bridges XState with React components: + +```typescript +export const useSidebarMachine = () => { + // 1. Create machine actor + const [state, send] = useActor(sidebarMachine); + + // 2. Sync with React Router + const location = useLocation(); + useEffect(() => { + send({ type: 'NAVIGATE', path: location.pathname }); + }, [location.pathname, send]); + + // 3. Return easy-to-use API + return { + // State queries + hasOpenSubmenu: sidebarState.hasOpenSubmenu, + openItemId: sidebarState.openItemId, + currentState: state.value, + + // Actions + toggleItem: (itemId: string) => send({ type: 'TOGGLE_ITEM', itemId }), + closeAll: () => send({ type: 'CLOSE_ALL' }), + isItemOpen: (itemId: string) => sidebarState.isItemOpen(itemId), + isItemActive: (itemPath?: string) => location.pathname === itemPath, + }; +}; +``` + +### Hook Benefits + +1. **Encapsulation**: State machine logic is hidden from components +2. **Type Safety**: TypeScript ensures correct usage +3. **Automatic Sync**: Route changes update the machine automatically +4. **Simple API**: Components only need simple functions + +## Data Loading (SidebarData) + +### Structure + +```typescript +interface SidebarItemType { + id: string; + name: string; + link?: string; + icon?: React.ComponentType; + submenu?: SidebarItemType[]; +} +``` + +### Implementation + +```typescript +const useSidebarData = () => { + const { t } = useLanguage(); + + return useMemo(() => [ + { + id: '1', + name: t('nav.team'), + link: '/team-bereich', + icon: MdOutlineWorkOutline, + }, + { + id: '2', + name: t('nav.dashboard'), + link: '/dashboard', + icon: LuTicket, + submenu: [ // Optional submenu + { id: '2-1', name: 'Analytics', link: '/dashboard/analytics' }, + { id: '2-2', name: 'Reports', link: '/dashboard/reports' }, + ], + }, + ], [t]); +}; +``` + +### Key Features + +- **Internationalization**: Uses language context for translations +- **Memoization**: Prevents unnecessary re-renders +- **Flexible Structure**: Supports nested submenus +- **Icon Support**: React component icons + +## Component Interactions + +### Sidebar (Main Container) + +```typescript +const Sidebar: React.FC = ({ data }) => { + const sidebarMachine = useSidebarMachine(); + + return ( +
+ {/* Logo and User sections */} + +
+ {data.map(item => ( + sidebarMachine.toggleItem(item.id)} + isActive={sidebarMachine.isItemActive(item.link)} + /> + ))} +
+
+ ); +}; +``` + +**Responsibilities:** +- Initialize state machine +- Pass state and actions to children +- Handle layout and styling + +### SidebarItem (Individual Menu Item) + +```typescript +const SidebarItem: React.FC = ({ + item, + isOpen, + onToggle, + isActive +}) => { + const hasSubItems = item.submenu && item.submenu.length > 0; + + const toggleSubmenu = (e: React.MouseEvent) => { + if (hasSubItems) { + e.preventDefault(); + onToggle(); // Call parent's toggle function + } + }; + + return ( +
+
  • + {hasSubItems ? ( + + {item.name} + + + ) : ( + {item.name} + )} +
  • + {hasSubItems && } +
    + ); +}; +``` + +**Key Changes from Original:** +- ❌ **Removed**: `useState` for local state +- ✅ **Added**: Props from parent state machine +- ✅ **Benefit**: No independent state management + +### SidebarSubmenu (Nested Menu) + +```typescript +const SidebarSubmenu: React.FC = ({ item, isOpen }) => { + return ( + + {isOpen && ( + + {/* Submenu items */} + + )} + + ); +}; +``` + +**Responsibilities:** +- Render submenu when `isOpen` is true +- Handle animations with Framer Motion +- Manage text overflow behavior + +## Data Flow + +``` +1. User clicks menu item + ↓ +2. SidebarItem calls onToggle() + ↓ +3. onToggle sends TOGGLE_ITEM event to machine + ↓ +4. Machine transitions states and updates context + ↓ +5. Hook returns new state + ↓ +6. Sidebar re-renders with new isOpen values + ↓ +7. SidebarSubmenu shows/hides based on isOpen +``` + +## Adding New States + +### Example: Adding "Pinned" State + +1. **Update Events:** +```typescript +export type SidebarEvent = + | { type: 'TOGGLE_ITEM'; itemId: string } + | { type: 'PIN_SIDEBAR' } // New event + | { type: 'UNPIN_SIDEBAR' } // New event + | { type: 'CLOSE_ALL' } + | { type: 'NAVIGATE'; path: string }; +``` + +2. **Update Context:** +```typescript +export interface SidebarContext { + openItemId: string | null; + activePath: string; + isPinned: boolean; // New context property +} +``` + +3. **Add New States:** +```typescript +states: { + collapsed: { + on: { + PIN_SIDEBAR: 'pinnedCollapsed', + // ... existing transitions + }, + }, + expanded: { + on: { + PIN_SIDEBAR: 'pinnedExpanded', + // ... existing transitions + }, + }, + pinnedCollapsed: { // New state + on: { + UNPIN_SIDEBAR: 'collapsed', + TOGGLE_ITEM: 'pinnedExpanded', + }, + }, + pinnedExpanded: { // New state + on: { + UNPIN_SIDEBAR: 'expanded', + // ... similar to expanded + }, + }, +}, +``` + +4. **Update Hook:** +```typescript +return { + // ... existing returns + isPinned: state.matches('pinnedCollapsed') || state.matches('pinnedExpanded'), + pinSidebar: () => send({ type: 'PIN_SIDEBAR' }), + unpinSidebar: () => send({ type: 'UNPIN_SIDEBAR' }), +}; +``` + +## Best Practices + +### State Machine Design + +1. **Keep States Simple**: Each state should represent a distinct UI mode +2. **Use Guards Wisely**: For conditional transitions based on context +3. **Minimize Context**: Only store data that affects behavior +4. **Clear Event Names**: Use descriptive, action-oriented names + +### Component Architecture + +1. **Single Source of Truth**: State machine holds all navigation state +2. **Props Down**: Pass state and actions as props +3. **Events Up**: Send events to the machine, not direct state updates +4. **Separation of Concerns**: Components handle UI, machine handles logic + +### Performance + +1. **Memoize Data**: Use `useMemo` for sidebar data +2. **Avoid Deep Objects**: Keep context flat when possible +3. **Selective Subscriptions**: Only subscribe to needed state slices + +## Debugging + +### Development Tools + +1. **Debug Props**: Hook returns `_debugState` and `_debugSend` +2. **State Logging**: Log state changes in development +3. **XState DevTools**: Use `@xstate/inspect` for visual debugging + +### Common Issues + +1. **Multiple Items Open**: Check guard conditions in TOGGLE_ITEM +2. **State Not Updating**: Ensure events are sent correctly +3. **Route Sync Issues**: Verify NAVIGATE event is sent on route change + +## Testing + +### Unit Testing State Machine + +```typescript +import { sidebarMachine } from '../machines/sidebarMachine'; + +test('should open submenu when item toggled', () => { + const state = sidebarMachine.transition('collapsed', { + type: 'TOGGLE_ITEM', + itemId: 'menu-1', + }); + + expect(state.value).toBe('expanded'); + expect(state.context.openItemId).toBe('menu-1'); +}); +``` + +### Integration Testing + +```typescript +import { render, fireEvent } from '@testing-library/react'; +import { SidebarWithData } from '../components/Sidebar'; + +test('should close submenu when same item clicked twice', () => { + const { getByText } = render(); + const menuItem = getByText('Dashboard'); + + fireEvent.click(menuItem); // Open + fireEvent.click(menuItem); // Close + + expect(/* submenu is closed */).toBeTruthy(); +}); +``` + +## Migration Guide + +### From useState to State Machine + +**Before:** +```typescript +const [isOpen, setIsOpen] = useState(false); +const toggle = () => setIsOpen(!isOpen); +``` + +**After:** +```typescript +const { isItemOpen, toggleItem } = useSidebarMachine(); +// Use: isItemOpen(itemId) and toggleItem(itemId) +``` + +### Benefits of Migration + +1. **Predictable State**: No more impossible state combinations +2. **Centralized Logic**: All sidebar behavior in one place +3. **Better Testing**: State machine logic is easily testable +4. **Type Safety**: TypeScript prevents invalid transitions +5. **Debugging**: Clear state visualization and logging diff --git a/package-lock.json b/package-lock.json index d7d74d4..1549bf8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@azure/msal-browser": "^4.12.0", "@azure/msal-react": "^3.0.12", + "@xstate/react": "^4.1.0", "axios": "^1.8.3", "dotenv": "^16.0.3", "express": "^4.18.2", @@ -25,7 +26,8 @@ "react-dropzone": "^14.3.8", "react-icons": "^5.5.0", "react-markdown": "^10.1.0", - "react-router-dom": "^7.5.0" + "react-router-dom": "^7.5.0", + "xstate": "^5.18.0" }, "devDependencies": { "@eslint/js": "^9.21.0", @@ -1357,6 +1359,7 @@ "version": "19.1.6", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.6.tgz", "integrity": "sha512-JeG0rEWak0N6Itr6QUx+X60uQmN+5t3j9r/OVDtWzFXKaj6kD1BwJzOksD0FF6iWxZlbE1kB0q9vtnU2ekqa1Q==", + "dev": true, "dependencies": { "csstype": "^3.0.2" } @@ -1400,6 +1403,24 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" } }, + "node_modules/@xstate/react": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@xstate/react/-/react-4.1.0.tgz", + "integrity": "sha512-Fh89luCwuMXIVXIua67d8pNuVgdGpqke2jHfIIL+ZjkfNh6YFtPDSwNSZZDhdNUsOW1zZYSbtUzbC8MIUyTSHQ==", + "dependencies": { + "use-isomorphic-layout-effect": "^1.1.2", + "use-sync-external-store": "^1.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "xstate": "^5.6.2" + }, + "peerDependenciesMeta": { + "xstate": { + "optional": true + } + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -1820,7 +1841,8 @@ "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true }, "node_modules/debug": { "version": "4.4.1", @@ -4877,6 +4899,27 @@ "punycode": "^2.1.0" } }, + "node_modules/use-isomorphic-layout-effect": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz", + "integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -5017,6 +5060,15 @@ "node": ">=0.10.0" } }, + "node_modules/xstate": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/xstate/-/xstate-5.18.0.tgz", + "integrity": "sha512-MKlq/jhyFBYm6Z9+P0k9nhMrHYTTg1ZGmhMw8tVe67oDq9nIlEf2/u/bY5kvUvqu4LTCiVl67hnfd92RMLRyVg==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/xstate" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 7cedb5b..0ee121e 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "dependencies": { "@azure/msal-browser": "^4.12.0", "@azure/msal-react": "^3.0.12", + "@xstate/react": "^4.1.0", "axios": "^1.8.3", "dotenv": "^16.0.3", "express": "^4.18.2", @@ -28,7 +29,8 @@ "react-dropzone": "^14.3.8", "react-icons": "^5.5.0", "react-markdown": "^10.1.0", - "react-router-dom": "^7.5.0" + "react-router-dom": "^7.5.0", + "xstate": "^5.18.0" }, "devDependencies": { "@eslint/js": "^9.21.0", diff --git a/src/components/Dashboard/DashboardChat/DashboardChatHistory/DashboardChatHistoryItem.tsx b/src/components/Dashboard/DashboardChat/DashboardChatHistory/DashboardChatHistoryItem.tsx index 724b605..d13f73e 100644 --- a/src/components/Dashboard/DashboardChat/DashboardChatHistory/DashboardChatHistoryItem.tsx +++ b/src/components/Dashboard/DashboardChat/DashboardChatHistory/DashboardChatHistoryItem.tsx @@ -24,7 +24,8 @@ function DashboardChatHistoryItem({ workflow, onDelete, onResume }: DashboardCha const messagePreview = firstUserMessage?.content || t('chat_history.no_message_content', 'No message content available'); const handleDelete = async () => { - const confirmMessage = t('chat_history.confirm_delete', 'Are you sure you want to delete workflow "{id}..."?').replace('{id}', workflow.id.substring(0, 8)); + const workflowName = workflow.title || `Workflow ${workflow.id.substring(0, 8)}...`; + const confirmMessage = t('chat_history.confirm_delete', 'Are you sure you want to delete "{name}"?').replace('{name}', workflowName); if (window.confirm(confirmMessage)) { const success = await deleteWorkflow(workflow.id); if (success && onDelete) { @@ -145,7 +146,7 @@ function DashboardChatHistoryItem({ workflow, onDelete, onResume }: DashboardCha

    - Workflow {workflow.id.substring(0, 8)}... + {workflow.title || `Workflow ${workflow.id.substring(0, 8)}...`}

    {renderStatusIndicator()} diff --git a/src/components/Dashboard/DashboardPrompt/DashboardPromptSet/DashboardPromptSet.tsx b/src/components/Dashboard/DashboardPrompt/DashboardPromptSet/DashboardPromptSet.tsx index ea66f80..02a3842 100644 --- a/src/components/Dashboard/DashboardPrompt/DashboardPromptSet/DashboardPromptSet.tsx +++ b/src/components/Dashboard/DashboardPrompt/DashboardPromptSet/DashboardPromptSet.tsx @@ -76,6 +76,7 @@ function DashboardPromptSet({ onPromptRun }: DashboardPromptSetProps) { prompt={prompt} onDelete={refetch} onRun={onPromptRun} + onShare={refetch} /> ))}
    diff --git a/src/components/Dashboard/DashboardPrompt/DashboardPromptSet/DashboardPromptSetItem.tsx b/src/components/Dashboard/DashboardPrompt/DashboardPromptSet/DashboardPromptSetItem.tsx index af37274..3fd7cee 100644 --- a/src/components/Dashboard/DashboardPrompt/DashboardPromptSet/DashboardPromptSetItem.tsx +++ b/src/components/Dashboard/DashboardPrompt/DashboardPromptSet/DashboardPromptSetItem.tsx @@ -4,21 +4,25 @@ import { AiOutlineDelete } from 'react-icons/ai'; import { BsShareFill } from 'react-icons/bs'; import { usePromptOperations, Prompt } from '../../../../hooks/usePrompts'; import { useLanguage } from '../../../../contexts/LanguageContext'; +import PromptShareModal from './PromptShareModal'; import styles from './DashboardPromptSetItem.module.css'; interface DashboardPromptSetItemProps { prompt: Prompt; onDelete?: () => void; onRun: (prompt: Prompt) => void; + onShare?: () => void; } -function DashboardPromptSetItem({ prompt, onDelete, onRun }: DashboardPromptSetItemProps) { +function DashboardPromptSetItem({ prompt, onDelete, onRun, onShare }: DashboardPromptSetItemProps) { const { t } = useLanguage(); - const { handlePromptDelete, deletingPrompts, deleteError } = usePromptOperations(); + const { handlePromptDelete, handlePromptShare, deletingPrompts, sharingPrompts, deleteError, shareError } = usePromptOperations(); const contentRef = useRef(null); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [showShareModal, setShowShareModal] = useState(false); const isDeleting = deletingPrompts.has(prompt.id); + const isSharing = sharingPrompts.has(prompt.id); const handleDeleteClick = async () => { if (showDeleteConfirm) { @@ -41,76 +45,110 @@ function DashboardPromptSetItem({ prompt, onDelete, onRun }: DashboardPromptSetI }; const handleShare = () => { - console.log('Sharing prompt:', prompt); + setShowShareModal(true); + }; + + const handleShareSubmit = async (shareData: { userIds: number[]; message?: string; title?: string }) => { + const result = await handlePromptShare(prompt.id, shareData); + if (result.success) { + setShowShareModal(false); + if (onShare) { + onShare(); // Trigger refresh of prompts list + } + } + // Error handling is done by the hook + }; + + const handleCloseShareModal = () => { + setShowShareModal(false); }; return ( -
    -
    -
    -
    -

    - {prompt.name} -

    - {prompt.createdAt && ( -

    - {t('promptset.created')}: {new Date(prompt.createdAt).toLocaleDateString('de-DE')} + <> +

    +
    +
    +
    +

    + {prompt.name} +

    + {prompt.createdAt && ( +

    + {t('promptset.created')}: {new Date(prompt.createdAt).toLocaleDateString('de-DE')} +

    + )} +
    + +
    +

    + {prompt.content}

    - )} +
    -
    -

    - {prompt.content} -

    +
    + + + + +
    -
    - - - - - -
    + {deleteError && ( +
    + {t('promptset.delete_error')}: {deleteError} +
    + )} + + {shareError && ( +
    + {t('share_modal.share_error')}: {shareError} +
    + )} + + {isDeleting && ( +
    + {t('promptset.deleting_message')} +
    + )}
    - - {deleteError && ( -
    - {t('promptset.delete_error')}: {deleteError} -
    - )} - - {isDeleting && ( -
    - {t('promptset.deleting_message')} -
    - )} -
    + + {/* Share Modal */} + + ); } diff --git a/src/components/Dashboard/DashboardPrompt/DashboardPromptSet/PromptShareModal.module.css b/src/components/Dashboard/DashboardPrompt/DashboardPromptSet/PromptShareModal.module.css new file mode 100644 index 0000000..aa570ea --- /dev/null +++ b/src/components/Dashboard/DashboardPrompt/DashboardPromptSet/PromptShareModal.module.css @@ -0,0 +1,315 @@ +.overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; + padding: 20px; +} + +.modal { + background: var(--color-bg); + border-radius: 12px; + border: 1px solid var(--color-gray); + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); + width: 100%; + max-width: 600px; + max-height: 90vh; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 24px 24px 0 24px; + border-bottom: 1px solid var(--color-gray); + margin-bottom: 20px; +} + +.title { + font-size: 20px; + font-weight: 600; + color: var(--color-text); + margin: 0; + word-break: break-word; +} + +.closeButton { + background: none; + border: none; + padding: 8px; + cursor: pointer; + color: var(--color-text); + border-radius: 6px; + transition: all 0.2s; + flex-shrink: 0; +} + +.closeButton:hover { + background-color: var(--color-gray-disabled); + color: var(--color-text); +} + +.closeButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.form { + padding: 0 24px 24px 24px; + flex: 1; + overflow-y: auto; +} + +.formGroup { + margin-bottom: 24px; +} + +.sectionHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.label { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 500; + color: var(--color-text); +} + +.labelIcon { + font-size: 12px; + color: var(--color-secondary); +} + +.selectAllButton { + background: none; + border: 1px solid var(--color-secondary); + color: var(--color-secondary); + padding: 6px 12px; + border-radius: 6px; + font-size: 12px; + cursor: pointer; + transition: all 0.2s; +} + +.selectAllButton:hover { + background-color: var(--color-secondary); + color: white; +} + +.selectAllButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.usersList { + border: 1px solid var(--color-gray); + border-radius: 8px; + max-height: 200px; + overflow-y: auto; + background-color: var(--color-bg); +} + +.userItem { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + cursor: pointer; + transition: background-color 0.2s; + border-bottom: 1px solid var(--color-gray); +} + +.userItem:last-child { + border-bottom: none; +} + +.userItem:hover { + background-color: var(--color-gray-disabled); +} + +.userItem.selected { + background-color: rgba(59, 130, 246, 0.1); + border-color: var(--color-secondary); +} + +.checkbox { + cursor: pointer; + width: 16px; + height: 16px; +} + +.userIcon { + color: var(--color-secondary); + font-size: 14px; + flex-shrink: 0; +} + +.userInfo { + flex: 1; + min-width: 0; +} + +.userName { + font-weight: 500; + color: var(--color-text); + font-size: 14px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.userUsername { + font-size: 12px; + color: var(--color-text-disabled); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.loading { + padding: 20px; + text-align: center; + color: var(--color-text-disabled); + font-style: italic; +} + +.noUsers { + padding: 20px; + text-align: center; + color: var(--color-text-disabled); + font-style: italic; +} + +.selectedCount { + margin-top: 8px; + font-size: 12px; + color: var(--color-secondary); + font-weight: 500; +} + +.input { + width: 100%; + padding: 12px 16px; + border: 1px solid var(--color-gray); + border-radius: 8px; + font-size: 14px; + transition: border-color 0.2s; + box-sizing: border-box; + background-color: var(--color-bg); + color: var(--color-text); +} + +.input:focus { + outline: none; + border-color: var(--color-secondary); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.input:disabled { + background-color: var(--color-gray-disabled); + opacity: 0.7; +} + +.textarea { + width: 100%; + padding: 12px 16px; + border: 1px solid var(--color-gray); + border-radius: 8px; + font-size: 14px; + font-family: inherit; + resize: vertical; + min-height: 80px; + transition: border-color 0.2s; + box-sizing: border-box; + background-color: var(--color-bg); + color: var(--color-text); +} + +.textarea:focus { + outline: none; + border-color: var(--color-secondary); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + background-color: var(--color-bg); + color: var(--color-text); +} + +.textarea:disabled { + background-color: var(--color-gray-disabled); + opacity: 0.7; +} + +.error { + background-color: var(--color-red-disabled); + border: 1px solid var(--color-red); + padding: 12px 16px; + border-radius: 8px; + font-size: 14px; + margin-bottom: 20px; + color: var(--color-red); +} + +.buttons { + display: flex; + gap: 12px; + justify-content: flex-end; + margin-top: 24px; + padding-top: 20px; + border-top: 1px solid var(--color-gray); +} + +.cancelButton { + padding: 10px 20px; + border: 1px solid var(--color-red); + background: var(--color-red); + color: white; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.cancelButton:hover { + background-color: var(--color-red-hover); + border-color: var(--color-red-hover); +} + +.cancelButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.submitButton { + padding: 10px 20px; + background: var(--color-secondary); + color: white; + border: none; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.submitButton:hover:not(:disabled) { + background-color: var(--color-secondary-hover); +} + +.submitButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} \ No newline at end of file diff --git a/src/components/Dashboard/DashboardPrompt/DashboardPromptSet/PromptShareModal.tsx b/src/components/Dashboard/DashboardPrompt/DashboardPromptSet/PromptShareModal.tsx new file mode 100644 index 0000000..127afbe --- /dev/null +++ b/src/components/Dashboard/DashboardPrompt/DashboardPromptSet/PromptShareModal.tsx @@ -0,0 +1,249 @@ +import React, { useState, useEffect } from 'react'; +import { FaTimes, FaUser, FaUsers } from 'react-icons/fa'; +import { useLanguage } from '../../../../contexts/LanguageContext'; +import { useUsers, User } from '../../../../hooks/usePrompts'; +import styles from './PromptShareModal.module.css'; + +interface PromptShareModalProps { + isOpen: boolean; + onClose: () => void; + onSubmit: (shareData: { userIds: number[]; message?: string; title?: string }) => Promise; + promptName: string; + isLoading?: boolean; +} + +function PromptShareModal({ isOpen, onClose, onSubmit, promptName, isLoading = false }: PromptShareModalProps) { + const { t } = useLanguage(); + const { users, loading: usersLoading, error: usersError } = useUsers(); + const [selectedUsers, setSelectedUsers] = useState>(new Set()); + const [message, setMessage] = useState(''); + const [title, setTitle] = useState(''); + const [error, setError] = useState(null); + + // Reset form when modal opens/closes + useEffect(() => { + if (isOpen) { + setSelectedUsers(new Set()); + setMessage(''); + setTitle(''); + setError(null); + } + }, [isOpen]); + + const handleUserToggle = (userId: number) => { + console.log('Toggling user:', userId); // Debug log + setSelectedUsers(prev => { + const newSet = new Set(prev); + if (newSet.has(userId)) { + newSet.delete(userId); + console.log('Removed user:', userId); // Debug log + } else { + newSet.add(userId); + console.log('Added user:', userId); // Debug log + } + console.log('New selected users:', Array.from(newSet)); // Debug log + return newSet; + }); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (selectedUsers.size === 0) { + setError(t('share_modal.no_users_selected')); + return; + } + + setError(null); + + try { + await onSubmit({ + userIds: Array.from(selectedUsers), + message: message.trim() || undefined, + title: title.trim() || undefined + }); + onClose(); + } catch (err: any) { + setError(err.message || t('share_modal.share_error')); + } + }; + + const handleClose = () => { + setSelectedUsers(new Set()); + setMessage(''); + setTitle(''); + setError(null); + onClose(); + }; + + const handleSelectAll = () => { + if (selectedUsers.size === users.length) { + setSelectedUsers(new Set()); + } else { + setSelectedUsers(new Set(users.map(user => user.id))); + } + }; + + if (!isOpen) return null; + + return ( +
    +
    +
    +

    + {t('share_modal.title')} "{promptName}" +

    + +
    + +
    + {/* Users Selection */} +
    +
    + + +
    + +
    + {usersLoading && ( +
    + {t('share_modal.loading_users')} +
    + )} + + {usersError && ( +
    + {t('share_modal.error_loading_users')}: {usersError} +
    + )} + + {users.map(user => ( + + ))} + + {!usersLoading && !usersError && users.length === 0 && ( +
    + {t('share_modal.no_users_available')} +
    + )} +
    + + {selectedUsers.size > 0 && ( +
    + {selectedUsers.size === 1 + ? t('share_modal.one_user_selected') + : t('share_modal.multiple_users_selected').replace('{count}', selectedUsers.size.toString()) + } +
    + )} +
    + + {/* Custom Title */} +
    + + setTitle(e.target.value)} + className={styles.input} + placeholder={t('share_modal.title_placeholder')} + disabled={isLoading} + maxLength={100} + /> +
    + + {/* Message */} +
    + +