changed code structure to use state machine
This commit is contained in:
parent
7f9f5b0539
commit
fd396d09e1
20 changed files with 1800 additions and 103 deletions
463
documentation/sidebar.md
Normal file
463
documentation/sidebar.md
Normal 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
56
package-lock.json
generated
|
|
@ -10,6 +10,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@azure/msal-browser": "^4.12.0",
|
"@azure/msal-browser": "^4.12.0",
|
||||||
"@azure/msal-react": "^3.0.12",
|
"@azure/msal-react": "^3.0.12",
|
||||||
|
"@xstate/react": "^4.1.0",
|
||||||
"axios": "^1.8.3",
|
"axios": "^1.8.3",
|
||||||
"dotenv": "^16.0.3",
|
"dotenv": "^16.0.3",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
|
@ -25,7 +26,8 @@
|
||||||
"react-dropzone": "^14.3.8",
|
"react-dropzone": "^14.3.8",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.5.0"
|
"react-router-dom": "^7.5.0",
|
||||||
|
"xstate": "^5.18.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.21.0",
|
"@eslint/js": "^9.21.0",
|
||||||
|
|
@ -1357,6 +1359,7 @@
|
||||||
"version": "19.1.6",
|
"version": "19.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.6.tgz",
|
||||||
"integrity": "sha512-JeG0rEWak0N6Itr6QUx+X60uQmN+5t3j9r/OVDtWzFXKaj6kD1BwJzOksD0FF6iWxZlbE1kB0q9vtnU2ekqa1Q==",
|
"integrity": "sha512-JeG0rEWak0N6Itr6QUx+X60uQmN+5t3j9r/OVDtWzFXKaj6kD1BwJzOksD0FF6iWxZlbE1kB0q9vtnU2ekqa1Q==",
|
||||||
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
|
|
@ -1400,6 +1403,24 @@
|
||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0"
|
"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": {
|
"node_modules/accepts": {
|
||||||
"version": "1.3.8",
|
"version": "1.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||||
|
|
@ -1820,7 +1841,8 @@
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.1",
|
"version": "4.4.1",
|
||||||
|
|
@ -4877,6 +4899,27 @@
|
||||||
"punycode": "^2.1.0"
|
"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": {
|
"node_modules/utils-merge": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||||
|
|
@ -5017,6 +5060,15 @@
|
||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/xtend": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@azure/msal-browser": "^4.12.0",
|
"@azure/msal-browser": "^4.12.0",
|
||||||
"@azure/msal-react": "^3.0.12",
|
"@azure/msal-react": "^3.0.12",
|
||||||
|
"@xstate/react": "^4.1.0",
|
||||||
"axios": "^1.8.3",
|
"axios": "^1.8.3",
|
||||||
"dotenv": "^16.0.3",
|
"dotenv": "^16.0.3",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
|
@ -28,7 +29,8 @@
|
||||||
"react-dropzone": "^14.3.8",
|
"react-dropzone": "^14.3.8",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.5.0"
|
"react-router-dom": "^7.5.0",
|
||||||
|
"xstate": "^5.18.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.21.0",
|
"@eslint/js": "^9.21.0",
|
||||||
|
|
|
||||||
|
|
@ -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 messagePreview = firstUserMessage?.content || t('chat_history.no_message_content', 'No message content available');
|
||||||
|
|
||||||
const handleDelete = async () => {
|
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)) {
|
if (window.confirm(confirmMessage)) {
|
||||||
const success = await deleteWorkflow(workflow.id);
|
const success = await deleteWorkflow(workflow.id);
|
||||||
if (success && onDelete) {
|
if (success && onDelete) {
|
||||||
|
|
@ -145,7 +146,7 @@ function DashboardChatHistoryItem({ workflow, onDelete, onResume }: DashboardCha
|
||||||
<div className={styles.workflowContent}>
|
<div className={styles.workflowContent}>
|
||||||
<div className={styles.workflowInfo}>
|
<div className={styles.workflowInfo}>
|
||||||
<h3 className={styles.workflowId}>
|
<h3 className={styles.workflowId}>
|
||||||
Workflow {workflow.id.substring(0, 8)}...
|
{workflow.title || `Workflow ${workflow.id.substring(0, 8)}...`}
|
||||||
</h3>
|
</h3>
|
||||||
<div className={styles.workflowMeta}>
|
<div className={styles.workflowMeta}>
|
||||||
{renderStatusIndicator()}
|
{renderStatusIndicator()}
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,7 @@ function DashboardPromptSet({ onPromptRun }: DashboardPromptSetProps) {
|
||||||
prompt={prompt}
|
prompt={prompt}
|
||||||
onDelete={refetch}
|
onDelete={refetch}
|
||||||
onRun={onPromptRun}
|
onRun={onPromptRun}
|
||||||
|
onShare={refetch}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,21 +4,25 @@ import { AiOutlineDelete } from 'react-icons/ai';
|
||||||
import { BsShareFill } from 'react-icons/bs';
|
import { BsShareFill } from 'react-icons/bs';
|
||||||
import { usePromptOperations, Prompt } from '../../../../hooks/usePrompts';
|
import { usePromptOperations, Prompt } from '../../../../hooks/usePrompts';
|
||||||
import { useLanguage } from '../../../../contexts/LanguageContext';
|
import { useLanguage } from '../../../../contexts/LanguageContext';
|
||||||
|
import PromptShareModal from './PromptShareModal';
|
||||||
import styles from './DashboardPromptSetItem.module.css';
|
import styles from './DashboardPromptSetItem.module.css';
|
||||||
|
|
||||||
interface DashboardPromptSetItemProps {
|
interface DashboardPromptSetItemProps {
|
||||||
prompt: Prompt;
|
prompt: Prompt;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
onRun: (prompt: Prompt) => void;
|
onRun: (prompt: Prompt) => void;
|
||||||
|
onShare?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DashboardPromptSetItem({ prompt, onDelete, onRun }: DashboardPromptSetItemProps) {
|
function DashboardPromptSetItem({ prompt, onDelete, onRun, onShare }: DashboardPromptSetItemProps) {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { handlePromptDelete, deletingPrompts, deleteError } = usePromptOperations();
|
const { handlePromptDelete, handlePromptShare, deletingPrompts, sharingPrompts, deleteError, shareError } = usePromptOperations();
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [showShareModal, setShowShareModal] = useState(false);
|
||||||
|
|
||||||
const isDeleting = deletingPrompts.has(prompt.id);
|
const isDeleting = deletingPrompts.has(prompt.id);
|
||||||
|
const isSharing = sharingPrompts.has(prompt.id);
|
||||||
|
|
||||||
const handleDeleteClick = async () => {
|
const handleDeleteClick = async () => {
|
||||||
if (showDeleteConfirm) {
|
if (showDeleteConfirm) {
|
||||||
|
|
@ -41,76 +45,110 @@ function DashboardPromptSetItem({ prompt, onDelete, onRun }: DashboardPromptSetI
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleShare = () => {
|
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 (
|
return (
|
||||||
<div
|
<>
|
||||||
className={styles.promptItem}
|
<div
|
||||||
>
|
className={styles.promptItem}
|
||||||
<div className={styles.promptMain}>
|
>
|
||||||
<div className={styles.promptContent}>
|
<div className={styles.promptMain}>
|
||||||
<div className={styles.promptInfo}>
|
<div className={styles.promptContent}>
|
||||||
<h3 className={styles.promptName}>
|
<div className={styles.promptInfo}>
|
||||||
{prompt.name}
|
<h3 className={styles.promptName}>
|
||||||
</h3>
|
{prompt.name}
|
||||||
{prompt.createdAt && (
|
</h3>
|
||||||
<p className={styles.promptDate}>
|
{prompt.createdAt && (
|
||||||
{t('promptset.created')}: {new Date(prompt.createdAt).toLocaleDateString('de-DE')}
|
<p className={styles.promptDate}>
|
||||||
|
{t('promptset.created')}: {new Date(prompt.createdAt).toLocaleDateString('de-DE')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref={contentRef}>
|
||||||
|
<p className={styles.promptText}>
|
||||||
|
{prompt.content}
|
||||||
</p>
|
</p>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref={contentRef}>
|
<div className={styles.actionButtons}>
|
||||||
<p className={styles.promptText}>
|
<button
|
||||||
{prompt.content}
|
onClick={handleRun}
|
||||||
</p>
|
className={`${styles.actionButton} ${styles.runButton}`}
|
||||||
|
title={t('promptset.run_tooltip')}
|
||||||
|
>
|
||||||
|
<FaArrowRight size={16} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<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
|
||||||
|
onClick={handleDeleteClick}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className={`${styles.actionButton} ${styles.deleteButton} ${showDeleteConfirm ? styles.confirm : ''}`}
|
||||||
|
title={showDeleteConfirm ? t('promptset.confirm_delete') : t('promptset.delete_tooltip')}
|
||||||
|
onBlur={handleCancelDelete}
|
||||||
|
>
|
||||||
|
<AiOutlineDelete size={16} />
|
||||||
|
{isDeleting && <span className={styles.actionText}>{t('promptset.deleting')}</span>}
|
||||||
|
{showDeleteConfirm && <span className={styles.actionText}>{t('promptset.confirm_click')}</span>}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.actionButtons}>
|
{deleteError && (
|
||||||
<button
|
<div className={styles.errorMessage}>
|
||||||
onClick={handleRun}
|
{t('promptset.delete_error')}: {deleteError}
|
||||||
className={`${styles.actionButton} ${styles.runButton}`}
|
</div>
|
||||||
title={t('promptset.run_tooltip')}
|
)}
|
||||||
>
|
|
||||||
<FaArrowRight size={16} />
|
{shareError && (
|
||||||
</button>
|
<div className={styles.errorMessage}>
|
||||||
|
{t('share_modal.share_error')}: {shareError}
|
||||||
<button
|
</div>
|
||||||
onClick={handleShare}
|
)}
|
||||||
className={`${styles.actionButton} ${styles.shareButton}`}
|
|
||||||
title={t('promptset.share_tooltip')}
|
{isDeleting && (
|
||||||
>
|
<div className={styles.deletingMessage}>
|
||||||
<BsShareFill size={16} />
|
{t('promptset.deleting_message')}
|
||||||
</button>
|
</div>
|
||||||
|
)}
|
||||||
<button
|
|
||||||
onClick={handleDeleteClick}
|
|
||||||
disabled={isDeleting}
|
|
||||||
className={`${styles.actionButton} ${styles.deleteButton} ${showDeleteConfirm ? styles.confirm : ''}`}
|
|
||||||
title={showDeleteConfirm ? t('promptset.confirm_delete') : t('promptset.delete_tooltip')}
|
|
||||||
onBlur={handleCancelDelete}
|
|
||||||
>
|
|
||||||
<AiOutlineDelete size={16} />
|
|
||||||
{isDeleting && <span className={styles.actionText}>{t('promptset.deleting')}</span>}
|
|
||||||
{showDeleteConfirm && <span className={styles.actionText}>{t('promptset.confirm_click')}</span>}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{deleteError && (
|
{/* Share Modal */}
|
||||||
<div className={styles.errorMessage}>
|
<PromptShareModal
|
||||||
{t('promptset.delete_error')}: {deleteError}
|
isOpen={showShareModal}
|
||||||
</div>
|
onClose={handleCloseShareModal}
|
||||||
)}
|
onSubmit={handleShareSubmit}
|
||||||
|
promptName={prompt.name}
|
||||||
{isDeleting && (
|
isLoading={isSharing}
|
||||||
<div className={styles.deletingMessage}>
|
/>
|
||||||
{t('promptset.deleting_message')}
|
</>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -27,11 +27,20 @@
|
||||||
.logoContainer {
|
.logoContainer {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: 80px; /* Fixed height instead of auto */
|
||||||
padding: 30px 20px 7px 20px;
|
padding: 30px 20px 7px 20px;
|
||||||
justify-content: center;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoWrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
|
|
@ -40,6 +49,53 @@
|
||||||
color: var(--color-primary);
|
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 */
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import styles from './Sidebar.module.css'
|
import styles from './Sidebar.module.css'
|
||||||
import SidebarItem from './SidebarItem';
|
import SidebarItem from './SidebarItem';
|
||||||
import useSidebarData from './SidebarData';
|
import useSidebarData from '../../contexts/SidebarData';
|
||||||
import SidebarUser from './SidebarUser';
|
import SidebarUser from './SidebarUser';
|
||||||
import { useCurrentUser } from '../../hooks/useUsers';
|
import { useCurrentUser } from '../../hooks/useUsers';
|
||||||
|
import { useSidebarMachine } from '../../hooks/machines/useSidebarMachine';
|
||||||
|
import { GoSidebarExpand, GoSidebarCollapse } from 'react-icons/go';
|
||||||
|
|
||||||
interface SidebarItemType {
|
interface SidebarItemType {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -18,11 +21,48 @@ interface SidebarProps {
|
||||||
|
|
||||||
const Sidebar: React.FC<SidebarProps> = ({ data }) => {
|
const Sidebar: React.FC<SidebarProps> = ({ data }) => {
|
||||||
const { user, isLoading, error } = useCurrentUser();
|
const { user, isLoading, error } = useCurrentUser();
|
||||||
|
|
||||||
|
const sidebarMachine = useSidebarMachine();
|
||||||
|
|
||||||
return (
|
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}>
|
<div className={styles.logoContainer}>
|
||||||
<img src="/logos/PowerOn_transparent.png" alt="Logo" className={styles.logo} />
|
<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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -34,15 +74,35 @@ const Sidebar: React.FC<SidebarProps> = ({ data }) => {
|
||||||
}}
|
}}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
error={error}
|
error={error}
|
||||||
|
isMinimized={sidebarMachine.isMinimized}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 => {
|
{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>
|
</motion.div>
|
||||||
</div>
|
|
||||||
|
</motion.div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,20 +50,73 @@
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 20px;
|
width: 25px;
|
||||||
height: 20px;
|
height: 25px;
|
||||||
padding: 2.292px 2.3px 2.508px 2.292px;
|
padding: 2.292px 2.3px 2.508px 2.292px;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-left: 20px;
|
margin-left: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hassubmenu {
|
.hassubmenu {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
transition: transform 0.3s ease;
|
transition: transform 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rotated {
|
.rotated {
|
||||||
transform: rotate(180deg);
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useState } from "react";
|
import React from "react";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { IoIosArrowDown } from "react-icons/io";
|
import { IoIosArrowDown } from "react-icons/io";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
|
||||||
import styles from './SidebarItem.module.css';
|
import styles from './SidebarItem.module.css';
|
||||||
import SidebarSubmenu from "./SidebarSubmenu";
|
import SidebarSubmenu from "./SidebarSubmenu";
|
||||||
|
|
@ -17,38 +18,57 @@ interface SidebarItemProps {
|
||||||
link?: string;
|
link?: string;
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
|
isOpen: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
isActive: boolean;
|
||||||
|
isMinimized: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SidebarItem: React.FC<SidebarItemProps> = ({ item }) => {
|
const SidebarItem: React.FC<SidebarItemProps> = ({
|
||||||
const location = useLocation();
|
item,
|
||||||
|
isOpen,
|
||||||
|
onToggle,
|
||||||
|
isActive,
|
||||||
|
isMinimized
|
||||||
|
}) => {
|
||||||
const Icon = item.icon as React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
const Icon = item.icon as React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||||||
const hasSubItems = item.submenu && item.submenu.length > 0;
|
const hasSubItems = item.submenu && item.submenu.length > 0;
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
|
|
||||||
const toggleSubmenu = (e: React.MouseEvent) => {
|
const toggleSubmenu = (e: React.MouseEvent) => {
|
||||||
if (hasSubItems) {
|
if (hasSubItems) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsOpen(!isOpen);
|
onToggle();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isActive = item.link && location.pathname === item.link;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.menu}>
|
<div className={`${styles.menu} ${isMinimized ? styles.minimized : ''}`}>
|
||||||
<li className={`${isActive ? styles.active : ""}`}>
|
<li className={`${isActive ? styles.active : ""}`}>
|
||||||
|
{/* Icon is always present */}
|
||||||
{Icon && <Icon className={styles.icon} />}
|
{Icon && <Icon className={styles.icon} />}
|
||||||
|
|
||||||
|
{/* Text content - always present but hidden when minimized */}
|
||||||
{hasSubItems ? (
|
{hasSubItems ? (
|
||||||
<a href="#" onClick={toggleSubmenu} className={styles.menuLink}>
|
<a href="#" onClick={toggleSubmenu} className={styles.menuLink}>
|
||||||
{item.name}
|
<span className={styles.menuText}>{item.name}</span>
|
||||||
<IoIosArrowDown className={`${styles.hassubmenu} ${isOpen ? styles.rotated : ''}`} />
|
<IoIosArrowDown className={`${styles.hassubmenu} ${isOpen ? styles.rotated : ''}`} />
|
||||||
</a>
|
</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>
|
</li>
|
||||||
{hasSubItems && <SidebarSubmenu item={item} isOpen={isOpen} />}
|
{hasSubItems && !isMinimized && <SidebarSubmenu item={item} isOpen={isOpen} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
.user_section {
|
.user_section {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 240px;
|
width: 240px;
|
||||||
height: auto;
|
height: 100px; /* Fixed height instead of auto */
|
||||||
min-height: 100px;
|
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-family: var(--font-family);
|
font-family: var(--font-family);
|
||||||
|
box-sizing: border-box; /* Include padding in height calculation */
|
||||||
|
margin-bottom: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user_info {
|
.user_info {
|
||||||
|
|
@ -15,18 +16,22 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
transition: justify-content 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user_icon {
|
.user_icon {
|
||||||
font-size: 40px;
|
font-size: 40px;
|
||||||
color: var(--color-gray);
|
color: var(--color-gray);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text_content {
|
.text_content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
transition: opacity 0.4s ease, width 0.4s ease;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user_section h1 {
|
.user_section h1 {
|
||||||
|
|
@ -35,6 +40,8 @@
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
font-family: var(--font-family);
|
font-family: var(--font-family);
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user_section p {
|
.user_section p {
|
||||||
|
|
@ -42,6 +49,8 @@
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: var(--color-gray);
|
color: var(--color-gray);
|
||||||
font-family: var(--font-family);
|
font-family: var(--font-family);
|
||||||
|
transition: opacity 0.35s ease;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logout_button {
|
.logout_button {
|
||||||
|
|
@ -54,7 +63,7 @@
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-family: var(--font-family);
|
font-family: var(--font-family);
|
||||||
transition: background-color 0.2s;
|
transition: all 0.4s ease;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -67,3 +76,51 @@
|
||||||
background-color: var(--color-red);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,10 @@ interface SidebarUserProps {
|
||||||
user: User;
|
user: User;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
error?: string | null;
|
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 { instance } = useMsal();
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
|
|
@ -42,7 +43,7 @@ const SidebarUser: React.FC<SidebarUserProps> = ({ user, isLoading, error }) =>
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.user_section}>
|
<div className={`${styles.user_section} ${isMinimized ? styles.minimized : ''}`}>
|
||||||
<div className={styles.user_info}>
|
<div className={styles.user_info}>
|
||||||
<FaUserCircle className={styles.user_icon} />
|
<FaUserCircle className={styles.user_icon} />
|
||||||
<div className={styles.text_content}>
|
<div className={styles.text_content}>
|
||||||
|
|
@ -54,7 +55,7 @@ const SidebarUser: React.FC<SidebarUserProps> = ({ user, isLoading, error }) =>
|
||||||
className={styles.logout_button}
|
className={styles.logout_button}
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
>
|
>
|
||||||
Logout
|
<span className={styles.logout_text}>Logout</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { FaRegFileAlt } from "react-icons/fa";
|
||||||
import { TbLogs } from "react-icons/tb";
|
import { TbLogs } from "react-icons/tb";
|
||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useLanguage } from '../../contexts/LanguageContext';
|
import { useLanguage } from './LanguageContext';
|
||||||
|
|
||||||
const useSidebarData = () => {
|
const useSidebarData = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
@ -100,6 +100,25 @@ export const translations: Translations = {
|
||||||
'modal.creating': 'Erstellen...',
|
'modal.creating': 'Erstellen...',
|
||||||
'modal.create': 'Prompt 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
|
||||||
'prompt_settings.title': 'Prompt Einstellungen',
|
'prompt_settings.title': 'Prompt Einstellungen',
|
||||||
'prompt_settings.content_placeholder': 'Einstellungen werden in zukünftigen Updates hinzugefügt.',
|
'prompt_settings.content_placeholder': 'Einstellungen werden in zukünftigen Updates hinzugefügt.',
|
||||||
|
|
@ -304,6 +323,25 @@ export const translations: Translations = {
|
||||||
'modal.creating': 'Creating...',
|
'modal.creating': 'Creating...',
|
||||||
'modal.create': 'Create Prompt',
|
'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
|
||||||
'prompt_settings.title': 'Prompt Settings',
|
'prompt_settings.title': 'Prompt Settings',
|
||||||
'prompt_settings.content_placeholder': 'Settings content will be added here in future updates.',
|
'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.creating': 'Création...',
|
||||||
'modal.create': 'Créer le prompt',
|
'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
|
||||||
'prompt_settings.title': 'Paramètres de prompt',
|
'prompt_settings.title': 'Paramètres de prompt',
|
||||||
'prompt_settings.content_placeholder': 'Le contenu des paramètres sera ajouté dans les futures mises à jour.',
|
'prompt_settings.content_placeholder': 'Le contenu des paramètres sera ajouté dans les futures mises à jour.',
|
||||||
|
|
|
||||||
91
src/hooks/machines/useSidebarMachine.ts
Normal file
91
src/hooks/machines/useSidebarMachine.ts
Normal 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>;
|
||||||
|
|
@ -7,21 +7,43 @@ export interface Prompt {
|
||||||
name: string;
|
name: string;
|
||||||
content: string;
|
content: string;
|
||||||
createdAt?: 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
|
// Prompts list hook
|
||||||
export function usePrompts() {
|
export function usePrompts() {
|
||||||
const [prompts, setPrompts] = useState<Prompt[]>([]);
|
const [prompts, setPrompts] = useState<Prompt[]>([]);
|
||||||
const { request, isLoading: loading, error } = useApiRequest<null, Prompt[]>();
|
const { request, isLoading: loading, error } = useApiRequest<null, any>();
|
||||||
|
|
||||||
const fetchPrompts = async () => {
|
const fetchPrompts = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await request({
|
const data = await request({
|
||||||
url: '/api/prompts',
|
url: '/api/prompts/all-with-shared',
|
||||||
method: 'get'
|
method: 'get'
|
||||||
});
|
});
|
||||||
|
|
||||||
setPrompts(data);
|
// Combine owned and shared prompts into a single list
|
||||||
|
const allPrompts = [
|
||||||
|
...(data.owned || []),
|
||||||
|
...(data.sharedWithMe || [])
|
||||||
|
];
|
||||||
|
|
||||||
|
setPrompts(allPrompts);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Error is already handled by useApiRequest
|
// Error is already handled by useApiRequest
|
||||||
}
|
}
|
||||||
|
|
@ -34,15 +56,44 @@ export function usePrompts() {
|
||||||
return { prompts, loading, error, refetch: fetchPrompts };
|
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
|
// Prompt operations hook
|
||||||
export function usePromptOperations() {
|
export function usePromptOperations() {
|
||||||
const [deletingPrompts, setDeletingPrompts] = useState<Set<number>>(new Set());
|
const [deletingPrompts, setDeletingPrompts] = useState<Set<number>>(new Set());
|
||||||
const [creatingPrompt, setCreatingPrompt] = useState(false);
|
const [creatingPrompt, setCreatingPrompt] = useState(false);
|
||||||
const [updatingPrompts, setUpdatingPrompts] = useState<Set<number>>(new Set());
|
const [updatingPrompts, setUpdatingPrompts] = useState<Set<number>>(new Set());
|
||||||
|
const [sharingPrompts, setSharingPrompts] = useState<Set<number>>(new Set());
|
||||||
const { request, error: apiError, isLoading } = useApiRequest();
|
const { request, error: apiError, isLoading } = useApiRequest();
|
||||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
const [createError, setCreateError] = useState<string | null>(null);
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
const [updateError, setUpdateError] = useState<string | null>(null);
|
const [updateError, setUpdateError] = useState<string | null>(null);
|
||||||
|
const [shareError, setShareError] = useState<string | null>(null);
|
||||||
|
|
||||||
const handlePromptDelete = async (promptId: number) => {
|
const handlePromptDelete = async (promptId: number) => {
|
||||||
setDeleteError(null);
|
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 {
|
return {
|
||||||
deletingPrompts,
|
deletingPrompts,
|
||||||
creatingPrompt,
|
creatingPrompt,
|
||||||
updatingPrompts,
|
updatingPrompts,
|
||||||
|
sharingPrompts,
|
||||||
deleteError,
|
deleteError,
|
||||||
createError,
|
createError,
|
||||||
updateError,
|
updateError,
|
||||||
|
shareError,
|
||||||
handlePromptDelete,
|
handlePromptDelete,
|
||||||
handlePromptCreate,
|
handlePromptCreate,
|
||||||
handlePromptUpdate,
|
handlePromptUpdate,
|
||||||
|
handlePromptShare,
|
||||||
isLoading
|
isLoading
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import { useApiRequest } from './useApi';
|
||||||
export interface Workflow {
|
export interface Workflow {
|
||||||
id: string;
|
id: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
title?: string;
|
||||||
status: string;
|
status: string;
|
||||||
startedAt?: string;
|
startedAt?: string;
|
||||||
lastActivity?: string;
|
lastActivity?: string;
|
||||||
|
|
|
||||||
102
src/machines/sidebarMachine.ts
Normal file
102
src/machines/sidebarMachine.ts
Normal 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>;
|
||||||
Loading…
Reference in a new issue