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": {
|
||||
"@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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ function DashboardPromptSet({ onPromptRun }: DashboardPromptSetProps) {
|
|||
prompt={prompt}
|
||||
onDelete={refetch}
|
||||
onRun={onPromptRun}
|
||||
onShare={refetch}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
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 */
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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.',
|
||||
|
|
|
|||
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;
|
||||
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
|
||||
};
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { useApiRequest } from './useApi';
|
|||
export interface Workflow {
|
||||
id: string;
|
||||
name?: string;
|
||||
title?: string;
|
||||
status: string;
|
||||
startedAt?: 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