changed code structure to use state machine

This commit is contained in:
idittrich-valueon 2025-06-27 12:43:07 +02:00
parent 7f9f5b0539
commit fd396d09e1
20 changed files with 1800 additions and 103 deletions

463
documentation/sidebar.md Normal file
View file

@ -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<SidebarProps> = ({ data }) => {
const sidebarMachine = useSidebarMachine();
return (
<div className={styles.sidebarContainer}>
{/* Logo and User sections */}
<div className={styles.sidebar}>
{data.map(item => (
<SidebarItem
key={item.id}
item={item}
isOpen={sidebarMachine.isItemOpen(item.id)}
onToggle={() => sidebarMachine.toggleItem(item.id)}
isActive={sidebarMachine.isItemActive(item.link)}
/>
))}
</div>
</div>
);
};
```
**Responsibilities:**
- Initialize state machine
- Pass state and actions to children
- Handle layout and styling
### SidebarItem (Individual Menu Item)
```typescript
const SidebarItem: React.FC<SidebarItemProps> = ({
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 (
<div className={styles.menu}>
<li className={`${isActive ? styles.active : ""}`}>
{hasSubItems ? (
<a href="#" onClick={toggleSubmenu}>
{item.name}
<IoIosArrowDown className={`${isOpen ? styles.rotated : ''}`} />
</a>
) : (
<Link to={item.link || "#"}>{item.name}</Link>
)}
</li>
{hasSubItems && <SidebarSubmenu item={item} isOpen={isOpen} />}
</div>
);
};
```
**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<SidebarSubmenuProps> = ({ item, isOpen }) => {
return (
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
>
{/* Submenu items */}
</motion.div>
)}
</AnimatePresence>
);
};
```
**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(<SidebarWithData />);
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

56
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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
<div className={styles.workflowContent}>
<div className={styles.workflowInfo}>
<h3 className={styles.workflowId}>
Workflow {workflow.id.substring(0, 8)}...
{workflow.title || `Workflow ${workflow.id.substring(0, 8)}...`}
</h3>
<div className={styles.workflowMeta}>
{renderStatusIndicator()}

View file

@ -76,6 +76,7 @@ function DashboardPromptSet({ onPromptRun }: DashboardPromptSetProps) {
prompt={prompt}
onDelete={refetch}
onRun={onPromptRun}
onShare={refetch}
/>
))}
</div>

View file

@ -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<HTMLDivElement>(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,10 +45,26 @@ 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 (
<>
<div
className={styles.promptItem}
>
@ -79,10 +99,12 @@ function DashboardPromptSetItem({ prompt, onDelete, onRun }: DashboardPromptSetI
<button
onClick={handleShare}
disabled={isSharing}
className={`${styles.actionButton} ${styles.shareButton}`}
title={t('promptset.share_tooltip')}
>
<BsShareFill size={16} />
{isSharing && <span className={styles.actionText}>{t('share_modal.sharing')}</span>}
</button>
<button
@ -105,12 +127,28 @@ function DashboardPromptSetItem({ prompt, onDelete, onRun }: DashboardPromptSetI
</div>
)}
{shareError && (
<div className={styles.errorMessage}>
{t('share_modal.share_error')}: {shareError}
</div>
)}
{isDeleting && (
<div className={styles.deletingMessage}>
{t('promptset.deleting_message')}
</div>
)}
</div>
{/* Share Modal */}
<PromptShareModal
isOpen={showShareModal}
onClose={handleCloseShareModal}
onSubmit={handleShareSubmit}
promptName={prompt.name}
isLoading={isSharing}
/>
</>
);
}

View file

@ -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;
}

View file

@ -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<void>;
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<Set<number>>(new Set());
const [message, setMessage] = useState('');
const [title, setTitle] = useState('');
const [error, setError] = useState<string | null>(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 (
<div className={styles.overlay}>
<div className={styles.modal}>
<div className={styles.header}>
<h2 className={styles.title}>
{t('share_modal.title')} "{promptName}"
</h2>
<button
onClick={handleClose}
className={styles.closeButton}
disabled={isLoading}
>
<FaTimes />
</button>
</div>
<form onSubmit={handleSubmit} className={styles.form}>
{/* Users Selection */}
<div className={styles.formGroup}>
<div className={styles.sectionHeader}>
<label className={styles.label}>
<FaUsers className={styles.labelIcon} />
{t('share_modal.select_users')} *
</label>
<button
type="button"
onClick={handleSelectAll}
className={styles.selectAllButton}
disabled={isLoading || usersLoading}
>
{selectedUsers.size === users.length
? t('share_modal.deselect_all')
: t('share_modal.select_all')
}
</button>
</div>
<div className={styles.usersList}>
{usersLoading && (
<div className={styles.loading}>
{t('share_modal.loading_users')}
</div>
)}
{usersError && (
<div className={styles.error}>
{t('share_modal.error_loading_users')}: {usersError}
</div>
)}
{users.map(user => (
<label
key={user.id}
className={`${styles.userItem} ${selectedUsers.has(user.id) ? styles.selected : ''}`}
htmlFor={`user-${user.id}`}
>
<input
id={`user-${user.id}`}
type="checkbox"
checked={selectedUsers.has(user.id)}
onChange={() => {
console.log('Checkbox clicked for user:', user.id); // Debug log
handleUserToggle(user.id);
}}
className={styles.checkbox}
disabled={isLoading}
/>
<FaUser className={styles.userIcon} />
<div className={styles.userInfo}>
<div className={styles.userName}>
{user.fullName || user.username}
</div>
{user.fullName && user.username && (
<div className={styles.userUsername}>
@{user.username}
</div>
)}
</div>
</label>
))}
{!usersLoading && !usersError && users.length === 0 && (
<div className={styles.noUsers}>
{t('share_modal.no_users_available')}
</div>
)}
</div>
{selectedUsers.size > 0 && (
<div className={styles.selectedCount}>
{selectedUsers.size === 1
? t('share_modal.one_user_selected')
: t('share_modal.multiple_users_selected').replace('{count}', selectedUsers.size.toString())
}
</div>
)}
</div>
{/* Custom Title */}
<div className={styles.formGroup}>
<label htmlFor="shareTitle" className={styles.label}>
{t('share_modal.custom_title')}
</label>
<input
id="shareTitle"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className={styles.input}
placeholder={t('share_modal.title_placeholder')}
disabled={isLoading}
maxLength={100}
/>
</div>
{/* Message */}
<div className={styles.formGroup}>
<label htmlFor="shareMessage" className={styles.label}>
{t('share_modal.message')}
</label>
<textarea
id="shareMessage"
value={message}
onChange={(e) => setMessage(e.target.value)}
className={styles.textarea}
placeholder={t('share_modal.message_placeholder')}
disabled={isLoading}
rows={4}
/>
</div>
{error && (
<div className={styles.error}>
{error}
</div>
)}
<div className={styles.buttons}>
<button
type="button"
onClick={handleClose}
className={styles.cancelButton}
disabled={isLoading}
>
{t('modal.cancel')}
</button>
<button
type="submit"
className={styles.submitButton}
disabled={isLoading || selectedUsers.size === 0}
>
{isLoading ? t('share_modal.sharing') : t('share_modal.share')}
</button>
</div>
</form>
</div>
</div>
);
}
export default PromptShareModal;

View file

@ -27,11 +27,20 @@
.logoContainer {
display: flex;
width: 100%;
height: auto;
height: 80px; /* Fixed height instead of auto */
padding: 30px 20px 7px 20px;
justify-content: center;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
position: relative;
box-sizing: border-box;
}
.logoWrapper {
display: flex;
justify-content: center;
align-items: center;
flex: 1;
}
.logo {
@ -40,6 +49,53 @@
color: var(--color-primary);
}
/* Toggle Button Styles */
.toggleButton {
background: var(--color-primary);
border: none;
border-radius: 10px;
color: white;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
flex-shrink: 0;
}
.toggleButton:hover {
background: var(--color-primary-hover);
transform: scale(1.05);
}
/* Minimized Sidebar Styles */
.sidebarContainer.minimized {
width: 80px;
display: flex;
justify-content: center;
align-items: center;
}
.sidebarContainer.minimized .sidebar {
width: 80px;
align-items: center;
}
.sidebarContainer.minimized .logoContainer {
height: 80px; /* Same fixed height as expanded */
padding: 15px 10px 7px 10px;
justify-content: center;
box-sizing: border-box;
}
.sidebarContainer.minimized .toggleButton {
margin: 0 auto; /* Center the toggle button */
}

View file

@ -1,9 +1,12 @@
import React from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import styles from './Sidebar.module.css'
import SidebarItem from './SidebarItem';
import useSidebarData from './SidebarData';
import useSidebarData from '../../contexts/SidebarData';
import SidebarUser from './SidebarUser';
import { useCurrentUser } from '../../hooks/useUsers';
import { useSidebarMachine } from '../../hooks/machines/useSidebarMachine';
import { GoSidebarExpand, GoSidebarCollapse } from 'react-icons/go';
interface SidebarItemType {
id: string;
@ -19,10 +22,47 @@ interface SidebarProps {
const Sidebar: React.FC<SidebarProps> = ({ data }) => {
const { user, isLoading, error } = useCurrentUser();
const sidebarMachine = useSidebarMachine();
return (
<div className={styles.sidebarContainer}>
<motion.div
className={`${styles.sidebarContainer} ${sidebarMachine.isMinimized ? styles.minimized : ''}`}
animate={{
width: sidebarMachine.isMinimized ? 80 : 240
}}
transition={{
duration: 0.3,
ease: "easeInOut"
}}
>
<div className={styles.logoContainer}>
<AnimatePresence mode="wait">
{!sidebarMachine.isMinimized && (
<motion.div
key="logo"
className={styles.logoWrapper}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.2 }}
>
<img src="/logos/PowerOn_transparent.png" alt="Logo" className={styles.logo} />
</motion.div>
)}
</AnimatePresence>
{/* Minimize/Expand Toggle Button */}
<button
className={styles.toggleButton}
onClick={sidebarMachine.isMinimized ? sidebarMachine.expandSidebar : sidebarMachine.minimizeSidebar}
title={sidebarMachine.isMinimized ? "Expand Sidebar" : "Minimize Sidebar"}
>
{sidebarMachine.isMinimized ? (
<GoSidebarCollapse size={20} />
) : (
<GoSidebarExpand size={20} />
)}
</button>
</div>
<div>
@ -34,15 +74,35 @@ const Sidebar: React.FC<SidebarProps> = ({ data }) => {
}}
isLoading={isLoading}
error={error}
isMinimized={sidebarMachine.isMinimized}
/>
</div>
<div className={styles.sidebar}>
<motion.div
className={styles.sidebar}
animate={{
width: sidebarMachine.isMinimized ? 80 : 240
}}
transition={{
duration: 0.3,
ease: "easeInOut"
}}
>
{data.map(item => {
return <SidebarItem key={item.id} item={item} />;
return (
<SidebarItem
key={item.id}
item={item}
isOpen={sidebarMachine.isItemOpen(item.id)}
onToggle={() => sidebarMachine.toggleItem(item.id)}
isActive={sidebarMachine.isItemActive(item.link)}
isMinimized={sidebarMachine.isMinimized}
/>
);
})}
</div>
</div>
</motion.div>
</motion.div>
)
}

View file

@ -50,20 +50,73 @@
.icon {
display: flex;
width: 20px;
height: 20px;
width: 25px;
height: 25px;
padding: 2.292px 2.3px 2.508px 2.292px;
justify-content: center;
align-items: center;
margin-left: 20px;
flex-shrink: 0;
}
.hassubmenu {
width: 20px;
height: 20px;
transition: transform 0.3s ease;
transition: transform 0.2s ease;
}
.rotated {
transform: rotate(180deg);
}
/* Text content styling */
.menuText {
transition: opacity 0.3s ease, width 0.3s ease;
white-space: nowrap;
overflow: hidden;
}
/* Minimized overlay */
.minimizedOverlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 10;
}
/* Minimized Menu Styles */
.menu.minimized li {
/* Keep the same height as expanded */
width: 46px;
padding: 0 3px 0 15px; /* Keep same padding structure */
justify-content: flex-start; /* Keep icons in same position */
margin: 0; /* Remove auto centering that causes jumping */
position: relative;
}
.menu.minimized .icon {
/* Keep icon in exact same position as expanded */
margin-left: -4px;
}
.menu.minimized .menuText {
/* Hide text content */
opacity: 0;
width: 0;
overflow: hidden;
}
.menu.minimized .hassubmenu {
/* Hide arrow */
opacity: 0;
width: 0;
}
.menu.minimized li:hover,
.menu.minimized li.active {
/* Keep same hover/active styles but adjust border radius for smaller width */
border-radius: 15px;
color: var(--color-text);
}

View file

@ -1,6 +1,7 @@
import React, { useState } from "react";
import { Link, useLocation } from "react-router-dom";
import React from "react";
import { Link } from "react-router-dom";
import { IoIosArrowDown } from "react-icons/io";
import { motion, AnimatePresence } from "framer-motion";
import styles from './SidebarItem.module.css';
import SidebarSubmenu from "./SidebarSubmenu";
@ -17,38 +18,57 @@ interface SidebarItemProps {
link?: string;
}[];
};
isOpen: boolean;
onToggle: () => void;
isActive: boolean;
isMinimized: boolean;
}
const SidebarItem: React.FC<SidebarItemProps> = ({ item }) => {
const location = useLocation();
const SidebarItem: React.FC<SidebarItemProps> = ({
item,
isOpen,
onToggle,
isActive,
isMinimized
}) => {
const Icon = item.icon as React.ComponentType<React.SVGProps<SVGSVGElement>>;
const hasSubItems = item.submenu && item.submenu.length > 0;
const [isOpen, setIsOpen] = useState(false);
const toggleSubmenu = (e: React.MouseEvent) => {
if (hasSubItems) {
e.preventDefault();
setIsOpen(!isOpen);
onToggle();
}
};
const isActive = item.link && location.pathname === item.link;
return (
<div className={styles.menu}>
<div className={`${styles.menu} ${isMinimized ? styles.minimized : ''}`}>
<li className={`${isActive ? styles.active : ""}`}>
{/* Icon is always present */}
{Icon && <Icon className={styles.icon} />}
{/* Text content - always present but hidden when minimized */}
{hasSubItems ? (
<a href="#" onClick={toggleSubmenu} className={styles.menuLink}>
{item.name}
<span className={styles.menuText}>{item.name}</span>
<IoIosArrowDown className={`${styles.hassubmenu} ${isOpen ? styles.rotated : ''}`} />
</a>
) : (
<Link to={item.link || "#"}>{item.name}</Link>
<Link to={item.link || "#"} className={styles.menuLink}>
<span className={styles.menuText}>{item.name}</span>
</Link>
)}
{/* Overlay for minimized state to ensure full area is clickable */}
{isMinimized && (
<Link
to={item.link || "#"}
className={styles.minimizedOverlay}
title={item.name}
/>
)}
</li>
{hasSubItems && <SidebarSubmenu item={item} isOpen={isOpen} />}
{hasSubItems && !isMinimized && <SidebarSubmenu item={item} isOpen={isOpen} />}
</div>
);
};

View file

@ -1,13 +1,14 @@
.user_section {
display: flex;
width: 240px;
height: auto;
min-height: 100px;
height: 100px; /* Fixed height instead of auto */
padding: 20px;
flex-direction: column;
align-items: flex-start;
gap: 8px;
font-family: var(--font-family);
box-sizing: border-box; /* Include padding in height calculation */
margin-bottom: 30px;
}
.user_info {
@ -15,18 +16,22 @@
align-items: center;
gap: 12px;
width: 100%;
transition: justify-content 0.3s ease;
}
.user_icon {
font-size: 40px;
color: var(--color-gray);
flex-shrink: 0;
transition: all 0.3s ease;
}
.text_content {
display: flex;
flex-direction: column;
gap: 4px;
transition: opacity 0.4s ease, width 0.4s ease;
overflow: hidden;
}
.user_section h1 {
@ -35,6 +40,8 @@
line-height: 1.2;
color: var(--color-text);
font-family: var(--font-family);
transition: opacity 0.3s ease;
white-space: nowrap;
}
.user_section p {
@ -42,6 +49,8 @@
font-size: 0.9rem;
color: var(--color-gray);
font-family: var(--font-family);
transition: opacity 0.35s ease;
white-space: nowrap;
}
.logout_button {
@ -54,7 +63,7 @@
cursor: pointer;
font-size: 14px;
font-family: var(--font-family);
transition: background-color 0.2s;
transition: all 0.4s ease;
width: 100%;
}
@ -67,3 +76,51 @@
background-color: var(--color-red);
}
.logout_text {
transition: opacity 0.3s ease, width 0.3s ease;
white-space: nowrap;
overflow: hidden;
}
/* Minimized User Section Styles */
.user_section.minimized {
width: 46px; /* Match menu item width */
height: 100px; /* Same fixed height as expanded */
padding: 20px 15px 20px 15px; /* Match menu item padding structure */
align-items: center;
box-sizing: border-box;
justify-content: center;
}
.user_section.minimized .user_info {
justify-content: flex-start; /* Match menu items positioning */
width: 100%;
}
.user_section.minimized .user_icon {
margin-left: -12px; /* Align with menu item icons at margin-left: -4px */
font-size: 40px; /* Keep same size as expanded state */
}
.user_section.minimized .text_content {
opacity: 0;
width: 0;
}
.user_section.minimized h1 {
opacity: 0;
}
.user_section.minimized p {
opacity: 0;
}
.user_section.minimized .logout_button {
opacity: 0;
width: 0;
height: 0;
overflow: hidden;
margin: 0;
padding: 0;
}

View file

@ -13,9 +13,10 @@ interface SidebarUserProps {
user: User;
isLoading?: boolean;
error?: string | null;
isMinimized?: boolean;
}
const SidebarUser: React.FC<SidebarUserProps> = ({ user, isLoading, error }) => {
const SidebarUser: React.FC<SidebarUserProps> = ({ user, isLoading, error, isMinimized = false }) => {
const { instance } = useMsal();
const handleLogout = async () => {
@ -42,7 +43,7 @@ const SidebarUser: React.FC<SidebarUserProps> = ({ user, isLoading, error }) =>
}
return (
<div className={styles.user_section}>
<div className={`${styles.user_section} ${isMinimized ? styles.minimized : ''}`}>
<div className={styles.user_info}>
<FaUserCircle className={styles.user_icon} />
<div className={styles.text_content}>
@ -54,7 +55,7 @@ const SidebarUser: React.FC<SidebarUserProps> = ({ user, isLoading, error }) =>
className={styles.logout_button}
onClick={handleLogout}
>
Logout
<span className={styles.logout_text}>Logout</span>
</button>
</div>
)

View file

@ -8,7 +8,7 @@ import { FaRegFileAlt } from "react-icons/fa";
import { TbLogs } from "react-icons/tb";
import { useMemo } from 'react';
import { useLanguage } from '../../contexts/LanguageContext';
import { useLanguage } from './LanguageContext';
const useSidebarData = () => {
const { t } = useLanguage();

View file

@ -100,6 +100,25 @@ export const translations: Translations = {
'modal.creating': 'Erstellen...',
'modal.create': 'Prompt erstellen',
// Share Modal
'share_modal.title': 'Prompt teilen',
'share_modal.select_users': 'Benutzer auswählen',
'share_modal.select_all': 'Alle auswählen',
'share_modal.deselect_all': 'Alle abwählen',
'share_modal.loading_users': 'Benutzer werden geladen...',
'share_modal.error_loading_users': 'Fehler beim Laden der Benutzer',
'share_modal.no_users_available': 'Keine Benutzer verfügbar',
'share_modal.no_users_selected': 'Bitte wählen Sie mindestens einen Benutzer aus',
'share_modal.one_user_selected': '1 Benutzer ausgewählt',
'share_modal.multiple_users_selected': '{count} Benutzer ausgewählt',
'share_modal.custom_title': 'Benutzerdefinierter Titel (optional)',
'share_modal.title_placeholder': 'Geben Sie einen benutzerdefinierten Titel ein',
'share_modal.message': 'Nachricht (optional)',
'share_modal.message_placeholder': 'Fügen Sie eine Nachricht für die Empfänger hinzu',
'share_modal.share': 'Teilen',
'share_modal.sharing': 'Wird geteilt...',
'share_modal.share_error': 'Fehler beim Teilen des Prompts',
// Prompt Settings
'prompt_settings.title': 'Prompt Einstellungen',
'prompt_settings.content_placeholder': 'Einstellungen werden in zukünftigen Updates hinzugefügt.',
@ -304,6 +323,25 @@ export const translations: Translations = {
'modal.creating': 'Creating...',
'modal.create': 'Create Prompt',
// Share Modal
'share_modal.title': 'Share Prompt',
'share_modal.select_users': 'Select Users',
'share_modal.select_all': 'Select All',
'share_modal.deselect_all': 'Deselect All',
'share_modal.loading_users': 'Loading users...',
'share_modal.error_loading_users': 'Error loading users',
'share_modal.no_users_available': 'No users available',
'share_modal.no_users_selected': 'Please select at least one user',
'share_modal.one_user_selected': '1 user selected',
'share_modal.multiple_users_selected': '{count} users selected',
'share_modal.custom_title': 'Custom Title (optional)',
'share_modal.title_placeholder': 'Enter a custom title',
'share_modal.message': 'Message (optional)',
'share_modal.message_placeholder': 'Add a message for recipients',
'share_modal.share': 'Share',
'share_modal.sharing': 'Sharing...',
'share_modal.share_error': 'Error sharing prompt',
// Prompt Settings
'prompt_settings.title': 'Prompt Settings',
'prompt_settings.content_placeholder': 'Settings content will be added here in future updates.',
@ -508,6 +546,25 @@ export const translations: Translations = {
'modal.creating': 'Création...',
'modal.create': 'Créer le prompt',
// Share Modal
'share_modal.title': 'Partager le prompt',
'share_modal.select_users': 'Sélectionner les utilisateurs',
'share_modal.select_all': 'Tout sélectionner',
'share_modal.deselect_all': 'Tout désélectionner',
'share_modal.loading_users': 'Chargement des utilisateurs...',
'share_modal.error_loading_users': 'Erreur lors du chargement des utilisateurs',
'share_modal.no_users_available': 'Aucun utilisateur disponible',
'share_modal.no_users_selected': 'Veuillez sélectionner au moins un utilisateur',
'share_modal.one_user_selected': '1 utilisateur sélectionné',
'share_modal.multiple_users_selected': '{count} utilisateurs sélectionnés',
'share_modal.custom_title': 'Titre personnalisé (facultatif)',
'share_modal.title_placeholder': 'Entrez un titre personnalisé',
'share_modal.message': 'Message (facultatif)',
'share_modal.message_placeholder': 'Ajoutez un message pour les destinataires',
'share_modal.share': 'Partager',
'share_modal.sharing': 'Partage en cours...',
'share_modal.share_error': 'Erreur lors du partage du prompt',
// Prompt Settings
'prompt_settings.title': 'Paramètres de prompt',
'prompt_settings.content_placeholder': 'Le contenu des paramètres sera ajouté dans les futures mises à jour.',

View file

@ -0,0 +1,91 @@
import { useActor } from '@xstate/react';
import { useLocation } from 'react-router-dom';
import { useEffect } from 'react';
import { sidebarMachine, getSidebarState } from '../../machines/sidebarMachine';
// Custom hook that provides all sidebar functionality
export const useSidebarMachine = () => {
// Step 1: Create the machine actor (this is like useState but for state machines)
const [state, send] = useActor(sidebarMachine);
// Step 2: Get the current route from React Router
const location = useLocation();
// Step 3: Update the machine when the route changes
useEffect(() => {
send({
type: 'NAVIGATE',
path: location.pathname,
});
}, [location.pathname, send]);
// Step 4: Get derived state using our helper function
const sidebarState = getSidebarState(state.context);
// Step 5: Create easy-to-use action functions
const actions = {
// Toggle a specific menu item
toggleItem: (itemId: string) => {
send({
type: 'TOGGLE_ITEM',
itemId,
});
},
// Close all submenus
closeAll: () => {
send({
type: 'CLOSE_ALL',
});
},
// Check if a specific item is open
isItemOpen: (itemId: string) => {
return sidebarState.isItemOpen(itemId);
},
// Check if an item is the active route
isItemActive: (itemPath?: string) => {
if (!itemPath) return false;
return location.pathname === itemPath;
},
// Minimize/expand sidebar
minimizeSidebar: () => {
send({
type: 'MINIMIZE_SIDEBAR',
});
},
expandSidebar: () => {
send({
type: 'EXPAND_SIDEBAR',
});
},
};
// Step 6: Return everything the components need
return {
// State queries (read-only)
hasOpenSubmenu: sidebarState.hasOpenSubmenu,
openItemId: sidebarState.openItemId,
activePath: sidebarState.activePath,
isMinimized: sidebarState.isMinimized,
currentState: state.value, // For debugging: 'collapsed' or 'expanded'
// Actions (functions to call)
toggleItem: actions.toggleItem,
closeAll: actions.closeAll,
isItemOpen: actions.isItemOpen,
isItemActive: actions.isItemActive,
minimizeSidebar: actions.minimizeSidebar,
expandSidebar: actions.expandSidebar,
// Raw state machine state (for debugging)
_debugState: state,
_debugSend: send,
};
};
// Step 7: Type export for components that need it
export type UseSidebarMachine = ReturnType<typeof useSidebarMachine>;

View file

@ -7,21 +7,43 @@ export interface Prompt {
name: string;
content: string;
createdAt?: string;
isShared?: boolean;
}
// User interface for sharing
export interface User {
id: number;
username: string;
fullName?: string;
email?: string;
}
// Share request interface
export interface ShareRequest {
userIds: number[];
message?: string;
title?: string;
}
// Prompts list hook
export function usePrompts() {
const [prompts, setPrompts] = useState<Prompt[]>([]);
const { request, isLoading: loading, error } = useApiRequest<null, Prompt[]>();
const { request, isLoading: loading, error } = useApiRequest<null, any>();
const fetchPrompts = async () => {
try {
const data = await request({
url: '/api/prompts',
url: '/api/prompts/all-with-shared',
method: 'get'
});
setPrompts(data);
// Combine owned and shared prompts into a single list
const allPrompts = [
...(data.owned || []),
...(data.sharedWithMe || [])
];
setPrompts(allPrompts);
} catch (error) {
// Error is already handled by useApiRequest
}
@ -34,15 +56,44 @@ export function usePrompts() {
return { prompts, loading, error, refetch: fetchPrompts };
}
// Users hook for sharing functionality
export function useUsers() {
const [users, setUsers] = useState<User[]>([]);
const { request, isLoading: loading, error } = useApiRequest<null, User[]>();
const fetchUsers = async () => {
try {
const data = await request({
url: '/api/users',
method: 'get'
});
// Filter out users that shouldn't be shown for sharing
const shareableUsers = data.filter(user => !(user as any).disabled);
setUsers(shareableUsers);
} catch (error) {
// Error is already handled by useApiRequest
}
};
useEffect(() => {
fetchUsers();
}, []);
return { users, loading, error, refetch: fetchUsers };
}
// Prompt operations hook
export function usePromptOperations() {
const [deletingPrompts, setDeletingPrompts] = useState<Set<number>>(new Set());
const [creatingPrompt, setCreatingPrompt] = useState(false);
const [updatingPrompts, setUpdatingPrompts] = useState<Set<number>>(new Set());
const [sharingPrompts, setSharingPrompts] = useState<Set<number>>(new Set());
const { request, error: apiError, isLoading } = useApiRequest();
const [deleteError, setDeleteError] = useState<string | null>(null);
const [createError, setCreateError] = useState<string | null>(null);
const [updateError, setUpdateError] = useState<string | null>(null);
const [shareError, setShareError] = useState<string | null>(null);
const handlePromptDelete = async (promptId: number) => {
setDeleteError(null);
@ -113,16 +164,43 @@ export function usePromptOperations() {
}
};
const handlePromptShare = async (promptId: number, shareData: ShareRequest) => {
setShareError(null);
setSharingPrompts(prev => new Set(prev).add(promptId));
try {
const shareResult = await request({
url: `/api/prompts/${promptId}/share`,
method: 'post',
data: shareData
});
return { success: true, shareData: shareResult };
} catch (error: any) {
setShareError(error.message);
return { success: false, error: error.message };
} finally {
setSharingPrompts(prev => {
const newSet = new Set(prev);
newSet.delete(promptId);
return newSet;
});
}
};
return {
deletingPrompts,
creatingPrompt,
updatingPrompts,
sharingPrompts,
deleteError,
createError,
updateError,
shareError,
handlePromptDelete,
handlePromptCreate,
handlePromptUpdate,
handlePromptShare,
isLoading
};
}

View file

@ -5,6 +5,7 @@ import { useApiRequest } from './useApi';
export interface Workflow {
id: string;
name?: string;
title?: string;
status: string;
startedAt?: string;
lastActivity?: string;

View file

@ -0,0 +1,102 @@
import { setup, assign } from 'xstate';
export type SidebarEvent =
| { type: 'TOGGLE_ITEM'; itemId: string }
| { type: 'CLOSE_ALL' }
| { type: 'NAVIGATE'; path: string }
| { type: 'MINIMIZE_SIDEBAR' }
| { type: 'EXPAND_SIDEBAR' };
export interface SidebarContext {
openItemId: string | null;
activePath: string;
isMinimized: boolean;
}
export const sidebarMachine = setup({
types: {
context: {} as SidebarContext,
events: {} as SidebarEvent,
},
}).createMachine({
id: 'sidebar',
initial: 'collapsed',
context: {
openItemId: null,
activePath: '/',
isMinimized: false,
},
states: {
collapsed: {
on: {
TOGGLE_ITEM: {
guard: ({ context }) => !context.isMinimized,
target: 'expanded',
actions: assign({
openItemId: ({ event }) => event.itemId,
}),
},
CLOSE_ALL: {
},
},
},
expanded: {
on: {
TOGGLE_ITEM: [
{
guard: ({ context, event }) => !context.isMinimized && context.openItemId === event.itemId,
target: 'collapsed',
actions: assign({
openItemId: null,
}),
},
{
guard: ({ context }) => !context.isMinimized,
target: 'expanded',
actions: assign({
openItemId: ({ event }) => event.itemId,
}),
},
],
CLOSE_ALL: {
target: 'collapsed',
actions: assign({
openItemId: null,
}),
},
},
},
},
on: {
NAVIGATE: {
actions: assign({
activePath: ({ event }) => event.path,
}),
},
MINIMIZE_SIDEBAR: {
actions: assign({
isMinimized: true,
openItemId: null, // Close any open submenu when minimizing
}),
},
EXPAND_SIDEBAR: {
actions: assign({
isMinimized: false,
}),
},
},
});
export const getSidebarState = (context: SidebarContext) => {
return {
hasOpenSubmenu: context.openItemId !== null,
openItemId: context.openItemId,
isItemOpen: (itemId: string) => context.openItemId === itemId,
activePath: context.activePath,
isMinimized: context.isMinimized,
};
};
export type SidebarMachineState = ReturnType<typeof sidebarMachine.transition>;